1use std::sync::Arc;
4
5use async_trait::async_trait;
6use machi_tools::registry::CapabilityMode;
7use machi_tools::{DynTool, ToolCallContext, ToolMetadata, ToolResult};
8use machi_types::{ErrorCode, MachiError};
9use serde_json::{Value, json};
10use tokio_util::sync::CancellationToken;
11
12use crate::host::{SessionHost, SpawnOpts};
13
14pub struct SpawnAgentTool {
24 host: Arc<dyn SessionHost>,
25 default_capability: CapabilityMode,
26 allowed_agent_types: Option<Vec<String>>,
28}
29
30impl std::fmt::Debug for SpawnAgentTool {
31 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32 f.debug_struct("SpawnAgentTool")
33 .field("default_capability", &self.default_capability)
34 .field("allowed_agent_types", &self.allowed_agent_types)
35 .finish_non_exhaustive()
36 }
37}
38
39impl SpawnAgentTool {
40 #[must_use]
42 pub fn new(host: Arc<dyn SessionHost>) -> Self {
43 Self {
44 host,
45 default_capability: CapabilityMode::Full,
46 allowed_agent_types: None,
47 }
48 }
49
50 #[must_use]
52 pub const fn with_default_capability(mut self, mode: CapabilityMode) -> Self {
53 self.default_capability = mode;
54 self
55 }
56
57 #[must_use]
59 pub fn with_allowed_agent_types(
60 mut self,
61 types: impl IntoIterator<Item = impl Into<String>>,
62 ) -> Self {
63 self.allowed_agent_types = Some(types.into_iter().map(Into::into).collect());
64 self
65 }
66}
67
68#[async_trait]
69impl DynTool for SpawnAgentTool {
70 fn name(&self) -> &'static str {
71 "spawn_agent"
72 }
73
74 fn description(&self) -> &'static str {
75 "Spawn a nested agent to handle a subtask with its own context. \
76 Provide a clear prompt; optionally set label, capability_mode \
77 (full | read_only | plan), agent_type, max_steps, and output_schema."
78 }
79
80 fn parameters(&self) -> Value {
81 json!({
82 "type": "object",
83 "properties": {
84 "prompt": {
85 "type": "string",
86 "description": "Task instructions for the nested agent"
87 },
88 "label": {
89 "type": "string",
90 "description": "Optional short label for logs and aggregation"
91 },
92 "capability_mode": {
93 "type": "string",
94 "enum": ["full", "read_only", "plan"],
95 "description": "Tool capability filter for the child agent"
96 },
97 "max_steps": {
98 "type": "integer",
99 "minimum": 1,
100 "description": "Optional max ReAct steps for the child turn"
101 },
102 "agent_type": {
103 "type": "string",
104 "description": "Optional registered agent definition name"
105 },
106 "output_schema": {
107 "type": "object",
108 "description": "Optional JSON schema for structured child output"
109 },
110 "max_output_tokens": {
111 "type": "integer",
112 "minimum": 1,
113 "description": "Optional max output tokens for the child sampler"
114 }
115 },
116 "required": ["prompt"],
117 "additionalProperties": false
118 })
119 }
120
121 fn metadata(&self) -> ToolMetadata {
122 ToolMetadata::spawn()
123 }
124
125 async fn call(&self, ctx: ToolCallContext, arguments: Value) -> Result<ToolResult, MachiError> {
126 if ctx.is_cancelled() {
127 return Err(MachiError::new(
128 ErrorCode::ToolCancelled,
129 "spawn_agent cancelled",
130 ));
131 }
132
133 let prompt = arguments
134 .get("prompt")
135 .and_then(Value::as_str)
136 .map(str::trim)
137 .filter(|s| !s.is_empty())
138 .ok_or_else(|| {
139 MachiError::new(ErrorCode::ToolInvalidArgs, "spawn_agent requires prompt")
140 })?;
141
142 let label = arguments
143 .get("label")
144 .and_then(Value::as_str)
145 .map(str::to_owned);
146 let capability_mode = arguments
147 .get("capability_mode")
148 .and_then(Value::as_str)
149 .map_or(self.default_capability, parse_capability);
150 let max_steps = arguments
151 .get("max_steps")
152 .and_then(Value::as_u64)
153 .and_then(|n| usize::try_from(n).ok());
154 let agent_type = arguments
155 .get("agent_type")
156 .and_then(Value::as_str)
157 .map(str::to_owned);
158 if let Some(allow) = &self.allowed_agent_types {
159 let Some(ref t) = agent_type else {
160 return Err(MachiError::new(
161 ErrorCode::ToolInvalidArgs,
162 "spawn_agent requires agent_type when an allowlist is configured",
163 ));
164 };
165 if !allow.iter().any(|a| a == t) {
166 return Err(MachiError::new(
167 ErrorCode::ToolInvalidArgs,
168 format!("agent_type '{t}' is not in the spawn allowlist"),
169 ));
170 }
171 }
172 let output_schema = arguments.get("output_schema").cloned();
173 let max_output_tokens = arguments.get("max_output_tokens").and_then(Value::as_u64);
174
175 let child_cancel = if ctx.cancel.is_cancelled() {
177 CancellationToken::new()
178 } else {
179 ctx.cancel.child_token()
180 };
181
182 let depth = ctx.spawn_depth().map_or(0, |d| d.saturating_add(1));
184
185 let mut opts = SpawnOpts::new(prompt)
186 .with_capability(capability_mode)
187 .with_cancel(child_cancel)
188 .with_depth(depth);
189 if let Some(label) = label {
190 opts = opts.with_label(label);
191 }
192 if let Some(max_steps) = max_steps {
193 opts = opts.with_max_steps(max_steps);
194 }
195 if let Some(agent_type) = agent_type {
196 opts = opts.with_agent_type(agent_type);
197 }
198 if let Some(schema) = output_schema {
199 opts = opts.with_output_schema(schema);
200 }
201 if let Some(n) = max_output_tokens {
202 opts = opts.with_max_output_tokens(n);
203 }
204
205 let run = self.host.spawn_agent(opts).await.map_err(|e| {
206 MachiError::new(ErrorCode::ToolExecution, e.message().to_owned()).with_source(e)
207 })?;
208
209 let content = serde_json::to_string_pretty(&json!({
210 "agent_id": run.agent_id.to_string(),
211 "label": run.label,
212 "success": run.success,
213 "cancelled": run.cancelled,
214 "output": run.output,
215 "steps": run.steps,
216 "duration_ms": run.duration_ms,
217 }))
218 .unwrap_or_else(|_| run.output.to_string());
219
220 Ok(ToolResult {
221 content,
222 structured: Some(json!({
223 "agent_id": run.agent_id.to_string(),
224 "label": run.label,
225 "success": run.success,
226 "output": run.output,
227 })),
228 is_error: !run.success || run.cancelled,
229 })
230 }
231}
232
233fn parse_capability(mode: &str) -> CapabilityMode {
234 match mode {
235 "read_only" | "read-only" | "readonly" => CapabilityMode::ReadOnly,
236 "plan" => CapabilityMode::Plan,
237 _ => CapabilityMode::Full,
238 }
239}
240
241#[cfg(test)]
242mod tests {
243 use std::sync::Arc;
244
245 use machi_llm::MockSampler;
246 use serde_json::json;
247
248 use super::*;
249 use crate::host::InProcessHost;
250
251 #[tokio::test]
252 async fn spawn_tool_runs_child() {
253 let sampler = Arc::new(MockSampler::new());
254 sampler.map_user_text("child task", "child-done");
255 let host: Arc<dyn SessionHost> = Arc::new(InProcessHost::new(sampler, Vec::new()));
256 let tool = SpawnAgentTool::new(host);
257 let result = tool
258 .call(
259 ToolCallContext::default(),
260 json!({"prompt": "child task", "label": "w1"}),
261 )
262 .await
263 .expect("call");
264 assert!(!result.is_error);
265 let structured = result.structured.expect("structured");
266 assert_eq!(
267 structured.get("output").and_then(|v| v.as_str()),
268 Some("child-done")
269 );
270 assert_eq!(structured.get("label").and_then(|v| v.as_str()), Some("w1"));
271 }
272
273 #[tokio::test]
274 async fn spawn_tool_depth_fail_closed() {
275 let sampler = Arc::new(MockSampler::new());
276 let host: Arc<dyn SessionHost> =
277 Arc::new(InProcessHost::new(sampler, Vec::new()).with_max_spawn_depth(Some(1)));
278 let tool = SpawnAgentTool::new(host);
279 let mut extras = std::collections::HashMap::new();
281 extras.insert(machi_tools::EXTRA_SPAWN_DEPTH.to_owned(), "0".into());
282 let ctx = ToolCallContext::default().with_extras(extras);
283 let err = tool
284 .call(ctx, json!({"prompt": "too deep"}))
285 .await
286 .expect_err("depth");
287 assert_eq!(err.code(), ErrorCode::ToolExecution);
288 assert!(
289 err.message().contains("depth") || err.message().contains("spawn"),
290 "unexpected message: {}",
291 err.message()
292 );
293 }
294
295 #[tokio::test]
296 async fn spawn_tool_agent_type_allowlist() {
297 let sampler = Arc::new(MockSampler::new());
298 sampler.map_user_text("task", "ok");
299 let host: Arc<dyn SessionHost> = Arc::new(InProcessHost::new(sampler, Vec::new()));
300 let tool = SpawnAgentTool::new(host).with_allowed_agent_types(["explore"]);
301 let err = tool
302 .call(
303 ToolCallContext::default(),
304 json!({"prompt": "task", "agent_type": "plan"}),
305 )
306 .await
307 .expect_err("allowlist");
308 assert_eq!(err.code(), ErrorCode::ToolInvalidArgs);
309 let ok = tool
310 .call(
311 ToolCallContext::default(),
312 json!({"prompt": "task", "agent_type": "explore"}),
313 )
314 .await
315 .expect("allowed");
316 assert!(!ok.is_error);
317 }
318}