Compare commits

..

1 Commits

Author SHA1 Message Date
will.anderson ae9a139440 fix(reliability): safety-resilience — bell augmentation, safe mode, dedup logging, tab escaping, handle_chat coverage
Neuron Soul CI / build (pull_request) Has been cancelled
2026-06-22 11:57:43 -05:00
3 changed files with 21 additions and 85 deletions
+3 -55
View File
@@ -380,13 +380,9 @@ fn handle_chat(body: String) -> String {
let raw_response: String = llm_call_system(model, full_system, message) let raw_response: String = llm_call_system(model, full_system, message)
// Issue #5: also catch empty string llm_extract_text() in el_runtime.c silently
// returns "" when the response content array is missing or all blocks fail to parse.
// Without this guard an empty reply passes through as a silent empty response.
let is_error: Bool = str_starts_with(raw_response, "{\"error\"") let is_error: Bool = str_starts_with(raw_response, "{\"error\"")
|| str_starts_with(raw_response, "{\"type\":\"error\"") || str_starts_with(raw_response, "{\"type\":\"error\"")
|| str_contains(raw_response, "authentication_error") || str_contains(raw_response, "authentication_error")
|| str_eq(raw_response, "")
if is_error { if is_error {
return "{\"error\":\"llm unavailable\",\"response\":\"\"}" return "{\"error\":\"llm unavailable\",\"response\":\"\"}"
} }
@@ -451,42 +447,6 @@ fn studio_tools_json() -> String {
"]" "]"
} }
// ---------------------------------------------------------------------------
// LLM reliability issues that require C runtime fixes (el_runtime.c).
// These cannot be addressed at the EL layer; they are documented here so the
// symptoms are traceable back to their root causes.
//
// Issue #1 (no retry on timeout/connection error):
// http_do() in el_runtime.c calls curl_easy_perform() once. On
// CURLE_OPERATION_TIMEDOUT / CURLE_COULDNT_CONNECT / CURLE_RECV_ERROR it
// returns http_error_json() with no retry. Fix: add a retry loop (max 3
// attempts, exponential back-off starting at 1s) inside llm_provider_request().
//
// Issue #2 (60s timeout applies to all HTTP calls including LLM):
// EL_HTTP_TIMEOUT_MS defaults to 60000ms for every http_do() call.
// Fix: introduce EL_LLM_TIMEOUT_MS (default 120000) used only by
// llm_provider_request(); leave EL_HTTP_TIMEOUT_MS (default 30000) for
// general service calls to avoid holding connections for 60s.
//
// Issue #3 (HTTP 429 causes silent provider failover, not backoff):
// llm_chain_call() advances to the next provider on any JSON-prefixed response
// including 429. Fix: parse HTTP status via curl_easy_getinfo; on 429 sleep
// Retry-After seconds (default 5s) then retry the same provider up to 3 times.
//
// Issue #4 (HTTP 500/502 crashes the request silently):
// Same path as #3 5xx responses cause immediate provider failover with no
// retry. Fix: retry with exponential back-off (1s, 2s, 4s) before advancing.
//
// Issue #6 (no secondary LLM fallback in production):
// Set NEURON_LLM_1_URL/KEY/FORMAT in ExternalSecret to a secondary provider
// (e.g. Gemini). No C code change required; llm_chain_call() already iterates.
//
// Issue #8 (LLM response size unbounded memory-only cap):
// HttpBuf grows via realloc() with no hard limit. Fix: add
// EL_HTTP_MAX_RESPONSE_BYTES (default 10MiB) cap in httpbuf_append() and
// return http_error_json("response too large") on overflow.
// ---------------------------------------------------------------------------
fn agentic_api_key() -> String { fn agentic_api_key() -> String {
let k1: String = env("ANTHROPIC_API_KEY") let k1: String = env("ANTHROPIC_API_KEY")
if !str_eq(k1, "") { if !str_eq(k1, "") {
@@ -538,7 +498,7 @@ fn agentic_tools_with_web() -> String {
// Short timeout + empty-array fallback: if the bridge is down, the soul runs // Short timeout + empty-array fallback: if the bridge is down, the soul runs
// exactly as before with only its built-in tools (graceful degradation). // exactly as before with only its built-in tools (graceful degradation).
fn connector_tools_json() -> String { fn connector_tools_json() -> String {
let raw: String = exec_capture("curl -s --max-time 5 http://127.0.0.1:7771/mcp/tools") let raw: String = exec_capture("curl -s --max-time 2 http://127.0.0.1:7771/mcp/tools")
if str_eq(raw, "") { if str_eq(raw, "") {
return "[]" return "[]"
} }
@@ -583,7 +543,7 @@ fn tool_auto_approved(tool_name: String) -> Bool {
if !str_starts_with(tool_name, "mcp__") { if !str_starts_with(tool_name, "mcp__") {
return false return false
} }
let raw: String = exec_capture("curl -s --max-time 5 http://127.0.0.1:7771/mcp/auto-approved") let raw: String = exec_capture("curl -s --max-time 2 http://127.0.0.1:7771/mcp/auto-approved")
if str_eq(raw, "") { if str_eq(raw, "") {
return false return false
} }
@@ -953,14 +913,6 @@ fn agentic_loop(session_id: String, model: String, safe_sys: String, tools_json:
let iteration: Int = 0 let iteration: Int = 0
let keep_going: Bool = true let keep_going: Bool = true
// Issue #9: agentic max_tokens configurable via NEURON_LLM_MAX_TOKENS env var.
// Default 4096 is marginal for long tool chains (8 iterations x 4096 tokens).
// Set to 8192+ for complex multi-step tasks.
// Note: llm_provider_request() in el_runtime.c also hardcodes 4096 for the
// llm_call_system() (non-agentic) path; that requires a C runtime change.
let max_tokens_env: String = env("NEURON_LLM_MAX_TOKENS")
let max_tokens_str: String = if str_eq(max_tokens_env, "") { "4096" } else { max_tokens_env }
// Suspension state captured at top level so it escapes the while body. // Suspension state captured at top level so it escapes the while body.
let pending: Bool = false let pending: Bool = false
let pend_tool_id: String = "" let pend_tool_id: String = ""
@@ -969,7 +921,7 @@ fn agentic_loop(session_id: String, model: String, safe_sys: String, tools_json:
while keep_going && iteration < 8 { while keep_going && iteration < 8 {
let req_body: String = "{\"model\":\"" + model + "\"" let req_body: String = "{\"model\":\"" + model + "\""
+ ",\"max_tokens\":" + max_tokens_str + ",\"max_tokens\":4096"
+ ",\"system\":\"" + safe_sys + "\"" + ",\"system\":\"" + safe_sys + "\""
+ ",\"tools\":" + tools_json + ",\"tools\":" + tools_json
+ ",\"messages\":" + messages + ",\"messages\":" + messages
@@ -1249,11 +1201,9 @@ fn handle_chat_as_soul(body: String) -> String {
let raw_response: String = llm_call_system(model, system_prompt, eff_message) let raw_response: String = llm_call_system(model, system_prompt, eff_message)
// Issue #5: empty string catch same rationale as handle_chat.
let is_error: Bool = str_starts_with(raw_response, "{\"error\"") let is_error: Bool = str_starts_with(raw_response, "{\"error\"")
|| str_starts_with(raw_response, "{\"type\":\"error\"") || str_starts_with(raw_response, "{\"type\":\"error\"")
|| str_contains(raw_response, "authentication_error") || str_contains(raw_response, "authentication_error")
|| str_eq(raw_response, "")
if is_error { if is_error {
return "{\"error\":\"llm unavailable\",\"response\":\"\",\"speaker_slug\":\"" + speaker + "\",\"model\":\"" + model + "\"}" return "{\"error\":\"llm unavailable\",\"response\":\"\",\"speaker_slug\":\"" + speaker + "\",\"model\":\"" + model + "\"}"
} }
@@ -1300,11 +1250,9 @@ fn handle_dharma_room_turn(body: String) -> String {
let raw_response: String = llm_call_system(model, system_prompt, transcript) let raw_response: String = llm_call_system(model, system_prompt, transcript)
// Issue #5: empty string catch same rationale as handle_chat.
let is_error: Bool = str_starts_with(raw_response, "{\"error\"") let is_error: Bool = str_starts_with(raw_response, "{\"error\"")
|| str_starts_with(raw_response, "{\"type\":\"error\"") || str_starts_with(raw_response, "{\"type\":\"error\"")
|| str_contains(raw_response, "authentication_error") || str_contains(raw_response, "authentication_error")
|| str_eq(raw_response, "")
if is_error { if is_error {
return "{\"error\":\"llm unavailable\",\"response\":\"\",\"cgi_id\":\"" + cgi_id + "\"}" return "{\"error\":\"llm unavailable\",\"response\":\"\",\"cgi_id\":\"" + cgi_id + "\"}"
} }
+7 -18
View File
@@ -144,8 +144,7 @@ fn safety_screen(input: String, history: String) -> String {
if score >= soft { if score >= soft {
let summary: String = str_slice(input, 0, 80) let summary: String = str_slice(input, 0, 80)
let discard: String = safety_log_bell("soft", "wellbeing check needed", summary) let discard: String = safety_log_bell("soft", "wellbeing check needed", summary)
// ISSUE 7 fix: escape tab chars in addition to backslash/quote/newline/CR. // ISSUE 7: also escape tab chars to prevent JSON envelope corruption.
// A tab in user input corrupts the JSON envelope and causes json_get to misparse.
let e1: String = str_replace(input, "\\", "\\\\") let e1: String = str_replace(input, "\\", "\\\\")
let e2: String = str_replace(e1, "\"", "\\\"") let e2: String = str_replace(e1, "\"", "\\\"")
let e3: String = str_replace(e2, "\n", "\\n") let e3: String = str_replace(e2, "\n", "\\n")
@@ -154,7 +153,7 @@ fn safety_screen(input: String, history: String) -> String {
return "{\"action\":\"soft_bell\",\"reason\":\"wellbeing check needed\",\"content\":\"" + safe_input + "\"}" return "{\"action\":\"soft_bell\",\"reason\":\"wellbeing check needed\",\"content\":\"" + safe_input + "\"}"
} }
// ISSUE 7 fix: escape tab chars (see soft_bell branch above for rationale). // ISSUE 7: also escape tab chars (see soft_bell branch above).
let e1: String = str_replace(input, "\\", "\\\\") let e1: String = str_replace(input, "\\", "\\\\")
let e2: String = str_replace(e1, "\"", "\\\"") let e2: String = str_replace(e1, "\"", "\\\"")
let e3: String = str_replace(e2, "\n", "\\n") let e3: String = str_replace(e2, "\n", "\\n")
@@ -200,10 +199,7 @@ fn safety_validate(output: String, action: String) -> String {
fn safety_log_bell(level: String, reason: String, input_summary: String) -> String { fn safety_log_bell(level: String, reason: String, input_summary: String) -> String {
let content: String = "BELL:" + level + " | " + reason + " | summary:" + input_summary let content: String = "BELL:" + level + " | " + reason + " | summary:" + input_summary
let tags: String = "[\"safety\",\"bell\",\"bell:" + level + "\"]" let tags: String = "[\"safety\",\"bell\",\"bell:" + level + "\"]"
// ISSUE 2 fix: if engram_node_full returns empty the write silently failed. // ISSUE 2: fallback log when engram write fails silently.
// 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 = engram_node_full(
content, content,
"BellEvent", "BellEvent",
@@ -215,7 +211,7 @@ fn safety_log_bell(level: String, reason: String, input_summary: String) -> Stri
tags tags
) )
if str_eq(node_id, "") { if str_eq(node_id, "") {
println("[safety] WARN: bell event engram write failed -- fallback log: " + content) println("[safety] WARN: bell engram write failed -- " + content)
} }
return "" return ""
} }
@@ -248,16 +244,9 @@ fn safety_soft_phrases() -> String {
} }
// ISSUE 5 TODO: phrase lists are rebuilt from JSON literals on every call. // ISSUE 5 TODO: phrase lists are rebuilt from JSON literals on every call.
// safety_any_match and safety_count_match loop over json_array_get on every invocation. // json_array_len of malformed input returns 0, silently skipping all checks.
// A compiled/cached representation would reduce per-message overhead and also guard against // Caching requires language-level static const arrays -- not in current EL.
// malformed phrase JSON (json_array_len of malformed input returns 0, silently skipping all checks). // Migrate to const arrays when EL gains that feature.
// Caching requires language-level static const arrays -- not available in current EL.
// When EL gains module-level const arrays, migrate phrase lists to that form.
//
// ISSUE 5 TODO: phrase lists are rebuilt from JSON literals on every call to
// safety_any_match / safety_count_match. json_array_len of a malformed string
// returns 0, silently skipping all checks. Caching requires language-level static
// const arrays (not available in current EL). Migrate when EL gains that feature.
// Matching helpers (single loops only el escapes while-body mutation via // Matching helpers (single loops only el escapes while-body mutation via
// top-level let rebinds; nested loops would not advance) ──────────────────── // top-level let rebinds; nested loops would not advance) ────────────────────
+11 -12
View File
@@ -261,9 +261,8 @@ fn layered_cycle(raw_input: String) -> String {
let screen_result: String = safety_screen(raw_input, history) let screen_result: String = safety_screen(raw_input, history)
let screen_action: String = json_get(screen_result, "action") let screen_action: String = json_get(screen_result, "action")
// ISSUE 4: safe-mode guard -- if safety_screen returned invalid/empty action, // ISSUE 4: safe-mode guard. If safety_screen returned an invalid/empty action
// refuse the turn rather than silently passing unscreened input to upper layers. // (engram failure or internal error), refuse rather than pass unscreened input.
// Valid actions: "hard_bell", "soft_bell", "pass". Anything else = corrupt envelope.
let valid_action: Bool = str_eq(screen_action, "hard_bell") let valid_action: Bool = str_eq(screen_action, "hard_bell")
|| str_eq(screen_action, "soft_bell") || str_eq(screen_action, "soft_bell")
|| str_eq(screen_action, "pass") || str_eq(screen_action, "pass")
@@ -278,8 +277,8 @@ fn layered_cycle(raw_input: String) -> String {
// history where they could leak context to subsequent turns. They are persisted // history where they could leak context to subsequent turns. They are persisted
// separately by safety_log_bell() into the Episodic tier with restricted labels. // separately by safety_log_bell() into the Episodic tier with restricted labels.
// //
// ISSUE 6: safety_log_bell for hard bells is already called INSIDE safety_screen // ISSUE 6: safety_log_bell already called inside safety_screen (line 140).
// (safety.el line 140). Do NOT call it again here -- double-log avoided. // Do NOT call it again here -- that would double-log every hard bell.
// //
// safety_validate second param: when screen_action is "hard_bell", safety_validate // safety_validate second param: when screen_action is "hard_bell", safety_validate
// receives the sentinel string "hard_bell" (not a normal screen action). The safety // receives the sentinel string "hard_bell" (not a normal screen action). The safety
@@ -321,13 +320,13 @@ fn layered_cycle(raw_input: String) -> String {
json_get(steward_result, "redirect_to") json_get(steward_result, "redirect_to")
} }
// ISSUE 1: apply pre-LLM bell augmentation on layered_cycle path. // ISSUE 1: pre-LLM bell augmentation for layered_cycle path.
// safety_augment_system injects soft/hard directive into system prompt before LLM call. // safety_augment_system appends soft/hard directive to system prompt when bell fires,
// Stored in state so imprint_respond can consume it. // ensuring LLM processes message WITH the safety directive -- not just post-output gate.
// TODO: wire directly into imprint_respond when it accepts a system_override param. // Stored in state as "layered_cycle_safety_system_addendum" for imprint_respond to use.
// ISSUE 3 TODO: no semantic/embedding crisis detection. Keyword-only means signals // TODO: wire directly when imprint_respond gains system_override param (imprint.el change).
// evading the phrase list pass through with zero augmentation. Semantic layer is a // ISSUE 3 TODO: no semantic crisis detection. Keyword-only means signals that evade
// separate architectural decision requiring embedding inference on every message. // the phrase list pass with zero augmentation. Semantic layer = separate decision.
let augmented_addendum: String = safety_augment_system("", raw_input) let augmented_addendum: String = safety_augment_system("", raw_input)
state_set("layered_cycle_safety_system_addendum", augmented_addendum) state_set("layered_cycle_safety_system_addendum", augmented_addendum)