1use crate::agent::loop_::AgentRunOverrides;
9use crate::subagent::{SubAgentOverrides, SubAgentSpawn};
10use anyhow::Result;
11use async_trait::async_trait;
12use serde_json::json;
13use std::sync::Arc;
14use zeroclaw_api::tool::{Tool, ToolResult};
15use zeroclaw_config::schema::Config;
16use zeroclaw_log::scope;
17
18pub struct SpawnSubagentTool {
21 config: Arc<Config>,
22 parent_alias: String,
23 is_subagent_caller: bool,
28}
29
30impl SpawnSubagentTool {
31 pub fn new(config: Arc<Config>, parent_alias: impl Into<String>) -> Self {
32 Self {
33 config,
34 parent_alias: parent_alias.into(),
35 is_subagent_caller: false,
36 }
37 }
38
39 #[must_use]
43 pub fn with_subagent_caller(mut self, is_subagent_caller: bool) -> Self {
44 self.is_subagent_caller = is_subagent_caller;
45 self
46 }
47}
48
49#[async_trait]
50impl Tool for SpawnSubagentTool {
51 fn name(&self) -> &str {
52 "spawn_subagent"
53 }
54
55 fn description(&self) -> &str {
56 "Spawn an ephemeral SubAgent that inherits this agent's identity, \
57 security policy, and memory allowlist. The SubAgent runs the supplied \
58 prompt to completion under the parent's permissions envelope and \
59 returns its response. Use for focused subtasks (research lookup, \
60 multi-step reasoning, etc.) that should not pollute this agent's main \
61 conversation history. Cost-aware: each SubAgent run is a full agent \
62 loop and consumes provider tokens."
63 }
64
65 fn parameters_schema(&self) -> serde_json::Value {
66 json!({
67 "type": "object",
68 "properties": {
69 "prompt": {
70 "type": "string",
71 "description": "The task or question for the SubAgent. Be specific and self-contained — the SubAgent does not see this conversation's history."
72 }
73 },
74 "required": ["prompt"]
75 })
76 }
77
78 async fn execute(&self, args: serde_json::Value) -> Result<ToolResult> {
79 if self.is_subagent_caller {
84 return Ok(ToolResult {
85 success: false,
86 output: String::new(),
87 error: Some(
88 "spawn_subagent: a subagent may not spawn its own subagents (depth-1 cap)"
89 .into(),
90 ),
91 });
92 }
93
94 let risk_profile = self.config.risk_profile_for_agent(&self.parent_alias);
102 if let Some(rp) = risk_profile {
103 let excluded = rp.excluded_tools.iter().any(|t| t == "spawn_subagent");
104 let allowed_when_listed = rp.allowed_tools.is_empty()
105 || rp.allowed_tools.iter().any(|t| t == "spawn_subagent");
106 if excluded || !allowed_when_listed {
107 return Ok(ToolResult {
108 success: false,
109 output: String::new(),
110 error: Some(format!(
111 "spawn_subagent: refused — agent '{}' risk_profile does not list spawn_subagent in allowed_tools",
112 self.parent_alias
113 )),
114 });
115 }
116 }
117
118 let prompt = match args
123 .get("prompt")
124 .and_then(|value| value.as_str())
125 .map(str::trim)
126 .filter(|value| !value.is_empty())
127 {
128 Some(p) => p.to_string(),
129 None => {
130 return Ok(ToolResult {
131 success: false,
132 output: String::new(),
133 error: Some("Missing or empty 'prompt' parameter".into()),
134 });
135 }
136 };
137
138 let subagent_ctx = match SubAgentSpawn::for_agent(&self.config, &self.parent_alias)
139 .and_then(|spawn| spawn.build(SubAgentOverrides::default()))
140 {
141 Ok(ctx) => ctx,
142 Err(e) => {
143 return Ok(ToolResult {
144 success: false,
145 output: String::new(),
146 error: Some(format!("subagent spawn failed: {e:#}")),
147 });
148 }
149 };
150
151 let run_id = uuid::Uuid::new_v4().to_string();
152
153 let temperature: Option<f64> = self
154 .config
155 .model_provider_for_agent(&self.parent_alias)
156 .and_then(|e| e.temperature);
157 let session_path = std::path::PathBuf::from(format!("subagent-{run_id}"));
158
159 let run_overrides = AgentRunOverrides {
165 security: Some(subagent_ctx.policy.clone()),
166 memory: None,
167 is_subagent: true,
168 };
169 let parent_alias = subagent_ctx.parent_alias.clone();
170 let run_result = Box::pin(scope!(
171 agent_alias: parent_alias,
172 session_key: run_id,
173 =>
174 crate::agent::run(
175 (*self.config).clone(),
176 &self.parent_alias,
177 Some(prompt),
178 None,
179 None,
180 temperature,
181 vec![],
182 false,
183 Some(session_path),
184 None,
185 run_overrides,
186 )
187 ))
188 .await;
189
190 match run_result {
191 Ok(response) => Ok(ToolResult {
192 success: true,
193 output: if response.trim().is_empty() {
194 "subagent completed without output".to_string()
195 } else {
196 response
197 },
198 error: None,
199 }),
200 Err(e) => Ok(ToolResult {
201 success: false,
202 output: String::new(),
203 error: Some(format!("subagent run failed: {e}")),
204 }),
205 }
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212 use zeroclaw_config::schema::{AliasedAgentConfig, Config, RiskProfileConfig};
213
214 fn config_with_agent(alias: &str) -> Config {
215 let mut config = Config::default();
216 config
217 .risk_profiles
218 .insert("default".to_string(), RiskProfileConfig::default());
219 config.agents.insert(
220 alias.to_string(),
221 AliasedAgentConfig {
222 risk_profile: "default".to_string(),
223 ..AliasedAgentConfig::default()
224 },
225 );
226 config
227 }
228
229 #[tokio::test]
230 async fn empty_or_missing_prompt_is_rejected() {
231 let tool = SpawnSubagentTool::new(Arc::new(config_with_agent("alpha")), "alpha");
232 for args in [json!({}), json!({ "prompt": " " })] {
233 let result = tool
234 .execute(args)
235 .await
236 .expect("execute returns Ok with structured failure");
237 assert!(!result.success);
238 assert!(
239 result
240 .error
241 .as_deref()
242 .unwrap_or_default()
243 .contains("prompt"),
244 "expected prompt-validation error, got: {:?}",
245 result.error
246 );
247 }
248 }
249
250 #[tokio::test]
251 async fn unknown_parent_alias_surfaces_spawn_failure() {
252 let tool = SpawnSubagentTool::new(Arc::new(Config::default()), "missing-alpha");
256 let result = tool
257 .execute(json!({ "prompt": "hello" }))
258 .await
259 .expect("execute returns Ok with structured failure");
260 assert!(!result.success);
261 assert!(
262 result
263 .error
264 .as_deref()
265 .unwrap_or_default()
266 .contains("subagent spawn failed"),
267 "expected spawn-failure error, got: {:?}",
268 result.error
269 );
270 }
271
272 #[tokio::test]
275 async fn refuses_recursive_spawn_when_caller_is_subagent() {
276 let tool = SpawnSubagentTool::new(Arc::new(config_with_agent("alpha")), "alpha")
277 .with_subagent_caller(true);
278 let result = tool
279 .execute(json!({ "prompt": "hello" }))
280 .await
281 .expect("execute returns Ok with structured failure");
282 assert!(!result.success);
283 let err = result.error.as_deref().unwrap_or_default();
284 assert!(
285 err.contains("subagent") && err.contains("depth"),
286 "expected depth-cap refusal mentioning subagent + depth, got: {err:?}"
287 );
288 }
289
290 #[tokio::test]
291 async fn allows_top_level_spawn_when_caller_is_not_subagent() {
292 let tool = SpawnSubagentTool::new(Arc::new(config_with_agent("alpha")), "alpha")
297 .with_subagent_caller(false);
298 let result = tool
299 .execute(json!({ "prompt": "hello" }))
300 .await
301 .expect("execute returns Ok");
302 let err = result.error.as_deref().unwrap_or_default();
303 assert!(
304 !(err.contains("subagent") && err.contains("depth")),
305 "top-level caller must not see the depth-cap refusal, got: {err:?}"
306 );
307 }
308
309 fn config_with_allowed_tools(alias: &str, allowed_tools: Vec<String>) -> Config {
312 let mut config = Config::default();
313 config.risk_profiles.insert(
314 "default".to_string(),
315 RiskProfileConfig {
316 allowed_tools,
317 ..RiskProfileConfig::default()
318 },
319 );
320 config.agents.insert(
321 alias.to_string(),
322 AliasedAgentConfig {
323 risk_profile: "default".to_string(),
324 ..AliasedAgentConfig::default()
325 },
326 );
327 config
328 }
329
330 #[tokio::test]
331 async fn refuses_when_risk_profile_excludes_spawn_subagent() {
332 let config = config_with_allowed_tools("alpha", vec!["shell".into()]);
336 let tool = SpawnSubagentTool::new(Arc::new(config), "alpha");
337 let result = tool
338 .execute(json!({ "prompt": "hello" }))
339 .await
340 .expect("execute returns Ok with structured failure");
341 assert!(!result.success);
342 let err = result.error.as_deref().unwrap_or_default();
343 assert!(
344 err.contains("risk_profile") && err.contains("spawn_subagent"),
345 "expected risk_profile-gate refusal naming spawn_subagent, got: {err:?}"
346 );
347 }
348
349 #[tokio::test]
350 async fn admits_when_risk_profile_lists_spawn_subagent() {
351 let config =
356 config_with_allowed_tools("alpha", vec!["spawn_subagent".into(), "shell".into()]);
357 let tool = SpawnSubagentTool::new(Arc::new(config), "alpha");
358 let result = tool
359 .execute(json!({ "prompt": "hello" }))
360 .await
361 .expect("execute returns Ok");
362 let err = result.error.as_deref().unwrap_or_default();
363 assert!(
364 !(err.contains("risk_profile") && err.contains("spawn_subagent")),
365 "spawn_subagent in allowed_tools must not trigger the gate refusal, got: {err:?}"
366 );
367 }
368
369 #[test]
379 fn agent_run_overrides_default_is_top_level() {
380 use crate::agent::loop_::AgentRunOverrides;
381 let overrides = AgentRunOverrides::default();
382 assert!(
383 !overrides.is_subagent,
384 "AgentRunOverrides::default().is_subagent must be false so cron paths inherit a top-level shape"
385 );
386 }
387
388 #[test]
399 fn spawn_subagent_dyn_tool_implements_attributable() {
400 use zeroclaw_api::attribution::{Attributable, Role, ToolKind};
401
402 let tool: Box<dyn Tool> = Box::new(SpawnSubagentTool::new(
403 Arc::new(config_with_agent("alpha")),
404 "alpha",
405 ));
406 assert_eq!(
407 Attributable::role(tool.as_ref()),
408 Role::Tool(ToolKind::SpawnSubagent),
409 "SpawnSubagentTool must surface its kind through the Tool trait object"
410 );
411 assert!(
412 !Attributable::alias(tool.as_ref()).is_empty(),
413 "Attributable::alias on a Tool must be non-empty so composite keys never produce `.<bare>`"
414 );
415 }
416}