feat(engine): tools + agentic loop on the OpenAI wire, and two chat-breaking fixes found proving it

Teaches the OpenAI-format lane (Groq/OpenAI/Grok/Gemini/Ollama) to offer tools,
execute them, and loop — the capability that until now existed only on the
Anthropic wire. The tool-execution, consent, bridge and run-progress machinery is
reused unchanged; only the wire dialect is new.

Two pre-existing defects were found while proving it, and are fixed here because
both silently break chat:

1. PROVIDER WIRING NEVER CONNECTED. The launcher exports SOUL_LLM_PROVIDER /
   SOUL_LLM_BASE_URL and puts the provider key in ANTHROPIC_API_KEY + SOUL_API_KEY;
   the engine's provider fork read only NEURON_LLM_0_*, which nothing sets in a
   customer build. So use_openai was ALWAYS false: every non-Anthropic user's turns
   went to api.anthropic.com carrying, say, a Groq key, and came back
   "llm unavailable". Proven side-by-side against the pinned round-9 brain
   (sha256 15cf7d1b…): identical env, shipped brain = "llm unavailable" both chat
   modes with ZERO calls to the configured endpoint; this build = a real answer,
   with the probe logging POST /v1/chat/completions and Bearer <provider key>.
   Fixed brain-side only (env fallbacks) — no app or launcher change needed.

2. TRUNCATION SPLITS UTF-8 CHARACTERS. The session preload cuts recalled memory at
   fixed BYTE lengths (continuity snippet 350; session_preload_bullets per bullet).
   A cut landing inside a multi-byte character leaves a dangling lead byte in the
   SYSTEM PROMPT, making the whole request body invalid UTF-8 — providers reject it
   and the user sees an unexplained failure. Captured from a real body: 18,710 bytes,
   decode fails at 18,248 on 'e2', a box-drawing rule (U+2500 = E2 94 80) sliced in
   half. Trigger is ordinary content — em dash, curly quote, accented name, emoji,
   table border — and it gets MORE likely as memory grows. Shared code: this hit the
   Anthropic wire too. Fixed with utf8_safe_slice() applied at BOTH cut sites.

WHAT IS IN THE PORT
- llm_base_url / llm_wire_format / agentic_api_key: fall back to the launcher's own
  SOUL_LLM_* names; anthropic deliberately still returns "" so its native path is
  untouched (endpoint configurability remains neuron#62).
- openai_tools_json(): Anthropic tool schema -> OpenAI function schema; entries with
  no input_schema (Anthropic's server-side web_search) are skipped — they cannot
  execute on this wire.
- agentic_tools_no_web(): the standard set minus that server tool.
- openai_agentic_loop(): forked rather than parameterised, so agentic_loop — which
  carries every round-7/8/9 fix — is provably untouched. Same envelopes, same state
  keys, same consent policy (ask_all / escalate / builtin / always-allow), same
  client-bridge contract, same run-progress ledger, same 12-iteration cap.
- ADR-0005 mirrored on this wire: parallel_tool_calls:false is sent explicitly, and
  if a provider ignores it we honour the FIRST call and echo only that one, so the
  conversation we send is never self-contradictory. The drop is logged loudly.
- The assistant turn echoes the provider's own content bytes (json_get_raw), so a
  JSON null stays null and nothing is lost to a decode/re-encode round trip.
- Tool results are embedded already-escaped (dispatch_tool json_safe's them);
  truncation trims a dangling escape so a cut can't invalidate the body.
- bridge_save() gains a "wire" scalar and agentic_resume branches on it, so a
  suspended turn resumes on the wire it suspended on. Legacy blobs (no field) resume
  as anthropic. The field is read from the blob's SCALAR HEAD only — an unbounded
  first-match scan would run on into messages_raw, which is model-controlled, and
  that is exactly the round-9 resume defect. Pinned by a test.
- Three fork sites: handle_chat_agentic, handle_dharma_room_turn_agentic,
  agentic_resume. Tool assembly is computed once per lane at both entry points
  (it makes an HTTP call to the connector bridge; it was being paid for twice).

TOOLING THAT DID NOT EXIST
- tests/run-el-test.sh — engine tests were never runnable: elc is a compiler, it
  emits C and exits. This emits the test to C, compiles soul.c with main renamed
  away, links the rest + the repo-pinned runtime, and runs it. It also COMPUTES THE
  VERDICT, because every counted test file's "N passed, M failed" summary is a
  permanent 0/0 — the counters increment inside if BLOCKS, which El scoping
  discards (9 files; real fix filed as neuron#116). Proven to discriminate with a
  deliberately-broken assertion.
- tests/gate-openai/ — deterministic OpenAI-dialect provider stub + scenarios +
  driver + hostile modes, and a strict request validator that rejects any
  Anthropic-shaped field so dialect leakage fails loudly.

VERIFICATION (rungs named)
- E2E-VERIFIED against a LIVE provider (Anthropic's OpenAI-compatible endpoint,
  confirmed live): real answer; a tool call whose out-of-root path was DENIED by the
  guard, after which the model refused to claim success ("I won't tell you I did it,
  because I didn't"); then a valid path -> file physically on disk with exact content,
  honest reply, ledger with per-round entries + {done:true}.
- Deterministic lane gate: 11/12 in both consent configurations (bridge + local);
  hostile providers produce no hang and no fabricated answer; the 12-iteration cap
  trips with its honest message. The one FAIL is oa-tools-off and is NOT this port —
  see "Known, not fixed here".
- ANTHROPIC LANE UNCHANGED: gate9 32/32 on this build and on the pinned round-9
  brain; request bytes differ only within the noise band that two runs of the
  UNMODIFIED brain also produce (proven with a baseline-vs-baseline control), and
  the preload sections — the shared code touched here — are byte-identical.
  The rig discriminates: the round-8 brain scores 24/32 on it.
- verify-soul-contract.sh: PASS (27/27 routes, no hard-deletes).
- Unit: test_bridge_serialization 36/36 (incl. 8 new wire/field-order assertions),
  test_utf8_slice 18/18, test_agentic_tools 18 PASS / 0 FAIL / 3 documented skips.

KNOWN, NOT FIXED HERE (deliberate)
- Tools:Off on an OpenAI provider still fails: the non-agentic path goes through the
  el-runtime provider chain, which appends /v1/chat/completions to a base URL that
  already ends in /v1 -> /v1/v1/... 404. Runtime/plain-chat territory, untouched
  mid-beta. Note openai_chat_complete() has zero callers — that lane is served
  entirely by the runtime chain.
- The 12-iteration cap does not bound a chain of BRIDGED tools (iteration is
  per-invocation and resume starts fresh). Parity with the Anthropic lane.
- run_progress resets on each resume, so a client rendering cumulative steps across a
  consent pause sees earlier legs vanish. Parity with the Anthropic lane.
- verify-soul-contract.sh needs bash >= 4; under macOS's stock bash 3.2 it dies
  instantly with a FALSE red ("local: -n: invalid option").
- Groq-specific live E2E not run: no Groq key exists on this machine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tim Lingo
2026-08-06 16:41:18 -05:00
parent dba755dcec
commit de65991807
12 changed files with 2778 additions and 27 deletions
+458 -21
View File
@@ -1408,7 +1408,7 @@ fn session_preload_bullets(nodes: String, max_bullets: Int, snip_len: Int) -> St
while i < limit {
let node: String = json_array_get(nodes, i)
let content: String = json_get(node, "content")
let snip: String = if str_len(content) > snip_len { str_slice(content, 0, snip_len) } else { content }
let snip: String = utf8_safe_slice(content, snip_len)
let bullets = if str_eq(snip, "") {
bullets
} else {
@@ -1770,7 +1770,14 @@ fn agentic_api_key() -> String {
if !str_eq(k1, "") {
return k1
}
return env("NEURON_LLM_0_KEY")
let k2: String = env("NEURON_LLM_0_KEY")
if !str_eq(k2, "") {
return k2
}
// Step-0 bridge (2026-08-06): the shipped launcher also exports the Keychain key as
// SOUL_API_KEY (neuron-daemons.sh). Honor it so a provider key configured through the
// app reaches this lane without any launcher change.
return env("SOUL_API_KEY")
}
// OpenAI-compatible providers (Ollama / OpenAI / Grok / Gemini)
@@ -1778,19 +1785,42 @@ fn agentic_api_key() -> String {
// OpenAI-compatible wire format (NEURON_LLM_0_FORMAT=openai) with a configured base URL
// (NEURON_LLM_0_URL, e.g. http://localhost:11434/v1 for local Ollama), basic chat turns are served
// here instead of the Anthropic agentic loop.
// v1 SCOPE: plain chat completion only NO tools / agentic loop yet (that is a follow-up port).
// This block is ADDITIVE: the Anthropic path is untouched and stays the default.
// v2 SCOPE (2026-08-06, SPEC-soul-openai-tools-v2): tools + the agentic loop now run on
// this wire too (openai_agentic_loop below). Plain completion (openai_chat_complete)
// remains for non-agentic turns. Still ADDITIVE: the Anthropic path is untouched.
fn llm_base_url() -> String {
return env("NEURON_LLM_0_URL")
let u: String = env("NEURON_LLM_0_URL")
if !str_eq(u, "") {
return u
}
// Step-0 bridge (2026-08-06): the shipped launcher exports SOUL_LLM_BASE_URL +
// SOUL_LLM_PROVIDER (installer/macos/neuron-daemons.sh:288-300) and nothing in a
// customer build exports the NEURON_LLM_0_* names so this lane was unreachable
// outside test harnesses. Honor the launcher's names as a fallback. Anthropic
// deliberately returns "" here: its native path stays hardcoded (endpoint
// configurability is neuron#62, out of scope).
let p: String = env("SOUL_LLM_PROVIDER")
if str_eq(p, "") || str_eq(p, "anthropic") {
return ""
}
return env("SOUL_LLM_BASE_URL")
}
fn llm_wire_format() -> String {
let f: String = env("NEURON_LLM_0_FORMAT")
if str_eq(f, "") {
return "anthropic"
if !str_eq(f, "") {
return f
}
return f
// Step-0 bridge (2026-08-06): derive the wire format from the launcher's provider
// name when the explicit format is unset. Every non-Anthropic provider in the app's
// catalog speaks the OpenAI-compatible format (ProviderKeys.kt: llmFormat="openai"
// for openai/grok/gemini/groq/ollama).
let p: String = env("SOUL_LLM_PROVIDER")
if str_eq(p, "openai") || str_eq(p, "grok") || str_eq(p, "gemini") || str_eq(p, "groq") || str_eq(p, "ollama") {
return "openai"
}
return "anthropic"
}
// Escape a decoded string so it can be embedded back into a JSON string literal.
@@ -1853,6 +1883,354 @@ fn openai_chat_complete(model: String, base_url: String, api_key: String, safe_s
return "{\"reply\":\"" + json_escape(content) + "\",\"tools_used\":[]}"
}
// OpenAI-format TOOLS PORT (v2, 2026-08-06, SPEC-soul-openai-tools-v2)
// The agentic loop for OpenAI-compatible providers (Groq/OpenAI/Grok/Gemini/Ollama).
// The tool-execution, consent, bridge and run-progress machinery is the SAME wire-agnostic
// layer agentic_loop uses (dispatch_tool, classify_tool_risk, is_builtin_tool, bridge_save,
// handle_tool_result) only the wire dialect differs. ADR-0005's single-tool constraint is
// mirrored on this wire as parallel_tool_calls:false; a provider that ignores it gets its
// first call honored and the rest dropped LOUDLY. Anthropic's server-side web_search has no
// analogue here, so this lane's tool set comes from agentic_tools_no_web() and "sources"
// is always empty an honest degradation, disclosed in the spec, not a bug.
// Convert an Anthropic-shape tools array ({"name","description","input_schema"}) to the
// OpenAI shape ({"type":"function","function":{"name","description","parameters"}}).
// Entries without an input_schema (Anthropic server tools like web_search) are skipped
// they cannot execute on this wire.
fn openai_tools_json(tools_anthropic: String) -> String {
let out: String = ""
let i: Int = 0
let n: Int = json_array_len(tools_anthropic)
while i < n {
let entry: String = json_array_get(tools_anthropic, i)
let name: String = json_get(entry, "name")
let desc: String = json_get(entry, "description")
let schema: String = json_get_raw(entry, "input_schema")
let keep: Bool = !str_eq(name, "") && !str_eq(schema, "")
let piece: String = if keep {
"{\"type\":\"function\",\"function\":{\"name\":\"" + json_escape(name) + "\""
+ ",\"description\":\"" + json_escape(desc) + "\""
+ ",\"parameters\":" + schema + "}}"
} else { "" }
let out = if keep {
if str_eq(out, "") { piece } else { out + "," + piece }
} else { out }
let i = i + 1
}
return "[" + out + "]"
}
// utf8_safe_slice str_slice with the guarantee that it never splits a character.
//
// str_slice and str_len count BYTES. Every fixed-length content cut in this file
// therefore risks landing inside a multi-byte UTF-8 character and leaving a dangling
// lead byte, which makes the ENTIRE request body invalid UTF-8 providers reject it
// and the user gets an unexplained failure. Found live 2026-08-06 in the session
// preload: a recalled memory containing box-drawing rules (U+2500 = E2 94 80) was cut
// at 350 bytes mid-character, and every turn on that session died. Ordinary content
// triggers it an em dash, a curly quote, an accented name, an emoji and it gets
// MORE likely as a user's memory grows.
//
// Walk back from the cut over UTF-8 continuation bytes (0x80-0xBF) to the lead byte,
// and keep the character only if all of its bytes survived the cut.
fn utf8_safe_slice(s: String, n: Int) -> String {
if str_len(s) <= n { return s }
let cut: String = str_slice(s, 0, n)
let total: Int = str_len(cut)
let i: Int = total - 1
let keep: Int = total
let scanning: Bool = true
let steps: Int = 0
// A UTF-8 character is at most 4 bytes, so at most 4 steps are ever needed.
while scanning && steps < 4 && i >= 0 {
let c: Int = str_char_code(cut, i)
let is_ascii: Bool = c < 128
let is_lead: Bool = c >= 192
// Expected length declared by the lead byte: 0xF0+ = 4, 0xE0+ = 3, else 2.
let need: Int = if c >= 240 { 4 } else { if c >= 224 { 3 } else { 2 } }
let have: Int = total - i
let keep = if is_ascii { total } else {
if is_lead { if have == need { total } else { i } } else { keep }
}
let scanning = if is_ascii || is_lead { false } else { true }
let i = i - 1
let steps = steps + 1
}
return str_slice(cut, 0, keep)
}
// A tool result arrives already json_safe'd from dispatch_tool, so it is embedded into
// the wire message RAW (escaping it a second time is what made the model read literal
// backslashes). But it is also TRUNCATED at a fixed byte count, and a cut can land in the
// middle of an escape pair leaving a dangling backslash that makes the enclosing JSON
// string invalid and 400s the whole turn. Trim any trailing backslash run so the cut is
// always on a clean boundary. (The Anthropic lane truncates the same way and has the same
// latent exposure; not changed here, flagged in the PR.)
fn json_trim_dangling_escape(s: String) -> String {
let out: String = s
while str_ends_with(out, "\\") {
let out = str_slice(out, 0, str_len(out) - 1)
}
return out
}
// The standard agentic tool set WITHOUT Anthropic's native web_search entry: built-ins +
// every connector tool. Same merge as agentic_tools_all(), minus the server-tool tail.
fn agentic_tools_no_web() -> 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)
let merged: String = if str_eq(conn_inner, "") {
base_inner
} else {
base_inner + "," + conn_inner
}
return "[" + strip_client_web_search(merged) + "]"
}
// openai_agentic_loop the resumable agentic turn on the OpenAI wire. Same two envelopes
// as agentic_loop (done / tool_pending), same client-bridge contract, same state keys.
// [tools_json] arrives ANTHROPIC-shaped (the bridge blob stays wire-uniform); it is
// converted once here. The system prompt travels as the first message (no top-level
// "system" on this wire).
fn openai_agentic_loop(session_id: String, model: String, safe_sys: String, tools_json: String, messages_in: String, tools_log_in: String) -> String {
let api_url: String = llm_base_url() + "/chat/completions"
let api_key: String = agentic_api_key()
let h: Map = {}
map_set(h, "content-type", "application/json")
if !str_eq(api_key, "") {
map_set(h, "Authorization", "Bearer " + api_key)
}
let ask_all: Bool = !str_eq(session_id, "") && str_eq(state_get("require_approval_" + session_id), "true")
let tools_oai: String = openai_tools_json(tools_json)
let has_tools: Bool = json_array_len(tools_oai) > 0
let messages: String = messages_in
let final_text: String = ""
let tools_log: String = tools_log_in
let iteration: Int = 0
let keep_going: Bool = true
// Suspension state top level so it escapes the while body (El scope rule).
let pending: Bool = false
let pend_tool_id: String = ""
let pend_tool_name: String = ""
let pend_tool_input: String = ""
let pend_tool_tier: String = ""
let pend_narration: String = ""
if !str_eq(session_id, "") {
state_set("run_progress_" + session_id, "")
}
while keep_going && iteration < 12 {
let inner_msgs: String = str_slice(messages, 1, str_len(messages) - 1)
let all_msgs: String = if str_eq(inner_msgs, "") {
"[{\"role\":\"system\",\"content\":\"" + safe_sys + "\"}]"
} else {
"[{\"role\":\"system\",\"content\":\"" + safe_sys + "\"}," + inner_msgs + "]"
}
// tools + the ADR-0005 mirror travel only when there are tools to offer: an empty
// tools array is a 400 on real OpenAI-format providers.
let tool_frag: String = if has_tools {
",\"tools\":" + tools_oai + ",\"tool_choice\":\"auto\",\"parallel_tool_calls\":false"
} else { "" }
let req_body: String = "{\"model\":\"" + model + "\""
+ ",\"max_tokens\":16384"
+ tool_frag
+ ",\"messages\":" + all_msgs
+ "}"
let raw_resp: String = http_post_with_headers(api_url, req_body, h)
// OpenAI-format errors arrive as a top-level {"error":{...}} object. Content
// strings inside a valid response are JSON-escaped, so a top-level match cannot
// false-positive on reply text.
let is_error: Bool = str_eq(raw_resp, "") || str_starts_with(raw_resp, "{\"error\"")
if is_error {
let err_head: String = if str_len(raw_resp) > 220 { str_slice(raw_resp, 0, 220) } else { raw_resp }
println("[soul] llm error (openai lane): " + err_head)
return "{\"error\":\"llm unavailable\",\"reply\":\"\"}"
}
let choices: String = json_get_raw(raw_resp, "choices")
let eff_choices: String = if str_eq(choices, "") { "[]" } else { choices }
if json_array_len(eff_choices) < 1 {
// Log the body head, as the error branch does. A provider that answers 200
// with an unexpected shape is otherwise undiagnosable from the log alone.
let noc_head: String = if str_len(raw_resp) > 220 { str_slice(raw_resp, 0, 220) } else { raw_resp }
println("[soul] llm error (openai lane): no choices in response: " + noc_head)
return "{\"error\":\"llm unavailable\",\"reply\":\"\"}"
}
let first: String = json_array_get(eff_choices, 0)
let message_o: String = json_get_raw(first, "message")
let finish: String = json_get(first, "finish_reason")
// Content, read TWO ways on purpose.
// content_raw the provider's own bytes: `null` on a pure tool-call turn, or a
// quoted, already-escaped string. This is what goes back on the wire, verbatim.
// text_out the DECODED text, for narration, the ledger and the final reply.
// json_get decodes, and a JSON null decodes to the 4-char string "null", which is
// never "" so without the raw check a pure tool-call turn produced the literal
// word "null" as the assistant's narration, in the run-progress ledger, and inside
// the tool_pending envelope, and echoed `"content":"null"` instead of `content:null`.
let content_raw: String = json_get_raw(message_o, "content")
let is_null_content: Bool = str_eq(content_raw, "null") || str_eq(content_raw, "")
let text_out: String = if is_null_content { "" } else { json_get(message_o, "content") }
let tc_raw: String = json_get_raw(message_o, "tool_calls")
let tc_arr: String = if str_eq(tc_raw, "") || str_eq(tc_raw, "null") { "[]" } else { tc_raw }
let tc_n: Int = json_array_len(tc_arr)
let has_tool: Bool = tc_n > 0
// ADR-0005 mirror: we ask for one call per round; a provider that returns
// several anyway gets the FIRST honored and the drop logged loudly.
if tc_n > 1 {
println("[soul] DRIFT: provider returned " + int_to_str(tc_n) + " parallel tool_calls despite parallel_tool_calls:false - keeping the first only (ADR-0005 mirror)")
}
// Unknown finish reasons (future API drift): log loudly, never treat an
// unrecognised terminal state as a completed answer silently.
if !str_eq(finish, "stop") && !str_eq(finish, "tool_calls") && !str_eq(finish, "length") && !str_eq(finish, "") {
println("[soul] DRIFT: unknown finish_reason from API: " + finish)
}
let tc0: String = if has_tool { json_array_get(tc_arr, 0) } else { "" }
let tool_id: String = if has_tool { json_get(tc0, "id") } else { "" }
let tc_fn: String = if has_tool { json_get_raw(tc0, "function") } else { "" }
let tool_name: String = if has_tool { json_get(tc_fn, "name") } else { "" }
// arguments is a JSON-ENCODED STRING on this wire; json_get decodes it exactly
// once, yielding the raw object text dispatch_tool expects. Decoding again or
// re-encoding before dispatch is the two-escaper trap the gate's echo-mismatch
// check exists to catch.
let tool_input_raw: String = if has_tool { json_get(tc_fn, "arguments") } else { "" }
let tool_input: String = if str_eq(tool_input_raw, "") { "{}" } else { tool_input_raw }
let is_tool_turn: Bool = has_tool
// Consent policy IDENTICAL to the Anthropic lane: ask_all bridges everything,
// escalate always bridges, non-builtins bridge unless "always allow" granted.
let always_key: String = "always_allow_" + session_id
let always_list: String = if !str_eq(session_id, "") { state_get(always_key) } else { "" }
let is_always_allowed: Bool = !str_eq(tool_name, "") && !str_eq(always_list, "") && str_contains(always_list, tool_name)
let risk_tier: String = if is_tool_turn { classify_tool_risk(tool_name, tool_input) } else { "" }
let needs_bridge: Bool = is_tool_turn && (ask_all || str_eq(risk_tier, "escalate") || (!is_builtin_tool(tool_name) && !is_always_allowed))
let tool_result_raw: String = if is_tool_turn && !needs_bridge { dispatch_tool(tool_name, tool_input) } else { "" }
let tool_result: String = if str_len(tool_result_raw) > 6000 {
json_trim_dangling_escape(str_slice(tool_result_raw, 0, 6000)) + "...[truncated]"
} else { tool_result_raw }
let tool_quoted: String = "\"" + tool_name + "\""
let tools_log = if is_tool_turn {
if str_eq(tools_log, "") { tool_quoted } else { tools_log + "," + tool_quoted }
} else { tools_log }
// The assistant turn echoed with its tool_calls array VERBATIM (raw), so the
// tool_call_id pairing stays valid on the wire and across a bridge resume.
// Echo the provider's content BYTES, never a re-escaped round-trip. Decoding and
// re-encoding is where fidelity is lost: json_escape/json_safe both handle only
// \\ " \n \r, so any other control character the model emits (a tab, say) would go
// back out raw and make the next request body invalid JSON — a provider 400 that
// looks like a random failure. The Anthropic lane never had this exposure because
// it echoes the response's content array untouched; this now matches it.
let content_frag: String = if is_null_content { "null" } else { content_raw }
// Echo ONLY the call we actually honor — never the provider's full array.
// The loop can assemble exactly one tool response per round, so replaying N
// tool_calls while answering one leaves the conversation self-contradictory and
// every OpenAI-format provider 400s on the next request ("no tool response for
// id X"). That is precisely the failure ADR-0005 documents on the Anthropic wire,
// where the block walk keeps the first tool_use and the rest die without a
// tool_result. Here it costs one slice to close: the dropped calls simply never
// existed from the model's point of view, and the DRIFT line above keeps the
// accounting honest about what we discarded.
let assist_turn: String = if has_tool {
"{\"role\":\"assistant\",\"content\":" + content_frag + ",\"tool_calls\":[" + tc0 + "]}"
} else {
"{\"role\":\"assistant\",\"content\":" + content_frag + "}"
}
let inner_now: String = str_slice(messages, 1, str_len(messages) - 1)
let messages_with_assistant: String = "[" + inner_now + "," + assist_turn + "]"
// Local built-in tool turn: append assistant echo + role:"tool" result, loop on.
let local_continue: Bool = is_tool_turn && !needs_bridge
let messages = if local_continue {
let inner2: String = str_slice(messages_with_assistant, 1, str_len(messages_with_assistant) - 1)
"[" + inner2 + ",{\"role\":\"tool\",\"tool_call_id\":\"" + tool_id + "\",\"content\":\"" + tool_result + "\"}]"
} else { messages }
// Live run-progress ledger — same key, same shape, same poller as the Anthropic
// lane; a forked loop that omitted this would silently kill live step rendering.
if !str_eq(session_id, "") {
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 }
let prog_entry: String = "{\"i\":" + int_to_str(iteration)
+ ",\"t\":\"" + json_safe(prog_snip) + "\""
+ ",\"tool\":\"" + json_safe(tool_name) + "\"}"
let prog_next: String = if str_eq(prog_prev, "") { prog_entry } else { prog_prev + "," + prog_entry }
state_set(prog_key, prog_next)
}
// Bridge turn: persist the continuation (wire-tagged) and stop the loop.
let pending = if needs_bridge { true } else { pending }
let pend_tool_id = if needs_bridge { tool_id } else { pend_tool_id }
let pend_tool_name = if needs_bridge { tool_name } else { pend_tool_name }
let pend_tool_input = if needs_bridge { tool_input } else { pend_tool_input }
let pend_tool_tier = if needs_bridge { risk_tier } else { pend_tool_tier }
let pend_narration = if needs_bridge { text_out } else { pend_narration }
if needs_bridge {
bridge_save(session_id, model, safe_sys, tools_json, messages_with_assistant, tools_log, tool_id, "openai")
}
// Text accumulation: rounds are separated by tool executions, so the resume seam
// is unconditionally a boundary (same rule as the Anthropic loop's seam 2).
let final_text = if !is_tool_turn {
final_text + text_join_sep(final_text, text_out, true) + text_out
} else { final_text }
// Output cap hit mid-action (finish_reason "length" with a tool call pending).
let final_text = if str_eq(finish, "length") && 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 }
let keep_going = if local_continue { keep_going } else { false }
let iteration = iteration + 1
}
if pending {
let safe_in: String = if str_eq(pend_tool_input, "") { "{}" } else { pend_tool_input }
let tools_arr: String = if str_eq(tools_log, "") { "[]" } else { "[" + tools_log + "]" }
return "{\"tool_pending\":true"
+ ",\"session_id\":\"" + session_id + "\""
+ ",\"call_id\":\"" + pend_tool_id + "\""
+ ",\"tool_name\":\"" + pend_tool_name + "\""
+ ",\"tool_input\":" + safe_in
+ ",\"risk_tier\":\"" + pend_tool_tier + "\""
+ ",\"narration\":\"" + json_safe(pend_narration) + "\""
+ ",\"model\":\"" + model + "\""
+ ",\"agentic\":true"
+ ",\"sources\":\"\""
+ ",\"tools_used\":" + tools_arr + "}"
}
let final_text = receipt_strip(final_text)
if str_eq(final_text, "") {
let hit_cap: Bool = iteration >= 12
let err_msg: String = if hit_cap {
"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"
}
return "{\"error\":\"" + err_msg + "\",\"reply\":\"\",\"iterations\":" + int_to_str(iteration) + "}"
}
let safe_text: String = json_safe(final_text)
let tools_arr: String = if str_eq(tools_log, "") { "[]" } else { "[" + tools_log + "]" }
if !str_eq(session_id, "") {
let done_key: String = "run_progress_" + session_id
let done_prev: String = state_get(done_key)
let done_next: String = if str_eq(done_prev, "") { "{\"done\":true}" } else { done_prev + ",{\"done\":true}" }
state_set(done_key, done_next)
}
return "{\"reply\":\"" + safe_text + "\",\"model\":\"" + model + "\",\"agentic\":true,\"tools_used\":" + tools_arr + ",\"sources\":\"\",\"iterations\":" + int_to_str(iteration) + "}"
}
fn agentic_tools_literal() -> String {
return "[" +
"{\"name\":\"read_file\",\"description\":\"Read contents of a file from disk.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Absolute file path\"}},\"required\":[\"path\"]}}," +
@@ -2618,7 +2996,7 @@ fn handle_chat_agentic(body: String) -> String {
let ag_continuity_snip: String = if ag_continuity_ok {
let acn0: String = json_array_get(ag_continuity_nodes, 0)
let acc: String = json_get(acn0, "content")
if str_len(acc) > 350 { str_slice(acc, 0, 350) } else { acc }
utf8_safe_slice(acc, 350)
} else { "" }
let ag_profile_bullets: String = session_preload_bullets(ag_profile_nodes2, 8, 350)
let ag_work_bullets: String = session_preload_bullets(ag_work_nodes2, 6, 350)
@@ -2649,7 +3027,13 @@ fn handle_chat_agentic(body: String) -> String {
" + ctx + ag_session_preload + receipt_rule()
let api_key: String = agentic_api_key()
let tools_json: String = agentic_tools_all()
// Assemble the tool set ONCE, for the lane this turn will actually take. Both
// builders call connector_tools_json(), which is an HTTP round-trip to the
// connectors bridge on :7771 computing both would pay that cost, and its timeout
// exposure, twice per turn. The OpenAI lane drops Anthropic's server-side
// web_search (it has no analogue on that wire and cannot execute there).
let tools_lane_openai: Bool = !str_eq(llm_base_url(), "") && str_eq(llm_wire_format(), "openai")
let tools_json: String = if tools_lane_openai { agentic_tools_no_web() } else { agentic_tools_all() }
let safe_msg: String = json_safe(message)
let safe_sys: String = json_safe(system)
@@ -2690,11 +3074,12 @@ fn handle_chat_agentic(body: String) -> String {
// for the rest of the run. Absent/false = behavior identical to before this fix.
let req_ask_all: String = json_get(body, "require_approval")
state_set("require_approval_" + session_id, if str_eq(req_ask_all, "true") { "true" } else { "" })
// Provider fork: OpenAI-compatible providers (Ollama/OpenAI/Grok/Gemini) take the plain-completion
// path (v1, no tools); everything else stays on the Anthropic agentic loop (the default).
let use_openai: Bool = !str_eq(llm_base_url(), "") && str_eq(llm_wire_format(), "openai")
// Provider fork (v2 port, 2026-08-06): OpenAI-compatible providers now take their own
// AGENTIC loop same tools (minus Anthropic-server web_search), same consent policy,
// same bridge contract. The Anthropic native path stays the default and is untouched.
let use_openai: Bool = tools_lane_openai
let result: String = if use_openai {
openai_chat_complete(model, llm_base_url(), agentic_api_key(), safe_sys, messages)
openai_agentic_loop(session_id, model, safe_sys, tools_json, messages, "")
} else {
agentic_loop(session_id, model, safe_sys, tools_json, messages, h, "")
}
@@ -3097,7 +3482,7 @@ fn agentic_loop(session_id: String, model: String, safe_sys: String, tools_json:
// client's tool_result block. messages_with_assistant is only meaningful when a
// tool was requested, so guard on needs_bridge before persisting.
if needs_bridge {
bridge_save(session_id, model, safe_sys, tools_json, messages_with_assistant, tools_log, pend_tool_id)
bridge_save(session_id, model, safe_sys, tools_json, messages_with_assistant, tools_log, pend_tool_id, "anthropic")
}
// ACCUMULATE across pause/resume cycles instead of overwriting. A resumed turn
@@ -3179,7 +3564,7 @@ fn agentic_loop(session_id: String, model: String, safe_sys: String, tools_json:
// single JSON blob in soul state so agentic_resume can rebuild the exact loop. The
// stored `messages` already includes the assistant turn that requested the tool, so
// resume just appends the client's tool_result for `tool_use_id`.
fn bridge_save(session_id: String, model: String, safe_sys: String, tools_json: String, messages: String, tools_log: String, tool_use_id: String) -> Bool {
fn bridge_save(session_id: String, model: String, safe_sys: String, tools_json: String, messages: String, tools_log: String, tool_use_id: String, wire: String) -> Bool {
// Guard: empty messages or tools_json would produce syntactically invalid JSON.
// Return false so the caller detects the failure rather than writing a corrupt
// blob that agentic_resume would later resume with no context.
@@ -3209,10 +3594,13 @@ fn bridge_save(session_id: String, model: String, safe_sys: String, tools_json:
// 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.
// "wire" (v2 port, 2026-08-06) is a json_safe'd SCALAR and therefore sits with the
// other scalars BEFORE both raw fields, per the field-order rule above.
let blob: String = "{\"model\":\"" + json_safe(model) + "\""
+ ",\"safe_sys\":\"" + json_safe(safe_sys) + "\""
+ ",\"tools_log\":\"" + json_safe(tools_log) + "\""
+ ",\"tool_use_id\":\"" + json_safe(tool_use_id) + "\""
+ ",\"wire\":\"" + json_safe(wire) + "\""
+ ",\"tools_raw\":" + tools_json
+ ",\"messages_raw\":" + messages + "}"
state_set("mcp_bridge:" + session_id, blob)
@@ -3268,14 +3656,52 @@ fn agentic_resume(session_id: String, tool_use_id: String, content: String) -> S
str_slice(content, 0, 6000) + "...[truncated]"
} else { content }
let safe_result: String = json_safe(trimmed)
let tool_msg: String = "{\"type\":\"tool_result\",\"tool_use_id\":\"" + eff_use_id + "\",\"content\":\"" + safe_result + "\"}"
let inner: String = str_slice(messages, 1, str_len(messages) - 1)
let resumed_messages: String = "[" + inner + ",{\"role\":\"user\",\"content\":[" + tool_msg + "]}]"
// One-shot: clear the saved turn so a session_id can't be replayed.
state_set("mcp_bridge:" + session_id, "")
// Wire-aware resume (v2 port, 2026-08-06): blobs written since the port carry a
// "wire" scalar ("anthropic" | "openai") among the scalar fields, where first-match
// scanning is safe (see bridge_save's field-order rule). A blob with no wire field
// is a legacy pre-port suspension always Anthropic. On the OpenAI wire the
// client's result goes back as a role:"tool" turn keyed by tool_call_id, and a
// result for an already-answered id must never be re-sent (the resumed messages
// end at the assistant echo, so appending exactly one tool turn preserves that).
// Read "wire" from the blob's SCALAR HEAD ONLY never the whole blob.
//
// json_get is a first-substring-match scanner. On a blob written by this binary the
// scalar sits ahead of the raw fields and wins, but on a LEGACY blob (suspended
// before this field existed) there is no match up front, so the scan runs on into
// messages_raw model- and user-controlled bytes. A conversation that merely
// CONTAINS the literal "wire":"openai" would then misroute the resume onto the wrong
// loop and kill the run. That is exactly the round-9 defect (json_get(blob,
// "tool_use_id") matching a web_search_tool_result id inside the replayed
// conversation), and the fix is the same shape: bound the search.
//
// bridge_save guarantees every json_safe'd scalar precedes the bulk fields, so
// truncating at the earliest bulk key makes this deterministic the decoy is not
// even inside the string we search. Both the current keys (tools_raw/messages_raw)
// and the pre-round-9 legacy ones (tools_json/messages) are covered.
let i_traw: Int = str_index_of(blob, ",\"tools_raw\":")
let i_tjson: Int = str_index_of(blob, ",\"tools_json\":")
let i_mraw: Int = str_index_of(blob, ",\"messages_raw\":")
let i_msgs: Int = str_index_of(blob, ",\"messages\":")
let cut1: Int = if i_traw > 0 { i_traw } else { str_len(blob) }
let cut2: Int = if i_tjson > 0 && i_tjson < cut1 { i_tjson } else { cut1 }
let cut3: Int = if i_mraw > 0 && i_mraw < cut2 { i_mraw } else { cut2 }
let cut: Int = if i_msgs > 0 && i_msgs < cut3 { i_msgs } else { cut3 }
let blob_head: String = str_slice(blob, 0, cut)
let wire: String = json_get(blob_head, "wire")
if str_eq(wire, "openai") {
let tool_msg_o: String = "{\"role\":\"tool\",\"tool_call_id\":\"" + eff_use_id + "\",\"content\":\"" + safe_result + "\"}"
let resumed_o: String = "[" + inner + "," + tool_msg_o + "]"
return openai_agentic_loop(session_id, model, safe_sys, tools_json, resumed_o, tools_log)
}
let tool_msg: String = "{\"type\":\"tool_result\",\"tool_use_id\":\"" + eff_use_id + "\",\"content\":\"" + safe_result + "\"}"
let resumed_messages: String = "[" + inner + ",{\"role\":\"user\",\"content\":[" + tool_msg + "]}]"
let api_key: String = agentic_api_key()
let h: Map = {}
map_set(h, "x-api-key", api_key)
@@ -3451,7 +3877,11 @@ fn handle_dharma_room_turn_agentic(body: String) -> String {
// Hard Bell: pre-LLM safety evaluation on agentic dharma room turns.
let system = safety_augment_system(system, transcript)
let tools_json: String = agentic_tools_all()
// One assembly, for the lane this turn takes (see the same note in handle_chat_agentic:
// both builders hit the connectors bridge over HTTP, so computing both doubles the cost
// and the timeout exposure).
let use_openai_d: Bool = !str_eq(llm_base_url(), "") && str_eq(llm_wire_format(), "openai")
let tools_json: String = if use_openai_d { agentic_tools_no_web() } else { agentic_tools_all() }
let safe_transcript: String = json_safe(transcript)
let safe_sys: String = json_safe(system)
let messages: String = "[{\"role\":\"user\",\"content\":\"" + safe_transcript + "\"}]"
@@ -3462,7 +3892,14 @@ fn handle_dharma_room_turn_agentic(body: String) -> String {
// Use dharma-prefixed session_id so bridge suspension works correctly per room.
let session_id: String = if str_eq(room_id, "") { "dharma:" + next_bridge_id() } else { "dharma:" + room_id }
let loop_result: String = agentic_loop(session_id, model, safe_sys, tools_json, messages, h, "")
// Provider fork (v2 port, 2026-08-06): same routing rule as handle_chat_agentic.
// The Hard Bell augmentation above is baked into safe_sys BEFORE the fork, so the
// safety pass is identical on both wires.
let loop_result: String = if use_openai_d {
openai_agentic_loop(session_id, model, safe_sys, tools_json, messages, "")
} else {
agentic_loop(session_id, model, safe_sys, tools_json, messages, h, "")
}
let result_error: String = json_get(loop_result, "error")
if !str_eq(result_error, "") {