BareGit

Expose safe invocation error codes

Author: MetroWind <chris.corsair@gmail.com>
Date: Thu Jul 23 21:47:47 2026 -0700
Commit: ec447d0cdc1b59f655162dc0c19b128db66493f6

Changes

diff --git a/config.example.toml b/config.example.toml
index a3f3816..2f3983c 100644
--- a/config.example.toml
+++ b/config.example.toml
@@ -32,7 +32,7 @@ temperature = 0.7
 
 [persona]
 path = "/etc/lisa/persona.md"
-generic_error = "Sorry—my thoughts got tangled. Please try me again."
+generic_error = "Sorry—my thoughts got tangled. Please try me again. [Error: {error_code}]"
 overload_error = "I have too much to untangle at once. Try a narrower question."
 
 [context]
diff --git a/designs/design-0-architecture.md b/designs/design-0-architecture.md
index 368e0ef..94d2ce4 100644
--- a/designs/design-0-architecture.md
+++ b/designs/design-0-architecture.md
@@ -426,7 +426,7 @@ temperature = 0.7
 
 [persona]
 path = "/etc/lisa/persona.md"
-generic_error = "Sorry—my thoughts got tangled. Please try me again."
+generic_error = "Sorry—my thoughts got tangled. Please try me again. [Error: {error_code}]"
 overload_error = "I have too much to untangle at once. Try a narrower question."
 
 [context]
diff --git a/src/app.rs b/src/app.rs
index 8392dda..b33462b 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -16,7 +16,7 @@ use tokio_util::sync::CancellationToken;
 use tracing::{info, warn};
 
 use crate::{
-    config::AppConfig,
+    config::{AppConfig, ERROR_CODE_PLACEHOLDER},
     context::{buildContext, renderContext},
     errors::AppError,
     llm::{LlmClient, ModelReply, appendToolResult, initialMessages, systemPrompt},
@@ -268,8 +268,9 @@ impl LisaApp {
                         .answerInvocation(&invocation, &event)
                         .await
                         .unwrap_or_else(|error| {
-                            warn!(error = %error, "invocation_using_fallback");
-                            self.fallbackPayload(&invocation, &event)
+                            let error_code = error.userVisibleCode();
+                            warn!(error = %error, error_code, "invocation_using_fallback");
+                            self.fallbackPayload(&invocation, &event, error_code)
                         });
                     typing.stop().await;
                     self.state
@@ -357,6 +358,7 @@ impl LisaApp {
         &self,
         invocation: &crate::models::InvocationRecord,
         event: &Value,
+        error_code: &str,
     ) -> String {
         let excerpt = event
             .pointer("/content/body")
@@ -366,7 +368,11 @@ impl LisaApp {
             &invocation.event_id,
             &invocation.sender_id,
             excerpt,
-            &self.config.persona.generic_error,
+            &self
+                .config
+                .persona
+                .generic_error
+                .replace(ERROR_CODE_PLACEHOLDER, error_code),
             self.config.llm.max_response_characters,
         )
         .expect("validated configured fallback must render")
diff --git a/src/config.rs b/src/config.rs
index 13dfa6e..a4dc304 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -11,6 +11,9 @@ use url::Url;
 
 use crate::errors::ConfigurationError;
 
+/// Placeholder in `persona.generic_error` replaced with a safe error code.
+pub const ERROR_CODE_PLACEHOLDER: &str = "{error_code}";
+
 const MAX_SYNC_TIMELINE_LIMIT: u32 = 1_000;
 const MAX_GAP_PAGE_LIMIT: u32 = 100;
 const MAX_CONCURRENT_ROOMS: u32 = 64;
@@ -548,8 +551,16 @@ fn validate_persona(value: &RawPersonaConfig) -> Result<(), ConfigurationError>
         || value.overload_error.trim().is_empty()
         || value.generic_error.chars().count() > 4_000
         || value.overload_error.chars().count() > 4_000
+        || value
+            .generic_error
+            .matches(ERROR_CODE_PLACEHOLDER)
+            .count()
+            != 1
     {
-        return Err(invalid("persona", "contains an empty or oversized value"));
+        return Err(invalid(
+            "persona",
+            "must contain non-empty bounded values and exactly one {error_code} placeholder",
+        ));
     }
     Ok(())
 }
@@ -641,7 +652,7 @@ temperature = 0.7
 
 [persona]
 path = "persona.md"
-generic_error = "Sorry—my thoughts got tangled. Please try me again."
+generic_error = "Sorry—my thoughts got tangled. Please try me again. [Error: {error_code}]"
 overload_error = "I have too much to untangle at once. Try a narrower question."
 
 [context]
@@ -691,6 +702,16 @@ user_agent = "LisaBot/1.0"
         assert!(config.matrix.allowed_room_ids.contains("!private"));
     }
 
+    #[test]
+    fn rejects_error_template_without_placeholder() {
+        let config = CONFIG.replace(" [Error: {error_code}]", "");
+        assert!(AppConfig::from_toml_with_env(&config, |name| {
+            matches!(name, "LISA_MATRIX_PASSWORD" | "LISA_LLM_API_KEY")
+                .then(|| "secret-canary".to_owned())
+        })
+        .is_err());
+    }
+
     #[test]
     fn rejects_literal_secret_and_duplicate_room() {
         let literal = CONFIG.replace("env:LISA_MATRIX_PASSWORD", "secret");
diff --git a/src/errors.rs b/src/errors.rs
index cc3e6ee..121950a 100644
--- a/src/errors.rs
+++ b/src/errors.rs
@@ -113,6 +113,24 @@ pub enum AppError {
 }
 
 impl AppError {
+    /// Returns a safe, stable code that may be shown in a room reply.
+    #[must_use]
+    pub fn userVisibleCode(&self) -> &'static str {
+        match self {
+            Self::Configuration(_) => "E_CONFIGURATION",
+            Self::State(_) => "E_STATE",
+            Self::Matrix(MatrixError::InvalidCredentials) => "E_MATRIX_CREDENTIALS",
+            Self::Matrix(MatrixError::IdentityMismatch) => "E_MATRIX_IDENTITY",
+            Self::Matrix(_) => "E_MATRIX_REQUEST",
+            Self::Llm(LlmError::Request(error)) if error.is_timeout() => "E_LLM_TIMEOUT",
+            Self::Llm(LlmError::Request(_)) => "E_LLM_REQUEST",
+            Self::Llm(LlmError::Status(status)) if status.as_u16() == 429 => "E_LLM_HTTP_429",
+            Self::Llm(LlmError::Status(status)) if status.is_server_error() => "E_LLM_HTTP_5XX",
+            Self::Llm(LlmError::Status(_)) => "E_LLM_HTTP",
+            Self::Llm(LlmError::Protocol) => "E_LLM_PROTOCOL",
+        }
+    }
+
     /// Returns whether the Matrix session must be recreated before continuing.
     #[must_use]
     pub fn needsMatrixReauthentication(&self) -> bool {
@@ -129,3 +147,16 @@ impl AppError {
         )
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::{AppError, LlmError};
+
+    #[test]
+    fn exposes_only_stable_llm_error_codes() {
+        assert_eq!(
+            AppError::from(LlmError::Protocol).userVisibleCode(),
+            "E_LLM_PROTOCOL"
+        );
+    }
+}
diff --git a/src/matrix.rs b/src/matrix.rs
index e971084..a80cbab 100644
--- a/src/matrix.rs
+++ b/src/matrix.rs
@@ -454,7 +454,7 @@ max_tool_calls = 6
 temperature = 0.7
 [persona]
 path = "persona.md"
-generic_error = "Sorry"
+generic_error = "Sorry [{error_code}]"
 overload_error = "Too much"
 [context]
 max_events = 40