de65991807
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>
140 lines
10 KiB
Markdown
140 lines
10 KiB
Markdown
# 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":"<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 `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 <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_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 <tests/test_x.el> | --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-<worktree>/`; `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.
|