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;
19use crate::events::log_info_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, the carrier is
113/// built from `CapabilityPolicy::default()` (empty ceilings) plus the
114/// budget — which is the right thing for a top-level `agent_loop`
115/// whose options.policy scopes its tools but should not gate its own
116/// LLM turn.
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 pushed = if let Some(limit) = child_limit {
150        let mut carrier = parent.unwrap_or_default();
151        carrier.recursion_limit = Some(limit);
152        push_execution_policy(carrier);
153        true
154    } else {
155        false
156    };
157
158    Ok(NestedExecutionGuard {
159        pushed,
160        parent_limit,
161        child_limit,
162        kind,
163        label: label.to_string(),
164    })
165}
166
167/// Tag an `agent_loop` options dict with the nested-execution kind and
168/// label so [`enter_nested_execution_policy`] picks up the right
169/// surface attribution at session init. Call sites that build options
170/// for downstream agent_loop invocations (sub_agent_run, workflow
171/// stages, spawn_agent worker setup) use this rather than rewriting
172/// the dict-insert pattern.
173pub fn annotate_nested_execution_options(
174    options: &mut crate::value::DictMap,
175    kind: NestedExecutionKind,
176    label: &str,
177) {
178    options.insert(
179        crate::value::intern_key(NESTED_KIND_OPTION_KEY),
180        VmValue::String(arcstr::ArcStr::from(kind.as_str().to_string())),
181    );
182    options.insert(
183        crate::value::intern_key(NESTED_LABEL_OPTION_KEY),
184        VmValue::String(arcstr::ArcStr::from(label.to_string())),
185    );
186}
187
188fn nested_budget_exhausted(kind: NestedExecutionKind, label: &str) -> VmError {
189    let label = if label.is_empty() { "<unnamed>" } else { label };
190    VmError::CategorizedError {
191        message: format!(
192            "nested execution budget exhausted before {}: {}",
193            kind.as_str(),
194            label
195        ),
196        category: ErrorCategory::BudgetExceeded,
197    }
198}
199
200fn emit_descent_event(
201    kind: NestedExecutionKind,
202    label: &str,
203    parent_limit: Option<usize>,
204    child_limit: Option<usize>,
205    rejected: bool,
206) {
207    let mut metadata = std::collections::BTreeMap::new();
208    metadata.insert(
209        "kind".to_string(),
210        serde_json::Value::String(kind.as_str().to_string()),
211    );
212    metadata.insert(
213        "label".to_string(),
214        serde_json::Value::String(label.to_string()),
215    );
216    metadata.insert(
217        "parent_recursion_limit".to_string(),
218        recursion_limit_to_json(parent_limit),
219    );
220    metadata.insert(
221        "child_recursion_limit".to_string(),
222        recursion_limit_to_json(child_limit),
223    );
224    metadata.insert("rejected".to_string(), serde_json::Value::Bool(rejected));
225    let message = if rejected {
226        format!(
227            "nested execution budget exhausted before {}: {}",
228            kind.as_str(),
229            label
230        )
231    } else {
232        format!("nested execution descent into {}: {}", kind.as_str(), label)
233    };
234    log_info_meta("policy.nested_execution_descent", &message, metadata);
235}
236
237fn recursion_limit_to_json(value: Option<usize>) -> serde_json::Value {
238    match value {
239        Some(n) => serde_json::Value::Number(serde_json::Number::from(n)),
240        None => serde_json::Value::Null,
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use crate::orchestration::clear_execution_policy_stacks;
248
249    fn policy_with_limit(limit: Option<usize>) -> CapabilityPolicy {
250        CapabilityPolicy {
251            recursion_limit: limit,
252            ..Default::default()
253        }
254    }
255
256    #[test]
257    fn none_parent_preserves_requested_limit() {
258        clear_execution_policy_stacks();
259        let requested = Some(policy_with_limit(Some(3)));
260        let guard =
261            enter_nested_execution_policy(requested, NestedExecutionKind::AgentLoop, "session-a")
262                .unwrap();
263        assert_eq!(guard.parent_limit, None);
264        assert_eq!(guard.child_limit, Some(3));
265        assert_eq!(current_execution_policy().unwrap().recursion_limit, Some(3));
266        drop(guard);
267        assert!(current_execution_policy().is_none());
268    }
269
270    #[test]
271    fn some_one_allows_one_child_and_gives_child_zero() {
272        clear_execution_policy_stacks();
273        push_execution_policy(policy_with_limit(Some(1)));
274        let guard =
275            enter_nested_execution_policy(None, NestedExecutionKind::SubAgentRun, "child-1")
276                .unwrap();
277        assert_eq!(guard.parent_limit, Some(1));
278        assert_eq!(guard.child_limit, Some(0));
279        assert_eq!(current_execution_policy().unwrap().recursion_limit, Some(0));
280        drop(guard);
281        pop_execution_policy();
282    }
283
284    #[test]
285    fn some_zero_rejects_with_budget_exceeded() {
286        clear_execution_policy_stacks();
287        push_execution_policy(policy_with_limit(Some(0)));
288        let error =
289            enter_nested_execution_policy(None, NestedExecutionKind::AgentLoop, "research-worker")
290                .unwrap_err();
291        match error {
292            VmError::CategorizedError { message, category } => {
293                assert_eq!(category, ErrorCategory::BudgetExceeded);
294                assert!(
295                    message.contains("agent_loop"),
296                    "missing kind in message: {message}"
297                );
298                assert!(
299                    message.contains("research-worker"),
300                    "missing label in message: {message}"
301                );
302            }
303            other => panic!("expected CategorizedError, got {other:?}"),
304        }
305        pop_execution_policy();
306    }
307
308    #[test]
309    fn nested_chain_decrements_until_exhausted() {
310        clear_execution_policy_stacks();
311        let outer = enter_nested_execution_policy(
312            Some(policy_with_limit(Some(2))),
313            NestedExecutionKind::AgentLoop,
314            "outer",
315        )
316        .unwrap();
317        assert_eq!(outer.child_limit, Some(2));
318        let middle =
319            enter_nested_execution_policy(None, NestedExecutionKind::SubAgentRun, "middle")
320                .unwrap();
321        assert_eq!(middle.child_limit, Some(1));
322        let inner =
323            enter_nested_execution_policy(None, NestedExecutionKind::AgentLoop, "inner").unwrap();
324        assert_eq!(inner.child_limit, Some(0));
325        let exhausted =
326            enter_nested_execution_policy(None, NestedExecutionKind::SubAgentRun, "innermost")
327                .unwrap_err();
328        assert!(matches!(
329            exhausted,
330            VmError::CategorizedError {
331                category: ErrorCategory::BudgetExceeded,
332                ..
333            }
334        ));
335        drop(inner);
336        drop(middle);
337        drop(outer);
338    }
339
340    #[test]
341    fn requested_limit_caps_below_parent() {
342        clear_execution_policy_stacks();
343        push_execution_policy(policy_with_limit(Some(8)));
344        let guard = enter_nested_execution_policy(
345            Some(policy_with_limit(Some(2))),
346            NestedExecutionKind::WorkflowStage,
347            "stage-1",
348        )
349        .unwrap();
350        assert_eq!(guard.parent_limit, Some(8));
351        // Decremented parent (7) intersected with requested (2) → 2.
352        assert_eq!(guard.child_limit, Some(2));
353        drop(guard);
354        pop_execution_policy();
355    }
356
357    #[test]
358    fn none_parent_and_none_requested_pushes_no_policy() {
359        clear_execution_policy_stacks();
360        let guard =
361            enter_nested_execution_policy(None, NestedExecutionKind::NestedWorkflow, "wf-1")
362                .unwrap();
363        assert!(current_execution_policy().is_none());
364        assert_eq!(guard.parent_limit, None);
365        assert_eq!(guard.child_limit, None);
366        drop(guard);
367        assert!(current_execution_policy().is_none());
368    }
369
370    #[test]
371    fn top_level_carrier_does_not_propagate_requested_tools_or_capabilities() {
372        // Regression: at the top level (no parent on stack), the carrier
373        // intentionally exposes only the budget to subsequent stack
374        // lookups. Tool, capability, and side-effect ceilings flow
375        // through the per-tool-dispatch guard instead, so the agent's
376        // own `llm_call` turn is not gated by a policy that scopes the
377        // agent's tools to a read-only allowlist.
378        clear_execution_policy_stacks();
379        let requested = CapabilityPolicy {
380            tools: vec!["read_only".to_string()],
381            capabilities: std::collections::BTreeMap::from_iter([(
382                "workspace".to_string(),
383                vec!["read_text".to_string()],
384            )]),
385            side_effect_level: Some("read_only".to_string()),
386            recursion_limit: Some(4),
387            ..Default::default()
388        };
389        let guard = enter_nested_execution_policy(
390            Some(requested),
391            NestedExecutionKind::AgentLoop,
392            "session-x",
393        )
394        .unwrap();
395        let pushed = current_execution_policy().unwrap();
396        assert_eq!(pushed.recursion_limit, Some(4));
397        assert!(pushed.tools.is_empty());
398        assert!(pushed.capabilities.is_empty());
399        assert!(pushed.side_effect_level.is_none());
400        drop(guard);
401    }
402
403    #[test]
404    fn carrier_inherits_parent_restrictions_when_nesting() {
405        // Regression: when an agent_loop is invoked under an outer policy
406        // (e.g., a workflow stage that restricts capabilities), the
407        // carrier must preserve those restrictions so the inner agent's
408        // own infrastructure calls observe the outer ceiling rather than
409        // a permissive carrier shadowing it.
410        clear_execution_policy_stacks();
411        let outer = CapabilityPolicy {
412            capabilities: std::collections::BTreeMap::from_iter([(
413                "workspace".to_string(),
414                vec!["read_text".to_string()],
415            )]),
416            side_effect_level: Some("read_only".to_string()),
417            recursion_limit: Some(3),
418            ..Default::default()
419        };
420        push_execution_policy(outer);
421        let guard =
422            enter_nested_execution_policy(None, NestedExecutionKind::WorkflowStage, "stage-1")
423                .unwrap();
424        let pushed = current_execution_policy().unwrap();
425        // Budget decremented by one descent.
426        assert_eq!(pushed.recursion_limit, Some(2));
427        // Outer ceiling preserved so inner llm_call/tool calls remain
428        // gated by the workflow stage's policy, not shadowed by an empty
429        // carrier.
430        assert_eq!(
431            pushed.capabilities.get("workspace"),
432            Some(&vec!["read_text".to_string()])
433        );
434        assert_eq!(pushed.side_effect_level.as_deref(), Some("read_only"));
435        drop(guard);
436        pop_execution_policy();
437    }
438
439    #[test]
440    fn workflow_stage_kind_observes_same_budget_semantics() {
441        clear_execution_policy_stacks();
442        push_execution_policy(policy_with_limit(Some(1)));
443        // Workflow stage is just another nested surface — the budget
444        // gate decrements identically and surfaces the stage label on
445        // rejection so workflow authors can see which node tripped.
446        let guard =
447            enter_nested_execution_policy(None, NestedExecutionKind::WorkflowStage, "build_stage")
448                .unwrap();
449        assert_eq!(guard.child_limit, Some(0));
450        // Next stage would try to nest under a zero-budget parent.
451        let denied =
452            enter_nested_execution_policy(None, NestedExecutionKind::WorkflowStage, "verify_stage")
453                .unwrap_err();
454        match denied {
455            VmError::CategorizedError { message, category } => {
456                assert_eq!(category, ErrorCategory::BudgetExceeded);
457                assert!(message.contains("workflow_stage"));
458                assert!(message.contains("verify_stage"));
459            }
460            other => panic!("expected CategorizedError, got {other:?}"),
461        }
462        drop(guard);
463        pop_execution_policy();
464    }
465
466    #[test]
467    fn annotate_nested_execution_options_writes_canonical_keys() {
468        let mut options: crate::value::DictMap = crate::value::DictMap::new();
469        annotate_nested_execution_options(
470            &mut options,
471            NestedExecutionKind::SubAgentRun,
472            "research-worker",
473        );
474        match options.get(NESTED_KIND_OPTION_KEY).unwrap() {
475            VmValue::String(text) => assert_eq!(text.as_str(), "sub_agent_run"),
476            _ => panic!("kind not stored as string"),
477        }
478        match options.get(NESTED_LABEL_OPTION_KEY).unwrap() {
479            VmValue::String(text) => assert_eq!(text.as_str(), "research-worker"),
480            _ => panic!("label not stored as string"),
481        }
482    }
483}