Skip to main content

machi_runtime/
spawn_tool.rs

1//! Model-facing `spawn_agent` tool for dynamic multi-agent delegation.
2
3use 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
14/// Tool that spawns a nested agent through a shared [`SessionHost`].
15///
16/// This is the **dynamic delegation** entry point for a parent agent’s `ReAct`
17/// loop: the model calls `spawn_agent`, the tool blocks until the child finishes,
18/// and the child’s output is returned as the tool result.
19///
20/// Nesting depth is derived from [`ToolCallContext::spawn_depth`]: a top-level
21/// session turn spawns at depth `0`; a host-spawned agent at depth `d` spawns at
22/// `d + 1`.
23pub struct SpawnAgentTool {
24    host: Arc<dyn SessionHost>,
25    default_capability: CapabilityMode,
26    /// When set, only these `agent_type` values may be spawned (fail-closed).
27    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    /// Bind to a host.
41    #[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    /// Default capability mode for children when the model omits it.
51    #[must_use]
52    pub const fn with_default_capability(mut self, mode: CapabilityMode) -> Self {
53        self.default_capability = mode;
54        self
55    }
56
57    /// Restrict spawnable `agent_type` values (W5.3). Empty allowlist rejects all typed spawns.
58    #[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        // Child cancel is linked to the parent turn token when present.
176        let child_cancel = if ctx.cancel.is_cancelled() {
177            CancellationToken::new()
178        } else {
179            ctx.cancel.child_token()
180        };
181
182        // Top-level session → depth 0; host-spawned agent at d → child at d+1.
183        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        // Parent already at depth 0 → child would be depth 1 → rejected when max=1.
280        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}