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>
10 KiB
PORT-NOTES — openai tools port working state (2026-08-06, session handoff-safe)
Spec: docs/specs/SPEC-soul-openai-tools-v2-2026-08-06.md (Tim-approved 2026-08-06). Tasks #1-5
tracked in-session (1 ✓ wiring verdict, 2 ✓ stub rig, 3 in-progress = THIS, 4-5 pending).
Worktree: HERE (_wt-openai-tools, branch feat/soul-openai-tools-v2 @ dba755d). Round-9 trees
READ-ONLY. Nothing committed yet.
Step-0 verdict (evidence in journal note ncli-653ba964dd76)
Shipped app never wires the v1 lane: launcher exports SOUL_LLM_MODEL/PROVIDER/BASE_URL +
ANTHROPIC_API_KEY+SOUL_API_KEY (= Keychain key for WHATEVER provider; installer/macos/
neuron-daemons.sh:288-300 on hotfix/beta-round9); brain reads only SOUL_LLM_MODEL (chat.el:8) and
NEURON_LLM_0_* (chat.el:1768-1794) which nothing sets. /api/config PATCH ignores llm_* fields
(studio.el:36 handle_config: POST-only, reads model/provider/api_key only).
Bridge = brain-side ONLY (zero app-repo edits, zero round-9 collision):
llm_base_url(): NEURON_LLM_0_URL → fallback SOUL_LLM_BASE_URL when SOUL_LLM_PROVIDER ∉ {"","anthropic"}llm_wire_format(): NEURON_LLM_0_FORMAT → fallback derive from SOUL_LLM_PROVIDER (openai/grok/gemini/groq/ollama → "openai"; else "anthropic")agentic_api_key(): already works (ANTHROPIC_API_KEY carries the provider key); add NEURON_LLM_0_KEY → SOUL_API_KEY fallback.
Design pins (stub asserts these — stub is green 58/58, tests/gate-openai/)
- Request MUST send
"tool_choice":"auto"(string) +"parallel_tool_calls":falseexplicitly. argumentsin tool_calls = JSON-ENCODED STRING; decode ONCE via json_get → feed dispatch_tool verbatim. Stub's echo-mismatch check catches double-encode/decode (two-escaper trap).- Assistant echo turn:
{"role":"assistant","content":null,"tool_calls":[...]}VERBATIM from response. - Feedback:
{"role":"tool","tool_call_id":"<id>","content":"<result string>"}. - Resume must NOT re-answer an answered id (stub 400s on repeat tool_call_id).
- Parallel tool_calls in a response: take FIRST only + log skip (mirror ADR-0005 stopgap); stub
scenario
parallelproves behavior. - No tools in request when tools array empty/absent turns (boot probes) — stub defaults tolerate.
el idioms confirmed (from openai_chat_complete :1808-1854 + agentic_loop :2751-2838)
- JSON:
json_get(s,k)decoded string ·json_get_raw(s,k)raw subtree ·json_array_len·json_array_get(arr,i)· build by string concat +json_escape()(:1797, OpenAI-lane escaper). - HTTP:
let h: Map = {}+map_set(h,k,v)+http_post_with_headers(url, body, h); Bearer auth viaAuthorizationheader when key non-empty (:1825-1830). - Loop-carried vars must be top-level locals in the fn, mutated as if-expressions at while-body top level (see :2760-2791 pattern + comment :2903-2904 region).
- Error shape:
str_starts_with(raw,"{\"error\"") || str_contains(raw,"\"error\":")→ return{"error":"llm unavailable","reply":""}(:1835-1838).
Remaining read map (before writing the fork)
- chat.el 2840-3200: block walk (2923-3000), policy gate (3009-3023: classify_tool_risk / is_builtin_tool / ask_all / tool_auto_approved → needs_bridge), dispatch_tool call (3025), tool_result feedback (3031, 3067-3072), run-progress ledger append (3078-3087), bridge_save (3182), loop end + done envelope (~3100-3200).
- agentic_resume 3227-3293 (hardcoded Anthropic headers to make wire-aware; blob gets
wirefield, legacy default anthropic) · handle_tool_result 3293+ · dharma fork site 3465 (calls agentic_loop direct, no use_openai check today).
Write plan (order)
- Env fallbacks (edit llm_base_url/llm_wire_format/agentic_api_key) — small, first, testable alone.
openai_tools_json(anthropic_tools: String) -> Stringconverter (walk array; per entry build {"type":"function","function":{name,description,parameters:input_schema-raw}}).openai_agentic_loop(...)fork: same signature as agentic_loop minus Anthropic-only params; INCLUDE run-progress ledger + tools_log + iteration cap 12; NO container_id/ws_drift/web_search (out of scope; strip web_search entry from tools via agentic_tools_literal()+connector merge, NOT _with_web()).- Fork sites ×3: handle_chat_agentic :2695-2700 (route agentic to new loop when use_openai); dharma :3465; agentic_resume wire-branch.
chat.elhextern decls. 6. Compile (recipe: dist/ + elc/elb per neuron-soul-build-deploy memory; round-9 tree soul.c regen'd 08-06 proves toolchain live). 7. Gate: stub selftest recipe in tests/gate-openai/README.md. 8. Anthropic-lane regression via gate9 (READ-ONLY consume from _wt-beta-round9). 9. Live Groq E2E (scratch profile, free port, key via Keychain read-only).
BUILD RECIPE — CORRECTED 2026-08-06 (the June memory is STALE for August code)
~/el-sdk/el_runtime.c (Jun 15) is MISSING builtins the Aug engine calls (engram_wm_count,
engram_wm_top_json, http_delete_json, http_serve_async) → link fails with
"symbol(s) not found for architecture arm64". Use the REPO-PINNED runtime:
mkdir -p <scratch>
elb --elc=$HOME/el-sdk/elc --runtime=vendor/el-runtime/v1.0.0-20260501 --out=<scratch>/
# "elb: link failed" at the end is EXPECTED and harmless — the per-module .c files are produced
cc -std=c11 -O1 -DHAVE_CURL -rdynamic \
-I vendor/el-runtime/v1.0.0-20260501 -I <scratch> -I /opt/homebrew/opt/openssl@3/include \
-L /opt/homebrew/opt/openssl@3/lib \
-include dist/elp-c-decls.h -Wno-error=implicit-function-declaration \
-o <scratch>/soul <scratch>/*.c vendor/el-runtime/v1.0.0-20260501/el_runtime.c \
-lssl -lcrypto -lcurl -lpthread -lm
Source: _engine-plainchat-20260805/README.md:396-412. Verified today: 0 errors, 887,296 B.
elb ALSO rewrites every *.elh in the tree (cosmetic em-dash→hyphen in the auto-gen banner,
plus true-ups) and drops a stray soul..elh — git restore the unrelated ones and delete the
stray before staging, or the diff drowns in noise.
SELF-REVIEW FIX LIST (found by reading my own diff, 2026-08-06 — apply in ONE batch, then rebuild once)
- F3 (CORRECTNESS, do first): the assistant echo currently replays the provider's FULL
tool_callsarray (tc_arr) while the loop answers only the FIRST call. If a provider ignoresparallel_tool_calls:false, the next request carries an assistant turn with N tool_calls and only ONErole:"tool"response → most OpenAI-format providers 400 ("missing tool response for id X") and the run dies. This is the same class as ADR-0005's Anthropic failure, but here it is cheap to close: echo ONLY the honored call ("[" + tc0 + "]"), so the conversation we send is self-consistent and the dropped call never existed from the model's view. The DRIFT log line stays (honest accounting of what we dropped). - F4 (efficiency/latency):
handle_chat_agenticcomputesagentic_tools_all()at ~:2681 BEFORE the fork, then the OpenAI branch computesagentic_tools_no_web()again — twoconnector_tools_json()calls per turn, each an HTTP round-trip to the connector bridge on :7771 (two timeout exposures). Fix: compute the tools array ONCE, per lane, afteruse_openaiis known (check no other use oftools_jsonsits between :2681 and the fork before moving it). Note:openai_tools_json()already skips any entry with noinput_schema, so Anthropic's server-sideweb_searchentry is auto-dropped even if the full array is passed —agentic_tools_no_web()is kept for EXPLICITNESS, not necessity. - F1 (debuggability): the "no choices in response" branch logs a generic string and discards
the body. Log the response head (as the
is_errorbranch does) — a provider that returns 200 with an unexpected shape is otherwise undiagnosable from the log. - OPEN QUESTION (evidence pending from the gate): the tool-result feedback turn escapes with
json_escape()(this lane's escaper) rather thanjson_safe()(used everywhere else). The Anthropic lane escapes that field with NEITHER, which is a latent defect on that side. If the torture scenario shows any escaping loss, switch tojson_safeand note the Anthropic-side finding for Will.
TEST HARNESS — built 2026-08-06 (Task 4 side-work, reusable by anyone)
tests/run-el-test.sh <tests/test_x.el> | --all— the engine tests were NEVER runnable before this (elcis a compiler: emits C to stdout and exits). It emits the test to C, compilessoul.cseparately withmainrenamed away (soul.c owns the daemon's real main but also defineslayered_cycleet al.), links the remaining modules + the repo-pinned runtime, and executes. Modules cached under/tmp/el-test-<worktree>/;REBUILD=1forces.- The runner computes the verdict itself because the test FILES cannot: all 9 counted
test files do
let pass_count = pass_count + 1inside an if BLOCK, which El scoping discards, so every summary line reads0 passed, 0 failedforever. Per-assertionPASS:/FAIL:lines ARE reliable; the runner counts those, exits non-zero on any FAIL or on zero assertions, and was proven to discriminate with a negative control (broken assertion → 31 passed / 1 failed / exit 1). Real in-file fix filed: neuron#116. tests/test_bridge_serialization.el: 4bridge_savecalls updated for the newwireargument, plus Section 9 (8 new assertions) covering wire round-trip both ways, the legacy no-wire blob (resumes as anthropic), and a FIELD-ORDER decoy guard — a fake"wire":"anthropic"planted insidemessages_rawmust not beat the blob's own scalar. That decoy is the round-9 first-match-scanner bug class, now pinned by a test. 32/32 green.
MEMORY-SAVE CAVEAT RESOLVED 2026-08-06
Earlier saves this session reported -> OUTBOX only (real mind unreachable or read-back failed). That was a read-back verifier false negative, not data loss — a direct
POST :7770/api/neuron/recall returns those notes from the live mind verbatim. Another
terminal was fixing exactly this (multi-word read-back probe) the same afternoon. Do NOT
re-save on an OUTBOX report without first querying the mind directly, or you duplicate nodes.
Standing cautions
- PERSIST OFF on the real mind this boot (neuron#98/#92): journal saves only, ferry later. MCP link down this terminal; use neuron_remember.py / neuron_recall.py.
- Aug-16: Groq retires llama-3.3-70b-versatile (separate P0, Tim's call, catalog swap).
- Never bind 7770/7779/17779; never touch ~/.neuron; round-9 worktrees read-only.