From 4c5b812cea51676fef3f71958ab6ca3355d3694c Mon Sep 17 00:00:00 2001 From: Clemento Date: Fri, 22 May 2026 10:25:26 -0300 Subject: [PATCH 1/2] chore(chat): log full provider errors during chat turns The user-facing chat panel only sees the short `LLMError::to_string()` wrapper (e.g. "Error decoding response"), which strips the original HTTP status / body / decode path. Thread the session Logger and a `/` tag through `ChatTurn` and log every: - stream-open retry failure (Display + Debug shape) - mid-stream error (with round + partial-text length) - tool dispatch drop - tool-budget overrun - turn start / done summary - build_client failure so post-mortem diagnosis from `.rowdy/.log` is possible. --- src/action/chat.rs | 11 +++++ src/llm/worker.rs | 100 +++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 103 insertions(+), 8 deletions(-) diff --git a/src/action/chat.rs b/src/action/chat.rs index 59057b3..73be58b 100644 --- a/src/action/chat.rs +++ b/src/action/chat.rs @@ -145,6 +145,13 @@ fn submit(app: &mut App) { let client = match build_client(&entry, keystore, &system_prompt) { Ok(c) => c, Err(err) => { + app.log.error( + "chat", + format!( + "build_client failed (provider={:?}, model={}): {err}", + entry.backend, entry.model + ), + ); app.chat .push_message(ChatMessage::assistant_text(format!("Build error: {err}"))); return; @@ -153,6 +160,8 @@ fn submit(app: &mut App) { let history = app.chat.messages.clone(); let evt_tx = app.evt_tx.clone(); + let log = app.log.clone(); + let provider_tag = format!("{:?}/{}", entry.backend, entry.model).to_lowercase(); // Snapshot the tool list at submit time so a mid-turn settings // change doesn't shift the catalog the model is reasoning against // (the gate in `on_tool_request` reads the *current* mode at call @@ -166,6 +175,8 @@ fn submit(app: &mut App) { history, evt_tx, tools, + log, + provider_tag, }); app.chat.streaming = true; } diff --git a/src/llm/worker.rs b/src/llm/worker.rs index 82f52d7..388bbff 100644 --- a/src/llm/worker.rs +++ b/src/llm/worker.rs @@ -22,9 +22,12 @@ use serde_json::Value; use tokio::sync::mpsc::UnboundedSender; use tokio::sync::oneshot; +use crate::log::Logger; use crate::state::chat::{ChatBlock, ChatMessage, ChatRole}; use crate::worker::{IntrospectTarget, WorkerEvent}; +const TARGET: &str = "chat"; + /// Cap on tool-call rounds in a single turn so a misbehaving model can't /// pin the worker. Sized to comfortably fit codebase-exploration tasks /// (grep → read → grep → describe → write_buffer chains can run 6-8 @@ -79,6 +82,16 @@ pub struct ChatTurn { /// time, filtered by the user's `ReadToolsMode` preference (Off /// strips the fs read tools from the list entirely). pub tools: Vec, + /// Rotating session log. The user only sees the short + /// `ChatDelta::Error(msg)` blurb in the chat panel; full provider + /// errors (including the original `LLMError` Debug shape) land here + /// so failures like "Error decoding response" can be diagnosed + /// after the fact. + pub log: Logger, + /// Human-readable provider tag for log lines — + /// `"/"` (e.g. `"openai/gpt-4.1-mini"`). Logged on + /// every retry / error so it's obvious which provider misbehaved. + pub provider_tag: String, } /// A tool call whose execution is paused until an introspection result @@ -125,19 +138,35 @@ async fn run_turn(turn: ChatTurn) { history, evt_tx, tools, + log, + provider_tag, } = turn; let mut messages: Vec = history.iter().map(translate_message).collect(); let mut full_text = String::new(); + log.info( + TARGET, + format!( + "turn start: provider={provider_tag} history_msgs={} tools={}", + messages.len(), + tools.len() + ), + ); + for round in 0..=MAX_TOOL_ROUNDS { - let mut stream = match open_stream_with_retry(&*client, &messages, &tools).await { - Ok(s) => s, - Err(err) => { - send_error(&evt_tx, err); - return; - } - }; + let mut stream = + match open_stream_with_retry(&*client, &messages, &tools, &log, &provider_tag).await { + Ok(s) => s, + Err(err) => { + log.error( + TARGET, + format!("turn aborted (round={round}, provider={provider_tag}): {err}"), + ); + send_error(&evt_tx, err); + return; + } + }; let mut round_text = String::new(); let mut completed_tool_calls: Vec = Vec::new(); @@ -173,6 +202,12 @@ async fn run_turn(turn: ChatTurn) { Err(_) => { // Receiver dropped without replying — surface // an error rather than hanging silently. + log.error( + TARGET, + format!( + "tool dispatch dropped (provider={provider_tag}, round={round})" + ), + ); send_error(&evt_tx, "tool dispatch dropped".into()); return; } @@ -190,6 +225,19 @@ async fn run_turn(turn: ChatTurn) { } Ok(_) => {} // ToolUseStart, ToolUseInputDelta, Done — fine to ignore. Err(err) => { + // Mid-stream errors don't retry (partial deltas may + // already be painted), so this is terminal for the + // turn — log the full Debug shape because Display + // on `LLMError` collapses provider HTTP / decode + // detail into the short "Error decoding response" + // / "HTTP error" wrappers we'd otherwise lose. + log.error( + TARGET, + format!( + "mid-stream error (provider={provider_tag}, round={round}, partial_text_len={}): {err} debug={err:?}", + round_text.len() + ), + ); send_error(&evt_tx, err.to_string()); return; } @@ -198,11 +246,25 @@ async fn run_turn(turn: ChatTurn) { if completed_tool_calls.is_empty() { // Model finished without calling another tool — done. + log.info( + TARGET, + format!( + "turn done: provider={provider_tag} rounds={} full_text_len={}", + round + 1, + full_text.len() + ), + ); let _ = evt_tx.send(WorkerEvent::ChatDelta(ChatDelta::Done { full_text })); return; } if round == MAX_TOOL_ROUNDS { + log.error( + TARGET, + format!( + "tool-call budget exceeded (provider={provider_tag}, MAX_TOOL_ROUNDS={MAX_TOOL_ROUNDS})" + ), + ); send_error(&evt_tx, "tool-call budget exceeded — aborting turn".into()); return; } @@ -236,6 +298,8 @@ async fn open_stream_with_retry( client: &dyn LLMProvider, messages: &[LlmChatMessage], tools: &[Tool], + log: &Logger, + provider_tag: &str, ) -> Result< std::pin::Pin< Box> + Send>, @@ -245,8 +309,28 @@ async fn open_stream_with_retry( let mut last_err: Option = None; for attempt in 1..=MAX_STREAM_ATTEMPTS { match client.chat_stream_with_tools(messages, Some(tools)).await { - Ok(stream) => return Ok(stream), + Ok(stream) => { + if attempt > 1 { + log.info( + TARGET, + format!( + "chat stream opened on retry (provider={provider_tag}, attempt={attempt}/{MAX_STREAM_ATTEMPTS})" + ), + ); + } + return Ok(stream); + } Err(err) => { + // Display strips a lot of provider detail (the "Error + // decoding response" wrapper, for instance, hides the + // actual JSON parse path and the source body). Log + // Debug too so the failure is recoverable from logs. + log.warn( + TARGET, + format!( + "chat stream open failed (provider={provider_tag}, attempt={attempt}/{MAX_STREAM_ATTEMPTS}): {err} debug={err:?}" + ), + ); last_err = Some(err.to_string()); if attempt < MAX_STREAM_ATTEMPTS { let backoff = STREAM_RETRY_BACKOFF_MS[(attempt - 1) as usize]; From 3e6ea27b2f2740b67c05b496e1d7d3d34c049652 Mon Sep 17 00:00:00 2001 From: Clemento Date: Fri, 22 May 2026 10:37:17 -0300 Subject: [PATCH 2/2] chore: bump version to 0.16.1 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 451fd7a..2d738e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2863,7 +2863,7 @@ dependencies = [ [[package]] name = "rowdy" -version = "0.16.0" +version = "0.16.1" dependencies = [ "anyhow", "arboard", diff --git a/Cargo.toml b/Cargo.toml index 5def414..8bdb118 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rowdy" -version = "0.16.0" +version = "0.16.1" edition = "2024" rust-version = "1.86" license = "MIT"