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:
@@ -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, "") {
|
||||
|
||||
Reference in New Issue
Block a user