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::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
18/// Spawn an ephemeral SubAgent that inherits the parent agent's
19/// identity and runs a focused prompt under the same alias.
20pub struct SpawnSubagentTool {
21    config: Arc<Config>,
22    parent_alias: String,
23    /// `true` when this tool is registered inside a run that is itself
24    /// a SubAgent. Triggers a depth-1 cap refusal in `execute` before
25    /// any spawn work happens. Set by the agent loop from
26    /// `AgentRunOverrides.is_subagent` at registry construction time.
27    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    /// Mark this tool instance as belonging to a SubAgent's tool
40    /// registry. Triggers the depth-1 refusal on `execute`. The agent
41    /// loop sets this from `AgentRunOverrides.is_subagent`.
42    #[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        // Depth-1 cap: a SubAgent may not spawn its own subagents.
80        // The caller-side flag is set at registry construction time
81        // from `AgentRunOverrides.is_subagent`, so the refusal fires
82        // before any spawn work and before the risk_profile gate.
83        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        // risk_profile gate: a parent's risk_profile.allowed_tools that
95        // omits `spawn_subagent` must refuse pre-spawn. The agent-loop
96        // dispatch filter (apply_policy_tool_filter) already drops the
97        // tool from the registry when the policy excludes it, but this
98        // tool also runs from cron and other registry construction
99        // sites that don't currently apply the filter; refuse here so
100        // the gate is honored everywhere the tool is reachable.
101        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        // Argument validation surfaces as a structured `ToolResult`
119        // failure (matching the unknown-parent and run-failure shapes
120        // below) so the agent loop receives a uniform "tool reported
121        // failure" signal regardless of which step rejected the call.
122        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        // Pass the validated SubAgent context as run-time overrides so
160        // the subset-confirmed policy reaches the agent loop instead
161        // of being silently re-derived from config. `is_subagent: true`
162        // marks the child run so its own SpawnSubagentTool is
163        // registered with the depth-cap refusal armed.
164        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        // Parent alias that is not configured: SubAgentSpawn::for_agent
253        // returns Err, the tool reports a structured spawn failure
254        // (no panic, no recursion attempt).
255        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    // ── Depth-1 cap: subagent may not spawn its own subagent ──
273
274    #[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        // The top-level path may still fail later for unrelated reasons
293        // (e.g. no model provider configured in this minimal harness),
294        // but it MUST NOT trip the depth-cap refusal. Pin that the
295        // depth-cap error is absent.
296        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    // ── risk_profile.allowed_tools gates spawn_subagent ──
310
311    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        // Parent's risk_profile.allowed_tools omits "spawn_subagent" —
333        // the tool itself refuses pre-spawn so the dispatch-site filter
334        // doesn't have to be the only line of defense.
335        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        // When the parent's risk_profile.allowed_tools explicitly lists
352        // spawn_subagent, the tool does NOT short-circuit on the gate.
353        // It may still fail later for unrelated reasons; pin only that
354        // the gate refusal is absent.
355        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    // ── Cron path stays depth-0: AgentRunOverrides::default() ──
370    //
371    // The cron `JobType::Agent` site constructs `AgentRunOverrides`
372    // without explicit `is_subagent`, so a `false` Default is the
373    // load-bearing invariant. A future refactor flipping the default
374    // would silently turn every cron-launched agent into a depth-1
375    // subagent and break recursive-spawn guarantees from the other
376    // direction. Pin the default explicitly.
377
378    #[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    // ── Tool : Attributable contract ──────────────────────────
389    //
390    // Every Tool impl carries a structured role + alias the same way
391    // channels do, so log emissions, audit traces, and ops banners can
392    // tag tool activity with the same `<kind>.<alias>` composite shape
393    // they use for the rest of the runtime. The trait supertrait is
394    // the load-bearing piece: a `&dyn Tool` must coerce to a
395    // `&dyn Attributable` automatically. Without `Tool: Attributable`
396    // the line below does not compile.
397
398    #[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}