1use super::config::AgentConfig;
21#[cfg(feature = "structured")]
22use super::structured::{StructuredOutcome, StructuredValidator};
23#[cfg(feature = "structured")]
24use crate::agent::TypedAgent;
25use crate::agent::events::ReActEvent;
26use crate::agent::{
27 Agent, AgentAction, AgentError, AgentEvent, AgentKernel, MessageChunk, ModelObservation,
28 ModelRequest, Observation, RunSummary,
29};
30use crate::effect::{EffectObservation, EffectRequest};
31use crate::event_channel::EventChannel;
32use crate::memory::{Memory, WindowMemory};
33use crate::message::{Message, ToolCall};
34use crate::provider::FakeProvider;
35use crate::provider::{
36 ChatRequest, FinishReason, ModelOptions, Provider, ProviderError, ProviderRequestContext,
37 StreamEvent, Usage,
38};
39#[cfg(feature = "structured")]
40use crate::run::TypedRunOutput;
41use crate::run::{Artifact, RunContext, RunMetadata, RunOutput, RunRequest};
42use crate::tool::{SharedState, Tool, ToolMemoryPolicy, ToolOutput, ToolRegistry, ToolResult};
43use futures::StreamExt;
44use futures::stream::BoxStream;
45#[cfg(feature = "structured")]
46use schemars::JsonSchema;
47#[cfg(feature = "structured")]
48use serde::de::DeserializeOwned;
49use std::collections::HashMap;
50use std::collections::HashSet;
51use std::collections::VecDeque;
52use std::fmt;
53use std::future::Future;
54use std::pin::Pin;
55use std::sync::Arc;
56use std::time::Instant;
57#[cfg(feature = "tracing")]
58use tracing::Instrument;
59
60#[macro_export]
101macro_rules! react_agent {
102 ($provider:expr $(,)?) => {
104 $crate::agent::ReActAgent::new(
105 $provider,
106 $crate::tool::ToolRegistry::new(),
107 "",
108 )
109 };
110 ($provider:expr, $system:literal $(,)?) => {
111 $crate::agent::ReActAgent::new(
112 $provider,
113 $crate::tool::ToolRegistry::new(),
114 $system,
115 )
116 };
117 ($provider:expr, [$($tool:expr),* $(,)?] $(,)?) => {{
119 let mut __molo_registry = $crate::tool::ToolRegistry::new();
120 $(__molo_registry.register($tool);)*
121 $crate::agent::ReActAgent::new($provider, __molo_registry, "")
122 }};
123 ($provider:expr, [$($tool:expr),* $(,)?], $system:expr $(,)?) => {{
124 let mut __molo_registry = $crate::tool::ToolRegistry::new();
125 $(__molo_registry.register($tool);)*
126 $crate::agent::ReActAgent::new($provider, __molo_registry, $system)
127 }};
128 ($provider:expr, $registry:expr $(,)?) => {
130 $crate::agent::ReActAgent::new($provider, $registry, "")
131 };
132 ($provider:expr, $registry:expr, $system:expr $(,)?) => {
133 $crate::agent::ReActAgent::new($provider, $registry, $system)
134 };
135}
136
137pub struct ReActAgent {
221 provider: Box<dyn Provider>,
222 memory: Box<dyn Memory>,
223 registry: ToolRegistry,
224 system_prompt: String,
225 config: AgentConfig,
226 pub state: SharedState,
230 events: Option<Arc<dyn EventChannel>>,
233 executor: Box<dyn ToolRoundExecutor>,
238 kernel_state: Option<ReActKernelState>,
240}
241
242const MAX_ROUND_TEXT: usize = 4 << 20;
246
247const DEFAULT_MEMORY_TOKENS: usize = 128_000;
254
255pub struct ReActAgentBuilder {
283 provider: Box<dyn Provider>,
284 memory: Box<dyn Memory>,
285 tools: ToolRegistry,
286 system_prompt: String,
287 config: AgentConfig,
288 state: SharedState,
289 events: Option<Arc<dyn EventChannel>>,
290 executor: Box<dyn ToolRoundExecutor>,
291}
292
293impl ReActAgentBuilder {
294 pub fn new(provider: impl Provider + 'static) -> Self {
296 Self {
297 provider: Box::new(provider),
298 memory: Box::new(WindowMemory::new(DEFAULT_MEMORY_TOKENS)),
299 tools: ToolRegistry::new(),
300 system_prompt: String::new(),
301 config: AgentConfig::default(),
302 state: SharedState::default(),
303 events: None,
304 executor: Box::new(SerialToolRoundExecutor),
305 }
306 }
307
308 pub fn with_provider(mut self, provider: impl Provider + 'static) -> Self {
310 self.provider = Box::new(provider);
311 self
312 }
313
314 pub fn with_tools(mut self, tools: ToolRegistry) -> Self {
316 self.tools = tools;
317 self
318 }
319
320 pub fn with_tool(mut self, tool: impl Tool + 'static) -> Self {
322 self.tools.register(tool);
323 self
324 }
325
326 pub fn with_system_prompt(mut self, system_prompt: impl Into<String>) -> Self {
328 self.system_prompt = system_prompt.into();
329 self
330 }
331
332 pub fn with_memory(mut self, memory: impl Memory + 'static) -> Self {
334 self.memory = Box::new(memory);
335 self
336 }
337
338 pub fn with_config(mut self, config: AgentConfig) -> Self {
340 self.config = config;
341 self
342 }
343
344 #[cfg(feature = "structured")]
346 pub fn with_structured_output(mut self, schema: serde_json::Value) -> Self {
347 self.config.options.structured = Some(schema);
348 self
349 }
350
351 pub fn with_state(mut self, state: SharedState) -> Self {
353 self.state = state;
354 self
355 }
356
357 pub fn with_event_channel(mut self, channel: impl EventChannel + 'static) -> Self {
359 self.events = Some(Arc::new(channel));
360 self
361 }
362
363 pub fn with_tool_round_executor(mut self, executor: impl ToolRoundExecutor + 'static) -> Self {
365 self.executor = Box::new(executor);
366 self
367 }
368
369 pub fn build(self) -> ReActAgent {
371 ReActAgent {
372 provider: self.provider,
373 memory: self.memory,
374 registry: self.tools,
375 system_prompt: self.system_prompt,
376 config: self.config,
377 state: self.state,
378 events: self.events,
379 executor: self.executor,
380 kernel_state: None,
381 }
382 }
383}
384
385impl fmt::Debug for ReActAgentBuilder {
386 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
387 let mut debug = f.debug_struct("ReActAgentBuilder");
388 debug
389 .field("provider", &"Box<dyn Provider>")
390 .field("memory", &"Box<dyn Memory>")
391 .field("tools", &self.tools)
392 .field("system_prompt", &self.system_prompt)
393 .field("config", &self.config)
394 .field("state", &self.state)
395 .field(
396 "events",
397 &match &self.events {
398 Some(_) => "Some<dyn EventChannel>",
399 None => "None",
400 },
401 );
402 debug.finish()
403 }
404}
405
406impl ReActAgent {
407 pub fn builder(provider: impl Provider + 'static) -> ReActAgentBuilder {
409 ReActAgentBuilder::new(provider)
410 }
411
412 pub fn new(
434 provider: impl Provider + 'static,
435 tools: ToolRegistry,
436 system_prompt: impl Into<String>,
437 ) -> Self {
438 Self {
439 provider: Box::new(provider),
440 memory: Box::new(WindowMemory::new(DEFAULT_MEMORY_TOKENS)),
441 registry: tools,
442 system_prompt: system_prompt.into(),
443 config: AgentConfig::default(),
444 state: SharedState::default(),
445 events: None,
446 executor: Box::new(SerialToolRoundExecutor),
447 kernel_state: None,
448 }
449 }
450
451 pub fn kernel(tools: ToolRegistry, system_prompt: impl Into<String>) -> Self {
458 Self::new(FakeProvider::new([]), tools, system_prompt)
459 }
460
461 pub fn with_memory(mut self, memory: impl Memory + 'static) -> Self {
495 self.memory = Box::new(memory);
496 self
497 }
498
499 pub fn with_config(mut self, config: AgentConfig) -> Self {
506 self.config = config;
507 self
508 }
509
510 #[cfg(feature = "structured")]
557 pub fn with_structured_output(mut self, schema: serde_json::Value) -> Self {
558 self.config.options.structured = Some(schema);
559 self
560 }
561
562 pub fn with_state(mut self, state: SharedState) -> Self {
566 self.state = state;
567 self
568 }
569
570 pub fn with_event_channel(mut self, channel: impl EventChannel + 'static) -> Self {
581 self.events = Some(Arc::new(channel));
582 self
583 }
584
585 pub fn with_tool_round_executor(mut self, executor: impl ToolRoundExecutor + 'static) -> Self {
652 self.executor = Box::new(executor);
653 self
654 }
655
656 fn publish<E: AgentEvent + 'static>(&self, make_event: impl FnOnce() -> Arc<E>) {
662 if let Some(pipe) = &self.events {
663 pipe.publish(make_event());
664 }
665 }
666
667 async fn run_rounds_with_context(
669 &mut self,
670 context: &RunContext,
671 run_id: &str,
672 counters: &mut RunCounters,
673 options: &ModelOptions,
674 schema: Option<&serde_json::Value>,
675 ) -> Result<FinalAnswer, AgentError> {
676 let schemas = self.registry.schemas();
677 #[cfg(feature = "structured")]
681 let mut validator = schema.map(|schema| {
682 StructuredValidator::new(schema.clone(), self.config.max_structured_retries)
683 });
684 #[cfg(not(feature = "structured"))]
685 let _ = schema;
686 let mut tool_rounds = 0usize;
693 loop {
694 if tool_rounds >= self.config.max_tool_rounds {
695 return Err(AgentError::TooManyToolRounds(self.config.max_tool_rounds));
696 }
697 counters.rounds += 1;
698 check_run_context(context)?;
702
703 let answer: Option<FinalAnswer> = async {
716 let llm_span = span_llm(run_id, counters.rounds);
717 let model_request_id = format!("{run_id}-model-{}", counters.rounds);
731 let provider_context =
732 ProviderRequestContext::from_run_context(model_request_id, context);
733 let chat = self.provider.chat_with_context(
734 ChatRequest {
735 messages: self.assemble_messages(self.memory.context().await?),
736 tools: schemas.clone(),
737 options: options.clone(),
738 },
739 &provider_context,
740 );
741 let response =
742 match run_until_context(context, instrument(chat, llm_span.clone())).await {
743 Ok(Ok(response)) => response,
744 Ok(Err(e)) => {
745 #[cfg(feature = "tracing")]
746 llm_span.record("error", e.to_string());
747 return Err(AgentError::Provider(e));
748 }
749 Err(e) => return Err(e),
750 };
751 if let Some(usage) = response.usage {
755 #[cfg(feature = "tracing")]
756 {
757 llm_span.record("usage.prompt_tokens", usage.prompt_tokens);
758 llm_span.record("usage.completion_tokens", usage.completion_tokens);
759 }
760 counters.usage_total += usage;
761 } else {
762 counters.usage_omitted = true;
763 }
764 let finish_reason = response.finish_reason.clone();
765
766 let Message::Assistant {
771 content,
772 reasoning,
773 tool_calls,
774 } = response.message
775 else {
776 return Err(AgentError::Provider(ProviderError::Protocol {
781 message: "provider returned a non-assistant message".into(),
782 }));
783 };
784 if content.len() + reasoning.as_deref().map_or(0, str::len) > MAX_ROUND_TEXT {
790 return Err(AgentError::Provider(ProviderError::ResponseTooLarge {
791 limit_bytes: MAX_ROUND_TEXT,
792 }));
793 }
794
795 let final_message = Message::Assistant {
800 content: content.clone(),
801 reasoning: reasoning.clone(),
802 tool_calls: Vec::new(),
803 };
804
805 if !content.is_empty() || reasoning.is_some() || !tool_calls.is_empty() {
806 self.memory
807 .record(Message::Assistant {
808 content: content.clone(),
809 reasoning,
810 tool_calls: tool_calls.clone(),
811 })
812 .await?;
813 }
814
815 if tool_calls.is_empty() {
816 #[cfg(feature = "structured")]
822 {
823 if let Some(validator) = &mut validator {
824 match validator.validate(&content) {
825 StructuredOutcome::Passed => {}
826 StructuredOutcome::Retry { message } => {
827 self.memory.record(message).await?;
828 return Ok::<Option<FinalAnswer>, AgentError>(None);
829 }
830 StructuredOutcome::Exhausted { max_retries } => {
831 return Err(AgentError::StructuredRetriesExhausted(
832 max_retries,
833 ));
834 }
835 }
836 }
837 }
838 return Ok::<Option<FinalAnswer>, AgentError>(Some(FinalAnswer {
839 answer: content,
840 final_message,
841 finish_reason: Some(finish_reason),
842 }));
843 }
844
845 counters.tool_calls_total += tool_calls.len();
846 tool_rounds += 1;
847 let ctx = ToolRoundCtx {
858 context,
859 round: counters.rounds,
860 registry: &self.registry,
861 state: &self.state,
862 events: &self.events,
863 };
864 let mut outcomes = self.executor.execute_round(ctx, tool_calls).await;
865 while let Some(outcome) = outcomes.next().await {
866 if let Some(effect) = outcome.effect_request() {
867 return Err(AgentError::EffectRequiresHarness(format!(
868 "{} ({})",
869 effect.description, effect.id
870 )));
871 }
872 record_tool_result(&mut self.memory, &outcome).await?;
877 }
878 Ok::<Option<FinalAnswer>, AgentError>(None)
879 }
880 .await?;
881 if let Some(answer) = answer {
882 return Ok(answer);
883 }
884 }
885 }
886
887 fn assemble_messages(&self, context: Vec<Message>) -> Vec<Message> {
896 let mut messages = Vec::with_capacity(context.len() + 1);
897 let system = self.assemble_system_prompt();
898 if !system.is_empty() {
899 messages.push(Message::system(&system));
900 }
901 messages.extend(context);
902 messages
903 }
904
905 fn assemble_system_prompt(&self) -> String {
907 self.system_prompt.clone()
908 }
909}
910
911pub struct ToolRoundCtx<'a> {
921 pub context: &'a RunContext,
924 pub round: usize,
926 pub registry: &'a ToolRegistry,
928 pub state: &'a SharedState,
930 pub events: &'a Option<Arc<dyn EventChannel>>,
932}
933
934impl fmt::Debug for ToolRoundCtx<'_> {
935 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
936 f.debug_struct("ToolRoundCtx")
939 .field("run_id", &self.context.run_id)
940 .field("round", &self.round)
941 .field("registry", &self.registry)
942 .field("state", &self.state)
943 .finish_non_exhaustive()
944 }
945}
946
947impl ToolRoundCtx<'_> {
948 pub async fn run(&self, call: ToolCall) -> ToolCallOutcome {
963 let tool_span = span_tool(&self.context.run_id, self.round, &call.name);
968 self.publish(|| {
971 Arc::new(ReActEvent::ToolStarted {
972 id: call.id.clone(),
973 name: call.name.clone(),
974 arguments: call.arguments.clone(),
975 })
976 });
977 let call_future = self.registry.call(&call, self.context, self.state);
981 let result = instrument(call_future, tool_span.clone()).await;
982 #[cfg(feature = "tracing")]
983 if let Err(e) = &result {
984 tool_span.record("error", e.to_string());
985 }
986 let outcome = match &result {
987 Ok(ToolResult::Output(output)) => ToolCallOutcome::output(call.clone(), output.clone()),
988 Ok(ToolResult::Effect(request)) => {
989 ToolCallOutcome::effect(call.clone(), request.clone())
990 }
991 Ok(other) => ToolCallOutcome::text(call.clone(), other.to_string()),
992 Err(e) => ToolCallOutcome::text(call.clone(), e.to_string()),
993 };
994 let publish_tool_completed = {
995 let id = call.id.clone();
996 let name = call.name.clone();
997 move || Arc::new(ReActEvent::ToolCompleted { id, name, result })
998 };
999 self.publish(publish_tool_completed);
1000 outcome
1001 }
1002
1003 fn publish<E: AgentEvent + 'static>(&self, make_event: impl FnOnce() -> Arc<E>) {
1008 if let Some(pipe) = self.events {
1009 pipe.publish(make_event());
1010 }
1011 }
1012}
1013
1014#[async_trait::async_trait]
1095pub trait ToolRoundExecutor: Send + Sync {
1096 async fn execute_round<'a>(
1112 &'a mut self,
1113 ctx: ToolRoundCtx<'a>,
1114 calls: Vec<ToolCall>,
1115 ) -> BoxStream<'a, ToolCallOutcome>;
1116}
1117
1118#[derive(Debug, Default, Clone, Copy)]
1133pub struct SerialToolRoundExecutor;
1134
1135#[async_trait::async_trait]
1136impl ToolRoundExecutor for SerialToolRoundExecutor {
1137 async fn execute_round<'a>(
1138 &'a mut self,
1139 ctx: ToolRoundCtx<'a>,
1140 calls: Vec<ToolCall>,
1141 ) -> BoxStream<'a, ToolCallOutcome> {
1142 Box::pin(async_stream::stream! {
1143 for call in calls {
1144 yield ctx.run(call).await;
1145 }
1146 })
1147 }
1148}
1149
1150#[derive(Debug, Clone, PartialEq)]
1176pub struct ToolCallOutcome {
1177 pub call: ToolCall,
1180 pub content: String,
1182 pub memory_policy: ToolMemoryPolicy,
1184 pub effect: Option<EffectRequest>,
1187}
1188
1189impl ToolCallOutcome {
1190 pub fn text(call: ToolCall, content: impl Into<String>) -> Self {
1192 Self::output(call, ToolOutput::text(content))
1193 }
1194
1195 pub fn output(call: ToolCall, output: ToolOutput) -> Self {
1197 Self {
1198 call,
1199 content: output.content,
1200 memory_policy: output.memory_policy,
1201 effect: None,
1202 }
1203 }
1204
1205 pub fn effect(call: ToolCall, request: EffectRequest) -> Self {
1207 Self {
1208 call,
1209 content: String::new(),
1210 memory_policy: ToolMemoryPolicy::Normal,
1211 effect: Some(request),
1212 }
1213 }
1214
1215 pub fn effect_request(&self) -> Option<&EffectRequest> {
1217 self.effect.as_ref()
1218 }
1219}
1220
1221#[cfg(feature = "structured")]
1222impl ReActAgent {
1223 pub async fn run_typed<U>(&mut self, input: &str) -> Result<U, AgentError>
1277 where
1278 U: DeserializeOwned + JsonSchema + Send + Sync,
1279 {
1280 TypedAgent::run_typed(self, input).await
1283 }
1284}
1285
1286#[cfg(feature = "structured")]
1287#[async_trait::async_trait]
1288impl TypedAgent for ReActAgent {
1289 async fn run_typed_request_with_context<U>(
1290 &mut self,
1291 request: RunRequest,
1292 context: RunContext,
1293 ) -> Result<TypedRunOutput<U>, AgentError>
1294 where
1295 U: DeserializeOwned + JsonSchema + Send + Sync,
1296 {
1297 let schema = serde_json::to_value(schemars::schema_for!(U))
1298 .expect("schemars-generated schema always serializes (pure JSON value structure)");
1299 let output = self
1300 .run_request_inner(request, context, Some(&schema))
1301 .await?;
1302 let value = serde_json::from_str(&output.answer)
1303 .map_err(|e| AgentError::StructuredParse(e.to_string()))?;
1304 Ok(TypedRunOutput { value, output })
1305 }
1306}
1307
1308#[derive(Default)]
1315struct RunCounters {
1316 rounds: usize,
1319 tool_calls_total: usize,
1321 usage_total: Usage,
1323 usage_omitted: bool,
1326}
1327
1328struct FinalAnswer {
1329 answer: String,
1330 final_message: Message,
1331 finish_reason: Option<FinishReason>,
1332}
1333
1334struct RunExecution {
1335 answer: String,
1336 final_message: Message,
1337 summary: RunSummary,
1338 artifacts: Vec<Artifact>,
1339 metadata: RunMetadata,
1340}
1341
1342struct ReActKernelState {
1343 run_id: String,
1344 started_at: Instant,
1345 provider_model: Option<String>,
1346 counters: RunCounters,
1347 options: ModelOptions,
1348 schemas: Vec<crate::tool::ToolSchema>,
1349 #[cfg(feature = "structured")]
1350 validator: Option<StructuredValidator>,
1351 tool_rounds: usize,
1352 pending_tools: VecDeque<ToolCall>,
1353 pending_tool_results: VecDeque<PendingToolResult>,
1354 next_model_request: u64,
1355}
1356
1357#[derive(Debug)]
1358enum PendingToolResult {
1359 Outcome(ToolCallOutcome),
1360 Effect {
1361 effect_id: String,
1362 call: ToolCall,
1363 observation: Option<EffectObservation>,
1364 },
1365}
1366
1367impl PendingToolResult {
1368 fn effect_id(&self) -> Option<&str> {
1369 match self {
1370 Self::Outcome(_) => None,
1371 Self::Effect { effect_id, .. } => Some(effect_id),
1372 }
1373 }
1374}
1375
1376impl ReActKernelState {
1377 fn next_model_request(&mut self, messages: Vec<Message>) -> AgentAction {
1378 self.next_model_request += 1;
1379 AgentAction::RequestModel {
1380 request: ModelRequest::new(
1381 format!("{}-model-{}", self.run_id, self.next_model_request),
1382 ChatRequest {
1383 messages,
1384 tools: self.schemas.clone(),
1385 options: self.options.clone(),
1386 },
1387 ),
1388 }
1389 }
1390
1391 fn summary(&self, finish_reason: Option<FinishReason>) -> RunSummary {
1392 run_summary(
1393 &self.counters,
1394 finish_reason,
1395 self.started_at,
1396 self.provider_model.clone(),
1397 )
1398 }
1399}
1400
1401impl fmt::Debug for ReActAgent {
1402 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1403 let mut debug = f.debug_struct("ReActAgent");
1406 debug
1407 .field("provider", &"Box<dyn Provider>")
1408 .field("memory", &"Box<dyn Memory>")
1409 .field("tools", &self.registry)
1410 .field("system_prompt", &self.system_prompt)
1411 .field("config", &self.config)
1412 .field("state", &self.state)
1413 .field(
1414 "events",
1415 &match &self.events {
1416 Some(_) => "Some<dyn EventChannel>",
1417 None => "None",
1418 },
1419 );
1420 debug.finish()
1421 }
1422}
1423
1424#[async_trait::async_trait]
1425impl Agent for ReActAgent {
1426 async fn run_request_with_context(
1427 &mut self,
1428 request: RunRequest,
1429 context: RunContext,
1430 ) -> Result<RunOutput, AgentError> {
1431 self.run_request_inner(request, context, None).await
1432 }
1433
1434 async fn run_stream_request_with_context<'a>(
1435 &'a mut self,
1436 request: RunRequest,
1437 context: RunContext,
1438 ) -> Result<BoxStream<'a, Result<MessageChunk, AgentError>>, AgentError> {
1439 self.run_stream_request_inner(request, context).await
1440 }
1441}
1442
1443#[async_trait::async_trait]
1444impl AgentKernel for ReActAgent {
1445 async fn start(
1446 &mut self,
1447 request: RunRequest,
1448 context: &RunContext,
1449 ) -> Result<AgentAction, AgentError> {
1450 check_run_context(context)?;
1451 let run_id = context.run_id.clone();
1452 let input = request.input;
1453 self.memory.record(input.clone().into_message()).await?;
1454 self.publish(|| {
1455 Arc::new(ReActEvent::RunStarted {
1456 run_id: run_id.clone(),
1457 input,
1458 })
1459 });
1460
1461 let options = request
1462 .options
1463 .clone()
1464 .unwrap_or_else(|| self.config.options.clone());
1465 #[cfg(feature = "structured")]
1466 let validator = options.structured.as_ref().map(|schema| {
1467 StructuredValidator::new(schema.clone(), self.config.max_structured_retries)
1468 });
1469 let mut state = ReActKernelState {
1470 run_id,
1471 started_at: Instant::now(),
1472 provider_model: self.provider.model().map(str::to_string),
1473 counters: RunCounters::default(),
1474 options,
1475 schemas: self.registry.schemas(),
1476 #[cfg(feature = "structured")]
1477 validator,
1478 tool_rounds: 0,
1479 pending_tools: VecDeque::new(),
1480 pending_tool_results: VecDeque::new(),
1481 next_model_request: 0,
1482 };
1483 state.counters.rounds += 1;
1484 let messages = self.assemble_messages(self.memory.context().await?);
1485 let action = state.next_model_request(messages);
1486 self.kernel_state = Some(state);
1487 Ok(action)
1488 }
1489
1490 async fn observe(
1491 &mut self,
1492 observation: Observation,
1493 context: &RunContext,
1494 ) -> Result<AgentAction, AgentError> {
1495 check_run_context(context)?;
1496 match observation {
1497 Observation::Model(observation) => self.observe_model(observation, context).await,
1498 Observation::Effect(observation) => self.observe_effect(observation, context).await,
1499 Observation::Effects(observations) => self.observe_effects(observations, context).await,
1500 _ => Err(AgentError::InvalidStep("unsupported observation".into())),
1501 }
1502 }
1503}
1504
1505impl ReActAgent {
1506 async fn observe_model(
1507 &mut self,
1508 observation: ModelObservation,
1509 context: &RunContext,
1510 ) -> Result<AgentAction, AgentError> {
1511 let mut state = self.kernel_state.take().ok_or_else(|| {
1512 AgentError::InvalidStep("model observation without active run".into())
1513 })?;
1514 if !state.pending_tool_results.is_empty() || !state.pending_tools.is_empty() {
1515 self.kernel_state = Some(state);
1516 return Err(AgentError::InvalidStep(
1517 "model observation received while tool calls are pending".into(),
1518 ));
1519 }
1520
1521 let response = observation.response;
1522 if let Some(usage) = response.usage {
1523 state.counters.usage_total += usage;
1524 } else {
1525 state.counters.usage_omitted = true;
1526 }
1527 let finish_reason = response.finish_reason.clone();
1528 let Message::Assistant {
1529 content,
1530 reasoning,
1531 tool_calls,
1532 } = response.message
1533 else {
1534 self.kernel_state = Some(state);
1535 return Err(AgentError::Provider(ProviderError::Protocol {
1536 message: "provider returned a non-assistant message".into(),
1537 }));
1538 };
1539 if content.len() + reasoning.as_deref().map_or(0, str::len) > MAX_ROUND_TEXT {
1540 self.kernel_state = Some(state);
1541 return Err(AgentError::Provider(ProviderError::ResponseTooLarge {
1542 limit_bytes: MAX_ROUND_TEXT,
1543 }));
1544 }
1545
1546 let final_message = Message::Assistant {
1547 content: content.clone(),
1548 reasoning: reasoning.clone(),
1549 tool_calls: Vec::new(),
1550 };
1551 if !content.is_empty() || reasoning.is_some() || !tool_calls.is_empty() {
1552 self.memory
1553 .record(Message::Assistant {
1554 content: content.clone(),
1555 reasoning,
1556 tool_calls: tool_calls.clone(),
1557 })
1558 .await?;
1559 }
1560
1561 if tool_calls.is_empty() {
1562 #[cfg(feature = "structured")]
1563 {
1564 if let Some(validator) = &mut state.validator {
1565 match validator.validate(&content) {
1566 StructuredOutcome::Passed => {}
1567 StructuredOutcome::Retry { message } => {
1568 self.memory.record(message).await?;
1569 state.counters.rounds += 1;
1570 let messages = self.assemble_messages(self.memory.context().await?);
1571 let action = state.next_model_request(messages);
1572 self.kernel_state = Some(state);
1573 return Ok(action);
1574 }
1575 StructuredOutcome::Exhausted { max_retries } => {
1576 self.kernel_state = None;
1577 return Err(AgentError::StructuredRetriesExhausted(max_retries));
1578 }
1579 }
1580 }
1581 }
1582 let summary = state.summary(Some(finish_reason));
1583 let output = RunOutput {
1584 run_id: state.run_id.clone(),
1585 answer: content,
1586 summary: summary.clone(),
1587 final_message,
1588 artifacts: Vec::new(),
1589 metadata: RunMetadata::new(),
1590 };
1591 publish_ended(&self.events, summary, None);
1592 self.kernel_state = None;
1593 return Ok(AgentAction::Respond { output });
1594 }
1595
1596 if state.tool_rounds >= self.config.max_tool_rounds {
1597 self.kernel_state = None;
1598 return Err(AgentError::TooManyToolRounds(self.config.max_tool_rounds));
1599 }
1600 state.tool_rounds += 1;
1601 state.counters.tool_calls_total += tool_calls.len();
1602 state.pending_tools = VecDeque::from(tool_calls);
1603 let action = self.process_kernel_pending_tools(state, context).await?;
1604 Ok(action)
1605 }
1606
1607 async fn observe_effect(
1608 &mut self,
1609 observation: EffectObservation,
1610 context: &RunContext,
1611 ) -> Result<AgentAction, AgentError> {
1612 let mut state = self.kernel_state.take().ok_or_else(|| {
1613 AgentError::InvalidStep("effect observation without active run".into())
1614 })?;
1615 let pending_effect_count = state
1616 .pending_tool_results
1617 .iter()
1618 .filter(|result| result.effect_id().is_some())
1619 .count();
1620 if pending_effect_count > 1 {
1621 self.kernel_state = Some(state);
1622 return Err(AgentError::InvalidStep(
1623 "single effect observation received while a batch is pending".into(),
1624 ));
1625 }
1626 let Some(expected_effect_id) = state
1627 .pending_tool_results
1628 .iter()
1629 .find_map(PendingToolResult::effect_id)
1630 .map(str::to_string)
1631 else {
1632 self.kernel_state = Some(state);
1633 return Err(AgentError::InvalidStep(
1634 "effect observation received with no pending effect".into(),
1635 ));
1636 };
1637 if observation.effect_id != expected_effect_id {
1638 self.kernel_state = Some(state);
1639 return Err(AgentError::InvalidStep(format!(
1640 "effect observation id mismatch: expected {expected_effect_id}, got {}",
1641 observation.effect_id
1642 )));
1643 }
1644
1645 if observation.output.observation_for_model.len() > MAX_ROUND_TEXT {
1646 self.kernel_state = Some(state);
1647 return Err(AgentError::Provider(ProviderError::ResponseTooLarge {
1648 limit_bytes: MAX_ROUND_TEXT,
1649 }));
1650 }
1651 let recorded = Self::mark_pending_effect_observed(
1652 &mut state.pending_tool_results,
1653 &expected_effect_id,
1654 observation,
1655 );
1656 debug_assert!(recorded);
1657 if let Err(error) = self.record_pending_tool_results(&mut state).await {
1658 self.kernel_state = Some(state);
1659 return Err(error);
1660 }
1661 self.process_kernel_pending_tools(state, context).await
1662 }
1663
1664 async fn observe_effects(
1665 &mut self,
1666 observations: Vec<EffectObservation>,
1667 context: &RunContext,
1668 ) -> Result<AgentAction, AgentError> {
1669 let mut state = self.kernel_state.take().ok_or_else(|| {
1670 AgentError::InvalidStep("effect observations without active run".into())
1671 })?;
1672 let pending_effect_ids = state
1673 .pending_tool_results
1674 .iter()
1675 .filter_map(PendingToolResult::effect_id)
1676 .map(str::to_string)
1677 .collect::<Vec<_>>();
1678 if pending_effect_ids.is_empty() {
1679 self.kernel_state = Some(state);
1680 return Err(AgentError::InvalidStep(
1681 "effect observations received with no pending effects".into(),
1682 ));
1683 }
1684 if observations.len() != pending_effect_ids.len() {
1685 let expected = pending_effect_ids.len();
1686 self.kernel_state = Some(state);
1687 return Err(AgentError::InvalidStep(format!(
1688 "effect observation count mismatch: expected {expected}, got {}",
1689 observations.len()
1690 )));
1691 }
1692
1693 let expected_ids = pending_effect_ids.iter().cloned().collect::<HashSet<_>>();
1694 let mut observations_by_id = HashMap::with_capacity(observations.len());
1695 for observation in observations {
1696 if observation.output.observation_for_model.len() > MAX_ROUND_TEXT {
1697 self.kernel_state = Some(state);
1698 return Err(AgentError::Provider(ProviderError::ResponseTooLarge {
1699 limit_bytes: MAX_ROUND_TEXT,
1700 }));
1701 }
1702 if !expected_ids.contains(&observation.effect_id) {
1703 let effect_id = observation.effect_id;
1704 self.kernel_state = Some(state);
1705 return Err(AgentError::InvalidStep(format!(
1706 "unexpected effect observation for {effect_id}"
1707 )));
1708 }
1709
1710 let effect_id = observation.effect_id.clone();
1711 if observations_by_id
1712 .insert(effect_id.clone(), observation)
1713 .is_some()
1714 {
1715 self.kernel_state = Some(state);
1716 return Err(AgentError::InvalidStep(format!(
1717 "duplicate effect observation for {effect_id}"
1718 )));
1719 }
1720 }
1721 for effect_id in &pending_effect_ids {
1722 if !observations_by_id.contains_key(effect_id) {
1723 self.kernel_state = Some(state);
1724 return Err(AgentError::InvalidStep(format!(
1725 "missing effect observation for {effect_id}"
1726 )));
1727 }
1728 }
1729
1730 for effect_id in pending_effect_ids {
1731 let observation = observations_by_id
1732 .remove(&effect_id)
1733 .expect("effect observation was prevalidated");
1734 let recorded = Self::mark_pending_effect_observed(
1735 &mut state.pending_tool_results,
1736 &effect_id,
1737 observation,
1738 );
1739 debug_assert!(recorded);
1740 }
1741 if let Err(error) = self.record_pending_tool_results(&mut state).await {
1742 self.kernel_state = Some(state);
1743 return Err(error);
1744 }
1745 self.process_kernel_pending_tools(state, context).await
1746 }
1747
1748 fn mark_pending_effect_observed(
1749 pending_tool_results: &mut VecDeque<PendingToolResult>,
1750 expected_effect_id: &str,
1751 observation: EffectObservation,
1752 ) -> bool {
1753 for result in pending_tool_results {
1754 let PendingToolResult::Effect {
1755 effect_id,
1756 observation: pending_observation,
1757 ..
1758 } = result
1759 else {
1760 continue;
1761 };
1762 if effect_id == expected_effect_id {
1763 *pending_observation = Some(observation);
1764 return true;
1765 }
1766 }
1767 false
1768 }
1769
1770 async fn record_pending_tool_results(
1771 &mut self,
1772 state: &mut ReActKernelState,
1773 ) -> Result<(), AgentError> {
1774 while let Some(outcome) =
1775 state
1776 .pending_tool_results
1777 .front()
1778 .and_then(|pending| match pending {
1779 PendingToolResult::Outcome(outcome) => Some(outcome.clone()),
1780 PendingToolResult::Effect {
1781 call,
1782 observation: Some(observation),
1783 ..
1784 } => Some(Self::effect_observation_outcome(
1785 call.clone(),
1786 observation.clone(),
1787 )),
1788 PendingToolResult::Effect {
1789 observation: None, ..
1790 } => None,
1791 })
1792 {
1793 record_tool_result(&mut self.memory, &outcome).await?;
1794 state.pending_tool_results.pop_front();
1795 }
1796 Ok(())
1797 }
1798
1799 fn effect_observation_outcome(
1800 call: ToolCall,
1801 observation: EffectObservation,
1802 ) -> ToolCallOutcome {
1803 ToolCallOutcome {
1804 call,
1805 content: observation.output.observation_for_model,
1806 memory_policy: observation.output.memory_policy,
1807 effect: None,
1808 }
1809 }
1810
1811 async fn process_kernel_pending_tools(
1812 &mut self,
1813 mut state: ReActKernelState,
1814 context: &RunContext,
1815 ) -> Result<AgentAction, AgentError> {
1816 let mut effects = Vec::new();
1817 let mut effect_ids = HashSet::new();
1818 while let Some(call) = state.pending_tools.pop_front() {
1819 let ctx = ToolRoundCtx {
1820 context,
1821 round: state.counters.rounds,
1822 registry: &self.registry,
1823 state: &self.state,
1824 events: &self.events,
1825 };
1826 let outcome = ctx.run(call).await;
1827 if let Some(effect) = outcome.effect_request().cloned() {
1828 if !effect_ids.insert(effect.id.clone()) {
1829 return Err(AgentError::InvalidStep(format!(
1830 "duplicate effect request id: {}",
1831 effect.id
1832 )));
1833 }
1834 state
1835 .pending_tool_results
1836 .push_back(PendingToolResult::Effect {
1837 effect_id: effect.id.clone(),
1838 call: outcome.call.clone(),
1839 observation: None,
1840 });
1841 effects.push(effect);
1842 continue;
1843 }
1844 state
1845 .pending_tool_results
1846 .push_back(PendingToolResult::Outcome(outcome));
1847 }
1848 if let Err(error) = self.record_pending_tool_results(&mut state).await {
1849 self.kernel_state = Some(state);
1850 return Err(error);
1851 }
1852 if !effects.is_empty() {
1853 self.kernel_state = Some(state);
1854 return if effects.len() == 1 {
1855 let request = effects
1856 .pop()
1857 .expect("single effect request must be present");
1858 Ok(AgentAction::RequestEffect { request })
1859 } else {
1860 Ok(AgentAction::RequestEffects { requests: effects })
1861 };
1862 }
1863
1864 check_run_context(context)?;
1865 state.counters.rounds += 1;
1866 let messages = self.assemble_messages(self.memory.context().await?);
1867 let action = state.next_model_request(messages);
1868 self.kernel_state = Some(state);
1869 Ok(action)
1870 }
1871}
1872
1873#[cfg(feature = "tracing")]
1885type TraceSpan = tracing::Span;
1886
1887#[cfg(not(feature = "tracing"))]
1888#[derive(Debug, Clone)]
1889struct TraceSpan;
1890
1891#[cfg(feature = "tracing")]
1892fn instrument<F>(future: F, span: TraceSpan) -> tracing::instrument::Instrumented<F>
1893where
1894 F: Future,
1895{
1896 future.instrument(span)
1897}
1898
1899#[cfg(not(feature = "tracing"))]
1900fn instrument<F>(future: F, _span: TraceSpan) -> F
1901where
1902 F: Future,
1903{
1904 future
1905}
1906
1907fn span_run(run_id: &str) -> TraceSpan {
1908 #[cfg(feature = "tracing")]
1909 {
1910 tracing::info_span!("agent.run", "run.id" = %run_id, error = tracing::field::Empty)
1911 }
1912 #[cfg(not(feature = "tracing"))]
1913 {
1914 let _ = run_id;
1915 TraceSpan
1916 }
1917}
1918
1919fn span_llm(run_id: &str, round: usize) -> TraceSpan {
1924 #[cfg(feature = "tracing")]
1925 {
1926 tracing::debug_span!(
1927 "llm_request",
1928 "run.id" = %run_id,
1929 round = round,
1930 usage.prompt_tokens = tracing::field::Empty,
1931 usage.completion_tokens = tracing::field::Empty,
1932 error = tracing::field::Empty,
1933 )
1934 }
1935 #[cfg(not(feature = "tracing"))]
1936 {
1937 let _ = (run_id, round);
1938 TraceSpan
1939 }
1940}
1941
1942fn span_tool(run_id: &str, round: usize, name: &str) -> TraceSpan {
1945 #[cfg(feature = "tracing")]
1946 {
1947 tracing::debug_span!(
1948 "tool",
1949 "run.id" = %run_id,
1950 round = round,
1951 name = %name,
1952 error = tracing::field::Empty,
1953 )
1954 }
1955 #[cfg(not(feature = "tracing"))]
1956 {
1957 let _ = (run_id, round, name);
1958 TraceSpan
1959 }
1960}
1961
1962fn publish_ended(
1971 events: &Option<Arc<dyn EventChannel>>,
1972 summary: RunSummary,
1973 error: Option<AgentError>,
1974) {
1975 if let Some(pipe) = events {
1976 pipe.publish(Arc::new(ReActEvent::RunEnded { summary, error }));
1977 }
1978}
1979
1980fn stream_end(
1991 events: &Option<Arc<dyn EventChannel>>,
1992 #[cfg_attr(not(feature = "tracing"), allow(unused_variables))] run_span: &TraceSpan,
1993 summary: RunSummary,
1994 error: AgentError,
1995) -> Result<MessageChunk, AgentError> {
1996 #[cfg(feature = "tracing")]
2001 if !matches!(error, AgentError::Cancelled) {
2002 run_span.record("error", error.to_string());
2003 }
2004 publish_ended(events, summary, Some(error.clone()));
2005 match error {
2006 AgentError::Cancelled => Ok(MessageChunk::Cancelled),
2007 e => Err(e),
2008 }
2009}
2010
2011fn run_summary(
2012 counters: &RunCounters,
2013 finish_reason: Option<FinishReason>,
2014 started_at: Instant,
2015 provider_model: Option<String>,
2016) -> RunSummary {
2017 run_summary_from_parts(
2018 counters.rounds,
2019 counters.tool_calls_total,
2020 counters.usage_total,
2021 counters.usage_omitted,
2022 finish_reason,
2023 started_at,
2024 provider_model,
2025 )
2026}
2027
2028fn run_summary_from_parts(
2029 rounds: usize,
2030 tool_calls: usize,
2031 usage: Usage,
2032 usage_omitted: bool,
2033 finish_reason: Option<FinishReason>,
2034 started_at: Instant,
2035 provider_model: Option<String>,
2036) -> RunSummary {
2037 RunSummary {
2038 rounds,
2039 tool_calls,
2040 usage,
2041 usage_omitted,
2042 finish_reason,
2043 latency: started_at.elapsed(),
2044 provider_model,
2045 }
2046}
2047
2048fn check_run_context(context: &RunContext) -> Result<(), AgentError> {
2049 if context.is_cancelled() {
2050 Err(AgentError::Cancelled)
2051 } else if context.is_expired() {
2052 Err(AgentError::DeadlineExceeded)
2053 } else {
2054 Ok(())
2055 }
2056}
2057
2058async fn run_until_context<F>(context: &RunContext, future: F) -> Result<F::Output, AgentError>
2059where
2060 F: Future,
2061{
2062 check_run_context(context)?;
2063 match context.remaining() {
2064 Some(remaining) if remaining.is_zero() => Err(AgentError::DeadlineExceeded),
2065 Some(remaining) => {
2066 tokio::select! {
2067 _ = context.cancellation.cancelled() => Err(AgentError::Cancelled),
2068 _ = tokio::time::sleep(remaining) => Err(AgentError::DeadlineExceeded),
2069 output = future => Ok(output),
2070 }
2071 }
2072 None => context
2073 .cancellation
2074 .run_until_cancelled(future)
2075 .await
2076 .ok_or(AgentError::Cancelled),
2077 }
2078}
2079
2080async fn record_tool_result(
2097 memory: &mut Box<dyn Memory>,
2098 outcome: &ToolCallOutcome,
2099) -> Result<(), AgentError> {
2100 let message = Message::tool_result(outcome.call.id.clone(), outcome.content.clone());
2101 let record_result = if outcome.memory_policy.is_protected() {
2102 memory.record_protected(message).await
2103 } else {
2104 memory.record(message).await
2105 };
2106 match record_result {
2107 Ok(()) => Ok(()),
2108 Err(e) => {
2109 let fallback = Message::tool_result(
2110 outcome.call.id.clone(),
2111 format!("memory record failed: {e}"),
2112 );
2113 let _ = if outcome.memory_policy.is_protected() {
2114 memory.record_protected(fallback).await
2115 } else {
2116 memory.record(fallback).await
2117 };
2118 Err(e.into())
2119 }
2120 }
2121}
2122
2123impl ReActAgent {
2124 async fn run_request_inner(
2125 &mut self,
2126 request: RunRequest,
2127 context: RunContext,
2128 schema: Option<&serde_json::Value>,
2129 ) -> Result<RunOutput, AgentError> {
2130 let run_id = context.run_id.clone();
2136 let run_span = span_run(&run_id);
2137 let provider_model = self.provider.model().map(str::to_string);
2138 let started_at = Instant::now();
2139 let run_future = async {
2140 let input = request.input;
2146 self.memory.record(input.clone().into_message()).await?;
2147 self.publish(|| {
2148 Arc::new(ReActEvent::RunStarted {
2149 run_id: run_id.clone(),
2150 input,
2151 })
2152 });
2153
2154 let mut counters = RunCounters::default();
2158 let mut options = request
2159 .options
2160 .clone()
2161 .unwrap_or_else(|| self.config.options.clone());
2162 if let Some(schema) = schema {
2163 options.structured = Some(schema.clone());
2164 }
2165 let validation_schema = options.structured.as_ref();
2166 let result: Result<FinalAnswer, AgentError> = self
2167 .run_rounds_with_context(
2168 &context,
2169 &run_id,
2170 &mut counters,
2171 &options,
2172 validation_schema,
2173 )
2174 .await;
2175 let output_result = result.map(|final_answer| {
2176 let summary = run_summary(
2177 &counters,
2178 final_answer.finish_reason.clone(),
2179 started_at,
2180 provider_model.clone(),
2181 );
2182 RunExecution {
2183 answer: final_answer.answer,
2184 final_message: final_answer.final_message,
2185 summary,
2186 artifacts: Vec::new(),
2187 metadata: RunMetadata::new(),
2188 }
2189 });
2190 let summary = match &output_result {
2191 Ok(execution) => execution.summary.clone(),
2192 Err(_) => run_summary(&counters, None, started_at, provider_model.clone()),
2193 };
2194 publish_ended(&self.events, summary, output_result.as_ref().err().cloned());
2199 output_result
2200 };
2201 let result = instrument(run_future, run_span.clone()).await;
2202 #[cfg(feature = "tracing")]
2203 if let Err(e) = &result {
2204 run_span.record("error", e.to_string());
2205 }
2206 result.map(|execution| RunOutput {
2207 run_id,
2208 answer: execution.answer,
2209 summary: execution.summary,
2210 final_message: execution.final_message,
2211 artifacts: execution.artifacts,
2212 metadata: execution.metadata,
2213 })
2214 }
2215
2216 async fn run_stream_request_inner<'a>(
2217 &'a mut self,
2218 request: RunRequest,
2219 context: RunContext,
2220 ) -> Result<BoxStream<'a, Result<MessageChunk, AgentError>>, AgentError> {
2221 let run_id = context.run_id.clone();
2234 let run_span = span_run(&run_id);
2235 let stream_span = run_span.clone();
2236 let input = request.input;
2237 self.memory.record(input.clone().into_message()).await?;
2238 self.publish(|| {
2239 Arc::new(ReActEvent::RunStarted {
2240 run_id: run_id.clone(),
2241 input,
2242 })
2243 });
2244 let schemas = self.registry.schemas();
2245 let max_rounds = self.config.max_tool_rounds;
2246 let provider_model = self.provider.model().map(str::to_string);
2247 let started_at = Instant::now();
2248 let options = request
2249 .options
2250 .clone()
2251 .unwrap_or_else(|| self.config.options.clone());
2252 let validation_schema = options.structured.clone();
2253
2254 let context = context.clone();
2258 let stream = async_stream::stream! {
2259 let mut rounds = 0usize;
2260 let mut tool_calls_total = 0usize;
2265 let mut usage_total = Usage::default();
2266 let mut usage_omitted = false;
2267 #[cfg(feature = "structured")]
2271 let mut validator = validation_schema.as_ref().map(|schema| {
2272 StructuredValidator::new(schema.clone(), self.config.max_structured_retries)
2273 });
2274 #[cfg(not(feature = "structured"))]
2275 let _ = &validation_schema;
2276 let mut tool_rounds = 0usize;
2281 'rounds: loop {
2285 if tool_rounds >= max_rounds {
2288 let summary = run_summary_from_parts(
2289 rounds,
2290 tool_calls_total,
2291 usage_total,
2292 usage_omitted,
2293 None,
2294 started_at,
2295 provider_model.clone(),
2296 );
2297 yield stream_end(&self.events, &run_span, summary,
2298 AgentError::TooManyToolRounds(max_rounds));
2299 break;
2300 }
2301
2302 rounds += 1;
2307
2308 if let Err(e) = check_run_context(&context) {
2312 usage_omitted = true;
2315 let summary = run_summary_from_parts(
2316 rounds,
2317 tool_calls_total,
2318 usage_total,
2319 usage_omitted,
2320 None,
2321 started_at,
2322 provider_model.clone(),
2323 );
2324 yield stream_end(&self.events, &run_span, summary, e);
2325 break;
2326 }
2327
2328 let messages = match self.memory.context().await {
2332 Ok(messages) => messages,
2333 Err(e) => {
2334 usage_omitted = true;
2337 let summary = run_summary_from_parts(
2338 rounds,
2339 tool_calls_total,
2340 usage_total,
2341 usage_omitted,
2342 None,
2343 started_at,
2344 provider_model.clone(),
2345 );
2346 yield stream_end(&self.events, &run_span, summary,
2347 AgentError::Memory(e));
2348 break;
2349 }
2350 };
2351 let llm_span = span_llm(&run_id, rounds);
2359 let model_request_id = format!("{run_id}-model-{rounds}");
2360 let provider_context =
2361 ProviderRequestContext::from_run_context(model_request_id, &context);
2362 let stream_chat = self.provider.stream_chat_with_context(
2363 ChatRequest {
2364 messages: self.assemble_messages(messages),
2365 tools: schemas.clone(),
2366 options: options.clone(),
2367 },
2368 &provider_context,
2369 );
2370 let mut provider_stream = match run_until_context(
2371 &context,
2372 instrument(stream_chat, llm_span.clone()),
2373 )
2374 .await
2375 {
2376 Ok(Ok(stream)) => stream,
2377 Ok(Err(e)) => {
2378 #[cfg(feature = "tracing")]
2381 llm_span.record("error", e.to_string());
2382 usage_omitted = true;
2385 let summary = run_summary_from_parts(
2386 rounds,
2387 tool_calls_total,
2388 usage_total,
2389 usage_omitted,
2390 None,
2391 started_at,
2392 provider_model.clone(),
2393 );
2394 yield stream_end(&self.events, &run_span, summary,
2395 AgentError::Provider(e));
2396 break;
2397 }
2398 Err(e) => {
2399 usage_omitted = true;
2402 let summary = run_summary_from_parts(
2403 rounds,
2404 tool_calls_total,
2405 usage_total,
2406 usage_omitted,
2407 None,
2408 started_at,
2409 provider_model.clone(),
2410 );
2411 yield stream_end(&self.events, &run_span, summary, e);
2412 break;
2413 }
2414 };
2415
2416 let mut text = String::new();
2428 let mut reasoning = String::new();
2429 let mut calls = Vec::new();
2430 let mut round_finish_reason = None::<FinishReason>;
2431 let mut round_usage_reported = false;
2435 loop {
2436 let next = run_until_context(
2442 &context,
2443 instrument(provider_stream.next(), llm_span.clone()),
2444 )
2445 .await;
2446 let Some(event) = (match next {
2447 Ok(event) => event,
2448 Err(e) => {
2449 usage_omitted = true;
2452 let summary = run_summary_from_parts(
2453 rounds,
2454 tool_calls_total,
2455 usage_total,
2456 usage_omitted,
2457 None,
2458 started_at,
2459 provider_model.clone(),
2460 );
2461 yield stream_end(&self.events, &run_span, summary, e);
2462 break 'rounds;
2463 }
2464 }) else {
2465 break;
2468 };
2469 match event {
2470 Ok(StreamEvent::Delta(delta)) => {
2471 if text.len() + delta.len() > MAX_ROUND_TEXT {
2476 usage_omitted = true;
2480 let summary = run_summary_from_parts(
2481 rounds,
2482 tool_calls_total,
2483 usage_total,
2484 usage_omitted,
2485 None,
2486 started_at,
2487 provider_model.clone(),
2488 );
2489 yield stream_end(&self.events, &run_span, summary,
2490 AgentError::Provider(ProviderError::ResponseTooLarge {
2491 limit_bytes: MAX_ROUND_TEXT,
2492 }));
2493 break 'rounds;
2494 }
2495 text.push_str(&delta);
2496 self.publish(|| Arc::new(ReActEvent::Delta { text: delta.clone() }));
2497 yield Ok(MessageChunk::Delta(delta));
2498 }
2499 Ok(StreamEvent::Reasoning(chunk)) => {
2500 if reasoning.len() + chunk.len() > MAX_ROUND_TEXT {
2503 usage_omitted = true;
2507 let summary = run_summary_from_parts(
2508 rounds,
2509 tool_calls_total,
2510 usage_total,
2511 usage_omitted,
2512 None,
2513 started_at,
2514 provider_model.clone(),
2515 );
2516 yield stream_end(&self.events, &run_span, summary,
2517 AgentError::Provider(ProviderError::ResponseTooLarge {
2518 limit_bytes: MAX_ROUND_TEXT,
2519 }));
2520 break 'rounds;
2521 }
2522 reasoning.push_str(&chunk);
2523 self.publish(move || Arc::new(ReActEvent::Reasoning { text: chunk }));
2524 }
2525 Ok(StreamEvent::ToolCall { id, name, arguments }) => {
2526 calls.push(ToolCall {
2527 id: id.clone(),
2528 name: name.clone(),
2529 arguments: arguments.clone(),
2530 });
2531 yield Ok(MessageChunk::ToolCall { id, name, arguments });
2532 }
2533 Ok(StreamEvent::Done { reason, usage }) => {
2534 if let Some(usage) = usage {
2542 #[cfg(feature = "tracing")]
2543 {
2544 llm_span.record("usage.prompt_tokens", usage.prompt_tokens);
2545 llm_span.record("usage.completion_tokens", usage.completion_tokens);
2546 }
2547 usage_total += usage;
2548 round_usage_reported = true;
2549 } else {
2550 usage_omitted = true;
2551 }
2552 round_finish_reason = Some(reason);
2553 break;
2560 }
2561 Err(e) => {
2562 #[cfg(feature = "tracing")]
2567 llm_span.record("error", e.to_string());
2568 usage_omitted = true;
2571 let summary = run_summary_from_parts(
2572 rounds,
2573 tool_calls_total,
2574 usage_total,
2575 usage_omitted,
2576 None,
2577 started_at,
2578 provider_model.clone(),
2579 );
2580 yield stream_end(&self.events, &run_span, summary,
2581 AgentError::Provider(e));
2582 break 'rounds;
2583 }
2584 Ok(_) => {}
2585 }
2586 }
2587
2588 usage_omitted |= !round_usage_reported;
2592
2593 if !text.is_empty() || !reasoning.is_empty() || !calls.is_empty() {
2596 let message = Message::Assistant {
2597 content: text.clone(),
2598 reasoning: (!reasoning.is_empty()).then_some(reasoning),
2599 tool_calls: calls.clone(),
2600 };
2601 match self.memory.record(message).await {
2602 Ok(()) => {}
2603 Err(e) => {
2604 let summary = run_summary_from_parts(
2605 rounds,
2606 tool_calls_total,
2607 usage_total,
2608 usage_omitted,
2609 None,
2610 started_at,
2611 provider_model.clone(),
2612 );
2613 yield stream_end(&self.events, &run_span, summary,
2614 AgentError::Memory(e));
2615 break;
2616 }
2617 }
2618 }
2619
2620 if calls.is_empty() {
2621 #[cfg(feature = "structured")]
2629 {
2630 if let Some(validator) = &mut validator {
2631 match validator.validate(&text) {
2632 StructuredOutcome::Passed => {}
2633 StructuredOutcome::Retry { message } => {
2634 match self.memory.record(message).await {
2637 Ok(()) => continue 'rounds,
2638 Err(e) => {
2639 let summary = run_summary_from_parts(
2640 rounds,
2641 tool_calls_total,
2642 usage_total,
2643 usage_omitted,
2644 None,
2645 started_at,
2646 provider_model.clone(),
2647 );
2648 yield stream_end(&self.events, &run_span, summary,
2649 AgentError::Memory(e));
2650 break;
2651 }
2652 }
2653 }
2654 StructuredOutcome::Exhausted { max_retries } => {
2655 let summary = run_summary_from_parts(
2656 rounds,
2657 tool_calls_total,
2658 usage_total,
2659 usage_omitted,
2660 None,
2661 started_at,
2662 provider_model.clone(),
2663 );
2664 yield stream_end(&self.events, &run_span, summary,
2665 AgentError::StructuredRetriesExhausted(max_retries));
2666 break 'rounds;
2667 }
2668 }
2669 }
2670 }
2671 let summary = run_summary_from_parts(
2674 rounds,
2675 tool_calls_total,
2676 usage_total,
2677 usage_omitted,
2678 round_finish_reason,
2679 started_at,
2680 provider_model.clone(),
2681 );
2682 publish_ended(&self.events, summary.clone(), None);
2683 yield Ok(MessageChunk::Done(summary));
2684 break;
2685 }
2686
2687 tool_calls_total += calls.len();
2692 tool_rounds += 1;
2693 let ctx = ToolRoundCtx {
2694 context: &context,
2695 round: rounds,
2696 registry: &self.registry,
2697 state: &self.state,
2698 events: &self.events,
2699 };
2700 let mut outcomes = self.executor.execute_round(ctx, calls).await;
2701 while let Some(outcome) = outcomes.next().await {
2702 if let Some(effect) = outcome.effect_request() {
2703 let summary = run_summary_from_parts(
2704 rounds,
2705 tool_calls_total,
2706 usage_total,
2707 usage_omitted,
2708 None,
2709 started_at,
2710 provider_model.clone(),
2711 );
2712 yield stream_end(
2713 &self.events,
2714 &run_span,
2715 summary,
2716 AgentError::EffectRequiresHarness(format!(
2717 "{} ({})",
2718 effect.description, effect.id
2719 )),
2720 );
2721 break 'rounds;
2722 }
2723 yield Ok(MessageChunk::ToolResult {
2724 id: outcome.call.id.clone(),
2725 name: outcome.call.name.clone(),
2726 content: outcome.content.clone(),
2727 });
2728 if let Err(e) = record_tool_result(&mut self.memory, &outcome).await {
2733 let summary = run_summary_from_parts(
2734 rounds,
2735 tool_calls_total,
2736 usage_total,
2737 usage_omitted,
2738 None,
2739 started_at,
2740 provider_model.clone(),
2741 );
2742 yield stream_end(&self.events, &run_span, summary, e);
2743 break 'rounds;
2749 }
2750 }
2751 }
2752 };
2753 Ok(Box::pin(SpanStream {
2754 stream: Box::pin(stream),
2755 span: stream_span,
2756 }))
2757 }
2758}
2759
2760struct SpanStream<S> {
2773 stream: Pin<Box<S>>,
2774 #[cfg_attr(not(feature = "tracing"), allow(dead_code))]
2775 span: TraceSpan,
2776}
2777
2778impl<S: futures::Stream> futures::Stream for SpanStream<S> {
2779 type Item = S::Item;
2780
2781 fn poll_next(
2782 mut self: Pin<&mut Self>,
2783 cx: &mut std::task::Context<'_>,
2784 ) -> std::task::Poll<Option<Self::Item>> {
2785 #[cfg(feature = "tracing")]
2789 let span = self.span.clone();
2790 #[cfg(feature = "tracing")]
2791 let _enter = span.enter();
2792 self.stream.as_mut().poll_next(cx)
2796 }
2797}
2798
2799#[cfg(test)]
2800mod tests {
2801 use super::*;
2802 use crate::CancellationToken;
2803 use crate::effect::{EffectKind, EffectObservation, EffectRequest};
2804 use crate::memory::MemoryError;
2805 use crate::message::ContentBlock;
2806 use crate::provider::{
2807 ChatResponse, FakeProvider, FakeReply, FinishReason, ModelOptions, ProviderError,
2808 StreamEvent, TimeoutStage,
2809 };
2810 use crate::tool::{Tool, ToolContext, ToolError, ToolOutput, ToolResult, ToolSchema};
2811 use futures::StreamExt;
2812 #[cfg(feature = "structured")]
2813 use serde::Deserialize;
2814 use std::sync::Arc;
2815 use std::sync::atomic::{AtomicUsize, Ordering};
2816 use std::time::Duration;
2817
2818 #[derive(Clone)]
2823 struct SharedFake(Arc<FakeProvider>);
2824
2825 impl SharedFake {
2826 fn new(replies: impl IntoIterator<Item = FakeReply>) -> Self {
2827 Self(Arc::new(FakeProvider::new(replies)))
2828 }
2829
2830 fn requests(&self) -> Vec<ChatRequest> {
2831 self.0.requests()
2832 }
2833 }
2834
2835 #[async_trait::async_trait]
2836 impl Provider for SharedFake {
2837 fn model(&self) -> Option<&str> {
2838 self.0.model()
2839 }
2840
2841 async fn chat(&self, request: ChatRequest) -> Result<ChatResponse, ProviderError> {
2842 self.0.chat(request).await
2843 }
2844
2845 async fn chat_with_context(
2846 &self,
2847 request: ChatRequest,
2848 context: &ProviderRequestContext,
2849 ) -> Result<ChatResponse, ProviderError> {
2850 self.0.chat_with_context(request, context).await
2851 }
2852
2853 async fn stream_chat(
2854 &self,
2855 request: ChatRequest,
2856 ) -> Result<BoxStream<'static, Result<StreamEvent, ProviderError>>, ProviderError> {
2857 self.0.stream_chat(request).await
2858 }
2859
2860 async fn stream_chat_with_context(
2861 &self,
2862 request: ChatRequest,
2863 context: &ProviderRequestContext,
2864 ) -> Result<BoxStream<'static, Result<StreamEvent, ProviderError>>, ProviderError> {
2865 self.0.stream_chat_with_context(request, context).await
2866 }
2867 }
2868
2869 #[derive(Debug, Clone)]
2871 struct FakeTool {
2872 name: &'static str,
2873 result: &'static str,
2874 calls: Arc<AtomicUsize>,
2875 }
2876
2877 impl FakeTool {
2878 fn new(name: &'static str, result: &'static str) -> (Self, Arc<AtomicUsize>) {
2879 let calls = Arc::new(AtomicUsize::new(0));
2880 (
2881 Self {
2882 name,
2883 result,
2884 calls: calls.clone(),
2885 },
2886 calls,
2887 )
2888 }
2889 }
2890
2891 #[async_trait::async_trait]
2892 impl Tool for FakeTool {
2893 fn schema(&self) -> ToolSchema {
2894 ToolSchema::new(self.name, "Test tool", serde_json::json!({}))
2895 }
2896
2897 async fn call(
2898 &self,
2899 _arguments: serde_json::Value,
2900 _context: ToolContext<'_>,
2901 ) -> Result<ToolResult, ToolError> {
2902 self.calls.fetch_add(1, Ordering::Relaxed);
2903 Ok(ToolOutput::text(self.result).into())
2904 }
2905 }
2906
2907 #[derive(Debug, Clone)]
2908 struct EffectTool {
2909 name: &'static str,
2910 effect_id: &'static str,
2911 description: &'static str,
2912 }
2913
2914 impl EffectTool {
2915 fn new(name: &'static str, effect_id: &'static str, description: &'static str) -> Self {
2916 Self {
2917 name,
2918 effect_id,
2919 description,
2920 }
2921 }
2922 }
2923
2924 #[async_trait::async_trait]
2925 impl Tool for EffectTool {
2926 fn schema(&self) -> ToolSchema {
2927 ToolSchema::new(self.name, "Effect tool", serde_json::json!({}))
2928 }
2929
2930 async fn call(
2931 &self,
2932 _arguments: serde_json::Value,
2933 _context: ToolContext<'_>,
2934 ) -> Result<ToolResult, ToolError> {
2935 Ok(ToolResult::Effect(
2936 EffectRequest::new(
2937 EffectKind::Custom("test.effect".into()),
2938 self.description,
2939 serde_json::json!({}),
2940 )
2941 .with_id(self.effect_id),
2942 ))
2943 }
2944 }
2945
2946 fn call(id: &str, name: &str, arguments: &str) -> ToolCall {
2947 ToolCall {
2948 id: id.into(),
2949 name: name.into(),
2950 arguments: arguments.into(),
2951 }
2952 }
2953
2954 fn done_summary(chunk: &MessageChunk) -> Option<&RunSummary> {
2955 match chunk {
2956 MessageChunk::Done(summary) => Some(summary),
2957 _ => None,
2958 }
2959 }
2960
2961 fn assert_done_summary(
2962 chunk: &MessageChunk,
2963 rounds: usize,
2964 tool_calls: usize,
2965 usage: Usage,
2966 usage_omitted: bool,
2967 ) {
2968 assert_done_summary_with_finish(
2969 chunk,
2970 rounds,
2971 tool_calls,
2972 usage,
2973 usage_omitted,
2974 Some(FinishReason::Stop),
2975 );
2976 }
2977
2978 fn assert_done_summary_with_finish(
2979 chunk: &MessageChunk,
2980 rounds: usize,
2981 tool_calls: usize,
2982 usage: Usage,
2983 usage_omitted: bool,
2984 finish_reason: Option<FinishReason>,
2985 ) {
2986 let summary = done_summary(chunk).expect("expected Done chunk");
2987 assert_eq!(summary.rounds, rounds);
2988 assert_eq!(summary.tool_calls, tool_calls);
2989 assert_eq!(summary.usage, usage);
2990 assert_eq!(summary.usage_omitted, usage_omitted);
2991 assert_eq!(summary.finish_reason, finish_reason);
2992 assert_eq!(summary.provider_model, None);
2993 }
2994
2995 fn agent(fake: SharedFake, system_prompt: &str) -> ReActAgent {
2998 ReActAgent::new(fake, ToolRegistry::new(), system_prompt)
2999 }
3000
3001 fn agent_with_registry(
3003 fake: SharedFake,
3004 registry: ToolRegistry,
3005 config: AgentConfig,
3006 ) -> ReActAgent {
3007 ReActAgent::new(fake, registry, "").with_config(config)
3008 }
3009
3010 fn cancellation_context(token: &CancellationToken) -> RunContext {
3011 RunContext::generated().with_cancellation(token.clone())
3012 }
3013
3014 #[tokio::test]
3015 async fn builder_assembles_agent_components() {
3016 let fake = SharedFake::new([FakeReply::Text("built".into())]);
3017 let (tool, _calls) = FakeTool::new("builder_tool", "unused");
3018 let mut agent = ReActAgent::builder(fake.clone())
3019 .with_tool(tool)
3020 .with_system_prompt("Builder system")
3021 .with_config(AgentConfig {
3022 options: ModelOptions {
3023 temperature: Some(0.4),
3024 ..Default::default()
3025 },
3026 ..Default::default()
3027 })
3028 .build();
3029
3030 assert_eq!(agent.run("hi").await.unwrap(), "built");
3031 let request = &fake.requests()[0];
3032 assert_eq!(request.messages[0], Message::system("Builder system"));
3033 assert_eq!(request.tools.len(), 1);
3034 assert_eq!(request.tools[0].name, "builder_tool");
3035 assert_eq!(request.options.temperature, Some(0.4));
3036 }
3037
3038 #[tokio::test]
3039 async fn direct_answer() {
3040 let fake = SharedFake::new([FakeReply::Text("Hello".into())]);
3041 let mut agent = agent(fake.clone(), "");
3042
3043 let answer = agent.run("Are you there").await.unwrap();
3044 assert_eq!(answer, "Hello");
3045
3046 let requests = fake.requests();
3047 assert_eq!(requests.len(), 1);
3048 assert_eq!(requests[0].messages.len(), 1);
3049 assert_eq!(requests[0].messages[0], Message::user("Are you there"));
3050 }
3051
3052 #[tokio::test]
3053 async fn run_request_returns_structured_output() {
3054 let fake = SharedFake::new([FakeReply::text_with_usage("Hello", Usage::new(5, 2))]);
3055 let mut agent = agent(fake.clone(), "");
3056 let output = agent
3057 .run_request_with_context(RunRequest::text("Are you there"), RunContext::new("r1"))
3058 .await
3059 .unwrap();
3060
3061 assert_eq!(output.run_id, "r1");
3062 assert_eq!(output.answer, "Hello");
3063 assert_eq!(output.final_message, Message::assistant("Hello"));
3064 assert!(output.artifacts.is_empty());
3065 assert!(output.metadata.is_empty());
3066 assert_eq!(output.summary.rounds, 1);
3067 assert_eq!(output.summary.tool_calls, 0);
3068 assert_eq!(output.summary.usage, Usage::new(5, 2));
3069 assert_eq!(output.summary.finish_reason, Some(FinishReason::Stop));
3070 assert_eq!(output.summary.provider_model, None);
3071 }
3072
3073 #[tokio::test]
3074 async fn run_request_blocks_are_recorded_as_user_blocks() {
3075 let blocks = vec![ContentBlock::Text("What is this?".into())];
3076 let fake = SharedFake::new([FakeReply::Text("A block".into())]);
3077 let mut agent = agent(fake.clone(), "");
3078
3079 agent
3080 .run_request(RunRequest::blocks(blocks.clone()))
3081 .await
3082 .unwrap();
3083
3084 let requests = fake.requests();
3085 assert_eq!(requests[0].messages[0], Message::user_blocks(blocks));
3086 }
3087
3088 #[tokio::test]
3089 async fn run_summary_carries_provider_model_when_available() {
3090 #[derive(Clone)]
3091 struct NamedProvider(SharedFake);
3092
3093 #[async_trait::async_trait]
3094 impl Provider for NamedProvider {
3095 fn model(&self) -> Option<&str> {
3096 Some("named-model")
3097 }
3098
3099 async fn chat(&self, request: ChatRequest) -> Result<ChatResponse, ProviderError> {
3100 self.0.chat(request).await
3101 }
3102
3103 async fn chat_with_context(
3104 &self,
3105 request: ChatRequest,
3106 context: &ProviderRequestContext,
3107 ) -> Result<ChatResponse, ProviderError> {
3108 self.0.chat_with_context(request, context).await
3109 }
3110
3111 async fn stream_chat(
3112 &self,
3113 request: ChatRequest,
3114 ) -> Result<BoxStream<'static, Result<StreamEvent, ProviderError>>, ProviderError>
3115 {
3116 self.0.stream_chat(request).await
3117 }
3118
3119 async fn stream_chat_with_context(
3120 &self,
3121 request: ChatRequest,
3122 context: &ProviderRequestContext,
3123 ) -> Result<BoxStream<'static, Result<StreamEvent, ProviderError>>, ProviderError>
3124 {
3125 self.0.stream_chat_with_context(request, context).await
3126 }
3127 }
3128
3129 let mut agent = ReActAgent::new(
3130 NamedProvider(SharedFake::new([FakeReply::Text("hi".into())])),
3131 ToolRegistry::new(),
3132 "",
3133 );
3134 let output = agent.run_request(RunRequest::text("hi")).await.unwrap();
3135 assert_eq!(output.summary.provider_model, Some("named-model".into()));
3136 }
3137
3138 #[tokio::test]
3139 async fn single_tool_round() {
3140 let (calc, calls) = FakeTool::new("calc", "42");
3141 let mut registry = ToolRegistry::new();
3142 registry.register(calc);
3143 let fake = SharedFake::new([
3144 FakeReply::ToolCalls {
3145 content: "".into(),
3146 calls: vec![call("c1", "calc", r#"{"a":1}"#)],
3147 },
3148 FakeReply::Text("The answer is 42".into()),
3149 ]);
3150 let mut agent = agent_with_registry(fake.clone(), registry, AgentConfig::default());
3151
3152 let answer = agent.run("Compute 1+1").await.unwrap();
3153 assert_eq!(answer, "The answer is 42");
3154 assert_eq!(calls.load(Ordering::Relaxed), 1);
3155
3156 let requests = fake.requests();
3159 assert_eq!(requests.len(), 2);
3160 assert!(requests[1].messages.iter().any(|m| matches!(
3161 m,
3162 Message::ToolResult { id, content } if id == "c1" && content == "42"
3163 )));
3164 }
3165
3166 #[tokio::test]
3167 async fn multiple_tools_same_round() {
3168 let (t1, calls1) = FakeTool::new("t1", "one");
3169 let (t2, calls2) = FakeTool::new("t2", "two");
3170 let mut registry = ToolRegistry::new();
3171 registry.register(t1).register(t2);
3172 let fake = SharedFake::new([
3173 FakeReply::ToolCalls {
3174 content: "".into(),
3175 calls: vec![call("c1", "t1", "{}"), call("c2", "t2", "{}")],
3176 },
3177 FakeReply::Text("done".into()),
3178 ]);
3179 let mut agent = agent_with_registry(fake.clone(), registry, AgentConfig::default());
3180
3181 let answer = agent.run("Run them all").await.unwrap();
3182 assert_eq!(answer, "done");
3183 assert_eq!(calls1.load(Ordering::Relaxed), 1);
3184 assert_eq!(calls2.load(Ordering::Relaxed), 1);
3185
3186 let requests = fake.requests();
3189 let assistant = requests[1]
3190 .messages
3191 .iter()
3192 .find_map(|m| match m {
3193 Message::Assistant { tool_calls, .. } => Some(tool_calls),
3194 _ => None,
3195 })
3196 .expect("second round should contain Assistant");
3197 assert_eq!(assistant.len(), 2);
3198
3199 let results: Vec<&str> = requests[1]
3200 .messages
3201 .iter()
3202 .filter_map(|m| match m {
3203 Message::ToolResult { content, .. } => Some(content.as_str()),
3204 _ => None,
3205 })
3206 .collect();
3207 assert_eq!(results, vec!["one", "two"]);
3208 }
3209
3210 #[tokio::test]
3213 async fn empty_assistant_not_recorded() {
3214 let fake = SharedFake::new([FakeReply::Text("".into())]);
3215 let mut agent = agent(fake.clone(), "");
3216
3217 let answer = agent.run("hi").await.unwrap();
3218 assert_eq!(answer, "");
3219
3220 let requests = fake.requests();
3221 assert_eq!(requests.len(), 1);
3222 assert_eq!(requests[0].messages.len(), 1); }
3224
3225 #[tokio::test]
3229 async fn default_memory_is_bounded_window() {
3230 let fake = SharedFake::new([FakeReply::Text("hi".into())]);
3231 let mut agent = agent(fake, "");
3232
3233 agent
3237 .memory
3238 .record(Message::user("x".repeat(600_000)))
3239 .await
3240 .unwrap();
3241 agent
3242 .memory
3243 .record(Message::assistant("first-round reply"))
3244 .await
3245 .unwrap();
3246 agent
3247 .memory
3248 .record(Message::user("second round"))
3249 .await
3250 .unwrap();
3251 agent
3252 .memory
3253 .record(Message::assistant("second-round reply"))
3254 .await
3255 .unwrap();
3256
3257 let ctx = agent.memory.context().await.unwrap();
3258 assert_eq!(
3259 ctx,
3260 vec![
3261 Message::user("second round"),
3262 Message::assistant("second-round reply")
3263 ]
3264 );
3265 }
3266
3267 #[tokio::test]
3270 async fn too_many_tool_rounds() {
3271 let (calc, _calls) = FakeTool::new("calc", "42");
3272 let mut registry = ToolRegistry::new();
3273 registry.register(calc);
3274 let fake = SharedFake::new([
3275 FakeReply::ToolCalls {
3276 content: "".into(),
3277 calls: vec![call("c1", "calc", "{}")],
3278 },
3279 FakeReply::ToolCalls {
3280 content: "".into(),
3281 calls: vec![call("c2", "calc", "{}")],
3282 },
3283 ]);
3284 let mut agent = agent_with_registry(
3285 fake.clone(),
3286 registry,
3287 AgentConfig {
3288 max_tool_rounds: 2,
3289 ..Default::default()
3290 },
3291 );
3292
3293 let err = agent.run("Keep computing").await.unwrap_err();
3294 assert!(matches!(err, AgentError::TooManyToolRounds(2)));
3295 assert_eq!(fake.requests().len(), 2); }
3297
3298 #[tokio::test]
3301 async fn system_prompt_assembled_every_request() {
3302 let fake = SharedFake::new([
3303 FakeReply::Text("Hello".into()),
3304 FakeReply::Text("Goodbye".into()),
3305 ]);
3306 let mut agent = agent(fake.clone(), "You are an assistant");
3307
3308 agent.run("Are you there").await.unwrap();
3309 agent.run("Any more?").await.unwrap();
3310
3311 let requests = fake.requests();
3312 assert_eq!(requests.len(), 2);
3313 for request in &requests {
3316 let systems = request
3317 .messages
3318 .iter()
3319 .filter(|m| matches!(m, Message::System(_)))
3320 .count();
3321 assert_eq!(systems, 1);
3322 assert_eq!(request.messages[0], Message::system("You are an assistant"));
3323 }
3324 assert_eq!(requests[1].messages.len(), 4); assert_eq!(requests[1].messages[3], Message::user("Any more?"));
3326 }
3327
3328 #[tokio::test]
3331 async fn macro_arms_without_system_prompt() {
3332 let fake = SharedFake::new([FakeReply::Text("hi".into())]);
3334 let mut agent = crate::react_agent!(fake.clone());
3335 assert_eq!(agent.run("hi").await.unwrap(), "hi");
3336 assert_eq!(fake.requests()[0].messages.len(), 1); let (t1, calls1) = FakeTool::new("t1", "one");
3340 let fake = SharedFake::new([
3341 FakeReply::ToolCalls {
3342 content: "".into(),
3343 calls: vec![call("c1", "t1", "{}")],
3344 },
3345 FakeReply::Text("done".into()),
3346 ]);
3347 let mut agent = crate::react_agent!(fake.clone(), [t1]);
3348 assert_eq!(agent.run("x").await.unwrap(), "done");
3349 assert_eq!(calls1.load(Ordering::Relaxed), 1);
3350 assert_eq!(fake.requests()[1].messages.len(), 3); let (t2, _calls2) = FakeTool::new("t2", "two");
3354 let mut registry = ToolRegistry::new();
3355 registry.register(t2);
3356 let fake = SharedFake::new([FakeReply::Text("hi".into())]);
3357 let mut agent = crate::react_agent!(fake.clone(), registry);
3358 assert_eq!(agent.run("hi").await.unwrap(), "hi");
3359 assert_eq!(fake.requests()[0].messages.len(), 1); }
3361
3362 #[tokio::test]
3365 async fn macro_three_arms() {
3366 let fake = SharedFake::new([FakeReply::Text("hi".into())]);
3368 let mut agent = crate::react_agent!(fake.clone(), "");
3369 assert_eq!(agent.run("hi").await.unwrap(), "hi");
3370 assert_eq!(fake.requests()[0].messages.len(), 1);
3371
3372 let (t1, calls1) = FakeTool::new("t1", "one");
3375 let (t2, calls2) = FakeTool::new("t2", "two");
3376 let fake = SharedFake::new([
3377 FakeReply::ToolCalls {
3378 content: "".into(),
3379 calls: vec![call("c1", "t1", "{}")],
3380 },
3381 FakeReply::Text("done".into()),
3382 ]);
3383 let mut agent = crate::react_agent!(fake.clone(), [t1, t2], "");
3384 assert_eq!(agent.run("x").await.unwrap(), "done");
3385 assert_eq!(calls1.load(Ordering::Relaxed), 1);
3386 assert_eq!(calls2.load(Ordering::Relaxed), 0);
3387
3388 let (t3, calls3) = FakeTool::new("t3", "three");
3390 let mut registry = ToolRegistry::new();
3391 registry.register(t3);
3392 let fake = SharedFake::new([
3393 FakeReply::ToolCalls {
3394 content: "".into(),
3395 calls: vec![call("c1", "t3", "{}")],
3396 },
3397 FakeReply::Text("done".into()),
3398 ]);
3399 let mut agent = crate::react_agent!(fake, registry, "");
3400 assert_eq!(agent.run("x").await.unwrap(), "done");
3401 assert_eq!(calls3.load(Ordering::Relaxed), 1);
3402 }
3403
3404 #[test]
3407 fn macro_all_arms_compile() {
3408 struct Echo;
3409 #[async_trait::async_trait]
3410 impl Tool for Echo {
3411 fn schema(&self) -> ToolSchema {
3412 ToolSchema::new("echo", "Echo", serde_json::json!({}))
3413 }
3414 async fn call(
3415 &self,
3416 _arguments: serde_json::Value,
3417 _context: ToolContext<'_>,
3418 ) -> Result<ToolResult, ToolError> {
3419 Ok(ToolOutput::text("echo").into())
3420 }
3421 }
3422
3423 fn fake() -> SharedFake {
3424 SharedFake::new([FakeReply::Text("hi".into())])
3425 }
3426
3427 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();
3432 registry.register(Echo);
3433 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);
3436 }
3437
3438 #[tokio::test]
3440 async fn empty_system_prompt_skips_system_message() {
3441 let fake = SharedFake::new([FakeReply::Text("hi".into())]);
3442 let mut agent = agent(fake.clone(), "");
3443
3444 agent.run("hi").await.unwrap();
3445
3446 let requests = fake.requests();
3447 assert_eq!(requests[0].messages.len(), 1);
3448 assert_eq!(requests[0].messages[0], Message::user("hi"));
3449 }
3450
3451 #[tokio::test]
3454 async fn tool_failure_returns_text_and_continues() {
3455 struct FailingTool;
3457 #[async_trait::async_trait]
3458 impl Tool for FailingTool {
3459 fn schema(&self) -> ToolSchema {
3460 ToolSchema::new("boom", "Tool that always fails", serde_json::json!({}))
3461 }
3462 async fn call(
3463 &self,
3464 _arguments: serde_json::Value,
3465 _context: ToolContext<'_>,
3466 ) -> Result<ToolResult, ToolError> {
3467 Err(ToolError::Execution("internal error".into()))
3468 }
3469 }
3470
3471 let mut registry = ToolRegistry::new();
3472 registry.register(FailingTool);
3473 let fake = SharedFake::new([
3474 FakeReply::ToolCalls {
3475 content: "".into(),
3476 calls: vec![call("c1", "boom", "{}")],
3477 },
3478 FakeReply::Text("Got it".into()),
3479 ]);
3480 let mut agent = agent_with_registry(fake.clone(), registry, AgentConfig::default());
3481
3482 let answer = agent.run("Trigger failure").await.unwrap();
3483 assert_eq!(answer, "Got it");
3484
3485 let requests = fake.requests();
3488 assert!(requests[1].messages.iter().any(|m| matches!(
3489 m,
3490 Message::ToolResult { content, .. } if content.contains("internal error")
3491 )));
3492 }
3493
3494 #[tokio::test]
3499 async fn stream_too_many_tool_rounds_terminates() {
3500 let (calc, _calls) = FakeTool::new("calc", "42");
3501 let mut registry = ToolRegistry::new();
3502 registry.register(calc);
3503 let fake = SharedFake::new([
3504 FakeReply::ToolCalls {
3505 content: "".into(),
3506 calls: vec![call("c1", "calc", "{}")],
3507 },
3508 FakeReply::ToolCalls {
3509 content: "".into(),
3510 calls: vec![call("c2", "calc", "{}")],
3511 },
3512 ]);
3513 let mut agent = agent_with_registry(
3514 fake.clone(),
3515 registry,
3516 AgentConfig {
3517 max_tool_rounds: 2,
3518 ..Default::default()
3519 },
3520 );
3521
3522 let mut stream = agent.run_stream("Keep computing").await.unwrap();
3523 let chunks: Vec<Result<MessageChunk, AgentError>> = stream.by_ref().collect().await;
3524 assert_eq!(chunks.len(), 5);
3529 assert!(matches!(
3530 chunks.last(),
3531 Some(Err(AgentError::TooManyToolRounds(2)))
3532 ));
3533 assert_eq!(fake.requests().len(), 2);
3534 }
3535
3536 #[tokio::test]
3539 async fn stream_entry_record_user_failure_returns_error() {
3540 struct FailingUserMemory;
3541 #[async_trait::async_trait]
3542 impl Memory for FailingUserMemory {
3543 async fn record(&mut self, _message: Message) -> Result<(), MemoryError> {
3544 Err(MemoryError::Storage("disk full".into()))
3545 }
3546 async fn context(&self) -> Result<Vec<Message>, MemoryError> {
3547 Ok(Vec::new())
3548 }
3549 }
3550
3551 let fake = SharedFake::new([FakeReply::Text("hi".into())]);
3552 let mut agent = agent(fake.clone(), "").with_memory(FailingUserMemory);
3553 let err = match agent.run_stream("hi").await {
3554 Err(e) => e,
3555 Ok(_) => panic!("expected input recording failure to return Err directly"),
3556 };
3557 assert!(matches!(err, AgentError::Memory(MemoryError::Storage(_))));
3558 }
3559
3560 #[tokio::test]
3563 async fn stream_context_failure_terminates_with_err() {
3564 struct FailingContextMemory;
3565 #[async_trait::async_trait]
3566 impl Memory for FailingContextMemory {
3567 async fn record(&mut self, _message: Message) -> Result<(), MemoryError> {
3568 Ok(())
3569 }
3570 async fn context(&self) -> Result<Vec<Message>, MemoryError> {
3571 Err(MemoryError::Storage("disk full".into()))
3572 }
3573 }
3574
3575 let fake = SharedFake::new([FakeReply::Text("hi".into())]);
3576 let mut agent = agent(fake.clone(), "").with_memory(FailingContextMemory);
3577 let mut stream = agent.run_stream("hi").await.unwrap();
3578 let chunks: Vec<Result<MessageChunk, AgentError>> = stream.by_ref().collect().await;
3579 assert_eq!(chunks.len(), 1);
3580 assert!(matches!(
3581 chunks[0],
3582 Err(AgentError::Memory(MemoryError::Storage(_)))
3583 ));
3584 }
3585
3586 #[tokio::test]
3589 async fn stream_assistant_record_failure_terminates_with_err() {
3590 struct FailingAssistantMemory;
3591 #[async_trait::async_trait]
3592 impl Memory for FailingAssistantMemory {
3593 async fn record(&mut self, message: Message) -> Result<(), MemoryError> {
3594 if matches!(message, Message::Assistant { .. }) {
3595 Err(MemoryError::Storage("disk full".into()))
3596 } else {
3597 Ok(())
3598 }
3599 }
3600 async fn context(&self) -> Result<Vec<Message>, MemoryError> {
3601 Ok(Vec::new())
3602 }
3603 }
3604
3605 let fake = SharedFake::new([FakeReply::Text("hi".into())]);
3606 let mut agent = agent(fake.clone(), "").with_memory(FailingAssistantMemory);
3607 let mut stream = agent.run_stream("hi").await.unwrap();
3608 let chunks: Vec<Result<MessageChunk, AgentError>> = stream.by_ref().collect().await;
3609 assert_eq!(chunks.len(), 2); assert!(matches!(
3611 chunks.last(),
3612 Some(Err(AgentError::Memory(MemoryError::Storage(_))))
3613 ));
3614 }
3615
3616 #[tokio::test]
3619 async fn stream_tool_failure_returns_text_and_continues() {
3620 struct FailingTool;
3621 #[async_trait::async_trait]
3622 impl Tool for FailingTool {
3623 fn schema(&self) -> ToolSchema {
3624 ToolSchema::new("boom", "Tool that always fails", serde_json::json!({}))
3625 }
3626 async fn call(
3627 &self,
3628 _arguments: serde_json::Value,
3629 _context: ToolContext<'_>,
3630 ) -> Result<ToolResult, ToolError> {
3631 Err(ToolError::Execution("internal error".into()))
3632 }
3633 }
3634
3635 let mut registry = ToolRegistry::new();
3636 registry.register(FailingTool);
3637 let fake = SharedFake::new([
3638 FakeReply::ToolCalls {
3639 content: "".into(),
3640 calls: vec![call("c1", "boom", "{}")],
3641 },
3642 FakeReply::Text("Got it".into()),
3643 ]);
3644 let mut agent = agent_with_registry(fake.clone(), registry, AgentConfig::default());
3645
3646 let mut stream = agent.run_stream("Trigger failure").await.unwrap();
3647 let chunks: Vec<MessageChunk> = stream.by_ref().map(|e| e.unwrap()).collect().await;
3648 assert!(matches!(
3649 &chunks[1],
3650 MessageChunk::ToolResult { content, .. } if content.contains("internal error")
3651 ));
3652 assert_done_summary(chunks.last().unwrap(), 2, 1, Usage::default(), true);
3655 }
3656
3657 #[tokio::test]
3660 async fn stream_multiple_tools_same_round() {
3661 let (calc_a, _calls) = FakeTool::new("calc_a", "A");
3662 let (calc_b, _calls) = FakeTool::new("calc_b", "B");
3663 let mut registry = ToolRegistry::new();
3664 registry.register(calc_a).register(calc_b);
3665 let fake = SharedFake::new([
3666 FakeReply::ToolCalls {
3667 content: "".into(),
3668 calls: vec![call("c1", "calc_a", "{}"), call("c2", "calc_b", "{}")],
3669 },
3670 FakeReply::Text("Done".into()),
3671 ]);
3672 let mut agent = agent_with_registry(fake.clone(), registry, AgentConfig::default());
3673
3674 let mut stream = agent.run_stream("Compute").await.unwrap();
3675 let chunks: Vec<MessageChunk> = stream.by_ref().map(|e| e.unwrap()).collect().await;
3676 assert_eq!(chunks.len(), 6);
3677 assert_eq!(
3678 &chunks[..5],
3679 &[
3680 MessageChunk::ToolCall {
3681 id: "c1".into(),
3682 name: "calc_a".into(),
3683 arguments: "{}".into()
3684 },
3685 MessageChunk::ToolCall {
3686 id: "c2".into(),
3687 name: "calc_b".into(),
3688 arguments: "{}".into()
3689 },
3690 MessageChunk::ToolResult {
3691 id: "c1".into(),
3692 name: "calc_a".into(),
3693 content: "A".into()
3694 },
3695 MessageChunk::ToolResult {
3696 id: "c2".into(),
3697 name: "calc_b".into(),
3698 content: "B".into()
3699 },
3700 MessageChunk::Delta("Done".into()),
3701 ]
3702 );
3703 assert_done_summary(&chunks[5], 2, 2, Usage::default(), true);
3704 }
3705
3706 #[tokio::test]
3710 async fn run_and_stream_error_semantics_equivalent() {
3711 let script = |fake: &SharedFake| {
3712 let (calc, _calls) = FakeTool::new("calc", "42");
3713 let mut registry = ToolRegistry::new();
3714 registry.register(calc);
3715 agent_with_registry(
3716 fake.clone(),
3717 registry,
3718 AgentConfig {
3719 max_tool_rounds: 1,
3720 ..Default::default()
3721 },
3722 )
3723 };
3724
3725 let fake = SharedFake::new([
3727 FakeReply::ToolCalls {
3728 content: "".into(),
3729 calls: vec![call("c1", "calc", "{}")],
3730 },
3731 FakeReply::ToolCalls {
3732 content: "".into(),
3733 calls: vec![call("c2", "calc", "{}")],
3734 },
3735 ]);
3736 let mut agent = script(&fake);
3737 let run_err = agent.run("Compute").await.unwrap_err();
3738
3739 let fake2 = SharedFake::new([
3741 FakeReply::ToolCalls {
3742 content: "".into(),
3743 calls: vec![call("c1", "calc", "{}")],
3744 },
3745 FakeReply::ToolCalls {
3746 content: "".into(),
3747 calls: vec![call("c2", "calc", "{}")],
3748 },
3749 ]);
3750 let mut agent = script(&fake2);
3751 let mut stream = agent.run_stream("Compute").await.unwrap();
3752 let chunks: Vec<Result<MessageChunk, AgentError>> = stream.by_ref().collect().await;
3753 let stream_err = chunks.into_iter().find_map(|e| e.err());
3754
3755 assert_eq!(run_err, AgentError::TooManyToolRounds(1));
3757 assert_eq!(stream_err, Some(AgentError::TooManyToolRounds(1)));
3758 assert_eq!(fake.requests().len(), fake2.requests().len());
3759 }
3760
3761 #[tokio::test]
3764 async fn run_id_differs_across_instances() {
3765 let id_a = RunContext::generated().run_id;
3766 let id_b = RunContext::generated().run_id;
3767 assert_ne!(
3768 id_a, id_b,
3769 "back-to-back instances must not collide on run_id"
3770 );
3771 assert!(id_a.starts_with("run-") && id_b.starts_with("run-"));
3773 }
3774
3775 #[tokio::test]
3778 async fn run_context_failure_returns_memory_error() {
3779 struct FailingContextMemory;
3780 #[async_trait::async_trait]
3781 impl Memory for FailingContextMemory {
3782 async fn record(&mut self, _message: Message) -> Result<(), MemoryError> {
3783 Ok(())
3784 }
3785 async fn context(&self) -> Result<Vec<Message>, MemoryError> {
3786 Err(MemoryError::Storage("disk full".into()))
3787 }
3788 }
3789
3790 let fake = SharedFake::new([FakeReply::Text("hi".into())]);
3791 let mut agent = agent(fake.clone(), "").with_memory(FailingContextMemory);
3792 let err = agent.run("hi").await.unwrap_err();
3793 assert!(matches!(err, AgentError::Memory(MemoryError::Storage(_))));
3794 assert!(fake.requests().is_empty());
3796 }
3797
3798 #[tokio::test]
3805 async fn stream_empty_provider_stream_yields_empty_answer() {
3806 struct EmptyStreamProvider;
3807 #[async_trait::async_trait]
3808 impl Provider for EmptyStreamProvider {
3809 async fn chat_with_context(
3810 &self,
3811 _r: ChatRequest,
3812 _context: &ProviderRequestContext,
3813 ) -> Result<ChatResponse, ProviderError> {
3814 unreachable!("this test uses streaming only")
3815 }
3816 async fn stream_chat_with_context(
3817 &self,
3818 _r: ChatRequest,
3819 _context: &ProviderRequestContext,
3820 ) -> Result<BoxStream<'static, Result<StreamEvent, ProviderError>>, ProviderError>
3821 {
3822 Ok(Box::pin(futures::stream::empty()))
3823 }
3824 }
3825
3826 let mut agent = ReActAgent::new(EmptyStreamProvider, ToolRegistry::new(), "");
3827 let chunks: Vec<Result<MessageChunk, AgentError>> = {
3828 let mut stream = agent.run_stream("hi").await.unwrap();
3829 stream.by_ref().collect().await
3830 };
3831 assert_eq!(chunks.len(), 1);
3833 let chunk = chunks[0].as_ref().unwrap();
3834 assert_done_summary_with_finish(chunk, 1, 0, Usage::default(), true, None);
3835 assert_eq!(
3838 agent.memory.context().await.unwrap(),
3839 vec![Message::user("hi")]
3840 );
3841 }
3842
3843 #[tokio::test]
3847 async fn stream_event_order() {
3848 let (calc, _calls) = FakeTool::new("calc", "42");
3849 let mut registry = ToolRegistry::new();
3850 registry.register(calc);
3851 let fake = SharedFake::new([
3852 FakeReply::ToolCalls {
3853 content: "Thinking: ".into(),
3854 calls: vec![call("c1", "calc", "{}")],
3855 },
3856 FakeReply::TextWithReasoning {
3857 content: "The answer is 42".into(),
3858 reasoning: "Reasoning steps".into(),
3859 },
3860 ]);
3861 let mut agent = agent_with_registry(fake.clone(), registry, AgentConfig::default());
3862
3863 let mut stream = agent.run_stream("Compute").await.unwrap();
3864 let events: Vec<MessageChunk> = stream.by_ref().map(|e| e.unwrap()).collect().await;
3865 assert_eq!(events.len(), 5);
3866 assert_eq!(
3867 &events[..4],
3868 &[
3869 MessageChunk::Delta("Thinking: ".into()),
3870 MessageChunk::ToolCall {
3871 id: "c1".into(),
3872 name: "calc".into(),
3873 arguments: "{}".into()
3874 },
3875 MessageChunk::ToolResult {
3876 id: "c1".into(),
3877 name: "calc".into(),
3878 content: "42".into()
3879 },
3880 MessageChunk::Delta("The answer is 42".into()),
3881 ]
3882 );
3883 assert_done_summary(&events[4], 2, 1, Usage::default(), true);
3884 }
3885
3886 #[tokio::test]
3889 async fn stream_pure_tool_round_no_delta() {
3890 let (calc, _calls) = FakeTool::new("calc", "42");
3891 let mut registry = ToolRegistry::new();
3892 registry.register(calc);
3893 let fake = SharedFake::new([
3894 FakeReply::ToolCalls {
3895 content: "".into(),
3896 calls: vec![call("c1", "calc", "{}")],
3897 },
3898 FakeReply::Text("42".into()),
3899 ]);
3900 let mut agent = agent_with_registry(fake.clone(), registry, AgentConfig::default());
3901
3902 let mut stream = agent.run_stream("Compute").await.unwrap();
3903 let events: Vec<MessageChunk> = stream.by_ref().map(|e| e.unwrap()).collect().await;
3904 assert_eq!(events.len(), 4);
3905 assert_eq!(
3906 &events[..3],
3907 &[
3908 MessageChunk::ToolCall {
3909 id: "c1".into(),
3910 name: "calc".into(),
3911 arguments: "{}".into()
3912 },
3913 MessageChunk::ToolResult {
3914 id: "c1".into(),
3915 name: "calc".into(),
3916 content: "42".into()
3917 },
3918 MessageChunk::Delta("42".into()),
3919 ]
3920 );
3921 assert_done_summary(&events[3], 2, 1, Usage::default(), true);
3922 }
3923
3924 #[tokio::test]
3928 async fn stream_done_summary_accumulates_usage() {
3929 let (calc, _calls) = FakeTool::new("calc", "42");
3930 let mut registry = ToolRegistry::new();
3931 registry.register(calc);
3932 let fake = SharedFake::new([
3933 FakeReply::WithUsage {
3934 reply: Box::new(FakeReply::ToolCalls {
3935 content: "".into(),
3936 calls: vec![call("c1", "calc", "{}")],
3937 }),
3938 usage: Usage::new(10, 2),
3939 },
3940 FakeReply::text_with_usage("42", Usage::new(20, 5)),
3941 ]);
3942 let mut agent = agent_with_registry(fake.clone(), registry, AgentConfig::default());
3943
3944 let mut stream = agent.run_stream("Compute").await.unwrap();
3945 let events: Vec<MessageChunk> = stream.by_ref().map(|e| e.unwrap()).collect().await;
3946
3947 assert_done_summary(events.last().unwrap(), 2, 1, Usage::new(30, 7), false);
3950 }
3951
3952 #[tokio::test]
3955 async fn summary_tracks_omitted_usage_rounds() {
3956 let (calc, _calls) = FakeTool::new("calc", "42");
3957 let mut registry = ToolRegistry::new();
3958 registry.register(calc);
3959 let fake = SharedFake::new([
3963 FakeReply::ToolCalls {
3964 content: "".into(),
3965 calls: vec![call("c1", "calc", "{}")],
3966 },
3967 FakeReply::text_with_usage("42", Usage::new(20, 5)),
3968 ]);
3969 let mut agent = agent_with_registry(fake.clone(), registry, AgentConfig::default());
3970
3971 let mut stream = agent.run_stream("Compute").await.unwrap();
3972 let events: Vec<MessageChunk> = stream.by_ref().map(|e| e.unwrap()).collect().await;
3973
3974 assert_done_summary(events.last().unwrap(), 2, 1, Usage::new(20, 5), true);
3975 }
3976
3977 #[tokio::test]
3980 async fn run_and_stream_same_semantics() {
3981 let script = [
3982 FakeReply::ToolCalls {
3983 content: "".into(),
3984 calls: vec![call("c1", "calc", "{}")],
3985 },
3986 FakeReply::Text("42".into()),
3987 ];
3988 let (calc, _calls) = FakeTool::new("calc", "42");
3989
3990 let mut registry = ToolRegistry::new();
3992 registry.register(calc.clone());
3993 let fake1 = SharedFake::new(script.clone());
3994 let mut agent1 = agent_with_registry(fake1.clone(), registry, AgentConfig::default());
3995 let answer1 = agent1.run("Compute").await.unwrap();
3996
3997 let mut registry = ToolRegistry::new();
3999 registry.register(calc);
4000 let fake2 = SharedFake::new(script);
4001 let mut agent2 = agent_with_registry(fake2.clone(), registry, AgentConfig::default());
4002 let events: Vec<MessageChunk> = agent2
4003 .run_stream("Compute")
4004 .await
4005 .unwrap()
4006 .map(|e| e.unwrap())
4007 .collect()
4008 .await;
4009 let answer2: String = events
4011 .iter()
4012 .filter_map(|e| match e {
4013 MessageChunk::Delta(d) => Some(d.as_str()),
4014 _ => None,
4015 })
4016 .collect();
4017 assert_eq!(answer1, answer2);
4018 assert_eq!(answer1, "42");
4019
4020 assert_eq!(fake1.requests(), fake2.requests());
4023 }
4024
4025 #[tokio::test]
4032 async fn stream_error_terminates_without_done() {
4033 struct FailInStream;
4034 #[async_trait::async_trait]
4035 impl Provider for FailInStream {
4036 async fn chat_with_context(
4037 &self,
4038 _request: ChatRequest,
4039 _context: &ProviderRequestContext,
4040 ) -> Result<ChatResponse, ProviderError> {
4041 unreachable!("this test uses streaming path only")
4042 }
4043 async fn stream_chat_with_context(
4044 &self,
4045 _request: ChatRequest,
4046 _context: &ProviderRequestContext,
4047 ) -> Result<BoxStream<'static, Result<StreamEvent, ProviderError>>, ProviderError>
4048 {
4049 Ok(Box::pin(futures::stream::iter(vec![
4050 Ok(StreamEvent::Delta("hi".into())),
4051 Err(ProviderError::Protocol {
4052 message: "boom".into(),
4053 }),
4054 ])))
4055 }
4056 }
4057
4058 let mut agent = ReActAgent::new(FailInStream, ToolRegistry::new(), "");
4059
4060 let mut stream = agent.run_stream("two").await.unwrap();
4061 assert_eq!(
4062 stream.next().await.unwrap().unwrap(),
4063 MessageChunk::Delta("hi".into())
4064 );
4065 assert!(matches!(
4066 stream.next().await.unwrap(),
4067 Err(AgentError::Provider(ProviderError::Protocol { message: m })) if m == "boom"
4068 ));
4069 assert!(stream.next().await.is_none()); }
4071
4072 #[tokio::test]
4075 async fn script_exhausted_fails_explicitly() {
4076 let fake = SharedFake::new([FakeReply::Text("hi".into())]);
4077 let mut agent = agent(fake, "");
4078 agent.run("one").await.unwrap();
4079
4080 let err = agent.run("two").await.unwrap_err();
4081 assert!(
4082 matches!(err, AgentError::Provider(ProviderError::Protocol { message: m }) if m.contains("exhausted"))
4083 );
4084 }
4085
4086 #[tokio::test]
4090 async fn shared_state_flows_to_tools() {
4091 struct CounterTool;
4092 #[async_trait::async_trait]
4093 impl Tool for CounterTool {
4094 fn schema(&self) -> ToolSchema {
4095 ToolSchema::new("counter", "Count", serde_json::json!({}))
4096 }
4097 async fn call(
4098 &self,
4099 _arguments: serde_json::Value,
4100 context: ToolContext<'_>,
4101 ) -> Result<ToolResult, ToolError> {
4102 let state = context.state;
4103 state.with_mut::<usize>(|n| *n += 1);
4104 Ok(ToolOutput::text(format!("count={}", state.get::<usize>().unwrap_or(0))).into())
4105 }
4106 }
4107
4108 let mut registry = ToolRegistry::new();
4109 registry.register(CounterTool);
4110 let fake = SharedFake::new([
4111 FakeReply::ToolCalls {
4112 content: "".into(),
4113 calls: vec![call("c1", "counter", "{}"), call("c2", "counter", "{}")],
4114 },
4115 FakeReply::Text("done".into()),
4116 ]);
4117 let state = SharedState::new();
4118 state.insert(0usize);
4119 let mut agent = ReActAgent::new(fake, registry, "").with_state(state.clone());
4120
4121 agent.run("Count").await.unwrap();
4122
4123 assert_eq!(state.get::<usize>(), Some(2));
4127 }
4128
4129 #[tokio::test]
4132 async fn memory_error_passthrough() {
4133 struct FailingMemory;
4134 #[async_trait::async_trait]
4135 impl Memory for FailingMemory {
4136 async fn record(&mut self, _message: Message) -> Result<(), MemoryError> {
4137 Err(MemoryError::Storage("disk full".into()))
4138 }
4139 async fn context(&self) -> Result<Vec<Message>, MemoryError> {
4140 Ok(Vec::new())
4141 }
4142 }
4143
4144 let mut agent = ReActAgent::new(
4145 FakeProvider::new([FakeReply::Text("hi".into())]),
4146 ToolRegistry::new(),
4147 "",
4148 )
4149 .with_memory(FailingMemory);
4150 let err = agent.run("hi").await.unwrap_err();
4151 assert!(matches!(err, AgentError::Memory(MemoryError::Storage(_))));
4152 }
4153
4154 #[tokio::test]
4162 async fn stream_tool_result_record_failure_terminates_stream() {
4163 struct FailingToolResultMemory;
4164 #[async_trait::async_trait]
4165 impl Memory for FailingToolResultMemory {
4166 async fn record(&mut self, message: Message) -> Result<(), MemoryError> {
4167 if matches!(message, Message::ToolResult { .. }) {
4168 Err(MemoryError::Storage("disk full".into()))
4169 } else {
4170 Ok(())
4171 }
4172 }
4173 async fn context(&self) -> Result<Vec<Message>, MemoryError> {
4174 Ok(Vec::new())
4175 }
4176 }
4177
4178 let (calc, _calls) = FakeTool::new("calc", "42");
4179 let mut registry = ToolRegistry::new();
4180 registry.register(calc);
4181 let fake = SharedFake::new([
4182 FakeReply::ToolCalls {
4183 content: "".into(),
4184 calls: vec![call("c1", "calc", "{}")],
4185 },
4186 FakeReply::Text("42".into()),
4187 ]);
4188 let mut agent = agent_with_registry(fake.clone(), registry, AgentConfig::default())
4189 .with_memory(FailingToolResultMemory);
4190
4191 let mut stream = agent.run_stream("Compute").await.unwrap();
4192 let chunks: Vec<Result<MessageChunk, AgentError>> = stream.by_ref().collect().await;
4193 assert_eq!(chunks.len(), 3);
4195 assert!(matches!(
4196 chunks[2],
4197 Err(AgentError::Memory(MemoryError::Storage(_)))
4198 ));
4199 }
4200
4201 #[tokio::test]
4204 async fn config_options_forwarded_to_chat_request() {
4205 let fake = SharedFake::new([FakeReply::Text("hi".into())]);
4206 let mut agent = agent(fake.clone(), "").with_config(AgentConfig {
4207 max_tool_rounds: 10,
4208 options: ModelOptions {
4209 temperature: Some(0.2),
4210 max_tokens: Some(128),
4211 extra: Default::default(),
4212 structured: None,
4213 },
4214 ..Default::default()
4215 });
4216 agent.run("hi").await.unwrap();
4217
4218 let req = &fake.requests()[0];
4219 assert_eq!(req.options.temperature, Some(0.2));
4220 assert_eq!(req.options.max_tokens, Some(128));
4221 }
4222
4223 #[tokio::test]
4224 async fn request_options_replace_config_options() {
4225 let fake = SharedFake::new([FakeReply::Text("hi".into())]);
4226 let mut agent = agent(fake.clone(), "").with_config(AgentConfig {
4227 options: ModelOptions {
4228 temperature: Some(0.2),
4229 max_tokens: Some(128),
4230 ..Default::default()
4231 },
4232 ..Default::default()
4233 });
4234 agent
4235 .run_request(RunRequest::text("hi").with_options(ModelOptions {
4236 max_tokens: Some(64),
4237 ..Default::default()
4238 }))
4239 .await
4240 .unwrap();
4241
4242 let req = &fake.requests()[0];
4243 assert_eq!(req.options.temperature, None);
4244 assert_eq!(req.options.max_tokens, Some(64));
4245 }
4246
4247 #[tokio::test]
4254 async fn cancelled_before_run_returns_cancelled() {
4255 let fake = SharedFake::new([FakeReply::Text("hi".into())]);
4256 let mut agent = agent(fake.clone(), "");
4257 let token = CancellationToken::new();
4258 token.cancel();
4259
4260 let err = agent
4261 .run_request_with_context(RunRequest::text("hi"), cancellation_context(&token))
4262 .await
4263 .unwrap_err();
4264 assert!(matches!(err, AgentError::Cancelled));
4265 assert_eq!(fake.requests().len(), 0); assert_eq!(agent.memory.context().await.unwrap().len(), 1); }
4268
4269 #[tokio::test]
4272 async fn pre_cancelled_token_rounds_consistent_across_paths() {
4273 let fake = SharedFake::new([FakeReply::Text("hi".into())]);
4274 let token = CancellationToken::new();
4275 token.cancel();
4276
4277 let (mut run_agent, mut rx) = attach_channel(agent(fake.clone(), ""));
4279 let err = run_agent
4280 .run_request_with_context(RunRequest::text("hi"), cancellation_context(&token))
4281 .await
4282 .unwrap_err();
4283 assert!(matches!(err, AgentError::Cancelled));
4284 drop(run_agent);
4285 let events = drain(&mut rx).await;
4286 let rounds_run = match react_event(&**events.last().unwrap()) {
4287 ReActEvent::RunEnded { summary, error, .. } => {
4288 assert_eq!(error, &Some(AgentError::Cancelled));
4289 summary.rounds
4290 }
4291 _ => panic!("expected RunEnded"),
4292 };
4293
4294 let (mut stream_agent, mut rx) = attach_channel(agent(fake, ""));
4296 let mut stream = stream_agent
4297 .run_stream_request_with_context(RunRequest::text("hi"), cancellation_context(&token))
4298 .await
4299 .unwrap();
4300 let chunks: Vec<MessageChunk> = stream.by_ref().map(|e| e.unwrap()).collect().await;
4301 assert!(chunks.contains(&MessageChunk::Cancelled));
4302 drop(stream);
4303 drop(stream_agent);
4304 let events = drain(&mut rx).await;
4305 let rounds_stream = match react_event(&**events.last().unwrap()) {
4306 ReActEvent::RunEnded { summary, error, .. } => {
4307 assert_eq!(error, &Some(AgentError::Cancelled));
4308 summary.rounds
4309 }
4310 _ => panic!("expected RunEnded"),
4311 };
4312
4313 assert_eq!(rounds_run, rounds_stream);
4314 assert_eq!(rounds_run, 1);
4315 }
4316
4317 #[tokio::test]
4322 async fn cancel_during_chat_drops_inflight() {
4323 struct PendingProvider;
4324 #[async_trait::async_trait]
4325 impl Provider for PendingProvider {
4326 async fn chat_with_context(
4327 &self,
4328 _request: ChatRequest,
4329 _context: &ProviderRequestContext,
4330 ) -> Result<ChatResponse, ProviderError> {
4331 std::future::pending().await }
4333 async fn stream_chat_with_context(
4334 &self,
4335 _request: ChatRequest,
4336 _context: &ProviderRequestContext,
4337 ) -> Result<BoxStream<'static, Result<StreamEvent, ProviderError>>, ProviderError>
4338 {
4339 unreachable!("this test uses non-streaming path only")
4340 }
4341 }
4342
4343 let mut agent = ReActAgent::new(PendingProvider, ToolRegistry::new(), "");
4344 let token = CancellationToken::new();
4345 let result = tokio::select! {
4346 r = agent.run_request_with_context(RunRequest::text("hi"), cancellation_context(&token)) => r.map(|output| output.answer),
4347 _ = async {
4348 tokio::time::sleep(Duration::from_millis(20)).await;
4349 token.cancel();
4350 std::future::pending::<()>().await; } => unreachable!("cancellation branch only sends a signal"),
4352 };
4353 assert!(matches!(result, Err(AgentError::Cancelled)));
4354 assert_eq!(agent.memory.context().await.unwrap().len(), 1); }
4356
4357 #[tokio::test]
4362 async fn provider_error_propagates_from_both_paths() {
4363 struct FailProvider(ProviderError);
4364 #[async_trait::async_trait]
4365 impl Provider for FailProvider {
4366 async fn chat_with_context(
4367 &self,
4368 _request: ChatRequest,
4369 _context: &ProviderRequestContext,
4370 ) -> Result<ChatResponse, ProviderError> {
4371 Err(self.0.clone())
4372 }
4373 async fn stream_chat_with_context(
4374 &self,
4375 _request: ChatRequest,
4376 _context: &ProviderRequestContext,
4377 ) -> Result<BoxStream<'static, Result<StreamEvent, ProviderError>>, ProviderError>
4378 {
4379 Err(self.0.clone())
4380 }
4381 }
4382
4383 let err = ProviderError::Timeout(TimeoutStage::Request);
4384 let mut agent = ReActAgent::new(FailProvider(err.clone()), ToolRegistry::new(), "");
4386 let got = agent.run("hi").await.unwrap_err();
4387 assert!(matches!(
4388 got,
4389 AgentError::Provider(ProviderError::Timeout(_))
4390 ));
4391 assert_eq!(agent.memory.context().await.unwrap().len(), 1); let mut agent = ReActAgent::new(FailProvider(err.clone()), ToolRegistry::new(), "");
4396 let mut stream = agent.run_stream("hi").await.unwrap();
4397 let item = stream.next().await.unwrap().unwrap_err();
4398 assert!(matches!(
4399 item,
4400 AgentError::Provider(ProviderError::Timeout(_))
4401 ));
4402 assert!(stream.next().await.is_none());
4403 drop(stream); assert_eq!(agent.memory.context().await.unwrap().len(), 1);
4405 }
4406
4407 #[tokio::test]
4408 async fn deadline_exceeded_during_chat_is_distinct_from_provider_timeout() {
4409 struct PendingProvider;
4410 #[async_trait::async_trait]
4411 impl Provider for PendingProvider {
4412 async fn chat_with_context(
4413 &self,
4414 _request: ChatRequest,
4415 _context: &ProviderRequestContext,
4416 ) -> Result<ChatResponse, ProviderError> {
4417 std::future::pending().await
4418 }
4419
4420 async fn stream_chat_with_context(
4421 &self,
4422 _request: ChatRequest,
4423 _context: &ProviderRequestContext,
4424 ) -> Result<BoxStream<'static, Result<StreamEvent, ProviderError>>, ProviderError>
4425 {
4426 std::future::pending().await
4427 }
4428 }
4429
4430 let mut agent = ReActAgent::new(PendingProvider, ToolRegistry::new(), "");
4431 let err = agent
4432 .run_request_with_context(
4433 RunRequest::text("hi"),
4434 RunContext::new("deadline").with_timeout(Duration::from_millis(10)),
4435 )
4436 .await
4437 .unwrap_err();
4438 assert_eq!(err, AgentError::DeadlineExceeded);
4439 assert_eq!(agent.memory.context().await.unwrap().len(), 1);
4440 }
4441
4442 #[tokio::test]
4443 async fn streaming_deadline_exceeded_terminates_with_error_item() {
4444 struct PendingStreamProvider;
4445 #[async_trait::async_trait]
4446 impl Provider for PendingStreamProvider {
4447 async fn chat_with_context(
4448 &self,
4449 _request: ChatRequest,
4450 _context: &ProviderRequestContext,
4451 ) -> Result<ChatResponse, ProviderError> {
4452 unreachable!("this test uses streaming only")
4453 }
4454
4455 async fn stream_chat_with_context(
4456 &self,
4457 _request: ChatRequest,
4458 _context: &ProviderRequestContext,
4459 ) -> Result<BoxStream<'static, Result<StreamEvent, ProviderError>>, ProviderError>
4460 {
4461 Ok(Box::pin(futures::stream::pending()))
4462 }
4463 }
4464
4465 let mut agent = ReActAgent::new(PendingStreamProvider, ToolRegistry::new(), "");
4466 let mut stream = agent
4467 .run_stream_request_with_context(
4468 RunRequest::text("hi"),
4469 RunContext::new("stream-deadline").with_timeout(Duration::from_millis(10)),
4470 )
4471 .await
4472 .unwrap();
4473 assert_eq!(
4474 stream.next().await.unwrap().unwrap_err(),
4475 AgentError::DeadlineExceeded
4476 );
4477 }
4478
4479 #[cfg(feature = "structured")]
4482 #[tokio::test]
4483 async fn structured_output_valid_answer_passes() {
4484 let schema = serde_json::json!({
4485 "type": "object",
4486 "properties": { "city": { "type": "string" } },
4487 "required": ["city"],
4488 });
4489 let fake = SharedFake::new([FakeReply::Text(r#"{"city":"Beijing"}"#.into())]);
4490 let mut agent = agent(fake.clone(), "").with_structured_output(schema);
4491 let answer = agent.run("Beijing weather").await.unwrap();
4492 assert_eq!(answer, r#"{"city":"Beijing"}"#);
4493 assert_eq!(fake.requests().len(), 1); }
4495
4496 #[cfg(feature = "structured")]
4499 #[tokio::test]
4500 async fn structured_output_retries_after_invalid_answer() {
4501 let schema = serde_json::json!({
4502 "type": "object",
4503 "properties": { "city": { "type": "string" } },
4504 "required": ["city"],
4505 });
4506 let fake = SharedFake::new([
4507 FakeReply::Text("Not JSON".into()),
4508 FakeReply::Text(r#"{"city":"Beijing"}"#.into()),
4509 ]);
4510 let mut agent = agent(fake.clone(), "").with_structured_output(schema);
4511 let answer = agent.run("Beijing weather").await.unwrap();
4512 assert_eq!(answer, r#"{"city":"Beijing"}"#);
4513 assert_eq!(fake.requests().len(), 2); let context = agent.memory.context().await.unwrap();
4517 assert!(context.iter().any(|m| matches!(
4518 m,
4519 Message::User(blocks) if blocks.iter().any(|b| matches!(b, ContentBlock::Text(t) if t.contains("JSON schema validation")))
4520 )));
4521 }
4522
4523 #[cfg(feature = "structured")]
4528 #[tokio::test]
4529 async fn structured_output_exhausts_retry_budget() {
4530 let schema = serde_json::json!({ "type": "object" });
4531 let fake = SharedFake::new([
4532 FakeReply::Text("bad1".into()),
4533 FakeReply::Text("bad2".into()),
4534 FakeReply::Text("bad3".into()),
4535 FakeReply::Text("bad4".into()),
4536 ]);
4537 let mut agent = agent(fake.clone(), "").with_config(AgentConfig {
4540 max_tool_rounds: 1,
4541 max_structured_retries: 3,
4542 ..Default::default()
4543 });
4544 agent = agent.with_structured_output(schema);
4545 let err = agent.run("hi").await.unwrap_err();
4546 assert!(matches!(err, AgentError::StructuredRetriesExhausted(3)));
4547 assert_eq!(fake.requests().len(), 4); }
4549
4550 #[cfg(feature = "structured")]
4554 #[tokio::test]
4555 async fn typed_output_parses_valid_answer() {
4556 #[derive(Debug, Deserialize, JsonSchema)]
4557 struct Weather {
4558 city: String,
4559 }
4560 let fake = SharedFake::new([FakeReply::Text(r#"{"city":"Beijing"}"#.into())]);
4561 let mut agent = agent(fake.clone(), "");
4562 let weather: Weather = agent.run_typed("Beijing weather").await.unwrap();
4563 assert_eq!(weather.city, "Beijing");
4564 assert_eq!(fake.requests().len(), 1); assert!(fake.requests()[0].options.structured.is_some());
4568 assert!(agent.config.options.structured.is_none());
4569 }
4570
4571 #[cfg(feature = "structured")]
4572 #[tokio::test]
4573 async fn typed_run_request_returns_value_and_output() {
4574 #[derive(Debug, Deserialize, JsonSchema, PartialEq)]
4575 struct Weather {
4576 city: String,
4577 }
4578 let fake = SharedFake::new([FakeReply::Text(r#"{"city":"Beijing"}"#.into())]);
4579 let mut agent = agent(fake.clone(), "");
4580 let typed = agent
4581 .run_typed_request_with_context::<Weather>(
4582 RunRequest::text("Beijing weather"),
4583 RunContext::new("typed-1"),
4584 )
4585 .await
4586 .unwrap();
4587
4588 assert_eq!(
4589 typed.value,
4590 Weather {
4591 city: "Beijing".into()
4592 }
4593 );
4594 assert_eq!(typed.output.run_id, "typed-1");
4595 assert_eq!(typed.output.answer, r#"{"city":"Beijing"}"#);
4596 assert_eq!(
4597 typed.output.final_message,
4598 Message::assistant(r#"{"city":"Beijing"}"#)
4599 );
4600 }
4601
4602 #[cfg(feature = "structured")]
4603 #[tokio::test]
4604 async fn typed_schema_overrides_request_and_config_schema() {
4605 #[derive(Debug, Deserialize, JsonSchema)]
4606 struct Weather {
4607 city: String,
4608 }
4609 let config_schema = serde_json::json!({
4610 "type": "object",
4611 "properties": { "config_only": { "type": "string" } },
4612 "required": ["config_only"],
4613 });
4614 let request_schema = serde_json::json!({
4615 "type": "object",
4616 "properties": { "request_only": { "type": "string" } },
4617 "required": ["request_only"],
4618 });
4619 let fake = SharedFake::new([FakeReply::Text(r#"{"city":"Beijing"}"#.into())]);
4620 let mut agent = agent(fake.clone(), "").with_structured_output(config_schema);
4621 let options = ModelOptions {
4622 structured: Some(request_schema),
4623 ..Default::default()
4624 };
4625
4626 let weather: Weather = agent
4627 .run_typed_request(RunRequest::text("Beijing weather").with_options(options))
4628 .await
4629 .unwrap()
4630 .value;
4631
4632 assert_eq!(weather.city, "Beijing");
4633 let requests = fake.requests();
4634 let structured = requests[0]
4635 .options
4636 .structured
4637 .as_ref()
4638 .expect("typed schema should be sent");
4639 let props = &structured["properties"];
4640 assert!(props.get("city").is_some());
4641 assert!(props.get("request_only").is_none());
4642 assert!(props.get("config_only").is_none());
4643 }
4644
4645 #[cfg(feature = "structured")]
4648 #[tokio::test]
4649 async fn typed_output_retries_then_parses() {
4650 #[derive(Debug, Deserialize, JsonSchema)]
4651 struct Weather {
4652 city: String,
4653 }
4654 let fake = SharedFake::new([
4655 FakeReply::Text("Not JSON".into()),
4656 FakeReply::Text(r#"{"city":"Beijing"}"#.into()),
4657 ]);
4658 let mut agent = agent(fake.clone(), "");
4659 let weather: Weather = agent.run_typed("Beijing weather").await.unwrap();
4660 assert_eq!(weather.city, "Beijing");
4661 assert_eq!(fake.requests().len(), 2);
4662 }
4663
4664 #[cfg(feature = "structured")]
4670 #[tokio::test]
4671 async fn typed_output_parse_failure_on_schema_mismatch() {
4672 fn string_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
4673 serde_json::from_value(serde_json::json!({ "type": "string" })).unwrap()
4674 }
4675 #[derive(Debug, Deserialize, JsonSchema)]
4676 #[allow(dead_code)] struct Weather {
4678 #[schemars(schema_with = "string_schema")]
4679 temperature: i32,
4680 }
4681 let fake = SharedFake::new([FakeReply::Text(r#"{"temperature":"30"}"#.into())]);
4682 let mut agent = agent(fake.clone(), "");
4683 let err: AgentError = agent
4684 .run_typed::<Weather>("Beijing weather")
4685 .await
4686 .unwrap_err();
4687 assert!(matches!(err, AgentError::StructuredParse(_)));
4688 }
4689
4690 #[cfg(feature = "structured")]
4694 #[tokio::test]
4695 async fn typed_agent_trait_generic_call() {
4696 #[derive(Debug, Deserialize, JsonSchema)]
4697 struct Weather {
4698 city: String,
4699 }
4700 async fn typed_run<A: TypedAgent + Send>(
4701 agent: &mut A,
4702 input: &str,
4703 ) -> Result<Weather, AgentError> {
4704 agent.run_typed(input).await
4705 }
4706
4707 let fake = SharedFake::new([FakeReply::Text(r#"{"city":"Beijing"}"#.into())]);
4708 let mut agent = agent(fake.clone(), "");
4709 let weather = typed_run(&mut agent, "Beijing weather").await.unwrap();
4710 assert_eq!(weather.city, "Beijing");
4711 }
4712
4713 #[cfg(feature = "structured")]
4717 #[tokio::test]
4718 async fn typed_output_agent_trait_run_returns_text() {
4719 #[derive(Debug, Deserialize, JsonSchema)]
4720 struct Weather {
4721 city: String,
4722 }
4723 let fake = SharedFake::new([
4724 FakeReply::Text(r#"{"city":"Beijing"}"#.into()),
4725 FakeReply::Text("Hello".into()),
4726 ]);
4727 let mut agent = agent(fake.clone(), "");
4728 let weather: Weather = agent.run_typed("Beijing weather").await.unwrap();
4729 assert_eq!(weather.city, "Beijing");
4730 let text = Agent::run(&mut agent, "Say hi").await.unwrap();
4733 assert_eq!(text, "Hello");
4734 }
4735
4736 #[cfg(feature = "structured")]
4740 #[tokio::test]
4741 async fn structured_output_stream_exhausts_retry_budget() {
4742 let schema = serde_json::json!({ "type": "object" });
4743 let fake = SharedFake::new([
4744 FakeReply::Text("bad1".into()),
4745 FakeReply::Text("bad2".into()),
4746 ]);
4747 let mut agent = agent(fake.clone(), "").with_config(AgentConfig {
4748 max_structured_retries: 1,
4749 ..Default::default()
4750 });
4751 agent = agent.with_structured_output(schema);
4752 let mut stream = agent.run_stream("hi").await.unwrap();
4753 let mut saw_err = false;
4754 while let Some(item) = stream.next().await {
4755 if let Err(e) = item {
4756 assert!(matches!(e, AgentError::StructuredRetriesExhausted(1)));
4757 saw_err = true;
4758 break;
4759 }
4760 }
4761 assert!(saw_err);
4762 }
4763
4764 #[tokio::test]
4769 async fn tool_round_atomic_under_cancel() {
4770 struct SlowTool {
4772 calls: Arc<AtomicUsize>,
4773 }
4774 #[async_trait::async_trait]
4775 impl Tool for SlowTool {
4776 fn schema(&self) -> ToolSchema {
4777 ToolSchema::new("slow", "Slow tool", serde_json::json!({}))
4778 }
4779 async fn call(
4780 &self,
4781 _arguments: serde_json::Value,
4782 _context: ToolContext<'_>,
4783 ) -> Result<ToolResult, ToolError> {
4784 self.calls.fetch_add(1, Ordering::Relaxed);
4785 tokio::time::sleep(Duration::from_millis(100)).await;
4786 Ok(ToolOutput::text("42").into())
4787 }
4788 }
4789
4790 let calls = Arc::new(AtomicUsize::new(0));
4791 let mut registry = ToolRegistry::new();
4792 registry.register(SlowTool {
4793 calls: calls.clone(),
4794 });
4795 let fake = SharedFake::new([
4796 FakeReply::ToolCalls {
4797 content: "".into(),
4798 calls: vec![call("c1", "slow", "{}")],
4799 },
4800 FakeReply::Text("42".into()),
4801 ]);
4802 let mut agent = agent_with_registry(fake.clone(), registry, AgentConfig::default());
4803 let token = CancellationToken::new();
4804
4805 let result = tokio::select! {
4808 r = agent.run_request_with_context(RunRequest::text("Compute"), cancellation_context(&token)) => r.map(|output| output.answer),
4809 _ = async {
4810 tokio::time::sleep(Duration::from_millis(50)).await;
4811 token.cancel();
4812 std::future::pending::<()>().await; } => unreachable!("cancellation branch only sends a signal"),
4814 };
4815 assert!(matches!(result, Err(AgentError::Cancelled)));
4816 assert_eq!(calls.load(Ordering::Relaxed), 1); assert_eq!(fake.requests().len(), 1); let ctx = agent.memory.context().await.unwrap();
4821 assert_eq!(ctx.len(), 3);
4822 assert!(matches!(
4823 &ctx[1],
4824 Message::Assistant { tool_calls, .. } if tool_calls.len() == 1
4825 ));
4826 assert!(matches!(&ctx[2], Message::ToolResult { content, .. } if content == "42"));
4827 }
4828
4829 #[tokio::test]
4835 async fn custom_tool_round_executor() {
4836 #[derive(Default)]
4839 struct DenyAlphaToolRoundExecutor {
4840 denied: bool,
4841 }
4842 #[async_trait::async_trait]
4843 impl ToolRoundExecutor for DenyAlphaToolRoundExecutor {
4844 async fn execute_round<'a>(
4845 &'a mut self,
4846 ctx: ToolRoundCtx<'a>,
4847 calls: Vec<ToolCall>,
4848 ) -> BoxStream<'a, ToolCallOutcome> {
4849 let mut outcomes = Vec::with_capacity(calls.len());
4850 for call in calls.into_iter().rev() {
4851 if call.name == "alpha" && !self.denied {
4852 self.denied = true;
4853 outcomes.push(ToolCallOutcome {
4857 call,
4858 content: "denied by policy".into(),
4859 memory_policy: ToolMemoryPolicy::Normal,
4860 effect: None,
4861 });
4862 } else {
4863 outcomes.push(ctx.run(call).await);
4864 }
4865 }
4866 Box::pin(futures::stream::iter(outcomes))
4867 }
4868 }
4869
4870 let (alpha_tool, alpha_calls) = FakeTool::new("alpha", "A");
4871 let (beta_tool, beta_calls) = FakeTool::new("beta", "B");
4872 let mut registry = ToolRegistry::new();
4873 registry.register(alpha_tool);
4874 registry.register(beta_tool);
4875 let fake = SharedFake::new([
4876 FakeReply::ToolCalls {
4877 content: "".into(),
4878 calls: vec![call("c1", "alpha", "{}"), call("c2", "beta", "{}")],
4879 },
4880 FakeReply::Text("done".into()),
4881 ]);
4882 let mut agent = agent_with_registry(fake.clone(), registry, AgentConfig::default())
4883 .with_tool_round_executor(DenyAlphaToolRoundExecutor::default());
4884
4885 let answer = agent.run("Compute").await.unwrap();
4886 assert_eq!(answer, "done");
4887 assert_eq!(alpha_calls.load(Ordering::Relaxed), 0);
4889 assert_eq!(beta_calls.load(Ordering::Relaxed), 1);
4890 let requests = fake.requests();
4893 assert_eq!(requests.len(), 2);
4894 assert_eq!(requests[1].messages[2], Message::tool_result("c2", "B"),);
4895 assert_eq!(
4896 requests[1].messages[3],
4897 Message::tool_result("c1", "denied by policy"),
4898 );
4899 }
4900
4901 #[tokio::test]
4905 async fn custom_tool_round_executor_streaming() {
4906 #[derive(Default)]
4907 struct ReversedToolRoundExecutor;
4908 #[async_trait::async_trait]
4909 impl ToolRoundExecutor for ReversedToolRoundExecutor {
4910 async fn execute_round<'a>(
4911 &'a mut self,
4912 ctx: ToolRoundCtx<'a>,
4913 calls: Vec<ToolCall>,
4914 ) -> BoxStream<'a, ToolCallOutcome> {
4915 let mut outcomes = Vec::with_capacity(calls.len());
4916 for call in calls.into_iter().rev() {
4917 outcomes.push(ctx.run(call).await);
4918 }
4919 Box::pin(futures::stream::iter(outcomes))
4920 }
4921 }
4922
4923 let (alpha_tool, _) = FakeTool::new("alpha", "A");
4924 let (beta_tool, _) = FakeTool::new("beta", "B");
4925 let mut registry = ToolRegistry::new();
4926 registry.register(alpha_tool);
4927 registry.register(beta_tool);
4928 let fake = SharedFake::new([
4929 FakeReply::ToolCalls {
4930 content: "".into(),
4931 calls: vec![call("c1", "alpha", "{}"), call("c2", "beta", "{}")],
4932 },
4933 FakeReply::Text("done".into()),
4934 ]);
4935 let mut agent = agent_with_registry(fake.clone(), registry, AgentConfig::default())
4936 .with_tool_round_executor(ReversedToolRoundExecutor);
4937
4938 let mut stream = agent.run_stream("Compute").await.unwrap();
4939 let mut results = Vec::new();
4940 let mut done = false;
4941 while let Some(item) = stream.next().await {
4942 match item.unwrap() {
4943 MessageChunk::ToolResult { id, content, .. } => {
4944 results.push((id, content));
4945 }
4946 MessageChunk::Done(_) => done = true,
4947 _ => {}
4948 }
4949 }
4950 assert!(done);
4951 assert_eq!(
4953 results,
4954 vec![
4955 ("c2".to_string(), "B".to_string()),
4956 ("c1".to_string(), "A".to_string())
4957 ]
4958 );
4959 }
4960
4961 #[tokio::test]
4962 async fn kernel_batches_effect_requests_from_same_round() {
4963 let mut registry = ToolRegistry::new();
4964 registry
4965 .register(EffectTool::new("read_a", "effect-a", "read A"))
4966 .register(EffectTool::new("read_b", "effect-b", "read B"));
4967 let fake = SharedFake::new([
4968 FakeReply::ToolCalls {
4969 content: "".into(),
4970 calls: vec![call("c1", "read_a", "{}"), call("c2", "read_b", "{}")],
4971 },
4972 FakeReply::Text("done".into()),
4973 ]);
4974 let mut agent = agent_with_registry(fake.clone(), registry, AgentConfig::default());
4975 let context = RunContext::new("kernel-batch-effects");
4976
4977 let action = agent
4978 .start(RunRequest::text("read both"), &context)
4979 .await
4980 .unwrap();
4981 let AgentAction::RequestModel { request } = action else {
4982 panic!("expected initial model request");
4983 };
4984 let action = agent
4985 .observe(
4986 Observation::Model(ModelObservation::new(
4987 request.id,
4988 fake.chat(request.chat).await.unwrap(),
4989 )),
4990 &context,
4991 )
4992 .await
4993 .unwrap();
4994 let AgentAction::RequestEffects { requests } = action else {
4995 panic!("expected batch effect request");
4996 };
4997 assert_eq!(
4998 requests
4999 .iter()
5000 .map(|request| request.id.as_str())
5001 .collect::<Vec<_>>(),
5002 vec!["effect-a", "effect-b"]
5003 );
5004 assert_eq!(
5005 requests
5006 .iter()
5007 .map(|request| request.source.tool_call_id.as_deref())
5008 .collect::<Vec<_>>(),
5009 vec![Some("c1"), Some("c2")]
5010 );
5011
5012 let action = agent
5015 .observe(
5016 Observation::Effects(vec![
5017 EffectObservation::succeeded("effect-b", "observed B"),
5018 EffectObservation::succeeded("effect-a", "observed A"),
5019 ]),
5020 &context,
5021 )
5022 .await
5023 .unwrap();
5024 let AgentAction::RequestModel { request } = action else {
5025 panic!("expected next model request");
5026 };
5027 assert!(matches!(
5028 &request.chat.messages[2],
5029 Message::ToolResult { id, content } if id == "c1" && content == "observed A"
5030 ));
5031 assert!(matches!(
5032 &request.chat.messages[3],
5033 Message::ToolResult { id, content } if id == "c2" && content == "observed B"
5034 ));
5035
5036 let action = agent
5037 .observe(
5038 Observation::Model(ModelObservation::new(
5039 request.id,
5040 fake.chat(request.chat).await.unwrap(),
5041 )),
5042 &context,
5043 )
5044 .await
5045 .unwrap();
5046 let AgentAction::Respond { output } = action else {
5047 panic!("expected final response");
5048 };
5049 assert_eq!(output.answer, "done");
5050 }
5051
5052 #[tokio::test]
5053 async fn kernel_records_mixed_outputs_and_effects_in_tool_call_order() {
5054 let mut registry = ToolRegistry::new();
5055 registry
5056 .register(FakeTool::new("before", "plain before").0)
5057 .register(EffectTool::new("read", "effect-read", "read"))
5058 .register(FakeTool::new("after", "plain after").0);
5059 let fake = SharedFake::new([
5060 FakeReply::ToolCalls {
5061 content: "".into(),
5062 calls: vec![
5063 call("c1", "before", "{}"),
5064 call("c2", "read", "{}"),
5065 call("c3", "after", "{}"),
5066 ],
5067 },
5068 FakeReply::Text("done".into()),
5069 ]);
5070 let mut agent = agent_with_registry(fake.clone(), registry, AgentConfig::default());
5071 let context = RunContext::new("kernel-mixed-effects");
5072
5073 let AgentAction::RequestModel { request } = agent
5074 .start(RunRequest::text("read with context"), &context)
5075 .await
5076 .unwrap()
5077 else {
5078 panic!("expected initial model request");
5079 };
5080 let AgentAction::RequestEffect { request } = agent
5081 .observe(
5082 Observation::Model(ModelObservation::new(
5083 request.id,
5084 fake.chat(request.chat).await.unwrap(),
5085 )),
5086 &context,
5087 )
5088 .await
5089 .unwrap()
5090 else {
5091 panic!("expected single effect request");
5092 };
5093 assert_eq!(request.id, "effect-read");
5094
5095 let action = agent
5096 .observe(
5097 Observation::Effect(EffectObservation::succeeded("effect-read", "observed read")),
5098 &context,
5099 )
5100 .await
5101 .unwrap();
5102 let AgentAction::RequestModel { request } = action else {
5103 panic!("expected next model request");
5104 };
5105 assert!(matches!(
5106 &request.chat.messages[2],
5107 Message::ToolResult { id, content } if id == "c1" && content == "plain before"
5108 ));
5109 assert!(matches!(
5110 &request.chat.messages[3],
5111 Message::ToolResult { id, content } if id == "c2" && content == "observed read"
5112 ));
5113 assert!(matches!(
5114 &request.chat.messages[4],
5115 Message::ToolResult { id, content } if id == "c3" && content == "plain after"
5116 ));
5117 }
5118
5119 #[tokio::test]
5120 async fn kernel_rejects_partial_effect_batch_observation() {
5121 let mut registry = ToolRegistry::new();
5122 registry
5123 .register(EffectTool::new("read_a", "effect-a", "read A"))
5124 .register(EffectTool::new("read_b", "effect-b", "read B"));
5125 let fake = SharedFake::new([FakeReply::ToolCalls {
5126 content: "".into(),
5127 calls: vec![call("c1", "read_a", "{}"), call("c2", "read_b", "{}")],
5128 }]);
5129 let mut agent = agent_with_registry(fake.clone(), registry, AgentConfig::default());
5130 let context = RunContext::new("kernel-batch-effects-partial");
5131
5132 let AgentAction::RequestModel { request } = agent
5133 .start(RunRequest::text("read both"), &context)
5134 .await
5135 .unwrap()
5136 else {
5137 panic!("expected initial model request");
5138 };
5139 let AgentAction::RequestEffects { .. } = agent
5140 .observe(
5141 Observation::Model(ModelObservation::new(
5142 request.id,
5143 fake.chat(request.chat).await.unwrap(),
5144 )),
5145 &context,
5146 )
5147 .await
5148 .unwrap()
5149 else {
5150 panic!("expected batch effect request");
5151 };
5152 let err = agent
5153 .observe(
5154 Observation::Effects(vec![EffectObservation::succeeded("effect-a", "observed A")]),
5155 &context,
5156 )
5157 .await
5158 .unwrap_err();
5159 assert!(
5160 matches!(err, AgentError::InvalidStep(message) if message.contains("count mismatch"))
5161 );
5162 }
5163
5164 #[tokio::test]
5165 async fn kernel_rejects_duplicate_effect_batch_observation_and_can_retry() {
5166 let mut registry = ToolRegistry::new();
5167 registry
5168 .register(EffectTool::new("read_a", "effect-a", "read A"))
5169 .register(EffectTool::new("read_b", "effect-b", "read B"));
5170 let fake = SharedFake::new([
5171 FakeReply::ToolCalls {
5172 content: "".into(),
5173 calls: vec![call("c1", "read_a", "{}"), call("c2", "read_b", "{}")],
5174 },
5175 FakeReply::Text("done".into()),
5176 ]);
5177 let mut agent = agent_with_registry(fake.clone(), registry, AgentConfig::default());
5178 let context = RunContext::new("kernel-batch-effects-duplicate");
5179
5180 let AgentAction::RequestModel { request } = agent
5181 .start(RunRequest::text("read both"), &context)
5182 .await
5183 .unwrap()
5184 else {
5185 panic!("expected initial model request");
5186 };
5187 let AgentAction::RequestEffects { .. } = agent
5188 .observe(
5189 Observation::Model(ModelObservation::new(
5190 request.id,
5191 fake.chat(request.chat).await.unwrap(),
5192 )),
5193 &context,
5194 )
5195 .await
5196 .unwrap()
5197 else {
5198 panic!("expected batch effect request");
5199 };
5200 let err = agent
5201 .observe(
5202 Observation::Effects(vec![
5203 EffectObservation::succeeded("effect-a", "observed A"),
5204 EffectObservation::succeeded("effect-a", "observed A again"),
5205 ]),
5206 &context,
5207 )
5208 .await
5209 .unwrap_err();
5210 assert!(matches!(err, AgentError::InvalidStep(message) if message.contains("duplicate")));
5211
5212 let action = agent
5213 .observe(
5214 Observation::Effects(vec![
5215 EffectObservation::succeeded("effect-b", "observed B"),
5216 EffectObservation::succeeded("effect-a", "observed A"),
5217 ]),
5218 &context,
5219 )
5220 .await
5221 .unwrap();
5222 let AgentAction::RequestModel { request } = action else {
5223 panic!("expected next model request");
5224 };
5225 assert!(matches!(
5226 &request.chat.messages[2],
5227 Message::ToolResult { id, content } if id == "c1" && content == "observed A"
5228 ));
5229 assert!(matches!(
5230 &request.chat.messages[3],
5231 Message::ToolResult { id, content } if id == "c2" && content == "observed B"
5232 ));
5233 }
5234
5235 #[tokio::test]
5239 async fn stream_cancel_mid_generation() {
5240 struct SlowStreamProvider;
5243 #[async_trait::async_trait]
5244 impl Provider for SlowStreamProvider {
5245 async fn chat_with_context(
5246 &self,
5247 _request: ChatRequest,
5248 _context: &ProviderRequestContext,
5249 ) -> Result<ChatResponse, ProviderError> {
5250 unreachable!("this test uses streaming path only")
5251 }
5252 async fn stream_chat_with_context(
5253 &self,
5254 _request: ChatRequest,
5255 _context: &ProviderRequestContext,
5256 ) -> Result<BoxStream<'static, Result<StreamEvent, ProviderError>>, ProviderError>
5257 {
5258 Ok(Box::pin(async_stream::stream! {
5259 yield Ok(StreamEvent::Delta("d0".into()));
5260 tokio::time::sleep(Duration::from_millis(100)).await;
5261 yield Ok(StreamEvent::Delta("d1".into()));
5262 tokio::time::sleep(Duration::from_millis(100)).await;
5263 yield Ok(StreamEvent::Done {
5264 reason: FinishReason::Stop,
5265 usage: None,
5266 });
5267 }))
5268 }
5269 }
5270
5271 let mut agent = ReActAgent::new(SlowStreamProvider, ToolRegistry::new(), "");
5272 let token = CancellationToken::new();
5273 let mut stream = agent
5274 .run_stream_request_with_context(RunRequest::text("hi"), cancellation_context(&token))
5275 .await
5276 .unwrap();
5277
5278 let mut first = Vec::new();
5281 tokio::select! {
5282 _ = async {
5283 while let Some(ev) = stream.next().await {
5284 let ev = ev.unwrap();
5285 if ev == MessageChunk::Cancelled {
5286 break;
5287 }
5288 first.push(ev);
5289 }
5290 } => {}
5291 _ = async {
5292 tokio::time::sleep(Duration::from_millis(50)).await;
5293 token.cancel();
5294 } => {}
5295 }
5296 assert_eq!(first, vec![MessageChunk::Delta("d0".into())]);
5297
5298 let rest: Vec<MessageChunk> = stream.by_ref().map(|e| e.unwrap()).collect().await;
5301 assert_eq!(rest, vec![MessageChunk::Cancelled]);
5302 drop(stream); assert_eq!(agent.memory.context().await.unwrap().len(), 1);
5305 }
5306
5307 #[tokio::test]
5311 async fn cancelled_then_fresh_token_run_works() {
5312 let fake = SharedFake::new([FakeReply::Text("hi".into())]);
5313 let mut agent = agent(fake, "");
5314
5315 let t1 = CancellationToken::new();
5316 t1.cancel();
5317 assert!(matches!(
5318 agent
5319 .run_request_with_context(RunRequest::text("one"), cancellation_context(&t1))
5320 .await,
5321 Err(AgentError::Cancelled)
5322 ));
5323
5324 let t2 = CancellationToken::new();
5325 assert_eq!(
5326 agent
5327 .run_request_with_context(RunRequest::text("two"), cancellation_context(&t2))
5328 .await
5329 .unwrap()
5330 .answer,
5331 "hi"
5332 );
5333 }
5334
5335 use crate::event_channel::{BroadcastEventChannel, EventReceiver};
5339 use crate::tool::RegistryError;
5340
5341 fn attach_channel(agent: ReActAgent) -> (ReActAgent, Box<dyn EventReceiver>) {
5345 let channel = BroadcastEventChannel::new(64);
5346 let rx = channel.subscribe();
5347 (agent.with_event_channel(channel), rx)
5348 }
5349
5350 async fn drain(rx: &mut Box<dyn EventReceiver>) -> Vec<Arc<dyn AgentEvent>> {
5352 let mut out = Vec::new();
5353 while let Some(ev) = rx.recv().await {
5354 out.push(ev);
5355 }
5356 out
5357 }
5358
5359 fn names(events: &[Arc<dyn AgentEvent>]) -> Vec<&'static str> {
5360 events.iter().map(|e| e.name()).collect()
5361 }
5362
5363 fn react_event(ev: &dyn AgentEvent) -> &ReActEvent {
5366 ev.as_any()
5367 .downcast_ref::<ReActEvent>()
5368 .expect("test event should be ReActEvent")
5369 }
5370
5371 #[tokio::test]
5375 async fn events_published_on_run() {
5376 let (calc, _calls) = FakeTool::new("calc", "42");
5377 let mut registry = ToolRegistry::new();
5378 registry.register(calc);
5379 let fake = SharedFake::new([
5380 FakeReply::ToolCalls {
5381 content: "".into(),
5382 calls: vec![call("c1", "calc", r#"{"a":1}"#)],
5383 },
5384 FakeReply::Text("The answer is 42".into()),
5385 ]);
5386 let (mut agent, mut rx) =
5387 attach_channel(agent_with_registry(fake, registry, AgentConfig::default()));
5388 let answer = agent.run("Compute").await.unwrap();
5389 assert_eq!(answer, "The answer is 42");
5390 drop(agent);
5391 let events = drain(&mut rx).await;
5392
5393 assert_eq!(
5394 names(&events),
5395 ["run.started", "tool.started", "tool.completed", "run.ended"]
5396 );
5397 match react_event(&*events[1]) {
5400 ReActEvent::ToolStarted {
5403 id,
5404 name,
5405 arguments,
5406 } => {
5407 assert_eq!(id, "c1");
5408 assert_eq!(name, "calc");
5409 assert_eq!(arguments, r#"{"a":1}"#);
5410 }
5411 _ => panic!("expected ToolStarted"),
5412 }
5413 match react_event(&*events[2]) {
5414 ReActEvent::ToolCompleted { result, .. } => {
5416 assert_eq!(result, &Ok(ToolOutput::text("42").into()));
5417 }
5418 _ => panic!("expected ToolCompleted"),
5419 }
5420 match react_event(&*events[3]) {
5421 ReActEvent::RunEnded { summary, error } => {
5424 assert_eq!(error, &None);
5425 assert_eq!(summary.rounds, 2);
5426 assert_eq!(summary.tool_calls, 1);
5427 }
5428 _ => panic!("expected RunEnded"),
5429 }
5430 }
5431
5432 #[tokio::test]
5433 async fn run_started_event_preserves_block_input() {
5434 let blocks = vec![ContentBlock::Text("look".into())];
5435 let fake = SharedFake::new([FakeReply::Text("done".into())]);
5436 let (mut agent, mut rx) = attach_channel(agent(fake, ""));
5437 agent
5438 .run_request(RunRequest::blocks(blocks.clone()))
5439 .await
5440 .unwrap();
5441 drop(agent);
5442 let events = drain(&mut rx).await;
5443
5444 match react_event(&*events[0]) {
5445 ReActEvent::RunStarted { input, .. } => {
5446 assert_eq!(input, &crate::UserInput::Blocks(blocks));
5447 }
5448 _ => panic!("expected RunStarted"),
5449 }
5450 }
5451
5452 #[tokio::test]
5456 async fn events_carry_tool_failure() {
5457 let fake = SharedFake::new([
5458 FakeReply::ToolCalls {
5459 content: "".into(),
5460 calls: vec![call("c1", "nope", "{}")],
5461 },
5462 FakeReply::Text("Got it".into()),
5463 ]);
5464 let (mut agent, mut rx) = attach_channel(agent(fake, ""));
5465 agent.run("Trigger").await.unwrap();
5466 drop(agent);
5467
5468 let events = drain(&mut rx).await;
5469 let completed = events
5470 .iter()
5471 .find_map(|e| match react_event(&**e) {
5472 ReActEvent::ToolCompleted { result, .. } => Some(result),
5473 _ => None,
5474 })
5475 .expect("expected ToolCompleted event");
5476 assert!(matches!(completed, Err(RegistryError::NotFound(n)) if n == "nope"));
5477 assert_eq!(
5478 completed.as_ref().unwrap_err().to_string(),
5479 "tool not found: nope"
5480 );
5481 }
5482
5483 #[tokio::test]
5486 async fn stream_events_include_delta_and_reasoning() {
5487 let (calc, _calls) = FakeTool::new("calc", "42");
5488 let mut registry = ToolRegistry::new();
5489 registry.register(calc);
5490 let fake = SharedFake::new([
5491 FakeReply::ToolCalls {
5492 content: "Thinking: ".into(),
5493 calls: vec![call("c1", "calc", "{}")],
5494 },
5495 FakeReply::TextWithReasoning {
5496 content: "The answer is 42".into(),
5497 reasoning: "Reasoning steps".into(),
5498 },
5499 ]);
5500 let (mut agent, mut rx) =
5501 attach_channel(agent_with_registry(fake, registry, AgentConfig::default()));
5502
5503 let answer: String = agent
5505 .run_stream("Compute")
5506 .await
5507 .unwrap()
5508 .map(|e| e.unwrap())
5509 .filter_map(|e| async move {
5510 match e {
5511 MessageChunk::Delta(d) => Some(d),
5512 _ => None,
5513 }
5514 })
5515 .collect()
5516 .await;
5517 assert_eq!(answer, "Thinking: The answer is 42");
5518 drop(agent);
5519
5520 let events = drain(&mut rx).await;
5521 assert_eq!(
5525 names(&events),
5526 [
5527 "run.started",
5528 "delta",
5529 "tool.started",
5530 "tool.completed",
5531 "delta",
5532 "reasoning",
5533 "run.ended",
5534 ]
5535 );
5536 let reasoning = events
5537 .iter()
5538 .find_map(|e| match react_event(&**e) {
5539 ReActEvent::Reasoning { text } => Some(text.as_str()),
5540 _ => None,
5541 })
5542 .expect("expected Reasoning event");
5543 assert_eq!(reasoning, "Reasoning steps");
5544 match react_event(&**events.last().unwrap()) {
5545 ReActEvent::RunEnded { summary, error } => {
5546 assert_eq!(error, &None);
5547 assert_eq!(summary.rounds, 2);
5548 assert_eq!(summary.tool_calls, 1);
5549 }
5550 _ => panic!("expected RunEnded"),
5551 }
5552 }
5553
5554 #[tokio::test]
5557 async fn cancelled_run_publishes_run_ended_with_error() {
5558 struct SlowStreamProvider;
5560 #[async_trait::async_trait]
5561 impl Provider for SlowStreamProvider {
5562 async fn chat_with_context(
5563 &self,
5564 _request: ChatRequest,
5565 _context: &ProviderRequestContext,
5566 ) -> Result<ChatResponse, ProviderError> {
5567 unreachable!("this test uses streaming path only")
5568 }
5569 async fn stream_chat_with_context(
5570 &self,
5571 _request: ChatRequest,
5572 _context: &ProviderRequestContext,
5573 ) -> Result<BoxStream<'static, Result<StreamEvent, ProviderError>>, ProviderError>
5574 {
5575 Ok(Box::pin(async_stream::stream! {
5576 yield Ok(StreamEvent::Delta("d0".into()));
5577 tokio::time::sleep(Duration::from_millis(100)).await;
5578 yield Ok(StreamEvent::Delta("d1".into()));
5579 tokio::time::sleep(Duration::from_millis(100)).await;
5580 yield Ok(StreamEvent::Done {
5581 reason: FinishReason::Stop,
5582 usage: None,
5583 });
5584 }))
5585 }
5586 }
5587
5588 let (mut agent, mut rx) =
5589 attach_channel(ReActAgent::new(SlowStreamProvider, ToolRegistry::new(), ""));
5590 let token = CancellationToken::new();
5591 let mut stream = agent
5592 .run_stream_request_with_context(RunRequest::text("hi"), cancellation_context(&token))
5593 .await
5594 .unwrap();
5595
5596 tokio::select! {
5601 _ = async {
5602 while let Some(ev) = stream.next().await {
5603 let ev = ev.unwrap();
5604 if ev == MessageChunk::Cancelled {
5605 break;
5606 }
5607 }
5608 } => {}
5609 _ = async {
5610 tokio::time::sleep(Duration::from_millis(50)).await;
5611 token.cancel();
5612 } => {}
5613 }
5614 let rest: Vec<MessageChunk> = stream.by_ref().map(|e| e.unwrap()).collect().await;
5615 assert!(rest.contains(&MessageChunk::Cancelled));
5616 drop(stream);
5617 drop(agent);
5618
5619 let events = drain(&mut rx).await;
5620 assert!(matches!(names(&events).as_slice(), [.., "run.ended"]));
5621 match react_event(&**events.last().unwrap()) {
5622 ReActEvent::RunEnded { error, .. } => {
5623 assert_eq!(error, &Some(AgentError::Cancelled));
5624 }
5625 _ => panic!("expected RunEnded"),
5626 }
5627 }
5628
5629 #[cfg(feature = "tracing")]
5630 mod tracing_tests {
5631 use super::*;
5632
5633 use std::collections::HashMap;
5637 use std::sync::atomic::AtomicU64;
5638 use tracing::field::{Field, Visit};
5639 use tracing::subscriber::Subscriber;
5640 use tracing::{Event, Id, Level, Metadata};
5641
5642 #[derive(Debug, Clone, PartialEq, Eq)]
5645 struct SpanInfo {
5646 name: &'static str,
5647 level: Level,
5648 fields: Vec<(String, String)>,
5649 }
5650
5651 #[derive(Debug, Clone, PartialEq, Eq)]
5654 enum Op {
5655 Enter(String),
5656 Exit(String),
5657 Close(String),
5658 Record(String, String),
5659 }
5660
5661 #[derive(Debug, Default)]
5668 struct CollectSubscriber {
5669 spans: std::sync::Mutex<Vec<SpanInfo>>,
5670 ops: std::sync::Mutex<Vec<Op>>,
5671 names: std::sync::Mutex<HashMap<Id, String>>,
5672 next_id: AtomicU64,
5673 }
5674
5675 struct FieldCollector<'a>(&'a mut Vec<(String, String)>);
5678
5679 impl Visit for FieldCollector<'_> {
5680 fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) {
5681 self.0
5682 .push((field.name().to_string(), format!("{value:?}")));
5683 }
5684 }
5685
5686 impl Subscriber for CollectSubscriber {
5687 fn enabled(&self, _metadata: &Metadata<'_>) -> bool {
5688 true
5689 }
5690
5691 fn new_span(&self, span: &tracing::span::Attributes<'_>) -> Id {
5692 let id = Id::from_u64(self.next_id.fetch_add(1, Ordering::Relaxed) + 1);
5695 let mut fields = Vec::new();
5696 span.record(&mut FieldCollector(&mut fields));
5697 self.spans.lock().unwrap().push(SpanInfo {
5698 name: span.metadata().name(),
5699 level: *span.metadata().level(),
5700 fields,
5701 });
5702 self.names
5703 .lock()
5704 .unwrap()
5705 .insert(id.clone(), span.metadata().name().to_string());
5706 id
5707 }
5708
5709 fn record(&self, id: &Id, values: &tracing::span::Record<'_>) {
5710 let name = self.names.lock().unwrap().get(id).cloned();
5711 let Some(name) = name else { return };
5712 let mut fields = Vec::new();
5713 values.record(&mut FieldCollector(&mut fields));
5714 let mut ops = self.ops.lock().unwrap();
5715 for (field, value) in fields {
5716 ops.push(Op::Record(name.clone(), format!("{field}={value}")));
5717 }
5718 }
5719
5720 fn enter(&self, id: &Id) {
5721 let name = self.names.lock().unwrap().get(id).cloned();
5722 if let Some(name) = name {
5723 self.ops.lock().unwrap().push(Op::Enter(name));
5724 }
5725 }
5726
5727 fn exit(&self, id: &Id) {
5728 let name = self.names.lock().unwrap().get(id).cloned();
5729 if let Some(name) = name {
5730 self.ops.lock().unwrap().push(Op::Exit(name));
5731 }
5732 }
5733
5734 fn try_close(&self, id: Id) -> bool {
5735 let name = self.names.lock().unwrap().get(&id).cloned();
5736 if let Some(name) = name {
5737 self.ops.lock().unwrap().push(Op::Close(name));
5738 }
5739 true
5740 }
5741
5742 fn clone_span(&self, id: &Id) -> Id {
5743 id.clone()
5744 }
5745
5746 fn event(&self, _event: &Event<'_>) {}
5747 fn record_follows_from(&self, _span: &Id, _follows: &Id) {}
5748 }
5749
5750 fn collect_guard(sub: &Arc<CollectSubscriber>) -> tracing::dispatcher::DefaultGuard {
5753 tracing::dispatcher::set_default(&tracing::Dispatch::new(sub.clone()))
5754 }
5755
5756 fn enter_names(ops: &[Op]) -> Vec<String> {
5757 ops.iter()
5758 .filter_map(|op| match op {
5759 Op::Enter(name) => Some(name.clone()),
5760 _ => None,
5761 })
5762 .collect()
5763 }
5764
5765 fn records_of(ops: &[Op], span: &str) -> Vec<String> {
5766 ops.iter()
5767 .filter_map(|op| match op {
5768 Op::Record(s, kv) if s == span => Some(kv.clone()),
5769 _ => None,
5770 })
5771 .collect()
5772 }
5773
5774 fn assert_nesting_invariants(ops: &[Op]) {
5780 let mut stack: Vec<String> = Vec::new();
5781 for op in ops {
5782 match op {
5783 Op::Enter(name) => {
5784 if name != "agent.run" {
5785 assert!(
5786 stack.contains(&"agent.run".to_string()),
5787 "span {name} requires agent.run on the stack when entering (stack: {stack:?})"
5788 );
5789 }
5790 assert!(
5791 !stack.contains(name),
5792 "same span entered twice (double instrumenting): {name} (stack: {stack:?})"
5793 );
5794 stack.push(name.clone());
5795 }
5796 Op::Exit(name) => {
5797 assert_eq!(
5798 stack.pop().as_deref(),
5799 Some(name.as_str()),
5800 "exit must pair with enter: {name}"
5801 );
5802 }
5803 _ => {}
5804 }
5805 }
5806 }
5807
5808 #[tokio::test(flavor = "current_thread")]
5813 async fn trace_span_tree_non_stream() {
5814 let sub = Arc::new(CollectSubscriber::default());
5815 let _guard = collect_guard(&sub);
5816
5817 let (calc, _calls) = FakeTool::new("calc", "42");
5818 let mut registry = ToolRegistry::new();
5819 registry.register(calc);
5820 let fake = SharedFake::new([
5821 FakeReply::WithUsage {
5822 reply: Box::new(FakeReply::ToolCalls {
5823 content: "".into(),
5824 calls: vec![call("c1", "calc", "{}")],
5825 }),
5826 usage: Usage::new(10, 2),
5827 },
5828 FakeReply::text_with_usage("42", Usage::new(20, 5)),
5829 ]);
5830 let mut agent = agent_with_registry(fake, registry, AgentConfig::default());
5831 assert_eq!(agent.run("Compute").await.unwrap(), "42");
5832
5833 let ops = sub.ops.lock().unwrap().clone();
5834 assert_nesting_invariants(&ops);
5837 let enters = enter_names(&ops);
5844 let mut first_seen = Vec::new();
5845 for name in enters.iter() {
5846 if !first_seen.contains(name) {
5847 first_seen.push(name.clone());
5848 }
5849 }
5850 assert_eq!(first_seen, ["agent.run", "llm_request", "tool"]);
5851 assert!(enters.iter().filter(|n| *n == "llm_request").count() >= 2);
5852 assert!(enters.iter().filter(|n| *n == "tool").count() >= 1);
5853
5854 assert_eq!(
5857 records_of(&ops, "llm_request"),
5858 [
5859 "usage.prompt_tokens=10",
5860 "usage.completion_tokens=2",
5861 "usage.prompt_tokens=20",
5862 "usage.completion_tokens=5",
5863 ]
5864 );
5865
5866 let spans = sub.spans.lock().unwrap().clone();
5869 let run_span = spans.iter().find(|s| s.name == "agent.run").unwrap();
5870 assert_eq!(run_span.level, Level::INFO);
5871 assert_eq!(
5872 spans
5873 .iter()
5874 .find(|s| s.name == "llm_request")
5875 .unwrap()
5876 .level,
5877 Level::DEBUG
5878 );
5879 assert_eq!(
5880 spans.iter().find(|s| s.name == "tool").unwrap().level,
5881 Level::DEBUG
5882 );
5883
5884 let llm_rounds: Vec<u64> = spans
5887 .iter()
5888 .filter(|s| s.name == "llm_request")
5889 .map(|s| {
5890 s.fields
5891 .iter()
5892 .find(|(f, _)| f == "round")
5893 .map(|(_, v)| v.parse().unwrap())
5894 .unwrap()
5895 })
5896 .collect();
5897 assert_eq!(llm_rounds, vec![1, 2]);
5898
5899 let run_ids: Vec<String> = spans
5902 .iter()
5903 .map(|s| {
5904 s.fields
5905 .iter()
5906 .find(|(f, _)| f == "run.id")
5907 .map(|(_, v)| v.clone())
5908 .unwrap_or_else(|| panic!("span {} must carry a run.id field", s.name))
5909 })
5910 .collect();
5911 assert!(run_ids.iter().all(|id| id == &run_ids[0]));
5912 assert!(run_ids[0].starts_with("run-"));
5913 }
5914
5915 #[tokio::test(flavor = "current_thread")]
5919 async fn trace_span_tree_stream() {
5920 let sub = Arc::new(CollectSubscriber::default());
5921 let _guard = collect_guard(&sub);
5922
5923 let (calc, _calls) = FakeTool::new("calc", "42");
5924 let mut registry = ToolRegistry::new();
5925 registry.register(calc);
5926 let fake = SharedFake::new([
5927 FakeReply::WithUsage {
5928 reply: Box::new(FakeReply::ToolCalls {
5929 content: "".into(),
5930 calls: vec![call("c1", "calc", "{}")],
5931 }),
5932 usage: Usage::new(10, 2),
5933 },
5934 FakeReply::text_with_usage("42", Usage::new(20, 5)),
5935 ]);
5936 let mut agent = agent_with_registry(fake, registry, AgentConfig::default());
5937 agent
5938 .run_stream("Compute")
5939 .await
5940 .unwrap()
5941 .for_each(|_| async {})
5942 .await;
5943
5944 let ops = sub.ops.lock().unwrap().clone();
5945 assert_nesting_invariants(&ops);
5949 let enters = enter_names(&ops);
5954 let mut first_seen = Vec::new();
5955 for name in enters.iter() {
5956 if !first_seen.contains(name) {
5957 first_seen.push(name.clone());
5958 }
5959 }
5960 assert_eq!(first_seen, ["agent.run", "llm_request", "tool"]);
5961 assert!(enters.iter().filter(|n| *n == "agent.run").count() > 2);
5964
5965 assert_eq!(
5967 records_of(&ops, "llm_request"),
5968 [
5969 "usage.prompt_tokens=10",
5970 "usage.completion_tokens=2",
5971 "usage.prompt_tokens=20",
5972 "usage.completion_tokens=5",
5973 ]
5974 );
5975 }
5976
5977 #[tokio::test(flavor = "current_thread")]
5981 async fn trace_run_id_differs_between_runs() {
5982 let sub = Arc::new(CollectSubscriber::default());
5983 let _guard = collect_guard(&sub);
5984
5985 let fake =
5986 SharedFake::new([FakeReply::Text("hi".into()), FakeReply::Text("bye".into())]);
5987 let (mut agent, mut rx) = attach_channel(agent(fake, ""));
5988 agent.run("one").await.unwrap();
5989 agent.run("two").await.unwrap();
5990 drop(agent);
5991
5992 let spans = sub.spans.lock().unwrap().clone();
5993 let run_ids: Vec<String> = spans
5994 .iter()
5995 .filter(|s| s.name == "agent.run")
5996 .map(|s| {
5997 s.fields
5998 .iter()
5999 .find(|(f, _)| f == "run.id")
6000 .map(|(_, v)| v.clone())
6001 .unwrap()
6002 })
6003 .collect();
6004 assert_eq!(run_ids.len(), 2);
6005 assert_ne!(run_ids[0], run_ids[1]);
6006
6007 let events = drain(&mut rx).await;
6010 let event_ids: Vec<String> = events
6011 .iter()
6012 .filter_map(|e| match react_event(&**e) {
6013 ReActEvent::RunStarted { run_id, .. } => Some(run_id.clone()),
6014 _ => None,
6015 })
6016 .collect();
6017 assert_eq!(event_ids, run_ids);
6018 }
6019
6020 #[tokio::test(flavor = "current_thread")]
6023 async fn trace_run_error_recorded() {
6024 let sub = Arc::new(CollectSubscriber::default());
6025 let _guard = collect_guard(&sub);
6026
6027 let (calc, _calls) = FakeTool::new("calc", "42");
6028 let mut registry = ToolRegistry::new();
6029 registry.register(calc);
6030 let fake = SharedFake::new([
6031 FakeReply::ToolCalls {
6032 content: "".into(),
6033 calls: vec![call("c1", "calc", "{}")],
6034 },
6035 FakeReply::ToolCalls {
6036 content: "".into(),
6037 calls: vec![call("c2", "calc", "{}")],
6038 },
6039 ]);
6040 let mut agent = agent_with_registry(
6041 fake,
6042 registry,
6043 AgentConfig {
6044 max_tool_rounds: 2,
6045 ..Default::default()
6046 },
6047 );
6048 assert!(matches!(
6049 agent.run("Keep computing").await,
6050 Err(AgentError::TooManyToolRounds(2))
6051 ));
6052
6053 let ops = sub.ops.lock().unwrap().clone();
6054 let records = records_of(&ops, "agent.run");
6058 assert_eq!(records.len(), 1);
6059 assert!(
6060 records[0].starts_with("error=\"model requested tools for more than 2 rounds"),
6061 "unexpected records: {records:?}"
6062 );
6063 }
6064
6065 #[tokio::test(flavor = "current_thread")]
6068 async fn trace_tool_error_recorded() {
6069 struct FailingTool;
6070 #[async_trait::async_trait]
6071 impl Tool for FailingTool {
6072 fn schema(&self) -> ToolSchema {
6073 ToolSchema::new("boom", "Tool that always fails", serde_json::json!({}))
6074 }
6075 async fn call(
6076 &self,
6077 _arguments: serde_json::Value,
6078 _context: ToolContext<'_>,
6079 ) -> Result<ToolResult, ToolError> {
6080 Err(ToolError::Execution("internal error".into()))
6081 }
6082 }
6083
6084 let sub = Arc::new(CollectSubscriber::default());
6085 let _guard = collect_guard(&sub);
6086
6087 let mut registry = ToolRegistry::new();
6088 registry.register(FailingTool);
6089 let fake = SharedFake::new([
6090 FakeReply::ToolCalls {
6091 content: "".into(),
6092 calls: vec![call("c1", "boom", "{}")],
6093 },
6094 FakeReply::Text("Got it".into()),
6095 ]);
6096 let mut agent = agent_with_registry(fake, registry, AgentConfig::default());
6097 agent.run("Trigger failure").await.unwrap();
6098
6099 let ops = sub.ops.lock().unwrap().clone();
6100 let tool_errors = records_of(&ops, "tool");
6103 assert_eq!(tool_errors.len(), 1);
6104 assert!(tool_errors[0].contains("internal error"));
6105 assert!(records_of(&ops, "agent.run").is_empty());
6106 }
6107
6108 #[tokio::test(flavor = "current_thread")]
6112 async fn trace_llm_error_recorded() {
6113 let sub = Arc::new(CollectSubscriber::default());
6114 let _guard = collect_guard(&sub);
6115
6116 let fake = SharedFake::new([FakeReply::Text("hi".into())]);
6118 let mut agent = agent(fake, "");
6119 agent.run("one").await.unwrap();
6120 let err = agent.run("two").await.unwrap_err();
6121 assert!(matches!(err, AgentError::Provider(_)));
6122
6123 let ops = sub.ops.lock().unwrap().clone();
6124 let llm_records = records_of(&ops, "llm_request");
6128 assert!(
6129 llm_records.iter().any(|r| r.starts_with("error=")),
6130 "the failed round's llm span should have an error: {llm_records:?}"
6131 );
6132 assert!(
6134 records_of(&ops, "agent.run")
6135 .iter()
6136 .any(|r| r.starts_with("error="))
6137 );
6138 }
6139
6140 #[tokio::test(flavor = "current_thread")]
6144 async fn trace_stream_llm_error_recorded() {
6145 struct FailInStream;
6146 #[async_trait::async_trait]
6147 impl Provider for FailInStream {
6148 async fn chat_with_context(
6149 &self,
6150 _request: ChatRequest,
6151 _context: &ProviderRequestContext,
6152 ) -> Result<ChatResponse, ProviderError> {
6153 unreachable!("this test uses streaming path only")
6154 }
6155 async fn stream_chat_with_context(
6156 &self,
6157 _request: ChatRequest,
6158 _context: &ProviderRequestContext,
6159 ) -> Result<BoxStream<'static, Result<StreamEvent, ProviderError>>, ProviderError>
6160 {
6161 Ok(Box::pin(futures::stream::iter(vec![
6162 Ok(StreamEvent::Delta("hi".into())),
6163 Err(ProviderError::Protocol {
6164 message: "boom".into(),
6165 }),
6166 ])))
6167 }
6168 }
6169
6170 let sub = Arc::new(CollectSubscriber::default());
6171 let _guard = collect_guard(&sub);
6172
6173 let mut agent = ReActAgent::new(FailInStream, ToolRegistry::new(), "");
6174 let mut stream = agent.run_stream("two").await.unwrap();
6175 while stream.next().await.is_some() {}
6176
6177 let ops = sub.ops.lock().unwrap().clone();
6178 assert_nesting_invariants(&ops);
6179 assert!(
6180 records_of(&ops, "llm_request")
6181 .iter()
6182 .any(|r| r.contains("boom"))
6183 );
6184 assert!(
6185 records_of(&ops, "agent.run")
6186 .iter()
6187 .any(|r| r.contains("boom"))
6188 );
6189 }
6190 }
6191}