From 62af5649fecd8d58a3f409db2cdb42196d489fb1 Mon Sep 17 00:00:00 2001 From: Tim Lingo <1timlingo@gmail.com> Date: Tue, 4 Aug 2026 22:57:59 -0500 Subject: [PATCH] feat(engine): port Anthropic server-side web_search into the agentic loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-authors soul-webfix-20260711.patch in El (the patch is a diff against generated C at month-old offsets, so nothing was applied as a patch). Its last two hunks — an unrelated /api/safety-contact implementation — were deliberately not ported; that route already exists and is safety-critical. Activation restores existing design, it does not invent a mechanism: commit 8eea1d9 (2026-06-09, Tim-approved) made native web_search built-in with no user-facing toggle, and tests/test_agentic_tools.el section 2 still asserts agentic_tools_all() contains it — an assertion main currently fails. The call site was lost when agentic_tools_all() (connector tools, PR #19) replaced agentic_tools_with_web(). Attaching in agentic_tools_all() covers handle_chat_agentic, handle_dharma_room_turn_agentic and agentic_resume. pause_turn handling is included and was genuinely missing: the shipped binary has zero occurrences of it. Without it a paused server-side search returns only the text written so far and the loop treats it as final — a silently truncated answer. final_text now accumulates across resume cycles rather than overwriting (overwriting would discard everything written before the pause). Default tool version is web_search_20250305, NOT the newer _20260209, and that is a measured choice: _20260209's dynamic filtering uses server-side programmatic tool calling, which the API refuses to combine with ADR 0005's stopgap — HTTP 400 invalid_request_error tool_choice.disable_parallel_tool_use: true cannot be used with programmatic tool calling Dropping the stopgap would resurrect neuron#78 bug b (killed runs 3/3 in ADR 0005's own A/B). The basic variant is compatible with the stopgap and returns real results, so neither feature is dropped. Version lives in state key web_search_tool_version; flipping it once the stopgap retires is a config write, no recompile. The fallback also fires on "programmatic tool calling" so a premature flip self-heals loudly instead of dying. Also fixes a real bug this port exposed: json_get is a first-match scanner and a cited text block serialises citations FIRST, so json_get(block,"type") returned the nested citation's type and every citation-bearing block — the ones carrying the searched facts — was silently dropped from the reply. Reply length on the same question: 79 -> 360 chars. Other loop changes: server_tool_use accounting into tools_used, iteration cap 8->12 for pause/resume cycles, max_tokens 4096->16384, container-id carry-forward, API error head logged instead of swallowed, tools_used gated on is_tool_turn so a truncated tool block is not reported as work done. dist/soul.c is deliberately NOT regenerated — the local elc predates Will's last regen (e610a41) and regenerating with an older compiler risks unrelated codegen drift. Source only; regen is Will's. E2E-VERIFIED on a sandbox soul (scratch HOME/engram, dead axon+ISE, explicit NEURON_PORT): live Bentonville weather with tools_used ["web_search"]; the 27-route contract gate PASSes; the parallel-tool stopgap still completes a 3-file mission with no 400; a 3-search 3,486-char answer kept its end sentinel. Honest gap: pause_turn is compiled in but not exercised by a live pause (max_uses:5 caps the server loop below the pause threshold). Co-Authored-By: Claude Opus 5 --- chat.el | 278 +++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 256 insertions(+), 22 deletions(-) diff --git a/chat.el b/chat.el index 201924f..578f67c 100644 --- a/chat.el +++ b/chat.el @@ -1410,6 +1410,71 @@ fn agentic_tools_literal() -> String { "]" } +// web_search_tool_json — the ONE place the server-side web_search tool block is built. +// +// Anthropic executes this tool on their side, inside the same /v1/messages call: no +// third-party search key, no new account, and no anthropic-beta header (the plain +// "anthropic-version: 2023-06-01" this soul already sends is sufficient). +// +// FUTURE-PROOF (design carried from soul-webfix-20260711.patch, Tim-approved): the tool +// VERSION lives in exactly one place — state key "web_search_tool_version" — so a future +// bump is a config write, not a recompile. +// +// DEFAULT IS THE BASIC VARIANT, AND THAT IS A DELIBERATE, MEASURED CHOICE. +// The July patch defaulted to web_search_20260209 (dynamic filtering). That variant is +// HARD-INCOMPATIBLE with the parallel-tool stopgap this engine currently depends on +// (ADR 0005). Dynamic filtering is implemented with server-side programmatic tool +// calling, and the API rejects the pair outright — verified live 2026-08-04, verbatim: +// +// HTTP 400 invalid_request_error +// "tool_choice.disable_parallel_tool_use: true cannot be used with programmatic +// tool calling" +// +// So the two cannot both ship today. Dropping the stopgap would resurrect neuron#78 +// bug b, which killed agentic runs 3/3 in the ADR's own A/B — a functional regression. +// Dropping web search entirely would leave the product's promise unmet. The basic +// variant is compatible with the stopgap AND returns real, current results (proven +// E2E), so it is what we default to. What we give up is dynamic filtering — a +// token-efficiency and result-quality nicety, not the capability itself. +// +// REMOVAL TRIGGER: this default should move to web_search_20260209 the moment ADR +// 0005's stopgap is retired (i.e. when the soul calls el_runtime's llm_call_agentic +// and no longer needs disable_parallel_tool_use). At that point it is a one-line +// config write — state_set("web_search_tool_version", "web_search_20260209") — with +// no recompile. Flagged for Will's ratification; see the PR body. +fn web_search_tool_json() -> String { + let ver: String = state_get("web_search_tool_version") + let eff: String = if str_eq(ver, "") { "web_search_20250305" } else { ver } + return "{\"type\":\"" + eff + "\",\"name\":\"web_search\",\"max_uses\":5}" +} + +// strip_client_web_search — remove any CLIENT-side tool literally named "web_search" +// from a tools-array interior, so attaching Anthropic's server-side tool cannot produce +// two tools sharing one name (the API rejects that with "Tool names must be unique", +// and before it did, the model picked between them nondeterministically). +// +// The soul's own literal set carries "web_get", not "web_search", so today this is a +// no-op there — it exists for the MCP connector tools merged in by agentic_tools_all(), +// which are third-party and may well ship a "web_search". +// +// LIMITATION (inherited from the July patch, stated rather than hidden): the scan keys +// on the object terminator "]}}," so it only strips a matching tool that is followed by +// another tool. A client web_search in the LAST array position is left in place. +fn strip_client_web_search(tools_inner: String) -> String { + let ws_start: Int = str_index_of(tools_inner, "{\"name\":\"web_search\"") + if ws_start < 0 { + return tools_inner + } + let ws_rest: String = str_slice(tools_inner, ws_start, str_len(tools_inner)) + let ws_end: Int = str_index_of(ws_rest, "]}},") + if ws_end <= 0 { + return tools_inner + } + let head: String = str_slice(tools_inner, 0, ws_start) + let tail: String = str_slice(ws_rest, ws_end + 4, str_len(ws_rest)) + return head + tail +} + // agentic_tools_with_web — the standard tool set, always plus Anthropic's NATIVE // server-side web_search tool. Web search is BUILT IN: the model invokes it only when a // query needs fresh info (max_uses caps it), so there is no user-facing toggle. The native @@ -1417,8 +1482,8 @@ fn agentic_tools_literal() -> String { // and needs no local runtime — it sidesteps the soul's lack of executable tools entirely. fn agentic_tools_with_web() -> String { let base: String = agentic_tools_literal() - let inner: String = str_slice(base, 1, str_len(base) - 1) - return "[" + inner + ",{\"type\":\"web_search_20250305\",\"name\":\"web_search\",\"max_uses\":5}]" + let inner: String = strip_client_web_search(str_slice(base, 1, str_len(base) - 1)) + return "[" + inner + "," + web_search_tool_json() + "]" } // --------------------------------------------------------------------------- @@ -1443,20 +1508,35 @@ fn connector_tools_json() -> String { return arr } -// Built-in tools + every connector tool, as one tools array. -// Uses agentic_tools_literal (not agentic_tools_with_web) to avoid a duplicate -// "web_search" name — the literal already includes a custom web_search handler, -// and adding the Anthropic server-side web_search_20250305 (same name) causes -// Anthropic to reject with "Tool names must be unique." +// agentic_tools_all — built-in tools + every connector tool + Anthropic's NATIVE +// server-side web_search, as one tools array. This is the tool set EVERY agentic route +// uses (handle_chat_agentic and handle_dharma_room_turn_agentic both call it; a +// bridge-suspended run carries the same array through agentic_resume), so attaching the +// web tool here is what actually turns web search on for the product. +// +// ACTIVATION — restoring an existing design, not inventing a mechanism: +// * Commit 8eea1d9 (2026-06-09, Tim-approved) made native web_search BUILT IN with no +// user-facing toggle — "the model invokes it only when a query needs fresh info +// (max_uses:5 caps it)" — and the desktop app removed its web-search toggle to pair +// with that. The call site was lost when agentic_tools_all() (connector tools, PR #19) +// replaced agentic_tools_with_web() in handle_chat_agentic. +// * tests/test_agentic_tools.el §2 still asserts agentic_tools_all() contains the +// native web_search tool. main currently FAILS that assertion; this restores it. +// The old comment here claimed "the literal already includes a custom web_search handler" +// — that is stale: agentic_tools_literal() ships web_get, and is_builtin_tool() has no +// web_search. The duplicate-name hazard now lives only in third-party connector tools, +// which is exactly what strip_client_web_search() handles. fn agentic_tools_all() -> String { let base: String = agentic_tools_literal() let conn: String = connector_tools_json() + let base_inner: String = str_slice(base, 1, str_len(base) - 1) let conn_inner: String = str_slice(conn, 1, str_len(conn) - 1) - if str_eq(conn_inner, "") { - return base + let merged: String = if str_eq(conn_inner, "") { + base_inner + } else { + base_inner + "," + conn_inner } - let base_open: String = str_slice(base, 0, str_len(base) - 1) - return base_open + "," + conn_inner + "]" + return "[" + strip_client_web_search(merged) + "," + web_search_tool_json() + "]" } // Proxy one tool call to the bridge. The model-supplied input is written to a @@ -2223,6 +2303,20 @@ fn agentic_loop(session_id: String, model: String, safe_sys: String, tools_json: let iteration: Int = 0 let keep_going: Bool = true + // Server-side web_search state, all three carried across iterations. + // + // tools_eff: a local copy of the caller's tools array, because the DRIFT branch below + // may rewrite the web_search version in place after an API rejection. (Parameters are + // not rebindable across loop iterations; a top-level local is.) + // container_id: web_search's dynamic filtering runs server-side code execution on + // Anthropic's side, which hands back a container id that MUST be echoed on every + // follow-up request in the same turn chain or the API 400s with "container_id is + // required". Captured from each response, replayed on the next. + // ws_drift: set when we fell back, so we only ever fall back once per run. + let tools_eff: String = tools_json + let container_id: String = "" + let ws_drift: Bool = false + // Suspension state — captured at top level so it escapes the while body. let pending: Bool = false let pend_tool_id: String = "" @@ -2242,7 +2336,16 @@ fn agentic_loop(session_id: String, model: String, safe_sys: String, tools_json: state_set("run_progress_" + session_id, "") } - while keep_going && iteration < 8 { + // Cap raised 8 -> 12: a server-side web_search turn can pause and resume several + // times (see is_pause below), and each resume consumes an iteration. At 8 a + // multi-search answer could exhaust the loop before the model finished writing. + while keep_going && iteration < 12 { + // Carry the server-side code-execution container forward when we have one. + let cont_frag: String = if str_eq(container_id, "") { + "" + } else { + ",\"container\":\"" + container_id + "\"" + } // 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 @@ -2250,11 +2353,22 @@ fn agentic_loop(session_id: String, model: String, safe_sys: String, tools_json: // 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. + // + // The stopgap and server-side web_search coexist: disable_parallel_tool_use + // constrains how many CLIENT tool_use blocks the model may emit per response, and + // Anthropic runs web_search itself (it comes back as server_tool_use + + // web_search_tool_result, never as a client tool_use the loop has to answer). + // Verified live, not assumed — see the README's probe transcript. + // + // max_tokens raised 4096 -> 16384: web_search injects whole result documents into + // the response, and a multi-search answer at 4096 truncated mid-sentence. This is + // the second half of the anti-truncation fix; pause_turn handling is the first. let req_body: String = "{\"model\":\"" + model + "\"" - + ",\"max_tokens\":4096" + + cont_frag + + ",\"max_tokens\":16384" + ",\"tool_choice\":{\"type\":\"auto\",\"disable_parallel_tool_use\":true}" + ",\"system\":\"" + safe_sys + "\"" - + ",\"tools\":" + tools_json + + ",\"tools\":" + tools_eff + ",\"messages\":" + messages + "}" @@ -2263,11 +2377,60 @@ fn agentic_loop(session_id: String, model: String, safe_sys: String, tools_json: let is_error: Bool = str_starts_with(raw_resp, "{\"error\"") || str_starts_with(raw_resp, "{\"type\":\"error\"") || str_contains(raw_resp, "authentication_error") - if is_error { + + // DRIFT / FALLBACK: if the API rejected our configured web_search variant (the + // usual cause is a model too old for it — chat_default_model() is sonnet-4-5, + // which predates web_search_20260209), swap to the known-old basic variant, + // record the drift for the drift-watch, and retry this iteration. + // + // Everything here is computed as top-level if-expressions rather than inside an + // if-BLOCK, because in El a mutation inside an if-block does not escape its scope + // (see the block-walk note below). There is deliberately no `continue`: the + // fallback simply leaves `messages` untouched and keeps `keep_going` true, so the + // next iteration re-sends the same turn with the corrected tools array. + let ws_pos: Int = if is_error { str_index_of(tools_eff, "\"type\":\"web_search_") } else { 0 - 1 } + let ws_rest: String = if ws_pos >= 0 { str_slice(tools_eff, ws_pos + 8, str_len(tools_eff)) } else { "" } + // '"type":"' is 8 chars, so the version starts at ws_pos+8 and ends at the next quote. + let ws_vend: Int = if ws_pos >= 0 { str_index_of(ws_rest, "\"") } else { 0 - 1 } + let ws_cur: String = if ws_vend > 0 { str_slice(ws_rest, 0, ws_vend) } else { "" } + // Fall back on either of two signals, and ONLY these two — a loose prefix guess + // here once matched errors that had nothing to do with the tool: + // 1. the API names OUR configured version in its complaint (version drift, e.g. + // a model too old for the configured variant); or + // 2. the API rejects the request for "programmatic tool calling" — the verified + // signature of the dynamic-filtering variant colliding with ADR 0005's + // disable_parallel_tool_use. An operator who sets web_search_tool_version to + // web_search_20260209 while the stopgap still stands would otherwise get a + // dead chat; this downgrades them automatically and says so in the log. + let ws_conflict: Bool = str_contains(raw_resp, "programmatic tool calling") + let can_fallback: Bool = is_error && !ws_drift && ws_pos >= 0 && ws_vend > 0 + && !str_eq(ws_cur, "") && !str_eq(ws_cur, "web_search_20250305") + && (str_contains(raw_resp, ws_cur) || ws_conflict) + let tools_eff = if can_fallback { + str_slice(tools_eff, 0, ws_pos + 8) + "web_search_20250305" + str_slice(ws_rest, ws_vend, str_len(ws_rest)) + } else { tools_eff } + let ws_drift = if can_fallback { true } else { ws_drift } + if can_fallback { + println("[soul] DRIFT: web_search variant '" + ws_cur + "' rejected by API - fell back to web_search_20250305") + state_set("web_search_version_drift", ws_cur) + } + + if is_error && !can_fallback { + // Log the actual API error head — the old code swallowed it entirely, which + // made every upstream failure look identical from the outside. + let err_head: String = if str_len(raw_resp) > 220 { str_slice(raw_resp, 0, 220) } else { raw_resp } + println("[soul] llm error: " + err_head) return "{\"error\":\"llm unavailable\",\"reply\":\"\"}" } let stop_reason: String = json_get(raw_resp, "stop_reason") + + // Capture/refresh the server-side container id when the response carries one. + let cont_raw: String = json_get_raw(raw_resp, "container") + let cont_id_new: String = if !str_eq(cont_raw, "") && !str_eq(cont_raw, "null") { + json_get(cont_raw, "id") + } else { "" } + let container_id = if !str_eq(cont_id_new, "") { cont_id_new } else { container_id } // json_get_raw needed — content is an array, json_get returns "" for non-strings let content_arr: String = json_get_raw(raw_resp, "content") let eff_content: String = if str_eq(content_arr, "") { "[]" } else { content_arr } @@ -2279,13 +2442,39 @@ fn agentic_loop(session_id: String, model: String, safe_sys: String, tools_json: let tool_id: String = "" let tool_name: String = "" let tool_input: String = "" + // Server-executed tool names seen this round, quoted and comma-joined. Kept + // separate from tools_log so this inner walk has exactly one mutation site per + // variable (the El scope rule below), then merged in at the outer level. + let srv_log: String = "" let ci: Int = 0 let c_total: Int = json_array_len(eff_content) while ci < c_total { let block: String = json_array_get(eff_content, ci) - let btype: String = json_get(block, "type") + // CITATION-BLOCK FIX (2026-08-04, found by E2E once web_search was live). + // json_get is a first-match scanner, and a cited text block serialises as + // {"citations":[{"type":"web_search_result_location",...}],"type":"text",...} + // — citations FIRST. So json_get(block,"type") returns the nested citation's + // type, "text" never matches, and the block is silently dropped. Those are + // exactly the blocks carrying the searched facts, so the user got an answer + // with the data punched out of it ("The current temperature is , with ."). + // Only text blocks carry a citations array, so its presence identifies the + // block type unambiguously without needing a real JSON parser. + let cit_raw: String = json_get_raw(block, "citations") + let has_cit: Bool = !str_eq(cit_raw, "") && !str_eq(cit_raw, "null") + let btype_scan: String = json_get(block, "type") + let btype: String = if has_cit { "text" } else { btype_scan } // Accumulate text at top level using if-expression let text_out = if str_eq(btype, "text") { text_out + json_get(block, "text") } else { text_out } + // FUTURE-PROOF: tools Anthropic runs on our behalf (web_search today, whatever + // ships tomorrow) arrive as server_tool_use blocks, never as client tool_use. + // Count the CATEGORY by the block's own name so a new server tool appears in + // tools_used with zero code changes — honest accounting for work we didn't run. + let is_srv: Bool = str_eq(btype, "server_tool_use") + let srv_name_raw: String = if is_srv { json_get(block, "name") } else { "" } + let srv_name: String = if is_srv && str_eq(srv_name_raw, "") { "server_tool" } else { srv_name_raw } + let srv_log = if is_srv { + if str_eq(srv_log, "") { "\"" + srv_name + "\"" } else { srv_log + ",\"" + srv_name + "\"" } + } else { srv_log } // Capture first tool_use block only let is_new_tool: Bool = str_eq(btype, "tool_use") && !has_tool let has_tool = if is_new_tool { true } else { has_tool } @@ -2299,6 +2488,24 @@ fn agentic_loop(session_id: String, model: String, safe_sys: String, tools_json: // A real tool turn that targets a tool the soul cannot run in-process is a // CLIENT bridge: suspend the loop and hand the tool to the client. let is_tool_turn: Bool = str_eq(stop_reason, "tool_use") && has_tool + + // pause_turn — REQUIRED for server-side web_search, not optional. + // Anthropic's server-side tool loop has its own iteration limit. When it hits it + // mid-answer the turn comes back with stop_reason "pause_turn" and only the text + // written SO FAR. Per Anthropic's contract the caller must re-send with the + // assistant content appended and NO tool_result, and the server resumes where it + // left off. Skip this and the user silently gets a truncated answer — no error, + // no warning, just a sentence that stops. (The bug that ate Tim's report, + // 2026-07-11.) Nothing else in this engine handles it: "pause_turn" appears + // nowhere else in the .el sources or in the shipped binary. + let is_pause: Bool = str_eq(stop_reason, "pause_turn") + // Unknown stop reasons (future API drift): finish honestly and log loudly rather + // than treating an unrecognised terminal state as a completed answer. + if !str_eq(stop_reason, "end_turn") && !str_eq(stop_reason, "tool_use") && !is_pause + && !str_eq(stop_reason, "max_tokens") && !str_eq(stop_reason, "refusal") + && !str_eq(stop_reason, "") { + println("[soul] DRIFT: unknown stop_reason from API: " + stop_reason) + } // If the user previously chose "always allow" for this tool in this session, // treat it like a builtin — run server-side via dispatch_tool and skip the // bridge suspension entirely so the approval UI is never shown again. @@ -2326,10 +2533,19 @@ fn agentic_loop(session_id: String, model: String, safe_sys: String, tools_json: let tool_msg: String = "{\"type\":\"tool_result\",\"tool_use_id\":\"" + tool_id + "\",\"content\":\"" + tool_result + "\"}" // Accumulate tool names for the tools_used log surfaced in the response. + // Gated on is_tool_turn, not has_tool: a round truncated by max_tokens can carry a + // half-written tool_use block that will never run, and logging it would claim work + // the soul did not do. let tool_quoted: String = "\"" + tool_name + "\"" - let tools_log = if has_tool { + let tools_log = if is_tool_turn { if str_eq(tools_log, "") { tool_quoted } else { tools_log + "," + tool_quoted } } else { tools_log } + // Merge in the server-executed tools (web_search) seen in this round's blocks. + let tools_log = if str_eq(srv_log, "") { + tools_log + } else { + if str_eq(tools_log, "") { srv_log } else { tools_log + "," + srv_log } + } // The assistant turn that requested the tool — needed verbatim on resume so the // tool_use/tool_result pairing stays valid when the client posts its result. @@ -2340,15 +2556,22 @@ fn agentic_loop(session_id: String, model: String, safe_sys: String, tools_json: // Local built-in tool turn: append assistant + tool_result and keep looping. let local_continue: Bool = is_tool_turn && !needs_bridge + // Pause resume: the assistant content goes back VERBATIM with NO tool_result — + // that is the whole contract. Anthropic's server resumes its own tool loop from + // there; sending a tool_result (or a "continue" user turn) breaks it. + let is_pause_resume: Bool = is_pause && !needs_bridge let messages = if local_continue { let inner2: String = str_slice(messages_with_assistant, 1, str_len(messages_with_assistant) - 1) "[" + inner2 + ",{\"role\":\"user\",\"content\":[" + tool_msg + "]}]" + } else if is_pause_resume { + messages_with_assistant } else { messages } // Live progress ledger: one entry per round — the model's own narration // (its pre-tool prose, previously discarded here) plus the tool it reached // for. Clients poll /api/run-progress/ to render these live. - if !str_eq(session_id, "") { + // Skipped on a version-fallback round: nothing happened that a poller should see. + if !str_eq(session_id, "") && !can_fallback { let prog_key: String = "run_progress_" + session_id let prog_prev: String = state_get(prog_key) let prog_snip: String = if str_len(text_out) > 280 { str_slice(text_out, 0, 280) } else { text_out } @@ -2373,8 +2596,19 @@ fn agentic_loop(session_id: String, model: String, safe_sys: String, tools_json: bridge_save(session_id, model, safe_sys, tools_json, messages_with_assistant, tools_log, pend_tool_id) } - let final_text = if !is_tool_turn { text_out } else { final_text } - let keep_going = if local_continue { keep_going } else { false } + // ACCUMULATE across pause/resume cycles instead of overwriting. A resumed turn + // CONTINUES the answer, it does not repeat it — overwriting here would throw away + // everything the model wrote before the pause, which is the same truncation the + // pause handling exists to prevent. A version-fallback round contributes nothing. + let final_text = if !is_tool_turn && !can_fallback { final_text + text_out } else { final_text } + // Output cap hit mid-action: the tool block is truncated and will NOT run. Say so + // instead of ending on silent almost-work. + let final_text = if str_eq(stop_reason, "max_tokens") && has_tool { + final_text + "\n\n[Output limit reached mid-action - the last planned action did not run. Ask me to continue to finish it.]" + } else { final_text } + // Keep looping for a local tool round, a server-side pause resume, or a one-shot + // web_search version fallback. + let keep_going = if local_continue || is_pause_resume || can_fallback { keep_going } else { false } let iteration = iteration + 1 } @@ -2398,9 +2632,9 @@ fn agentic_loop(session_id: String, model: String, safe_sys: String, tools_json: // means the task was too complex for the agentic loop depth — surface it clearly // so the caller/operator knows to increase the cap or break the task apart. if str_eq(final_text, "") { - let hit_cap: Bool = iteration >= 8 + let hit_cap: Bool = iteration >= 12 let err_msg: String = if hit_cap { - "agentic loop hit the 8-iteration cap without producing a final reply - task may be too complex or a tool call is looping" + "agentic loop hit the 12-iteration cap without producing a final reply - task may be too complex or a tool call is looping" } else { "no response" }