use std::collections::HashSet;
use std::sync::Arc;
use crate::event::{Event, EventSink};
use crate::message::{Content, Message, Role};
use crate::output::{ModelOutput, Step, StopReason};
use crate::provider::{ModelProvider, ProviderError};
use crate::request::{ChatParams, ModelRequest};
use crate::session::Session;
use crate::tool::{ToolContext, ToolProgress, ToolResolver, ToolResult, ToolStatus};
#[derive(Debug, PartialEq, Eq, thiserror::Error)]
pub enum AgentError {
#[error("agent built without a model provider")]
MissingProvider,
#[error(transparent)]
Provider(#[from] ProviderError),
#[error(transparent)]
Commit(#[from] CommitError),
#[error(
"stopped after {0} steps without finishing; send a follow-up \
to keep going, or raise the step limit"
)]
MaxIterations(u32),
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("commit failed: {0}")]
pub struct CommitError(
pub String,
);
impl AgentError {
pub fn is_retryable(&self) -> bool {
matches!(self, AgentError::Provider(err) if err.is_retryable())
}
}
struct ForwardingSink<'a> {
inner: &'a mut dyn EventSink,
announced: HashSet<String>,
}
impl EventSink for ForwardingSink<'_> {
fn emit(&mut self, event: Event<'_>) {
if let Event::ToolCallStart { id, .. } = event {
self.announced.insert(id.to_owned());
}
self.inner.emit(event);
}
}
struct CallProgress<'sink, 'a> {
id: String,
events: &'sink mut ForwardingSink<'a>,
}
impl ToolProgress for CallProgress<'_, '_> {
fn output(&mut self, chunk: &str) {
self.events.emit(Event::ToolOutputDelta {
id: &self.id,
chunk,
});
}
fn status(&mut self, status: ToolStatus) {
self.events.emit(Event::ToolStatus {
id: &self.id,
status: &status,
});
}
}
pub type CommitHook = Box<dyn Fn(&Session) -> Result<(), CommitError> + Send + Sync>;
pub struct Agent {
provider: Arc<dyn ModelProvider>,
resolver: Option<Arc<dyn ToolResolver>>,
system: Option<String>,
params: ChatParams,
commit: Option<CommitHook>,
steps: u32,
}
impl std::fmt::Debug for Agent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Agent")
.field("provider", &"<dyn ModelProvider>")
.field(
"resolver",
&self.resolver.as_ref().map(|_| "<dyn ToolResolver>"),
)
.field("system", &self.system)
.field("params", &self.params)
.field("commit", &self.commit.as_ref().map(|_| "<commit hook>"))
.field("steps", &self.steps)
.finish()
}
}
impl Agent {
pub fn builder() -> AgentBuilder {
AgentBuilder::default()
}
pub async fn step(
&mut self,
session: &mut Session,
sink: &mut dyn EventSink,
ctx: &ToolContext,
) -> Result<Step, AgentError> {
let iteration = self.steps;
self.steps += 1;
close_orphaned_calls(&mut session.history);
let mut events = ForwardingSink {
inner: sink,
announced: HashSet::new(),
};
events.emit(Event::StepStart { iteration });
if let Some(commit) = &self.commit {
commit(session).map_err(AgentError::Commit)?;
}
let req = ModelRequest {
system: self.system.clone(),
messages: session.history.clone(),
tools: self
.resolver
.as_ref()
.map(|r| r.specs())
.unwrap_or_default(),
params: self.params.clone(),
};
let out = self.provider.complete(req, &mut events).await?;
let call_indices: Vec<usize> = out
.message
.content
.iter()
.enumerate()
.filter_map(|(i, c)| matches!(c, Content::ToolCall(_)).then_some(i))
.collect();
session.history.push(out.message.clone());
if out.stop_reason == StopReason::ToolUse {
if let Some(commit) = &self.commit {
commit(session).map_err(AgentError::Commit)?;
}
for i in call_indices {
let Some(Content::ToolCall(call)) = out.message.content.get(i) else {
continue;
};
if !events.announced.contains(&call.id) {
events.emit(Event::ToolCallStart {
id: &call.id,
name: &call.name,
});
}
let result = {
let mut progress = CallProgress {
id: call.id.clone(),
events: &mut events,
};
match &self.resolver {
Some(resolver) => {
resolver
.resolve_streaming(call.clone(), ctx, &mut progress)
.await
}
None => ToolResult::failure(&call.id, "no tool resolver is configured"),
}
};
events.emit(Event::ToolResult {
id: &result.id,
content: &result.content,
is_error: result.is_error,
code: result.code,
});
session.history.push(Message {
role: Role::Tool,
content: vec![Content::ToolResult(result)],
});
}
}
if let Some(commit) = &self.commit {
commit(session).map_err(AgentError::Commit)?;
}
events.emit(Event::StepEnd {
usage: out.usage,
stop_reason: &out.stop_reason,
});
Ok(Step { output: out })
}
pub fn system(&self) -> Option<&str> {
self.system.as_deref()
}
pub fn set_commit_hook(&mut self, hook: Option<CommitHook>) {
self.commit = hook;
}
}
#[derive(Default)]
pub struct AgentBuilder {
provider: Option<Arc<dyn ModelProvider>>,
resolver: Option<Arc<dyn ToolResolver>>,
system: Option<String>,
params: Option<ChatParams>,
commit: Option<CommitHook>,
}
impl std::fmt::Debug for AgentBuilder {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AgentBuilder")
.field(
"provider",
&self.provider.as_ref().map(|_| "<dyn ModelProvider>"),
)
.field(
"resolver",
&self.resolver.as_ref().map(|_| "<dyn ToolResolver>"),
)
.field("system", &self.system)
.field("params", &self.params)
.field("commit", &self.commit.as_ref().map(|_| "<commit hook>"))
.finish()
}
}
impl AgentBuilder {
pub fn provider(mut self, provider: Arc<dyn ModelProvider>) -> Self {
self.provider = Some(provider);
self
}
pub fn resolver(mut self, resolver: Arc<dyn ToolResolver>) -> Self {
self.resolver = Some(resolver);
self
}
pub fn system(mut self, system: impl Into<String>) -> Self {
self.system = Some(system.into());
self
}
pub fn params(mut self, params: ChatParams) -> Self {
self.params = Some(params);
self
}
pub fn commit_hook(mut self, hook: CommitHook) -> Self {
self.commit = Some(hook);
self
}
pub fn build(self) -> Result<Agent, AgentError> {
Ok(Agent {
provider: self.provider.ok_or(AgentError::MissingProvider)?,
resolver: self.resolver,
system: self.system,
params: self.params.unwrap_or_default(),
commit: self.commit,
steps: 0,
})
}
}
pub fn close_orphaned_calls(history: &mut Vec<Message>) -> Vec<String> {
let called: Vec<&String> = history
.iter()
.flat_map(|message| message.content.iter())
.filter_map(|content| match content {
Content::ToolCall(call) => Some(&call.id),
_ => None,
})
.collect();
let answered: HashSet<&String> = history
.iter()
.flat_map(|message| message.content.iter())
.filter_map(|content| match content {
Content::ToolResult(result) => Some(&result.id),
_ => None,
})
.collect();
let mut orphans: Vec<String> = Vec::new();
for id in called {
if !answered.contains(id) && !orphans.contains(id) {
orphans.push(id.clone());
}
}
for id in &orphans {
history.push(Message {
role: Role::Tool,
content: vec![Content::ToolResult(ToolResult::failure(
id.clone(),
"the turn was interrupted before this call ran; nothing happened",
))],
});
}
orphans
}
pub async fn run_turn(
agent: &mut Agent,
session: &mut Session,
input: Message,
sink: &mut dyn EventSink,
ctx: &ToolContext,
max_iterations: u32,
) -> Result<ModelOutput, AgentError> {
session.history.push(input);
for _ in 0..max_iterations {
let step = agent.step(session, sink, ctx).await?;
if step.output.stop_reason != StopReason::ToolUse {
return Ok(step.output);
}
}
Err(AgentError::MaxIterations(max_iterations))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tool::ToolCall;
#[test]
fn a_dangling_call_is_answered_with_one_synthetic_failure() {
let mut history = vec![
Message::text(Role::User, "run it"),
Message {
role: Role::Assistant,
content: vec![Content::ToolCall(ToolCall {
id: "call-1".into(),
name: "bash".into(),
args: serde_json::json!({"command": "echo hi"}),
provider_data: serde_json::Value::Null,
})],
},
];
let answered = close_orphaned_calls(&mut history);
assert_eq!(answered, ["call-1"]);
assert_eq!(history.len(), 3);
let Some(Content::ToolResult(result)) = history[2].content.first() else {
panic!("the tail is the synthetic result");
};
assert_eq!(result.id, "call-1");
assert!(result.is_error, "an interrupted call reads as a failure");
}
#[test]
fn an_answered_history_is_left_alone() {
let mut history = vec![
Message {
role: Role::Assistant,
content: vec![Content::ToolCall(ToolCall {
id: "call-1".into(),
name: "bash".into(),
args: serde_json::json!({"command": "echo hi"}),
provider_data: serde_json::Value::Null,
})],
},
Message {
role: Role::Tool,
content: vec![Content::ToolResult(ToolResult::failure(
"call-1",
"already answered",
))],
},
];
let before = history.clone();
assert!(close_orphaned_calls(&mut history).is_empty());
assert_eq!(history.len(), before.len());
}
#[test]
fn only_the_unanswered_calls_are_repaired() {
let call = |id: &str| {
Content::ToolCall(ToolCall {
id: id.into(),
name: "bash".into(),
args: serde_json::json!({"command": "echo hi"}),
provider_data: serde_json::Value::Null,
})
};
let mut history = vec![
Message {
role: Role::Assistant,
content: vec![call("answered"), call("dangling"), call("duplicated")],
},
Message {
role: Role::Tool,
content: vec![Content::ToolResult(ToolResult::failure(
"answered",
"the one that ran",
))],
},
Message {
role: Role::Tool,
content: vec![Content::ToolResult(ToolResult::failure(
"duplicated",
"the twin that ran",
))],
},
Message {
role: Role::Assistant,
content: vec![Content::Text("done".into())],
},
];
let answered = close_orphaned_calls(&mut history);
assert_eq!(answered, ["dangling"]);
assert_eq!(history.len(), 5);
}
#[test]
fn a_retryable_provider_failure_reads_retryable() {
assert!(AgentError::Provider(ProviderError::Network("dead link".into())).is_retryable());
assert!(AgentError::Provider(ProviderError::Timeout).is_retryable());
assert!(
AgentError::Provider(ProviderError::Api {
message: "server error (HTTP 502)".into(),
status: Some(502),
})
.is_retryable()
);
}
#[test]
fn everything_else_reads_permanent() {
assert_eq!(AgentError::MissingProvider, AgentError::MissingProvider);
assert!(!AgentError::MissingProvider.is_retryable());
assert!(
!AgentError::Provider(ProviderError::Api {
message: "rate limit".into(),
status: Some(429),
})
.is_retryable()
);
assert!(!AgentError::Commit(CommitError("disk full".into())).is_retryable());
assert!(!AgentError::MaxIterations(32).is_retryable());
}
}