fix(engine): resume reads the bridged tool id from the blob's own field, not from inside the replayed conversation
ROOT CAUSE (round 9; live-repro'd 5/5 this morning, both faces stub-proven by the
prompt-matrix gate). json_get is a first-substring-match scanner (strstr for
'"key":', el_runtime.c). bridge_save serialized the RAW messages array BEFORE the
tool_use_id scalar, so agentic_resume's json_get(blob, 'tool_use_id') returned the
FIRST '"tool_use_id":' occurrence inside the replayed conversation, not the saved
field. The resume guard then preferred that misread over the client's correct
call_id (its two branches both reduced to saved_use_id), attached the tool_result
to the wrong id, and Anthropic 400'd the resume ('unexpected tool_use_id found in
tool_result blocks'), surfaced as {"error":"llm unavailable"}.
ONE MISREAD, TWO FACES — whichever block owns the first tool_use_id in the array:
FACE 1 (search-then-bridge, the Key West killer): the first occurrence is the
first web_search_tool_result's srvtoolu_… id — every agentic turn that ran
server-side web_search and then bridged on a client tool died on approval,
deterministically (messages.2.content.0 … srvtoolu_…). The write itself had
already succeeded; only the resume died.
FACE 2 (multi-cycle missions): with no search, the first occurrence is ROUND 0's
tool_result block — so every LATER approve/resume cycle replayed the round-0
client id (stale-resume-id), killing multi-file missions after ~2 files.
And the shape that PASSES on round 8 confirms the mechanism: a single-cycle
bridge with no prior tool round has no 'tool_use_id' substring in its messages
at all (tool_use blocks carry 'id'), so the scan fell through to the blob's own
field and resumed correctly.
The server_tool_use ↔ web_search_tool_result pairs themselves replay intact — the
defect was a cross-field misread of the blob, the same first-match-scanner class
as BUG-6 (approve 'content' matched inside tool_input, 2026-07-17) and round 8's
citation-block fix.
THE FIX, the pattern not the spot:
1. bridge_save writes every json_safe'd scalar BEFORE both raw fields (an escaped
value cannot contain a bare '"key":' byte pattern, so first-match always lands
on the blob's own fields), and tools_raw (our fixed schema) before messages_raw
(arbitrary conversation), so the raw extractions cannot first-match into
model-controlled bytes either. Field order documented as load-bearing.
2. agentic_resume now honors the client's echoed call_id when present — the value
with clean provenance (minted from pend_tool_id, never blob-round-tripped) —
falling back to the saved id only when the client omits it. Each approve cycle
therefore binds to ITS OWN round's id (kills FACE 2 even against a blob written
by a pre-fix binary), and an omitted call_id still resumes on the saved id,
which the reordered blob now reads correctly.
Pattern sweep: the legacy synthetic blob (sessions.el handle_session_approve) embeds
only json_safe'd fields — no raw hazard, untouched. No other json_get read of any
container that embeds raw conversation JSON before the read field.
PROOF: prompt-matrix gate 24/32 RED on the round-8 brain (fails exactly the two
resume classes, named) -> 32/32 GREEN on this build; live-key Key West tracer
3/3 consecutive full round-trips (bridge -> approve-as-the-app -> real completion,
file on disk), plain-chat and weather-only controls PASS; unpatched round-8 brain
and a same-toolchain unpatched baseline build both still fail the identical
sequence with the identical srvtoolu 400 (the test discriminates, and the only
variable between failing and passing builds is this diff).
Refs neuron#109
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3190,12 +3190,31 @@ fn bridge_save(session_id: String, model: String, safe_sys: String, tools_json:
|
|||||||
// JSON values (not string-escaped) so the round-trip through state_get/json_get_raw
|
// JSON values (not string-escaped) so the round-trip through state_get/json_get_raw
|
||||||
// never corrupts nested quotes. Scalar strings (model, safe_sys, tools_log,
|
// never corrupts nested quotes. Scalar strings (model, safe_sys, tools_log,
|
||||||
// tool_use_id) stay as string fields via json_safe as before.
|
// tool_use_id) stay as string fields via json_safe as before.
|
||||||
|
//
|
||||||
|
// FIELD ORDER IS LOAD-BEARING (round-9 fix, 2026-08-06). json_get is a first-
|
||||||
|
// substring-match scanner (strstr for "\"key\":", el_runtime.c), and the two raw
|
||||||
|
// fields embed the UNESCAPED conversation — every key the model's own blocks carry
|
||||||
|
// ("tool_use_id" in each web_search_tool_result, "content", "type", ...) is findable
|
||||||
|
// by a whole-blob scan. With messages_raw serialized BEFORE tool_use_id, the resume
|
||||||
|
// read json_get(blob, "tool_use_id") returned the FIRST web_search_tool_result's
|
||||||
|
// srvtoolu_… id instead of the saved client-tool id, so every search-then-bridge
|
||||||
|
// turn 400'd on approval ("unexpected tool_use_id found in tool_result blocks:
|
||||||
|
// srvtoolu_…") and the run died as {"error":"llm unavailable"}. Same first-match-
|
||||||
|
// scanner class as BUG-6 (approve "content" matched inside tool_input) and the
|
||||||
|
// round-8 citation-block fix.
|
||||||
|
//
|
||||||
|
// The rule: every json_safe'd scalar precedes both raw fields (escaping means a
|
||||||
|
// scalar value can never contain a bare "key": byte pattern, so first-match lands
|
||||||
|
// on the blob's own fields), and tools_raw — our own fixed tool schema — precedes
|
||||||
|
// messages_raw — arbitrary model/user content — so neither raw extraction can
|
||||||
|
// first-match into model-controlled bytes either. Do not reorder; do not add a
|
||||||
|
// field after messages_raw.
|
||||||
let blob: String = "{\"model\":\"" + json_safe(model) + "\""
|
let blob: String = "{\"model\":\"" + json_safe(model) + "\""
|
||||||
+ ",\"safe_sys\":\"" + json_safe(safe_sys) + "\""
|
+ ",\"safe_sys\":\"" + json_safe(safe_sys) + "\""
|
||||||
+ ",\"messages_raw\":" + messages
|
|
||||||
+ ",\"tools_raw\":" + tools_json
|
|
||||||
+ ",\"tools_log\":\"" + json_safe(tools_log) + "\""
|
+ ",\"tools_log\":\"" + json_safe(tools_log) + "\""
|
||||||
+ ",\"tool_use_id\":\"" + json_safe(tool_use_id) + "\"}"
|
+ ",\"tool_use_id\":\"" + json_safe(tool_use_id) + "\""
|
||||||
|
+ ",\"tools_raw\":" + tools_json
|
||||||
|
+ ",\"messages_raw\":" + messages + "}"
|
||||||
state_set("mcp_bridge:" + session_id, blob)
|
state_set("mcp_bridge:" + session_id, blob)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -3232,11 +3251,17 @@ fn agentic_resume(session_id: String, tool_use_id: String, content: String) -> S
|
|||||||
let tools_log: String = json_get(blob, "tools_log")
|
let tools_log: String = json_get(blob, "tools_log")
|
||||||
let saved_use_id: String = json_get(blob, "tool_use_id")
|
let saved_use_id: String = json_get(blob, "tool_use_id")
|
||||||
|
|
||||||
// Bind the result to the tool the soul actually suspended on. The client should
|
// Bind the result to the tool the loop actually suspended on. The client echoes
|
||||||
// echo the call_id; if it omits or mismatches it, fall back to the saved id so a
|
// the call_id from the pending envelope; that value came straight from
|
||||||
// late/partial client still resumes correctly.
|
// pend_tool_id and never round-tripped through this blob, so when both are
|
||||||
let use_id: String = if str_eq(tool_use_id, "") { saved_use_id } else { tool_use_id }
|
// present and disagree the CLIENT's id is the one with clean provenance (a blob
|
||||||
let eff_use_id: String = if str_eq(use_id, saved_use_id) { use_id } else { saved_use_id }
|
// written by a pre-round-9 binary misreads tool_use_id by first-match scanning
|
||||||
|
// into messages_raw — see bridge_save). A client that omits call_id still
|
||||||
|
// resumes on the saved id, which the reordered blob now reads correctly.
|
||||||
|
// (The old guard here — "on mismatch, prefer saved" — reduced to eff_use_id ≡
|
||||||
|
// saved_use_id in both branches: the client's correct id could never win, which
|
||||||
|
// is what turned the misread into a deterministic 400 on resume.)
|
||||||
|
let eff_use_id: String = if str_eq(tool_use_id, "") { saved_use_id } else { tool_use_id }
|
||||||
|
|
||||||
// Result may be large (an MCP page/file); truncate like local tool results do.
|
// Result may be large (an MCP page/file); truncate like local tool results do.
|
||||||
let trimmed: String = if str_len(content) > 6000 {
|
let trimmed: String = if str_len(content) > 6000 {
|
||||||
|
|||||||
Reference in New Issue
Block a user