1use crate::agent::loop_::AgentRunOverrides;
9use crate::security::SecurityPolicy;
10use crate::security::policy::ToolOperation;
11use crate::subagent::{SubAgentOverrides, SubAgentSpawn};
12use anyhow::Result;
13use async_trait::async_trait;
14use serde_json::json;
15use std::sync::Arc;
16use zeroclaw_api::tool::{Tool, ToolResult};
17use zeroclaw_config::schema::Config;
18use zeroclaw_log::scope;
19
20pub struct SpawnSubagentTool {
23 config: Arc<Config>,
24 parent_alias: String,
25 security: Arc<SecurityPolicy>,
32 is_subagent_caller: bool,
37}
38
39impl SpawnSubagentTool {
40 pub const NAME: &'static str = "spawn_subagent";
43
44 pub fn new(
45 config: Arc<Config>,
46 parent_alias: impl Into<String>,
47 security: Arc<SecurityPolicy>,
48 ) -> Self {
49 Self {
50 config,
51 parent_alias: parent_alias.into(),
52 security,
53 is_subagent_caller: false,
54 }
55 }
56
57 #[must_use]
61 pub fn with_subagent_caller(mut self, is_subagent_caller: bool) -> Self {
62 self.is_subagent_caller = is_subagent_caller;
63 self
64 }
65}
66
67#[async_trait]
68impl Tool for SpawnSubagentTool {
69 fn name(&self) -> &str {
70 Self::NAME
71 }
72
73 fn description(&self) -> &str {
74 "Spawn an ephemeral SubAgent that inherits this agent's identity, \
75 security policy, and memory allowlist. The SubAgent runs the supplied \
76 prompt to completion under the parent's permissions envelope and \
77 returns its response. Use for focused subtasks (research lookup, \
78 multi-step reasoning, etc.) that should not pollute this agent's main \
79 conversation history. Cost-aware: each SubAgent run is a full agent \
80 loop and consumes provider tokens."
81 }
82
83 fn parameters_schema(&self) -> serde_json::Value {
84 json!({
85 "type": "object",
86 "properties": {
87 "prompt": {
88 "type": "string",
89 "description": "The task or question for the SubAgent. Be specific and self-contained — the SubAgent does not see this conversation's history."
90 }
91 },
92 "required": ["prompt"]
93 })
94 }
95
96 async fn execute(&self, args: serde_json::Value) -> Result<ToolResult> {
97 if self.is_subagent_caller {
102 return Ok(ToolResult {
103 success: false,
104 output: String::new(),
105 error: Some(
106 "spawn_subagent: a subagent may not spawn its own subagents (depth-1 cap)"
107 .into(),
108 ),
109 });
110 }
111
112 let risk_profile = self.config.risk_profile_for_agent(&self.parent_alias);
120 if let Some(rp) = risk_profile {
121 let excluded = rp.excluded_tools.iter().any(|t| t == "spawn_subagent");
122 let allowed_when_listed = rp.allowed_tools.is_empty()
123 || rp.allowed_tools.iter().any(|t| t == "spawn_subagent");
124 if excluded || !allowed_when_listed {
125 return Ok(ToolResult {
126 success: false,
127 output: String::new(),
128 error: Some(format!(
129 "spawn_subagent: refused — agent '{}' risk_profile does not list spawn_subagent in allowed_tools",
130 self.parent_alias
131 )),
132 });
133 }
134 }
135
136 let prompt = match args
141 .get("prompt")
142 .and_then(|value| value.as_str())
143 .map(str::trim)
144 .filter(|value| !value.is_empty())
145 {
146 Some(p) => p.to_string(),
147 None => {
148 return Ok(ToolResult {
149 success: false,
150 output: String::new(),
151 error: Some("Missing or empty 'prompt' parameter".into()),
152 });
153 }
154 };
155
156 if let Err(error) = self
166 .security
167 .enforce_tool_operation(ToolOperation::Act, Self::NAME)
168 {
169 return Ok(ToolResult {
170 success: false,
171 output: String::new(),
172 error: Some(error),
173 });
174 }
175
176 let subagent_ctx = match SubAgentSpawn::for_agent(&self.config, &self.parent_alias)
177 .and_then(|spawn| spawn.build(SubAgentOverrides::default()))
178 {
179 Ok(ctx) => ctx,
180 Err(e) => {
181 return Ok(ToolResult {
182 success: false,
183 output: String::new(),
184 error: Some(format!("subagent spawn failed: {e:#}")),
185 });
186 }
187 };
188
189 let run_id = uuid::Uuid::new_v4().to_string();
190
191 let temperature: Option<f64> = self
192 .config
193 .model_provider_for_agent(&self.parent_alias)
194 .and_then(|e| e.temperature);
195 let session_path = std::path::PathBuf::from(format!("subagent-{run_id}"));
196
197 let run_overrides = AgentRunOverrides {
203 security: Some(subagent_ctx.policy.clone()),
204 memory: None,
205 is_subagent: true,
206 };
207 let parent_alias = subagent_ctx.parent_alias.clone();
208 let run_result = Box::pin(scope!(
209 agent_alias: parent_alias,
210 session_key: run_id,
211 =>
212 crate::agent::run(
213 (*self.config).clone(),
214 &self.parent_alias,
215 Some(prompt),
216 None,
217 None,
218 temperature,
219 vec![],
220 false,
221 Some(session_path),
222 None,
223 run_overrides,
224 )
225 ))
226 .await;
227
228 match run_result {
229 Ok(response) => Ok(ToolResult {
230 success: true,
231 output: if response.trim().is_empty() {
232 "subagent completed without output".to_string()
233 } else {
234 response
235 },
236 error: None,
237 }),
238 Err(e) => Ok(ToolResult {
239 success: false,
240 output: String::new(),
241 error: Some(format!("subagent run failed: {e}")),
242 }),
243 }
244 }
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250 use zeroclaw_config::schema::{AliasedAgentConfig, Config, RiskProfileConfig};
251
252 fn config_with_agent(alias: &str) -> Config {
253 let mut config = Config::default();
254 config
255 .risk_profiles
256 .insert("default".to_string(), RiskProfileConfig::default());
257 config.agents.insert(
258 alias.to_string(),
259 AliasedAgentConfig {
260 risk_profile: "default".to_string(),
261 ..AliasedAgentConfig::default()
262 },
263 );
264 config
265 }
266
267 #[tokio::test]
268 async fn empty_or_missing_prompt_is_rejected() {
269 let tool = SpawnSubagentTool::new(
270 Arc::new(config_with_agent("alpha")),
271 "alpha",
272 Arc::new(SecurityPolicy::default()),
273 );
274 for args in [json!({}), json!({ "prompt": " " })] {
275 let result = tool
276 .execute(args)
277 .await
278 .expect("execute returns Ok with structured failure");
279 assert!(!result.success);
280 assert!(
281 result
282 .error
283 .as_deref()
284 .unwrap_or_default()
285 .contains("prompt"),
286 "expected prompt-validation error, got: {:?}",
287 result.error
288 );
289 }
290 }
291
292 #[tokio::test]
293 async fn unknown_parent_alias_surfaces_spawn_failure() {
294 let tool = SpawnSubagentTool::new(
298 Arc::new(Config::default()),
299 "missing-alpha",
300 Arc::new(SecurityPolicy::default()),
301 );
302 let result = tool
303 .execute(json!({ "prompt": "hello" }))
304 .await
305 .expect("execute returns Ok with structured failure");
306 assert!(!result.success);
307 assert!(
308 result
309 .error
310 .as_deref()
311 .unwrap_or_default()
312 .contains("subagent spawn failed"),
313 "expected spawn-failure error, got: {:?}",
314 result.error
315 );
316 }
317
318 #[tokio::test]
321 async fn refuses_recursive_spawn_when_caller_is_subagent() {
322 let tool = SpawnSubagentTool::new(
323 Arc::new(config_with_agent("alpha")),
324 "alpha",
325 Arc::new(SecurityPolicy::default()),
326 )
327 .with_subagent_caller(true);
328 let result = tool
329 .execute(json!({ "prompt": "hello" }))
330 .await
331 .expect("execute returns Ok with structured failure");
332 assert!(!result.success);
333 let err = result.error.as_deref().unwrap_or_default();
334 assert!(
335 err.contains("subagent") && err.contains("depth"),
336 "expected depth-cap refusal mentioning subagent + depth, got: {err:?}"
337 );
338 }
339
340 #[tokio::test]
341 async fn allows_top_level_spawn_when_caller_is_not_subagent() {
342 let tool = SpawnSubagentTool::new(
347 Arc::new(config_with_agent("alpha")),
348 "alpha",
349 Arc::new(SecurityPolicy::default()),
350 )
351 .with_subagent_caller(false);
352 let result = tool
353 .execute(json!({ "prompt": "hello" }))
354 .await
355 .expect("execute returns Ok");
356 let err = result.error.as_deref().unwrap_or_default();
357 assert!(
358 !(err.contains("subagent") && err.contains("depth")),
359 "top-level caller must not see the depth-cap refusal, got: {err:?}"
360 );
361 }
362
363 fn config_with_allowed_tools(alias: &str, allowed_tools: Vec<String>) -> Config {
366 let mut config = Config::default();
367 config.risk_profiles.insert(
368 "default".to_string(),
369 RiskProfileConfig {
370 allowed_tools,
371 ..RiskProfileConfig::default()
372 },
373 );
374 config.agents.insert(
375 alias.to_string(),
376 AliasedAgentConfig {
377 risk_profile: "default".to_string(),
378 ..AliasedAgentConfig::default()
379 },
380 );
381 config
382 }
383
384 #[tokio::test]
385 async fn refuses_when_risk_profile_excludes_spawn_subagent() {
386 let config = config_with_allowed_tools("alpha", vec!["shell".into()]);
390 let tool = SpawnSubagentTool::new(
391 Arc::new(config),
392 "alpha",
393 Arc::new(SecurityPolicy::default()),
394 );
395 let result = tool
396 .execute(json!({ "prompt": "hello" }))
397 .await
398 .expect("execute returns Ok with structured failure");
399 assert!(!result.success);
400 let err = result.error.as_deref().unwrap_or_default();
401 assert!(
402 err.contains("risk_profile") && err.contains("spawn_subagent"),
403 "expected risk_profile-gate refusal naming spawn_subagent, got: {err:?}"
404 );
405 }
406
407 #[tokio::test]
408 async fn admits_when_risk_profile_lists_spawn_subagent() {
409 let config =
414 config_with_allowed_tools("alpha", vec!["spawn_subagent".into(), "shell".into()]);
415 let tool = SpawnSubagentTool::new(
416 Arc::new(config),
417 "alpha",
418 Arc::new(SecurityPolicy::default()),
419 );
420 let result = tool
421 .execute(json!({ "prompt": "hello" }))
422 .await
423 .expect("execute returns Ok");
424 let err = result.error.as_deref().unwrap_or_default();
425 assert!(
426 !(err.contains("risk_profile") && err.contains("spawn_subagent")),
427 "spawn_subagent in allowed_tools must not trigger the gate refusal, got: {err:?}"
428 );
429 }
430
431 #[tokio::test]
434 async fn repeated_spawns_blocked_once_action_budget_is_exhausted() {
435 let security = Arc::new(SecurityPolicy {
441 max_actions_per_hour: 2,
442 ..SecurityPolicy::default()
443 });
444 let tool = SpawnSubagentTool::new(
445 Arc::new(config_with_agent("alpha")),
446 "alpha",
447 Arc::clone(&security),
448 );
449
450 for attempt in 1..=2 {
451 let result = tool
452 .execute(json!({ "prompt": "same fan-out prompt" }))
453 .await
454 .expect("execute returns Ok");
455 let err = result.error.as_deref().unwrap_or_default();
456 assert!(
457 !err.contains("Rate limit exceeded"),
458 "attempt {attempt} within budget must not be rate-limited, got: {err:?}"
459 );
460 }
461
462 let result = tool
463 .execute(json!({ "prompt": "same fan-out prompt" }))
464 .await
465 .expect("execute returns Ok with structured failure");
466 assert!(!result.success);
467 assert!(
468 result
469 .error
470 .as_deref()
471 .unwrap_or_default()
472 .contains("Rate limit exceeded"),
473 "3rd launch attempt past a budget of 2 must be refused, got: {:?}",
474 result.error
475 );
476 }
477
478 #[tokio::test]
479 async fn validation_failures_do_not_consume_launch_budget() {
480 let security = Arc::new(SecurityPolicy {
484 max_actions_per_hour: 1,
485 ..SecurityPolicy::default()
486 });
487 let tool = SpawnSubagentTool::new(
488 Arc::new(config_with_agent("alpha")),
489 "alpha",
490 Arc::clone(&security),
491 );
492
493 for _ in 0..3 {
494 let result = tool
495 .execute(json!({ "prompt": " " }))
496 .await
497 .expect("execute returns Ok with structured failure");
498 assert!(
499 result
500 .error
501 .as_deref()
502 .unwrap_or_default()
503 .contains("prompt"),
504 "invalid-prompt refusal expected, got: {:?}",
505 result.error
506 );
507 }
508
509 let result = tool
510 .execute(json!({ "prompt": "valid" }))
511 .await
512 .expect("execute returns Ok");
513 let err = result.error.as_deref().unwrap_or_default();
514 assert!(
515 !err.contains("Rate limit exceeded"),
516 "validation failures must not have consumed the budget, got: {err:?}"
517 );
518 }
519
520 #[test]
530 fn agent_run_overrides_default_is_top_level() {
531 use crate::agent::loop_::AgentRunOverrides;
532 let overrides = AgentRunOverrides::default();
533 assert!(
534 !overrides.is_subagent,
535 "AgentRunOverrides::default().is_subagent must be false so cron paths inherit a top-level shape"
536 );
537 }
538
539 #[test]
550 fn spawn_subagent_dyn_tool_implements_attributable() {
551 use zeroclaw_api::attribution::{Attributable, Role, ToolKind};
552
553 let tool: Box<dyn Tool> = Box::new(SpawnSubagentTool::new(
554 Arc::new(config_with_agent("alpha")),
555 "alpha",
556 Arc::new(SecurityPolicy::default()),
557 ));
558 assert_eq!(
559 Attributable::role(tool.as_ref()),
560 Role::Tool(ToolKind::SpawnSubagent),
561 "SpawnSubagentTool must surface its kind through the Tool trait object"
562 );
563 assert!(
564 !Attributable::alias(tool.as_ref()).is_empty(),
565 "Attributable::alias on a Tool must be non-empty so composite keys never produce `.<bare>`"
566 );
567 }
568}