Files
neuron/PORT-NOTES.md
T
Tim Lingo de65991807 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>
2026-08-06 16:41:18 -05:00

10 KiB
Raw Blame History

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..elhgit 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.