Skip to content

fix(message): normalize empty json.RawMessage tool-call arguments#42

Merged
andybons merged 2 commits into
mainfrom
fix/goal-stalled-marshal
Jul 9, 2026
Merged

fix(message): normalize empty json.RawMessage tool-call arguments#42
andybons merged 2 commits into
mainfrom
fix/goal-stalled-marshal

Conversation

@andybons

@andybons andybons commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Forensic fix for a session-poisoning regression caught by the first workload on a fresh binary: ToolCall.Arguments (json.RawMessage) fails to marshal when empty-but-non-nil — RawMessage.MarshalJSON special-cases only nil, and omitempty tests the Go zero value, not len == 0. One zero-length arguments payload from a provider poisons the entire history: every subsequent marshal (persist, /message, transcode) fails, and in a goal loop the failure surfaced as goal.stalled with a partially-written record. Reproduced verbatim in a red test; empty values now normalize so the poisoned-history state is unrepresentable.

Latent in the canonical format since its creation — the goal-retry path merely exercised marshaling enough to trip it. Found and fixed by a box session reading the incident session's log off the Volume (ses_01kx3q7w0zfbdrxw8e0hfrcdsd), box-pushed.

🤖 Generated with Claude Code

An empty-but-non-nil json.RawMessage (e.g. ToolCall.Arguments) is not
valid JSON: json.RawMessage.MarshalJSON only special-cases nil ->
"null", so encoding/json fails with "error calling MarshalJSON for
type json.RawMessage: unexpected end of JSON input" for anything else
of length zero. omitempty does not help (it tests the Go zero value,
not len == 0).

This is the root cause of a goal-supervised worker turn dying mid-
stream with exactly that error (session
ses_01kx3pvqttfwgbf2n5x1f1y8yh.jsonl), which then stalled and cleared
the goal.

Add ToolCall.safeArguments (normalizing empty/nil to null) and a
ToolCall.MarshalJSON for any direct encoding of a bare ToolCall
(e.g. an event's ToolCall field). marshalPart's tagged-union wrapper
cannot use the new MarshalJSON directly -- embedding a type that
implements json.Marshaler promotes the method onto the wrapper and
silently drops its "type" discriminator -- so it reconstructs
ToolCall's fields explicitly instead of embedding, applying the same
normalization inline.
@claude

claude Bot commented Jul 9, 2026

Copy link
Copy Markdown
📦 Previous Review (superseded by newer review)

Claude finished @andybons's task in 4m 32s —— View job


Review: normalize empty json.RawMessage tool-call arguments

  • Read AGENTS.md and understand review priorities
  • Analyze the diff (message/message.go, server/server.go, tests)
  • Assess correctness of the marshaling fix
  • Post review feedback

The core fix is correct and well-targeted. The diagnosis is spot-on: json.RawMessage.MarshalJSON special-cases only nil, omitempty keys off the Go zero value (not len == 0), so an empty-but-non-nil Arguments poisons every subsequent marshal. Making the poisoned state unrepresentable at the encoder boundary is the right layer to fix it. The alias-type trick correctly avoids MarshalJSON recursion, the value receiver covers both ToolCall and *ToolCall, and the reasoning for reconstructing fields in marshalPart (rather than embedding a json.Marshaler and losing the type discriminator) is sound. The writeJSON marshal-to-buffer-first change is a good defense-in-depth companion — no more truncated body behind a committed 200. Tests are thorough and follow the repo's TDD/synctest discipline (red-verifiable reproduction, truncated-final vs corrupt-middle distinction, synctest bubble for the goal path). Nice forensic work.

Two points, one substantive:

1. null vs {} normalization diverges from the transcoders — bites on resume (medium — inline)
safeArguments returns json.RawMessage("null"), but every transcoder substitutes {} for a zero-length Arguments (anthropic/transcode.go:180, openai/transcode.go:175). After a persist→LoadSession round-trip the value comes back as a 4-byte json.RawMessage("null") (verified locally: len=4 nil=false), so those len == 0 guards no longer fire and a resumed no-arg tool call transcodes to "input": null (Anthropic) / "arguments": "null" (OpenAI) instead of {} — where the live path for the same call produces {}. Anthropic requires tool_use.input to be an object, so a resumed session carrying an Anthropic no-arg tool_use (reachable via anthropic.go:337's bytes.Clone, the same empty-non-nil source as the bug) can 400 on its next turn. Normalizing to {} instead makes live and resumed encodings identical and stays API-valid. (Pre-existing for nil too, but this PR is the natural place to settle it.)

2. marshalPart field-sync is a silent footgun (low — inline)
The explicit struct reconstruction must be hand-synced with ToolCall; a future field addition is silently dropped from the Parts-element encoding with no test to catch it. A small guard test asserting the bare-value and Parts-element encodings agree on every field but type would fail loudly instead.

Nothing here blocks the fix — the poisoning bug is genuinely resolved. Point 1 is worth addressing in this PR since it's about the same canonical normalization; a one-line change plus optionally an assertion on the resumed/transcoded value.

Comment thread message/message.go
Comment thread message/message.go
…ld drift

- safeArguments now normalizes a zero-length Arguments to {} instead of
  null, matching the convention both the anthropic and openai transcoders
  already use when coercing empty arguments on the wire. A null here
  diverged from what actually gets sent, and would not survive a resumed
  session's retranscode as a usable arguments value.
- Add TestToolCallEmptyArgumentsRoundTripMatchesTranscodeConvention: a
  marshal -> unmarshal -> transcode-path round trip proving the reloaded
  Arguments is a parseable empty object, never the literal null.
- Add TestMarshalPartToolCallFieldsMatchStruct: a reflection-based
  divergence guard comparing ToolCall's own json-tagged fields against the
  keys marshalPart's explicit *ToolCall reconstruction actually emits, so
  adding a field to ToolCall without updating that reconstruction fails
  with the specific missing key named instead of silently dropping the
  field from every persisted tool call.
@claude

claude Bot commented Jul 9, 2026

Copy link
Copy Markdown

Claude finished @andybons's task in 2m 10s —— View job


Review: normalize empty tool-call arguments to {} and guard field drift

  • Read AGENTS.md and the current diff (both commits)
  • Verify the two prior review points were addressed
  • Assess correctness of the marshaling fix + server.go change
  • Post review feedback

Both findings from the previous round are fully addressed in 8167cab, and go test -race ./message/ ./server/ ./engine/ is green. This is a clean, well-scoped fix. No new blocking issues.

1. null{} normalization (was medium) — resolved ✅
safeArguments now returns json.RawMessage("{}") for a zero-length value (message/message.go:119-124). I verified this matches every transcoder's own empty-args convention:

  • provider/anthropic/transcode.go:180-181if len(input) == 0 { input = json.RawMessage({}) }
  • provider/openai/transcode.go:175-176if args == "" { args = "{}" }

So a resumed no-arg tool call now transcodes identically to the live path ("input": {} / "arguments": "{}"), and the Anthropic-400-on-resume hazard is gone. TestToolCallEmptyArgumentsRoundTripMatchesTranscodeConvention (message_test.go:259) locks this in by asserting the reloaded value is a non-empty, parseable empty object and explicitly not the 4-byte "null".

2. marshalPart field-drift footgun (was low) — resolved ✅
TestMarshalPartToolCallFieldsMatchStruct (message_test.go:320) reflects over ToolCall's JSON tags and diffs them against the keys marshalPart actually emits (minus type), failing loudly with the missing key named if a future field is added to ToolCall but not to the inline reconstruction. That's exactly the guard requested.

Additional verification

  • Both marshal paths covered. The value-receiver ToolCall.MarshalJSON (message.go:134) is in the method set of both ToolCall and *ToolCall, so bare-struct encodes (e.g. an event's *ToolCall) and the Parts-element path (marshalPart) both normalize. TestToolCallEmptyArgumentsMarshal exercises nil, json.RawMessage{}, and json.RawMessage("") across both paths and confirms the type discriminator survives.
  • writeJSON defense-in-depth is sound. Marshaling to a buffer before WriteHeader means a mid-encode failure can no longer commit a 200 and then truncate the body; the error path uses a fixed map[string]string shape that cannot re-trigger the same failure. TestWriteJSONMarshalFailureIsLoud5xxNotSilentPartial200 covers it. (Minor, non-blocking: this buffers the full response rather than streaming, but for these payloads the atomic status+body tradeoff is clearly the right call and is documented.)
  • Round-trip normalization is intentional and safe: an empty/nil Arguments reloads as {} (len 2) rather than as its original zero-length form — not identity, but that's the whole point, and no round-trip test asserts otherwise.

Nice forensic work — the poisoned-history state is now unrepresentable at the encoder boundary, which is the right layer.

@andybons andybons merged commit ecb9876 into main Jul 9, 2026
2 checks passed
@andybons andybons deleted the fix/goal-stalled-marshal branch July 9, 2026 15:45
andybons pushed a commit that referenced this pull request Jul 9, 2026
…ge footgun

#42 normalized ToolCall.Arguments (a bare json.RawMessage field) but left
Reasoning.ProviderData - a map[string]json.RawMessage carrying the exact
same encoding/json footgun one layer of map indirection away - completely
unguarded, which is why 'json: error calling MarshalJSON for type
json.RawMessage: unexpected end of JSON input' recurred on a binary that
already had #42's fix.

Systematic guard, at the message package (mirroring ToolCall's own
safeArguments/MarshalJSON pattern) rather than patching individual call
sites:

- ProviderData.Get(family) is the one choke point every transcoder must use
  to read an entry; it treats a present-but-zero-length entry as absent, so
  a transcoder never unmarshals or replays a poisoned value (anthropic's
  transcoder previously failed with a related but distinct 'bad anthropic
  reasoning data: unexpected end of JSON input' via json.Unmarshal on such
  an entry; openai's transcoder silently emitted a spurious null wire item
  instead of dropping it).
- ProviderData.MarshalJSON drops zero-length entries from the encoded
  object, guarding every direct marshal of a Reasoning part (the session
  log, the server journal, a chat.message plugin hook payload) regardless
  of whether any transcoder's Get ever ran.
- Message.Normalize, called from engine.Session.append (the single ingest
  point every message passes through), deletes zero-length entries from the
  Go value itself at ingest time, so an in-memory session history never
  diverges from what LoadSession would reload (a map with one zero-length
  entry is 'non-empty' to encoding/json's omitempty, which only checks
  length, so leaving the entry in place would make the marshaled shape
  reload-unstable even though it no longer crashes).

Anthropic, OpenAI Responses, and OpenAI-compat transcoders all now read
ProviderData through Get instead of indexing the map directly.

Tests reconstruct the incident at every layer required: message-encoder
unit marshal (message_test.go), full transcode-request marshal for both
anthropic and openai (provider/*/transcode_test.go), and the full
worker-turn path - a scripted provider producing the incident's exact part
shape, run through a real Session.Prompt and PursueGoal turn, persisted to
and reloaded from the session log (engine/store_test.go,
engine/goal_test.go). All reproduce the exact production error before this
fix and pass after it.
andybons added a commit that referenced this pull request Jul 9, 2026
…ge footgun (#43)

#42 normalized ToolCall.Arguments (a bare json.RawMessage field) but left
Reasoning.ProviderData - a map[string]json.RawMessage carrying the exact
same encoding/json footgun one layer of map indirection away - completely
unguarded, which is why 'json: error calling MarshalJSON for type
json.RawMessage: unexpected end of JSON input' recurred on a binary that
already had #42's fix.

Systematic guard, at the message package (mirroring ToolCall's own
safeArguments/MarshalJSON pattern) rather than patching individual call
sites:

- ProviderData.Get(family) is the one choke point every transcoder must use
  to read an entry; it treats a present-but-zero-length entry as absent, so
  a transcoder never unmarshals or replays a poisoned value (anthropic's
  transcoder previously failed with a related but distinct 'bad anthropic
  reasoning data: unexpected end of JSON input' via json.Unmarshal on such
  an entry; openai's transcoder silently emitted a spurious null wire item
  instead of dropping it).
- ProviderData.MarshalJSON drops zero-length entries from the encoded
  object, guarding every direct marshal of a Reasoning part (the session
  log, the server journal, a chat.message plugin hook payload) regardless
  of whether any transcoder's Get ever ran.
- Message.Normalize, called from engine.Session.append (the single ingest
  point every message passes through), deletes zero-length entries from the
  Go value itself at ingest time, so an in-memory session history never
  diverges from what LoadSession would reload (a map with one zero-length
  entry is 'non-empty' to encoding/json's omitempty, which only checks
  length, so leaving the entry in place would make the marshaled shape
  reload-unstable even though it no longer crashes).

Anthropic, OpenAI Responses, and OpenAI-compat transcoders all now read
ProviderData through Get instead of indexing the map directly.

Tests reconstruct the incident at every layer required: message-encoder
unit marshal (message_test.go), full transcode-request marshal for both
anthropic and openai (provider/*/transcode_test.go), and the full
worker-turn path - a scripted provider producing the incident's exact part
shape, run through a real Session.Prompt and PursueGoal turn, persisted to
and reloaded from the session log (engine/store_test.go,
engine/goal_test.go). All reproduce the exact production error before this
fix and pass after it.

Co-authored-by: Harness Forensics Agent <harness-agent@example.com>
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