From 710761e2d5f0d7dccbdcd550576bac221cb41838 Mon Sep 17 00:00:00 2001 From: Tim Lingo <1timlingo@gmail.com> Date: Mon, 3 Aug 2026 16:58:22 -0500 Subject: [PATCH] fix(engine): send tool_choice.disable_parallel_tool_use on agentic loop (STOPGAP) The agentic loop keeps only the FIRST tool_use block per round (chat.el:2281, "Capture first tool_use block only"). Anthropic lets a model emit several tool_use blocks in one message and requires a tool_result for every one, so a parallel-tool turn is answered once, the rest are dropped, and the next request dies with: tool_use ids were found without tool_result blocks immediately after (neuron#78 quotes this as "tool_use ids found without tool_result"; the above is the API's actual wording - recorded so the next person's grep matches.) This constrains the wire to match what the loop can assemble: "tool_choice":{"type":"auto","disable_parallel_tool_use":true} STOPGAP - AND THE DURABLE FIX ALREADY EXISTS. A correct multi-tool loop is already in Will's EL runtime, in C, and the soul does not call it. Verified on el:origin/main lang/el-compiler/runtime/el_runtime.c: llm_register_tool:9616, llm_build_tool_results:9743 - which walks EVERY content block, emits one tool_result per tool_use, and sets is_error for an unregistered tool - llm_call_agentic:9817 calling it at :9918, iteration cap 10 at :9847. Will's commit 12d5e77 (2026-04-30). grep for llm_call_agentic/llm_register_tool across every neuron/*.el returns nothing; dist/soul.c has zero references. chat.el hand-rolls its own single-tool loop instead, and that is the one that breaks. The durable fix is therefore to register the soul's tools via llm_register_tool and call llm_call_agentic - deleting a loop, not writing one. See ADR 0005. Our own approved spec called this seven weeks ago: docs/research/agentic-tool-approval-design.md (2026-06-12, "Approved for build"), line 20 on the defect, line 30 on the goal ("Execute all tool_use blocks in a turn (one result per block)"). Two edits, because dist/soul.c cannot be regenerated here (Will's gated elc/elb toolchain is not on this machine): (a) chat.el:2255 - source of truth, so a later regen carries the fix. One edit covers all three routes: agentic_loop is called from chat.el:2152 (/api/chat agentic), :2676 (dharma room) and :2496 (agentic_resume). (b) dist/soul.c:28173 - generated form, hand-spliced. Line 27624 is the non-agentic/OpenAI-compat req_body (no tools) and was left untouched. Prior art reused rather than reinvented: soul-narrated-runs-20260713.patch (27,824 bytes) line 78 spliced this same string into the same concat chain on 2026-07-13. Deliberately NOT ported from that patch, having read it: max_tokens is not changed by it (16384 sits on both sides of the hunk; our main's 4096 is a separate output-truncation concern), and its pause_turn pairing fix - same defect class - is unreachable today because no server-side web_search is wired (agentic_tools_with_web at chat.el:1418 is never called), so it is untestable and logged instead. Proven E2E on a scratch profile and port 7791, never the live chain. A/B against a pristine origin/main control built from the same vendored runtime: fixed completed the mission (tools_used read_file x3, 4 iterations, correct answer); control failed 3/3. Direct API probe confirmed the mechanism - without the field the model emits 3 parallel tool_use blocks and replaying the unfixed loop's next turn returns HTTP 400; with it, exactly 1 block. Co-Authored-By: Claude Fable 5 --- chat.el | 8 ++++++++ dist/soul.c | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/chat.el b/chat.el index 930935c..201924f 100644 --- a/chat.el +++ b/chat.el @@ -2243,8 +2243,16 @@ fn agentic_loop(session_id: String, model: String, safe_sys: String, tools_json: } while keep_going && iteration < 8 { + // STOPGAP (2026-08-03, neuron#78 bug b / ADR 0005): the block walk below keeps + // only the FIRST tool_use block per round, so a PARALLEL tool_use response leaves + // the other ids without tool_result and the next turn 400s with + // "tool_use ids found without tool_result" — killing the run. Sending Anthropic's + // documented tool_choice.disable_parallel_tool_use makes the wire contract match + // what this loop can actually assemble. The real fix — a multi-tool_result loop + // that answers every block in a round — is Will's; see neuron#78. let req_body: String = "{\"model\":\"" + model + "\"" + ",\"max_tokens\":4096" + + ",\"tool_choice\":{\"type\":\"auto\",\"disable_parallel_tool_use\":true}" + ",\"system\":\"" + safe_sys + "\"" + ",\"tools\":" + tools_json + ",\"messages\":" + messages diff --git a/dist/soul.c b/dist/soul.c index 7c47185..1dbe516 100644 --- a/dist/soul.c +++ b/dist/soul.c @@ -28170,7 +28170,7 @@ el_val_t agentic_loop(el_val_t session_id, el_val_t model, el_val_t safe_sys, el state_set(el_str_concat(EL_STR("run_progress_"), session_id), EL_STR("")); } while (keep_going && (iteration < 8)) { - el_val_t req_body = el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(EL_STR("{\"model\":\""), model), EL_STR("\"")), EL_STR(",\"max_tokens\":4096")), EL_STR(",\"system\":\"")), safe_sys), EL_STR("\"")), EL_STR(",\"tools\":")), tools_json), EL_STR(",\"messages\":")), messages), EL_STR("}")); + el_val_t req_body = el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(EL_STR("{\"model\":\""), model), EL_STR("\"")), EL_STR(",\"max_tokens\":4096,\"tool_choice\":{\"type\":\"auto\",\"disable_parallel_tool_use\":true}")), EL_STR(",\"system\":\"")), safe_sys), EL_STR("\"")), EL_STR(",\"tools\":")), tools_json), EL_STR(",\"messages\":")), messages), EL_STR("}")); el_val_t raw_resp = http_post_with_headers(api_url, req_body, h); el_val_t is_error = ((str_starts_with(raw_resp, EL_STR("{\"error\"")) || str_starts_with(raw_resp, EL_STR("{\"type\":\"error\""))) || str_contains(raw_resp, EL_STR("authentication_error"))); if (is_error) {