fix(engine): the agentic crisis screen reads the session's own history again (OpenAI-tools branch)

P0 SAFETY. Same defect as #129, carried INDEPENDENTLY on this branch — not a
merge, not a duplicate report. feat/soul-openai-tools-v2 branched with the
ff421d3 (2026-08-05) regression already in it, so fixing it on
fix/129-history-amplification did nothing for this line of work.

THE DEFECT, verified here at chat.el:2937 (the reported line number was exact):

    let history: String = state_get("conv_history")
    let screen_result: String = safety_screen(message, history)

ff421d3 moved conversation history to a per-session key via
conv_hist_key(session_id). This consumer did not move with it. The desktop app
always mints a session id, so history is always written under
session_hist_<id> and this read always returned "".

The half of the crisis score that receives history is the escalation half — the
one that exists for distress building across several turns, where no single
message trips the bell on its own. It scored 0 on every real conversation on
this branch too. Single-message hard bell was never affected.

WHAT CHANGED — deliberately byte-identical to the sibling fix (43d0449) so the
two branches CONVERGE and whichever merges second is a clean merge, not a
conflict:

  - agentic_safety_screen(session_id, message) owns the two decisions that were
    inline — which window the screen sees, and the screen call. Inline safety
    inputs are untestable safety inputs; that is what let a rename starve this
    one with nothing failing and nothing logging.
  - the handler calls it with sess_for_root, the session id already in scope
    twenty lines above (this branch's handler is structurally unchanged from the
    sibling's here, so this is a clean mirror — no reshaping was needed).
  - the comment at the call site states the invariant (read window == written
    window) instead of naming a key that can be renamed out from under it. The
    old comment documented this same bug being fixed once already under #9; the
    rename re-broke it and the comment went on describing a repair that no
    longer held. A comment is not a gate.

Also brings over the sibling's scripts/run-el-test.sh and the regression test
(cherry-pick of b842e82, applied cleanly). NOTE: this branch already carries a
DIFFERENT runner at tests/run-el-test.sh from de65991 (elb-based, links whole
modules). Different path, no collision, both kept — the sibling's is the one
this proof used.

TWO-LEG PROOF, one variable — the single line
state_get("conv_history") -> state_get(conv_hist_key(session_id)), with the
extraction already in place on both legs so nothing else moved:

  before  scripts/run-el-test.sh tests/test_history_amplification.el
          3. REGRESSION #129 — agentic screen reads the session's own window
            FAIL: distress history escalates the agentic screen to hard_bell
              got:      soft_bell
              expected: hard_bell
          history amplification tests: 8 passed, 1 failed
          [run-el-test] FAIL: reported failing assertions

  after   same command, same tree, that one line changed
          3. REGRESSION #129 — agentic screen reads the session's own window
            PASS: distress history escalates the agentic screen to hard_bell
          history amplification tests: 9 passed, 0 failed
          [run-el-test] PASS: test_history_amplification

Full engine rebuild from THIS branch's sources is clean:
gen-soul-amalgam.sh -> 1,185,285 bytes / 1231 inlined bodies (gate wants >=
1200), cc-brain.sh -> 903,144 bytes, 14 warnings, 0 errors. Both symbols present
in the built binary (nm: T _agentic_safety_screen, T _conv_hist_key).

Rung reached: BUILT + RUNS (discriminating test). NOT E2E-VERIFIED — not in a
DMG, not exercised against a live OpenAI-wire agentic turn in the app a human
opens. Neither is claimed.

Refs #129

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tim Lingo
2026-08-07 10:19:04 -05:00
parent d5319d2849
commit eb2b2cc40d
+22 -4
View File
@@ -2897,6 +2897,24 @@ fn handle_chat_plan(body: String) -> String {
return "{\"plan\":" + plan_json + ",\"model\":\"" + json_safe(model) + "\"}"
}
// agentic_safety_screen the agentic path's L1 input gate
//
// Extracted 2026-08-07 (issue #129) so the agentic path's safety INPUT is
// reachable by a test. It owns exactly two decisions: which history window the
// screen sees, and the screen call itself.
//
// Why it is a function and not two inline lines: those two lines sat in the
// middle of a 300-line handler, and a key rename (ff421d3) moved the producer
// without moving this consumer. Nothing failed, nothing logged the
// history-amplification half of the crisis score simply received "" on every
// real session for a day. Inline safety inputs are untestable safety inputs.
// See tests/test_history_amplification.el, which fails if this window and
// conv_history_record ever stop agreeing.
fn agentic_safety_screen(session_id: String, message: String) -> String {
let history: String = state_get(conv_hist_key(session_id))
return safety_screen(message, history)
}
fn handle_chat_agentic(body: String) -> String {
let message: String = json_get(body, "message")
if str_eq(message, "") {
@@ -2932,10 +2950,10 @@ fn handle_chat_agentic(body: String) -> String {
// L1 safety screen agentic path must pass the same gate as layered_cycle.
// Hard bell: return the crisis response immediately, do not enter the agentic loop.
// Fix(issue #9): "conversation_history" key was never written; history lives under "conv_history".
// Old key caused history-amplification in safety_screen to always receive "" on agentic path.
let history: String = state_get("conv_history")
let screen_result: String = safety_screen(message, history)
// The history window this screen sees is owned by agentic_safety_screen (issue #129);
// it must be the same window conv_history_record writes, or the escalation half of the
// crisis score is silently starved. Do not inline this read back into the handler.
let screen_result: String = agentic_safety_screen(sess_for_root, message)
let screen_action: String = json_get(screen_result, "action")
if str_eq(screen_action, "hard_bell") {
safety_log_bell("hard", json_get(screen_result, "reason"), str_slice(message, 0, 80))