fix(message,engine): truncated ToolCall.Arguments must never poison history#52
Conversation
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 finished @andybons's task in 3m 43s —— View job Review: truncated
|
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 inputat 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 throughengine.LoadSession: all lines load and re-marshal).Mechanism: a provider stream that ends mid
tool_useblock — a connection drop duringinput_json_deltaaccumulation, or, notably, a max_tokens cutoff that the Anthropic API closes out with a normal stop sequence rather than an error — leavesToolCall.Argumentsholding non-empty but truncated JSON. Every existing guard (safeArguments,ProviderData.Get) special-caseslen == 0only;json.RawMessage.MarshalJSONdoesn'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):
ee45eb8—Message.Normalizedrops 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:safeArgumentsnow also normalizes non-json.ValidArguments 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 appliedNote: an earlier duplicate of this fix (
fcb5d7f) briefly landed onfeat/mcp-enginevia 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