#![allow(dead_code)]
use crate::{
default_context::DefaultContext,
errors::AgentError,
hooks::{
AfterCompletionFn, AfterEachFn, AfterToolFn, BeforeAllFn, BeforeCompletionFn, BeforeToolFn,
Hook, HookTypes, MessageHookFn, OnStartFn, OnStopFn, OnStreamFn,
},
invoke_hooks,
state::{self, StopReason},
tools::{arg_preprocessor::ArgPreprocessor, control::Stop},
};
use std::{
collections::{HashMap, HashSet, VecDeque},
hash::{DefaultHasher, Hash as _, Hasher as _},
sync::Arc,
};
use derive_builder::Builder;
use futures_util::stream::StreamExt;
use swiftide_core::{
AgentContext, ToolBox,
chat_completion::{
ChatCompletion, ChatCompletionRequest, ChatMessage, Tool, ToolCall, ToolOutput,
},
prompt::Prompt,
};
use tracing::{Instrument, debug};
#[derive(Builder)]
pub struct Agent {
#[builder(default, setter(custom))]
pub(crate) hooks: Arc<Vec<Hook>>,
#[builder(
setter(custom),
default = Arc::new(DefaultContext::default()) as Arc<dyn AgentContext>
)]
pub(crate) context: Arc<dyn AgentContext>,
#[builder(default = Agent::default_tools(), setter(custom))]
pub(crate) tools: HashSet<Box<dyn Tool>>,
#[builder(default)]
pub(crate) toolboxes: Vec<Box<dyn ToolBox>>,
#[builder(setter(custom))]
pub(crate) llm: Box<dyn ChatCompletion>,
#[builder(setter(into, strip_option), default)]
pub(crate) system_prompt: Option<Prompt>,
#[builder(private, default = state::State::default())]
pub(crate) state: state::State,
#[builder(default, setter(strip_option))]
pub(crate) limit: Option<usize>,
#[builder(default = 3)]
pub(crate) tool_retry_limit: usize,
#[builder(default)]
pub(crate) streaming: bool,
#[builder(private, default)]
pub(crate) clear_default_tools: bool,
#[builder(private, default)]
pub(crate) tool_retries_counter: HashMap<u64, usize>,
#[builder(default = "unnamed_agent".into(), setter(into))]
pub(crate) name: String,
#[builder(private, default)]
pub(crate) pending_user_messages: VecDeque<String>,
}
impl Clone for Agent {
fn clone(&self) -> Self {
Agent {
hooks: self.hooks.clone(),
context: Arc::new(self.context.clone()),
tools: self.tools.clone(),
toolboxes: self.toolboxes.clone(),
llm: self.llm.clone(),
system_prompt: self.system_prompt.clone(),
state: self.state.clone(),
limit: self.limit,
tool_retry_limit: self.tool_retry_limit,
tool_retries_counter: HashMap::new(),
streaming: self.streaming,
name: self.name.clone(),
clear_default_tools: self.clear_default_tools,
pending_user_messages: VecDeque::new(),
}
}
}
impl std::fmt::Debug for Agent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Agent")
.field("name", &self.name)
.field(
"hooks",
&self
.hooks
.iter()
.map(std::string::ToString::to_string)
.collect::<Vec<_>>(),
)
.field(
"tools",
&self
.tools
.iter()
.map(swiftide_core::Tool::name)
.collect::<Vec<_>>(),
)
.field("llm", &"Box<dyn ChatCompletion>")
.field("state", &self.state)
.finish()
}
}
impl AgentBuilder {
pub fn context(&mut self, context: impl AgentContext + 'static) -> &mut AgentBuilder
where
Self: Clone,
{
self.context = Some(Arc::new(context) as Arc<dyn AgentContext>);
self
}
pub fn system_prompt_mut(&mut self) -> Option<&mut Prompt> {
self.system_prompt.as_mut().and_then(Option::as_mut)
}
pub fn no_system_prompt(&mut self) -> &mut Self {
self.system_prompt = Some(None);
self
}
pub fn add_hook(&mut self, hook: Hook) -> &mut Self {
let hooks = self.hooks.get_or_insert_with(Arc::default);
Arc::make_mut(hooks).push(hook);
self
}
pub fn hooks(&mut self, hooks: impl Into<Vec<Hook>>) -> &mut Self {
self.hooks = Some(Arc::new(hooks.into()));
self
}
pub fn add_tool(&mut self, tool: impl Tool + 'static) -> &mut Self {
let tools = self.tools.get_or_insert_with(HashSet::new);
if let Some(tool) = tools.replace(tool.boxed()) {
tracing::debug!("Tool {} already exists, replacing", tool.name());
}
self
}
pub fn before_all(&mut self, hook: impl BeforeAllFn + 'static) -> &mut Self {
self.add_hook(Hook::BeforeAll(Box::new(hook)))
}
pub fn on_start(&mut self, hook: impl OnStartFn + 'static) -> &mut Self {
self.add_hook(Hook::OnStart(Box::new(hook)))
}
pub fn on_stream(&mut self, hook: impl OnStreamFn + 'static) -> &mut Self {
self.streaming = Some(true);
self.add_hook(Hook::OnStream(Box::new(hook)))
}
pub fn before_completion(&mut self, hook: impl BeforeCompletionFn + 'static) -> &mut Self {
self.add_hook(Hook::BeforeCompletion(Box::new(hook)))
}
pub fn after_tool(&mut self, hook: impl AfterToolFn + 'static) -> &mut Self {
self.add_hook(Hook::AfterTool(Box::new(hook)))
}
pub fn before_tool(&mut self, hook: impl BeforeToolFn + 'static) -> &mut Self {
self.add_hook(Hook::BeforeTool(Box::new(hook)))
}
pub fn after_completion(&mut self, hook: impl AfterCompletionFn + 'static) -> &mut Self {
self.add_hook(Hook::AfterCompletion(Box::new(hook)))
}
pub fn after_each(&mut self, hook: impl AfterEachFn + 'static) -> &mut Self {
self.add_hook(Hook::AfterEach(Box::new(hook)))
}
pub fn on_new_message(&mut self, hook: impl MessageHookFn + 'static) -> &mut Self {
self.add_hook(Hook::OnNewMessage(Box::new(hook)))
}
pub fn on_stop(&mut self, hook: impl OnStopFn + 'static) -> &mut Self {
self.add_hook(Hook::OnStop(Box::new(hook)))
}
pub fn llm<LLM: ChatCompletion + Clone + 'static>(&mut self, llm: &LLM) -> &mut Self {
let boxed: Box<dyn ChatCompletion> = Box::new(llm.clone()) as Box<dyn ChatCompletion>;
self.llm = Some(boxed);
self
}
pub fn without_default_stop_tool(&mut self) -> &mut Self {
self.clear_default_tools = Some(true);
self
}
fn builder_default_tools(&self) -> HashSet<Box<dyn Tool>> {
if self.clear_default_tools.is_some_and(|b| b) {
HashSet::new()
} else {
Agent::default_tools()
}
}
pub fn tools<TOOL, I: IntoIterator<Item = TOOL>>(&mut self, tools: I) -> &mut Self
where
TOOL: Into<Box<dyn Tool>>,
{
self.tools = Some(
self.builder_default_tools()
.into_iter()
.chain(tools.into_iter().map(Into::into))
.collect(),
);
self
}
pub fn add_toolbox(&mut self, toolbox: impl ToolBox + 'static) -> &mut Self {
let toolboxes = self.toolboxes.get_or_insert_with(Vec::new);
toolboxes.push(Box::new(toolbox));
self
}
}
impl Agent {
pub fn builder() -> AgentBuilder {
AgentBuilder::default()
.tools(Agent::default_tools())
.to_owned()
}
pub fn name(&self) -> &str {
&self.name
}
pub fn default_tools() -> HashSet<Box<dyn Tool>> {
HashSet::from([Stop::default().boxed()])
}
#[tracing::instrument(skip_all, name = "agent.query", err)]
pub async fn query(&mut self, query: impl Into<Prompt>) -> Result<(), AgentError> {
let query = query
.into()
.render()
.map_err(AgentError::FailedToRenderPrompt)?;
self.run_agent(Some(query), false).await
}
pub fn add_tool(&mut self, tool: Box<dyn Tool>) {
if let Some(tool) = self.tools.replace(tool) {
tracing::debug!("Tool {} already exists, replacing", tool.name());
}
}
pub fn tools_mut(&mut self) -> &mut HashSet<Box<dyn Tool>> {
&mut self.tools
}
#[tracing::instrument(skip_all, name = "agent.query_once", err)]
pub async fn query_once(&mut self, query: impl Into<Prompt>) -> Result<(), AgentError> {
self.run_agent(Some(query), true).await
}
#[tracing::instrument(skip_all, name = "agent.run", err)]
pub async fn run(&mut self) -> Result<(), AgentError> {
self.run_agent(None::<Prompt>, false).await
}
#[tracing::instrument(skip_all, name = "agent.run_once", err)]
pub async fn run_once(&mut self) -> Result<(), AgentError> {
self.run_agent(None::<Prompt>, true).await
}
pub async fn history(&self) -> Result<Vec<ChatMessage>, AgentError> {
self.context
.history()
.await
.map_err(AgentError::MessageHistoryError)
}
pub(crate) async fn run_agent(
&mut self,
maybe_query: Option<impl Into<Prompt>>,
just_once: bool,
) -> Result<(), AgentError> {
let maybe_query = maybe_query
.map(|q| q.into().render())
.transpose()
.map_err(AgentError::FailedToRenderPrompt)?;
if self.state.is_running() {
return Err(AgentError::AlreadyRunning);
}
if self.state.is_pending() {
invoke_hooks!(BeforeAll, self);
if let Some(system_prompt) = &self.system_prompt {
self.context
.add_messages(vec![ChatMessage::System(
system_prompt
.render()
.map_err(AgentError::FailedToRenderSystemPrompt)?,
)])
.await
.map_err(AgentError::MessageHistoryError)?;
}
self.load_toolboxes().await?;
}
if let Some(query) = maybe_query {
if cfg!(feature = "langfuse") {
debug!(langfuse.input = query);
}
tracing::debug!("Queueing user message until tool outputs are recorded");
self.pending_user_messages.push_back(query);
}
self.invoke_pending_tool_calls().await?;
if self.has_unfulfilled_tool_calls().await? {
tracing::warn!(
"Unfulfilled tool calls remain after invocation; agent/tool configuration is invalid"
);
return Err(AgentError::UnfulfilledToolCalls);
}
self.flush_pending_user_messages().await?;
invoke_hooks!(OnStart, self);
self.state = state::State::Running;
let mut loop_counter = 0;
while let Some(messages) = self
.context
.next_completion()
.await
.map_err(AgentError::MessageHistoryError)?
{
if let Some(limit) = self.limit
&& loop_counter >= limit
{
tracing::warn!("Agent loop limit reached");
break;
}
if let Some(ChatMessage::Assistant(_, tool_calls)) =
maybe_tool_call_without_output(&messages)
&& tool_calls
.as_ref()
.is_some_and(|tool_calls| !tool_calls.is_empty())
{
tracing::debug!("Uncompleted tool calls found; invoking tools");
if let Some(tool_calls) = tool_calls.as_ref() {
self.invoke_tools(tool_calls).await?;
}
continue;
}
let result = self.step(&messages, loop_counter).await;
if let Err(err) = result {
self.stop_with_error(&err).await;
tracing::error!(error = ?err, "Agent stopped with error {err}");
return Err(err);
}
if just_once || self.state.is_stopped() {
break;
}
loop_counter += 1;
}
self.stop(StopReason::NoNewMessages).await;
Ok(())
}
#[tracing::instrument(skip(self, messages), err, fields(otel.name))]
async fn step(
&mut self,
messages: &[ChatMessage],
step_count: usize,
) -> Result<(), AgentError> {
tracing::Span::current().record("otel.name", format!("step-{step_count}"));
debug!(
tools = ?self
.tools
.iter()
.map(|t| t.name())
.collect::<Vec<_>>()
,
"Running completion for agent with {} new messages",
messages.len()
);
let mut chat_completion_request = ChatCompletionRequest::builder()
.messages(messages)
.tool_specs(self.tools.iter().map(swiftide_core::Tool::tool_spec))
.build()
.map_err(AgentError::FailedToBuildRequest)?;
invoke_hooks!(BeforeCompletion, self, &mut chat_completion_request);
debug!(
"Calling LLM with the following new messages:\n {}",
self.context
.current_new_messages()
.await
.map_err(AgentError::MessageHistoryError)?
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(",\n")
);
let mut response = if self.streaming {
let mut last_response = None;
let mut stream = self.llm.complete_stream(&chat_completion_request).await;
while let Some(response) = stream.next().await {
let response = response.map_err(AgentError::CompletionsFailed)?;
invoke_hooks!(OnStream, self, &response);
last_response = Some(response);
}
tracing::trace!(?last_response, "Streaming completed");
last_response.ok_or(AgentError::EmptyStream)
} else {
self.llm
.complete(&chat_completion_request)
.await
.map_err(AgentError::CompletionsFailed)
}?;
response
.tool_calls
.as_deref_mut()
.map(ArgPreprocessor::preprocess_tool_calls);
invoke_hooks!(AfterCompletion, self, &mut response);
let assistant_content = response.message.take();
let assistant_tool_calls = response.tool_calls.clone();
let has_assistant_message = assistant_content.is_some()
|| assistant_tool_calls
.as_ref()
.is_some_and(|calls| !calls.is_empty());
if let Some(reasoning_items) = response.reasoning.take() {
for item in reasoning_items {
self.add_message(ChatMessage::Reasoning(item)).await?;
}
}
if has_assistant_message {
self.add_message(ChatMessage::Assistant(
assistant_content,
assistant_tool_calls,
))
.await?;
}
if let Some(tool_calls) = response.tool_calls {
self.invoke_tools(&tool_calls).await?;
}
invoke_hooks!(AfterEach, self);
Ok(())
}
async fn invoke_tools(&mut self, tool_calls: &[ToolCall]) -> Result<(), AgentError> {
tracing::debug!("LLM returned tool calls: {:?}", tool_calls);
let mut handles = vec![];
for tool_call in tool_calls {
let Some(tool) = self.find_tool_by_name(tool_call.name()) else {
tracing::warn!("Tool {} not found", tool_call.name());
continue;
};
tracing::info!("Calling tool `{}`", tool_call.name());
let context: Arc<dyn AgentContext> = Arc::clone(&self.context);
invoke_hooks!(BeforeTool, self, &tool_call);
let tool_span = tracing::info_span!(
"tool",
"otel.name" = format!("tool.{}", tool.name().as_ref()),
)
.or_current();
let handle_tool_call = tool_call.clone();
let handle = tokio::spawn(
async move { tool.invoke(&*context, &handle_tool_call).await }
.instrument(tool_span.clone()),
);
handles.push((handle, tool_call, tool_span));
}
for (handle, tool_call, tool_span) in handles {
let mut output = handle
.await
.map_err(|err| AgentError::ToolFailedToJoin(tool_call.name().to_string(), err))?;
invoke_hooks!(AfterTool, self, &tool_call, &mut output);
if let Err(error) = output {
let failed = ToolOutput::fail(error.to_string());
tool_span.in_scope(|| trace_tool_output(tool_call, &failed));
let stop = self.tool_calls_over_limit(tool_call);
if stop {
tracing::error!(
?error,
"Tool call failed, retry limit reached, stopping agent: {error}",
);
} else {
tracing::warn!(
?error,
tool_call = ?tool_call,
"Tool call failed, retrying",
);
}
self.add_message(ChatMessage::ToolOutput(tool_call.clone(), failed))
.await?;
if stop {
self.stop(StopReason::ToolCallsOverLimit(tool_call.to_owned()))
.await;
return Err(error.into());
}
continue;
}
let output = output?;
tool_span.in_scope(|| trace_tool_output(tool_call, &output));
self.handle_control_tools(tool_call, &output).await;
if !output.is_feedback_required() {
self.add_message(ChatMessage::ToolOutput(tool_call.to_owned(), output))
.await?;
}
}
Ok(())
}
fn find_tool_by_name(&self, tool_name: &str) -> Option<Box<dyn Tool>> {
self.tools
.iter()
.find(|tool| tool.name() == tool_name)
.cloned()
}
async fn handle_control_tools(&mut self, tool_call: &ToolCall, output: &ToolOutput) {
match output {
ToolOutput::Stop(maybe_message) => {
tracing::warn!("Stop tool called, stopping agent");
self.stop(StopReason::RequestedByTool(
tool_call.clone(),
maybe_message.clone(),
))
.await;
}
ToolOutput::FeedbackRequired(maybe_payload) => {
tracing::warn!("Feedback required, stopping agent");
self.stop(StopReason::FeedbackRequired {
tool_call: tool_call.clone(),
payload: maybe_payload.clone(),
})
.await;
}
ToolOutput::AgentFailed(output) => {
tracing::warn!("Agent failed, stopping agent");
self.stop(StopReason::AgentFailed(output.clone())).await;
}
_ => (),
}
}
pub fn system_prompt(&self) -> Option<&Prompt> {
self.system_prompt.as_ref()
}
pub fn system_prompt_mut(&mut self) -> Option<&mut Prompt> {
self.system_prompt.as_mut()
}
fn tool_calls_over_limit(&mut self, tool_call: &ToolCall) -> bool {
let mut s = DefaultHasher::new();
tool_call.hash(&mut s);
let hash = s.finish();
if let Some(retries) = self.tool_retries_counter.get_mut(&hash) {
let val = *retries >= self.tool_retry_limit;
*retries += 1;
val
} else {
self.tool_retries_counter.insert(hash, 1);
false
}
}
#[tracing::instrument(skip_all, fields(message = message.to_string()))]
pub async fn add_message(&self, mut message: ChatMessage) -> Result<(), AgentError> {
invoke_hooks!(OnNewMessage, self, &mut message);
self.context
.add_message(message)
.await
.map_err(AgentError::MessageHistoryError)?;
Ok(())
}
pub async fn stop(&mut self, reason: impl Into<StopReason>) {
if self.state.is_stopped() {
return;
}
let reason = reason.into();
invoke_hooks!(OnStop, self, reason.clone(), None);
if cfg!(feature = "langfuse") {
debug!(langfuse.output = serde_json::to_string_pretty(&reason).ok());
}
self.state = state::State::Stopped(reason);
}
pub async fn stop_with_error(&mut self, error: &AgentError) {
if self.state.is_stopped() {
return;
}
invoke_hooks!(OnStop, self, StopReason::Error, Some(error));
self.state = state::State::Stopped(StopReason::Error);
}
pub fn context(&self) -> &dyn AgentContext {
&self.context
}
pub fn is_running(&self) -> bool {
self.state.is_running()
}
pub fn is_stopped(&self) -> bool {
self.state.is_stopped()
}
pub fn is_pending(&self) -> bool {
self.state.is_pending()
}
pub fn tools(&self) -> &HashSet<Box<dyn Tool>> {
&self.tools
}
pub fn state(&self) -> &state::State {
&self.state
}
pub fn stop_reason(&self) -> Option<&StopReason> {
self.state.stop_reason()
}
async fn has_unfulfilled_tool_calls(&self) -> Result<bool, AgentError> {
let history = self
.context
.history()
.await
.map_err(AgentError::MessageHistoryError)?;
Ok(maybe_tool_call_without_output(&history).is_some())
}
async fn invoke_pending_tool_calls(&mut self) -> Result<(), AgentError> {
let history = self
.context
.history()
.await
.map_err(AgentError::MessageHistoryError)?;
if let Some(ChatMessage::Assistant(_, tool_calls)) =
maybe_tool_call_without_output(&history)
&& tool_calls
.as_ref()
.is_some_and(|tool_calls| !tool_calls.is_empty())
&& let Some(tool_calls) = tool_calls.as_ref()
{
self.invoke_tools(tool_calls).await?;
}
Ok(())
}
async fn flush_pending_user_messages(&mut self) -> Result<(), AgentError> {
if self.pending_user_messages.is_empty() {
return Ok(());
}
let messages = self
.pending_user_messages
.drain(..)
.map(ChatMessage::new_user)
.collect();
self.context
.add_messages(messages)
.await
.map_err(AgentError::MessageHistoryError)?;
Ok(())
}
async fn load_toolboxes(&mut self) -> Result<(), AgentError> {
for toolbox in &self.toolboxes {
let tools = toolbox
.available_tools()
.await
.map_err(AgentError::ToolBoxFailedToLoad)?;
self.tools.extend(tools);
}
Ok(())
}
}
fn maybe_tool_call_without_output(messages: &[ChatMessage]) -> Option<&ChatMessage> {
for message in messages.iter().rev() {
if let ChatMessage::ToolOutput(..) = message {
return None;
}
if let ChatMessage::Assistant(_, tool_calls) = message
&& tool_calls
.as_ref()
.is_some_and(|tool_calls| !tool_calls.is_empty())
{
return Some(message);
}
}
None
}
fn trace_tool_output(tool_call: &ToolCall, output: &ToolOutput) {
if cfg!(feature = "langfuse") {
debug!(
langfuse.output = %output,
langfuse.input = tool_call.args(),
tool_name = tool_call.name(),
);
} else {
debug!(
output = %output,
args = ?tool_call.args(),
tool_name = tool_call.name(),
"Completed tool call"
);
}
}
#[cfg(test)]
mod tests {
use serde::ser::Error;
use swiftide_core::ToolFeedback;
use swiftide_core::chat_completion::errors::ToolError;
use swiftide_core::chat_completion::{ChatCompletionResponse, ReasoningItem, ToolCall};
use swiftide_core::test_utils::MockChatCompletion;
use super::*;
use crate::test_utils::{MockHook, MockTool};
use crate::tools::control::ApprovalRequired;
use crate::{
State, assistant, chat_request, chat_response, summary, system, tool_failed, tool_output,
user,
};
#[test_log::test(tokio::test)]
async fn test_agent_builder_defaults() {
let mock_llm = MockChatCompletion::new();
let agent = Agent::builder().llm(&mock_llm).build().unwrap();
assert!(agent.system_prompt().is_none());
assert!(agent.find_tool_by_name("stop").is_some());
let agent = Agent::builder()
.tools([Stop::default(), Stop::default()])
.llm(&mock_llm)
.build()
.unwrap();
assert_eq!(agent.tools.len(), 1);
let agent = Agent::builder()
.tools([MockTool::new("mock_tool")])
.llm(&mock_llm)
.build()
.unwrap();
assert_eq!(agent.tools.len(), 2);
assert!(agent.find_tool_by_name("mock_tool").is_some());
assert!(agent.find_tool_by_name("stop").is_some());
assert!(agent.context().history().await.unwrap().is_empty());
}
#[test_log::test(tokio::test)]
async fn test_agent_tool_calling_loop() {
let prompt = "Write a poem";
let mock_llm = MockChatCompletion::new();
let mock_tool = MockTool::new("mock_tool");
let chat_request = chat_request! {
user!("Write a poem");
tools = [mock_tool.clone()]
};
let mock_tool_response = chat_response! {
"Roses are red";
tool_calls = ["mock_tool"]
};
mock_llm.expect_complete(chat_request.clone(), Ok(mock_tool_response));
let chat_request = chat_request! {
user!("Write a poem"),
assistant!("Roses are red", ["mock_tool"]),
tool_output!("mock_tool", "Great!");
tools = [mock_tool.clone()]
};
let stop_response = chat_response! {
"Roses are red";
tool_calls = ["stop"]
};
mock_llm.expect_complete(chat_request, Ok(stop_response));
mock_tool.expect_invoke_ok("Great!".into(), None);
let mut agent = Agent::builder()
.tools([mock_tool])
.llm(&mock_llm)
.no_system_prompt()
.build()
.unwrap();
agent.query(prompt).await.unwrap();
}
#[test_log::test(tokio::test)]
async fn test_agent_tool_run_once() {
let prompt = "Write a poem";
let mock_llm = MockChatCompletion::new();
let mock_tool = MockTool::default();
let chat_request = chat_request! {
system!("My system prompt"),
user!("Write a poem");
tools = [mock_tool.clone()]
};
let mock_tool_response = chat_response! {
"Roses are red";
tool_calls = ["mock_tool"]
};
mock_tool.expect_invoke_ok("Great!".into(), None);
mock_llm.expect_complete(chat_request.clone(), Ok(mock_tool_response));
let mut agent = Agent::builder()
.tools([mock_tool])
.system_prompt("My system prompt")
.llm(&mock_llm)
.build()
.unwrap();
agent.query_once(prompt).await.unwrap();
}
#[test_log::test(tokio::test)]
async fn test_agent_tool_via_toolbox_run_once() {
let prompt = "Write a poem";
let mock_llm = MockChatCompletion::new();
let mock_tool = MockTool::default();
let chat_request = chat_request! {
system!("My system prompt"),
user!("Write a poem");
tools = [mock_tool.clone()]
};
let mock_tool_response = chat_response! {
"Roses are red";
tool_calls = ["mock_tool"]
};
mock_tool.expect_invoke_ok("Great!".into(), None);
mock_llm.expect_complete(chat_request.clone(), Ok(mock_tool_response));
let mut agent = Agent::builder()
.add_toolbox(vec![mock_tool.boxed()])
.system_prompt("My system prompt")
.llm(&mock_llm)
.build()
.unwrap();
agent.query_once(prompt).await.unwrap();
}
#[test_log::test(tokio::test(flavor = "multi_thread"))]
async fn test_multiple_tool_calls() {
let prompt = "Write a poem";
let mock_llm = MockChatCompletion::new();
let mock_tool = MockTool::new("mock_tool1");
let mock_tool2 = MockTool::new("mock_tool2");
let chat_request = chat_request! {
system!("My system prompt"),
user!("Write a poem");
tools = [mock_tool.clone(), mock_tool2.clone()]
};
let mock_tool_response = chat_response! {
"Roses are red";
tool_calls = ["mock_tool1", "mock_tool2"]
};
dbg!(&chat_request);
mock_tool.expect_invoke_ok("Great!".into(), None);
mock_tool2.expect_invoke_ok("Great!".into(), None);
mock_llm.expect_complete(chat_request.clone(), Ok(mock_tool_response));
let chat_request = chat_request! {
system!("My system prompt"),
user!("Write a poem"),
assistant!("Roses are red", ["mock_tool1", "mock_tool2"]),
tool_output!("mock_tool1", "Great!"),
tool_output!("mock_tool2", "Great!");
tools = [mock_tool.clone(), mock_tool2.clone()]
};
let mock_tool_response = chat_response! {
"Ok!";
tool_calls = ["stop"]
};
mock_llm.expect_complete(chat_request, Ok(mock_tool_response));
let mut agent = Agent::builder()
.tools([mock_tool, mock_tool2])
.system_prompt("My system prompt")
.llm(&mock_llm)
.build()
.unwrap();
agent.query(prompt).await.unwrap();
}
#[test_log::test(tokio::test)]
async fn test_agent_state_machine() {
let prompt = "Write a poem";
let mock_llm = MockChatCompletion::new();
let chat_request = chat_request! {
user!("Write a poem");
tools = []
};
let mock_tool_response = chat_response! {
"Roses are red";
tool_calls = []
};
mock_llm.expect_complete(chat_request.clone(), Ok(mock_tool_response));
let mut agent = Agent::builder()
.llm(&mock_llm)
.no_system_prompt()
.build()
.unwrap();
assert!(agent.state.is_pending());
agent.query_once(prompt).await.unwrap();
assert!(agent.state.is_stopped());
}
#[test_log::test(tokio::test)]
async fn test_summary() {
let prompt = "Write a poem";
let mock_llm = MockChatCompletion::new();
let mock_tool_response = chat_response! {
"Roses are red";
tool_calls = []
};
let expected_chat_request = chat_request! {
system!("My system prompt"),
user!("Write a poem");
tools = []
};
mock_llm.expect_complete(expected_chat_request, Ok(mock_tool_response.clone()));
let mut agent = Agent::builder()
.system_prompt("My system prompt")
.llm(&mock_llm)
.build()
.unwrap();
agent.query_once(prompt).await.unwrap();
agent
.context
.add_message(ChatMessage::new_summary("Summary"))
.await
.unwrap();
let expected_chat_request = chat_request! {
system!("My system prompt"),
summary!("Summary"),
user!("Write another poem");
tools = []
};
mock_llm.expect_complete(expected_chat_request, Ok(mock_tool_response.clone()));
agent.query_once("Write another poem").await.unwrap();
agent
.context
.add_message(ChatMessage::new_summary("Summary 2"))
.await
.unwrap();
let expected_chat_request = chat_request! {
system!("My system prompt"),
summary!("Summary 2"),
user!("Write a third poem");
tools = []
};
mock_llm.expect_complete(expected_chat_request, Ok(mock_tool_response));
agent.query_once("Write a third poem").await.unwrap();
}
#[test_log::test(tokio::test)]
async fn test_reasoning_only_completion_is_added_to_history() {
let prompt = "Plan the fix";
let mock_llm = MockChatCompletion::new();
let expected_chat_request = chat_request! {
user!(prompt);
tools = []
};
let reasoning_item = ReasoningItem {
id: Some("rs_123".into()),
summary: vec!["Inspect the failing path".into()],
content: None,
encrypted_content: None,
status: None,
};
let reasoning_only_response = ChatCompletionResponse::builder()
.reasoning(vec![reasoning_item.clone()])
.build()
.unwrap();
mock_llm.expect_complete(expected_chat_request, Ok(reasoning_only_response));
let mut agent = Agent::builder()
.llm(&mock_llm)
.no_system_prompt()
.build()
.unwrap();
agent.query_once(prompt).await.unwrap();
let history = agent.history().await.unwrap();
assert_eq!(history.len(), 2);
assert_eq!(history[0], user!(prompt));
assert_eq!(history[1], ChatMessage::Reasoning(reasoning_item));
}
#[test_log::test(tokio::test)]
async fn test_agent_hooks() {
let mock_before_all = MockHook::new("before_all").expect_calls(1).to_owned();
let mock_on_start_fn = MockHook::new("on_start").expect_calls(1).to_owned();
let mock_before_completion = MockHook::new("before_completion")
.expect_calls(2)
.to_owned();
let mock_after_completion = MockHook::new("after_completion").expect_calls(2).to_owned();
let mock_after_each = MockHook::new("after_each").expect_calls(2).to_owned();
let mock_on_message = MockHook::new("on_message").expect_calls(4).to_owned();
let mock_on_stop = MockHook::new("on_stop").expect_calls(1).to_owned();
let mock_before_tool = MockHook::new("before_tool").expect_calls(2).to_owned();
let mock_after_tool = MockHook::new("after_tool").expect_calls(2).to_owned();
let prompt = "Write a poem";
let mock_llm = MockChatCompletion::new();
let mock_tool = MockTool::default();
let chat_request = chat_request! {
user!("Write a poem");
tools = [mock_tool.clone()]
};
let mock_tool_response = chat_response! {
"Roses are red";
tool_calls = ["mock_tool"]
};
mock_llm.expect_complete(chat_request.clone(), Ok(mock_tool_response));
let chat_request = chat_request! {
user!("Write a poem"),
assistant!("Roses are red", ["mock_tool"]),
tool_output!("mock_tool", "Great!");
tools = [mock_tool.clone()]
};
let stop_response = chat_response! {
"Roses are red";
tool_calls = ["stop"]
};
mock_llm.expect_complete(chat_request, Ok(stop_response));
mock_tool.expect_invoke_ok("Great!".into(), None);
let mut agent = Agent::builder()
.tools([mock_tool])
.llm(&mock_llm)
.no_system_prompt()
.before_all(mock_before_all.hook_fn())
.on_start(mock_on_start_fn.on_start_fn())
.before_completion(mock_before_completion.before_completion_fn())
.before_tool(mock_before_tool.before_tool_fn())
.after_completion(mock_after_completion.after_completion_fn())
.after_tool(mock_after_tool.after_tool_fn())
.after_each(mock_after_each.after_each_fn())
.on_new_message(mock_on_message.message_hook_fn())
.on_stop(mock_on_stop.stop_hook_fn())
.build()
.unwrap();
agent.query(prompt).await.unwrap();
}
#[test_log::test(tokio::test)]
async fn before_all_can_extend_the_initial_system_prompt() {
let mock_llm = MockChatCompletion::new();
mock_llm.expect_complete(
chat_request! {
system!("Base\nSkills"),
user!("Hello"); tools = []
},
Ok(chat_response!("Done"; tool_calls = [])),
);
let mut agent = Agent::builder()
.llm(&mock_llm)
.system_prompt("Base\n{{skills}}")
.before_all(|agent: &mut Agent| {
Box::pin(async move {
agent
.system_prompt_mut()
.unwrap()
.insert_context_value("skills", "Skills");
Ok(())
})
})
.build()
.unwrap();
agent.query_once("Hello").await.unwrap();
}
#[test_log::test(tokio::test)]
async fn test_agent_loop_limit() {
let prompt = "Generate content"; let mock_llm = MockChatCompletion::new();
let mock_tool = MockTool::new("mock_tool");
let chat_request = chat_request! {
user!(prompt);
tools = [mock_tool.clone()]
};
mock_tool.expect_invoke_ok("Great!".into(), None);
let mock_tool_response = chat_response! {
"Some response";
tool_calls = ["mock_tool"]
};
mock_llm.expect_complete(chat_request.clone(), Ok(mock_tool_response.clone()));
let stop_response = chat_response! {
"Final response";
tool_calls = ["stop"]
};
mock_llm.expect_complete(chat_request, Ok(stop_response));
let mut agent = Agent::builder()
.tools([mock_tool])
.llm(&mock_llm)
.no_system_prompt()
.limit(1) .build()
.unwrap();
agent.query(prompt).await.unwrap();
let remaining = mock_llm.expectations.lock().unwrap().pop();
assert!(remaining.is_some());
assert!(agent.is_stopped());
}
#[test_log::test(tokio::test)]
async fn test_tool_retry_mechanism() {
let prompt = "Execute my tool";
let mock_llm = MockChatCompletion::new();
let mock_tool = MockTool::new("retry_tool");
mock_tool.expect_invoke(
Err(ToolError::WrongArguments(serde_json::Error::custom(
"missing `query`",
))),
None,
);
mock_tool.expect_invoke(
Err(ToolError::WrongArguments(serde_json::Error::custom(
"missing `query`",
))),
None,
);
let chat_request = chat_request! {
user!(prompt);
tools = [mock_tool.clone()]
};
let retry_response = chat_response! {
"First failing attempt";
tool_calls = ["retry_tool"]
};
mock_llm.expect_complete(chat_request.clone(), Ok(retry_response));
let chat_request = chat_request! {
user!(prompt),
assistant!("First failing attempt", ["retry_tool"]),
tool_failed!("retry_tool", "arguments for tool failed to parse: missing `query`");
tools = [mock_tool.clone()]
};
let will_fail_response = chat_response! {
"Finished execution";
tool_calls = ["retry_tool"]
};
mock_llm.expect_complete(chat_request.clone(), Ok(will_fail_response));
let mut agent = Agent::builder()
.tools([mock_tool])
.llm(&mock_llm)
.no_system_prompt()
.tool_retry_limit(1) .build()
.unwrap();
let result = agent.query(prompt).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("missing `query`"));
assert!(agent.is_stopped());
}
#[test_log::test(tokio::test(flavor = "multi_thread"))]
async fn test_streaming() {
let prompt = "Generate content"; let mock_llm = MockChatCompletion::new();
let on_stream_fn = MockHook::new("on_stream").expect_calls(3).to_owned();
let chat_request = chat_request! {
user!(prompt);
tools = []
};
let response = chat_response! {
"one two three";
tool_calls = ["stop"]
};
mock_llm.expect_complete(chat_request, Ok(response));
let mut agent = Agent::builder()
.llm(&mock_llm)
.on_stream(on_stream_fn.on_stream_fn())
.no_system_prompt()
.build()
.unwrap();
agent.query(prompt).await.unwrap();
tracing::debug!("Agent finished running");
assert!(agent.is_stopped());
}
#[test_log::test(tokio::test)]
async fn test_recovering_agent_existing_history() {
let prompt = "Write a poem";
let mock_llm = MockChatCompletion::new();
let mock_tool = MockTool::new("mock_tool");
let chat_request = chat_request! {
user!("Write a poem");
tools = [mock_tool.clone()]
};
let mock_tool_response = chat_response! {
"Roses are red";
tool_calls = ["mock_tool"]
};
mock_llm.expect_complete(chat_request.clone(), Ok(mock_tool_response));
let chat_request = chat_request! {
user!("Write a poem"),
assistant!("Roses are red", ["mock_tool"]),
tool_output!("mock_tool", "Great!");
tools = [mock_tool.clone()]
};
let stop_response = chat_response! {
"Roses are red";
tool_calls = ["stop"]
};
mock_llm.expect_complete(chat_request, Ok(stop_response));
mock_tool.expect_invoke_ok("Great!".into(), None);
let mut agent = Agent::builder()
.tools([mock_tool.clone()])
.llm(&mock_llm)
.no_system_prompt()
.build()
.unwrap();
agent.query(prompt).await.unwrap();
let history = agent.history().await.unwrap();
let serialized = serde_json::to_string(&history).unwrap();
let history: Vec<ChatMessage> =
serde_json::from_str::<Vec<swiftide_core::chat_completion::ChatMessage>>(&serialized)
.unwrap()
.into_iter()
.map(|message| message.to_owned())
.collect();
let context = DefaultContext::default()
.with_existing_messages(history)
.await
.unwrap()
.to_owned();
let stop_output = ToolOutput::stop();
let expected_chat_request = chat_request! {
user!("Write a poem"),
assistant!("Roses are red", ["mock_tool"]),
tool_output!("mock_tool", "Great!"),
assistant!("Roses are red", ["stop"]),
tool_output!("stop", stop_output),
user!("Try again!");
tools = [mock_tool.clone()]
};
let stop_response = chat_response! {
"Really stopping now";
tool_calls = ["stop"]
};
mock_llm.expect_complete(expected_chat_request, Ok(stop_response));
let mut agent = Agent::builder()
.context(context)
.tools([mock_tool])
.llm(&mock_llm)
.no_system_prompt()
.build()
.unwrap();
agent.query_once("Try again!").await.unwrap();
}
#[test_log::test(tokio::test)]
async fn test_agent_with_approval_required_tool() {
let mock_tool = MockTool::default();
mock_tool.expect_invoke_ok("Great!".into(), None);
let approval_tool = ApprovalRequired(mock_tool.boxed());
let mock_llm = MockChatCompletion::new();
let chat_req1 = chat_request! {
user!("Request with approval");
tools = [approval_tool.clone()]
};
let chat_resp1 = chat_response! {
"Completion message";
tool_calls = ["mock_tool"]
};
mock_llm.expect_complete(chat_req1.clone(), Ok(chat_resp1));
let chat_req2 = chat_request! {
user!("Request with approval"),
assistant!("Completion message", ["mock_tool"]),
tool_output!("mock_tool", "Great!");
tools = [approval_tool.clone()]
};
let chat_resp2 = chat_response! {
"Post-feedback message";
tool_calls = ["stop"]
};
mock_llm.expect_complete(chat_req2.clone(), Ok(chat_resp2));
let mut agent = Agent::builder()
.tools([approval_tool])
.llm(&mock_llm)
.no_system_prompt()
.build()
.unwrap();
agent.query_once("Request with approval").await.unwrap();
assert!(matches!(
agent.state,
crate::state::State::Stopped(crate::state::StopReason::FeedbackRequired { .. })
));
let State::Stopped(StopReason::FeedbackRequired { tool_call, .. }) = agent.state.clone()
else {
panic!("Expected feedback required");
};
agent
.context
.feedback_received(&tool_call, &ToolFeedback::approved())
.await
.unwrap();
tracing::debug!("running after approval");
agent.run_once().await.unwrap();
assert!(agent.is_stopped());
}
#[test_log::test(tokio::test)]
async fn test_agent_with_approval_required_tool_denied() {
let mock_tool = MockTool::default();
let approval_tool = ApprovalRequired(mock_tool.boxed());
let mock_llm = MockChatCompletion::new();
let chat_req1 = chat_request! {
user!("Request with approval");
tools = [approval_tool.clone()]
};
let chat_resp1 = chat_response! {
"Completion message";
tool_calls = ["mock_tool"]
};
mock_llm.expect_complete(chat_req1.clone(), Ok(chat_resp1));
let chat_req2 = chat_request! {
user!("Request with approval"),
assistant!("Completion message", ["mock_tool"]),
tool_output!("mock_tool", "This tool call was refused");
tools = [approval_tool.clone()]
};
let chat_resp2 = chat_response! {
"Post-feedback message";
tool_calls = ["stop"]
};
mock_llm.expect_complete(chat_req2.clone(), Ok(chat_resp2));
let mut agent = Agent::builder()
.tools([approval_tool])
.llm(&mock_llm)
.no_system_prompt()
.build()
.unwrap();
agent.query_once("Request with approval").await.unwrap();
assert!(matches!(
agent.state,
crate::state::State::Stopped(crate::state::StopReason::FeedbackRequired { .. })
));
let State::Stopped(StopReason::FeedbackRequired { tool_call, .. }) = agent.state.clone()
else {
panic!("Expected feedback required");
};
agent
.context
.feedback_received(&tool_call, &ToolFeedback::refused())
.await
.unwrap();
tracing::debug!("running after approval");
agent.run_once().await.unwrap();
let history = agent.context().history().await.unwrap();
history
.iter()
.rfind(|m| {
let ChatMessage::ToolOutput(.., ToolOutput::Text(msg)) = m else {
return false;
};
msg.contains("refused")
})
.expect("Could not find refusal message");
assert!(agent.is_stopped());
}
#[test_log::test(tokio::test)]
async fn test_defers_user_message_until_pending_tool_calls_complete() {
let mock_llm = MockChatCompletion::new();
let mock_tool = MockTool::default();
mock_tool.expect_invoke_ok("Tool done".into(), None);
let context = DefaultContext::default()
.with_existing_messages(vec![user!("Hello"), assistant!("Need tool", ["mock_tool"])])
.await
.unwrap()
.to_owned();
let expected_request = chat_request! {
user!("Hello"),
assistant!("Need tool", ["mock_tool"]),
tool_output!("mock_tool", "Tool done"),
user!("Next");
tools = [mock_tool.clone()]
};
let response = chat_response! {
"All set";
tool_calls = ["stop"]
};
mock_llm.expect_complete(expected_request, Ok(response));
let mut agent = Agent::builder()
.context(context)
.tools([mock_tool])
.llm(&mock_llm)
.no_system_prompt()
.build()
.unwrap();
agent.query_once("Next").await.unwrap();
}
#[test_log::test(tokio::test)]
async fn test_removing_default_stop_tool() {
let mock_llm = MockChatCompletion::new();
let mock_tool = MockTool::new("mock_tool");
let agent = Agent::builder()
.without_default_stop_tool()
.tools([mock_tool.clone()])
.llm(&mock_llm)
.no_system_prompt()
.build()
.unwrap();
assert!(agent.find_tool_by_name("stop").is_none());
assert!(agent.find_tool_by_name("mock_tool").is_some());
}
}