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>
93 lines
4.5 KiB
EmacsLisp
93 lines
4.5 KiB
EmacsLisp
// tests/test_utf8_slice.el
|
|
//
|
|
// Guards utf8_safe_slice(), the fix for a live defect found 2026-08-06:
|
|
//
|
|
// The session preload cuts recalled memory content at a fixed length
|
|
// (chat.el: `if str_len(acc) > 350 { str_slice(acc, 0, 350) }` and
|
|
// session_preload_bullets' identical per-bullet cut). str_slice and str_len count
|
|
// BYTES, so any cut landing inside a multi-byte UTF-8 character leaves a dangling
|
|
// lead byte in the system prompt — and the whole request body is then invalid UTF-8.
|
|
// Providers reject it outright, so the user sees "AI unavailable" with no clue why,
|
|
// on both wire formats. Caught by an OpenAI-lane gate whose stub decodes strictly;
|
|
// reproduced from a real memory whose content contained box-drawing rules (E2 94 80).
|
|
//
|
|
// Trigger is ordinary content: an em dash, a curly quote, an accented name, a table
|
|
// border, an emoji — anything non-ASCII sitting on the cut boundary. It gets MORE
|
|
// likely as a user's memory grows, which is the opposite of what should happen.
|
|
//
|
|
// §1 also pins the semantics this fix depends on: that str_char_code returns the
|
|
// BYTE value at a byte index (not a decoded code point). If a future runtime changes
|
|
// that, these assertions fail loudly instead of the truncation silently rotting.
|
|
|
|
import "../chat.el"
|
|
|
|
let pass_count: Int = 0
|
|
let fail_count: Int = 0
|
|
|
|
fn assert_eq(label: String, got: String, expected: String) -> Void {
|
|
if str_eq(got, expected) {
|
|
let pass_count = pass_count + 1
|
|
println(" PASS: " + label)
|
|
} else {
|
|
let fail_count = fail_count + 1
|
|
println(" FAIL: " + label)
|
|
println(" got: " + got)
|
|
println(" expected: " + expected)
|
|
}
|
|
}
|
|
|
|
fn assert_eq_int(label: String, got: Int, expected: Int) -> Void {
|
|
assert_eq(label, int_to_str(got), int_to_str(expected))
|
|
}
|
|
|
|
println("")
|
|
println("1. runtime semantics this fix relies on")
|
|
|
|
// "─" is U+2500 = E2 94 80 (three bytes). If str_len counts bytes, len("─") is 3.
|
|
let dash: String = "─"
|
|
assert_eq_int("str_len counts BYTES (one box-drawing char = 3)", str_len(dash), 3)
|
|
assert_eq_int("str_char_code returns the BYTE value (lead byte of U+2500 = 0xE2 = 226)", str_char_code(dash, 0), 226)
|
|
assert_eq_int("str_char_code second byte = 0x94 = 148", str_char_code(dash, 1), 148)
|
|
assert_eq_int("str_char_code third byte = 0x80 = 128", str_char_code(dash, 2), 128)
|
|
|
|
println("")
|
|
println("2. utf8_safe_slice — never leaves a partial character")
|
|
|
|
// Pure ASCII: behaves exactly like str_slice.
|
|
assert_eq("ascii under the limit is untouched", utf8_safe_slice("hello", 10), "hello")
|
|
assert_eq("ascii over the limit cuts exactly", utf8_safe_slice("hello world", 5), "hello")
|
|
|
|
// A cut landing INSIDE a 3-byte character must drop that character entirely.
|
|
// "ab─cd": bytes a b E2 94 80 c d. Cutting at 3 or 4 lands mid-dash.
|
|
let mixed: String = "ab─cd"
|
|
assert_eq_int("fixture is 7 bytes (2 ascii + 3 + 2 ascii)", str_len(mixed), 7)
|
|
assert_eq("cut inside the char (n=3) drops the partial char", utf8_safe_slice(mixed, 3), "ab")
|
|
assert_eq("cut inside the char (n=4) drops the partial char", utf8_safe_slice(mixed, 4), "ab")
|
|
// A cut landing exactly AFTER a complete character keeps it.
|
|
assert_eq("cut on the char boundary (n=5) keeps the whole char", utf8_safe_slice(mixed, 5), "ab─")
|
|
|
|
// 2-byte character (é = C3 A9) and 4-byte character (😀 = F0 9F 98 80).
|
|
let acc: String = "xé"
|
|
assert_eq("cut inside a 2-byte char drops it", utf8_safe_slice(acc, 2), "x")
|
|
assert_eq("cut after a 2-byte char keeps it", utf8_safe_slice(acc, 3), "xé")
|
|
let emo: String = "x😀"
|
|
assert_eq("cut inside a 4-byte char drops it (n=3)", utf8_safe_slice(emo, 3), "x")
|
|
assert_eq("cut inside a 4-byte char drops it (n=4)", utf8_safe_slice(emo, 4), "x")
|
|
assert_eq("cut after a 4-byte char keeps it", utf8_safe_slice(emo, 5), "x😀")
|
|
|
|
println("")
|
|
println("3. the real-world shape that produced the bug")
|
|
|
|
// A run of box-drawing rules, cut mid-character — the exact captured failure.
|
|
let rules: String = "──────"
|
|
assert_eq_int("six box rules = 18 bytes", str_len(rules), 18)
|
|
// n=16 lands one byte into the sixth character.
|
|
let cut16: String = utf8_safe_slice(rules, 16)
|
|
assert_eq_int("cut at 16 backs off to a clean 15-byte boundary", str_len(cut16), 15)
|
|
// Every byte of the result must belong to a complete character: the last byte of a
|
|
// well-formed run of these is always 0x80, and 15 is divisible by 3.
|
|
assert_eq_int("result ends on a complete char (last byte 0x80)", str_char_code(cut16, 14), 128)
|
|
|
|
println("")
|
|
println("test_utf8_slice.el: " + int_to_str(pass_count) + " passed, " + int_to_str(fail_count) + " failed")
|