Skip to content

fix: synthesize tool results for orphaned tool calls at ingest and request build#56

Merged
andybons merged 6 commits into
mainfrom
fix/orphaned-tool-use
Jul 9, 2026
Merged

fix: synthesize tool results for orphaned tool calls at ingest and request build#56
andybons merged 6 commits into
mainfrom
fix/orphaned-tool-use

Conversation

@andybons

@andybons andybons commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Fixes the second member of the truncated-turn poison family (#52 fixed the first): when a turn dies between emitting a tool_call and executing it — max_tokens cutoff mid tool call arrives as a NORMAL stop, mid-stream disconnect, process death — the history contains a tool_use with no tool_result. Anthropic rejects any subsequent request over that history with a 400 (tool_use ids without tool_result blocks), permanently poisoning the session. This killed the github-port box session three times.

Three layers, defense in depth:

  1. In-process turn death (engine.go): a turn that ends abnormally after recording tool_calls synthesizes their is_error results ("interrupted: tool call was never executed…") immediately, before the history could ever be replayed. This is the durable primary fix for turns that die inside this process — the synthetic results are appended and persisted.
  2. Load-path ingest (store.go): LoadSession runs message.ResolveOrphanToolCalls after the log scan — the load-path counterpart of Session.append's Normalize — so a history written by an older binary or an external writer is repaired the moment it enters memory, and GET /message, goal replay, and every downstream consumer see a protocol-valid history. The log stays append-only; the repair is re-derived deterministically on each load.
  3. Transcoder request build (both anthropic and openaicompat adapters): any orphan that still reaches the wire (a history mutated by a plugin chat.message hook, a hand-rolled adapter, a scripted test provider) is paired with a synthetic error result at transcode time, so the request is valid regardless of how the history was produced. Wire-time backstop only — operates on the request copy, never persists.

Synthetic results are visibly marked ("synthesized: no tool_result was found…") so they can never be mistaken for real tool output in a transcript.

Tests cover: orphan at end of history, orphan mid-history followed by user text, multiple orphans in one assistant message, mixed resolved/orphaned calls, both adapters' wire shapes, LoadSession repair of an orphaned log, and an engine-level replay test that kills a turn between tool_call emission and execution then proves the next prompt succeeds.

go test -race ./... green.

Harness Agent added 4 commits July 9, 2026 21:50
…ries

Incident ses_01kx48z4rqfkpbwmzfdv1jzeg6: a goal worker turn died with the
Anthropic API 400 "tool_use ids were found without tool_result blocks
immediately after", and every subsequent goal-loop retry then failed
identically, killing the goal. An assistant message carrying a ToolCall
part had entered session history with no tool-role result ever following
it -- the provider stream died (or the turn otherwise errored) between
emitting the tool_call and the engine executing it -- so every later
request replayed the same orphaned tool_use and was rejected the same
way by the API. This is the wire-protocol sibling of the marshal-level
poisoning fixed in "fix(message,engine): truncated ToolCall.Arguments
must never poison history": that fix keeps a poisoned ToolCall
marshalable; this one must keep a poisoned history transcodable.

ResolveOrphanToolCalls(messages) is the shared primitive both transcoders
(anthropic, openaicompat -- wired in a following commit) will call at
request-build time: for every ToolCall without a matching ToolResult in
the immediately-following message, it merges a synthetic, is_error
ToolResult into that message (partial orphan) or inserts a new RoleTool
message carrying only the synthetic results (no following tool message at
all, including the orphan being the final message in history). Synthetic
results are never silent: Content always says "synthesized" via the new
message.SyntheticOrphanResultText constant, so they are visibly
distinguishable from a result a tool actually produced. Input is never
mutated; a history with no orphans returns the identical input slice.

TestResolveOrphanToolCallsNoOrphans/MidHistory/FinalMessage/PartialMerge
cover: no-op on well-formed history, an orphan buried mid-transcript, an
orphan as the very last message (both single- and multi-call turns), and
a turn where only some of several tool calls got a result before the
turn died. Verified red first: reverting only the message.go change (the
tests already existed) fails the package build with "undefined:
ResolveOrphanToolCalls"; re-applying it is green.
…uild

Defense-in-depth for incident ses_01kx48z4rqfkpbwmzfdv1jzeg6: transcodeRequest
now calls message.ResolveOrphanToolCalls(req.Messages) before transcoding, so
any ToolCall lacking a matching ToolResult in the immediately-following
message — however it got into history: a plugin's chat.message hook, a
hand-rolled provider adapter, a replayed log from an older, unpatched binary,
or simply a producer other than engine.Session's own turn loop (which gets
its own primary fix in the next commit) — gets a synthetic, is_error
tool_result injected rather than shipping (or silently dropping) a dangling
tool_use the API rejects wholesale with HTTP 400 "tool_use ids were found
without tool_result blocks immediately after".

TestTranscodeOrphanToolUseMidHistory covers an orphan buried mid-transcript
(no tool message follows it at all); TestTranscodeOrphanToolUseFinalMessage
covers the orphan as the last message in history (nothing to look at, let
alone merge into). Both assert every tool_use is immediately followed by a
tool_result carrying its id, IsError true, with text containing
"synthesized". Verified red first: reverting only transcode.go's
ResolveOrphanToolCalls call (tests already in place) fails both new tests —
"no matching tool_result in the immediately-following wire message" /
"is in the final wire message, no tool_result can follow" — and passes
once re-applied.
…est build

Defense-in-depth for incident ses_01kx48z4rqfkpbwmzfdv1jzeg6, mirroring the
identical anthropic fix: transcodeRequest now calls
message.ResolveOrphanToolCalls(req.Messages) before transcoding, so any
tool_call with no matching result in the immediately-following message gets
a synthetic, is_error "tool"-role message injected rather than shipping a
dangling tool_calls entry this wire protocol also requires a paired result
for.

TestTranscodeOrphanToolCallMidHistory covers an orphan buried
mid-transcript; TestTranscodeOrphanToolCallFinalMessage covers the orphan
as the last message in history (a turn with two tool calls, both
orphaned). Both assert the synthesized "tool" message's content contains
"synthesized" and the existing is_error string marker
(toolResultOutput's "[tool error]" prefix — this wire has no boolean
error field). Verified red first: reverting only transcode.go's
ResolveOrphanToolCalls call fails both new tests with "no matching
\"tool\" wire message after it"; re-applying it is green.

TestTranscodeAssistantTextAndToolCalls and
TestTranscodeAssistantToolCallOnlyNoContent previously assumed the
assistant message transcodes to the *last* wire message, which no longer
holds now that an orphaned final tool_call gets a synthetic message
appended after it (both tests' own scripted histories are, unintentionally,
exactly that shape) — updated to locate the assistant message by role via
two new small helpers (findWireMessage/findRawWireMessage) rather than by
position, preserving each test's original intent unchanged.
…a tool_call

Incident ses_01kx48z4rqfkpbwmzfdv1jzeg6: a goal worker turn died with the
Anthropic API 400 "tool_use ids were found without tool_result blocks
immediately after", naming one tool_use id, and every subsequent
goal-loop retry then failed identically, killing the goal. Mechanism: a
provider stream can emit one or more complete tool_call blocks
(provider.EventToolCall — see provider/anthropic/anthropic.go's
content_block_stop handler and provider/openaicompat/openaicompat.go's
emitToolCalls, both of which queue it once a tool_use/tool_call's
arguments are fully buffered) and then die — a transport drop, or any
other stream.Next error — before ever reaching EventDone. Before this
fix, streamTurn's error path discarded that accumulated content entirely:
nothing entered history, so no *this-session* history looked poisoned,
but the model's tool_call vanished with no record and no result, and
Prompt's caller (the goal loop) simply saw an ordinary turn error with
nothing to indicate a tool call had ever been in flight. This is the
wire-protocol sibling of the marshal-level poisoning fixed in "fix
(message,engine): truncated ToolCall.Arguments must never poison
history": that fix keeps a poisoned ToolCall marshalable at ingest; this
one keeps a turn that died mid-flight from ever being replayed unpaired
by any later code path.

streamTurn now accumulates text and tool calls as EventTextDelta/
EventToolCall events arrive. When stream.Next ultimately errors, a turn
that recorded zero tool calls behaves exactly as before (the ordinary
provider-failure case, unaffected); one that recorded at least one wraps
the error in a new interruptedTurnError carrying the assembled partial
assistant message. Prompt unwraps it via errors.As, appends the partial
message (preserving the model's visible intent — which tool, with what
arguments) followed immediately by a synthetic tool-role message: one
is_error ToolResult per recorded call, Content
"interrupted: tool call was never executed because the turn ended
abnormally" (interruptedTurnErrorText). The turn is still reported as a
failure — Unwrap() makes the original error visible to errors.Is/As, so
goal.go's context.Canceled check and promptTurnWithRetry's retry
bookkeeping are unaffected — but history is self-consistent at ingest, so
every later request build replays a paired tool_use/tool_result instead
of the orphan. toolExecCount is deliberately NOT incremented for these
synthetic results (the calls never ran), so a goal-loop retry of the same
attempt remains exactly as safe as it always was for a turn that failed
before executing anything (see promptTurnWithRetry's non-idempotency doc
comment in goal.go).

provider/anthropic/transcode.go and provider/openaicompat/transcode.go
carry the composing defense-in-depth layer (message.ResolveOrphanToolCalls,
landed in prior commits on this branch) for histories poisoned by any
producer OTHER than this loop.

TestOrphanedToolCallAppendsSyntheticResult reproduces the incident
end-to-end with a scripted provider (diesAfterToolCallProvider/
dyingStream) standing in for a stream that emits one tool_call then a
transport-style error: asserts the interrupted assistant message and its
synthetic tool result land in history in order, that history marshals
cleanly (the GET /message shape), that toolExecutions() stays at 0, and —
the actual production failure mode — that a SUBSEQUENT worker turn's
request build succeeds and recovers instead of dying identically again.
TestOrphanedToolCallMultipleCalls covers a turn that recorded more than
one tool call before dying (each gets its own synthetic result, none
dropped). TestStreamErrorWithoutToolCallIsUnaffected proves the fix is
scoped: a turn erroring with no tool call ever recorded is untouched —
same bare, unwrapped error, nothing appended to history — exactly the
ordinary-failure case every other engine test already covers. Verified red
first: every one of these tests references interruptedTurnError/
interruptedTurnErrorText, so reverting engine.go's change alone (these
tests already in place) fails the package build outright; re-applying it
is green under go test -race.
@claude

claude Bot commented Jul 9, 2026

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

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


Review: PR #56 — synthesize tool results for orphaned tool calls

I read AGENTS.md, the full diff (origin/main...HEAD), and traced the ingest/transcode/load paths. This is a careful, well-tested defensive fix. go vet-clean surface, thorough tests, and the doc comments are excellent. One substantive point about the description vs. the code, plus a couple of minor notes.

  • Read AGENTS.md and gather context
  • Review message.ResolveOrphanToolCalls
  • Review engine ingest integration (turn-death path)
  • Review anthropic + openaicompat transcoder synthesis
  • Review tests
  • Post review

Overall assessment

The two-layer approach is sound and the mechanics are correct:

  • ResolveOrphanToolCalls — never mutates its input (verified: merged.Parts = append(append(Parts(nil), …)) copies; test asserts input untouched), returns the same slice with no allocation when clean, and correctly handles all shapes (end-of-history insert, mid-history insert, merge into an existing partial RoleTool message, multiple orphans). "Immediately after" adjacency matches the wire requirement exactly.
  • Engine turn-death path — accumulating toolCalls from EventToolCall and only using them on error (the normal path uses EventDone's assembled ev.Message, so no double-count) is right. toolExecCount is correctly left untouched, keeping goal-loop retries safe. TestStreamErrorWithoutToolCallIsUnaffected proves the scoping.
  • No duplicate-result risk across the two layers: once the engine appends the synthetic RoleTool result, the transcoder's ResolveOrphanToolCalls sees the call as resolved and does nothing.
  • Tests are red-first, cover both adapters' wire shapes, use scripted providers / net.Pipe-style fakes (no raw sleeps), and the engine replay test proves the next prompt recovers — the operationally important assertion.

Main point — description vs. implementation (see inline on transcode.go:140)

The PR description says layer 1 is "Engine ingest (message.ResolveOrphanToolCalls, called on load and turn start) … The session self-heals durably — the repaired history is what persists." The code doesn't do that: ResolveOrphanToolCalls is called only in the two transcoders, and LoadSession (engine/store.go:233) / Prompt-at-turn-start never invoke it.

  • The durable engine fix is the separate interruptedTurnError path, which covers only in-process turn death.
  • For an orphan that entered history some other way (older-binary log, external writer, plugin chat.message hook), the transcoder repair is ephemeral — it rewrites req.Messages per request and is never persisted, so the on-disk log stays orphaned and the synthesis re-runs on every future request.

Contrast with the sibling incident: message.Normalize runs at the Session.append ingest choke point, so that poison heals durably. If durable self-heal for externally-loaded orphans is intended, run ResolveOrphanToolCalls inside LoadSession after scanLog. Otherwise, correct the description — the transcoder guard is a wire-time backstop, not durable ingest repair. Functionally the current code is safe either way (no request is ever rejected); this is about matching the stated durability guarantee.

Minor notes (non-blocking)

  1. Synthetic RoleTool message is appended but never emitted as an event (engine.go:463). This is consistent with the normal path (line 481 also appends tool results without an EventMessage), so it's not a regression — but an event-stream-only consumer (e.g. a server journaling purely from events, with no tool.execute.* for calls that never ran) would see the emitted partial assistant message with no following tool result in the stream. It's correct on LoadSession from the persisted log. Worth a sentence in the doc comment if event-stream consumers matter here.

  2. Synthetic message ID can collide (message.go): "synthetic-orphan-tool-result-" + strings.Join(callIDs, "-"). If two different assistant turns reuse the same CallID (IDs aren't guaranteed globally unique across turns), two synthetic messages get identical IDs, which could confuse a UI keyed by message ID. Low likelihood; consider mixing in the source message index.

  3. Reasoning/thinking blocks dropped from the reconstructed partial (assemblePartial): the partial assistant message carries text + tool calls but no ProviderData/thinking signature. For an errored turn this is acceptable, and the message ends up mid-history so Anthropic's strict thinking-preservation constraints (which bite on the final assistant turn) don't apply. Noting it only so it's a conscious tradeoff.

Nice work — the incident-linked doc comments in message.go and engine.go are genuinely useful for the next person.
· branch fix/orphaned-tool-use

Comment thread provider/anthropic/transcode.go
The transcoder-level ResolveOrphanToolCalls is a wire-time backstop: it
operates on the request copy and never persists, so a history loaded
with an orphan (older-binary log, external writer) re-ran the synthesis
on every request while GET /message and goal replay kept showing a
protocol-invalid history. Run the repair in LoadSession after the log
scan — the load-path counterpart of Session.append's Normalize — so
resident history is protocol-valid everywhere. The log stays
append-only; the repair is re-derived deterministically on each load.
@claude

claude Bot commented Jul 9, 2026

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

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


Review: PR #56 — synthesize tool results for orphaned tool calls

  • Read AGENTS.md and gather context
  • Review new LoadSession repair path (598e770)
  • Re-check message.ResolveOrphanToolCalls
  • Re-check engine turn-death path + transcoders
  • Review tests (red-first, no sleeps, synctest)
  • Post review

I re-read AGENTS.md, the full diff (origin/main...HEAD), and traced the three layers end to end. The new commit 598e770 correctly resolves my earlier finding — the three-layer story now matches the code. go test -race ./engine/ ./message/ ./provider/anthropic/ ./provider/openaicompat/ is green locally.

The three layers now hold together

  1. In-process turn death → durable append (engine.go). streamTurn accumulates EventToolCalls and, if the stream errors before EventDone, wraps them in interruptedTurnError; Prompt appends the partial assistant message and a synthetic is_error tool message. Correctly scoped: the normal path uses ev.Message (no double-count), toolExecCount is untouched so goal-loop retries stay safe, and the len(toolCalls) == 0 guard (engine.go:565) preserves pre-fix behavior for ordinary failures.
  2. Load ingest → resident repair (store.go:300). s.history = message.ResolveOrphanToolCalls(s.history) after scanLog is the load-path counterpart to Session.append's Normalize. The log stays append-only and the repair is re-derived deterministically each load — a clean way to heal externally-written / older-binary logs without mutating the journal. TestLoadSessionRepairsOrphanedToolCalls pins the shape with a real orphaned-tool_use fixture.
  3. Transcode → wire backstop (both adapters). ResolveOrphanToolCalls(req.Messages) at build time catches anything that bypassed both layers above.

Correctness checks I ran

  • No double-synthesis across layers. ResolveOrphanToolCalls is idempotent (a resolved call finds its result and is skipped), so layer 2's repaired history transcodes with layer 3 as a no-op. Same for load-then-transcode.
  • No input mutation / no data race. ResolveOrphanToolCalls copies on merge (append(append(Parts(nil), …))) and returns the input slice untouched when clean; History() hands the transcoder a fresh slice. Shared Parts pointers are never written in place. TestResolveOrphanToolCallsPartialMerge asserts the input is unmodified.
  • "Immediately after" adjacency matches the wire rule for both Anthropic (tool_result must open the following user turn) and openai-compat (each result its own tool message). Mid-history insert, end-of-history insert, and partial-merge all produce protocol-valid ordering, including after Anthropic's same-role merge.

Minor, non-blocking (carried over — all still just notes)

  1. Synthetic message ID can collide (message.go:626): "synthetic-orphan-tool-result-" + join(callIDs). Two orphaned turns that happen to reuse a CallID yield identical message IDs, which could confuse a UI keyed by message ID. Low likelihood (provider call IDs are effectively unique); mixing in the source index would make it airtight.
  2. Synthetic tool message isn't emitted as an EventMessage (engine.go:463). Consistent with the normal path (line 481 also appends results without an event), and correct on LoadSession replay — but a pure event-stream consumer would see the partial assistant with no following result in the live stream. Worth a sentence in the doc comment if that matters.
  3. Reasoning/thinking blocks are dropped from assemblePartial. Fine for an errored turn that lands mid-history (Anthropic's thinking-preservation only bites on the final assistant turn) — noting it as a conscious tradeoff.

Nice work — the incident-linked doc comments in message.go, engine.go, and both transcoders are genuinely useful, and the durability guarantee now reads accurately. LGTM.
· branch fix/orphaned-tool-use

…rce index

Provider call IDs are not guaranteed unique across turns; two orphaned
turns reusing a CallID produced identical synthetic message IDs. Mix in
the source message index — still deterministic for retranscode/reload.
Also document that the interrupted-turn synthetic results are appended
without an EventMessage emit (review note on PR #56).
@claude

claude Bot commented Jul 9, 2026

Copy link
Copy Markdown

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


Review: PR #56 — synthesize tool results for orphaned tool calls

I re-read AGENTS.md, the full diff (origin/main...HEAD), and traced the four commits added since the last review round — the LoadSession repair (598e770) and the synthetic-ID disambiguation (e475606). go build ./... is clean and go test -race ./message/ ./engine/ ./provider/anthropic/ ./provider/openaicompat/ is green locally.

  • Read AGENTS.md and gather context
  • Review message.ResolveOrphanToolCalls + synthetic ID logic
  • Review engine turn-death path
  • Review LoadSession repair + store
  • Review both transcoders
  • Review tests (TDD, no sleeps, synctest)
  • Post review

Assessment: LGTM — no new findings; prior notes resolved

The three-layer story is coherent and each layer is correct in isolation and in composition.

Layer 1 — in-process turn death (engine.go). streamTurn accumulates EventToolCalls (complete blocks only, per the content_block_stop/emitToolCalls contract) and, on a stream error before EventDone, wraps them in interruptedTurnError. Prompt then appends the partial assistant message and a synthetic is_error tool message. Correctly scoped:

  • The len(toolCalls) == 0 guard (engine.go:565) preserves exact pre-fix behavior for ordinary failures.
  • The normal path uses ev.Message (engine.go:469), so there's no double-count with the accumulator.
  • toolExecCount is deliberately untouched (engine.go:669 doc) — these calls never ran, so goal-loop retry accounting stays safe.

Layer 2 — load ingest (store.go:300). s.history = message.ResolveOrphanToolCalls(s.history) after scanLog is the load-path counterpart to Session.append's Normalize. The log stays append-only; the repair is re-derived each load. This is the fix that closes the gap the last review round flagged — externally-written / older-binary logs now heal for GET /message and goal replay, not just at the wire.

Layer 3 — transcode backstop (both adapters). ResolveOrphanToolCalls(req.Messages) at build time (anthropic/transcode.go:140, openaicompat/transcode.go:136) catches anything that bypassed layers 1–2. Operates on the request copy, never persists — correct for a wire-time guard.

Correctness checks I ran:

  • Idempotent / no double-synthesis across layers. A resolved call finds its result and is skipped, so load-then-transcode and layer-1-append-then-transcode both make the later layers no-ops.
  • No input mutation / no data race. ResolveOrphanToolCalls returns the input slice untouched when clean (no alloc), and copies on merge (append(append(Parts(nil), m.Parts...), extra...), message.go:619). TestResolveOrphanToolCallsPartialMerge pins input immutability.
  • Multi-result synthetic RoleTool message transcodes correctly on the openai-compat sidetranscodeMessage already fans a RoleTool message out to one tool wire message per ToolResult (transcode.go:151-159), so a synthetic message carrying N results is valid.
  • Call-ID mapping stays consistent — synthetic ToolResults carry the canonical CallID, which both the tool_use and tool_result sides run through the same deterministic ProviderCallID/wireCallID, so the injected result addresses the right call on both wires.
  • Synthetic-ID collision resolved (e475606): the inserted RoleTool ID now mixes in the source index (message.go:632), so two orphaned turns reusing a CallID no longer collide — while staying deterministic across loads.

Minor, non-blocking (both already documented in-code)

  1. Synthetic tool message isn't emitted as an EventMessage (engine.go:463). Consistent with the normal tool-result append (engine.go:481), and correct on LoadSession/GET /message replay — but a pure event-stream consumer sees the partial assistant with no following result in the live stream until reload. This is now explicitly called out in the interruptedToolResults doc comment (engine.go:688-693), so it's a conscious, documented tradeoff rather than a gap.
  2. The interrupted turn's token usage is unaccounted. The error path doesn't call addUsage — but the stream died before EventDone, which is where usage arrives, so there's nothing to add. Not actionable; noting only for completeness.

The incident-linked doc comments across message.go, engine.go, and both transcoders are genuinely excellent — they explain not just what but why this shape and not the obvious alternative, which is exactly what the next person debugging a poisoned session needs. Tests are red-first, use scripted providers / dying streams with no raw sleeps, and the engine replay test asserts the operationally important thing (the next prompt recovers). Nice work.
· branch fix/orphaned-tool-use

@andybons andybons merged commit 6485083 into main Jul 9, 2026
2 checks passed
@andybons andybons deleted the fix/orphaned-tool-use branch July 9, 2026 22:22
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