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
102impl NestedExecutionGuard {
103    /// Disarm cleanup when the ambient scope that owned this guard has already
104    /// been abandoned. Popping here would mutate the caller's unrelated stack.
105    pub(crate) fn disarm(mut self) {
106        self.pushed = false;
107    }
108}
109
110/// Enter a child execution: validate the parent's recursion budget,
111/// decrement once for this descent, and push a policy carrier onto
112/// the thread-local execution policy stack. The guard pops it on drop.
113///
114/// The carrier inherits every field from the currently-active parent
115/// policy and only overrides `recursion_limit` with the decremented
116/// child budget. That preserves any tool / capability / side-effect /
117/// workspace ceiling the parent had established (e.g., a workflow
118/// stage's restrictive `CapabilityPolicy`) so the child agent's own
119/// `llm_call` and infrastructure builtins continue to see the parent's
120/// restrictions. When there is no parent on the stack, a top-level
121/// `agent_loop` still installs an empty-ceiling `os_hardened` carrier
122/// so its subprocess tools require OS confinement by default; other
123/// top-level nested surfaces keep the historical no-carrier behavior
124/// unless a recursion budget is requested.
125///
126/// The agent's own `options.policy` (with tool / capability / etc.
127/// ceilings) is intentionally *not* installed by this helper; that
128/// continues to flow through the per-tool-dispatch policy guard
129/// (`install_session_policy_guard`), which intersects with the current
130/// outer at every dispatch. Per-tool-dispatch intersections preserve
131/// the decremented budget because `CapabilityPolicy::intersect` takes
132/// the `min` of `recursion_limit` across both sides.
133pub fn enter_nested_execution_policy(
134    requested: Option<CapabilityPolicy>,
135    kind: NestedExecutionKind,
136    label: &str,
137) -> Result<NestedExecutionGuard, VmError> {
138    let parent = current_execution_policy();
139    let parent_limit = parent.as_ref().and_then(|p| p.recursion_limit);
140
141    if matches!(parent_limit, Some(0)) {
142        emit_descent_event(kind, label, parent_limit, None, true);
143        return Err(nested_budget_exhausted(kind, label));
144    }
145
146    let requested_limit = requested.as_ref().and_then(|p| p.recursion_limit);
147    let decremented_parent = parent_limit.map(|n| n - 1);
148    let child_limit = match (decremented_parent, requested_limit) {
149        (Some(a), Some(b)) => Some(a.min(b)),
150        (Some(a), None) => Some(a),
151        (None, Some(b)) => Some(b),
152        (None, None) => None,
153    };
154
155    emit_descent_event(kind, label, parent_limit, child_limit, false);
156
157    let top_level_agent_loop = parent.is_none() && matches!(kind, NestedExecutionKind::AgentLoop);
158    let pushed = if child_limit.is_some() || top_level_agent_loop {
159        let mut carrier = parent.unwrap_or_else(|| {
160            if top_level_agent_loop {
161                top_level_agent_loop_policy()
162            } else {
163                CapabilityPolicy::default()
164            }
165        });
166        carrier.recursion_limit = child_limit;
167        push_execution_policy(carrier);
168        true
169    } else {
170        false
171    };
172
173    Ok(NestedExecutionGuard {
174        pushed,
175        parent_limit,
176        child_limit,
177        kind,
178        label: label.to_string(),
179    })
180}
181
182fn top_level_agent_loop_policy() -> CapabilityPolicy {
183    CapabilityPolicy {
184        sandbox_profile: SandboxProfile::OsHardened,
185        ..CapabilityPolicy::default()
186    }
187}
188
189/// Tag an `agent_loop` options dict with the nested-execution kind and
190/// label so [`enter_nested_execution_policy`] picks up the right
191/// surface attribution at session init. Call sites that build options
192/// for downstream agent_loop invocations (sub_agent_run, workflow
193/// stages, spawn_agent worker setup) use this rather than rewriting
194/// the dict-insert pattern.
195pub fn annotate_nested_execution_options(
196    options: &mut crate::value::DictMap,
197    kind: NestedExecutionKind,
198    label: &str,
199) {
200    options.insert(
201        crate::value::intern_key(NESTED_KIND_OPTION_KEY),
202        VmValue::String(arcstr::ArcStr::from(kind.as_str().to_string())),
203    );
204    options.insert(
205        crate::value::intern_key(NESTED_LABEL_OPTION_KEY),
206        VmValue::String(arcstr::ArcStr::from(label.to_string())),
207    );
208}
209
210fn nested_budget_exhausted(kind: NestedExecutionKind, label: &str) -> VmError {
211    let label = if label.is_empty() { "<unnamed>" } else { label };
212    VmError::CategorizedError {
213        message: format!(
214            "nested execution budget exhausted before {}: {}",
215            kind.as_str(),
216            label
217        ),
218        category: ErrorCategory::BudgetExceeded,
219    }
220}
221
222fn emit_descent_event(
223    kind: NestedExecutionKind,
224    label: &str,
225    parent_limit: Option<usize>,
226    child_limit: Option<usize>,
227    rejected: bool,
228) {
229    let mut metadata = std::collections::BTreeMap::new();
230    metadata.insert(
231        "kind".to_string(),
232        serde_json::Value::String(kind.as_str().to_string()),
233    );
234    metadata.insert(
235        "label".to_string(),
236        serde_json::Value::String(label.to_string()),
237    );
238    metadata.insert(
239        "parent_recursion_limit".to_string(),
240        recursion_limit_to_json(parent_limit),
241    );
242    metadata.insert(
243        "child_recursion_limit".to_string(),
244        recursion_limit_to_json(child_limit),
245    );
246    metadata.insert("rejected".to_string(), serde_json::Value::Bool(rejected));
247    let message = if rejected {
248        format!(
249            "nested execution budget exhausted before {}: {}",
250            kind.as_str(),
251            label
252        )
253    } else {
254        format!("nested execution descent into {}: {}", kind.as_str(), label)
255    };
256    log_debug_meta("policy.nested_execution_descent", &message, metadata);
257}
258
259fn recursion_limit_to_json(value: Option<usize>) -> serde_json::Value {
260    match value {
261        Some(n) => serde_json::Value::Number(serde_json::Number::from(n)),
262        None => serde_json::Value::Null,
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    use crate::orchestration::clear_execution_policy_stacks;
270
271    fn policy_with_limit(limit: Option<usize>) -> CapabilityPolicy {
272        CapabilityPolicy {
273            recursion_limit: limit,
274            ..Default::default()
275        }
276    }
277
278    #[test]
279    fn none_parent_preserves_requested_limit() {
280        clear_execution_policy_stacks();
281        let requested = Some(policy_with_limit(Some(3)));
282        let guard =
283            enter_nested_execution_policy(requested, NestedExecutionKind::AgentLoop, "session-a")
284                .unwrap();
285        assert_eq!(guard.parent_limit, None);
286        assert_eq!(guard.child_limit, Some(3));
287        assert_eq!(current_execution_policy().unwrap().recursion_limit, Some(3));
288        assert_eq!(
289            current_execution_policy().unwrap().sandbox_profile,
290            crate::orchestration::SandboxProfile::OsHardened
291        );
292        drop(guard);
293        assert!(current_execution_policy().is_none());
294    }
295
296    #[test]
297    fn some_one_allows_one_child_and_gives_child_zero() {
298        clear_execution_policy_stacks();
299        push_execution_policy(policy_with_limit(Some(1)));
300        let guard =
301            enter_nested_execution_policy(None, NestedExecutionKind::SubAgentRun, "child-1")
302                .unwrap();
303        assert_eq!(guard.parent_limit, Some(1));
304        assert_eq!(guard.child_limit, Some(0));
305        assert_eq!(current_execution_policy().unwrap().recursion_limit, Some(0));
306        drop(guard);
307        pop_execution_policy();
308    }
309
310    #[test]
311    fn some_zero_rejects_with_budget_exceeded() {
312        clear_execution_policy_stacks();
313        push_execution_policy(policy_with_limit(Some(0)));
314        let error =
315            enter_nested_execution_policy(None, NestedExecutionKind::AgentLoop, "research-worker")
316                .unwrap_err();
317        match error {
318            VmError::CategorizedError { message, category } => {
319                assert_eq!(category, ErrorCategory::BudgetExceeded);
320                assert!(
321                    message.contains("agent_loop"),
322                    "missing kind in message: {message}"
323                );
324                assert!(
325                    message.contains("research-worker"),
326                    "missing label in message: {message}"
327                );
328            }
329            other => panic!("expected CategorizedError, got {other:?}"),
330        }
331        pop_execution_policy();
332    }
333
334    #[test]
335    fn nested_chain_decrements_until_exhausted() {
336        clear_execution_policy_stacks();
337        let outer = enter_nested_execution_policy(
338            Some(policy_with_limit(Some(2))),
339            NestedExecutionKind::AgentLoop,
340            "outer",
341        )
342        .unwrap();
343        assert_eq!(outer.child_limit, Some(2));
344        let middle =
345            enter_nested_execution_policy(None, NestedExecutionKind::SubAgentRun, "middle")
346                .unwrap();
347        assert_eq!(middle.child_limit, Some(1));
348        let inner =
349            enter_nested_execution_policy(None, NestedExecutionKind::AgentLoop, "inner").unwrap();
350        assert_eq!(inner.child_limit, Some(0));
351        let exhausted =
352            enter_nested_execution_policy(None, NestedExecutionKind::SubAgentRun, "innermost")
353                .unwrap_err();
354        assert!(matches!(
355            exhausted,
356            VmError::CategorizedError {
357                category: ErrorCategory::BudgetExceeded,
358                ..
359            }
360        ));
361        drop(inner);
362        drop(middle);
363        drop(outer);
364    }
365
366    #[test]
367    fn requested_limit_caps_below_parent() {
368        clear_execution_policy_stacks();
369        push_execution_policy(policy_with_limit(Some(8)));
370        let guard = enter_nested_execution_policy(
371            Some(policy_with_limit(Some(2))),
372            NestedExecutionKind::WorkflowStage,
373            "stage-1",
374        )
375        .unwrap();
376        assert_eq!(guard.parent_limit, Some(8));
377        // Decremented parent (7) intersected with requested (2) → 2.
378        assert_eq!(guard.child_limit, Some(2));
379        drop(guard);
380        pop_execution_policy();
381    }
382
383    #[test]
384    fn none_parent_and_none_requested_pushes_no_policy() {
385        clear_execution_policy_stacks();
386        let guard =
387            enter_nested_execution_policy(None, NestedExecutionKind::NestedWorkflow, "wf-1")
388                .unwrap();
389        assert!(current_execution_policy().is_none());
390        assert_eq!(guard.parent_limit, None);
391        assert_eq!(guard.child_limit, None);
392        drop(guard);
393        assert!(current_execution_policy().is_none());
394    }
395
396    #[test]
397    fn top_level_agent_loop_pushes_os_hardened_carrier_without_budget() {
398        clear_execution_policy_stacks();
399        let guard =
400            enter_nested_execution_policy(None, NestedExecutionKind::AgentLoop, "session-secure")
401                .unwrap();
402        let pushed = current_execution_policy().unwrap();
403        assert_eq!(pushed.recursion_limit, None);
404        assert_eq!(
405            pushed.sandbox_profile,
406            crate::orchestration::SandboxProfile::OsHardened
407        );
408        assert!(pushed.tools.is_empty());
409        assert!(pushed.capabilities.is_empty());
410        drop(guard);
411        assert!(current_execution_policy().is_none());
412    }
413
414    #[test]
415    fn top_level_carrier_does_not_propagate_requested_tools_or_capabilities() {
416        // Regression: at the top level (no parent on stack), the carrier
417        // intentionally exposes only the budget to subsequent stack
418        // lookups. Tool, capability, and side-effect ceilings flow
419        // through the per-tool-dispatch guard instead, so the agent's
420        // own `llm_call` turn is not gated by a policy that scopes the
421        // agent's tools to a read-only allowlist.
422        clear_execution_policy_stacks();
423        let requested = CapabilityPolicy {
424            tools: vec!["read_only".to_string()],
425            capabilities: std::collections::BTreeMap::from_iter([(
426                "workspace".to_string(),
427                vec!["read_text".to_string()],
428            )]),
429            side_effect_level: Some("read_only".to_string()),
430            recursion_limit: Some(4),
431            ..Default::default()
432        };
433        let guard = enter_nested_execution_policy(
434            Some(requested),
435            NestedExecutionKind::AgentLoop,
436            "session-x",
437        )
438        .unwrap();
439        let pushed = current_execution_policy().unwrap();
440        assert_eq!(pushed.recursion_limit, Some(4));
441        assert_eq!(
442            pushed.sandbox_profile,
443            crate::orchestration::SandboxProfile::OsHardened
444        );
445        assert!(pushed.tools.is_empty());
446        assert!(pushed.capabilities.is_empty());
447        assert!(pushed.side_effect_level.is_none());
448        drop(guard);
449    }
450
451    #[test]
452    fn carrier_inherits_parent_restrictions_when_nesting() {
453        // Regression: when an agent_loop is invoked under an outer policy
454        // (e.g., a workflow stage that restricts capabilities), the
455        // carrier must preserve those restrictions so the inner agent's
456        // own infrastructure calls observe the outer ceiling rather than
457        // a permissive carrier shadowing it.
458        clear_execution_policy_stacks();
459        let outer = CapabilityPolicy {
460            capabilities: std::collections::BTreeMap::from_iter([(
461                "workspace".to_string(),
462                vec!["read_text".to_string()],
463            )]),
464            side_effect_level: Some("read_only".to_string()),
465            recursion_limit: Some(3),
466            ..Default::default()
467        };
468        push_execution_policy(outer);
469        let guard =
470            enter_nested_execution_policy(None, NestedExecutionKind::WorkflowStage, "stage-1")
471                .unwrap();
472        let pushed = current_execution_policy().unwrap();
473        // Budget decremented by one descent.
474        assert_eq!(pushed.recursion_limit, Some(2));
475        // Outer ceiling preserved so inner llm_call/tool calls remain
476        // gated by the workflow stage's policy, not shadowed by an empty
477        // carrier.
478        assert_eq!(
479            pushed.capabilities.get("workspace"),
480            Some(&vec!["read_text".to_string()])
481        );
482        assert_eq!(pushed.side_effect_level.as_deref(), Some("read_only"));
483        drop(guard);
484        pop_execution_policy();
485    }
486
487    #[test]
488    fn workflow_stage_kind_observes_same_budget_semantics() {
489        clear_execution_policy_stacks();
490        push_execution_policy(policy_with_limit(Some(1)));
491        // Workflow stage is just another nested surface — the budget
492        // gate decrements identically and surfaces the stage label on
493        // rejection so workflow authors can see which node tripped.
494        let guard =
495            enter_nested_execution_policy(None, NestedExecutionKind::WorkflowStage, "build_stage")
496                .unwrap();
497        assert_eq!(guard.child_limit, Some(0));
498        // Next stage would try to nest under a zero-budget parent.
499        let denied =
500            enter_nested_execution_policy(None, NestedExecutionKind::WorkflowStage, "verify_stage")
501                .unwrap_err();
502        match denied {
503            VmError::CategorizedError { message, category } => {
504                assert_eq!(category, ErrorCategory::BudgetExceeded);
505                assert!(message.contains("workflow_stage"));
506                assert!(message.contains("verify_stage"));
507            }
508            other => panic!("expected CategorizedError, got {other:?}"),
509        }
510        drop(guard);
511        pop_execution_policy();
512    }
513
514    #[test]
515    fn annotate_nested_execution_options_writes_canonical_keys() {
516        let mut options: crate::value::DictMap = crate::value::DictMap::new();
517        annotate_nested_execution_options(
518            &mut options,
519            NestedExecutionKind::SubAgentRun,
520            "research-worker",
521        );
522        match options.get(NESTED_KIND_OPTION_KEY).unwrap() {
523            VmValue::String(text) => assert_eq!(text.as_str(), "sub_agent_run"),
524            _ => panic!("kind not stored as string"),
525        }
526        match options.get(NESTED_LABEL_OPTION_KEY).unwrap() {
527            VmValue::String(text) => assert_eq!(text.as_str(), "research-worker"),
528            _ => panic!("label not stored as string"),
529        }
530    }
531}