Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions src/agent/recovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,13 +119,73 @@ pub fn classify_error(msg: &str) -> ErrorKind {
|| lower.contains("timed out")
|| lower.contains("request timeout")
|| lower.contains("server error")
// Mid-stream decode failures from reqwest/rig — the connection
// returned bytes but they didn't deserialize into the expected
// JSON envelope. Almost always transient (network blip,
// truncated chunked response, provider hiccup), so it should
// be retried like any other network error rather than surfacing
// as a hard "Other" failure.
|| lower.contains("error decoding response body")
|| lower.contains("invalid response body")
|| lower.contains("decode error")
{
return ErrorKind::Network;
}

ErrorKind::Other
}

/// Map a raw error message to a one-line user-facing explanation
/// that names *what* failed and *what to try next*. Used by the agent
/// runner when surfacing errors to the chat — beats dumping a stack
/// of `CompletionError: ProviderError: Http client error: …` at the
/// user.
///
/// The original message is appended in parentheses as the cause so
/// the user (and any bug reports) still have the underlying details.
pub fn user_facing_error(msg: &str, attempts: usize) -> String {
let kind = classify_error(msg);
let lower = msg.to_lowercase();

let (headline, hint) = match kind {
ErrorKind::Auth => (
"authentication failed talking to the LLM provider",
"check your API key env var (e.g. OPENROUTER_API_KEY) and provider config",
),
ErrorKind::RateLimit => (
"provider rate-limited the request",
"wait a moment and retry, or switch to a different model via /model",
),
ErrorKind::ContextLength => (
"conversation exceeds the model's context window",
"run /compress to summarize older turns and try again",
),
ErrorKind::Network if lower.contains("error decoding response body") => (
"lost the response stream from the provider (truncated or malformed body)",
"usually transient — retry. If it persists the provider may be having issues or returning non-JSON (HTML error pages, plaintext)",
),
ErrorKind::Network => (
"network error reaching the LLM provider",
"check connectivity / firewall / proxy; the request will retry automatically",
),
ErrorKind::Other => (
"the LLM provider returned an error we didn't recognize",
"see the cause below; consider /model to try a different provider",
),
};

let attempts_note = if attempts > 1 {
format!(" (after {} attempt(s))", attempts)
} else {
String::new()
};

format!(
"{}{}\n ↳ hint: {}\n ↳ cause: {}",
headline, attempts_note, hint, msg
)
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -158,6 +218,45 @@ mod tests {
classify_error("503 service unavailable"),
ErrorKind::Network
);
// Reqwest decode failure mid-stream — rig surfaces it as
// `CompletionError: ProviderError: Http client error: error
// decoding response body`. Should be retried like any other
// transient network blip rather than surfacing as Other.
assert_eq!(
classify_error(
"CompletionError: ProviderError: Http client error: error decoding response body"
),
ErrorKind::Network
);
assert_eq!(classify_error("decode error: EOF"), ErrorKind::Network);
}

/// `user_facing_error` produces a multi-line message with headline,
/// hint, and cause. The cause must contain the original raw
/// message so debug context isn't lost.
#[test]
fn user_facing_error_includes_cause() {
let raw = "CompletionError: ProviderError: Http client error: error decoding response body";
let pretty = user_facing_error(raw, 1);
assert!(pretty.contains("lost the response stream"));
assert!(pretty.contains("hint:"));
assert!(pretty.contains("cause:"));
assert!(pretty.contains(raw));
}

/// Auth errors get a distinct headline pointing at the API key.
#[test]
fn user_facing_error_classifies_auth() {
let pretty = user_facing_error("401 unauthorized", 1);
assert!(pretty.contains("authentication failed"));
assert!(pretty.contains("API key"));
}

/// Context-length errors point at /compress.
#[test]
fn user_facing_error_classifies_context_length() {
let pretty = user_facing_error("maximum context length exceeded", 1);
assert!(pretty.contains("/compress"));
}

#[test]
Expand Down
27 changes: 16 additions & 11 deletions src/agent/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -331,23 +331,23 @@ where

let kind = recovery::classify_error(&msg);

// Auth and unknown errors surface immediately
// Auth and unknown errors surface immediately with a
// user-friendly headline + hint + cause breakdown.
if kind == ErrorKind::Auth || kind == ErrorKind::Other {
let friendly = recovery::user_facing_error(&msg, attempts + 1);
let _ = event_tx
.send(AgentEvent::Error(CompactString::new(msg)))
.send(AgentEvent::Error(CompactString::new(friendly)))
.await;
break;
}

// Context-length errors: not retryable without compaction
// Surface a helpful error hinting at /compress
// Context-length errors aren't retryable without
// compaction — the friendly formatter already points the
// user at /compress.
if kind == ErrorKind::ContextLength {
let hint = format!(
"{} — try /compress to compact the conversation, then retry",
msg
);
let friendly = recovery::user_facing_error(&msg, attempts + 1);
let _ = event_tx
.send(AgentEvent::Error(CompactString::new(hint)))
.send(AgentEvent::Error(CompactString::new(friendly)))
.await;
break;
}
Expand All @@ -357,15 +357,20 @@ where
// without retrying — events already streamed live, so the user
// sees what got done.
if outcome.had_tool_calls {
let err = format!("{} (tool side effects already applied, not retrying)", msg);
let friendly = recovery::user_facing_error(&msg, attempts + 1);
let err = format!(
"{}\n ↳ note: tool side effects already applied; not retrying.",
friendly,
);
let _ = event_tx
.send(AgentEvent::Error(CompactString::new(err)))
.await;
break;
}

if !policy.should_retry(attempts, kind) {
let retry_msg = format!("{} (retries exhausted)", msg);
let friendly = recovery::user_facing_error(&msg, attempts + 1);
let retry_msg = format!("{}\n ↳ note: retries exhausted.", friendly);
let _ = event_tx
.send(AgentEvent::Error(CompactString::new(retry_msg)))
.await;
Expand Down
107 changes: 46 additions & 61 deletions src/ui/avatar.rs
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
//! Bottom-left ASCII avatar.
//! Inline ASCII avatar.
//!
//! A tiny 3-row × 5-col face that lives in the left margin (cols
//! 0..5) of the bottom three terminal rows. It updates based on what
//! the agent is doing — thinking, speaking, running a tool, erroring,
//! resting — to give the chat a personable focal point and visible
//! activity feedback even when no tokens are streaming yet.
//! A tiny single-row face that lives on the input row, centered in the
//! left margin between the screen edge and the input prompt. Updates
//! based on what the agent is doing — thinking, speaking, running a
//! tool, erroring, resting — to give the chat a personable focal
//! point and visible activity feedback even when no tokens are
//! streaming yet.
//!
//! Designed to fit inside the chat band's centering indent so it
//! never overlaps with chat content or the input prompt.
//! Single-row so it never gets caught in chat scroll: chat content
//! lives on rows 0..input_top-1, the avatar lives on input_top
//! beside the prompt, and `crossterm::ScrollUp` operations don't
//! touch the input row.

use crossterm::style::Color;

/// What the agent is currently doing. The renderer picks an ascii
/// face per state and draws it at the bottom-left of the screen.
/// face per state and draws it next to the input prompt.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(not(feature = "plugin"), allow(dead_code))]
pub enum AvatarState {
Expand Down Expand Up @@ -53,67 +56,53 @@ impl AvatarState {

/// Width of the avatar in terminal columns.
pub const AVATAR_W: usize = 5;
/// Height of the avatar in terminal rows.
pub const AVATAR_H: usize = 3;

/// Return three lines of ascii art for the given state + animation
/// tick. The `tick` boolean alternates between two slightly different
/// poses per state so the avatar visibly animates (eyes / mouth)
/// without going overboard.
pub fn art(state: AvatarState, tick: bool) -> [&'static str; AVATAR_H] {
/// Return the ASCII face for the given state + animation tick. `tick`
/// alternates between two slightly different poses (blinking eyes,
/// shifting mouth) so the avatar visibly animates while the agent
/// runs without being noisy.
pub fn art(state: AvatarState, tick: bool) -> &'static str {
use AvatarState::*;
match state {
Idle => {
if tick {
[" ,-, ", "(o o)", " \\_/ "]
"(o o)"
} else {
[" ,-, ", "(- -)", " \\_/ "]
"(- -)"
}
}
Thinking => {
if tick {
[" ? ", "(o ·)", " \\_/ "]
"(o .)"
} else {
[" ? ", "(· o)", " \\_/ "]
"(. o)"
}
}
Speaking => {
if tick {
[" ,-, ", "(o o)", " \\o/ "]
"(o o)"
} else {
[" ,-, ", "(o o)", " \\O/ "]
}
}
Reading => {
if tick {
[" ,-, ", "[@ @]", " \\_/ "]
} else {
[" ,-, ", "[@ @]", " \\.. "]
"(o O)"
}
}
Reading => "[@ @]",
Writing => {
if tick {
[" ,-, ", "(>_<)", " \\_/ "]
} else {
[" ,-, ", "(-_-)", " \\_/ "]
}
}
Bash => {
if tick {
["[___]", "[$_$]", "[___]"]
"(>_<)"
} else {
["[___]", "[$ $]", "[___]"]
"(-_-)"
}
}
Alert => [" ! ", "(O_O)", " /!\\ "],
Error => [" ,-, ", "(x_x)", " /v\\ "],
Done => [" ,-, ", "(^_^)", " \\_/ "],
Bash => "[$_$]",
Alert => "(O_O)",
Error => "(x_x)",
Done => "(^_^)",
}
}

/// Color the avatar should render in for the given state. Default is
/// the active theme's agent tone; alerts and errors override to the
/// loud yellow/red of the theme so the user notices.
/// Color the avatar should render in for the given state. Errors and
/// alerts override to the theme's perm / error tones; everything else
/// uses the agent tone so it visually belongs to the chat.
pub fn color(state: AvatarState) -> Color {
use AvatarState::*;
match state {
Expand All @@ -128,10 +117,10 @@ pub fn color(state: AvatarState) -> Color {
mod tests {
use super::*;

/// Every state must produce three lines exactly `AVATAR_W` cols
/// wide. A typo'd asymmetry would visually wobble the face.
/// Every face must be exactly `AVATAR_W` cols wide so the avatar's
/// position is stable across state transitions.
#[test]
fn every_state_has_uniform_dimensions() {
fn every_state_has_uniform_width() {
let states = [
AvatarState::Idle,
AvatarState::Thinking,
Expand All @@ -145,19 +134,15 @@ mod tests {
];
for state in states {
for tick in [false, true] {
let lines = art(state, tick);
assert_eq!(lines.len(), AVATAR_H, "{:?} wrong row count", state);
for (i, line) in lines.iter().enumerate() {
assert_eq!(
line.chars().count(),
AVATAR_W,
"{:?} tick={} row {} is {:?}",
state,
tick,
i,
line,
);
}
let face = art(state, tick);
assert_eq!(
face.chars().count(),
AVATAR_W,
"{:?} tick={} is {:?}",
state,
tick,
face,
);
}
}
}
Expand All @@ -170,7 +155,7 @@ mod tests {
assert_eq!(AvatarState::from_tool_name("edit"), AvatarState::Writing);
assert_eq!(AvatarState::from_tool_name("write"), AvatarState::Writing);
assert_eq!(AvatarState::from_tool_name("bash"), AvatarState::Bash);
// Unknown tools fall back to Reading (observational default).
// Unknown tools fall back to Reading.
assert_eq!(
AvatarState::from_tool_name("mcp_some_tool"),
AvatarState::Reading
Expand Down
Loading
Loading