# 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":false` explicitly. - `arguments` in 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":"","content":""}`. - 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 `parallel` proves 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 via `Authorization` header 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 `wire` field, legacy default anthropic) · handle_tool_result 3293+ · dharma fork site 3465 (calls agentic_loop direct, no use_openai check today). ## Write plan (order) 1. Env fallbacks (edit llm_base_url/llm_wire_format/agentic_api_key) — small, first, testable alone. 2. `openai_tools_json(anthropic_tools: String) -> String` converter (walk array; per entry build {"type":"function","function":{name,description,parameters:input_schema-raw}}). 3. `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()). 4. Fork sites ×3: handle_chat_agentic :2695-2700 (route agentic to new loop when use_openai); dharma :3465; agentic_resume wire-branch. 5. `chat.elh` extern 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 elb --elc=$HOME/el-sdk/elc --runtime=vendor/el-runtime/v1.0.0-20260501 --out=/ # "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 -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 /soul /*.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_calls` array (`tc_arr`) while the loop answers only the FIRST call. If a provider ignores `parallel_tool_calls:false`, the next request carries an assistant turn with N tool_calls and only ONE `role:"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_agentic` computes `agentic_tools_all()` at ~:2681 BEFORE the fork, then the OpenAI branch computes `agentic_tools_no_web()` again — two `connector_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, after `use_openai` is known (check no other use of `tools_json` sits between :2681 and the fork before moving it). Note: `openai_tools_json()` already skips any entry with no `input_schema`, so Anthropic's server-side `web_search` entry 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_error` branch 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 than `json_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 to `json_safe` and 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 | --all` — the engine tests were NEVER runnable before this (`elc` is a compiler: emits C to stdout and exits). It emits the test to C, compiles `soul.c` separately with `main` renamed away (soul.c owns the daemon's real main but also defines `layered_cycle` et al.), links the remaining modules + the repo-pinned runtime, and executes. Modules cached under `/tmp/el-test-/`; `REBUILD=1` forces. - **The runner computes the verdict itself** because the test FILES cannot: all 9 counted test files do `let pass_count = pass_count + 1` inside an if BLOCK, which El scoping discards, so every summary line reads `0 passed, 0 failed` forever. Per-assertion `PASS:`/`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`: 4 `bridge_save` calls updated for the new `wire` argument, 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 inside `messages_raw` must 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.