feat(engine): plain chat generates at L3 — inside the safety cycle, not around it (+ crisis-path segfault fix) #109

Merged
tim.lingo merged 3 commits from feat/soul-plain-chat-generation-20260805 into main 2026-08-07 15:53:12 +00:00
Member

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 from handle_chat onto layered_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_cycle parks a bell directive in the state key layered_cycle_safety_system_addendum (soul.el:495-499), and build_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 a chat_mode flag 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 removed safety_screen, the hard-bell short-circuit, the entire stewardship layer, and safety_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.

L1 screen -> safe-mode guard -> hard-bell short-circuit -> L2a -> L2b -> L2c
   -> L3  imprint_respond   (unchanged; produces the PROMPT)
   -> L3b layered_generate  (NEW; the only new step)
   -> L1  safety_validate   (unchanged; still the terminal gate)
   -> conv_history_record   (NEW; state side effect, cannot alter the return)
  • chat.el — NEW layered_generate() (L3 generation, no tools offered), conv_history_block(), conv_history_record(). FIX: build_system_prompt computed no_tools_rule and never concatenated it into its return, so [NO TOOLS THIS TURN] reached no model at all; restored. handle_chat annotated DO-NOT-WIRE with the reason.
  • soul.ellayered_cycle gains L3b plus post-validation turn bookkeeping.
  • routes.el — NEW plain_chat_envelope(); all three /api/chat dispatch sites (:246, :419, :583) wrap the cycle's output. Built outside the cycle so safety_validate always sees raw model text — nothing to unwrap or rebuild on the crisis path, which was SAFETY-STOP's main objection. Emits both reply and response: a survey of all 12 live consumers found the desktop app reads reply first while the CLI tools and telegram-gateway.sh read response (the gateway reads only response).

.elh headers deliberately excluded — elb regenerates 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

Gate After
safety_screen input gate unchanged, still first
safe-mode guard unchanged
hard-bell short-circuit unchanged — returns before L3, so a hard bell provably never reaches a model
safety_log_bell unchanged, still once
steward_session_check / steward_align unchanged
L2c affective injection unchanged behaviour (parser hoisted — see the crash fix)
safety_augment_system strengthened — its directive now actually reaches the model on this route (it had no consumer before). Still one evaluation, one InternalStateEvent per turn
safety_validate output gate unchanged, still terminal — and now validates real model output instead of an echo

Tools:Off means no tools are offered, two ways: llm_call_systemllm_chain_callllm_provider_request builds a body with only model/max_tokens/system/messages — there is no tools or tool_choice key in that builder at all; and build_system_prompt(ctx, true) injects the NO-TOOLS rule. Agentic callers pass chat_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.

elc compiles let n: Int = pos + str_len(marker) to el_str_concat(pos, str_len(marker)) — string concatenation on two integers — when the expression sits inside a block-expression initializer. el_str_concat takes the C string of each operand, so integers become a wild pointer:

_platform_strlen <- el_str_concat <- layered_cycle <- handle_request <- http_worker
EXC_BAD_ACCESS (SIGSEGV) KERN_INVALID_ADDRESS at 0x0

Six inline copies of the same " | ts:" parser had it: two in layered_cycle L2c, two in engram_compile (live on the agentic path too), two in affective_context_prefix. auto_persist writes BellEvent content 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 committed dist/soul.c:

el_val_t lbs = el_str_concat(lbmp, str_len(lbm));

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

Permanent El rule: never put integer arithmetic inside a block-expression initializer. Hoist it to a function. Check: grep -E 'el_str_concat\([a-z_]+, str_len\(' <generated>.c

How to test

Sandbox: throwaway HOME + engram, dead axon/ISE, NEURON_PORT set explicitly on a free port (~/neuron_soul_smokebuild.sh still exports SOUL_PORT, which the soul ignores — it defaults to 7770, the live port).

  1. POST /api/chat {"agentic":false,"message":"..."} → JSON envelope with a generated answer, not the input.
  2. Point the soul at a recording provider (NEURON_LLM_0_URL) and inspect the captured body: no tools, no tool_choice, NO-TOOLS rule in the system prompt.
  3. Send tests/test_safety.el §4's hard-bell fixture → fixed 988 message and the provider request count does not move.
  4. Send §6's soft-bell fixture with the provider returning a 2-char reply → safety_validate's care phrase is appended to the model's output.
  5. Hard-bell turn then soft-bell turn → baseline daemon dies (HTTP=000); this one returns 200.
  6. /opt/homebrew/bin/bash scripts/verify-soul-contract.sh <binary> 7796 → GATE PASS.

Results: test_layered_cycle PASS (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_20250305 and disable_parallel_tool_use still in the binary, so #108 and the ADR-0005 stopgap are intact; zero changed definition lines in any agentic_* 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

  1. Ratify the L3 generation step — that layered_cycle should call a model at L3. This is the CGI three-layer, governance-gated.
  2. Ratify the envelope — emitting both reply and response (both are required by live clients today).
  3. elc itself — 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.
  4. Regenerate dist/soul.c — not done here (local elc is dated 2026-06-05; Will last regenerated it). CI compiles dist/soul.c, so none of this reaches production until he does — including the crash fix.
  5. dist/soul-with-nlg.el — delete or confirm nothing needs it. It wires handle_chat into /api/chat and 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.)
  6. soul-launch.sh puts the API key on an env command line, visible in ps to any local process.
  7. llm_call_system ignores its model argument — the envelope's model field and current_engine_note can name a model the plain path did not use (the live dev stack sets SOUL_LLM_MODEL=claude-sonnet-4-6, which that path cannot honour). Pre-existing; handle_chat had it too.

Binary + full proof transcript: ~/Development/neuron-technologies/_engine-plainchat-20260805/
SHA256 f95c5f5b0dbbb0e2d55c8776c1d0f3a39f1542ced253354986d7c60c6bd26333

🤖 Generated with Claude Code

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 from `handle_chat` onto `layered_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_cycle` parks a bell directive in the state key `layered_cycle_safety_system_addendum` (soul.el:495-499), and `build_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 a `chat_mode` flag 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 removed `safety_screen`, the hard-bell short-circuit, the entire stewardship layer, and `safety_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**. ``` L1 screen -> safe-mode guard -> hard-bell short-circuit -> L2a -> L2b -> L2c -> L3 imprint_respond (unchanged; produces the PROMPT) -> L3b layered_generate (NEW; the only new step) -> L1 safety_validate (unchanged; still the terminal gate) -> conv_history_record (NEW; state side effect, cannot alter the return) ``` - **`chat.el`** — NEW `layered_generate()` (L3 generation, no tools offered), `conv_history_block()`, `conv_history_record()`. **FIX:** `build_system_prompt` computed `no_tools_rule` and never concatenated it into its return, so `[NO TOOLS THIS TURN]` reached no model at all; restored. `handle_chat` annotated DO-NOT-WIRE with the reason. - **`soul.el`** — `layered_cycle` gains L3b plus post-validation turn bookkeeping. - **`routes.el`** — NEW `plain_chat_envelope()`; all three `/api/chat` dispatch sites (`:246`, `:419`, `:583`) wrap the cycle's output. Built **outside** the cycle so `safety_validate` always sees raw model text — nothing to unwrap or rebuild on the crisis path, which was SAFETY-STOP's main objection. Emits both `reply` and `response`: a survey of all 12 live consumers found the desktop app reads `reply` first while the CLI tools and `telegram-gateway.sh` read `response` (the gateway reads *only* `response`). `.elh` headers deliberately excluded — `elb` regenerates 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 | Gate | After | |---|---| | `safety_screen` input gate | unchanged, still first | | safe-mode guard | unchanged | | hard-bell short-circuit | unchanged — **returns before L3**, so a hard bell provably never reaches a model | | `safety_log_bell` | unchanged, still once | | `steward_session_check` / `steward_align` | unchanged | | L2c affective injection | unchanged behaviour (parser hoisted — see the crash fix) | | `safety_augment_system` | **strengthened** — its directive now actually reaches the model on this route (it had no consumer before). Still one evaluation, one InternalStateEvent per turn | | `safety_validate` output gate | unchanged, still terminal — and now validates real model output instead of an echo | **Tools:Off means no tools are offered**, two ways: `llm_call_system` → `llm_chain_call` → `llm_provider_request` builds a body with only `model`/`max_tokens`/`system`/`messages` — there is no `tools` or `tool_choice` key in that builder at all; and `build_system_prompt(ctx, true)` injects the NO-TOOLS rule. Agentic callers pass `chat_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**. `elc` compiles `let n: Int = pos + str_len(marker)` to `el_str_concat(pos, str_len(marker))` — string concatenation on two integers — when the expression sits inside a block-expression initializer. `el_str_concat` takes the C string of each operand, so integers become a wild pointer: ``` _platform_strlen <- el_str_concat <- layered_cycle <- handle_request <- http_worker EXC_BAD_ACCESS (SIGSEGV) KERN_INVALID_ADDRESS at 0x0 ``` Six inline copies of the same `" | ts:"` parser had it: two in `layered_cycle` L2c, two in `engram_compile` (**live on the agentic path too**), two in `affective_context_prefix`. `auto_persist` writes `BellEvent` content 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 committed `dist/soul.c`: ```c el_val_t lbs = el_str_concat(lbmp, str_len(lbm)); ``` 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). > Permanent El rule: never put integer arithmetic inside a block-expression initializer. Hoist it to a function. Check: `grep -E 'el_str_concat\([a-z_]+, str_len\(' <generated>.c` ## How to test Sandbox: throwaway `HOME` + engram, dead axon/ISE, **`NEURON_PORT` set explicitly** on a free port (`~/neuron_soul_smokebuild.sh` still exports `SOUL_PORT`, which the soul ignores — it defaults to **7770, the live port**). 1. `POST /api/chat {"agentic":false,"message":"..."}` → JSON envelope with a generated answer, not the input. 2. Point the soul at a recording provider (`NEURON_LLM_0_URL`) and inspect the captured body: no `tools`, no `tool_choice`, NO-TOOLS rule in the system prompt. 3. Send `tests/test_safety.el` §4's hard-bell fixture → fixed 988 message **and the provider request count does not move**. 4. Send §6's soft-bell fixture with the provider returning a 2-char reply → `safety_validate`'s care phrase is appended to the model's output. 5. Hard-bell turn then soft-bell turn → baseline daemon dies (`HTTP=000`); this one returns 200. 6. `/opt/homebrew/bin/bash scripts/verify-soul-contract.sh <binary> 7796` → GATE PASS. **Results:** `test_layered_cycle` **PASS** (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_20250305` and `disable_parallel_tool_use` still in the binary, so #108 and the ADR-0005 stopgap are intact; zero changed definition lines in any `agentic_*` 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 1. **Ratify the L3 generation step** — that `layered_cycle` should call a model at L3. This is the CGI three-layer, governance-gated. 2. **Ratify the envelope** — emitting both `reply` and `response` (both are required by live clients today). 3. **`elc` itself** — 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. 4. **Regenerate `dist/soul.c`** — not done here (local `elc` is dated 2026-06-05; Will last regenerated it). CI compiles `dist/soul.c`, so **none of this reaches production until he does — including the crash fix.** 5. **`dist/soul-with-nlg.el`** — delete or confirm nothing needs it. It wires `handle_chat` into `/api/chat` and 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.) 6. **`soul-launch.sh` puts the API key on an `env` command line**, visible in `ps` to any local process. 7. **`llm_call_system` ignores its model argument** — the envelope's `model` field and `current_engine_note` can name a model the plain path did not use (the live dev stack sets `SOUL_LLM_MODEL=claude-sonnet-4-6`, which that path cannot honour). Pre-existing; `handle_chat` had it too. Binary + full proof transcript: `~/Development/neuron-technologies/_engine-plainchat-20260805/` SHA256 `f95c5f5b0dbbb0e2d55c8776c1d0f3a39f1542ced253354986d7c60c6bd26333` 🤖 Generated with [Claude Code](https://claude.com/claude-code)
tim.lingo added 3 commits 2026-08-05 14:13:50 +00:00
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>
feat(engine): port Anthropic server-side web_search into the agentic loop
Neuron Soul CI / build (pull_request) Failing after 10m45s
Neuron Soul CI / deploy (pull_request) Has been skipped
62af5649fe
Re-authors soul-webfix-20260711.patch in El (the patch is a diff against
generated C at month-old offsets, so nothing was applied as a patch). Its
last two hunks — an unrelated /api/safety-contact implementation — were
deliberately not ported; that route already exists and is safety-critical.

Activation restores existing design, it does not invent a mechanism:
commit 8eea1d9 (2026-06-09, Tim-approved) made native web_search built-in
with no user-facing toggle, and tests/test_agentic_tools.el section 2 still
asserts agentic_tools_all() contains it — an assertion main currently fails.
The call site was lost when agentic_tools_all() (connector tools, PR #19)
replaced agentic_tools_with_web(). Attaching in agentic_tools_all() covers
handle_chat_agentic, handle_dharma_room_turn_agentic and agentic_resume.

pause_turn handling is included and was genuinely missing: the shipped
binary has zero occurrences of it. Without it a paused server-side search
returns only the text written so far and the loop treats it as final —
a silently truncated answer. final_text now accumulates across resume
cycles rather than overwriting (overwriting would discard everything
written before the pause).

Default tool version is web_search_20250305, NOT the newer _20260209, and
that is a measured choice: _20260209's dynamic filtering uses server-side
programmatic tool calling, which the API refuses to combine with ADR 0005's
stopgap —

  HTTP 400 invalid_request_error
  tool_choice.disable_parallel_tool_use: true cannot be used with
  programmatic tool calling

Dropping the stopgap would resurrect neuron#78 bug b (killed runs 3/3 in
ADR 0005's own A/B). The basic variant is compatible with the stopgap and
returns real results, so neither feature is dropped. Version lives in state
key web_search_tool_version; flipping it once the stopgap retires is a
config write, no recompile. The fallback also fires on "programmatic tool
calling" so a premature flip self-heals loudly instead of dying.

Also fixes a real bug this port exposed: json_get is a first-match scanner
and a cited text block serialises citations FIRST, so json_get(block,"type")
returned the nested citation's type and every citation-bearing block — the
ones carrying the searched facts — was silently dropped from the reply.
Reply length on the same question: 79 -> 360 chars.

Other loop changes: server_tool_use accounting into tools_used, iteration
cap 8->12 for pause/resume cycles, max_tokens 4096->16384, container-id
carry-forward, API error head logged instead of swallowed, tools_used gated
on is_tool_turn so a truncated tool block is not reported as work done.

dist/soul.c is deliberately NOT regenerated — the local elc predates Will's
last regen (e610a41) and regenerating with an older compiler risks unrelated
codegen drift. Source only; regen is Will's.

E2E-VERIFIED on a sandbox soul (scratch HOME/engram, dead axon+ISE, explicit
NEURON_PORT): live Bentonville weather with tools_used ["web_search"]; the
27-route contract gate PASSes; the parallel-tool stopgap still completes a
3-file mission with no 400; a 3-search 3,486-char answer kept its end
sentinel. Honest gap: pause_turn is compiled in but not exercised by a live
pause (max_uses:5 caps the server loop below the pause threshold).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat(engine): plain chat generates at L3 — inside the safety cycle, not around it
Neuron Soul CI / build (pull_request) Failing after 12m8s
Neuron Soul CI / deploy (pull_request) Has been skipped
635f6febe4
Non-agentic /api/chat (the desktop app's default "Tools: Off" mode) returned the
user's own screened text as a bare non-JSON string. Every JSON client failed to
parse it and showed "Couldn't reach Neuron - it may be offline."

Root cause: f52d5bd (2026-06-11) correctly moved the route onto the layer spine
(handle_chat -> layered_cycle), but L3 never got a generator — imprint_respond()
annotates its input and returns it. Two pieces of the architecture were already
waiting for that step: layered_cycle parks a bell directive in the state key
build_system_prompt is written to consume, and build_system_prompt carries a
chat_mode ("no tools") flag with no live caller.

The fix composes rather than replaces. Wiring handle_chat would have removed
safety_screen, the hard-bell short-circuit, the whole stewardship layer and
safety_validate — the only enforcing output gate in the codebase — in exchange
for a working reply (see _engine-websearch-20260804/SAFETY-STOP.md). Instead
layered_cycle keeps every gate, in order, and gains a generation step between
imprint_respond and safety_validate.

  L1 screen -> guard -> hard-bell short-circuit -> L2a -> L2b -> L2c
    -> L3 imprint_respond (prompt) -> L3b layered_generate (NEW) -> L1 validate

- chat.el:  NEW layered_generate (L3 generation, no tools offered),
            conv_history_block, conv_history_record.
            FIX build_system_prompt never concatenated no_tools_rule into its
            return — the "[NO TOOLS THIS TURN]" rule reached no model at all.
            handle_chat annotated DO-NOT-WIRE with the reason.
- soul.el:  layered_cycle gains L3b + post-validation turn bookkeeping.
- routes.el: NEW plain_chat_envelope; all three /api/chat dispatch sites wrap the
            cycle's output. Built OUTSIDE the cycle so safety_validate always sees
            raw model text — nothing to unwrap or rebuild on the crisis path.
            Emits both `reply` and `response`: the desktop app reads `reply`,
            the CLI tools and telegram-gateway read `response`.

Also fixes BUG-PLAINCHAT-1, a pre-existing CRITICAL crash on the crisis path.
elc compiles `let n: Int = pos + str_len(marker)` to el_str_concat() — string
concat on two integers — inside a block-expression initializer, segfaulting the
daemon (SIGSEGV in strlen). Six inline copies of the same " | ts:" parser had it:
two in layered_cycle L2c, two in engram_compile (live on the AGENTIC path too),
two in affective_context_prefix. A distress turn following an earlier affective
turn killed the whole process. Proven pre-existing: an unmodified baseline binary
crashes identically, and the same bad C is in the committed dist/soul.c. Fixed by
hoisting to one top-level function, affective_node_ts(), where the expression
compiles to integer addition — verified in the generated C.

Proof (throwaway HOME/engram, explicit NEURON_PORT, live chain untouched):
- Plain turn returns a JSON envelope with the provider's answer, not an echo.
- Captured request body: no `tools`, no `tool_choice`; system prompt carries the
  NO-TOOLS rule. Tools:Off means no tool is offered, structurally.
- Hard bell: canned 988 message, and the provider request count does not move —
  the message never reaches a model.
- Soft bell + a 2-char model reply: safety_validate's care phrase is appended to
  the MODEL's output. Output gate acting, on this route.
- The L1 bell directive now reaches the model here for the first time (the state
  addendum had a producer and no consumer).
- test_layered_cycle PASS; all six El suites byte-identical to baseline.
- verify-soul-contract.sh (bash 5.3): GATE PASS, 27/27, immutability PASS.
- The crash sequence that killed the baseline daemon now returns HTTP 200.

Not proven: no live Anthropic call — the login keychain refuses the key to a
non-interactive process (rc=24 errSecInteractionNotAllowed). Details and the
one-command close-out are in _engine-plainchat-20260805/README.md §7.

Builds on PR #108. dist/soul.c deliberately not regenerated — Will's toolchain.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tim.lingo merged commit fc1745c652 into main 2026-08-07 15:53:12 +00:00
Sign in to join this conversation.