Skip to main content

molo_agent/agent/
sub_agent.rs

1//! Sub-agent parts: wrap another reasoning loop as a tool so the current
2//! loop can delegate to it.
3//!
4//! Two parts:
5//! - [`SubAgentTool`] — a sub-agent as a tool. Register it into the main
6//!   loop's [`ToolRegistry`](crate::tool::ToolRegistry) and the assembly is
7//!   complete; the main loop gains delegation with zero changes;
8//! - [`SubAgentPool`] — a pool of named sub-agents. Create and name them
9//!   dynamically, then address them by name to continue their conversation
10//!   (a task agent is not discarded when its task ends; you can come back
11//!   and continue talking anytime).
12//!
13//! Both depend only on the [`Agent`] trait and `Send`, not on any concrete
14//! loop implementation — application-written loop types work as sub-agents
15//! too; the built-in [`ReActAgent`] is just the most common implementation.
16//!
17//! Typical assembly (from the main agent's perspective):
18//!
19//! ```text
20//! main agent (any Agent implementation)
21//!   └─ ToolRegistry
22//!        ├─ SubAgentTool (persistent / dynamic)   ← model delegates on demand
23//!        └─ pool tools (spawn / send / list, wrapping SubAgentPool as
24//!            tools at the application layer; "@name" addressing on the UI
25//!            also resolves at the application layer)
26//! ```
27
28use crate::agent::{Agent, ReActAgent};
29use crate::provider::Provider;
30use crate::tool::{Tool, ToolContext, ToolError, ToolOutput, ToolRegistry, ToolResult, ToolSchema};
31use std::collections::HashMap;
32use std::fmt;
33use std::sync::Arc;
34use tokio::sync::Mutex;
35
36/// Factory for the dynamic form: receives this call's arguments and returns
37/// a newly created sub-agent.
38type SubAgentFactory =
39    Box<dyn Fn(serde_json::Value) -> Result<Box<dyn Agent + Send>, ToolError> + Send + Sync>;
40
41/// Provider constructor: creates a new provider instance per call (reused
42/// by the convenience form).
43type ProviderFactory = Box<dyn (Fn() -> Box<dyn Provider>) + Send + Sync>;
44
45/// Where a sub-agent's instance comes from (internal representation of the
46/// three forms).
47enum SubAgentSource {
48    /// Persistent instance: calls are serialized through an internal lock,
49    /// and the conversation accumulates across calls.
50    Instance(Mutex<Box<dyn Agent + Send>>),
51    /// Dynamic factory: each call creates a transient sub-agent that is
52    /// discarded when done; no shared state, naturally concurrent.
53    Factory(SubAgentFactory),
54    /// Convenience form: each call builds a standard ReAct sub-agent from
55    /// the `system_prompt` / `task` fields in the arguments (the model
56    /// defines the sub-agent's system prompt and task).
57    React {
58        /// Provider constructor for the sub-agent (captures the
59        /// user-supplied Clone provider).
60        make_provider: ProviderFactory,
61        /// The sub-agent's tool set.
62        tools: ToolRegistry,
63    },
64}
65
66/// A sub-agent as a tool: wrap another reasoning loop as a [`Tool`]; once
67/// registered into the main loop's
68/// [`ToolRegistry`](crate::tool::ToolRegistry), the model can delegate on
69/// demand during conversation.
70///
71/// Two forms, chosen by conversation need:
72/// - [`from_agent`](SubAgentTool::from_agent): **persistent** — holds the
73///   sub-agent instance and continues the same conversation across calls
74///   (context accumulates), suited to repeatedly consulting a "resident
75///   expert";
76/// - [`from_factory`](SubAgentTool::from_factory): **dynamic** — each call
77///   creates a transient sub-agent through the factory, discarded when done,
78///   suited to one-shot delegation; the factory sees this call's arguments
79///   and can freely assemble the sub-agent (type / tool subset / system
80///   prompt).
81///
82/// The sub-agent only needs to implement the [`Agent`] trait and be `Send`.
83/// On call, the model's arguments are passed to the sub-agent as their JSON
84/// text form, and the sub-agent's answer text is returned as the tool result
85/// to the main loop. Calls to the same tool instance are serialized
86/// (internal lock); different tool instances run in parallel.
87///
88/// Cancellation semantics: a sub-agent run receives no cancellation signal
89/// (tool calls have no cancellation parameter); when the main loop is
90/// cancelled, unfinished sub-agent calls abort as their future is dropped
91/// (non-cooperative), and already-recorded messages are kept.
92///
93/// # Examples
94///
95/// Any type implementing the [`Agent`] trait can be a sub-agent — this
96/// example uses a minimal application-written type (independent of the
97/// built-in loops):
98///
99/// ```
100/// # extern crate molo_agent as molo;
101/// use molo::agent::{Agent, AgentError, SubAgentTool};
102/// use molo::tool::{SharedState, Tool, ToolContext};
103/// use molo::{Message, RunContext, RunMetadata, RunOutput, RunRequest, RunSummary};
104/// use serde_json::json;
105///
106/// /// Demo sub-agent: echoes the input back verbatim.
107/// struct Echo;
108/// #[molo::async_trait]
109/// impl Agent for Echo {
110///     async fn run_request_with_context(
111///         &mut self,
112///         request: RunRequest,
113///         context: RunContext,
114///     ) -> Result<RunOutput, AgentError> {
115///         let answer = format!("echo: {}", input_text(request));
116///         Ok(run_output(context.run_id, answer))
117///     }
118/// }
119///
120/// fn input_text(request: RunRequest) -> String {
121///     request.input.as_text().unwrap_or("").to_string()
122/// }
123///
124/// fn run_output(run_id: String, answer: String) -> RunOutput {
125///     RunOutput {
126///         run_id,
127///         answer: answer.clone(),
128///         summary: RunSummary::default(),
129///         final_message: Message::assistant(answer),
130///         artifacts: Vec::new(),
131///         metadata: RunMetadata::new(),
132///     }
133/// }
134///
135/// # #[tokio::main]
136/// # async fn main() -> Result<(), molo::tool::ToolError> {
137/// let tool = SubAgentTool::from_agent(
138///     "echo",
139///     "Hand the content to the echo sub-agent",
140///     json!({ "type": "object", "properties": { "message": { "type": "string" } } }),
141///     Box::new(Echo),
142/// );
143///
144/// let run = RunContext::new("sub-agent-doc");
145/// let state = SharedState::default();
146/// let result = tool
147///     .call(
148///         json!({ "message": "Hello" }),
149///         ToolContext::new(&run, &state, "call-sub-agent", "echo"),
150///     )
151///     .await?;
152/// assert_eq!(result, "echo: {\"message\":\"Hello\"}");
153/// # Ok(())
154/// # }
155/// ```
156pub struct SubAgentTool {
157    schema: ToolSchema,
158    source: SubAgentSource,
159}
160
161impl SubAgentTool {
162    /// Persistent form: holds the sub-agent instance and continues the same
163    /// conversation across calls.
164    ///
165    /// # Parameters
166    ///
167    /// - `name` / `description`: the name and purpose exposed to the model
168    ///   (the model's basis for choosing the tool);
169    /// - `parameters`: the JSON Schema for the sub-task input (prefer
170    ///   generating it with `schemars::schema_for!` from a serde struct);
171    /// - `agent`: the sub-agent instance (implements the [`Agent`] trait
172    ///   and is `Send`).
173    pub fn from_agent(
174        name: impl Into<String>,
175        description: impl Into<String>,
176        parameters: serde_json::Value,
177        agent: Box<dyn Agent + Send>,
178    ) -> Self {
179        Self {
180            schema: ToolSchema::new(name, description, parameters),
181            source: SubAgentSource::Instance(Mutex::new(agent)),
182        }
183    }
184
185    /// Dynamic form: each call creates a transient sub-agent through the
186    /// factory, discarded when done.
187    ///
188    /// The factory receives this call's arguments (JSON) and can assemble
189    /// the sub-agent accordingly — choosing the type, trimming the tool
190    /// subset, or writing a system prompt; failing to parse the arguments
191    /// returns [`ToolError::InvalidArguments`].
192    ///
193    /// # Parameters
194    ///
195    /// - `name` / `description`: the name and purpose exposed to the model
196    ///   (the model's basis for choosing the tool);
197    /// - `parameters`: the JSON Schema for the sub-task input (prefer
198    ///   generating it with `schemars::schema_for!` from a serde struct);
199    /// - `factory`: the factory run on each call, returning the new
200    ///   sub-agent.
201    pub fn from_factory(
202        name: impl Into<String>,
203        description: impl Into<String>,
204        parameters: serde_json::Value,
205        factory: impl Fn(serde_json::Value) -> Result<Box<dyn Agent + Send>, ToolError>
206        + Send
207        + Sync
208        + 'static,
209    ) -> Self {
210        Self {
211            schema: ToolSchema::new(name, description, parameters),
212            source: SubAgentSource::Factory(Box::new(factory)),
213        }
214    }
215
216    /// Convenience form: each call builds a standard ReAct sub-agent from
217    /// the `system_prompt` / `task` fields in the model's arguments — the
218    /// model (main agent) defines the sub-agent's system prompt and task at
219    /// call time, no hand-written factory needed.
220    ///
221    /// The parameters schema should include two fields (both degrade
222    /// gracefully when missing): `system_prompt` (the sub-agent's system
223    /// prompt, empty when missing) and `task` (the sub-agent's task, empty
224    /// input when missing). What's passed to the sub-agent as input is the
225    /// text of the `task` field (not the whole argument JSON).
226    ///
227    /// # Parameters
228    ///
229    /// - `name` / `description`: the name and purpose exposed to the model
230    ///   (the model's basis for choosing the tool);
231    /// - `provider`: the sub-agent's provider (**must be `Clone`** — every
232    ///   call builds an independent loop); sharing one provider instance
233    ///   between the main and sub agents is fine;
234    /// - `tools`: the sub-agent's tool set;
235    /// - `parameters`: the JSON Schema for the sub-task input.
236    ///
237    /// # Examples
238    ///
239    /// ```
240    /// # extern crate molo_agent as molo;
241    /// use molo::agent::SubAgentTool;
242    /// use molo::provider::{FakeProvider, FakeReply};
243    /// use molo::RunContext;
244    /// use molo::tool::{SharedState, Tool, ToolContext, ToolRegistry};
245    /// use serde_json::json;
246    ///
247    /// # #[tokio::main]
248    /// # async fn main() -> Result<(), molo::tool::ToolError> {
249    /// let fake = FakeProvider::new([FakeReply::Text("sub answer".into())]);
250    /// let tool = SubAgentTool::from_react(
251    ///     "delegate",
252    ///     "Delegate a sub-task, defining the sub-agent's system prompt and task",
253    ///     fake,
254    ///     ToolRegistry::new(),
255    ///     json!({ "type": "object", "properties": {
256    ///         "system_prompt": { "type": "string" },
257    ///         "task": { "type": "string" },
258    ///     } }),
259    /// );
260    ///
261    /// // The model defines the system prompt and task → a new sub-agent is
262    /// // created and run
263    /// let run = RunContext::new("sub-agent-doc");
264    /// let state = SharedState::default();
265    /// let result = tool
266    ///     .call(
267    ///         json!({ "system_prompt": "You are a reviewer", "task": "Review this code" }),
268    ///         ToolContext::new(&run, &state, "call-sub-agent", "delegate"),
269    ///     )
270    ///     .await?;
271    /// assert_eq!(result, "sub answer");
272    /// # Ok(())
273    /// # }
274    /// ```
275    pub fn from_react(
276        name: impl Into<String>,
277        description: impl Into<String>,
278        provider: impl Provider + Clone + 'static,
279        tools: ToolRegistry,
280        parameters: serde_json::Value,
281    ) -> Self {
282        let make_provider: ProviderFactory =
283            Box::new(move || Box::new(provider.clone()) as Box<dyn Provider>);
284        Self {
285            schema: ToolSchema::new(name, description, parameters),
286            source: SubAgentSource::React {
287                make_provider,
288                tools,
289            },
290        }
291    }
292}
293
294impl fmt::Debug for SubAgentTool {
295    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
296        f.debug_struct("SubAgentTool")
297            .field("name", &self.schema.name)
298            .finish_non_exhaustive()
299    }
300}
301
302#[async_trait::async_trait]
303impl Tool for SubAgentTool {
304    fn schema(&self) -> ToolSchema {
305        self.schema.clone()
306    }
307
308    async fn call(
309        &self,
310        arguments: serde_json::Value,
311        _context: ToolContext<'_>,
312    ) -> Result<ToolResult, ToolError> {
313        let output = match &self.source {
314            SubAgentSource::Instance(agent) => {
315                let mut guard = agent.lock().await;
316                run_sub_agent(&mut **guard, &arguments).await
317            }
318            SubAgentSource::Factory(factory) => {
319                let mut agent = factory(arguments.clone())?;
320                run_sub_agent(&mut *agent, &arguments).await
321            }
322            SubAgentSource::React {
323                make_provider,
324                tools,
325            } => {
326                // The model defines the system prompt and task: the task
327                // field text is the input (not the whole JSON).
328                let system_prompt = arguments
329                    .get("system_prompt")
330                    .and_then(|v| v.as_str())
331                    .unwrap_or("");
332                let task = arguments.get("task").and_then(|v| v.as_str()).unwrap_or("");
333                let mut agent = ReActAgent::new((make_provider)(), tools.clone(), system_prompt);
334                agent
335                    .run(task)
336                    .await
337                    .map_err(|e| ToolError::Execution(e.to_string()))
338            }
339        }?;
340        Ok(ToolOutput::text(output).into())
341    }
342}
343
344/// Run a sub-agent once: the arguments, as JSON text, become the
345/// sub-agent's input; a sub-agent failure is mapped to an execution error
346/// (carrying the original error text, which the main loop can read and
347/// continue from).
348async fn run_sub_agent(
349    agent: &mut (dyn Agent + Send),
350    arguments: &serde_json::Value,
351) -> Result<String, ToolError> {
352    agent
353        .run(&arguments.to_string())
354        .await
355        .map_err(|e| ToolError::Execution(e.to_string()))
356}
357
358/// Addressing errors for the named sub-agent pool.
359///
360/// # Examples
361///
362/// ```
363/// # extern crate molo_agent as molo;
364/// use molo::agent::PoolError;
365///
366/// let err = PoolError::NotFound("ghost".into());
367/// assert_eq!(err.to_string(), "no such sub agent: 'ghost'");
368/// ```
369#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
370#[non_exhaustive]
371pub enum PoolError {
372    /// The name already exists at creation time (not overwritten, to avoid
373    /// accidental damage).
374    #[error("sub agent '{0}' already exists")]
375    Duplicate(String),
376    /// The name doesn't exist when addressing.
377    #[error("no such sub agent: '{0}'")]
378    NotFound(String),
379    /// The factory failed to create the sub-agent.
380    #[error("sub agent factory failed: {0}")]
381    Factory(String),
382    /// The sub-agent run failed (with the original error text).
383    #[error("sub agent run failed: {0}")]
384    Run(String),
385}
386
387impl From<PoolError> for ToolError {
388    fn from(err: PoolError) -> Self {
389        ToolError::Execution(err.to_string())
390    }
391}
392
393/// A single named slot in the pool: a sub-agent instance + a serialization
394/// lock (calls to the same name queue up).
395type AgentSlot = Arc<Mutex<Box<dyn Agent + Send>>>;
396
397/// A pool of named sub-agents: create and name sub-agents dynamically, then
398/// address them by name to continue their conversations.
399///
400/// Unlike the one-shot [`SubAgentTool::from_factory`], the pool keeps the
401/// instances it creates — they are not discarded when their task ends; the
402/// user (or the model) can hand a new task to the same sub-agent by name
403/// later, and it picks up the conversation with its prior memory. Suited to
404/// "the main agent distributes multiple tasks, each with its own
405/// independent-context sub-agent, and continues by name afterwards".
406///
407/// The pool is a shared part: cloning is cheap (internal `Arc`), and
408/// multiple loops / threads can hold the same pool; calls to the same
409/// sub-agent name are serialized (inner lock), different names run in
410/// parallel.
411///
412/// Cancellation semantics: same as [`SubAgentTool`] — sub-agent runs
413/// receive no cancellation signal; when the caller is cancelled, unfinished
414/// runs abort as their future is dropped.
415///
416/// # Examples
417///
418/// Create and name a sub-agent, run its first task immediately; continue by
419/// name afterwards:
420///
421/// ```
422/// # extern crate molo_agent as molo;
423/// use molo::agent::{Agent, AgentError, SubAgentPool};
424/// use molo::{Message, RunContext, RunMetadata, RunOutput, RunRequest, RunSummary};
425///
426/// /// Demo sub-agent: echoes the input back verbatim.
427/// struct Echo;
428/// #[molo::async_trait]
429/// impl Agent for Echo {
430///     async fn run_request_with_context(
431///         &mut self,
432///         request: RunRequest,
433///         context: RunContext,
434///     ) -> Result<RunOutput, AgentError> {
435///         let answer = format!("echo: {}", request.input.as_text().unwrap_or(""));
436///         Ok(RunOutput {
437///             run_id: context.run_id,
438///             answer: answer.clone(),
439///             summary: RunSummary::default(),
440///             final_message: Message::assistant(answer),
441///             artifacts: Vec::new(),
442///             metadata: RunMetadata::new(),
443///         })
444///     }
445/// }
446///
447/// # #[tokio::main]
448/// # async fn main() -> Result<(), molo::agent::PoolError> {
449/// let pool = SubAgentPool::new();
450///
451/// // Create a named sub-agent and run its first task (the reply returns to
452/// // the caller)
453/// let reply = pool
454///     .spawn(
455///         "red",
456///         || -> Result<Box<dyn Agent + Send>, molo::tool::ToolError> { Ok(Box::new(Echo)) },
457///         "task one",
458///     )
459///     .await?;
460/// assert_eq!(reply, "echo: task one");
461///
462/// // Continue by name: the same instance, with its prior memory
463/// let reply = pool.send("red", "task two").await?;
464/// assert_eq!(reply, "echo: task two");
465///
466/// // Name enumeration and probing (UI list / model queries)
467/// assert!(pool.contains("red").await);
468/// assert_eq!(pool.names().await, vec!["red".to_string()]);
469/// # Ok(())
470/// # }
471/// ```
472#[derive(Clone, Default)]
473pub struct SubAgentPool {
474    agents: Arc<Mutex<HashMap<String, AgentSlot>>>,
475}
476
477impl SubAgentPool {
478    /// Create an empty pool.
479    pub fn new() -> Self {
480        Self::default()
481    }
482
483    /// Create and name a sub-agent, running its first task immediately.
484    ///
485    /// The factory only builds the sub-agent (type / tool subset / system
486    /// prompt, all up to you); once built, the instance goes into the pool
487    /// before running — even if the first task fails, the sub-agent stays in
488    /// the pool and can be continued or retried via
489    /// [`send`](SubAgentPool::send).
490    ///
491    /// # Errors
492    ///
493    /// - The name already exists: returns
494    ///   [`PoolError::Duplicate`](PoolError::Duplicate);
495    /// - The factory failed: returns
496    ///   [`PoolError::Factory`](PoolError::Factory);
497    /// - The first task failed: returns [`PoolError::Run`](PoolError::Run)
498    ///   (the instance is kept).
499    pub async fn spawn(
500        &self,
501        name: &str,
502        factory: impl FnOnce() -> Result<Box<dyn Agent + Send>, ToolError>,
503        input: &str,
504    ) -> Result<String, PoolError> {
505        let slot = {
506            let mut agents = self.agents.lock().await;
507            if agents.contains_key(name) {
508                return Err(PoolError::Duplicate(name.to_string()));
509            }
510            let slot = Arc::new(Mutex::new(
511                factory().map_err(|e| PoolError::Factory(e.to_string()))?,
512            ));
513            agents.insert(name.to_string(), slot.clone());
514            slot
515        };
516        let mut guard = slot.lock().await;
517        guard
518            .run(input)
519            .await
520            .map_err(|e| PoolError::Run(e.to_string()))
521    }
522
523    /// Convenience form: create and name a standard ReAct sub-agent (with a
524    /// given system prompt), running its first task immediately; afterwards
525    /// you can continue by name via [`send`](SubAgentPool::send).
526    ///
527    /// The only difference from [`spawn`](SubAgentPool::spawn) is how the
528    /// sub-agent is built — here the system prompt and task are given and
529    /// handled by the standard ReAct loop; the provider doesn't need to be
530    /// `Clone` (a named creation calls the factory only once).
531    ///
532    /// # Errors
533    ///
534    /// Same as [`spawn`](SubAgentPool::spawn): duplicate name
535    /// [`PoolError::Duplicate`](PoolError::Duplicate); first-task failure
536    /// [`PoolError::Run`](PoolError::Run) (the instance is kept, and the
537    /// conversation can continue).
538    pub async fn spawn_react(
539        &self,
540        name: &str,
541        provider: impl Provider + 'static,
542        tools: ToolRegistry,
543        system_prompt: &str,
544        task: &str,
545    ) -> Result<String, PoolError> {
546        self.spawn(
547            name,
548            move || -> Result<Box<dyn Agent + Send>, ToolError> {
549                Ok(Box::new(ReActAgent::new(provider, tools, system_prompt)))
550            },
551            task,
552        )
553        .await
554    }
555
556    /// Address by name: hand a new task to an already-created sub-agent,
557    /// continuing its conversation (with its prior memory).
558    ///
559    /// # Errors
560    ///
561    /// Returns [`PoolError::NotFound`](PoolError::NotFound) when the name
562    /// doesn't exist; [`PoolError::Run`](PoolError::Run) when the run fails.
563    pub async fn send(&self, name: &str, input: &str) -> Result<String, PoolError> {
564        let slot = {
565            let agents = self.agents.lock().await;
566            agents
567                .get(name)
568                .cloned()
569                .ok_or_else(|| PoolError::NotFound(name.to_string()))?
570        };
571        let mut guard = slot.lock().await;
572        guard
573            .run(input)
574            .await
575            .map_err(|e| PoolError::Run(e.to_string()))
576    }
577
578    /// Whether the name already exists (duplicate check before creation /
579    /// UI probing).
580    pub async fn contains(&self, name: &str) -> bool {
581        self.agents.lock().await.contains_key(name)
582    }
583
584    /// All names in the pool (sorted), for UI lists or model queries.
585    ///
586    /// # Examples
587    ///
588    /// ```
589    /// # extern crate molo_agent as molo;
590    /// # #[tokio::main]
591    /// # async fn main() {
592    /// use molo::agent::SubAgentPool;
593    ///
594    /// let pool = SubAgentPool::new();
595    /// assert!(pool.names().await.is_empty());
596    /// # }
597    /// ```
598    pub async fn names(&self) -> Vec<String> {
599        let mut names: Vec<String> = self.agents.lock().await.keys().cloned().collect();
600        names.sort();
601        names
602    }
603}
604
605impl fmt::Debug for SubAgentPool {
606    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
607        f.debug_struct("SubAgentPool").finish_non_exhaustive()
608    }
609}
610
611#[cfg(test)]
612mod tests {
613    use super::*;
614    use crate::agent::{AgentError, ReActAgent};
615    use crate::message::{ContentBlock, Message, ToolCall};
616    use crate::provider::{FakeProvider, FakeReply, ProviderError};
617    use crate::run::{RunContext, RunMetadata, RunOutput, RunRequest, RunSummary};
618    use crate::tool::{SharedState, ToolContext, ToolRegistry};
619    use serde_json::json;
620
621    /// Whether a User message contains the given text (message content is
622    /// content blocks).
623    fn user_has_text(msg: &Message, text: &str) -> bool {
624        matches!(
625            msg,
626            Message::User(blocks)
627                if blocks.iter().any(|b| matches!(b, ContentBlock::Text(t) if t == text))
628        )
629    }
630
631    /// Test stub: records every input, used to assert whether the instance
632    /// continues and whether inputs arrive as arguments.
633    #[derive(Default)]
634    struct RecordingAgent {
635        seen: Arc<Mutex<Vec<String>>>,
636    }
637
638    #[async_trait::async_trait]
639    impl Agent for RecordingAgent {
640        async fn run_request_with_context(
641            &mut self,
642            request: RunRequest,
643            context: RunContext,
644        ) -> Result<RunOutput, AgentError> {
645            let input = input_text(request);
646            self.seen.lock().await.push(input.to_string());
647            let answer = format!("processed: {input}");
648            Ok(test_run_output(context.run_id, answer))
649        }
650    }
651
652    /// Test stub: fails on first run, succeeds afterwards (verifies
653    /// "failures keep the agent, ready to continue").
654    struct FlakyAgent {
655        failed_once: bool,
656    }
657
658    #[async_trait::async_trait]
659    impl Agent for FlakyAgent {
660        async fn run_request_with_context(
661            &mut self,
662            _request: RunRequest,
663            context: RunContext,
664        ) -> Result<RunOutput, AgentError> {
665            if !self.failed_once {
666                self.failed_once = true;
667                return Err(AgentError::Provider(ProviderError::Protocol {
668                    message: "boom".into(),
669                }));
670            }
671            Ok(test_run_output(context.run_id, "recovered"))
672        }
673    }
674
675    fn input_text(request: RunRequest) -> String {
676        match request.input.into_message() {
677            Message::User(blocks) => blocks
678                .into_iter()
679                .filter_map(|block| match block {
680                    ContentBlock::Text(text) => Some(text),
681                    _ => None,
682                })
683                .collect::<Vec<_>>()
684                .join(""),
685            _ => String::new(),
686        }
687    }
688
689    fn test_run_output(run_id: String, answer: impl Into<String>) -> RunOutput {
690        let answer = answer.into();
691        RunOutput {
692            run_id,
693            answer: answer.clone(),
694            summary: RunSummary::default(),
695            final_message: Message::assistant(answer),
696            artifacts: Vec::new(),
697            metadata: RunMetadata::new(),
698        }
699    }
700
701    async fn call_tool(
702        tool: &SubAgentTool,
703        arguments: serde_json::Value,
704    ) -> Result<String, ToolError> {
705        let run = RunContext::new("sub-agent-tool-test");
706        let state = SharedState::new();
707        let output = tool
708            .call(
709                arguments,
710                ToolContext::new(&run, &state, "call-sub-agent", "sub_agent"),
711            )
712            .await?;
713        Ok(output.to_string())
714    }
715
716    #[test]
717    fn schema_passthrough() {
718        let tool = SubAgentTool::from_agent(
719            "consult",
720            "Consult a sub-agent",
721            json!({ "type": "object", "properties": { "q": { "type": "string" } } }),
722            Box::new(RecordingAgent::default()),
723        );
724        let schema = tool.schema();
725        assert_eq!(schema.name, "consult");
726        assert_eq!(schema.description, "Consult a sub-agent");
727        assert_eq!(schema.parameters["properties"]["q"]["type"], "string");
728    }
729
730    #[tokio::test]
731    async fn persistent_agent_continues_session_across_calls() {
732        let seen = Arc::new(Mutex::new(Vec::new()));
733        let tool = SubAgentTool::from_agent(
734            "consult",
735            "Consult a sub-agent",
736            json!({}),
737            Box::new(RecordingAgent { seen: seen.clone() }),
738        );
739
740        call_tool(&tool, json!({ "q": 1 })).await.unwrap();
741        call_tool(&tool, json!({ "q": 2 })).await.unwrap();
742
743        // Same instance continues: both inputs are recorded, and the second
744        // input is the second call's arguments.
745        let all = seen.lock().await;
746        assert_eq!(all.len(), 2);
747        assert!(all[0].contains("1"));
748        assert!(all[1].contains("2"));
749    }
750
751    #[tokio::test]
752    async fn dynamic_factory_fresh_agent_per_call() {
753        let spawns = Arc::new(std::sync::atomic::AtomicUsize::new(0));
754        let spawns_factory = spawns.clone();
755        let tool = SubAgentTool::from_factory(
756            "delegate",
757            "One-shot delegation",
758            json!({}),
759            move |_args| {
760                spawns_factory.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
761                Ok(Box::new(RecordingAgent::default()))
762            },
763        );
764
765        call_tool(&tool, json!({ "q": 1 })).await.unwrap();
766        call_tool(&tool, json!({ "q": 2 })).await.unwrap();
767
768        // Every call creates a new instance through the factory (the old
769        // instance is discarded when the call ends — no state to continue).
770        assert_eq!(spawns.load(std::sync::atomic::Ordering::SeqCst), 2);
771    }
772
773    #[tokio::test]
774    async fn factory_failure_maps_to_invalid_arguments() {
775        let tool = SubAgentTool::from_factory("delegate", "One-shot delegation", json!({}), |_| {
776            Err(ToolError::InvalidArguments("bad kind".into()))
777        });
778        let err = call_tool(&tool, json!({})).await.unwrap_err();
779        assert_eq!(err, ToolError::InvalidArguments("bad kind".into()));
780    }
781
782    #[tokio::test]
783    async fn sub_agent_failure_maps_to_execution() {
784        let tool = SubAgentTool::from_agent(
785            "flaky",
786            "A sub-agent that fails",
787            json!({}),
788            Box::new(FlakyAgent { failed_once: false }),
789        );
790        let err = call_tool(&tool, json!({})).await.unwrap_err();
791        match err {
792            ToolError::Execution(msg) => assert!(msg.contains("boom")),
793            other => panic!("expected Execution, got {other:?}"),
794        }
795    }
796
797    #[tokio::test]
798    async fn pool_spawn_then_send_continues_session() {
799        let pool = SubAgentPool::new();
800        let seen = Arc::new(Mutex::new(Vec::new()));
801
802        let spawn_factory = {
803            let seen = seen.clone();
804            move || -> Result<Box<dyn Agent + Send>, ToolError> {
805                Ok(Box::new(RecordingAgent { seen }))
806            }
807        };
808        let reply = pool.spawn("red", spawn_factory, "task one").await.unwrap();
809        assert_eq!(reply, "processed: task one");
810
811        let reply = pool.send("red", "task two").await.unwrap();
812        assert_eq!(reply, "processed: task two");
813
814        // Same instance continues: both inputs recorded, and the first input
815        // is spawn's task.
816        let all = seen.lock().await;
817        assert_eq!(all.len(), 2);
818        assert!(all[0].contains("task one"));
819    }
820
821    #[tokio::test]
822    async fn pool_rejects_duplicate_names() {
823        let pool = SubAgentPool::new();
824        pool.spawn(
825            "a",
826            || -> Result<Box<dyn Agent + Send>, ToolError> {
827                Ok(Box::new(RecordingAgent::default()))
828            },
829            "x",
830        )
831        .await
832        .unwrap();
833        let err = pool
834            .spawn(
835                "a",
836                || -> Result<Box<dyn Agent + Send>, ToolError> {
837                    Ok(Box::new(RecordingAgent::default()))
838                },
839                "y",
840            )
841            .await
842            .unwrap_err();
843        assert_eq!(err, PoolError::Duplicate("a".into()));
844    }
845
846    #[tokio::test]
847    async fn pool_send_unknown_name_errors() {
848        let pool = SubAgentPool::new();
849        let err = pool.send("ghost", "hi").await.unwrap_err();
850        assert_eq!(err, PoolError::NotFound("ghost".into()));
851    }
852
853    #[tokio::test]
854    async fn pool_keeps_failed_agent_for_retry() {
855        let pool = SubAgentPool::new();
856        let err = pool
857            .spawn(
858                "flaky",
859                || -> Result<Box<dyn Agent + Send>, ToolError> {
860                    Ok(Box::new(FlakyAgent { failed_once: false }))
861                },
862                "first task",
863            )
864            .await
865            .unwrap_err();
866        assert!(err.to_string().contains("boom"));
867
868        // After the failure the instance is still in the pool; the
869        // conversation can continue (retry).
870        let reply = pool.send("flaky", "try again").await.unwrap();
871        assert_eq!(reply, "recovered");
872    }
873
874    #[tokio::test]
875    async fn pool_names_sorted_and_contains() {
876        let pool = SubAgentPool::new();
877        pool.spawn(
878            "b",
879            || -> Result<Box<dyn Agent + Send>, ToolError> {
880                Ok(Box::new(RecordingAgent::default()))
881            },
882            "x",
883        )
884        .await
885        .unwrap();
886        pool.spawn(
887            "a",
888            || -> Result<Box<dyn Agent + Send>, ToolError> {
889                Ok(Box::new(RecordingAgent::default()))
890            },
891            "x",
892        )
893        .await
894        .unwrap();
895
896        assert_eq!(pool.names().await, vec!["a".to_string(), "b".to_string()]);
897        assert!(pool.contains("a").await);
898        assert!(!pool.contains("c").await);
899    }
900
901    #[tokio::test]
902    async fn concurrent_sends_to_same_name_serialize() {
903        let pool = SubAgentPool::new();
904        pool.spawn(
905            "solo",
906            || -> Result<Box<dyn Agent + Send>, ToolError> {
907                Ok(Box::new(RecordingAgent::default()))
908            },
909            "x",
910        )
911        .await
912        .unwrap();
913
914        // Two concurrent sends to the same name: the inner lock serializes
915        // them; both succeed and both inputs are recorded.
916        let (a, b) = tokio::join!(pool.send("solo", "one"), pool.send("solo", "two"));
917        assert_eq!(a.unwrap(), "processed: one");
918        assert_eq!(b.unwrap(), "processed: two");
919    }
920
921    #[tokio::test]
922    async fn nested_parent_calls_subagent() {
923        // Sub-agent: independent loop, answers directly.
924        let sub = ReActAgent::new(
925            FakeProvider::new([FakeReply::Text("sub conclusion".into())]),
926            ToolRegistry::new(),
927            "sub-agent",
928        );
929        let sub_tool = SubAgentTool::from_agent(
930            "consult",
931            "Consult a sub-agent",
932            json!({ "type": "object", "properties": {} }),
933            Box::new(sub),
934        );
935        let mut registry = ToolRegistry::new();
936        registry.register(sub_tool);
937
938        // Parent agent: the first-round request calls consult; after
939        // receiving the result, the second round gives the final answer.
940        let parent = ReActAgent::new(
941            FakeProvider::new([
942                FakeReply::ToolCalls {
943                    content: String::new(),
944                    calls: vec![ToolCall {
945                        id: "t1".into(),
946                        name: "consult".into(),
947                        arguments: "{\"q\":\"x\"}".into(),
948                    }],
949                },
950                FakeReply::Text("Overall conclusion".into()),
951            ]),
952            registry,
953            "parent agent",
954        );
955        let mut parent = parent;
956        let answer = parent.run("go").await.unwrap();
957        assert_eq!(answer, "Overall conclusion");
958    }
959
960    #[tokio::test]
961    async fn from_react_system_prompt_and_task_reach_sub_agent() {
962        // Arc shares one instance: the sub-agent's request history can be
963        // asserted (FakeProvider's deep-copying Clone would leave records in
964        // copies; the test stub uses the Arc form).
965        let fake = Arc::new(FakeProvider::new([FakeReply::Text("sub answer".into())]));
966        let tool = SubAgentTool::from_react(
967            "delegate",
968            "Delegate a sub-task",
969            fake.clone(),
970            ToolRegistry::new(),
971            json!({}),
972        );
973
974        let result = call_tool(
975            &tool,
976            json!({ "system_prompt": "You are a reviewer", "task": "Review this code" }),
977        )
978        .await
979        .unwrap();
980        assert_eq!(result, "sub answer");
981
982        // The system prompt enters the sub-agent's request (System message);
983        // the input is the task field text.
984        let reqs = fake.requests();
985        assert_eq!(reqs.len(), 1);
986        assert!(
987            reqs[0]
988                .messages
989                .iter()
990                .any(|m| matches!(m, Message::System(s) if s.contains("You are a reviewer")))
991        );
992        assert!(
993            reqs[0]
994                .messages
995                .iter()
996                .any(|m| user_has_text(m, "Review this code"))
997        );
998    }
999
1000    #[tokio::test]
1001    async fn from_react_missing_fields_default_to_empty() {
1002        let fake = Arc::new(FakeProvider::new([FakeReply::Text("sub answer".into())]));
1003        let tool = SubAgentTool::from_react(
1004            "delegate",
1005            "Delegate a sub-task",
1006            fake.clone(),
1007            ToolRegistry::new(),
1008            json!({}),
1009        );
1010
1011        // No system_prompt / task fields: empty system prompt (no System
1012        // message assembled), empty input.
1013        call_tool(&tool, json!({})).await.unwrap();
1014        let reqs = fake.requests();
1015        assert!(
1016            reqs[0]
1017                .messages
1018                .iter()
1019                .all(|m| !matches!(m, Message::System(_)))
1020        );
1021        assert!(reqs[0].messages.iter().any(|m| user_has_text(m, "")));
1022    }
1023
1024    #[tokio::test]
1025    async fn spawn_react_then_send_continues_session() {
1026        let pool = SubAgentPool::new();
1027        let fake = Arc::new(FakeProvider::new([
1028            FakeReply::Text("answer one".into()),
1029            FakeReply::Text("answer two".into()),
1030        ]));
1031
1032        let reply = pool
1033            .spawn_react(
1034                "red",
1035                fake.clone(),
1036                ToolRegistry::new(),
1037                "review expert",
1038                "task one",
1039            )
1040            .await
1041            .unwrap();
1042        assert_eq!(reply, "answer one");
1043        pool.send("red", "task two").await.unwrap();
1044
1045        // Continue: the second request carries the first conversation (same
1046        // instance, session continues).
1047        let reqs = fake.requests();
1048        assert_eq!(reqs.len(), 2);
1049        assert!(
1050            reqs[1]
1051                .messages
1052                .iter()
1053                .any(|m| matches!(m, Message::System(s) if s.contains("review expert")))
1054        );
1055        assert!(
1056            reqs[1]
1057                .messages
1058                .iter()
1059                .any(|m| user_has_text(m, "task one"))
1060        );
1061        assert!(
1062            reqs[1]
1063                .messages
1064                .iter()
1065                .any(|m| user_has_text(m, "task two"))
1066        );
1067    }
1068}