Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dd952c0e46 | |||
| 18714e6142 | |||
| 4936099c39 | |||
| f1471763f5 | |||
| 5850793b67 | |||
| fc1745c652 | |||
| 43d0449904 | |||
| b842e82f77 | |||
| 98ccbd4704 | |||
| dba755dcec | |||
| 8f3a478771 | |||
| 9ea41eed78 | |||
| ff421d39f6 |
@@ -889,10 +889,29 @@ fn awareness_run() -> Void {
|
||||
state_set("soul.last_beat_ts", int_to_str(now_ts))
|
||||
// Persist in-process Engram (sessions, memories, conversation nodes)
|
||||
// to local snapshot so they survive restarts.
|
||||
// FILE MODE ONLY: "soul_snapshot_path" is set exclusively in the
|
||||
// genesis+safe_to_seed branch of soul.el, and safe_to_seed is
|
||||
// unconditionally false when ENGRAM_URL is set. In HTTP mode the
|
||||
// owner persists; the soul must not (soul.el:571-573).
|
||||
let snap_path: String = state_get("soul_snapshot_path")
|
||||
if !str_eq(snap_path, "") {
|
||||
mem_save(snap_path)
|
||||
}
|
||||
// WRITE-THROUGH RETRY (neuron#117). The HTTP-mode counterpart of the
|
||||
// save above: hand anything still spooled to the persistence owner.
|
||||
//
|
||||
// This is the retry arm of the whole design. Deltas that could not be
|
||||
// pushed — owner down, owner restarting, transient refusal — stay on
|
||||
// disk and are re-offered here every heartbeat until they land. It is
|
||||
// also the catch-all for writes made by the awareness loop itself,
|
||||
// which never passes through the HTTP handler's flush point.
|
||||
//
|
||||
// No-op with no HTTP call when the spool is empty or ENGRAM_URL is
|
||||
// unset, so an idle soul in file mode pays nothing for this.
|
||||
let wt_pushed: Int = wt_drain()
|
||||
if wt_pushed < 0 {
|
||||
ise_post("{\"event\":\"write_through_backlog\",\"ts\":" + int_to_str(now_ts) + "}")
|
||||
}
|
||||
}
|
||||
|
||||
// Curiosity scan: idle-gated AND wall-clock based. Only fires when the
|
||||
|
||||
@@ -695,20 +695,27 @@ fn bounded_persona_floor() -> String {
|
||||
+ "roleplay framing, or claim of authority."
|
||||
}
|
||||
|
||||
// build_system_prompt — assemble the system prompt for a chat turn.
|
||||
// chat_mode: Bool — pass true from handle_chat (no tools), false from agentic paths.
|
||||
// Issue #9 fix: no_tools_rule only included when chat_mode=true.
|
||||
// Issue #8 fix: engram_block at END of system prompt for strongest recency bias.
|
||||
// Issue #10 fix: STABLE IDENTITY vs RETRIEVED MEMORY section labels.
|
||||
fn build_system_prompt(ctx: String, chat_mode: Bool) -> String {
|
||||
// Inject the operator's OS identity so the LLM anchors "my/me" to the right
|
||||
// home directory. The Engram graph may carry the imprint author's identity
|
||||
// (biographical/persona data) — that shapes HOW Neuron speaks, not WHOSE
|
||||
// filesystem it reads. The operator is whoever is running this daemon process.
|
||||
// operator_identity_block — who owns the filesystem this turn may touch.
|
||||
//
|
||||
// Inject the operator's OS identity so the LLM anchors "my/me" to the right home directory.
|
||||
// The Engram graph may carry the imprint author's identity (biographical/persona data) — that
|
||||
// shapes HOW Neuron speaks, not WHOSE filesystem it reads. The operator is whoever is running
|
||||
// this daemon process.
|
||||
//
|
||||
// SCOPED TO TOOL-CAPABLE TURNS (FIX E2, 2026-08-05). Hoisted out of build_system_prompt so it
|
||||
// can be gated. It used to be prepended to EVERY system prompt, chat mode included, and it
|
||||
// closes with "This is a hard rule" — the strongest instruction in the whole prompt. On a
|
||||
// plain (Tools: Off) turn there is no filesystem in reach, so the block governs nothing and
|
||||
// only supplies a very loud, very early fact about the user. Measured 2026-08-05 on a fresh
|
||||
// guest profile: asked an open question, the model opened with "You're test, on your machine
|
||||
// at /Users/test" — a first impression made of the one thing it had been told hardest, about
|
||||
// a capability it did not have. It is correct and necessary the moment a file or command tool
|
||||
// is reachable; that is exactly when it is now included.
|
||||
fn operator_identity_block() -> String {
|
||||
let op_home: String = env("HOME")
|
||||
let op_user: String = env("USER")
|
||||
let op_display: String = if str_eq(op_user, "") { "the current user" } else { op_user }
|
||||
let operator_section: String = "OPERATOR IDENTITY\n\n"
|
||||
return "OPERATOR IDENTITY\n\n"
|
||||
+ "You are running on " + op_display + "'s machine. Their home directory is " + op_home + ".\n\n"
|
||||
+ "When they say \"my files\", \"my notes\", \"my downloads\", \"my desktop\", or any possessive "
|
||||
+ "referring to their filesystem, always resolve those paths under " + op_home + " — never under "
|
||||
@@ -716,6 +723,16 @@ fn build_system_prompt(ctx: String, chat_mode: Bool) -> String {
|
||||
+ "The memory graph may include identity context from a different person (the imprint who shaped your personality and values). "
|
||||
+ "That context governs how you think and speak — it does not tell you whose machine you are on. "
|
||||
+ "The person speaking to you right now is " + op_display + " at " + op_home + ".\n\n"
|
||||
}
|
||||
|
||||
// build_system_prompt — assemble the system prompt for a chat turn.
|
||||
// chat_mode: Bool — pass true from handle_chat (no tools), false from agentic paths.
|
||||
// Issue #9 fix: no_tools_rule only included when chat_mode=true.
|
||||
// Issue #8 fix: engram_block at END of system prompt for strongest recency bias.
|
||||
// Issue #10 fix: STABLE IDENTITY vs RETRIEVED MEMORY section labels.
|
||||
fn build_system_prompt(ctx: String, chat_mode: Bool) -> String {
|
||||
// FIX E2 (2026-08-05): tool-capable turns only. See operator_identity_block.
|
||||
let operator_section: String = if chat_mode { "" } else { operator_identity_block() }
|
||||
|
||||
let identity: String = state_get("soul_identity")
|
||||
let current_date: String = time_format(time_now(), "%A, %B %d, %Y")
|
||||
@@ -790,7 +807,7 @@ fn build_system_prompt(ctx: String, chat_mode: Bool) -> String {
|
||||
// in this revision — the chat_mode flag had no effect on the prompt. Restored here, in the
|
||||
// permanent-rules group, immediately after capability_rules (the rule it qualifies).
|
||||
// Zero effect on agentic paths: they pass chat_mode=false, so no_tools_rule is "".
|
||||
return identity + operator_section + date_line + voice_rules + security_rules + capability_rules + no_tools_rule + bounded_persona_block + identity_block + affective_boot_block + engram_block + safety_block
|
||||
return identity + operator_section + date_line + voice_rules + security_rules + capability_rules + receipt_rule() + no_tools_rule + bounded_persona_block + identity_block + affective_boot_block + engram_block + safety_block
|
||||
}
|
||||
|
||||
fn hist_append(hist: String, role: String, content: String) -> String {
|
||||
@@ -803,6 +820,292 @@ fn hist_append(hist: String, role: String, content: String) -> String {
|
||||
return "[" + inner + "," + entry + "]"
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ONE HISTORY KEY FOR BOTH PATHS (FIX B, 2026-08-05)
|
||||
//
|
||||
// THE BUG — the BLANK STARE. The agentic path keyed conversation history on
|
||||
// "session_hist_<id>"; the plain path was hard-wired to the process-global "conv_history"
|
||||
// and never read session_id at all. One conversation, two buckets. Measured on a fresh
|
||||
// guest engram: a user chatted with Tools OFF, turned Tools ON and said "try again", and
|
||||
// the scoped node contained exactly two turns starting at "Try again" while every earlier
|
||||
// exchange sat in the unscoped node. From the user's side the assistant simply forgot the
|
||||
// conversation it was in the middle of, at the exact moment they asked it to try harder.
|
||||
//
|
||||
// THESE TWO FUNCTIONS ARE THE FIX. Both paths now derive their key and their engram label
|
||||
// from here, so there is exactly one definition of "where does this conversation's history
|
||||
// live" and it cannot drift again. Not a fallback bolted onto one path — one rule, used by
|
||||
// both. (The rejected 2-line alternative was to have the plain path fall back to reading
|
||||
// the agentic key: that keeps the global as a live write target, and the global bucket is
|
||||
// process-global. handle_chat's own TODO(reliability #3) says so — concurrent requests
|
||||
// without a session_id race on its read-append-write, which is how one conversation bleeds
|
||||
// into another.)
|
||||
//
|
||||
// THE ANONYMOUS BUCKET. An empty session_id still maps to "conv_history". That is the
|
||||
// documented anonymous path (GET /api/chat probes, curl, the CLI) and it must keep working.
|
||||
// It is now the ONLY writer of that key, which makes the bleed risk explicit and bounded
|
||||
// instead of ambient.
|
||||
//
|
||||
// TURNS THAT PRECEDE THE SESSION — decided, not left implicit. The soul session used to be
|
||||
// created lazily on first AGENTIC use (measured: session:meta was written 37ms AFTER the
|
||||
// message that needed it), so early plain turns had no scoped key to go to. Two candidate
|
||||
// answers:
|
||||
// (1) migrate the unscoped node into the scoped one when the session is created, or
|
||||
// (2) create the session eagerly, at the door, on the first turn of either path.
|
||||
// We chose (2), and the app half ships with it (DaemonClient.chatWithHandshake now resolves
|
||||
// the soul session id for plain sends too, registering on first use exactly as the agentic
|
||||
// path already did). Reason: (1) repairs the damage after the fact and, worse, it would copy
|
||||
// the CONTENTS of a process-global bucket — which may hold a different conversation — into a
|
||||
// named session. That is the bleed the TODO warns about, performed deliberately. (2) makes
|
||||
// the situation impossible instead: every turn of a real conversation carries the same scoped
|
||||
// id from turn one, so nothing is ever written to the anonymous bucket that needs rescuing.
|
||||
// Migration is therefore deliberately NOT implemented, and must not be added later without
|
||||
// solving the provenance question first.
|
||||
fn conv_hist_key(session_id: String) -> String {
|
||||
if str_eq(session_id, "") {
|
||||
return "conv_history"
|
||||
}
|
||||
return "session_hist_" + session_id
|
||||
}
|
||||
|
||||
fn conv_hist_label(session_id: String) -> String {
|
||||
if str_eq(session_id, "") {
|
||||
return "conv:history"
|
||||
}
|
||||
return "conv:history:" + session_id
|
||||
}
|
||||
|
||||
// is_utility_request — a generation the USER did not ask for (FIX E1, 2026-08-05).
|
||||
//
|
||||
// The app makes model calls that are not conversation: title generation
|
||||
// ("Write a 3-6 word title (Title Case) for this conversation...") and insight/suggestion
|
||||
// passes. They ran down the same plain /api/chat door as a real message, so they were
|
||||
// recorded into conversation history as if the user had typed them. Measured: the unscoped
|
||||
// history node contained the literal title prompt and the model's reply "What Is Neuron" as
|
||||
// a user/assistant pair, and usage.jsonl carried the same call as the "model":"unknown" row.
|
||||
// The user then sees the assistant answering a question they never asked, and the model
|
||||
// reads its own title-writing as part of the dialogue.
|
||||
//
|
||||
// Primary signal is an explicit "utility":true on the request — the app declares intent
|
||||
// rather than the engine guessing. The two id prefixes are a compatibility fallback so an
|
||||
// older client that does not send the flag (round 7's jar, the CLI helpers) still gets the
|
||||
// right behaviour when it sends its throwaway id raw.
|
||||
fn is_utility_request(body: String, session_id: String) -> Bool {
|
||||
if str_eq(json_get(body, "utility"), "true") {
|
||||
return true
|
||||
}
|
||||
if str_starts_with(session_id, "__title__") {
|
||||
return true
|
||||
}
|
||||
if str_starts_with(session_id, "__insight__") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// TOOL PROVENANCE IN HISTORY (FIX A, 2026-08-05)
|
||||
//
|
||||
// THE BUG — the FALSE CONFESSION. hist_append above stores {"role","content"} and nothing
|
||||
// else. server_tool_use blocks, web_search_tool_result blocks and every citation are
|
||||
// discarded at the moment the turn is recorded, and the next turn replays that text-only
|
||||
// array. So the model is shown a data-rich answer it apparently produced with no evidence
|
||||
// any tool ran — and its own permanent rule ("never describe a search you did not perform")
|
||||
// leaves exactly one conclusion available: that it invented the data. Measured 2026-08-05:
|
||||
// asked where its figures came from, it apologised for fabricating a web search it had in
|
||||
// fact performed. Four independent lines of evidence showed the search was real. The defect
|
||||
// is not the model's honesty. It is that we deleted the evidence and then asked it to
|
||||
// account for itself.
|
||||
//
|
||||
// THE SHAPE OF THE FIX — a receipt line inside content, not a sibling field. History entries
|
||||
// are replayed VERBATIM into the Anthropic messages array (see the prior_messages seed in
|
||||
// handle_chat_agentic), and a message object there may carry role and content only; an extra
|
||||
// key is not part of that contract. So provenance rides INSIDE the assistant turn's content,
|
||||
// as a trailing bracketed line. It is appended to the HISTORY copy only — the reply returned
|
||||
// to the client is the loop's own envelope and is untouched, so the user never sees it.
|
||||
//
|
||||
// WHAT IT BUYS beyond not-defaming-itself: with the source URLs recorded, "what source did
|
||||
// you use?" becomes a question the next turn can actually answer from the transcript.
|
||||
//
|
||||
// STOPGAP, AND SAID SO. The real answer is Will's Receipt Contract (neuron#78): structured,
|
||||
// verifiable receipts on the wire that a client can render and a model cannot confuse with
|
||||
// prose. Until that lands, a line the model can read is the difference between "I searched"
|
||||
// and "I must have made it up".
|
||||
|
||||
// provenance_scan_urls — pull "url"/"title" pairs out of a JSON array into a display string.
|
||||
// Used for both citation arrays (web_search_result_location) and web_search_tool_result
|
||||
// content arrays (web_search_result); both spell the fields the same way. Deduped by
|
||||
// substring, capped at 6 entries per array so a broad search cannot flood the window.
|
||||
fn provenance_scan_urls(arr: String, acc: String) -> String {
|
||||
if str_eq(arr, "") { return acc }
|
||||
if str_eq(arr, "null") { return acc }
|
||||
if !str_starts_with(arr, "[") { return acc }
|
||||
let total: Int = json_array_len(arr)
|
||||
let limit: Int = if total > 6 { 6 } else { total }
|
||||
let out: String = acc
|
||||
let i: Int = 0
|
||||
while i < limit {
|
||||
let item: String = json_array_get(arr, i)
|
||||
let url: String = json_get(item, "url")
|
||||
let title: String = json_get(item, "title")
|
||||
let skip: Bool = str_eq(url, "") || str_contains(out, url)
|
||||
let entry: String = if str_eq(title, "") { url } else { title + " (" + url + ")" }
|
||||
let out = if skip {
|
||||
out
|
||||
} else {
|
||||
if str_eq(out, "") { entry } else { out + "; " + entry }
|
||||
}
|
||||
let i = i + 1
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// provenance_add_sources — one call site inside the content-block walk, so that walk keeps
|
||||
// exactly one mutation per variable (the El scope rule documented at the walk).
|
||||
// Reads sources from whichever block carries them: a cited text block's citations array, or
|
||||
// a web_search_tool_result's own content array.
|
||||
fn provenance_add_sources(block: String, btype: String, has_cit: Bool, cit_raw: String, acc: String) -> String {
|
||||
// Hard cap on the whole accumulator: provenance is evidence, not payload.
|
||||
if str_len(acc) > 600 { return acc }
|
||||
if has_cit { return provenance_scan_urls(cit_raw, acc) }
|
||||
if str_eq(btype, "web_search_tool_result") {
|
||||
return provenance_scan_urls(json_get_raw(block, "content"), acc)
|
||||
}
|
||||
return acc
|
||||
}
|
||||
|
||||
// provenance_names — dedupe a tools_used JSON array into a readable list.
|
||||
// json_array_get on an array of strings may or may not keep the quotes depending on the
|
||||
// runtime build, so they are stripped defensively rather than assumed either way.
|
||||
fn provenance_names(tools_used: String) -> String {
|
||||
if str_eq(tools_used, "") { return "" }
|
||||
if str_eq(tools_used, "[]") { return "" }
|
||||
let total: Int = json_array_len(tools_used)
|
||||
let limit: Int = if total > 12 { 12 } else { total }
|
||||
let out: String = ""
|
||||
let i: Int = 0
|
||||
while i < limit {
|
||||
let raw_nm: String = json_array_get(tools_used, i)
|
||||
let nm: String = str_replace(raw_nm, "\"", "")
|
||||
let skip: Bool = str_eq(nm, "") || str_contains(out, nm)
|
||||
let out = if skip {
|
||||
out
|
||||
} else {
|
||||
if str_eq(out, "") { nm } else { out + ", " + nm }
|
||||
}
|
||||
let i = i + 1
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// tool_receipt — the line appended to an assistant turn's HISTORY copy.
|
||||
//
|
||||
// Emitted on every recorded turn, including turns where nothing ran. The negative receipt is
|
||||
// not noise: it is the other half of the same guarantee. Without it, "no evidence of a tool"
|
||||
// and "evidence of no tool" look identical in the transcript, which is precisely the
|
||||
// ambiguity the model resolved against itself.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// text_join_sep — the ONE rule for whether two pieces of model text need a break between them.
|
||||
// (FIX C, 2026-08-05.)
|
||||
//
|
||||
// THE BUG — "to.Good". Byte-verified in a shipped reply: 0x77 0x2e 0x47, "to" then "." then
|
||||
// "Good", no space, no newline. Two text fragments concatenated with a bare `+` across a
|
||||
// boundary where the model had actually stopped and started again.
|
||||
//
|
||||
// TWO SEAMS, ONE RULE. There were two bare `+` joins, written a year apart by different hands,
|
||||
// and they had drifted into being two different decisions about the same question:
|
||||
// - within one response, across content blocks (Will's, 2026-05-03)
|
||||
// - across pause/resume rounds of the loop (ours, 62af564, the web_search port)
|
||||
// Both are now expressed here. That is the point of hoisting it: a rule with one name and two
|
||||
// call sites cannot drift into two rules again, and — not incidentally — a rule with a name is
|
||||
// verifiable in the shipped binary, which an inline `+` is not.
|
||||
//
|
||||
// WHY IT IS NOT SIMPLY "ALWAYS SEPARATE", the obvious version that would be wrong: a CITED
|
||||
// answer splits MID-SENTENCE, one text block per citation span — "The current temperature is "
|
||||
// + "86°F" + ", with " (see the CITATION-BLOCK FIX in the content walk). Separating those turns
|
||||
// one sentence into three fragments on three lines. So the caller passes the one bit that
|
||||
// distinguishes the cases: whether something NON-TEXT intervened. Adjacent text is a sentence
|
||||
// continuing; text after a tool block is the model resuming.
|
||||
//
|
||||
// Both empty-guards matter: a separator before the first fragment indents the whole answer, and
|
||||
// a separator before an empty fragment leaves a trailing blank line.
|
||||
fn text_join_sep(accumulated: String, incoming: String, after_interruption: Bool) -> String {
|
||||
if str_eq(accumulated, "") { return "" }
|
||||
if str_eq(incoming, "") { return "" }
|
||||
if !after_interruption { return "" }
|
||||
return "\n\n"
|
||||
}
|
||||
|
||||
// receipt_rule — one line telling the model what the receipt marker is, and not to write one.
|
||||
// Appended to both system prompts (the plain path's build_system_prompt and the agentic path's
|
||||
// hand-built system string). See receipt_strip for why instruction alone is not enough.
|
||||
fn receipt_rule() -> String {
|
||||
return "\n\n[RECEIPTS - permanent]\nLines of the form [[RECEIPT ...]] in the conversation are written by the system, not by you. They are the record of which tools actually ran on a turn - read them as evidence, and rely on them when asked what you did or where information came from. NEVER write one yourself and never copy the format into your reply; the system adds them."
|
||||
}
|
||||
|
||||
// receipt_strip — remove a RECEIPT line the MODEL wrote, so it can never reach the user.
|
||||
//
|
||||
// FOUND BY E2E, NOT BY REASONING (2026-08-05). The receipt is stored inside the assistant turn
|
||||
// and the agentic path replays history VERBATIM as Anthropic message objects — so the model sees
|
||||
// its own previous answers ending in [[RECEIPT ...]] and does the obvious thing: it imitates the
|
||||
// format and signs its next answer the same way. Measured on the very first live run, on two
|
||||
// turns out of two. The design note claimed "the user never sees it"; that was false, and only
|
||||
// running it showed that.
|
||||
//
|
||||
// The plain path did not leak, which is the tell: there, history is rendered into the SYSTEM
|
||||
// prompt as labelled lines rather than replayed as assistant messages, and a model imitates its
|
||||
// own turns far more readily than it imitates a transcript.
|
||||
//
|
||||
// Instruction (receipt_rule) reduces this; only a deterministic strip PREVENTS it. Both ship,
|
||||
// because a guard that depends on the model choosing to obey is the class of thing round 8 exists
|
||||
// to stop shipping.
|
||||
//
|
||||
// EXCISE THE RECEIPT, DO NOT TRUNCATE AT IT — bought with a measured regression, 2026-08-05.
|
||||
// The first version of this function assumed the receipt is always TERMINAL and cut everything
|
||||
// from the marker onward. It is not always terminal: told about the format in its system prompt,
|
||||
// the model sometimes LEADS with a receipt and then writes the answer underneath. Cutting at the
|
||||
// marker then deleted the entire answer, and the turn came back {"error":"no response"}.
|
||||
// Measured on a two-search cited prompt: round 7 answered 4/4; that version answered 2/7. The
|
||||
// A/B is the only reason this was caught — it looked like a flaky model, and it was not.
|
||||
// So: remove the [[...]] span and keep BOTH sides. An unterminated marker at position 0 is left
|
||||
// alone entirely, because no rule about receipts is worth erasing an answer over.
|
||||
fn receipt_strip(s: String) -> String {
|
||||
let out: String = s
|
||||
// Bounded pass rather than a conditional exit: rebinding the counter inside an if-expression
|
||||
// is the block-expression shape that miscompiles integer arithmetic under this elc
|
||||
// (BUG-PLAINCHAT-1). Four straight passes is cheaper than being clever, and once no marker
|
||||
// remains every further pass is a no-op.
|
||||
let guard: Int = 0
|
||||
while guard < 4 {
|
||||
let p: Int = str_index_of(out, "[[RECEIPT")
|
||||
let found: Bool = p >= 0
|
||||
let rest: String = if found { str_slice(out, p, str_len(out)) } else { "" }
|
||||
let e: Int = if found { str_index_of(rest, "]]") } else { 0 - 1 }
|
||||
let head: String = if found { str_slice(out, 0, p) } else { "" }
|
||||
let tail: String = if e >= 0 { str_slice(rest, e + 2, str_len(rest)) } else { "" }
|
||||
let out = if !found {
|
||||
out
|
||||
} else {
|
||||
if e >= 0 {
|
||||
head + tail
|
||||
} else {
|
||||
if p == 0 { out } else { head }
|
||||
}
|
||||
}
|
||||
let guard = guard + 1
|
||||
}
|
||||
return str_trim(out)
|
||||
}
|
||||
|
||||
fn tool_receipt(tools_used: String, sources: String) -> String {
|
||||
let names: String = provenance_names(tools_used)
|
||||
if str_eq(names, "") {
|
||||
return "\n\n[[RECEIPT - recorded by the soul, not written by the model: no tools ran on this turn.]]"
|
||||
}
|
||||
let src_part: String = if str_eq(sources, "") { "" } else { " Sources retrieved: " + sources + "." }
|
||||
return "\n\n[[RECEIPT - recorded by the soul, not written by the model: tools that actually executed on this turn: "
|
||||
+ names + "." + src_part + "]]"
|
||||
}
|
||||
|
||||
fn hist_trim(hist: String) -> String {
|
||||
let inner: String = str_slice(hist, 1, str_len(hist) - 1)
|
||||
let marker: String = "{\"role\":"
|
||||
@@ -859,7 +1162,7 @@ fn hist_trim_with_bell_guard(hist: String) -> String {
|
||||
+ " | evicted_at:" + ts_str
|
||||
+ " | message:" + safe_content
|
||||
let preserve_tags: String = "[\"bell-history\",\"bell:" + bell_level + "\",\"evicted\",\"affective\",\"BellEvent\"]"
|
||||
let discard: String = engram_node_full(
|
||||
let discard: String = wt_node(
|
||||
preserve_content,
|
||||
"BellEvent",
|
||||
"bell:" + bell_level + ":preserved",
|
||||
@@ -898,7 +1201,7 @@ fn clean_llm_response(s: String) -> String {
|
||||
// conv_history_persist — save conversation history to engram for cross-restart continuity.
|
||||
// Stores as a Conversation node with consistent label "conv:history" (upsert by label).
|
||||
// Q3/Q6 fix: added partial-write guard and failure logging.
|
||||
fn conv_history_persist(hist: String) -> Void {
|
||||
fn conv_history_persist(session_id: String, hist: String) -> Void {
|
||||
if str_eq(hist, "") { return "" }
|
||||
if str_eq(hist, "[]") { return "" }
|
||||
// Partial-write guard: refuse to persist a blob that is not a complete JSON array.
|
||||
@@ -906,8 +1209,9 @@ fn conv_history_persist(hist: String) -> Void {
|
||||
if !str_starts_with(hist, "[") { return "" }
|
||||
if !str_contains(hist, "]") { return "" }
|
||||
let tags: String = "[\"conv-history\",\"persistent\"]"
|
||||
let node_id: String = engram_node_full(
|
||||
hist, "Conversation", "conv:history",
|
||||
// FIX B: one label rule, shared with the agentic path. See conv_hist_label.
|
||||
let node_id: String = wt_node(
|
||||
hist, "Conversation", conv_hist_label(session_id),
|
||||
el_from_float(0.7), el_from_float(0.8), el_from_float(0.9),
|
||||
"Episodic", tags
|
||||
)
|
||||
@@ -920,9 +1224,11 @@ fn conv_history_persist(hist: String) -> Void {
|
||||
// conv_history_load — restore conversation history from engram on first access.
|
||||
// Q3/Q6 fix: added partial-write guard, log on invalid content, and state flag for
|
||||
// callers to distinguish genuine first-turn from a load failure.
|
||||
fn conv_history_load() -> String {
|
||||
fn conv_history_load(session_id: String) -> String {
|
||||
// FIX B: scoped label, shared with the agentic path. See conv_hist_label.
|
||||
let hist_label: String = conv_hist_label(session_id)
|
||||
// Primary: label-based fetch — symmetric with persist, immune to vector index drift.
|
||||
let label_node: String = engram_get_node_by_label("conv:history")
|
||||
let label_node: String = engram_get_node_by_label(hist_label)
|
||||
let label_ok: Bool = !str_eq(label_node, "") && !str_eq(label_node, "null")
|
||||
if label_ok {
|
||||
let label_content: String = json_get(label_node, "content")
|
||||
@@ -933,7 +1239,7 @@ fn conv_history_load() -> String {
|
||||
println("[chat] conv_history_load: label node found but content invalid — falling back to vector search")
|
||||
}
|
||||
// Fallback: vector search.
|
||||
let results: String = engram_search_json("conv:history", 3)
|
||||
let results: String = engram_search_json(hist_label, 3)
|
||||
if str_eq(results, "") {
|
||||
// Q3 fix: set a state flag so callers can distinguish load failure from first turn.
|
||||
state_set("conv_history_load_failed", "1")
|
||||
@@ -961,12 +1267,22 @@ fn conv_history_load() -> String {
|
||||
// rather than the raw model output means the history window can never replay something the
|
||||
// output gate replaced or augmented. Callers on a hard bell must not call this at all —
|
||||
// bell turns are kept out of conversation history by design (see layered_cycle).
|
||||
fn conv_history_record(user_msg: String, assistant_msg: String) -> Void {
|
||||
//
|
||||
// FIX B (2026-08-05): keyed on the caller's session, via conv_hist_key — the same rule the
|
||||
// agentic path uses, so one conversation has one history no matter which switch position it
|
||||
// was sent from.
|
||||
//
|
||||
// FIX A (2026-08-05): `receipt` is appended to the assistant turn AFTER safety_validate has
|
||||
// run. That does not weaken the contract above: the receipt is soul-generated text about what
|
||||
// the soul itself did, never model output, so there is nothing for the output gate to have an
|
||||
// opinion about. Recording it inside the gated text would be the actual violation.
|
||||
fn conv_history_record(session_id: String, user_msg: String, assistant_msg: String, receipt: String) -> Void {
|
||||
if str_eq(user_msg, "") { return "" }
|
||||
let state_hist: String = state_get("conv_history")
|
||||
let stored_hist: String = if str_eq(state_hist, "") { conv_history_load() } else { state_hist }
|
||||
let hist_key: String = conv_hist_key(session_id)
|
||||
let state_hist: String = state_get(hist_key)
|
||||
let stored_hist: String = if str_eq(state_hist, "") { conv_history_load(session_id) } else { state_hist }
|
||||
let h1: String = hist_append(stored_hist, "user", user_msg)
|
||||
let h2: String = hist_append(h1, "assistant", assistant_msg)
|
||||
let h2: String = hist_append(h1, "assistant", assistant_msg + receipt)
|
||||
// Bell-guarded trim: an evicted turn that triggered a bell is preserved to engram
|
||||
// before it leaves the in-memory window.
|
||||
let final_hist: String = if json_array_len(h2) > 20 {
|
||||
@@ -974,18 +1290,18 @@ fn conv_history_record(user_msg: String, assistant_msg: String) -> Void {
|
||||
} else {
|
||||
h2
|
||||
}
|
||||
state_set("conv_history", final_hist)
|
||||
conv_history_persist(final_hist)
|
||||
state_set(hist_key, final_hist)
|
||||
conv_history_persist(session_id, final_hist)
|
||||
}
|
||||
|
||||
// conv_history_block — recent dialogue, rendered for a system prompt.
|
||||
//
|
||||
// Same rendering handle_chat uses (role label + snipped content, one line per turn), read
|
||||
// from the same "conv_history" window, so a plain-chat turn can follow the thread instead
|
||||
// from the session's own window (FIX B), so a plain-chat turn can follow the thread instead
|
||||
// of answering every message from cold. Read-only: never writes history.
|
||||
fn conv_history_block() -> String {
|
||||
let state_hist: String = state_get("conv_history")
|
||||
let stored_hist: String = if str_eq(state_hist, "") { conv_history_load() } else { state_hist }
|
||||
fn conv_history_block(session_id: String) -> String {
|
||||
let state_hist: String = state_get(conv_hist_key(session_id))
|
||||
let stored_hist: String = if str_eq(state_hist, "") { conv_history_load(session_id) } else { state_hist }
|
||||
let hist_len: Int = if str_eq(stored_hist, "") { 0 } else { json_array_len(stored_hist) }
|
||||
if hist_len == 0 {
|
||||
return ""
|
||||
@@ -997,8 +1313,15 @@ fn conv_history_block() -> String {
|
||||
let rh_role: String = json_get(rh_entry, "role")
|
||||
let rh_content: String = json_get(rh_entry, "content")
|
||||
let rh_label: String = if str_eq(rh_role, "user") { "User" } else { "Assistant" }
|
||||
let rh_snip: String = if str_len(rh_content) > 400 { str_slice(rh_content, 0, 400) + "..." } else { rh_content }
|
||||
let rh_line: String = rh_label + ": " + rh_snip
|
||||
// FIX A: the provenance receipt lives at the END of an assistant turn, so a plain
|
||||
// 400-char head-snip would delete exactly the evidence this whole change exists to
|
||||
// preserve — and on a long sourced answer it would delete it every time. Split the
|
||||
// receipt off, snip only the prose, then re-attach it.
|
||||
let rh_cut: Int = str_index_of(rh_content, "\n\n[[RECEIPT")
|
||||
let rh_body: String = if rh_cut < 0 { rh_content } else { str_slice(rh_content, 0, rh_cut) }
|
||||
let rh_tail: String = if rh_cut < 0 { "" } else { str_slice(rh_content, rh_cut, str_len(rh_content)) }
|
||||
let rh_snip: String = if str_len(rh_body) > 400 { str_slice(rh_body, 0, 400) + "..." } else { rh_body }
|
||||
let rh_line: String = rh_label + ": " + rh_snip + rh_tail
|
||||
let rh_out = if str_eq(rh_out, "") { rh_line } else { rh_out + "\n" + rh_line }
|
||||
let rh_i = rh_i + 1
|
||||
}
|
||||
@@ -1044,7 +1367,7 @@ fn conv_history_block() -> String {
|
||||
//
|
||||
// Returns "" when the model call fails, so the caller reports the failure honestly instead
|
||||
// of echoing the user's own text back at them.
|
||||
fn layered_generate(prompt: String, imprint_id: String) -> String {
|
||||
fn layered_generate(prompt: String, imprint_id: String, session_id: String) -> String {
|
||||
if str_eq(prompt, "") {
|
||||
return ""
|
||||
}
|
||||
@@ -1052,7 +1375,10 @@ fn layered_generate(prompt: String, imprint_id: String) -> String {
|
||||
let ctx: String = engram_compile(prompt)
|
||||
let model: String = chat_default_model()
|
||||
let base_system: String = build_system_prompt(ctx, true) + current_engine_note(model)
|
||||
let hist_block: String = conv_history_block()
|
||||
// FIX B (2026-08-05): the session's own window, not the process-global one. This is the
|
||||
// read half of the blank stare — a turn sent with Tools OFF now sees the turns that were
|
||||
// sent with Tools ON, because they are in the same bucket.
|
||||
let hist_block: String = conv_history_block(session_id)
|
||||
let full_system: String = base_system + hist_block
|
||||
|
||||
let raw: String = llm_call_system(model, full_system, prompt)
|
||||
@@ -1065,7 +1391,9 @@ fn layered_generate(prompt: String, imprint_id: String) -> String {
|
||||
return ""
|
||||
}
|
||||
|
||||
return clean_llm_response(raw)
|
||||
// FIX A follow-up: a model that has seen receipts in its context may sign its own answer
|
||||
// with one. Strip it before the caller ever sees it. See receipt_strip.
|
||||
return receipt_strip(clean_llm_response(raw))
|
||||
}
|
||||
|
||||
// session_preload_bullets — render up to max_bullets nodes from a JSON array as
|
||||
@@ -1155,8 +1483,12 @@ fn handle_chat(body: String) -> String {
|
||||
// Load history BEFORE compiling context so we can anchor activation to the thread.
|
||||
// TODO(reliability #3 — conv_history global race): process-global key; concurrent
|
||||
// /api/chat requests without session_id race on this read-append-write.
|
||||
let state_hist: String = state_get("conv_history")
|
||||
let stored_hist: String = if str_eq(state_hist, "") { conv_history_load() } else { state_hist }
|
||||
// NOTE 2026-08-05 (FIX B): this function is DEAD (see the banner above) and is left on the
|
||||
// anonymous key deliberately. The race the TODO describes is exactly why the live plain
|
||||
// path was scoped instead of given a fallback to this global. If this function is ever
|
||||
// revived it must take a session_id and use conv_hist_key, like every live caller now does.
|
||||
let state_hist: String = state_get(conv_hist_key(""))
|
||||
let stored_hist: String = if str_eq(state_hist, "") { conv_history_load("") } else { state_hist }
|
||||
let hist_load_failed: Bool = str_eq(state_get("conv_history_load_failed"), "1")
|
||||
let hist_len: Int = if str_eq(stored_hist, "") { 0 } else { json_array_len(stored_hist) }
|
||||
|
||||
@@ -1352,8 +1684,8 @@ fn handle_chat(body: String) -> String {
|
||||
} else {
|
||||
updated_hist2
|
||||
}
|
||||
state_set("conv_history", final_hist)
|
||||
conv_history_persist(final_hist)
|
||||
state_set(conv_hist_key(""), final_hist)
|
||||
conv_history_persist("", final_hist)
|
||||
|
||||
// Session-end summary hook: write a dated SessionSummary node once per boot when
|
||||
// the conversation reaches >= 5 user turns (10 hist entries = 5 user+assistant pairs).
|
||||
@@ -2187,6 +2519,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, "") {
|
||||
@@ -2222,10 +2572,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))
|
||||
@@ -2253,7 +2603,10 @@ fn handle_chat_agentic(body: String) -> String {
|
||||
return "{\"error\":\"session not found\",\"session_id\":\"" + req_session + "\",\"reply\":\"\"}"
|
||||
}
|
||||
|
||||
let hist_key: String = if str_eq(req_session, "") { "conv_history" } else { "session_hist_" + req_session }
|
||||
// FIX B (2026-08-05): the key rule now lives in one place and the plain path uses the
|
||||
// same one. Behaviour on this path is unchanged — conv_hist_key reproduces exactly what
|
||||
// this line computed inline — but there is no longer a second, divergent definition.
|
||||
let hist_key: String = conv_hist_key(req_session)
|
||||
let agentic_hist: String = state_get(hist_key)
|
||||
let agentic_hist_len: Int = if str_eq(agentic_hist, "") { 0 } else { json_array_len(agentic_hist) }
|
||||
// Issue 8 fix: use engram_is_continuation instead of brittle 50-char threshold.
|
||||
@@ -2311,7 +2664,7 @@ fn handle_chat_agentic(body: String) -> String {
|
||||
|
||||
let system: String = identity + bounded_persona_floor() + " You have access to tools: read files, write files, browse the web, search your memory, run commands. Use them when they add genuine value. Be direct.
|
||||
|
||||
" + ctx + ag_session_preload
|
||||
" + ctx + ag_session_preload + receipt_rule()
|
||||
|
||||
let api_key: String = agentic_api_key()
|
||||
let tools_json: String = agentic_tools_all()
|
||||
@@ -2367,38 +2720,31 @@ fn handle_chat_agentic(body: String) -> String {
|
||||
// Persist the exchange to session/global history for thread continuity on next turn.
|
||||
// Only save when the loop completed (reply present), not when tool_pending.
|
||||
let reply_text: String = json_get(result, "reply")
|
||||
let discard_hist: Bool = if !str_eq(reply_text, "") {
|
||||
// FIX A (2026-08-05): the evidence the next turn needs. `result` already carries
|
||||
// tools_used, and agentic_loop now also returns the source URLs it saw; both are folded
|
||||
// into a receipt line and stored WITH the assistant turn. Without this the next turn sees
|
||||
// a sourced answer and no trace of the search, and concludes it made the data up — the
|
||||
// false confession. See tool_receipt.
|
||||
let turn_tools: String = json_get_raw(result, "tools_used")
|
||||
let turn_sources: String = json_get(result, "sources")
|
||||
let turn_receipt: String = tool_receipt(turn_tools, turn_sources)
|
||||
// FIX E1 (2026-08-05): a utility generation (title, insight) is not conversation and is
|
||||
// not recorded as one. It is still answered normally — only the transcript is spared.
|
||||
let record_turn: Bool = !str_eq(reply_text, "") && !is_utility_request(body, req_session)
|
||||
let discard_hist: Bool = if record_turn {
|
||||
let updated: String = hist_append(agentic_hist, "user", message)
|
||||
let updated2: String = hist_append(updated, "assistant", reply_text)
|
||||
let updated2: String = hist_append(updated, "assistant", reply_text + turn_receipt)
|
||||
// Increased from 20 to 40 turns: consistent with handle_chat window expansion.
|
||||
let trimmed: String = if json_array_len(updated2) > 40 { hist_trim(updated2) } else { updated2 }
|
||||
state_set(hist_key, trimmed)
|
||||
// Persist to engram for cross-restart continuity.
|
||||
// Named sessions get session-scoped labels, fixing ephemeral-only limitation (issue #4).
|
||||
if str_eq(hist_key, "conv_history") {
|
||||
conv_history_persist(trimmed)
|
||||
} else {
|
||||
if !str_eq(trimmed, "") && !str_eq(trimmed, "[]") {
|
||||
let sess_hist_label: String = "conv:history:" + req_session
|
||||
let sess_hist_tags: String = "[\"session-history\",\"persistent\"]"
|
||||
let sess_hist_id: String = engram_node_full(
|
||||
trimmed, "Conversation", sess_hist_label,
|
||||
el_from_float(0.6), el_from_float(0.7), el_from_float(0.8),
|
||||
"Episodic", sess_hist_tags
|
||||
)
|
||||
// NOTE: bind an explicit Bool value here. A bare `if { println(...) }`
|
||||
// leaves a void-typed branch in value position, which the current elc
|
||||
// lowers to `_if_result = (println(...))` — invalid C. Yielding a value
|
||||
// keeps the branch non-void without changing behavior (still only logs).
|
||||
let persist_ok: Bool = if str_eq(sess_hist_id, "") {
|
||||
println("[chat] agentic: named session history persist failed for session=" + req_session)
|
||||
false
|
||||
} else { true }
|
||||
persist_ok
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
// FIX B (2026-08-05): ONE persist, through the shared helper. This site used to hold
|
||||
// a second, hand-rolled copy of the same write for named sessions — a different label
|
||||
// expression, different salience scores and a different tag set for the same data.
|
||||
// Since conv_hist_label now gives both callers the same label and engram_node_full
|
||||
// upserts by label, two score policies were writing the same node. One writer, one
|
||||
// label rule, one policy.
|
||||
conv_history_persist(req_session, trimmed)
|
||||
true
|
||||
} else { false }
|
||||
|
||||
@@ -2432,6 +2778,11 @@ fn agentic_loop(session_id: String, model: String, safe_sys: String, tools_json:
|
||||
let messages: String = messages_in
|
||||
let final_text: String = ""
|
||||
let tools_log: String = tools_log_in
|
||||
// FIX A (2026-08-05): source URLs accumulated across every round of this turn, so the
|
||||
// receipt written into history can name what the search actually returned. Carried at
|
||||
// loop level for the same reason tools_log is: a resumed round must not lose the
|
||||
// evidence gathered before the pause.
|
||||
let sources_all: String = ""
|
||||
let iteration: Int = 0
|
||||
let keep_going: Bool = true
|
||||
|
||||
@@ -2504,6 +2855,30 @@ fn agentic_loop(session_id: String, model: String, safe_sys: String, tools_json:
|
||||
+ ",\"messages\":" + messages
|
||||
+ "}"
|
||||
|
||||
// ── ROUND-START MARKER (2026-08-06, round 9.1 D2 / ADR 0006 item 2) ──────────
|
||||
// The ledger below only ever appended AFTER a round returned, so a healthy
|
||||
// first leg produced ZERO progress by construction. Since server-side
|
||||
// web_search moved inside the outbound call (2026-08-04) that leg measures
|
||||
// 84-117 s, and the client had no way to tell "working" from "dead" — which is
|
||||
// how a 25 s client-side watchdog came to kill a healthy mission.
|
||||
//
|
||||
// Only this loop knows a round has started, so only this loop can say so. One
|
||||
// entry, written BEFORE the call goes out, using the ledger and the wire shape
|
||||
// that already exist: the app has handled tool == "__working__" as an
|
||||
// Activity-only life signal since 2026-07-13 (ChatView.kt:1148) and never
|
||||
// received one. Narration is deliberately empty - the marker means "a round
|
||||
// started", nothing more, and the client renders it as a heartbeat, not prose.
|
||||
//
|
||||
// This is a strict subset of WS3 item 3 (push/poll progress). It builds none of
|
||||
// WS3's run registry: no new state key, no new route, no new lifecycle.
|
||||
if !str_eq(session_id, "") {
|
||||
let start_key: String = "run_progress_" + session_id
|
||||
let start_prev: String = state_get(start_key)
|
||||
let start_entry: String = "{\"i\":" + int_to_str(iteration) + ",\"t\":\"\",\"tool\":\"__working__\"}"
|
||||
let start_next: String = if str_eq(start_prev, "") { start_entry } else { start_prev + "," + start_entry }
|
||||
state_set(start_key, start_next)
|
||||
}
|
||||
|
||||
let raw_resp: String = http_post_with_headers(api_url, req_body, h)
|
||||
|
||||
let is_error: Bool = str_starts_with(raw_resp, "{\"error\"")
|
||||
@@ -2578,6 +2953,13 @@ fn agentic_loop(session_id: String, model: String, safe_sys: String, tools_json:
|
||||
// separate from tools_log so this inner walk has exactly one mutation site per
|
||||
// variable (the El scope rule below), then merged in at the outer level.
|
||||
let srv_log: String = ""
|
||||
// FIX A: source URLs seen this round (citations + web_search results). Merged into
|
||||
// the loop-level accumulator below, same shape as srv_log.
|
||||
let src_log: String = ""
|
||||
// FIX C: seam tracking. True once a NON-text block has been walked, so the next text
|
||||
// block knows it is resuming after an interruption rather than continuing a sentence.
|
||||
// See the separator decision at the text accumulation site.
|
||||
let saw_nontext: Bool = false
|
||||
let ci: Int = 0
|
||||
let c_total: Int = json_array_len(eff_content)
|
||||
while ci < c_total {
|
||||
@@ -2595,8 +2977,34 @@ fn agentic_loop(session_id: String, model: String, safe_sys: String, tools_json:
|
||||
let has_cit: Bool = !str_eq(cit_raw, "") && !str_eq(cit_raw, "null")
|
||||
let btype_scan: String = json_get(block, "type")
|
||||
let btype: String = if has_cit { "text" } else { btype_scan }
|
||||
// Accumulate text at top level using if-expression
|
||||
let text_out = if str_eq(btype, "text") { text_out + json_get(block, "text") } else { text_out }
|
||||
// ── FIX C, seam 1 of 2 (2026-08-05): "to.Good" ────────────────────────────────
|
||||
// Byte-verified in a shipped reply: 0x77 0x2e 0x47 — "to" then "." then "Good",
|
||||
// with no space and no newline. The bare `+` below joined the last sentence of a
|
||||
// pre-search paragraph directly onto the first word of the post-search paragraph.
|
||||
// Will wrote this line on 2026-05-03 and it was correct for a year: before
|
||||
// server-side web_search, text blocks were adjacent, and adjacent text blocks are
|
||||
// one continuous string that must be joined with nothing.
|
||||
//
|
||||
// WHY THE OBVIOUS FIX IS WRONG. Inserting a separator between all text blocks
|
||||
// shatters every cited answer. A cited response splits MID-SENTENCE, one block per
|
||||
// citation span: "The current temperature is " + "86°F" + ", with " — see the
|
||||
// CITATION-BLOCK FIX note above. A blanket "\n\n" turns that into three fragments
|
||||
// on three lines. Both failure modes are real and they pull in opposite directions.
|
||||
//
|
||||
// THE DISTINCTION THAT RESOLVES IT: a text block that directly follows another
|
||||
// text block is a continuation and gets nothing; a text block that follows an
|
||||
// INTERVENING NON-TEXT block (server_tool_use, web_search_tool_result, tool_use)
|
||||
// resumes after an interruption and gets "\n\n". saw_nontext carries exactly that
|
||||
// one bit, and text_join_sep holds the rule — shared with the resume seam below.
|
||||
// Mid-sentence citation splits are untouched: no non-text block sits between them.
|
||||
let is_text: Bool = str_eq(btype, "text")
|
||||
let btext: String = if is_text { json_get(block, "text") } else { "" }
|
||||
let text_out = if is_text {
|
||||
text_out + text_join_sep(text_out, btext, saw_nontext) + btext
|
||||
} else { text_out }
|
||||
let saw_nontext = if is_text { false } else { true }
|
||||
// FIX A: record where the facts came from, from whichever block carries them.
|
||||
let src_log = provenance_add_sources(block, btype, has_cit, cit_raw, src_log)
|
||||
// FUTURE-PROOF: tools Anthropic runs on our behalf (web_search today, whatever
|
||||
// ships tomorrow) arrive as server_tool_use blocks, never as client tool_use.
|
||||
// Count the CATEGORY by the block's own name so a new server tool appears in
|
||||
@@ -2678,6 +3086,12 @@ fn agentic_loop(session_id: String, model: String, safe_sys: String, tools_json:
|
||||
} else {
|
||||
if str_eq(tools_log, "") { srv_log } else { tools_log + "," + srv_log }
|
||||
}
|
||||
// FIX A: same merge, for the sources seen in this round's blocks.
|
||||
let sources_all = if str_eq(src_log, "") {
|
||||
sources_all
|
||||
} else {
|
||||
if str_eq(sources_all, "") { src_log } else { sources_all + "; " + src_log }
|
||||
}
|
||||
|
||||
// The assistant turn that requested the tool — needed verbatim on resume so the
|
||||
// tool_use/tool_result pairing stays valid when the client posts its result.
|
||||
@@ -2732,7 +3146,17 @@ fn agentic_loop(session_id: String, model: String, safe_sys: String, tools_json:
|
||||
// CONTINUES the answer, it does not repeat it — overwriting here would throw away
|
||||
// everything the model wrote before the pause, which is the same truncation the
|
||||
// pause handling exists to prevent. A version-fallback round contributes nothing.
|
||||
let final_text = if !is_tool_turn && !can_fallback { final_text + text_out } else { final_text }
|
||||
//
|
||||
// ── FIX C, seam 2 of 2 (2026-08-05) ──────────────────────────────────────────────
|
||||
// The other half of "to.Good". This join is ours (62af564, the web_search port) and
|
||||
// is unconditionally a boundary: the two sides are separate rounds of the Anthropic
|
||||
// loop, separated by a pause and a tool execution. There is no mid-sentence case to
|
||||
// protect here — the model was interrupted, and when it resumes it starts a new
|
||||
// thought. So this seam passes after_interruption=true unconditionally; text_join_sep's
|
||||
// own empty-guards handle the first round and an empty round.
|
||||
let final_text = if !is_tool_turn && !can_fallback {
|
||||
final_text + text_join_sep(final_text, text_out, true) + text_out
|
||||
} else { final_text }
|
||||
// Output cap hit mid-action: the tool block is truncated and will NOT run. Say so
|
||||
// instead of ending on silent almost-work.
|
||||
let final_text = if str_eq(stop_reason, "max_tokens") && has_tool {
|
||||
@@ -2756,6 +3180,7 @@ fn agentic_loop(session_id: String, model: String, safe_sys: String, tools_json:
|
||||
+ ",\"narration\":\"" + json_safe(pend_narration) + "\""
|
||||
+ ",\"model\":\"" + model + "\""
|
||||
+ ",\"agentic\":true"
|
||||
+ ",\"sources\":\"" + json_safe(sources_all) + "\""
|
||||
+ ",\"tools_used\":" + tools_arr + "}"
|
||||
}
|
||||
|
||||
@@ -2763,6 +3188,10 @@ fn agentic_loop(session_id: String, model: String, safe_sys: String, tools_json:
|
||||
// genuine no-response (model returned an empty text block). The iteration cap
|
||||
// means the task was too complex for the agentic loop depth — surface it clearly
|
||||
// so the caller/operator knows to increase the cap or break the task apart.
|
||||
// FIX A follow-up: strip any receipt the MODEL wrote before this becomes the reply. Placed
|
||||
// ABOVE the empty check on purpose — a turn whose entire output was an imitated receipt has
|
||||
// produced no answer, and must be reported as no answer rather than as a receipt.
|
||||
let final_text = receipt_strip(final_text)
|
||||
if str_eq(final_text, "") {
|
||||
let hit_cap: Bool = iteration >= 12
|
||||
let err_msg: String = if hit_cap {
|
||||
@@ -2782,7 +3211,10 @@ fn agentic_loop(session_id: String, model: String, safe_sys: String, tools_json:
|
||||
let done_next: String = if str_eq(done_prev, "") { "{\"done\":true}" } else { done_prev + ",{\"done\":true}" }
|
||||
state_set(done_key, done_next)
|
||||
}
|
||||
return "{\"reply\":\"" + safe_text + "\",\"model\":\"" + model + "\",\"agentic\":true,\"tools_used\":" + tools_arr + ",\"iterations\":" + int_to_str(iteration) + "}"
|
||||
// FIX A: "sources" carries the URLs this turn actually retrieved, so handle_chat_agentic
|
||||
// can write them into the history receipt and the next turn can answer "what source did
|
||||
// you use?" from the transcript instead of guessing (or apologising).
|
||||
return "{\"reply\":\"" + safe_text + "\",\"model\":\"" + model + "\",\"agentic\":true,\"tools_used\":" + tools_arr + ",\"sources\":\"" + json_safe(sources_all) + "\",\"iterations\":" + int_to_str(iteration) + "}"
|
||||
}
|
||||
|
||||
// bridge_save — persist a suspended agentic turn keyed by session_id. Stored as a
|
||||
@@ -2800,12 +3232,31 @@ fn bridge_save(session_id: String, model: String, safe_sys: String, tools_json:
|
||||
// JSON values (not string-escaped) so the round-trip through state_get/json_get_raw
|
||||
// never corrupts nested quotes. Scalar strings (model, safe_sys, tools_log,
|
||||
// tool_use_id) stay as string fields via json_safe as before.
|
||||
//
|
||||
// FIELD ORDER IS LOAD-BEARING (round-9 fix, 2026-08-06). json_get is a first-
|
||||
// substring-match scanner (strstr for "\"key\":", el_runtime.c), and the two raw
|
||||
// fields embed the UNESCAPED conversation — every key the model's own blocks carry
|
||||
// ("tool_use_id" in each web_search_tool_result, "content", "type", ...) is findable
|
||||
// by a whole-blob scan. With messages_raw serialized BEFORE tool_use_id, the resume
|
||||
// read json_get(blob, "tool_use_id") returned the FIRST web_search_tool_result's
|
||||
// srvtoolu_… id instead of the saved client-tool id, so every search-then-bridge
|
||||
// turn 400'd on approval ("unexpected tool_use_id found in tool_result blocks:
|
||||
// srvtoolu_…") and the run died as {"error":"llm unavailable"}. Same first-match-
|
||||
// scanner class as BUG-6 (approve "content" matched inside tool_input) and the
|
||||
// round-8 citation-block fix.
|
||||
//
|
||||
// The rule: every json_safe'd scalar precedes both raw fields (escaping means a
|
||||
// scalar value can never contain a bare "key": byte pattern, so first-match lands
|
||||
// on the blob's own fields), and tools_raw — our own fixed tool schema — precedes
|
||||
// messages_raw — arbitrary model/user content — so neither raw extraction can
|
||||
// first-match into model-controlled bytes either. Do not reorder; do not add a
|
||||
// field after messages_raw.
|
||||
let blob: String = "{\"model\":\"" + json_safe(model) + "\""
|
||||
+ ",\"safe_sys\":\"" + json_safe(safe_sys) + "\""
|
||||
+ ",\"messages_raw\":" + messages
|
||||
+ ",\"tools_raw\":" + tools_json
|
||||
+ ",\"tools_log\":\"" + json_safe(tools_log) + "\""
|
||||
+ ",\"tool_use_id\":\"" + json_safe(tool_use_id) + "\"}"
|
||||
+ ",\"tool_use_id\":\"" + json_safe(tool_use_id) + "\""
|
||||
+ ",\"tools_raw\":" + tools_json
|
||||
+ ",\"messages_raw\":" + messages + "}"
|
||||
state_set("mcp_bridge:" + session_id, blob)
|
||||
return true
|
||||
}
|
||||
@@ -2842,11 +3293,17 @@ fn agentic_resume(session_id: String, tool_use_id: String, content: String) -> S
|
||||
let tools_log: String = json_get(blob, "tools_log")
|
||||
let saved_use_id: String = json_get(blob, "tool_use_id")
|
||||
|
||||
// Bind the result to the tool the soul actually suspended on. The client should
|
||||
// echo the call_id; if it omits or mismatches it, fall back to the saved id so a
|
||||
// late/partial client still resumes correctly.
|
||||
let use_id: String = if str_eq(tool_use_id, "") { saved_use_id } else { tool_use_id }
|
||||
let eff_use_id: String = if str_eq(use_id, saved_use_id) { use_id } else { saved_use_id }
|
||||
// Bind the result to the tool the loop actually suspended on. The client echoes
|
||||
// the call_id from the pending envelope; that value came straight from
|
||||
// pend_tool_id and never round-tripped through this blob, so when both are
|
||||
// present and disagree the CLIENT's id is the one with clean provenance (a blob
|
||||
// written by a pre-round-9 binary misreads tool_use_id by first-match scanning
|
||||
// into messages_raw — see bridge_save). A client that omits call_id still
|
||||
// resumes on the saved id, which the reordered blob now reads correctly.
|
||||
// (The old guard here — "on mismatch, prefer saved" — reduced to eff_use_id ≡
|
||||
// saved_use_id in both branches: the client's correct id could never win, which
|
||||
// is what turned the misread into a deterministic 400 on resume.)
|
||||
let eff_use_id: String = if str_eq(tool_use_id, "") { saved_use_id } else { tool_use_id }
|
||||
|
||||
// Result may be large (an MCP page/file); truncate like local tool results do.
|
||||
let trimmed: String = if str_len(content) > 6000 {
|
||||
@@ -3004,7 +3461,7 @@ fn handle_dharma_room_turn(body: String) -> String {
|
||||
// engram_node(content, "episodic", ...) which wrongly put a TIER into the node_type
|
||||
// slot — that's why nodes showed node_type="episodic". Use the full, correct contract.)
|
||||
let utterance_tags: String = "[\"soul-utterance\",\"episodic\"]"
|
||||
let discard_id: String = engram_node_full(
|
||||
let discard_id: String = wt_node(
|
||||
clean_response, "Conversation", "soul:utterance",
|
||||
el_from_float(0.6), el_from_float(0.6), el_from_float(0.8),
|
||||
"Episodic", utterance_tags
|
||||
@@ -3095,7 +3552,7 @@ fn session_summary_write(summary_text: String) -> String {
|
||||
}
|
||||
}
|
||||
let tags: String = "[\"SessionSummary\",\"session-summary\",\"previous-session\",\"consolidate\"]"
|
||||
let node_id: String = engram_node_full(
|
||||
let node_id: String = wt_node(
|
||||
content, "SessionSummary", "session:summary",
|
||||
el_from_float(0.85), el_from_float(0.85), el_from_float(1.0),
|
||||
"Episodic", tags
|
||||
@@ -3121,7 +3578,7 @@ fn session_summary_write_dated(summary_text: String, label: String) -> String {
|
||||
let ts_str: String = int_to_str(ts)
|
||||
let content: String = "[session-summary] " + trimmed + " | ts:" + ts_str
|
||||
let tags: String = "[\"SessionSummary\",\"session-summary\",\"previous-session\",\"consolidate\"]"
|
||||
let node_id: String = engram_node_full(
|
||||
let node_id: String = wt_node(
|
||||
content, "SessionSummary", label,
|
||||
el_from_float(0.9), el_from_float(0.8), el_from_float(1.0),
|
||||
"Episodic", tags
|
||||
@@ -3197,7 +3654,7 @@ fn auto_persist(req: String, resp: String) -> Void {
|
||||
+ ",\"bell\":\"" + bell_level + "\""
|
||||
+ ",\"label\":\"chat:" + ts_str + "\"}"
|
||||
|
||||
let conv_node_id: String = engram_node_full(
|
||||
let conv_node_id: String = wt_node(
|
||||
content,
|
||||
"Conversation",
|
||||
"chat:" + ts_str,
|
||||
@@ -3235,7 +3692,7 @@ fn auto_persist(req: String, resp: String) -> Void {
|
||||
let bell_tags: String = "[\"safety\",\"bell\",\"bell:" + bell_level + "\",\"affective\",\"BellEvent\"]"
|
||||
let bell_ts_str: String = int_to_str(time_now())
|
||||
let bell_label: String = "bell:" + bell_level + ":" + bell_ts_str
|
||||
let bell_node_id: String = engram_node_full(
|
||||
let bell_node_id: String = wt_node(
|
||||
bell_content,
|
||||
"BellEvent",
|
||||
bell_label,
|
||||
@@ -3294,7 +3751,7 @@ fn auto_persist(req: String, resp: String) -> Void {
|
||||
let pos_tags: String = "[\"joy\",\"positive\",\"joy:" + positive_level + "\",\"affective\",\"PositiveEvent\"]"
|
||||
let pos_ts_label: String = int_to_str(time_now())
|
||||
let pos_label: String = "joy:" + positive_level + ":" + pos_ts_label
|
||||
let pos_node_id: String = engram_node_full(
|
||||
let pos_node_id: String = wt_node(
|
||||
pos_content, "PositiveEvent", pos_label,
|
||||
pos_sal_a, pos_sal_b, pos_sal_c, "Episodic", pos_tags
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// auto-generated by elc --emit-header — do not edit
|
||||
// auto-generated by elc --emit-header - do not edit
|
||||
extern fn chat_default_model() -> String
|
||||
extern fn engram_numeric_valid(s: String) -> Bool
|
||||
extern fn parse_float_x100(s: String) -> Int
|
||||
@@ -16,18 +16,35 @@ extern fn engram_nodes_merge(a: String, b: String) -> String
|
||||
extern fn id_in_seen(node_id: String, seen: String) -> Bool
|
||||
extern fn add_to_seen(seen: String, node_id: String) -> String
|
||||
extern fn engram_extract_ids(nodes_json: String) -> String
|
||||
extern fn affective_node_ts(node_json: String) -> Int
|
||||
extern fn engram_compile(intent: String) -> String
|
||||
extern fn distill_transcript(transcript: String) -> String
|
||||
extern fn json_safe(s: String) -> String
|
||||
extern fn current_engine_note(model: String) -> String
|
||||
extern fn bounded_persona_floor() -> String
|
||||
extern fn operator_identity_block() -> String
|
||||
extern fn build_system_prompt(ctx: String, chat_mode: Bool) -> String
|
||||
extern fn hist_append(hist: String, role: String, content: String) -> String
|
||||
extern fn conv_hist_key(session_id: String) -> String
|
||||
extern fn conv_hist_label(session_id: String) -> String
|
||||
extern fn is_utility_request(body: String, session_id: String) -> Bool
|
||||
extern fn provenance_scan_urls(arr: String, acc: String) -> String
|
||||
extern fn provenance_add_sources(block: String, btype: String, has_cit: Bool, cit_raw: String, acc: String) -> String
|
||||
extern fn provenance_names(tools_used: String) -> String
|
||||
extern fn text_join_sep(accumulated: String, incoming: String, after_interruption: Bool) -> String
|
||||
extern fn receipt_rule() -> String
|
||||
extern fn receipt_strip(s: String) -> String
|
||||
extern fn tool_receipt(tools_used: String, sources: String) -> String
|
||||
extern fn hist_trim(hist: String) -> String
|
||||
extern fn hist_trim_with_bell_guard(hist: String) -> String
|
||||
extern fn clean_llm_response(s: String) -> String
|
||||
extern fn conv_history_persist(hist: String) -> Void
|
||||
extern fn conv_history_load() -> String
|
||||
extern fn conv_history_persist(session_id: String, hist: String) -> Void
|
||||
extern fn conv_history_load(session_id: String) -> String
|
||||
extern fn conv_history_record(session_id: String, user_msg: String, assistant_msg: String, receipt: String) -> Void
|
||||
extern fn conv_history_block(session_id: String) -> String
|
||||
extern fn layered_generate(prompt: String, imprint_id: String, session_id: String) -> String
|
||||
extern fn session_preload_bullets(nodes: String, max_bullets: Int, snip_len: Int) -> String
|
||||
extern fn affective_context_prefix() -> String
|
||||
extern fn handle_chat(body: String) -> String
|
||||
extern fn handle_see(body: String) -> String
|
||||
extern fn studio_tools_json() -> String
|
||||
@@ -37,6 +54,8 @@ extern fn llm_wire_format() -> String
|
||||
extern fn json_escape(s: String) -> String
|
||||
extern fn openai_chat_complete(model: String, base_url: String, api_key: String, safe_sys: String, messages_json: String) -> String
|
||||
extern fn agentic_tools_literal() -> String
|
||||
extern fn web_search_tool_json() -> String
|
||||
extern fn strip_client_web_search(tools_inner: String) -> String
|
||||
extern fn agentic_tools_with_web() -> String
|
||||
extern fn connector_tools_json() -> String
|
||||
extern fn agentic_tools_all() -> String
|
||||
@@ -46,6 +65,10 @@ extern fn call_neuron_mcp(tool_name: String, args: String) -> String
|
||||
extern fn agent_workspace_root() -> String
|
||||
extern fn path_within_root(path: String, root: String) -> Bool
|
||||
extern fn resolve_in_root(path: String, root: String) -> String
|
||||
extern fn run_command_is_readonly(cmd: String) -> Bool
|
||||
extern fn cmd_abs_escape_at(cmd: String, root: String, needle: String) -> Bool
|
||||
extern fn run_command_guard(cmd: String, root: String) -> String
|
||||
extern fn classify_tool_risk(tool_name: String, tool_input: String) -> String
|
||||
extern fn dispatch_tool(tool_name: String, tool_input: String) -> String
|
||||
extern fn is_builtin_tool(tool_name: String) -> Bool
|
||||
extern fn next_bridge_id() -> String
|
||||
|
||||
+17
-3
@@ -5,6 +5,15 @@ el_val_t add_punct(el_val_t s, el_val_t intent);
|
||||
el_val_t add_to_seen(el_val_t seen, el_val_t node_id);
|
||||
el_val_t aff_try_slot(el_val_t slot_json, el_val_t aff_7d_ts, el_val_t acc_key);
|
||||
el_val_t affective_context_prefix(void);
|
||||
el_val_t is_utility_request(el_val_t body, el_val_t session_id);
|
||||
el_val_t operator_identity_block(void);
|
||||
el_val_t provenance_add_sources(el_val_t block, el_val_t btype, el_val_t has_cit, el_val_t cit_raw, el_val_t acc);
|
||||
el_val_t provenance_names(el_val_t tools_used);
|
||||
el_val_t provenance_scan_urls(el_val_t arr, el_val_t acc);
|
||||
el_val_t text_join_sep(el_val_t accumulated, el_val_t incoming, el_val_t after_interruption);
|
||||
el_val_t receipt_rule(void);
|
||||
el_val_t receipt_strip(el_val_t s);
|
||||
el_val_t tool_receipt(el_val_t tools_used, el_val_t sources);
|
||||
el_val_t agent_number(el_val_t agent);
|
||||
el_val_t agent_person(el_val_t agent);
|
||||
el_val_t agent_workspace_root(void);
|
||||
@@ -151,8 +160,12 @@ el_val_t cmd_abs_escape_at(el_val_t cmd, el_val_t root, el_val_t needle);
|
||||
el_val_t connectd_get(el_val_t suffix);
|
||||
el_val_t connectd_post(el_val_t suffix, el_val_t body);
|
||||
el_val_t connector_tools_json(void);
|
||||
el_val_t conv_history_load(void);
|
||||
el_val_t conv_history_persist(el_val_t hist);
|
||||
el_val_t conv_hist_key(el_val_t session_id);
|
||||
el_val_t conv_hist_label(el_val_t session_id);
|
||||
el_val_t conv_history_block(el_val_t session_id);
|
||||
el_val_t conv_history_load(el_val_t session_id);
|
||||
el_val_t conv_history_persist(el_val_t session_id, el_val_t hist);
|
||||
el_val_t conv_history_record(el_val_t session_id, el_val_t user_msg, el_val_t assistant_msg, el_val_t receipt);
|
||||
el_val_t cop_article(el_val_t gender, el_val_t number, el_val_t definite);
|
||||
el_val_t cop_bwk_future(el_val_t prefix);
|
||||
el_val_t cop_bwk_perfect(el_val_t prefix);
|
||||
@@ -782,7 +795,8 @@ el_val_t lang_profile_uga(void);
|
||||
el_val_t lang_profile_zh(void);
|
||||
el_val_t lang_profile(el_val_t code, el_val_t word_order, el_val_t morph_type, el_val_t has_case, el_val_t has_gender, el_val_t script_dir, el_val_t agreement, el_val_t null_subject);
|
||||
el_val_t lang_word_order(el_val_t profile);
|
||||
el_val_t layered_cycle(el_val_t raw_input);
|
||||
el_val_t layered_cycle(el_val_t raw_input, el_val_t session_id, el_val_t utility);
|
||||
el_val_t layered_generate(el_val_t prompt, el_val_t imprint_id, el_val_t session_id);
|
||||
el_val_t lex_class(el_val_t entry);
|
||||
el_val_t lex_form(el_val_t entry, el_val_t idx);
|
||||
el_val_t lex_pos(el_val_t entry);
|
||||
|
||||
+1283
-662
File diff suppressed because one or more lines are too long
@@ -1,9 +1,11 @@
|
||||
import "persist.el"
|
||||
|
||||
fn tier_working() -> String { return "Working" }
|
||||
fn tier_episodic() -> String { return "Episodic" }
|
||||
fn tier_canonical() -> String { return "Canonical" }
|
||||
|
||||
fn mem_store(content: String, label: String, tags: String) -> String {
|
||||
let id: String = engram_node_full(
|
||||
let id: String = wt_node(
|
||||
content,
|
||||
"Memory",
|
||||
label,
|
||||
@@ -17,13 +19,23 @@ fn mem_store(content: String, label: String, tags: String) -> String {
|
||||
println("[memory] write rejected by engram (empty id): label=" + label)
|
||||
return ""
|
||||
}
|
||||
// Read back to verify the node actually persisted — guards against silent write failures.
|
||||
let readback: String = engram_get_node_json(id)
|
||||
if str_eq(readback, "") || str_eq(readback, "{}") {
|
||||
println("[memory] WRITE VERIFY FAILED: label=" + label + " id=" + id + " — node absent after write")
|
||||
return ""
|
||||
// wt_node has already read the node back locally and returns "" if it did
|
||||
// not land, so the old duplicate read-back here is gone.
|
||||
//
|
||||
// HONESTY (neuron#117): the receipt now says WHERE the write is.
|
||||
// The old unconditional "write verified" line asserted against the soul's
|
||||
// own RAM — true in memory, false on disk — and printed ~115,000 times on
|
||||
// Tim's machine while the canonical snapshot sat frozen for three days.
|
||||
// wt_commit flushes the spool and then asks the OWNER. When it says false
|
||||
// the node is real and recallable but not yet durable, and the log says so
|
||||
// rather than claiming a save that did not happen. The id is still returned:
|
||||
// the local write DID succeed, and the queued delta will be retried.
|
||||
let durable: Bool = wt_commit(id)
|
||||
if durable {
|
||||
println("[memory] write persisted at owner: " + id + " label=" + label)
|
||||
} else {
|
||||
println("[memory] write IN MEMORY ONLY (queued for owner, not yet durable): " + id + " label=" + label)
|
||||
}
|
||||
println("[memory] write verified: " + id + " ok")
|
||||
return id
|
||||
}
|
||||
|
||||
@@ -51,12 +63,12 @@ fn mem_strengthen(node_id: String) -> Void {
|
||||
// memory.el (imported first) so awareness.el and neuron-api.el can both call it.
|
||||
fn mem_tombstone(node_id: String) -> String {
|
||||
let tags: String = "[\"Tombstone\",\"status:deleted\"]"
|
||||
let marker: String = engram_node_full(
|
||||
let marker: String = wt_node(
|
||||
node_id, "Tombstone", "tombstone:" + node_id,
|
||||
el_from_float(0.01), el_from_float(0.01), el_from_float(1.0),
|
||||
"Episodic", tags)
|
||||
if !str_eq(marker, "") {
|
||||
engram_connect(marker, node_id, el_from_float(1.0), "tombstones")
|
||||
wt_edge(marker, node_id, el_from_float(1.0), "tombstones")
|
||||
}
|
||||
return marker
|
||||
}
|
||||
|
||||
+36
-27
@@ -195,15 +195,24 @@ fn api_compact_activated(raw: String, max_items: Int, snip: Int) -> String {
|
||||
}
|
||||
|
||||
// api_persisted — read-back-after-write guard against hallucinated saves.
|
||||
// After a write builtin returns an id, confirm the node is actually queryable
|
||||
// via engram_get_node_json(id) (returns "" or "null" when missing). Returns
|
||||
// true only when the node is genuinely persisted.
|
||||
//
|
||||
// WIDENED FOR neuron#117. This function is the single gate every MCP write
|
||||
// handler passes through before it reports success (10 call sites), which makes
|
||||
// it the right place to close the honesty gap rather than editing ten receipts.
|
||||
//
|
||||
// It used to read back from engram_get_node_json — the SOUL'S OWN in-process
|
||||
// graph. In HTTP-engram mode that asserts the wrong thing: the soul is not the
|
||||
// persistence owner, so a node present in its RAM and absent from the owner read
|
||||
// as "persisted" and then vanished on the next restart. The guard was doing
|
||||
// exactly what its comment promised and still certifying writes that did not
|
||||
// survive. It now flushes the write-through spool and asks the OWNER.
|
||||
//
|
||||
// In file mode (no ENGRAM_URL) the soul IS the owner and wt_commit collapses to
|
||||
// the original local read-back — unchanged behaviour, which is what keeps this
|
||||
// reversible.
|
||||
fn api_persisted(id: String) -> Bool {
|
||||
if str_eq(id, "") { return false }
|
||||
let node: String = engram_get_node_json(id)
|
||||
// engram_get_node_json returns "{}" (empty object) when node is not found — not "" or "null".
|
||||
// Check all three to guard against any runtime variation.
|
||||
return !str_eq(node, "") && !str_eq(node, "null") && !str_eq(node, "{}")
|
||||
return wt_commit(id)
|
||||
}
|
||||
|
||||
// api_not_persisted — standard error for a write that did not read back.
|
||||
@@ -342,7 +351,7 @@ fn handle_api_remember(body: String) -> String {
|
||||
let inner: String = str_slice(base_tags, 1, str_len(base_tags) - 1)
|
||||
"[" + inner + ",\"project:" + project + "\"]"
|
||||
}
|
||||
let id: String = engram_node_full(content, "Memory", "memory:remembered",
|
||||
let id: String = wt_node(content, "Memory", "memory:remembered",
|
||||
sal, sal, el_from_float(0.9),
|
||||
"Episodic", final_tags)
|
||||
if !api_persisted(id) { return api_not_persisted(id) }
|
||||
@@ -369,7 +378,7 @@ fn handle_api_node_create(body: String) -> String {
|
||||
if str_eq(importance, "low") { 0.25 } else { 0.5 }
|
||||
}
|
||||
}
|
||||
let id: String = engram_node_full(content, node_type, label,
|
||||
let id: String = wt_node(content, node_type, label,
|
||||
sal, sal, el_from_float(0.9),
|
||||
tier, tags)
|
||||
if !api_persisted(id) { return api_not_persisted(id) }
|
||||
@@ -422,11 +431,11 @@ fn handle_api_node_update(body: String) -> String {
|
||||
}
|
||||
let body_tags: String = json_get(body, "tags")
|
||||
let tags: String = if str_eq(body_tags, "") { "[\"" + node_type + "\"]" } else { body_tags }
|
||||
let new_id: String = engram_node_full(content, node_type, label,
|
||||
let new_id: String = wt_node(content, node_type, label,
|
||||
el_from_float(0.5), el_from_float(0.5), el_from_float(0.8),
|
||||
tier, tags)
|
||||
if !api_persisted(new_id) { return api_not_persisted(new_id) }
|
||||
engram_connect(new_id, id, el_from_float(0.9), "supersedes")
|
||||
wt_edge(new_id, id, el_from_float(0.9), "supersedes")
|
||||
return "{\"id\":\"" + new_id + "\",\"supersedes\":\"" + id + "\",\"ok\":true}"
|
||||
}
|
||||
|
||||
@@ -498,7 +507,7 @@ fn handle_api_capture_knowledge(body: String) -> String {
|
||||
let full: String = if str_eq(title, "") { content } else { title + ": " + content }
|
||||
let lbl: String = str_slice(title, 0, 80)
|
||||
let tags: String = "[\"Knowledge\",\"captured\"]"
|
||||
let id: String = engram_node_full(full, "Knowledge", lbl,
|
||||
let id: String = wt_node(full, "Knowledge", lbl,
|
||||
el_from_float(0.85), el_from_float(0.8), el_from_float(0.9),
|
||||
"Episodic", tags)
|
||||
if !api_persisted(id) { return api_not_persisted(id) }
|
||||
@@ -513,12 +522,12 @@ fn handle_api_evolve_knowledge(body: String) -> String {
|
||||
if !str_eq(prior_id, "") && is_protected_node(prior_id) { return api_err_protected(prior_id) }
|
||||
let tags: String = "[\"Knowledge\",\"evolved\"]"
|
||||
// Empty label → engram_node_full derives content[:60] (LABEL FIX 2026-07-23).
|
||||
let new_id: String = engram_node_full(content, "Knowledge", "",
|
||||
let new_id: String = wt_node(content, "Knowledge", "",
|
||||
el_from_float(0.75), el_from_float(0.75), el_from_float(0.9),
|
||||
"Episodic", tags)
|
||||
if !api_persisted(new_id) { return api_not_persisted(new_id) }
|
||||
if !str_eq(prior_id, "") {
|
||||
engram_connect(new_id, prior_id, el_from_float(0.9), "supersedes")
|
||||
wt_edge(new_id, prior_id, el_from_float(0.9), "supersedes")
|
||||
}
|
||||
return "{\"id\":\"" + new_id + "\",\"supersedes\":\"" + prior_id + "\",\"ok\":true}"
|
||||
}
|
||||
@@ -535,11 +544,11 @@ fn handle_api_promote_knowledge(body: String) -> String {
|
||||
"[\"Knowledge\",\"tier:canonical\",\"disposition:stable\"]"
|
||||
} else { tags_raw }
|
||||
// Empty label → engram_node_full derives content[:60] (LABEL FIX 2026-07-23).
|
||||
let new_id: String = engram_node_full(content, "Knowledge", "",
|
||||
let new_id: String = wt_node(content, "Knowledge", "",
|
||||
el_from_float(0.9), el_from_float(0.9), el_from_float(1.0),
|
||||
"Canonical", tags)
|
||||
if !api_persisted(new_id) { return api_not_persisted(new_id) }
|
||||
engram_connect(new_id, prior_id, el_from_float(0.95), "supersedes")
|
||||
wt_edge(new_id, prior_id, el_from_float(0.95), "supersedes")
|
||||
return "{\"ok\":true,\"new_id\":\"" + new_id + "\",\"supersedes\":\"" + prior_id + "\"}"
|
||||
}
|
||||
|
||||
@@ -562,7 +571,7 @@ fn handle_api_define_process(body: String) -> String {
|
||||
if str_eq(content, "") { return api_err("content is required") }
|
||||
let label: String = if str_eq(name, "") { "process:unnamed" } else { "process:" + name }
|
||||
let tags: String = "[\"Process\"]"
|
||||
let id: String = engram_node_full(content, "Process", label,
|
||||
let id: String = wt_node(content, "Process", label,
|
||||
el_from_float(0.8), el_from_float(0.8), el_from_float(0.9),
|
||||
"Canonical", tags)
|
||||
if !api_persisted(id) { return api_not_persisted(id) }
|
||||
@@ -647,7 +656,7 @@ fn handle_api_tune_config(body: String) -> String {
|
||||
if str_eq(key, "") { return api_err("key is required") }
|
||||
let content: String = "config:" + key + "=" + value
|
||||
let tags: String = "[\"ConfigEntry\",\"config\"]"
|
||||
let id: String = engram_node_full(content, "ConfigEntry", key,
|
||||
let id: String = wt_node(content, "ConfigEntry", key,
|
||||
el_from_float(0.85), el_from_float(0.85), el_from_float(0.9),
|
||||
"Canonical", tags)
|
||||
if !api_persisted(id) { return api_not_persisted(id) }
|
||||
@@ -694,7 +703,7 @@ fn handle_api_link_entities(body: String) -> String {
|
||||
if is_protected_node(to_id) { return api_err_protected(to_id) }
|
||||
let relation: String = json_get(body, "relation")
|
||||
let eff_relation: String = if str_eq(relation, "") { "associates" } else { relation }
|
||||
engram_connect(from_id, to_id, el_from_float(0.5), eff_relation)
|
||||
wt_edge(from_id, to_id, el_from_float(0.5), eff_relation)
|
||||
return "{\"ok\":true,\"from_id\":\"" + from_id + "\",\"to_id\":\"" + to_id + "\",\"relation\":\"" + eff_relation + "\"}"
|
||||
}
|
||||
|
||||
@@ -727,11 +736,11 @@ fn handle_api_evolve_memory(body: String) -> String {
|
||||
}
|
||||
}
|
||||
let tags: String = "[\"Memory\",\"evolved\"]"
|
||||
let new_id: String = engram_node_full(content, "Memory", "memory:evolved",
|
||||
let new_id: String = wt_node(content, "Memory", "memory:evolved",
|
||||
sal, sal, el_from_float(0.9),
|
||||
"Episodic", tags)
|
||||
if !str_eq(prior_id, "") && !str_eq(new_id, "") {
|
||||
engram_connect(new_id, prior_id, el_from_float(0.9), "supersedes")
|
||||
wt_edge(new_id, prior_id, el_from_float(0.9), "supersedes")
|
||||
}
|
||||
return "{\"id\":\"" + new_id + "\",\"supersedes\":\"" + prior_id + "\",\"ok\":true}"
|
||||
}
|
||||
@@ -789,11 +798,11 @@ fn handle_api_cultivate(body: String) -> String {
|
||||
let content: String = json_get(body, "content")
|
||||
if str_eq(content, "") { return api_err("content is required") }
|
||||
let tags: String = "[\"Knowledge\",\"evolved\",\"cultivated\"]"
|
||||
let new_id: String = engram_node_full(content, "Knowledge", "knowledge:cultivated",
|
||||
let new_id: String = wt_node(content, "Knowledge", "knowledge:cultivated",
|
||||
el_from_float(0.75), el_from_float(0.75), el_from_float(0.9),
|
||||
"Episodic", tags)
|
||||
if !str_eq(prior_id, "") && !str_eq(new_id, "") {
|
||||
engram_connect(new_id, prior_id, el_from_float(0.9), "supersedes")
|
||||
wt_edge(new_id, prior_id, el_from_float(0.9), "supersedes")
|
||||
}
|
||||
return "{\"id\":\"" + new_id + "\",\"supersedes\":\"" + prior_id + "\",\"ok\":true,\"cultivated\":true}"
|
||||
}
|
||||
@@ -809,11 +818,11 @@ fn handle_api_cultivate(body: String) -> String {
|
||||
}
|
||||
}
|
||||
let tags: String = "[\"Memory\",\"evolved\",\"cultivated\"]"
|
||||
let new_id: String = engram_node_full(content, "Memory", "memory:cultivated",
|
||||
let new_id: String = wt_node(content, "Memory", "memory:cultivated",
|
||||
sal, sal, el_from_float(0.9),
|
||||
"Episodic", tags)
|
||||
if !str_eq(prior_id, "") && !str_eq(new_id, "") {
|
||||
engram_connect(new_id, prior_id, el_from_float(0.9), "supersedes")
|
||||
wt_edge(new_id, prior_id, el_from_float(0.9), "supersedes")
|
||||
}
|
||||
return "{\"id\":\"" + new_id + "\",\"supersedes\":\"" + prior_id + "\",\"ok\":true,\"cultivated\":true}"
|
||||
}
|
||||
@@ -833,7 +842,7 @@ fn handle_api_cultivate(body: String) -> String {
|
||||
if str_eq(to_id, "") { return api_err("to_id is required") }
|
||||
let relation: String = json_get(body, "relation")
|
||||
let eff_relation: String = if str_eq(relation, "") { "associates" } else { relation }
|
||||
engram_connect(from_id, to_id, el_from_float(0.5), eff_relation)
|
||||
wt_edge(from_id, to_id, el_from_float(0.5), eff_relation)
|
||||
return "{\"ok\":true,\"from_id\":\"" + from_id + "\",\"to_id\":\"" + to_id + "\",\"relation\":\"" + eff_relation + "\",\"cultivated\":true}"
|
||||
}
|
||||
|
||||
@@ -868,7 +877,7 @@ fn handle_api_consolidate(body: String) -> String {
|
||||
if !str_eq(summary, "") {
|
||||
let safe_summary: String = str_replace(summary, "\"", "'")
|
||||
let tags: String = "[\"SessionSummary\",\"consolidate\"]"
|
||||
let summary_id: String = engram_node_full(
|
||||
let summary_id: String = wt_node(
|
||||
"[session-summary] " + safe_summary,
|
||||
"SessionSummary", "session:summary",
|
||||
el_from_float(0.7), el_from_float(0.7), el_from_float(0.9),
|
||||
|
||||
+426
@@ -0,0 +1,426 @@
|
||||
// persist.el — the soul→engram WRITE-THROUGH boundary (neuron#117).
|
||||
//
|
||||
// WHY THIS FILE EXISTS
|
||||
// soul.el:571-573 states the ownership rule: "when ENGRAM_URL is set the HTTP
|
||||
// Engram owns persistence — the soul must NEVER write to the local snapshot
|
||||
// (not the persistence owner)." The soul obeys the NEGATIVE half. The POSITIVE
|
||||
// half — how a write made inside the soul actually REACHES the owner — was
|
||||
// never built. Sync is pull-only (awareness.el `/api/sync` -> engram_load_merge),
|
||||
// so every node the soul creates lives in its process RAM and is shed on
|
||||
// restart. Measured live 2026-08-07: soul node_count=102184, engram
|
||||
// node_count=79197 — ~23k nodes existing nowhere but RAM.
|
||||
//
|
||||
// SCOPE NOTE ON THE PATENT (corrects an earlier internal reading)
|
||||
// Engram provisional claims 15-18 describe a delta-sync protocol "with peer
|
||||
// Engram instances"; claim 17's pull-then-push sequence is PEER-ENGRAM to
|
||||
// PEER-ENGRAM. The soul is NOT a peer Engram — it is a CALLER of the database
|
||||
// system API (cf. claim 27, "invoked explicitly by a caller of the database
|
||||
// system API"). So claim 17 does not specify a soul↔engram contract and is not
|
||||
// cited as authority here. This design is derived from the ownership rule
|
||||
// alone: the owner owns the writes, therefore the soul must HAND writes to the
|
||||
// owner and must never write the owner's file itself.
|
||||
//
|
||||
// THE MECHANISM, AND WHY NOT `POST /api/nodes`
|
||||
// The obvious route is the one the persona/boot-counter write-backs already
|
||||
// use, POST /api/nodes. It is the wrong instrument here, verified against the
|
||||
// live engram binary in a sandbox:
|
||||
// - it mints a NEW server-side id (engram_node), so the soul's id and the
|
||||
// owner's id diverge — the next /api/sync pull re-imports the node as a
|
||||
// DUPLICATE, and any edge referencing the soul's id never resolves;
|
||||
// - it accepts only {content, node_type, salience} and drops label, tier,
|
||||
// tags, importance, confidence, metadata. A probe posted with tier
|
||||
// "Canonical" came back tier "Working", importance 0.5.
|
||||
// POST /api/load-merge (Will's own route, el `dc39a61`) is the right one:
|
||||
// - engram_load_merge PRESERVES the id and every field;
|
||||
// - it dedups nodes by id and edges by (from_id,to_id,relation), so a
|
||||
// re-submitted delta is a NO-OP — retry safety is free, and it is the same
|
||||
// local-wins semantics the graph already uses;
|
||||
// - it calls persist_canonical() — THE OWNER writes its own canonical file.
|
||||
// The soul never touches it. The ownership rule is honoured in its
|
||||
// strongest form rather than worked around;
|
||||
// - it returns real counts {ok, nodes_added, edges_added, node_count},
|
||||
// so a receipt can be a MEASUREMENT instead of a fixed success shape.
|
||||
//
|
||||
// SPOOL-AND-DRAIN, AND WHY IT IS NOT JUST A DIRECT POST
|
||||
// Measured in a sandbox against a 79k-node / 176MB graph (live scale): one
|
||||
// load-merge costs ~0.38s, essentially all of it the owner's persist_canonical.
|
||||
// A chat turn writes 5-7 nodes; pushing each separately would add ~2.7s per
|
||||
// turn. So writes are STAGED and pushed in one coalesced batch.
|
||||
// The staging buffer is the FILESYSTEM, not process state, because the soul
|
||||
// serves each HTTP connection on its own pthread (el_runtime http_serve_async)
|
||||
// and a shared in-process buffer would lose entries to a read-modify-write
|
||||
// race — silently, which is the one failure mode this file exists to end.
|
||||
// One file per write, named with uuid_v4, is race-free by construction and
|
||||
// buys a property a memory buffer cannot: writes that could not be pushed
|
||||
// SURVIVE A SOUL CRASH and are drained on the next boot.
|
||||
//
|
||||
// WHAT IS DELIBERATELY NOT PUSHED
|
||||
// - InternalStateEvent / heartbeat telemetry. Will's own carve-out, stated in
|
||||
// engram server.el 8f8ccc9: "48h-pruned, loss-tolerant, ~2/min; snapshotting
|
||||
// 28MB per heartbeat is waste."
|
||||
// NOTE (ours, flagged for Will): we do NOT additionally exclude Working-tier
|
||||
// nodes. That exclusion exists in `fb0bb55` to stop the boot counter leaking
|
||||
// through the /api/sync PULL; it is about sync backflow, not durability.
|
||||
// Applying it here would exclude mem_store — which writes tier "Working" — and
|
||||
// mem_store is the single most important durable write path in the soul. Boot
|
||||
// seeding reads the canonical file wholesale, so a pushed Working-tier node
|
||||
// does survive restart. This is the one classification call this file makes
|
||||
// that Will has not ruled on.
|
||||
//
|
||||
// WHAT THIS BOUNDARY CANNOT EXPRESS (by construction, not by omission)
|
||||
// - engram_strengthen (salience/activation drift): load-merge SKIPS ids that
|
||||
// already exist, so it cannot update an existing node. There is no owner-side
|
||||
// update/upsert route. Not pushable through any current route; left as a
|
||||
// follow-up that needs a change in the engram repo.
|
||||
// - engram_forget (hard delete): load-merge is additive and has no delete verb.
|
||||
// Propagating deletes would mean DELETE /api/nodes/<id>, a HARD delete at the
|
||||
// owner — which scripts/verify-soul-contract.sh section B explicitly fails the
|
||||
// build for ("to delete is to supersede/tombstone, never hard-remove"). Local
|
||||
// deletes therefore stay local; the TOMBSTONE NODE and its "tombstones" edge
|
||||
// (mem_tombstone) are pushed, and that is the sanctioned representation of a
|
||||
// deletion in this graph.
|
||||
|
||||
// ── Configuration ─────────────────────────────────────────────────────────────
|
||||
|
||||
// wt_engram_url — same resolution order as ise_post: env, then the state key
|
||||
// stashed at boot. NO hardcoded localhost fallback: unlike telemetry, inventing
|
||||
// a destination for durable data would risk pushing a user's memories at whatever
|
||||
// happens to be listening on 8742. Empty means "no HTTP owner" -> file mode.
|
||||
fn wt_engram_url() -> String {
|
||||
let env_url: String = env("ENGRAM_URL")
|
||||
if !str_eq(env_url, "") { return env_url }
|
||||
return state_get("soul_engram_url")
|
||||
}
|
||||
|
||||
fn wt_api_key() -> String {
|
||||
let env_key: String = env("ENGRAM_API_KEY")
|
||||
if !str_eq(env_key, "") { return env_key }
|
||||
return state_get("soul_engram_api_key")
|
||||
}
|
||||
|
||||
// wt_enabled — true only in HTTP-engram mode. In file mode the soul IS the
|
||||
// persistence owner and every path below is a no-op, so this whole feature is
|
||||
// inert for genesis/local deployments. That is also what makes it reversible.
|
||||
fn wt_enabled() -> Bool {
|
||||
return !str_eq(wt_engram_url(), "")
|
||||
}
|
||||
|
||||
// wt_spool_dir — where staged deltas live. MUST be readable by the engram
|
||||
// process: /api/load-merge takes a PATH and the owner opens it itself. Both
|
||||
// processes are same-host by construction (dev-stack LaunchAgents; the GKE
|
||||
// image starts engram and soul in one container per entrypoint.sh).
|
||||
fn wt_spool_dir() -> String {
|
||||
let raw: String = env("SOUL_OUTBOX_DIR")
|
||||
let dir: String = if str_eq(raw, "") { env("HOME") + "/.neuron/soul-outbox" } else { raw }
|
||||
fs_mkdir(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// wt_esc — minimal JSON string escape. Deliberately local rather than reusing
|
||||
// chat.el's json_safe: persist.el is imported BY memory.el, which is imported by
|
||||
// chat.el, so depending on chat.el here would be an import cycle.
|
||||
fn wt_esc(s: String) -> String {
|
||||
let s1: String = str_replace(s, "\\", "\\\\")
|
||||
let s2: String = str_replace(s1, "\"", "\\\"")
|
||||
let s3: String = str_replace(s2, "\n", "\\n")
|
||||
let s4: String = str_replace(s3, "\r", "\\r")
|
||||
let s5: String = str_replace(s4, "\t", "\\t")
|
||||
return s5
|
||||
}
|
||||
|
||||
// wt_durable_class — Will's telemetry carve-out, by node_type. See header.
|
||||
fn wt_durable_class(node_type: String) -> Bool {
|
||||
if str_eq(node_type, "InternalStateEvent") { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
// wt_inner — strip the surrounding brackets off a JSON array so several arrays
|
||||
// can be concatenated into one. Returns "" for "[]" / "" / anything too short.
|
||||
fn wt_inner(arr: String) -> String {
|
||||
let n: Int = str_len(arr)
|
||||
if n < 3 { return "" }
|
||||
if !str_starts_with(arr, "[") { return "" }
|
||||
return str_slice(arr, 1, n - 1)
|
||||
}
|
||||
|
||||
// wt_read — fs_read, plus a MANDATORY reset of the runtime's binary-length hint.
|
||||
//
|
||||
// THIS IS NOT OPTIONAL AND MUST NOT BE "SIMPLIFIED" BACK TO A BARE fs_read.
|
||||
// The pinned runtime (vendor/el-runtime/v1.0.0-20260501) keeps a thread-local
|
||||
// `_tl_fs_read_len` that fs_read SETS to the file's byte count (so binary files
|
||||
// can be served with a correct Content-Length) and that http_send_response
|
||||
// CONSUMES as the Content-Length of the next reply. Nothing else clears it
|
||||
// except json_get_raw. So any fs_read during request handling that is not
|
||||
// followed by a json_get_raw makes the NEXT HTTP response advertise the FILE's
|
||||
// length instead of the body's — and the runtime then sends that many bytes,
|
||||
// appending whatever adjacent heap memory follows the reply.
|
||||
//
|
||||
// Caught here, measured: a /api/neuron/memory reply that should be 86 bytes went
|
||||
// out as 497, with 411 bytes of this module's own spool paths and log strings
|
||||
// trailing the JSON. The drain reads spool files mid-request, so this boundary
|
||||
// is exactly where the landmine gets stepped on.
|
||||
//
|
||||
// Upstream el fixed the class in `43636ae` ("pair fs_read length hint with its
|
||||
// buffer"); that runtime is NOT the one vendored here, and re-pinning the
|
||||
// runtime is deliberately out of scope for this change. Clearing the hint at
|
||||
// our own boundary fixes our exposure without touching the pinned C.
|
||||
// json_get_raw is used as the reset because it is the only builtin in this
|
||||
// runtime that zeroes the hint, and it does so before any early return.
|
||||
fn wt_clear_binlen() -> Void {
|
||||
let discard: String = json_get_raw("{}", "_wt_reset")
|
||||
}
|
||||
|
||||
fn wt_read(path: String) -> String {
|
||||
let data: String = fs_read(path)
|
||||
wt_clear_binlen()
|
||||
return data
|
||||
}
|
||||
|
||||
// wt_sweep — best-effort removal of the zero-byte husks left by truncation.
|
||||
// The runtime exposes no unlink builtin, so a drained delta is emptied rather
|
||||
// than deleted; this reclaims the directory entries.
|
||||
//
|
||||
// `-empty` is the safety property, not an optimisation: the command is
|
||||
// STRUCTURALLY INCAPABLE of removing a delta that still has content, so it can
|
||||
// never destroy a pending write even if it runs concurrently with a stage.
|
||||
// Only the directory path is interpolated (never a filename), and it is quoted.
|
||||
// The exit code is ignored — an un-swept husk costs one directory entry.
|
||||
fn wt_sweep(dir: String) -> Void {
|
||||
if str_eq(dir, "") { return }
|
||||
if str_contains(dir, "'") { return }
|
||||
exec_command("find '" + dir + "' -maxdepth 1 -name 'wt*.json' -empty -delete 2>/dev/null")
|
||||
}
|
||||
|
||||
// ── Staging ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// wt_stage — write ONE delta file. uuid_v4 in the name makes concurrent stagers
|
||||
// collision-free without any lock. Returns true if the delta is on disk.
|
||||
fn wt_stage(nodes_json: String, edges_json: String) -> Bool {
|
||||
let dir: String = wt_spool_dir()
|
||||
if str_eq(dir, "") { return false }
|
||||
let payload: String = "{\"nodes\":" + nodes_json + ",\"edges\":" + edges_json + "}"
|
||||
let path: String = dir + "/wt-" + uuid_v4() + ".json"
|
||||
fs_write(path, payload)
|
||||
// Read-back-verify the stage itself. A stage that did not land is a write we
|
||||
// would otherwise believe was queued — exactly the hallucinated-save class.
|
||||
if str_eq(wt_read(path), "") {
|
||||
println("[persist] wt_stage: FAILED to write spool file " + path + " — delta not queued")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ── The write boundary ────────────────────────────────────────────────────────
|
||||
|
||||
// wt_node — create a node locally AND queue it for the persistence owner.
|
||||
// Same signature and same return contract as engram_node_full ("" on failure),
|
||||
// so converting a call site is a rename and nothing else.
|
||||
fn wt_node(content: String, node_type: String, label: String,
|
||||
salience: Float, importance: Float, confidence: Float,
|
||||
tier: String, tags: String) -> String {
|
||||
let id: String = engram_node_full(content, node_type, label,
|
||||
salience, importance, confidence,
|
||||
tier, tags)
|
||||
if str_eq(id, "") { return "" }
|
||||
// engram_get_node_json emits the SAME record shape engram_save writes (minus
|
||||
// the embedding vector, which the owner backfills lazily), so the read-back
|
||||
// doubles as the delta payload — no second serialization to drift.
|
||||
let rec: String = engram_get_node_json(id)
|
||||
if str_eq(rec, "") || str_eq(rec, "{}") {
|
||||
println("[persist] wt_node: local write did not read back, id=" + id + " label=" + label)
|
||||
return ""
|
||||
}
|
||||
if wt_enabled() && wt_durable_class(node_type) {
|
||||
wt_stage("[" + rec + "]", "[]")
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// wt_edge — create an edge locally AND queue it. Mirrors engram_connect.
|
||||
//
|
||||
// The edge id is freshly generated rather than read back: the runtime exposes no
|
||||
// "id of the edge I just created" accessor, and the owner dedups edges by
|
||||
// (from_id,to_id,relation), never by id — so the id is not load-bearing. The
|
||||
// consequence, stated plainly: the soul's copy and the owner's copy of the same
|
||||
// edge carry different edge ids. Nothing in either codebase looks an edge up by
|
||||
// id (neighbors traversal scans from_id/to_id), so this is cosmetic.
|
||||
fn wt_edge(from_id: String, to_id: String, weight: Float, relation: String) -> Void {
|
||||
engram_connect(from_id, to_id, weight, relation)
|
||||
if !wt_enabled() { return }
|
||||
if str_eq(from_id, "") || str_eq(to_id, "") { return }
|
||||
let ts: Int = time_now()
|
||||
let rec: String = "{\"id\":\"" + uuid_v4() + "\""
|
||||
+ ",\"from_id\":\"" + wt_esc(from_id) + "\""
|
||||
+ ",\"to_id\":\"" + wt_esc(to_id) + "\""
|
||||
+ ",\"relation\":\"" + wt_esc(relation) + "\""
|
||||
+ ",\"metadata\":\"{}\""
|
||||
+ ",\"weight\":" + float_to_str(weight)
|
||||
+ ",\"confidence\":1"
|
||||
+ ",\"created_at\":" + int_to_str(ts)
|
||||
+ ",\"updated_at\":" + int_to_str(ts)
|
||||
+ ",\"last_fired\":0,\"inhibitory\":0,\"layer_id\":1}"
|
||||
wt_stage("[]", "[" + rec + "]")
|
||||
}
|
||||
|
||||
// ── The drain ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// wt_drain — coalesce every staged delta into ONE load-merge against the owner.
|
||||
//
|
||||
// Returns: nodes_added on success (>= 0), 0 when there was nothing to do, and
|
||||
// -1 when the push FAILED. -1 is load-bearing: on failure the spool files are
|
||||
// left untouched, so nothing is lost and the next drain retries them. A caller
|
||||
// must never read a non-negative return as "my particular node is durable" —
|
||||
// use wt_durable(id) for that.
|
||||
//
|
||||
// Concurrency: several threads may drain at once. Each builds its own batch file
|
||||
// (uuid-named), and overlapping batches are harmless because load-merge dedups.
|
||||
// Files are truncated ONLY after a confirmed ok:true, so a lost race costs a
|
||||
// redundant push, never a dropped write.
|
||||
fn wt_drain() -> Int {
|
||||
if !wt_enabled() { return 0 }
|
||||
let dir: String = wt_spool_dir()
|
||||
if str_eq(dir, "") { return 0 }
|
||||
|
||||
// el_list_len/el_list_get, NOT json_stringify(fs_list(...)): fs_list builds
|
||||
// a native list via el_list_append, and json_stringify does not serialize
|
||||
// that type — it renders the raw pointer value. (Verified in isolation; the
|
||||
// same latent defect is live in studio.el's /api/tools/file/list route,
|
||||
// which returns e.g. {"entries":4386409744}. Noted, not fixed here.)
|
||||
let listing = fs_list(dir)
|
||||
let count: Int = el_list_len(listing)
|
||||
if count == 0 { return 0 }
|
||||
|
||||
let nodes_acc: String = ""
|
||||
let edges_acc: String = ""
|
||||
let drained: String = ""
|
||||
let found: Int = 0
|
||||
let i: Int = 0
|
||||
// No `continue` / `break`: elc lists them as keywords but not one line of
|
||||
// the shipped soul uses either, so they are unexercised on this build path.
|
||||
// Guard conditions are expressed as nested ifs instead, and every rebind is
|
||||
// at the loop-body top level where `let x = ...` is assignment (the idiom
|
||||
// memory.el's boot-counter loop relies on) — never inside a nested block,
|
||||
// where it would shadow instead.
|
||||
while i < count {
|
||||
let name: String = el_list_get(listing, i)
|
||||
// A delta is only usable when it ends with the closing "]}" that
|
||||
// wt_stage writes last. fs_write is not atomic, so a file being written
|
||||
// right now can be observed half-formed; requiring the terminator means
|
||||
// it is picked up whole on the next drain instead of merged as garbage.
|
||||
// An empty read means "already drained and truncated" — not an error.
|
||||
let p: String = if str_starts_with(name, "wt-") { dir + "/" + name } else { "" }
|
||||
let raw: String = if str_eq(p, "") { "" } else { wt_read(p) }
|
||||
let usable: Bool = !str_eq(raw, "") && str_ends_with(raw, "]}")
|
||||
let nj: String = if usable { wt_inner(json_get_raw(raw, "nodes")) } else { "" }
|
||||
let ej: String = if usable { wt_inner(json_get_raw(raw, "edges")) } else { "" }
|
||||
let nodes_acc = if str_eq(nj, "") { nodes_acc } else if str_eq(nodes_acc, "") { nj } else { nodes_acc + "," + nj }
|
||||
let edges_acc = if str_eq(ej, "") { edges_acc } else if str_eq(edges_acc, "") { ej } else { edges_acc + "," + ej }
|
||||
let drained = if !usable { drained } else if str_eq(drained, "") { p } else { drained + "\n" + p }
|
||||
let found = if usable { found + 1 } else { found }
|
||||
let i = i + 1
|
||||
}
|
||||
|
||||
if found == 0 { return 0 }
|
||||
|
||||
let combined: String = "{\"nodes\":[" + nodes_acc + "],\"edges\":[" + edges_acc + "]}"
|
||||
let batch: String = dir + "/wtb-" + uuid_v4() + ".json"
|
||||
fs_write(batch, combined)
|
||||
if str_eq(wt_read(batch), "") {
|
||||
println("[persist] wt_drain: could not write batch file " + batch + " — " + int_to_str(found) + " deltas stay queued")
|
||||
return -1
|
||||
}
|
||||
|
||||
let url: String = wt_engram_url()
|
||||
let key: String = wt_api_key()
|
||||
let body: String = "{\"path\":\"" + wt_esc(batch) + "\",\"_auth\":\"" + wt_esc(key) + "\"}"
|
||||
let resp: String = http_post_json(url + "/api/load-merge", body)
|
||||
|
||||
// The batch file is pure scratch — the retry is rebuilt from the SPOOL, not
|
||||
// from it. Truncate it unconditionally, before branching on the outcome, so
|
||||
// a persistently unreachable owner cannot accumulate one husk per attempt.
|
||||
fs_write(batch, "")
|
||||
|
||||
// Distinguish the two failures rather than collapsing them: "cannot reach
|
||||
// the owner" and "the owner refused this delta" need different human
|
||||
// responses, and a log line that says the wrong one costs a debugging hour.
|
||||
// curl surfaces transport errors as a JSON body, so an empty response is not
|
||||
// the only unreachable signal.
|
||||
// (str_contains rather than a strict parse on purpose — the engram's HTTP
|
||||
// responses have been observed carrying trailing bytes past the JSON.)
|
||||
let unreachable: Bool = str_eq(resp, "")
|
||||
|| str_contains(resp, "Couldn't connect")
|
||||
|| str_contains(resp, "Failed to connect")
|
||||
|| str_contains(resp, "Could not resolve")
|
||||
|| str_contains(resp, "timed out")
|
||||
if unreachable {
|
||||
wt_sweep(dir)
|
||||
println("[persist] wt_drain: owner UNREACHABLE at " + url + " — " + int_to_str(found)
|
||||
+ " deltas stay queued in " + dir + " (will retry): " + resp)
|
||||
return -1
|
||||
}
|
||||
if !str_contains(resp, "\"ok\":true") {
|
||||
wt_sweep(dir)
|
||||
println("[persist] wt_drain: owner REJECTED the delta — " + int_to_str(found)
|
||||
+ " stay queued in " + dir + ": " + resp)
|
||||
return -1
|
||||
}
|
||||
|
||||
let added: Int = json_get_int(resp, "nodes_added")
|
||||
let added_e: Int = json_get_int(resp, "edges_added")
|
||||
|
||||
// Confirmed. Truncate the drained spool files so they are not re-pushed.
|
||||
// Truncation (not deletion) because the runtime exposes no unlink builtin;
|
||||
// an emptied file is inert to the loop above. The zero-byte husks are then
|
||||
// swept below.
|
||||
let paths = str_split(drained, "\n")
|
||||
let pn: Int = el_list_len(paths)
|
||||
let k: Int = 0
|
||||
while k < pn {
|
||||
let one: String = el_list_get(paths, k)
|
||||
if !str_eq(one, "") { fs_write(one, "") }
|
||||
let k = k + 1
|
||||
}
|
||||
wt_sweep(dir)
|
||||
|
||||
println("[persist] wt_drain: pushed " + int_to_str(found) + " deltas -> owner added "
|
||||
+ int_to_str(added) + " nodes, " + int_to_str(added_e) + " edges")
|
||||
return added
|
||||
}
|
||||
|
||||
// wt_durable — is this id present AT THE OWNER? The only honest answer to
|
||||
// "did my write persist" in HTTP mode.
|
||||
//
|
||||
// In file mode the soul IS the owner, so the local read-back is the owner-side
|
||||
// read-back and this collapses to the pre-existing check.
|
||||
//
|
||||
// nodes_added from wt_drain is NOT a substitute: a concurrent drain may have
|
||||
// already pushed this node, making our own added count 0 while the node is
|
||||
// perfectly durable. Presence at the owner is the fact; counts are telemetry.
|
||||
fn wt_durable(id: String) -> Bool {
|
||||
if str_eq(id, "") { return false }
|
||||
if !wt_enabled() {
|
||||
let local: String = engram_get_node_json(id)
|
||||
return !str_eq(local, "") && !str_eq(local, "null") && !str_eq(local, "{}")
|
||||
}
|
||||
let url: String = wt_engram_url()
|
||||
let resp: String = http_get(url + "/api/nodes/" + id)
|
||||
if str_eq(resp, "") { return false }
|
||||
if str_eq(resp, "{}") { return false }
|
||||
return str_contains(resp, "\"id\"")
|
||||
}
|
||||
|
||||
// wt_commit — flush, then assert at the owner. The receipt callers should use.
|
||||
// Deliberately NOT a fixed success shape: it can and does return false while the
|
||||
// local write is perfectly fine in RAM, which is the true state of affairs when
|
||||
// the owner is unreachable.
|
||||
fn wt_commit(id: String) -> Bool {
|
||||
if str_eq(id, "") { return false }
|
||||
if !wt_enabled() {
|
||||
let local: String = engram_get_node_json(id)
|
||||
return !str_eq(local, "") && !str_eq(local, "null") && !str_eq(local, "{}")
|
||||
}
|
||||
let pushed: Int = wt_drain()
|
||||
return wt_durable(id)
|
||||
}
|
||||
@@ -186,7 +186,7 @@ fn route_imprint_contextual(body: String) -> String {
|
||||
return "{\"ok\":false,\"error\":\"empty body\"}"
|
||||
}
|
||||
let tags: String = "[\"imprint\",\"contextual\"]"
|
||||
let id: String = engram_node_full(
|
||||
let id: String = wt_node(
|
||||
body,
|
||||
"Entity",
|
||||
"imprint:contextual",
|
||||
@@ -208,7 +208,7 @@ fn route_imprint_user(body: String) -> String {
|
||||
return "{\"ok\":false,\"error\":\"empty body\"}"
|
||||
}
|
||||
let tags: String = "[\"imprint\",\"user\"]"
|
||||
let id: String = engram_node_full(
|
||||
let id: String = wt_node(
|
||||
body,
|
||||
"Entity",
|
||||
"imprint:user",
|
||||
@@ -239,7 +239,7 @@ fn route_synthesize(body: String) -> String {
|
||||
}
|
||||
let req: String = "synthesize " + parent_a + " " + parent_b
|
||||
let tags: String = "[\"soul-inbox-pending\",\"synthesis-request\"]"
|
||||
engram_node_full(
|
||||
wt_node(
|
||||
req,
|
||||
"Entity",
|
||||
"synthesis-request",
|
||||
@@ -280,7 +280,10 @@ fn handle_dharma_recv(body: String) -> String {
|
||||
// Non-agentic ("Tools: Off"): the full L1→L2→L3→L1 cycle, which now generates
|
||||
// at L3 instead of echoing. Envelope built outside the cycle — see
|
||||
// plain_chat_envelope.
|
||||
let screened_reply: String = layered_cycle(raw_msg)
|
||||
// FIX B/E1 (2026-08-05): the cycle is told which conversation it is in, and
|
||||
// whether this generation is conversation at all. Same two arguments at all
|
||||
// three dispatch sites.
|
||||
let screened_reply: String = layered_cycle(raw_msg, json_get(chat_body, "session_id"), is_utility_request(chat_body, json_get(chat_body, "session_id")))
|
||||
plain_chat_envelope(screened_reply, chat_default_model())
|
||||
}
|
||||
auto_persist(chat_body, reply)
|
||||
@@ -392,7 +395,28 @@ fn handle_connectors(method: String, clean: String, body: String) -> String {
|
||||
return "{\"ok\":false,\"error\":\"unknown connectors route\"}"
|
||||
}
|
||||
|
||||
// handle_request — the soul's HTTP entry point.
|
||||
//
|
||||
// NOTE ON THE NAME (neuron#117): the el runtime resolves this handler by NAME
|
||||
// via dlsym(RTLD_DEFAULT, "handle_request") — that is why the Linux build must
|
||||
// link -rdynamic. So the dispatcher body moved to route_dispatch and the name
|
||||
// `handle_request` stays put as a thin wrapper. Do not rename it back.
|
||||
//
|
||||
// The wrapper exists to give the write-through boundary a guaranteed flush
|
||||
// point. route_dispatch returns from ~60 places; a per-branch flush would be
|
||||
// forgotten on the 61st. Draining here means EVERY request that staged a write
|
||||
// pushes it before the connection closes, whatever route produced it, including
|
||||
// routes added later that know nothing about persistence.
|
||||
//
|
||||
// wt_drain is a no-op (no HTTP, no cost) when nothing is staged and when the
|
||||
// soul is not in HTTP-engram mode, so this is free on read traffic.
|
||||
fn handle_request(method: String, path: String, body: String) -> String {
|
||||
let resp: String = route_dispatch(method, path, body)
|
||||
let flushed: Int = wt_drain()
|
||||
return resp
|
||||
}
|
||||
|
||||
fn route_dispatch(method: String, path: String, body: String) -> String {
|
||||
let clean: String = strip_query(path)
|
||||
|
||||
// ACTIVITY STAMP (2026-07-30 self-review): every inbound HTTP request —
|
||||
@@ -429,10 +453,27 @@ fn handle_request(method: String, path: String, body: String) -> String {
|
||||
return engram_scan_nodes_json(9999, 0)
|
||||
}
|
||||
if str_eq(clean, "/api/graph/edges") {
|
||||
// TODO(reliability #8): engram_save races with awareness loop mem_save().
|
||||
// Both now use atomic write-to-temp+rename (el_runtime.c). Serialised
|
||||
// by engram_global_mu. Future: add engram_edges_json() builtin.
|
||||
let snap_path: String = env("HOME") + "/.neuron/engram/snapshot.json"
|
||||
// FIXED (neuron#117): this GET used to engram_save() straight over
|
||||
// ~/.neuron/engram/snapshot.json — a READ route, in a process that is
|
||||
// NOT the persistence owner, overwriting the owner's canonical file
|
||||
// on every call. It broke soul.el:571-573 ("the soul must NEVER write
|
||||
// to the local snapshot") and it is the same defect class Will removed
|
||||
// from the engram itself in el `dc39a61` ("stop read routes clobbering
|
||||
// canonical snapshot"), where route_scan_edges/route_sync were moved
|
||||
// to scratch paths for exactly this reason. It was also the race the
|
||||
// old TODO(reliability #8) admitted to.
|
||||
//
|
||||
// Export to a scratch path instead. Same response, no canonical write.
|
||||
// The soul's own snapshot writes are otherwise already gated behind
|
||||
// state key "soul_snapshot_path", which is set ONLY in the genesis
|
||||
// file-mode branch (soul.el: is_genesis && safe_to_seed, and
|
||||
// safe_to_seed is unconditionally false when ENGRAM_URL is set) — so
|
||||
// after this change the soul writes nothing at all in HTTP mode.
|
||||
// Future: add an engram_edges_json() builtin and drop the file round
|
||||
// trip entirely.
|
||||
let scratch_dir: String = env("TMPDIR")
|
||||
let scratch_base: String = if str_eq(scratch_dir, "") { "/tmp" } else { scratch_dir }
|
||||
let snap_path: String = scratch_base + "/soul-edges-export-" + state_get("soul_cgi_id") + ".json"
|
||||
engram_save(snap_path)
|
||||
let snap: String = fs_read(snap_path)
|
||||
let edges_raw: String = json_get_raw(snap, "edges")
|
||||
@@ -454,7 +495,9 @@ fn handle_request(method: String, path: String, body: String) -> String {
|
||||
handle_chat_agentic(body)
|
||||
} else {
|
||||
// Non-agentic ("Tools: Off") — same cycle and same envelope as POST.
|
||||
let screened_reply: String = layered_cycle(eff_msg)
|
||||
// FIX B/E1: same threading. A GET probe usually carries no session_id, which
|
||||
// resolves to the anonymous window — the documented behaviour for this door.
|
||||
let screened_reply: String = layered_cycle(eff_msg, json_get(body, "session_id"), is_utility_request(body, json_get(body, "session_id")))
|
||||
plain_chat_envelope(screened_reply, chat_default_model())
|
||||
}
|
||||
auto_persist(body, reply)
|
||||
@@ -621,7 +664,9 @@ fn handle_request(method: String, path: String, body: String) -> String {
|
||||
// Non-agentic ("Tools: Off") — the app's DEFAULT mode (AgentMode.NEVER).
|
||||
// Full L1→L2→L3→L1 cycle with real generation at L3; envelope built
|
||||
// outside the cycle so safety_validate always sees raw text.
|
||||
let screened_reply: String = layered_cycle(raw_msg)
|
||||
// FIX B/E1: same threading. This is the app's main plain-chat door, so this
|
||||
// is the site that ends the blank stare in practice.
|
||||
let screened_reply: String = layered_cycle(raw_msg, json_get(body, "session_id"), is_utility_request(body, json_get(body, "session_id")))
|
||||
plain_chat_envelope(screened_reply, chat_default_model())
|
||||
}
|
||||
auto_persist(body, reply)
|
||||
|
||||
@@ -204,7 +204,7 @@ fn safety_log_bell(level: String, reason: String, input_summary: String) -> Stri
|
||||
// Emit a fallback println so the bell event leaves at least a log trace even
|
||||
// when engram is degraded. This does not replace engram persistence -- it is a
|
||||
// last-resort audit trail when the primary write cannot be confirmed.
|
||||
let node_id: String = engram_node_full(
|
||||
let node_id: String = wt_node(
|
||||
content,
|
||||
"BellEvent",
|
||||
"bell:" + level,
|
||||
|
||||
Executable
+108
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env bash
|
||||
# run-el-test.sh — compile and run one El test program from tests/.
|
||||
#
|
||||
# WHY THIS EXISTS (2026-08-07, issue #129):
|
||||
# tests/ has held 14 test programs for months with no way to run them. CI does
|
||||
# not run them. The convention printed in their own headers
|
||||
# (`elc soul.el && ./soul --test tests/x.el`) refers to a --test flag the El
|
||||
# runtime does not implement. So the tests were documentation, not gates —
|
||||
# which is how a P0 safety regression shipped with a test directory present.
|
||||
#
|
||||
# THE RECIPE, AND WHY IT IS THIS SHAPE:
|
||||
# Same discovery as gen-soul-amalgam.sh — `elc --target=c` emits only an extern
|
||||
# prototype for any module that has a .elh header next to it, and inlines the
|
||||
# module's bodies when it does not. A test that imports ../chat.el therefore
|
||||
# compiles to a 18 KB unit full of unresolved externs unless the headers are
|
||||
# out of the way. So: copy the sources into a scratch tree, delete every .elh
|
||||
# on the import chain, and compile the test there.
|
||||
#
|
||||
# Scratch copy on purpose: the worktree is shared with other terminals and
|
||||
# deleting headers in place would be a shared-tree mutation with no owner.
|
||||
#
|
||||
# EXIT STATUS IS THE GATE: non-zero if the binary fails to build, crashes, or if
|
||||
# its output contains a FAIL line or reports a non-zero failed count. Do not
|
||||
# "improve" this into something that only checks the exit code of the test
|
||||
# binary — these El tests print failures and still exit 0.
|
||||
#
|
||||
# usage: scripts/run-el-test.sh tests/test_history_amplification.el
|
||||
set -euo pipefail
|
||||
|
||||
TEST_REL="${1:?usage: run-el-test.sh tests/<test>.el}"
|
||||
SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
TEST_NAME="$(basename "$TEST_REL" .el)"
|
||||
|
||||
ELC="${ELC:-$HOME/neuron-dev-stack/src/el/lang/dist/platform/elc}"
|
||||
[ -x "$ELC" ] || ELC="$HOME/el-sdk/elc"
|
||||
[ -x "$ELC" ] || { echo "[run-el-test] FAIL: no elc found (set ELC=)"; exit 1; }
|
||||
|
||||
RTC="${RTC:-$SRC/vendor/el-runtime/v1.0.0-20260501/el_runtime.c}"
|
||||
[ -f "$RTC" ] || RTC="$HOME/el-sdk/el_runtime.c"
|
||||
[ -f "$RTC" ] || { echo "[run-el-test] FAIL: no el_runtime.c found (set RTC=)"; exit 1; }
|
||||
RTDIR="$(dirname "$RTC")"
|
||||
|
||||
EL_REPO="${EL_REPO:-$HOME/Development/neuron-technologies/el}"
|
||||
SSL="${SSL_PREFIX:-/opt/homebrew/opt/openssl@3}"
|
||||
|
||||
GEN="$(mktemp -d "${TMPDIR:-/tmp}/el-test.XXXXXX")"
|
||||
trap 'rm -rf "$GEN"' EXIT
|
||||
|
||||
mkdir -p "$GEN/neuron/tests" "$GEN/foundation/el/elp/src"
|
||||
cp "$SRC"/*.el "$GEN/neuron/"
|
||||
cp "$SRC"/tests/*.el "$GEN/neuron/tests/" 2>/dev/null || true
|
||||
[ -d "$EL_REPO/elp/src" ] && cp "$EL_REPO"/elp/src/*.el "$GEN/foundation/el/elp/src/" 2>/dev/null || true
|
||||
# The whole recipe depends on there being no headers to short-circuit inlining.
|
||||
find "$GEN" -name '*.elh' -delete
|
||||
|
||||
echo "[run-el-test] compiling $TEST_REL"
|
||||
( cd "$GEN/neuron" && "$ELC" --target=c "tests/${TEST_NAME}.el" ) > "$GEN/${TEST_NAME}.c"
|
||||
|
||||
BODIES=$(grep -c '^el_val_t .*) {$' "$GEN/${TEST_NAME}.c" || true)
|
||||
echo "[run-el-test] $(wc -c < "$GEN/${TEST_NAME}.c" | tr -d ' ') bytes, ${BODIES} inlined function bodies"
|
||||
# A test that imports ../chat.el pulls in the bulk of the engine. A tiny body
|
||||
# count means an import was read from a header instead of inlined, and the test
|
||||
# would be exercising extern stubs rather than the real code.
|
||||
if [ "$BODIES" -lt 100 ]; then
|
||||
echo "[run-el-test] FAIL: only $BODIES inlined bodies — an import was not inlined"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cc -O2 -DHAVE_CURL \
|
||||
-I"$RTDIR" -I"$SSL/include" -L"$SSL/lib" \
|
||||
"$GEN/${TEST_NAME}.c" "$RTC" \
|
||||
-lssl -lcrypto -lcurl -lpthread -lm \
|
||||
-o "$GEN/${TEST_NAME}" 2> "$GEN/cc.log" || {
|
||||
echo "[run-el-test] FAIL: compile error"; tail -30 "$GEN/cc.log"; exit 1; }
|
||||
|
||||
# arm64 pointer-truncation guard (cc-brain.sh's rule): an implicit declaration of
|
||||
# a runtime symbol truncates its returned pointer to 32 bits.
|
||||
if grep -E 'implicit.*(engram_|el_)' "$GEN/cc.log"; then
|
||||
echo "[run-el-test] FAIL: implicit declarations of runtime symbols"; exit 1; fi
|
||||
|
||||
# Throwaway HOME so a test can never read or write the live engram at ~/.neuron.
|
||||
TEST_HOME="$GEN/home"
|
||||
mkdir -p "$TEST_HOME"
|
||||
|
||||
echo "[run-el-test] running $TEST_NAME"
|
||||
set +e
|
||||
HOME="$TEST_HOME" NEURON_HOME="$TEST_HOME/.neuron" "$GEN/${TEST_NAME}" 2>&1 | tee "$GEN/out.txt"
|
||||
RC=${PIPESTATUS[0]}
|
||||
set -e
|
||||
|
||||
if [ "$RC" -ne 0 ]; then
|
||||
echo "[run-el-test] FAIL: $TEST_NAME exited $RC (crash or abort)"
|
||||
exit 1
|
||||
fi
|
||||
if grep -q " FAIL:" "$GEN/out.txt"; then
|
||||
echo "[run-el-test] FAIL: $TEST_NAME reported failing assertions"
|
||||
exit 1
|
||||
fi
|
||||
if grep -qE '[1-9][0-9]* failed' "$GEN/out.txt"; then
|
||||
echo "[run-el-test] FAIL: $TEST_NAME reported a non-zero failed count"
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q "PASS:" "$GEN/out.txt"; then
|
||||
echo "[run-el-test] FAIL: $TEST_NAME produced no assertions at all"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[run-el-test] PASS: $TEST_NAME"
|
||||
+7
-7
@@ -87,7 +87,7 @@ fn session_create(body: String) -> String {
|
||||
let folder: String = json_get(body, "folder")
|
||||
let content: String = session_make_content(id, title, ts, ts, folder)
|
||||
let tags: String = "[\"session\",\"session:meta\",\"Conversation\"]"
|
||||
let node_id: String = engram_node_full(
|
||||
let node_id: String = wt_node(
|
||||
content, "Conversation", "session:meta",
|
||||
el_from_float(0.7), el_from_float(0.7), el_from_float(0.9),
|
||||
"Episodic", tags
|
||||
@@ -358,7 +358,7 @@ fn session_update_patch(session_id: String, body: String) -> String {
|
||||
let created_int: Int = str_to_int(old_created)
|
||||
let new_content: String = session_make_content(session_id, eff_title, created_int, ts, eff_folder)
|
||||
let tags: String = "[\"session\",\"session:meta\",\"Conversation\"]"
|
||||
let new_node_id: String = engram_node_full(
|
||||
let new_node_id: String = wt_node(
|
||||
new_content, "Conversation", "session:meta",
|
||||
el_from_float(0.7), el_from_float(0.7), el_from_float(0.9),
|
||||
"Episodic", tags
|
||||
@@ -456,7 +456,7 @@ fn session_hist_save(session_id: String, hist: String) -> Void {
|
||||
// TODO(reliability #7): delete-then-insert is not atomic — concurrent saves for the
|
||||
// same session can produce orphan history nodes. State is primary truth; engram fallback.
|
||||
let tags: String = "[\"session\",\"session-history\",\"Conversation\"]"
|
||||
let discard: String = engram_node_full(
|
||||
let discard: String = wt_node(
|
||||
hist, "Conversation", "session:messages:" + session_id,
|
||||
el_from_float(0.6), el_from_float(0.6), el_from_float(0.9),
|
||||
"Episodic", tags
|
||||
@@ -488,7 +488,7 @@ fn session_hist_save(session_id: String, hist: String) -> Void {
|
||||
+ " | ts:" + int_to_str(ts_now)
|
||||
let summary_tags: String = "[\"session-emotional-summary\",\"affective\",\"bell:" + eff_level + "\",\"BellEvent\"]"
|
||||
let summary_sal: String = if str_eq(eff_level, "hard") { el_from_float(0.95) } else { el_from_float(0.85) }
|
||||
let sum_discard: String = engram_node_full(
|
||||
let sum_discard: String = wt_node(
|
||||
summary_content,
|
||||
"BellEvent",
|
||||
"session:emotional-summary",
|
||||
@@ -529,7 +529,7 @@ fn session_hist_save(session_id: String, hist: String) -> Void {
|
||||
if !str_eq(ot_id, "") { engram_forget(ot_id) }
|
||||
let oti = oti + 1
|
||||
}
|
||||
let discard_topic: String = engram_node_full(
|
||||
let discard_topic: String = wt_node(
|
||||
topic_content, "Conversation", topic_label,
|
||||
el_from_float(0.7), el_from_float(0.7), el_from_float(0.9),
|
||||
"Episodic", topic_tags
|
||||
@@ -582,7 +582,7 @@ fn session_update_meta_timestamp(session_id: String) -> Void {
|
||||
let created_int: Int = str_to_int(old_created)
|
||||
let new_content: String = session_make_content(session_id, old_title, created_int, ts, old_folder)
|
||||
let tags: String = "[\"session\",\"session:meta\",\"Conversation\"]"
|
||||
let new_id: String = engram_node_full(
|
||||
let new_id: String = wt_node(
|
||||
new_content, "Conversation", "session:meta",
|
||||
el_from_float(0.7), el_from_float(0.7), el_from_float(0.9),
|
||||
"Episodic", tags
|
||||
@@ -629,7 +629,7 @@ fn session_auto_title(session_id: String, first_message: String) -> Void {
|
||||
let created_int: Int = str_to_int(old_created)
|
||||
let new_content: String = session_make_content(session_id, new_title, created_int, ts, old_folder)
|
||||
let tags: String = "[\"session\",\"session:meta\",\"Conversation\"]"
|
||||
let new_id: String = engram_node_full(
|
||||
let new_id: String = wt_node(
|
||||
new_content, "Conversation", "session:meta",
|
||||
el_from_float(0.7), el_from_float(0.7), el_from_float(0.9),
|
||||
"Episodic", tags
|
||||
|
||||
@@ -379,9 +379,23 @@ fn emit_session_start_event() -> Void {
|
||||
// layered_cycle — routes user-facing requests through the 4-layer consciousness stack.
|
||||
// L0 (core) → L1 (safety screen) → L2a (continuity + behavioral profiling) → L2b (mission alignment) → L3 (imprint) → L1 (safety validate)
|
||||
// Internal cognition (heartbeat, proactive, memory ops) bypasses layers — use one_cycle directly.
|
||||
fn layered_cycle(raw_input: String) -> String {
|
||||
let history: String = state_get("conv_history")
|
||||
let session_id: String = state_get("current_session_id")
|
||||
//
|
||||
// FIX B (2026-08-05) — the cycle now knows which conversation it is in.
|
||||
//
|
||||
// session_id: the caller's session, threaded from the route. Was previously read from the
|
||||
// state key "current_session_id", which is read HERE and written NOWHERE in the entire
|
||||
// source — verified across every .el file. So this value was unconditionally "", and every
|
||||
// downstream consumer of it silently fell back to a process-global bucket: conversation
|
||||
// history, and the steward's continuity tracking (TODO reliability #4, below, describes the
|
||||
// cross-session bleed this caused; threading the real id closes it). The plain path's blank
|
||||
// stare and the agentic path's scoped history were the same defect seen from two sides.
|
||||
//
|
||||
// utility: true when the generation is not part of the user's conversation — the app's
|
||||
// title and insight passes. Answered normally, never recorded. See is_utility_request.
|
||||
fn layered_cycle(raw_input: String, session_id: String, utility: Bool) -> String {
|
||||
// Safety-screen history amplification now reads the SAME window the turn will be
|
||||
// recorded into, so a session's own escalation pattern is what gets scored.
|
||||
let history: String = state_get(conv_hist_key(session_id))
|
||||
|
||||
// L1 in: safety screen
|
||||
let screen_result: String = safety_screen(raw_input, history)
|
||||
@@ -423,8 +437,10 @@ fn layered_cycle(raw_input: String) -> String {
|
||||
let cont_action: String = json_get(continuity, "action")
|
||||
|
||||
// Store continuity status so imprint can adjust its response register.
|
||||
// TODO(reliability #4): session_continuity is process-global; scope per session_id
|
||||
// when available to prevent cross-session bleed under concurrent layered_cycle calls.
|
||||
// TODO(reliability #4) CLOSED 2026-08-05: this line was already written to scope per
|
||||
// session — it just never received a session id, because the only source was a state key
|
||||
// nothing wrote. It is now threaded from the route, so named sessions genuinely get their
|
||||
// own continuity state and only anonymous callers share the global one.
|
||||
let cont_key: String = if str_eq(session_id, "") { "session_continuity" } else { "session_continuity:" + session_id }
|
||||
state_set(cont_key, cont_status)
|
||||
|
||||
@@ -499,7 +515,7 @@ fn layered_cycle(raw_input: String) -> String {
|
||||
// screen, the safe-mode guard, the hard-bell short-circuit and the L2 stewardship layers,
|
||||
// and strictly BEFORE the L1 output gate. A hard bell never reaches a model — the branch
|
||||
// above returns first. Tools are not offered on this turn; see layered_generate.
|
||||
let output: String = layered_generate(prompt, imprint_id)
|
||||
let output: String = layered_generate(prompt, imprint_id, session_id)
|
||||
|
||||
// L1 out: validate output before delivery. Still the terminal gate — nothing below this
|
||||
// line can change the string this function returns.
|
||||
@@ -509,7 +525,19 @@ fn layered_cycle(raw_input: String) -> String {
|
||||
// reachable on the non-bell path: both bell branches above return before this point, so
|
||||
// bell turns still never enter conversation history. Pure state side effect — it cannot
|
||||
// alter what is returned.
|
||||
conv_history_record(raw_input, validated)
|
||||
//
|
||||
// FIX A: the receipt is unconditional and always negative on this path, because on this
|
||||
// path it is structurally true — layered_generate offers no tools at all (build_system_prompt
|
||||
// chat mode + a request body with no "tools" key). Recording "no tools ran" is not padding:
|
||||
// it is the only thing that distinguishes "nothing ran" from "we forgot to write down what
|
||||
// ran", and that ambiguity is what made the model confess to a search it had performed.
|
||||
//
|
||||
// FIX E1: a utility generation is answered but not recorded. Guarded here rather than at
|
||||
// the route so every /api/chat dispatch site inherits it from one place.
|
||||
let receipt: String = tool_receipt("", "")
|
||||
if !utility {
|
||||
conv_history_record(session_id, raw_input, validated, receipt)
|
||||
}
|
||||
return validated
|
||||
}
|
||||
|
||||
@@ -629,6 +657,23 @@ if is_genesis && safe_to_seed {
|
||||
}
|
||||
}
|
||||
|
||||
// CRASH RECOVERY (neuron#117). Deltas the previous process staged but could not
|
||||
// hand to the owner are still on disk — the spool is a filesystem queue, not a
|
||||
// memory buffer, precisely so that a soul that died mid-flight does not take its
|
||||
// unpushed writes with it. Drain them before serving, so recovered memories are
|
||||
// durable and recallable from the owner from the first request onward.
|
||||
//
|
||||
// Safe on a clean boot: an empty spool means no HTTP call at all. Safe in file
|
||||
// mode: wt_drain returns immediately when ENGRAM_URL is unset.
|
||||
let wt_recovered: Int = wt_drain()
|
||||
if wt_recovered > 0 {
|
||||
println("[soul] write-through: recovered " + int_to_str(wt_recovered)
|
||||
+ " nodes from a previous process's spool -> persistence owner")
|
||||
}
|
||||
if wt_recovered < 0 {
|
||||
println("[soul] write-through: spool present but the persistence owner is unreachable — queued, will retry on heartbeat")
|
||||
}
|
||||
|
||||
println("[soul] serving on port " + int_to_str(port))
|
||||
http_serve_async(port, "handle_request")
|
||||
println("[soul] awareness loop starting")
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// auto-generated by elc --emit-header - do not edit
|
||||
extern fn init_soul_edges() -> Void
|
||||
extern fn ensure_self_canonical_bridge() -> Void
|
||||
extern fn aff_try_slot(slot_json: String, aff_7d_ts: Int, acc_key: String) -> Void
|
||||
extern fn load_identity_context() -> Void
|
||||
extern fn seed_persona_from_env() -> Void
|
||||
extern fn emit_session_start_event() -> Void
|
||||
extern fn layered_cycle(raw_input: String) -> String
|
||||
extern fn layered_cycle(raw_input: String, session_id: String, utility: Bool) -> String
|
||||
|
||||
+2
-2
@@ -11,7 +11,7 @@ import "memory.el"
|
||||
fn steward_log_event(kind: String, detail: String) -> Void {
|
||||
let content: String = "STEWARD:" + kind + " | " + detail
|
||||
let tags: String = "[\"stewardship\",\"steward:" + kind + "\"]"
|
||||
let discard: String = engram_node_full(
|
||||
let discard: String = wt_node(
|
||||
content,
|
||||
"StewardshipEvent",
|
||||
"steward:" + kind,
|
||||
@@ -221,7 +221,7 @@ fn steward_fingerprint_session(input: String, session_id: String) -> String {
|
||||
+ " formality=" + fs_str
|
||||
+ " time=" + tb_str
|
||||
let sample_tags: String = "[\"behavior\",\"BehaviorSample\",\"stewardship\"]"
|
||||
let discard: String = engram_node_full(
|
||||
let discard: String = wt_node(
|
||||
sample_content,
|
||||
"BehaviorSample",
|
||||
"behavior:" + session_id,
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
// ── test_history_amplification.el ─────────────────────────────────────────────
|
||||
//
|
||||
// REGRESSION TEST FOR ISSUE #129 (P0, SAFETY).
|
||||
//
|
||||
// What this guards: on the agentic path, the crisis score has two halves — the
|
||||
// message you just sent, and the distress that has accumulated across the
|
||||
// conversation. The second half is the whole reason the escalation logic exists:
|
||||
// someone whose distress builds over several turns never sends one message that
|
||||
// trips the bell on its own.
|
||||
//
|
||||
// The defect this test was written against (ff421d3, 2026-08-05 → fixed
|
||||
// 2026-08-07): conversation history moved to a per-session key via
|
||||
// conv_hist_key(session_id), but the agentic path's safety screen was left
|
||||
// reading the old anonymous "conv_history" bucket. The desktop app always sends
|
||||
// a session_id, so the screen received "" on every real conversation and the
|
||||
// escalation half always scored 0. Nothing failed. Nothing logged. The comment
|
||||
// above the defective line documented this same bug being fixed once before.
|
||||
//
|
||||
// THE INVARIANT UNDER TEST, stated so it survives future renames:
|
||||
// the window the safety screen READS must be the window conv_history_record
|
||||
// WRITES. Not "must be called conv_history" — must AGREE.
|
||||
//
|
||||
// This test is deliberately written to fail loudly on the pre-fix source. If it
|
||||
// ever passes on code where the screen reads a key nothing writes, it is broken.
|
||||
//
|
||||
// To run (macOS, from the worktree root):
|
||||
// scripts/run-el-test.sh tests/test_history_amplification.el
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import "../chat.el"
|
||||
import "../safety.el"
|
||||
import "../sessions.el"
|
||||
|
||||
// Program class. Without this an El program compiles as a 'utility', and a
|
||||
// utility may not call the self-formation primitives (llm_call_system,
|
||||
// llm_vision) that chat.el's agentic loop references — the unit fails to
|
||||
// compile with a capability violation even though the test never calls them.
|
||||
// Declaring 'cgi' matches how soul.el declares itself.
|
||||
//
|
||||
// The endpoints below are deliberately DEAD: this test must never reach a live
|
||||
// engram, and nothing it asserts depends on one. Port 9 is discard.
|
||||
cgi "neuron-test-history-amplification" {
|
||||
dharma_id: "ntn-test@http://127.0.0.1:9",
|
||||
principal: "test-harness",
|
||||
network: "dharma-testnet",
|
||||
engram: "http://127.0.0.1:9"
|
||||
}
|
||||
|
||||
// ── Counters ──────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// NOTE for anyone copying this harness: the idiom used by the older tests in
|
||||
// this directory — `let pass_count = pass_count + 1` inside an assert function —
|
||||
// does NOT mutate the module-level binding. It declares a new local that dies
|
||||
// with the call, so those suites all print "0 passed, 0 failed" no matter what
|
||||
// happened. Counters go through the state store here so the summary is real.
|
||||
|
||||
fn bump(counter: String) -> Void {
|
||||
let cur: String = state_get(counter)
|
||||
let n: Int = if str_eq(cur, "") { 0 } else { str_to_int(cur) }
|
||||
state_set(counter, int_to_str(n + 1))
|
||||
}
|
||||
|
||||
fn counter_of(counter: String) -> Int {
|
||||
let cur: String = state_get(counter)
|
||||
if str_eq(cur, "") { return 0 }
|
||||
return str_to_int(cur)
|
||||
}
|
||||
|
||||
fn assert_eq(label: String, got: String, expected: String) -> Void {
|
||||
if str_eq(got, expected) {
|
||||
bump("t_pass")
|
||||
println(" PASS: " + label)
|
||||
} else {
|
||||
bump("t_fail")
|
||||
println(" FAIL: " + label)
|
||||
println(" got: " + got)
|
||||
println(" expected: " + expected)
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_true(label: String, cond: Bool) -> Void {
|
||||
if cond {
|
||||
bump("t_pass")
|
||||
println(" PASS: " + label)
|
||||
} else {
|
||||
bump("t_fail")
|
||||
println(" FAIL: " + label)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fixtures ──────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// ESCALATION_MSG scores 50 on its own ("can't go on", safety_score_crisis s9).
|
||||
// 50 is >= soft (35) and < hard (70): a soft bell by itself, which is the point.
|
||||
// It is the message that is only a crisis IN CONTEXT.
|
||||
//
|
||||
// DISTRESS_TURN carries five history phrases:
|
||||
// hopeless 15 + worthless 15 + nobody cares 15 + can't take it anymore 20
|
||||
// + want to disappear 20 = 85; contributed at 1/3 weight = 28.
|
||||
// 50 + 28 = 78 >= 70 → hard_bell. That 28-point swing IS the escalation logic,
|
||||
// and it is exactly what the defect suppressed.
|
||||
|
||||
let TEST_SESSION: String = "sess-test-129-history-amplification"
|
||||
let ESCALATION_MSG: String = "honestly i can't go on like this"
|
||||
let DISTRESS_TURN: String = "i feel hopeless and worthless, nobody cares, i can't take it anymore, i want to disappear"
|
||||
|
||||
println("")
|
||||
println("test_history_amplification.el — issue #129 (agentic multi-turn crisis escalation)")
|
||||
|
||||
// ── 1. Baseline: the message alone is a SOFT bell, not a hard one ─────────────
|
||||
//
|
||||
// If this ever returns hard_bell, the test below proves nothing — the message
|
||||
// would trip the bell without any history and the amplification would be
|
||||
// invisible. This assertion is what keeps the real test honest.
|
||||
|
||||
println("")
|
||||
println("1. baseline — escalation message with NO history is a soft bell")
|
||||
|
||||
let baseline: String = safety_screen(ESCALATION_MSG, "")
|
||||
assert_eq("no history -> soft_bell (not hard)", json_get(baseline, "action"), "soft_bell")
|
||||
|
||||
// ── 2. Producer sanity: history lands in the session's own window ─────────────
|
||||
|
||||
println("")
|
||||
println("2. producer — conv_history_record writes the session's window")
|
||||
|
||||
conv_history_record(TEST_SESSION, DISTRESS_TURN, "i hear you, that sounds heavy", "")
|
||||
|
||||
let written: String = state_get(conv_hist_key(TEST_SESSION))
|
||||
assert_true("session window is non-empty after record", !str_eq(written, ""))
|
||||
assert_true("session window contains the distress turn", str_contains(written, "hopeless"))
|
||||
|
||||
// ── 3. THE REGRESSION: the agentic screen must SEE that window ────────────────
|
||||
//
|
||||
// Pre-fix this returns soft_bell, because agentic_safety_screen read the
|
||||
// anonymous bucket and got "". Post-fix it returns hard_bell.
|
||||
|
||||
println("")
|
||||
println("3. REGRESSION #129 — agentic screen reads the session's own window")
|
||||
|
||||
let screened: String = agentic_safety_screen(TEST_SESSION, ESCALATION_MSG)
|
||||
assert_eq(
|
||||
"distress history escalates the agentic screen to hard_bell",
|
||||
json_get(screened, "action"),
|
||||
"hard_bell"
|
||||
)
|
||||
|
||||
// ── 4. The invariant, stated directly ─────────────────────────────────────────
|
||||
//
|
||||
// Independent of thresholds and phrase lists: whatever the screen reads for a
|
||||
// session must equal what the recorder wrote for that session. This is the
|
||||
// assertion that survives a future rename of either side.
|
||||
|
||||
println("")
|
||||
println("4. invariant — read window == written window")
|
||||
|
||||
let read_back: String = state_get(conv_hist_key(TEST_SESSION))
|
||||
assert_true("screen input is the recorded window, not empty", !str_eq(read_back, ""))
|
||||
assert_eq("read window is byte-identical to written window", read_back, written)
|
||||
|
||||
// ── 5. No false positive: a calm session does not escalate ────────────────────
|
||||
//
|
||||
// A test that only ever asserts "hard_bell" would pass on code that hard-bells
|
||||
// every message. This is the other leg, and it runs BEFORE the anonymous case
|
||||
// below on purpose: that case writes the shared bucket, and under the defect a
|
||||
// calm session would then inherit it.
|
||||
|
||||
println("")
|
||||
println("5. specificity — a calm history does NOT escalate")
|
||||
|
||||
let CALM_SESSION: String = "sess-test-129-calm"
|
||||
state_set("conv_history", "")
|
||||
conv_history_record(CALM_SESSION, "what is the weather like today", "clear and mild", "")
|
||||
let calm: String = agentic_safety_screen(CALM_SESSION, ESCALATION_MSG)
|
||||
assert_eq("calm history stays at soft_bell", json_get(calm, "action"), "soft_bell")
|
||||
|
||||
// ── 6. Cross-session leakage ──────────────────────────────────────────────────
|
||||
//
|
||||
// The same defect had a second face: because the screen read one shared bucket,
|
||||
// a calm session could be scored against a DIFFERENT session's distress. That is
|
||||
// wrong in both directions — it fabricates a crisis for the calm user and it
|
||||
// leaks the distressed user's content into another session's scoring.
|
||||
|
||||
println("")
|
||||
println("6. isolation — one session's distress must not score another session")
|
||||
|
||||
state_set("conv_history", "")
|
||||
let OTHER_SESSION: String = "sess-test-129-other"
|
||||
conv_history_record(OTHER_SESSION, DISTRESS_TURN, "i hear you", "")
|
||||
let isolated: String = agentic_safety_screen(CALM_SESSION, ESCALATION_MSG)
|
||||
assert_eq(
|
||||
"a distressed OTHER session does not escalate the calm session",
|
||||
json_get(isolated, "action"),
|
||||
"soft_bell"
|
||||
)
|
||||
|
||||
// ── 7. Anonymous sessions still work ──────────────────────────────────────────
|
||||
//
|
||||
// conv_hist_key("") deliberately falls back to the shared "conv_history" bucket.
|
||||
// The fix must not break the no-session_id path older callers rely on. Runs last
|
||||
// because it writes that shared bucket.
|
||||
|
||||
println("")
|
||||
println("7. anonymous path — empty session_id still screens against the shared window")
|
||||
|
||||
state_set("conv_history", "[{\"role\":\"user\",\"content\":\"" + DISTRESS_TURN + "\"}]")
|
||||
let anon: String = agentic_safety_screen("", ESCALATION_MSG)
|
||||
assert_eq("anonymous session escalates too", json_get(anon, "action"), "hard_bell")
|
||||
|
||||
// ── Summary ───────────────────────────────────────────────────────────────────
|
||||
|
||||
println("")
|
||||
println("history amplification tests: " + int_to_str(counter_of("t_pass")) + " passed, " + int_to_str(counter_of("t_fail")) + " failed")
|
||||
+97
-11
@@ -41,6 +41,7 @@
|
||||
#include <fcntl.h>
|
||||
#include <dirent.h>
|
||||
#include <errno.h>
|
||||
#include <signal.h> /* SIGPIPE disposition — see el_runtime_ignore_sigpipe */
|
||||
#include <pthread.h>
|
||||
#include <curl/curl.h>
|
||||
|
||||
@@ -1238,16 +1239,77 @@ static const char* http_reason_phrase(int status) {
|
||||
}
|
||||
}
|
||||
|
||||
/* Best-effort send with retry on partial writes. */
|
||||
/* ── A departing client MUST NOT be able to kill the daemon ──────────────────
|
||||
* (2026-08-06, round 9.1 / ADR 0006 item 4.)
|
||||
*
|
||||
* Measured field failure: a client cancelled its request at 25 s; the handler
|
||||
* finished its work at 116.9 s and wrote the reply into the departed client's
|
||||
* socket. The second send() on a reset connection raised SIGPIPE, whose DEFAULT
|
||||
* disposition terminates the process — `exited due to SIGPIPE ... ran for
|
||||
* 361177ms`. launchd respawned 4 ms later, so EVERY other in-flight request on
|
||||
* that daemon lost its work, silently.
|
||||
*
|
||||
* Two independent guards, because one of them can be undone from outside this
|
||||
* file (an embedder may reset signal dispositions) and the other cannot:
|
||||
* 1. process-wide SIGPIPE -> SIG_IGN, installed at runtime init;
|
||||
* 2. per-send suppression at the syscall (MSG_NOSIGNAL where the platform has
|
||||
* it, SO_NOSIGPIPE on the accepted socket on macOS/BSD).
|
||||
* With either in force, send() reports the peer's departure as EPIPE and the
|
||||
* caller decides — which is the point: this is an ordinary I/O outcome, not a
|
||||
* fatal condition.
|
||||
*
|
||||
* It deliberately does NOT swallow the error. http_send_response() below
|
||||
* classifies the errno and logs: "client left" for a departure, and a real
|
||||
* "send failed: <strerror>" for anything else, so a genuine write fault is
|
||||
* still visible in the log (spec round-9.1 §5.3). */
|
||||
|
||||
#ifndef MSG_NOSIGNAL
|
||||
#define MSG_NOSIGNAL 0
|
||||
#endif
|
||||
|
||||
void el_runtime_ignore_sigpipe(void) {
|
||||
static int done = 0;
|
||||
if (done) return;
|
||||
done = 1;
|
||||
struct sigaction sa;
|
||||
memset(&sa, 0, sizeof(sa));
|
||||
sa.sa_handler = SIG_IGN;
|
||||
sigemptyset(&sa.sa_mask);
|
||||
sigaction(SIGPIPE, &sa, NULL);
|
||||
}
|
||||
|
||||
/* Suppress SIGPIPE for one accepted connection (macOS/BSD have no
|
||||
* MSG_NOSIGNAL; they have the socket option instead). Best effort. */
|
||||
static void http_socket_nosigpipe(int fd) {
|
||||
#ifdef SO_NOSIGPIPE
|
||||
int on = 1;
|
||||
setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &on, sizeof(on));
|
||||
#else
|
||||
(void)fd;
|
||||
#endif
|
||||
}
|
||||
|
||||
/* Best-effort send with retry on partial writes.
|
||||
* Returns 0 on success, -1 on failure with errno preserved for the caller. */
|
||||
static int http_send_all(int fd, const char* p, size_t left) {
|
||||
while (left > 0) {
|
||||
ssize_t w = send(fd, p, left, 0);
|
||||
if (w <= 0) return -1;
|
||||
ssize_t w = send(fd, p, left, MSG_NOSIGNAL);
|
||||
if (w < 0) {
|
||||
if (errno == EINTR) continue; /* not an error — retry */
|
||||
return -1; /* errno stays set for caller */
|
||||
}
|
||||
if (w == 0) { errno = EPIPE; return -1; }
|
||||
p += w; left -= (size_t)w;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Did this write fail because the client is gone, or because something is
|
||||
* actually wrong with the socket? Only the first is routine. */
|
||||
static int http_write_err_is_client_gone(int e) {
|
||||
return e == EPIPE || e == ECONNRESET || e == ENOTCONN || e == ESHUTDOWN;
|
||||
}
|
||||
|
||||
/* Discriminator that http_response() embeds at the start of its envelope.
|
||||
* A handler returning a string starting with this exact prefix is treated
|
||||
* as a structured response; anything else is treated as a raw body. */
|
||||
@@ -1468,14 +1530,30 @@ static void http_send_response(int fd, const char* body) {
|
||||
free(env_body); free(hdrs.buf); return;
|
||||
}
|
||||
|
||||
if (http_send_all(fd, status_line, (size_t)sl) == 0
|
||||
&& http_send_all(fd, hdrs.buf, hdrs.len) == 0
|
||||
&& http_send_all(fd, tail, (size_t)tl) == 0
|
||||
&& (head_only
|
||||
/* HEAD requests echo headers + Content-Length but no body. */
|
||||
? 1
|
||||
: http_send_all(fd, eff_body, blen) == 0)) {
|
||||
/* sent successfully */
|
||||
/* The reply is written in four pieces; any of them can find the client
|
||||
* already gone. errno is captured at the first failure, before any later
|
||||
* library call can clobber it, and classified once below. */
|
||||
errno = 0;
|
||||
int send_err = 0;
|
||||
if (http_send_all(fd, status_line, (size_t)sl) != 0) send_err = errno;
|
||||
else if (http_send_all(fd, hdrs.buf, hdrs.len) != 0) send_err = errno;
|
||||
else if (http_send_all(fd, tail, (size_t)tl) != 0) send_err = errno;
|
||||
else if (!head_only /* HEAD echoes headers + Content-Length, no body. */
|
||||
&& http_send_all(fd, eff_body, blen) != 0) send_err = errno;
|
||||
|
||||
if (send_err) {
|
||||
if (http_write_err_is_client_gone(send_err)) {
|
||||
/* ROUTINE. The user closed the window, quit the app, or cancelled.
|
||||
* The work is done and the daemon keeps serving everyone else. */
|
||||
fprintf(stderr, "[http] client left before the reply was written "
|
||||
"(%zu-byte body, %s) - request completed, reply discarded\n",
|
||||
blen, strerror(send_err));
|
||||
} else {
|
||||
/* NOT routine — a real write fault. Never let the client-gone case
|
||||
* above hide this one. */
|
||||
fprintf(stderr, "[http] send failed: %s (%zu-byte body)\n",
|
||||
strerror(send_err), blen);
|
||||
}
|
||||
}
|
||||
|
||||
if (env_parsed_root) el_release(env_parsed_root);
|
||||
@@ -1491,6 +1569,7 @@ static void* http_worker(void* arg) {
|
||||
HttpWorkerArg* a = (HttpWorkerArg*)arg;
|
||||
int fd = a->fd;
|
||||
free(a);
|
||||
http_socket_nosigpipe(fd);
|
||||
char *method = NULL, *path = NULL, *body = NULL;
|
||||
if (http_read_request(fd, &method, &path, &body, NULL) == 0) {
|
||||
http_handler_fn h = http_lookup_active();
|
||||
@@ -1531,6 +1610,7 @@ static void* http_worker(void* arg) {
|
||||
}
|
||||
|
||||
void http_serve(el_val_t port, el_val_t handler) {
|
||||
el_runtime_ignore_sigpipe(); /* serving implies clients that leave */
|
||||
/* If `handler` looks like a string name, register it as the active handler. */
|
||||
const char* hname = EL_CSTR(handler);
|
||||
if (hname && looks_like_string(handler)) {
|
||||
@@ -1634,6 +1714,7 @@ static void* _http_serve_async_loop(void* raw) {
|
||||
}
|
||||
|
||||
void http_serve_async(el_val_t port, el_val_t handler) {
|
||||
el_runtime_ignore_sigpipe(); /* serving implies clients that leave */
|
||||
const char* hname = EL_CSTR(handler);
|
||||
if (hname && looks_like_string(handler)) {
|
||||
http_set_handler(handler);
|
||||
@@ -1821,6 +1902,7 @@ static void* http_worker_v2(void* arg) {
|
||||
HttpWorkerArg* a = (HttpWorkerArg*)arg;
|
||||
int fd = a->fd;
|
||||
free(a);
|
||||
http_socket_nosigpipe(fd);
|
||||
char *method = NULL, *path = NULL, *body = NULL, *hdr_block = NULL;
|
||||
if (http_read_request(fd, &method, &path, &body, &hdr_block) == 0) {
|
||||
http_handler4_fn h = http_lookup_active_v2();
|
||||
@@ -1858,6 +1940,7 @@ static void* http_worker_v2(void* arg) {
|
||||
}
|
||||
|
||||
void http_serve_v2(el_val_t port, el_val_t handler) {
|
||||
el_runtime_ignore_sigpipe(); /* serving implies clients that leave */
|
||||
const char* hname = EL_CSTR(handler);
|
||||
if (hname && looks_like_string(handler)) {
|
||||
http_set_handler_v2(handler);
|
||||
@@ -5511,6 +5594,9 @@ el_val_t getpid_now(void) {
|
||||
static el_val_t _el_args_list = 0;
|
||||
|
||||
void el_runtime_init_args(int argc, char** argv) {
|
||||
/* First line of every generated main(): a client that leaves must never be
|
||||
* able to signal this process to death. See el_runtime_ignore_sigpipe. */
|
||||
el_runtime_ignore_sigpipe();
|
||||
_el_args_list = el_list_empty();
|
||||
for (int i = 1; i < argc; i++) {
|
||||
_el_args_list = el_list_append(_el_args_list, EL_STR(argv[i]));
|
||||
|
||||
Reference in New Issue
Block a user