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}