Skip to main content

harn_vm/orchestration/policy/
nested_budget.rs

1//! Centralized nested-execution budget for capability policies.
2//!
3//! `CapabilityPolicy::recursion_limit` is treated as the *remaining*
4//! child-execution depth, not a static maximum. Entering a child
5//! execution consumes one slot off the parent's budget; the child
6//! receives `Some(n - 1)` in its effective policy. When the parent is
7//! already at `Some(0)`, the helper rejects the launch with a
8//! categorized [`crate::value::ErrorCategory::BudgetExceeded`] error
9//! that names the nested surface kind and the target label.
10//!
11//! All Harn-owned child execution surfaces — `agent_loop`,
12//! `sub_agent_run`, `spawn_agent` workers, workflow stage agent runs,
13//! and nested Harn invocations — route through [`enter_nested_execution_policy`]
14//! so the budget is checked + decremented exactly once per logical
15//! child execution, audited consistently, and the error surface is
16//! uniform.
17
18use super::{CapabilityPolicy, SandboxProfile};
19use crate::events::log_debug_meta;
20use crate::orchestration::{current_execution_policy, pop_execution_policy, push_execution_policy};
21use crate::value::{ErrorCategory, VmError, VmValue};
22
23/// Options-dict key for the nesting surface kind, read by
24/// [`enter_nested_execution_policy`] at agent_loop entry.
25pub const NESTED_KIND_OPTION_KEY: &str = "_nested_kind";
26/// Options-dict key for the nesting surface label, read by
27/// [`enter_nested_execution_policy`] at agent_loop entry.
28pub const NESTED_LABEL_OPTION_KEY: &str = "_nested_label";
29
30/// Categorizes the kind of nested execution surface for audit and
31/// error messaging. The Harn surfaces that decrement the budget pass
32/// the matching variant so users can tell which call exhausted the
33/// allowance.
34#[derive(Clone, Copy, Debug, PartialEq, Eq)]
35pub enum NestedExecutionKind {
36    /// A direct `agent_loop` invocation (top-level or nested).
37    AgentLoop,
38    /// A `sub_agent_run` foreground execution.
39    SubAgentRun,
40    /// A `spawn_agent` background worker about to run an agent loop.
41    SpawnAgent,
42    /// A workflow stage that launches agent work.
43    WorkflowStage,
44    /// A workflow execution started from inside another execution
45    /// (workflow-of-workflows / nested `workflow_execute`).
46    NestedWorkflow,
47    /// A nested Harn invocation from CLI/API (e.g., `harn run` inside
48    /// a parent policy scope, or a bridge/host re-entry).
49    NestedInvocation,
50}
51
52impl NestedExecutionKind {
53    pub fn as_str(self) -> &'static str {
54        match self {
55            Self::AgentLoop => "agent_loop",
56            Self::SubAgentRun => "sub_agent_run",
57            Self::SpawnAgent => "spawn_agent",
58            Self::WorkflowStage => "workflow_stage",
59            Self::NestedWorkflow => "nested_workflow",
60            Self::NestedInvocation => "nested_invocation",
61        }
62    }
63
64    /// Parse a kind string from an options dict; falls back to
65    /// [`Self::AgentLoop`] when the value is missing or unrecognized.
66    pub fn parse_or_default(value: Option<&str>) -> Self {
67        match value {
68            Some("agent_loop") => Self::AgentLoop,
69            Some("sub_agent_run") => Self::SubAgentRun,
70            Some("spawn_agent") => Self::SpawnAgent,
71            Some("workflow_stage") => Self::WorkflowStage,
72            Some("nested_workflow") => Self::NestedWorkflow,
73            Some("nested_invocation") => Self::NestedInvocation,
74            _ => Self::AgentLoop,
75        }
76    }
77}
78
79/// Outcome of deriving a child execution policy. The guard pops the
80/// pushed policy on drop; `parent_limit` / `child_limit` are preserved
81/// for trace metadata.
82#[derive(Debug)]
83pub struct NestedExecutionGuard {
84    pushed: bool,
85    /// Parent's `recursion_limit` at the time of the descent. `None`
86    /// means there was no Harn-side budget on the active stack.
87    pub parent_limit: Option<usize>,
88    /// `recursion_limit` that the child execution will observe.
89    pub child_limit: Option<usize>,
90    pub kind: NestedExecutionKind,
91    pub label: String,
92}
93
94impl Drop for NestedExecutionGuard {
95    fn drop(&mut self) {
96        if self.pushed {
97            pop_execution_policy();
98        }
99    }
100}
101
102/// Enter a child execution: validate the parent's recursion budget,
103/// decrement once for this descent, and push a policy carrier onto
104/// the thread-local execution policy stack. The guard pops it on drop.
105///
106/// The carrier inherits every field from the currently-active parent
107/// policy and only overrides `recursion_limit` with the decremented
108/// child budget. That preserves any tool / capability / side-effect /
109/// workspace ceiling the parent had established (e.g., a workflow
110/// stage's restrictive `CapabilityPolicy`) so the child agent's own
111/// `llm_call` and infrastructure builtins continue to see the parent's
112/// restrictions. When there is no parent on the stack, a top-level
113/// `agent_loop` still installs an empty-ceiling `os_hardened` carrier
114/// so its subprocess tools require OS confinement by default; other
115/// top-level nested surfaces keep the historical no-carrier behavior
116/// unless a recursion budget is requested.
117///
118/// The agent's own `options.policy` (with tool / capability / etc.
119/// ceilings) is intentionally *not* installed by this helper; that
120/// continues to flow through the per-tool-dispatch policy guard
121/// (`install_session_policy_guard`), which intersects with the current
122/// outer at every dispatch. Per-tool-dispatch intersections preserve
123/// the decremented budget because `CapabilityPolicy::intersect` takes
124/// the `min` of `recursion_limit` across both sides.
125pub fn enter_nested_execution_policy(
126    requested: Option<CapabilityPolicy>,
127    kind: NestedExecutionKind,
128    label: &str,
129) -> Result<NestedExecutionGuard, VmError> {
130    let parent = current_execution_policy();
131    let parent_limit = parent.as_ref().and_then(|p| p.recursion_limit);
132
133    if matches!(parent_limit, Some(0)) {
134        emit_descent_event(kind, label, parent_limit, None, true);
135        return Err(nested_budget_exhausted(kind, label));
136    }
137
138    let requested_limit = requested.as_ref().and_then(|p| p.recursion_limit);
139    let decremented_parent = parent_limit.map(|n| n - 1);
140    let child_limit = match (decremented_parent, requested_limit) {
141        (Some(a), Some(b)) => Some(a.min(b)),
142        (Some(a), None) => Some(a),
143        (None, Some(b)) => Some(b),
144        (None, None) => None,
145    };
146
147    emit_descent_event(kind, label, parent_limit, child_limit, false);
148
149    let top_level_agent_loop = parent.is_none() && matches!(kind, NestedExecutionKind::AgentLoop);
150    let pushed = if child_limit.is_some() || top_level_agent_loop {
151        let mut carrier = parent.unwrap_or_else(|| {
152            if top_level_agent_loop {
153                top_level_agent_loop_policy()
154            } else {
155                CapabilityPolicy::default()
156            }
157        });
158        carrier.recursion_limit = child_limit;
159        push_execution_policy(carrier);
160        true
161    } else {
162        false
163    };
164
165    Ok(NestedExecutionGuard {
166        pushed,
167        parent_limit,
168        child_limit,
169        kind,
170        label: label.to_string(),
171    })
172}
173
174fn top_level_agent_loop_policy() -> CapabilityPolicy {
175    CapabilityPolicy {
176        sandbox_profile: SandboxProfile::OsHardened,
177        ..CapabilityPolicy::default()
178    }
179}
180
181/// Tag an `agent_loop` options dict with the nested-execution kind and
182/// label so [`enter_nested_execution_policy`] picks up the right
183/// surface attribution at session init. Call sites that build options
184/// for downstream agent_loop invocations (sub_agent_run, workflow
185/// stages, spawn_agent worker setup) use this rather than rewriting
186/// the dict-insert pattern.
187pub fn annotate_nested_execution_options(
188    options: &mut crate::value::DictMap,
189    kind: NestedExecutionKind,
190    label: &str,
191) {
192    options.insert(
193        crate::value::intern_key(NESTED_KIND_OPTION_KEY),
194        VmValue::String(arcstr::ArcStr::from(kind.as_str().to_string())),
195    );
196    options.insert(
197        crate::value::intern_key(NESTED_LABEL_OPTION_KEY),
198        VmValue::String(arcstr::ArcStr::from(label.to_string())),
199    );
200}
201
202fn nested_budget_exhausted(kind: NestedExecutionKind, label: &str) -> VmError {
203    let label = if label.is_empty() { "<unnamed>" } else { label };
204    VmError::CategorizedError {
205        message: format!(
206            "nested execution budget exhausted before {}: {}",
207            kind.as_str(),
208            label
209        ),
210        category: ErrorCategory::BudgetExceeded,
211    }
212}
213
214fn emit_descent_event(
215    kind: NestedExecutionKind,
216    label: &str,
217    parent_limit: Option<usize>,
218    child_limit: Option<usize>,
219    rejected: bool,
220) {
221    let mut metadata = std::collections::BTreeMap::new();
222    metadata.insert(
223        "kind".to_string(),
224        serde_json::Value::String(kind.as_str().to_string()),
225    );
226    metadata.insert(
227        "label".to_string(),
228        serde_json::Value::String(label.to_string()),
229    );
230    metadata.insert(
231        "parent_recursion_limit".to_string(),
232        recursion_limit_to_json(parent_limit),
233    );
234    metadata.insert(
235        "child_recursion_limit".to_string(),
236        recursion_limit_to_json(child_limit),
237    );
238    metadata.insert("rejected".to_string(), serde_json::Value::Bool(rejected));
239    let message = if rejected {
240        format!(
241            "nested execution budget exhausted before {}: {}",
242            kind.as_str(),
243            label
244        )
245    } else {
246        format!("nested execution descent into {}: {}", kind.as_str(), label)
247    };
248    log_debug_meta("policy.nested_execution_descent", &message, metadata);
249}
250
251fn recursion_limit_to_json(value: Option<usize>) -> serde_json::Value {
252    match value {
253        Some(n) => serde_json::Value::Number(serde_json::Number::from(n)),
254        None => serde_json::Value::Null,
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261    use crate::orchestration::clear_execution_policy_stacks;
262
263    fn policy_with_limit(limit: Option<usize>) -> CapabilityPolicy {
264        CapabilityPolicy {
265            recursion_limit: limit,
266            ..Default::default()
267        }
268    }
269
270    #[test]
271    fn none_parent_preserves_requested_limit() {
272        clear_execution_policy_stacks();
273        let requested = Some(policy_with_limit(Some(3)));
274        let guard =
275            enter_nested_execution_policy(requested, NestedExecutionKind::AgentLoop, "session-a")
276                .unwrap();
277        assert_eq!(guard.parent_limit, None);
278        assert_eq!(guard.child_limit, Some(3));
279        assert_eq!(current_execution_policy().unwrap().recursion_limit, Some(3));
280        assert_eq!(
281            current_execution_policy().unwrap().sandbox_profile,
282            crate::orchestration::SandboxProfile::OsHardened
283        );
284        drop(guard);
285        assert!(current_execution_policy().is_none());
286    }
287
288    #[test]
289    fn some_one_allows_one_child_and_gives_child_zero() {
290        clear_execution_policy_stacks();
291        push_execution_policy(policy_with_limit(Some(1)));
292        let guard =
293            enter_nested_execution_policy(None, NestedExecutionKind::SubAgentRun, "child-1")
294                .unwrap();
295        assert_eq!(guard.parent_limit, Some(1));
296        assert_eq!(guard.child_limit, Some(0));
297        assert_eq!(current_execution_policy().unwrap().recursion_limit, Some(0));
298        drop(guard);
299        pop_execution_policy();
300    }
301
302    #[test]
303    fn some_zero_rejects_with_budget_exceeded() {
304        clear_execution_policy_stacks();
305        push_execution_policy(policy_with_limit(Some(0)));
306        let error =
307            enter_nested_execution_policy(None, NestedExecutionKind::AgentLoop, "research-worker")
308                .unwrap_err();
309        match error {
310            VmError::CategorizedError { message, category } => {
311                assert_eq!(category, ErrorCategory::BudgetExceeded);
312                assert!(
313                    message.contains("agent_loop"),
314                    "missing kind in message: {message}"
315                );
316                assert!(
317                    message.contains("research-worker"),
318                    "missing label in message: {message}"
319                );
320            }
321            other => panic!("expected CategorizedError, got {other:?}"),
322        }
323        pop_execution_policy();
324    }
325
326    #[test]
327    fn nested_chain_decrements_until_exhausted() {
328        clear_execution_policy_stacks();
329        let outer = enter_nested_execution_policy(
330            Some(policy_with_limit(Some(2))),
331            NestedExecutionKind::AgentLoop,
332            "outer",
333        )
334        .unwrap();
335        assert_eq!(outer.child_limit, Some(2));
336        let middle =
337            enter_nested_execution_policy(None, NestedExecutionKind::SubAgentRun, "middle")
338                .unwrap();
339        assert_eq!(middle.child_limit, Some(1));
340        let inner =
341            enter_nested_execution_policy(None, NestedExecutionKind::AgentLoop, "inner").unwrap();
342        assert_eq!(inner.child_limit, Some(0));
343        let exhausted =
344            enter_nested_execution_policy(None, NestedExecutionKind::SubAgentRun, "innermost")
345                .unwrap_err();
346        assert!(matches!(
347            exhausted,
348            VmError::CategorizedError {
349                category: ErrorCategory::BudgetExceeded,
350                ..
351            }
352        ));
353        drop(inner);
354        drop(middle);
355        drop(outer);
356    }
357
358    #[test]
359    fn requested_limit_caps_below_parent() {
360        clear_execution_policy_stacks();
361        push_execution_policy(policy_with_limit(Some(8)));
362        let guard = enter_nested_execution_policy(
363            Some(policy_with_limit(Some(2))),
364            NestedExecutionKind::WorkflowStage,
365            "stage-1",
366        )
367        .unwrap();
368        assert_eq!(guard.parent_limit, Some(8));
369        // Decremented parent (7) intersected with requested (2) → 2.
370        assert_eq!(guard.child_limit, Some(2));
371        drop(guard);
372        pop_execution_policy();
373    }
374
375    #[test]
376    fn none_parent_and_none_requested_pushes_no_policy() {
377        clear_execution_policy_stacks();
378        let guard =
379            enter_nested_execution_policy(None, NestedExecutionKind::NestedWorkflow, "wf-1")
380                .unwrap();
381        assert!(current_execution_policy().is_none());
382        assert_eq!(guard.parent_limit, None);
383        assert_eq!(guard.child_limit, None);
384        drop(guard);
385        assert!(current_execution_policy().is_none());
386    }
387
388    #[test]
389    fn top_level_agent_loop_pushes_os_hardened_carrier_without_budget() {
390        clear_execution_policy_stacks();
391        let guard =
392            enter_nested_execution_policy(None, NestedExecutionKind::AgentLoop, "session-secure")
393                .unwrap();
394        let pushed = current_execution_policy().unwrap();
395        assert_eq!(pushed.recursion_limit, None);
396        assert_eq!(
397            pushed.sandbox_profile,
398            crate::orchestration::SandboxProfile::OsHardened
399        );
400        assert!(pushed.tools.is_empty());
401        assert!(pushed.capabilities.is_empty());
402        drop(guard);
403        assert!(current_execution_policy().is_none());
404    }
405
406    #[test]
407    fn top_level_carrier_does_not_propagate_requested_tools_or_capabilities() {
408        // Regression: at the top level (no parent on stack), the carrier
409        // intentionally exposes only the budget to subsequent stack
410        // lookups. Tool, capability, and side-effect ceilings flow
411        // through the per-tool-dispatch guard instead, so the agent's
412        // own `llm_call` turn is not gated by a policy that scopes the
413        // agent's tools to a read-only allowlist.
414        clear_execution_policy_stacks();
415        let requested = CapabilityPolicy {
416            tools: vec!["read_only".to_string()],
417            capabilities: std::collections::BTreeMap::from_iter([(
418                "workspace".to_string(),
419                vec!["read_text".to_string()],
420            )]),
421            side_effect_level: Some("read_only".to_string()),
422            recursion_limit: Some(4),
423            ..Default::default()
424        };
425        let guard = enter_nested_execution_policy(
426            Some(requested),
427            NestedExecutionKind::AgentLoop,
428            "session-x",
429        )
430        .unwrap();
431        let pushed = current_execution_policy().unwrap();
432        assert_eq!(pushed.recursion_limit, Some(4));
433        assert_eq!(
434            pushed.sandbox_profile,
435            crate::orchestration::SandboxProfile::OsHardened
436        );
437        assert!(pushed.tools.is_empty());
438        assert!(pushed.capabilities.is_empty());
439        assert!(pushed.side_effect_level.is_none());
440        drop(guard);
441    }
442
443    #[test]
444    fn carrier_inherits_parent_restrictions_when_nesting() {
445        // Regression: when an agent_loop is invoked under an outer policy
446        // (e.g., a workflow stage that restricts capabilities), the
447        // carrier must preserve those restrictions so the inner agent's
448        // own infrastructure calls observe the outer ceiling rather than
449        // a permissive carrier shadowing it.
450        clear_execution_policy_stacks();
451        let outer = CapabilityPolicy {
452            capabilities: std::collections::BTreeMap::from_iter([(
453                "workspace".to_string(),
454                vec!["read_text".to_string()],
455            )]),
456            side_effect_level: Some("read_only".to_string()),
457            recursion_limit: Some(3),
458            ..Default::default()
459        };
460        push_execution_policy(outer);
461        let guard =
462            enter_nested_execution_policy(None, NestedExecutionKind::WorkflowStage, "stage-1")
463                .unwrap();
464        let pushed = current_execution_policy().unwrap();
465        // Budget decremented by one descent.
466        assert_eq!(pushed.recursion_limit, Some(2));
467        // Outer ceiling preserved so inner llm_call/tool calls remain
468        // gated by the workflow stage's policy, not shadowed by an empty
469        // carrier.
470        assert_eq!(
471            pushed.capabilities.get("workspace"),
472            Some(&vec!["read_text".to_string()])
473        );
474        assert_eq!(pushed.side_effect_level.as_deref(), Some("read_only"));
475        drop(guard);
476        pop_execution_policy();
477    }
478
479    #[test]
480    fn workflow_stage_kind_observes_same_budget_semantics() {
481        clear_execution_policy_stacks();
482        push_execution_policy(policy_with_limit(Some(1)));
483        // Workflow stage is just another nested surface — the budget
484        // gate decrements identically and surfaces the stage label on
485        // rejection so workflow authors can see which node tripped.
486        let guard =
487            enter_nested_execution_policy(None, NestedExecutionKind::WorkflowStage, "build_stage")
488                .unwrap();
489        assert_eq!(guard.child_limit, Some(0));
490        // Next stage would try to nest under a zero-budget parent.
491        let denied =
492            enter_nested_execution_policy(None, NestedExecutionKind::WorkflowStage, "verify_stage")
493                .unwrap_err();
494        match denied {
495            VmError::CategorizedError { message, category } => {
496                assert_eq!(category, ErrorCategory::BudgetExceeded);
497                assert!(message.contains("workflow_stage"));
498                assert!(message.contains("verify_stage"));
499            }
500            other => panic!("expected CategorizedError, got {other:?}"),
501        }
502        drop(guard);
503        pop_execution_policy();
504    }
505
506    #[test]
507    fn annotate_nested_execution_options_writes_canonical_keys() {
508        let mut options: crate::value::DictMap = crate::value::DictMap::new();
509        annotate_nested_execution_options(
510            &mut options,
511            NestedExecutionKind::SubAgentRun,
512            "research-worker",
513        );
514        match options.get(NESTED_KIND_OPTION_KEY).unwrap() {
515            VmValue::String(text) => assert_eq!(text.as_str(), "sub_agent_run"),
516            _ => panic!("kind not stored as string"),
517        }
518        match options.get(NESTED_LABEL_OPTION_KEY).unwrap() {
519            VmValue::String(text) => assert_eq!(text.as_str(), "research-worker"),
520            _ => panic!("label not stored as string"),
521        }
522    }
523}