feat(engine): plain chat generates at L3 — inside the safety cycle, not around it (+ crisis-path segfault fix) #109
Reference in New Issue
Block a user
Delete Branch "feat/soul-plain-chat-generation-20260805"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Builds on #108 (
feat/soul-native-web-search-20260804). Please review #108 first. Do not merge yet — needs Will's ratification on two governance-gated calls (below).Root cause
Non-agentic
/api/chat— the desktop app's default mode (AgentMode.NEVER) — returned the user's own screened text as a bare, non-JSON string. Every JSON client failed to parse it and surfaced "Couldn't reach Neuron - it may be offline."f52d5bd(2026-06-11, "wire consciousness layers") correctly moved the route fromhandle_chatontolayered_cycle. What it never got was a generator at L3 —imprint_respond()annotates its input and returns it, so the cycle screened, stewarded and validated an echo.Two parts of the architecture were already waiting for that step:
layered_cycleparks a bell directive in the state keylayered_cycle_safety_system_addendum(soul.el:495-499), andbuild_system_prompt()is written to consume and clear it (chat.el:765-767). A producer with no consumer.build_system_prompt(ctx, chat_mode)carries achat_modeflag documented as "pass true from handle_chat (no tools)" — a tool-less generation mode with no live caller.The change
Compose, don't replace. Wiring
handle_chat(the obvious fix) would have removedsafety_screen, the hard-bell short-circuit, the entire stewardship layer, andsafety_validate— the only enforcing output gate in the codebase — in exchange for a working reply. That is why the previous pass stopped:_engine-websearch-20260804/SAFETY-STOP.md. This implements its option 2.chat.el— NEWlayered_generate()(L3 generation, no tools offered),conv_history_block(),conv_history_record(). FIX:build_system_promptcomputedno_tools_ruleand never concatenated it into its return, so[NO TOOLS THIS TURN]reached no model at all; restored.handle_chatannotated DO-NOT-WIRE with the reason.soul.el—layered_cyclegains L3b plus post-validation turn bookkeeping.routes.el— NEWplain_chat_envelope(); all three/api/chatdispatch sites (:246,:419,:583) wrap the cycle's output. Built outside the cycle sosafety_validatealways sees raw model text — nothing to unwrap or rebuild on the crisis path, which was SAFETY-STOP's main objection. Emits bothreplyandresponse: a survey of all 12 live consumers found the desktop app readsreplyfirst while the CLI tools andtelegram-gateway.shreadresponse(the gateway reads onlyresponse)..elhheaders deliberately excluded —elbregenerates them into its own scratch dir; reverting the committed ones produces a byte-identical binary. Same call #108 made.Safety: no gate weakened, bypassed, or reordered
safety_screeninput gatesafety_log_bellsteward_session_check/steward_alignsafety_augment_systemsafety_validateoutput gateTools:Off means no tools are offered, two ways:
llm_call_system→llm_chain_call→llm_provider_requestbuilds a body with onlymodel/max_tokens/system/messages— there is notoolsortool_choicekey in that builder at all; andbuild_system_prompt(ctx, true)injects the NO-TOOLS rule. Agentic callers passchat_mode=false, where that rule is""— blast radius of the restored rule is exactly one path.Also fixes BUG-PLAINCHAT-1 — a shipped CRITICAL crash on the crisis path
A distress message following an earlier affective message segfaults the whole daemon.
elccompileslet n: Int = pos + str_len(marker)toel_str_concat(pos, str_len(marker))— string concatenation on two integers — when the expression sits inside a block-expression initializer.el_str_concattakes the C string of each operand, so integers become a wild pointer:Six inline copies of the same
" | ts:"parser had it: two inlayered_cycleL2c, two inengram_compile(live on the agentic path too), two inaffective_context_prefix.auto_persistwritesBellEventcontent containing" | ts:", so the next affective turn parses it and dies.Proven pre-existing, not introduced here: an unmodified binary built from #108's branch crashes identically on the same two-turn sequence (
HTTP=000, daemon dead), and the identical bad C is in the committeddist/soul.c:Fixed by hoisting to one top-level function
affective_node_ts(), where the same expression compiles correctly — verified in the generated C:el_val_t start = (mpos + str_len(marker));, and zero bad-concat sites remain across all 49 modules. Same defect family Will hit on 2026-06-23 and solved the same way (aff_try_slot, soul.el:120).How to test
Sandbox: throwaway
HOME+ engram, dead axon/ISE,NEURON_PORTset explicitly on a free port (~/neuron_soul_smokebuild.shstill exportsSOUL_PORT, which the soul ignores — it defaults to 7770, the live port).POST /api/chat {"agentic":false,"message":"..."}→ JSON envelope with a generated answer, not the input.NEURON_LLM_0_URL) and inspect the captured body: notools, notool_choice, NO-TOOLS rule in the system prompt.tests/test_safety.el§4's hard-bell fixture → fixed 988 message and the provider request count does not move.safety_validate's care phrase is appended to the model's output.HTTP=000); this one returns 200./opt/homebrew/bin/bash scripts/verify-soul-contract.sh <binary> 7796→ GATE PASS.Results:
test_layered_cyclePASS (0 failures); all six El suites byte-identical to the unmodified baseline (every failing assertion pre-dates this change); contract gate PASS 27/27, immutability PASS;web_search_20250305anddisable_parallel_tool_usestill in the binary, so #108 and the ADR-0005 stopgap are intact; zero changed definition lines in anyagentic_*function.Not proven: no live Anthropic call. The login keychain refuses the key to a non-interactive process (
rc=24,errSecInteractionNotAllowed) — environmental, #108 got it ~10h earlier. Full write-up and the one-command close-out:_engine-plainchat-20260805/README.md§7.Needs Will
layered_cycleshould call a model at L3. This is the CGI three-layer, governance-gated.replyandresponse(both are required by live clients today).elcitself — the fix is here, but the compiler still mis-types integer+in block-expression initializers. A compiler fix or diagnostic is the real poka-yoke.dist/soul.c— not done here (localelcis dated 2026-06-05; Will last regenerated it). CI compilesdist/soul.c, so none of this reaches production until he does — including the crash fix.dist/soul-with-nlg.el— delete or confirm nothing needs it. It wireshandle_chatinto/api/chatand contains no layer cycle; a binary built from it runs chat with no enforcing gates. A DO-NOT-BUILD banner is added here; deletion not taken unilaterally. (Git renders that file as binary, so the banner is not visible in the diff — it is at the top of the file.)soul-launch.shputs the API key on anenvcommand line, visible inpsto any local process.llm_call_systemignores its model argument — the envelope'smodelfield andcurrent_engine_notecan name a model the plain path did not use (the live dev stack setsSOUL_LLM_MODEL=claude-sonnet-4-6, which that path cannot honour). Pre-existing;handle_chathad it too.Binary + full proof transcript:
~/Development/neuron-technologies/_engine-plainchat-20260805/SHA256
f95c5f5b0dbbb0e2d55c8776c1d0f3a39f1542ced253354986d7c60c6bd26333🤖 Generated with Claude Code
The agentic loop keeps only the FIRST tool_use block per round (chat.el:2281, "Capture first tool_use block only"). Anthropic lets a model emit several tool_use blocks in one message and requires a tool_result for every one, so a parallel-tool turn is answered once, the rest are dropped, and the next request dies with: tool_use ids were found without tool_result blocks immediately after (neuron#78 quotes this as "tool_use ids found without tool_result"; the above is the API's actual wording - recorded so the next person's grep matches.) This constrains the wire to match what the loop can assemble: "tool_choice":{"type":"auto","disable_parallel_tool_use":true} STOPGAP - AND THE DURABLE FIX ALREADY EXISTS. A correct multi-tool loop is already in Will's EL runtime, in C, and the soul does not call it. Verified on el:origin/main lang/el-compiler/runtime/el_runtime.c: llm_register_tool:9616, llm_build_tool_results:9743 - which walks EVERY content block, emits one tool_result per tool_use, and sets is_error for an unregistered tool - llm_call_agentic:9817 calling it at :9918, iteration cap 10 at :9847. Will's commit 12d5e77 (2026-04-30). grep for llm_call_agentic/llm_register_tool across every neuron/*.el returns nothing; dist/soul.c has zero references. chat.el hand-rolls its own single-tool loop instead, and that is the one that breaks. The durable fix is therefore to register the soul's tools via llm_register_tool and call llm_call_agentic - deleting a loop, not writing one. See ADR 0005. Our own approved spec called this seven weeks ago: docs/research/agentic-tool-approval-design.md (2026-06-12, "Approved for build"), line 20 on the defect, line 30 on the goal ("Execute all tool_use blocks in a turn (one result per block)"). Two edits, because dist/soul.c cannot be regenerated here (Will's gated elc/elb toolchain is not on this machine): (a) chat.el:2255 - source of truth, so a later regen carries the fix. One edit covers all three routes: agentic_loop is called from chat.el:2152 (/api/chat agentic), :2676 (dharma room) and :2496 (agentic_resume). (b) dist/soul.c:28173 - generated form, hand-spliced. Line 27624 is the non-agentic/OpenAI-compat req_body (no tools) and was left untouched. Prior art reused rather than reinvented: soul-narrated-runs-20260713.patch (27,824 bytes) line 78 spliced this same string into the same concat chain on 2026-07-13. Deliberately NOT ported from that patch, having read it: max_tokens is not changed by it (16384 sits on both sides of the hunk; our main's 4096 is a separate output-truncation concern), and its pause_turn pairing fix - same defect class - is unreachable today because no server-side web_search is wired (agentic_tools_with_web at chat.el:1418 is never called), so it is untestable and logged instead. Proven E2E on a scratch profile and port 7791, never the live chain. A/B against a pristine origin/main control built from the same vendored runtime: fixed completed the mission (tools_used read_file x3, 4 iterations, correct answer); control failed 3/3. Direct API probe confirmed the mechanism - without the field the model emits 3 parallel tool_use blocks and replaying the unfixed loop's next turn returns HTTP 400; with it, exactly 1 block. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>