1use crate::budget::BudgetLimits;
6use crate::error::ToolError;
7use crate::session::DeferredToolLoadAuthority;
8use crate::types::{Message, ToolNameSet};
9use serde::{Deserialize, Serialize};
10use uuid::Uuid;
11
12#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
14pub struct OperationId(pub Uuid);
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum WaitPolicy {
23 Barrier,
25 Detached,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
35pub struct AsyncOpRef {
36 pub operation_id: OperationId,
37 pub wait_policy: WaitPolicy,
38}
39
40impl WaitPolicy {
41 pub fn barrier() -> Self {
43 Self::Barrier
44 }
45
46 pub fn detached() -> Self {
48 Self::Detached
49 }
50}
51
52impl AsyncOpRef {
53 pub fn barrier(operation_id: OperationId) -> Self {
55 Self {
56 operation_id,
57 wait_policy: WaitPolicy::barrier(),
58 }
59 }
60
61 pub fn detached(operation_id: OperationId) -> Self {
63 Self {
64 operation_id,
65 wait_policy: WaitPolicy::detached(),
66 }
67 }
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(tag = "effect_type", rename_all = "snake_case")]
84pub enum SessionEffect {
85 ReplaceMobToolAuthorityContext {
92 authority_context: crate::service::MobToolAuthorityContext,
93 },
94 RequestDeferredTools {
96 authorities: Vec<DeferredToolLoadAuthority>,
97 },
98 AppendAssistantBlocks {
101 blocks: Vec<crate::types::AssistantBlock>,
102 },
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum ToolDispatchTerminalErrorKind {
107 NotFound,
108 Unavailable,
109 InvalidArguments,
110 ExecutionFailed,
111 Timeout,
112 AccessDenied,
113 Other,
114 CallbackPending,
115}
116
117impl From<&ToolError> for ToolDispatchTerminalErrorKind {
118 fn from(error: &ToolError) -> Self {
119 match error {
120 ToolError::NotFound { .. } => Self::NotFound,
121 ToolError::Unavailable { .. } => Self::Unavailable,
122 ToolError::InvalidArguments { .. } => Self::InvalidArguments,
123 ToolError::ExecutionFailed { .. } | ToolError::ExecutionFailedWithData { .. } => {
124 Self::ExecutionFailed
125 }
126 ToolError::Timeout { .. } => Self::Timeout,
127 ToolError::AccessDenied { .. } => Self::AccessDenied,
128 ToolError::Other(_) => Self::Other,
129 ToolError::CallbackPending { .. } => Self::CallbackPending,
130 }
131 }
132}
133
134#[derive(Debug, Clone, PartialEq)]
142pub enum ToolDispatchTerminalCause {
143 RuntimeToolError { error: ToolError },
144}
145
146impl ToolDispatchTerminalCause {
147 #[must_use]
148 pub fn runtime_tool_error(error: &ToolError) -> Self {
149 Self::RuntimeToolError {
150 error: error.clone(),
151 }
152 }
153
154 #[must_use]
156 pub fn kind(&self) -> ToolDispatchTerminalErrorKind {
157 match self {
158 Self::RuntimeToolError { error } => ToolDispatchTerminalErrorKind::from(error),
159 }
160 }
161
162 #[must_use]
168 pub fn to_transcript_content(&self) -> String {
169 match self {
170 Self::RuntimeToolError { error } => error.to_transcript_content(),
171 }
172 }
173
174 #[must_use]
175 pub fn is_runtime_tool_timeout(&self) -> bool {
176 self.kind() == ToolDispatchTerminalErrorKind::Timeout
177 }
178}
179
180#[derive(Debug, Clone)]
181pub struct ToolDispatchOutcome {
182 pub result: crate::types::ToolResult,
184 pub async_ops: Vec<AsyncOpRef>,
189 pub session_effects: Vec<SessionEffect>,
195 terminal_cause: Option<ToolDispatchTerminalCause>,
200}
201
202#[derive(Debug, Clone, Copy, PartialEq, Eq)]
204pub enum ToolDispatchTimeoutPolicy {
205 Default { timeout: std::time::Duration },
207 Disabled,
210 Finite { timeout: std::time::Duration },
212}
213
214impl ToolDispatchTimeoutPolicy {
215 #[must_use]
216 pub fn timeout(self) -> Option<std::time::Duration> {
217 match self {
218 Self::Default { timeout } | Self::Finite { timeout } => Some(timeout),
219 Self::Disabled => None,
220 }
221 }
222
223 #[must_use]
224 pub fn timeout_ms(self) -> Option<u64> {
225 self.timeout()
226 .map(|timeout| u64::try_from(timeout.as_millis()).unwrap_or(u64::MAX))
227 }
228}
229
230impl ToolDispatchOutcome {
231 pub fn new(
233 result: crate::types::ToolResult,
234 async_ops: Vec<AsyncOpRef>,
235 session_effects: Vec<SessionEffect>,
236 ) -> Self {
237 Self {
238 result,
239 async_ops,
240 session_effects,
241 terminal_cause: None,
242 }
243 }
244
245 pub fn sync_result(result: crate::types::ToolResult) -> Self {
247 Self::new(result, Vec::new(), Vec::new())
248 }
249
250 #[must_use]
251 pub fn terminal_cause(&self) -> Option<&ToolDispatchTerminalCause> {
252 self.terminal_cause.as_ref()
253 }
254
255 #[must_use]
256 pub fn is_runtime_tool_timeout(&self) -> bool {
257 self.terminal_cause
258 .as_ref()
259 .is_some_and(ToolDispatchTerminalCause::is_runtime_tool_timeout)
260 }
261
262 pub(crate) fn clear_terminal_cause(&mut self) {
263 self.terminal_cause = None;
264 }
265}
266
267impl From<crate::types::ToolResult> for ToolDispatchOutcome {
268 fn from(result: crate::types::ToolResult) -> Self {
269 Self::sync_result(result)
270 }
271}
272
273pub fn terminal_tool_outcome_for_error(
276 tool_use_id: impl Into<String>,
277 error: ToolError,
278) -> ToolDispatchOutcome {
279 let terminal_cause = ToolDispatchTerminalCause::RuntimeToolError { error };
280 let content = terminal_cause.to_transcript_content();
284 let mut outcome = ToolDispatchOutcome::sync_result(crate::types::ToolResult::new(
285 tool_use_id.into(),
286 content,
287 true,
288 ));
289 outcome.terminal_cause = Some(terminal_cause);
290 outcome
291}
292
293impl OperationId {
294 pub fn new() -> Self {
296 Self(crate::time_compat::new_uuid_v7())
297 }
298}
299
300impl Default for OperationId {
301 fn default() -> Self {
302 Self::new()
303 }
304}
305
306impl std::fmt::Display for OperationId {
307 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
308 write!(f, "{}", self.0)
309 }
310}
311
312#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
314#[serde(rename_all = "snake_case")]
315pub enum WorkKind {
316 ToolCall,
318 ShellCommand,
320}
321
322#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
324#[serde(rename_all = "snake_case")]
325pub enum ResultShape {
326 Single,
328 Stream,
330 Batch,
332}
333
334#[derive(Debug, Clone, Default, Serialize, Deserialize)]
336#[serde(tag = "type", content = "value", rename_all = "snake_case")]
337pub enum ContextStrategy {
338 #[default]
340 FullHistory,
341 LastTurns(u32),
343 Summary { max_tokens: u32 },
345 Custom { messages: Vec<Message> },
347}
348
349#[derive(Debug, Clone, Default, Serialize, Deserialize)]
351#[serde(tag = "type", content = "value", rename_all = "snake_case")]
352pub enum ForkBudgetPolicy {
353 #[default]
355 Equal,
356 Proportional,
358 Fixed(u64),
360 Remaining,
362}
363
364#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
366#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
367#[serde(tag = "type", content = "value", rename_all = "snake_case")]
368pub enum ToolAccessPolicy {
369 #[default]
371 Inherit,
372 AllowList(ToolNameSet),
374 DenyList(ToolNameSet),
376}
377
378#[derive(Debug, Clone, Serialize, Deserialize, Default)]
380pub struct OperationPolicy {
381 pub timeout_ms: Option<u64>,
383 pub cancel_on_parent_cancel: bool,
385 pub checkpoint_results: bool,
387}
388
389#[derive(Debug, Clone, Serialize, Deserialize)]
391pub struct OperationSpec {
392 pub id: OperationId,
393 pub kind: WorkKind,
394 pub result_shape: ResultShape,
395 pub policy: OperationPolicy,
396 pub budget_reservation: BudgetLimits,
397 pub depth: u32,
398 pub depends_on: Vec<OperationId>,
399 pub context: Option<ContextStrategy>,
400 pub tool_access: Option<ToolAccessPolicy>,
401}
402
403#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
405pub struct OperationResult {
406 pub id: OperationId,
407 pub content: String,
408 pub is_error: bool,
409 pub duration_ms: u64,
410 pub tokens_used: u64,
411}
412
413#[derive(Debug, Clone, Serialize, Deserialize)]
415#[serde(tag = "type", rename_all = "snake_case")]
416pub enum OpEvent {
417 Started { id: OperationId, kind: WorkKind },
419
420 Progress {
422 id: OperationId,
423 message: String,
424 percent: Option<f32>,
425 },
426
427 Completed {
429 id: OperationId,
430 result: OperationResult,
431 },
432
433 Failed { id: OperationId, error: String },
435
436 Cancelled { id: OperationId },
438}
439
440#[derive(Debug, Clone, Serialize, Deserialize)]
442pub struct ConcurrencyLimits {
443 pub max_depth: u32,
445 pub max_concurrent_ops: usize,
447 pub max_concurrent_agents: usize,
449 pub max_children_per_agent: usize,
451}
452
453impl Default for ConcurrencyLimits {
454 fn default() -> Self {
455 Self {
456 max_depth: 3,
457 max_concurrent_ops: 32,
458 max_concurrent_agents: 8,
459 max_children_per_agent: 5,
460 }
461 }
462}
463
464#[derive(Debug, Clone, Serialize, Deserialize, Default)]
466pub struct SpawnSpec {
467 pub prompt: String,
469 pub context: ContextStrategy,
471 pub tool_access: ToolAccessPolicy,
473 pub budget: BudgetLimits,
475 pub allow_spawn: bool,
477 pub system_prompt: Option<String>,
479}
480
481#[derive(Debug, Clone, Serialize, Deserialize)]
483pub struct ForkBranch {
484 pub name: String,
486 pub prompt: String,
488 pub tool_access: Option<ToolAccessPolicy>,
490}
491
492#[cfg(test)]
493#[allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
494mod tests {
495 use super::*;
496
497 fn generated_mob_authority_for_test() -> crate::service::MobToolAuthorityContext {
498 crate::service::MobToolAuthorityContext::generated_for_test(
499 crate::service::OpaquePrincipalToken::new("generated-effect-test"),
500 true,
501 true,
502 false,
503 std::collections::BTreeSet::from(["test-mob".to_string()]),
504 std::collections::BTreeMap::new(),
505 None,
506 None,
507 )
508 }
509
510 #[test]
511 fn barrier_constructor_produces_barrier_policy() {
512 assert_eq!(WaitPolicy::barrier(), WaitPolicy::Barrier);
513 let op_ref = AsyncOpRef::barrier(OperationId::new());
514 assert_eq!(op_ref.wait_policy, WaitPolicy::Barrier);
515 }
516
517 #[test]
518 fn detached_constructor_produces_detached_policy() {
519 assert_eq!(WaitPolicy::detached(), WaitPolicy::Detached);
520 let op_ref = AsyncOpRef::detached(OperationId::new());
521 assert_eq!(op_ref.wait_policy, WaitPolicy::Detached);
522 }
523
524 #[test]
525 fn test_operation_id_encoding() {
526 let id = OperationId::new();
527 let json = serde_json::to_string(&id).unwrap();
528
529 let parsed: OperationId = serde_json::from_str(&json).unwrap();
530 assert_eq!(id, parsed);
531 }
532
533 #[test]
534 fn test_work_kind_serialization() {
535 assert_eq!(
536 serde_json::to_value(WorkKind::ToolCall).unwrap(),
537 "tool_call"
538 );
539 assert_eq!(
540 serde_json::to_value(WorkKind::ShellCommand).unwrap(),
541 "shell_command"
542 );
543 }
544
545 #[test]
546 fn test_context_strategy_serialization() {
547 let full = ContextStrategy::FullHistory;
548 let json = serde_json::to_value(&full).unwrap();
549 assert_eq!(json["type"], "full_history");
550
551 let last = ContextStrategy::LastTurns(5);
552 let json = serde_json::to_value(&last).unwrap();
553 assert_eq!(json["type"], "last_turns");
554 assert_eq!(json["value"], 5);
556
557 let summary = ContextStrategy::Summary { max_tokens: 1000 };
558 let json = serde_json::to_value(&summary).unwrap();
559 assert_eq!(json["type"], "summary");
560 assert_eq!(json["value"]["max_tokens"], 1000);
562
563 let parsed: ContextStrategy = serde_json::from_value(json).unwrap();
565 match parsed {
566 ContextStrategy::Summary { max_tokens } => assert_eq!(max_tokens, 1000),
567 _ => unreachable!("Wrong variant"),
568 }
569 }
570
571 #[test]
572 fn test_fork_budget_policy_serialization() {
573 let policies = vec![
574 (ForkBudgetPolicy::Equal, "equal"),
575 (ForkBudgetPolicy::Proportional, "proportional"),
576 (ForkBudgetPolicy::Remaining, "remaining"),
577 ];
578
579 for (policy, expected_type) in policies {
580 let json = serde_json::to_value(&policy).unwrap();
581 assert_eq!(json["type"], expected_type);
582 }
583
584 let fixed = ForkBudgetPolicy::Fixed(5000);
585 let json = serde_json::to_value(&fixed).unwrap();
586 assert_eq!(json["type"], "fixed");
587 assert_eq!(json["value"], 5000);
589
590 let parsed: ForkBudgetPolicy = serde_json::from_value(json).unwrap();
592 match parsed {
593 ForkBudgetPolicy::Fixed(tokens) => assert_eq!(tokens, 5000),
594 _ => unreachable!("Wrong variant"),
595 }
596 }
597
598 #[test]
599 fn test_tool_access_policy_serialization() {
600 let inherit = ToolAccessPolicy::Inherit;
601 let json = serde_json::to_value(&inherit).unwrap();
602 assert_eq!(json["type"], "inherit");
603
604 let allow = ToolAccessPolicy::AllowList(["read_file", "write_file"].into_iter().collect());
605 let json = serde_json::to_value(&allow).unwrap();
606 assert_eq!(json["type"], "allow_list");
607 assert!(json["value"].is_array());
609
610 let deny = ToolAccessPolicy::DenyList(["dangerous_tool"].into_iter().collect());
611 let json = serde_json::to_value(&deny).unwrap();
612 assert_eq!(json["type"], "deny_list");
613 assert!(json["value"].is_array());
614
615 let parsed: ToolAccessPolicy = serde_json::from_value(json).unwrap();
617 match parsed {
618 ToolAccessPolicy::DenyList(tools) => {
619 assert_eq!(tools.len(), 1);
620 assert!(tools.contains("dangerous_tool"));
621 }
622 _ => unreachable!("Wrong variant"),
623 }
624 }
625
626 #[test]
627 fn test_op_event_serialization() {
628 let events = vec![
629 OpEvent::Started {
630 id: OperationId::new(),
631 kind: WorkKind::ToolCall,
632 },
633 OpEvent::Progress {
634 id: OperationId::new(),
635 message: "50% complete".to_string(),
636 percent: Some(0.5),
637 },
638 OpEvent::Completed {
639 id: OperationId::new(),
640 result: OperationResult {
641 id: OperationId::new(),
642 content: "result".to_string(),
643 is_error: false,
644 duration_ms: 100,
645 tokens_used: 50,
646 },
647 },
648 OpEvent::Failed {
649 id: OperationId::new(),
650 error: "timeout".to_string(),
651 },
652 OpEvent::Cancelled {
653 id: OperationId::new(),
654 },
655 ];
656
657 for event in events {
658 let json = serde_json::to_value(&event).unwrap();
659 assert!(json.get("type").is_some());
660
661 let _: OpEvent = serde_json::from_value(json).unwrap();
663 }
664 }
665
666 #[test]
667 fn test_concurrency_limits_default() {
668 let limits = ConcurrencyLimits::default();
669 assert_eq!(limits.max_depth, 3);
670 assert_eq!(limits.max_concurrent_ops, 32);
671 assert_eq!(limits.max_concurrent_agents, 8);
672 assert_eq!(limits.max_children_per_agent, 5);
673 }
674
675 #[test]
676 fn session_effect_replace_mob_authority_context_deserializes_without_authority_seal() {
677 let effect = SessionEffect::ReplaceMobToolAuthorityContext {
678 authority_context: generated_mob_authority_for_test(),
679 };
680 let json = serde_json::to_value(&effect).unwrap();
681 let parsed: SessionEffect = serde_json::from_value(json).unwrap();
682 match parsed {
683 SessionEffect::ReplaceMobToolAuthorityContext { authority_context } => {
684 assert!(!authority_context.is_generated_authority_context());
685 assert!(!authority_context.can_create_mobs());
686 assert!(!authority_context.can_mutate_profiles());
687 assert!(!authority_context.can_manage_mob("test-mob"));
688 }
689 other => panic!("unexpected session effect: {other:?}"),
690 }
691 }
692
693 #[test]
694 fn tool_dispatch_outcome_with_session_effects() {
695 let result = crate::types::ToolResult::new("t1".into(), "ok".into(), false);
696 let outcome = ToolDispatchOutcome::new(
697 result,
698 vec![],
699 vec![SessionEffect::ReplaceMobToolAuthorityContext {
700 authority_context: generated_mob_authority_for_test(),
701 }],
702 );
703 assert_eq!(outcome.session_effects.len(), 1);
704 assert_eq!(outcome.terminal_cause(), None);
705 }
706
707 #[test]
708 fn tool_dispatch_outcome_sync_result_has_empty_effects() {
709 let result = crate::types::ToolResult::new("t1".into(), "ok".into(), false);
710 let outcome = ToolDispatchOutcome::sync_result(result);
711 assert!(outcome.session_effects.is_empty());
712 assert_eq!(outcome.terminal_cause(), None);
713 }
714
715 #[test]
716 fn terminal_tool_outcome_carries_runtime_timeout_cause() {
717 let outcome = terminal_tool_outcome_for_error("t1", ToolError::timeout("slow_tool", 50));
718
719 assert!(outcome.result.is_error);
720 assert!(outcome.is_runtime_tool_timeout());
721 let cause = outcome.terminal_cause().expect("terminal cause present");
722 assert_eq!(cause.kind(), ToolDispatchTerminalErrorKind::Timeout);
723 assert_eq!(
724 cause,
725 &ToolDispatchTerminalCause::RuntimeToolError {
726 error: ToolError::timeout("slow_tool", 50),
727 }
728 );
729 }
730
731 #[test]
732 fn terminal_tool_outcome_transcript_text_is_derived_purely_from_terminal_cause() {
733 let error = ToolError::execution_failed_with_data(
737 "boom",
738 serde_json::json!({ "detail": "structured", "n": 7 }),
739 );
740 let outcome = terminal_tool_outcome_for_error("t1", error);
741
742 let cause = outcome.terminal_cause().expect("terminal cause present");
743 assert_eq!(outcome.result.text_content(), cause.to_transcript_content());
744 }
745
746 #[test]
747 fn tool_authored_error_result_has_no_runtime_terminal_cause() {
748 let result =
749 crate::types::ToolResult::new("t1".into(), "{\"error\":\"timeout\"}".into(), true);
750 let outcome = ToolDispatchOutcome::sync_result(result);
751
752 assert!(outcome.result.is_error);
753 assert!(!outcome.is_runtime_tool_timeout());
754 assert_eq!(outcome.terminal_cause(), None);
755 }
756}