Skip to main content

zeroclaw_runtime/tools/
spawn_subagent.rs

1//! Agent-loop tool that spawns an ephemeral SubAgent inheriting the
2//! parent's identity, security policy, and memory allowlist, runs a
3//! focused prompt, and returns the response. Cron's `JobType::Agent`
4//! dispatch is the other SubAgent spawn site; both funnel through
5//! [`crate::subagent::SubAgentSpawn`] so permission inheritance,
6//! tracing-span shape, and audit attribution stay uniform.
7
8use 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
20/// Spawn an ephemeral SubAgent that inherits the parent agent's
21/// identity and runs a focused prompt under the same alias.
22pub struct SpawnSubagentTool {
23    config: Arc<Config>,
24    parent_alias: String,
25    /// The caller's live policy (the same `Arc` the agent loop and the
26    /// other acting tools share). Each launch attempt consumes one slot
27    /// from its action budget via `enforce_tool_operation(Act, ..)`,
28    /// mirroring `DelegateTool`, so the dedup exemption for re-entrant
29    /// agent tools cannot turn one model turn into unbounded child
30    /// agent starts.
31    security: Arc<SecurityPolicy>,
32    /// `true` when this tool is registered inside a run that is itself
33    /// a SubAgent. Triggers a depth-1 cap refusal in `execute` before
34    /// any spawn work happens. Set by the agent loop from
35    /// `AgentRunOverrides.is_subagent` at registry construction time.
36    is_subagent_caller: bool,
37}
38
39impl SpawnSubagentTool {
40    /// Canonical tool name. Referenced by `REENTRANT_AGENT_TOOLS` so a
41    /// rename cannot desync the two.
42    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    /// Mark this tool instance as belonging to a SubAgent's tool
58    /// registry. Triggers the depth-1 refusal on `execute`. The agent
59    /// loop sets this from `AgentRunOverrides.is_subagent`.
60    #[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        // Depth-1 cap: a SubAgent may not spawn its own subagents.
98        // The caller-side flag is set at registry construction time
99        // from `AgentRunOverrides.is_subagent`, so the refusal fires
100        // before any spawn work and before the risk_profile gate.
101        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        // risk_profile gate: a parent's risk_profile.allowed_tools that
113        // omits `spawn_subagent` must refuse pre-spawn. The agent-loop
114        // dispatch filter (apply_policy_tool_filter) already drops the
115        // tool from the registry when the policy excludes it, but this
116        // tool also runs from cron and other registry construction
117        // sites that don't currently apply the filter; refuse here so
118        // the gate is honored everywhere the tool is reachable.
119        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        // Argument validation surfaces as a structured `ToolResult`
137        // failure (matching the unknown-parent and run-failure shapes
138        // below) so the agent loop receives a uniform "tool reported
139        // failure" signal regardless of which step rejected the call.
140        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        // Launch-side budget gate: every spawn attempt past validation
157        // consumes one slot from the caller's shared action budget,
158        // mirroring DelegateTool (which validates target + depth, then
159        // calls enforce_tool_operation before spawning). The re-entrant
160        // dedup exemption means identical calls are not collapsed
161        // per-turn, so without this gate a single model turn could
162        // request unbounded child launches; with it, fan-out is bounded
163        // by `max_actions_per_hour` at launch time, not merely by work
164        // performed downstream.
165        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        // Pass the validated SubAgent context as run-time overrides so
198        // the subset-confirmed policy reaches the agent loop instead
199        // of being silently re-derived from config. `is_subagent: true`
200        // marks the child run so its own SpawnSubagentTool is
201        // registered with the depth-cap refusal armed.
202        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        // Parent alias that is not configured: SubAgentSpawn::for_agent
295        // returns Err, the tool reports a structured spawn failure
296        // (no panic, no recursion attempt).
297        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    // ── Depth-1 cap: subagent may not spawn its own subagent ──
319
320    #[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        // The top-level path may still fail later for unrelated reasons
343        // (e.g. no model provider configured in this minimal harness),
344        // but it MUST NOT trip the depth-cap refusal. Pin that the
345        // depth-cap error is absent.
346        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    // ── risk_profile.allowed_tools gates spawn_subagent ──
364
365    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        // Parent's risk_profile.allowed_tools omits "spawn_subagent" —
387        // the tool itself refuses pre-spawn so the dispatch-site filter
388        // doesn't have to be the only line of defense.
389        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        // When the parent's risk_profile.allowed_tools explicitly lists
410        // spawn_subagent, the tool does NOT short-circuit on the gate.
411        // It may still fail later for unrelated reasons; pin only that
412        // the gate refusal is absent.
413        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    // ── Launch-side fan-out bound: shared action budget ──
432
433    #[tokio::test]
434    async fn repeated_spawns_blocked_once_action_budget_is_exhausted() {
435        // The dedup exemption lets identical spawn_subagent calls all
436        // reach execute(); the launch-side budget gate must be what
437        // bounds them. With a budget of 2, the 3rd validated launch
438        // attempt is refused before any spawn work, regardless of
439        // whether the spawns themselves succeed.
440        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        // The budget gate sits after prompt validation: malformed calls
481        // must not burn launch slots (matching RateLimitedTool's
482        // only-work-consumes-budget semantics).
483        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    // ── Cron path stays depth-0: AgentRunOverrides::default() ──
521    //
522    // The cron `JobType::Agent` site constructs `AgentRunOverrides`
523    // without explicit `is_subagent`, so a `false` Default is the
524    // load-bearing invariant. A future refactor flipping the default
525    // would silently turn every cron-launched agent into a depth-1
526    // subagent and break recursive-spawn guarantees from the other
527    // direction. Pin the default explicitly.
528
529    #[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    // ── Tool : Attributable contract ──────────────────────────
540    //
541    // Every Tool impl carries a structured role + alias the same way
542    // channels do, so log emissions, audit traces, and ops banners can
543    // tag tool activity with the same `<kind>.<alias>` composite shape
544    // they use for the rest of the runtime. The trait supertrait is
545    // the load-bearing piece: a `&dyn Tool` must coerce to a
546    // `&dyn Attributable` automatically. Without `Tool: Attributable`
547    // the line below does not compile.
548
549    #[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}