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