supercode-harness 0.5.45

The optional native Volter Harness agent and tool harness
Documentation
//! P5-3 (COMPOSABLE-HARNESS-DESIGN.md §2 module 9 `subagents`: "D1 spawn
//! tool; D3 sub-agents/named-defs/background+resume/teams; D5 subagent
//! transcripts"; §2.1 D-1 "subagents → core.session(lineage), core.tools;
//! background-mode → permissions.approvals"; §2.2 C6): the data shapes and
//! pure-function resource-bound checks the spawn/join/background machinery
//! in `crate::agent::Agent` builds on. Kept separate from `agent.rs` so the
//! depth/concurrency-cap arithmetic and the lineage record shape are
//! unit-testable without a full `Agent`/mock-`Provider` harness — the same
//! "pure config → set, testable without the loop" precedent P3's
//! `crate::modules` module documents for itself.
//!
//! **Activation.** Everything here is inert until `Agent` actually consults
//! it, which only happens when `Config::subagents_enabled` is `true`
//! (`capabilities.subagents.enabled`, default `false`) — so importing this
//! module changes nothing for an agent that never turns the module on.

use std::collections::BTreeMap;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;

use serde::{Deserialize, Serialize};

use crate::error::{Error, Result};

/// BP-7 (catalog §4a "Named agent definitions as data": "Agent =
/// prompt+model+tools+**permissions** in a file/config"): the permission
/// bundle a named definition may carry, the component the shape was missing.
///
/// **Tightening only, by construction.** Every field here can make a child
/// stricter than its parent and nothing here can make one looser: the
/// approval/sandbox values are applied through the same rank comparison
/// `configfile::clamp_project_permissions` uses for the untrusted project
/// layer (a looser value is ignored, not honored), `auto_approved_tools` is
/// INTERSECTED with the parent's, and `deny` is a union. So an agent
/// definition — which may come from a `.claude/agents/*.md` file in the
/// repo, i.e. from the same trust tier as a project config — can never be
/// a privilege-escalation door.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AgentPermissions {
    /// Approval policy for the child. Applied only when STRICTER than the
    /// parent's.
    pub approval: Option<crate::config::ApprovalPolicy>,
    /// Filesystem sandbox tier for the child. Applied only when STRICTER
    /// than the parent's.
    pub sandbox: Option<crate::tools::SandboxPolicy>,
    /// Tools this agent may run without an approval prompt. Intersected
    /// with the parent's list — never a superset of it.
    pub auto_approved_tools: Option<Vec<String>>,
    /// Extra deny patterns, unioned onto the parent's.
    pub deny: Vec<String>,
}

/// A named subagent type (`[capabilities.subagents.agents.<name>]`, §3.1) —
/// the CC "subagent definition" shape: its own system prompt, an optionally
/// NARROWED tool set (a spawned child's tool surface is always the
/// intersection of the parent's already-enabled tools and this list — see
/// `Agent::run_spawn_subagent`'s doc comment for why it can only narrow,
/// never widen), and an optional model override.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct NamedAgentDefinition {
    /// The name the model passes as `spawn_subagent`'s `agent_type` arg.
    pub name: String,
    /// The child's system prompt (replaces the parent's).
    pub system_prompt: String,
    /// If `Some`, the child's enabled-tool set is narrowed to the
    /// intersection of this list and the parent's own enabled tools.
    /// `None` inherits the parent's tool set unchanged.
    pub tools: Option<Vec<String>>,
    /// If `Some`, the child runs this model instead of the parent's.
    pub model: Option<String>,
    /// BP-7: the per-agent permission bundle — see [`AgentPermissions`] for
    /// the tightening-only guarantee. `None` inherits the parent's posture
    /// verbatim, which is exactly the pre-BP-7 behavior.
    pub permissions: Option<AgentPermissions>,
}

/// `capabilities.subagents.background_prompts` (§2.2 C6's schema value,
/// §3.1): how a BACKGROUND child's tool-approval `Ask` decisions are
/// resolved, since a detached background task cannot block on an
/// interactive prompt it has no way to answer.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BackgroundPromptsPolicy {
    /// Deny/allow-list only, no interactive handler ever installed on the
    /// child — see `Agent::run_spawn_subagent`'s C6 wiring: with no
    /// [`crate::permissions::PermissionsApprovalHandler`] installed, every
    /// `Ask`-tier decision fail-closed-denies
    /// ([`crate::permissions::approval::resolve_ask`]'s pre-existing "no
    /// handler ⇒ deny" contract) — exactly "deny/allow-list, no
    /// interactive asks": whatever the rule engine already resolves to
    /// `Allow` proceeds; anything routed to `Ask` is refused, never asked.
    AutoPolicy,
    /// Approval requests are pushed onto the PARENT's queue
    /// (`Agent::pending_child_approvals`) instead of blocking — the
    /// request is recorded for later parent inspection, but still resolves
    /// to `Deny` immediately (never hangs waiting for an answer that can't
    /// arrive synchronously).
    Parent,
}

impl BackgroundPromptsPolicy {
    /// Parse the §3.1 schema string (`"auto_policy"` | `"parent"`).
    pub fn parse(s: &str) -> Option<Self> {
        match s {
            "auto_policy" => Some(BackgroundPromptsPolicy::AutoPolicy),
            "parent" => Some(BackgroundPromptsPolicy::Parent),
            _ => None,
        }
    }

    /// The exact schema string this variant parses from — round-trip
    /// inverse of [`Self::parse`].
    pub fn as_str(self) -> &'static str {
        match self {
            BackgroundPromptsPolicy::AutoPolicy => "auto_policy",
            BackgroundPromptsPolicy::Parent => "parent",
        }
    }
}

/// Typed, lossless NATIVE-WRITE lineage record for a spawned child (§1.13;
/// §5.2 P5 row 3: "native write side — store already parses CC sidechains +
/// CX lineage on import"). Field names deliberately mirror the keys the
/// IMPORT-side loaders already populate on
/// [`supercode_interchange::session::SessionMeta::parent_tool_use_id`]/
/// [`supercode_interchange::session::SessionMeta::lineage`] (see [`Self::to_lineage_map`]),
/// so a natively-spawned session and an imported CC/CX one land in the same
/// shape rather than two parallel formats a translator would need to know
/// about separately.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SubagentLineage {
    /// This child's own agent id (the native analog of CC's `agentId`).
    pub child_agent_id: String,
    /// The parent session's store name/id, if the parent is itself a
    /// stored session.
    pub parent_session_id: Option<String>,
    /// The `tool_use_id` of the parent's `spawn_subagent` call that created
    /// this child — the native analog of CC's recovered
    /// `parent_tool_use_id`.
    pub parent_tool_use_id: String,
    /// How deep in the spawn tree this child is (parent's own depth + 1;
    /// a top-level agent is depth 0).
    pub depth: usize,
    /// The named `capabilities.subagents.agents.<name>` type spawned, if
    /// any (`None` for an ad-hoc inline `system_prompt` spawn).
    pub agent_type: Option<String>,
    /// The task/prompt text the child was spawned with.
    pub task: String,
    /// Whether this child was spawned in background mode.
    pub background: bool,
    /// Unix-ms wall-clock time the spawn happened.
    pub spawned_at_ms: i64,
    /// The model the child ran.
    pub model: String,
}

impl SubagentLineage {
    /// The [`supercode_interchange::session::SessionMeta::lineage`] map a native-spawned
    /// child's [`supercode_interchange::session::Session`] carries. `parent_thread_id` is
    /// the SAME key [`supercode_interchange::session::Session::reconstruct_tree`]'s
    /// Codex-lineage nesting step already reads (see that function's doc
    /// comment) — reusing it rather than minting a new key means a
    /// native-spawned child nests under its parent via the identical
    /// mechanism an imported Codex thread-tree does.
    pub fn to_lineage_map(&self) -> BTreeMap<String, String> {
        let mut m = BTreeMap::new();
        if let Some(p) = &self.parent_session_id {
            m.insert("parent_thread_id".to_string(), p.clone());
            m.insert("parent_session_id".to_string(), p.clone());
        }
        m.insert("depth".to_string(), self.depth.to_string());
        if let Some(t) = &self.agent_type {
            m.insert("agent_role".to_string(), t.clone());
        }
        m.insert(
            "thread_source".to_string(),
            "supercode_native_spawn".to_string(),
        );
        m
    }
}

/// A queued approval request from a `background_prompts = "parent"` child,
/// surfaced via `Agent::pending_child_approvals` (§2.2 C6 "parent-surfaced
/// queue"). This struct is ALWAYS a record for the parent to inspect/audit
/// (never a pending decision the parent's answer changes retroactively) —
/// but what actually answers the underlying call depends on which
/// `PermissionsApprovalHandler` `Agent::run_spawn_subagent` installed for
/// the child:
/// - the DEFAULT [`ParentQueueApprovalHandler`] (no TUI factory installed)
///   resolves every request to `Deny` immediately, THEN records it here —
///   "queued" in name only, never actually blocking (P5-3's shipped,
///   never-blocking posture, unchanged).
/// - P5-4's `crate::tui::TuiChildApprovalHandler` (installed via
///   [`crate::agent::Agent::set_child_approval_handler_factory`]) records
///   the SAME entry here for audit purposes, but the underlying call
///   genuinely BLOCKS until a TUI operator answers it — which may resolve
///   `Allow`/`AllowForSession`, not only `Deny`. So: don't assume every
///   entry here was already denied — check the actual outcome the caller
///   observed (or the handler installed for this session) before treating
///   this queue as "purely historical, all denied."
///
/// `outcome` is how that last warning is answered mechanically rather than by
/// inspection: every handler records the decision it actually returned, so a
/// reader (ORCH-9's `harness.v1.approvals.list`) never has to guess whether
/// an entry is still waiting. `None` means the answer has not arrived yet.
#[derive(Debug, Clone)]
pub struct QueuedApproval {
    /// Which child raised this request.
    pub child_agent_id: String,
    /// The tool it tried to call.
    pub tool: String,
    /// The canonicalized command/path subject, if any.
    pub subject: Option<String>,
    /// Unix-ms wall-clock time it was queued.
    pub queued_at_ms: i64,
    /// The decision the installed handler returned, once it has one.
    pub outcome: Option<QueuedApprovalOutcome>,
}

/// The answer a queued child request eventually received.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QueuedApprovalOutcome {
    /// Allowed, once or for the session.
    Allowed,
    /// Refused.
    Denied,
}

impl From<crate::permissions::ApprovalOutcome> for QueuedApprovalOutcome {
    fn from(outcome: crate::permissions::ApprovalOutcome) -> Self {
        match outcome {
            crate::permissions::ApprovalOutcome::Deny => Self::Denied,
            crate::permissions::ApprovalOutcome::Allow
            | crate::permissions::ApprovalOutcome::AllowForSession => Self::Allowed,
        }
    }
}

/// Append one record to a parent's audit queue and return its stable index.
///
/// The queue is append-only for the life of the process, so the index stays
/// valid for [`record_queued_outcome`]. `None` means the record could not be
/// stored and no outcome should be written back.
pub fn queue_approval(
    queue: &Arc<std::sync::Mutex<Vec<QueuedApproval>>>,
    record: QueuedApproval,
) -> Option<usize> {
    let mut queue = queue
        .lock()
        .unwrap_or_else(std::sync::PoisonError::into_inner);
    queue.push(record);
    Some(queue.len() - 1)
}

/// Write the decision back onto a record queued by [`queue_approval`].
pub fn record_queued_outcome(
    queue: &Arc<std::sync::Mutex<Vec<QueuedApproval>>>,
    index: usize,
    outcome: QueuedApprovalOutcome,
) {
    if let Some(record) = queue
        .lock()
        .unwrap_or_else(std::sync::PoisonError::into_inner)
        .get_mut(index)
    {
        record.outcome = Some(outcome);
    }
}

/// §2.2 C6 `background_prompts = "parent"`'s
/// [`crate::permissions::PermissionsApprovalHandler`]: pushes every
/// `Ask`-tier request onto the parent's queue
/// ([`crate::agent::Agent::pending_child_approvals`]) and returns
/// [`crate::permissions::ApprovalOutcome::Deny`] immediately — NEVER
/// blocks, since a detached background child has no way to wait for an
/// answer that can't arrive synchronously (the hard C6 requirement this
/// whole policy exists to satisfy). "Surfaced to the parent" means exactly
/// that: recorded for the parent to inspect/audit, not a live prompt the
/// parent's later answer retroactively changes.
pub struct ParentQueueApprovalHandler {
    /// This child's own agent id, stamped on every queued record so the
    /// parent can tell multiple background children's requests apart.
    pub child_agent_id: String,
    /// The shared queue — the SAME `Arc` as the parent's own
    /// `pending_child_approvals` field, so a push here is immediately
    /// visible to the parent.
    pub queue: Arc<std::sync::Mutex<Vec<QueuedApproval>>>,
}

impl crate::permissions::PermissionsApprovalHandler for ParentQueueApprovalHandler {
    fn ask(
        &self,
        req: &crate::permissions::ApprovalRequest,
    ) -> crate::permissions::ApprovalOutcome {
        // This handler denies by design, so the record is complete the
        // moment it is written — it is never a request anyone can still
        // answer.
        let record = QueuedApproval {
            child_agent_id: self.child_agent_id.clone(),
            tool: req.tool.to_string(),
            subject: req.subject.map(String::from),
            queued_at_ms: now_ms(),
            outcome: Some(QueuedApprovalOutcome::Denied),
        };
        if let Ok(mut q) = self.queue.lock() {
            q.push(record);
        }
        crate::permissions::ApprovalOutcome::Deny
    }
}

/// Local `now_ms` (mirrors `crate::agent`'s private helper of the same
/// name/shape) — kept module-local rather than making `crate::agent`'s
/// version `pub(crate)` for one caller.
fn now_ms() -> i64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as i64)
        .unwrap_or(0)
}

/// Fail-closed depth check (resource bound, build-brief "a parent spawning
/// children spawning children… must not fork-bomb"): `Err` NAMES the
/// exceeded cap rather than silently clamping the depth or panicking.
/// `current_depth` is the SPAWNING agent's own depth (0 for a top-level
/// agent); the new child would be spawned at `current_depth + 1`.
pub fn check_depth(current_depth: usize, max_depth: usize) -> Result<()> {
    if current_depth >= max_depth {
        return Err(Error::SubagentDepthExceeded {
            max_depth,
            attempted_depth: current_depth + 1,
        });
    }
    Ok(())
}

/// A held slot against a [`try_acquire`] concurrency gauge. Decrements the
/// gauge on drop (including an early return, a panic-unwind, or the normal
/// end of a background task's future) so a completed spawn always releases
/// its slot — no separate "remember to release" call site to forget.
pub struct ConcurrencyGuard(Arc<AtomicUsize>);

impl Drop for ConcurrencyGuard {
    fn drop(&mut self) {
        self.0.fetch_sub(1, Ordering::SeqCst);
    }
}

/// Fail-closed concurrency check-and-acquire (resource bound: "a max
/// concurrent subagents… cap, fail-closed"). Atomic compare-exchange loop
/// (not a check-then-increment race, which would let two racing spawns both
/// pass a check against the same stale count) — `None` when
/// `max_concurrent` subagents are already in flight anywhere in this spawn
/// tree (the gauge is a single `Arc` shared root-to-leaf, per
/// `crate::agent::Agent`'s doc comment on its own concurrency-gauge field),
/// `Some(guard)` otherwise, with the slot already counted.
pub fn try_acquire(gauge: &Arc<AtomicUsize>, max_concurrent: usize) -> Option<ConcurrencyGuard> {
    let mut current = gauge.load(Ordering::SeqCst);
    loop {
        if current >= max_concurrent {
            return None;
        }
        match gauge.compare_exchange(current, current + 1, Ordering::SeqCst, Ordering::SeqCst) {
            Ok(_) => return Some(ConcurrencyGuard(gauge.clone())),
            Err(actual) => current = actual,
        }
    }
}