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