use super::config::AgentConfig;
#[cfg(feature = "structured")]
use super::structured::{StructuredOutcome, StructuredValidator};
#[cfg(feature = "structured")]
use crate::agent::TypedAgent;
use crate::agent::events::ReActEvent;
use crate::agent::{
Agent, AgentAction, AgentError, AgentEvent, AgentKernel, MessageChunk, ModelObservation,
ModelRequest, Observation, RunSummary,
};
use crate::effect::{EffectObservation, EffectRequest};
use crate::event_channel::EventChannel;
use crate::memory::{Memory, WindowMemory};
use crate::message::{Message, ToolCall};
use crate::provider::FakeProvider;
use crate::provider::{
ChatRequest, FinishReason, ModelOptions, Provider, ProviderError, ProviderRequestContext,
StreamEvent, Usage,
};
#[cfg(feature = "structured")]
use crate::run::TypedRunOutput;
use crate::run::{Artifact, RunContext, RunMetadata, RunOutput, RunRequest};
use crate::tool::{SharedState, Tool, ToolMemoryPolicy, ToolOutput, ToolRegistry, ToolResult};
use futures::StreamExt;
use futures::stream::BoxStream;
#[cfg(feature = "structured")]
use schemars::JsonSchema;
#[cfg(feature = "structured")]
use serde::de::DeserializeOwned;
use std::collections::HashMap;
use std::collections::HashSet;
use std::collections::VecDeque;
use std::fmt;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Instant;
#[cfg(feature = "tracing")]
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>,
kernel_state: Option<ReActKernelState>,
}
const MAX_ROUND_TEXT: usize = 4 << 20;
const DEFAULT_MEMORY_TOKENS: usize = 128_000;
pub struct ReActAgentBuilder {
provider: Box<dyn Provider>,
memory: Box<dyn Memory>,
tools: ToolRegistry,
system_prompt: String,
config: AgentConfig,
state: SharedState,
events: Option<Arc<dyn EventChannel>>,
executor: Box<dyn ToolRoundExecutor>,
}
impl ReActAgentBuilder {
pub fn new(provider: impl Provider + 'static) -> Self {
Self {
provider: Box::new(provider),
memory: Box::new(WindowMemory::new(DEFAULT_MEMORY_TOKENS)),
tools: ToolRegistry::new(),
system_prompt: String::new(),
config: AgentConfig::default(),
state: SharedState::default(),
events: None,
executor: Box::new(SerialToolRoundExecutor),
}
}
pub fn with_provider(mut self, provider: impl Provider + 'static) -> Self {
self.provider = Box::new(provider);
self
}
pub fn with_tools(mut self, tools: ToolRegistry) -> Self {
self.tools = tools;
self
}
pub fn with_tool(mut self, tool: impl Tool + 'static) -> Self {
self.tools.register(tool);
self
}
pub fn with_system_prompt(mut self, system_prompt: impl Into<String>) -> Self {
self.system_prompt = system_prompt.into();
self
}
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
}
#[cfg(feature = "structured")]
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 build(self) -> ReActAgent {
ReActAgent {
provider: self.provider,
memory: self.memory,
registry: self.tools,
system_prompt: self.system_prompt,
config: self.config,
state: self.state,
events: self.events,
executor: self.executor,
kernel_state: None,
}
}
}
impl fmt::Debug for ReActAgentBuilder {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut debug = f.debug_struct("ReActAgentBuilder");
debug
.field("provider", &"Box<dyn Provider>")
.field("memory", &"Box<dyn Memory>")
.field("tools", &self.tools)
.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",
},
);
debug.finish()
}
}
impl ReActAgent {
pub fn builder(provider: impl Provider + 'static) -> ReActAgentBuilder {
ReActAgentBuilder::new(provider)
}
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),
kernel_state: None,
}
}
pub fn kernel(tools: ToolRegistry, system_prompt: impl Into<String>) -> Self {
Self::new(FakeProvider::new([]), tools, system_prompt)
}
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
}
#[cfg(feature = "structured")]
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
}
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_with_context(
&mut self,
context: &RunContext,
run_id: &str,
counters: &mut RunCounters,
options: &ModelOptions,
schema: Option<&serde_json::Value>,
) -> Result<FinalAnswer, AgentError> {
let schemas = self.registry.schemas();
#[cfg(feature = "structured")]
let mut validator = schema.map(|schema| {
StructuredValidator::new(schema.clone(), self.config.max_structured_retries)
});
#[cfg(not(feature = "structured"))]
let _ = schema;
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;
check_run_context(context)?;
let answer: Option<FinalAnswer> = async {
let llm_span = span_llm(run_id, counters.rounds);
let model_request_id = format!("{run_id}-model-{}", counters.rounds);
let provider_context =
ProviderRequestContext::from_run_context(model_request_id, context);
let chat = self.provider.chat_with_context(
ChatRequest {
messages: self.assemble_messages(self.memory.context().await?),
tools: schemas.clone(),
options: options.clone(),
},
&provider_context,
);
let response =
match run_until_context(context, instrument(chat, llm_span.clone())).await {
Ok(Ok(response)) => response,
Ok(Err(e)) => {
#[cfg(feature = "tracing")]
llm_span.record("error", e.to_string());
return Err(AgentError::Provider(e));
}
Err(e) => return Err(e),
};
if let Some(usage) = response.usage {
#[cfg(feature = "tracing")]
{
llm_span.record("usage.prompt_tokens", usage.prompt_tokens);
llm_span.record("usage.completion_tokens", usage.completion_tokens);
}
counters.usage_total += usage;
} else {
counters.usage_omitted = true;
}
let finish_reason = response.finish_reason.clone();
let Message::Assistant {
content,
reasoning,
tool_calls,
} = response.message
else {
return Err(AgentError::Provider(ProviderError::Protocol {
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::ResponseTooLarge {
limit_bytes: MAX_ROUND_TEXT,
}));
}
let final_message = Message::Assistant {
content: content.clone(),
reasoning: reasoning.clone(),
tool_calls: Vec::new(),
};
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() {
#[cfg(feature = "structured")]
{
if let Some(validator) = &mut validator {
match validator.validate(&content) {
StructuredOutcome::Passed => {}
StructuredOutcome::Retry { message } => {
self.memory.record(message).await?;
return Ok::<Option<FinalAnswer>, AgentError>(None);
}
StructuredOutcome::Exhausted { max_retries } => {
return Err(AgentError::StructuredRetriesExhausted(
max_retries,
));
}
}
}
}
return Ok::<Option<FinalAnswer>, AgentError>(Some(FinalAnswer {
answer: content,
final_message,
finish_reason: Some(finish_reason),
}));
}
counters.tool_calls_total += tool_calls.len();
tool_rounds += 1;
let ctx = ToolRoundCtx {
context,
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 {
if let Some(effect) = outcome.effect_request() {
return Err(AgentError::EffectRequiresHarness(format!(
"{} ({})",
effect.description, effect.id
)));
}
record_tool_result(&mut self.memory, &outcome).await?;
}
Ok::<Option<FinalAnswer>, 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 {
self.system_prompt.clone()
}
}
pub struct ToolRoundCtx<'a> {
pub context: &'a RunContext,
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.context.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.context.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 call_future = self.registry.call(&call, self.context, self.state);
let result = instrument(call_future, tool_span.clone()).await;
#[cfg(feature = "tracing")]
if let Err(e) = &result {
tool_span.record("error", e.to_string());
}
let outcome = match &result {
Ok(ToolResult::Output(output)) => ToolCallOutcome::output(call.clone(), output.clone()),
Ok(ToolResult::Effect(request)) => {
ToolCallOutcome::effect(call.clone(), request.clone())
}
Ok(other) => ToolCallOutcome::text(call.clone(), other.to_string()),
Err(e) => ToolCallOutcome::text(call.clone(), e.to_string()),
};
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);
outcome
}
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)]
pub struct ToolCallOutcome {
pub call: ToolCall,
pub content: String,
pub memory_policy: ToolMemoryPolicy,
pub effect: Option<EffectRequest>,
}
impl ToolCallOutcome {
pub fn text(call: ToolCall, content: impl Into<String>) -> Self {
Self::output(call, ToolOutput::text(content))
}
pub fn output(call: ToolCall, output: ToolOutput) -> Self {
Self {
call,
content: output.content,
memory_policy: output.memory_policy,
effect: None,
}
}
pub fn effect(call: ToolCall, request: EffectRequest) -> Self {
Self {
call,
content: String::new(),
memory_policy: ToolMemoryPolicy::Normal,
effect: Some(request),
}
}
pub fn effect_request(&self) -> Option<&EffectRequest> {
self.effect.as_ref()
}
}
#[cfg(feature = "structured")]
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
}
}
#[cfg(feature = "structured")]
#[async_trait::async_trait]
impl TypedAgent for ReActAgent {
async fn run_typed_request_with_context<U>(
&mut self,
request: RunRequest,
context: RunContext,
) -> Result<TypedRunOutput<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 output = self
.run_request_inner(request, context, Some(&schema))
.await?;
let value = serde_json::from_str(&output.answer)
.map_err(|e| AgentError::StructuredParse(e.to_string()))?;
Ok(TypedRunOutput { value, output })
}
}
#[derive(Default)]
struct RunCounters {
rounds: usize,
tool_calls_total: usize,
usage_total: Usage,
usage_omitted: bool,
}
struct FinalAnswer {
answer: String,
final_message: Message,
finish_reason: Option<FinishReason>,
}
struct RunExecution {
answer: String,
final_message: Message,
summary: RunSummary,
artifacts: Vec<Artifact>,
metadata: RunMetadata,
}
struct ReActKernelState {
run_id: String,
started_at: Instant,
provider_model: Option<String>,
counters: RunCounters,
options: ModelOptions,
schemas: Vec<crate::tool::ToolSchema>,
#[cfg(feature = "structured")]
validator: Option<StructuredValidator>,
tool_rounds: usize,
pending_tools: VecDeque<ToolCall>,
pending_tool_results: VecDeque<PendingToolResult>,
next_model_request: u64,
}
#[derive(Debug)]
enum PendingToolResult {
Outcome(ToolCallOutcome),
Effect {
effect_id: String,
call: ToolCall,
observation: Option<EffectObservation>,
},
}
impl PendingToolResult {
fn effect_id(&self) -> Option<&str> {
match self {
Self::Outcome(_) => None,
Self::Effect { effect_id, .. } => Some(effect_id),
}
}
}
impl ReActKernelState {
fn next_model_request(&mut self, messages: Vec<Message>) -> AgentAction {
self.next_model_request += 1;
AgentAction::RequestModel {
request: ModelRequest::new(
format!("{}-model-{}", self.run_id, self.next_model_request),
ChatRequest {
messages,
tools: self.schemas.clone(),
options: self.options.clone(),
},
),
}
}
fn summary(&self, finish_reason: Option<FinishReason>) -> RunSummary {
run_summary(
&self.counters,
finish_reason,
self.started_at,
self.provider_model.clone(),
)
}
}
impl fmt::Debug for ReActAgent {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut debug = f.debug_struct("ReActAgent");
debug
.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",
},
);
debug.finish()
}
}
#[async_trait::async_trait]
impl Agent for ReActAgent {
async fn run_request_with_context(
&mut self,
request: RunRequest,
context: RunContext,
) -> Result<RunOutput, AgentError> {
self.run_request_inner(request, context, None).await
}
async fn run_stream_request_with_context<'a>(
&'a mut self,
request: RunRequest,
context: RunContext,
) -> Result<BoxStream<'a, Result<MessageChunk, AgentError>>, AgentError> {
self.run_stream_request_inner(request, context).await
}
}
#[async_trait::async_trait]
impl AgentKernel for ReActAgent {
async fn start(
&mut self,
request: RunRequest,
context: &RunContext,
) -> Result<AgentAction, AgentError> {
check_run_context(context)?;
let run_id = context.run_id.clone();
let input = request.input;
self.memory.record(input.clone().into_message()).await?;
self.publish(|| {
Arc::new(ReActEvent::RunStarted {
run_id: run_id.clone(),
input,
})
});
let options = request
.options
.clone()
.unwrap_or_else(|| self.config.options.clone());
#[cfg(feature = "structured")]
let validator = options.structured.as_ref().map(|schema| {
StructuredValidator::new(schema.clone(), self.config.max_structured_retries)
});
let mut state = ReActKernelState {
run_id,
started_at: Instant::now(),
provider_model: self.provider.model().map(str::to_string),
counters: RunCounters::default(),
options,
schemas: self.registry.schemas(),
#[cfg(feature = "structured")]
validator,
tool_rounds: 0,
pending_tools: VecDeque::new(),
pending_tool_results: VecDeque::new(),
next_model_request: 0,
};
state.counters.rounds += 1;
let messages = self.assemble_messages(self.memory.context().await?);
let action = state.next_model_request(messages);
self.kernel_state = Some(state);
Ok(action)
}
async fn observe(
&mut self,
observation: Observation,
context: &RunContext,
) -> Result<AgentAction, AgentError> {
check_run_context(context)?;
match observation {
Observation::Model(observation) => self.observe_model(observation, context).await,
Observation::Effect(observation) => self.observe_effect(observation, context).await,
Observation::Effects(observations) => self.observe_effects(observations, context).await,
_ => Err(AgentError::InvalidStep("unsupported observation".into())),
}
}
}
impl ReActAgent {
async fn observe_model(
&mut self,
observation: ModelObservation,
context: &RunContext,
) -> Result<AgentAction, AgentError> {
let mut state = self.kernel_state.take().ok_or_else(|| {
AgentError::InvalidStep("model observation without active run".into())
})?;
if !state.pending_tool_results.is_empty() || !state.pending_tools.is_empty() {
self.kernel_state = Some(state);
return Err(AgentError::InvalidStep(
"model observation received while tool calls are pending".into(),
));
}
let response = observation.response;
if let Some(usage) = response.usage {
state.counters.usage_total += usage;
} else {
state.counters.usage_omitted = true;
}
let finish_reason = response.finish_reason.clone();
let Message::Assistant {
content,
reasoning,
tool_calls,
} = response.message
else {
self.kernel_state = Some(state);
return Err(AgentError::Provider(ProviderError::Protocol {
message: "provider returned a non-assistant message".into(),
}));
};
if content.len() + reasoning.as_deref().map_or(0, str::len) > MAX_ROUND_TEXT {
self.kernel_state = Some(state);
return Err(AgentError::Provider(ProviderError::ResponseTooLarge {
limit_bytes: MAX_ROUND_TEXT,
}));
}
let final_message = Message::Assistant {
content: content.clone(),
reasoning: reasoning.clone(),
tool_calls: Vec::new(),
};
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() {
#[cfg(feature = "structured")]
{
if let Some(validator) = &mut state.validator {
match validator.validate(&content) {
StructuredOutcome::Passed => {}
StructuredOutcome::Retry { message } => {
self.memory.record(message).await?;
state.counters.rounds += 1;
let messages = self.assemble_messages(self.memory.context().await?);
let action = state.next_model_request(messages);
self.kernel_state = Some(state);
return Ok(action);
}
StructuredOutcome::Exhausted { max_retries } => {
self.kernel_state = None;
return Err(AgentError::StructuredRetriesExhausted(max_retries));
}
}
}
}
let summary = state.summary(Some(finish_reason));
let output = RunOutput {
run_id: state.run_id.clone(),
answer: content,
summary: summary.clone(),
final_message,
artifacts: Vec::new(),
metadata: RunMetadata::new(),
};
publish_ended(&self.events, summary, None);
self.kernel_state = None;
return Ok(AgentAction::Respond { output });
}
if state.tool_rounds >= self.config.max_tool_rounds {
self.kernel_state = None;
return Err(AgentError::TooManyToolRounds(self.config.max_tool_rounds));
}
state.tool_rounds += 1;
state.counters.tool_calls_total += tool_calls.len();
state.pending_tools = VecDeque::from(tool_calls);
let action = self.process_kernel_pending_tools(state, context).await?;
Ok(action)
}
async fn observe_effect(
&mut self,
observation: EffectObservation,
context: &RunContext,
) -> Result<AgentAction, AgentError> {
let mut state = self.kernel_state.take().ok_or_else(|| {
AgentError::InvalidStep("effect observation without active run".into())
})?;
let pending_effect_count = state
.pending_tool_results
.iter()
.filter(|result| result.effect_id().is_some())
.count();
if pending_effect_count > 1 {
self.kernel_state = Some(state);
return Err(AgentError::InvalidStep(
"single effect observation received while a batch is pending".into(),
));
}
let Some(expected_effect_id) = state
.pending_tool_results
.iter()
.find_map(PendingToolResult::effect_id)
.map(str::to_string)
else {
self.kernel_state = Some(state);
return Err(AgentError::InvalidStep(
"effect observation received with no pending effect".into(),
));
};
if observation.effect_id != expected_effect_id {
self.kernel_state = Some(state);
return Err(AgentError::InvalidStep(format!(
"effect observation id mismatch: expected {expected_effect_id}, got {}",
observation.effect_id
)));
}
if observation.output.observation_for_model.len() > MAX_ROUND_TEXT {
self.kernel_state = Some(state);
return Err(AgentError::Provider(ProviderError::ResponseTooLarge {
limit_bytes: MAX_ROUND_TEXT,
}));
}
let recorded = Self::mark_pending_effect_observed(
&mut state.pending_tool_results,
&expected_effect_id,
observation,
);
debug_assert!(recorded);
if let Err(error) = self.record_pending_tool_results(&mut state).await {
self.kernel_state = Some(state);
return Err(error);
}
self.process_kernel_pending_tools(state, context).await
}
async fn observe_effects(
&mut self,
observations: Vec<EffectObservation>,
context: &RunContext,
) -> Result<AgentAction, AgentError> {
let mut state = self.kernel_state.take().ok_or_else(|| {
AgentError::InvalidStep("effect observations without active run".into())
})?;
let pending_effect_ids = state
.pending_tool_results
.iter()
.filter_map(PendingToolResult::effect_id)
.map(str::to_string)
.collect::<Vec<_>>();
if pending_effect_ids.is_empty() {
self.kernel_state = Some(state);
return Err(AgentError::InvalidStep(
"effect observations received with no pending effects".into(),
));
}
if observations.len() != pending_effect_ids.len() {
let expected = pending_effect_ids.len();
self.kernel_state = Some(state);
return Err(AgentError::InvalidStep(format!(
"effect observation count mismatch: expected {expected}, got {}",
observations.len()
)));
}
let expected_ids = pending_effect_ids.iter().cloned().collect::<HashSet<_>>();
let mut observations_by_id = HashMap::with_capacity(observations.len());
for observation in observations {
if observation.output.observation_for_model.len() > MAX_ROUND_TEXT {
self.kernel_state = Some(state);
return Err(AgentError::Provider(ProviderError::ResponseTooLarge {
limit_bytes: MAX_ROUND_TEXT,
}));
}
if !expected_ids.contains(&observation.effect_id) {
let effect_id = observation.effect_id;
self.kernel_state = Some(state);
return Err(AgentError::InvalidStep(format!(
"unexpected effect observation for {effect_id}"
)));
}
let effect_id = observation.effect_id.clone();
if observations_by_id
.insert(effect_id.clone(), observation)
.is_some()
{
self.kernel_state = Some(state);
return Err(AgentError::InvalidStep(format!(
"duplicate effect observation for {effect_id}"
)));
}
}
for effect_id in &pending_effect_ids {
if !observations_by_id.contains_key(effect_id) {
self.kernel_state = Some(state);
return Err(AgentError::InvalidStep(format!(
"missing effect observation for {effect_id}"
)));
}
}
for effect_id in pending_effect_ids {
let observation = observations_by_id
.remove(&effect_id)
.expect("effect observation was prevalidated");
let recorded = Self::mark_pending_effect_observed(
&mut state.pending_tool_results,
&effect_id,
observation,
);
debug_assert!(recorded);
}
if let Err(error) = self.record_pending_tool_results(&mut state).await {
self.kernel_state = Some(state);
return Err(error);
}
self.process_kernel_pending_tools(state, context).await
}
fn mark_pending_effect_observed(
pending_tool_results: &mut VecDeque<PendingToolResult>,
expected_effect_id: &str,
observation: EffectObservation,
) -> bool {
for result in pending_tool_results {
let PendingToolResult::Effect {
effect_id,
observation: pending_observation,
..
} = result
else {
continue;
};
if effect_id == expected_effect_id {
*pending_observation = Some(observation);
return true;
}
}
false
}
async fn record_pending_tool_results(
&mut self,
state: &mut ReActKernelState,
) -> Result<(), AgentError> {
while let Some(outcome) =
state
.pending_tool_results
.front()
.and_then(|pending| match pending {
PendingToolResult::Outcome(outcome) => Some(outcome.clone()),
PendingToolResult::Effect {
call,
observation: Some(observation),
..
} => Some(Self::effect_observation_outcome(
call.clone(),
observation.clone(),
)),
PendingToolResult::Effect {
observation: None, ..
} => None,
})
{
record_tool_result(&mut self.memory, &outcome).await?;
state.pending_tool_results.pop_front();
}
Ok(())
}
fn effect_observation_outcome(
call: ToolCall,
observation: EffectObservation,
) -> ToolCallOutcome {
ToolCallOutcome {
call,
content: observation.output.observation_for_model,
memory_policy: observation.output.memory_policy,
effect: None,
}
}
async fn process_kernel_pending_tools(
&mut self,
mut state: ReActKernelState,
context: &RunContext,
) -> Result<AgentAction, AgentError> {
let mut effects = Vec::new();
let mut effect_ids = HashSet::new();
while let Some(call) = state.pending_tools.pop_front() {
let ctx = ToolRoundCtx {
context,
round: state.counters.rounds,
registry: &self.registry,
state: &self.state,
events: &self.events,
};
let outcome = ctx.run(call).await;
if let Some(effect) = outcome.effect_request().cloned() {
if !effect_ids.insert(effect.id.clone()) {
return Err(AgentError::InvalidStep(format!(
"duplicate effect request id: {}",
effect.id
)));
}
state
.pending_tool_results
.push_back(PendingToolResult::Effect {
effect_id: effect.id.clone(),
call: outcome.call.clone(),
observation: None,
});
effects.push(effect);
continue;
}
state
.pending_tool_results
.push_back(PendingToolResult::Outcome(outcome));
}
if let Err(error) = self.record_pending_tool_results(&mut state).await {
self.kernel_state = Some(state);
return Err(error);
}
if !effects.is_empty() {
self.kernel_state = Some(state);
return if effects.len() == 1 {
let request = effects
.pop()
.expect("single effect request must be present");
Ok(AgentAction::RequestEffect { request })
} else {
Ok(AgentAction::RequestEffects { requests: effects })
};
}
check_run_context(context)?;
state.counters.rounds += 1;
let messages = self.assemble_messages(self.memory.context().await?);
let action = state.next_model_request(messages);
self.kernel_state = Some(state);
Ok(action)
}
}
#[cfg(feature = "tracing")]
type TraceSpan = tracing::Span;
#[cfg(not(feature = "tracing"))]
#[derive(Debug, Clone)]
struct TraceSpan;
#[cfg(feature = "tracing")]
fn instrument<F>(future: F, span: TraceSpan) -> tracing::instrument::Instrumented<F>
where
F: Future,
{
future.instrument(span)
}
#[cfg(not(feature = "tracing"))]
fn instrument<F>(future: F, _span: TraceSpan) -> F
where
F: Future,
{
future
}
fn span_run(run_id: &str) -> TraceSpan {
#[cfg(feature = "tracing")]
{
tracing::info_span!("agent.run", "run.id" = %run_id, error = tracing::field::Empty)
}
#[cfg(not(feature = "tracing"))]
{
let _ = run_id;
TraceSpan
}
}
fn span_llm(run_id: &str, round: usize) -> TraceSpan {
#[cfg(feature = "tracing")]
{
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,
)
}
#[cfg(not(feature = "tracing"))]
{
let _ = (run_id, round);
TraceSpan
}
}
fn span_tool(run_id: &str, round: usize, name: &str) -> TraceSpan {
#[cfg(feature = "tracing")]
{
tracing::debug_span!(
"tool",
"run.id" = %run_id,
round = round,
name = %name,
error = tracing::field::Empty,
)
}
#[cfg(not(feature = "tracing"))]
{
let _ = (run_id, round, name);
TraceSpan
}
}
fn publish_ended(
events: &Option<Arc<dyn EventChannel>>,
summary: RunSummary,
error: Option<AgentError>,
) {
if let Some(pipe) = events {
pipe.publish(Arc::new(ReActEvent::RunEnded { summary, error }));
}
}
fn stream_end(
events: &Option<Arc<dyn EventChannel>>,
#[cfg_attr(not(feature = "tracing"), allow(unused_variables))] run_span: &TraceSpan,
summary: RunSummary,
error: AgentError,
) -> Result<MessageChunk, AgentError> {
#[cfg(feature = "tracing")]
if !matches!(error, AgentError::Cancelled) {
run_span.record("error", error.to_string());
}
publish_ended(events, summary, Some(error.clone()));
match error {
AgentError::Cancelled => Ok(MessageChunk::Cancelled),
e => Err(e),
}
}
fn run_summary(
counters: &RunCounters,
finish_reason: Option<FinishReason>,
started_at: Instant,
provider_model: Option<String>,
) -> RunSummary {
run_summary_from_parts(
counters.rounds,
counters.tool_calls_total,
counters.usage_total,
counters.usage_omitted,
finish_reason,
started_at,
provider_model,
)
}
fn run_summary_from_parts(
rounds: usize,
tool_calls: usize,
usage: Usage,
usage_omitted: bool,
finish_reason: Option<FinishReason>,
started_at: Instant,
provider_model: Option<String>,
) -> RunSummary {
RunSummary {
rounds,
tool_calls,
usage,
usage_omitted,
finish_reason,
latency: started_at.elapsed(),
provider_model,
}
}
fn check_run_context(context: &RunContext) -> Result<(), AgentError> {
if context.is_cancelled() {
Err(AgentError::Cancelled)
} else if context.is_expired() {
Err(AgentError::DeadlineExceeded)
} else {
Ok(())
}
}
async fn run_until_context<F>(context: &RunContext, future: F) -> Result<F::Output, AgentError>
where
F: Future,
{
check_run_context(context)?;
match context.remaining() {
Some(remaining) if remaining.is_zero() => Err(AgentError::DeadlineExceeded),
Some(remaining) => {
tokio::select! {
_ = context.cancellation.cancelled() => Err(AgentError::Cancelled),
_ = tokio::time::sleep(remaining) => Err(AgentError::DeadlineExceeded),
output = future => Ok(output),
}
}
None => context
.cancellation
.run_until_cancelled(future)
.await
.ok_or(AgentError::Cancelled),
}
}
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.memory_policy.is_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.memory_policy.is_protected() {
memory.record_protected(fallback).await
} else {
memory.record(fallback).await
};
Err(e.into())
}
}
}
impl ReActAgent {
async fn run_request_inner(
&mut self,
request: RunRequest,
context: RunContext,
schema: Option<&serde_json::Value>,
) -> Result<RunOutput, AgentError> {
let run_id = context.run_id.clone();
let run_span = span_run(&run_id);
let provider_model = self.provider.model().map(str::to_string);
let started_at = Instant::now();
let run_future = async {
let input = request.input;
self.memory.record(input.clone().into_message()).await?;
self.publish(|| {
Arc::new(ReActEvent::RunStarted {
run_id: run_id.clone(),
input,
})
});
let mut counters = RunCounters::default();
let mut options = request
.options
.clone()
.unwrap_or_else(|| self.config.options.clone());
if let Some(schema) = schema {
options.structured = Some(schema.clone());
}
let validation_schema = options.structured.as_ref();
let result: Result<FinalAnswer, AgentError> = self
.run_rounds_with_context(
&context,
&run_id,
&mut counters,
&options,
validation_schema,
)
.await;
let output_result = result.map(|final_answer| {
let summary = run_summary(
&counters,
final_answer.finish_reason.clone(),
started_at,
provider_model.clone(),
);
RunExecution {
answer: final_answer.answer,
final_message: final_answer.final_message,
summary,
artifacts: Vec::new(),
metadata: RunMetadata::new(),
}
});
let summary = match &output_result {
Ok(execution) => execution.summary.clone(),
Err(_) => run_summary(&counters, None, started_at, provider_model.clone()),
};
publish_ended(&self.events, summary, output_result.as_ref().err().cloned());
output_result
};
let result = instrument(run_future, run_span.clone()).await;
#[cfg(feature = "tracing")]
if let Err(e) = &result {
run_span.record("error", e.to_string());
}
result.map(|execution| RunOutput {
run_id,
answer: execution.answer,
summary: execution.summary,
final_message: execution.final_message,
artifacts: execution.artifacts,
metadata: execution.metadata,
})
}
async fn run_stream_request_inner<'a>(
&'a mut self,
request: RunRequest,
context: RunContext,
) -> Result<BoxStream<'a, Result<MessageChunk, AgentError>>, AgentError> {
let run_id = context.run_id.clone();
let run_span = span_run(&run_id);
let stream_span = run_span.clone();
let input = request.input;
self.memory.record(input.clone().into_message()).await?;
self.publish(|| {
Arc::new(ReActEvent::RunStarted {
run_id: run_id.clone(),
input,
})
});
let schemas = self.registry.schemas();
let max_rounds = self.config.max_tool_rounds;
let provider_model = self.provider.model().map(str::to_string);
let started_at = Instant::now();
let options = request
.options
.clone()
.unwrap_or_else(|| self.config.options.clone());
let validation_schema = options.structured.clone();
let context = context.clone();
let stream = async_stream::stream! {
let mut rounds = 0usize;
let mut tool_calls_total = 0usize;
let mut usage_total = Usage::default();
let mut usage_omitted = false;
#[cfg(feature = "structured")]
let mut validator = validation_schema.as_ref().map(|schema| {
StructuredValidator::new(schema.clone(), self.config.max_structured_retries)
});
#[cfg(not(feature = "structured"))]
let _ = &validation_schema;
let mut tool_rounds = 0usize;
'rounds: loop {
if tool_rounds >= max_rounds {
let summary = run_summary_from_parts(
rounds,
tool_calls_total,
usage_total,
usage_omitted,
None,
started_at,
provider_model.clone(),
);
yield stream_end(&self.events, &run_span, summary,
AgentError::TooManyToolRounds(max_rounds));
break;
}
rounds += 1;
if let Err(e) = check_run_context(&context) {
usage_omitted = true;
let summary = run_summary_from_parts(
rounds,
tool_calls_total,
usage_total,
usage_omitted,
None,
started_at,
provider_model.clone(),
);
yield stream_end(&self.events, &run_span, summary, e);
break;
}
let messages = match self.memory.context().await {
Ok(messages) => messages,
Err(e) => {
usage_omitted = true;
let summary = run_summary_from_parts(
rounds,
tool_calls_total,
usage_total,
usage_omitted,
None,
started_at,
provider_model.clone(),
);
yield stream_end(&self.events, &run_span, summary,
AgentError::Memory(e));
break;
}
};
let llm_span = span_llm(&run_id, rounds);
let model_request_id = format!("{run_id}-model-{rounds}");
let provider_context =
ProviderRequestContext::from_run_context(model_request_id, &context);
let stream_chat = self.provider.stream_chat_with_context(
ChatRequest {
messages: self.assemble_messages(messages),
tools: schemas.clone(),
options: options.clone(),
},
&provider_context,
);
let mut provider_stream = match run_until_context(
&context,
instrument(stream_chat, llm_span.clone()),
)
.await
{
Ok(Ok(stream)) => stream,
Ok(Err(e)) => {
#[cfg(feature = "tracing")]
llm_span.record("error", e.to_string());
usage_omitted = true;
let summary = run_summary_from_parts(
rounds,
tool_calls_total,
usage_total,
usage_omitted,
None,
started_at,
provider_model.clone(),
);
yield stream_end(&self.events, &run_span, summary,
AgentError::Provider(e));
break;
}
Err(e) => {
usage_omitted = true;
let summary = run_summary_from_parts(
rounds,
tool_calls_total,
usage_total,
usage_omitted,
None,
started_at,
provider_model.clone(),
);
yield stream_end(&self.events, &run_span, summary, e);
break;
}
};
let mut text = String::new();
let mut reasoning = String::new();
let mut calls = Vec::new();
let mut round_finish_reason = None::<FinishReason>;
let mut round_usage_reported = false;
loop {
let next = run_until_context(
&context,
instrument(provider_stream.next(), llm_span.clone()),
)
.await;
let Some(event) = (match next {
Ok(event) => event,
Err(e) => {
usage_omitted = true;
let summary = run_summary_from_parts(
rounds,
tool_calls_total,
usage_total,
usage_omitted,
None,
started_at,
provider_model.clone(),
);
yield stream_end(&self.events, &run_span, summary, e);
break 'rounds;
}
}) else {
break;
};
match event {
Ok(StreamEvent::Delta(delta)) => {
if text.len() + delta.len() > MAX_ROUND_TEXT {
usage_omitted = true;
let summary = run_summary_from_parts(
rounds,
tool_calls_total,
usage_total,
usage_omitted,
None,
started_at,
provider_model.clone(),
);
yield stream_end(&self.events, &run_span, summary,
AgentError::Provider(ProviderError::ResponseTooLarge {
limit_bytes: MAX_ROUND_TEXT,
}));
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 {
usage_omitted = true;
let summary = run_summary_from_parts(
rounds,
tool_calls_total,
usage_total,
usage_omitted,
None,
started_at,
provider_model.clone(),
);
yield stream_end(&self.events, &run_span, summary,
AgentError::Provider(ProviderError::ResponseTooLarge {
limit_bytes: MAX_ROUND_TEXT,
}));
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 { reason, usage }) => {
if let Some(usage) = usage {
#[cfg(feature = "tracing")]
{
llm_span.record("usage.prompt_tokens", usage.prompt_tokens);
llm_span.record("usage.completion_tokens", usage.completion_tokens);
}
usage_total += usage;
round_usage_reported = true;
} else {
usage_omitted = true;
}
round_finish_reason = Some(reason);
break;
}
Err(e) => {
#[cfg(feature = "tracing")]
llm_span.record("error", e.to_string());
usage_omitted = true;
let summary = run_summary_from_parts(
rounds,
tool_calls_total,
usage_total,
usage_omitted,
None,
started_at,
provider_model.clone(),
);
yield stream_end(&self.events, &run_span, summary,
AgentError::Provider(e));
break 'rounds;
}
Ok(_) => {}
}
}
usage_omitted |= !round_usage_reported;
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) => {
let summary = run_summary_from_parts(
rounds,
tool_calls_total,
usage_total,
usage_omitted,
None,
started_at,
provider_model.clone(),
);
yield stream_end(&self.events, &run_span, summary,
AgentError::Memory(e));
break;
}
}
}
if calls.is_empty() {
#[cfg(feature = "structured")]
{
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) => {
let summary = run_summary_from_parts(
rounds,
tool_calls_total,
usage_total,
usage_omitted,
None,
started_at,
provider_model.clone(),
);
yield stream_end(&self.events, &run_span, summary,
AgentError::Memory(e));
break;
}
}
}
StructuredOutcome::Exhausted { max_retries } => {
let summary = run_summary_from_parts(
rounds,
tool_calls_total,
usage_total,
usage_omitted,
None,
started_at,
provider_model.clone(),
);
yield stream_end(&self.events, &run_span, summary,
AgentError::StructuredRetriesExhausted(max_retries));
break 'rounds;
}
}
}
}
let summary = run_summary_from_parts(
rounds,
tool_calls_total,
usage_total,
usage_omitted,
round_finish_reason,
started_at,
provider_model.clone(),
);
publish_ended(&self.events, summary.clone(), None);
yield Ok(MessageChunk::Done(summary));
break;
}
tool_calls_total += calls.len();
tool_rounds += 1;
let ctx = ToolRoundCtx {
context: &context,
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 {
if let Some(effect) = outcome.effect_request() {
let summary = run_summary_from_parts(
rounds,
tool_calls_total,
usage_total,
usage_omitted,
None,
started_at,
provider_model.clone(),
);
yield stream_end(
&self.events,
&run_span,
summary,
AgentError::EffectRequiresHarness(format!(
"{} ({})",
effect.description, effect.id
)),
);
break 'rounds;
}
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 {
let summary = run_summary_from_parts(
rounds,
tool_calls_total,
usage_total,
usage_omitted,
None,
started_at,
provider_model.clone(),
);
yield stream_end(&self.events, &run_span, summary, e);
break 'rounds;
}
}
}
};
Ok(Box::pin(SpanStream {
stream: Box::pin(stream),
span: stream_span,
}))
}
}
struct SpanStream<S> {
stream: Pin<Box<S>>,
#[cfg_attr(not(feature = "tracing"), allow(dead_code))]
span: TraceSpan,
}
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>> {
#[cfg(feature = "tracing")]
let span = self.span.clone();
#[cfg(feature = "tracing")]
let _enter = span.enter();
self.stream.as_mut().poll_next(cx)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::CancellationToken;
use crate::effect::{EffectKind, EffectObservation, EffectRequest};
use crate::memory::MemoryError;
use crate::message::ContentBlock;
use crate::provider::{
ChatResponse, FakeProvider, FakeReply, FinishReason, ModelOptions, ProviderError,
StreamEvent, TimeoutStage,
};
use crate::tool::{Tool, ToolContext, ToolError, ToolOutput, ToolResult, ToolSchema};
use futures::StreamExt;
#[cfg(feature = "structured")]
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 {
fn model(&self) -> Option<&str> {
self.0.model()
}
async fn chat(&self, request: ChatRequest) -> Result<ChatResponse, ProviderError> {
self.0.chat(request).await
}
async fn chat_with_context(
&self,
request: ChatRequest,
context: &ProviderRequestContext,
) -> Result<ChatResponse, ProviderError> {
self.0.chat_with_context(request, context).await
}
async fn stream_chat(
&self,
request: ChatRequest,
) -> Result<BoxStream<'static, Result<StreamEvent, ProviderError>>, ProviderError> {
self.0.stream_chat(request).await
}
async fn stream_chat_with_context(
&self,
request: ChatRequest,
context: &ProviderRequestContext,
) -> Result<BoxStream<'static, Result<StreamEvent, ProviderError>>, ProviderError> {
self.0.stream_chat_with_context(request, context).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::new(self.name, "Test tool", serde_json::json!({}))
}
async fn call(
&self,
_arguments: serde_json::Value,
_context: ToolContext<'_>,
) -> Result<ToolResult, ToolError> {
self.calls.fetch_add(1, Ordering::Relaxed);
Ok(ToolOutput::text(self.result).into())
}
}
#[derive(Debug, Clone)]
struct EffectTool {
name: &'static str,
effect_id: &'static str,
description: &'static str,
}
impl EffectTool {
fn new(name: &'static str, effect_id: &'static str, description: &'static str) -> Self {
Self {
name,
effect_id,
description,
}
}
}
#[async_trait::async_trait]
impl Tool for EffectTool {
fn schema(&self) -> ToolSchema {
ToolSchema::new(self.name, "Effect tool", serde_json::json!({}))
}
async fn call(
&self,
_arguments: serde_json::Value,
_context: ToolContext<'_>,
) -> Result<ToolResult, ToolError> {
Ok(ToolResult::Effect(
EffectRequest::new(
EffectKind::Custom("test.effect".into()),
self.description,
serde_json::json!({}),
)
.with_id(self.effect_id),
))
}
}
fn call(id: &str, name: &str, arguments: &str) -> ToolCall {
ToolCall {
id: id.into(),
name: name.into(),
arguments: arguments.into(),
}
}
fn done_summary(chunk: &MessageChunk) -> Option<&RunSummary> {
match chunk {
MessageChunk::Done(summary) => Some(summary),
_ => None,
}
}
fn assert_done_summary(
chunk: &MessageChunk,
rounds: usize,
tool_calls: usize,
usage: Usage,
usage_omitted: bool,
) {
assert_done_summary_with_finish(
chunk,
rounds,
tool_calls,
usage,
usage_omitted,
Some(FinishReason::Stop),
);
}
fn assert_done_summary_with_finish(
chunk: &MessageChunk,
rounds: usize,
tool_calls: usize,
usage: Usage,
usage_omitted: bool,
finish_reason: Option<FinishReason>,
) {
let summary = done_summary(chunk).expect("expected Done chunk");
assert_eq!(summary.rounds, rounds);
assert_eq!(summary.tool_calls, tool_calls);
assert_eq!(summary.usage, usage);
assert_eq!(summary.usage_omitted, usage_omitted);
assert_eq!(summary.finish_reason, finish_reason);
assert_eq!(summary.provider_model, None);
}
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)
}
fn cancellation_context(token: &CancellationToken) -> RunContext {
RunContext::generated().with_cancellation(token.clone())
}
#[tokio::test]
async fn builder_assembles_agent_components() {
let fake = SharedFake::new([FakeReply::Text("built".into())]);
let (tool, _calls) = FakeTool::new("builder_tool", "unused");
let mut agent = ReActAgent::builder(fake.clone())
.with_tool(tool)
.with_system_prompt("Builder system")
.with_config(AgentConfig {
options: ModelOptions {
temperature: Some(0.4),
..Default::default()
},
..Default::default()
})
.build();
assert_eq!(agent.run("hi").await.unwrap(), "built");
let request = &fake.requests()[0];
assert_eq!(request.messages[0], Message::system("Builder system"));
assert_eq!(request.tools.len(), 1);
assert_eq!(request.tools[0].name, "builder_tool");
assert_eq!(request.options.temperature, Some(0.4));
}
#[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 run_request_returns_structured_output() {
let fake = SharedFake::new([FakeReply::text_with_usage("Hello", Usage::new(5, 2))]);
let mut agent = agent(fake.clone(), "");
let output = agent
.run_request_with_context(RunRequest::text("Are you there"), RunContext::new("r1"))
.await
.unwrap();
assert_eq!(output.run_id, "r1");
assert_eq!(output.answer, "Hello");
assert_eq!(output.final_message, Message::assistant("Hello"));
assert!(output.artifacts.is_empty());
assert!(output.metadata.is_empty());
assert_eq!(output.summary.rounds, 1);
assert_eq!(output.summary.tool_calls, 0);
assert_eq!(output.summary.usage, Usage::new(5, 2));
assert_eq!(output.summary.finish_reason, Some(FinishReason::Stop));
assert_eq!(output.summary.provider_model, None);
}
#[tokio::test]
async fn run_request_blocks_are_recorded_as_user_blocks() {
let blocks = vec![ContentBlock::Text("What is this?".into())];
let fake = SharedFake::new([FakeReply::Text("A block".into())]);
let mut agent = agent(fake.clone(), "");
agent
.run_request(RunRequest::blocks(blocks.clone()))
.await
.unwrap();
let requests = fake.requests();
assert_eq!(requests[0].messages[0], Message::user_blocks(blocks));
}
#[tokio::test]
async fn run_summary_carries_provider_model_when_available() {
#[derive(Clone)]
struct NamedProvider(SharedFake);
#[async_trait::async_trait]
impl Provider for NamedProvider {
fn model(&self) -> Option<&str> {
Some("named-model")
}
async fn chat(&self, request: ChatRequest) -> Result<ChatResponse, ProviderError> {
self.0.chat(request).await
}
async fn chat_with_context(
&self,
request: ChatRequest,
context: &ProviderRequestContext,
) -> Result<ChatResponse, ProviderError> {
self.0.chat_with_context(request, context).await
}
async fn stream_chat(
&self,
request: ChatRequest,
) -> Result<BoxStream<'static, Result<StreamEvent, ProviderError>>, ProviderError>
{
self.0.stream_chat(request).await
}
async fn stream_chat_with_context(
&self,
request: ChatRequest,
context: &ProviderRequestContext,
) -> Result<BoxStream<'static, Result<StreamEvent, ProviderError>>, ProviderError>
{
self.0.stream_chat_with_context(request, context).await
}
}
let mut agent = ReActAgent::new(
NamedProvider(SharedFake::new([FakeReply::Text("hi".into())])),
ToolRegistry::new(),
"",
);
let output = agent.run_request(RunRequest::text("hi")).await.unwrap();
assert_eq!(output.summary.provider_model, Some("named-model".into()));
}
#[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::new("echo", "Echo", serde_json::json!({}))
}
async fn call(
&self,
_arguments: serde_json::Value,
_context: ToolContext<'_>,
) -> Result<ToolResult, ToolError> {
Ok(ToolOutput::text("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::new("boom", "Tool that always fails", serde_json::json!({}))
}
async fn call(
&self,
_arguments: serde_json::Value,
_context: ToolContext<'_>,
) -> Result<ToolResult, 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::new("boom", "Tool that always fails", serde_json::json!({}))
}
async fn call(
&self,
_arguments: serde_json::Value,
_context: ToolContext<'_>,
) -> Result<ToolResult, 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_done_summary(chunks.last().unwrap(), 2, 1, Usage::default(), true);
}
#[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.len(), 6);
assert_eq!(
&chunks[..5],
&[
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()),
]
);
assert_done_summary(&chunks[5], 2, 2, Usage::default(), true);
}
#[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 id_a = RunContext::generated().run_id;
let id_b = RunContext::generated().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_with_context(
&self,
_r: ChatRequest,
_context: &ProviderRequestContext,
) -> Result<ChatResponse, ProviderError> {
unreachable!("this test uses streaming only")
}
async fn stream_chat_with_context(
&self,
_r: ChatRequest,
_context: &ProviderRequestContext,
) -> 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.len(), 1);
let chunk = chunks[0].as_ref().unwrap();
assert_done_summary_with_finish(chunk, 1, 0, Usage::default(), true, None);
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.len(), 5);
assert_eq!(
&events[..4],
&[
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()),
]
);
assert_done_summary(&events[4], 2, 1, Usage::default(), true);
}
#[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.len(), 4);
assert_eq!(
&events[..3],
&[
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()),
]
);
assert_done_summary(&events[3], 2, 1, Usage::default(), true);
}
#[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_done_summary(events.last().unwrap(), 2, 1, Usage::new(30, 7), false);
}
#[tokio::test]
async fn summary_tracks_omitted_usage_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::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_done_summary(events.last().unwrap(), 2, 1, Usage::new(20, 5), true);
}
#[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_with_context(
&self,
_request: ChatRequest,
_context: &ProviderRequestContext,
) -> Result<ChatResponse, ProviderError> {
unreachable!("this test uses streaming path only")
}
async fn stream_chat_with_context(
&self,
_request: ChatRequest,
_context: &ProviderRequestContext,
) -> Result<BoxStream<'static, Result<StreamEvent, ProviderError>>, ProviderError>
{
Ok(Box::pin(futures::stream::iter(vec![
Ok(StreamEvent::Delta("hi".into())),
Err(ProviderError::Protocol {
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::Protocol { 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::Protocol { 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::new("counter", "Count", serde_json::json!({}))
}
async fn call(
&self,
_arguments: serde_json::Value,
context: ToolContext<'_>,
) -> Result<ToolResult, ToolError> {
let state = context.state;
state.with_mut::<usize>(|n| *n += 1);
Ok(ToolOutput::text(format!("count={}", state.get::<usize>().unwrap_or(0))).into())
}
}
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() {
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 request_options_replace_config_options() {
let fake = SharedFake::new([FakeReply::Text("hi".into())]);
let mut agent = agent(fake.clone(), "").with_config(AgentConfig {
options: ModelOptions {
temperature: Some(0.2),
max_tokens: Some(128),
..Default::default()
},
..Default::default()
});
agent
.run_request(RunRequest::text("hi").with_options(ModelOptions {
max_tokens: Some(64),
..Default::default()
}))
.await
.unwrap();
let req = &fake.requests()[0];
assert_eq!(req.options.temperature, None);
assert_eq!(req.options.max_tokens, Some(64));
}
#[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_request_with_context(RunRequest::text("hi"), cancellation_context(&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_request_with_context(RunRequest::text("hi"), cancellation_context(&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_request_with_context(RunRequest::text("hi"), cancellation_context(&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_with_context(
&self,
_request: ChatRequest,
_context: &ProviderRequestContext,
) -> Result<ChatResponse, ProviderError> {
std::future::pending().await }
async fn stream_chat_with_context(
&self,
_request: ChatRequest,
_context: &ProviderRequestContext,
) -> 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_request_with_context(RunRequest::text("hi"), cancellation_context(&token)) => r.map(|output| output.answer),
_ = 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_with_context(
&self,
_request: ChatRequest,
_context: &ProviderRequestContext,
) -> Result<ChatResponse, ProviderError> {
Err(self.0.clone())
}
async fn stream_chat_with_context(
&self,
_request: ChatRequest,
_context: &ProviderRequestContext,
) -> 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 deadline_exceeded_during_chat_is_distinct_from_provider_timeout() {
struct PendingProvider;
#[async_trait::async_trait]
impl Provider for PendingProvider {
async fn chat_with_context(
&self,
_request: ChatRequest,
_context: &ProviderRequestContext,
) -> Result<ChatResponse, ProviderError> {
std::future::pending().await
}
async fn stream_chat_with_context(
&self,
_request: ChatRequest,
_context: &ProviderRequestContext,
) -> Result<BoxStream<'static, Result<StreamEvent, ProviderError>>, ProviderError>
{
std::future::pending().await
}
}
let mut agent = ReActAgent::new(PendingProvider, ToolRegistry::new(), "");
let err = agent
.run_request_with_context(
RunRequest::text("hi"),
RunContext::new("deadline").with_timeout(Duration::from_millis(10)),
)
.await
.unwrap_err();
assert_eq!(err, AgentError::DeadlineExceeded);
assert_eq!(agent.memory.context().await.unwrap().len(), 1);
}
#[tokio::test]
async fn streaming_deadline_exceeded_terminates_with_error_item() {
struct PendingStreamProvider;
#[async_trait::async_trait]
impl Provider for PendingStreamProvider {
async fn chat_with_context(
&self,
_request: ChatRequest,
_context: &ProviderRequestContext,
) -> Result<ChatResponse, ProviderError> {
unreachable!("this test uses streaming only")
}
async fn stream_chat_with_context(
&self,
_request: ChatRequest,
_context: &ProviderRequestContext,
) -> Result<BoxStream<'static, Result<StreamEvent, ProviderError>>, ProviderError>
{
Ok(Box::pin(futures::stream::pending()))
}
}
let mut agent = ReActAgent::new(PendingStreamProvider, ToolRegistry::new(), "");
let mut stream = agent
.run_stream_request_with_context(
RunRequest::text("hi"),
RunContext::new("stream-deadline").with_timeout(Duration::from_millis(10)),
)
.await
.unwrap();
assert_eq!(
stream.next().await.unwrap().unwrap_err(),
AgentError::DeadlineExceeded
);
}
#[cfg(feature = "structured")]
#[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); }
#[cfg(feature = "structured")]
#[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")))
)));
}
#[cfg(feature = "structured")]
#[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); }
#[cfg(feature = "structured")]
#[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());
}
#[cfg(feature = "structured")]
#[tokio::test]
async fn typed_run_request_returns_value_and_output() {
#[derive(Debug, Deserialize, JsonSchema, PartialEq)]
struct Weather {
city: String,
}
let fake = SharedFake::new([FakeReply::Text(r#"{"city":"Beijing"}"#.into())]);
let mut agent = agent(fake.clone(), "");
let typed = agent
.run_typed_request_with_context::<Weather>(
RunRequest::text("Beijing weather"),
RunContext::new("typed-1"),
)
.await
.unwrap();
assert_eq!(
typed.value,
Weather {
city: "Beijing".into()
}
);
assert_eq!(typed.output.run_id, "typed-1");
assert_eq!(typed.output.answer, r#"{"city":"Beijing"}"#);
assert_eq!(
typed.output.final_message,
Message::assistant(r#"{"city":"Beijing"}"#)
);
}
#[cfg(feature = "structured")]
#[tokio::test]
async fn typed_schema_overrides_request_and_config_schema() {
#[derive(Debug, Deserialize, JsonSchema)]
struct Weather {
city: String,
}
let config_schema = serde_json::json!({
"type": "object",
"properties": { "config_only": { "type": "string" } },
"required": ["config_only"],
});
let request_schema = serde_json::json!({
"type": "object",
"properties": { "request_only": { "type": "string" } },
"required": ["request_only"],
});
let fake = SharedFake::new([FakeReply::Text(r#"{"city":"Beijing"}"#.into())]);
let mut agent = agent(fake.clone(), "").with_structured_output(config_schema);
let options = ModelOptions {
structured: Some(request_schema),
..Default::default()
};
let weather: Weather = agent
.run_typed_request(RunRequest::text("Beijing weather").with_options(options))
.await
.unwrap()
.value;
assert_eq!(weather.city, "Beijing");
let requests = fake.requests();
let structured = requests[0]
.options
.structured
.as_ref()
.expect("typed schema should be sent");
let props = &structured["properties"];
assert!(props.get("city").is_some());
assert!(props.get("request_only").is_none());
assert!(props.get("config_only").is_none());
}
#[cfg(feature = "structured")]
#[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);
}
#[cfg(feature = "structured")]
#[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(_)));
}
#[cfg(feature = "structured")]
#[tokio::test]
async fn typed_agent_trait_generic_call() {
#[derive(Debug, Deserialize, JsonSchema)]
struct Weather {
city: String,
}
async fn typed_run<A: TypedAgent + Send>(
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");
}
#[cfg(feature = "structured")]
#[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");
}
#[cfg(feature = "structured")]
#[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::new("slow", "Slow tool", serde_json::json!({}))
}
async fn call(
&self,
_arguments: serde_json::Value,
_context: ToolContext<'_>,
) -> Result<ToolResult, ToolError> {
self.calls.fetch_add(1, Ordering::Relaxed);
tokio::time::sleep(Duration::from_millis(100)).await;
Ok(ToolOutput::text("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_request_with_context(RunRequest::text("Compute"), cancellation_context(&token)) => r.map(|output| output.answer),
_ = 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(),
memory_policy: ToolMemoryPolicy::Normal,
effect: None,
});
} 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 kernel_batches_effect_requests_from_same_round() {
let mut registry = ToolRegistry::new();
registry
.register(EffectTool::new("read_a", "effect-a", "read A"))
.register(EffectTool::new("read_b", "effect-b", "read B"));
let fake = SharedFake::new([
FakeReply::ToolCalls {
content: "".into(),
calls: vec![call("c1", "read_a", "{}"), call("c2", "read_b", "{}")],
},
FakeReply::Text("done".into()),
]);
let mut agent = agent_with_registry(fake.clone(), registry, AgentConfig::default());
let context = RunContext::new("kernel-batch-effects");
let action = agent
.start(RunRequest::text("read both"), &context)
.await
.unwrap();
let AgentAction::RequestModel { request } = action else {
panic!("expected initial model request");
};
let action = agent
.observe(
Observation::Model(ModelObservation::new(
request.id,
fake.chat(request.chat).await.unwrap(),
)),
&context,
)
.await
.unwrap();
let AgentAction::RequestEffects { requests } = action else {
panic!("expected batch effect request");
};
assert_eq!(
requests
.iter()
.map(|request| request.id.as_str())
.collect::<Vec<_>>(),
vec!["effect-a", "effect-b"]
);
assert_eq!(
requests
.iter()
.map(|request| request.source.tool_call_id.as_deref())
.collect::<Vec<_>>(),
vec![Some("c1"), Some("c2")]
);
let action = agent
.observe(
Observation::Effects(vec![
EffectObservation::succeeded("effect-b", "observed B"),
EffectObservation::succeeded("effect-a", "observed A"),
]),
&context,
)
.await
.unwrap();
let AgentAction::RequestModel { request } = action else {
panic!("expected next model request");
};
assert!(matches!(
&request.chat.messages[2],
Message::ToolResult { id, content } if id == "c1" && content == "observed A"
));
assert!(matches!(
&request.chat.messages[3],
Message::ToolResult { id, content } if id == "c2" && content == "observed B"
));
let action = agent
.observe(
Observation::Model(ModelObservation::new(
request.id,
fake.chat(request.chat).await.unwrap(),
)),
&context,
)
.await
.unwrap();
let AgentAction::Respond { output } = action else {
panic!("expected final response");
};
assert_eq!(output.answer, "done");
}
#[tokio::test]
async fn kernel_records_mixed_outputs_and_effects_in_tool_call_order() {
let mut registry = ToolRegistry::new();
registry
.register(FakeTool::new("before", "plain before").0)
.register(EffectTool::new("read", "effect-read", "read"))
.register(FakeTool::new("after", "plain after").0);
let fake = SharedFake::new([
FakeReply::ToolCalls {
content: "".into(),
calls: vec![
call("c1", "before", "{}"),
call("c2", "read", "{}"),
call("c3", "after", "{}"),
],
},
FakeReply::Text("done".into()),
]);
let mut agent = agent_with_registry(fake.clone(), registry, AgentConfig::default());
let context = RunContext::new("kernel-mixed-effects");
let AgentAction::RequestModel { request } = agent
.start(RunRequest::text("read with context"), &context)
.await
.unwrap()
else {
panic!("expected initial model request");
};
let AgentAction::RequestEffect { request } = agent
.observe(
Observation::Model(ModelObservation::new(
request.id,
fake.chat(request.chat).await.unwrap(),
)),
&context,
)
.await
.unwrap()
else {
panic!("expected single effect request");
};
assert_eq!(request.id, "effect-read");
let action = agent
.observe(
Observation::Effect(EffectObservation::succeeded("effect-read", "observed read")),
&context,
)
.await
.unwrap();
let AgentAction::RequestModel { request } = action else {
panic!("expected next model request");
};
assert!(matches!(
&request.chat.messages[2],
Message::ToolResult { id, content } if id == "c1" && content == "plain before"
));
assert!(matches!(
&request.chat.messages[3],
Message::ToolResult { id, content } if id == "c2" && content == "observed read"
));
assert!(matches!(
&request.chat.messages[4],
Message::ToolResult { id, content } if id == "c3" && content == "plain after"
));
}
#[tokio::test]
async fn kernel_rejects_partial_effect_batch_observation() {
let mut registry = ToolRegistry::new();
registry
.register(EffectTool::new("read_a", "effect-a", "read A"))
.register(EffectTool::new("read_b", "effect-b", "read B"));
let fake = SharedFake::new([FakeReply::ToolCalls {
content: "".into(),
calls: vec![call("c1", "read_a", "{}"), call("c2", "read_b", "{}")],
}]);
let mut agent = agent_with_registry(fake.clone(), registry, AgentConfig::default());
let context = RunContext::new("kernel-batch-effects-partial");
let AgentAction::RequestModel { request } = agent
.start(RunRequest::text("read both"), &context)
.await
.unwrap()
else {
panic!("expected initial model request");
};
let AgentAction::RequestEffects { .. } = agent
.observe(
Observation::Model(ModelObservation::new(
request.id,
fake.chat(request.chat).await.unwrap(),
)),
&context,
)
.await
.unwrap()
else {
panic!("expected batch effect request");
};
let err = agent
.observe(
Observation::Effects(vec![EffectObservation::succeeded("effect-a", "observed A")]),
&context,
)
.await
.unwrap_err();
assert!(
matches!(err, AgentError::InvalidStep(message) if message.contains("count mismatch"))
);
}
#[tokio::test]
async fn kernel_rejects_duplicate_effect_batch_observation_and_can_retry() {
let mut registry = ToolRegistry::new();
registry
.register(EffectTool::new("read_a", "effect-a", "read A"))
.register(EffectTool::new("read_b", "effect-b", "read B"));
let fake = SharedFake::new([
FakeReply::ToolCalls {
content: "".into(),
calls: vec![call("c1", "read_a", "{}"), call("c2", "read_b", "{}")],
},
FakeReply::Text("done".into()),
]);
let mut agent = agent_with_registry(fake.clone(), registry, AgentConfig::default());
let context = RunContext::new("kernel-batch-effects-duplicate");
let AgentAction::RequestModel { request } = agent
.start(RunRequest::text("read both"), &context)
.await
.unwrap()
else {
panic!("expected initial model request");
};
let AgentAction::RequestEffects { .. } = agent
.observe(
Observation::Model(ModelObservation::new(
request.id,
fake.chat(request.chat).await.unwrap(),
)),
&context,
)
.await
.unwrap()
else {
panic!("expected batch effect request");
};
let err = agent
.observe(
Observation::Effects(vec![
EffectObservation::succeeded("effect-a", "observed A"),
EffectObservation::succeeded("effect-a", "observed A again"),
]),
&context,
)
.await
.unwrap_err();
assert!(matches!(err, AgentError::InvalidStep(message) if message.contains("duplicate")));
let action = agent
.observe(
Observation::Effects(vec![
EffectObservation::succeeded("effect-b", "observed B"),
EffectObservation::succeeded("effect-a", "observed A"),
]),
&context,
)
.await
.unwrap();
let AgentAction::RequestModel { request } = action else {
panic!("expected next model request");
};
assert!(matches!(
&request.chat.messages[2],
Message::ToolResult { id, content } if id == "c1" && content == "observed A"
));
assert!(matches!(
&request.chat.messages[3],
Message::ToolResult { id, content } if id == "c2" && content == "observed B"
));
}
#[tokio::test]
async fn stream_cancel_mid_generation() {
struct SlowStreamProvider;
#[async_trait::async_trait]
impl Provider for SlowStreamProvider {
async fn chat_with_context(
&self,
_request: ChatRequest,
_context: &ProviderRequestContext,
) -> Result<ChatResponse, ProviderError> {
unreachable!("this test uses streaming path only")
}
async fn stream_chat_with_context(
&self,
_request: ChatRequest,
_context: &ProviderRequestContext,
) -> 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_request_with_context(RunRequest::text("hi"), cancellation_context(&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_request_with_context(RunRequest::text("one"), cancellation_context(&t1))
.await,
Err(AgentError::Cancelled)
));
let t2 = CancellationToken::new();
assert_eq!(
agent
.run_request_with_context(RunRequest::text("two"), cancellation_context(&t2))
.await
.unwrap()
.answer,
"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(ToolOutput::text("42").into()));
}
_ => 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 run_started_event_preserves_block_input() {
let blocks = vec![ContentBlock::Text("look".into())];
let fake = SharedFake::new([FakeReply::Text("done".into())]);
let (mut agent, mut rx) = attach_channel(agent(fake, ""));
agent
.run_request(RunRequest::blocks(blocks.clone()))
.await
.unwrap();
drop(agent);
let events = drain(&mut rx).await;
match react_event(&*events[0]) {
ReActEvent::RunStarted { input, .. } => {
assert_eq!(input, &crate::UserInput::Blocks(blocks));
}
_ => panic!("expected RunStarted"),
}
}
#[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_with_context(
&self,
_request: ChatRequest,
_context: &ProviderRequestContext,
) -> Result<ChatResponse, ProviderError> {
unreachable!("this test uses streaming path only")
}
async fn stream_chat_with_context(
&self,
_request: ChatRequest,
_context: &ProviderRequestContext,
) -> 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_request_with_context(RunRequest::text("hi"), cancellation_context(&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"),
}
}
#[cfg(feature = "tracing")]
mod tracing_tests {
use super::*;
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(flavor = "current_thread")]
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(flavor = "current_thread")]
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(flavor = "current_thread")]
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(flavor = "current_thread")]
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(flavor = "current_thread")]
async fn trace_tool_error_recorded() {
struct FailingTool;
#[async_trait::async_trait]
impl Tool for FailingTool {
fn schema(&self) -> ToolSchema {
ToolSchema::new("boom", "Tool that always fails", serde_json::json!({}))
}
async fn call(
&self,
_arguments: serde_json::Value,
_context: ToolContext<'_>,
) -> Result<ToolResult, 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(flavor = "current_thread")]
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(flavor = "current_thread")]
async fn trace_stream_llm_error_recorded() {
struct FailInStream;
#[async_trait::async_trait]
impl Provider for FailInStream {
async fn chat_with_context(
&self,
_request: ChatRequest,
_context: &ProviderRequestContext,
) -> Result<ChatResponse, ProviderError> {
unreachable!("this test uses streaming path only")
}
async fn stream_chat_with_context(
&self,
_request: ChatRequest,
_context: &ProviderRequestContext,
) -> Result<BoxStream<'static, Result<StreamEvent, ProviderError>>, ProviderError>
{
Ok(Box::pin(futures::stream::iter(vec![
Ok(StreamEvent::Delta("hi".into())),
Err(ProviderError::Protocol {
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"))
);
}
}
}