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}