molo/agent/mod.rs
1//! Agents: reasoning loops.
2//!
3//! An Agent is expressed as a trait (consistent with the rest of the
4//! library: Provider / Tool / Memory); the concrete reasoning loops (ReAct /
5//! Plan & Execute / ...) are provided by each implementation.
6//!
7//! This module provides:
8//! - Interfaces: [`Agent`] reasoning-loop trait, [`CancellableAgent`]
9//! optional cooperative cancellation, [`AgentEvent`] application-level
10//! event interface, [`AgentError`] run-failure reasons;
11//! - The classic assembly: [`ReActAgent`] generic ReAct loop and its
12//! convenience macro [`react_agent!`](crate::react_agent);
13//! - Sub-agent parts: [`SubAgentTool`] sub-agent as a tool, [`SubAgentPool`]
14//! named sub-agent pool (the main loop delegates sub-loops via tools);
15//! - Structured output: [`TypedAgent`] typed-output interface,
16//! [`StructuredValidator`] validation component (validation / feedback
17//! messages / retry budget in one);
18//! - Message chunks and summaries: [`MessageChunk`] / [`RunSummary`];
19//! - Optional behavior configuration: [`AgentConfig`].
20//!
21//! Execution state such as goal / plan / step does not belong to the
22//! [`Agent`] trait; each concrete loop manages it itself.
23//!
24//! # Examples
25//!
26//! Assemble an agent in one shot with [`react_agent!`](crate::react_agent)
27//! and run a round of conversation:
28//!
29//! ```
30//! # #[tokio::main]
31//! # async fn main() -> Result<(), molo::AgentError> {
32//! use molo::{react_agent, Agent, FakeProvider, FakeReply};
33//!
34//! let mut agent = react_agent!(
35//! FakeProvider::new([FakeReply::Text("Hello".into())]),
36//! "You are a helpful assistant",
37//! );
38//! let answer = agent.run("hi").await?;
39//! assert_eq!(answer, "Hello");
40//! # Ok(())
41//! # }
42//! ```
43
44mod config;
45mod events;
46mod react;
47mod structured;
48mod sub_agent;
49
50pub use config::AgentConfig;
51pub use events::ReActEvent;
52pub use react::ReActAgent;
53pub use structured::{
54 StructuredOutcome, StructuredValidator, structured_retry_message, validate_structured,
55};
56pub use sub_agent::{PoolError, SubAgentPool, SubAgentTool};
57
58use crate::memory::MemoryError;
59use crate::provider::{ProviderError, Usage};
60use futures::stream::BoxStream;
61use std::fmt;
62use tokio_util::sync::CancellationToken;
63
64/// Reasoning-loop interface: one `run` takes the user input, drives the
65/// reasoning loop, and returns the final answer.
66///
67/// Every reasoning loop (the built-in [`ReActAgent`] and custom
68/// implementations) implements this trait; implementations that want
69/// cooperative cancellation additionally implement [`CancellableAgent`].
70///
71/// The streaming and non-streaming entry points share the same semantics:
72/// the reply is either given whole ([`run`](Agent::run)) or returned as a
73/// [`MessageChunk`] stream ([`run_stream`](Agent::run_stream), ending with
74/// [`MessageChunk::Done`]).
75#[async_trait::async_trait]
76pub trait Agent {
77 /// One run: record the user input, drive the reasoning loop, and return
78 /// the model's final answer as text.
79 ///
80 /// # Errors
81 ///
82 /// Returns [`AgentError::Memory`] when context access fails;
83 /// [`AgentError::Provider`] when communicating with the LLM fails;
84 /// [`AgentError::TooManyToolRounds`] when the model keeps requesting
85 /// tools beyond [`AgentConfig::max_tool_rounds`] without a final answer;
86 /// [`AgentError::Cancelled`] when the run is cooperatively cancelled
87 /// (the passed [`CancellationToken`] is requested).
88 async fn run(&mut self, input: &str) -> Result<String, AgentError>;
89
90 /// Streaming run: same semantics as [`run`](Agent::run), with the reply
91 /// returned as a stream of message chunks (see [`MessageChunk`]), ending
92 /// with [`MessageChunk::Done`]; errors are produced as `Err` items and
93 /// terminate the stream (no Done afterwards).
94 ///
95 /// The default implementation is not truly streaming — the whole answer
96 /// is given as a single [`MessageChunk::Delta`] chunk; implementations
97 /// that need per-character streaming (including tool progress) should
98 /// override this method.
99 /// The default implementation calls [`run`](Agent::run) and only gets
100 /// text, so it can't count rounds / usage, and `Done` carries a
101 /// zero-valued summary; implementations that need a real summary should
102 /// override it.
103 async fn run_stream<'a>(
104 &'a mut self,
105 input: &'a str,
106 ) -> Result<BoxStream<'a, Result<MessageChunk, AgentError>>, AgentError> {
107 let answer = self.run(input).await?;
108 Ok(Box::pin(futures::stream::iter([
109 Ok(MessageChunk::Delta(answer)),
110 Ok(MessageChunk::Done(RunSummary::default())),
111 ])))
112 }
113}
114
115/// Optional capability: cooperative cancellation.
116///
117/// opt-in — implementations that don't need cancellation don't implement
118/// this trait (the methods don't even exist at compile time, so there's no
119/// fake cancellation where "the default implementation ignores the token");
120/// callers that need cancellation (such as interactive apps) call
121/// [`run_cancellable`](CancellableAgent::run_cancellable)
122/// / [`run_stream_cancellable`](CancellableAgent::run_stream_cancellable)
123/// directly on the concrete type.
124///
125/// Each run carries a [`CancellationToken`], the cooperative cancellation
126/// source for this run — any holder can cancel the same token (UI button /
127/// timeout / external signal); implementations should check at safe points
128/// and terminate promptly: `run_cancellable` returns
129/// `Err([`AgentError::Cancelled`])`, while `run_stream_cancellable`
130/// terminates with a [`MessageChunk::Cancelled`] terminal chunk (no `Done`).
131/// Messages already recorded are kept, not rolled back.
132///
133/// # Examples
134///
135/// An already-cancelled token makes the run fail immediately; a fresh token
136/// lets it proceed:
137///
138/// ```
139/// # #[tokio::main]
140/// # async fn main() -> Result<(), molo::AgentError> {
141/// use molo::agent::{CancellableAgent, ReActAgent};
142/// use molo::provider::{FakeProvider, FakeReply};
143/// use molo::tool::ToolRegistry;
144/// use molo::CancellationToken;
145///
146/// let mut agent = ReActAgent::new(
147/// FakeProvider::new([FakeReply::Text("Hello".into())]),
148/// ToolRegistry::new(),
149/// "",
150/// );
151///
152/// let cancelled = CancellationToken::new();
153/// cancelled.cancel();
154/// // Cancelled token: run returns Err(AgentError::Cancelled) immediately
155/// assert!(agent.run_cancellable("hi", &cancelled).await.is_err());
156///
157/// // Fresh token: completes normally
158/// let fresh = CancellationToken::new();
159/// assert_eq!(agent.run_cancellable("hi", &fresh).await?, "Hello");
160/// # Ok(())
161/// # }
162/// ```
163#[async_trait::async_trait]
164pub trait CancellableAgent: Agent {
165 /// Run with a cancellation source. Returns `Err(AgentError::Cancelled)`
166 /// on cancellation.
167 async fn run_cancellable(
168 &mut self,
169 input: &str,
170 token: &CancellationToken,
171 ) -> Result<String, AgentError>;
172
173 /// Streaming run with a cancellation source: same semantics as
174 /// [`run_cancellable`](CancellableAgent::run_cancellable); when
175 /// cancelled, terminates with a [`MessageChunk::Cancelled`] terminal
176 /// chunk (no `Done`).
177 ///
178 /// The default implementation is not truly streaming — the whole answer
179 /// is given as a single [`MessageChunk::Delta`] chunk; implementations
180 /// that need per-character streaming should override this method.
181 async fn run_stream_cancellable<'a>(
182 &'a mut self,
183 input: &'a str,
184 token: &CancellationToken,
185 ) -> Result<BoxStream<'a, Result<MessageChunk, AgentError>>, AgentError> {
186 // Cancellation (before or during the run) always terminates with an
187 // in-stream Cancelled terminal chunk — no Done, no Err — distinct
188 // from run_cancellable's Err(Cancelled) shape.
189 let answer = match self.run_cancellable(input, token).await {
190 Ok(answer) => answer,
191 Err(AgentError::Cancelled) => {
192 return Ok(Box::pin(futures::stream::iter([Ok(
193 MessageChunk::Cancelled,
194 )])));
195 }
196 Err(e) => return Err(e),
197 };
198 Ok(Box::pin(futures::stream::iter([
199 Ok(MessageChunk::Delta(answer)),
200 Ok(MessageChunk::Done(RunSummary::default())),
201 ])))
202 }
203}
204
205/// Optional capability: typed output (opt-in — implementations that don't
206/// need it don't implement it; the method doesn't even exist at compile
207/// time, the same pattern as [`CancellableAgent`]).
208///
209/// [`run_typed`](TypedAgent::run_typed) has the same semantics as
210/// [`Agent::run`] (records input, drives the reasoning loop), but
211/// deserializes the final answer into the type parameter `U` once it passes
212/// validation — this run auto-generates a JSON Schema from `U`
213/// (`schemars`-derived), feeds validation failures back to the model for
214/// retry, and reports [`AgentError::StructuredRetriesExhausted`] when the
215/// budget is exhausted.
216///
217/// **Why separate from [`Agent`]**: trait generic methods are not
218/// object-safe — putting it in `Agent` would immediately break
219/// `Box<dyn Agent>` (sub-agent delegation, etc.); a separate trait leaves
220/// `Box<dyn Agent>` unaffected, and code with the generic bound
221/// `A: TypedAgent` can call it on any implementation.
222///
223/// **No default implementation**: validation retries happen inside the
224/// reasoning loop (a failure is fed back to the model and the conversation
225/// continues), while `Agent::run` is a one-shot call — a default
226/// implementation couldn't retry within the loop; implementors assemble the
227/// public parts [`StructuredValidator`] (validation / feedback messages /
228/// retry budget in one) or the pure functions [`validate_structured`] /
229/// [`structured_retry_message`] inside their own loops (the built-in
230/// [`ReActAgent`] assembly is exactly this shape).
231#[async_trait::async_trait]
232pub trait TypedAgent: Agent {
233 /// Typed run: the final answer is deserialized into `U` once validation
234 /// passes.
235 ///
236 /// # Errors
237 ///
238 /// - [`AgentError::StructuredRetriesExhausted`][]: validation failures
239 /// are fed back to the model for retry, with the budget defined by the
240 /// implementation (see
241 /// [`AgentConfig::max_structured_retries`](crate::agent::AgentConfig)
242 /// for the built-in assembly); returned when the budget is exhausted
243 /// without success;
244 /// - [`AgentError::StructuredParse`][]: validation passed but
245 /// deserialization failed;
246 /// - otherwise the same as [`Agent::run`](Agent::run).
247 async fn run_typed<U>(&mut self, input: &str) -> Result<U, AgentError>
248 where
249 U: serde::de::DeserializeOwned + schemars::JsonSchema + Send + Sync;
250}
251
252/// Execution summary for one run (business-facing data; carried with the
253/// streaming [`MessageChunk::Done`] chunk).
254///
255/// - `rounds`: number of conversation rounds. One conversation plus the
256/// tool executions that follow counts as one round, matching the
257/// tool-round limit
258/// ([`AgentConfig::max_tool_rounds`](crate::agent::AgentConfig));
259/// error paths (cancellation, exceeding the round limit, etc.) don't
260/// produce a `Done`, so there is no summary;
261/// - `tool_calls`: total number of tool executions;
262/// - `usage`: sum of token usage across rounds; rounds where the streaming
263/// endpoint didn't return usage count as zero.
264///
265/// The summary is for business display ("how many rounds / how many
266/// tokens"); observability metrics like latency and call hierarchy don't
267/// enter the message-chunk stream. The non-streaming [`run`](Agent::run)
268/// returns plain text without a summary — use the streaming entry point
269/// when you need one.
270///
271/// # Examples
272///
273/// ```
274/// use molo::agent::RunSummary;
275///
276/// let summary = RunSummary { rounds: 3, tool_calls: 2, ..Default::default() };
277/// assert_eq!(summary.rounds, 3);
278/// ```
279#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
280pub struct RunSummary {
281 /// Number of conversation rounds.
282 pub rounds: usize,
283 /// Total number of tool executions.
284 pub tool_calls: usize,
285 /// Sum of token usage across rounds.
286 pub usage: Usage,
287}
288
289/// Message chunks for a streaming run — the streaming output of one run,
290/// sliced into pieces.
291///
292/// `Delta` / `ToolCall` / `ToolResult` are the streaming projection of the
293/// message record (text the model is generating, recorded Assistant tool
294/// requests, and returned ToolResult messages); `Done` / `Cancelled` are
295/// terminal markers. These are not "events" — real events are the
296/// application-level event abstraction
297/// [`AgentEvent`](trait, where each Agent implementation defines its own
298/// event variants (the framework doesn't anticipate them), flowing through
299/// an event pipeline.
300///
301/// # Reasoning
302///
303/// Reasoning produces no chunks: reasoning deltas from thinking models do
304/// not appear in this enum — matching on `MessageChunk::Reasoning` won't
305/// compile, and that's intentional. To surface reasoning, attach an
306/// [`EventChannel`](crate::event_channel::EventChannel) and subscribe to
307/// [`ReActEvent::Reasoning`], or consume
308/// [`StreamEvent::Reasoning`](crate::provider::StreamEvent::Reasoning) at
309/// the Provider layer.
310///
311/// The enum carries `#[non_exhaustive]` (reserved for extension): matches
312/// must include a wildcard arm.
313#[derive(Debug, Clone, PartialEq, Eq)]
314#[non_exhaustive]
315pub enum MessageChunk {
316 /// An increment of the reply text; increments within the same round are
317 /// concatenated in order.
318 Delta(String),
319 /// The model requested a tool call (the tool_calls of a recorded
320 /// Assistant message).
321 ToolCall {
322 /// The id of this call, matching [`ToolCall::id`](crate::ToolCall::id)
323 /// in the recorded memory; used to pair multiple calls of the same
324 /// tool within a round.
325 id: String,
326 /// The tool name.
327 name: String,
328 /// The arguments generated by the model (JSON text).
329 arguments: String,
330 },
331 /// A tool execution completed (records a ToolResult message; on failure
332 /// the content is the error text).
333 ToolResult {
334 /// The id of this execution, matching
335 /// [`Message::ToolResult`](crate::Message::ToolResult) in the
336 /// recorded memory; paired with the
337 /// [`ToolCall`](MessageChunk::ToolCall) id in the same round.
338 id: String,
339 /// The tool name.
340 name: String,
341 /// The execution result text (the error text on failure).
342 content: String,
343 },
344 /// The run ended normally; carries the execution summary for this run
345 /// ([`RunSummary`]); the stream produces no further chunks afterwards.
346 Done(RunSummary),
347 /// The run was cooperatively cancelled (via the CancellationToken passed
348 /// to run/run_stream); terminal chunk, the stream produces no further
349 /// chunks afterwards (no Done).
350 Cancelled,
351}
352
353/// Application-level event abstraction.
354///
355/// Each Agent implementation defines its own event types (tool lifecycle /
356/// plan steps / retrieval / sub-agents, etc.); the framework doesn't
357/// anticipate variants. Events are pushed through
358/// [`EventChannel`](crate::event_channel::EventChannel) for external
359/// subscription. Consumers downcast known types precisely via `as_any` (see
360/// [`impl dyn AgentEvent`](AgentEvent) below) and fall back to
361/// [`name`](AgentEvent::name) for unknown types.
362///
363/// Event payloads are uniformly `Arc<dyn AgentEvent>`: `Arc` covers the
364/// clone requirement of broadcast channels, the trait itself needs no
365/// `Clone`, and event types are zero-boilerplate.
366pub trait AgentEvent: std::any::Any + Send + Sync + fmt::Debug {
367 /// Event name: lets subscribers at least display a name for unknown
368 /// types. Default = the type's full path; override for a short name
369 /// (e.g. `"tool.started"`).
370 fn name(&self) -> &'static str {
371 std::any::type_name::<Self>()
372 }
373}
374
375impl dyn AgentEvent {
376 /// Typed access: `event.as_any().downcast_ref::<ToolStarted>()`.
377 ///
378 /// Declared as an inherent method rather than a trait default method:
379 /// `&dyn AgentEvent` → `&dyn Any` is a trait-object upcast (Rust 1.86+,
380 /// with `Any` as a supertrait), which can't be expressed directly in a
381 /// trait default method.
382 pub fn as_any(&self) -> &dyn std::any::Any {
383 self as &dyn std::any::Any
384 }
385}
386
387/// Reasons an Agent run can fail.
388///
389/// A tool execution failure is not an `AgentError` — it is fed back to the
390/// model as text, and the model decides what to do next. `#[non_exhaustive]`
391/// ensures future error categories won't be a breaking change.
392///
393/// # Examples
394///
395/// ```
396/// use molo::AgentError;
397///
398/// // The round-limit error carries the limit value, useful for prompting
399/// // the user to adjust the config
400/// let err = AgentError::TooManyToolRounds(10);
401/// assert!(err.to_string().contains("10"));
402/// ```
403#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
404#[non_exhaustive]
405pub enum AgentError {
406 /// Context access failed.
407 #[error("memory error: {0}")]
408 Memory(#[from] MemoryError),
409 /// Communication with the LLM failed.
410 #[error("provider error: {0}")]
411 Provider(#[from] ProviderError),
412 /// The model kept requesting tools past the implementation's maximum
413 /// number of rounds without giving a final answer. Increase via
414 /// [`AgentConfig::max_tool_rounds`](crate::agent::AgentConfig) (chained
415 /// `with_config(AgentConfig { max_tool_rounds: N, ..Default::default() })`).
416 #[error(
417 "model requested tools for more than {0} rounds; increase AgentConfig::max_tool_rounds (via with_config) if intended"
418 )]
419 TooManyToolRounds(usize),
420 /// The run was cooperatively cancelled (via the CancellationToken passed
421 /// to run/run_stream); already-recorded messages are kept, not rolled
422 /// back.
423 #[error("run cancelled")]
424 Cancelled,
425 /// Structured output: validation passed but deserializing into the
426 /// target type failed — triggered when the JSON the schema allows is
427 /// inconsistent with the serde representation of `run_typed`'s type
428 /// parameter `U` (auto-generated schemas agree with `U` by default;
429 /// conflicts come from `#[schemars(...)]` custom derives).
430 #[error("structured output failed to deserialize: {0}")]
431 StructuredParse(String),
432 /// Structured output: validation failed more times than the configured
433 /// limit. Increase via
434 /// [`AgentConfig::max_structured_retries`](crate::agent::AgentConfig)
435 /// (chained
436 /// `with_config(AgentConfig { max_structured_retries: N, ..Default::default() })`).
437 #[error(
438 "structured output failed validation for more than {0} attempts; increase AgentConfig::max_structured_retries (via with_config) if intended"
439 )]
440 StructuredRetriesExhausted(usize),
441}