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}