use super::config::AgentConfig;
use super::structured::{StructuredOutcome, StructuredValidator};
use crate::CancellationToken;
use crate::agent::events::ReActEvent;
use crate::agent::{
Agent, AgentError, AgentEvent, CancellableAgent, MessageChunk, RunSummary, TypedAgent,
};
use crate::event_channel::EventChannel;
use crate::memory::{Memory, WindowMemory};
use crate::message::{Message, ToolCall};
use crate::provider::{ChatRequest, Provider, ProviderError, StreamEvent, Usage};
use crate::skill::{LoadSkillTool, SkillRegistry};
use crate::tool::{SharedState, ToolRegistry};
use futures::StreamExt;
use futures::stream::BoxStream;
use schemars::JsonSchema;
use serde::de::DeserializeOwned;
use std::collections::HashSet;
use std::fmt;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use tracing::Instrument;
#[macro_export]
macro_rules! react_agent {
($provider:expr $(,)?) => {
$crate::agent::ReActAgent::new(
$provider,
$crate::tool::ToolRegistry::new(),
"",
)
};
($provider:expr, $system:literal $(,)?) => {
$crate::agent::ReActAgent::new(
$provider,
$crate::tool::ToolRegistry::new(),
$system,
)
};
($provider:expr, [$($tool:expr),* $(,)?] $(,)?) => {{
let mut __molo_registry = $crate::tool::ToolRegistry::new();
$(__molo_registry.register($tool);)*
$crate::agent::ReActAgent::new($provider, __molo_registry, "")
}};
($provider:expr, [$($tool:expr),* $(,)?], $system:expr $(,)?) => {{
let mut __molo_registry = $crate::tool::ToolRegistry::new();
$(__molo_registry.register($tool);)*
$crate::agent::ReActAgent::new($provider, __molo_registry, $system)
}};
($provider:expr, $registry:expr $(,)?) => {
$crate::agent::ReActAgent::new($provider, $registry, "")
};
($provider:expr, $registry:expr, $system:expr $(,)?) => {
$crate::agent::ReActAgent::new($provider, $registry, $system)
};
}
pub struct ReActAgent {
provider: Box<dyn Provider>,
memory: Box<dyn Memory>,
registry: ToolRegistry,
system_prompt: String,
config: AgentConfig,
pub state: SharedState,
events: Option<Arc<dyn EventChannel>>,
executor: Box<dyn ToolRoundExecutor>,
pub skills: Arc<SkillRegistry>,
enabled_skills: Option<Arc<HashSet<String>>>,
activated_skills: Vec<String>,
skill_mode: SkillMode,
created_nanos: u128,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SkillMode {
None,
Dynamic,
Inline,
}
static PROCESS_RUN_COUNTER: AtomicU64 = AtomicU64::new(0);
const MAX_ROUND_TEXT: usize = 4 << 20;
const DEFAULT_MEMORY_TOKENS: usize = 128_000;
impl ReActAgent {
pub fn new(
provider: impl Provider + 'static,
tools: ToolRegistry,
system_prompt: impl Into<String>,
) -> Self {
Self {
provider: Box::new(provider),
memory: Box::new(WindowMemory::new(DEFAULT_MEMORY_TOKENS)),
registry: tools,
system_prompt: system_prompt.into(),
config: AgentConfig::default(),
state: SharedState::default(),
events: None,
executor: Box::new(SerialToolRoundExecutor),
skills: Arc::new(SkillRegistry::new()),
enabled_skills: None,
activated_skills: Vec::new(),
skill_mode: SkillMode::None,
created_nanos: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos(),
}
}
fn next_run_id(&self) -> String {
let n = PROCESS_RUN_COUNTER.fetch_add(1, Ordering::Relaxed);
format!("run-{}-{n}", self.created_nanos)
}
pub fn with_memory(mut self, memory: impl Memory + 'static) -> Self {
self.memory = Box::new(memory);
self
}
pub fn with_config(mut self, config: AgentConfig) -> Self {
self.config = config;
self
}
pub fn with_structured_output(mut self, schema: serde_json::Value) -> Self {
self.config.options.structured = Some(schema);
self
}
pub fn with_state(mut self, state: SharedState) -> Self {
self.state = state;
self
}
pub fn with_event_channel(mut self, channel: impl EventChannel + 'static) -> Self {
self.events = Some(Arc::new(channel));
self
}
pub fn with_tool_round_executor(mut self, executor: impl ToolRoundExecutor + 'static) -> Self {
self.executor = Box::new(executor);
self
}
pub fn with_skills(mut self, registry: SkillRegistry) -> Self {
self.skills = Arc::new(registry);
self.skill_mode = SkillMode::Dynamic;
let enabled = self.enabled_skills.clone();
self.registry
.register(LoadSkillTool::new(self.skills.clone(), enabled));
self
}
pub fn with_skills_inline(mut self, registry: SkillRegistry) -> Self {
self.skills = Arc::new(registry);
self.registry.remove("load_skill");
self.skill_mode = SkillMode::Inline;
self
}
pub fn with_enabled_skills(mut self, names: &[&str]) -> Self {
let set: HashSet<String> = names.iter().map(|n| n.to_string()).collect();
self.enabled_skills = Some(Arc::new(set));
if self.skill_mode == SkillMode::Dynamic {
let enabled = self.enabled_skills.clone();
self.registry
.register(LoadSkillTool::new(self.skills.clone(), enabled));
}
self
}
pub fn activate_skill(&mut self, name: &str) -> bool {
if self.skill_mode != SkillMode::Dynamic {
return false;
}
if !self.skill_visible(name) || self.skills.get(name).is_none() {
return false;
}
if self.is_activated(name) {
return true;
}
self.activated_skills.push(name.to_string());
true
}
pub fn deactivate_skill(&mut self, name: &str) -> bool {
if self.skill_mode != SkillMode::Dynamic {
return false;
}
if let Some(pos) = self.activated_skills.iter().position(|n| n == name) {
self.activated_skills.remove(pos);
true
} else {
false
}
}
fn skill_visible(&self, name: &str) -> bool {
match &self.enabled_skills {
None => true,
Some(enabled) => enabled.contains(name),
}
}
fn is_activated(&self, name: &str) -> bool {
self.activated_skills.iter().any(|n| n == name)
}
fn publish<E: AgentEvent + 'static>(&self, make_event: impl FnOnce() -> Arc<E>) {
if let Some(pipe) = &self.events {
pipe.publish(make_event());
}
}
async fn run_rounds_cancellable(
&mut self,
token: &CancellationToken,
run_id: &str,
counters: &mut RunCounters,
schema: Option<&serde_json::Value>,
) -> Result<String, AgentError> {
let schemas = self.registry.schemas();
let mut validator = schema.map(|schema| {
StructuredValidator::new(schema.clone(), self.config.max_structured_retries)
});
let mut tool_rounds = 0usize;
loop {
if tool_rounds >= self.config.max_tool_rounds {
return Err(AgentError::TooManyToolRounds(self.config.max_tool_rounds));
}
counters.rounds += 1;
if token.is_cancelled() {
return Err(AgentError::Cancelled);
}
let answer: Option<String> = async {
let llm_span = span_llm(run_id, counters.rounds);
let mut options = self.config.options.clone();
if options.structured.is_none() {
options.structured = schema.cloned();
}
let response = match token
.run_until_cancelled(
self.provider
.chat(ChatRequest {
messages: self.assemble_messages(self.memory.context().await?),
tools: schemas.clone(),
options,
})
.instrument(llm_span.clone()),
)
.await
{
Some(Ok(response)) => response,
Some(Err(e)) => {
llm_span.record("error", e.to_string());
return Err(AgentError::Provider(e));
}
None => return Err(AgentError::Cancelled),
};
llm_span.record("usage.prompt_tokens", response.usage.prompt_tokens);
llm_span.record("usage.completion_tokens", response.usage.completion_tokens);
counters.usage_total += response.usage;
let Message::Assistant {
content,
reasoning,
tool_calls,
} = response.message
else {
return Err(AgentError::Provider(ProviderError::Api {
status: 0,
message: "provider returned a non-assistant message".into(),
}));
};
if content.len() + reasoning.as_deref().map_or(0, str::len) > MAX_ROUND_TEXT {
return Err(AgentError::Provider(ProviderError::Api {
status: 0,
message: format!("round text exceeds size limit ({MAX_ROUND_TEXT} bytes)"),
}));
}
if !content.is_empty() || reasoning.is_some() || !tool_calls.is_empty() {
self.memory
.record(Message::Assistant {
content: content.clone(),
reasoning,
tool_calls: tool_calls.clone(),
})
.await?;
}
if tool_calls.is_empty() {
if let Some(validator) = &mut validator {
match validator.validate(&content) {
StructuredOutcome::Passed => {}
StructuredOutcome::Retry { message } => {
self.memory.record(message).await?;
return Ok::<Option<String>, AgentError>(None);
}
StructuredOutcome::Exhausted { max_retries } => {
return Err(AgentError::StructuredRetriesExhausted(max_retries));
}
}
}
return Ok::<Option<String>, AgentError>(Some(content));
}
counters.tool_calls_total += tool_calls.len();
tool_rounds += 1;
let ctx = ToolRoundCtx {
run_id,
round: counters.rounds,
registry: &self.registry,
state: &self.state,
events: &self.events,
};
let mut outcomes = self.executor.execute_round(ctx, tool_calls).await;
while let Some(outcome) = outcomes.next().await {
record_tool_result(&mut self.memory, &outcome).await?;
}
Ok::<Option<String>, AgentError>(None)
}
.await?;
if let Some(answer) = answer {
return Ok(answer);
}
}
}
fn assemble_messages(&self, context: Vec<Message>) -> Vec<Message> {
let mut messages = Vec::with_capacity(context.len() + 1);
let system = self.assemble_system_prompt();
if !system.is_empty() {
messages.push(Message::system(&system));
}
messages.extend(context);
messages
}
fn assemble_system_prompt(&self) -> String {
let base = self.system_prompt.as_str();
let skills = self.skills.skills();
if skills.is_empty() {
return base.to_string();
}
let mut out = String::new();
out.push_str(base);
match self.skill_mode {
SkillMode::None => {}
SkillMode::Dynamic => {
let menu: Vec<String> = skills
.iter()
.filter(|s| self.skill_visible(s.name()) && !self.is_activated(s.name()))
.map(|s| format!("- {}: {}", s.name(), s.description()))
.collect();
append_sections(&mut out, &menu);
let activated: Vec<String> = self
.activated_skills
.iter()
.filter_map(|n| self.skills.get(n))
.map(|s| format!("[Skill {}]\n{}", s.name(), s.body()))
.collect();
append_sections(&mut out, &activated);
}
SkillMode::Inline => {
let bodies: Vec<String> = skills
.iter()
.map(|s| format!("[Skill {}]\n{}", s.name(), s.body()))
.collect();
append_sections(&mut out, &bodies);
}
}
out
}
}
pub struct ToolRoundCtx<'a> {
pub run_id: &'a str,
pub round: usize,
pub registry: &'a ToolRegistry,
pub state: &'a SharedState,
pub events: &'a Option<Arc<dyn EventChannel>>,
}
impl fmt::Debug for ToolRoundCtx<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ToolRoundCtx")
.field("run_id", &self.run_id)
.field("round", &self.round)
.field("registry", &self.registry)
.field("state", &self.state)
.finish_non_exhaustive()
}
}
impl ToolRoundCtx<'_> {
pub async fn run(&self, call: ToolCall) -> ToolCallOutcome {
let tool_span = span_tool(self.run_id, self.round, &call.name);
self.publish(|| {
Arc::new(ReActEvent::ToolStarted {
id: call.id.clone(),
name: call.name.clone(),
arguments: call.arguments.clone(),
})
});
let result = self
.registry
.call(&call.name, &call.arguments, self.state)
.instrument(tool_span.clone())
.await;
if let Err(e) = &result {
tool_span.record("error", e.to_string());
}
let content = match &result {
Ok(text) => text.clone(),
Err(e) => e.to_string(),
};
let protected = self
.registry
.get(&call.name)
.map(|tool| tool.protected_output())
.unwrap_or(false);
let publish_tool_completed = {
let id = call.id.clone();
let name = call.name.clone();
move || Arc::new(ReActEvent::ToolCompleted { id, name, result })
};
self.publish(publish_tool_completed);
ToolCallOutcome {
call,
content,
protected,
}
}
fn publish<E: AgentEvent + 'static>(&self, make_event: impl FnOnce() -> Arc<E>) {
if let Some(pipe) = self.events {
pipe.publish(make_event());
}
}
}
#[async_trait::async_trait]
pub trait ToolRoundExecutor: Send + Sync {
async fn execute_round<'a>(
&'a mut self,
ctx: ToolRoundCtx<'a>,
calls: Vec<ToolCall>,
) -> BoxStream<'a, ToolCallOutcome>;
}
#[derive(Debug, Default, Clone, Copy)]
pub struct SerialToolRoundExecutor;
#[async_trait::async_trait]
impl ToolRoundExecutor for SerialToolRoundExecutor {
async fn execute_round<'a>(
&'a mut self,
ctx: ToolRoundCtx<'a>,
calls: Vec<ToolCall>,
) -> BoxStream<'a, ToolCallOutcome> {
Box::pin(async_stream::stream! {
for call in calls {
yield ctx.run(call).await;
}
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolCallOutcome {
pub call: ToolCall,
pub content: String,
pub protected: bool,
}
impl ReActAgent {
pub async fn run_typed<U>(&mut self, input: &str) -> Result<U, AgentError>
where
U: DeserializeOwned + JsonSchema + Send + Sync,
{
TypedAgent::run_typed(self, input).await
}
}
#[async_trait::async_trait]
impl TypedAgent for ReActAgent {
async fn run_typed<U>(&mut self, input: &str) -> Result<U, AgentError>
where
U: DeserializeOwned + JsonSchema + Send + Sync,
{
let schema = serde_json::to_value(schemars::schema_for!(U))
.expect("schemars-generated schema always serializes (pure JSON value structure)");
let text = self
.run_cancellable_inner(input, &CancellationToken::new(), Some(&schema))
.await?;
serde_json::from_str(&text).map_err(|e| AgentError::StructuredParse(e.to_string()))
}
}
#[derive(Default)]
struct RunCounters {
rounds: usize,
tool_calls_total: usize,
usage_total: Usage,
}
impl fmt::Debug for ReActAgent {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ReActAgent")
.field("provider", &"Box<dyn Provider>")
.field("memory", &"Box<dyn Memory>")
.field("tools", &self.registry)
.field("system_prompt", &self.system_prompt)
.field("config", &self.config)
.field("state", &self.state)
.field(
"events",
&match &self.events {
Some(_) => "Some<dyn EventChannel>",
None => "None",
},
)
.field("skills", &self.skills)
.finish()
}
}
fn append_sections(out: &mut String, sections: &[String]) {
if sections.is_empty() {
return;
}
if !out.is_empty() {
out.push_str("\n\n");
}
for (i, section) in sections.iter().enumerate() {
if i > 0 {
out.push('\n');
}
out.push_str(section);
}
}
#[async_trait::async_trait]
impl Agent for ReActAgent {
async fn run(&mut self, input: &str) -> Result<String, AgentError> {
self.run_cancellable(input, &CancellationToken::new()).await
}
async fn run_stream<'a>(
&'a mut self,
input: &'a str,
) -> Result<BoxStream<'a, Result<MessageChunk, AgentError>>, AgentError> {
let token = CancellationToken::new();
self.run_stream_cancellable(input, &token).await
}
}
fn span_run(run_id: &str) -> tracing::Span {
tracing::info_span!("agent.run", "run.id" = %run_id, error = tracing::field::Empty)
}
fn span_llm(run_id: &str, round: usize) -> tracing::Span {
tracing::debug_span!(
"llm_request",
"run.id" = %run_id,
round = round,
usage.prompt_tokens = tracing::field::Empty,
usage.completion_tokens = tracing::field::Empty,
error = tracing::field::Empty,
)
}
fn span_tool(run_id: &str, round: usize, name: &str) -> tracing::Span {
tracing::debug_span!(
"tool",
"run.id" = %run_id,
round = round,
name = %name,
error = tracing::field::Empty,
)
}
fn publish_ended(
events: &Option<Arc<dyn EventChannel>>,
rounds: usize,
tool_calls_total: usize,
usage_total: Usage,
error: Option<AgentError>,
) {
if let Some(pipe) = events {
pipe.publish(Arc::new(ReActEvent::RunEnded {
summary: RunSummary {
rounds,
tool_calls: tool_calls_total,
usage: usage_total,
},
error,
}));
}
}
fn stream_end(
events: &Option<Arc<dyn EventChannel>>,
run_span: &tracing::Span,
rounds: usize,
tool_calls_total: usize,
usage_total: Usage,
error: AgentError,
) -> Result<MessageChunk, AgentError> {
if !matches!(error, AgentError::Cancelled) {
run_span.record("error", error.to_string());
}
publish_ended(
events,
rounds,
tool_calls_total,
usage_total,
Some(error.clone()),
);
match error {
AgentError::Cancelled => Ok(MessageChunk::Cancelled),
e => Err(e),
}
}
async fn record_tool_result(
memory: &mut Box<dyn Memory>,
outcome: &ToolCallOutcome,
) -> Result<(), AgentError> {
let message = Message::tool_result(outcome.call.id.clone(), outcome.content.clone());
let record_result = if outcome.protected {
memory.record_protected(message).await
} else {
memory.record(message).await
};
match record_result {
Ok(()) => Ok(()),
Err(e) => {
let fallback = Message::tool_result(
outcome.call.id.clone(),
format!("memory record failed: {e}"),
);
let _ = if outcome.protected {
memory.record_protected(fallback).await
} else {
memory.record(fallback).await
};
Err(e.into())
}
}
}
impl ReActAgent {
async fn run_cancellable_inner(
&mut self,
input: &str,
token: &CancellationToken,
schema: Option<&serde_json::Value>,
) -> Result<String, AgentError> {
let run_id = self.next_run_id();
let run_span = span_run(&run_id);
let result = async {
self.memory.record(Message::user(input)).await?;
self.publish(|| {
Arc::new(ReActEvent::RunStarted {
run_id: run_id.clone(),
input: input.to_string(),
})
});
let mut counters = RunCounters::default();
let result = self
.run_rounds_cancellable(token, &run_id, &mut counters, schema)
.await;
publish_ended(
&self.events,
counters.rounds,
counters.tool_calls_total,
counters.usage_total,
result.as_ref().err().cloned(),
);
result
}
.instrument(run_span.clone())
.await;
if let Err(e) = &result {
run_span.record("error", e.to_string());
}
result
}
async fn run_stream_cancellable_inner<'a>(
&'a mut self,
input: &'a str,
token: &CancellationToken,
) -> Result<BoxStream<'a, Result<MessageChunk, AgentError>>, AgentError> {
let run_id = self.next_run_id();
let run_span = span_run(&run_id);
let stream_span = run_span.clone();
self.memory.record(Message::user(input)).await?;
self.publish(|| {
Arc::new(ReActEvent::RunStarted {
run_id: run_id.clone(),
input: input.to_string(),
})
});
let schemas = self.registry.schemas();
let max_rounds = self.config.max_tool_rounds;
let token = token.clone();
let stream = async_stream::stream! {
let mut rounds = 0usize;
let mut tool_calls_total = 0usize;
let mut usage_total = Usage::default();
let mut validator = self.config.options.structured.as_ref().map(|schema| {
StructuredValidator::new(schema.clone(), self.config.max_structured_retries)
});
let mut tool_rounds = 0usize;
'rounds: loop {
if tool_rounds >= max_rounds {
yield stream_end(&self.events, &run_span, rounds, tool_calls_total,
usage_total, AgentError::TooManyToolRounds(max_rounds));
break;
}
rounds += 1;
if token.is_cancelled() {
yield stream_end(&self.events, &run_span, rounds, tool_calls_total,
usage_total, AgentError::Cancelled);
break;
}
let context = match self.memory.context().await {
Ok(messages) => messages,
Err(e) => {
yield stream_end(&self.events, &run_span, rounds, tool_calls_total,
usage_total, AgentError::Memory(e));
break;
}
};
let llm_span = span_llm(&run_id, rounds);
let mut provider_stream = match token
.run_until_cancelled(
self.provider
.stream_chat(ChatRequest {
messages: self.assemble_messages(context),
tools: schemas.clone(),
options: self.config.options.clone(),
})
.instrument(llm_span.clone()),
)
.await
{
Some(Ok(stream)) => stream,
Some(Err(e)) => {
llm_span.record("error", e.to_string());
yield stream_end(&self.events, &run_span, rounds, tool_calls_total,
usage_total, AgentError::Provider(e));
break;
}
None => {
yield stream_end(&self.events, &run_span, rounds, tool_calls_total,
usage_total, AgentError::Cancelled);
break;
}
};
let mut text = String::new();
let mut reasoning = String::new();
let mut calls = Vec::new();
loop {
let next = token
.run_until_cancelled(provider_stream.next().instrument(llm_span.clone()))
.await;
let Some(Some(event)) = next else {
if next.is_none() {
yield stream_end(&self.events, &run_span, rounds, tool_calls_total,
usage_total, AgentError::Cancelled);
break 'rounds;
}
break;
};
match event {
Ok(StreamEvent::Delta(delta)) => {
if text.len() + delta.len() > MAX_ROUND_TEXT {
yield stream_end(&self.events, &run_span, rounds,
tool_calls_total, usage_total,
AgentError::Provider(ProviderError::Api {
status: 0,
message: format!(
"round text exceeds size limit ({MAX_ROUND_TEXT} bytes)"
),
}));
break 'rounds;
}
text.push_str(&delta);
self.publish(|| Arc::new(ReActEvent::Delta { text: delta.clone() }));
yield Ok(MessageChunk::Delta(delta));
}
Ok(StreamEvent::Reasoning(chunk)) => {
if reasoning.len() + chunk.len() > MAX_ROUND_TEXT {
yield stream_end(&self.events, &run_span, rounds,
tool_calls_total, usage_total,
AgentError::Provider(ProviderError::Api {
status: 0,
message: format!(
"round reasoning exceeds size limit ({MAX_ROUND_TEXT} bytes)"
),
}));
break 'rounds;
}
reasoning.push_str(&chunk);
self.publish(move || Arc::new(ReActEvent::Reasoning { text: chunk }));
}
Ok(StreamEvent::ToolCall { id, name, arguments }) => {
calls.push(ToolCall {
id: id.clone(),
name: name.clone(),
arguments: arguments.clone(),
});
yield Ok(MessageChunk::ToolCall { id, name, arguments });
}
Ok(StreamEvent::Done { usage, .. }) => {
if let Some(usage) = usage {
llm_span.record("usage.prompt_tokens", usage.prompt_tokens);
llm_span.record("usage.completion_tokens", usage.completion_tokens);
usage_total += usage;
}
break;
}
Err(e) => {
llm_span.record("error", e.to_string());
yield stream_end(&self.events, &run_span, rounds, tool_calls_total,
usage_total, AgentError::Provider(e));
break 'rounds;
}
}
}
if !text.is_empty() || !reasoning.is_empty() || !calls.is_empty() {
let message = Message::Assistant {
content: text.clone(),
reasoning: (!reasoning.is_empty()).then_some(reasoning),
tool_calls: calls.clone(),
};
match self.memory.record(message).await {
Ok(()) => {}
Err(e) => {
yield stream_end(&self.events, &run_span, rounds, tool_calls_total,
usage_total, AgentError::Memory(e));
break;
}
}
}
if calls.is_empty() {
if let Some(validator) = &mut validator {
match validator.validate(&text) {
StructuredOutcome::Passed => {}
StructuredOutcome::Retry { message } => {
match self.memory.record(message).await {
Ok(()) => continue 'rounds,
Err(e) => {
yield stream_end(&self.events, &run_span, rounds,
tool_calls_total, usage_total,
AgentError::Memory(e));
break;
}
}
}
StructuredOutcome::Exhausted { max_retries } => {
yield stream_end(&self.events, &run_span, rounds,
tool_calls_total, usage_total,
AgentError::StructuredRetriesExhausted(max_retries));
break 'rounds;
}
}
}
publish_ended(
&self.events,
rounds,
tool_calls_total,
usage_total,
None,
);
yield Ok(MessageChunk::Done(RunSummary {
rounds,
tool_calls: tool_calls_total,
usage: usage_total,
}));
break;
}
tool_calls_total += calls.len();
tool_rounds += 1;
let ctx = ToolRoundCtx {
run_id: &run_id,
round: rounds,
registry: &self.registry,
state: &self.state,
events: &self.events,
};
let mut outcomes = self.executor.execute_round(ctx, calls).await;
while let Some(outcome) = outcomes.next().await {
yield Ok(MessageChunk::ToolResult {
id: outcome.call.id.clone(),
name: outcome.call.name.clone(),
content: outcome.content.clone(),
});
if let Err(e) = record_tool_result(&mut self.memory, &outcome).await {
yield stream_end(&self.events, &run_span, rounds, tool_calls_total,
usage_total, e);
break 'rounds;
}
}
}
};
Ok(Box::pin(SpanStream {
stream: Box::pin(stream),
span: stream_span,
}))
}
}
#[async_trait::async_trait]
impl CancellableAgent for ReActAgent {
async fn run_cancellable(
&mut self,
input: &str,
token: &CancellationToken,
) -> Result<String, AgentError> {
let schema = self.config.options.structured.clone();
self.run_cancellable_inner(input, token, schema.as_ref())
.await
}
async fn run_stream_cancellable<'a>(
&'a mut self,
input: &'a str,
token: &CancellationToken,
) -> Result<BoxStream<'a, Result<MessageChunk, AgentError>>, AgentError> {
self.run_stream_cancellable_inner(input, token).await
}
}
struct SpanStream<S> {
stream: Pin<Box<S>>,
span: tracing::Span,
}
impl<S: futures::Stream> futures::Stream for SpanStream<S> {
type Item = S::Item;
fn poll_next(
mut self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Option<Self::Item>> {
let span = self.span.clone();
let _enter = span.enter();
self.stream.as_mut().poll_next(cx)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::CancellationToken;
use crate::memory::MemoryError;
use crate::message::ContentBlock;
use crate::provider::{
ChatResponse, FakeProvider, FakeReply, FinishReason, ProviderError, StreamEvent,
TimeoutStage,
};
use crate::tool::{Tool, ToolError, ToolSchema};
use futures::StreamExt;
use serde::Deserialize;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
#[derive(Clone)]
struct SharedFake(Arc<FakeProvider>);
impl SharedFake {
fn new(replies: impl IntoIterator<Item = FakeReply>) -> Self {
Self(Arc::new(FakeProvider::new(replies)))
}
fn requests(&self) -> Vec<ChatRequest> {
self.0.requests()
}
}
#[async_trait::async_trait]
impl Provider for SharedFake {
async fn chat(&self, request: ChatRequest) -> Result<ChatResponse, ProviderError> {
self.0.chat(request).await
}
async fn stream_chat(
&self,
request: ChatRequest,
) -> Result<BoxStream<'static, Result<StreamEvent, ProviderError>>, ProviderError> {
self.0.stream_chat(request).await
}
}
#[derive(Debug, Clone)]
struct FakeTool {
name: &'static str,
result: &'static str,
calls: Arc<AtomicUsize>,
}
impl FakeTool {
fn new(name: &'static str, result: &'static str) -> (Self, Arc<AtomicUsize>) {
let calls = Arc::new(AtomicUsize::new(0));
(
Self {
name,
result,
calls: calls.clone(),
},
calls,
)
}
}
#[async_trait::async_trait]
impl Tool for FakeTool {
fn schema(&self) -> ToolSchema {
ToolSchema {
name: self.name.into(),
description: "Test tool".into(),
parameters: serde_json::json!({}),
}
}
async fn call(
&self,
_arguments: serde_json::Value,
_state: &SharedState,
) -> Result<String, ToolError> {
self.calls.fetch_add(1, Ordering::Relaxed);
Ok(self.result.to_string())
}
}
fn call(id: &str, name: &str, arguments: &str) -> ToolCall {
ToolCall {
id: id.into(),
name: name.into(),
arguments: arguments.into(),
}
}
fn agent(fake: SharedFake, system_prompt: &str) -> ReActAgent {
ReActAgent::new(fake, ToolRegistry::new(), system_prompt)
}
fn agent_with_registry(
fake: SharedFake,
registry: ToolRegistry,
config: AgentConfig,
) -> ReActAgent {
ReActAgent::new(fake, registry, "").with_config(config)
}
#[tokio::test]
async fn direct_answer() {
let fake = SharedFake::new([FakeReply::Text("Hello".into())]);
let mut agent = agent(fake.clone(), "");
let answer = agent.run("Are you there").await.unwrap();
assert_eq!(answer, "Hello");
let requests = fake.requests();
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].messages.len(), 1);
assert_eq!(requests[0].messages[0], Message::user("Are you there"));
}
#[tokio::test]
async fn single_tool_round() {
let (calc, calls) = FakeTool::new("calc", "42");
let mut registry = ToolRegistry::new();
registry.register(calc);
let fake = SharedFake::new([
FakeReply::ToolCalls {
content: "".into(),
calls: vec![call("c1", "calc", r#"{"a":1}"#)],
},
FakeReply::Text("The answer is 42".into()),
]);
let mut agent = agent_with_registry(fake.clone(), registry, AgentConfig::default());
let answer = agent.run("Compute 1+1").await.unwrap();
assert_eq!(answer, "The answer is 42");
assert_eq!(calls.load(Ordering::Relaxed), 1);
let requests = fake.requests();
assert_eq!(requests.len(), 2);
assert!(requests[1].messages.iter().any(|m| matches!(
m,
Message::ToolResult { id, content } if id == "c1" && content == "42"
)));
}
#[tokio::test]
async fn multiple_tools_same_round() {
let (t1, calls1) = FakeTool::new("t1", "one");
let (t2, calls2) = FakeTool::new("t2", "two");
let mut registry = ToolRegistry::new();
registry.register(t1).register(t2);
let fake = SharedFake::new([
FakeReply::ToolCalls {
content: "".into(),
calls: vec![call("c1", "t1", "{}"), call("c2", "t2", "{}")],
},
FakeReply::Text("done".into()),
]);
let mut agent = agent_with_registry(fake.clone(), registry, AgentConfig::default());
let answer = agent.run("Run them all").await.unwrap();
assert_eq!(answer, "done");
assert_eq!(calls1.load(Ordering::Relaxed), 1);
assert_eq!(calls2.load(Ordering::Relaxed), 1);
let requests = fake.requests();
let assistant = requests[1]
.messages
.iter()
.find_map(|m| match m {
Message::Assistant { tool_calls, .. } => Some(tool_calls),
_ => None,
})
.expect("second round should contain Assistant");
assert_eq!(assistant.len(), 2);
let results: Vec<&str> = requests[1]
.messages
.iter()
.filter_map(|m| match m {
Message::ToolResult { content, .. } => Some(content.as_str()),
_ => None,
})
.collect();
assert_eq!(results, vec!["one", "two"]);
}
#[tokio::test]
async fn empty_assistant_not_recorded() {
let fake = SharedFake::new([FakeReply::Text("".into())]);
let mut agent = agent(fake.clone(), "");
let answer = agent.run("hi").await.unwrap();
assert_eq!(answer, "");
let requests = fake.requests();
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].messages.len(), 1); }
#[tokio::test]
async fn default_memory_is_bounded_window() {
let fake = SharedFake::new([FakeReply::Text("hi".into())]);
let mut agent = agent(fake, "");
agent
.memory
.record(Message::user("x".repeat(600_000)))
.await
.unwrap();
agent
.memory
.record(Message::assistant("first-round reply"))
.await
.unwrap();
agent
.memory
.record(Message::user("second round"))
.await
.unwrap();
agent
.memory
.record(Message::assistant("second-round reply"))
.await
.unwrap();
let ctx = agent.memory.context().await.unwrap();
assert_eq!(
ctx,
vec![
Message::user("second round"),
Message::assistant("second-round reply")
]
);
}
#[tokio::test]
async fn too_many_tool_rounds() {
let (calc, _calls) = FakeTool::new("calc", "42");
let mut registry = ToolRegistry::new();
registry.register(calc);
let fake = SharedFake::new([
FakeReply::ToolCalls {
content: "".into(),
calls: vec![call("c1", "calc", "{}")],
},
FakeReply::ToolCalls {
content: "".into(),
calls: vec![call("c2", "calc", "{}")],
},
]);
let mut agent = agent_with_registry(
fake.clone(),
registry,
AgentConfig {
max_tool_rounds: 2,
..Default::default()
},
);
let err = agent.run("Keep computing").await.unwrap_err();
assert!(matches!(err, AgentError::TooManyToolRounds(2)));
assert_eq!(fake.requests().len(), 2); }
#[tokio::test]
async fn system_prompt_assembled_every_request() {
let fake = SharedFake::new([
FakeReply::Text("Hello".into()),
FakeReply::Text("Goodbye".into()),
]);
let mut agent = agent(fake.clone(), "You are an assistant");
agent.run("Are you there").await.unwrap();
agent.run("Any more?").await.unwrap();
let requests = fake.requests();
assert_eq!(requests.len(), 2);
for request in &requests {
let systems = request
.messages
.iter()
.filter(|m| matches!(m, Message::System(_)))
.count();
assert_eq!(systems, 1);
assert_eq!(request.messages[0], Message::system("You are an assistant"));
}
assert_eq!(requests[1].messages.len(), 4); assert_eq!(requests[1].messages[3], Message::user("Any more?"));
}
#[tokio::test]
async fn macro_arms_without_system_prompt() {
let fake = SharedFake::new([FakeReply::Text("hi".into())]);
let mut agent = crate::react_agent!(fake.clone());
assert_eq!(agent.run("hi").await.unwrap(), "hi");
assert_eq!(fake.requests()[0].messages.len(), 1);
let (t1, calls1) = FakeTool::new("t1", "one");
let fake = SharedFake::new([
FakeReply::ToolCalls {
content: "".into(),
calls: vec![call("c1", "t1", "{}")],
},
FakeReply::Text("done".into()),
]);
let mut agent = crate::react_agent!(fake.clone(), [t1]);
assert_eq!(agent.run("x").await.unwrap(), "done");
assert_eq!(calls1.load(Ordering::Relaxed), 1);
assert_eq!(fake.requests()[1].messages.len(), 3);
let (t2, _calls2) = FakeTool::new("t2", "two");
let mut registry = ToolRegistry::new();
registry.register(t2);
let fake = SharedFake::new([FakeReply::Text("hi".into())]);
let mut agent = crate::react_agent!(fake.clone(), registry);
assert_eq!(agent.run("hi").await.unwrap(), "hi");
assert_eq!(fake.requests()[0].messages.len(), 1); }
#[tokio::test]
async fn macro_three_arms() {
let fake = SharedFake::new([FakeReply::Text("hi".into())]);
let mut agent = crate::react_agent!(fake.clone(), "");
assert_eq!(agent.run("hi").await.unwrap(), "hi");
assert_eq!(fake.requests()[0].messages.len(), 1);
let (t1, calls1) = FakeTool::new("t1", "one");
let (t2, calls2) = FakeTool::new("t2", "two");
let fake = SharedFake::new([
FakeReply::ToolCalls {
content: "".into(),
calls: vec![call("c1", "t1", "{}")],
},
FakeReply::Text("done".into()),
]);
let mut agent = crate::react_agent!(fake.clone(), [t1, t2], "");
assert_eq!(agent.run("x").await.unwrap(), "done");
assert_eq!(calls1.load(Ordering::Relaxed), 1);
assert_eq!(calls2.load(Ordering::Relaxed), 0);
let (t3, calls3) = FakeTool::new("t3", "three");
let mut registry = ToolRegistry::new();
registry.register(t3);
let fake = SharedFake::new([
FakeReply::ToolCalls {
content: "".into(),
calls: vec![call("c1", "t3", "{}")],
},
FakeReply::Text("done".into()),
]);
let mut agent = crate::react_agent!(fake, registry, "");
assert_eq!(agent.run("x").await.unwrap(), "done");
assert_eq!(calls3.load(Ordering::Relaxed), 1);
}
#[test]
fn macro_all_arms_compile() {
struct Echo;
#[async_trait::async_trait]
impl Tool for Echo {
fn schema(&self) -> ToolSchema {
ToolSchema {
name: "echo".into(),
description: "Echo".into(),
parameters: serde_json::json!({}),
}
}
async fn call(
&self,
_arguments: serde_json::Value,
_state: &SharedState,
) -> Result<String, ToolError> {
Ok("echo".into())
}
}
fn fake() -> SharedFake {
SharedFake::new([FakeReply::Text("hi".into())])
}
let a1 = crate::react_agent!(fake()); let a2 = crate::react_agent!(fake(), "You are an assistant"); let a3 = crate::react_agent!(fake(), [Echo]); let a4 = crate::react_agent!(fake(), [Echo], "You are an assistant"); let mut registry = ToolRegistry::new();
registry.register(Echo);
let a5 = crate::react_agent!(fake(), registry.clone()); let a6 = crate::react_agent!(fake(), registry, "You are an assistant"); let _ = (a1, a2, a3, a4, a5, a6);
}
#[tokio::test]
async fn empty_system_prompt_skips_system_message() {
let fake = SharedFake::new([FakeReply::Text("hi".into())]);
let mut agent = agent(fake.clone(), "");
agent.run("hi").await.unwrap();
let requests = fake.requests();
assert_eq!(requests[0].messages.len(), 1);
assert_eq!(requests[0].messages[0], Message::user("hi"));
}
#[tokio::test]
async fn tool_failure_returns_text_and_continues() {
struct FailingTool;
#[async_trait::async_trait]
impl Tool for FailingTool {
fn schema(&self) -> ToolSchema {
ToolSchema {
name: "boom".into(),
description: "Tool that always fails".into(),
parameters: serde_json::json!({}),
}
}
async fn call(
&self,
_arguments: serde_json::Value,
_state: &SharedState,
) -> Result<String, ToolError> {
Err(ToolError::Execution("internal error".into()))
}
}
let mut registry = ToolRegistry::new();
registry.register(FailingTool);
let fake = SharedFake::new([
FakeReply::ToolCalls {
content: "".into(),
calls: vec![call("c1", "boom", "{}")],
},
FakeReply::Text("Got it".into()),
]);
let mut agent = agent_with_registry(fake.clone(), registry, AgentConfig::default());
let answer = agent.run("Trigger failure").await.unwrap();
assert_eq!(answer, "Got it");
let requests = fake.requests();
assert!(requests[1].messages.iter().any(|m| matches!(
m,
Message::ToolResult { content, .. } if content.contains("internal error")
)));
}
#[tokio::test]
async fn stream_too_many_tool_rounds_terminates() {
let (calc, _calls) = FakeTool::new("calc", "42");
let mut registry = ToolRegistry::new();
registry.register(calc);
let fake = SharedFake::new([
FakeReply::ToolCalls {
content: "".into(),
calls: vec![call("c1", "calc", "{}")],
},
FakeReply::ToolCalls {
content: "".into(),
calls: vec![call("c2", "calc", "{}")],
},
]);
let mut agent = agent_with_registry(
fake.clone(),
registry,
AgentConfig {
max_tool_rounds: 2,
..Default::default()
},
);
let mut stream = agent.run_stream("Keep computing").await.unwrap();
let chunks: Vec<Result<MessageChunk, AgentError>> = stream.by_ref().collect().await;
assert_eq!(chunks.len(), 5);
assert!(matches!(
chunks.last(),
Some(Err(AgentError::TooManyToolRounds(2)))
));
assert_eq!(fake.requests().len(), 2);
}
#[tokio::test]
async fn stream_entry_record_user_failure_returns_error() {
struct FailingUserMemory;
#[async_trait::async_trait]
impl Memory for FailingUserMemory {
async fn record(&mut self, _message: Message) -> Result<(), MemoryError> {
Err(MemoryError::Storage("disk full".into()))
}
async fn context(&self) -> Result<Vec<Message>, MemoryError> {
Ok(Vec::new())
}
}
let fake = SharedFake::new([FakeReply::Text("hi".into())]);
let mut agent = agent(fake.clone(), "").with_memory(FailingUserMemory);
let err = match agent.run_stream("hi").await {
Err(e) => e,
Ok(_) => panic!("expected input recording failure to return Err directly"),
};
assert!(matches!(err, AgentError::Memory(MemoryError::Storage(_))));
}
#[tokio::test]
async fn stream_context_failure_terminates_with_err() {
struct FailingContextMemory;
#[async_trait::async_trait]
impl Memory for FailingContextMemory {
async fn record(&mut self, _message: Message) -> Result<(), MemoryError> {
Ok(())
}
async fn context(&self) -> Result<Vec<Message>, MemoryError> {
Err(MemoryError::Storage("disk full".into()))
}
}
let fake = SharedFake::new([FakeReply::Text("hi".into())]);
let mut agent = agent(fake.clone(), "").with_memory(FailingContextMemory);
let mut stream = agent.run_stream("hi").await.unwrap();
let chunks: Vec<Result<MessageChunk, AgentError>> = stream.by_ref().collect().await;
assert_eq!(chunks.len(), 1);
assert!(matches!(
chunks[0],
Err(AgentError::Memory(MemoryError::Storage(_)))
));
}
#[tokio::test]
async fn stream_assistant_record_failure_terminates_with_err() {
struct FailingAssistantMemory;
#[async_trait::async_trait]
impl Memory for FailingAssistantMemory {
async fn record(&mut self, message: Message) -> Result<(), MemoryError> {
if matches!(message, Message::Assistant { .. }) {
Err(MemoryError::Storage("disk full".into()))
} else {
Ok(())
}
}
async fn context(&self) -> Result<Vec<Message>, MemoryError> {
Ok(Vec::new())
}
}
let fake = SharedFake::new([FakeReply::Text("hi".into())]);
let mut agent = agent(fake.clone(), "").with_memory(FailingAssistantMemory);
let mut stream = agent.run_stream("hi").await.unwrap();
let chunks: Vec<Result<MessageChunk, AgentError>> = stream.by_ref().collect().await;
assert_eq!(chunks.len(), 2); assert!(matches!(
chunks.last(),
Some(Err(AgentError::Memory(MemoryError::Storage(_))))
));
}
#[tokio::test]
async fn stream_tool_failure_returns_text_and_continues() {
struct FailingTool;
#[async_trait::async_trait]
impl Tool for FailingTool {
fn schema(&self) -> ToolSchema {
ToolSchema {
name: "boom".into(),
description: "Tool that always fails".into(),
parameters: serde_json::json!({}),
}
}
async fn call(
&self,
_arguments: serde_json::Value,
_state: &SharedState,
) -> Result<String, ToolError> {
Err(ToolError::Execution("internal error".into()))
}
}
let mut registry = ToolRegistry::new();
registry.register(FailingTool);
let fake = SharedFake::new([
FakeReply::ToolCalls {
content: "".into(),
calls: vec![call("c1", "boom", "{}")],
},
FakeReply::Text("Got it".into()),
]);
let mut agent = agent_with_registry(fake.clone(), registry, AgentConfig::default());
let mut stream = agent.run_stream("Trigger failure").await.unwrap();
let chunks: Vec<MessageChunk> = stream.by_ref().map(|e| e.unwrap()).collect().await;
assert!(matches!(
&chunks[1],
MessageChunk::ToolResult { content, .. } if content.contains("internal error")
));
assert_eq!(
chunks.last(),
Some(&MessageChunk::Done(RunSummary {
rounds: 2,
tool_calls: 1,
usage: Usage::default(),
}))
);
}
#[tokio::test]
async fn stream_multiple_tools_same_round() {
let (calc_a, _calls) = FakeTool::new("calc_a", "A");
let (calc_b, _calls) = FakeTool::new("calc_b", "B");
let mut registry = ToolRegistry::new();
registry.register(calc_a).register(calc_b);
let fake = SharedFake::new([
FakeReply::ToolCalls {
content: "".into(),
calls: vec![call("c1", "calc_a", "{}"), call("c2", "calc_b", "{}")],
},
FakeReply::Text("Done".into()),
]);
let mut agent = agent_with_registry(fake.clone(), registry, AgentConfig::default());
let mut stream = agent.run_stream("Compute").await.unwrap();
let chunks: Vec<MessageChunk> = stream.by_ref().map(|e| e.unwrap()).collect().await;
assert_eq!(
chunks,
vec![
MessageChunk::ToolCall {
id: "c1".into(),
name: "calc_a".into(),
arguments: "{}".into()
},
MessageChunk::ToolCall {
id: "c2".into(),
name: "calc_b".into(),
arguments: "{}".into()
},
MessageChunk::ToolResult {
id: "c1".into(),
name: "calc_a".into(),
content: "A".into()
},
MessageChunk::ToolResult {
id: "c2".into(),
name: "calc_b".into(),
content: "B".into()
},
MessageChunk::Delta("Done".into()),
MessageChunk::Done(RunSummary {
rounds: 2,
tool_calls: 2,
usage: Usage::default(),
}),
]
);
}
#[tokio::test]
async fn run_and_stream_error_semantics_equivalent() {
let script = |fake: &SharedFake| {
let (calc, _calls) = FakeTool::new("calc", "42");
let mut registry = ToolRegistry::new();
registry.register(calc);
agent_with_registry(
fake.clone(),
registry,
AgentConfig {
max_tool_rounds: 1,
..Default::default()
},
)
};
let fake = SharedFake::new([
FakeReply::ToolCalls {
content: "".into(),
calls: vec![call("c1", "calc", "{}")],
},
FakeReply::ToolCalls {
content: "".into(),
calls: vec![call("c2", "calc", "{}")],
},
]);
let mut agent = script(&fake);
let run_err = agent.run("Compute").await.unwrap_err();
let fake2 = SharedFake::new([
FakeReply::ToolCalls {
content: "".into(),
calls: vec![call("c1", "calc", "{}")],
},
FakeReply::ToolCalls {
content: "".into(),
calls: vec![call("c2", "calc", "{}")],
},
]);
let mut agent = script(&fake2);
let mut stream = agent.run_stream("Compute").await.unwrap();
let chunks: Vec<Result<MessageChunk, AgentError>> = stream.by_ref().collect().await;
let stream_err = chunks.into_iter().find_map(|e| e.err());
assert_eq!(run_err, AgentError::TooManyToolRounds(1));
assert_eq!(stream_err, Some(AgentError::TooManyToolRounds(1)));
assert_eq!(fake.requests().len(), fake2.requests().len());
}
#[tokio::test]
async fn run_id_differs_across_instances() {
let fake_a = SharedFake::new([FakeReply::Text("hi".into())]);
let fake_b = SharedFake::new([FakeReply::Text("hi".into())]);
let agent_a = agent(fake_a.clone(), "");
let agent_b = agent(fake_b.clone(), "");
let id_a = agent_a.next_run_id();
let id_b = agent_b.next_run_id();
assert_ne!(
id_a, id_b,
"back-to-back instances must not collide on run_id"
);
assert!(id_a.starts_with("run-") && id_b.starts_with("run-"));
}
#[tokio::test]
async fn run_context_failure_returns_memory_error() {
struct FailingContextMemory;
#[async_trait::async_trait]
impl Memory for FailingContextMemory {
async fn record(&mut self, _message: Message) -> Result<(), MemoryError> {
Ok(())
}
async fn context(&self) -> Result<Vec<Message>, MemoryError> {
Err(MemoryError::Storage("disk full".into()))
}
}
let fake = SharedFake::new([FakeReply::Text("hi".into())]);
let mut agent = agent(fake.clone(), "").with_memory(FailingContextMemory);
let err = agent.run("hi").await.unwrap_err();
assert!(matches!(err, AgentError::Memory(MemoryError::Storage(_))));
assert!(fake.requests().is_empty());
}
#[tokio::test]
async fn stream_empty_provider_stream_yields_empty_answer() {
struct EmptyStreamProvider;
#[async_trait::async_trait]
impl Provider for EmptyStreamProvider {
async fn chat(&self, _r: ChatRequest) -> Result<ChatResponse, ProviderError> {
unreachable!("this test uses streaming only")
}
async fn stream_chat(
&self,
_r: ChatRequest,
) -> Result<BoxStream<'static, Result<StreamEvent, ProviderError>>, ProviderError>
{
Ok(Box::pin(futures::stream::empty()))
}
}
let mut agent = ReActAgent::new(EmptyStreamProvider, ToolRegistry::new(), "");
let chunks: Vec<Result<MessageChunk, AgentError>> = {
let mut stream = agent.run_stream("hi").await.unwrap();
stream.by_ref().collect().await
};
assert_eq!(
chunks,
vec![Ok(MessageChunk::Done(RunSummary {
rounds: 1,
tool_calls: 0,
usage: Usage::default(),
}))]
);
assert_eq!(
agent.memory.context().await.unwrap(),
vec![Message::user("hi")]
);
}
#[tokio::test]
async fn stream_event_order() {
let (calc, _calls) = FakeTool::new("calc", "42");
let mut registry = ToolRegistry::new();
registry.register(calc);
let fake = SharedFake::new([
FakeReply::ToolCalls {
content: "Thinking: ".into(),
calls: vec![call("c1", "calc", "{}")],
},
FakeReply::TextWithReasoning {
content: "The answer is 42".into(),
reasoning: "Reasoning steps".into(),
},
]);
let mut agent = agent_with_registry(fake.clone(), registry, AgentConfig::default());
let mut stream = agent.run_stream("Compute").await.unwrap();
let events: Vec<MessageChunk> = stream.by_ref().map(|e| e.unwrap()).collect().await;
assert_eq!(
events,
vec![
MessageChunk::Delta("Thinking: ".into()),
MessageChunk::ToolCall {
id: "c1".into(),
name: "calc".into(),
arguments: "{}".into()
},
MessageChunk::ToolResult {
id: "c1".into(),
name: "calc".into(),
content: "42".into()
},
MessageChunk::Delta("The answer is 42".into()),
MessageChunk::Done(RunSummary {
rounds: 2,
tool_calls: 1,
usage: Usage::default(),
}),
]
);
}
#[tokio::test]
async fn stream_pure_tool_round_no_delta() {
let (calc, _calls) = FakeTool::new("calc", "42");
let mut registry = ToolRegistry::new();
registry.register(calc);
let fake = SharedFake::new([
FakeReply::ToolCalls {
content: "".into(),
calls: vec![call("c1", "calc", "{}")],
},
FakeReply::Text("42".into()),
]);
let mut agent = agent_with_registry(fake.clone(), registry, AgentConfig::default());
let mut stream = agent.run_stream("Compute").await.unwrap();
let events: Vec<MessageChunk> = stream.by_ref().map(|e| e.unwrap()).collect().await;
assert_eq!(
events,
vec![
MessageChunk::ToolCall {
id: "c1".into(),
name: "calc".into(),
arguments: "{}".into()
},
MessageChunk::ToolResult {
id: "c1".into(),
name: "calc".into(),
content: "42".into()
},
MessageChunk::Delta("42".into()),
MessageChunk::Done(RunSummary {
rounds: 2,
tool_calls: 1,
usage: Usage::default(),
}),
]
);
}
#[tokio::test]
async fn stream_done_summary_accumulates_usage() {
let (calc, _calls) = FakeTool::new("calc", "42");
let mut registry = ToolRegistry::new();
registry.register(calc);
let fake = SharedFake::new([
FakeReply::WithUsage {
reply: Box::new(FakeReply::ToolCalls {
content: "".into(),
calls: vec![call("c1", "calc", "{}")],
}),
usage: Usage::new(10, 2),
},
FakeReply::text_with_usage("42", Usage::new(20, 5)),
]);
let mut agent = agent_with_registry(fake.clone(), registry, AgentConfig::default());
let mut stream = agent.run_stream("Compute").await.unwrap();
let events: Vec<MessageChunk> = stream.by_ref().map(|e| e.unwrap()).collect().await;
assert_eq!(
events.last(),
Some(&MessageChunk::Done(RunSummary {
rounds: 2,
tool_calls: 1,
usage: Usage::new(30, 7), }))
);
}
#[tokio::test]
async fn run_and_stream_same_semantics() {
let script = [
FakeReply::ToolCalls {
content: "".into(),
calls: vec![call("c1", "calc", "{}")],
},
FakeReply::Text("42".into()),
];
let (calc, _calls) = FakeTool::new("calc", "42");
let mut registry = ToolRegistry::new();
registry.register(calc.clone());
let fake1 = SharedFake::new(script.clone());
let mut agent1 = agent_with_registry(fake1.clone(), registry, AgentConfig::default());
let answer1 = agent1.run("Compute").await.unwrap();
let mut registry = ToolRegistry::new();
registry.register(calc);
let fake2 = SharedFake::new(script);
let mut agent2 = agent_with_registry(fake2.clone(), registry, AgentConfig::default());
let events: Vec<MessageChunk> = agent2
.run_stream("Compute")
.await
.unwrap()
.map(|e| e.unwrap())
.collect()
.await;
let answer2: String = events
.iter()
.filter_map(|e| match e {
MessageChunk::Delta(d) => Some(d.as_str()),
_ => None,
})
.collect();
assert_eq!(answer1, answer2);
assert_eq!(answer1, "42");
assert_eq!(fake1.requests(), fake2.requests());
}
#[tokio::test]
async fn stream_error_terminates_without_done() {
struct FailInStream;
#[async_trait::async_trait]
impl Provider for FailInStream {
async fn chat(&self, _request: ChatRequest) -> Result<ChatResponse, ProviderError> {
unreachable!("this test uses streaming path only")
}
async fn stream_chat(
&self,
_request: ChatRequest,
) -> Result<BoxStream<'static, Result<StreamEvent, ProviderError>>, ProviderError>
{
Ok(Box::pin(futures::stream::iter(vec![
Ok(StreamEvent::Delta("hi".into())),
Err(ProviderError::Api {
status: 0,
message: "boom".into(),
}),
])))
}
}
let mut agent = ReActAgent::new(FailInStream, ToolRegistry::new(), "");
let mut stream = agent.run_stream("two").await.unwrap();
assert_eq!(
stream.next().await.unwrap().unwrap(),
MessageChunk::Delta("hi".into())
);
assert!(matches!(
stream.next().await.unwrap(),
Err(AgentError::Provider(ProviderError::Api { message: m, .. })) if m == "boom"
));
assert!(stream.next().await.is_none()); }
#[tokio::test]
async fn script_exhausted_fails_explicitly() {
let fake = SharedFake::new([FakeReply::Text("hi".into())]);
let mut agent = agent(fake, "");
agent.run("one").await.unwrap();
let err = agent.run("two").await.unwrap_err();
assert!(
matches!(err, AgentError::Provider(ProviderError::Api { message: m, .. }) if m.contains("exhausted"))
);
}
#[tokio::test]
async fn shared_state_flows_to_tools() {
struct CounterTool;
#[async_trait::async_trait]
impl Tool for CounterTool {
fn schema(&self) -> ToolSchema {
ToolSchema {
name: "counter".into(),
description: "Count".into(),
parameters: serde_json::json!({}),
}
}
async fn call(
&self,
_arguments: serde_json::Value,
state: &SharedState,
) -> Result<String, ToolError> {
state.with_mut::<usize>(|n| *n += 1);
Ok(format!("count={}", state.get::<usize>().unwrap_or(0)))
}
}
let mut registry = ToolRegistry::new();
registry.register(CounterTool);
let fake = SharedFake::new([
FakeReply::ToolCalls {
content: "".into(),
calls: vec![call("c1", "counter", "{}"), call("c2", "counter", "{}")],
},
FakeReply::Text("done".into()),
]);
let state = SharedState::new();
state.insert(0usize);
let mut agent = ReActAgent::new(fake, registry, "").with_state(state.clone());
agent.run("Count").await.unwrap();
assert_eq!(state.get::<usize>(), Some(2));
}
#[tokio::test]
async fn memory_error_passthrough() {
struct FailingMemory;
#[async_trait::async_trait]
impl Memory for FailingMemory {
async fn record(&mut self, _message: Message) -> Result<(), MemoryError> {
Err(MemoryError::Storage("disk full".into()))
}
async fn context(&self) -> Result<Vec<Message>, MemoryError> {
Ok(Vec::new())
}
}
let mut agent = ReActAgent::new(
FakeProvider::new([FakeReply::Text("hi".into())]),
ToolRegistry::new(),
"",
)
.with_memory(FailingMemory);
let err = agent.run("hi").await.unwrap_err();
assert!(matches!(err, AgentError::Memory(MemoryError::Storage(_))));
}
#[tokio::test]
async fn stream_tool_result_record_failure_terminates_stream() {
struct FailingToolResultMemory;
#[async_trait::async_trait]
impl Memory for FailingToolResultMemory {
async fn record(&mut self, message: Message) -> Result<(), MemoryError> {
if matches!(message, Message::ToolResult { .. }) {
Err(MemoryError::Storage("disk full".into()))
} else {
Ok(())
}
}
async fn context(&self) -> Result<Vec<Message>, MemoryError> {
Ok(Vec::new())
}
}
let (calc, _calls) = FakeTool::new("calc", "42");
let mut registry = ToolRegistry::new();
registry.register(calc);
let fake = SharedFake::new([
FakeReply::ToolCalls {
content: "".into(),
calls: vec![call("c1", "calc", "{}")],
},
FakeReply::Text("42".into()),
]);
let mut agent = agent_with_registry(fake.clone(), registry, AgentConfig::default())
.with_memory(FailingToolResultMemory);
let mut stream = agent.run_stream("Compute").await.unwrap();
let chunks: Vec<Result<MessageChunk, AgentError>> = stream.by_ref().collect().await;
assert_eq!(chunks.len(), 3);
assert!(matches!(
chunks[2],
Err(AgentError::Memory(MemoryError::Storage(_)))
));
}
#[tokio::test]
async fn config_options_forwarded_to_chat_request() {
use crate::provider::ModelOptions;
let fake = SharedFake::new([FakeReply::Text("hi".into())]);
let mut agent = agent(fake.clone(), "").with_config(AgentConfig {
max_tool_rounds: 10,
options: ModelOptions {
temperature: Some(0.2),
max_tokens: Some(128),
extra: Default::default(),
structured: None,
},
..Default::default()
});
agent.run("hi").await.unwrap();
let req = &fake.requests()[0];
assert_eq!(req.options.temperature, Some(0.2));
assert_eq!(req.options.max_tokens, Some(128));
}
#[tokio::test]
async fn cancelled_before_run_returns_cancelled() {
let fake = SharedFake::new([FakeReply::Text("hi".into())]);
let mut agent = agent(fake.clone(), "");
let token = CancellationToken::new();
token.cancel();
let err = agent.run_cancellable("hi", &token).await.unwrap_err();
assert!(matches!(err, AgentError::Cancelled));
assert_eq!(fake.requests().len(), 0); assert_eq!(agent.memory.context().await.unwrap().len(), 1); }
#[tokio::test]
async fn pre_cancelled_token_rounds_consistent_across_paths() {
let fake = SharedFake::new([FakeReply::Text("hi".into())]);
let token = CancellationToken::new();
token.cancel();
let (mut run_agent, mut rx) = attach_channel(agent(fake.clone(), ""));
let err = run_agent.run_cancellable("hi", &token).await.unwrap_err();
assert!(matches!(err, AgentError::Cancelled));
drop(run_agent);
let events = drain(&mut rx).await;
let rounds_run = match react_event(&**events.last().unwrap()) {
ReActEvent::RunEnded { summary, error, .. } => {
assert_eq!(error, &Some(AgentError::Cancelled));
summary.rounds
}
_ => panic!("expected RunEnded"),
};
let (mut stream_agent, mut rx) = attach_channel(agent(fake, ""));
let mut stream = stream_agent
.run_stream_cancellable("hi", &token)
.await
.unwrap();
let chunks: Vec<MessageChunk> = stream.by_ref().map(|e| e.unwrap()).collect().await;
assert!(chunks.contains(&MessageChunk::Cancelled));
drop(stream);
drop(stream_agent);
let events = drain(&mut rx).await;
let rounds_stream = match react_event(&**events.last().unwrap()) {
ReActEvent::RunEnded { summary, error, .. } => {
assert_eq!(error, &Some(AgentError::Cancelled));
summary.rounds
}
_ => panic!("expected RunEnded"),
};
assert_eq!(rounds_run, rounds_stream);
assert_eq!(rounds_run, 1);
}
#[tokio::test]
async fn cancel_during_chat_drops_inflight() {
struct PendingProvider;
#[async_trait::async_trait]
impl Provider for PendingProvider {
async fn chat(&self, _request: ChatRequest) -> Result<ChatResponse, ProviderError> {
std::future::pending().await }
async fn stream_chat(
&self,
_request: ChatRequest,
) -> Result<BoxStream<'static, Result<StreamEvent, ProviderError>>, ProviderError>
{
unreachable!("this test uses non-streaming path only")
}
}
let mut agent = ReActAgent::new(PendingProvider, ToolRegistry::new(), "");
let token = CancellationToken::new();
let result = tokio::select! {
r = agent.run_cancellable("hi", &token) => r,
_ = async {
tokio::time::sleep(Duration::from_millis(20)).await;
token.cancel();
std::future::pending::<()>().await; } => unreachable!("cancellation branch only sends a signal"),
};
assert!(matches!(result, Err(AgentError::Cancelled)));
assert_eq!(agent.memory.context().await.unwrap().len(), 1); }
#[tokio::test]
async fn provider_error_propagates_from_both_paths() {
struct FailProvider(ProviderError);
#[async_trait::async_trait]
impl Provider for FailProvider {
async fn chat(&self, _request: ChatRequest) -> Result<ChatResponse, ProviderError> {
Err(self.0.clone())
}
async fn stream_chat(
&self,
_request: ChatRequest,
) -> Result<BoxStream<'static, Result<StreamEvent, ProviderError>>, ProviderError>
{
Err(self.0.clone())
}
}
let err = ProviderError::Timeout(TimeoutStage::Request);
let mut agent = ReActAgent::new(FailProvider(err.clone()), ToolRegistry::new(), "");
let got = agent.run("hi").await.unwrap_err();
assert!(matches!(
got,
AgentError::Provider(ProviderError::Timeout(_))
));
assert_eq!(agent.memory.context().await.unwrap().len(), 1);
let mut agent = ReActAgent::new(FailProvider(err.clone()), ToolRegistry::new(), "");
let mut stream = agent.run_stream("hi").await.unwrap();
let item = stream.next().await.unwrap().unwrap_err();
assert!(matches!(
item,
AgentError::Provider(ProviderError::Timeout(_))
));
assert!(stream.next().await.is_none());
drop(stream); assert_eq!(agent.memory.context().await.unwrap().len(), 1);
}
#[tokio::test]
async fn structured_output_valid_answer_passes() {
let schema = serde_json::json!({
"type": "object",
"properties": { "city": { "type": "string" } },
"required": ["city"],
});
let fake = SharedFake::new([FakeReply::Text(r#"{"city":"Beijing"}"#.into())]);
let mut agent = agent(fake.clone(), "").with_structured_output(schema);
let answer = agent.run("Beijing weather").await.unwrap();
assert_eq!(answer, r#"{"city":"Beijing"}"#);
assert_eq!(fake.requests().len(), 1); }
#[tokio::test]
async fn structured_output_retries_after_invalid_answer() {
let schema = serde_json::json!({
"type": "object",
"properties": { "city": { "type": "string" } },
"required": ["city"],
});
let fake = SharedFake::new([
FakeReply::Text("Not JSON".into()),
FakeReply::Text(r#"{"city":"Beijing"}"#.into()),
]);
let mut agent = agent(fake.clone(), "").with_structured_output(schema);
let answer = agent.run("Beijing weather").await.unwrap();
assert_eq!(answer, r#"{"city":"Beijing"}"#);
assert_eq!(fake.requests().len(), 2); let context = agent.memory.context().await.unwrap();
assert!(context.iter().any(|m| matches!(
m,
Message::User(blocks) if blocks.iter().any(|b| matches!(b, ContentBlock::Text(t) if t.contains("JSON schema validation")))
)));
}
#[tokio::test]
async fn structured_output_exhausts_retry_budget() {
let schema = serde_json::json!({ "type": "object" });
let fake = SharedFake::new([
FakeReply::Text("bad1".into()),
FakeReply::Text("bad2".into()),
FakeReply::Text("bad3".into()),
FakeReply::Text("bad4".into()),
]);
let mut agent = agent(fake.clone(), "").with_config(AgentConfig {
max_tool_rounds: 1,
max_structured_retries: 3,
..Default::default()
});
agent = agent.with_structured_output(schema);
let err = agent.run("hi").await.unwrap_err();
assert!(matches!(err, AgentError::StructuredRetriesExhausted(3)));
assert_eq!(fake.requests().len(), 4); }
#[tokio::test]
async fn typed_output_parses_valid_answer() {
#[derive(Debug, Deserialize, JsonSchema)]
struct Weather {
city: String,
}
let fake = SharedFake::new([FakeReply::Text(r#"{"city":"Beijing"}"#.into())]);
let mut agent = agent(fake.clone(), "");
let weather: Weather = agent.run_typed("Beijing weather").await.unwrap();
assert_eq!(weather.city, "Beijing");
assert_eq!(fake.requests().len(), 1); assert!(fake.requests()[0].options.structured.is_some());
assert!(agent.config.options.structured.is_none());
}
#[tokio::test]
async fn typed_output_retries_then_parses() {
#[derive(Debug, Deserialize, JsonSchema)]
struct Weather {
city: String,
}
let fake = SharedFake::new([
FakeReply::Text("Not JSON".into()),
FakeReply::Text(r#"{"city":"Beijing"}"#.into()),
]);
let mut agent = agent(fake.clone(), "");
let weather: Weather = agent.run_typed("Beijing weather").await.unwrap();
assert_eq!(weather.city, "Beijing");
assert_eq!(fake.requests().len(), 2);
}
#[tokio::test]
async fn typed_output_parse_failure_on_schema_mismatch() {
fn string_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
serde_json::from_value(serde_json::json!({ "type": "string" })).unwrap()
}
#[derive(Debug, Deserialize, JsonSchema)]
#[allow(dead_code)] struct Weather {
#[schemars(schema_with = "string_schema")]
temperature: i32,
}
let fake = SharedFake::new([FakeReply::Text(r#"{"temperature":"30"}"#.into())]);
let mut agent = agent(fake.clone(), "");
let err: AgentError = agent
.run_typed::<Weather>("Beijing weather")
.await
.unwrap_err();
assert!(matches!(err, AgentError::StructuredParse(_)));
}
#[tokio::test]
async fn typed_agent_trait_generic_call() {
#[derive(Debug, Deserialize, JsonSchema)]
struct Weather {
city: String,
}
async fn typed_run<A: TypedAgent>(
agent: &mut A,
input: &str,
) -> Result<Weather, AgentError> {
agent.run_typed(input).await
}
let fake = SharedFake::new([FakeReply::Text(r#"{"city":"Beijing"}"#.into())]);
let mut agent = agent(fake.clone(), "");
let weather = typed_run(&mut agent, "Beijing weather").await.unwrap();
assert_eq!(weather.city, "Beijing");
}
#[tokio::test]
async fn typed_output_agent_trait_run_returns_text() {
#[derive(Debug, Deserialize, JsonSchema)]
struct Weather {
city: String,
}
let fake = SharedFake::new([
FakeReply::Text(r#"{"city":"Beijing"}"#.into()),
FakeReply::Text("Hello".into()),
]);
let mut agent = agent(fake.clone(), "");
let weather: Weather = agent.run_typed("Beijing weather").await.unwrap();
assert_eq!(weather.city, "Beijing");
let text = Agent::run(&mut agent, "Say hi").await.unwrap();
assert_eq!(text, "Hello");
}
#[tokio::test]
async fn structured_output_stream_exhausts_retry_budget() {
let schema = serde_json::json!({ "type": "object" });
let fake = SharedFake::new([
FakeReply::Text("bad1".into()),
FakeReply::Text("bad2".into()),
]);
let mut agent = agent(fake.clone(), "").with_config(AgentConfig {
max_structured_retries: 1,
..Default::default()
});
agent = agent.with_structured_output(schema);
let mut stream = agent.run_stream("hi").await.unwrap();
let mut saw_err = false;
while let Some(item) = stream.next().await {
if let Err(e) = item {
assert!(matches!(e, AgentError::StructuredRetriesExhausted(1)));
saw_err = true;
break;
}
}
assert!(saw_err);
}
#[tokio::test]
async fn tool_round_atomic_under_cancel() {
struct SlowTool {
calls: Arc<AtomicUsize>,
}
#[async_trait::async_trait]
impl Tool for SlowTool {
fn schema(&self) -> ToolSchema {
ToolSchema {
name: "slow".into(),
description: "Slow tool".into(),
parameters: serde_json::json!({}),
}
}
async fn call(
&self,
_arguments: serde_json::Value,
_state: &SharedState,
) -> Result<String, ToolError> {
self.calls.fetch_add(1, Ordering::Relaxed);
tokio::time::sleep(Duration::from_millis(100)).await;
Ok("42".into())
}
}
let calls = Arc::new(AtomicUsize::new(0));
let mut registry = ToolRegistry::new();
registry.register(SlowTool {
calls: calls.clone(),
});
let fake = SharedFake::new([
FakeReply::ToolCalls {
content: "".into(),
calls: vec![call("c1", "slow", "{}")],
},
FakeReply::Text("42".into()),
]);
let mut agent = agent_with_registry(fake.clone(), registry, AgentConfig::default());
let token = CancellationToken::new();
let result = tokio::select! {
r = agent.run_cancellable("Compute", &token) => r,
_ = async {
tokio::time::sleep(Duration::from_millis(50)).await;
token.cancel();
std::future::pending::<()>().await; } => unreachable!("cancellation branch only sends a signal"),
};
assert!(matches!(result, Err(AgentError::Cancelled)));
assert_eq!(calls.load(Ordering::Relaxed), 1); assert_eq!(fake.requests().len(), 1); let ctx = agent.memory.context().await.unwrap();
assert_eq!(ctx.len(), 3);
assert!(matches!(
&ctx[1],
Message::Assistant { tool_calls, .. } if tool_calls.len() == 1
));
assert!(matches!(&ctx[2], Message::ToolResult { content, .. } if content == "42"));
}
#[tokio::test]
async fn custom_tool_round_executor() {
#[derive(Default)]
struct DenyAlphaToolRoundExecutor {
denied: bool,
}
#[async_trait::async_trait]
impl ToolRoundExecutor for DenyAlphaToolRoundExecutor {
async fn execute_round<'a>(
&'a mut self,
ctx: ToolRoundCtx<'a>,
calls: Vec<ToolCall>,
) -> BoxStream<'a, ToolCallOutcome> {
let mut outcomes = Vec::with_capacity(calls.len());
for call in calls.into_iter().rev() {
if call.name == "alpha" && !self.denied {
self.denied = true;
outcomes.push(ToolCallOutcome {
call,
content: "denied by policy".into(),
protected: false,
});
} else {
outcomes.push(ctx.run(call).await);
}
}
Box::pin(futures::stream::iter(outcomes))
}
}
let (alpha_tool, alpha_calls) = FakeTool::new("alpha", "A");
let (beta_tool, beta_calls) = FakeTool::new("beta", "B");
let mut registry = ToolRegistry::new();
registry.register(alpha_tool);
registry.register(beta_tool);
let fake = SharedFake::new([
FakeReply::ToolCalls {
content: "".into(),
calls: vec![call("c1", "alpha", "{}"), call("c2", "beta", "{}")],
},
FakeReply::Text("done".into()),
]);
let mut agent = agent_with_registry(fake.clone(), registry, AgentConfig::default())
.with_tool_round_executor(DenyAlphaToolRoundExecutor::default());
let answer = agent.run("Compute").await.unwrap();
assert_eq!(answer, "done");
assert_eq!(alpha_calls.load(Ordering::Relaxed), 0);
assert_eq!(beta_calls.load(Ordering::Relaxed), 1);
let requests = fake.requests();
assert_eq!(requests.len(), 2);
assert_eq!(requests[1].messages[2], Message::tool_result("c2", "B"),);
assert_eq!(
requests[1].messages[3],
Message::tool_result("c1", "denied by policy"),
);
}
#[tokio::test]
async fn custom_tool_round_executor_streaming() {
#[derive(Default)]
struct ReversedToolRoundExecutor;
#[async_trait::async_trait]
impl ToolRoundExecutor for ReversedToolRoundExecutor {
async fn execute_round<'a>(
&'a mut self,
ctx: ToolRoundCtx<'a>,
calls: Vec<ToolCall>,
) -> BoxStream<'a, ToolCallOutcome> {
let mut outcomes = Vec::with_capacity(calls.len());
for call in calls.into_iter().rev() {
outcomes.push(ctx.run(call).await);
}
Box::pin(futures::stream::iter(outcomes))
}
}
let (alpha_tool, _) = FakeTool::new("alpha", "A");
let (beta_tool, _) = FakeTool::new("beta", "B");
let mut registry = ToolRegistry::new();
registry.register(alpha_tool);
registry.register(beta_tool);
let fake = SharedFake::new([
FakeReply::ToolCalls {
content: "".into(),
calls: vec![call("c1", "alpha", "{}"), call("c2", "beta", "{}")],
},
FakeReply::Text("done".into()),
]);
let mut agent = agent_with_registry(fake.clone(), registry, AgentConfig::default())
.with_tool_round_executor(ReversedToolRoundExecutor);
let mut stream = agent.run_stream("Compute").await.unwrap();
let mut results = Vec::new();
let mut done = false;
while let Some(item) = stream.next().await {
match item.unwrap() {
MessageChunk::ToolResult { id, content, .. } => {
results.push((id, content));
}
MessageChunk::Done(_) => done = true,
_ => {}
}
}
assert!(done);
assert_eq!(
results,
vec![
("c2".to_string(), "B".to_string()),
("c1".to_string(), "A".to_string())
]
);
}
#[tokio::test]
async fn stream_cancel_mid_generation() {
struct SlowStreamProvider;
#[async_trait::async_trait]
impl Provider for SlowStreamProvider {
async fn chat(&self, _request: ChatRequest) -> Result<ChatResponse, ProviderError> {
unreachable!("this test uses streaming path only")
}
async fn stream_chat(
&self,
_request: ChatRequest,
) -> Result<BoxStream<'static, Result<StreamEvent, ProviderError>>, ProviderError>
{
Ok(Box::pin(async_stream::stream! {
yield Ok(StreamEvent::Delta("d0".into()));
tokio::time::sleep(Duration::from_millis(100)).await;
yield Ok(StreamEvent::Delta("d1".into()));
tokio::time::sleep(Duration::from_millis(100)).await;
yield Ok(StreamEvent::Done {
reason: FinishReason::Stop,
usage: None,
});
}))
}
}
let mut agent = ReActAgent::new(SlowStreamProvider, ToolRegistry::new(), "");
let token = CancellationToken::new();
let mut stream = agent.run_stream_cancellable("hi", &token).await.unwrap();
let mut first = Vec::new();
tokio::select! {
_ = async {
while let Some(ev) = stream.next().await {
let ev = ev.unwrap();
if ev == MessageChunk::Cancelled {
break;
}
first.push(ev);
}
} => {}
_ = async {
tokio::time::sleep(Duration::from_millis(50)).await;
token.cancel();
} => {}
}
assert_eq!(first, vec![MessageChunk::Delta("d0".into())]);
let rest: Vec<MessageChunk> = stream.by_ref().map(|e| e.unwrap()).collect().await;
assert_eq!(rest, vec![MessageChunk::Cancelled]);
drop(stream); assert_eq!(agent.memory.context().await.unwrap().len(), 1);
}
#[tokio::test]
async fn cancelled_then_fresh_token_run_works() {
let fake = SharedFake::new([FakeReply::Text("hi".into())]);
let mut agent = agent(fake, "");
let t1 = CancellationToken::new();
t1.cancel();
assert!(matches!(
agent.run_cancellable("one", &t1).await,
Err(AgentError::Cancelled)
));
let t2 = CancellationToken::new();
assert_eq!(agent.run_cancellable("two", &t2).await.unwrap(), "hi");
}
use crate::event_channel::{BroadcastEventChannel, EventReceiver};
use crate::tool::RegistryError;
fn attach_channel(agent: ReActAgent) -> (ReActAgent, Box<dyn EventReceiver>) {
let channel = BroadcastEventChannel::new(64);
let rx = channel.subscribe();
(agent.with_event_channel(channel), rx)
}
async fn drain(rx: &mut Box<dyn EventReceiver>) -> Vec<Arc<dyn AgentEvent>> {
let mut out = Vec::new();
while let Some(ev) = rx.recv().await {
out.push(ev);
}
out
}
fn names(events: &[Arc<dyn AgentEvent>]) -> Vec<&'static str> {
events.iter().map(|e| e.name()).collect()
}
fn react_event(ev: &dyn AgentEvent) -> &ReActEvent {
ev.as_any()
.downcast_ref::<ReActEvent>()
.expect("test event should be ReActEvent")
}
#[tokio::test]
async fn events_published_on_run() {
let (calc, _calls) = FakeTool::new("calc", "42");
let mut registry = ToolRegistry::new();
registry.register(calc);
let fake = SharedFake::new([
FakeReply::ToolCalls {
content: "".into(),
calls: vec![call("c1", "calc", r#"{"a":1}"#)],
},
FakeReply::Text("The answer is 42".into()),
]);
let (mut agent, mut rx) =
attach_channel(agent_with_registry(fake, registry, AgentConfig::default()));
let answer = agent.run("Compute").await.unwrap();
assert_eq!(answer, "The answer is 42");
drop(agent);
let events = drain(&mut rx).await;
assert_eq!(
names(&events),
["run.started", "tool.started", "tool.completed", "run.ended"]
);
match react_event(&*events[1]) {
ReActEvent::ToolStarted {
id,
name,
arguments,
} => {
assert_eq!(id, "c1");
assert_eq!(name, "calc");
assert_eq!(arguments, r#"{"a":1}"#);
}
_ => panic!("expected ToolStarted"),
}
match react_event(&*events[2]) {
ReActEvent::ToolCompleted { result, .. } => {
assert_eq!(result, &Ok("42".to_string()));
}
_ => panic!("expected ToolCompleted"),
}
match react_event(&*events[3]) {
ReActEvent::RunEnded { summary, error } => {
assert_eq!(error, &None);
assert_eq!(summary.rounds, 2);
assert_eq!(summary.tool_calls, 1);
}
_ => panic!("expected RunEnded"),
}
}
#[tokio::test]
async fn events_carry_tool_failure() {
let fake = SharedFake::new([
FakeReply::ToolCalls {
content: "".into(),
calls: vec![call("c1", "nope", "{}")],
},
FakeReply::Text("Got it".into()),
]);
let (mut agent, mut rx) = attach_channel(agent(fake, ""));
agent.run("Trigger").await.unwrap();
drop(agent);
let events = drain(&mut rx).await;
let completed = events
.iter()
.find_map(|e| match react_event(&**e) {
ReActEvent::ToolCompleted { result, .. } => Some(result),
_ => None,
})
.expect("expected ToolCompleted event");
assert!(matches!(completed, Err(RegistryError::NotFound(n)) if n == "nope"));
assert_eq!(
completed.as_ref().unwrap_err().to_string(),
"tool not found: nope"
);
}
#[tokio::test]
async fn stream_events_include_delta_and_reasoning() {
let (calc, _calls) = FakeTool::new("calc", "42");
let mut registry = ToolRegistry::new();
registry.register(calc);
let fake = SharedFake::new([
FakeReply::ToolCalls {
content: "Thinking: ".into(),
calls: vec![call("c1", "calc", "{}")],
},
FakeReply::TextWithReasoning {
content: "The answer is 42".into(),
reasoning: "Reasoning steps".into(),
},
]);
let (mut agent, mut rx) =
attach_channel(agent_with_registry(fake, registry, AgentConfig::default()));
let answer: String = agent
.run_stream("Compute")
.await
.unwrap()
.map(|e| e.unwrap())
.filter_map(|e| async move {
match e {
MessageChunk::Delta(d) => Some(d),
_ => None,
}
})
.collect()
.await;
assert_eq!(answer, "Thinking: The answer is 42");
drop(agent);
let events = drain(&mut rx).await;
assert_eq!(
names(&events),
[
"run.started",
"delta",
"tool.started",
"tool.completed",
"delta",
"reasoning",
"run.ended",
]
);
let reasoning = events
.iter()
.find_map(|e| match react_event(&**e) {
ReActEvent::Reasoning { text } => Some(text.as_str()),
_ => None,
})
.expect("expected Reasoning event");
assert_eq!(reasoning, "Reasoning steps");
match react_event(&**events.last().unwrap()) {
ReActEvent::RunEnded { summary, error } => {
assert_eq!(error, &None);
assert_eq!(summary.rounds, 2);
assert_eq!(summary.tool_calls, 1);
}
_ => panic!("expected RunEnded"),
}
}
#[tokio::test]
async fn cancelled_run_publishes_run_ended_with_error() {
struct SlowStreamProvider;
#[async_trait::async_trait]
impl Provider for SlowStreamProvider {
async fn chat(&self, _request: ChatRequest) -> Result<ChatResponse, ProviderError> {
unreachable!("this test uses streaming path only")
}
async fn stream_chat(
&self,
_request: ChatRequest,
) -> Result<BoxStream<'static, Result<StreamEvent, ProviderError>>, ProviderError>
{
Ok(Box::pin(async_stream::stream! {
yield Ok(StreamEvent::Delta("d0".into()));
tokio::time::sleep(Duration::from_millis(100)).await;
yield Ok(StreamEvent::Delta("d1".into()));
tokio::time::sleep(Duration::from_millis(100)).await;
yield Ok(StreamEvent::Done {
reason: FinishReason::Stop,
usage: None,
});
}))
}
}
let (mut agent, mut rx) =
attach_channel(ReActAgent::new(SlowStreamProvider, ToolRegistry::new(), ""));
let token = CancellationToken::new();
let mut stream = agent.run_stream_cancellable("hi", &token).await.unwrap();
tokio::select! {
_ = async {
while let Some(ev) = stream.next().await {
let ev = ev.unwrap();
if ev == MessageChunk::Cancelled {
break;
}
}
} => {}
_ = async {
tokio::time::sleep(Duration::from_millis(50)).await;
token.cancel();
} => {}
}
let rest: Vec<MessageChunk> = stream.by_ref().map(|e| e.unwrap()).collect().await;
assert!(rest.contains(&MessageChunk::Cancelled));
drop(stream);
drop(agent);
let events = drain(&mut rx).await;
assert!(matches!(names(&events).as_slice(), [.., "run.ended"]));
match react_event(&**events.last().unwrap()) {
ReActEvent::RunEnded { error, .. } => {
assert_eq!(error, &Some(AgentError::Cancelled));
}
_ => panic!("expected RunEnded"),
}
}
use std::collections::HashMap;
use std::sync::atomic::AtomicU64;
use tracing::field::{Field, Visit};
use tracing::subscriber::Subscriber;
use tracing::{Event, Id, Level, Metadata};
#[derive(Debug, Clone, PartialEq, Eq)]
struct SpanInfo {
name: &'static str,
level: Level,
fields: Vec<(String, String)>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Op {
Enter(String),
Exit(String),
Close(String),
Record(String, String),
}
#[derive(Debug, Default)]
struct CollectSubscriber {
spans: std::sync::Mutex<Vec<SpanInfo>>,
ops: std::sync::Mutex<Vec<Op>>,
names: std::sync::Mutex<HashMap<Id, String>>,
next_id: AtomicU64,
}
struct FieldCollector<'a>(&'a mut Vec<(String, String)>);
impl Visit for FieldCollector<'_> {
fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) {
self.0
.push((field.name().to_string(), format!("{value:?}")));
}
}
impl Subscriber for CollectSubscriber {
fn enabled(&self, _metadata: &Metadata<'_>) -> bool {
true
}
fn new_span(&self, span: &tracing::span::Attributes<'_>) -> Id {
let id = Id::from_u64(self.next_id.fetch_add(1, Ordering::Relaxed) + 1);
let mut fields = Vec::new();
span.record(&mut FieldCollector(&mut fields));
self.spans.lock().unwrap().push(SpanInfo {
name: span.metadata().name(),
level: *span.metadata().level(),
fields,
});
self.names
.lock()
.unwrap()
.insert(id.clone(), span.metadata().name().to_string());
id
}
fn record(&self, id: &Id, values: &tracing::span::Record<'_>) {
let name = self.names.lock().unwrap().get(id).cloned();
let Some(name) = name else { return };
let mut fields = Vec::new();
values.record(&mut FieldCollector(&mut fields));
let mut ops = self.ops.lock().unwrap();
for (field, value) in fields {
ops.push(Op::Record(name.clone(), format!("{field}={value}")));
}
}
fn enter(&self, id: &Id) {
let name = self.names.lock().unwrap().get(id).cloned();
if let Some(name) = name {
self.ops.lock().unwrap().push(Op::Enter(name));
}
}
fn exit(&self, id: &Id) {
let name = self.names.lock().unwrap().get(id).cloned();
if let Some(name) = name {
self.ops.lock().unwrap().push(Op::Exit(name));
}
}
fn try_close(&self, id: Id) -> bool {
let name = self.names.lock().unwrap().get(&id).cloned();
if let Some(name) = name {
self.ops.lock().unwrap().push(Op::Close(name));
}
true
}
fn clone_span(&self, id: &Id) -> Id {
id.clone()
}
fn event(&self, _event: &Event<'_>) {}
fn record_follows_from(&self, _span: &Id, _follows: &Id) {}
}
fn collect_guard(sub: &Arc<CollectSubscriber>) -> tracing::dispatcher::DefaultGuard {
tracing::dispatcher::set_default(&tracing::Dispatch::new(sub.clone()))
}
fn enter_names(ops: &[Op]) -> Vec<String> {
ops.iter()
.filter_map(|op| match op {
Op::Enter(name) => Some(name.clone()),
_ => None,
})
.collect()
}
fn records_of(ops: &[Op], span: &str) -> Vec<String> {
ops.iter()
.filter_map(|op| match op {
Op::Record(s, kv) if s == span => Some(kv.clone()),
_ => None,
})
.collect()
}
fn assert_nesting_invariants(ops: &[Op]) {
let mut stack: Vec<String> = Vec::new();
for op in ops {
match op {
Op::Enter(name) => {
if name != "agent.run" {
assert!(
stack.contains(&"agent.run".to_string()),
"span {name} requires agent.run on the stack when entering (stack: {stack:?})"
);
}
assert!(
!stack.contains(name),
"same span entered twice (double instrumenting): {name} (stack: {stack:?})"
);
stack.push(name.clone());
}
Op::Exit(name) => {
assert_eq!(
stack.pop().as_deref(),
Some(name.as_str()),
"exit must pair with enter: {name}"
);
}
_ => {}
}
}
}
#[tokio::test]
async fn trace_span_tree_non_stream() {
let sub = Arc::new(CollectSubscriber::default());
let _guard = collect_guard(&sub);
let (calc, _calls) = FakeTool::new("calc", "42");
let mut registry = ToolRegistry::new();
registry.register(calc);
let fake = SharedFake::new([
FakeReply::WithUsage {
reply: Box::new(FakeReply::ToolCalls {
content: "".into(),
calls: vec![call("c1", "calc", "{}")],
}),
usage: Usage::new(10, 2),
},
FakeReply::text_with_usage("42", Usage::new(20, 5)),
]);
let mut agent = agent_with_registry(fake, registry, AgentConfig::default());
assert_eq!(agent.run("Compute").await.unwrap(), "42");
let ops = sub.ops.lock().unwrap().clone();
assert_nesting_invariants(&ops);
let enters = enter_names(&ops);
let mut first_seen = Vec::new();
for name in enters.iter() {
if !first_seen.contains(name) {
first_seen.push(name.clone());
}
}
assert_eq!(first_seen, ["agent.run", "llm_request", "tool"]);
assert!(enters.iter().filter(|n| *n == "llm_request").count() >= 2);
assert!(enters.iter().filter(|n| *n == "tool").count() >= 1);
assert_eq!(
records_of(&ops, "llm_request"),
[
"usage.prompt_tokens=10",
"usage.completion_tokens=2",
"usage.prompt_tokens=20",
"usage.completion_tokens=5",
]
);
let spans = sub.spans.lock().unwrap().clone();
let run_span = spans.iter().find(|s| s.name == "agent.run").unwrap();
assert_eq!(run_span.level, Level::INFO);
assert_eq!(
spans
.iter()
.find(|s| s.name == "llm_request")
.unwrap()
.level,
Level::DEBUG
);
assert_eq!(
spans.iter().find(|s| s.name == "tool").unwrap().level,
Level::DEBUG
);
let llm_rounds: Vec<u64> = spans
.iter()
.filter(|s| s.name == "llm_request")
.map(|s| {
s.fields
.iter()
.find(|(f, _)| f == "round")
.map(|(_, v)| v.parse().unwrap())
.unwrap()
})
.collect();
assert_eq!(llm_rounds, vec![1, 2]);
let run_ids: Vec<String> = spans
.iter()
.map(|s| {
s.fields
.iter()
.find(|(f, _)| f == "run.id")
.map(|(_, v)| v.clone())
.unwrap_or_else(|| panic!("span {} must carry a run.id field", s.name))
})
.collect();
assert!(run_ids.iter().all(|id| id == &run_ids[0]));
assert!(run_ids[0].starts_with("run-"));
}
#[tokio::test]
async fn trace_span_tree_stream() {
let sub = Arc::new(CollectSubscriber::default());
let _guard = collect_guard(&sub);
let (calc, _calls) = FakeTool::new("calc", "42");
let mut registry = ToolRegistry::new();
registry.register(calc);
let fake = SharedFake::new([
FakeReply::WithUsage {
reply: Box::new(FakeReply::ToolCalls {
content: "".into(),
calls: vec![call("c1", "calc", "{}")],
}),
usage: Usage::new(10, 2),
},
FakeReply::text_with_usage("42", Usage::new(20, 5)),
]);
let mut agent = agent_with_registry(fake, registry, AgentConfig::default());
agent
.run_stream("Compute")
.await
.unwrap()
.for_each(|_| async {})
.await;
let ops = sub.ops.lock().unwrap().clone();
assert_nesting_invariants(&ops);
let enters = enter_names(&ops);
let mut first_seen = Vec::new();
for name in enters.iter() {
if !first_seen.contains(name) {
first_seen.push(name.clone());
}
}
assert_eq!(first_seen, ["agent.run", "llm_request", "tool"]);
assert!(enters.iter().filter(|n| *n == "agent.run").count() > 2);
assert_eq!(
records_of(&ops, "llm_request"),
[
"usage.prompt_tokens=10",
"usage.completion_tokens=2",
"usage.prompt_tokens=20",
"usage.completion_tokens=5",
]
);
}
#[tokio::test]
async fn trace_run_id_differs_between_runs() {
let sub = Arc::new(CollectSubscriber::default());
let _guard = collect_guard(&sub);
let fake = SharedFake::new([FakeReply::Text("hi".into()), FakeReply::Text("bye".into())]);
let (mut agent, mut rx) = attach_channel(agent(fake, ""));
agent.run("one").await.unwrap();
agent.run("two").await.unwrap();
drop(agent);
let spans = sub.spans.lock().unwrap().clone();
let run_ids: Vec<String> = spans
.iter()
.filter(|s| s.name == "agent.run")
.map(|s| {
s.fields
.iter()
.find(|(f, _)| f == "run.id")
.map(|(_, v)| v.clone())
.unwrap()
})
.collect();
assert_eq!(run_ids.len(), 2);
assert_ne!(run_ids[0], run_ids[1]);
let events = drain(&mut rx).await;
let event_ids: Vec<String> = events
.iter()
.filter_map(|e| match react_event(&**e) {
ReActEvent::RunStarted { run_id, .. } => Some(run_id.clone()),
_ => None,
})
.collect();
assert_eq!(event_ids, run_ids);
}
#[tokio::test]
async fn trace_run_error_recorded() {
let sub = Arc::new(CollectSubscriber::default());
let _guard = collect_guard(&sub);
let (calc, _calls) = FakeTool::new("calc", "42");
let mut registry = ToolRegistry::new();
registry.register(calc);
let fake = SharedFake::new([
FakeReply::ToolCalls {
content: "".into(),
calls: vec![call("c1", "calc", "{}")],
},
FakeReply::ToolCalls {
content: "".into(),
calls: vec![call("c2", "calc", "{}")],
},
]);
let mut agent = agent_with_registry(
fake,
registry,
AgentConfig {
max_tool_rounds: 2,
..Default::default()
},
);
assert!(matches!(
agent.run("Keep computing").await,
Err(AgentError::TooManyToolRounds(2))
));
let ops = sub.ops.lock().unwrap().clone();
let records = records_of(&ops, "agent.run");
assert_eq!(records.len(), 1);
assert!(
records[0].starts_with("error=\"model requested tools for more than 2 rounds"),
"unexpected records: {records:?}"
);
}
#[tokio::test]
async fn trace_tool_error_recorded() {
struct FailingTool;
#[async_trait::async_trait]
impl Tool for FailingTool {
fn schema(&self) -> ToolSchema {
ToolSchema {
name: "boom".into(),
description: "Tool that always fails".into(),
parameters: serde_json::json!({}),
}
}
async fn call(
&self,
_arguments: serde_json::Value,
_state: &SharedState,
) -> Result<String, ToolError> {
Err(ToolError::Execution("internal error".into()))
}
}
let sub = Arc::new(CollectSubscriber::default());
let _guard = collect_guard(&sub);
let mut registry = ToolRegistry::new();
registry.register(FailingTool);
let fake = SharedFake::new([
FakeReply::ToolCalls {
content: "".into(),
calls: vec![call("c1", "boom", "{}")],
},
FakeReply::Text("Got it".into()),
]);
let mut agent = agent_with_registry(fake, registry, AgentConfig::default());
agent.run("Trigger failure").await.unwrap();
let ops = sub.ops.lock().unwrap().clone();
let tool_errors = records_of(&ops, "tool");
assert_eq!(tool_errors.len(), 1);
assert!(tool_errors[0].contains("internal error"));
assert!(records_of(&ops, "agent.run").is_empty());
}
#[tokio::test]
async fn trace_llm_error_recorded() {
let sub = Arc::new(CollectSubscriber::default());
let _guard = collect_guard(&sub);
let fake = SharedFake::new([FakeReply::Text("hi".into())]);
let mut agent = agent(fake, "");
agent.run("one").await.unwrap();
let err = agent.run("two").await.unwrap_err();
assert!(matches!(err, AgentError::Provider(_)));
let ops = sub.ops.lock().unwrap().clone();
let llm_records = records_of(&ops, "llm_request");
assert!(
llm_records.iter().any(|r| r.starts_with("error=")),
"the failed round's llm span should have an error: {llm_records:?}"
);
assert!(
records_of(&ops, "agent.run")
.iter()
.any(|r| r.starts_with("error="))
);
}
#[tokio::test]
async fn trace_stream_llm_error_recorded() {
struct FailInStream;
#[async_trait::async_trait]
impl Provider for FailInStream {
async fn chat(&self, _request: ChatRequest) -> Result<ChatResponse, ProviderError> {
unreachable!("this test uses streaming path only")
}
async fn stream_chat(
&self,
_request: ChatRequest,
) -> Result<BoxStream<'static, Result<StreamEvent, ProviderError>>, ProviderError>
{
Ok(Box::pin(futures::stream::iter(vec![
Ok(StreamEvent::Delta("hi".into())),
Err(ProviderError::Api {
status: 0,
message: "boom".into(),
}),
])))
}
}
let sub = Arc::new(CollectSubscriber::default());
let _guard = collect_guard(&sub);
let mut agent = ReActAgent::new(FailInStream, ToolRegistry::new(), "");
let mut stream = agent.run_stream("two").await.unwrap();
while stream.next().await.is_some() {}
let ops = sub.ops.lock().unwrap().clone();
assert_nesting_invariants(&ops);
assert!(
records_of(&ops, "llm_request")
.iter()
.any(|r| r.contains("boom"))
);
assert!(
records_of(&ops, "agent.run")
.iter()
.any(|r| r.contains("boom"))
);
}
fn skill(name: &str, description: &str, body: &str) -> crate::skill::Skill {
crate::skill::Skill::parse(&format!(
"---\nname: {name}\ndescription: {description}\n---\n{body}"
))
.unwrap()
}
fn skill_registry(skills: &[(&str, &str, &str)]) -> SkillRegistry {
let registry = SkillRegistry::new();
for (name, description, body) in skills {
registry.add(skill(name, description, body));
}
registry
}
fn system_text(fake: &SharedFake) -> String {
let requests = fake.requests();
let last = requests.last().expect("expected a request");
match &last.messages[0] {
Message::System(s) => s.clone(),
other => panic!("first message must be System, got: {other:?}"),
}
}
fn tool_result_contains(fake: &SharedFake, needle: &str) {
let requests = fake.requests();
let last = requests.last().expect("expected a request");
let content = last
.messages
.iter()
.find_map(|m| match m {
Message::ToolResult { content, .. } => Some(content.clone()),
_ => None,
})
.expect("expected ToolResult");
assert!(
content.contains(needle),
"ToolResult should contain {needle:?}, got: {content:?}"
);
}
#[tokio::test]
async fn with_skills_adds_menu_to_system_prompt() {
let fake = SharedFake::new([FakeReply::Text("OK".into())]);
let mut agent = agent(fake.clone(), "You are an assistant").with_skills(skill_registry(&[
("code-review", "Review code", "Step one"),
("greet", "Say hello", "Hello"),
]));
agent.run("Are you there").await.unwrap();
let system = system_text(&fake);
assert!(
system.starts_with("You are an assistant"),
"base prompt must come first: {system}"
);
assert!(system.contains("- code-review: Review code"));
assert!(system.contains("- greet: Say hello"));
}
#[tokio::test]
async fn with_skills_registers_load_skill() {
let fake = SharedFake::new([FakeReply::Text("OK".into())]);
let mut agent = agent(fake.clone(), "You are an assistant")
.with_skills(skill_registry(&[("greet", "Say hello", "Hello body")]));
assert!(agent.registry.names().contains(&"load_skill".to_string()));
agent.run("Are you there").await.unwrap();
assert!(
fake.requests()[0]
.tools
.iter()
.any(|t| t.name == "load_skill")
);
}
#[tokio::test]
async fn load_skill_used_in_loop() {
let fake = SharedFake::new([
FakeReply::ToolCalls {
content: "".into(),
calls: vec![call("c1", "load_skill", r#"{"name":"greet"}"#)],
},
FakeReply::Text("Done".into()),
]);
let mut agent = agent(fake.clone(), "You are an assistant")
.with_skills(skill_registry(&[("greet", "Say hello", "Hello body")]));
let answer = agent.run("Say hello").await.unwrap();
assert_eq!(answer, "Done");
let requests = fake.requests();
assert_eq!(requests.len(), 2);
let tool_results: Vec<&Message> = requests[1]
.messages
.iter()
.filter(|m| matches!(m, Message::ToolResult { .. }))
.collect();
assert_eq!(tool_results.len(), 1);
match tool_results[0] {
Message::ToolResult { content, .. } => assert!(content.contains("Hello body")),
_ => unreachable!(),
}
}
#[tokio::test]
async fn load_skill_not_found_returns_error_text() {
let fake = SharedFake::new([
FakeReply::ToolCalls {
content: "".into(),
calls: vec![call("c1", "load_skill", r#"{"name":"ghost"}"#)],
},
FakeReply::Text("Try another".into()),
]);
let mut agent = agent(fake.clone(), "You are an assistant")
.with_skills(skill_registry(&[("greet", "Say hello", "Hello body")]));
agent.run("Load skill").await.unwrap();
tool_result_contains(&fake, "not found");
}
#[tokio::test]
async fn load_skill_not_enabled_returns_text() {
let fake = SharedFake::new([
FakeReply::ToolCalls {
content: "".into(),
calls: vec![call("c1", "load_skill", r#"{"name":"other"}"#)],
},
FakeReply::Text("Understood".into()),
]);
let mut agent = agent(fake.clone(), "You are an assistant")
.with_skills(skill_registry(&[
("greet", "Say hello", "Hello body"),
("other", "Another", "Other content"),
]))
.with_enabled_skills(&["greet"]);
agent.run("Load").await.unwrap();
tool_result_contains(&fake, "not enabled");
}
#[tokio::test]
async fn with_skills_inline_embeds_all_bodies() {
let fake = SharedFake::new([FakeReply::Text("OK".into())]);
let mut agent =
agent(fake.clone(), "You are an assistant").with_skills_inline(skill_registry(&[
("greet", "Say hello", "Hello body"),
("other", "Another", "Other content"),
]));
assert!(!agent.registry.names().contains(&"load_skill".to_string()));
agent.run("Are you there").await.unwrap();
let system = system_text(&fake);
assert!(system.contains("[Skill greet]\nHello body"));
assert!(system.contains("[Skill other]\nOther content"));
assert!(!system.contains("- greet:"));
}
#[tokio::test]
async fn with_enabled_skills_filters_menu() {
let fake = SharedFake::new([FakeReply::Text("OK".into())]);
let mut agent = agent(fake.clone(), "You are an assistant")
.with_skills(skill_registry(&[
("greet", "Say hello", "Hello body"),
("other", "Another", "Other content"),
]))
.with_enabled_skills(&["greet"]);
agent.run("Are you there").await.unwrap();
let system = system_text(&fake);
assert!(system.contains("- greet: Say hello"));
assert!(!system.contains("- other:"));
}
#[tokio::test]
async fn activate_skill_embeds_body_and_leaves_menu() {
let fake = SharedFake::new([FakeReply::Text("OK".into())]);
let mut agent = agent(fake.clone(), "You are an assistant").with_skills(skill_registry(&[
("greet", "Say hello", "Hello body"),
("other", "Another", "Other content"),
]));
assert!(agent.activate_skill("greet"));
agent.run("Are you there").await.unwrap();
let system = system_text(&fake);
assert!(system.contains("[Skill greet]\nHello body"));
assert!(!system.contains("- greet:"));
assert!(system.contains("- other: Another"));
}
#[tokio::test]
async fn deactivate_skill_returns_to_menu() {
let fake = SharedFake::new([FakeReply::Text("OK".into())]);
let mut agent = agent(fake.clone(), "You are an assistant")
.with_skills(skill_registry(&[("greet", "Say hello", "Hello body")]));
assert!(agent.activate_skill("greet"));
assert!(agent.deactivate_skill("greet"));
agent.run("Are you there").await.unwrap();
let system = system_text(&fake);
assert!(system.contains("- greet: Say hello"));
assert!(!system.contains("[Skill greet]"));
assert!(!agent.deactivate_skill("greet"));
}
#[tokio::test]
async fn activate_skill_failure_paths() {
let mut whitelisted = agent(SharedFake::new([FakeReply::Text("OK".into())]), "")
.with_skills(skill_registry(&[("greet", "Say hello", "Hello body")]))
.with_enabled_skills(&["greet"]);
assert!(!whitelisted.activate_skill("ghost"));
assert!(!whitelisted.activate_skill("other"));
assert!(whitelisted.activate_skill("greet"));
assert!(whitelisted.activate_skill("greet"));
let mut inline = agent(SharedFake::new([FakeReply::Text("OK".into())]), "")
.with_skills_inline(skill_registry(&[("greet", "Say hello", "Hello body")]));
assert!(!inline.activate_skill("greet"));
assert!(!inline.deactivate_skill("greet"));
}
#[tokio::test]
async fn empty_skills_registry_zero_cost() {
let fake = SharedFake::new([FakeReply::Text("OK".into())]);
let mut agent =
agent(fake.clone(), "You are an assistant").with_skills(SkillRegistry::new());
agent.run("Are you there").await.unwrap();
assert_eq!(system_text(&fake), "You are an assistant");
assert!(agent.registry.names().contains(&"load_skill".to_string()));
}
#[tokio::test]
async fn skills_hot_swap_takes_effect_next_request() {
let fake = SharedFake::new([
FakeReply::Text("first round".into()),
FakeReply::Text("second round".into()),
]);
let mut swap_agent =
agent(fake.clone(), "You are an assistant").with_skills(SkillRegistry::new());
swap_agent.run("Are you there").await.unwrap();
swap_agent
.skills
.add(skill("late", "Skill added later", "Late body"));
swap_agent.run("Once more").await.unwrap();
assert!(system_text(&fake).contains("- late: Skill added later"));
let fake2 = SharedFake::new([
FakeReply::ToolCalls {
content: "".into(),
calls: vec![call("c1", "load_skill", r#"{"name":"late"}"#)],
},
FakeReply::Text("Done".into()),
]);
let mut late_agent =
agent(fake2.clone(), "You are an assistant").with_skills(SkillRegistry::new());
late_agent
.skills
.add(skill("late", "Skill added later", "Late body"));
late_agent.run("Load").await.unwrap();
tool_result_contains(&fake2, "Late body");
}
#[tokio::test]
async fn with_skills_inline_overrides_with_skills() {
let fake = SharedFake::new([FakeReply::Text("OK".into())]);
let mut agent = agent(fake.clone(), "You are an assistant")
.with_skills(skill_registry(&[("a", "A", "Body-a")]))
.with_skills_inline(skill_registry(&[("b", "B", "Body-b")]));
assert!(!agent.registry.names().contains(&"load_skill".to_string()));
agent.run("Are you there").await.unwrap();
let system = system_text(&fake);
assert!(system.contains("[Skill b]\nBody-b"));
assert!(!system.contains("Body-a"));
}
#[tokio::test]
async fn load_skill_content_survives_window_trim() {
let fake = SharedFake::new([
FakeReply::ToolCalls {
content: "".into(),
calls: vec![call("c1", "load_skill", r#"{"name":"greet"}"#)],
},
FakeReply::Text("round one".into()),
FakeReply::Text("round two".into()),
FakeReply::Text("round three".into()),
]);
let mut agent = agent(fake.clone(), "You are an assistant")
.with_memory(crate::memory::WindowMemory::new(3))
.with_skills(skill_registry(&[(
"greet",
"Say hello",
"Skill body content",
)]));
agent.run("Load").await.unwrap();
agent.run("round two").await.unwrap();
agent.run("round three").await.unwrap();
let requests = fake.requests();
let last = requests.last().unwrap();
let texts: Vec<String> = last
.messages
.iter()
.map(|m| match m {
Message::ToolResult { content, .. } => content.clone(),
Message::User(blocks) => blocks
.iter()
.map(|b| match b {
crate::ContentBlock::Text(t) => t.clone(),
crate::ContentBlock::Image(_) | crate::ContentBlock::Wire(_) => {
String::new()
}
})
.collect(),
_ => String::new(),
})
.collect();
let joined = texts.join("|");
assert!(
joined.contains("Skill body content"),
"skill body should stay resident: {joined}"
);
assert!(
joined.contains("round three"),
"latest round should be kept: {joined}"
);
assert!(
!joined.contains("round two"),
"middle regular rounds should be trimmed: {joined}"
);
}
#[tokio::test]
async fn enabled_skills_before_with_skills_order_independent() {
let fake = SharedFake::new([
FakeReply::ToolCalls {
content: "".into(),
calls: vec![call("c1", "load_skill", r#"{"name":"other"}"#)],
},
FakeReply::Text("Understood".into()),
]);
let mut agent = agent(fake.clone(), "You are an assistant")
.with_enabled_skills(&["greet"])
.with_skills(skill_registry(&[
("greet", "Say hello", "Hello body"),
("other", "Another", "Other content"),
]));
agent.run("Load").await.unwrap();
tool_result_contains(&fake, "not enabled");
}
}