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/// A named subagent type (`[capabilities.subagents.agents.<name>]`, §3.1) —
26/// the CC "subagent definition" shape: its own system prompt, an optionally
27/// NARROWED tool set (a spawned child's tool surface is always the
28/// intersection of the parent's already-enabled tools and this list — see
29/// `Agent::run_spawn_subagent`'s doc comment for why it can only narrow,
30/// never widen), and an optional model override.
31#[derive(Debug, Clone, Default, PartialEq, Eq)]
32pub struct NamedAgentDefinition {
33    /// The name the model passes as `spawn_subagent`'s `agent_type` arg.
34    pub name: String,
35    /// The child's system prompt (replaces the parent's).
36    pub system_prompt: String,
37    /// If `Some`, the child's enabled-tool set is narrowed to the
38    /// intersection of this list and the parent's own enabled tools.
39    /// `None` inherits the parent's tool set unchanged.
40    pub tools: Option<Vec<String>>,
41    /// If `Some`, the child runs this model instead of the parent's.
42    pub model: Option<String>,
43}
44
45/// `capabilities.subagents.background_prompts` (§2.2 C6's schema value,
46/// §3.1): how a BACKGROUND child's tool-approval `Ask` decisions are
47/// resolved, since a detached background task cannot block on an
48/// interactive prompt it has no way to answer.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum BackgroundPromptsPolicy {
51    /// Deny/allow-list only, no interactive handler ever installed on the
52    /// child — see `Agent::run_spawn_subagent`'s C6 wiring: with no
53    /// [`crate::permissions::PermissionsApprovalHandler`] installed, every
54    /// `Ask`-tier decision fail-closed-denies
55    /// ([`crate::permissions::approval::resolve_ask`]'s pre-existing "no
56    /// handler ⇒ deny" contract) — exactly "deny/allow-list, no
57    /// interactive asks": whatever the rule engine already resolves to
58    /// `Allow` proceeds; anything routed to `Ask` is refused, never asked.
59    AutoPolicy,
60    /// Approval requests are pushed onto the PARENT's queue
61    /// (`Agent::pending_child_approvals`) instead of blocking — the
62    /// request is recorded for later parent inspection, but still resolves
63    /// to `Deny` immediately (never hangs waiting for an answer that can't
64    /// arrive synchronously).
65    Parent,
66}
67
68impl BackgroundPromptsPolicy {
69    /// Parse the §3.1 schema string (`"auto_policy"` | `"parent"`).
70    pub fn parse(s: &str) -> Option<Self> {
71        match s {
72            "auto_policy" => Some(BackgroundPromptsPolicy::AutoPolicy),
73            "parent" => Some(BackgroundPromptsPolicy::Parent),
74            _ => None,
75        }
76    }
77
78    /// The exact schema string this variant parses from — round-trip
79    /// inverse of [`Self::parse`].
80    pub fn as_str(self) -> &'static str {
81        match self {
82            BackgroundPromptsPolicy::AutoPolicy => "auto_policy",
83            BackgroundPromptsPolicy::Parent => "parent",
84        }
85    }
86}
87
88/// Typed, lossless NATIVE-WRITE lineage record for a spawned child (§1.13;
89/// §5.2 P5 row 3: "native write side — store already parses CC sidechains +
90/// CX lineage on import"). Field names deliberately mirror the keys the
91/// IMPORT-side loaders already populate on
92/// [`crate::session::SessionMeta::parent_tool_use_id`]/
93/// [`crate::session::SessionMeta::lineage`] (see [`Self::to_lineage_map`]),
94/// so a natively-spawned session and an imported CC/CX one land in the same
95/// shape rather than two parallel formats a translator would need to know
96/// about separately.
97#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
98pub struct SubagentLineage {
99    /// This child's own agent id (the native analog of CC's `agentId`).
100    pub child_agent_id: String,
101    /// The parent session's store name/id, if the parent is itself a
102    /// stored session.
103    pub parent_session_id: Option<String>,
104    /// The `tool_use_id` of the parent's `spawn_subagent` call that created
105    /// this child — the native analog of CC's recovered
106    /// `parent_tool_use_id`.
107    pub parent_tool_use_id: String,
108    /// How deep in the spawn tree this child is (parent's own depth + 1;
109    /// a top-level agent is depth 0).
110    pub depth: usize,
111    /// The named `capabilities.subagents.agents.<name>` type spawned, if
112    /// any (`None` for an ad-hoc inline `system_prompt` spawn).
113    pub agent_type: Option<String>,
114    /// The task/prompt text the child was spawned with.
115    pub task: String,
116    /// Whether this child was spawned in background mode.
117    pub background: bool,
118    /// Unix-ms wall-clock time the spawn happened.
119    pub spawned_at_ms: i64,
120    /// The model the child ran.
121    pub model: String,
122}
123
124impl SubagentLineage {
125    /// The [`crate::session::SessionMeta::lineage`] map a native-spawned
126    /// child's [`crate::session::Session`] carries. `parent_thread_id` is
127    /// the SAME key [`crate::session::Session::reconstruct_tree`]'s
128    /// Codex-lineage nesting step already reads (see that function's doc
129    /// comment) — reusing it rather than minting a new key means a
130    /// native-spawned child nests under its parent via the identical
131    /// mechanism an imported Codex thread-tree does.
132    pub fn to_lineage_map(&self) -> BTreeMap<String, String> {
133        let mut m = BTreeMap::new();
134        if let Some(p) = &self.parent_session_id {
135            m.insert("parent_thread_id".to_string(), p.clone());
136            m.insert("parent_session_id".to_string(), p.clone());
137        }
138        m.insert("depth".to_string(), self.depth.to_string());
139        if let Some(t) = &self.agent_type {
140            m.insert("agent_role".to_string(), t.clone());
141        }
142        m.insert(
143            "thread_source".to_string(),
144            "supercode_native_spawn".to_string(),
145        );
146        m
147    }
148}
149
150/// A queued approval request from a `background_prompts = "parent"` child,
151/// surfaced via `Agent::pending_child_approvals` (§2.2 C6 "parent-surfaced
152/// queue"). This struct is ALWAYS a record for the parent to inspect/audit
153/// (never a pending decision the parent's answer changes retroactively) —
154/// but what actually answers the underlying call depends on which
155/// `PermissionsApprovalHandler` `Agent::run_spawn_subagent` installed for
156/// the child:
157/// - the DEFAULT [`ParentQueueApprovalHandler`] (no TUI factory installed)
158///   resolves every request to `Deny` immediately, THEN records it here —
159///   "queued" in name only, never actually blocking (P5-3's shipped,
160///   never-blocking posture, unchanged).
161/// - P5-4's `crate::tui::TuiChildApprovalHandler` (installed via
162///   [`crate::agent::Agent::set_child_approval_handler_factory`]) records
163///   the SAME entry here for audit purposes, but the underlying call
164///   genuinely BLOCKS until a TUI operator answers it — which may resolve
165///   `Allow`/`AllowForSession`, not only `Deny`. So: don't assume every
166///   entry here was already denied — check the actual outcome the caller
167///   observed (or the handler installed for this session) before treating
168///   this queue as "purely historical, all denied."
169#[derive(Debug, Clone)]
170pub struct QueuedApproval {
171    /// Which child raised this request.
172    pub child_agent_id: String,
173    /// The tool it tried to call.
174    pub tool: String,
175    /// The canonicalized command/path subject, if any.
176    pub subject: Option<String>,
177    /// Unix-ms wall-clock time it was queued.
178    pub queued_at_ms: i64,
179}
180
181/// §2.2 C6 `background_prompts = "parent"`'s
182/// [`crate::permissions::PermissionsApprovalHandler`]: pushes every
183/// `Ask`-tier request onto the parent's queue
184/// ([`crate::agent::Agent::pending_child_approvals`]) and returns
185/// [`crate::permissions::ApprovalOutcome::Deny`] immediately — NEVER
186/// blocks, since a detached background child has no way to wait for an
187/// answer that can't arrive synchronously (the hard C6 requirement this
188/// whole policy exists to satisfy). "Surfaced to the parent" means exactly
189/// that: recorded for the parent to inspect/audit, not a live prompt the
190/// parent's later answer retroactively changes.
191pub struct ParentQueueApprovalHandler {
192    /// This child's own agent id, stamped on every queued record so the
193    /// parent can tell multiple background children's requests apart.
194    pub child_agent_id: String,
195    /// The shared queue — the SAME `Arc` as the parent's own
196    /// `pending_child_approvals` field, so a push here is immediately
197    /// visible to the parent.
198    pub queue: Arc<std::sync::Mutex<Vec<QueuedApproval>>>,
199}
200
201impl crate::permissions::PermissionsApprovalHandler for ParentQueueApprovalHandler {
202    fn ask(
203        &self,
204        req: &crate::permissions::ApprovalRequest,
205    ) -> crate::permissions::ApprovalOutcome {
206        let record = QueuedApproval {
207            child_agent_id: self.child_agent_id.clone(),
208            tool: req.tool.to_string(),
209            subject: req.subject.map(String::from),
210            queued_at_ms: now_ms(),
211        };
212        if let Ok(mut q) = self.queue.lock() {
213            q.push(record);
214        }
215        crate::permissions::ApprovalOutcome::Deny
216    }
217}
218
219/// Local `now_ms` (mirrors `crate::agent`'s private helper of the same
220/// name/shape) — kept module-local rather than making `crate::agent`'s
221/// version `pub(crate)` for one caller.
222fn now_ms() -> i64 {
223    std::time::SystemTime::now()
224        .duration_since(std::time::UNIX_EPOCH)
225        .map(|d| d.as_millis() as i64)
226        .unwrap_or(0)
227}
228
229/// Fail-closed depth check (resource bound, build-brief "a parent spawning
230/// children spawning children… must not fork-bomb"): `Err` NAMES the
231/// exceeded cap rather than silently clamping the depth or panicking.
232/// `current_depth` is the SPAWNING agent's own depth (0 for a top-level
233/// agent); the new child would be spawned at `current_depth + 1`.
234pub fn check_depth(current_depth: usize, max_depth: usize) -> Result<()> {
235    if current_depth >= max_depth {
236        return Err(Error::SubagentDepthExceeded {
237            max_depth,
238            attempted_depth: current_depth + 1,
239        });
240    }
241    Ok(())
242}
243
244/// A held slot against a [`try_acquire`] concurrency gauge. Decrements the
245/// gauge on drop (including an early return, a panic-unwind, or the normal
246/// end of a background task's future) so a completed spawn always releases
247/// its slot — no separate "remember to release" call site to forget.
248pub struct ConcurrencyGuard(Arc<AtomicUsize>);
249
250impl Drop for ConcurrencyGuard {
251    fn drop(&mut self) {
252        self.0.fetch_sub(1, Ordering::SeqCst);
253    }
254}
255
256/// Fail-closed concurrency check-and-acquire (resource bound: "a max
257/// concurrent subagents… cap, fail-closed"). Atomic compare-exchange loop
258/// (not a check-then-increment race, which would let two racing spawns both
259/// pass a check against the same stale count) — `None` when
260/// `max_concurrent` subagents are already in flight anywhere in this spawn
261/// tree (the gauge is a single `Arc` shared root-to-leaf, per
262/// `crate::agent::Agent`'s doc comment on its own concurrency-gauge field),
263/// `Some(guard)` otherwise, with the slot already counted.
264pub fn try_acquire(gauge: &Arc<AtomicUsize>, max_concurrent: usize) -> Option<ConcurrencyGuard> {
265    let mut current = gauge.load(Ordering::SeqCst);
266    loop {
267        if current >= max_concurrent {
268            return None;
269        }
270        match gauge.compare_exchange(current, current + 1, Ordering::SeqCst, Ordering::SeqCst) {
271            Ok(_) => return Some(ConcurrencyGuard(gauge.clone())),
272            Err(actual) => current = actual,
273        }
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    #[test]
282    fn background_prompts_policy_round_trips_through_its_schema_string() {
283        for p in [
284            BackgroundPromptsPolicy::AutoPolicy,
285            BackgroundPromptsPolicy::Parent,
286        ] {
287            assert_eq!(BackgroundPromptsPolicy::parse(p.as_str()), Some(p));
288        }
289        assert_eq!(BackgroundPromptsPolicy::parse("bogus"), None);
290        assert_eq!(BackgroundPromptsPolicy::parse(""), None);
291    }
292
293    #[test]
294    fn check_depth_allows_up_to_the_cap_and_refuses_past_it() {
295        // max_depth = 2: a depth-0 or depth-1 spawner may spawn (landing the
296        // child at depth 1 or 2); a depth-2 spawner may not (would land the
297        // child at depth 3).
298        assert!(check_depth(0, 2).is_ok());
299        assert!(check_depth(1, 2).is_ok());
300        let err = check_depth(2, 2).unwrap_err();
301        match err {
302            Error::SubagentDepthExceeded {
303                max_depth,
304                attempted_depth,
305            } => {
306                assert_eq!(max_depth, 2);
307                assert_eq!(attempted_depth, 3);
308            }
309            other => panic!("expected SubagentDepthExceeded, got {other:?}"),
310        }
311    }
312
313    #[test]
314    fn check_depth_zero_cap_refuses_every_spawn() {
315        assert!(check_depth(0, 0).is_err());
316    }
317
318    #[test]
319    fn try_acquire_enforces_the_cap_and_release_frees_a_slot() {
320        let gauge = Arc::new(AtomicUsize::new(0));
321        let g1 = try_acquire(&gauge, 2).expect("first slot free");
322        let g2 = try_acquire(&gauge, 2).expect("second slot free");
323        assert!(
324            try_acquire(&gauge, 2).is_none(),
325            "cap of 2 must refuse a third concurrent holder"
326        );
327        drop(g1);
328        let g3 = try_acquire(&gauge, 2).expect("a released slot must be reusable");
329        drop(g2);
330        drop(g3);
331        assert_eq!(gauge.load(Ordering::SeqCst), 0);
332    }
333
334    #[test]
335    fn try_acquire_zero_cap_never_grants_a_slot() {
336        let gauge = Arc::new(AtomicUsize::new(0));
337        assert!(try_acquire(&gauge, 0).is_none());
338    }
339
340    #[test]
341    fn lineage_to_map_carries_parent_thread_id_alias_and_depth() {
342        let rec = SubagentLineage {
343            child_agent_id: "agent-1".to_string(),
344            parent_session_id: Some("sess-a".to_string()),
345            parent_tool_use_id: "call_1".to_string(),
346            depth: 1,
347            agent_type: Some("researcher".to_string()),
348            task: "look into X".to_string(),
349            background: false,
350            spawned_at_ms: 1_700_000_000_000,
351            model: "vendor/model-a".to_string(),
352        };
353        let m = rec.to_lineage_map();
354        assert_eq!(m.get("parent_thread_id"), Some(&"sess-a".to_string()));
355        assert_eq!(m.get("parent_session_id"), Some(&"sess-a".to_string()));
356        assert_eq!(m.get("depth"), Some(&"1".to_string()));
357        assert_eq!(m.get("agent_role"), Some(&"researcher".to_string()));
358        assert_eq!(
359            m.get("thread_source"),
360            Some(&"supercode_native_spawn".to_string())
361        );
362    }
363
364    #[test]
365    fn parent_queue_handler_never_hangs_and_records_the_request() {
366        use crate::permissions::{ApprovalOutcome, ApprovalRequest, PermissionsApprovalHandler};
367
368        let queue = Arc::new(std::sync::Mutex::new(Vec::new()));
369        let handler = ParentQueueApprovalHandler {
370            child_agent_id: "agent-bg-1".to_string(),
371            queue: queue.clone(),
372        };
373        let args = serde_json::json!({});
374        let outcome = handler.ask(&ApprovalRequest {
375            tool: "bash",
376            subject: Some("rm -rf /tmp/x"),
377            raw_args: &args,
378        });
379        // Never asks interactively — always resolves synchronously.
380        assert_eq!(outcome, ApprovalOutcome::Deny);
381        let recorded = queue.lock().unwrap();
382        assert_eq!(recorded.len(), 1);
383        assert_eq!(recorded[0].child_agent_id, "agent-bg-1");
384        assert_eq!(recorded[0].tool, "bash");
385        assert_eq!(recorded[0].subject.as_deref(), Some("rm -rf /tmp/x"));
386    }
387
388    #[test]
389    fn lineage_round_trips_through_json() {
390        let rec = SubagentLineage {
391            child_agent_id: "agent-2".to_string(),
392            parent_session_id: None,
393            parent_tool_use_id: "call_9".to_string(),
394            depth: 0,
395            agent_type: None,
396            task: "t".to_string(),
397            background: true,
398            spawned_at_ms: 42,
399            model: "vendor/model-b".to_string(),
400        };
401        let json = serde_json::to_string(&rec).unwrap();
402        let back: SubagentLineage = serde_json::from_str(&json).unwrap();
403        assert_eq!(rec, back);
404    }
405}