1use 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
26pub const NESTED_KIND_OPTION_KEY: &str = "_nested_kind";
29pub const NESTED_LABEL_OPTION_KEY: &str = "_nested_label";
32
33#[derive(Clone, Copy, Debug, PartialEq, Eq)]
38pub enum NestedExecutionKind {
39 AgentLoop,
41 SubAgentRun,
43 SpawnAgent,
45 WorkflowStage,
47 NestedWorkflow,
50 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 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#[derive(Debug)]
86pub struct NestedExecutionGuard {
87 pushed: bool,
88 pub parent_limit: Option<usize>,
91 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
105pub 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
170pub 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 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 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 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 assert_eq!(pushed.recursion_limit, Some(2));
430 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 let guard =
450 enter_nested_execution_policy(None, NestedExecutionKind::WorkflowStage, "build_stage")
451 .unwrap();
452 assert_eq!(guard.child_limit, Some(0));
453 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}