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