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
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "rowdy"
version = "0.16.0"
version = "0.16.1"
edition = "2024"
rust-version = "1.86"
license = "MIT"
Expand Down
11 changes: 11 additions & 0 deletions src/action/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -166,6 +175,8 @@ fn submit(app: &mut App) {
history,
evt_tx,
tools,
log,
provider_tag,
});
app.chat.streaming = true;
}
Expand Down
100 changes: 92 additions & 8 deletions src/llm/worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Tool>,
/// 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 —
/// `"<backend>/<model>"` (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
Expand Down Expand Up @@ -125,19 +138,35 @@ async fn run_turn(turn: ChatTurn) {
history,
evt_tx,
tools,
log,
provider_tag,
} = turn;

let mut messages: Vec<LlmChatMessage> = 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<ToolCall> = Vec::new();
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
}
Expand All @@ -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;
}
Expand Down Expand Up @@ -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<dyn futures::stream::Stream<Item = Result<StreamChunk, llm::error::LLMError>> + Send>,
Expand All @@ -245,8 +309,28 @@ async fn open_stream_with_retry(
let mut last_err: Option<String> = 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];
Expand Down
Loading