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