Skip to main content

molo_agent/agent/
react.rs

1//! The ReAct reasoning loop: the classic Agent assembly shipped with the
2//! framework.
3//!
4//! The generic loop shape: record the user input → start a conversation →
5//! execute tool requests from the model and feed the results back → until
6//! the model answers directly; tool execution failures are fed back to the
7//! model as text, and the model decides what to do next.
8//!
9//! Getting started needs just three required parameters: provider
10//! ([`Provider`]) + tools ([`ToolRegistry`]) + system_prompt; Memory
11//! defaults to a bounded window ([`WindowMemory`], 128k token budget, long
12//! conversations auto-trim the oldest rounds); replace it with
13//! [`with_memory`](ReActAgent::with_memory) when you need something custom.
14//!
15//! Pre-call approval at the loop level is not built in; a custom
16//! [`ToolRoundExecutor`] injected via
17//! [`with_tool_round_executor`](ReActAgent::with_tool_round_executor) is
18//! the application-side hook for round-level gates.
19
20use 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/// Convenience assembly macro: registers a list of tools (possibly
61/// heterogeneous) with automatic boxing, creating a
62/// [`ToolRegistry`](crate::tool::ToolRegistry) internally. The system prompt
63/// is **optional** (omitted = no system prompt). Six arms:
64///
65/// - `react_agent!(provider)` — no tools, no system prompt;
66/// - `react_agent!(provider, "system prompt")` — no tools, with a system
67///   prompt (the string must be a **literal**, distinguishing it from the
68///   "existing registry" arm; to use a variable, write
69///   `react_agent!(provider, [], system_var)` or the three-arg arm);
70/// - `react_agent!(provider, [tool_1, tool_2, ...])` — a heterogeneous tool
71///   list (types need not match), auto-registered, no system prompt;
72/// - `react_agent!(provider, [tool_1, ...], "system prompt")` — list +
73///   system prompt;
74/// - `react_agent!(provider, registry)` — an existing registry (e.g. a
75///   sub-agent trimmed via `subset`), no system prompt;
76/// - `react_agent!(provider, registry, "system prompt")` — registry +
77///   system prompt.
78///
79/// Returns [`ReActAgent`](crate::agent::ReActAgent); chained
80/// [`with_memory`](ReActAgent::with_memory) / [`with_config`](ReActAgent::with_config) /
81/// [`with_state`](ReActAgent::with_state) / [`with_event_channel`](ReActAgent::with_event_channel) /
82/// [`with_tool_round_executor`](ReActAgent::with_tool_round_executor) work as usual.
83/// `ReActAgent::new` keeps a single signature; the macro handles the
84/// multiple shapes.
85///
86/// ```
87/// # extern crate molo_agent as molo;
88/// # #[tokio::main]
89/// # async fn main() -> Result<(), molo::AgentError> {
90/// use molo::{react_agent, Agent, FakeProvider, FakeReply};
91///
92/// let mut agent = react_agent!(
93///     FakeProvider::new([FakeReply::Text("Hello".into())]),
94///     "You are a helpful assistant",
95/// );
96/// assert_eq!(agent.run("Are you there").await?, "Hello");
97/// # Ok(())
98/// # }
99/// ```
100#[macro_export]
101macro_rules! react_agent {
102    // No tools: bare / with a system prompt (a string literal).
103    ($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    // Tool list: auto-registered, without / with a system prompt.
118    ($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    // Existing registry: without / with a system prompt.
129    ($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
137/// The classic ReAct reasoning loop: conversation → tool execution fed
138/// back → until the model answers directly.
139///
140/// Assembly: three required parameters (Provider / ToolRegistry /
141/// system_prompt) + chained optional
142/// ([`with_memory`](ReActAgent::with_memory) / [`with_config`](ReActAgent::with_config) /
143/// [`with_state`](ReActAgent::with_state) / [`with_event_channel`](ReActAgent::with_event_channel) /
144/// [`with_tool_round_executor`](ReActAgent::with_tool_round_executor));
145/// for simpler assembly use the macro [`react_agent!`](crate::react_agent).
146///
147/// # Examples
148///
149/// ```rust
150/// # extern crate molo_agent as molo;
151/// # #[tokio::main]
152/// # async fn main() -> Result<(), molo::AgentError> {
153/// use molo::agent::{Agent, ReActAgent};
154/// use molo::provider::{FakeProvider, FakeReply};
155/// use molo::tool::ToolRegistry;
156///
157/// let mut agent = ReActAgent::new(
158///     FakeProvider::new([FakeReply::Text("Hello".into())]),
159///     ToolRegistry::new(),
160///     "You are a helpful assistant",
161/// );
162/// let answer = agent.run("Are you there").await?;
163/// assert_eq!(answer, "Hello");
164/// # Ok(())
165/// # }
166/// ```
167///
168/// # Cancellation semantics
169///
170/// ReActAgent observes cancellation and deadlines carried by [`RunContext`].
171/// Cancellation is cooperative and only checked at safe points:
172///
173/// - Cancelled mid-conversation: the in-flight request is dropped, and
174///   nothing is recorded for this round;
175/// - Cancelled during a tool round: the running tool call is not
176///   interrupted (tools are user code; a partial execution risks side
177///   effects); the Assistant message already recorded this round and the
178///   ToolResult fed back right after remain complete;
179/// - Streaming path: already-dispatched `Delta` chunks are kept, then
180///   the stream concludes with a [`MessageChunk::Cancelled`] terminal
181///   chunk — no `Done`;
182/// - After cancellation, already-recorded messages are kept, not rolled
183///   back; subsequent runs continue from the retained memory.
184///
185/// Callers that don't need cancellation can just use
186/// [`run`](Agent::run) / [`run_stream`](Agent::run_stream) — they
187/// internally use a token that is never cancelled.
188///
189/// # Errors
190///
191/// Tool execution failures are **not** [`AgentError`] — the error text
192/// is fed back to the model via `ToolResult`, the model decides what to
193/// do next, and the loop continues. [`AgentError`] only represents
194/// run-level failures (Memory / Provider failures, exceeding the
195/// tool-round limit, cancellation).
196///
197/// # Structured output
198///
199/// The ability to require the model's answer to conform to a given JSON
200/// Schema; both entry points share the same validation-and-retry loop
201/// (see [`run_typed`](ReActAgent::run_typed) and
202/// [`with_structured_output`](ReActAgent::with_structured_output)):
203///
204/// - [`run_typed`](ReActAgent::run_typed): **typed output** —
205///   `run_typed`'s return type directly declares the target type; this
206///   run auto-generates a JSON Schema from the type
207///   ([`schemars`](https://docs.rs/schemars)-derived, the same pipeline
208///   as tool parameter schemas), and deserializes after validation;
209/// - [`with_structured_output`](ReActAgent::with_structured_output): a
210///   hand-written Schema (or the serialized result of
211///   `schemars::schema_for!(T)`), paired with [`Agent::run`](Agent::run)
212///   returning JSON text that you parse yourself.
213///
214/// Both entry points validate the final answer with framework-side
215/// jsonschema; on failure the error is fed back to the model for retry
216/// (budget [`AgentConfig::max_structured_retries`]); compatible
217/// endpoints are additionally constrained as best effort via
218/// `response_format` (see
219/// [`ModelOptions::structured`](crate::provider::ModelOptions::structured)).
220pub struct ReActAgent {
221    provider: Box<dyn Provider>,
222    memory: Box<dyn Memory>,
223    registry: ToolRegistry,
224    system_prompt: String,
225    config: AgentConfig,
226    /// Shared state: the application reads and writes this field directly
227    /// across runs; it is injected via [`Tool::call`](crate::Tool::call) on
228    /// every tool call, so multiple tools / Agents can share one instance.
229    pub state: SharedState,
230    /// Observation channel (optional, not attached by default): the loop
231    /// pushes process events here, and the host side subscribes.
232    events: Option<Arc<dyn EventChannel>>,
233    /// Tool-round execution policy (default: [`SerialToolRoundExecutor`] —
234    /// calls run one by one, in request order). The application injects a
235    /// custom policy (order / concurrency / approval gates) via
236    /// [`with_tool_round_executor`](ReActAgent::with_tool_round_executor).
237    executor: Box<dyn ToolRoundExecutor>,
238    /// Step-wise kernel state. `None` means no `AgentKernel` run is active.
239    kernel_state: Option<ReActKernelState>,
240}
241
242/// Per-round reply text accumulation limit (4 MiB, shared by streaming and
243/// non-streaming): exceeding it is treated as a malicious / abnormal
244/// endpoint and terminates the round with `AgentError::Provider`.
245const MAX_ROUND_TEXT: usize = 4 << 20;
246
247/// Token budget of the default Memory (128k): `ReActAgent::new` defaults
248/// to [`WindowMemory`](crate::memory::WindowMemory), which auto-trims the
249/// oldest rounds in long conversations — an unbounded default would let
250/// memory grow indefinitely with the conversation; callers needing full
251/// history explicitly replace it with
252/// [`with_memory`](ReActAgent::with_memory).
253const DEFAULT_MEMORY_TOKENS: usize = 128_000;
254
255/// Builder for assembling a [`ReActAgent`] from the same components used by
256/// [`ReActAgent::new`], plus optional runtime configuration.
257///
258/// The builder is only an assembly helper: provider calls still go through
259/// the agent loop, immediate tools still run through the tool registry, and
260/// governed side effects still belong to the outer harness/kernel path.
261/// Skill assembly is handled by the `molo-skills` layer so `ReActAgent`
262/// does not own skill policy.
263///
264/// # Examples
265///
266/// ```
267/// # extern crate molo_agent as molo;
268/// # #[tokio::main]
269/// # async fn main() -> Result<(), molo::AgentError> {
270/// use molo::{Agent, FakeProvider, FakeReply, ReActAgent};
271///
272/// let mut agent = ReActAgent::builder(FakeProvider::new([
273///     FakeReply::Text("Hello".into()),
274/// ]))
275/// .with_system_prompt("You are a helpful assistant")
276/// .build();
277///
278/// assert_eq!(agent.run("Are you there").await?, "Hello");
279/// # Ok(())
280/// # }
281/// ```
282pub 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    /// Starts a builder with a provider and default optional components.
295    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    /// Replaces the provider.
309    pub fn with_provider(mut self, provider: impl Provider + 'static) -> Self {
310        self.provider = Box::new(provider);
311        self
312    }
313
314    /// Replaces the tool registry.
315    pub fn with_tools(mut self, tools: ToolRegistry) -> Self {
316        self.tools = tools;
317        self
318    }
319
320    /// Registers one tool into the builder's registry.
321    pub fn with_tool(mut self, tool: impl Tool + 'static) -> Self {
322        self.tools.register(tool);
323        self
324    }
325
326    /// Sets the system prompt. Empty means no system message is assembled.
327    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    /// Replaces the default Memory.
333    pub fn with_memory(mut self, memory: impl Memory + 'static) -> Self {
334        self.memory = Box::new(memory);
335        self
336    }
337
338    /// Replaces the agent config.
339    pub fn with_config(mut self, config: AgentConfig) -> Self {
340        self.config = config;
341        self
342    }
343
344    /// Enables structured output for [`Agent::run`](crate::agent::Agent::run).
345    #[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    /// Attaches shared state for tool calls.
352    pub fn with_state(mut self, state: SharedState) -> Self {
353        self.state = state;
354        self
355    }
356
357    /// Attaches an observation channel.
358    pub fn with_event_channel(mut self, channel: impl EventChannel + 'static) -> Self {
359        self.events = Some(Arc::new(channel));
360        self
361    }
362
363    /// Replaces the tool-round executor.
364    pub fn with_tool_round_executor(mut self, executor: impl ToolRoundExecutor + 'static) -> Self {
365        self.executor = Box::new(executor);
366        self
367    }
368
369    /// Builds the agent.
370    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    /// Starts a [`ReActAgentBuilder`] with a provider.
408    pub fn builder(provider: impl Provider + 'static) -> ReActAgentBuilder {
409        ReActAgentBuilder::new(provider)
410    }
411
412    /// Simple construction: three required parameters — provider
413    /// ([`Provider`]) + tools ([`ToolRegistry`]) + system_prompt (empty
414    /// string = no system prompt); `run` returns the model's answer text
415    /// (consistent with [`Agent::run`]).
416    ///
417    /// Memory defaults to a bounded window
418    /// ([`WindowMemory`](crate::memory::WindowMemory), 128k token budget,
419    /// long conversations auto-trim the oldest rounds); when you need
420    /// something custom (full history / persistence, etc.), chain
421    /// [`with_memory`](ReActAgent::with_memory) to replace it; other
422    /// optional behaviors (such as the max tool rounds) go through
423    /// [`with_config`](ReActAgent::with_config).
424    ///
425    /// Structured output: typed output goes straight to
426    /// [`run_typed`](ReActAgent::run_typed) (this run generates the Schema
427    /// from the target type, no constructor configuration needed); for
428    /// hand-written Schemas use
429    /// [`with_structured_output`](ReActAgent::with_structured_output).
430    ///
431    /// For simpler assembly see the macro
432    /// [`react_agent!`](crate::react_agent).
433    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    /// Constructs a provider-free ReAct kernel for use with an outer
452    /// `HarnessRuntime`.
453    ///
454    /// The runtime owns provider execution; this kernel only assembles
455    /// model requests, runs immediate tools, requests governed effects, and
456    /// consumes observations.
457    pub fn kernel(tools: ToolRegistry, system_prompt: impl Into<String>) -> Self {
458        Self::new(FakeProvider::new([]), tools, system_prompt)
459    }
460
461    /// Replace the default Memory (default
462    /// [`WindowMemory`](crate::memory::WindowMemory), 128k token budget,
463    /// long conversations auto-trim the oldest rounds).
464    ///
465    /// The Agent owns the Memory: `Memory::record` needs `&mut self`, which
466    /// a shared (Arc) form can't write through; sharing the same
467    /// conversation history across Agents is a Workflow-orchestration
468    /// concern, out of this method's scope.
469    ///
470    /// # Examples
471    ///
472    /// Replace it with [`InMemoryMemory`](crate::memory::InMemoryMemory),
473    /// which keeps all history verbatim:
474    ///
475    /// ```
476    /// # extern crate molo_agent as molo;
477    /// # #[tokio::main]
478    /// # async fn main() {
479    /// use molo::agent::{Agent, ReActAgent};
480    /// use molo::memory::InMemoryMemory;
481    /// use molo::provider::{FakeProvider, FakeReply};
482    /// use molo::tool::ToolRegistry;
483    ///
484    /// let mut agent = ReActAgent::new(
485    ///     FakeProvider::new([FakeReply::Text("Hello".into())]),
486    ///     ToolRegistry::new(),
487    ///     "",
488    /// )
489    /// .with_memory(InMemoryMemory::default());
490    ///
491    /// assert_eq!(agent.run("Are you there").await.unwrap(), "Hello");
492    /// # }
493    /// ```
494    pub fn with_memory(mut self, memory: impl Memory + 'static) -> Self {
495        self.memory = Box::new(memory);
496        self
497    }
498
499    /// Replace the default config (default [`AgentConfig::default`]): the
500    /// `max_tool_rounds` round limit and the `options` model parameters
501    /// (temperature / max_tokens / extra parameters).
502    ///
503    /// See [`AgentConfig`](crate::agent::AgentConfig) for how to write a
504    /// config.
505    pub fn with_config(mut self, config: AgentConfig) -> Self {
506        self.config = config;
507        self
508    }
509
510    /// Enable structured output: the final answer must be JSON conforming
511    /// to this **JSON Schema document**.
512    ///
513    /// `schema` is usually the serialized `RootSchema` produced by
514    /// `schemars::schema_for!(T)` (the same pipeline as tool parameter
515    /// schemas); it can also be hand-written. Semantics:
516    ///
517    /// - Compatible endpoints constrain the model as best effort via
518    ///   `response_format` once they receive it;
519    /// - **Framework-side fallback validation**: when the final answer
520    ///   doesn't conform, the validation error is fed back to the model for
521    ///   retry (independent budget
522    ///   [`AgentConfig::max_structured_retries`]; exceeding it fails);
523    /// - Once validation passes, the JSON text is returned as-is as the
524    ///   answer.
525    ///
526    /// Typed output goes through [`run_typed`](ReActAgent::run_typed): the
527    /// schema is auto-generated from the target type and deserialized,
528    /// ignoring the hand-written schema set here — the two paths don't
529    /// combine (the hand-written schema serves `Agent::run`'s text form;
530    /// `run_typed` is always "the type is the schema").
531    ///
532    /// # Examples
533    ///
534    /// ```rust
535    /// # extern crate molo_agent as molo;
536    /// # #[tokio::main]
537    /// # async fn main() -> Result<(), molo::AgentError> {
538    /// use molo::{react_agent, Agent, FakeProvider, FakeReply};
539    /// use serde_json::json;
540    ///
541    /// let mut agent = react_agent!(
542    ///     FakeProvider::new([FakeReply::Text(r#"{"city":"Beijing"}"#.into())]),
543    ///     "You are a structured-output assistant",
544    /// )
545    /// .with_structured_output(json!({
546    ///     "type": "object",
547    ///     "properties": { "city": { "type": "string" } },
548    ///     "required": ["city"],
549    /// }));
550    ///
551    /// let answer = agent.run("How's the weather in Beijing").await?;
552    /// assert_eq!(answer, r#"{"city":"Beijing"}"#);
553    /// # Ok(())
554    /// # }
555    /// ```
556    #[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    /// Attach shared state (empty by default). The application reads and
563    /// writes via [`state`](ReActAgent::state) across runs, and tools
564    /// access the same instance through the `state` parameter at call time.
565    pub fn with_state(mut self, state: SharedState) -> Self {
566        self.state = state;
567        self
568    }
569
570    /// Attach an observation channel (not attached by default, zero cost):
571    /// the loop pushes process events into the pipeline, and the host side
572    /// `subscribe`s.
573    ///
574    /// The pipeline is **long-lived**: attached once, events from many runs
575    /// flow into the same pipeline (the Agent is the event source); each
576    /// run is one segment of the pipeline, delimited by
577    /// [`ReActEvent::RunStarted`] / [`ReActEvent::RunEnded`]; both the
578    /// streaming and non-streaming paths publish. See
579    /// [`ReActEvent`](crate::agent::ReActEvent) for the event set.
580    pub fn with_event_channel(mut self, channel: impl EventChannel + 'static) -> Self {
581        self.events = Some(Arc::new(channel));
582        self
583    }
584
585    /// Replace the default tool-round executor (default:
586    /// [`SerialToolRoundExecutor`] — the round's calls run one by one, in
587    /// request order) with a custom round policy: order / concurrency /
588    /// approval gates.
589    ///
590    /// The executor decides *how* the round's tool calls are executed; the
591    /// per-call mechanics (tool span, ToolStarted / ToolCompleted events,
592    /// registry dispatch) stay framework-owned via
593    /// [`ToolRoundCtx::run`]. Each outcome is recorded (and, on the
594    /// streaming path, dispatched) by the agent as the executor's stream
595    /// yields it. The round stays atomic by contract — the executor owns
596    /// that responsibility.
597    ///
598    /// # Examples
599    ///
600    /// A round policy that runs the round's calls concurrently; outcomes
601    /// flow back as each completes:
602    ///
603    /// ```
604    /// # extern crate molo_agent as molo;
605    /// # #[tokio::main]
606    /// # async fn main() -> Result<(), molo::AgentError> {
607    /// use molo::agent::{
608    ///     Agent, ReActAgent, ToolCallOutcome, ToolRoundCtx, ToolRoundExecutor,
609    /// };
610    /// use molo::message::ToolCall;
611    /// use molo::provider::{FakeProvider, FakeReply};
612    /// use molo::tool::ToolRegistry;
613    /// use futures::stream::{BoxStream, FuturesUnordered};
614    /// use futures::StreamExt;
615    ///
616    /// #[derive(Default)]
617    /// struct ParallelToolRoundExecutor;
618    ///
619    /// #[molo::async_trait]
620    /// impl ToolRoundExecutor for ParallelToolRoundExecutor {
621    ///     async fn execute_round<'a>(
622    ///         &'a mut self,
623    ///         ctx: ToolRoundCtx<'a>,
624    ///         calls: Vec<ToolCall>,
625    ///     ) -> BoxStream<'a, ToolCallOutcome> {
626    ///         // The run futures borrow ctx, so they materialize inside the
627    ///         // generator (which owns ctx), not across the return.
628    ///         Box::pin(async_stream::stream! {
629    ///             let mut tasks = FuturesUnordered::new();
630    ///             for call in calls {
631    ///                 tasks.push(ctx.run(call));
632    ///             }
633    ///             while let Some(outcome) = tasks.next().await {
634    ///                 yield outcome;
635    ///             }
636    ///         })
637    ///     }
638    /// }
639    ///
640    /// let mut agent = ReActAgent::new(
641    ///     FakeProvider::new([FakeReply::Text("Hello".into())]),
642    ///     ToolRegistry::new(),
643    ///     "",
644    /// )
645    /// .with_tool_round_executor(ParallelToolRoundExecutor);
646    ///
647    /// assert_eq!(agent.run("Are you there").await?, "Hello");
648    /// # Ok(())
649    /// # }
650    /// ```
651    pub fn with_tool_round_executor(mut self, executor: impl ToolRoundExecutor + 'static) -> Self {
652        self.executor = Box::new(executor);
653        self
654    }
655
656    /// Publish an event (no-op when no channel is attached). Takes a
657    /// **construction closure** rather than the event itself: with no
658    /// observation channel attached (the default), construction cost is
659    /// zero — the hot path (every Delta chunk) skips an unconditional heap
660    /// allocation and copy.
661    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    /// Non-streaming round loop (same semantics as the streaming path).
668    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        // Structured validator: built when this run has a schema; the retry
678        // budget lives in the component (the count accumulates across
679        // rounds and doesn't consume the tool-round budget).
680        #[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        // Tool-round counter: the limit check only counts rounds where the
687        // model requested tools — conversation rounds and structured
688        // validation-retry rounds don't consume the tool-round budget
689        // (structured retries have their own `max_structured_retries`
690        // budget); errors no longer report "tool round limit exceeded"
691        // because of structured retries.
692        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            // Between-round check: cancellation takes effect immediately —
699            // this round hasn't started a conversation, so the memory is
700            // clean (nothing from this round beyond the user message).
701            check_run_context(context)?;
702
703            // Provider-call span for this round: duration comes from span
704            // timing automatically; usage goes out through both channels
705            // (the event stream's RunSummary as usual, and observability
706            // records it on the span fields at wrap-up); the span is created
707            // while the run is on the stack (the outer run block is
708            // instrumented), so the hierarchy is established by the ambient
709            // parent automatically. The round body runs directly inside the
710            // run scope.
711            // The block returns Option<String> = whether this round already
712            // answered directly; internal errors propagate via `?` (the two
713            // Ok arms pin the error type with turbofish — inference would
714            // otherwise fail under multiple From impls).
715            let answer: Option<FinalAnswer> = async {
716                let llm_span = span_llm(run_id, counters.rounds);
717                // Cancelled mid-conversation: run_until_cancelled drops the
718                // in-flight request and returns immediately (nothing is
719                // recorded for this round). When cancellation races with
720                // completion, completion wins (primitive semantics).
721                // Provider errors are recorded on the llm span here
722                // (observability pinpoints the failed call); cancellations
723                // are not (cancellation is a run-level outcome, covered by
724                // the run span and the RunEnded event).
725                // Model parameters on the request: the schema passed for
726                // this run (typed path) takes precedence over the
727                // hand-written schema in the config — endpoint-side
728                // constraint and framework-side validation use the same
729                // one.
730                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                // Usage may be absent (the endpoint didn't report it);
752                // reported parts are summed and omitted turns are tracked so
753                // the summary's sum is not misread as exact.
754                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                        if let Some(cached) = usage.cached_tokens {
760                            llm_span.record("usage.cached_tokens", cached);
761                        }
762                    }
763                    counters.usage.record(usage);
764                } else {
765                    counters.usage.omit();
766                }
767                let finish_reason = response.finish_reason.clone();
768
769                // Per the Provider contract, this round's reply is exactly
770                // one Assistant message; text, reasoning, and tool requests
771                // stay in the same message (wire constraint: multiple tool
772                // requests in the same round are not split apart).
773                let Message::Assistant {
774                    content,
775                    reasoning,
776                    tool_calls,
777                } = response.message
778                else {
779                    // Defensive handling: a custom Provider may return a
780                    // non-Assistant message; the library boundary responds
781                    // to expected inputs with an error (same rigor as tool
782                    // panic catching, see the registry).
783                    return Err(AgentError::Provider(ProviderError::Protocol {
784                        message: "provider returned a non-assistant message".into(),
785                    }));
786                };
787                // Round text limit: a malicious endpoint emitting unbounded
788                // text in one round could blow up memory (the Provider layer
789                // only limits single lines; this is the per-round backstop,
790                // sharing the same constant as the streaming path; reasoning
791                // counts toward it too).
792                if content.len() + reasoning.as_deref().map_or(0, str::len) > MAX_ROUND_TEXT {
793                    return Err(AgentError::Provider(ProviderError::ResponseTooLarge {
794                        limit_bytes: MAX_ROUND_TEXT,
795                    }));
796                }
797
798                // Empty replies are not recorded: Assistant messages with no
799                // content, no reasoning, and no tool requests don't go into
800                // Memory; reasoning and tool requests are saved along with
801                // the content.
802                let final_message = Message::Assistant {
803                    content: content.clone(),
804                    reasoning: reasoning.clone(),
805                    tool_calls: Vec::new(),
806                };
807
808                if !content.is_empty() || reasoning.is_some() || !tool_calls.is_empty() {
809                    self.memory
810                        .record(Message::Assistant {
811                            content: content.clone(),
812                            reasoning,
813                            tool_calls: tool_calls.clone(),
814                        })
815                        .await?;
816                }
817
818                if tool_calls.is_empty() {
819                    // The model answered directly. Structured output: the
820                    // component validates the final answer — on failure,
821                    // feed it back to the model for retry (budget is built
822                    // into the component, decoupled from the tool-round
823                    // limit); exceeding it fails the run.
824                    #[cfg(feature = "structured")]
825                    {
826                        if let Some(validator) = &mut validator {
827                            match validator.validate(&content) {
828                                StructuredOutcome::Passed => {}
829                                StructuredOutcome::Retry { message } => {
830                                    self.memory.record(message).await?;
831                                    return Ok::<Option<FinalAnswer>, AgentError>(None);
832                                }
833                                StructuredOutcome::Exhausted { max_retries } => {
834                                    return Err(AgentError::StructuredRetriesExhausted(
835                                        max_retries,
836                                    ));
837                                }
838                            }
839                        }
840                    }
841                    return Ok::<Option<FinalAnswer>, AgentError>(Some(FinalAnswer {
842                        answer: content,
843                        final_message,
844                        finish_reason: Some(finish_reason),
845                    }));
846                }
847
848                counters.tool_calls_total += tool_calls.len();
849                tool_rounds += 1;
850                // Tool round: executed atomically and never interrupted —
851                // tools are user code with no cancellation interface, and a
852                // partial execution risks side effects; interrupting
853                // mid-way would also leave the Assistant recorded without
854                // its ToolResult, breaking the next round's message
855                // sequence. Cancellation takes effect naturally after the
856                // tool round ends, before the next round's conversation.
857                // The round policy (default: one by one, in request order)
858                // is injected via the ToolRoundExecutor; each outcome is
859                // recorded as it arrives.
860                let ctx = ToolRoundCtx {
861                    context,
862                    round: counters.rounds,
863                    registry: &self.registry,
864                    state: &self.state,
865                    events: &self.events,
866                };
867                let mut outcomes = self.executor.execute_round(ctx, tool_calls).await;
868                while let Some(outcome) = outcomes.next().await {
869                    if let Some(effect) = outcome.effect_request() {
870                        return Err(AgentError::EffectRequiresHarness(format!(
871                            "{} ({})",
872                            effect.description, effect.id
873                        )));
874                    }
875                    // Protected results (skill bodies, etc.) are recorded
876                    // via record_protected, exempt from window trimming;
877                    // when recording fails, the error text is recorded as a
878                    // fallback (memory integrity, see record_tool_result).
879                    record_tool_result(&mut self.memory, &outcome).await?;
880                }
881                Ok::<Option<FinalAnswer>, AgentError>(None)
882            }
883            .await?;
884            if let Some(answer) = answer {
885                return Ok(answer);
886            }
887        }
888    }
889
890    /// Assemble the full prompt for each request: System first, followed by
891    /// Memory's conversation history.
892    ///
893    /// Assembly happens when the conversation starts and is not written to
894    /// Memory — the system prompt is static configuration while Memory is
895    /// the dynamic conversation record; the two are managed separately. The
896    /// skill part reads the registry fresh on every request: hot-swapped
897    /// additions/removals take effect on the next request.
898    fn assemble_messages(&self, context: Vec<Message>) -> Vec<Message> {
899        let mut messages = Vec::with_capacity(context.len() + 1);
900        let system = self.assemble_system_prompt();
901        if !system.is_empty() {
902            messages.push(Message::system(&system));
903        }
904        messages.extend(context);
905        messages
906    }
907
908    /// Assemble the system prompt; an empty result = no system prompt.
909    fn assemble_system_prompt(&self) -> String {
910        self.system_prompt.clone()
911    }
912}
913
914/// What a round policy gets handed to execute one tool round.
915///
916/// The agent constructs the context and passes it by value to your
917/// [`ToolRoundExecutor::execute_round`]; you only read it, and execute
918/// calls through [`run`](ToolRoundCtx::run) — the framework-owned per-call
919/// mechanics (span, events, registry dispatch) stay out of your hands, so
920/// a custom policy reorders / parallelizes / gates calls without
921/// reimplementing any of that. See [`ToolRoundExecutor`] for a working
922/// example.
923pub struct ToolRoundCtx<'a> {
924    /// Run context (the run id is the correlation key carried by spans and
925    /// events).
926    pub context: &'a RunContext,
927    /// The current round number (carried into the tool span).
928    pub round: usize,
929    /// The tool registry (call dispatch + protected declarations).
930    pub registry: &'a ToolRegistry,
931    /// Shared state, injected into every tool call.
932    pub state: &'a SharedState,
933    /// The observation channel (None when not attached).
934    pub events: &'a Option<Arc<dyn EventChannel>>,
935}
936
937impl fmt::Debug for ToolRoundCtx<'_> {
938    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
939        // The observation channel is opaque (no Debug on the trait
940        // object); finish_non_exhaustive marks the omission.
941        f.debug_struct("ToolRoundCtx")
942            .field("run_id", &self.context.run_id)
943            .field("round", &self.round)
944            .field("registry", &self.registry)
945            .field("state", &self.state)
946            .finish_non_exhaustive()
947    }
948}
949
950impl ToolRoundCtx<'_> {
951    /// Dispatch one tool call with the framework's standard mechanics and
952    /// return its outcome.
953    ///
954    /// Call this for each call your policy decides to run: the call is
955    /// dispatched to the registry (tool span + ToolStarted / ToolCompleted
956    /// events included), and the outcome carries immediate output text, a
957    /// registry error text, or an effect request. Tool failures are **not**
958    /// errors here; they ride along in [`content`](ToolCallOutcome::content)
959    /// and are fed back to the model.
960    ///
961    /// Takes `&self` rather than `&mut self`: the mechanics are all shared
962    /// borrows, so a policy can hold multiple `run` futures concurrently
963    /// (see the parallel example on
964    /// [`with_tool_round_executor`](ReActAgent::with_tool_round_executor)).
965    pub async fn run(&self, call: ToolCall) -> ToolCallOutcome {
966        // Tool-call span: carries duration; records error on failure. The
967        // span is created while the run is on the stack (ambient parent on
968        // both paths, see the span-construction comments), so the hierarchy
969        // is correct automatically.
970        let tool_span = span_tool(&self.context.run_id, self.round, &call.name);
971        // Tool-started event (carries id / arguments; subscribers pair it
972        // with ToolCompleted by id).
973        self.publish(|| {
974            Arc::new(ReActEvent::ToolStarted {
975                id: call.id.clone(),
976                name: call.name.clone(),
977                arguments: call.arguments.clone(),
978            })
979        });
980        // Execute; the Result status rides along with the event (Ok/Err is
981        // classified by the registry), and the text (Ok result / Err's
982        // Display) is recorded and fed back to the model.
983        let call_future = self.registry.call(&call, self.context, self.state);
984        let result = instrument(call_future, tool_span.clone()).await;
985        #[cfg(feature = "tracing")]
986        if let Err(e) = &result {
987            tool_span.record("error", e.to_string());
988        }
989        let outcome = match &result {
990            Ok(ToolResult::Output(output)) => ToolCallOutcome::output(call.clone(), output.clone()),
991            Ok(ToolResult::Effect(request)) => {
992                ToolCallOutcome::effect(call.clone(), request.clone())
993            }
994            Ok(other) => ToolCallOutcome::text(call.clone(), other.to_string()),
995            Err(e) => ToolCallOutcome::text(call.clone(), e.to_string()),
996        };
997        let publish_tool_completed = {
998            let id = call.id.clone();
999            let name = call.name.clone();
1000            move || Arc::new(ReActEvent::ToolCompleted { id, name, result })
1001        };
1002        self.publish(publish_tool_completed);
1003        outcome
1004    }
1005
1006    /// Publish an event (no-op when no channel is attached). Takes a
1007    /// construction closure rather than the event itself: with no
1008    /// observation channel attached (the default), construction cost is
1009    /// zero.
1010    fn publish<E: AgentEvent + 'static>(&self, make_event: impl FnOnce() -> Arc<E>) {
1011        if let Some(pipe) = self.events {
1012            pipe.publish(make_event());
1013        }
1014    }
1015}
1016
1017/// Round-level tool-call policy: decides how one round's tool calls are
1018/// executed — order, concurrency, approval gates. Inject it with
1019/// [`with_tool_round_executor`](ReActAgent::with_tool_round_executor); the
1020/// default [`SerialToolRoundExecutor`] runs calls one by one in request
1021/// order, the classic serial loop with identical behavior.
1022///
1023/// The executor owns *how* calls run; the framework owns *that* they run
1024/// correctly: every call goes through [`ToolRoundCtx::run`], which carries
1025/// the standard mechanics (tool span, ToolStarted / ToolCompleted events,
1026/// registry dispatch, protected-result declaration) — a custom executor
1027/// can reorder, parallelize, or gate calls without reimplementing any of
1028/// that.
1029///
1030/// # Contract
1031///
1032/// - **Atomicity**: tool rounds are executed atomically and never
1033///   interrupted — tools are user code with no cancellation interface, and
1034///   a partial execution risks side effects; cancellation takes effect
1035///   after the round ends, before the next round's conversation. An
1036///   executor that interrupts mid-round accepts that risk itself.
1037/// - **Outcomes stream back as they complete**: the agent records (and,
1038///   on the streaming path, dispatches) each outcome as the stream yields
1039///   it. Dropping the stream early aborts the rest of the round —
1040///   already-produced outcomes stay recorded, and the model adapts to the
1041///   partial results.
1042/// - **Decisions feed back as text**: to deny a call (approval gate, rate
1043///   limit, ...), yield a synthetic [`ToolCallOutcome`] carrying the
1044///   decision text — it reaches the model through the normal ToolResult
1045///   channel without the tool ever running.
1046///
1047/// # Examples
1048///
1049/// An approval gate: calls to "dangerous" are denied without running,
1050/// everything else executes as usual.
1051///
1052/// ```
1053/// # extern crate molo_agent as molo;
1054/// # #[tokio::main]
1055/// # async fn main() -> Result<(), molo::AgentError> {
1056/// use molo::agent::{
1057///     Agent, ReActAgent, ToolCallOutcome, ToolRoundCtx, ToolRoundExecutor,
1058/// };
1059/// use molo::message::ToolCall;
1060/// use molo::provider::{FakeProvider, FakeReply};
1061/// use molo::tool::ToolRegistry;
1062/// use futures::stream::BoxStream;
1063///
1064/// #[derive(Default)]
1065/// struct ApprovalGateToolRoundExecutor;
1066///
1067/// #[molo::async_trait]
1068/// impl ToolRoundExecutor for ApprovalGateToolRoundExecutor {
1069///     async fn execute_round<'a>(
1070///         &'a mut self,
1071///         ctx: ToolRoundCtx<'a>,
1072///         calls: Vec<ToolCall>,
1073///     ) -> BoxStream<'a, ToolCallOutcome> {
1074///         Box::pin(async_stream::stream! {
1075///             for call in calls {
1076///                 if call.name == "dangerous" {
1077///                     yield ToolCallOutcome::text(call, "denied by approval gate");
1078///                 } else {
1079///                     yield ctx.run(call).await;
1080///                 }
1081///             }
1082///         })
1083///     }
1084/// }
1085///
1086/// let mut agent = ReActAgent::new(
1087///     FakeProvider::new([FakeReply::Text("Hello".into())]),
1088///     ToolRegistry::new(),
1089///     "",
1090/// )
1091/// .with_tool_round_executor(ApprovalGateToolRoundExecutor);
1092///
1093/// assert_eq!(agent.run("Are you there").await?, "Hello");
1094/// # Ok(())
1095/// # }
1096/// ```
1097#[async_trait::async_trait]
1098pub trait ToolRoundExecutor: Send + Sync {
1099    /// Execute one tool round: decide the policy over `calls` — which to
1100    /// run, in what order, with what concurrency — and yield each
1101    /// [`ToolCallOutcome`] as it completes.
1102    ///
1103    /// Use [`ToolRoundCtx::run`] to execute a call; its futures borrow
1104    /// `ctx`, so when the policy runs calls concurrently, the futures must
1105    /// materialize inside the returned stream (which owns `ctx`) rather
1106    /// than across this method's return — see the parallel example on
1107    /// [`with_tool_round_executor`](ReActAgent::with_tool_round_executor).
1108    ///
1109    /// # Errors
1110    ///
1111    /// None at the round level: a tool's failure is not an error here — it
1112    /// rides along in the outcome's text, which the agent records and
1113    /// feeds back to the model.
1114    async fn execute_round<'a>(
1115        &'a mut self,
1116        ctx: ToolRoundCtx<'a>,
1117        calls: Vec<ToolCall>,
1118    ) -> BoxStream<'a, ToolCallOutcome>;
1119}
1120
1121/// The default round policy: execute the round's calls one by one, in
1122/// request order — the classic serial loop, identical behavior.
1123///
1124/// This is the executor used when none is injected, so you usually don't
1125/// need to name it; reach for it when replacing the default with a custom
1126/// policy and keeping the serial behavior handy as a fallback:
1127///
1128/// ```
1129/// # extern crate molo_agent as molo;
1130/// use molo::agent::SerialToolRoundExecutor;
1131/// use molo::agent::ToolRoundExecutor;
1132///
1133/// let serial: Box<dyn ToolRoundExecutor> = Box::new(SerialToolRoundExecutor::default());
1134/// ```
1135#[derive(Debug, Default, Clone, Copy)]
1136pub struct SerialToolRoundExecutor;
1137
1138#[async_trait::async_trait]
1139impl ToolRoundExecutor for SerialToolRoundExecutor {
1140    async fn execute_round<'a>(
1141        &'a mut self,
1142        ctx: ToolRoundCtx<'a>,
1143        calls: Vec<ToolCall>,
1144    ) -> BoxStream<'a, ToolCallOutcome> {
1145        Box::pin(async_stream::stream! {
1146            for call in calls {
1147                yield ctx.run(call).await;
1148            }
1149        })
1150    }
1151}
1152
1153/// The outcome of one tool call dispatch: the call info, the text fed back
1154/// to the model when immediately available, recording metadata, and an
1155/// optional effect request.
1156///
1157/// `run` produces outcomes; policies can also construct **synthetic**
1158/// outcomes to feed a decision back to the model without running the tool
1159/// (see the approval-gate example on [`ToolRoundExecutor`]) — the agent
1160/// records the text through the normal ToolResult channel:
1161///
1162/// ```
1163/// # extern crate molo_agent as molo;
1164/// use molo::agent::ToolCallOutcome;
1165/// use molo::message::ToolCall;
1166///
1167/// let denied = ToolCallOutcome {
1168///     call: ToolCall {
1169///         id: "call_1".into(),
1170///         name: "dangerous".into(),
1171///         arguments: "{}".into(),
1172///     },
1173///     content: "denied by approval gate".into(),
1174///     memory_policy: molo::ToolMemoryPolicy::Normal,
1175///     effect: None,
1176/// };
1177/// ```
1178#[derive(Debug, Clone, PartialEq)]
1179pub struct ToolCallOutcome {
1180    /// The call as-is (with id / name / arguments, used for event pairing
1181    /// and locating the record).
1182    pub call: ToolCall,
1183    /// The text fed back (Ok result / Err's Display, visible to the model).
1184    pub content: String,
1185    /// Memory policy for the model-visible content.
1186    pub memory_policy: ToolMemoryPolicy,
1187    /// Effect request produced by the tool, when the tool requested a
1188    /// governed side effect instead of immediate output.
1189    pub effect: Option<EffectRequest>,
1190}
1191
1192impl ToolCallOutcome {
1193    /// Constructs an immediate text outcome.
1194    pub fn text(call: ToolCall, content: impl Into<String>) -> Self {
1195        Self::output(call, ToolOutput::text(content))
1196    }
1197
1198    /// Constructs an immediate output outcome.
1199    pub fn output(call: ToolCall, output: ToolOutput) -> Self {
1200        Self {
1201            call,
1202            content: output.content,
1203            memory_policy: output.memory_policy,
1204            effect: None,
1205        }
1206    }
1207
1208    /// Constructs an effect outcome.
1209    pub fn effect(call: ToolCall, request: EffectRequest) -> Self {
1210        Self {
1211            call,
1212            content: String::new(),
1213            memory_policy: ToolMemoryPolicy::Normal,
1214            effect: Some(request),
1215        }
1216    }
1217
1218    /// Whether this outcome requests a governed effect.
1219    pub fn effect_request(&self) -> Option<&EffectRequest> {
1220        self.effect.as_ref()
1221    }
1222}
1223
1224#[cfg(feature = "structured")]
1225impl ReActAgent {
1226    /// Typed run: same semantics as [`Agent::run`](Agent::run) (records
1227    /// input, drives the loop), but the final answer is deserialized into
1228    /// the type parameter `U` after validation — this run auto-generates a
1229    /// JSON Schema from `U`
1230    /// ([`schemars`](https://docs.rs/schemars)-derived, the same pipeline
1231    /// as tool parameter schemas), with no constructor configuration
1232    /// needed.
1233    ///
1234    /// Relation to [`Agent::run`](Agent::run): run returns the model's
1235    /// answer text (free text, or the JSON text of a hand-written Schema
1236    /// from
1237    /// [`with_structured_output`](ReActAgent::with_structured_output));
1238    /// run_typed generates the Schema from the type and returns the type.
1239    /// The return type is declared in the let annotation (see `# Examples`);
1240    /// no turbofish needed.
1241    ///
1242    /// # Errors
1243    ///
1244    /// - [`AgentError::StructuredRetriesExhausted`][]: validation failures
1245    ///   are fed back to the model for retry, but the
1246    ///   [`AgentConfig::max_structured_retries`] budget is exhausted
1247    ///   without success;
1248    /// - [`AgentError::StructuredParse`][]: validation passed but
1249    ///   deserialization failed (when a derived schema customized with
1250    ///   `#[schemars(...)]` disagrees with the serde representation);
1251    /// - otherwise the same as [`Agent::run`](Agent::run) (Memory /
1252    ///   Provider / round limit / cancellation).
1253    ///
1254    /// # Examples
1255    ///
1256    /// ```
1257    /// # extern crate molo_agent as molo;
1258    /// # #[tokio::main]
1259    /// # async fn main() -> Result<(), molo::AgentError> {
1260    /// use molo::{FakeProvider, FakeReply, ReActAgent};
1261    /// use schemars::JsonSchema;
1262    /// use serde::Deserialize;
1263    ///
1264    /// #[derive(Deserialize, JsonSchema)]
1265    /// struct Weather {
1266    ///     city: String,
1267    /// }
1268    ///
1269    /// let mut agent = ReActAgent::new(
1270    ///     FakeProvider::new([FakeReply::Text(r#"{"city":"Beijing"}"#.into())]),
1271    ///     molo::tool::ToolRegistry::new(),
1272    ///     "You are a weather assistant",
1273    /// );
1274    /// let weather: Weather = agent.run_typed("How's the weather in Beijing").await?;
1275    /// assert_eq!(weather.city, "Beijing");
1276    /// # Ok(())
1277    /// # }
1278    /// ```
1279    pub async fn run_typed<U>(&mut self, input: &str) -> Result<U, AgentError>
1280    where
1281        U: DeserializeOwned + JsonSchema + Send + Sync,
1282    {
1283        // Implementation lives in [`TypedAgent`]; the inherent delegate
1284        // lets call sites avoid importing the trait.
1285        TypedAgent::run_typed(self, input).await
1286    }
1287}
1288
1289#[cfg(feature = "structured")]
1290#[async_trait::async_trait]
1291impl TypedAgent for ReActAgent {
1292    async fn run_typed_request_with_context<U>(
1293        &mut self,
1294        request: RunRequest,
1295        context: RunContext,
1296    ) -> Result<TypedRunOutput<U>, AgentError>
1297    where
1298        U: DeserializeOwned + JsonSchema + Send + Sync,
1299    {
1300        let schema = serde_json::to_value(schemars::schema_for!(U))
1301            .expect("schemars-generated schema always serializes (pure JSON value structure)");
1302        let output = self
1303            .run_request_inner(request, context, Some(&schema))
1304            .await?;
1305        let value = serde_json::from_str(&output.answer)
1306            .map_err(|e| AgentError::StructuredParse(e.to_string()))?;
1307        Ok(TypedRunOutput { value, output })
1308    }
1309}
1310
1311/// Cumulative counters for a single run (rounds / tools / usage), for the
1312/// `RunEnded` summary at wrap-up; the non-streaming path accumulates
1313/// through a mutable reference (the streaming path accumulates in local
1314/// variables inside the generator, same semantics).
1315/// Structured validation-retry counts don't live here — they're held by the
1316/// [`StructuredValidator`] component.
1317#[derive(Default)]
1318struct RunCounters {
1319    /// Number of conversation rounds (same semantics as
1320    /// `RunSummary.rounds`).
1321    rounds: usize,
1322    /// Total number of tool executions.
1323    tool_calls_total: usize,
1324    /// Token usage across rounds (reported parts only).
1325    usage: UsageTotals,
1326}
1327
1328/// Accumulated token usage across rounds plus the latches that say whether
1329/// the sums are exact; shared by the non-streaming path (held in
1330/// [`RunCounters`]) and the streaming path (held in generator locals, same
1331/// semantics).
1332#[derive(Default, Clone, Copy)]
1333struct UsageTotals {
1334    /// Sum of the reported parts; turns that reported nothing contribute
1335    /// nothing.
1336    total: Usage,
1337    /// `true` = at least one round did not report usage at all, so
1338    /// [`total`](Self::total) is a lower bound.
1339    omitted: bool,
1340    /// `true` = at least one round reported usage without the prompt-cache
1341    /// breakdown, so the cached part of [`total`](Self::total) is a lower
1342    /// bound. A round that omitted usage entirely omitted the breakdown too
1343    /// (OR'd in at summary build).
1344    cache_omitted: bool,
1345}
1346
1347impl UsageTotals {
1348    /// Accumulates one round's reported usage; a missing prompt-cache
1349    /// breakdown latches [`cache_omitted`](Self::cache_omitted).
1350    fn record(&mut self, usage: Usage) {
1351        self.total += usage;
1352        self.cache_omitted |= usage.cached_tokens.is_none();
1353    }
1354
1355    /// Marks a counted round that contributed no usage at all (provider
1356    /// error, cancellation, or an endpoint that reported nothing).
1357    fn omit(&mut self) {
1358        self.omitted = true;
1359    }
1360}
1361
1362struct FinalAnswer {
1363    answer: String,
1364    final_message: Message,
1365    finish_reason: Option<FinishReason>,
1366}
1367
1368struct RunExecution {
1369    answer: String,
1370    final_message: Message,
1371    summary: RunSummary,
1372    artifacts: Vec<Artifact>,
1373    metadata: RunMetadata,
1374}
1375
1376struct ReActKernelState {
1377    run_id: String,
1378    started_at: Instant,
1379    provider_model: Option<String>,
1380    counters: RunCounters,
1381    options: ModelOptions,
1382    schemas: Vec<crate::tool::ToolSchema>,
1383    #[cfg(feature = "structured")]
1384    validator: Option<StructuredValidator>,
1385    tool_rounds: usize,
1386    pending_tools: VecDeque<ToolCall>,
1387    pending_tool_results: VecDeque<PendingToolResult>,
1388    next_model_request: u64,
1389}
1390
1391#[derive(Debug)]
1392enum PendingToolResult {
1393    Outcome(ToolCallOutcome),
1394    Effect {
1395        effect_id: String,
1396        call: ToolCall,
1397        observation: Option<EffectObservation>,
1398    },
1399}
1400
1401impl PendingToolResult {
1402    fn effect_id(&self) -> Option<&str> {
1403        match self {
1404            Self::Outcome(_) => None,
1405            Self::Effect { effect_id, .. } => Some(effect_id),
1406        }
1407    }
1408}
1409
1410impl ReActKernelState {
1411    fn next_model_request(&mut self, messages: Vec<Message>) -> AgentAction {
1412        self.next_model_request += 1;
1413        AgentAction::RequestModel {
1414            request: ModelRequest::new(
1415                format!("{}-model-{}", self.run_id, self.next_model_request),
1416                ChatRequest {
1417                    messages,
1418                    tools: self.schemas.clone(),
1419                    options: self.options.clone(),
1420                },
1421            ),
1422        }
1423    }
1424
1425    fn summary(&self, finish_reason: Option<FinishReason>) -> RunSummary {
1426        run_summary(
1427            &self.counters,
1428            finish_reason,
1429            self.started_at,
1430            self.provider_model.clone(),
1431        )
1432    }
1433}
1434
1435impl fmt::Debug for ReActAgent {
1436    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1437        // Provider / Memory are trait objects and can't be Debug'd; print
1438        // the composition and behavior config.
1439        let mut debug = f.debug_struct("ReActAgent");
1440        debug
1441            .field("provider", &"Box<dyn Provider>")
1442            .field("memory", &"Box<dyn Memory>")
1443            .field("tools", &self.registry)
1444            .field("system_prompt", &self.system_prompt)
1445            .field("config", &self.config)
1446            .field("state", &self.state)
1447            .field(
1448                "events",
1449                &match &self.events {
1450                    Some(_) => "Some<dyn EventChannel>",
1451                    None => "None",
1452                },
1453            );
1454        debug.finish()
1455    }
1456}
1457
1458#[async_trait::async_trait]
1459impl Agent for ReActAgent {
1460    async fn run_request_with_context(
1461        &mut self,
1462        request: RunRequest,
1463        context: RunContext,
1464    ) -> Result<RunOutput, AgentError> {
1465        self.run_request_inner(request, context, None).await
1466    }
1467
1468    async fn run_stream_request_with_context<'a>(
1469        &'a mut self,
1470        request: RunRequest,
1471        context: RunContext,
1472    ) -> Result<BoxStream<'a, Result<MessageChunk, AgentError>>, AgentError> {
1473        self.run_stream_request_inner(request, context).await
1474    }
1475}
1476
1477#[async_trait::async_trait]
1478impl AgentKernel for ReActAgent {
1479    async fn start(
1480        &mut self,
1481        request: RunRequest,
1482        context: &RunContext,
1483    ) -> Result<AgentAction, AgentError> {
1484        check_run_context(context)?;
1485        let run_id = context.run_id.clone();
1486        let input = request.input;
1487        self.memory.record(input.clone().into_message()).await?;
1488        self.publish(|| {
1489            Arc::new(ReActEvent::RunStarted {
1490                run_id: run_id.clone(),
1491                input,
1492            })
1493        });
1494
1495        let options = request
1496            .options
1497            .clone()
1498            .unwrap_or_else(|| self.config.options.clone());
1499        #[cfg(feature = "structured")]
1500        let validator = options.structured.as_ref().map(|schema| {
1501            StructuredValidator::new(schema.clone(), self.config.max_structured_retries)
1502        });
1503        let mut state = ReActKernelState {
1504            run_id,
1505            started_at: Instant::now(),
1506            provider_model: self.provider.model().map(str::to_string),
1507            counters: RunCounters::default(),
1508            options,
1509            schemas: self.registry.schemas(),
1510            #[cfg(feature = "structured")]
1511            validator,
1512            tool_rounds: 0,
1513            pending_tools: VecDeque::new(),
1514            pending_tool_results: VecDeque::new(),
1515            next_model_request: 0,
1516        };
1517        state.counters.rounds += 1;
1518        let messages = self.assemble_messages(self.memory.context().await?);
1519        let action = state.next_model_request(messages);
1520        self.kernel_state = Some(state);
1521        Ok(action)
1522    }
1523
1524    async fn observe(
1525        &mut self,
1526        observation: Observation,
1527        context: &RunContext,
1528    ) -> Result<AgentAction, AgentError> {
1529        check_run_context(context)?;
1530        match observation {
1531            Observation::Model(observation) => self.observe_model(observation, context).await,
1532            Observation::Effect(observation) => self.observe_effect(observation, context).await,
1533            Observation::Effects(observations) => self.observe_effects(observations, context).await,
1534            _ => Err(AgentError::InvalidStep("unsupported observation".into())),
1535        }
1536    }
1537}
1538
1539impl ReActAgent {
1540    async fn observe_model(
1541        &mut self,
1542        observation: ModelObservation,
1543        context: &RunContext,
1544    ) -> Result<AgentAction, AgentError> {
1545        let mut state = self.kernel_state.take().ok_or_else(|| {
1546            AgentError::InvalidStep("model observation without active run".into())
1547        })?;
1548        if !state.pending_tool_results.is_empty() || !state.pending_tools.is_empty() {
1549            self.kernel_state = Some(state);
1550            return Err(AgentError::InvalidStep(
1551                "model observation received while tool calls are pending".into(),
1552            ));
1553        }
1554
1555        let response = observation.response;
1556        if let Some(usage) = response.usage {
1557            state.counters.usage.record(usage);
1558        } else {
1559            state.counters.usage.omit();
1560        }
1561        let finish_reason = response.finish_reason.clone();
1562        let Message::Assistant {
1563            content,
1564            reasoning,
1565            tool_calls,
1566        } = response.message
1567        else {
1568            self.kernel_state = Some(state);
1569            return Err(AgentError::Provider(ProviderError::Protocol {
1570                message: "provider returned a non-assistant message".into(),
1571            }));
1572        };
1573        if content.len() + reasoning.as_deref().map_or(0, str::len) > MAX_ROUND_TEXT {
1574            self.kernel_state = Some(state);
1575            return Err(AgentError::Provider(ProviderError::ResponseTooLarge {
1576                limit_bytes: MAX_ROUND_TEXT,
1577            }));
1578        }
1579
1580        let final_message = Message::Assistant {
1581            content: content.clone(),
1582            reasoning: reasoning.clone(),
1583            tool_calls: Vec::new(),
1584        };
1585        if !content.is_empty() || reasoning.is_some() || !tool_calls.is_empty() {
1586            self.memory
1587                .record(Message::Assistant {
1588                    content: content.clone(),
1589                    reasoning,
1590                    tool_calls: tool_calls.clone(),
1591                })
1592                .await?;
1593        }
1594
1595        if tool_calls.is_empty() {
1596            #[cfg(feature = "structured")]
1597            {
1598                if let Some(validator) = &mut state.validator {
1599                    match validator.validate(&content) {
1600                        StructuredOutcome::Passed => {}
1601                        StructuredOutcome::Retry { message } => {
1602                            self.memory.record(message).await?;
1603                            state.counters.rounds += 1;
1604                            let messages = self.assemble_messages(self.memory.context().await?);
1605                            let action = state.next_model_request(messages);
1606                            self.kernel_state = Some(state);
1607                            return Ok(action);
1608                        }
1609                        StructuredOutcome::Exhausted { max_retries } => {
1610                            self.kernel_state = None;
1611                            return Err(AgentError::StructuredRetriesExhausted(max_retries));
1612                        }
1613                    }
1614                }
1615            }
1616            let summary = state.summary(Some(finish_reason));
1617            let output = RunOutput {
1618                run_id: state.run_id.clone(),
1619                answer: content,
1620                summary: summary.clone(),
1621                final_message,
1622                artifacts: Vec::new(),
1623                metadata: RunMetadata::new(),
1624            };
1625            publish_ended(&self.events, summary, None);
1626            self.kernel_state = None;
1627            return Ok(AgentAction::Respond { output });
1628        }
1629
1630        if state.tool_rounds >= self.config.max_tool_rounds {
1631            self.kernel_state = None;
1632            return Err(AgentError::TooManyToolRounds(self.config.max_tool_rounds));
1633        }
1634        state.tool_rounds += 1;
1635        state.counters.tool_calls_total += tool_calls.len();
1636        state.pending_tools = VecDeque::from(tool_calls);
1637        let action = self.process_kernel_pending_tools(state, context).await?;
1638        Ok(action)
1639    }
1640
1641    async fn observe_effect(
1642        &mut self,
1643        observation: EffectObservation,
1644        context: &RunContext,
1645    ) -> Result<AgentAction, AgentError> {
1646        let mut state = self.kernel_state.take().ok_or_else(|| {
1647            AgentError::InvalidStep("effect observation without active run".into())
1648        })?;
1649        let pending_effect_count = state
1650            .pending_tool_results
1651            .iter()
1652            .filter(|result| result.effect_id().is_some())
1653            .count();
1654        if pending_effect_count > 1 {
1655            self.kernel_state = Some(state);
1656            return Err(AgentError::InvalidStep(
1657                "single effect observation received while a batch is pending".into(),
1658            ));
1659        }
1660        let Some(expected_effect_id) = state
1661            .pending_tool_results
1662            .iter()
1663            .find_map(PendingToolResult::effect_id)
1664            .map(str::to_string)
1665        else {
1666            self.kernel_state = Some(state);
1667            return Err(AgentError::InvalidStep(
1668                "effect observation received with no pending effect".into(),
1669            ));
1670        };
1671        if observation.effect_id != expected_effect_id {
1672            self.kernel_state = Some(state);
1673            return Err(AgentError::InvalidStep(format!(
1674                "effect observation id mismatch: expected {expected_effect_id}, got {}",
1675                observation.effect_id
1676            )));
1677        }
1678
1679        if observation.output.observation_for_model.len() > MAX_ROUND_TEXT {
1680            self.kernel_state = Some(state);
1681            return Err(AgentError::Provider(ProviderError::ResponseTooLarge {
1682                limit_bytes: MAX_ROUND_TEXT,
1683            }));
1684        }
1685        let recorded = Self::mark_pending_effect_observed(
1686            &mut state.pending_tool_results,
1687            &expected_effect_id,
1688            observation,
1689        );
1690        debug_assert!(recorded);
1691        if let Err(error) = self.record_pending_tool_results(&mut state).await {
1692            self.kernel_state = Some(state);
1693            return Err(error);
1694        }
1695        self.process_kernel_pending_tools(state, context).await
1696    }
1697
1698    async fn observe_effects(
1699        &mut self,
1700        observations: Vec<EffectObservation>,
1701        context: &RunContext,
1702    ) -> Result<AgentAction, AgentError> {
1703        let mut state = self.kernel_state.take().ok_or_else(|| {
1704            AgentError::InvalidStep("effect observations without active run".into())
1705        })?;
1706        let pending_effect_ids = state
1707            .pending_tool_results
1708            .iter()
1709            .filter_map(PendingToolResult::effect_id)
1710            .map(str::to_string)
1711            .collect::<Vec<_>>();
1712        if pending_effect_ids.is_empty() {
1713            self.kernel_state = Some(state);
1714            return Err(AgentError::InvalidStep(
1715                "effect observations received with no pending effects".into(),
1716            ));
1717        }
1718        if observations.len() != pending_effect_ids.len() {
1719            let expected = pending_effect_ids.len();
1720            self.kernel_state = Some(state);
1721            return Err(AgentError::InvalidStep(format!(
1722                "effect observation count mismatch: expected {expected}, got {}",
1723                observations.len()
1724            )));
1725        }
1726
1727        let expected_ids = pending_effect_ids.iter().cloned().collect::<HashSet<_>>();
1728        let mut observations_by_id = HashMap::with_capacity(observations.len());
1729        for observation in observations {
1730            if observation.output.observation_for_model.len() > MAX_ROUND_TEXT {
1731                self.kernel_state = Some(state);
1732                return Err(AgentError::Provider(ProviderError::ResponseTooLarge {
1733                    limit_bytes: MAX_ROUND_TEXT,
1734                }));
1735            }
1736            if !expected_ids.contains(&observation.effect_id) {
1737                let effect_id = observation.effect_id;
1738                self.kernel_state = Some(state);
1739                return Err(AgentError::InvalidStep(format!(
1740                    "unexpected effect observation for {effect_id}"
1741                )));
1742            }
1743
1744            let effect_id = observation.effect_id.clone();
1745            if observations_by_id
1746                .insert(effect_id.clone(), observation)
1747                .is_some()
1748            {
1749                self.kernel_state = Some(state);
1750                return Err(AgentError::InvalidStep(format!(
1751                    "duplicate effect observation for {effect_id}"
1752                )));
1753            }
1754        }
1755        for effect_id in &pending_effect_ids {
1756            if !observations_by_id.contains_key(effect_id) {
1757                self.kernel_state = Some(state);
1758                return Err(AgentError::InvalidStep(format!(
1759                    "missing effect observation for {effect_id}"
1760                )));
1761            }
1762        }
1763
1764        for effect_id in pending_effect_ids {
1765            let observation = observations_by_id
1766                .remove(&effect_id)
1767                .expect("effect observation was prevalidated");
1768            let recorded = Self::mark_pending_effect_observed(
1769                &mut state.pending_tool_results,
1770                &effect_id,
1771                observation,
1772            );
1773            debug_assert!(recorded);
1774        }
1775        if let Err(error) = self.record_pending_tool_results(&mut state).await {
1776            self.kernel_state = Some(state);
1777            return Err(error);
1778        }
1779        self.process_kernel_pending_tools(state, context).await
1780    }
1781
1782    fn mark_pending_effect_observed(
1783        pending_tool_results: &mut VecDeque<PendingToolResult>,
1784        expected_effect_id: &str,
1785        observation: EffectObservation,
1786    ) -> bool {
1787        for result in pending_tool_results {
1788            let PendingToolResult::Effect {
1789                effect_id,
1790                observation: pending_observation,
1791                ..
1792            } = result
1793            else {
1794                continue;
1795            };
1796            if effect_id == expected_effect_id {
1797                *pending_observation = Some(observation);
1798                return true;
1799            }
1800        }
1801        false
1802    }
1803
1804    async fn record_pending_tool_results(
1805        &mut self,
1806        state: &mut ReActKernelState,
1807    ) -> Result<(), AgentError> {
1808        while let Some(outcome) =
1809            state
1810                .pending_tool_results
1811                .front()
1812                .and_then(|pending| match pending {
1813                    PendingToolResult::Outcome(outcome) => Some(outcome.clone()),
1814                    PendingToolResult::Effect {
1815                        call,
1816                        observation: Some(observation),
1817                        ..
1818                    } => Some(Self::effect_observation_outcome(
1819                        call.clone(),
1820                        observation.clone(),
1821                    )),
1822                    PendingToolResult::Effect {
1823                        observation: None, ..
1824                    } => None,
1825                })
1826        {
1827            record_tool_result(&mut self.memory, &outcome).await?;
1828            state.pending_tool_results.pop_front();
1829        }
1830        Ok(())
1831    }
1832
1833    fn effect_observation_outcome(
1834        call: ToolCall,
1835        observation: EffectObservation,
1836    ) -> ToolCallOutcome {
1837        ToolCallOutcome {
1838            call,
1839            content: observation.output.observation_for_model,
1840            memory_policy: observation.output.memory_policy,
1841            effect: None,
1842        }
1843    }
1844
1845    async fn process_kernel_pending_tools(
1846        &mut self,
1847        mut state: ReActKernelState,
1848        context: &RunContext,
1849    ) -> Result<AgentAction, AgentError> {
1850        let mut effects = Vec::new();
1851        let mut effect_ids = HashSet::new();
1852        while let Some(call) = state.pending_tools.pop_front() {
1853            let ctx = ToolRoundCtx {
1854                context,
1855                round: state.counters.rounds,
1856                registry: &self.registry,
1857                state: &self.state,
1858                events: &self.events,
1859            };
1860            let outcome = ctx.run(call).await;
1861            if let Some(effect) = outcome.effect_request().cloned() {
1862                if !effect_ids.insert(effect.id.clone()) {
1863                    return Err(AgentError::InvalidStep(format!(
1864                        "duplicate effect request id: {}",
1865                        effect.id
1866                    )));
1867                }
1868                state
1869                    .pending_tool_results
1870                    .push_back(PendingToolResult::Effect {
1871                        effect_id: effect.id.clone(),
1872                        call: outcome.call.clone(),
1873                        observation: None,
1874                    });
1875                effects.push(effect);
1876                continue;
1877            }
1878            state
1879                .pending_tool_results
1880                .push_back(PendingToolResult::Outcome(outcome));
1881        }
1882        if let Err(error) = self.record_pending_tool_results(&mut state).await {
1883            self.kernel_state = Some(state);
1884            return Err(error);
1885        }
1886        if !effects.is_empty() {
1887            self.kernel_state = Some(state);
1888            return if effects.len() == 1 {
1889                let request = effects
1890                    .pop()
1891                    .expect("single effect request must be present");
1892                Ok(AgentAction::RequestEffect { request })
1893            } else {
1894                Ok(AgentAction::RequestEffects { requests: effects })
1895            };
1896        }
1897
1898        check_run_context(context)?;
1899        state.counters.rounds += 1;
1900        let messages = self.assemble_messages(self.memory.context().await?);
1901        let action = state.next_model_request(messages);
1902        self.kernel_state = Some(state);
1903        Ok(action)
1904    }
1905}
1906
1907// Span construction is unified — both run / run_stream paths share the
1908// same field set. round is passed in by the caller (already incremented on
1909// the non-streaming path; current round + 1 on the streaming path). A
1910// single hierarchy mechanism: the ambient parent at creation time — llm/tool
1911// spans are created inside the round body, and while the round body runs,
1912// `agent.run` is always on the stack (non-streaming: the run block
1913// instruments the whole span; streaming: SpanStream enters on every poll),
1914// so no explicit parent and no double instrumenting are needed.
1915
1916/// The `agent.run` root span: the observability identifier of a whole run
1917/// (including the error recorded on failure).
1918#[cfg(feature = "tracing")]
1919type TraceSpan = tracing::Span;
1920
1921#[cfg(not(feature = "tracing"))]
1922#[derive(Debug, Clone)]
1923struct TraceSpan;
1924
1925#[cfg(feature = "tracing")]
1926fn instrument<F>(future: F, span: TraceSpan) -> tracing::instrument::Instrumented<F>
1927where
1928    F: Future,
1929{
1930    future.instrument(span)
1931}
1932
1933#[cfg(not(feature = "tracing"))]
1934fn instrument<F>(future: F, _span: TraceSpan) -> F
1935where
1936    F: Future,
1937{
1938    future
1939}
1940
1941fn span_run(run_id: &str) -> TraceSpan {
1942    #[cfg(feature = "tracing")]
1943    {
1944        tracing::info_span!("agent.run", "run.id" = %run_id, error = tracing::field::Empty)
1945    }
1946    #[cfg(not(feature = "tracing"))]
1947    {
1948        let _ = run_id;
1949        TraceSpan
1950    }
1951}
1952
1953/// Provider-call span: duration comes from span timing automatically; usage
1954/// goes out through both channels (the business-side RunSummary as usual,
1955/// observability records it on the span fields at wrap-up); errors are
1956/// recorded when the Provider fails (both paths' error branches record).
1957fn span_llm(run_id: &str, round: usize) -> TraceSpan {
1958    #[cfg(feature = "tracing")]
1959    {
1960        tracing::debug_span!(
1961            "llm_request",
1962            "run.id" = %run_id,
1963            round = round,
1964            usage.prompt_tokens = tracing::field::Empty,
1965            usage.completion_tokens = tracing::field::Empty,
1966            usage.cached_tokens = tracing::field::Empty,
1967            error = tracing::field::Empty,
1968        )
1969    }
1970    #[cfg(not(feature = "tracing"))]
1971    {
1972        let _ = (run_id, round);
1973        TraceSpan
1974    }
1975}
1976
1977/// Tool-call span: carries duration; records error on failure (see
1978/// run_tool_call).
1979fn span_tool(run_id: &str, round: usize, name: &str) -> TraceSpan {
1980    #[cfg(feature = "tracing")]
1981    {
1982        tracing::debug_span!(
1983            "tool",
1984            "run.id" = %run_id,
1985            round = round,
1986            name = %name,
1987            error = tracing::field::Empty,
1988        )
1989    }
1990    #[cfg(not(feature = "tracing"))]
1991    {
1992        let _ = (run_id, round, name);
1993        TraceSpan
1994    }
1995}
1996
1997/// Generator terminal wrap-up: publish `RunEnded` (with the accumulated
1998/// summary and error state; error=None on normal completion, error set on
1999/// cancellation / failure; no-op when no channel is attached).
2000///
2001/// A free function rather than a method: the generator borrows both
2002/// `self.events` (immutably) and other `&mut self` calls (record, etc.)
2003/// simultaneously; passing references pointwise avoids closure-capture
2004/// borrow tangles.
2005fn publish_ended(
2006    events: &Option<Arc<dyn EventChannel>>,
2007    summary: RunSummary,
2008    error: Option<AgentError>,
2009) {
2010    if let Some(pipe) = events {
2011        pipe.publish(Arc::new(ReActEvent::RunEnded { summary, error }));
2012    }
2013}
2014
2015/// Streaming termination helper: observability wrap-up (span error +
2016/// RunEnded event) plus the terminal chunk — cancellation →
2017/// `Ok(Cancelled)`, other errors → `Err(e)`; the call site `yield`s and
2018/// `break`s. Consolidates the fixed four-step sequence of every failure
2019/// branch in the generator (record → publish_ended → yield → break).
2020///
2021/// A free function rather than a method: the generator borrows both
2022/// `self.events` and `&mut self` calls (record, etc.) simultaneously;
2023/// passing references pointwise avoids closure-capture borrow tangles (same
2024/// as publish_ended).
2025fn stream_end(
2026    events: &Option<Arc<dyn EventChannel>>,
2027    #[cfg_attr(not(feature = "tracing"), allow(unused_variables))] run_span: &TraceSpan,
2028    summary: RunSummary,
2029    error: AgentError,
2030) -> Result<MessageChunk, AgentError> {
2031    // Cancellation is a normal outcome (terminating with the Cancelled
2032    // terminal chunk), so no span error is recorded — otherwise the
2033    // observability dashboard would mark a user-initiated stop as a
2034    // failure.
2035    #[cfg(feature = "tracing")]
2036    if !matches!(error, AgentError::Cancelled) {
2037        run_span.record("error", error.to_string());
2038    }
2039    publish_ended(events, summary, Some(error.clone()));
2040    match error {
2041        AgentError::Cancelled => Ok(MessageChunk::Cancelled),
2042        e => Err(e),
2043    }
2044}
2045
2046fn run_summary(
2047    counters: &RunCounters,
2048    finish_reason: Option<FinishReason>,
2049    started_at: Instant,
2050    provider_model: Option<String>,
2051) -> RunSummary {
2052    run_summary_from_parts(
2053        counters.rounds,
2054        counters.tool_calls_total,
2055        counters.usage,
2056        finish_reason,
2057        started_at,
2058        provider_model,
2059    )
2060}
2061
2062fn run_summary_from_parts(
2063    rounds: usize,
2064    tool_calls: usize,
2065    usage: UsageTotals,
2066    finish_reason: Option<FinishReason>,
2067    started_at: Instant,
2068    provider_model: Option<String>,
2069) -> RunSummary {
2070    RunSummary {
2071        rounds,
2072        tool_calls,
2073        usage: usage.total,
2074        usage_omitted: usage.omitted,
2075        // A round that reported no usage reported no cache breakdown either,
2076        // so the coarser latch implies the finer one (record only latches
2077        // rounds that reported usage without a breakdown).
2078        cache_omitted: usage.cache_omitted || usage.omitted,
2079        finish_reason,
2080        latency: started_at.elapsed(),
2081        provider_model,
2082    }
2083}
2084
2085fn check_run_context(context: &RunContext) -> Result<(), AgentError> {
2086    if context.is_cancelled() {
2087        Err(AgentError::Cancelled)
2088    } else if context.is_expired() {
2089        Err(AgentError::DeadlineExceeded)
2090    } else {
2091        Ok(())
2092    }
2093}
2094
2095async fn run_until_context<F>(context: &RunContext, future: F) -> Result<F::Output, AgentError>
2096where
2097    F: Future,
2098{
2099    check_run_context(context)?;
2100    match context.remaining() {
2101        Some(remaining) if remaining.is_zero() => Err(AgentError::DeadlineExceeded),
2102        Some(remaining) => {
2103            tokio::select! {
2104                _ = context.cancellation.cancelled() => Err(AgentError::Cancelled),
2105                _ = tokio::time::sleep(remaining) => Err(AgentError::DeadlineExceeded),
2106                output = future => Ok(output),
2107            }
2108        }
2109        None => context
2110            .cancellation
2111            .run_until_cancelled(future)
2112            .await
2113            .ok_or(AgentError::Cancelled),
2114    }
2115}
2116
2117/// Record a tool result, falling back to recording the error text before
2118/// re-raising on failure.
2119///
2120/// The tool has already run (side effects happened), so its result text
2121/// must not be lost: an Assistant message recorded without its ToolResult
2122/// leaves the memory incomplete, the next request would carry an unpaired
2123/// assistant/tool sequence, real endpoints reject it (400), and the
2124/// reported cause would be disconnected from the real failure. If the
2125/// fallback recording itself fails, the error is swallowed — the original
2126/// error takes priority, and there's no point retrying when the same
2127/// Memory keeps failing.
2128///
2129/// A free function rather than a method: while the round's outcome stream
2130/// is consumed, the agent's registry / state / events are borrowed (via
2131/// [`ToolRoundCtx`]); recording goes through the memory field directly —
2132/// a disjoint borrow (same rationale as publish_ended / stream_end).
2133async fn record_tool_result(
2134    memory: &mut Box<dyn Memory>,
2135    outcome: &ToolCallOutcome,
2136) -> Result<(), AgentError> {
2137    let message = Message::tool_result(outcome.call.id.clone(), outcome.content.clone());
2138    let record_result = if outcome.memory_policy.is_protected() {
2139        memory.record_protected(message).await
2140    } else {
2141        memory.record(message).await
2142    };
2143    match record_result {
2144        Ok(()) => Ok(()),
2145        Err(e) => {
2146            let fallback = Message::tool_result(
2147                outcome.call.id.clone(),
2148                format!("memory record failed: {e}"),
2149            );
2150            let _ = if outcome.memory_policy.is_protected() {
2151                memory.record_protected(fallback).await
2152            } else {
2153                memory.record(fallback).await
2154            };
2155            Err(e.into())
2156        }
2157    }
2158}
2159
2160impl ReActAgent {
2161    async fn run_request_inner(
2162        &mut self,
2163        request: RunRequest,
2164        context: RunContext,
2165        schema: Option<&serde_json::Value>,
2166    ) -> Result<RunOutput, AgentError> {
2167        // Run id and root span — one agent.run span for the whole run (from
2168        // recording the input to the wrap-up event), sharing the same
2169        // run.id as the event stream's RunStarted (the correlation key
2170        // between the two channels); on Err, the error is recorded at
2171        // wrap-up (observability spots the failed run at a glance).
2172        let run_id = context.run_id.clone();
2173        let run_span = span_run(&run_id);
2174        let provider_model = self.provider.model().map(str::to_string);
2175        let started_at = Instant::now();
2176        let run_future = async {
2177            // Record first, publish after (consistent with the streaming
2178            // path): RunStarted's claim that "the user input is recorded"
2179            // holds; when recording fails, neither RunStarted nor RunEnded
2180            // is published, leaving no dangling segment in the event
2181            // stream.
2182            let input = request.input;
2183            self.memory.record(input.clone().into_message()).await?;
2184            self.publish(|| {
2185                Arc::new(ReActEvent::RunStarted {
2186                    run_id: run_id.clone(),
2187                    input,
2188                })
2189            });
2190
2191            // Round / tool / structured-retry / usage counters: for the
2192            // RunEnded summary at wrap-up (also accumulated on the
2193            // non-streaming path).
2194            let mut counters = RunCounters::default();
2195            let mut options = request
2196                .options
2197                .clone()
2198                .unwrap_or_else(|| self.config.options.clone());
2199            if let Some(schema) = schema {
2200                options.structured = Some(schema.clone());
2201            }
2202            let validation_schema = options.structured.as_ref();
2203            let result: Result<FinalAnswer, AgentError> = self
2204                .run_rounds_with_context(
2205                    &context,
2206                    &run_id,
2207                    &mut counters,
2208                    &options,
2209                    validation_schema,
2210                )
2211                .await;
2212            let output_result = result.map(|final_answer| {
2213                let summary = run_summary(
2214                    &counters,
2215                    final_answer.finish_reason.clone(),
2216                    started_at,
2217                    provider_model.clone(),
2218                );
2219                RunExecution {
2220                    answer: final_answer.answer,
2221                    final_message: final_answer.final_message,
2222                    summary,
2223                    artifacts: Vec::new(),
2224                    metadata: RunMetadata::new(),
2225                }
2226            });
2227            let summary = match &output_result {
2228                Ok(execution) => execution.summary.clone(),
2229                Err(_) => run_summary(&counters, None, started_at, provider_model.clone()),
2230            };
2231            // Wrap-up event: error=None on success; error set on
2232            // cancellation / failure (bystanders can observe the outcome of
2233            // a non-streaming run); built with publish_ended, shared with
2234            // the streaming path.
2235            publish_ended(&self.events, summary, output_result.as_ref().err().cloned());
2236            output_result
2237        };
2238        let result = instrument(run_future, run_span.clone()).await;
2239        #[cfg(feature = "tracing")]
2240        if let Err(e) = &result {
2241            run_span.record("error", e.to_string());
2242        }
2243        result.map(|execution| RunOutput {
2244            run_id,
2245            answer: execution.answer,
2246            summary: execution.summary,
2247            final_message: execution.final_message,
2248            artifacts: execution.artifacts,
2249            metadata: execution.metadata,
2250        })
2251    }
2252
2253    async fn run_stream_request_inner<'a>(
2254        &'a mut self,
2255        request: RunRequest,
2256        context: RunContext,
2257    ) -> Result<BoxStream<'a, Result<MessageChunk, AgentError>>, AgentError> {
2258        // Run id and root span — the run span covers the consumption period
2259        // from "stream returned" to "stream dropped" (SpanStream enters on
2260        // every poll); the error field is recorded by the generator's error
2261        // branches; the same run.id is shared with the event stream's
2262        // RunStarted (the correlation key between the two channels).
2263        //
2264        // Recording and RunStarted are published before the stream is
2265        // returned (failing fast, so callers see the error before holding
2266        // the stream): if a caller drops the returned stream without
2267        // polling, the event stream is left with a dangling segment without
2268        // RunEnded — event consumers pair by run.id and wrap up such cases
2269        // themselves.
2270        let run_id = context.run_id.clone();
2271        let run_span = span_run(&run_id);
2272        let stream_span = run_span.clone();
2273        let input = request.input;
2274        self.memory.record(input.clone().into_message()).await?;
2275        self.publish(|| {
2276            Arc::new(ReActEvent::RunStarted {
2277                run_id: run_id.clone(),
2278                input,
2279            })
2280        });
2281        let schemas = self.registry.schemas();
2282        let max_rounds = self.config.max_tool_rounds;
2283        let provider_model = self.provider.model().map(str::to_string);
2284        let started_at = Instant::now();
2285        let options = request
2286            .options
2287            .clone()
2288            .unwrap_or_else(|| self.config.options.clone());
2289        let validation_schema = options.structured.clone();
2290
2291        // Streaming state machine: an async_stream generator — sequential
2292        // awaits in the loop body, syntax isomorphic to a synchronous loop
2293        // (replacing a hand-written unfold state machine).
2294        let context = context.clone();
2295        let stream = async_stream::stream! {
2296            let mut rounds = 0usize;
2297            // Execution summary (carried with Done): rounds is the `rounds`
2298            // above; tool counts and usage accumulate per round; rounds whose
2299            // provider omitted usage are tracked so the sum is not misread
2300            // as exact.
2301            let mut tool_calls_total = 0usize;
2302            let mut usage_totals = UsageTotals::default();
2303            // Structured validator: built when this run has a hand-written
2304            // schema from the request or config; the retry budget lives in
2305            // the component.
2306            #[cfg(feature = "structured")]
2307            let mut validator = validation_schema.as_ref().map(|schema| {
2308                StructuredValidator::new(schema.clone(), self.config.max_structured_retries)
2309            });
2310            #[cfg(not(feature = "structured"))]
2311            let _ = &validation_schema;
2312            // Tool-round counter (same semantics as the non-streaming
2313            // path): the limit only counts rounds where the model requested
2314            // tools; structured validation-retry rounds don't consume it
2315            // (independent max_structured_retries budget).
2316            let mut tool_rounds = 0usize;
2317            // Labeled loop: in-stream errors must terminate the whole
2318            // generator — a plain break only exits the current loop;
2319            // break 'rounds terminates the entire generator.
2320            'rounds: loop {
2321                // Same semantics as run: the model still requests tools
2322                // past the limit → error event.
2323                if tool_rounds >= max_rounds {
2324                    let summary = run_summary_from_parts(
2325                        rounds,
2326                        tool_calls_total,
2327                        usage_totals,
2328                        None,
2329                        started_at,
2330                        provider_model.clone(),
2331                    );
2332                    yield stream_end(&self.events, &run_span, summary,
2333                        AgentError::TooManyToolRounds(max_rounds));
2334                    break;
2335                }
2336
2337                // Increment at the start of the round (same semantics as
2338                // the non-streaming path): RunEnded reports "rounds
2339                // started" — both paths report 1 for a pre-cancelled token,
2340                // no drift.
2341                rounds += 1;
2342
2343                // Between-round check: cancellation takes effect
2344                // immediately — this round hasn't started a conversation,
2345                // so the memory is clean.
2346                if let Err(e) = check_run_context(&context) {
2347                    // This counted round got no provider usage: the sum is a
2348                    // lower bound from here on.
2349                    usage_totals.omit();
2350                    let summary = run_summary_from_parts(
2351                        rounds,
2352                        tool_calls_total,
2353                        usage_totals,
2354                        None,
2355                        started_at,
2356                        provider_model.clone(),
2357                    );
2358                    yield stream_end(&self.events, &run_span, summary, e);
2359                    break;
2360                }
2361
2362                // Start a streaming conversation round (cancelled during
2363                // establishment → nothing recorded for this round, memory
2364                // clean).
2365                let messages = match self.memory.context().await {
2366                    Ok(messages) => messages,
2367                    Err(e) => {
2368                        // This counted round got no provider usage: the sum
2369                        // is a lower bound from here on.
2370                        usage_totals.omit();
2371                        let summary = run_summary_from_parts(
2372                            rounds,
2373                            tool_calls_total,
2374                            usage_totals,
2375                            None,
2376                            started_at,
2377                            provider_model.clone(),
2378                        );
2379                        yield stream_end(&self.events, &run_span, summary,
2380                            AgentError::Memory(e));
2381                        break;
2382                    }
2383                };
2384                // Provider-call span (streaming: establishment and
2385                // per-event consumption both belong to llm_request, the span
2386                // is shared across the round's consumption loop; usage is
2387                // recorded at wrap-up when the Done event arrives — the
2388                // business-side RunSummary as usual); the span is created
2389                // while the run is on the stack (SpanStream enters on every
2390                // poll), so the hierarchy is correct automatically.
2391                let llm_span = span_llm(&run_id, rounds);
2392                let model_request_id = format!("{run_id}-model-{rounds}");
2393                let provider_context =
2394                    ProviderRequestContext::from_run_context(model_request_id, &context);
2395                let stream_chat = self.provider.stream_chat_with_context(
2396                    ChatRequest {
2397                    messages: self.assemble_messages(messages),
2398                    tools: schemas.clone(),
2399                    options: options.clone(),
2400                    },
2401                    &provider_context,
2402                );
2403                let mut provider_stream = match run_until_context(
2404                    &context,
2405                    instrument(stream_chat, llm_span.clone()),
2406                )
2407                .await
2408                {
2409                    Ok(Ok(stream)) => stream,
2410                    Ok(Err(e)) => {
2411                        // Provider errors are recorded on the llm span
2412                        // (observability pinpoints the failed call).
2413                        #[cfg(feature = "tracing")]
2414                        llm_span.record("error", e.to_string());
2415                        // This counted round got no provider usage: the sum
2416                        // is a lower bound from here on.
2417                        usage_totals.omit();
2418                        let summary = run_summary_from_parts(
2419                            rounds,
2420                            tool_calls_total,
2421                            usage_totals,
2422                            None,
2423                            started_at,
2424                            provider_model.clone(),
2425                        );
2426                        yield stream_end(&self.events, &run_span, summary,
2427                            AgentError::Provider(e));
2428                        break;
2429                    }
2430                    Err(e) => {
2431                        // This counted round got no provider usage: the sum
2432                        // is a lower bound from here on.
2433                        usage_totals.omit();
2434                        let summary = run_summary_from_parts(
2435                            rounds,
2436                            tool_calls_total,
2437                            usage_totals,
2438                            None,
2439                            started_at,
2440                            provider_model.clone(),
2441                        );
2442                        yield stream_end(&self.events, &run_span, summary, e);
2443                        break;
2444                    }
2445                };
2446
2447                // Consume this round's full event stream: text dispatches
2448                // character by character in real time; tool requests are
2449                // collected whole at the end of the round.
2450                // Every next is wrapped in run_until_cancelled —
2451                // cancellation stops per-character dispatch immediately;
2452                // when cancellation races with event arrival, completion
2453                // wins (same trade-off as the non-streaming path): keep
2454                // consuming as long as events keep flowing, and only void
2455                // the round when next itself is preempted by cancellation
2456                // (None) (record happens at the end of the round, so the
2457                // memory is clean when voided).
2458                let mut text = String::new();
2459                let mut reasoning = String::new();
2460                let mut calls = Vec::new();
2461                let mut round_finish_reason = None::<FinishReason>;
2462                // True when this round's Done carried reported usage; a
2463                // round that ends without it marks the run's usage sum as a
2464                // lower bound (see the wrap-up after the loop below).
2465                let mut round_usage_reported = false;
2466                loop {
2467                    // next()'s Output is itself an Option, and
2468                    // run_until_cancelled wraps it in another (returns None
2469                    // on cancellation): Some(Some(event)) / Some(None) =
2470                    // stream ended naturally / None = cancelled during next
2471                    // (this round is voided).
2472                    let next = run_until_context(
2473                        &context,
2474                        instrument(provider_stream.next(), llm_span.clone()),
2475                    )
2476                    .await;
2477                    let Some(event) = (match next {
2478                        Ok(event) => event,
2479                        Err(e) => {
2480                            // The round was aborted before its Done: its
2481                            // usage is unknown, the sum is a lower bound.
2482                            usage_totals.omit();
2483                            let summary = run_summary_from_parts(
2484                                rounds,
2485                                tool_calls_total,
2486                                usage_totals,
2487                                None,
2488                                started_at,
2489                                provider_model.clone(),
2490                            );
2491                            yield stream_end(&self.events, &run_span, summary, e);
2492                            break 'rounds;
2493                        }
2494                    }) else {
2495                        // The event stream ended naturally; move to
2496                        // end-of-round wrap-up.
2497                        break;
2498                    };
2499                    match event {
2500                        Ok(StreamEvent::Delta(delta)) => {
2501                            // Round text accumulation limit: a malicious
2502                            // endpoint can keep sending Deltas without
2503                            // bound (the Provider layer only limits single
2504                            // lines; this is the per-round backstop).
2505                            if text.len() + delta.len() > MAX_ROUND_TEXT {
2506                                // The round was aborted before its Done:
2507                                // its usage is unknown, the sum is a lower
2508                                // bound.
2509                                usage_totals.omit();
2510                                let summary = run_summary_from_parts(
2511                                    rounds,
2512                                    tool_calls_total,
2513                                    usage_totals,
2514                                    None,
2515                                    started_at,
2516                                    provider_model.clone(),
2517                                );
2518                                yield stream_end(&self.events, &run_span, summary,
2519                                    AgentError::Provider(ProviderError::ResponseTooLarge {
2520                                        limit_bytes: MAX_ROUND_TEXT,
2521                                    }));
2522                                break 'rounds;
2523                            }
2524                            text.push_str(&delta);
2525                            self.publish(|| Arc::new(ReActEvent::Delta { text: delta.clone() }));
2526                            yield Ok(MessageChunk::Delta(delta));
2527                        }
2528                        Ok(StreamEvent::Reasoning(chunk)) => {
2529                            // Same limit as text: a custom Provider may
2530                            // send reasoning deltas without bound.
2531                            if reasoning.len() + chunk.len() > MAX_ROUND_TEXT {
2532                                // The round was aborted before its Done:
2533                                // its usage is unknown, the sum is a lower
2534                                // bound.
2535                                usage_totals.omit();
2536                                let summary = run_summary_from_parts(
2537                                    rounds,
2538                                    tool_calls_total,
2539                                    usage_totals,
2540                                    None,
2541                                    started_at,
2542                                    provider_model.clone(),
2543                                );
2544                                yield stream_end(&self.events, &run_span, summary,
2545                                    AgentError::Provider(ProviderError::ResponseTooLarge {
2546                                        limit_bytes: MAX_ROUND_TEXT,
2547                                    }));
2548                                break 'rounds;
2549                            }
2550                            reasoning.push_str(&chunk);
2551                            self.publish(move || Arc::new(ReActEvent::Reasoning { text: chunk }));
2552                        }
2553                        Ok(StreamEvent::ToolCall { id, name, arguments }) => {
2554                            calls.push(ToolCall {
2555                                id: id.clone(),
2556                                name: name.clone(),
2557                                arguments: arguments.clone(),
2558                            });
2559                            yield Ok(MessageChunk::ToolCall { id, name, arguments });
2560                        }
2561                        Ok(StreamEvent::Done { reason, usage }) => {
2562                            // Streaming usage may be absent (the endpoint
2563                            // didn't return it); missing rounds are tracked
2564                            // so the summary's sum is not misread as exact.
2565                            // usage goes through both channels:
2566                            // observability records it on the llm_request
2567                            // span at wrap-up, and the business-side
2568                            // RunSummary accumulates as usual.
2569                            if let Some(usage) = usage {
2570                                #[cfg(feature = "tracing")]
2571                                {
2572                                llm_span.record("usage.prompt_tokens", usage.prompt_tokens);
2573                                llm_span.record("usage.completion_tokens", usage.completion_tokens);
2574                                if let Some(cached) = usage.cached_tokens {
2575                                llm_span.record("usage.cached_tokens", cached);
2576                                }
2577                                }
2578                                usage_totals.record(usage);
2579                                round_usage_reported = true;
2580                            } else {
2581                                usage_totals.omit();
2582                            }
2583                            round_finish_reason = Some(reason);
2584                            // Done = this round is complete: move to
2585                            // end-of-round wrap-up immediately, consuming no
2586                            // further events — the Provider's Done is always
2587                            // the stream-final event, and anything after it
2588                            // was already discarded by the Provider; this
2589                            // break is belt and braces.
2590                            break;
2591                        }
2592                        Err(e) => {
2593                            // Errors are produced as Err events and
2594                            // terminate the stream (no Done afterwards);
2595                            // recorded on the llm span (observability
2596                            // pinpoints the failed call).
2597                            #[cfg(feature = "tracing")]
2598                            llm_span.record("error", e.to_string());
2599                            // The round was aborted before its Done: its
2600                            // usage is unknown, the sum is a lower bound.
2601                            usage_totals.omit();
2602                            let summary = run_summary_from_parts(
2603                                rounds,
2604                                tool_calls_total,
2605                                usage_totals,
2606                                None,
2607                                started_at,
2608                                provider_model.clone(),
2609                            );
2610                            yield stream_end(&self.events, &run_span, summary,
2611                                AgentError::Provider(e));
2612                            break 'rounds;
2613                        }
2614                        Ok(_) => {}
2615                    }
2616                }
2617
2618                // Round ended without reported usage (stream ended without
2619                // Done, or Done carried None): the accumulated sum is a
2620                // lower bound from here on.
2621                if !round_usage_reported {
2622                    usage_totals.omit();
2623                }
2624
2625                // Empty Assistant messages are not recorded (consistent
2626                // with run).
2627                if !text.is_empty() || !reasoning.is_empty() || !calls.is_empty() {
2628                    let message = Message::Assistant {
2629                        content: text.clone(),
2630                        reasoning: (!reasoning.is_empty()).then_some(reasoning),
2631                        tool_calls: calls.clone(),
2632                    };
2633                    match self.memory.record(message).await {
2634                        Ok(()) => {}
2635                        Err(e) => {
2636                            let summary = run_summary_from_parts(
2637                                rounds,
2638                                tool_calls_total,
2639                                usage_totals,
2640                                None,
2641                                started_at,
2642                                provider_model.clone(),
2643                            );
2644                            yield stream_end(&self.events, &run_span, summary,
2645                                AgentError::Memory(e));
2646                            break;
2647                        }
2648                    }
2649                }
2650
2651                if calls.is_empty() {
2652                    // The model answered directly. Structured output: the
2653                    // component validates the final answer — on failure,
2654                    // feed it back to the model for retry (no Done
2655                    // dispatched; the loop continues; the budget is built
2656                    // into the component, decoupled from the tool-round
2657                    // limit), and exceeding it terminates the stream with
2658                    // an error; only a pass wraps up.
2659                    #[cfg(feature = "structured")]
2660                    {
2661                        if let Some(validator) = &mut validator {
2662                            match validator.validate(&text) {
2663                                StructuredOutcome::Passed => {}
2664                                StructuredOutcome::Retry { message } => {
2665                                    // Record the feedback and continue to the
2666                                    // next round (no Done dispatched).
2667                                    match self.memory.record(message).await {
2668                                        Ok(()) => continue 'rounds,
2669                                        Err(e) => {
2670                                            let summary = run_summary_from_parts(
2671                                                rounds,
2672                                                tool_calls_total,
2673                                                usage_totals,
2674                                                None,
2675                                                started_at,
2676                                                provider_model.clone(),
2677                                            );
2678                                            yield stream_end(&self.events, &run_span, summary,
2679                                                AgentError::Memory(e));
2680                                            break;
2681                                        }
2682                                    }
2683                                }
2684                                StructuredOutcome::Exhausted { max_retries } => {
2685                                    let summary = run_summary_from_parts(
2686                                        rounds,
2687                                        tool_calls_total,
2688                                        usage_totals,
2689                                        None,
2690                                        started_at,
2691                                        provider_model.clone(),
2692                                    );
2693                                    yield stream_end(&self.events, &run_span, summary,
2694                                        AgentError::StructuredRetriesExhausted(max_retries));
2695                                    break 'rounds;
2696                                }
2697                            }
2698                        }
2699                    }
2700                    // Validation passed (or no structured constraint): this
2701                    // round ran to completion; wrap up directly.
2702                    let summary = run_summary_from_parts(
2703                        rounds,
2704                        tool_calls_total,
2705                        usage_totals,
2706                        round_finish_reason,
2707                        started_at,
2708                        provider_model.clone(),
2709                    );
2710                    publish_ended(&self.events, summary.clone(), None);
2711                    yield Ok(MessageChunk::Done(summary));
2712                    break;
2713                }
2714
2715                // Tool round: the round policy (default: execute one by
2716                // one, feeding results back right after each) is injected
2717                // via the ToolRoundExecutor; each outcome is dispatched
2718                // and recorded as it arrives.
2719                tool_calls_total += calls.len();
2720                tool_rounds += 1;
2721                let ctx = ToolRoundCtx {
2722                    context: &context,
2723                    round: rounds,
2724                    registry: &self.registry,
2725                    state: &self.state,
2726                    events: &self.events,
2727                };
2728                let mut outcomes = self.executor.execute_round(ctx, calls).await;
2729                while let Some(outcome) = outcomes.next().await {
2730                    if let Some(effect) = outcome.effect_request() {
2731                        let summary = run_summary_from_parts(
2732                            rounds,
2733                            tool_calls_total,
2734                            usage_totals,
2735                            None,
2736                            started_at,
2737                            provider_model.clone(),
2738                        );
2739                        yield stream_end(
2740                            &self.events,
2741                            &run_span,
2742                            summary,
2743                            AgentError::EffectRequiresHarness(format!(
2744                                "{} ({})",
2745                                effect.description, effect.id
2746                            )),
2747                        );
2748                        break 'rounds;
2749                    }
2750                    yield Ok(MessageChunk::ToolResult {
2751                        id: outcome.call.id.clone(),
2752                        name: outcome.call.name.clone(),
2753                        content: outcome.content.clone(),
2754                    });
2755                    // Protected results (skill bodies, etc.) are recorded
2756                    // via record_protected, exempt from window trimming;
2757                    // when recording fails, the error text is recorded as a
2758                    // fallback (memory integrity, see record_tool_result).
2759                    if let Err(e) = record_tool_result(&mut self.memory, &outcome).await {
2760                        let summary = run_summary_from_parts(
2761                            rounds,
2762                            tool_calls_total,
2763                            usage_totals,
2764                            None,
2765                            started_at,
2766                            provider_model.clone(),
2767                        );
2768                        yield stream_end(&self.events, &run_span, summary, e);
2769                        // Errors are produced as Err items and terminate
2770                        // the stream — the break must exit the whole round
2771                        // loop, not just the outcome loop (otherwise the
2772                        // loop would continue to the next round after an
2773                        // Err).
2774                        break 'rounds;
2775                    }
2776                }
2777            }
2778        };
2779        Ok(Box::pin(SpanStream {
2780            stream: Box::pin(stream),
2781            span: stream_span,
2782        }))
2783    }
2784}
2785
2786/// Wrap a span around a stream: enters on each poll and exits on return;
2787/// the span's lifetime = the stream's consumption period (creation to drop,
2788/// including time spent waiting while the consumer hasn't polled yet).
2789///
2790/// tracing's `Instrument` only applies to Futures; Streams need this thin
2791/// wrapper (per-await instrumenting inside the generator is already done
2792/// explicitly at each await point; this covers the `agent.run` root span
2793/// across the whole consumption). The inner stream is boxed and pinned at
2794/// construction (the generator is not Unpin — async_stream uses internal
2795/// pin_project; once pinned it never moves), and both `Pin<Box<S>>` and
2796/// `Span` are Unpin ⇒ the wrapper is always Unpin, so projection is safe,
2797/// no unsafe.
2798struct SpanStream<S> {
2799    stream: Pin<Box<S>>,
2800    #[cfg_attr(not(feature = "tracing"), allow(dead_code))]
2801    span: TraceSpan,
2802}
2803
2804impl<S: futures::Stream> futures::Stream for SpanStream<S> {
2805    type Item = S::Item;
2806
2807    fn poll_next(
2808        mut self: Pin<&mut Self>,
2809        cx: &mut std::task::Context<'_>,
2810    ) -> std::task::Poll<Option<Self::Item>> {
2811        // Clone the span handle before entering: the guard borrows the
2812        // local handle, not self (we need &mut self below; Span is a shared
2813        // handle, so the clone refers to the same span).
2814        #[cfg(feature = "tracing")]
2815        let span = self.span.clone();
2816        #[cfg(feature = "tracing")]
2817        let _enter = span.enter();
2818        // SpanStream is Unpin (both Pin<Box<S>> and Span are Unpin) ⇒
2819        // DerefMut is safe; poll the pinned inner stream directly, no
2820        // unsafe.
2821        self.stream.as_mut().poll_next(cx)
2822    }
2823}
2824
2825#[cfg(test)]
2826mod tests {
2827    use super::*;
2828    use crate::CancellationToken;
2829    use crate::effect::{EffectKind, EffectObservation, EffectRequest};
2830    use crate::memory::MemoryError;
2831    use crate::message::ContentBlock;
2832    use crate::provider::{
2833        ChatResponse, FakeProvider, FakeReply, FinishReason, ModelOptions, ProviderError,
2834        StreamEvent, TimeoutStage,
2835    };
2836    use crate::tool::{Tool, ToolContext, ToolError, ToolOutput, ToolResult, ToolSchema};
2837    use futures::StreamExt;
2838    #[cfg(feature = "structured")]
2839    use serde::Deserialize;
2840    use std::sync::Arc;
2841    use std::sync::atomic::{AtomicUsize, Ordering};
2842    use std::time::Duration;
2843
2844    /// Shared wrapper: a shared reference to FakeProvider + Provider
2845    /// delegation, letting tests inspect the request history
2846    /// ([`SharedFake::requests`]) after the Agent has run to assert
2847    /// behavioral expectations.
2848    #[derive(Clone)]
2849    struct SharedFake(Arc<FakeProvider>);
2850
2851    impl SharedFake {
2852        fn new(replies: impl IntoIterator<Item = FakeReply>) -> Self {
2853            Self(Arc::new(FakeProvider::new(replies)))
2854        }
2855
2856        fn requests(&self) -> Vec<ChatRequest> {
2857            self.0.requests()
2858        }
2859    }
2860
2861    #[async_trait::async_trait]
2862    impl Provider for SharedFake {
2863        fn model(&self) -> Option<&str> {
2864            self.0.model()
2865        }
2866
2867        async fn chat(&self, request: ChatRequest) -> Result<ChatResponse, ProviderError> {
2868            self.0.chat(request).await
2869        }
2870
2871        async fn chat_with_context(
2872            &self,
2873            request: ChatRequest,
2874            context: &ProviderRequestContext,
2875        ) -> Result<ChatResponse, ProviderError> {
2876            self.0.chat_with_context(request, context).await
2877        }
2878
2879        async fn stream_chat(
2880            &self,
2881            request: ChatRequest,
2882        ) -> Result<BoxStream<'static, Result<StreamEvent, ProviderError>>, ProviderError> {
2883            self.0.stream_chat(request).await
2884        }
2885
2886        async fn stream_chat_with_context(
2887            &self,
2888            request: ChatRequest,
2889            context: &ProviderRequestContext,
2890        ) -> Result<BoxStream<'static, Result<StreamEvent, ProviderError>>, ProviderError> {
2891            self.0.stream_chat_with_context(request, context).await
2892        }
2893    }
2894
2895    /// Fake tool for tests: returns fixed text and counts calls.
2896    #[derive(Debug, Clone)]
2897    struct FakeTool {
2898        name: &'static str,
2899        result: &'static str,
2900        calls: Arc<AtomicUsize>,
2901    }
2902
2903    impl FakeTool {
2904        fn new(name: &'static str, result: &'static str) -> (Self, Arc<AtomicUsize>) {
2905            let calls = Arc::new(AtomicUsize::new(0));
2906            (
2907                Self {
2908                    name,
2909                    result,
2910                    calls: calls.clone(),
2911                },
2912                calls,
2913            )
2914        }
2915    }
2916
2917    #[async_trait::async_trait]
2918    impl Tool for FakeTool {
2919        fn schema(&self) -> ToolSchema {
2920            ToolSchema::new(self.name, "Test tool", serde_json::json!({}))
2921        }
2922
2923        async fn call(
2924            &self,
2925            _arguments: serde_json::Value,
2926            _context: ToolContext<'_>,
2927        ) -> Result<ToolResult, ToolError> {
2928            self.calls.fetch_add(1, Ordering::Relaxed);
2929            Ok(ToolOutput::text(self.result).into())
2930        }
2931    }
2932
2933    #[derive(Debug, Clone)]
2934    struct EffectTool {
2935        name: &'static str,
2936        effect_id: &'static str,
2937        description: &'static str,
2938    }
2939
2940    impl EffectTool {
2941        fn new(name: &'static str, effect_id: &'static str, description: &'static str) -> Self {
2942            Self {
2943                name,
2944                effect_id,
2945                description,
2946            }
2947        }
2948    }
2949
2950    #[async_trait::async_trait]
2951    impl Tool for EffectTool {
2952        fn schema(&self) -> ToolSchema {
2953            ToolSchema::new(self.name, "Effect tool", serde_json::json!({}))
2954        }
2955
2956        async fn call(
2957            &self,
2958            _arguments: serde_json::Value,
2959            _context: ToolContext<'_>,
2960        ) -> Result<ToolResult, ToolError> {
2961            Ok(ToolResult::Effect(
2962                EffectRequest::new(
2963                    EffectKind::Custom("test.effect".into()),
2964                    self.description,
2965                    serde_json::json!({}),
2966                )
2967                .with_id(self.effect_id),
2968            ))
2969        }
2970    }
2971
2972    fn call(id: &str, name: &str, arguments: &str) -> ToolCall {
2973        ToolCall {
2974            id: id.into(),
2975            name: name.into(),
2976            arguments: arguments.into(),
2977        }
2978    }
2979
2980    fn done_summary(chunk: &MessageChunk) -> Option<&RunSummary> {
2981        match chunk {
2982            MessageChunk::Done(summary) => Some(summary),
2983            _ => None,
2984        }
2985    }
2986
2987    fn assert_done_summary(
2988        chunk: &MessageChunk,
2989        rounds: usize,
2990        tool_calls: usize,
2991        usage: Usage,
2992        usage_omitted: bool,
2993    ) {
2994        assert_done_summary_with_finish(
2995            chunk,
2996            rounds,
2997            tool_calls,
2998            usage,
2999            usage_omitted,
3000            Some(FinishReason::Stop),
3001        );
3002    }
3003
3004    fn assert_done_summary_with_finish(
3005        chunk: &MessageChunk,
3006        rounds: usize,
3007        tool_calls: usize,
3008        usage: Usage,
3009        usage_omitted: bool,
3010        finish_reason: Option<FinishReason>,
3011    ) {
3012        let summary = done_summary(chunk).expect("expected Done chunk");
3013        assert_eq!(summary.rounds, rounds);
3014        assert_eq!(summary.tool_calls, tool_calls);
3015        assert_eq!(summary.usage, usage);
3016        assert_eq!(summary.usage_omitted, usage_omitted);
3017        assert_eq!(summary.finish_reason, finish_reason);
3018        assert_eq!(summary.provider_model, None);
3019    }
3020
3021    /// Simple assembly: default Memory + optional system prompt (empty
3022    /// string = none).
3023    fn agent(fake: SharedFake, system_prompt: &str) -> ReActAgent {
3024        ReActAgent::new(fake, ToolRegistry::new(), system_prompt)
3025    }
3026
3027    /// Assembly: custom registry + config (chained).
3028    fn agent_with_registry(
3029        fake: SharedFake,
3030        registry: ToolRegistry,
3031        config: AgentConfig,
3032    ) -> ReActAgent {
3033        ReActAgent::new(fake, registry, "").with_config(config)
3034    }
3035
3036    fn cancellation_context(token: &CancellationToken) -> RunContext {
3037        RunContext::generated().with_cancellation(token.clone())
3038    }
3039
3040    #[tokio::test]
3041    async fn builder_assembles_agent_components() {
3042        let fake = SharedFake::new([FakeReply::Text("built".into())]);
3043        let (tool, _calls) = FakeTool::new("builder_tool", "unused");
3044        let mut agent = ReActAgent::builder(fake.clone())
3045            .with_tool(tool)
3046            .with_system_prompt("Builder system")
3047            .with_config(AgentConfig {
3048                options: ModelOptions {
3049                    temperature: Some(0.4),
3050                    ..Default::default()
3051                },
3052                ..Default::default()
3053            })
3054            .build();
3055
3056        assert_eq!(agent.run("hi").await.unwrap(), "built");
3057        let request = &fake.requests()[0];
3058        assert_eq!(request.messages[0], Message::system("Builder system"));
3059        assert_eq!(request.tools.len(), 1);
3060        assert_eq!(request.tools[0].name, "builder_tool");
3061        assert_eq!(request.options.temperature, Some(0.4));
3062    }
3063
3064    #[tokio::test]
3065    async fn direct_answer() {
3066        let fake = SharedFake::new([FakeReply::Text("Hello".into())]);
3067        let mut agent = agent(fake.clone(), "");
3068
3069        let answer = agent.run("Are you there").await.unwrap();
3070        assert_eq!(answer, "Hello");
3071
3072        let requests = fake.requests();
3073        assert_eq!(requests.len(), 1);
3074        assert_eq!(requests[0].messages.len(), 1);
3075        assert_eq!(requests[0].messages[0], Message::user("Are you there"));
3076    }
3077
3078    #[tokio::test]
3079    async fn run_request_returns_structured_output() {
3080        let fake = SharedFake::new([FakeReply::text_with_usage("Hello", Usage::new(5, 2))]);
3081        let mut agent = agent(fake.clone(), "");
3082        let output = agent
3083            .run_request_with_context(RunRequest::text("Are you there"), RunContext::new("r1"))
3084            .await
3085            .unwrap();
3086
3087        assert_eq!(output.run_id, "r1");
3088        assert_eq!(output.answer, "Hello");
3089        assert_eq!(output.final_message, Message::assistant("Hello"));
3090        assert!(output.artifacts.is_empty());
3091        assert!(output.metadata.is_empty());
3092        assert_eq!(output.summary.rounds, 1);
3093        assert_eq!(output.summary.tool_calls, 0);
3094        assert_eq!(output.summary.usage, Usage::new(5, 2));
3095        assert_eq!(output.summary.finish_reason, Some(FinishReason::Stop));
3096        assert_eq!(output.summary.provider_model, None);
3097    }
3098
3099    /// A reported prompt-cache breakdown lands in the summary as an exact
3100    /// value (`cache_omitted` stays false).
3101    #[tokio::test]
3102    async fn run_request_carries_reported_cache_breakdown() {
3103        let fake = SharedFake::new([FakeReply::text_with_usage(
3104            "Hello",
3105            Usage::new(5, 2).with_cached_tokens(4),
3106        )]);
3107        let mut agent = agent(fake.clone(), "");
3108        let output = agent
3109            .run_request_with_context(RunRequest::text("hi"), RunContext::new("r1"))
3110            .await
3111            .unwrap();
3112
3113        assert_eq!(output.summary.usage.cached_tokens, Some(4));
3114        assert!(!output.summary.usage_omitted);
3115        assert!(!output.summary.cache_omitted);
3116    }
3117
3118    /// A round that reports usage without the prompt-cache breakdown latches
3119    /// `cache_omitted` (the cached part of the sum is a lower bound) even
3120    /// though the usage totals themselves are exact.
3121    #[tokio::test]
3122    async fn run_request_tracks_omitted_cache_breakdown() {
3123        let fake = SharedFake::new([FakeReply::text_with_usage("Hello", Usage::new(5, 2))]);
3124        let mut agent = agent(fake.clone(), "");
3125        let output = agent
3126            .run_request_with_context(RunRequest::text("hi"), RunContext::new("r1"))
3127            .await
3128            .unwrap();
3129
3130        // The totals are exact (usage was reported), the cached part is not:
3131        // `None` — no round reported one — with the latch set.
3132        assert_eq!(output.summary.usage.cached_tokens, None);
3133        assert!(!output.summary.usage_omitted);
3134        assert!(output.summary.cache_omitted);
3135    }
3136
3137    #[tokio::test]
3138    async fn run_request_blocks_are_recorded_as_user_blocks() {
3139        let blocks = vec![ContentBlock::Text("What is this?".into())];
3140        let fake = SharedFake::new([FakeReply::Text("A block".into())]);
3141        let mut agent = agent(fake.clone(), "");
3142
3143        agent
3144            .run_request(RunRequest::blocks(blocks.clone()))
3145            .await
3146            .unwrap();
3147
3148        let requests = fake.requests();
3149        assert_eq!(requests[0].messages[0], Message::user_blocks(blocks));
3150    }
3151
3152    #[tokio::test]
3153    async fn run_summary_carries_provider_model_when_available() {
3154        #[derive(Clone)]
3155        struct NamedProvider(SharedFake);
3156
3157        #[async_trait::async_trait]
3158        impl Provider for NamedProvider {
3159            fn model(&self) -> Option<&str> {
3160                Some("named-model")
3161            }
3162
3163            async fn chat(&self, request: ChatRequest) -> Result<ChatResponse, ProviderError> {
3164                self.0.chat(request).await
3165            }
3166
3167            async fn chat_with_context(
3168                &self,
3169                request: ChatRequest,
3170                context: &ProviderRequestContext,
3171            ) -> Result<ChatResponse, ProviderError> {
3172                self.0.chat_with_context(request, context).await
3173            }
3174
3175            async fn stream_chat(
3176                &self,
3177                request: ChatRequest,
3178            ) -> Result<BoxStream<'static, Result<StreamEvent, ProviderError>>, ProviderError>
3179            {
3180                self.0.stream_chat(request).await
3181            }
3182
3183            async fn stream_chat_with_context(
3184                &self,
3185                request: ChatRequest,
3186                context: &ProviderRequestContext,
3187            ) -> Result<BoxStream<'static, Result<StreamEvent, ProviderError>>, ProviderError>
3188            {
3189                self.0.stream_chat_with_context(request, context).await
3190            }
3191        }
3192
3193        let mut agent = ReActAgent::new(
3194            NamedProvider(SharedFake::new([FakeReply::Text("hi".into())])),
3195            ToolRegistry::new(),
3196            "",
3197        );
3198        let output = agent.run_request(RunRequest::text("hi")).await.unwrap();
3199        assert_eq!(output.summary.provider_model, Some("named-model".into()));
3200    }
3201
3202    #[tokio::test]
3203    async fn single_tool_round() {
3204        let (calc, calls) = FakeTool::new("calc", "42");
3205        let mut registry = ToolRegistry::new();
3206        registry.register(calc);
3207        let fake = SharedFake::new([
3208            FakeReply::ToolCalls {
3209                content: "".into(),
3210                calls: vec![call("c1", "calc", r#"{"a":1}"#)],
3211            },
3212            FakeReply::Text("The answer is 42".into()),
3213        ]);
3214        let mut agent = agent_with_registry(fake.clone(), registry, AgentConfig::default());
3215
3216        let answer = agent.run("Compute 1+1").await.unwrap();
3217        assert_eq!(answer, "The answer is 42");
3218        assert_eq!(calls.load(Ordering::Relaxed), 1);
3219
3220        // Second-round request: the tool result was fed back (the history
3221        // contains the ToolResult).
3222        let requests = fake.requests();
3223        assert_eq!(requests.len(), 2);
3224        assert!(requests[1].messages.iter().any(|m| matches!(
3225            m,
3226            Message::ToolResult { id, content } if id == "c1" && content == "42"
3227        )));
3228    }
3229
3230    #[tokio::test]
3231    async fn multiple_tools_same_round() {
3232        let (t1, calls1) = FakeTool::new("t1", "one");
3233        let (t2, calls2) = FakeTool::new("t2", "two");
3234        let mut registry = ToolRegistry::new();
3235        registry.register(t1).register(t2);
3236        let fake = SharedFake::new([
3237            FakeReply::ToolCalls {
3238                content: "".into(),
3239                calls: vec![call("c1", "t1", "{}"), call("c2", "t2", "{}")],
3240            },
3241            FakeReply::Text("done".into()),
3242        ]);
3243        let mut agent = agent_with_registry(fake.clone(), registry, AgentConfig::default());
3244
3245        let answer = agent.run("Run them all").await.unwrap();
3246        assert_eq!(answer, "done");
3247        assert_eq!(calls1.load(Ordering::Relaxed), 1);
3248        assert_eq!(calls2.load(Ordering::Relaxed), 1);
3249
3250        // Two calls in the same round live in the same Assistant message;
3251        // results follow in order.
3252        let requests = fake.requests();
3253        let assistant = requests[1]
3254            .messages
3255            .iter()
3256            .find_map(|m| match m {
3257                Message::Assistant { tool_calls, .. } => Some(tool_calls),
3258                _ => None,
3259            })
3260            .expect("second round should contain Assistant");
3261        assert_eq!(assistant.len(), 2);
3262
3263        let results: Vec<&str> = requests[1]
3264            .messages
3265            .iter()
3266            .filter_map(|m| match m {
3267                Message::ToolResult { content, .. } => Some(content.as_str()),
3268                _ => None,
3269            })
3270            .collect();
3271        assert_eq!(results, vec!["one", "two"]);
3272    }
3273
3274    /// Empty Assistant messages are not recorded: a purely empty reply is
3275    /// not recorded, and the request history only contains the user.
3276    #[tokio::test]
3277    async fn empty_assistant_not_recorded() {
3278        let fake = SharedFake::new([FakeReply::Text("".into())]);
3279        let mut agent = agent(fake.clone(), "");
3280
3281        let answer = agent.run("hi").await.unwrap();
3282        assert_eq!(answer, "");
3283
3284        let requests = fake.requests();
3285        assert_eq!(requests.len(), 1);
3286        assert_eq!(requests[0].messages.len(), 1); // user only
3287    }
3288
3289    /// Default Memory is a bounded window: over-budget conversations
3290    /// auto-trim the oldest rounds and stop growing unboundedly; short
3291    /// conversations don't trigger trimming.
3292    #[tokio::test]
3293    async fn default_memory_is_bounded_window() {
3294        let fake = SharedFake::new([FakeReply::Text("hi".into())]);
3295        let mut agent = agent(fake, "");
3296
3297        // First round: 600k ASCII chars ≈ 150k tokens (CharTokenCounter,
3298        // 4 chars / 1 token) > the default 128k budget — triggers trimming;
3299        // the second round is small and should be fully kept.
3300        agent
3301            .memory
3302            .record(Message::user("x".repeat(600_000)))
3303            .await
3304            .unwrap();
3305        agent
3306            .memory
3307            .record(Message::assistant("first-round reply"))
3308            .await
3309            .unwrap();
3310        agent
3311            .memory
3312            .record(Message::user("second round"))
3313            .await
3314            .unwrap();
3315        agent
3316            .memory
3317            .record(Message::assistant("second-round reply"))
3318            .await
3319            .unwrap();
3320
3321        let ctx = agent.memory.context().await.unwrap();
3322        assert_eq!(
3323            ctx,
3324            vec![
3325                Message::user("second round"),
3326                Message::assistant("second-round reply")
3327            ]
3328        );
3329    }
3330
3331    /// Round limit: the model keeps requesting tools → Err(TooManyToolRounds);
3332    /// no further conversations are started.
3333    #[tokio::test]
3334    async fn too_many_tool_rounds() {
3335        let (calc, _calls) = FakeTool::new("calc", "42");
3336        let mut registry = ToolRegistry::new();
3337        registry.register(calc);
3338        let fake = SharedFake::new([
3339            FakeReply::ToolCalls {
3340                content: "".into(),
3341                calls: vec![call("c1", "calc", "{}")],
3342            },
3343            FakeReply::ToolCalls {
3344                content: "".into(),
3345                calls: vec![call("c2", "calc", "{}")],
3346            },
3347        ]);
3348        let mut agent = agent_with_registry(
3349            fake.clone(),
3350            registry,
3351            AgentConfig {
3352                max_tool_rounds: 2,
3353                ..Default::default()
3354            },
3355        );
3356
3357        let err = agent.run("Keep computing").await.unwrap_err();
3358        assert!(matches!(err, AgentError::TooManyToolRounds(2)));
3359        assert_eq!(fake.requests().len(), 2); // only two conversation rounds were sent
3360    }
3361
3362    /// system_prompt: assembled on every request, exactly one System at the
3363    /// front; not written to Memory.
3364    #[tokio::test]
3365    async fn system_prompt_assembled_every_request() {
3366        let fake = SharedFake::new([
3367            FakeReply::Text("Hello".into()),
3368            FakeReply::Text("Goodbye".into()),
3369        ]);
3370        let mut agent = agent(fake.clone(), "You are an assistant");
3371
3372        agent.run("Are you there").await.unwrap();
3373        agent.run("Any more?").await.unwrap();
3374
3375        let requests = fake.requests();
3376        assert_eq!(requests.len(), 2);
3377        // Each request: exactly one System and it's first; the second
3378        // request also carries the previous round's conversation history.
3379        for request in &requests {
3380            let systems = request
3381                .messages
3382                .iter()
3383                .filter(|m| matches!(m, Message::System(_)))
3384                .count();
3385            assert_eq!(systems, 1);
3386            assert_eq!(request.messages[0], Message::system("You are an assistant"));
3387        }
3388        assert_eq!(requests[1].messages.len(), 4); // System + previous round's user/assistant + this round's user
3389        assert_eq!(requests[1].messages[3], Message::user("Any more?"));
3390    }
3391
3392    /// Macro: arms without a system prompt — no System message is
3393    /// assembled when system_prompt is omitted.
3394    #[tokio::test]
3395    async fn macro_arms_without_system_prompt() {
3396        // Bare arm: no tools, no system prompt.
3397        let fake = SharedFake::new([FakeReply::Text("hi".into())]);
3398        let mut agent = crate::react_agent!(fake.clone());
3399        assert_eq!(agent.run("hi").await.unwrap(), "hi");
3400        assert_eq!(fake.requests()[0].messages.len(), 1); // user only, no System
3401
3402        // Tool-list arm: no system prompt.
3403        let (t1, calls1) = FakeTool::new("t1", "one");
3404        let fake = SharedFake::new([
3405            FakeReply::ToolCalls {
3406                content: "".into(),
3407                calls: vec![call("c1", "t1", "{}")],
3408            },
3409            FakeReply::Text("done".into()),
3410        ]);
3411        let mut agent = crate::react_agent!(fake.clone(), [t1]);
3412        assert_eq!(agent.run("x").await.unwrap(), "done");
3413        assert_eq!(calls1.load(Ordering::Relaxed), 1);
3414        assert_eq!(fake.requests()[1].messages.len(), 3); // user + assistant + tool, no System
3415
3416        // Registry arm: no system prompt.
3417        let (t2, _calls2) = FakeTool::new("t2", "two");
3418        let mut registry = ToolRegistry::new();
3419        registry.register(t2);
3420        let fake = SharedFake::new([FakeReply::Text("hi".into())]);
3421        let mut agent = crate::react_agent!(fake.clone(), registry);
3422        assert_eq!(agent.run("hi").await.unwrap(), "hi");
3423        assert_eq!(fake.requests()[0].messages.len(), 1); // no System
3424    }
3425
3426    /// Macro, three arms: no tools / heterogeneous tool list
3427    /// (auto-registered) / existing registry.
3428    #[tokio::test]
3429    async fn macro_three_arms() {
3430        // No-tool arm: empty registry, answers directly.
3431        let fake = SharedFake::new([FakeReply::Text("hi".into())]);
3432        let mut agent = crate::react_agent!(fake.clone(), "");
3433        assert_eq!(agent.run("hi").await.unwrap(), "hi");
3434        assert_eq!(fake.requests()[0].messages.len(), 1);
3435
3436        // Heterogeneous tool-list arm: two tools of different types are
3437        // auto-registered and actually executed.
3438        let (t1, calls1) = FakeTool::new("t1", "one");
3439        let (t2, calls2) = FakeTool::new("t2", "two");
3440        let fake = SharedFake::new([
3441            FakeReply::ToolCalls {
3442                content: "".into(),
3443                calls: vec![call("c1", "t1", "{}")],
3444            },
3445            FakeReply::Text("done".into()),
3446        ]);
3447        let mut agent = crate::react_agent!(fake.clone(), [t1, t2], "");
3448        assert_eq!(agent.run("x").await.unwrap(), "done");
3449        assert_eq!(calls1.load(Ordering::Relaxed), 1);
3450        assert_eq!(calls2.load(Ordering::Relaxed), 0);
3451
3452        // Registry arm: the existing registry is passed through as-is.
3453        let (t3, calls3) = FakeTool::new("t3", "three");
3454        let mut registry = ToolRegistry::new();
3455        registry.register(t3);
3456        let fake = SharedFake::new([
3457            FakeReply::ToolCalls {
3458                content: "".into(),
3459                calls: vec![call("c1", "t3", "{}")],
3460            },
3461            FakeReply::Text("done".into()),
3462        ]);
3463        let mut agent = crate::react_agent!(fake, registry, "");
3464        assert_eq!(agent.run("x").await.unwrap(), "done");
3465        assert_eq!(calls3.load(Ordering::Relaxed), 1);
3466    }
3467
3468    /// All six macro arms construct (isomorphic to the examples/
3469    /// react_agent.rs doc demo, ensuring the example doc code compiles).
3470    #[test]
3471    fn macro_all_arms_compile() {
3472        struct Echo;
3473        #[async_trait::async_trait]
3474        impl Tool for Echo {
3475            fn schema(&self) -> ToolSchema {
3476                ToolSchema::new("echo", "Echo", serde_json::json!({}))
3477            }
3478            async fn call(
3479                &self,
3480                _arguments: serde_json::Value,
3481                _context: ToolContext<'_>,
3482            ) -> Result<ToolResult, ToolError> {
3483                Ok(ToolOutput::text("echo").into())
3484            }
3485        }
3486
3487        fn fake() -> SharedFake {
3488            SharedFake::new([FakeReply::Text("hi".into())])
3489        }
3490
3491        let a1 = crate::react_agent!(fake()); // no tools, no system prompt
3492        let a2 = crate::react_agent!(fake(), "You are an assistant"); // no tools, with system prompt
3493        let a3 = crate::react_agent!(fake(), [Echo]); // tool list, no system prompt
3494        let a4 = crate::react_agent!(fake(), [Echo], "You are an assistant"); // tool list, with system prompt
3495        let mut registry = ToolRegistry::new();
3496        registry.register(Echo);
3497        let a5 = crate::react_agent!(fake(), registry.clone()); // existing registry, no system prompt
3498        let a6 = crate::react_agent!(fake(), registry, "You are an assistant"); // existing registry, with system prompt
3499        let _ = (a1, a2, a3, a4, a5, a6);
3500    }
3501
3502    /// Empty system prompt: no System message is assembled.
3503    #[tokio::test]
3504    async fn empty_system_prompt_skips_system_message() {
3505        let fake = SharedFake::new([FakeReply::Text("hi".into())]);
3506        let mut agent = agent(fake.clone(), "");
3507
3508        agent.run("hi").await.unwrap();
3509
3510        let requests = fake.requests();
3511        assert_eq!(requests[0].messages.len(), 1);
3512        assert_eq!(requests[0].messages[0], Message::user("hi"));
3513    }
3514
3515    /// Tool failures are not AgentError: the error text is fed back to the
3516    /// model and the loop continues.
3517    #[tokio::test]
3518    async fn tool_failure_returns_text_and_continues() {
3519        // Failing tool: always Err on execution.
3520        struct FailingTool;
3521        #[async_trait::async_trait]
3522        impl Tool for FailingTool {
3523            fn schema(&self) -> ToolSchema {
3524                ToolSchema::new("boom", "Tool that always fails", serde_json::json!({}))
3525            }
3526            async fn call(
3527                &self,
3528                _arguments: serde_json::Value,
3529                _context: ToolContext<'_>,
3530            ) -> Result<ToolResult, ToolError> {
3531                Err(ToolError::Execution("internal error".into()))
3532            }
3533        }
3534
3535        let mut registry = ToolRegistry::new();
3536        registry.register(FailingTool);
3537        let fake = SharedFake::new([
3538            FakeReply::ToolCalls {
3539                content: "".into(),
3540                calls: vec![call("c1", "boom", "{}")],
3541            },
3542            FakeReply::Text("Got it".into()),
3543        ]);
3544        let mut agent = agent_with_registry(fake.clone(), registry, AgentConfig::default());
3545
3546        let answer = agent.run("Trigger failure").await.unwrap();
3547        assert_eq!(answer, "Got it");
3548
3549        // The error was turned into text and fed back (registry
3550        // semantics); the loop didn't stop.
3551        let requests = fake.requests();
3552        assert!(requests[1].messages.iter().any(|m| matches!(
3553            m,
3554            Message::ToolResult { content, .. } if content.contains("internal error")
3555        )));
3556    }
3557
3558    // ---- Streaming-path error semantics ----
3559
3560    /// Streaming: exceeding the round limit terminates with
3561    /// Err(TooManyToolRounds); no Done / third request.
3562    #[tokio::test]
3563    async fn stream_too_many_tool_rounds_terminates() {
3564        let (calc, _calls) = FakeTool::new("calc", "42");
3565        let mut registry = ToolRegistry::new();
3566        registry.register(calc);
3567        let fake = SharedFake::new([
3568            FakeReply::ToolCalls {
3569                content: "".into(),
3570                calls: vec![call("c1", "calc", "{}")],
3571            },
3572            FakeReply::ToolCalls {
3573                content: "".into(),
3574                calls: vec![call("c2", "calc", "{}")],
3575            },
3576        ]);
3577        let mut agent = agent_with_registry(
3578            fake.clone(),
3579            registry,
3580            AgentConfig {
3581                max_tool_rounds: 2,
3582                ..Default::default()
3583            },
3584        );
3585
3586        let mut stream = agent.run_stream("Keep computing").await.unwrap();
3587        let chunks: Vec<Result<MessageChunk, AgentError>> = stream.by_ref().collect().await;
3588        // Both rounds fully execute (tool rounds run before the limit
3589        // check: ToolCall+ToolResult ×2), and the top-of-round check of the
3590        // third round hits the limit: Err terminates, no Done, only two
3591        // conversation rounds sent.
3592        assert_eq!(chunks.len(), 5);
3593        assert!(matches!(
3594            chunks.last(),
3595            Some(Err(AgentError::TooManyToolRounds(2)))
3596        ));
3597        assert_eq!(fake.requests().len(), 2);
3598    }
3599
3600    /// Streaming: entry record(user) failure → direct Err, no stream
3601    /// produced.
3602    #[tokio::test]
3603    async fn stream_entry_record_user_failure_returns_error() {
3604        struct FailingUserMemory;
3605        #[async_trait::async_trait]
3606        impl Memory for FailingUserMemory {
3607            async fn record(&mut self, _message: Message) -> Result<(), MemoryError> {
3608                Err(MemoryError::Storage("disk full".into()))
3609            }
3610            async fn context(&self) -> Result<Vec<Message>, MemoryError> {
3611                Ok(Vec::new())
3612            }
3613        }
3614
3615        let fake = SharedFake::new([FakeReply::Text("hi".into())]);
3616        let mut agent = agent(fake.clone(), "").with_memory(FailingUserMemory);
3617        let err = match agent.run_stream("hi").await {
3618            Err(e) => e,
3619            Ok(_) => panic!("expected input recording failure to return Err directly"),
3620        };
3621        assert!(matches!(err, AgentError::Memory(MemoryError::Storage(_))));
3622    }
3623
3624    /// Streaming: in-round context() failure → Err terminates the stream
3625    /// (no further chunks).
3626    #[tokio::test]
3627    async fn stream_context_failure_terminates_with_err() {
3628        struct FailingContextMemory;
3629        #[async_trait::async_trait]
3630        impl Memory for FailingContextMemory {
3631            async fn record(&mut self, _message: Message) -> Result<(), MemoryError> {
3632                Ok(())
3633            }
3634            async fn context(&self) -> Result<Vec<Message>, MemoryError> {
3635                Err(MemoryError::Storage("disk full".into()))
3636            }
3637        }
3638
3639        let fake = SharedFake::new([FakeReply::Text("hi".into())]);
3640        let mut agent = agent(fake.clone(), "").with_memory(FailingContextMemory);
3641        let mut stream = agent.run_stream("hi").await.unwrap();
3642        let chunks: Vec<Result<MessageChunk, AgentError>> = stream.by_ref().collect().await;
3643        assert_eq!(chunks.len(), 1);
3644        assert!(matches!(
3645            chunks[0],
3646            Err(AgentError::Memory(MemoryError::Storage(_)))
3647        ));
3648    }
3649
3650    /// Streaming: Assistant record failure → Err terminates the stream
3651    /// (Delta already produced, no Done).
3652    #[tokio::test]
3653    async fn stream_assistant_record_failure_terminates_with_err() {
3654        struct FailingAssistantMemory;
3655        #[async_trait::async_trait]
3656        impl Memory for FailingAssistantMemory {
3657            async fn record(&mut self, message: Message) -> Result<(), MemoryError> {
3658                if matches!(message, Message::Assistant { .. }) {
3659                    Err(MemoryError::Storage("disk full".into()))
3660                } else {
3661                    Ok(())
3662                }
3663            }
3664            async fn context(&self) -> Result<Vec<Message>, MemoryError> {
3665                Ok(Vec::new())
3666            }
3667        }
3668
3669        let fake = SharedFake::new([FakeReply::Text("hi".into())]);
3670        let mut agent = agent(fake.clone(), "").with_memory(FailingAssistantMemory);
3671        let mut stream = agent.run_stream("hi").await.unwrap();
3672        let chunks: Vec<Result<MessageChunk, AgentError>> = stream.by_ref().collect().await;
3673        assert_eq!(chunks.len(), 2); // Delta + Err
3674        assert!(matches!(
3675            chunks.last(),
3676            Some(Err(AgentError::Memory(MemoryError::Storage(_))))
3677        ));
3678    }
3679
3680    /// Streaming: tool failures turn into text fed back; the stream
3681    /// continues to Done (same semantics as run).
3682    #[tokio::test]
3683    async fn stream_tool_failure_returns_text_and_continues() {
3684        struct FailingTool;
3685        #[async_trait::async_trait]
3686        impl Tool for FailingTool {
3687            fn schema(&self) -> ToolSchema {
3688                ToolSchema::new("boom", "Tool that always fails", serde_json::json!({}))
3689            }
3690            async fn call(
3691                &self,
3692                _arguments: serde_json::Value,
3693                _context: ToolContext<'_>,
3694            ) -> Result<ToolResult, ToolError> {
3695                Err(ToolError::Execution("internal error".into()))
3696            }
3697        }
3698
3699        let mut registry = ToolRegistry::new();
3700        registry.register(FailingTool);
3701        let fake = SharedFake::new([
3702            FakeReply::ToolCalls {
3703                content: "".into(),
3704                calls: vec![call("c1", "boom", "{}")],
3705            },
3706            FakeReply::Text("Got it".into()),
3707        ]);
3708        let mut agent = agent_with_registry(fake.clone(), registry, AgentConfig::default());
3709
3710        let mut stream = agent.run_stream("Trigger failure").await.unwrap();
3711        let chunks: Vec<MessageChunk> = stream.by_ref().map(|e| e.unwrap()).collect().await;
3712        assert!(matches!(
3713            &chunks[1],
3714            MessageChunk::ToolResult { content, .. } if content.contains("internal error")
3715        ));
3716        // The stream continues: the model answers directly in the second
3717        // round and Done follows.
3718        assert_done_summary(chunks.last().unwrap(), 2, 1, Usage::default(), true);
3719    }
3720
3721    /// Streaming: multiple tools in one round, chunks produced strictly in
3722    /// request order → result order, with correct summary counts.
3723    #[tokio::test]
3724    async fn stream_multiple_tools_same_round() {
3725        let (calc_a, _calls) = FakeTool::new("calc_a", "A");
3726        let (calc_b, _calls) = FakeTool::new("calc_b", "B");
3727        let mut registry = ToolRegistry::new();
3728        registry.register(calc_a).register(calc_b);
3729        let fake = SharedFake::new([
3730            FakeReply::ToolCalls {
3731                content: "".into(),
3732                calls: vec![call("c1", "calc_a", "{}"), call("c2", "calc_b", "{}")],
3733            },
3734            FakeReply::Text("Done".into()),
3735        ]);
3736        let mut agent = agent_with_registry(fake.clone(), registry, AgentConfig::default());
3737
3738        let mut stream = agent.run_stream("Compute").await.unwrap();
3739        let chunks: Vec<MessageChunk> = stream.by_ref().map(|e| e.unwrap()).collect().await;
3740        assert_eq!(chunks.len(), 6);
3741        assert_eq!(
3742            &chunks[..5],
3743            &[
3744                MessageChunk::ToolCall {
3745                    id: "c1".into(),
3746                    name: "calc_a".into(),
3747                    arguments: "{}".into()
3748                },
3749                MessageChunk::ToolCall {
3750                    id: "c2".into(),
3751                    name: "calc_b".into(),
3752                    arguments: "{}".into()
3753                },
3754                MessageChunk::ToolResult {
3755                    id: "c1".into(),
3756                    name: "calc_a".into(),
3757                    content: "A".into()
3758                },
3759                MessageChunk::ToolResult {
3760                    id: "c2".into(),
3761                    name: "calc_b".into(),
3762                    content: "B".into()
3763                },
3764                MessageChunk::Delta("Done".into()),
3765            ]
3766        );
3767        assert_done_summary(&chunks[5], 2, 2, Usage::default(), true);
3768    }
3769
3770    /// Error semantics are equivalent across paths: with the same failure
3771    /// script (round limit), run and run_stream agree on error type and
3772    /// request history.
3773    #[tokio::test]
3774    async fn run_and_stream_error_semantics_equivalent() {
3775        let script = |fake: &SharedFake| {
3776            let (calc, _calls) = FakeTool::new("calc", "42");
3777            let mut registry = ToolRegistry::new();
3778            registry.register(calc);
3779            agent_with_registry(
3780                fake.clone(),
3781                registry,
3782                AgentConfig {
3783                    max_tool_rounds: 1,
3784                    ..Default::default()
3785                },
3786            )
3787        };
3788
3789        // Non-streaming: round-limit Err.
3790        let fake = SharedFake::new([
3791            FakeReply::ToolCalls {
3792                content: "".into(),
3793                calls: vec![call("c1", "calc", "{}")],
3794            },
3795            FakeReply::ToolCalls {
3796                content: "".into(),
3797                calls: vec![call("c2", "calc", "{}")],
3798            },
3799        ]);
3800        let mut agent = script(&fake);
3801        let run_err = agent.run("Compute").await.unwrap_err();
3802
3803        // Streaming: same script, terminates with Err.
3804        let fake2 = SharedFake::new([
3805            FakeReply::ToolCalls {
3806                content: "".into(),
3807                calls: vec![call("c1", "calc", "{}")],
3808            },
3809            FakeReply::ToolCalls {
3810                content: "".into(),
3811                calls: vec![call("c2", "calc", "{}")],
3812            },
3813        ]);
3814        let mut agent = script(&fake2);
3815        let mut stream = agent.run_stream("Compute").await.unwrap();
3816        let chunks: Vec<Result<MessageChunk, AgentError>> = stream.by_ref().collect().await;
3817        let stream_err = chunks.into_iter().find_map(|e| e.err());
3818
3819        // Same error type, same request count.
3820        assert_eq!(run_err, AgentError::TooManyToolRounds(1));
3821        assert_eq!(stream_err, Some(AgentError::TooManyToolRounds(1)));
3822        assert_eq!(fake.requests().len(), fake2.requests().len());
3823    }
3824
3825    /// run_id is unique across instances: two back-to-back instances have
3826    /// different ids on their first run.
3827    #[tokio::test]
3828    async fn run_id_differs_across_instances() {
3829        let id_a = RunContext::generated().run_id;
3830        let id_b = RunContext::generated().run_id;
3831        assert_ne!(
3832            id_a, id_b,
3833            "back-to-back instances must not collide on run_id"
3834        );
3835        // Same format as the trace tests: run-{ts}-{n}.
3836        assert!(id_a.starts_with("run-") && id_b.starts_with("run-"));
3837    }
3838
3839    /// Non-streaming: in-round context() failure → Err(Memory) passes
3840    /// through (symmetric with streaming).
3841    #[tokio::test]
3842    async fn run_context_failure_returns_memory_error() {
3843        struct FailingContextMemory;
3844        #[async_trait::async_trait]
3845        impl Memory for FailingContextMemory {
3846            async fn record(&mut self, _message: Message) -> Result<(), MemoryError> {
3847                Ok(())
3848            }
3849            async fn context(&self) -> Result<Vec<Message>, MemoryError> {
3850                Err(MemoryError::Storage("disk full".into()))
3851            }
3852        }
3853
3854        let fake = SharedFake::new([FakeReply::Text("hi".into())]);
3855        let mut agent = agent(fake.clone(), "").with_memory(FailingContextMemory);
3856        let err = agent.run("hi").await.unwrap_err();
3857        assert!(matches!(err, AgentError::Memory(MemoryError::Storage(_))));
3858        // No conversation was started.
3859        assert!(fake.requests().is_empty());
3860    }
3861
3862    /// Empty-stream semantics: end-of-round wrap-up with no answer
3863    /// recorded, and a normal Done.
3864    ///
3865    /// Note: reporting `Err` for truncated / empty streams is the Provider
3866    /// implementation's job; this test covers the Agent's behavior when the
3867    /// Provider delivers a legitimately empty stream.
3868    #[tokio::test]
3869    async fn stream_empty_provider_stream_yields_empty_answer() {
3870        struct EmptyStreamProvider;
3871        #[async_trait::async_trait]
3872        impl Provider for EmptyStreamProvider {
3873            async fn chat_with_context(
3874                &self,
3875                _r: ChatRequest,
3876                _context: &ProviderRequestContext,
3877            ) -> Result<ChatResponse, ProviderError> {
3878                unreachable!("this test uses streaming only")
3879            }
3880            async fn stream_chat_with_context(
3881                &self,
3882                _r: ChatRequest,
3883                _context: &ProviderRequestContext,
3884            ) -> Result<BoxStream<'static, Result<StreamEvent, ProviderError>>, ProviderError>
3885            {
3886                Ok(Box::pin(futures::stream::empty()))
3887            }
3888        }
3889
3890        let mut agent = ReActAgent::new(EmptyStreamProvider, ToolRegistry::new(), "");
3891        let chunks: Vec<Result<MessageChunk, AgentError>> = {
3892            let mut stream = agent.run_stream("hi").await.unwrap();
3893            stream.by_ref().collect().await
3894        };
3895        // Empty answer: not recorded + normal Done (rounds counts 1).
3896        assert_eq!(chunks.len(), 1);
3897        let chunk = chunks[0].as_ref().unwrap();
3898        assert_done_summary_with_finish(chunk, 1, 0, Usage::default(), true, None);
3899        // The memory only holds the user input; no empty Assistant was
3900        // recorded.
3901        assert_eq!(
3902            agent.memory.context().await.unwrap(),
3903            vec![Message::user("hi")]
3904        );
3905    }
3906
3907    /// Streaming: event order Delta → ToolCall → ToolResult → Done;
3908    /// reasoning never enters MessageChunk (only recorded with the
3909    /// message).
3910    #[tokio::test]
3911    async fn stream_event_order() {
3912        let (calc, _calls) = FakeTool::new("calc", "42");
3913        let mut registry = ToolRegistry::new();
3914        registry.register(calc);
3915        let fake = SharedFake::new([
3916            FakeReply::ToolCalls {
3917                content: "Thinking: ".into(),
3918                calls: vec![call("c1", "calc", "{}")],
3919            },
3920            FakeReply::TextWithReasoning {
3921                content: "The answer is 42".into(),
3922                reasoning: "Reasoning steps".into(),
3923            },
3924        ]);
3925        let mut agent = agent_with_registry(fake.clone(), registry, AgentConfig::default());
3926
3927        let mut stream = agent.run_stream("Compute").await.unwrap();
3928        let events: Vec<MessageChunk> = stream.by_ref().map(|e| e.unwrap()).collect().await;
3929        assert_eq!(events.len(), 5);
3930        assert_eq!(
3931            &events[..4],
3932            &[
3933                MessageChunk::Delta("Thinking: ".into()),
3934                MessageChunk::ToolCall {
3935                    id: "c1".into(),
3936                    name: "calc".into(),
3937                    arguments: "{}".into()
3938                },
3939                MessageChunk::ToolResult {
3940                    id: "c1".into(),
3941                    name: "calc".into(),
3942                    content: "42".into()
3943                },
3944                MessageChunk::Delta("The answer is 42".into()),
3945            ]
3946        );
3947        assert_done_summary(&events[4], 2, 1, Usage::default(), true);
3948    }
3949
3950    /// Pure tool round: no text, no Delta dispatched; the tool call still
3951    /// executes and feeds back.
3952    #[tokio::test]
3953    async fn stream_pure_tool_round_no_delta() {
3954        let (calc, _calls) = FakeTool::new("calc", "42");
3955        let mut registry = ToolRegistry::new();
3956        registry.register(calc);
3957        let fake = SharedFake::new([
3958            FakeReply::ToolCalls {
3959                content: "".into(),
3960                calls: vec![call("c1", "calc", "{}")],
3961            },
3962            FakeReply::Text("42".into()),
3963        ]);
3964        let mut agent = agent_with_registry(fake.clone(), registry, AgentConfig::default());
3965
3966        let mut stream = agent.run_stream("Compute").await.unwrap();
3967        let events: Vec<MessageChunk> = stream.by_ref().map(|e| e.unwrap()).collect().await;
3968        assert_eq!(events.len(), 4);
3969        assert_eq!(
3970            &events[..3],
3971            &[
3972                MessageChunk::ToolCall {
3973                    id: "c1".into(),
3974                    name: "calc".into(),
3975                    arguments: "{}".into()
3976                },
3977                MessageChunk::ToolResult {
3978                    id: "c1".into(),
3979                    name: "calc".into(),
3980                    content: "42".into()
3981                },
3982                MessageChunk::Delta("42".into()),
3983            ]
3984        );
3985        assert_done_summary(&events[3], 2, 1, Usage::default(), true);
3986    }
3987
3988    /// The Done summary carries real token usage (injected via
3989    /// FakeReply::WithUsage, summed across rounds), with rounds and tool
3990    /// counts tallied by execution.
3991    #[tokio::test]
3992    async fn stream_done_summary_accumulates_usage() {
3993        let (calc, _calls) = FakeTool::new("calc", "42");
3994        let mut registry = ToolRegistry::new();
3995        registry.register(calc);
3996        let fake = SharedFake::new([
3997            FakeReply::WithUsage {
3998                reply: Box::new(FakeReply::ToolCalls {
3999                    content: "".into(),
4000                    calls: vec![call("c1", "calc", "{}")],
4001                }),
4002                usage: Usage::new(10, 2),
4003            },
4004            FakeReply::text_with_usage("42", Usage::new(20, 5)),
4005        ]);
4006        let mut agent = agent_with_registry(fake.clone(), registry, AgentConfig::default());
4007
4008        let mut stream = agent.run_stream("Compute").await.unwrap();
4009        let events: Vec<MessageChunk> = stream.by_ref().map(|e| e.unwrap()).collect().await;
4010
4011        // Two conversation rounds (tool round + direct-answer round), one
4012        // tool execution; usage accumulates per round.
4013        assert_done_summary(events.last().unwrap(), 2, 1, Usage::new(30, 7), false);
4014    }
4015
4016    /// Mixed usage presence: only reported parts are summed, and the
4017    /// summary marks the sum as a lower bound (usage_omitted).
4018    #[tokio::test]
4019    async fn summary_tracks_omitted_usage_rounds() {
4020        let (calc, _calls) = FakeTool::new("calc", "42");
4021        let mut registry = ToolRegistry::new();
4022        registry.register(calc);
4023        // Round 1 (tool round) reports nothing; round 2 (direct answer)
4024        // reports usage: the sum is the reported part only, and the summary
4025        // flags the omission.
4026        let fake = SharedFake::new([
4027            FakeReply::ToolCalls {
4028                content: "".into(),
4029                calls: vec![call("c1", "calc", "{}")],
4030            },
4031            FakeReply::text_with_usage("42", Usage::new(20, 5)),
4032        ]);
4033        let mut agent = agent_with_registry(fake.clone(), registry, AgentConfig::default());
4034
4035        let mut stream = agent.run_stream("Compute").await.unwrap();
4036        let events: Vec<MessageChunk> = stream.by_ref().map(|e| e.unwrap()).collect().await;
4037
4038        assert_done_summary(events.last().unwrap(), 2, 1, Usage::new(20, 5), true);
4039    }
4040
4041    /// The streaming summary sums the reported prompt-cache counts across
4042    /// rounds (only the reported parts) and latches `cache_omitted` when a
4043    /// round reported usage without the breakdown.
4044    #[tokio::test]
4045    async fn stream_done_summary_accumulates_cache_counts() {
4046        let (calc, _calls) = FakeTool::new("calc", "42");
4047        let mut registry = ToolRegistry::new();
4048        registry.register(calc);
4049        // Round 1 (tool round) reports a cache breakdown, round 2 (direct
4050        // answer) reports usage without one: the cached sum keeps the
4051        // reported part and the omission is latched.
4052        let fake = SharedFake::new([
4053            FakeReply::WithUsage {
4054                reply: Box::new(FakeReply::ToolCalls {
4055                    content: "".into(),
4056                    calls: vec![call("c1", "calc", "{}")],
4057                }),
4058                usage: Usage::new(10, 2).with_cached_tokens(4),
4059            },
4060            FakeReply::text_with_usage("42", Usage::new(20, 5)),
4061        ]);
4062        let mut agent = agent_with_registry(fake.clone(), registry, AgentConfig::default());
4063
4064        let mut stream = agent.run_stream("Compute").await.unwrap();
4065        let events: Vec<MessageChunk> = stream.by_ref().map(|e| e.unwrap()).collect().await;
4066
4067        let summary = done_summary(events.last().unwrap()).expect("expected Done chunk");
4068        assert_eq!(summary.usage, Usage::new(30, 7).with_cached_tokens(4));
4069        assert!(!summary.usage_omitted);
4070        assert!(summary.cache_omitted);
4071
4072        // Every round reports a breakdown: the cached sum is exact.
4073        let (calc, _calls) = FakeTool::new("calc", "42");
4074        let mut registry = ToolRegistry::new();
4075        registry.register(calc);
4076        let fake = SharedFake::new([
4077            FakeReply::WithUsage {
4078                reply: Box::new(FakeReply::ToolCalls {
4079                    content: "".into(),
4080                    calls: vec![call("c1", "calc", "{}")],
4081                }),
4082                usage: Usage::new(10, 2).with_cached_tokens(4),
4083            },
4084            FakeReply::text_with_usage("42", Usage::new(20, 5).with_cached_tokens(6)),
4085        ]);
4086        let mut agent = agent_with_registry(fake.clone(), registry, AgentConfig::default());
4087
4088        let mut stream = agent.run_stream("Compute").await.unwrap();
4089        let events: Vec<MessageChunk> = stream.by_ref().map(|e| e.unwrap()).collect().await;
4090
4091        let summary = done_summary(events.last().unwrap()).expect("expected Done chunk");
4092        assert_eq!(summary.usage, Usage::new(30, 7).with_cached_tokens(10));
4093        assert!(!summary.usage_omitted);
4094        assert!(!summary.cache_omitted);
4095    }
4096
4097    /// run and run_stream with the same script share semantics: identical
4098    /// final text, identical request history (identical records).
4099    #[tokio::test]
4100    async fn run_and_stream_same_semantics() {
4101        let script = [
4102            FakeReply::ToolCalls {
4103                content: "".into(),
4104                calls: vec![call("c1", "calc", "{}")],
4105            },
4106            FakeReply::Text("42".into()),
4107        ];
4108        let (calc, _calls) = FakeTool::new("calc", "42");
4109
4110        // Non-streaming path.
4111        let mut registry = ToolRegistry::new();
4112        registry.register(calc.clone());
4113        let fake1 = SharedFake::new(script.clone());
4114        let mut agent1 = agent_with_registry(fake1.clone(), registry, AgentConfig::default());
4115        let answer1 = agent1.run("Compute").await.unwrap();
4116
4117        // Streaming path.
4118        let mut registry = ToolRegistry::new();
4119        registry.register(calc);
4120        let fake2 = SharedFake::new(script);
4121        let mut agent2 = agent_with_registry(fake2.clone(), registry, AgentConfig::default());
4122        let events: Vec<MessageChunk> = agent2
4123            .run_stream("Compute")
4124            .await
4125            .unwrap()
4126            .map(|e| e.unwrap())
4127            .collect()
4128            .await;
4129        // Final answer = concatenation of the Deltas, verbatim.
4130        let answer2: String = events
4131            .iter()
4132            .filter_map(|e| match e {
4133                MessageChunk::Delta(d) => Some(d.as_str()),
4134                _ => None,
4135            })
4136            .collect();
4137        assert_eq!(answer1, answer2);
4138        assert_eq!(answer1, "42");
4139
4140        // Both paths send the same message sequence to the model (same
4141        // recorded history).
4142        assert_eq!(fake1.requests(), fake2.requests());
4143    }
4144
4145    /// In-stream error: after an Err event the stream terminates; no Done.
4146    ///
4147    /// Note: this path (an error mid-event-stream after establishment)
4148    /// can't be built with FakeReply::Error — its semantics are "this round
4149    /// failed; the method returns Err directly"; here a custom Provider
4150    /// produces a stream that errors midway.
4151    #[tokio::test]
4152    async fn stream_error_terminates_without_done() {
4153        struct FailInStream;
4154        #[async_trait::async_trait]
4155        impl Provider for FailInStream {
4156            async fn chat_with_context(
4157                &self,
4158                _request: ChatRequest,
4159                _context: &ProviderRequestContext,
4160            ) -> Result<ChatResponse, ProviderError> {
4161                unreachable!("this test uses streaming path only")
4162            }
4163            async fn stream_chat_with_context(
4164                &self,
4165                _request: ChatRequest,
4166                _context: &ProviderRequestContext,
4167            ) -> Result<BoxStream<'static, Result<StreamEvent, ProviderError>>, ProviderError>
4168            {
4169                Ok(Box::pin(futures::stream::iter(vec![
4170                    Ok(StreamEvent::Delta("hi".into())),
4171                    Err(ProviderError::Protocol {
4172                        message: "boom".into(),
4173                    }),
4174                ])))
4175            }
4176        }
4177
4178        let mut agent = ReActAgent::new(FailInStream, ToolRegistry::new(), "");
4179
4180        let mut stream = agent.run_stream("two").await.unwrap();
4181        assert_eq!(
4182            stream.next().await.unwrap().unwrap(),
4183            MessageChunk::Delta("hi".into())
4184        );
4185        assert!(matches!(
4186            stream.next().await.unwrap(),
4187            Err(AgentError::Provider(ProviderError::Protocol { message: m })) if m == "boom"
4188        ));
4189        assert!(stream.next().await.is_none()); // terminated, no Done
4190    }
4191
4192    /// Script exhausted (one extra round): Err(ProviderError::Protocol("script
4193    /// exhausted")).
4194    #[tokio::test]
4195    async fn script_exhausted_fails_explicitly() {
4196        let fake = SharedFake::new([FakeReply::Text("hi".into())]);
4197        let mut agent = agent(fake, "");
4198        agent.run("one").await.unwrap();
4199
4200        let err = agent.run("two").await.unwrap_err();
4201        assert!(
4202            matches!(err, AgentError::Provider(ProviderError::Protocol { message: m }) if m.contains("exhausted"))
4203        );
4204    }
4205
4206    /// Shared state flows: the loop injects the agent-held state into every
4207    /// tool call, and tools read/write the same instance (the application
4208    /// side can also read/write across runs).
4209    #[tokio::test]
4210    async fn shared_state_flows_to_tools() {
4211        struct CounterTool;
4212        #[async_trait::async_trait]
4213        impl Tool for CounterTool {
4214            fn schema(&self) -> ToolSchema {
4215                ToolSchema::new("counter", "Count", serde_json::json!({}))
4216            }
4217            async fn call(
4218                &self,
4219                _arguments: serde_json::Value,
4220                context: ToolContext<'_>,
4221            ) -> Result<ToolResult, ToolError> {
4222                let state = context.state;
4223                state.with_mut::<usize>(|n| *n += 1);
4224                Ok(ToolOutput::text(format!("count={}", state.get::<usize>().unwrap_or(0))).into())
4225            }
4226        }
4227
4228        let mut registry = ToolRegistry::new();
4229        registry.register(CounterTool);
4230        let fake = SharedFake::new([
4231            FakeReply::ToolCalls {
4232                content: "".into(),
4233                calls: vec![call("c1", "counter", "{}"), call("c2", "counter", "{}")],
4234            },
4235            FakeReply::Text("done".into()),
4236        ]);
4237        let state = SharedState::new();
4238        state.insert(0usize);
4239        let mut agent = ReActAgent::new(fake, registry, "").with_state(state.clone());
4240
4241        agent.run("Count").await.unwrap();
4242
4243        // Two calls in the same round share one instance: the count
4244        // accumulates, and the application side reads the final cross-run
4245        // value.
4246        assert_eq!(state.get::<usize>(), Some(2));
4247    }
4248
4249    /// Memory errors pass through (custom Memory injected via
4250    /// with_memory).
4251    #[tokio::test]
4252    async fn memory_error_passthrough() {
4253        struct FailingMemory;
4254        #[async_trait::async_trait]
4255        impl Memory for FailingMemory {
4256            async fn record(&mut self, _message: Message) -> Result<(), MemoryError> {
4257                Err(MemoryError::Storage("disk full".into()))
4258            }
4259            async fn context(&self) -> Result<Vec<Message>, MemoryError> {
4260                Ok(Vec::new())
4261            }
4262        }
4263
4264        let mut agent = ReActAgent::new(
4265            FakeProvider::new([FakeReply::Text("hi".into())]),
4266            ToolRegistry::new(),
4267            "",
4268        )
4269        .with_memory(FailingMemory);
4270        let err = agent.run("hi").await.unwrap_err();
4271        assert!(matches!(err, AgentError::Memory(MemoryError::Storage(_))));
4272    }
4273
4274    /// Streaming: tool-result record failure → an Err item terminates the
4275    /// stream; no further chunks.
4276    /// Errors are produced as Err items and terminate the stream — the
4277    /// break must exit the whole round loop, otherwise the stream would
4278    /// keep producing the next round after the Err (the memory would lack
4279    /// the ToolResult, leaving the next round's message sequence
4280    /// incomplete).
4281    #[tokio::test]
4282    async fn stream_tool_result_record_failure_terminates_stream() {
4283        struct FailingToolResultMemory;
4284        #[async_trait::async_trait]
4285        impl Memory for FailingToolResultMemory {
4286            async fn record(&mut self, message: Message) -> Result<(), MemoryError> {
4287                if matches!(message, Message::ToolResult { .. }) {
4288                    Err(MemoryError::Storage("disk full".into()))
4289                } else {
4290                    Ok(())
4291                }
4292            }
4293            async fn context(&self) -> Result<Vec<Message>, MemoryError> {
4294                Ok(Vec::new())
4295            }
4296        }
4297
4298        let (calc, _calls) = FakeTool::new("calc", "42");
4299        let mut registry = ToolRegistry::new();
4300        registry.register(calc);
4301        let fake = SharedFake::new([
4302            FakeReply::ToolCalls {
4303                content: "".into(),
4304                calls: vec![call("c1", "calc", "{}")],
4305            },
4306            FakeReply::Text("42".into()),
4307        ]);
4308        let mut agent = agent_with_registry(fake.clone(), registry, AgentConfig::default())
4309            .with_memory(FailingToolResultMemory);
4310
4311        let mut stream = agent.run_stream("Compute").await.unwrap();
4312        let chunks: Vec<Result<MessageChunk, AgentError>> = stream.by_ref().collect().await;
4313        // ToolCall → ToolResult → Err (terminates): nothing after the Err.
4314        assert_eq!(chunks.len(), 3);
4315        assert!(matches!(
4316            chunks[2],
4317            Err(AgentError::Memory(MemoryError::Storage(_)))
4318        ));
4319    }
4320
4321    /// AgentConfig.options passes through: every round's ChatRequest
4322    /// carries the configured model parameters.
4323    #[tokio::test]
4324    async fn config_options_forwarded_to_chat_request() {
4325        let fake = SharedFake::new([FakeReply::Text("hi".into())]);
4326        let mut agent = agent(fake.clone(), "").with_config(AgentConfig {
4327            max_tool_rounds: 10,
4328            options: ModelOptions {
4329                temperature: Some(0.2),
4330                max_tokens: Some(128),
4331                extra: Default::default(),
4332                structured: None,
4333            },
4334            ..Default::default()
4335        });
4336        agent.run("hi").await.unwrap();
4337
4338        let req = &fake.requests()[0];
4339        assert_eq!(req.options.temperature, Some(0.2));
4340        assert_eq!(req.options.max_tokens, Some(128));
4341    }
4342
4343    #[tokio::test]
4344    async fn request_options_replace_config_options() {
4345        let fake = SharedFake::new([FakeReply::Text("hi".into())]);
4346        let mut agent = agent(fake.clone(), "").with_config(AgentConfig {
4347            options: ModelOptions {
4348                temperature: Some(0.2),
4349                max_tokens: Some(128),
4350                ..Default::default()
4351            },
4352            ..Default::default()
4353        });
4354        agent
4355            .run_request(RunRequest::text("hi").with_options(ModelOptions {
4356                max_tokens: Some(64),
4357                ..Default::default()
4358            }))
4359            .await
4360            .unwrap();
4361
4362        let req = &fake.requests()[0];
4363        assert_eq!(req.options.temperature, None);
4364        assert_eq!(req.options.max_tokens, Some(64));
4365    }
4366
4367    // ---- Cancellation through RunContext ----
4368
4369    /// Pre-round cancellation: with an already-cancelled token,
4370    /// run_request_with_context returns Cancelled immediately, having started no
4371    /// conversation; the user message is already recorded (kept, consistent
4372    /// with the main path).
4373    #[tokio::test]
4374    async fn cancelled_before_run_returns_cancelled() {
4375        let fake = SharedFake::new([FakeReply::Text("hi".into())]);
4376        let mut agent = agent(fake.clone(), "");
4377        let token = CancellationToken::new();
4378        token.cancel();
4379
4380        let err = agent
4381            .run_request_with_context(RunRequest::text("hi"), cancellation_context(&token))
4382            .await
4383            .unwrap_err();
4384        assert!(matches!(err, AgentError::Cancelled));
4385        assert_eq!(fake.requests().len(), 0); // no conversation started
4386        assert_eq!(agent.memory.context().await.unwrap().len(), 1); // user only
4387    }
4388
4389    /// Pre-cancelled token: both paths report the same rounds in RunEnded
4390    /// (incremented at the start of the round, both report 1).
4391    #[tokio::test]
4392    async fn pre_cancelled_token_rounds_consistent_across_paths() {
4393        let fake = SharedFake::new([FakeReply::Text("hi".into())]);
4394        let token = CancellationToken::new();
4395        token.cancel();
4396
4397        // Non-streaming path.
4398        let (mut run_agent, mut rx) = attach_channel(agent(fake.clone(), ""));
4399        let err = run_agent
4400            .run_request_with_context(RunRequest::text("hi"), cancellation_context(&token))
4401            .await
4402            .unwrap_err();
4403        assert!(matches!(err, AgentError::Cancelled));
4404        drop(run_agent);
4405        let events = drain(&mut rx).await;
4406        let rounds_run = match react_event(&**events.last().unwrap()) {
4407            ReActEvent::RunEnded { summary, error, .. } => {
4408                assert_eq!(error, &Some(AgentError::Cancelled));
4409                summary.rounds
4410            }
4411            _ => panic!("expected RunEnded"),
4412        };
4413
4414        // Streaming path.
4415        let (mut stream_agent, mut rx) = attach_channel(agent(fake, ""));
4416        let mut stream = stream_agent
4417            .run_stream_request_with_context(RunRequest::text("hi"), cancellation_context(&token))
4418            .await
4419            .unwrap();
4420        let chunks: Vec<MessageChunk> = stream.by_ref().map(|e| e.unwrap()).collect().await;
4421        assert!(chunks.contains(&MessageChunk::Cancelled));
4422        drop(stream);
4423        drop(stream_agent);
4424        let events = drain(&mut rx).await;
4425        let rounds_stream = match react_event(&**events.last().unwrap()) {
4426            ReActEvent::RunEnded { summary, error, .. } => {
4427                assert_eq!(error, &Some(AgentError::Cancelled));
4428                summary.rounds
4429            }
4430            _ => panic!("expected RunEnded"),
4431        };
4432
4433        assert_eq!(rounds_run, rounds_stream);
4434        assert_eq!(rounds_run, 1);
4435    }
4436
4437    /// Immediate cancellation mid-conversation: cancelling while chat is
4438    /// pending makes run return Cancelled immediately (dropping the
4439    /// in-flight request); nothing is recorded for this round, leaving no
4440    /// orphan Assistant in the memory.
4441    #[tokio::test]
4442    async fn cancel_during_chat_drops_inflight() {
4443        struct PendingProvider;
4444        #[async_trait::async_trait]
4445        impl Provider for PendingProvider {
4446            async fn chat_with_context(
4447                &self,
4448                _request: ChatRequest,
4449                _context: &ProviderRequestContext,
4450            ) -> Result<ChatResponse, ProviderError> {
4451                std::future::pending().await // never completes: simulates a slow LLM
4452            }
4453            async fn stream_chat_with_context(
4454                &self,
4455                _request: ChatRequest,
4456                _context: &ProviderRequestContext,
4457            ) -> Result<BoxStream<'static, Result<StreamEvent, ProviderError>>, ProviderError>
4458            {
4459                unreachable!("this test uses non-streaming path only")
4460            }
4461        }
4462
4463        let mut agent = ReActAgent::new(PendingProvider, ToolRegistry::new(), "");
4464        let token = CancellationToken::new();
4465        let result = tokio::select! {
4466            r = agent.run_request_with_context(RunRequest::text("hi"), cancellation_context(&token)) => r.map(|output| output.answer),
4467            _ = async {
4468                tokio::time::sleep(Duration::from_millis(20)).await;
4469                token.cancel();
4470                std::future::pending::<()>().await; // stay pending so the run branch wins
4471            } => unreachable!("cancellation branch only sends a signal"),
4472        };
4473        assert!(matches!(result, Err(AgentError::Cancelled)));
4474        assert_eq!(agent.memory.context().await.unwrap().len(), 1); // user only
4475    }
4476
4477    /// Non-cancellation error propagation: when the Provider returns a
4478    /// Timeout, both run / run_stream return AgentError::Provider(Timeout),
4479    /// and the memory holds no orphan messages (user only, no half-recorded
4480    /// residue).
4481    #[tokio::test]
4482    async fn provider_error_propagates_from_both_paths() {
4483        struct FailProvider(ProviderError);
4484        #[async_trait::async_trait]
4485        impl Provider for FailProvider {
4486            async fn chat_with_context(
4487                &self,
4488                _request: ChatRequest,
4489                _context: &ProviderRequestContext,
4490            ) -> Result<ChatResponse, ProviderError> {
4491                Err(self.0.clone())
4492            }
4493            async fn stream_chat_with_context(
4494                &self,
4495                _request: ChatRequest,
4496                _context: &ProviderRequestContext,
4497            ) -> Result<BoxStream<'static, Result<StreamEvent, ProviderError>>, ProviderError>
4498            {
4499                Err(self.0.clone())
4500            }
4501        }
4502
4503        let err = ProviderError::Timeout(TimeoutStage::Request);
4504        // Non-streaming: the error is returned directly.
4505        let mut agent = ReActAgent::new(FailProvider(err.clone()), ToolRegistry::new(), "");
4506        let got = agent.run("hi").await.unwrap_err();
4507        assert!(matches!(
4508            got,
4509            AgentError::Provider(ProviderError::Timeout(_))
4510        ));
4511        assert_eq!(agent.memory.context().await.unwrap().len(), 1); // user only
4512
4513        // Streaming: the error terminates the stream as an in-stream Err
4514        // item; nothing follows (no Done).
4515        let mut agent = ReActAgent::new(FailProvider(err.clone()), ToolRegistry::new(), "");
4516        let mut stream = agent.run_stream("hi").await.unwrap();
4517        let item = stream.next().await.unwrap().unwrap_err();
4518        assert!(matches!(
4519            item,
4520            AgentError::Provider(ProviderError::Timeout(_))
4521        ));
4522        assert!(stream.next().await.is_none());
4523        drop(stream); // release the borrow of agent before inspecting the memory
4524        assert_eq!(agent.memory.context().await.unwrap().len(), 1);
4525    }
4526
4527    #[tokio::test]
4528    async fn deadline_exceeded_during_chat_is_distinct_from_provider_timeout() {
4529        struct PendingProvider;
4530        #[async_trait::async_trait]
4531        impl Provider for PendingProvider {
4532            async fn chat_with_context(
4533                &self,
4534                _request: ChatRequest,
4535                _context: &ProviderRequestContext,
4536            ) -> Result<ChatResponse, ProviderError> {
4537                std::future::pending().await
4538            }
4539
4540            async fn stream_chat_with_context(
4541                &self,
4542                _request: ChatRequest,
4543                _context: &ProviderRequestContext,
4544            ) -> Result<BoxStream<'static, Result<StreamEvent, ProviderError>>, ProviderError>
4545            {
4546                std::future::pending().await
4547            }
4548        }
4549
4550        let mut agent = ReActAgent::new(PendingProvider, ToolRegistry::new(), "");
4551        let err = agent
4552            .run_request_with_context(
4553                RunRequest::text("hi"),
4554                RunContext::new("deadline").with_timeout(Duration::from_millis(10)),
4555            )
4556            .await
4557            .unwrap_err();
4558        assert_eq!(err, AgentError::DeadlineExceeded);
4559        assert_eq!(agent.memory.context().await.unwrap().len(), 1);
4560    }
4561
4562    #[tokio::test]
4563    async fn streaming_deadline_exceeded_terminates_with_error_item() {
4564        struct PendingStreamProvider;
4565        #[async_trait::async_trait]
4566        impl Provider for PendingStreamProvider {
4567            async fn chat_with_context(
4568                &self,
4569                _request: ChatRequest,
4570                _context: &ProviderRequestContext,
4571            ) -> Result<ChatResponse, ProviderError> {
4572                unreachable!("this test uses streaming only")
4573            }
4574
4575            async fn stream_chat_with_context(
4576                &self,
4577                _request: ChatRequest,
4578                _context: &ProviderRequestContext,
4579            ) -> Result<BoxStream<'static, Result<StreamEvent, ProviderError>>, ProviderError>
4580            {
4581                Ok(Box::pin(futures::stream::pending()))
4582            }
4583        }
4584
4585        let mut agent = ReActAgent::new(PendingStreamProvider, ToolRegistry::new(), "");
4586        let mut stream = agent
4587            .run_stream_request_with_context(
4588                RunRequest::text("hi"),
4589                RunContext::new("stream-deadline").with_timeout(Duration::from_millis(10)),
4590            )
4591            .await
4592            .unwrap();
4593        assert_eq!(
4594            stream.next().await.unwrap().unwrap_err(),
4595            AgentError::DeadlineExceeded
4596        );
4597    }
4598
4599    /// Structured output: an answer conforming to the schema is returned
4600    /// directly (validation passes in a single round).
4601    #[cfg(feature = "structured")]
4602    #[tokio::test]
4603    async fn structured_output_valid_answer_passes() {
4604        let schema = serde_json::json!({
4605            "type": "object",
4606            "properties": { "city": { "type": "string" } },
4607            "required": ["city"],
4608        });
4609        let fake = SharedFake::new([FakeReply::Text(r#"{"city":"Beijing"}"#.into())]);
4610        let mut agent = agent(fake.clone(), "").with_structured_output(schema);
4611        let answer = agent.run("Beijing weather").await.unwrap();
4612        assert_eq!(answer, r#"{"city":"Beijing"}"#);
4613        assert_eq!(fake.requests().len(), 1); // passed in one round, no retry
4614    }
4615
4616    /// Structured output: invalid JSON is first fed back to the model for
4617    /// retry; the corrected second round passes.
4618    #[cfg(feature = "structured")]
4619    #[tokio::test]
4620    async fn structured_output_retries_after_invalid_answer() {
4621        let schema = serde_json::json!({
4622            "type": "object",
4623            "properties": { "city": { "type": "string" } },
4624            "required": ["city"],
4625        });
4626        let fake = SharedFake::new([
4627            FakeReply::Text("Not JSON".into()),
4628            FakeReply::Text(r#"{"city":"Beijing"}"#.into()),
4629        ]);
4630        let mut agent = agent(fake.clone(), "").with_structured_output(schema);
4631        let answer = agent.run("Beijing weather").await.unwrap();
4632        assert_eq!(answer, r#"{"city":"Beijing"}"#);
4633        assert_eq!(fake.requests().len(), 2); // round one fails, round two passes after feedback
4634        // The validation-failure feedback was recorded (a User message);
4635        // the memory contains the retry round.
4636        let context = agent.memory.context().await.unwrap();
4637        assert!(context.iter().any(|m| matches!(
4638            m,
4639            Message::User(blocks) if blocks.iter().any(|b| matches!(b, ContentBlock::Text(t) if t.contains("JSON schema validation")))
4640        )));
4641    }
4642
4643    /// Structured output: validation retries have an independent budget
4644    /// (`max_structured_retries`); exhausting it without success fails the
4645    /// run — the tool-round limit is not consumed, and no "tool round limit
4646    /// exceeded" is reported.
4647    #[cfg(feature = "structured")]
4648    #[tokio::test]
4649    async fn structured_output_exhausts_retry_budget() {
4650        let schema = serde_json::json!({ "type": "object" });
4651        let fake = SharedFake::new([
4652            FakeReply::Text("bad1".into()),
4653            FakeReply::Text("bad2".into()),
4654            FakeReply::Text("bad3".into()),
4655            FakeReply::Text("bad4".into()),
4656        ]);
4657        // Even a tool-round limit of 1 doesn't affect structured retries
4658        // (independent budget).
4659        let mut agent = agent(fake.clone(), "").with_config(AgentConfig {
4660            max_tool_rounds: 1,
4661            max_structured_retries: 3,
4662            ..Default::default()
4663        });
4664        agent = agent.with_structured_output(schema);
4665        let err = agent.run("hi").await.unwrap_err();
4666        assert!(matches!(err, AgentError::StructuredRetriesExhausted(3)));
4667        assert_eq!(fake.requests().len(), 4); // 4 attempts: the 4th failure hits the limit
4668    }
4669
4670    /// Typed run: the schema is auto-generated from the type and injected
4671    /// into the request; after validation passes, the answer deserializes
4672    /// into the target type.
4673    #[cfg(feature = "structured")]
4674    #[tokio::test]
4675    async fn typed_output_parses_valid_answer() {
4676        #[derive(Debug, Deserialize, JsonSchema)]
4677        struct Weather {
4678            city: String,
4679        }
4680        let fake = SharedFake::new([FakeReply::Text(r#"{"city":"Beijing"}"#.into())]);
4681        let mut agent = agent(fake.clone(), "");
4682        let weather: Weather = agent.run_typed("Beijing weather").await.unwrap();
4683        assert_eq!(weather.city, "Beijing");
4684        assert_eq!(fake.requests().len(), 1); // passed in one round, no retry
4685        // This run's generated schema was injected into the request (the
4686        // config is untouched — no pollution of later text runs).
4687        assert!(fake.requests()[0].options.structured.is_some());
4688        assert!(agent.config.options.structured.is_none());
4689    }
4690
4691    #[cfg(feature = "structured")]
4692    #[tokio::test]
4693    async fn typed_run_request_returns_value_and_output() {
4694        #[derive(Debug, Deserialize, JsonSchema, PartialEq)]
4695        struct Weather {
4696            city: String,
4697        }
4698        let fake = SharedFake::new([FakeReply::Text(r#"{"city":"Beijing"}"#.into())]);
4699        let mut agent = agent(fake.clone(), "");
4700        let typed = agent
4701            .run_typed_request_with_context::<Weather>(
4702                RunRequest::text("Beijing weather"),
4703                RunContext::new("typed-1"),
4704            )
4705            .await
4706            .unwrap();
4707
4708        assert_eq!(
4709            typed.value,
4710            Weather {
4711                city: "Beijing".into()
4712            }
4713        );
4714        assert_eq!(typed.output.run_id, "typed-1");
4715        assert_eq!(typed.output.answer, r#"{"city":"Beijing"}"#);
4716        assert_eq!(
4717            typed.output.final_message,
4718            Message::assistant(r#"{"city":"Beijing"}"#)
4719        );
4720    }
4721
4722    #[cfg(feature = "structured")]
4723    #[tokio::test]
4724    async fn typed_schema_overrides_request_and_config_schema() {
4725        #[derive(Debug, Deserialize, JsonSchema)]
4726        struct Weather {
4727            city: String,
4728        }
4729        let config_schema = serde_json::json!({
4730            "type": "object",
4731            "properties": { "config_only": { "type": "string" } },
4732            "required": ["config_only"],
4733        });
4734        let request_schema = serde_json::json!({
4735            "type": "object",
4736            "properties": { "request_only": { "type": "string" } },
4737            "required": ["request_only"],
4738        });
4739        let fake = SharedFake::new([FakeReply::Text(r#"{"city":"Beijing"}"#.into())]);
4740        let mut agent = agent(fake.clone(), "").with_structured_output(config_schema);
4741        let options = ModelOptions {
4742            structured: Some(request_schema),
4743            ..Default::default()
4744        };
4745
4746        let weather: Weather = agent
4747            .run_typed_request(RunRequest::text("Beijing weather").with_options(options))
4748            .await
4749            .unwrap()
4750            .value;
4751
4752        assert_eq!(weather.city, "Beijing");
4753        let requests = fake.requests();
4754        let structured = requests[0]
4755            .options
4756            .structured
4757            .as_ref()
4758            .expect("typed schema should be sent");
4759        let props = &structured["properties"];
4760        assert!(props.get("city").is_some());
4761        assert!(props.get("request_only").is_none());
4762        assert!(props.get("config_only").is_none());
4763    }
4764
4765    /// Typed run: an invalid answer is fed back for retry first; the
4766    /// corrected second round deserializes successfully.
4767    #[cfg(feature = "structured")]
4768    #[tokio::test]
4769    async fn typed_output_retries_then_parses() {
4770        #[derive(Debug, Deserialize, JsonSchema)]
4771        struct Weather {
4772            city: String,
4773        }
4774        let fake = SharedFake::new([
4775            FakeReply::Text("Not JSON".into()),
4776            FakeReply::Text(r#"{"city":"Beijing"}"#.into()),
4777        ]);
4778        let mut agent = agent(fake.clone(), "");
4779        let weather: Weather = agent.run_typed("Beijing weather").await.unwrap();
4780        assert_eq!(weather.city, "Beijing");
4781        assert_eq!(fake.requests().len(), 2);
4782    }
4783
4784    /// Typed run: schema disagrees with the serde type (the custom schema
4785    /// says string while the type expects number) → validation passes but
4786    /// deserialization fails → StructuredParse (the schemars-generated
4787    /// path agrees with serde by default; conflicts only come from
4788    /// user-custom schemas).
4789    #[cfg(feature = "structured")]
4790    #[tokio::test]
4791    async fn typed_output_parse_failure_on_schema_mismatch() {
4792        fn string_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
4793            serde_json::from_value(serde_json::json!({ "type": "string" })).unwrap()
4794        }
4795        #[derive(Debug, Deserialize, JsonSchema)]
4796        #[allow(dead_code)] // error-path test: the field serves deserialization validation, never read
4797        struct Weather {
4798            #[schemars(schema_with = "string_schema")]
4799            temperature: i32,
4800        }
4801        let fake = SharedFake::new([FakeReply::Text(r#"{"temperature":"30"}"#.into())]);
4802        let mut agent = agent(fake.clone(), "");
4803        let err: AgentError = agent
4804            .run_typed::<Weather>("Beijing weather")
4805            .await
4806            .unwrap_err();
4807        assert!(matches!(err, AgentError::StructuredParse(_)));
4808    }
4809
4810    /// TypedAgent interface: code with the generic bound `A: TypedAgent`
4811    /// can call run_typed on any implementation (independent of the
4812    /// concrete type; Box<dyn Agent> is unaffected).
4813    #[cfg(feature = "structured")]
4814    #[tokio::test]
4815    async fn typed_agent_trait_generic_call() {
4816        #[derive(Debug, Deserialize, JsonSchema)]
4817        struct Weather {
4818            city: String,
4819        }
4820        async fn typed_run<A: TypedAgent + Send>(
4821            agent: &mut A,
4822            input: &str,
4823        ) -> Result<Weather, AgentError> {
4824            agent.run_typed(input).await
4825        }
4826
4827        let fake = SharedFake::new([FakeReply::Text(r#"{"city":"Beijing"}"#.into())]);
4828        let mut agent = agent(fake.clone(), "");
4829        let weather = typed_run(&mut agent, "Beijing weather").await.unwrap();
4830        assert_eq!(weather.city, "Beijing");
4831    }
4832
4833    /// Typed and text runs can be mixed: run_typed yields typed results,
4834    /// Agent::run yields text (no structured constraint; free text is
4835    /// unaffected by this run's schema).
4836    #[cfg(feature = "structured")]
4837    #[tokio::test]
4838    async fn typed_output_agent_trait_run_returns_text() {
4839        #[derive(Debug, Deserialize, JsonSchema)]
4840        struct Weather {
4841            city: String,
4842        }
4843        let fake = SharedFake::new([
4844            FakeReply::Text(r#"{"city":"Beijing"}"#.into()),
4845            FakeReply::Text("Hello".into()),
4846        ]);
4847        let mut agent = agent(fake.clone(), "");
4848        let weather: Weather = agent.run_typed("Beijing weather").await.unwrap();
4849        assert_eq!(weather.city, "Beijing");
4850        // Later text run: unaffected by run_typed's schema (no structured
4851        // constraint).
4852        let text = Agent::run(&mut agent, "Say hi").await.unwrap();
4853        assert_eq!(text, "Hello");
4854    }
4855
4856    /// Streaming: structured validation retries have an independent budget;
4857    /// exceeding it terminates with an in-stream error (same semantics as
4858    /// run).
4859    #[cfg(feature = "structured")]
4860    #[tokio::test]
4861    async fn structured_output_stream_exhausts_retry_budget() {
4862        let schema = serde_json::json!({ "type": "object" });
4863        let fake = SharedFake::new([
4864            FakeReply::Text("bad1".into()),
4865            FakeReply::Text("bad2".into()),
4866        ]);
4867        let mut agent = agent(fake.clone(), "").with_config(AgentConfig {
4868            max_structured_retries: 1,
4869            ..Default::default()
4870        });
4871        agent = agent.with_structured_output(schema);
4872        let mut stream = agent.run_stream("hi").await.unwrap();
4873        let mut saw_err = false;
4874        while let Some(item) = stream.next().await {
4875            if let Err(e) = item {
4876                assert!(matches!(e, AgentError::StructuredRetriesExhausted(1)));
4877                saw_err = true;
4878                break;
4879            }
4880        }
4881        assert!(saw_err);
4882    }
4883
4884    /// Tool-round atomicity: cancellation during a tool round doesn't stop
4885    /// the tool from finishing and recording its result (no interruption,
4886    /// no half-recorded residue); cancellation takes effect before the next
4887    /// round's conversation.
4888    #[tokio::test]
4889    async fn tool_round_atomic_under_cancel() {
4890        // Slow tool: 100ms execution, counts calls.
4891        struct SlowTool {
4892            calls: Arc<AtomicUsize>,
4893        }
4894        #[async_trait::async_trait]
4895        impl Tool for SlowTool {
4896            fn schema(&self) -> ToolSchema {
4897                ToolSchema::new("slow", "Slow tool", serde_json::json!({}))
4898            }
4899            async fn call(
4900                &self,
4901                _arguments: serde_json::Value,
4902                _context: ToolContext<'_>,
4903            ) -> Result<ToolResult, ToolError> {
4904                self.calls.fetch_add(1, Ordering::Relaxed);
4905                tokio::time::sleep(Duration::from_millis(100)).await;
4906                Ok(ToolOutput::text("42").into())
4907            }
4908        }
4909
4910        let calls = Arc::new(AtomicUsize::new(0));
4911        let mut registry = ToolRegistry::new();
4912        registry.register(SlowTool {
4913            calls: calls.clone(),
4914        });
4915        let fake = SharedFake::new([
4916            FakeReply::ToolCalls {
4917                content: "".into(),
4918                calls: vec![call("c1", "slow", "{}")],
4919            },
4920            FakeReply::Text("42".into()),
4921        ]);
4922        let mut agent = agent_with_registry(fake.clone(), registry, AgentConfig::default());
4923        let token = CancellationToken::new();
4924
4925        // Cancel at 50ms: right in the middle of the tool round (tool takes
4926        // 100ms).
4927        let result = tokio::select! {
4928            r = agent.run_request_with_context(RunRequest::text("Compute"), cancellation_context(&token)) => r.map(|output| output.answer),
4929            _ = async {
4930                tokio::time::sleep(Duration::from_millis(50)).await;
4931                token.cancel();
4932                std::future::pending::<()>().await; // stay pending so the run branch wins
4933            } => unreachable!("cancellation branch only sends a signal"),
4934        };
4935        assert!(matches!(result, Err(AgentError::Cancelled)));
4936        assert_eq!(calls.load(Ordering::Relaxed), 1); // tool finished (atomic)
4937        assert_eq!(fake.requests().len(), 1); // only one conversation round sent
4938        // Memory is complete: user + assistant (with tool requests) +
4939        // tool_result paired.
4940        let ctx = agent.memory.context().await.unwrap();
4941        assert_eq!(ctx.len(), 3);
4942        assert!(matches!(
4943            &ctx[1],
4944            Message::Assistant { tool_calls, .. } if tool_calls.len() == 1
4945        ));
4946        assert!(matches!(&ctx[2], Message::ToolResult { content, .. } if content == "42"));
4947    }
4948
4949    /// Custom tool-round executor injection (non-streaming): the round
4950    /// policy is the application's to own — here, calls run in reversed
4951    /// order, and a call the policy refuses is answered with a synthetic
4952    /// denial outcome (the tool never runs). Memory records results in
4953    /// the order the policy yields them.
4954    #[tokio::test]
4955    async fn custom_tool_round_executor() {
4956        // Deny the "alpha" call on the first round, run everything else;
4957        // yield outcomes in reversed order.
4958        #[derive(Default)]
4959        struct DenyAlphaToolRoundExecutor {
4960            denied: bool,
4961        }
4962        #[async_trait::async_trait]
4963        impl ToolRoundExecutor for DenyAlphaToolRoundExecutor {
4964            async fn execute_round<'a>(
4965                &'a mut self,
4966                ctx: ToolRoundCtx<'a>,
4967                calls: Vec<ToolCall>,
4968            ) -> BoxStream<'a, ToolCallOutcome> {
4969                let mut outcomes = Vec::with_capacity(calls.len());
4970                for call in calls.into_iter().rev() {
4971                    if call.name == "alpha" && !self.denied {
4972                        self.denied = true;
4973                        // Policy refuses: a synthetic outcome feeds the
4974                        // denial text back through the normal ToolResult
4975                        // channel, without running the tool.
4976                        outcomes.push(ToolCallOutcome {
4977                            call,
4978                            content: "denied by policy".into(),
4979                            memory_policy: ToolMemoryPolicy::Normal,
4980                            effect: None,
4981                        });
4982                    } else {
4983                        outcomes.push(ctx.run(call).await);
4984                    }
4985                }
4986                Box::pin(futures::stream::iter(outcomes))
4987            }
4988        }
4989
4990        let (alpha_tool, alpha_calls) = FakeTool::new("alpha", "A");
4991        let (beta_tool, beta_calls) = FakeTool::new("beta", "B");
4992        let mut registry = ToolRegistry::new();
4993        registry.register(alpha_tool);
4994        registry.register(beta_tool);
4995        let fake = SharedFake::new([
4996            FakeReply::ToolCalls {
4997                content: "".into(),
4998                calls: vec![call("c1", "alpha", "{}"), call("c2", "beta", "{}")],
4999            },
5000            FakeReply::Text("done".into()),
5001        ]);
5002        let mut agent = agent_with_registry(fake.clone(), registry, AgentConfig::default())
5003            .with_tool_round_executor(DenyAlphaToolRoundExecutor::default());
5004
5005        let answer = agent.run("Compute").await.unwrap();
5006        assert_eq!(answer, "done");
5007        // alpha never ran (denied); beta ran once.
5008        assert_eq!(alpha_calls.load(Ordering::Relaxed), 0);
5009        assert_eq!(beta_calls.load(Ordering::Relaxed), 1);
5010        // The second request carries the round's results in the order the
5011        // policy yielded them: beta (executed) first, then alpha (denied).
5012        let requests = fake.requests();
5013        assert_eq!(requests.len(), 2);
5014        assert_eq!(requests[1].messages[2], Message::tool_result("c2", "B"),);
5015        assert_eq!(
5016            requests[1].messages[3],
5017            Message::tool_result("c1", "denied by policy"),
5018        );
5019    }
5020
5021    /// Custom tool-round executor injection (streaming): each outcome is
5022    /// dispatched as a ToolResult chunk as the policy yields it, in the
5023    /// policy's order.
5024    #[tokio::test]
5025    async fn custom_tool_round_executor_streaming() {
5026        #[derive(Default)]
5027        struct ReversedToolRoundExecutor;
5028        #[async_trait::async_trait]
5029        impl ToolRoundExecutor for ReversedToolRoundExecutor {
5030            async fn execute_round<'a>(
5031                &'a mut self,
5032                ctx: ToolRoundCtx<'a>,
5033                calls: Vec<ToolCall>,
5034            ) -> BoxStream<'a, ToolCallOutcome> {
5035                let mut outcomes = Vec::with_capacity(calls.len());
5036                for call in calls.into_iter().rev() {
5037                    outcomes.push(ctx.run(call).await);
5038                }
5039                Box::pin(futures::stream::iter(outcomes))
5040            }
5041        }
5042
5043        let (alpha_tool, _) = FakeTool::new("alpha", "A");
5044        let (beta_tool, _) = FakeTool::new("beta", "B");
5045        let mut registry = ToolRegistry::new();
5046        registry.register(alpha_tool);
5047        registry.register(beta_tool);
5048        let fake = SharedFake::new([
5049            FakeReply::ToolCalls {
5050                content: "".into(),
5051                calls: vec![call("c1", "alpha", "{}"), call("c2", "beta", "{}")],
5052            },
5053            FakeReply::Text("done".into()),
5054        ]);
5055        let mut agent = agent_with_registry(fake.clone(), registry, AgentConfig::default())
5056            .with_tool_round_executor(ReversedToolRoundExecutor);
5057
5058        let mut stream = agent.run_stream("Compute").await.unwrap();
5059        let mut results = Vec::new();
5060        let mut done = false;
5061        while let Some(item) = stream.next().await {
5062            match item.unwrap() {
5063                MessageChunk::ToolResult { id, content, .. } => {
5064                    results.push((id, content));
5065                }
5066                MessageChunk::Done(_) => done = true,
5067                _ => {}
5068            }
5069        }
5070        assert!(done);
5071        // Dispatched in policy order: beta (reversed) before alpha.
5072        assert_eq!(
5073            results,
5074            vec![
5075                ("c2".to_string(), "B".to_string()),
5076                ("c1".to_string(), "A".to_string())
5077            ]
5078        );
5079    }
5080
5081    #[tokio::test]
5082    async fn kernel_batches_effect_requests_from_same_round() {
5083        let mut registry = ToolRegistry::new();
5084        registry
5085            .register(EffectTool::new("read_a", "effect-a", "read A"))
5086            .register(EffectTool::new("read_b", "effect-b", "read B"));
5087        let fake = SharedFake::new([
5088            FakeReply::ToolCalls {
5089                content: "".into(),
5090                calls: vec![call("c1", "read_a", "{}"), call("c2", "read_b", "{}")],
5091            },
5092            FakeReply::Text("done".into()),
5093        ]);
5094        let mut agent = agent_with_registry(fake.clone(), registry, AgentConfig::default());
5095        let context = RunContext::new("kernel-batch-effects");
5096
5097        let action = agent
5098            .start(RunRequest::text("read both"), &context)
5099            .await
5100            .unwrap();
5101        let AgentAction::RequestModel { request } = action else {
5102            panic!("expected initial model request");
5103        };
5104        let action = agent
5105            .observe(
5106                Observation::Model(ModelObservation::new(
5107                    request.id,
5108                    fake.chat(request.chat).await.unwrap(),
5109                )),
5110                &context,
5111            )
5112            .await
5113            .unwrap();
5114        let AgentAction::RequestEffects { requests } = action else {
5115            panic!("expected batch effect request");
5116        };
5117        assert_eq!(
5118            requests
5119                .iter()
5120                .map(|request| request.id.as_str())
5121                .collect::<Vec<_>>(),
5122            vec!["effect-a", "effect-b"]
5123        );
5124        assert_eq!(
5125            requests
5126                .iter()
5127                .map(|request| request.source.tool_call_id.as_deref())
5128                .collect::<Vec<_>>(),
5129            vec![Some("c1"), Some("c2")]
5130        );
5131
5132        // Harnesses may execute in parallel; observations are matched by id
5133        // and recorded back in the model's tool-call order.
5134        let action = agent
5135            .observe(
5136                Observation::Effects(vec![
5137                    EffectObservation::succeeded("effect-b", "observed B"),
5138                    EffectObservation::succeeded("effect-a", "observed A"),
5139                ]),
5140                &context,
5141            )
5142            .await
5143            .unwrap();
5144        let AgentAction::RequestModel { request } = action else {
5145            panic!("expected next model request");
5146        };
5147        assert!(matches!(
5148            &request.chat.messages[2],
5149            Message::ToolResult { id, content } if id == "c1" && content == "observed A"
5150        ));
5151        assert!(matches!(
5152            &request.chat.messages[3],
5153            Message::ToolResult { id, content } if id == "c2" && content == "observed B"
5154        ));
5155
5156        let action = agent
5157            .observe(
5158                Observation::Model(ModelObservation::new(
5159                    request.id,
5160                    fake.chat(request.chat).await.unwrap(),
5161                )),
5162                &context,
5163            )
5164            .await
5165            .unwrap();
5166        let AgentAction::Respond { output } = action else {
5167            panic!("expected final response");
5168        };
5169        assert_eq!(output.answer, "done");
5170    }
5171
5172    #[tokio::test]
5173    async fn kernel_records_mixed_outputs_and_effects_in_tool_call_order() {
5174        let mut registry = ToolRegistry::new();
5175        registry
5176            .register(FakeTool::new("before", "plain before").0)
5177            .register(EffectTool::new("read", "effect-read", "read"))
5178            .register(FakeTool::new("after", "plain after").0);
5179        let fake = SharedFake::new([
5180            FakeReply::ToolCalls {
5181                content: "".into(),
5182                calls: vec![
5183                    call("c1", "before", "{}"),
5184                    call("c2", "read", "{}"),
5185                    call("c3", "after", "{}"),
5186                ],
5187            },
5188            FakeReply::Text("done".into()),
5189        ]);
5190        let mut agent = agent_with_registry(fake.clone(), registry, AgentConfig::default());
5191        let context = RunContext::new("kernel-mixed-effects");
5192
5193        let AgentAction::RequestModel { request } = agent
5194            .start(RunRequest::text("read with context"), &context)
5195            .await
5196            .unwrap()
5197        else {
5198            panic!("expected initial model request");
5199        };
5200        let AgentAction::RequestEffect { request } = agent
5201            .observe(
5202                Observation::Model(ModelObservation::new(
5203                    request.id,
5204                    fake.chat(request.chat).await.unwrap(),
5205                )),
5206                &context,
5207            )
5208            .await
5209            .unwrap()
5210        else {
5211            panic!("expected single effect request");
5212        };
5213        assert_eq!(request.id, "effect-read");
5214
5215        let action = agent
5216            .observe(
5217                Observation::Effect(EffectObservation::succeeded("effect-read", "observed read")),
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 == "plain before"
5228        ));
5229        assert!(matches!(
5230            &request.chat.messages[3],
5231            Message::ToolResult { id, content } if id == "c2" && content == "observed read"
5232        ));
5233        assert!(matches!(
5234            &request.chat.messages[4],
5235            Message::ToolResult { id, content } if id == "c3" && content == "plain after"
5236        ));
5237    }
5238
5239    #[tokio::test]
5240    async fn kernel_rejects_partial_effect_batch_observation() {
5241        let mut registry = ToolRegistry::new();
5242        registry
5243            .register(EffectTool::new("read_a", "effect-a", "read A"))
5244            .register(EffectTool::new("read_b", "effect-b", "read B"));
5245        let fake = SharedFake::new([FakeReply::ToolCalls {
5246            content: "".into(),
5247            calls: vec![call("c1", "read_a", "{}"), call("c2", "read_b", "{}")],
5248        }]);
5249        let mut agent = agent_with_registry(fake.clone(), registry, AgentConfig::default());
5250        let context = RunContext::new("kernel-batch-effects-partial");
5251
5252        let AgentAction::RequestModel { request } = agent
5253            .start(RunRequest::text("read both"), &context)
5254            .await
5255            .unwrap()
5256        else {
5257            panic!("expected initial model request");
5258        };
5259        let AgentAction::RequestEffects { .. } = agent
5260            .observe(
5261                Observation::Model(ModelObservation::new(
5262                    request.id,
5263                    fake.chat(request.chat).await.unwrap(),
5264                )),
5265                &context,
5266            )
5267            .await
5268            .unwrap()
5269        else {
5270            panic!("expected batch effect request");
5271        };
5272        let err = agent
5273            .observe(
5274                Observation::Effects(vec![EffectObservation::succeeded("effect-a", "observed A")]),
5275                &context,
5276            )
5277            .await
5278            .unwrap_err();
5279        assert!(
5280            matches!(err, AgentError::InvalidStep(message) if message.contains("count mismatch"))
5281        );
5282    }
5283
5284    #[tokio::test]
5285    async fn kernel_rejects_duplicate_effect_batch_observation_and_can_retry() {
5286        let mut registry = ToolRegistry::new();
5287        registry
5288            .register(EffectTool::new("read_a", "effect-a", "read A"))
5289            .register(EffectTool::new("read_b", "effect-b", "read B"));
5290        let fake = SharedFake::new([
5291            FakeReply::ToolCalls {
5292                content: "".into(),
5293                calls: vec![call("c1", "read_a", "{}"), call("c2", "read_b", "{}")],
5294            },
5295            FakeReply::Text("done".into()),
5296        ]);
5297        let mut agent = agent_with_registry(fake.clone(), registry, AgentConfig::default());
5298        let context = RunContext::new("kernel-batch-effects-duplicate");
5299
5300        let AgentAction::RequestModel { request } = agent
5301            .start(RunRequest::text("read both"), &context)
5302            .await
5303            .unwrap()
5304        else {
5305            panic!("expected initial model request");
5306        };
5307        let AgentAction::RequestEffects { .. } = agent
5308            .observe(
5309                Observation::Model(ModelObservation::new(
5310                    request.id,
5311                    fake.chat(request.chat).await.unwrap(),
5312                )),
5313                &context,
5314            )
5315            .await
5316            .unwrap()
5317        else {
5318            panic!("expected batch effect request");
5319        };
5320        let err = agent
5321            .observe(
5322                Observation::Effects(vec![
5323                    EffectObservation::succeeded("effect-a", "observed A"),
5324                    EffectObservation::succeeded("effect-a", "observed A again"),
5325                ]),
5326                &context,
5327            )
5328            .await
5329            .unwrap_err();
5330        assert!(matches!(err, AgentError::InvalidStep(message) if message.contains("duplicate")));
5331
5332        let action = agent
5333            .observe(
5334                Observation::Effects(vec![
5335                    EffectObservation::succeeded("effect-b", "observed B"),
5336                    EffectObservation::succeeded("effect-a", "observed A"),
5337                ]),
5338                &context,
5339            )
5340            .await
5341            .unwrap();
5342        let AgentAction::RequestModel { request } = action else {
5343            panic!("expected next model request");
5344        };
5345        assert!(matches!(
5346            &request.chat.messages[2],
5347            Message::ToolResult { id, content } if id == "c1" && content == "observed A"
5348        ));
5349        assert!(matches!(
5350            &request.chat.messages[3],
5351            Message::ToolResult { id, content } if id == "c2" && content == "observed B"
5352        ));
5353    }
5354
5355    /// Mid-stream cancellation: already-dispatched Deltas are kept, a
5356    /// Cancelled terminal event wraps up, no Done; nothing is recorded for
5357    /// this round (recording happens at the end of the round).
5358    #[tokio::test]
5359    async fn stream_cancel_mid_generation() {
5360        // Slow-stream Provider: 100ms between Deltas (wide gaps, so the
5361        // cancellation landing point is stable).
5362        struct SlowStreamProvider;
5363        #[async_trait::async_trait]
5364        impl Provider for SlowStreamProvider {
5365            async fn chat_with_context(
5366                &self,
5367                _request: ChatRequest,
5368                _context: &ProviderRequestContext,
5369            ) -> Result<ChatResponse, ProviderError> {
5370                unreachable!("this test uses streaming path only")
5371            }
5372            async fn stream_chat_with_context(
5373                &self,
5374                _request: ChatRequest,
5375                _context: &ProviderRequestContext,
5376            ) -> Result<BoxStream<'static, Result<StreamEvent, ProviderError>>, ProviderError>
5377            {
5378                Ok(Box::pin(async_stream::stream! {
5379                    yield Ok(StreamEvent::Delta("d0".into()));
5380                    tokio::time::sleep(Duration::from_millis(100)).await;
5381                    yield Ok(StreamEvent::Delta("d1".into()));
5382                    tokio::time::sleep(Duration::from_millis(100)).await;
5383                    yield Ok(StreamEvent::Done {
5384                        reason: FinishReason::Stop,
5385                        usage: None,
5386                    });
5387                }))
5388            }
5389        }
5390
5391        let mut agent = ReActAgent::new(SlowStreamProvider, ToolRegistry::new(), "");
5392        let token = CancellationToken::new();
5393        let mut stream = agent
5394            .run_stream_request_with_context(RunRequest::text("hi"), cancellation_context(&token))
5395            .await
5396            .unwrap();
5397
5398        // Cancel at 50ms: d0 already received (0ms), d1 still pending
5399        // (100ms) — cancellation lands in the gap.
5400        let mut first = Vec::new();
5401        tokio::select! {
5402            _ = async {
5403                while let Some(ev) = stream.next().await {
5404                    let ev = ev.unwrap();
5405                    if ev == MessageChunk::Cancelled {
5406                        break;
5407                    }
5408                    first.push(ev);
5409                }
5410            } => {}
5411            _ = async {
5412                tokio::time::sleep(Duration::from_millis(50)).await;
5413                token.cancel();
5414            } => {}
5415        }
5416        assert_eq!(first, vec![MessageChunk::Delta("d0".into())]);
5417
5418        // Keep consuming: after cancellation the stream terminates with
5419        // Cancelled, no Done.
5420        let rest: Vec<MessageChunk> = stream.by_ref().map(|e| e.unwrap()).collect().await;
5421        assert_eq!(rest, vec![MessageChunk::Cancelled]);
5422        drop(stream); // release the borrow of agent
5423        // Nothing recorded for this round: the memory only holds the user.
5424        assert_eq!(agent.memory.context().await.unwrap().len(), 1);
5425    }
5426
5427    /// No cancellation residue: after a cancellation, a fresh token runs
5428    /// normally (each run has an independent cancellation source — the
5429    /// basis of multi-round conversations).
5430    #[tokio::test]
5431    async fn cancelled_then_fresh_token_run_works() {
5432        let fake = SharedFake::new([FakeReply::Text("hi".into())]);
5433        let mut agent = agent(fake, "");
5434
5435        let t1 = CancellationToken::new();
5436        t1.cancel();
5437        assert!(matches!(
5438            agent
5439                .run_request_with_context(RunRequest::text("one"), cancellation_context(&t1))
5440                .await,
5441            Err(AgentError::Cancelled)
5442        ));
5443
5444        let t2 = CancellationToken::new();
5445        assert_eq!(
5446            agent
5447                .run_request_with_context(RunRequest::text("two"), cancellation_context(&t2))
5448                .await
5449                .unwrap()
5450                .answer,
5451            "hi"
5452        );
5453    }
5454
5455    // ---- Observation channel: the loop pushes process events once an
5456    // EventChannel is attached ----
5457
5458    use crate::event_channel::{BroadcastEventChannel, EventReceiver};
5459    use crate::tool::RegistryError;
5460
5461    /// Attach a broadcast channel and return a receiver; after the run,
5462    /// dropping the agent (the channel's last holder) closes the channel so
5463    /// the receiver can drain the final events.
5464    fn attach_channel(agent: ReActAgent) -> (ReActAgent, Box<dyn EventReceiver>) {
5465        let channel = BroadcastEventChannel::new(64);
5466        let rx = channel.subscribe();
5467        (agent.with_event_channel(channel), rx)
5468    }
5469
5470    /// Drain: all events until the channel closes (None).
5471    async fn drain(rx: &mut Box<dyn EventReceiver>) -> Vec<Arc<dyn AgentEvent>> {
5472        let mut out = Vec::new();
5473        while let Some(ev) = rx.recv().await {
5474            out.push(ev);
5475        }
5476        out
5477    }
5478
5479    fn names(events: &[Arc<dyn AgentEvent>]) -> Vec<&'static str> {
5480        events.iter().map(|e| e.name()).collect()
5481    }
5482
5483    /// Downcast an event to ReActEvent (enum event set: one downcast, then
5484    /// an exhaustive match).
5485    fn react_event(ev: &dyn AgentEvent) -> &ReActEvent {
5486        ev.as_any()
5487            .downcast_ref::<ReActEvent>()
5488            .expect("test event should be ReActEvent")
5489    }
5490
5491    /// Non-streaming run event sequence: RunStarted → ToolStarted →
5492    /// ToolCompleted(Ok) → RunEnded (summary, error=None); bystanders can
5493    /// observe a non-streaming run.
5494    #[tokio::test]
5495    async fn events_published_on_run() {
5496        let (calc, _calls) = FakeTool::new("calc", "42");
5497        let mut registry = ToolRegistry::new();
5498        registry.register(calc);
5499        let fake = SharedFake::new([
5500            FakeReply::ToolCalls {
5501                content: "".into(),
5502                calls: vec![call("c1", "calc", r#"{"a":1}"#)],
5503            },
5504            FakeReply::Text("The answer is 42".into()),
5505        ]);
5506        let (mut agent, mut rx) =
5507            attach_channel(agent_with_registry(fake, registry, AgentConfig::default()));
5508        let answer = agent.run("Compute").await.unwrap();
5509        assert_eq!(answer, "The answer is 42");
5510        drop(agent);
5511        let events = drain(&mut rx).await;
5512
5513        assert_eq!(
5514            names(&events),
5515            ["run.started", "tool.started", "tool.completed", "run.ended"]
5516        );
5517        // One downcast + exhaustive match (the consumption style of an
5518        // enum event set).
5519        match react_event(&*events[1]) {
5520            // ToolStarted: carries id / arguments, paired with
5521            // ToolCompleted.
5522            ReActEvent::ToolStarted {
5523                id,
5524                name,
5525                arguments,
5526            } => {
5527                assert_eq!(id, "c1");
5528                assert_eq!(name, "calc");
5529                assert_eq!(arguments, r#"{"a":1}"#);
5530            }
5531            _ => panic!("expected ToolStarted"),
5532        }
5533        match react_event(&*events[2]) {
5534            // ToolCompleted: Ok carries the result text.
5535            ReActEvent::ToolCompleted { result, .. } => {
5536                assert_eq!(result, &Ok(ToolOutput::text("42").into()));
5537            }
5538            _ => panic!("expected ToolCompleted"),
5539        }
5540        match react_event(&*events[3]) {
5541            // RunEnded: normal end with error=None; the non-streaming
5542            // summary is real (2 rounds + 1 tool).
5543            ReActEvent::RunEnded { summary, error } => {
5544                assert_eq!(error, &None);
5545                assert_eq!(summary.rounds, 2);
5546                assert_eq!(summary.tool_calls, 1);
5547            }
5548            _ => panic!("expected RunEnded"),
5549        }
5550    }
5551
5552    #[tokio::test]
5553    async fn run_started_event_preserves_block_input() {
5554        let blocks = vec![ContentBlock::Text("look".into())];
5555        let fake = SharedFake::new([FakeReply::Text("done".into())]);
5556        let (mut agent, mut rx) = attach_channel(agent(fake, ""));
5557        agent
5558            .run_request(RunRequest::blocks(blocks.clone()))
5559            .await
5560            .unwrap();
5561        drop(agent);
5562        let events = drain(&mut rx).await;
5563
5564        match react_event(&*events[0]) {
5565            ReActEvent::RunStarted { input, .. } => {
5566                assert_eq!(input, &crate::UserInput::Blocks(blocks));
5567            }
5568            _ => panic!("expected RunStarted"),
5569        }
5570    }
5571
5572    /// Tool failure: ToolCompleted's Err carries the registry
5573    /// classification (NotFound), whose Display is the error text fed back
5574    /// to the model.
5575    #[tokio::test]
5576    async fn events_carry_tool_failure() {
5577        let fake = SharedFake::new([
5578            FakeReply::ToolCalls {
5579                content: "".into(),
5580                calls: vec![call("c1", "nope", "{}")],
5581            },
5582            FakeReply::Text("Got it".into()),
5583        ]);
5584        let (mut agent, mut rx) = attach_channel(agent(fake, ""));
5585        agent.run("Trigger").await.unwrap();
5586        drop(agent);
5587
5588        let events = drain(&mut rx).await;
5589        let completed = events
5590            .iter()
5591            .find_map(|e| match react_event(&**e) {
5592                ReActEvent::ToolCompleted { result, .. } => Some(result),
5593                _ => None,
5594            })
5595            .expect("expected ToolCompleted event");
5596        assert!(matches!(completed, Err(RegistryError::NotFound(n)) if n == "nope"));
5597        assert_eq!(
5598            completed.as_ref().unwrap_err().to_string(),
5599            "tool not found: nope"
5600        );
5601    }
5602
5603    /// Streaming path: Delta / Reasoning events push along with the stream;
5604    /// the RunEnded summary matches the pulling path's semantics.
5605    #[tokio::test]
5606    async fn stream_events_include_delta_and_reasoning() {
5607        let (calc, _calls) = FakeTool::new("calc", "42");
5608        let mut registry = ToolRegistry::new();
5609        registry.register(calc);
5610        let fake = SharedFake::new([
5611            FakeReply::ToolCalls {
5612                content: "Thinking: ".into(),
5613                calls: vec![call("c1", "calc", "{}")],
5614            },
5615            FakeReply::TextWithReasoning {
5616                content: "The answer is 42".into(),
5617                reasoning: "Reasoning steps".into(),
5618            },
5619        ]);
5620        let (mut agent, mut rx) =
5621            attach_channel(agent_with_registry(fake, registry, AgentConfig::default()));
5622
5623        // Pulling path as usual: text = concatenated Deltas.
5624        let answer: String = agent
5625            .run_stream("Compute")
5626            .await
5627            .unwrap()
5628            .map(|e| e.unwrap())
5629            .filter_map(|e| async move {
5630                match e {
5631                    MessageChunk::Delta(d) => Some(d),
5632                    _ => None,
5633                }
5634            })
5635            .collect()
5636            .await;
5637        assert_eq!(answer, "Thinking: The answer is 42");
5638        drop(agent);
5639
5640        let events = drain(&mut rx).await;
5641        // Order matches the StreamEvent docs: content Deltas first,
5642        // Reasoning at the end of the round (OpenAiProvider and
5643        // FakeProvider share the order).
5644        assert_eq!(
5645            names(&events),
5646            [
5647                "run.started",
5648                "delta",
5649                "tool.started",
5650                "tool.completed",
5651                "delta",
5652                "reasoning",
5653                "run.ended",
5654            ]
5655        );
5656        let reasoning = events
5657            .iter()
5658            .find_map(|e| match react_event(&**e) {
5659                ReActEvent::Reasoning { text } => Some(text.as_str()),
5660                _ => None,
5661            })
5662            .expect("expected Reasoning event");
5663        assert_eq!(reasoning, "Reasoning steps");
5664        match react_event(&**events.last().unwrap()) {
5665            ReActEvent::RunEnded { summary, error } => {
5666                assert_eq!(error, &None);
5667                assert_eq!(summary.rounds, 2);
5668                assert_eq!(summary.tool_calls, 1);
5669            }
5670            _ => panic!("expected RunEnded"),
5671        }
5672    }
5673
5674    /// Cancellation: RunEnded carries error=Some(Cancelled), so bystanders
5675    /// know the run's outcome.
5676    #[tokio::test]
5677    async fn cancelled_run_publishes_run_ended_with_error() {
5678        // Slow-stream Provider: 100ms between Deltas (stable cancellation landing point).
5679        struct SlowStreamProvider;
5680        #[async_trait::async_trait]
5681        impl Provider for SlowStreamProvider {
5682            async fn chat_with_context(
5683                &self,
5684                _request: ChatRequest,
5685                _context: &ProviderRequestContext,
5686            ) -> Result<ChatResponse, ProviderError> {
5687                unreachable!("this test uses streaming path only")
5688            }
5689            async fn stream_chat_with_context(
5690                &self,
5691                _request: ChatRequest,
5692                _context: &ProviderRequestContext,
5693            ) -> Result<BoxStream<'static, Result<StreamEvent, ProviderError>>, ProviderError>
5694            {
5695                Ok(Box::pin(async_stream::stream! {
5696                    yield Ok(StreamEvent::Delta("d0".into()));
5697                    tokio::time::sleep(Duration::from_millis(100)).await;
5698                    yield Ok(StreamEvent::Delta("d1".into()));
5699                    tokio::time::sleep(Duration::from_millis(100)).await;
5700                    yield Ok(StreamEvent::Done {
5701                        reason: FinishReason::Stop,
5702                        usage: None,
5703                    });
5704                }))
5705            }
5706        }
5707
5708        let (mut agent, mut rx) =
5709            attach_channel(ReActAgent::new(SlowStreamProvider, ToolRegistry::new(), ""));
5710        let token = CancellationToken::new();
5711        let mut stream = agent
5712            .run_stream_request_with_context(RunRequest::text("hi"), cancellation_context(&token))
5713            .await
5714            .unwrap();
5715
5716        // Cancel at 50ms: the select cancellation branch wins and the
5717        // consumption branch is dropped; after cancellation, keep consuming
5718        // the stream and wrap up with Cancelled (isomorphic to
5719        // stream_cancel_mid_generation).
5720        tokio::select! {
5721            _ = async {
5722                while let Some(ev) = stream.next().await {
5723                    let ev = ev.unwrap();
5724                    if ev == MessageChunk::Cancelled {
5725                        break;
5726                    }
5727                }
5728            } => {}
5729            _ = async {
5730                tokio::time::sleep(Duration::from_millis(50)).await;
5731                token.cancel();
5732            } => {}
5733        }
5734        let rest: Vec<MessageChunk> = stream.by_ref().map(|e| e.unwrap()).collect().await;
5735        assert!(rest.contains(&MessageChunk::Cancelled));
5736        drop(stream);
5737        drop(agent);
5738
5739        let events = drain(&mut rx).await;
5740        assert!(matches!(names(&events).as_slice(), [.., "run.ended"]));
5741        match react_event(&**events.last().unwrap()) {
5742            ReActEvent::RunEnded { error, .. } => {
5743                assert_eq!(error, &Some(AgentError::Cancelled));
5744            }
5745            _ => panic!("expected RunEnded"),
5746        }
5747    }
5748
5749    #[cfg(feature = "tracing")]
5750    mod tracing_tests {
5751        use super::*;
5752
5753        // ---- Observability spans: structure and fields (asserted with a
5754        // collecting subscriber) ----
5755
5756        use std::collections::HashMap;
5757        use std::sync::atomic::AtomicU64;
5758        use tracing::field::{Field, Visit};
5759        use tracing::subscriber::Subscriber;
5760        use tracing::{Event, Id, Level, Metadata};
5761
5762        /// Creation info of one span (name / level / field values at
5763        /// creation).
5764        #[derive(Debug, Clone, PartialEq, Eq)]
5765        struct SpanInfo {
5766            name: &'static str,
5767            level: Level,
5768            fields: Vec<(String, String)>,
5769        }
5770
5771        /// The operation sequence received by the subscriber (enter / exit /
5772        /// close / field record).
5773        #[derive(Debug, Clone, PartialEq, Eq)]
5774        enum Op {
5775            Enter(String),
5776            Exit(String),
5777            Close(String),
5778            Record(String, String),
5779        }
5780
5781        /// Collecting subscriber: gathers span creation, enter/exit, and field
5782        /// records into Vecs for the tests to assert hierarchy and fields.
5783        /// The tracing tests below force a current-thread runtime because the
5784        /// collecting subscriber is installed with a thread-local dispatch
5785        /// guard. A multi-thread runtime can resume the test future on a worker
5786        /// that does not have that guard.
5787        #[derive(Debug, Default)]
5788        struct CollectSubscriber {
5789            spans: std::sync::Mutex<Vec<SpanInfo>>,
5790            ops: std::sync::Mutex<Vec<Op>>,
5791            names: std::sync::Mutex<HashMap<Id, String>>,
5792            next_id: AtomicU64,
5793        }
5794
5795        /// Collect field values as debug text (all of Visit's default methods
5796        /// land in record_debug).
5797        struct FieldCollector<'a>(&'a mut Vec<(String, String)>);
5798
5799        impl Visit for FieldCollector<'_> {
5800            fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) {
5801                self.0
5802                    .push((field.name().to_string(), format!("{value:?}")));
5803            }
5804        }
5805
5806        impl Subscriber for CollectSubscriber {
5807            fn enabled(&self, _metadata: &Metadata<'_>) -> bool {
5808                true
5809            }
5810
5811            fn new_span(&self, span: &tracing::span::Attributes<'_>) -> Id {
5812                // fetch_add returns the old value (0 on the first call), and
5813                // tracing Ids must be non-zero.
5814                let id = Id::from_u64(self.next_id.fetch_add(1, Ordering::Relaxed) + 1);
5815                let mut fields = Vec::new();
5816                span.record(&mut FieldCollector(&mut fields));
5817                self.spans.lock().unwrap().push(SpanInfo {
5818                    name: span.metadata().name(),
5819                    level: *span.metadata().level(),
5820                    fields,
5821                });
5822                self.names
5823                    .lock()
5824                    .unwrap()
5825                    .insert(id.clone(), span.metadata().name().to_string());
5826                id
5827            }
5828
5829            fn record(&self, id: &Id, values: &tracing::span::Record<'_>) {
5830                let name = self.names.lock().unwrap().get(id).cloned();
5831                let Some(name) = name else { return };
5832                let mut fields = Vec::new();
5833                values.record(&mut FieldCollector(&mut fields));
5834                let mut ops = self.ops.lock().unwrap();
5835                for (field, value) in fields {
5836                    ops.push(Op::Record(name.clone(), format!("{field}={value}")));
5837                }
5838            }
5839
5840            fn enter(&self, id: &Id) {
5841                let name = self.names.lock().unwrap().get(id).cloned();
5842                if let Some(name) = name {
5843                    self.ops.lock().unwrap().push(Op::Enter(name));
5844                }
5845            }
5846
5847            fn exit(&self, id: &Id) {
5848                let name = self.names.lock().unwrap().get(id).cloned();
5849                if let Some(name) = name {
5850                    self.ops.lock().unwrap().push(Op::Exit(name));
5851                }
5852            }
5853
5854            fn try_close(&self, id: Id) -> bool {
5855                let name = self.names.lock().unwrap().get(&id).cloned();
5856                if let Some(name) = name {
5857                    self.ops.lock().unwrap().push(Op::Close(name));
5858                }
5859                true
5860            }
5861
5862            fn clone_span(&self, id: &Id) -> Id {
5863                id.clone()
5864            }
5865
5866            fn event(&self, _event: &Event<'_>) {}
5867            fn record_follows_from(&self, _span: &Id, _follows: &Id) {}
5868        }
5869
5870        /// Swap the current thread's default dispatch to the collecting
5871        /// subscriber (the guard is held across awaits).
5872        fn collect_guard(sub: &Arc<CollectSubscriber>) -> tracing::dispatcher::DefaultGuard {
5873            tracing::dispatcher::set_default(&tracing::Dispatch::new(sub.clone()))
5874        }
5875
5876        fn enter_names(ops: &[Op]) -> Vec<String> {
5877            ops.iter()
5878                .filter_map(|op| match op {
5879                    Op::Enter(name) => Some(name.clone()),
5880                    _ => None,
5881                })
5882                .collect()
5883        }
5884
5885        fn records_of(ops: &[Op], span: &str) -> Vec<String> {
5886            ops.iter()
5887                .filter_map(|op| match op {
5888                    Op::Record(s, kv) if s == span => Some(kv.clone()),
5889                    _ => None,
5890                })
5891                .collect()
5892        }
5893
5894        /// Assert hierarchy invariants (shared by both paths): (1) whenever a
5895        /// span is entered, `agent.run` must be on the enter/exit stack (no
5896        /// orphan spans — llm/tool must render under run);
5897        /// (2) no span with the same name on the stack (the same span can't be
5898        /// entered twice); (3) enter/exit strictly pair up.
5899        fn assert_nesting_invariants(ops: &[Op]) {
5900            let mut stack: Vec<String> = Vec::new();
5901            for op in ops {
5902                match op {
5903                    Op::Enter(name) => {
5904                        if name != "agent.run" {
5905                            assert!(
5906                                stack.contains(&"agent.run".to_string()),
5907                                "span {name} requires agent.run on the stack when entering (stack: {stack:?})"
5908                            );
5909                        }
5910                        assert!(
5911                            !stack.contains(name),
5912                            "same span entered twice (double instrumenting): {name} (stack: {stack:?})"
5913                        );
5914                        stack.push(name.clone());
5915                    }
5916                    Op::Exit(name) => {
5917                        assert_eq!(
5918                            stack.pop().as_deref(),
5919                            Some(name.as_str()),
5920                            "exit must pair with enter: {name}"
5921                        );
5922                    }
5923                    _ => {}
5924                }
5925            }
5926        }
5927
5928        /// Non-streaming path: span tree agent.run → llm_request / tool (two
5929        /// levels, grouped by the round attribute); hierarchy invariants hold
5930        /// (no orphans, no double enters); llm_request records usage at wrap-up
5931        /// (dual channel); run.id is consistent across the tree.
5932        #[tokio::test(flavor = "current_thread")]
5933        async fn trace_span_tree_non_stream() {
5934            let sub = Arc::new(CollectSubscriber::default());
5935            let _guard = collect_guard(&sub);
5936
5937            let (calc, _calls) = FakeTool::new("calc", "42");
5938            let mut registry = ToolRegistry::new();
5939            registry.register(calc);
5940            let fake = SharedFake::new([
5941                FakeReply::WithUsage {
5942                    reply: Box::new(FakeReply::ToolCalls {
5943                        content: "".into(),
5944                        calls: vec![call("c1", "calc", "{}")],
5945                    }),
5946                    usage: Usage::new(10, 2),
5947                },
5948                FakeReply::text_with_usage("42", Usage::new(20, 5)),
5949            ]);
5950            let mut agent = agent_with_registry(fake, registry, AgentConfig::default());
5951            assert_eq!(agent.run("Compute").await.unwrap(), "42");
5952
5953            let ops = sub.ops.lock().unwrap().clone();
5954            // Hierarchy invariants (no orphan spans, no double enters, paired
5955            // exits).
5956            assert_nesting_invariants(&ops);
5957            // Structure: two rounds, one llm_request each (round one also has a
5958            // tool). Note every instrumented future enters and exits twice (one
5959            // poll + one more enter inside Instrumented's drop; tracing
5960            // guarantees inner's Drop also runs in the span context), so the
5961            // assertions use "first-appearance order" and lower-bound counts
5962            // rather than exact enter/exit sequences.
5963            let enters = enter_names(&ops);
5964            let mut first_seen = Vec::new();
5965            for name in enters.iter() {
5966                if !first_seen.contains(name) {
5967                    first_seen.push(name.clone());
5968                }
5969            }
5970            assert_eq!(first_seen, ["agent.run", "llm_request", "tool"]);
5971            assert!(enters.iter().filter(|n| *n == "llm_request").count() >= 2);
5972            assert!(enters.iter().filter(|n| *n == "tool").count() >= 1);
5973
5974            // usage via both channels: llm_request records on the span at
5975            // wrap-up (the usage injected per round).
5976            assert_eq!(
5977                records_of(&ops, "llm_request"),
5978                [
5979                    "usage.prompt_tokens=10",
5980                    "usage.completion_tokens=2",
5981                    "usage.prompt_tokens=20",
5982                    "usage.completion_tokens=5",
5983                ]
5984            );
5985
5986            // Levels: agent.run is INFO (skeleton visible by default), detail
5987            // spans are DEBUG.
5988            let spans = sub.spans.lock().unwrap().clone();
5989            let run_span = spans.iter().find(|s| s.name == "agent.run").unwrap();
5990            assert_eq!(run_span.level, Level::INFO);
5991            assert_eq!(
5992                spans
5993                    .iter()
5994                    .find(|s| s.name == "llm_request")
5995                    .unwrap()
5996                    .level,
5997                Level::DEBUG
5998            );
5999            assert_eq!(
6000                spans.iter().find(|s| s.name == "tool").unwrap().level,
6001                Level::DEBUG
6002            );
6003
6004            // round attribute: llm_request carries the round number (two
6005            // rounds = 1, 2 — the grouping key).
6006            let llm_rounds: Vec<u64> = spans
6007                .iter()
6008                .filter(|s| s.name == "llm_request")
6009                .map(|s| {
6010                    s.fields
6011                        .iter()
6012                        .find(|(f, _)| f == "round")
6013                        .map(|(_, v)| v.parse().unwrap())
6014                        .unwrap()
6015                })
6016                .collect();
6017            assert_eq!(llm_rounds, vec![1, 2]);
6018
6019            // run.id: every span carries the same run id (the correlation key
6020            // with the event stream's RunStarted).
6021            let run_ids: Vec<String> = spans
6022                .iter()
6023                .map(|s| {
6024                    s.fields
6025                        .iter()
6026                        .find(|(f, _)| f == "run.id")
6027                        .map(|(_, v)| v.clone())
6028                        .unwrap_or_else(|| panic!("span {} must carry a run.id field", s.name))
6029                })
6030                .collect();
6031            assert!(run_ids.iter().all(|id| id == &run_ids[0]));
6032            assert!(run_ids[0].starts_with("run-"));
6033        }
6034
6035        /// Streaming path: the same span structure; the run span enters on
6036        /// every poll (covering the whole consumption period); usage is
6037        /// recorded at wrap-up when the Done event arrives.
6038        #[tokio::test(flavor = "current_thread")]
6039        async fn trace_span_tree_stream() {
6040            let sub = Arc::new(CollectSubscriber::default());
6041            let _guard = collect_guard(&sub);
6042
6043            let (calc, _calls) = FakeTool::new("calc", "42");
6044            let mut registry = ToolRegistry::new();
6045            registry.register(calc);
6046            let fake = SharedFake::new([
6047                FakeReply::WithUsage {
6048                    reply: Box::new(FakeReply::ToolCalls {
6049                        content: "".into(),
6050                        calls: vec![call("c1", "calc", "{}")],
6051                    }),
6052                    usage: Usage::new(10, 2),
6053                },
6054                FakeReply::text_with_usage("42", Usage::new(20, 5)),
6055            ]);
6056            let mut agent = agent_with_registry(fake, registry, AgentConfig::default());
6057            agent
6058                .run_stream("Compute")
6059                .await
6060                .unwrap()
6061                .for_each(|_| async {})
6062                .await;
6063
6064            let ops = sub.ops.lock().unwrap().clone();
6065            // Hierarchy invariants: any change that breaks the span hierarchy
6066            // (e.g. losing an instrument on the tool path) is caught by this
6067            // assertion.
6068            assert_nesting_invariants(&ops);
6069            // First-appearance enter order = the hierarchy; per-await
6070            // instrumenting inside the generator makes the same span enter and
6071            // exit many times (across polls + drops), so only first-appearance
6072            // order and multiple enters of run are asserted.
6073            let enters = enter_names(&ops);
6074            let mut first_seen = Vec::new();
6075            for name in enters.iter() {
6076                if !first_seen.contains(name) {
6077                    first_seen.push(name.clone());
6078                }
6079            }
6080            assert_eq!(first_seen, ["agent.run", "llm_request", "tool"]);
6081            // The run span enters on every poll: it enters many times during
6082            // stream consumption, far beyond the 2 of poll+drop.
6083            assert!(enters.iter().filter(|n| *n == "agent.run").count() > 2);
6084
6085            // usage recorded at wrap-up (the per-round injected usage).
6086            assert_eq!(
6087                records_of(&ops, "llm_request"),
6088                [
6089                    "usage.prompt_tokens=10",
6090                    "usage.completion_tokens=2",
6091                    "usage.prompt_tokens=20",
6092                    "usage.completion_tokens=5",
6093                ]
6094            );
6095        }
6096
6097        /// Two runs: run.id differs (per-instance increment); the event stream's
6098        /// RunStarted carries the same id — the correlation key between trace
6099        /// and the event stream.
6100        #[tokio::test(flavor = "current_thread")]
6101        async fn trace_run_id_differs_between_runs() {
6102            let sub = Arc::new(CollectSubscriber::default());
6103            let _guard = collect_guard(&sub);
6104
6105            let fake =
6106                SharedFake::new([FakeReply::Text("hi".into()), FakeReply::Text("bye".into())]);
6107            let (mut agent, mut rx) = attach_channel(agent(fake, ""));
6108            agent.run("one").await.unwrap();
6109            agent.run("two").await.unwrap();
6110            drop(agent);
6111
6112            let spans = sub.spans.lock().unwrap().clone();
6113            let run_ids: Vec<String> = spans
6114                .iter()
6115                .filter(|s| s.name == "agent.run")
6116                .map(|s| {
6117                    s.fields
6118                        .iter()
6119                        .find(|(f, _)| f == "run.id")
6120                        .map(|(_, v)| v.clone())
6121                        .unwrap()
6122                })
6123                .collect();
6124            assert_eq!(run_ids.len(), 2);
6125            assert_ne!(run_ids[0], run_ids[1]);
6126
6127            // Event-stream side: RunStarted carries the same run id as the span
6128            // (one per run).
6129            let events = drain(&mut rx).await;
6130            let event_ids: Vec<String> = events
6131                .iter()
6132                .filter_map(|e| match react_event(&**e) {
6133                    ReActEvent::RunStarted { run_id, .. } => Some(run_id.clone()),
6134                    _ => None,
6135                })
6136                .collect();
6137            assert_eq!(event_ids, run_ids);
6138        }
6139
6140        /// Run failure: the agent.run span records an error at wrap-up
6141        /// (observability spots the failed run at a glance).
6142        #[tokio::test(flavor = "current_thread")]
6143        async fn trace_run_error_recorded() {
6144            let sub = Arc::new(CollectSubscriber::default());
6145            let _guard = collect_guard(&sub);
6146
6147            let (calc, _calls) = FakeTool::new("calc", "42");
6148            let mut registry = ToolRegistry::new();
6149            registry.register(calc);
6150            let fake = SharedFake::new([
6151                FakeReply::ToolCalls {
6152                    content: "".into(),
6153                    calls: vec![call("c1", "calc", "{}")],
6154                },
6155                FakeReply::ToolCalls {
6156                    content: "".into(),
6157                    calls: vec![call("c2", "calc", "{}")],
6158                },
6159            ]);
6160            let mut agent = agent_with_registry(
6161                fake,
6162                registry,
6163                AgentConfig {
6164                    max_tool_rounds: 2,
6165                    ..Default::default()
6166                },
6167            );
6168            assert!(matches!(
6169                agent.run("Keep computing").await,
6170                Err(AgentError::TooManyToolRounds(2))
6171            ));
6172
6173            let ops = sub.ops.lock().unwrap().clone();
6174            // Values are Debug-formatted, so strings carry quotes; the error
6175            // text is asserted with starts_with (extensible, not locked to the
6176            // full text).
6177            let records = records_of(&ops, "agent.run");
6178            assert_eq!(records.len(), 1);
6179            assert!(
6180                records[0].starts_with("error=\"model requested tools for more than 2 rounds"),
6181                "unexpected records: {records:?}"
6182            );
6183        }
6184
6185        /// Tool failure: the tool span records an error (the failure is fed
6186        /// back as text; the run ends normally, agent.run has no error).
6187        #[tokio::test(flavor = "current_thread")]
6188        async fn trace_tool_error_recorded() {
6189            struct FailingTool;
6190            #[async_trait::async_trait]
6191            impl Tool for FailingTool {
6192                fn schema(&self) -> ToolSchema {
6193                    ToolSchema::new("boom", "Tool that always fails", serde_json::json!({}))
6194                }
6195                async fn call(
6196                    &self,
6197                    _arguments: serde_json::Value,
6198                    _context: ToolContext<'_>,
6199                ) -> Result<ToolResult, ToolError> {
6200                    Err(ToolError::Execution("internal error".into()))
6201                }
6202            }
6203
6204            let sub = Arc::new(CollectSubscriber::default());
6205            let _guard = collect_guard(&sub);
6206
6207            let mut registry = ToolRegistry::new();
6208            registry.register(FailingTool);
6209            let fake = SharedFake::new([
6210                FakeReply::ToolCalls {
6211                    content: "".into(),
6212                    calls: vec![call("c1", "boom", "{}")],
6213                },
6214                FakeReply::Text("Got it".into()),
6215            ]);
6216            let mut agent = agent_with_registry(fake, registry, AgentConfig::default());
6217            agent.run("Trigger failure").await.unwrap();
6218
6219            let ops = sub.ops.lock().unwrap().clone();
6220            // The tool span records the failure (the registry classification's
6221            // Display); the run ends normally, with no error.
6222            let tool_errors = records_of(&ops, "tool");
6223            assert_eq!(tool_errors.len(), 1);
6224            assert!(tool_errors[0].contains("internal error"));
6225            assert!(records_of(&ops, "agent.run").is_empty());
6226        }
6227
6228        /// Provider failure (non-streaming): the llm_request span records the
6229        /// error — observability can pinpoint which call of which round failed;
6230        /// the run span records too (the whole run failed).
6231        #[tokio::test(flavor = "current_thread")]
6232        async fn trace_llm_error_recorded() {
6233            let sub = Arc::new(CollectSubscriber::default());
6234            let _guard = collect_guard(&sub);
6235
6236            // Script exhausted: the second run's conversation fails outright.
6237            let fake = SharedFake::new([FakeReply::Text("hi".into())]);
6238            let mut agent = agent(fake, "");
6239            agent.run("one").await.unwrap();
6240            let err = agent.run("two").await.unwrap_err();
6241            assert!(matches!(err, AgentError::Provider(_)));
6242
6243            let ops = sub.ops.lock().unwrap().clone();
6244            // The failed call: the llm_request span has an error record (the
6245            // first run's successful call only has usage records; the two spans'
6246            // records land in the same collector).
6247            let llm_records = records_of(&ops, "llm_request");
6248            assert!(
6249                llm_records.iter().any(|r| r.starts_with("error=")),
6250                "the failed round's llm span should have an error: {llm_records:?}"
6251            );
6252            // The run span also records the failed outcome.
6253            assert!(
6254                records_of(&ops, "agent.run")
6255                    .iter()
6256                    .any(|r| r.starts_with("error="))
6257            );
6258        }
6259
6260        /// Provider failure mid-stream (streaming): the consumption loop's Err
6261        /// event records the error on the llm_request span, and the run span
6262        /// records in sync; hierarchy invariants still hold.
6263        #[tokio::test(flavor = "current_thread")]
6264        async fn trace_stream_llm_error_recorded() {
6265            struct FailInStream;
6266            #[async_trait::async_trait]
6267            impl Provider for FailInStream {
6268                async fn chat_with_context(
6269                    &self,
6270                    _request: ChatRequest,
6271                    _context: &ProviderRequestContext,
6272                ) -> Result<ChatResponse, ProviderError> {
6273                    unreachable!("this test uses streaming path only")
6274                }
6275                async fn stream_chat_with_context(
6276                    &self,
6277                    _request: ChatRequest,
6278                    _context: &ProviderRequestContext,
6279                ) -> Result<BoxStream<'static, Result<StreamEvent, ProviderError>>, ProviderError>
6280                {
6281                    Ok(Box::pin(futures::stream::iter(vec![
6282                        Ok(StreamEvent::Delta("hi".into())),
6283                        Err(ProviderError::Protocol {
6284                            message: "boom".into(),
6285                        }),
6286                    ])))
6287                }
6288            }
6289
6290            let sub = Arc::new(CollectSubscriber::default());
6291            let _guard = collect_guard(&sub);
6292
6293            let mut agent = ReActAgent::new(FailInStream, ToolRegistry::new(), "");
6294            let mut stream = agent.run_stream("two").await.unwrap();
6295            while stream.next().await.is_some() {}
6296
6297            let ops = sub.ops.lock().unwrap().clone();
6298            assert_nesting_invariants(&ops);
6299            assert!(
6300                records_of(&ops, "llm_request")
6301                    .iter()
6302                    .any(|r| r.contains("boom"))
6303            );
6304            assert!(
6305                records_of(&ops, "agent.run")
6306                    .iter()
6307                    .any(|r| r.contains("boom"))
6308            );
6309        }
6310    }
6311}