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 crates/goat-agent/src/delegate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ pub(crate) async fn run_delegation(
let result = match outcome {
LoopOutcome::Completed => Ok(final_text(conversation.messages())),
LoopOutcome::Cancelled => Ok("(agent interrupted)".to_owned()),
LoopOutcome::Failed(message) => Err(message),
LoopOutcome::Failed(message, _) => Err(message),
};
let _ = ctx
.events
Expand Down
11 changes: 8 additions & 3 deletions crates/goat-agent/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1172,10 +1172,14 @@ mod tests {

let mut saw_retry = false;
let mut error_message = String::new();
let mut error_hint = String::new();
while let Some(event) = events.recv().await {
match event {
Event::Retrying { .. } => saw_retry = true,
Event::Error { message, .. } => error_message = message,
Event::Error { message, hint, .. } => {
error_message = message;
error_hint = hint.unwrap_or_default();
}
Event::TaskDone { interrupted, .. } => {
assert!(interrupted);
break;
Expand All @@ -1185,10 +1189,11 @@ mod tests {
}
assert!(!saw_retry, "auth failures must not retry");
assert!(
error_message.contains("/config to re-login"),
error_message.contains("authentication failed"),
"{error_message}"
);
assert!(error_message.contains("progress saved"), "{error_message}");
assert!(error_hint.contains("/config to re-login"), "{error_hint}");
assert!(error_hint.contains("progress saved"), "{error_hint}");
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
}

Expand Down
3 changes: 2 additions & 1 deletion crates/goat-agent/src/persist.rs
Original file line number Diff line number Diff line change
Expand Up @@ -271,12 +271,13 @@ pub(crate) async fn finalize_turn(ctx: &Ctx<'_>, id: TaskId, outcome: &TurnEnd,
})
.await;
}
TurnEnd::Failed(message) => {
TurnEnd::Failed(message, hint) => {
let _ = ctx
.events
.send(Event::Error {
id: Some(id),
message: message.clone(),
hint: hint.clone(),
})
.await;
if let Some(turn) = ids.turn_db_id
Expand Down
36 changes: 33 additions & 3 deletions crates/goat-agent/src/retry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,29 @@ pub(crate) fn reason_label(error: &StreamError) -> &'static str {
pub(crate) fn failure_message(error: &StreamError, target: &goat_protocol::ModelTarget) -> String {
match error {
StreamError::Auth { message } => format!(
"authentication failed ({}/{}): {message} · /config to re-login · progress saved — send a message to continue",
"authentication failed ({}/{}): {message}",
target.provider, target.account,
),
other => other.to_string(),
}
}

pub(crate) fn error_hint(error: &StreamError) -> Option<String> {
match error {
StreamError::Auth { .. } => {
Some("/config to re-login — progress saved, send a message to continue".to_owned())
}
StreamError::ContextOverflow { .. } => {
Some("/compact to free context, then resend".to_owned())
}
StreamError::RateLimited { .. } | StreamError::Overloaded { .. } => {
Some("wait a moment and resend, or /model to switch".to_owned())
}
StreamError::Transport { .. } => Some("check your connection and resend".to_owned()),
StreamError::InvalidRequest { .. } | StreamError::Other { .. } => None,
}
}

fn format_elapsed(elapsed: Duration) -> String {
let secs = elapsed.as_secs();
if secs < 60 {
Expand Down Expand Up @@ -189,7 +205,21 @@ mod tests {
};
let message = super::failure_message(&StreamError::auth("expired"), &target);
assert!(message.contains("anthropic/work"));
assert!(message.contains("/config to re-login"));
assert!(message.contains("progress saved"));
let hint = super::error_hint(&StreamError::auth("expired")).unwrap();
assert!(hint.contains("/config to re-login"));
assert!(hint.contains("progress saved"));
}

#[test]
fn error_hint_covers_retryable_and_overflow() {
assert!(super::error_hint(&StreamError::rate_limited("x", None)).is_some());
assert!(super::error_hint(&StreamError::transport("x")).is_some());
assert!(
super::error_hint(&StreamError::ContextOverflow {
message: "x".into()
})
.is_some()
);
assert!(super::error_hint(&StreamError::other("x")).is_none());
}
}
14 changes: 10 additions & 4 deletions crates/goat-agent/src/rounds.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ pub(crate) enum RoundOutcome {
pub(crate) enum LoopOutcome {
Completed,
Cancelled,
Failed(String),
Failed(String, Option<String>),
}

async fn drain_steering(ctx: &Ctx<'_>, run: &Run<'_>, conversation: &mut Conversation) {
Expand Down Expand Up @@ -403,7 +403,7 @@ pub(crate) async fn core_loop(
) -> LoopOutcome {
let mut tool_ctx = match ToolContext::new(env.cwd) {
Ok(tool_ctx) => tool_ctx,
Err(err) => return LoopOutcome::Failed(err.to_string()),
Err(err) => return LoopOutcome::Failed(err.to_string(), None),
};
tool_ctx.exec_policy = env.exec_policy.clone();
let mut rounds = 0usize;
Expand Down Expand Up @@ -444,12 +444,18 @@ pub(crate) async fn core_loop(
return LoopOutcome::Cancelled;
}
Err(crate::compaction::CompactionError::Failed(message)) => {
return LoopOutcome::Failed(message);
return LoopOutcome::Failed(
message,
Some("/clear to reset the conversation".to_owned()),
);
}
}
}
RoundEnd::Failed(error) => {
return LoopOutcome::Failed(crate::retry::failure_message(error, env.target));
return LoopOutcome::Failed(
crate::retry::failure_message(error, env.target),
crate::retry::error_hint(error),
);
}
RoundEnd::Completed => {
compacted_for_overflow = false;
Expand Down
9 changes: 6 additions & 3 deletions crates/goat-agent/src/threads.rs
Original file line number Diff line number Diff line change
Expand Up @@ -265,9 +265,12 @@ pub(crate) async fn handle_resume(
});
}
}
ContentBlock::Image { .. }
| ContentBlock::Thinking { .. }
| ContentBlock::RedactedThinking { .. } => {}
ContentBlock::Thinking { text, .. } => {
if matches!(role, MessageRole::Assistant) {
entries.push(TranscriptEntry::Thinking { text: text.clone() });
}
}
ContentBlock::Image { .. } | ContentBlock::RedactedThinking { .. } => {}
}
}
parsed.push((stored.id, role, content));
Expand Down
31 changes: 25 additions & 6 deletions crates/goat-agent/src/turn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,16 +77,22 @@ async fn run_shell_command(tools: &ToolRegistry, command: &str, cwd: &std::path:
pub(crate) enum TurnEnd {
Done,
Interrupted,
Failed(String),
Failed(String, Option<String>),
Shutdown,
}

pub(crate) async fn emit_task_error(ctx: &Ctx<'_>, id: TaskId, message: String) {
pub(crate) async fn emit_task_error(
ctx: &Ctx<'_>,
id: TaskId,
message: String,
hint: Option<String>,
) {
let _ = ctx
.events
.send(Event::Error {
id: Some(id),
message,
hint,
})
.await;
let _ = ctx
Expand Down Expand Up @@ -489,7 +495,13 @@ pub(crate) async fn handle_compact(
.await;
}
Err(crate::compaction::CompactionError::Failed(message)) => {
emit_task_error(ctx, id, format!("compaction failed: {message}")).await;
emit_task_error(
ctx,
id,
format!("compaction failed: {message}"),
Some("/clear to reset the conversation".to_owned()),
)
.await;
}
}
if shutdown {
Expand Down Expand Up @@ -525,7 +537,8 @@ async fn run_one_turn(
emit_task_error(
ctx,
id,
"no model selected · /config to connect a provider".to_owned(),
"no model selected".to_owned(),
Some("/config to connect a provider".to_owned()),
)
.await;
return (TurnFlow::Idle, Vec::new());
Expand All @@ -536,7 +549,13 @@ async fn run_one_turn(
&goat_provider::ProviderId::from(resolved.provider.as_str()),
);
let Some(provider) = resolved_provider else {
emit_task_error(ctx, id, format!("unknown provider: {}", resolved.provider)).await;
emit_task_error(
ctx,
id,
format!("unknown provider: {}", resolved.provider),
Some("/config to select a provider".to_owned()),
)
.await;
return (TurnFlow::Idle, Vec::new());
};

Expand Down Expand Up @@ -660,7 +679,7 @@ async fn run_one_turn(

let turn_end = match outcome {
LoopOutcome::Completed => TurnEnd::Done,
LoopOutcome::Failed(message) => TurnEnd::Failed(message),
LoopOutcome::Failed(message, hint) => TurnEnd::Failed(message, hint),
LoopOutcome::Cancelled => {
if shutdown {
TurnEnd::Shutdown
Expand Down
6 changes: 5 additions & 1 deletion crates/goat-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,11 @@ fn frame_to_event(frame: ServerFrame) -> Option<Event> {
})
.collect(),
}),
ServerFrame::Error { message } => Some(Event::Error { id: None, message }),
ServerFrame::Error { message } => Some(Event::Error {
id: None,
message,
hint: None,
}),
_ => None,
}
}
Expand Down
1 change: 1 addition & 0 deletions crates/goat-code/src/headless.rs
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@ fn emit_decode_error(message: &str) {
let event = Event::Error {
id: None,
message: format!("headless decode error: {message}"),
hint: None,
};
if let Ok(line) = serde_json::to_string(&event) {
emit_line(&line);
Expand Down
2 changes: 2 additions & 0 deletions crates/goat-protocol/src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ pub enum Event {
Error {
id: Option<TaskId>,
message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
hint: Option<String>,
},
Notify {
kind: NotifyKind,
Expand Down
3 changes: 3 additions & 0 deletions crates/goat-protocol/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,9 @@ pub enum TranscriptEntry {
Assistant {
text: String,
},
Thinking {
text: String,
},
Tool {
call: ToolCall,
outcome: ToolOutcome,
Expand Down
15 changes: 12 additions & 3 deletions crates/goat-tui/src/app/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ impl App {
TranscriptEntry::Assistant { text } => {
self.transcript.commit_text(&text);
}
TranscriptEntry::Thinking { text } => {
self.transcript.push_thinking(text);
}
TranscriptEntry::Tool { call, outcome } => {
let id = call.id;
self.transcript.push_tool(call);
Expand Down Expand Up @@ -91,8 +94,13 @@ impl App {
}
self.model = Some(target);
}
EngineEvent::ThinkingDelta { .. } => {
EngineEvent::ThinkingDelta { id, chunk } => {
self.turn.thinking = true;
if let Some(i) = self.agent_index(id) {
self.agent_runs[i].transcript.push_thinking_delta(&chunk);
} else {
self.transcript.push_thinking_delta(&chunk);
}
}
EngineEvent::LoginProviders { .. } | EngineEvent::ThreadBound { .. } => {}
EngineEvent::CompactionStarted { id } => {
Expand Down Expand Up @@ -288,14 +296,15 @@ impl App {
if !self.focused {
self.queue_notification(crate::notification::Notification::Completion);
}
self.transcript.flush_thinking();
self.transcript.complete(interrupted);
self.reset_active_state();
if interrupted {
self.restore_queued_to_composer();
}
}
EngineEvent::Error { message, .. } => {
self.transcript.push_error(message);
EngineEvent::Error { message, hint, .. } => {
self.transcript.push_error(message, hint);
self.reset_active_state();
self.restore_queued_to_composer();
}
Expand Down
21 changes: 20 additions & 1 deletion crates/goat-tui/src/app/keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ use crate::{
impl App {
pub(crate) fn on_key(&mut self, key: KeyEvent) -> Vec<Op> {
tracing::trace!(code = ?key.code, modifiers = ?key.modifiers, "key");
if key
.modifiers
.intersects(KeyModifiers::SUPER | KeyModifiers::META)
&& matches!(key.code, KeyCode::Char('c' | 'C'))
{
self.copy_selection();
return Vec::new();
}
match &self.overlay {
Overlay::Model(_) => return self.on_picker_key(key),
Overlay::Effort(_) => return self.on_effort_picker_key(key),
Expand All @@ -30,6 +38,11 @@ impl App {
}
}
Overlay::Usage | Overlay::Help => return self.on_usage_key(key),
Overlay::ImageZoom(_) => {
self.overlay = Overlay::None;
self.dirty = true;
return Vec::new();
}
Overlay::None => {}
}
if let Some(ch) = keymap::ctrl_key(&key) {
Expand All @@ -50,6 +63,9 @@ impl App {
self.update_command_menu();
self.dirty = true;
}
't' => {
self.dirty |= self.transcript.toggle_thinking();
}
_ => {}
}
return Vec::new();
Expand Down Expand Up @@ -251,6 +267,9 @@ impl App {
}
KeyCode::Esc => {
self.dirty = true;
if self.selection.take().is_some() {
return Vec::new();
}
if let Some(id) = self.turn.active {
self.clear_arm = None;
return vec![Op::Interrupt { id }];
Expand All @@ -262,7 +281,7 @@ impl App {
return Vec::new();
}
if self.clear_arm.take().is_some() {
self.composer.clear();
self.composer.discard();
} else {
self.clear_arm = Some(CLEAR_ARM_TICKS);
}
Expand Down
Loading
Loading