1use 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
23pub const NESTED_KIND_OPTION_KEY: &str = "_nested_kind";
26pub const NESTED_LABEL_OPTION_KEY: &str = "_nested_label";
29
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
35pub enum NestedExecutionKind {
36 AgentLoop,
38 SubAgentRun,
40 SpawnAgent,
42 WorkflowStage,
44 NestedWorkflow,
47 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 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#[derive(Debug)]
83pub struct NestedExecutionGuard {
84 pushed: bool,
85 pub parent_limit: Option<usize>,
88 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
102pub 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 top_level_agent_loop = parent.is_none() && matches!(kind, NestedExecutionKind::AgentLoop);
150 let pushed = if child_limit.is_some() || top_level_agent_loop {
151 let mut carrier = parent.unwrap_or_else(|| {
152 if top_level_agent_loop {
153 top_level_agent_loop_policy()
154 } else {
155 CapabilityPolicy::default()
156 }
157 });
158 carrier.recursion_limit = child_limit;
159 push_execution_policy(carrier);
160 true
161 } else {
162 false
163 };
164
165 Ok(NestedExecutionGuard {
166 pushed,
167 parent_limit,
168 child_limit,
169 kind,
170 label: label.to_string(),
171 })
172}
173
174fn top_level_agent_loop_policy() -> CapabilityPolicy {
175 CapabilityPolicy {
176 sandbox_profile: SandboxProfile::OsHardened,
177 ..CapabilityPolicy::default()
178 }
179}
180
181pub fn annotate_nested_execution_options(
188 options: &mut crate::value::DictMap,
189 kind: NestedExecutionKind,
190 label: &str,
191) {
192 options.insert(
193 crate::value::intern_key(NESTED_KIND_OPTION_KEY),
194 VmValue::String(arcstr::ArcStr::from(kind.as_str().to_string())),
195 );
196 options.insert(
197 crate::value::intern_key(NESTED_LABEL_OPTION_KEY),
198 VmValue::String(arcstr::ArcStr::from(label.to_string())),
199 );
200}
201
202fn nested_budget_exhausted(kind: NestedExecutionKind, label: &str) -> VmError {
203 let label = if label.is_empty() { "<unnamed>" } else { label };
204 VmError::CategorizedError {
205 message: format!(
206 "nested execution budget exhausted before {}: {}",
207 kind.as_str(),
208 label
209 ),
210 category: ErrorCategory::BudgetExceeded,
211 }
212}
213
214fn emit_descent_event(
215 kind: NestedExecutionKind,
216 label: &str,
217 parent_limit: Option<usize>,
218 child_limit: Option<usize>,
219 rejected: bool,
220) {
221 let mut metadata = std::collections::BTreeMap::new();
222 metadata.insert(
223 "kind".to_string(),
224 serde_json::Value::String(kind.as_str().to_string()),
225 );
226 metadata.insert(
227 "label".to_string(),
228 serde_json::Value::String(label.to_string()),
229 );
230 metadata.insert(
231 "parent_recursion_limit".to_string(),
232 recursion_limit_to_json(parent_limit),
233 );
234 metadata.insert(
235 "child_recursion_limit".to_string(),
236 recursion_limit_to_json(child_limit),
237 );
238 metadata.insert("rejected".to_string(), serde_json::Value::Bool(rejected));
239 let message = if rejected {
240 format!(
241 "nested execution budget exhausted before {}: {}",
242 kind.as_str(),
243 label
244 )
245 } else {
246 format!("nested execution descent into {}: {}", kind.as_str(), label)
247 };
248 log_debug_meta("policy.nested_execution_descent", &message, metadata);
249}
250
251fn recursion_limit_to_json(value: Option<usize>) -> serde_json::Value {
252 match value {
253 Some(n) => serde_json::Value::Number(serde_json::Number::from(n)),
254 None => serde_json::Value::Null,
255 }
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261 use crate::orchestration::clear_execution_policy_stacks;
262
263 fn policy_with_limit(limit: Option<usize>) -> CapabilityPolicy {
264 CapabilityPolicy {
265 recursion_limit: limit,
266 ..Default::default()
267 }
268 }
269
270 #[test]
271 fn none_parent_preserves_requested_limit() {
272 clear_execution_policy_stacks();
273 let requested = Some(policy_with_limit(Some(3)));
274 let guard =
275 enter_nested_execution_policy(requested, NestedExecutionKind::AgentLoop, "session-a")
276 .unwrap();
277 assert_eq!(guard.parent_limit, None);
278 assert_eq!(guard.child_limit, Some(3));
279 assert_eq!(current_execution_policy().unwrap().recursion_limit, Some(3));
280 assert_eq!(
281 current_execution_policy().unwrap().sandbox_profile,
282 crate::orchestration::SandboxProfile::OsHardened
283 );
284 drop(guard);
285 assert!(current_execution_policy().is_none());
286 }
287
288 #[test]
289 fn some_one_allows_one_child_and_gives_child_zero() {
290 clear_execution_policy_stacks();
291 push_execution_policy(policy_with_limit(Some(1)));
292 let guard =
293 enter_nested_execution_policy(None, NestedExecutionKind::SubAgentRun, "child-1")
294 .unwrap();
295 assert_eq!(guard.parent_limit, Some(1));
296 assert_eq!(guard.child_limit, Some(0));
297 assert_eq!(current_execution_policy().unwrap().recursion_limit, Some(0));
298 drop(guard);
299 pop_execution_policy();
300 }
301
302 #[test]
303 fn some_zero_rejects_with_budget_exceeded() {
304 clear_execution_policy_stacks();
305 push_execution_policy(policy_with_limit(Some(0)));
306 let error =
307 enter_nested_execution_policy(None, NestedExecutionKind::AgentLoop, "research-worker")
308 .unwrap_err();
309 match error {
310 VmError::CategorizedError { message, category } => {
311 assert_eq!(category, ErrorCategory::BudgetExceeded);
312 assert!(
313 message.contains("agent_loop"),
314 "missing kind in message: {message}"
315 );
316 assert!(
317 message.contains("research-worker"),
318 "missing label in message: {message}"
319 );
320 }
321 other => panic!("expected CategorizedError, got {other:?}"),
322 }
323 pop_execution_policy();
324 }
325
326 #[test]
327 fn nested_chain_decrements_until_exhausted() {
328 clear_execution_policy_stacks();
329 let outer = enter_nested_execution_policy(
330 Some(policy_with_limit(Some(2))),
331 NestedExecutionKind::AgentLoop,
332 "outer",
333 )
334 .unwrap();
335 assert_eq!(outer.child_limit, Some(2));
336 let middle =
337 enter_nested_execution_policy(None, NestedExecutionKind::SubAgentRun, "middle")
338 .unwrap();
339 assert_eq!(middle.child_limit, Some(1));
340 let inner =
341 enter_nested_execution_policy(None, NestedExecutionKind::AgentLoop, "inner").unwrap();
342 assert_eq!(inner.child_limit, Some(0));
343 let exhausted =
344 enter_nested_execution_policy(None, NestedExecutionKind::SubAgentRun, "innermost")
345 .unwrap_err();
346 assert!(matches!(
347 exhausted,
348 VmError::CategorizedError {
349 category: ErrorCategory::BudgetExceeded,
350 ..
351 }
352 ));
353 drop(inner);
354 drop(middle);
355 drop(outer);
356 }
357
358 #[test]
359 fn requested_limit_caps_below_parent() {
360 clear_execution_policy_stacks();
361 push_execution_policy(policy_with_limit(Some(8)));
362 let guard = enter_nested_execution_policy(
363 Some(policy_with_limit(Some(2))),
364 NestedExecutionKind::WorkflowStage,
365 "stage-1",
366 )
367 .unwrap();
368 assert_eq!(guard.parent_limit, Some(8));
369 assert_eq!(guard.child_limit, Some(2));
371 drop(guard);
372 pop_execution_policy();
373 }
374
375 #[test]
376 fn none_parent_and_none_requested_pushes_no_policy() {
377 clear_execution_policy_stacks();
378 let guard =
379 enter_nested_execution_policy(None, NestedExecutionKind::NestedWorkflow, "wf-1")
380 .unwrap();
381 assert!(current_execution_policy().is_none());
382 assert_eq!(guard.parent_limit, None);
383 assert_eq!(guard.child_limit, None);
384 drop(guard);
385 assert!(current_execution_policy().is_none());
386 }
387
388 #[test]
389 fn top_level_agent_loop_pushes_os_hardened_carrier_without_budget() {
390 clear_execution_policy_stacks();
391 let guard =
392 enter_nested_execution_policy(None, NestedExecutionKind::AgentLoop, "session-secure")
393 .unwrap();
394 let pushed = current_execution_policy().unwrap();
395 assert_eq!(pushed.recursion_limit, None);
396 assert_eq!(
397 pushed.sandbox_profile,
398 crate::orchestration::SandboxProfile::OsHardened
399 );
400 assert!(pushed.tools.is_empty());
401 assert!(pushed.capabilities.is_empty());
402 drop(guard);
403 assert!(current_execution_policy().is_none());
404 }
405
406 #[test]
407 fn top_level_carrier_does_not_propagate_requested_tools_or_capabilities() {
408 clear_execution_policy_stacks();
415 let requested = CapabilityPolicy {
416 tools: vec!["read_only".to_string()],
417 capabilities: std::collections::BTreeMap::from_iter([(
418 "workspace".to_string(),
419 vec!["read_text".to_string()],
420 )]),
421 side_effect_level: Some("read_only".to_string()),
422 recursion_limit: Some(4),
423 ..Default::default()
424 };
425 let guard = enter_nested_execution_policy(
426 Some(requested),
427 NestedExecutionKind::AgentLoop,
428 "session-x",
429 )
430 .unwrap();
431 let pushed = current_execution_policy().unwrap();
432 assert_eq!(pushed.recursion_limit, Some(4));
433 assert_eq!(
434 pushed.sandbox_profile,
435 crate::orchestration::SandboxProfile::OsHardened
436 );
437 assert!(pushed.tools.is_empty());
438 assert!(pushed.capabilities.is_empty());
439 assert!(pushed.side_effect_level.is_none());
440 drop(guard);
441 }
442
443 #[test]
444 fn carrier_inherits_parent_restrictions_when_nesting() {
445 clear_execution_policy_stacks();
451 let outer = CapabilityPolicy {
452 capabilities: std::collections::BTreeMap::from_iter([(
453 "workspace".to_string(),
454 vec!["read_text".to_string()],
455 )]),
456 side_effect_level: Some("read_only".to_string()),
457 recursion_limit: Some(3),
458 ..Default::default()
459 };
460 push_execution_policy(outer);
461 let guard =
462 enter_nested_execution_policy(None, NestedExecutionKind::WorkflowStage, "stage-1")
463 .unwrap();
464 let pushed = current_execution_policy().unwrap();
465 assert_eq!(pushed.recursion_limit, Some(2));
467 assert_eq!(
471 pushed.capabilities.get("workspace"),
472 Some(&vec!["read_text".to_string()])
473 );
474 assert_eq!(pushed.side_effect_level.as_deref(), Some("read_only"));
475 drop(guard);
476 pop_execution_policy();
477 }
478
479 #[test]
480 fn workflow_stage_kind_observes_same_budget_semantics() {
481 clear_execution_policy_stacks();
482 push_execution_policy(policy_with_limit(Some(1)));
483 let guard =
487 enter_nested_execution_policy(None, NestedExecutionKind::WorkflowStage, "build_stage")
488 .unwrap();
489 assert_eq!(guard.child_limit, Some(0));
490 let denied =
492 enter_nested_execution_policy(None, NestedExecutionKind::WorkflowStage, "verify_stage")
493 .unwrap_err();
494 match denied {
495 VmError::CategorizedError { message, category } => {
496 assert_eq!(category, ErrorCategory::BudgetExceeded);
497 assert!(message.contains("workflow_stage"));
498 assert!(message.contains("verify_stage"));
499 }
500 other => panic!("expected CategorizedError, got {other:?}"),
501 }
502 drop(guard);
503 pop_execution_policy();
504 }
505
506 #[test]
507 fn annotate_nested_execution_options_writes_canonical_keys() {
508 let mut options: crate::value::DictMap = crate::value::DictMap::new();
509 annotate_nested_execution_options(
510 &mut options,
511 NestedExecutionKind::SubAgentRun,
512 "research-worker",
513 );
514 match options.get(NESTED_KIND_OPTION_KEY).unwrap() {
515 VmValue::String(text) => assert_eq!(text.as_str(), "sub_agent_run"),
516 _ => panic!("kind not stored as string"),
517 }
518 match options.get(NESTED_LABEL_OPTION_KEY).unwrap() {
519 VmValue::String(text) => assert_eq!(text.as_str(), "research-worker"),
520 _ => panic!("label not stored as string"),
521 }
522 }
523}