use std::collections::BTreeMap;
use std::rc::Rc;
use super::CapabilityPolicy;
use crate::events::log_info_meta;
use crate::orchestration::{current_execution_policy, pop_execution_policy, push_execution_policy};
use crate::value::{ErrorCategory, VmError, VmValue};
pub const NESTED_KIND_OPTION_KEY: &str = "_nested_kind";
pub const NESTED_LABEL_OPTION_KEY: &str = "_nested_label";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum NestedExecutionKind {
AgentLoop,
SubAgentRun,
SpawnAgent,
WorkflowStage,
NestedWorkflow,
NestedInvocation,
}
impl NestedExecutionKind {
pub fn as_str(self) -> &'static str {
match self {
Self::AgentLoop => "agent_loop",
Self::SubAgentRun => "sub_agent_run",
Self::SpawnAgent => "spawn_agent",
Self::WorkflowStage => "workflow_stage",
Self::NestedWorkflow => "nested_workflow",
Self::NestedInvocation => "nested_invocation",
}
}
pub fn parse_or_default(value: Option<&str>) -> Self {
match value {
Some("agent_loop") => Self::AgentLoop,
Some("sub_agent_run") => Self::SubAgentRun,
Some("spawn_agent") => Self::SpawnAgent,
Some("workflow_stage") => Self::WorkflowStage,
Some("nested_workflow") => Self::NestedWorkflow,
Some("nested_invocation") => Self::NestedInvocation,
_ => Self::AgentLoop,
}
}
}
#[derive(Debug)]
pub struct NestedExecutionGuard {
pushed: bool,
pub parent_limit: Option<usize>,
pub child_limit: Option<usize>,
pub kind: NestedExecutionKind,
pub label: String,
}
impl Drop for NestedExecutionGuard {
fn drop(&mut self) {
if self.pushed {
pop_execution_policy();
}
}
}
pub fn enter_nested_execution_policy(
requested: Option<CapabilityPolicy>,
kind: NestedExecutionKind,
label: &str,
) -> Result<NestedExecutionGuard, VmError> {
let parent = current_execution_policy();
let parent_limit = parent.as_ref().and_then(|p| p.recursion_limit);
if matches!(parent_limit, Some(0)) {
emit_descent_event(kind, label, parent_limit, None, true);
return Err(nested_budget_exhausted(kind, label));
}
let requested_limit = requested.as_ref().and_then(|p| p.recursion_limit);
let decremented_parent = parent_limit.map(|n| n - 1);
let child_limit = match (decremented_parent, requested_limit) {
(Some(a), Some(b)) => Some(a.min(b)),
(Some(a), None) => Some(a),
(None, Some(b)) => Some(b),
(None, None) => None,
};
emit_descent_event(kind, label, parent_limit, child_limit, false);
let pushed = if let Some(limit) = child_limit {
let mut carrier = parent.unwrap_or_default();
carrier.recursion_limit = Some(limit);
push_execution_policy(carrier);
true
} else {
false
};
Ok(NestedExecutionGuard {
pushed,
parent_limit,
child_limit,
kind,
label: label.to_string(),
})
}
pub fn annotate_nested_execution_options(
options: &mut BTreeMap<String, VmValue>,
kind: NestedExecutionKind,
label: &str,
) {
options.insert(
NESTED_KIND_OPTION_KEY.to_string(),
VmValue::String(Rc::from(kind.as_str().to_string())),
);
options.insert(
NESTED_LABEL_OPTION_KEY.to_string(),
VmValue::String(Rc::from(label.to_string())),
);
}
fn nested_budget_exhausted(kind: NestedExecutionKind, label: &str) -> VmError {
let label = if label.is_empty() { "<unnamed>" } else { label };
VmError::CategorizedError {
message: format!(
"nested execution budget exhausted before {}: {}",
kind.as_str(),
label
),
category: ErrorCategory::BudgetExceeded,
}
}
fn emit_descent_event(
kind: NestedExecutionKind,
label: &str,
parent_limit: Option<usize>,
child_limit: Option<usize>,
rejected: bool,
) {
let mut metadata = BTreeMap::new();
metadata.insert(
"kind".to_string(),
serde_json::Value::String(kind.as_str().to_string()),
);
metadata.insert(
"label".to_string(),
serde_json::Value::String(label.to_string()),
);
metadata.insert(
"parent_recursion_limit".to_string(),
recursion_limit_to_json(parent_limit),
);
metadata.insert(
"child_recursion_limit".to_string(),
recursion_limit_to_json(child_limit),
);
metadata.insert("rejected".to_string(), serde_json::Value::Bool(rejected));
let message = if rejected {
format!(
"nested execution budget exhausted before {}: {}",
kind.as_str(),
label
)
} else {
format!("nested execution descent into {}: {}", kind.as_str(), label)
};
log_info_meta("policy.nested_execution_descent", &message, metadata);
}
fn recursion_limit_to_json(value: Option<usize>) -> serde_json::Value {
match value {
Some(n) => serde_json::Value::Number(serde_json::Number::from(n)),
None => serde_json::Value::Null,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::orchestration::clear_execution_policy_stacks;
fn policy_with_limit(limit: Option<usize>) -> CapabilityPolicy {
CapabilityPolicy {
recursion_limit: limit,
..Default::default()
}
}
#[test]
fn none_parent_preserves_requested_limit() {
clear_execution_policy_stacks();
let requested = Some(policy_with_limit(Some(3)));
let guard =
enter_nested_execution_policy(requested, NestedExecutionKind::AgentLoop, "session-a")
.unwrap();
assert_eq!(guard.parent_limit, None);
assert_eq!(guard.child_limit, Some(3));
assert_eq!(current_execution_policy().unwrap().recursion_limit, Some(3));
drop(guard);
assert!(current_execution_policy().is_none());
}
#[test]
fn some_one_allows_one_child_and_gives_child_zero() {
clear_execution_policy_stacks();
push_execution_policy(policy_with_limit(Some(1)));
let guard =
enter_nested_execution_policy(None, NestedExecutionKind::SubAgentRun, "child-1")
.unwrap();
assert_eq!(guard.parent_limit, Some(1));
assert_eq!(guard.child_limit, Some(0));
assert_eq!(current_execution_policy().unwrap().recursion_limit, Some(0));
drop(guard);
pop_execution_policy();
}
#[test]
fn some_zero_rejects_with_budget_exceeded() {
clear_execution_policy_stacks();
push_execution_policy(policy_with_limit(Some(0)));
let error =
enter_nested_execution_policy(None, NestedExecutionKind::AgentLoop, "research-worker")
.unwrap_err();
match error {
VmError::CategorizedError { message, category } => {
assert_eq!(category, ErrorCategory::BudgetExceeded);
assert!(
message.contains("agent_loop"),
"missing kind in message: {message}"
);
assert!(
message.contains("research-worker"),
"missing label in message: {message}"
);
}
other => panic!("expected CategorizedError, got {other:?}"),
}
pop_execution_policy();
}
#[test]
fn nested_chain_decrements_until_exhausted() {
clear_execution_policy_stacks();
let outer = enter_nested_execution_policy(
Some(policy_with_limit(Some(2))),
NestedExecutionKind::AgentLoop,
"outer",
)
.unwrap();
assert_eq!(outer.child_limit, Some(2));
let middle =
enter_nested_execution_policy(None, NestedExecutionKind::SubAgentRun, "middle")
.unwrap();
assert_eq!(middle.child_limit, Some(1));
let inner =
enter_nested_execution_policy(None, NestedExecutionKind::AgentLoop, "inner").unwrap();
assert_eq!(inner.child_limit, Some(0));
let exhausted =
enter_nested_execution_policy(None, NestedExecutionKind::SubAgentRun, "innermost")
.unwrap_err();
assert!(matches!(
exhausted,
VmError::CategorizedError {
category: ErrorCategory::BudgetExceeded,
..
}
));
drop(inner);
drop(middle);
drop(outer);
}
#[test]
fn requested_limit_caps_below_parent() {
clear_execution_policy_stacks();
push_execution_policy(policy_with_limit(Some(8)));
let guard = enter_nested_execution_policy(
Some(policy_with_limit(Some(2))),
NestedExecutionKind::WorkflowStage,
"stage-1",
)
.unwrap();
assert_eq!(guard.parent_limit, Some(8));
assert_eq!(guard.child_limit, Some(2));
drop(guard);
pop_execution_policy();
}
#[test]
fn none_parent_and_none_requested_pushes_no_policy() {
clear_execution_policy_stacks();
let guard =
enter_nested_execution_policy(None, NestedExecutionKind::NestedWorkflow, "wf-1")
.unwrap();
assert!(current_execution_policy().is_none());
assert_eq!(guard.parent_limit, None);
assert_eq!(guard.child_limit, None);
drop(guard);
assert!(current_execution_policy().is_none());
}
#[test]
fn top_level_carrier_does_not_propagate_requested_tools_or_capabilities() {
clear_execution_policy_stacks();
let requested = CapabilityPolicy {
tools: vec!["read_only".to_string()],
capabilities: std::collections::BTreeMap::from([(
"workspace".to_string(),
vec!["read_text".to_string()],
)]),
side_effect_level: Some("read_only".to_string()),
recursion_limit: Some(4),
..Default::default()
};
let guard = enter_nested_execution_policy(
Some(requested),
NestedExecutionKind::AgentLoop,
"session-x",
)
.unwrap();
let pushed = current_execution_policy().unwrap();
assert_eq!(pushed.recursion_limit, Some(4));
assert!(pushed.tools.is_empty());
assert!(pushed.capabilities.is_empty());
assert!(pushed.side_effect_level.is_none());
drop(guard);
}
#[test]
fn carrier_inherits_parent_restrictions_when_nesting() {
clear_execution_policy_stacks();
let outer = CapabilityPolicy {
capabilities: std::collections::BTreeMap::from([(
"workspace".to_string(),
vec!["read_text".to_string()],
)]),
side_effect_level: Some("read_only".to_string()),
recursion_limit: Some(3),
..Default::default()
};
push_execution_policy(outer);
let guard =
enter_nested_execution_policy(None, NestedExecutionKind::WorkflowStage, "stage-1")
.unwrap();
let pushed = current_execution_policy().unwrap();
assert_eq!(pushed.recursion_limit, Some(2));
assert_eq!(
pushed.capabilities.get("workspace"),
Some(&vec!["read_text".to_string()])
);
assert_eq!(pushed.side_effect_level.as_deref(), Some("read_only"));
drop(guard);
pop_execution_policy();
}
#[test]
fn workflow_stage_kind_observes_same_budget_semantics() {
clear_execution_policy_stacks();
push_execution_policy(policy_with_limit(Some(1)));
let guard =
enter_nested_execution_policy(None, NestedExecutionKind::WorkflowStage, "build_stage")
.unwrap();
assert_eq!(guard.child_limit, Some(0));
let denied =
enter_nested_execution_policy(None, NestedExecutionKind::WorkflowStage, "verify_stage")
.unwrap_err();
match denied {
VmError::CategorizedError { message, category } => {
assert_eq!(category, ErrorCategory::BudgetExceeded);
assert!(message.contains("workflow_stage"));
assert!(message.contains("verify_stage"));
}
other => panic!("expected CategorizedError, got {other:?}"),
}
drop(guard);
pop_execution_policy();
}
#[test]
fn annotate_nested_execution_options_writes_canonical_keys() {
let mut options: BTreeMap<String, VmValue> = BTreeMap::new();
annotate_nested_execution_options(
&mut options,
NestedExecutionKind::SubAgentRun,
"research-worker",
);
match options.get(NESTED_KIND_OPTION_KEY).unwrap() {
VmValue::String(text) => assert_eq!(text.as_ref(), "sub_agent_run"),
_ => panic!("kind not stored as string"),
}
match options.get(NESTED_LABEL_OPTION_KEY).unwrap() {
VmValue::String(text) => assert_eq!(text.as_ref(), "research-worker"),
_ => panic!("label not stored as string"),
}
}
}