Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bca7d8ac99 | |||
| 18714e6142 | |||
| 4936099c39 | |||
| f1471763f5 | |||
| 5850793b67 | |||
| fc1745c652 | |||
| 43d0449904 | |||
| b842e82f77 | |||
| 98ccbd4704 | |||
| dba755dcec | |||
| 8f3a478771 | |||
| 9ea41eed78 | |||
| ff421d39f6 | |||
| 635f6febe4 |
@@ -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);
|
||||
|
||||
+15
@@ -1,3 +1,18 @@
|
||||
// ╔══════════════════════════════════════════════════════════════════════════╗
|
||||
// ║ STALE BUNDLE — DO NOT BUILD. UNSAFE CHAT PATH. ║
|
||||
// ╚══════════════════════════════════════════════════════════════════════════╝
|
||||
// This concatenated bundle is a snapshot, not a source of truth, and it is stale in
|
||||
// a way that matters for safety: it wires /api/chat straight to handle_chat and
|
||||
// contains NO layered_cycle at all (verified: zero occurrences in the bundled code —
|
||||
// the only textual hit in this file is this banner). A binary built from
|
||||
// this file would run chat with no enforcing input gate (no safety_screen, no
|
||||
// hard-bell short-circuit) and no enforcing output gate (no safety_validate).
|
||||
//
|
||||
// Build from the .el sources via manifest.el (entry soul.el), or from dist/soul.c.
|
||||
// Nothing in the repo references this file. It is kept only as a historical artifact
|
||||
// and should be deleted once Will confirms nothing external depends on it.
|
||||
// (Flagged 2026-08-04 in _engine-websearch-20260804/SAFETY-STOP.md; banner added
|
||||
// 2026-08-05 with the plain-chat generation fix.)
|
||||
// language-profile.el - Language profile data and accessors.
|
||||
//
|
||||
// A language profile is a slot map ([String] key-value list) describing the
|
||||
|
||||
+134
-10
@@ -430,7 +430,130 @@ fn handle_api_node_update(body: String) -> String {
|
||||
return "{\"id\":\"" + new_id + "\",\"supersedes\":\"" + id + "\",\"ok\":true}"
|
||||
}
|
||||
|
||||
// handle_api_recall — search or activate memory by query.
|
||||
// ── Recall through spreading activation ───────────────────────────────────────
|
||||
//
|
||||
// api_activation_depth — traversal depth for a retrieval query. Honours ?depth= /
|
||||
// body "depth" for callers that want a wider or tighter associative horizon;
|
||||
// defaults to 2, matching every other production activation caller (the
|
||||
// knowledge-search path here, chat.el's per-turn activation) — one hop reaches a
|
||||
// node's direct associations, two reaches its siblings through a shared hub,
|
||||
// which is exactly the sibling-recovery case recall was failing.
|
||||
fn api_activation_depth(path: String, body: String) -> Int {
|
||||
let d: Int = api_query_int(path, "depth", 0)
|
||||
let d = if d == 0 { json_get_int(body, "depth") } else { d }
|
||||
if d <= 0 { return 2 }
|
||||
return d
|
||||
}
|
||||
|
||||
// api_merge_activated_nodes — project an activation result array down to a bare
|
||||
// node array in activation order, then backfill from the lexical seed list until
|
||||
// `limit` nodes are collected. Deduped by node id.
|
||||
//
|
||||
// SHAPE CONTRACT: the return value is a BARE array of full engram node objects —
|
||||
// byte-for-byte the same node JSON engram_search_json emits, so every existing
|
||||
// /recall consumer keeps working unchanged (the MCP wrapper's recall/
|
||||
// searchKnowledge, tools/telegram-gateway.sh which reads `.value.content`,
|
||||
// cli/neuron_mcp.py). Activation strength is a RANKING input here, not a payload
|
||||
// change; the scalars stay available on /api/activate and in compileCtx.
|
||||
fn api_merge_activated_nodes(act_raw: String, lex_raw: String, limit: Int) -> String {
|
||||
let seen: String = ""
|
||||
let out: String = ""
|
||||
let n: Int = 0
|
||||
// Pass 1 — activation-ranked. engram_activate_json already sorts promoted
|
||||
// (working-memory) nodes first by wm_weight desc, then background-only nodes
|
||||
// by background_activation desc, so element order IS the activation ranking.
|
||||
let an: Int = if api_nonempty(act_raw) { json_array_len(act_raw) } else { 0 }
|
||||
let i: Int = 0
|
||||
while i < an && n < limit {
|
||||
let entry: String = json_array_get(act_raw, i)
|
||||
let anode: String = json_get_raw(entry, "node")
|
||||
let aid: String = json_get(anode, "id")
|
||||
let adup: Bool = str_eq(aid, "") || str_contains(seen, "<" + aid + ">")
|
||||
let asep: String = if n == 0 { "" } else { "," }
|
||||
let out = if adup { out } else { out + asep + anode }
|
||||
let seen = if adup { seen } else { seen + "<" + aid + ">" }
|
||||
let n = if adup { n } else { n + 1 }
|
||||
let i = i + 1
|
||||
}
|
||||
// Pass 2 — lexical seed backfill (see the exact-lookup note on
|
||||
// handle_api_recall). Only runs when activation left room under `limit`.
|
||||
let ln: Int = if api_nonempty(lex_raw) { json_array_len(lex_raw) } else { 0 }
|
||||
let j: Int = 0
|
||||
while j < ln && n < limit {
|
||||
let lnode: String = json_array_get(lex_raw, j)
|
||||
let lid: String = json_get(lnode, "id")
|
||||
let ldup: Bool = str_eq(lid, "") || str_contains(seen, "<" + lid + ">")
|
||||
let lsep: String = if n == 0 { "" } else { "," }
|
||||
let out = if ldup { out } else { out + lsep + lnode }
|
||||
let seen = if ldup { seen } else { seen + "<" + lid + ">" }
|
||||
let n = if ldup { n } else { n + 1 }
|
||||
let j = j + 1
|
||||
}
|
||||
return "[" + out + "]"
|
||||
}
|
||||
|
||||
// api_retrieve — THE retrieval path. Spreading activation over the weighted
|
||||
// directed graph, lexical seeds backfilling the tail.
|
||||
//
|
||||
// WAS (until 2026-08-07): `engram_search_json(q, limit)` alone — a case-
|
||||
// insensitive substring matcher scored by how many distinct query tokens appear
|
||||
// in a node's content/label/tags, tie-broken by raw salience. It never read a
|
||||
// single edge. Recall could not see an association: querying an identity value
|
||||
// returned unrelated documents that happened to contain the word, and NOT the
|
||||
// twelve sibling value nodes one hop off the same hub.
|
||||
//
|
||||
// NOW: recall runs the spreading-activation traversal that has been compiled
|
||||
// into the runtime the whole time (engram_activate / engram_activate_json,
|
||||
// el_runtime.c) and ranks by the resulting activation strength. This restores
|
||||
// the designed retrieval mechanism — Engram provisional 64/064,260, claim 1:
|
||||
// "no data is retrieved from the weighted directed graph except through the
|
||||
// spreading activation traversal", with activation strength computed as the
|
||||
// PRODUCT of parent strength, edge weight, target salience, and query/target
|
||||
// cosine similarity, because "the multiplication of all four factors enforces a
|
||||
// conjunctive property... addition would allow many weak associations to
|
||||
// accumulate into false relevance."
|
||||
//
|
||||
// SEEDING — derived from the runtime, not assumed. engram_activate takes the
|
||||
// query TEXT (not seed ids) and seeds internally in two passes: (1) lexical —
|
||||
// every node matching at least one query token seeds, with initial activation
|
||||
// = salience x temporal_decay x dampening x token_coverage, so a node covering
|
||||
// the whole phrase ignites harder than one covering a single word; (2) semantic
|
||||
// supplement — the top-K unreached nodes by cosine against the query embedding.
|
||||
// All four other production call sites (neuron-api.el begin_session/compileCtx,
|
||||
// chat.el:352/1715, awareness.el's curiosity scans) pass query text the same
|
||||
// way, so this follows the established convention exactly. The consequence for
|
||||
// recall is direct: the lexical surface recall used to RETURN is now the SEED
|
||||
// SET of the traversal, and what comes back is what those seeds activate. That
|
||||
// is why multi-word queries stop returning nothing — every token that matches
|
||||
// anything ignites, and the traversal ranks the resulting field.
|
||||
//
|
||||
// EXACT-LOOKUP GUARANTEE (no regression): engram_activate's result collector
|
||||
// drops any reached node whose background_activation x confidence < 0.1 unless
|
||||
// it was promoted to working memory, and it never seeds from InternalStateEvent
|
||||
// nodes. So a rare exact token on a dormant, low-salience node can seed the
|
||||
// traversal and still go unreported. Retrieval therefore appends the lexical
|
||||
// seed list after the activated ranking, deduped by id, until `limit` is filled.
|
||||
// This is a seeded hybrid, not a parallel search bolted alongside activation:
|
||||
// the backfill is the SAME seed set the traversal itself computed, restored to
|
||||
// the tail of the result rather than recomputed by a different mechanism.
|
||||
// Activation always leads the ranking; nothing that used to be findable becomes
|
||||
// unfindable.
|
||||
//
|
||||
// COST/EFFECT NOTE: activation is a stateful read by design — claim 29, "update
|
||||
// the last-activation timestamp and increment the activation count... in
|
||||
// response to any access to that node record during spreading activation
|
||||
// traversal". Promoted nodes get reinforced, working-memory weights are
|
||||
// rewritten, and the query folds into the context centroid. That is the
|
||||
// intended semantics of retrieval-as-activation and is already what every chat
|
||||
// turn does; it does mean recall now participates in shaping working memory.
|
||||
fn api_retrieve(q: String, path: String, body: String, limit: Int) -> String {
|
||||
let depth: Int = api_activation_depth(path, body)
|
||||
let act_raw: String = engram_activate_json(q, depth)
|
||||
let lex_raw: String = engram_search_json(q, limit)
|
||||
return api_or_empty(api_merge_activated_nodes(act_raw, lex_raw, limit))
|
||||
}
|
||||
|
||||
// handle_api_recall — retrieve memory by query, through spreading activation.
|
||||
fn handle_api_recall(method: String, path: String, body: String) -> String {
|
||||
// Accept the query from the URL ?query= / ?q= params, or, when those are
|
||||
// empty (e.g. a POST with a JSON body), from the body fields "query"/"q".
|
||||
@@ -450,8 +573,7 @@ fn handle_api_recall(method: String, path: String, body: String) -> String {
|
||||
if str_eq(eff_q, "") {
|
||||
return api_or_empty(engram_scan_nodes_json(limit, 0))
|
||||
}
|
||||
let results: String = engram_search_json(eff_q, limit)
|
||||
return api_or_empty(results)
|
||||
return api_retrieve(eff_q, path, body, limit)
|
||||
}
|
||||
|
||||
// ── Knowledge ─────────────────────────────────────────────────────────────────
|
||||
@@ -470,13 +592,15 @@ fn handle_api_search_knowledge(method: String, path: String, body: String) -> St
|
||||
let limit = if limit == 0 { json_get_int(body, "limit") } else { limit }
|
||||
let limit = if limit == 0 { 10 } else { limit }
|
||||
if str_eq(q, "") { return api_err("query is required") }
|
||||
let results: String = engram_search_json(q, limit)
|
||||
if str_eq(results, "") { return "[]" }
|
||||
let first: String = str_slice(results, 0, 1)
|
||||
if !str_eq(first, "[") && !str_eq(first, "{") {
|
||||
return api_or_empty(engram_activate_json(q, 2))
|
||||
}
|
||||
return results
|
||||
// Same retrieval path as recall — and it is the SAME change, not a copy of
|
||||
// one. The "activate fallback" this replaced was unreachable dead code: it
|
||||
// only fired when engram_search_json's return did not start with '[' or '{',
|
||||
// and engram_search_json always emits a '['-prefixed array (el_runtime.c
|
||||
// jb_putc('[') before any hit test), so the guard was false on every call
|
||||
// including the zero-hit "[]" case. Knowledge search therefore had exactly
|
||||
// the substring-matcher behavior recall had, with a comment claiming
|
||||
// otherwise. Routing it through api_retrieve makes the claim true.
|
||||
return api_retrieve(q, path, body, limit)
|
||||
}
|
||||
|
||||
// handle_api_browse_knowledge — list Knowledge nodes.
|
||||
|
||||
@@ -15,6 +15,40 @@ fn flag_true(body: String, key: String) -> Bool {
|
||||
return json_get_bool(body, key) || json_get_int(body, key) > 0
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// plain_chat_envelope — the JSON response contract for a non-agentic ("Tools: Off")
|
||||
// chat turn. Every /api/chat dispatch that calls layered_cycle goes through here, so
|
||||
// the three call sites cannot drift apart.
|
||||
//
|
||||
// WHY THE ENVELOPE IS BUILT HERE AND NOT INSIDE layered_cycle:
|
||||
// layered_cycle returns the user-facing text AFTER safety_validate has acted on it.
|
||||
// Keeping the JSON out of the cycle means the output gate always sees raw model text
|
||||
// and never an escaped blob — there is nothing to unwrap and re-wrap on the crisis
|
||||
// path, which is exactly the failure mode that made wiring handle_chat unsafe.
|
||||
// Escaping is the last thing that happens, strictly after the gate.
|
||||
//
|
||||
// FIELDS: `reply` and `response` carry the same validated text. Both are required by
|
||||
// live clients — the desktop app reads `reply` first (DaemonClient.parseChatResponse),
|
||||
// while the CLI tools and the Telegram gateway read `response` (the gateway reads only
|
||||
// `response`). Emitting one would break the other.
|
||||
//
|
||||
// EMPTY MEANS FAILURE, NOT AN EMPTY ANSWER: a hard bell returns the fixed crisis
|
||||
// message and a soft bell is padded to non-empty by safety_validate, so the only way
|
||||
// an empty string leaves the cycle is a failed model call. It is reported as an error
|
||||
// rather than dressed up as a successful blank reply.
|
||||
// ---------------------------------------------------------------------------
|
||||
fn plain_chat_envelope(validated: String, model: String) -> String {
|
||||
if str_eq(validated, "") {
|
||||
return "{\"error\":\"llm unavailable\",\"reply\":\"\",\"response\":\"\",\"agentic\":false,\"tools_used\":[]}"
|
||||
}
|
||||
let safe: String = json_safe(validated)
|
||||
return "{\"reply\":\"" + safe + "\""
|
||||
+ ",\"response\":\"" + safe + "\""
|
||||
+ ",\"model\":\"" + json_safe(model) + "\""
|
||||
+ ",\"agentic\":false"
|
||||
+ ",\"tools_used\":[]}"
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rate limiting — simple in-memory per-IP sliding window counter.
|
||||
//
|
||||
@@ -243,8 +277,14 @@ fn handle_dharma_recv(body: String) -> String {
|
||||
} else if agentic_flag {
|
||||
handle_chat_agentic(chat_body)
|
||||
} else {
|
||||
let screened_reply: String = layered_cycle(raw_msg)
|
||||
screened_reply
|
||||
// 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.
|
||||
// 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)
|
||||
return reply
|
||||
@@ -416,8 +456,11 @@ fn handle_request(method: String, path: String, body: String) -> String {
|
||||
} else if agentic_flag {
|
||||
handle_chat_agentic(body)
|
||||
} else {
|
||||
let screened_reply: String = layered_cycle(eff_msg)
|
||||
screened_reply
|
||||
// Non-agentic ("Tools: Off") — same cycle and same envelope as POST.
|
||||
// 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)
|
||||
return reply
|
||||
@@ -580,8 +623,13 @@ fn handle_request(method: String, path: String, body: String) -> String {
|
||||
} else if agentic_flag {
|
||||
handle_chat_agentic(body)
|
||||
} else {
|
||||
let screened_reply: String = layered_cycle(raw_msg)
|
||||
screened_reply
|
||||
// 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.
|
||||
// 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)
|
||||
return reply
|
||||
|
||||
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"
|
||||
@@ -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)
|
||||
|
||||
@@ -453,40 +469,24 @@ fn layered_cycle(raw_input: String) -> String {
|
||||
let lc_aff_cutoff: Int = time_now() - 259200
|
||||
let lc_bell_nodes: String = engram_search_json("bell:soft bell:hard BellEvent affective", 2)
|
||||
let lc_has_bell: Bool = !str_eq(lc_bell_nodes, "") && !str_eq(lc_bell_nodes, "[]")
|
||||
// CRASH FIX 2026-08-05 (BUG-PLAINCHAT-1): the " | ts:" parser used to be inline here.
|
||||
// Inside this block-expression initializer elc compiled `lbmp + str_len(lbm)` to
|
||||
// el_str_concat() on two integers, which segfaulted the whole daemon the moment a
|
||||
// distress turn followed an earlier affective turn — i.e. exactly on the crisis path.
|
||||
// Verified against the unmodified baseline binary AND present in the committed
|
||||
// dist/soul.c. affective_node_ts() is a top-level function, where the same expression
|
||||
// compiles to integer addition. Do not inline it back.
|
||||
let lc_bell_note: String = if lc_has_bell {
|
||||
let lb0: String = json_array_get(lc_bell_nodes, 0)
|
||||
let lb_c: String = json_get(lb0, "content")
|
||||
let lbm: String = " | ts:"
|
||||
let lbmp: Int = str_index_of(lb_c, lbm)
|
||||
let lb_ts_raw: String = if lbmp >= 0 {
|
||||
let lbs: Int = lbmp + str_len(lbm)
|
||||
let lbr: String = str_slice(lb_c, lbs, str_len(lb_c))
|
||||
let lbn: Int = str_index_of(lbr, " | ")
|
||||
if lbn < 0 { lbr } else { str_slice(lbr, 0, lbn) }
|
||||
} else {
|
||||
let lbca: String = json_get(lb0, "created_at")
|
||||
if str_eq(lbca, "") { json_get(lb0, "updated_at") } else { lbca }
|
||||
}
|
||||
let lb_ts: Int = if str_eq(lb_ts_raw, "") { 0 } else { str_to_int(lb_ts_raw) }
|
||||
let lb_ts: Int = affective_node_ts(lb0)
|
||||
if lb_ts > lc_aff_cutoff { "[AFFECTIVE NOTE: User was in distress in a recent session.]" } else { "" }
|
||||
} else { "" }
|
||||
let lc_pos_nodes: String = engram_search_json("PositiveEvent joy:high joy:low affective", 2)
|
||||
let lc_has_pos: Bool = !str_eq(lc_pos_nodes, "") && !str_eq(lc_pos_nodes, "[]")
|
||||
// Same crash fix as the bell note above (BUG-PLAINCHAT-1).
|
||||
let lc_pos_note: String = if lc_has_pos && str_eq(lc_bell_note, "") {
|
||||
let lp0: String = json_array_get(lc_pos_nodes, 0)
|
||||
let lp_c: String = json_get(lp0, "content")
|
||||
let lpm: String = " | ts:"
|
||||
let lpmp: Int = str_index_of(lp_c, lpm)
|
||||
let lp_ts_raw: String = if lpmp >= 0 {
|
||||
let lps: Int = lpmp + str_len(lpm)
|
||||
let lpr: String = str_slice(lp_c, lps, str_len(lp_c))
|
||||
let lpn: Int = str_index_of(lpr, " | ")
|
||||
if lpn < 0 { lpr } else { str_slice(lpr, 0, lpn) }
|
||||
} else {
|
||||
let lpca: String = json_get(lp0, "created_at")
|
||||
if str_eq(lpca, "") { json_get(lp0, "updated_at") } else { lpca }
|
||||
}
|
||||
let lp_ts: Int = if str_eq(lp_ts_raw, "") { 0 } else { str_to_int(lp_ts_raw) }
|
||||
let lp_ts: Int = affective_node_ts(lp0)
|
||||
if lp_ts > lc_aff_cutoff { "[AFFECTIVE NOTE: User shared positive news in a recent session.]" } else { "" }
|
||||
} else { "" }
|
||||
let lc_affective_note: String = if !str_eq(lc_bell_note, "") { lc_bell_note } else { lc_pos_note }
|
||||
@@ -498,11 +498,47 @@ fn layered_cycle(raw_input: String) -> String {
|
||||
}
|
||||
state_set("layered_cycle_safety_system_addendum", augmented_addendum)
|
||||
|
||||
// L3: imprint responds
|
||||
let output: String = imprint_respond(aligned, imprint_id)
|
||||
// L3: imprint responds — applies the active imprint's voice/domain annotation to the
|
||||
// steward-aligned input. This produces the PROMPT, not the answer.
|
||||
let prompt: String = imprint_respond(aligned, imprint_id)
|
||||
|
||||
// L1 out: validate output before delivery
|
||||
return safety_validate(output, screen_action)
|
||||
// L3b: the imprint SPEAKS (added 2026-08-05).
|
||||
//
|
||||
// Until now the cycle stopped at the annotation above, so /api/chat with agentic:false
|
||||
// handed the user's own screened text back as the "reply" — every gate ran, but nothing
|
||||
// ever generated. The generation is placed HERE, inside the cycle, rather than by
|
||||
// pointing the route at handle_chat(): handle_chat has no enforcing input gate and no
|
||||
// enforcing output gate, so calling it instead of this cycle would have traded the whole
|
||||
// safety pipeline for a working reply. Composing keeps both.
|
||||
//
|
||||
// Order is deliberate and must not be rearranged: this call sits strictly AFTER the L1
|
||||
// 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, session_id)
|
||||
|
||||
// L1 out: validate output before delivery. Still the terminal gate — nothing below this
|
||||
// line can change the string this function returns.
|
||||
let validated: String = safety_validate(output, screen_action)
|
||||
|
||||
// Turn bookkeeping. Records the VALIDATED text, never the raw model output, and is only
|
||||
// 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.
|
||||
//
|
||||
// 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
|
||||
}
|
||||
|
||||
let soul_cgi_id_raw: String = env("SOUL_CGI_ID")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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