1use super::CapabilityPolicy;
19use crate::events::log_info_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 pushed = if let Some(limit) = child_limit {
150 let mut carrier = parent.unwrap_or_default();
151 carrier.recursion_limit = Some(limit);
152 push_execution_policy(carrier);
153 true
154 } else {
155 false
156 };
157
158 Ok(NestedExecutionGuard {
159 pushed,
160 parent_limit,
161 child_limit,
162 kind,
163 label: label.to_string(),
164 })
165}
166
167pub fn annotate_nested_execution_options(
174 options: &mut crate::value::DictMap,
175 kind: NestedExecutionKind,
176 label: &str,
177) {
178 options.insert(
179 crate::value::intern_key(NESTED_KIND_OPTION_KEY),
180 VmValue::String(arcstr::ArcStr::from(kind.as_str().to_string())),
181 );
182 options.insert(
183 crate::value::intern_key(NESTED_LABEL_OPTION_KEY),
184 VmValue::String(arcstr::ArcStr::from(label.to_string())),
185 );
186}
187
188fn nested_budget_exhausted(kind: NestedExecutionKind, label: &str) -> VmError {
189 let label = if label.is_empty() { "<unnamed>" } else { label };
190 VmError::CategorizedError {
191 message: format!(
192 "nested execution budget exhausted before {}: {}",
193 kind.as_str(),
194 label
195 ),
196 category: ErrorCategory::BudgetExceeded,
197 }
198}
199
200fn emit_descent_event(
201 kind: NestedExecutionKind,
202 label: &str,
203 parent_limit: Option<usize>,
204 child_limit: Option<usize>,
205 rejected: bool,
206) {
207 let mut metadata = std::collections::BTreeMap::new();
208 metadata.insert(
209 "kind".to_string(),
210 serde_json::Value::String(kind.as_str().to_string()),
211 );
212 metadata.insert(
213 "label".to_string(),
214 serde_json::Value::String(label.to_string()),
215 );
216 metadata.insert(
217 "parent_recursion_limit".to_string(),
218 recursion_limit_to_json(parent_limit),
219 );
220 metadata.insert(
221 "child_recursion_limit".to_string(),
222 recursion_limit_to_json(child_limit),
223 );
224 metadata.insert("rejected".to_string(), serde_json::Value::Bool(rejected));
225 let message = if rejected {
226 format!(
227 "nested execution budget exhausted before {}: {}",
228 kind.as_str(),
229 label
230 )
231 } else {
232 format!("nested execution descent into {}: {}", kind.as_str(), label)
233 };
234 log_info_meta("policy.nested_execution_descent", &message, metadata);
235}
236
237fn recursion_limit_to_json(value: Option<usize>) -> serde_json::Value {
238 match value {
239 Some(n) => serde_json::Value::Number(serde_json::Number::from(n)),
240 None => serde_json::Value::Null,
241 }
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247 use crate::orchestration::clear_execution_policy_stacks;
248
249 fn policy_with_limit(limit: Option<usize>) -> CapabilityPolicy {
250 CapabilityPolicy {
251 recursion_limit: limit,
252 ..Default::default()
253 }
254 }
255
256 #[test]
257 fn none_parent_preserves_requested_limit() {
258 clear_execution_policy_stacks();
259 let requested = Some(policy_with_limit(Some(3)));
260 let guard =
261 enter_nested_execution_policy(requested, NestedExecutionKind::AgentLoop, "session-a")
262 .unwrap();
263 assert_eq!(guard.parent_limit, None);
264 assert_eq!(guard.child_limit, Some(3));
265 assert_eq!(current_execution_policy().unwrap().recursion_limit, Some(3));
266 drop(guard);
267 assert!(current_execution_policy().is_none());
268 }
269
270 #[test]
271 fn some_one_allows_one_child_and_gives_child_zero() {
272 clear_execution_policy_stacks();
273 push_execution_policy(policy_with_limit(Some(1)));
274 let guard =
275 enter_nested_execution_policy(None, NestedExecutionKind::SubAgentRun, "child-1")
276 .unwrap();
277 assert_eq!(guard.parent_limit, Some(1));
278 assert_eq!(guard.child_limit, Some(0));
279 assert_eq!(current_execution_policy().unwrap().recursion_limit, Some(0));
280 drop(guard);
281 pop_execution_policy();
282 }
283
284 #[test]
285 fn some_zero_rejects_with_budget_exceeded() {
286 clear_execution_policy_stacks();
287 push_execution_policy(policy_with_limit(Some(0)));
288 let error =
289 enter_nested_execution_policy(None, NestedExecutionKind::AgentLoop, "research-worker")
290 .unwrap_err();
291 match error {
292 VmError::CategorizedError { message, category } => {
293 assert_eq!(category, ErrorCategory::BudgetExceeded);
294 assert!(
295 message.contains("agent_loop"),
296 "missing kind in message: {message}"
297 );
298 assert!(
299 message.contains("research-worker"),
300 "missing label in message: {message}"
301 );
302 }
303 other => panic!("expected CategorizedError, got {other:?}"),
304 }
305 pop_execution_policy();
306 }
307
308 #[test]
309 fn nested_chain_decrements_until_exhausted() {
310 clear_execution_policy_stacks();
311 let outer = enter_nested_execution_policy(
312 Some(policy_with_limit(Some(2))),
313 NestedExecutionKind::AgentLoop,
314 "outer",
315 )
316 .unwrap();
317 assert_eq!(outer.child_limit, Some(2));
318 let middle =
319 enter_nested_execution_policy(None, NestedExecutionKind::SubAgentRun, "middle")
320 .unwrap();
321 assert_eq!(middle.child_limit, Some(1));
322 let inner =
323 enter_nested_execution_policy(None, NestedExecutionKind::AgentLoop, "inner").unwrap();
324 assert_eq!(inner.child_limit, Some(0));
325 let exhausted =
326 enter_nested_execution_policy(None, NestedExecutionKind::SubAgentRun, "innermost")
327 .unwrap_err();
328 assert!(matches!(
329 exhausted,
330 VmError::CategorizedError {
331 category: ErrorCategory::BudgetExceeded,
332 ..
333 }
334 ));
335 drop(inner);
336 drop(middle);
337 drop(outer);
338 }
339
340 #[test]
341 fn requested_limit_caps_below_parent() {
342 clear_execution_policy_stacks();
343 push_execution_policy(policy_with_limit(Some(8)));
344 let guard = enter_nested_execution_policy(
345 Some(policy_with_limit(Some(2))),
346 NestedExecutionKind::WorkflowStage,
347 "stage-1",
348 )
349 .unwrap();
350 assert_eq!(guard.parent_limit, Some(8));
351 assert_eq!(guard.child_limit, Some(2));
353 drop(guard);
354 pop_execution_policy();
355 }
356
357 #[test]
358 fn none_parent_and_none_requested_pushes_no_policy() {
359 clear_execution_policy_stacks();
360 let guard =
361 enter_nested_execution_policy(None, NestedExecutionKind::NestedWorkflow, "wf-1")
362 .unwrap();
363 assert!(current_execution_policy().is_none());
364 assert_eq!(guard.parent_limit, None);
365 assert_eq!(guard.child_limit, None);
366 drop(guard);
367 assert!(current_execution_policy().is_none());
368 }
369
370 #[test]
371 fn top_level_carrier_does_not_propagate_requested_tools_or_capabilities() {
372 clear_execution_policy_stacks();
379 let requested = CapabilityPolicy {
380 tools: vec!["read_only".to_string()],
381 capabilities: std::collections::BTreeMap::from_iter([(
382 "workspace".to_string(),
383 vec!["read_text".to_string()],
384 )]),
385 side_effect_level: Some("read_only".to_string()),
386 recursion_limit: Some(4),
387 ..Default::default()
388 };
389 let guard = enter_nested_execution_policy(
390 Some(requested),
391 NestedExecutionKind::AgentLoop,
392 "session-x",
393 )
394 .unwrap();
395 let pushed = current_execution_policy().unwrap();
396 assert_eq!(pushed.recursion_limit, Some(4));
397 assert!(pushed.tools.is_empty());
398 assert!(pushed.capabilities.is_empty());
399 assert!(pushed.side_effect_level.is_none());
400 drop(guard);
401 }
402
403 #[test]
404 fn carrier_inherits_parent_restrictions_when_nesting() {
405 clear_execution_policy_stacks();
411 let outer = CapabilityPolicy {
412 capabilities: std::collections::BTreeMap::from_iter([(
413 "workspace".to_string(),
414 vec!["read_text".to_string()],
415 )]),
416 side_effect_level: Some("read_only".to_string()),
417 recursion_limit: Some(3),
418 ..Default::default()
419 };
420 push_execution_policy(outer);
421 let guard =
422 enter_nested_execution_policy(None, NestedExecutionKind::WorkflowStage, "stage-1")
423 .unwrap();
424 let pushed = current_execution_policy().unwrap();
425 assert_eq!(pushed.recursion_limit, Some(2));
427 assert_eq!(
431 pushed.capabilities.get("workspace"),
432 Some(&vec!["read_text".to_string()])
433 );
434 assert_eq!(pushed.side_effect_level.as_deref(), Some("read_only"));
435 drop(guard);
436 pop_execution_policy();
437 }
438
439 #[test]
440 fn workflow_stage_kind_observes_same_budget_semantics() {
441 clear_execution_policy_stacks();
442 push_execution_policy(policy_with_limit(Some(1)));
443 let guard =
447 enter_nested_execution_policy(None, NestedExecutionKind::WorkflowStage, "build_stage")
448 .unwrap();
449 assert_eq!(guard.child_limit, Some(0));
450 let denied =
452 enter_nested_execution_policy(None, NestedExecutionKind::WorkflowStage, "verify_stage")
453 .unwrap_err();
454 match denied {
455 VmError::CategorizedError { message, category } => {
456 assert_eq!(category, ErrorCategory::BudgetExceeded);
457 assert!(message.contains("workflow_stage"));
458 assert!(message.contains("verify_stage"));
459 }
460 other => panic!("expected CategorizedError, got {other:?}"),
461 }
462 drop(guard);
463 pop_execution_policy();
464 }
465
466 #[test]
467 fn annotate_nested_execution_options_writes_canonical_keys() {
468 let mut options: crate::value::DictMap = crate::value::DictMap::new();
469 annotate_nested_execution_options(
470 &mut options,
471 NestedExecutionKind::SubAgentRun,
472 "research-worker",
473 );
474 match options.get(NESTED_KIND_OPTION_KEY).unwrap() {
475 VmValue::String(text) => assert_eq!(text.as_str(), "sub_agent_run"),
476 _ => panic!("kind not stored as string"),
477 }
478 match options.get(NESTED_LABEL_OPTION_KEY).unwrap() {
479 VmValue::String(text) => assert_eq!(text.as_str(), "research-worker"),
480 _ => panic!("label not stored as string"),
481 }
482 }
483}