Improve agent retries and tool progress

This commit is contained in:
2026-08-23 22:22:32 -05:00
parent 7c106eecd5
commit f4dc6231d6
14 changed files with 913 additions and 141 deletions
@@ -353,6 +353,9 @@ pub enum ProviderRunProtocolError {
InvalidDriverObservation {
message: String,
},
InvalidTranscriptCompaction {
message: String,
},
InvalidRestoredState {
message: String,
},
@@ -415,6 +418,9 @@ impl fmt::Display for ProviderRunProtocolError {
Self::InvalidDriverObservation { message } => {
write!(f, "invalid driver observation: {message}")
}
Self::InvalidTranscriptCompaction { message } => {
write!(f, "invalid transcript compaction: {message}")
}
Self::InvalidRestoredState { message } => {
write!(f, "invalid restored provider run: {message}")
}
@@ -490,6 +496,36 @@ impl ProviderRun {
self.tool_result_archive = archive;
}
/// Replaces a summarized prefix while the run is parked between model calls.
///
/// Tool calls and results removed from the live transcript remain available to
/// `recall_tool_history` through the run-owned archive.
pub fn compact_transcript_at_model_boundary(
&mut self,
summarized_messages: usize,
summary_prefix: Vec<ConversationMessage>,
) -> Result<(), ProviderRunProtocolError> {
if !matches!(self.state, ProviderRunState::ReadyToCallModel) {
return Err(self.unexpected_state(ProviderRunPhase::ReadyToCallModel));
}
if summarized_messages == 0 || summarized_messages > self.transcript.len() {
return Err(ProviderRunProtocolError::InvalidTranscriptCompaction {
message: format!(
"cannot summarize {summarized_messages} of {} messages",
self.transcript.len()
),
});
}
let drained = self
.transcript
.drain(0..summarized_messages)
.collect::<Vec<_>>();
archive_tool_results(&mut self.tool_result_archive, drained);
self.transcript.splice(0..0, summary_prefix);
Ok(())
}
pub fn usage(&self) -> &Usage {
&self.usage
}
@@ -1662,6 +1698,48 @@ fn add_usage(total: &mut Usage, turn: &Usage) {
.saturating_add(turn.cache_creation_input_tokens);
}
fn archive_tool_results(
archive: &mut Vec<ConversationMessage>,
messages: Vec<ConversationMessage>,
) {
const MAX_TOOL_RESULT_ARCHIVE_ENTRIES: usize = 400;
let mut pending_tool_uses = Vec::new();
for message in messages {
match &message.content {
MessageContent::ToolUse { .. } => pending_tool_uses.push(message),
MessageContent::ToolResult { .. } => {
if let Some(tool_use) = pending_tool_uses.pop() {
archive.push(tool_use);
}
archive.push(message);
}
MessageContent::MultiPart(parts) => {
for part in parts {
match part {
ContentPart::ToolUse { .. } => pending_tool_uses.push(message.clone()),
ContentPart::ToolResult { .. } => {
if let Some(tool_use) = pending_tool_uses.pop() {
archive.push(tool_use);
}
archive.push(message.clone());
}
ContentPart::Text(_)
| ContentPart::Reasoning { .. }
| ContentPart::Image { .. } => {}
}
}
}
MessageContent::Text(_) => {}
}
}
archive.extend(pending_tool_uses);
if archive.len() > MAX_TOOL_RESULT_ARCHIVE_ENTRIES {
archive.drain(0..archive.len() - MAX_TOOL_RESULT_ARCHIVE_ENTRIES);
}
}
#[cfg(test)]
#[path = "provider_run_tests.rs"]
mod tests;
@@ -1211,6 +1211,60 @@ fn recovery_pending_tool_survives_another_restore_and_completes_once() {
assert_eq!(restored.state().phase(), ProviderRunPhase::ReadyToCallModel);
}
#[test]
fn transcript_compaction_at_model_boundary_archives_drained_tool_history() {
let mut run = run();
let batch = accept_tool_turn(
&mut run,
tool_turn(vec![tool_call("read", "read_files")], &["read_files"]),
);
run.complete_tool(
&batch.work_id,
successful_result("read", "important file contents"),
)
.unwrap();
run.commit_tool_batch(&batch.work_id).unwrap();
let original_len = run.transcript().len();
let summary_prefix = vec![ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text("summary".to_string()),
}];
run.compact_transcript_at_model_boundary(original_len, summary_prefix.clone())
.unwrap();
assert_eq!(run.transcript(), summary_prefix);
assert_eq!(run.tool_result_archive().len(), 2);
assert!(matches!(
run.tool_result_archive()[0].content,
MessageContent::MultiPart(_)
));
assert!(matches!(
run.tool_result_archive()[1].content,
MessageContent::MultiPart(_)
));
}
#[test]
fn transcript_compaction_is_rejected_during_a_model_call() {
let mut run = run();
let _ = next_model_call(&mut run);
assert!(matches!(
run.compact_transcript_at_model_boundary(
1,
vec![ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text("summary".to_string()),
}],
),
Err(ProviderRunProtocolError::UnexpectedState {
expected: ProviderRunPhase::ReadyToCallModel,
actual: ProviderRunPhase::AwaitingModel,
})
));
}
#[test]
fn restore_normalization_commits_a_fully_resolved_batch() {
let mut run = run();
+19
View File
@@ -383,13 +383,28 @@ fn text_indicates_context_window_exceeded(text: &str) -> bool {
|| normalized.contains("exceeds the context")
}
fn completion_error_indicates_recoverable_transport(error: &CompletionError) -> bool {
match error {
// Rig flattens non-status SSE transport failures into ProviderError.
// Preserve their transport semantics so the durable provider run can
// retry a truncated or otherwise interrupted response stream.
CompletionError::ProviderError(message) => message
.to_ascii_lowercase()
.starts_with("http client error:"),
_ => false,
}
}
fn map_completion_error(error: CompletionError) -> AgentError {
let is_context_window_exceeded = completion_error_indicates_context_window_exceeded(&error);
let is_recoverable_transport = completion_error_indicates_recoverable_transport(&error);
let status = error
.provider_response_status()
.map(|status| status.as_u16());
let kind = if is_context_window_exceeded {
AgentErrorKind::ContextWindowExceeded
} else if is_recoverable_transport {
AgentErrorKind::Transport
} else {
match status {
Some(401 | 403) => AgentErrorKind::Authentication,
@@ -522,3 +537,7 @@ mod tests {
assert!(mapped.recoverable);
}
}
#[cfg(test)]
#[path = "stream_tests.rs"]
mod regression_tests;
@@ -0,0 +1,16 @@
use galaxy_agent_core::AgentErrorKind;
use rig_core::completion::CompletionError;
use super::map_completion_error;
#[test]
fn flattened_sse_http_client_error_is_recoverable_transport() {
let error = CompletionError::ProviderError(
"Http client error: error decoding response body".to_string(),
);
let mapped = map_completion_error(error);
assert_eq!(mapped.kind, AgentErrorKind::Transport);
assert!(mapped.recoverable);
}