Skip to main content

supercode_harness/
subagents.rs

1//! P5-3 (COMPOSABLE-HARNESS-DESIGN.md §2 module 9 `subagents`: "D1 spawn
2//! tool; D3 sub-agents/named-defs/background+resume/teams; D5 subagent
3//! transcripts"; §2.1 D-1 "subagents → core.session(lineage), core.tools;
4//! background-mode → permissions.approvals"; §2.2 C6): the data shapes and
5//! pure-function resource-bound checks the spawn/join/background machinery
6//! in `crate::agent::Agent` builds on. Kept separate from `agent.rs` so the
7//! depth/concurrency-cap arithmetic and the lineage record shape are
8//! unit-testable without a full `Agent`/mock-`Provider` harness — the same
9//! "pure config → set, testable without the loop" precedent P3's
10//! `crate::modules` module documents for itself.
11//!
12//! **Activation.** Everything here is inert until `Agent` actually consults
13//! it, which only happens when `Config::subagents_enabled` is `true`
14//! (`capabilities.subagents.enabled`, default `false`) — so importing this
15//! module changes nothing for an agent that never turns the module on.
16
17use std::collections::BTreeMap;
18use std::sync::atomic::{AtomicUsize, Ordering};
19use std::sync::Arc;
20
21use serde::{Deserialize, Serialize};
22
23use crate::error::{Error, Result};
24
25/// BP-7 (catalog §4a "Named agent definitions as data": "Agent =
26/// prompt+model+tools+**permissions** in a file/config"): the permission
27/// bundle a named definition may carry, the component the shape was missing.
28///
29/// **Tightening only, by construction.** Every field here can make a child
30/// stricter than its parent and nothing here can make one looser: the
31/// approval/sandbox values are applied through the same rank comparison
32/// `configfile::clamp_project_permissions` uses for the untrusted project
33/// layer (a looser value is ignored, not honored), `auto_approved_tools` is
34/// INTERSECTED with the parent's, and `deny` is a union. So an agent
35/// definition — which may come from a `.claude/agents/*.md` file in the
36/// repo, i.e. from the same trust tier as a project config — can never be
37/// a privilege-escalation door.
38#[derive(Debug, Clone, Default, PartialEq, Eq)]
39pub struct AgentPermissions {
40    /// Approval policy for the child. Applied only when STRICTER than the
41    /// parent's.
42    pub approval: Option<crate::config::ApprovalPolicy>,
43    /// Filesystem sandbox tier for the child. Applied only when STRICTER
44    /// than the parent's.
45    pub sandbox: Option<crate::tools::SandboxPolicy>,
46    /// Tools this agent may run without an approval prompt. Intersected
47    /// with the parent's list — never a superset of it.
48    pub auto_approved_tools: Option<Vec<String>>,
49    /// Extra deny patterns, unioned onto the parent's.
50    pub deny: Vec<String>,
51}
52
53/// A named subagent type (`[capabilities.subagents.agents.<name>]`, §3.1) —
54/// the CC "subagent definition" shape: its own system prompt, an optionally
55/// NARROWED tool set (a spawned child's tool surface is always the
56/// intersection of the parent's already-enabled tools and this list — see
57/// `Agent::run_spawn_subagent`'s doc comment for why it can only narrow,
58/// never widen), and an optional model override.
59#[derive(Debug, Clone, Default, PartialEq, Eq)]
60pub struct NamedAgentDefinition {
61    /// The name the model passes as `spawn_subagent`'s `agent_type` arg.
62    pub name: String,
63    /// The child's system prompt (replaces the parent's).
64    pub system_prompt: String,
65    /// If `Some`, the child's enabled-tool set is narrowed to the
66    /// intersection of this list and the parent's own enabled tools.
67    /// `None` inherits the parent's tool set unchanged.
68    pub tools: Option<Vec<String>>,
69    /// If `Some`, the child runs this model instead of the parent's.
70    pub model: Option<String>,
71    /// BP-7: the per-agent permission bundle — see [`AgentPermissions`] for
72    /// the tightening-only guarantee. `None` inherits the parent's posture
73    /// verbatim, which is exactly the pre-BP-7 behavior.
74    pub permissions: Option<AgentPermissions>,
75}
76
77/// `capabilities.subagents.background_prompts` (§2.2 C6's schema value,
78/// §3.1): how a BACKGROUND child's tool-approval `Ask` decisions are
79/// resolved, since a detached background task cannot block on an
80/// interactive prompt it has no way to answer.
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum BackgroundPromptsPolicy {
83    /// Deny/allow-list only, no interactive handler ever installed on the
84    /// child — see `Agent::run_spawn_subagent`'s C6 wiring: with no
85    /// [`crate::permissions::PermissionsApprovalHandler`] installed, every
86    /// `Ask`-tier decision fail-closed-denies
87    /// ([`crate::permissions::approval::resolve_ask`]'s pre-existing "no
88    /// handler ⇒ deny" contract) — exactly "deny/allow-list, no
89    /// interactive asks": whatever the rule engine already resolves to
90    /// `Allow` proceeds; anything routed to `Ask` is refused, never asked.
91    AutoPolicy,
92    /// Approval requests are pushed onto the PARENT's queue
93    /// (`Agent::pending_child_approvals`) instead of blocking — the
94    /// request is recorded for later parent inspection, but still resolves
95    /// to `Deny` immediately (never hangs waiting for an answer that can't
96    /// arrive synchronously).
97    Parent,
98}
99
100impl BackgroundPromptsPolicy {
101    /// Parse the §3.1 schema string (`"auto_policy"` | `"parent"`).
102    pub fn parse(s: &str) -> Option<Self> {
103        match s {
104            "auto_policy" => Some(BackgroundPromptsPolicy::AutoPolicy),
105            "parent" => Some(BackgroundPromptsPolicy::Parent),
106            _ => None,
107        }
108    }
109
110    /// The exact schema string this variant parses from — round-trip
111    /// inverse of [`Self::parse`].
112    pub fn as_str(self) -> &'static str {
113        match self {
114            BackgroundPromptsPolicy::AutoPolicy => "auto_policy",
115            BackgroundPromptsPolicy::Parent => "parent",
116        }
117    }
118}
119
120/// Typed, lossless NATIVE-WRITE lineage record for a spawned child (§1.13;
121/// §5.2 P5 row 3: "native write side — store already parses CC sidechains +
122/// CX lineage on import"). Field names deliberately mirror the keys the
123/// IMPORT-side loaders already populate on
124/// [`crate::session::SessionMeta::parent_tool_use_id`]/
125/// [`crate::session::SessionMeta::lineage`] (see [`Self::to_lineage_map`]),
126/// so a natively-spawned session and an imported CC/CX one land in the same
127/// shape rather than two parallel formats a translator would need to know
128/// about separately.
129#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
130pub struct SubagentLineage {
131    /// This child's own agent id (the native analog of CC's `agentId`).
132    pub child_agent_id: String,
133    /// The parent session's store name/id, if the parent is itself a
134    /// stored session.
135    pub parent_session_id: Option<String>,
136    /// The `tool_use_id` of the parent's `spawn_subagent` call that created
137    /// this child — the native analog of CC's recovered
138    /// `parent_tool_use_id`.
139    pub parent_tool_use_id: String,
140    /// How deep in the spawn tree this child is (parent's own depth + 1;
141    /// a top-level agent is depth 0).
142    pub depth: usize,
143    /// The named `capabilities.subagents.agents.<name>` type spawned, if
144    /// any (`None` for an ad-hoc inline `system_prompt` spawn).
145    pub agent_type: Option<String>,
146    /// The task/prompt text the child was spawned with.
147    pub task: String,
148    /// Whether this child was spawned in background mode.
149    pub background: bool,
150    /// Unix-ms wall-clock time the spawn happened.
151    pub spawned_at_ms: i64,
152    /// The model the child ran.
153    pub model: String,
154}
155
156impl SubagentLineage {
157    /// The [`crate::session::SessionMeta::lineage`] map a native-spawned
158    /// child's [`crate::session::Session`] carries. `parent_thread_id` is
159    /// the SAME key [`crate::session::Session::reconstruct_tree`]'s
160    /// Codex-lineage nesting step already reads (see that function's doc
161    /// comment) — reusing it rather than minting a new key means a
162    /// native-spawned child nests under its parent via the identical
163    /// mechanism an imported Codex thread-tree does.
164    pub fn to_lineage_map(&self) -> BTreeMap<String, String> {
165        let mut m = BTreeMap::new();
166        if let Some(p) = &self.parent_session_id {
167            m.insert("parent_thread_id".to_string(), p.clone());
168            m.insert("parent_session_id".to_string(), p.clone());
169        }
170        m.insert("depth".to_string(), self.depth.to_string());
171        if let Some(t) = &self.agent_type {
172            m.insert("agent_role".to_string(), t.clone());
173        }
174        m.insert(
175            "thread_source".to_string(),
176            "supercode_native_spawn".to_string(),
177        );
178        m
179    }
180}
181
182/// A queued approval request from a `background_prompts = "parent"` child,
183/// surfaced via `Agent::pending_child_approvals` (§2.2 C6 "parent-surfaced
184/// queue"). This struct is ALWAYS a record for the parent to inspect/audit
185/// (never a pending decision the parent's answer changes retroactively) —
186/// but what actually answers the underlying call depends on which
187/// `PermissionsApprovalHandler` `Agent::run_spawn_subagent` installed for
188/// the child:
189/// - the DEFAULT [`ParentQueueApprovalHandler`] (no TUI factory installed)
190///   resolves every request to `Deny` immediately, THEN records it here —
191///   "queued" in name only, never actually blocking (P5-3's shipped,
192///   never-blocking posture, unchanged).
193/// - P5-4's `crate::tui::TuiChildApprovalHandler` (installed via
194///   [`crate::agent::Agent::set_child_approval_handler_factory`]) records
195///   the SAME entry here for audit purposes, but the underlying call
196///   genuinely BLOCKS until a TUI operator answers it — which may resolve
197///   `Allow`/`AllowForSession`, not only `Deny`. So: don't assume every
198///   entry here was already denied — check the actual outcome the caller
199///   observed (or the handler installed for this session) before treating
200///   this queue as "purely historical, all denied."
201///
202/// `outcome` is how that last warning is answered mechanically rather than by
203/// inspection: every handler records the decision it actually returned, so a
204/// reader (ORCH-9's `harness.v1.approvals.list`) never has to guess whether
205/// an entry is still waiting. `None` means the answer has not arrived yet.
206#[derive(Debug, Clone)]
207pub struct QueuedApproval {
208    /// Which child raised this request.
209    pub child_agent_id: String,
210    /// The tool it tried to call.
211    pub tool: String,
212    /// The canonicalized command/path subject, if any.
213    pub subject: Option<String>,
214    /// Unix-ms wall-clock time it was queued.
215    pub queued_at_ms: i64,
216    /// The decision the installed handler returned, once it has one.
217    pub outcome: Option<QueuedApprovalOutcome>,
218}
219
220/// The answer a queued child request eventually received.
221#[derive(Debug, Clone, Copy, PartialEq, Eq)]
222pub enum QueuedApprovalOutcome {
223    /// Allowed, once or for the session.
224    Allowed,
225    /// Refused.
226    Denied,
227}
228
229impl From<crate::permissions::ApprovalOutcome> for QueuedApprovalOutcome {
230    fn from(outcome: crate::permissions::ApprovalOutcome) -> Self {
231        match outcome {
232            crate::permissions::ApprovalOutcome::Deny => Self::Denied,
233            crate::permissions::ApprovalOutcome::Allow
234            | crate::permissions::ApprovalOutcome::AllowForSession => Self::Allowed,
235        }
236    }
237}
238
239/// Append one record to a parent's audit queue and return its stable index.
240///
241/// The queue is append-only for the life of the process, so the index stays
242/// valid for [`record_queued_outcome`]. `None` means the record could not be
243/// stored and no outcome should be written back.
244pub fn queue_approval(
245    queue: &Arc<std::sync::Mutex<Vec<QueuedApproval>>>,
246    record: QueuedApproval,
247) -> Option<usize> {
248    let mut queue = queue
249        .lock()
250        .unwrap_or_else(std::sync::PoisonError::into_inner);
251    queue.push(record);
252    Some(queue.len() - 1)
253}
254
255/// Write the decision back onto a record queued by [`queue_approval`].
256pub fn record_queued_outcome(
257    queue: &Arc<std::sync::Mutex<Vec<QueuedApproval>>>,
258    index: usize,
259    outcome: QueuedApprovalOutcome,
260) {
261    if let Some(record) = queue
262        .lock()
263        .unwrap_or_else(std::sync::PoisonError::into_inner)
264        .get_mut(index)
265    {
266        record.outcome = Some(outcome);
267    }
268}
269
270/// §2.2 C6 `background_prompts = "parent"`'s
271/// [`crate::permissions::PermissionsApprovalHandler`]: pushes every
272/// `Ask`-tier request onto the parent's queue
273/// ([`crate::agent::Agent::pending_child_approvals`]) and returns
274/// [`crate::permissions::ApprovalOutcome::Deny`] immediately — NEVER
275/// blocks, since a detached background child has no way to wait for an
276/// answer that can't arrive synchronously (the hard C6 requirement this
277/// whole policy exists to satisfy). "Surfaced to the parent" means exactly
278/// that: recorded for the parent to inspect/audit, not a live prompt the
279/// parent's later answer retroactively changes.
280pub struct ParentQueueApprovalHandler {
281    /// This child's own agent id, stamped on every queued record so the
282    /// parent can tell multiple background children's requests apart.
283    pub child_agent_id: String,
284    /// The shared queue — the SAME `Arc` as the parent's own
285    /// `pending_child_approvals` field, so a push here is immediately
286    /// visible to the parent.
287    pub queue: Arc<std::sync::Mutex<Vec<QueuedApproval>>>,
288}
289
290impl crate::permissions::PermissionsApprovalHandler for ParentQueueApprovalHandler {
291    fn ask(
292        &self,
293        req: &crate::permissions::ApprovalRequest,
294    ) -> crate::permissions::ApprovalOutcome {
295        // This handler denies by design, so the record is complete the
296        // moment it is written — it is never a request anyone can still
297        // answer.
298        let record = QueuedApproval {
299            child_agent_id: self.child_agent_id.clone(),
300            tool: req.tool.to_string(),
301            subject: req.subject.map(String::from),
302            queued_at_ms: now_ms(),
303            outcome: Some(QueuedApprovalOutcome::Denied),
304        };
305        if let Ok(mut q) = self.queue.lock() {
306            q.push(record);
307        }
308        crate::permissions::ApprovalOutcome::Deny
309    }
310}
311
312/// Local `now_ms` (mirrors `crate::agent`'s private helper of the same
313/// name/shape) — kept module-local rather than making `crate::agent`'s
314/// version `pub(crate)` for one caller.
315fn now_ms() -> i64 {
316    std::time::SystemTime::now()
317        .duration_since(std::time::UNIX_EPOCH)
318        .map(|d| d.as_millis() as i64)
319        .unwrap_or(0)
320}
321
322/// Fail-closed depth check (resource bound, build-brief "a parent spawning
323/// children spawning children… must not fork-bomb"): `Err` NAMES the
324/// exceeded cap rather than silently clamping the depth or panicking.
325/// `current_depth` is the SPAWNING agent's own depth (0 for a top-level
326/// agent); the new child would be spawned at `current_depth + 1`.
327pub fn check_depth(current_depth: usize, max_depth: usize) -> Result<()> {
328    if current_depth >= max_depth {
329        return Err(Error::SubagentDepthExceeded {
330            max_depth,
331            attempted_depth: current_depth + 1,
332        });
333    }
334    Ok(())
335}
336
337/// A held slot against a [`try_acquire`] concurrency gauge. Decrements the
338/// gauge on drop (including an early return, a panic-unwind, or the normal
339/// end of a background task's future) so a completed spawn always releases
340/// its slot — no separate "remember to release" call site to forget.
341pub struct ConcurrencyGuard(Arc<AtomicUsize>);
342
343impl Drop for ConcurrencyGuard {
344    fn drop(&mut self) {
345        self.0.fetch_sub(1, Ordering::SeqCst);
346    }
347}
348
349/// Fail-closed concurrency check-and-acquire (resource bound: "a max
350/// concurrent subagents… cap, fail-closed"). Atomic compare-exchange loop
351/// (not a check-then-increment race, which would let two racing spawns both
352/// pass a check against the same stale count) — `None` when
353/// `max_concurrent` subagents are already in flight anywhere in this spawn
354/// tree (the gauge is a single `Arc` shared root-to-leaf, per
355/// `crate::agent::Agent`'s doc comment on its own concurrency-gauge field),
356/// `Some(guard)` otherwise, with the slot already counted.
357pub fn try_acquire(gauge: &Arc<AtomicUsize>, max_concurrent: usize) -> Option<ConcurrencyGuard> {
358    let mut current = gauge.load(Ordering::SeqCst);
359    loop {
360        if current >= max_concurrent {
361            return None;
362        }
363        match gauge.compare_exchange(current, current + 1, Ordering::SeqCst, Ordering::SeqCst) {
364            Ok(_) => return Some(ConcurrencyGuard(gauge.clone())),
365            Err(actual) => current = actual,
366        }
367    }
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    #[test]
375    fn background_prompts_policy_round_trips_through_its_schema_string() {
376        for p in [
377            BackgroundPromptsPolicy::AutoPolicy,
378            BackgroundPromptsPolicy::Parent,
379        ] {
380            assert_eq!(BackgroundPromptsPolicy::parse(p.as_str()), Some(p));
381        }
382        assert_eq!(BackgroundPromptsPolicy::parse("bogus"), None);
383        assert_eq!(BackgroundPromptsPolicy::parse(""), None);
384    }
385
386    #[test]
387    fn check_depth_allows_up_to_the_cap_and_refuses_past_it() {
388        // max_depth = 2: a depth-0 or depth-1 spawner may spawn (landing the
389        // child at depth 1 or 2); a depth-2 spawner may not (would land the
390        // child at depth 3).
391        assert!(check_depth(0, 2).is_ok());
392        assert!(check_depth(1, 2).is_ok());
393        let err = check_depth(2, 2).unwrap_err();
394        match err {
395            Error::SubagentDepthExceeded {
396                max_depth,
397                attempted_depth,
398            } => {
399                assert_eq!(max_depth, 2);
400                assert_eq!(attempted_depth, 3);
401            }
402            other => panic!("expected SubagentDepthExceeded, got {other:?}"),
403        }
404    }
405
406    #[test]
407    fn check_depth_zero_cap_refuses_every_spawn() {
408        assert!(check_depth(0, 0).is_err());
409    }
410
411    #[test]
412    fn try_acquire_enforces_the_cap_and_release_frees_a_slot() {
413        let gauge = Arc::new(AtomicUsize::new(0));
414        let g1 = try_acquire(&gauge, 2).expect("first slot free");
415        let g2 = try_acquire(&gauge, 2).expect("second slot free");
416        assert!(
417            try_acquire(&gauge, 2).is_none(),
418            "cap of 2 must refuse a third concurrent holder"
419        );
420        drop(g1);
421        let g3 = try_acquire(&gauge, 2).expect("a released slot must be reusable");
422        drop(g2);
423        drop(g3);
424        assert_eq!(gauge.load(Ordering::SeqCst), 0);
425    }
426
427    #[test]
428    fn try_acquire_zero_cap_never_grants_a_slot() {
429        let gauge = Arc::new(AtomicUsize::new(0));
430        assert!(try_acquire(&gauge, 0).is_none());
431    }
432
433    #[test]
434    fn lineage_to_map_carries_parent_thread_id_alias_and_depth() {
435        let rec = SubagentLineage {
436            child_agent_id: "agent-1".to_string(),
437            parent_session_id: Some("sess-a".to_string()),
438            parent_tool_use_id: "call_1".to_string(),
439            depth: 1,
440            agent_type: Some("researcher".to_string()),
441            task: "look into X".to_string(),
442            background: false,
443            spawned_at_ms: 1_700_000_000_000,
444            model: "vendor/model-a".to_string(),
445        };
446        let m = rec.to_lineage_map();
447        assert_eq!(m.get("parent_thread_id"), Some(&"sess-a".to_string()));
448        assert_eq!(m.get("parent_session_id"), Some(&"sess-a".to_string()));
449        assert_eq!(m.get("depth"), Some(&"1".to_string()));
450        assert_eq!(m.get("agent_role"), Some(&"researcher".to_string()));
451        assert_eq!(
452            m.get("thread_source"),
453            Some(&"supercode_native_spawn".to_string())
454        );
455    }
456
457    #[test]
458    fn parent_queue_handler_never_hangs_and_records_the_request() {
459        use crate::permissions::{ApprovalOutcome, ApprovalRequest, PermissionsApprovalHandler};
460
461        let queue = Arc::new(std::sync::Mutex::new(Vec::new()));
462        let handler = ParentQueueApprovalHandler {
463            child_agent_id: "agent-bg-1".to_string(),
464            queue: queue.clone(),
465        };
466        let args = serde_json::json!({});
467        let outcome = handler.ask(&ApprovalRequest {
468            tool: "bash",
469            subject: Some("rm -rf /tmp/x"),
470            raw_args: &args,
471        });
472        // Never asks interactively — always resolves synchronously.
473        assert_eq!(outcome, ApprovalOutcome::Deny);
474        let recorded = queue.lock().unwrap();
475        assert_eq!(recorded.len(), 1);
476        assert_eq!(recorded[0].child_agent_id, "agent-bg-1");
477        assert_eq!(recorded[0].tool, "bash");
478        assert_eq!(recorded[0].subject.as_deref(), Some("rm -rf /tmp/x"));
479    }
480
481    #[test]
482    fn lineage_round_trips_through_json() {
483        let rec = SubagentLineage {
484            child_agent_id: "agent-2".to_string(),
485            parent_session_id: None,
486            parent_tool_use_id: "call_9".to_string(),
487            depth: 0,
488            agent_type: None,
489            task: "t".to_string(),
490            background: true,
491            spawned_at_ms: 42,
492            model: "vendor/model-b".to_string(),
493        };
494        let json = serde_json::to_string(&rec).unwrap();
495        let back: SubagentLineage = serde_json::from_str(&json).unwrap();
496        assert_eq!(rec, back);
497    }
498}