Two compounding defects made every pause->approve write_file report success
while writing NOTHING:
1. The naive json_get scanner matches "content" anywhere in the approve
body — including INSIDE tool_input, which for write_file always carries
a content field. The handler therefore treated every approved builtin
write as already-client-executed, skipped dispatch entirely, and handed
the model the file's own content as the 'tool result'. The model then
narrated 'Done, created' — a false receipt with no file. Builtin tools
now ALWAYS dispatch server-side; client content is only honored for
non-builtin (MCP/client-executed) tools. Stricter only.
2. write_file returned {"ok":true} unconditionally — fs_write's outcome
was never checked, so any failed write also reported success. The write
now verifies the file landed (fs_exists) and returns the RESOLVED path
in the ok payload; failures return a real error naming the destination.
E2E on the test brain (boot 38): approve-path write lands byte-exact and
the result carries the resolved path; auto-run writes unchanged; denied
writes execute nothing. BUG-5 (approve wire lacked tool_name) had been
masking this one — two stacked bugs on the same path.
NOTE for review: the deeper cure is a nesting-aware json reader; this fix
removes the dangerous consequence at the two spots that lie about disk.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two consent-flow fixes, gates only get stricter:
1. PAUSE-CONTRACT: the client has sent require_approval:true on every
agentic request since Phase 1c, but needs_bridge never consulted it —
builtin sub-escalate tools ran server-side unasked, making the app's
Ask autonomy silently inert for that whole class. Now the flag is
persisted per session (set/reset every request, so /approve resumes
keep it) and ask_all bridges EVERY tool turn. Absent/false = behavior
byte-identical to before. E2E: the exact probe that executed a write
unasked now returns the tool_pending envelope with nothing on disk;
full in-app circle verified (card → crash → resurrection → late
approve → fence re-fires on re-entry).
2. BUG-LEAK: agent_workspace_root lived in ONE shared state key — any
request that omitted a root inherited the previous session's folder
(proven: a rootless curl session wrote into another session's run
folder). Root is now stored per session and every request re-asserts
its own (possibly empty) root into the shared key the guards read;
same re-assert on the /approve and resume paths. Env fallback intact.
LIMITATION: assumes serialized handling; true per-call scoping means
threading session_id through dispatch — flagged for review.
Runnable C-patch for the test brain: neuron-container-build/
soul-pause-contract-20260716.patch (pause-contract only; the leak fix
needs the #23 root-write which the running C predates — source carries
both for the regen).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three unescaped-quote typos produced malformed El string literals that broke
compilation:
- safety.el:282 stray extra double-quote at the tail of safety_soft_phrases
(\"having a breakdown\""]") closed the string early, desyncing the lexer's
string/code phase for the rest of the file and shattering later apostrophe
text (can't, i'm) into bare identifiers -> invalid C.
- sessions.el:517 str_replace(topic_snip, """, ...) — the bare """ is an
empty string plus an unterminated string that swallowed the closing ) and };
with the current elc this triggers the parser overrun -> ~700GB OOM.
- sessions.el:520 unescaped nested quotes in the topic_tags literal.
All three now use escaped inner quotes. Verified: both files compile clean
under the current elc (safety.c and sessions.c well-formed, brace-balanced).
The while loop in session_search had too many let bindings in scope;
the ELC compiler's exponential rebinding accumulation caused OOM and
truncation of dist/sessions.c since June 30. Moving the per-node logic
into session_search_entry gives the compiler a clean scope boundary per
call, restoring O(N) compile behaviour.
- sessions.el: add session_exists() for chat-path session validation (ISSUE #6/#7)
- sessions.el: add session_create_cleanup() for ghost-session rollback (ISSUE #1)
- sessions.el: set session_pending_first_msg flag in session_create; clear it in
session_hist_save so the first successful chat marks the session active (ISSUE #1)
- sessions.el: session_delete now clears mcp_bridge:<id> and always_allow_<id>
state keys so abandoned pending-tool sessions do not accumulate (ISSUE #5)
- sessions.el: add TODO comments for ISSUE #2 (no TTL/expiry), ISSUE #3
(non-atomic delete-then-create), ISSUE #4 (no concurrent-create guard),
and ISSUE #8 (reconnect/duplicate resume race) where fixes are too invasive
to land without new runtime primitives
- chat.el: validate session_id exists via session_exists() before entering
agentic_loop; unknown session_ids now return a 404-style error instead of
silently starting a fresh empty session (ISSUE #6/#7)
- auto_persist: detect bell level (soft/hard) on every user message using
safety_detect_bell_level; write a dedicated BellEvent engram node with
calibrated salience alongside the Conversation node when a bell fires.
Tag the Conversation node with bell:soft/bell:hard and 'affective' for
direct discovery without scanning all chat nodes.
- auto_persist: track per-session bell count, dominant level, and last
signal in state (session_bell_count/level/signal keys) so downstream
functions can act on the emotional history without re-scanning engram.
- engram_compile: include the top-1 most recent BellEvent node within 72h
in every context build. Distress context from earlier turns (same or
recent session) automatically travels into all subsequent LLM calls.
- hist_trim_with_bell_guard: replace hist_trim at the handle_chat call site.
Before evicting the oldest turn from the 20-turn window, inspect the user
message for bell signals. If a bell was present, write a preservation
BellEvent to engram before dropping the turn so the full message survives
the rolling window.
- session_hist_save: after writing the history node, check session bell
counters. On the first save where bell_count > 0, write a
session:emotional-summary BellEvent node with distress signal, count,
and dominant level. A state flag prevents duplicate writes on subsequent
saves in the same session.
session_delete cleared the per-session state (session_hist_ and
session_node_) but not the shared session_index cache. The next call
to session_list() hit the fast path (state_get("session_index")) and
returned the deleted session until the daemon restarted.
session_update_patch already called state_set("session_index","") to
force a re-fetch from Engram; session_delete now does the same.
Add tests/test_sessions.el covering:
- session_title_from_message (pure function, all edge cases)
- session_make_content (JSON structure and required session:meta marker)
- DELETE cache invalidation: session_index cleared, fast path disabled
- PATCH cache invalidation: stale title/folder not returned via fast path
- GET /api/sessions: session_list() fast path returns session_index
(confirms removal of the stale route_sessions() engram stub)
BLOCKER 1 (sessions.el, modern path): Add guard that rejects allow
action when tool_name is missing from the body. Previously, omitting
tool_name caused dispatch_tool("", ...) to return "unknown tool: " and
silently inject a corrupted tool_result into the conversation.
BLOCKER 2 (sessions.el, modern path): Stop re-executing client-side
tools server-side. When the client provides body["content"], use it
directly as the tool result (matching the handle_tool_result contract).
Only fall back to dispatch_tool for builtin tools when no content is
present. Non-builtin tools with no client content now return a clear
error instead of a broken dispatch attempt.
WARNING 1 (chat.el, agentic_loop): Wire always_allow_<session_id> state
into the bridge-suspension decision. When a tool is in the session's
always-allow list, treat it as locally dispatchable (like a builtin)
and skip the bridge pause, so the approval UI is never shown again for
that tool in that session.
WARNING 2 (sessions.el, legacy path): Read a "tools_variant" field from
the legacy pending blob when present, and call the corresponding
agentic_tools_*() variant on resume. Falls back to agentic_tools_literal()
for blobs written before this field existed.
tests/test_sessions_approve.el: Add 10-case test suite covering:
- empty session_id / missing call_id / missing action guards
- no pending tool returns correct error
- missing tool_name on allow returns error (BLOCKER 1)
- deny action does not require tool_name
- legacy call_id mismatch returns mismatch error
- always action records tool_name in always_allow state
- allow with client content skips re-execution (BLOCKER 2)
The approve endpoint was permanently broken for all sessions going through
the modern agentic_loop path. agentic_loop suspends via bridge_save() into
mcp_bridge:<session_id>, but handle_session_approve was reading from
pending_tool_<session_id> — a different key — so it always returned
"no pending tool for session".
Replace the body of handle_session_approve with a two-path design:
Modern path: check mcp_bridge:<session_id> first. If the blob is there,
dispatch_tool() on allow (or build the denial string), then delegate to
agentic_resume() which re-enters agentic_loop from the exact suspension
point. This is the path all live sessions take.
Legacy path: if only pending_tool_<session_id> exists (in-flight session
from before this deploy), synthesise a bridge blob from the stored
messages_so_far and route through agentic_resume() as well. The stale
inline agentic loop (90 lines, agentic_tools_literal only, no MCP
connector support, no bridge suspension) is removed entirely.
routes.el already calls handle_session_approve correctly — no change needed.
- sessions.el: new sessions module with session management and approval gate
- routes.el: wire /api/sessions routes (list, get, create, approve, tool_result)
- chat.el: thread-aware activation — short messages anchor to last reply
before engram compilation so follow-ups stay on-topic
- chat.el: agentic path tracks per-session history (session_hist_{id})
instead of shared conv_history, seeding each turn with prior context
- chat.el: add call_neuron_mcp, dispatch_tool, is_builtin_tool, next_bridge_id
agentic_loop, bridge_save, agentic_resume, handle_tool_result
- dist/soul: rebuild with all of the above