Skip to content

fix(message,engine): truncated ToolCall.Arguments must never poison history#52

Merged
andybons merged 2 commits into
mainfrom
fix/partial-stream-poison
Jul 9, 2026
Merged

fix(message,engine): truncated ToolCall.Arguments must never poison history#52
andybons merged 2 commits into
mainfrom
fix/partial-stream-poison

Conversation

@andybons

@andybons andybons commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Fixes the in-memory history poisoning that killed two production goal sessions today (ses_01kx453ewfedqrg7p3c64f8sca, ses_01kx453ev9ejattygpf7rbzptw): json: error calling MarshalJSON for type json.RawMessage: unexpected end of JSON input at the start of a worker turn, three identical retry failures, and GET /session/{id}/message 500ing — while the on-disk journal stayed clean (verified by chunk-bisecting both logs through engine.LoadSession: all lines load and re-marshal).

Mechanism: a provider stream that ends mid tool_use block — a connection drop during input_json_delta accumulation, or, notably, a max_tokens cutoff that the Anthropic API closes out with a normal stop sequence rather than an error — leaves ToolCall.Arguments holding non-empty but truncated JSON. Every existing guard (safeArguments, ProviderData.Get) special-cases len == 0 only; json.RawMessage.MarshalJSON doesn't validate its bytes, so the invalid value fails only when embedded in a larger document — persist, request build, or a later GET.

Fix, two layers (red-first, incident-named tests):

  • ee45eb8Message.Normalize drops syntactically invalid Arguments at the single ingest choke point, so a poisoned tool call never enters history (tool name + call ID survive; only the unusable truncated bytes are cleared)
  • c497e65 — defense-in-depth: safeArguments now also normalizes non-json.Valid Arguments to {}, so a producer that bypasses Normalize (plugin chat.message hook, hand-rolled adapter, scripted test provider) still cannot make a history marshal fail; red-verified by reverting just this change with the Normalize fix still applied

Note: an earlier duplicate of this fix (fcb5d7f) briefly landed on feat/mcp-engine via a shared box workdir; it has been dropped from that branch — this PR is the canonical fix.

🤖 Generated with Claude Code

https://claude.ai/code/session_013B9pwUYhT4o8se9skmS5PP

Harness Agent added 2 commits July 9, 2026 19:52
Two production goal sessions, ses_01kx453ewfedqrg7p3c64f8sca and
ses_01kx453ev9ejattygpf7rbzptw, died at the start of a worker turn with
"json: error calling MarshalJSON for type json.RawMessage: unexpected
end of JSON input" — three identical attempts — and
GET /session/{id}/message on them then 500'd with the message.Parts
wrapper of the same error, while the on-disk log stayed clean (the
poisoned message failed to persist and was never journaled).

Every existing guard (ToolCall.safeArguments, ProviderData.Get/
MarshalJSON) only special-cased len(Arguments) == 0. A provider stream
that dies mid tool_use block — a connection drop during
input_json_delta accumulation, or, as audited in
provider/anthropic/anthropic.go, a max_tokens cutoff mid tool-call
(which the wire protocol still closes out normally with
content_block_stop/message_delta/message_stop rather than an error) —
can leave ToolCall.Arguments holding non-empty but syntactically
invalid (truncated) JSON. json.RawMessage.MarshalJSON does not
validate its bytes, so that value sails past every len==0 check and
only fails once embedded in a larger document forces encoding/json to
compact (and so validate) it — which is exactly the persist call and
the GET /message re-marshal the incident hit.

Message.Normalize is the one ingest choke point every message passes
through before entering a session's history
(engine.Session.append) — already used to scrub a present-but-
zero-length Reasoning.ProviderData entry — so it is extended to scrub
a ToolCall's invalid Arguments too: non-empty but json.Valid == false
is replaced with nil. Only Arguments is dropped, never the whole
ToolCall part: CallID and Name are plain provider-set strings, never
json.RawMessage, so they carry no marshal risk and remain useful
context even once the arguments are unrecoverable. nil (rather than
"{}") matches the value Normalize already uses for a zero-length
ProviderData entry, and safeArguments/every transcoder already coerce
a nil/empty Arguments to an empty JSON object at marshal/wire time.

TestPersistTruncatedToolCallArguments (engine) reproduces the full
incident end to end with a scripted provider standing in for the
fake/stub stream that dies mid tool-call arguments: before this fix it
is red with PersistErr carrying exactly the production error and
json.Marshal(History()) failing the way GET /message did; after the
fix the turn persists, reloads identically, and a subsequent worker
turn (previously identical failures on every retry) succeeds.
TestNormalizeDropsInvalidToolCallArguments (message) is the equivalent
unit-level guard directly on Normalize, verified red the same way
(reverting the Normalize change) before this fix landed.
…rguments

Defense-in-depth for the same incident as the prior commit
(ses_01kx453ewfedqrg7p3c64f8sca / ses_01kx453ev9ejattygpf7rbzptw):
Message.Normalize now sanitizes a salvaged ToolCall's invalid
Arguments at the one ingest choke point every message passes through,
but safeArguments is the last line of defense before any marshal —
direct (a bare ToolCall struct field) or as a Parts element — and it
only special-cased len(Arguments) == 0 until now. A non-empty but
syntactically invalid (truncated) Arguments sailed straight past that
guard: json.RawMessage.MarshalJSON does not validate its bytes, so the
value "succeeds" in isolation and only fails once nested inside a
larger document forces encoding/json to compact and validate it —
exactly the production error.

safeArguments now normalizes any Arguments that is non-empty but not
json.Valid to "{}", the same empty-object convention already used for
a zero-length Arguments, so a poisoned in-memory history remains
serializable and recoverable even if some future producer — a
plugin's chat.message hook, a hand-rolled provider adapter, a test's
scripted provider — bypasses Normalize entirely.

TestToolCallInvalidTruncatedArgumentsMarshal covers a bare ToolCall,
a Parts element, and the full Message/[]Message (the GET /message
response shape) across several invalid-JSON shapes (truncated object,
truncated string, a lone brace, non-JSON garbage). Verified red by
temporarily reverting just this safeArguments change (with the prior
commit's Normalize fix still applied) and confirming the failure
before re-applying it.
@claude

claude Bot commented Jul 9, 2026

Copy link
Copy Markdown

Claude finished @andybons's task in 3m 43s —— View job


Review: truncated ToolCall.Arguments poisoning fix

  • Read AGENTS.md and repo conventions
  • Review message/message.go (Normalize + safeArguments)
  • Review tests for TDD / red-first / no-sleep rules
  • Check edge cases and correctness gaps
  • Verify build + red-first regression

Verdict: LGTM. This is a correct, well-scoped, and genuinely red-verified fix. I reverted both fix lines locally and confirmed all three new tests fail against pre-fix code with the exact production error text, then pass once restored:

--- FAIL: TestPersistTruncatedToolCallArguments
    PersistErr = json: error calling MarshalJSON for type message.Parts:
      ...json.RawMessage: unexpected end of JSON input, want nil
--- FAIL: TestNormalizeDropsInvalidToolCallArguments
    Normalize left invalid Arguments = {"command":"echo hel, want cleared
--- FAIL: TestToolCallInvalidTruncatedArgumentsMarshal (all 4 subcases)

Satisfies the AGENTS.md red-verification rule; no time.Sleep, uses the scripted-provider harness.

What's right

  • Root cause is correctly identified. I traced the accumulation path: anthropic.go:258 appends raw input_json_delta bytes into b.inputJSON, and toolCall() (anthropic.go:337) clones them verbatim into Arguments — so a stream that ends mid-block (max_tokens cutoff closed out normally, per assemble()) leaves non-empty truncated JSON. json.RawMessage.MarshalJSON doesn't validate, so it only explodes once nested and compacted. Exactly as described.
  • Two-layer fix is the right shape. Normalize sanitizes at the single ingest choke point; safeArguments' json.Valid check is real defense-in-depth for producers that bypass Normalize (plugin chat.message hook, hand-rolled adapters). Reverting only safeArguments (with Normalize kept) still leaves the bare-ToolCall-marshal path red, so the second layer earns its place.
  • Clearing to nil and keeping CallID/Name is correct — those are provider-set strings with no marshal risk, and nil collapses to {} at every marshal/wire site, introducing no new downstream shape.
  • The ifswitch conversion in Normalize preserves the *Reasoning behavior unchanged.

One thing I checked that could look like a gap (it isn't)

The new doc comments (message.go:289–319) call Reasoning.ProviderData "the map-shaped twin of the ToolCall.Arguments footgun," yet the ProviderData guards (Get, MarshalJSON, and the Normalize *Reasoning branch) were not given the same json.Valid treatment — only len==0/oversize. That asymmetry is correct: ProviderData entries are always built via json.Marshal(anthropicReasoningData{...}) (anthropic.go:358,364), i.e. valid-by-construction — a truncated signature string still yields structurally valid JSON ({"signature":"partial…"}). Only Arguments, which accumulates raw wire bytes, can truncate into invalid JSON. So the "twin" comment is about the zero-length footgun, and extending json.Valid to ProviderData would be dead code. Worth keeping this reasoning in mind if anyone later "notices the twin was missed."

Minor (non-blocking, out of scope)

json.Valid accepts valid-but-non-object scalars (123, "x", [1]) as Arguments — these survive both guards and marshal fine, so they're not the incident and not a regression; the model effectively never emits them for a tool_use block. Flagging only for awareness; no change requested.

Nice work — clean, minimal, and the incident-named tests document the failure mode precisely.
· fix/partial-stream-poison

@andybons andybons merged commit f310412 into main Jul 9, 2026
2 checks passed
@andybons andybons deleted the fix/partial-stream-poison branch July 9, 2026 20:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant