From dba755dcec8f3d9495a5d80a8a6966854b2cd9bb Mon Sep 17 00:00:00 2001 From: Tim Lingo <1timlingo@gmail.com> Date: Thu, 6 Aug 2026 11:17:01 -0500 Subject: [PATCH] fix(engine): resume reads the bridged tool id from the blob's own field, not from inside the replayed conversation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- chat.el | 41 +++++++++++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/chat.el b/chat.el index a307fb1..e88bc60 100644 --- a/chat.el +++ b/chat.el @@ -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 // never corrupts nested quotes. Scalar strings (model, safe_sys, tools_log, // 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) + "\"" + ",\"safe_sys\":\"" + json_safe(safe_sys) + "\"" - + ",\"messages_raw\":" + messages - + ",\"tools_raw\":" + tools_json + ",\"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) 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 saved_use_id: String = json_get(blob, "tool_use_id") - // Bind the result to the tool the soul actually suspended on. The client should - // echo the call_id; if it omits or mismatches it, fall back to the saved id so a - // late/partial client still resumes correctly. - let use_id: String = if str_eq(tool_use_id, "") { saved_use_id } else { tool_use_id } - let eff_use_id: String = if str_eq(use_id, saved_use_id) { use_id } else { saved_use_id } + // Bind the result to the tool the loop actually suspended on. The client echoes + // the call_id from the pending envelope; that value came straight from + // pend_tool_id and never round-tripped through this blob, so when both are + // present and disagree the CLIENT's id is the one with clean provenance (a blob + // 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. let trimmed: String = if str_len(content) > 6000 {