1use 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
25pub const NESTED_KIND_OPTION_KEY: &str = "_nested_kind";
28pub const NESTED_LABEL_OPTION_KEY: &str = "_nested_label";
31
32#[derive(Clone, Copy, Debug, PartialEq, Eq)]
37pub enum NestedExecutionKind {
38 AgentLoop,
40 SubAgentRun,
42 SpawnAgent,
44 WorkflowStage,
46 NestedWorkflow,
49 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 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#[derive(Debug)]
85pub struct NestedExecutionGuard {
86 pushed: bool,
87 pub parent_limit: Option<usize>,
90 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
104pub 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
169pub 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 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 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 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 assert_eq!(pushed.recursion_limit, Some(2));
429 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 let guard =
449 enter_nested_execution_policy(None, NestedExecutionKind::WorkflowStage, "build_stage")
450 .unwrap();
451 assert_eq!(guard.child_limit, Some(0));
452 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}