1use std::collections::BTreeSet;
7use std::fmt::Write as _;
8use std::sync::Arc;
9use std::time::Duration;
10
11use sha2::{Digest, Sha256};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
15pub enum ToolExecutionMode {
16 Fast,
17 Streaming,
18 Detached,
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
23pub enum ToolDeadlineOwner {
24 CoreToolDispatch,
25 Dispatcher,
26 ToolInternal,
27 StreamingAbsolute,
28 MobkitPublicCallback,
29 SdkCallbackCancellation,
30 GatewayWire,
31 DetachedSubmission,
32 DirectCaller,
33}
34
35impl ToolDeadlineOwner {
36 pub const fn as_str(self) -> &'static str {
37 match self {
38 Self::CoreToolDispatch => "core tool dispatch",
39 Self::Dispatcher => "dispatcher",
40 Self::ToolInternal => "tool internal",
41 Self::StreamingAbsolute => "streaming absolute",
42 Self::MobkitPublicCallback => "mobkit public callback",
43 Self::SdkCallbackCancellation => "sdk callback cancellation",
44 Self::GatewayWire => "gateway wire",
45 Self::DetachedSubmission => "detached submission",
46 Self::DirectCaller => "direct caller",
47 }
48 }
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub struct ToolDeadlineContributor {
54 owner: ToolDeadlineOwner,
55 timeout: Option<Duration>,
56}
57
58impl ToolDeadlineContributor {
59 pub const fn finite(owner: ToolDeadlineOwner, timeout: Duration) -> Self {
60 Self {
61 owner,
62 timeout: Some(timeout),
63 }
64 }
65
66 pub const fn unbounded(owner: ToolDeadlineOwner) -> Self {
67 Self {
68 owner,
69 timeout: None,
70 }
71 }
72
73 pub const fn owner(&self) -> ToolDeadlineOwner {
74 self.owner
75 }
76
77 pub const fn timeout(&self) -> Option<Duration> {
78 self.timeout
79 }
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
84pub enum DeadlineChainError {
85 Empty,
86 Zero { owner: ToolDeadlineOwner },
87}
88
89impl std::fmt::Display for DeadlineChainError {
90 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91 match self {
92 Self::Empty => formatter.write_str("tool deadline chain must not be empty"),
93 Self::Zero { owner } => write!(
94 formatter,
95 "tool deadline from '{}' must be greater than zero",
96 owner.as_str()
97 ),
98 }
99 }
100}
101
102impl std::error::Error for DeadlineChainError {}
103
104#[derive(Debug, Clone, PartialEq, Eq)]
111pub enum DeadlineChainExtensionError {
112 ShorterThanUpstream {
113 upstream_len: usize,
114 resolved_len: usize,
115 },
116 ContributorMismatch {
117 index: usize,
118 expected: ToolDeadlineContributor,
119 actual: ToolDeadlineContributor,
120 },
121}
122
123impl std::fmt::Display for DeadlineChainExtensionError {
124 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125 match self {
126 Self::ShorterThanUpstream {
127 upstream_len,
128 resolved_len,
129 } => write!(
130 formatter,
131 "resolved tool deadline chain has {resolved_len} contributors but its upstream chain has {upstream_len}"
132 ),
133 Self::ContributorMismatch {
134 index,
135 expected,
136 actual,
137 } => write!(
138 formatter,
139 "resolved tool deadline contributor {index} replaced upstream owner '{}' ({:?}) with '{}' ({:?})",
140 expected.owner().as_str(),
141 expected.timeout(),
142 actual.owner().as_str(),
143 actual.timeout()
144 ),
145 }
146 }
147}
148
149impl std::error::Error for DeadlineChainExtensionError {}
150
151#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct ToolDeadlineChain {
154 contributors: Vec<ToolDeadlineContributor>,
155}
156
157impl ToolDeadlineChain {
158 pub fn new(contributors: Vec<ToolDeadlineContributor>) -> Result<Self, DeadlineChainError> {
159 if contributors.is_empty() {
160 return Err(DeadlineChainError::Empty);
161 }
162 if let Some(contributor) = contributors
163 .iter()
164 .find(|contributor| contributor.timeout == Some(Duration::ZERO))
165 {
166 return Err(DeadlineChainError::Zero {
167 owner: contributor.owner,
168 });
169 }
170 Ok(Self { contributors })
171 }
172
173 pub fn contributors(&self) -> &[ToolDeadlineContributor] {
174 &self.contributors
175 }
176
177 pub fn winner(&self) -> Option<&ToolDeadlineContributor> {
178 self.contributors
179 .iter()
180 .filter(|contributor| contributor.timeout.is_some())
181 .min_by_key(|contributor| contributor.timeout)
182 }
183
184 pub fn effective_timeout(&self) -> Option<Duration> {
185 self.winner().and_then(ToolDeadlineContributor::timeout)
186 }
187
188 pub fn diagnostic(&self) -> String {
189 let mut diagnostic = String::new();
190 match self.effective_timeout() {
191 Some(timeout) => {
192 let _ = writeln!(
193 diagnostic,
194 "effective deadline: {}",
195 format_duration(timeout)
196 );
197 }
198 None => diagnostic.push_str("effective deadline: unbounded\n"),
199 }
200 diagnostic.push_str("contributors:\n");
201 for contributor in &self.contributors {
202 let limit = contributor
203 .timeout
204 .map_or_else(|| "unbounded".to_string(), format_duration);
205 let _ = writeln!(diagnostic, " {}: {limit}", contributor.owner.as_str());
206 }
207 diagnostic.push_str("winner: ");
208 diagnostic.push_str(
209 self.winner()
210 .map(|winner| winner.owner.as_str())
211 .unwrap_or("unbounded"),
212 );
213 diagnostic
214 }
215
216 pub fn with_contributor(
217 &self,
218 contributor: ToolDeadlineContributor,
219 ) -> Result<Self, DeadlineChainError> {
220 let mut contributors = self.contributors.clone();
221 contributors.push(contributor);
222 Self::new(contributors)
223 }
224
225 pub fn validate_extends(&self, upstream: &Self) -> Result<(), DeadlineChainExtensionError> {
227 if self.contributors.len() < upstream.contributors.len() {
228 return Err(DeadlineChainExtensionError::ShorterThanUpstream {
229 upstream_len: upstream.contributors.len(),
230 resolved_len: self.contributors.len(),
231 });
232 }
233 for (index, expected) in upstream.contributors.iter().copied().enumerate() {
234 let actual = self.contributors[index];
235 if actual != expected {
236 return Err(DeadlineChainExtensionError::ContributorMismatch {
237 index,
238 expected,
239 actual,
240 });
241 }
242 }
243 Ok(())
244 }
245}
246
247fn format_duration(duration: Duration) -> String {
248 if duration.subsec_nanos() == 0 {
249 format!("{}s", duration.as_secs())
250 } else if duration.as_millis() > 0 {
251 format!("{}ms", duration.as_millis())
252 } else if duration.as_micros() > 0 {
253 format!("{}us", duration.as_micros())
254 } else {
255 format!("{}ns", duration.as_nanos())
256 }
257}
258
259#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
261pub enum RestartClass {
262 Adoptable,
263 CheckpointResumable,
264 Replayable,
265 NonResumable,
266}
267
268#[derive(Debug, Clone, Copy, PartialEq, Eq)]
270pub enum IdempotencyScope {
271 ToolCall,
272 InteractionAndArguments,
273 HostSemanticKey,
274}
275
276#[derive(Debug, Clone, PartialEq, Eq)]
278pub struct RunnerIdentity {
279 name: String,
280 version: String,
281}
282
283impl RunnerIdentity {
284 pub fn new(
285 name: impl Into<String>,
286 version: impl Into<String>,
287 ) -> Result<Self, ToolExecutionDeclarationError> {
288 let name = name.into();
289 let version = version.into();
290 if name.trim().is_empty() {
291 return Err(ToolExecutionDeclarationError::EmptyRunnerName);
292 }
293 if version.trim().is_empty() {
294 return Err(ToolExecutionDeclarationError::EmptyRunnerVersion);
295 }
296 Ok(Self { name, version })
297 }
298
299 pub fn name(&self) -> &str {
300 &self.name
301 }
302
303 pub fn version(&self) -> &str {
304 &self.version
305 }
306}
307
308#[derive(Debug, Clone, Copy, PartialEq, Eq)]
314pub enum ToolExecutionApplicability<T> {
315 NotApplicable,
316 Applicable(T),
317}
318
319#[derive(Debug, Clone, PartialEq, Eq)]
321pub enum ToolCredentialContextRef {
322 OwningProfile { required_scopes: BTreeSet<String> },
324 AuthBinding {
327 auth_binding: crate::AuthBindingRef,
328 required_scopes: BTreeSet<String>,
329 },
330}
331
332#[derive(Debug, Clone, Copy, PartialEq, Eq)]
334pub enum ToolOutputPolicy {
335 InlineTerminal,
336 StreamingEvents,
337 DurableJobResult,
338}
339
340#[derive(Debug, Clone, Copy, PartialEq, Eq)]
342pub enum ToolProgressPolicy {
343 StreamingEvents,
344 DurableJobEvents,
345}
346
347#[derive(Clone)]
364pub struct EphemeralToolBindingFingerprint {
365 tool_name: crate::ToolName,
366 description: String,
367 input_schema: serde_json::Value,
368 provenance: Option<crate::ToolProvenance>,
369 plane: crate::ToolPlaneClass,
370 callability: crate::ToolCallability,
371 deferred_eligibility: crate::ToolCatalogDeferredEligibility,
372 execution: ToolExecutionContract,
373 authority_chain: Vec<(usize, u64)>,
374}
375
376impl std::fmt::Debug for EphemeralToolBindingFingerprint {
377 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
378 formatter
379 .debug_struct("EphemeralToolBindingFingerprint")
380 .field("tool_name", &self.tool_name)
381 .field("authority_depth", &self.authority_chain.len())
382 .finish_non_exhaustive()
383 }
384}
385
386impl PartialEq for EphemeralToolBindingFingerprint {
387 fn eq(&self, other: &Self) -> bool {
388 self.tool_name == other.tool_name
389 && self.description == other.description
390 && self.input_schema == other.input_schema
391 && self.provenance == other.provenance
392 && self.plane == other.plane
393 && self.callability == other.callability
394 && self.deferred_eligibility == other.deferred_eligibility
395 && self.execution == other.execution
396 && self.authority_chain == other.authority_chain
397 }
398}
399
400impl Eq for EphemeralToolBindingFingerprint {}
401
402pub fn ephemeral_tool_catalog_binding_fingerprint(
403 entry: &crate::ToolCatalogEntry,
404) -> EphemeralToolBindingFingerprint {
405 EphemeralToolBindingFingerprint {
406 tool_name: entry.tool.name.clone(),
407 description: entry.tool.description.clone(),
408 input_schema: entry.tool.input_schema.clone(),
409 provenance: entry.tool.provenance.clone(),
410 plane: entry.plane,
411 callability: entry.callability,
412 deferred_eligibility: entry.deferred_eligibility.clone(),
413 execution: entry.execution.clone(),
414 authority_chain: Vec::new(),
415 }
416}
417
418impl EphemeralToolBindingFingerprint {
419 #[must_use]
420 pub fn with_live_authority(mut self, authority_instance: usize, epoch: u64) -> Self {
421 self.authority_chain.push((authority_instance, epoch));
422 self
423 }
424
425 #[must_use]
426 pub fn with_dependency(mut self, dependency: &Self) -> Self {
427 self.authority_chain
428 .extend(dependency.authority_chain.iter().copied());
429 self
430 }
431}
432
433#[derive(Debug, Clone, PartialEq, Eq)]
442pub struct ToolExecutionOwnerWitness {
443 authority_key: String,
444 owner_key: String,
445 binding_fingerprint: EphemeralToolBindingFingerprint,
446}
447
448impl ToolExecutionOwnerWitness {
449 pub fn new(
450 authority_key: impl Into<String>,
451 owner_key: impl Into<String>,
452 binding_fingerprint: EphemeralToolBindingFingerprint,
453 ) -> Result<Self, ToolExecutionDeclarationError> {
454 let authority_key = authority_key.into();
455 let owner_key = owner_key.into();
456 if authority_key.trim().is_empty() {
457 return Err(ToolExecutionDeclarationError::EmptyOwnerWitnessAuthorityKey);
458 }
459 if owner_key.trim().is_empty() {
460 return Err(ToolExecutionDeclarationError::EmptyOwnerWitnessKey);
461 }
462 Ok(Self {
463 authority_key,
464 owner_key,
465 binding_fingerprint,
466 })
467 }
468
469 pub fn authority_key(&self) -> &str {
470 &self.authority_key
471 }
472
473 pub fn owner_key(&self) -> &str {
474 &self.owner_key
475 }
476
477 pub const fn binding_fingerprint(&self) -> &EphemeralToolBindingFingerprint {
478 &self.binding_fingerprint
479 }
480}
481
482#[derive(Debug, Clone, PartialEq, Eq)]
484pub struct StreamingToolExecutionPolicy {
485 inactivity_timeout: Duration,
486 absolute_timeout: Duration,
487}
488
489impl StreamingToolExecutionPolicy {
490 pub fn new(
491 inactivity_timeout: Duration,
492 absolute_timeout: Duration,
493 ) -> Result<Self, ToolExecutionDeclarationError> {
494 if inactivity_timeout.is_zero() {
495 return Err(ToolExecutionDeclarationError::ZeroStreamingInactivity);
496 }
497 if absolute_timeout.is_zero() {
498 return Err(ToolExecutionDeclarationError::ZeroStreamingAbsolute);
499 }
500 if inactivity_timeout > absolute_timeout {
501 return Err(ToolExecutionDeclarationError::StreamingInactivityExceedsAbsolute);
502 }
503 Ok(Self {
504 inactivity_timeout,
505 absolute_timeout,
506 })
507 }
508
509 pub const fn inactivity_timeout(&self) -> Duration {
510 self.inactivity_timeout
511 }
512
513 pub const fn absolute_timeout(&self) -> Duration {
514 self.absolute_timeout
515 }
516}
517
518#[derive(Debug, Clone, PartialEq, Eq)]
520pub struct DetachedToolExecutionPolicy {
521 runner: RunnerIdentity,
522 restart_class: RestartClass,
523 idempotency_scope: IdempotencyScope,
524 submission_timeout: Duration,
525 credential_scopes: BTreeSet<String>,
526}
527
528impl DetachedToolExecutionPolicy {
529 pub fn new(
530 runner: RunnerIdentity,
531 restart_class: RestartClass,
532 idempotency_scope: IdempotencyScope,
533 submission_timeout: Duration,
534 ) -> Result<Self, ToolExecutionDeclarationError> {
535 if submission_timeout.is_zero() {
536 return Err(ToolExecutionDeclarationError::ZeroDetachedSubmissionDeadline);
537 }
538 Ok(Self {
539 runner,
540 restart_class,
541 idempotency_scope,
542 submission_timeout,
543 credential_scopes: BTreeSet::new(),
544 })
545 }
546
547 #[must_use]
548 pub fn with_credential_scopes<I, S>(mut self, scopes: I) -> Self
549 where
550 I: IntoIterator<Item = S>,
551 S: Into<String>,
552 {
553 self.credential_scopes.extend(
554 scopes
555 .into_iter()
556 .map(Into::into)
557 .filter(|scope: &String| !scope.trim().is_empty()),
558 );
559 self
560 }
561
562 pub fn runner(&self) -> &RunnerIdentity {
563 &self.runner
564 }
565
566 pub const fn restart_class(&self) -> RestartClass {
567 self.restart_class
568 }
569
570 pub const fn idempotency_scope(&self) -> IdempotencyScope {
571 self.idempotency_scope
572 }
573
574 pub const fn submission_timeout(&self) -> Duration {
575 self.submission_timeout
576 }
577
578 pub fn credential_scopes(&self) -> &BTreeSet<String> {
579 &self.credential_scopes
580 }
581}
582
583#[derive(Debug, Clone, PartialEq, Eq)]
585pub enum ToolExecutionDeclarationError {
586 EmptyRunnerName,
587 EmptyRunnerVersion,
588 EmptyOwnerWitnessAuthorityKey,
589 EmptyOwnerWitnessKey,
590 ZeroStreamingInactivity,
591 ZeroStreamingAbsolute,
592 StreamingInactivityExceedsAbsolute,
593 ZeroDetachedSubmissionDeadline,
594}
595
596impl std::fmt::Display for ToolExecutionDeclarationError {
597 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
598 match self {
599 Self::EmptyRunnerName => formatter.write_str("detached runner name must not be empty"),
600 Self::EmptyRunnerVersion => {
601 formatter.write_str("detached runner version must not be empty")
602 }
603 Self::EmptyOwnerWitnessAuthorityKey => {
604 formatter.write_str("tool execution owner witness authority key must not be empty")
605 }
606 Self::EmptyOwnerWitnessKey => {
607 formatter.write_str("tool execution owner witness key must not be empty")
608 }
609 Self::ZeroStreamingInactivity => {
610 formatter.write_str("streaming inactivity timeout must be greater than zero")
611 }
612 Self::ZeroStreamingAbsolute => {
613 formatter.write_str("streaming absolute timeout must be greater than zero")
614 }
615 Self::StreamingInactivityExceedsAbsolute => formatter
616 .write_str("streaming inactivity timeout must not exceed the absolute timeout"),
617 Self::ZeroDetachedSubmissionDeadline => {
618 formatter.write_str("detached submission timeout must be greater than zero")
619 }
620 }
621 }
622}
623
624impl std::error::Error for ToolExecutionDeclarationError {}
625
626#[derive(Debug, Clone, PartialEq, Eq)]
628pub enum ToolExecutionContractError {
629 NoSupportedModes,
630 DefaultModeUnsupported {
631 default_mode: ToolExecutionMode,
632 },
633 MissingStreamingPolicy,
634 UnexpectedStreamingPolicy,
635 MissingDetachedPolicy,
636 UnexpectedDetachedPolicy,
637 RequestedModeUnsupported {
638 requested_mode: ToolExecutionMode,
639 },
640 RequestedRestartClassUnsupported {
641 restart_class: RestartClass,
642 },
643 ResolvedPlanFacetMismatch {
644 mode: ToolExecutionMode,
645 facet: &'static str,
646 },
647}
648
649impl std::fmt::Display for ToolExecutionContractError {
650 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
651 match self {
652 Self::NoSupportedModes => {
653 formatter.write_str("tool execution contract must support at least one mode")
654 }
655 Self::DefaultModeUnsupported { default_mode } => write!(
656 formatter,
657 "default tool execution mode {default_mode:?} is not supported"
658 ),
659 Self::MissingStreamingPolicy => {
660 formatter.write_str("streaming mode requires a streaming policy")
661 }
662 Self::UnexpectedStreamingPolicy => {
663 formatter.write_str("streaming policy requires streaming mode support")
664 }
665 Self::MissingDetachedPolicy => {
666 formatter.write_str("detached mode requires a detached policy")
667 }
668 Self::UnexpectedDetachedPolicy => {
669 formatter.write_str("detached policy requires detached mode support")
670 }
671 Self::RequestedModeUnsupported { requested_mode } => write!(
672 formatter,
673 "requested tool execution mode {requested_mode:?} is not supported"
674 ),
675 Self::RequestedRestartClassUnsupported { restart_class } => write!(
676 formatter,
677 "requested detached restart class {restart_class:?} is not supported"
678 ),
679 Self::ResolvedPlanFacetMismatch { mode, facet } => write!(
680 formatter,
681 "resolved {mode:?} tool execution plan does not match advertised facet '{facet}'"
682 ),
683 }
684 }
685}
686
687impl std::error::Error for ToolExecutionContractError {}
688
689#[derive(Debug, Clone, PartialEq, Eq)]
691pub struct ToolExecutionResolutionContext {
692 deadlines: ToolDeadlineChain,
693}
694
695impl ToolExecutionResolutionContext {
696 pub const fn new(deadlines: ToolDeadlineChain) -> Self {
697 Self { deadlines }
698 }
699
700 pub fn deadlines(&self) -> &ToolDeadlineChain {
701 &self.deadlines
702 }
703
704 pub fn with_deadline(
705 &self,
706 contributor: ToolDeadlineContributor,
707 ) -> Result<Self, DeadlineChainError> {
708 self.deadlines.with_contributor(contributor).map(Self::new)
709 }
710
711 pub fn validate_resolved_plan(
714 &self,
715 plan: &ResolvedToolExecutionPlan,
716 ) -> Result<(), ToolExecutionResolutionError> {
717 plan.deadlines
718 .validate_extends(&self.deadlines)
719 .map_err(ToolExecutionResolutionError::DeadlineExtension)
720 }
721}
722
723#[derive(Debug, Clone, PartialEq, Eq)]
725pub enum ToolExecutionResolutionError {
726 NotFound {
727 tool_name: String,
728 },
729 Unavailable {
730 tool_name: String,
731 reason: crate::tool_catalog::ToolUnavailableReason,
732 },
733 AccessDenied {
734 tool_name: String,
735 },
736 InvalidArguments {
737 tool_name: String,
738 reason: String,
739 },
740 Deadline(DeadlineChainError),
741 DeadlineExtension(DeadlineChainExtensionError),
742 OwnerWitnessAlreadyAssigned {
743 existing: Box<ToolExecutionOwnerWitness>,
744 attempted: Box<ToolExecutionOwnerWitness>,
745 },
746 ResolvedCallMismatch {
747 tool_name: String,
748 },
749 RootDispatcherChanged {
750 tool_name: String,
751 },
752 Declaration(ToolExecutionDeclarationError),
753 Contract(ToolExecutionContractError),
754}
755
756impl std::fmt::Display for ToolExecutionResolutionError {
757 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
758 match self {
759 Self::NotFound { tool_name } => {
760 write!(
761 formatter,
762 "tool '{tool_name}' is not present in the catalog"
763 )
764 }
765 Self::Unavailable { tool_name, reason } => {
766 write!(formatter, "tool '{tool_name}' is unavailable: {reason}")
767 }
768 Self::AccessDenied { tool_name } => {
769 write!(
770 formatter,
771 "tool '{tool_name}' is denied by execution policy"
772 )
773 }
774 Self::InvalidArguments { tool_name, reason } => {
775 write!(
776 formatter,
777 "tool '{tool_name}' arguments cannot resolve an execution plan: {reason}"
778 )
779 }
780 Self::Deadline(error) => std::fmt::Display::fmt(error, formatter),
781 Self::DeadlineExtension(error) => {
782 write!(
783 formatter,
784 "tool deadline resolution replaced an upstream deadline: {error}"
785 )
786 }
787 Self::OwnerWitnessAlreadyAssigned {
788 existing,
789 attempted,
790 } => write!(
791 formatter,
792 "tool execution authority '{}' already selected owner '{}'; refusing replacement by '{}'",
793 existing.authority_key(),
794 existing.owner_key(),
795 attempted.owner_key(),
796 ),
797 Self::ResolvedCallMismatch { tool_name } => write!(
798 formatter,
799 "resolved execution plan for tool '{tool_name}' was dispatched with a different call identity"
800 ),
801 Self::RootDispatcherChanged { tool_name } => write!(
802 formatter,
803 "resolved execution plan for tool '{tool_name}' was dispatched through a different dispatcher instance"
804 ),
805 Self::Declaration(error) => std::fmt::Display::fmt(error, formatter),
806 Self::Contract(error) => std::fmt::Display::fmt(error, formatter),
807 }
808 }
809}
810
811impl std::error::Error for ToolExecutionResolutionError {}
812
813impl From<DeadlineChainError> for ToolExecutionResolutionError {
814 fn from(error: DeadlineChainError) -> Self {
815 Self::Deadline(error)
816 }
817}
818
819impl From<ToolExecutionContractError> for ToolExecutionResolutionError {
820 fn from(error: ToolExecutionContractError) -> Self {
821 Self::Contract(error)
822 }
823}
824
825impl From<ToolExecutionDeclarationError> for ToolExecutionResolutionError {
826 fn from(error: ToolExecutionDeclarationError) -> Self {
827 Self::Declaration(error)
828 }
829}
830
831impl From<ToolExecutionResolutionError> for crate::error::ToolError {
832 fn from(error: ToolExecutionResolutionError) -> Self {
833 match error {
834 ToolExecutionResolutionError::NotFound { tool_name } => Self::not_found(tool_name),
835 ToolExecutionResolutionError::Unavailable { tool_name, reason } => {
836 Self::unavailable(tool_name, reason)
837 }
838 ToolExecutionResolutionError::AccessDenied { tool_name } => {
839 Self::access_denied(tool_name)
840 }
841 ToolExecutionResolutionError::InvalidArguments { tool_name, reason } => {
842 Self::invalid_arguments(tool_name, reason)
843 }
844 ToolExecutionResolutionError::Deadline(error) => {
845 Self::execution_failed(format!("tool deadline resolution failed: {error}"))
846 }
847 ToolExecutionResolutionError::DeadlineExtension(error) => Self::execution_failed(
848 format!("tool deadline resolution replaced an upstream deadline: {error}"),
849 ),
850 ToolExecutionResolutionError::OwnerWitnessAlreadyAssigned {
851 existing,
852 attempted,
853 } => Self::execution_failed(format!(
854 "tool execution authority '{}' already selected owner '{}'; refusing replacement by '{}'",
855 existing.authority_key(),
856 existing.owner_key(),
857 attempted.owner_key(),
858 )),
859 ToolExecutionResolutionError::ResolvedCallMismatch { tool_name }
860 | ToolExecutionResolutionError::RootDispatcherChanged { tool_name } => {
861 Self::unavailable(
862 tool_name,
863 crate::ToolUnavailableReason::ExecutionOwnerChanged,
864 )
865 }
866 ToolExecutionResolutionError::Declaration(error) => {
867 Self::execution_failed(format!("tool execution declaration failed: {error}"))
868 }
869 ToolExecutionResolutionError::Contract(error) => Self::execution_failed(format!(
870 "tool execution contract resolution failed: {error}"
871 )),
872 }
873 }
874}
875
876#[derive(Debug, Clone, PartialEq, Eq)]
878pub struct ToolExecutionContract {
879 supported_modes: BTreeSet<ToolExecutionMode>,
880 default_mode: ToolExecutionMode,
881 streaming_policy: Option<StreamingToolExecutionPolicy>,
882 detached_policy: Option<DetachedToolExecutionPolicy>,
883 detached_restart_classes: BTreeSet<RestartClass>,
884}
885
886impl Default for ToolExecutionContract {
887 fn default() -> Self {
888 Self {
889 supported_modes: BTreeSet::from([ToolExecutionMode::Fast]),
890 default_mode: ToolExecutionMode::Fast,
891 streaming_policy: None,
892 detached_policy: None,
893 detached_restart_classes: BTreeSet::new(),
894 }
895 }
896}
897
898impl ToolExecutionContract {
899 pub fn new(
900 supported_modes: BTreeSet<ToolExecutionMode>,
901 default_mode: ToolExecutionMode,
902 streaming_policy: Option<StreamingToolExecutionPolicy>,
903 detached_policy: Option<DetachedToolExecutionPolicy>,
904 ) -> Result<Self, ToolExecutionContractError> {
905 if supported_modes.is_empty() {
906 return Err(ToolExecutionContractError::NoSupportedModes);
907 }
908 if !supported_modes.contains(&default_mode) {
909 return Err(ToolExecutionContractError::DefaultModeUnsupported { default_mode });
910 }
911 match (
912 supported_modes.contains(&ToolExecutionMode::Streaming),
913 streaming_policy.is_some(),
914 ) {
915 (true, false) => return Err(ToolExecutionContractError::MissingStreamingPolicy),
916 (false, true) => return Err(ToolExecutionContractError::UnexpectedStreamingPolicy),
917 _ => {}
918 }
919 match (
920 supported_modes.contains(&ToolExecutionMode::Detached),
921 detached_policy.is_some(),
922 ) {
923 (true, false) => return Err(ToolExecutionContractError::MissingDetachedPolicy),
924 (false, true) => return Err(ToolExecutionContractError::UnexpectedDetachedPolicy),
925 _ => {}
926 }
927 let detached_restart_classes = detached_policy
928 .as_ref()
929 .map(|policy| BTreeSet::from([policy.restart_class()]))
930 .unwrap_or_default();
931 Ok(Self {
932 supported_modes,
933 default_mode,
934 streaming_policy,
935 detached_policy,
936 detached_restart_classes,
937 })
938 }
939
940 pub fn supported_modes(&self) -> &BTreeSet<ToolExecutionMode> {
941 &self.supported_modes
942 }
943
944 pub const fn default_mode(&self) -> ToolExecutionMode {
945 self.default_mode
946 }
947
948 pub fn streaming_policy(&self) -> Option<&StreamingToolExecutionPolicy> {
949 self.streaming_policy.as_ref()
950 }
951
952 pub fn detached_policy(&self) -> Option<&DetachedToolExecutionPolicy> {
953 self.detached_policy.as_ref()
954 }
955
956 pub fn detached_restart_classes(&self) -> &BTreeSet<RestartClass> {
957 &self.detached_restart_classes
958 }
959
960 pub fn with_detached_restart_classes(
961 mut self,
962 restart_classes: BTreeSet<RestartClass>,
963 ) -> Result<Self, ToolExecutionContractError> {
964 let policy = self
965 .detached_policy
966 .as_ref()
967 .ok_or(ToolExecutionContractError::MissingDetachedPolicy)?;
968 if !restart_classes.contains(&policy.restart_class()) {
969 return Err(
970 ToolExecutionContractError::RequestedRestartClassUnsupported {
971 restart_class: policy.restart_class(),
972 },
973 );
974 }
975 self.detached_restart_classes = restart_classes;
976 Ok(self)
977 }
978
979 pub fn resolve_default(
980 &self,
981 deadlines: ToolDeadlineChain,
982 ) -> Result<ResolvedToolExecutionPlan, ToolExecutionContractError> {
983 self.resolve(self.default_mode, deadlines)
984 }
985
986 pub fn resolve(
987 &self,
988 mode: ToolExecutionMode,
989 mut deadlines: ToolDeadlineChain,
990 ) -> Result<ResolvedToolExecutionPlan, ToolExecutionContractError> {
991 if !self.supported_modes.contains(&mode) {
992 return Err(ToolExecutionContractError::RequestedModeUnsupported {
993 requested_mode: mode,
994 });
995 }
996 let (
997 kind,
998 runner,
999 restart_class,
1000 idempotency_scope,
1001 credential_context_refs,
1002 output_policy,
1003 progress_policy,
1004 ) = match mode {
1005 ToolExecutionMode::Fast => (
1006 ResolvedExecutionKind::Fast,
1007 ToolExecutionApplicability::NotApplicable,
1008 ToolExecutionApplicability::NotApplicable,
1009 ToolExecutionApplicability::NotApplicable,
1010 ToolExecutionApplicability::NotApplicable,
1011 ToolExecutionApplicability::Applicable(ToolOutputPolicy::InlineTerminal),
1012 ToolExecutionApplicability::NotApplicable,
1013 ),
1014 ToolExecutionMode::Streaming => {
1015 let policy = self
1016 .streaming_policy
1017 .clone()
1018 .ok_or(ToolExecutionContractError::MissingStreamingPolicy)?;
1019 deadlines.contributors.push(ToolDeadlineContributor::finite(
1020 ToolDeadlineOwner::StreamingAbsolute,
1021 policy.absolute_timeout(),
1022 ));
1023 (
1024 ResolvedExecutionKind::Streaming(policy),
1025 ToolExecutionApplicability::NotApplicable,
1026 ToolExecutionApplicability::NotApplicable,
1027 ToolExecutionApplicability::NotApplicable,
1028 ToolExecutionApplicability::NotApplicable,
1029 ToolExecutionApplicability::Applicable(ToolOutputPolicy::StreamingEvents),
1030 ToolExecutionApplicability::Applicable(ToolProgressPolicy::StreamingEvents),
1031 )
1032 }
1033 ToolExecutionMode::Detached => {
1034 let policy = self
1035 .detached_policy
1036 .clone()
1037 .ok_or(ToolExecutionContractError::MissingDetachedPolicy)?;
1038 deadlines.contributors.push(ToolDeadlineContributor::finite(
1039 ToolDeadlineOwner::DetachedSubmission,
1040 policy.submission_timeout(),
1041 ));
1042 (
1043 ResolvedExecutionKind::Detached(policy.clone()),
1044 ToolExecutionApplicability::Applicable(policy.runner().clone()),
1045 ToolExecutionApplicability::Applicable(policy.restart_class()),
1046 ToolExecutionApplicability::Applicable(policy.idempotency_scope()),
1047 ToolExecutionApplicability::Applicable(vec![
1048 ToolCredentialContextRef::OwningProfile {
1049 required_scopes: policy.credential_scopes().clone(),
1050 },
1051 ]),
1052 ToolExecutionApplicability::Applicable(ToolOutputPolicy::DurableJobResult),
1053 ToolExecutionApplicability::Applicable(ToolProgressPolicy::DurableJobEvents),
1054 )
1055 }
1056 };
1057 Ok(ResolvedToolExecutionPlan {
1058 deadlines,
1059 kind,
1060 runner,
1061 restart_class,
1062 idempotency_scope,
1063 credential_context_refs,
1064 output_policy,
1065 progress_policy,
1066 owner_witnesses: Vec::new(),
1067 resolved_call: None,
1068 root_dispatcher: None,
1069 })
1070 }
1071
1072 pub fn resolve_detached_with_restart_class(
1073 &self,
1074 restart_class: RestartClass,
1075 deadlines: ToolDeadlineChain,
1076 ) -> Result<ResolvedToolExecutionPlan, ToolExecutionContractError> {
1077 if !self.detached_restart_classes.contains(&restart_class) {
1078 return Err(
1079 ToolExecutionContractError::RequestedRestartClassUnsupported { restart_class },
1080 );
1081 }
1082 let mut policy = self
1083 .detached_policy
1084 .clone()
1085 .ok_or(ToolExecutionContractError::MissingDetachedPolicy)?;
1086 policy.restart_class = restart_class;
1087 let mut plan = self.resolve(ToolExecutionMode::Detached, deadlines)?;
1088 plan.kind = ResolvedExecutionKind::Detached(policy);
1089 plan.restart_class = ToolExecutionApplicability::Applicable(restart_class);
1090 Ok(plan)
1091 }
1092
1093 pub fn validate_resolved_plan(
1102 &self,
1103 plan: &ResolvedToolExecutionPlan,
1104 ) -> Result<(), ToolExecutionContractError> {
1105 let mode = plan.mode();
1106 if !self.supported_modes.contains(&mode) {
1107 return Err(ToolExecutionContractError::RequestedModeUnsupported {
1108 requested_mode: mode,
1109 });
1110 }
1111 let comparison_deadline = ToolDeadlineChain {
1112 contributors: vec![ToolDeadlineContributor::unbounded(
1113 ToolDeadlineOwner::Dispatcher,
1114 )],
1115 };
1116 let expected = if mode == ToolExecutionMode::Detached {
1117 let ToolExecutionApplicability::Applicable(restart_class) = plan.restart_class else {
1118 return Err(ToolExecutionContractError::ResolvedPlanFacetMismatch {
1119 mode,
1120 facet: "restart_class",
1121 });
1122 };
1123 self.resolve_detached_with_restart_class(restart_class, comparison_deadline)?
1124 } else {
1125 self.resolve(mode, comparison_deadline)?
1126 };
1127
1128 macro_rules! require_facet {
1129 ($field:ident) => {
1130 if plan.$field != expected.$field {
1131 return Err(ToolExecutionContractError::ResolvedPlanFacetMismatch {
1132 mode,
1133 facet: stringify!($field),
1134 });
1135 }
1136 };
1137 }
1138
1139 require_facet!(kind);
1140 require_facet!(runner);
1141 require_facet!(restart_class);
1142 require_facet!(idempotency_scope);
1143 require_facet!(credential_context_refs);
1144 require_facet!(output_policy);
1145 require_facet!(progress_policy);
1146 Ok(())
1147 }
1148}
1149
1150#[derive(Debug, Clone, PartialEq, Eq)]
1152pub enum ResolvedExecutionKind {
1153 Fast,
1154 Streaming(StreamingToolExecutionPolicy),
1155 Detached(DetachedToolExecutionPolicy),
1156}
1157
1158#[derive(Clone)]
1160pub struct ResolvedToolExecutionPlan {
1161 deadlines: ToolDeadlineChain,
1162 kind: ResolvedExecutionKind,
1163 runner: ToolExecutionApplicability<RunnerIdentity>,
1164 restart_class: ToolExecutionApplicability<RestartClass>,
1165 idempotency_scope: ToolExecutionApplicability<IdempotencyScope>,
1166 credential_context_refs: ToolExecutionApplicability<Vec<ToolCredentialContextRef>>,
1167 output_policy: ToolExecutionApplicability<ToolOutputPolicy>,
1168 progress_policy: ToolExecutionApplicability<ToolProgressPolicy>,
1169 owner_witnesses: Vec<ToolExecutionOwnerWitness>,
1170 resolved_call: Option<ResolvedToolCallIdentity>,
1171 root_dispatcher: Option<RootDispatcherLease>,
1172}
1173
1174#[derive(Debug, Clone, PartialEq, Eq)]
1175struct ResolvedToolCallIdentity {
1176 tool_use_id: String,
1177 tool_name: crate::ToolName,
1178 canonical_arguments_sha256: [u8; 32],
1179}
1180
1181trait ErasedRootDispatcherLease: Send + Sync {
1182 fn data_ptr(&self) -> *const ();
1183}
1184
1185struct TypedRootDispatcherLease<T: ?Sized + Send + Sync + 'static> {
1186 dispatcher: Arc<T>,
1187}
1188
1189impl<T: ?Sized + Send + Sync + 'static> ErasedRootDispatcherLease for TypedRootDispatcherLease<T> {
1190 fn data_ptr(&self) -> *const () {
1191 Arc::as_ptr(&self.dispatcher).cast::<()>()
1192 }
1193}
1194
1195#[derive(Clone)]
1196struct RootDispatcherLease(Arc<dyn ErasedRootDispatcherLease>);
1197
1198impl RootDispatcherLease {
1199 fn new<T: ?Sized + Send + Sync + 'static>(dispatcher: Arc<T>) -> Self {
1200 Self(Arc::new(TypedRootDispatcherLease { dispatcher }))
1201 }
1202
1203 fn matches<T: ?Sized + Send + Sync + 'static>(&self, dispatcher: &Arc<T>) -> bool {
1204 self.0.data_ptr() == Arc::as_ptr(dispatcher).cast::<()>()
1205 }
1206}
1207
1208impl std::fmt::Debug for ResolvedToolExecutionPlan {
1209 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1210 formatter
1211 .debug_struct("ResolvedToolExecutionPlan")
1212 .field("deadlines", &self.deadlines)
1213 .field("kind", &self.kind)
1214 .field("runner", &self.runner)
1215 .field("restart_class", &self.restart_class)
1216 .field("idempotency_scope", &self.idempotency_scope)
1217 .field("credential_context_refs", &self.credential_context_refs)
1218 .field("output_policy", &self.output_policy)
1219 .field("progress_policy", &self.progress_policy)
1220 .field("owner_witnesses", &self.owner_witnesses)
1221 .field("resolved_call", &self.resolved_call)
1222 .field("has_root_dispatcher_lease", &self.root_dispatcher.is_some())
1223 .finish()
1224 }
1225}
1226
1227impl PartialEq for ResolvedToolExecutionPlan {
1228 fn eq(&self, other: &Self) -> bool {
1229 self.deadlines == other.deadlines
1230 && self.kind == other.kind
1231 && self.runner == other.runner
1232 && self.restart_class == other.restart_class
1233 && self.idempotency_scope == other.idempotency_scope
1234 && self.credential_context_refs == other.credential_context_refs
1235 && self.output_policy == other.output_policy
1236 && self.progress_policy == other.progress_policy
1237 && self.owner_witnesses == other.owner_witnesses
1238 && self.resolved_call == other.resolved_call
1239 && match (&self.root_dispatcher, &other.root_dispatcher) {
1240 (Some(left), Some(right)) => left.0.data_ptr() == right.0.data_ptr(),
1241 (None, None) => true,
1242 (Some(_), None) | (None, Some(_)) => false,
1243 }
1244 }
1245}
1246
1247impl Eq for ResolvedToolExecutionPlan {}
1248
1249impl ResolvedToolExecutionPlan {
1250 pub const fn mode(&self) -> ToolExecutionMode {
1251 match self.kind {
1252 ResolvedExecutionKind::Fast => ToolExecutionMode::Fast,
1253 ResolvedExecutionKind::Streaming(_) => ToolExecutionMode::Streaming,
1254 ResolvedExecutionKind::Detached(_) => ToolExecutionMode::Detached,
1255 }
1256 }
1257
1258 pub fn deadlines(&self) -> &ToolDeadlineChain {
1259 &self.deadlines
1260 }
1261
1262 pub fn kind(&self) -> &ResolvedExecutionKind {
1263 &self.kind
1264 }
1265
1266 pub fn runner(&self) -> &ToolExecutionApplicability<RunnerIdentity> {
1267 &self.runner
1268 }
1269
1270 pub const fn restart_class(&self) -> ToolExecutionApplicability<RestartClass> {
1271 match self.restart_class {
1272 ToolExecutionApplicability::NotApplicable => ToolExecutionApplicability::NotApplicable,
1273 ToolExecutionApplicability::Applicable(value) => {
1274 ToolExecutionApplicability::Applicable(value)
1275 }
1276 }
1277 }
1278
1279 pub const fn idempotency_scope(&self) -> ToolExecutionApplicability<IdempotencyScope> {
1280 match self.idempotency_scope {
1281 ToolExecutionApplicability::NotApplicable => ToolExecutionApplicability::NotApplicable,
1282 ToolExecutionApplicability::Applicable(value) => {
1283 ToolExecutionApplicability::Applicable(value)
1284 }
1285 }
1286 }
1287
1288 pub fn credential_context_refs(
1289 &self,
1290 ) -> &ToolExecutionApplicability<Vec<ToolCredentialContextRef>> {
1291 &self.credential_context_refs
1292 }
1293
1294 pub const fn output_policy(&self) -> &ToolExecutionApplicability<ToolOutputPolicy> {
1295 &self.output_policy
1296 }
1297
1298 pub const fn progress_policy(&self) -> &ToolExecutionApplicability<ToolProgressPolicy> {
1299 &self.progress_policy
1300 }
1301
1302 pub fn owner_witness(&self, authority_key: &str) -> Option<&ToolExecutionOwnerWitness> {
1303 self.owner_witnesses
1304 .iter()
1305 .find(|witness| witness.authority_key() == authority_key)
1306 }
1307
1308 pub fn owner_witnesses(&self) -> &[ToolExecutionOwnerWitness] {
1309 &self.owner_witnesses
1310 }
1311
1312 pub fn canonical_arguments_sha256(&self) -> Option<[u8; 32]> {
1316 self.resolved_call
1317 .as_ref()
1318 .map(|resolved| resolved.canonical_arguments_sha256)
1319 }
1320
1321 pub fn with_owner_witness(
1322 mut self,
1323 witness: ToolExecutionOwnerWitness,
1324 ) -> Result<Self, ToolExecutionResolutionError> {
1325 if let Some(existing) = self.owner_witness(witness.authority_key()).cloned() {
1326 return Err(ToolExecutionResolutionError::OwnerWitnessAlreadyAssigned {
1327 existing: Box::new(existing),
1328 attempted: Box::new(witness),
1329 });
1330 }
1331 self.owner_witnesses.push(witness);
1332 Ok(self)
1333 }
1334
1335 pub(crate) fn bind_root_dispatch<T: ?Sized + Send + Sync + 'static>(
1336 mut self,
1337 dispatcher: Arc<T>,
1338 call: crate::ToolCallView<'_>,
1339 ) -> Result<Self, ToolExecutionResolutionError> {
1340 self.resolved_call = Some(ResolvedToolCallIdentity::from_call(call)?);
1341 self.root_dispatcher = Some(RootDispatcherLease::new(dispatcher));
1342 Ok(self)
1343 }
1344
1345 pub(crate) fn validate_root_dispatch<T: ?Sized + Send + Sync + 'static>(
1346 &self,
1347 dispatcher: &Arc<T>,
1348 call: crate::ToolCallView<'_>,
1349 ) -> Result<(), ToolExecutionResolutionError> {
1350 let Some(root_dispatcher) = self.root_dispatcher.as_ref() else {
1351 return Err(ToolExecutionResolutionError::RootDispatcherChanged {
1352 tool_name: call.name.to_string(),
1353 });
1354 };
1355 if !root_dispatcher.matches(dispatcher) {
1356 return Err(ToolExecutionResolutionError::RootDispatcherChanged {
1357 tool_name: call.name.to_string(),
1358 });
1359 }
1360 let actual = ResolvedToolCallIdentity::from_call(call)?;
1361 if self.resolved_call.as_ref() != Some(&actual) {
1362 return Err(ToolExecutionResolutionError::ResolvedCallMismatch {
1363 tool_name: call.name.to_string(),
1364 });
1365 }
1366 Ok(())
1367 }
1368}
1369
1370impl ResolvedToolCallIdentity {
1371 fn from_call(call: crate::ToolCallView<'_>) -> Result<Self, ToolExecutionResolutionError> {
1372 let arguments: serde_json::Value =
1373 serde_json::from_str(call.args.get()).map_err(|error| {
1374 ToolExecutionResolutionError::InvalidArguments {
1375 tool_name: call.name.to_string(),
1376 reason: error.to_string(),
1377 }
1378 })?;
1379 let mut canonical = Vec::new();
1380 write_canonical_json(&arguments, &mut canonical).map_err(|error| {
1381 ToolExecutionResolutionError::InvalidArguments {
1382 tool_name: call.name.to_string(),
1383 reason: error.to_string(),
1384 }
1385 })?;
1386 Ok(Self {
1387 tool_use_id: call.id.to_string(),
1388 tool_name: call.name.into(),
1389 canonical_arguments_sha256: Sha256::digest(canonical).into(),
1390 })
1391 }
1392}
1393
1394fn write_canonical_json(
1395 value: &serde_json::Value,
1396 output: &mut Vec<u8>,
1397) -> Result<(), serde_json::Error> {
1398 match value {
1399 serde_json::Value::Object(object) => {
1400 output.push(b'{');
1401 let mut keys: Vec<_> = object.keys().collect();
1402 keys.sort_unstable();
1403 for (index, key) in keys.into_iter().enumerate() {
1404 if index > 0 {
1405 output.push(b',');
1406 }
1407 serde_json::to_writer(&mut *output, key)?;
1408 output.push(b':');
1409 write_canonical_json(&object[key], output)?;
1410 }
1411 output.push(b'}');
1412 }
1413 serde_json::Value::Array(array) => {
1414 output.push(b'[');
1415 for (index, item) in array.iter().enumerate() {
1416 if index > 0 {
1417 output.push(b',');
1418 }
1419 write_canonical_json(item, output)?;
1420 }
1421 output.push(b']');
1422 }
1423 scalar => serde_json::to_writer(output, scalar)?,
1424 }
1425 Ok(())
1426}
1427
1428#[cfg(test)]
1429#[allow(clippy::expect_used)]
1430mod tests {
1431 use super::*;
1432 use std::collections::BTreeSet;
1433 use std::sync::Arc;
1434 use std::time::Duration;
1435
1436 fn finite(owner: ToolDeadlineOwner, seconds: u64) -> ToolDeadlineContributor {
1437 ToolDeadlineContributor::finite(owner, Duration::from_secs(seconds))
1438 }
1439
1440 fn ephemeral_fingerprint(label: &str) -> EphemeralToolBindingFingerprint {
1441 ephemeral_tool_catalog_binding_fingerprint(&crate::ToolCatalogEntry::session_inline(
1442 Arc::new(crate::ToolDef::new(
1443 format!("test_{label}"),
1444 label,
1445 serde_json::json!({"type": "object"}),
1446 )),
1447 true,
1448 ))
1449 }
1450
1451 #[test]
1452 fn deadline_chain_rejects_empty_and_zero_deadlines() {
1453 assert_eq!(
1454 ToolDeadlineChain::new(Vec::new()),
1455 Err(DeadlineChainError::Empty)
1456 );
1457 assert_eq!(
1458 ToolDeadlineChain::new(vec![finite(ToolDeadlineOwner::CoreToolDispatch, 0,)]),
1459 Err(DeadlineChainError::Zero {
1460 owner: ToolDeadlineOwner::CoreToolDispatch,
1461 })
1462 );
1463 }
1464
1465 #[test]
1466 fn deadline_chain_retains_every_contributor_and_selects_narrowest() {
1467 let chain = ToolDeadlineChain::new(vec![
1468 finite(ToolDeadlineOwner::CoreToolDispatch, 600),
1469 finite(ToolDeadlineOwner::MobkitPublicCallback, 120),
1470 finite(ToolDeadlineOwner::SdkCallbackCancellation, 125),
1471 finite(ToolDeadlineOwner::GatewayWire, 130),
1472 ])
1473 .expect("valid deadline chain");
1474
1475 assert_eq!(chain.contributors().len(), 4);
1476 assert_eq!(chain.effective_timeout(), Some(Duration::from_secs(120)));
1477 assert_eq!(
1478 chain.winner().map(ToolDeadlineContributor::owner),
1479 Some(ToolDeadlineOwner::MobkitPublicCallback)
1480 );
1481 assert_eq!(
1482 chain.diagnostic(),
1483 "effective deadline: 120s\ncontributors:\n core tool dispatch: 600s\n mobkit public callback: 120s\n sdk callback cancellation: 125s\n gateway wire: 130s\nwinner: mobkit public callback"
1484 );
1485 }
1486
1487 #[test]
1488 fn equal_deadlines_use_declaration_order_as_deterministic_tie_breaker() {
1489 let chain = ToolDeadlineChain::new(vec![
1490 finite(ToolDeadlineOwner::Dispatcher, 30),
1491 finite(ToolDeadlineOwner::ToolInternal, 30),
1492 ])
1493 .expect("valid deadline chain");
1494
1495 assert_eq!(
1496 chain.winner().map(ToolDeadlineContributor::owner),
1497 Some(ToolDeadlineOwner::Dispatcher)
1498 );
1499 }
1500
1501 #[test]
1502 fn unbounded_contributors_are_diagnostic_but_never_win_a_finite_chain() {
1503 let chain = ToolDeadlineChain::new(vec![
1504 ToolDeadlineContributor::unbounded(ToolDeadlineOwner::DirectCaller),
1505 finite(ToolDeadlineOwner::ToolInternal, 30),
1506 ])
1507 .expect("valid deadline chain");
1508
1509 assert_eq!(chain.effective_timeout(), Some(Duration::from_secs(30)));
1510 assert_eq!(
1511 chain.winner().map(ToolDeadlineContributor::owner),
1512 Some(ToolDeadlineOwner::ToolInternal)
1513 );
1514 assert!(chain.diagnostic().contains("direct caller: unbounded"));
1515 }
1516
1517 #[test]
1518 fn deadline_diagnostic_never_rounds_a_nonzero_timeout_to_zero() {
1519 let chain = ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
1520 ToolDeadlineOwner::ToolInternal,
1521 Duration::from_nanos(1),
1522 )])
1523 .expect("valid deadline chain");
1524
1525 assert!(chain.diagnostic().contains("effective deadline: 1ns"));
1526 assert!(chain.diagnostic().contains("tool internal: 1ns"));
1527 assert!(!chain.diagnostic().contains("0ms"));
1528 }
1529
1530 #[test]
1531 fn execution_contract_default_is_fast_only() {
1532 let contract = ToolExecutionContract::default();
1533
1534 assert_eq!(contract.default_mode(), ToolExecutionMode::Fast);
1535 assert_eq!(
1536 contract.supported_modes(),
1537 &BTreeSet::from([ToolExecutionMode::Fast])
1538 );
1539 assert!(contract.streaming_policy().is_none());
1540 assert!(contract.detached_policy().is_none());
1541 }
1542
1543 #[test]
1544 fn execution_contract_rejects_incoherent_mode_policies() {
1545 let streaming =
1546 StreamingToolExecutionPolicy::new(Duration::from_secs(5), Duration::from_secs(60))
1547 .expect("valid streaming policy");
1548 let detached = DetachedToolExecutionPolicy::new(
1549 RunnerIdentity::new("homecore.security_scan", "v1").expect("valid runner"),
1550 RestartClass::NonResumable,
1551 IdempotencyScope::InteractionAndArguments,
1552 Duration::from_secs(10),
1553 )
1554 .expect("valid detached policy");
1555
1556 assert_eq!(
1557 ToolExecutionContract::new(BTreeSet::new(), ToolExecutionMode::Fast, None, None,),
1558 Err(ToolExecutionContractError::NoSupportedModes)
1559 );
1560 assert_eq!(
1561 ToolExecutionContract::new(
1562 BTreeSet::from([ToolExecutionMode::Fast]),
1563 ToolExecutionMode::Detached,
1564 None,
1565 None,
1566 ),
1567 Err(ToolExecutionContractError::DefaultModeUnsupported {
1568 default_mode: ToolExecutionMode::Detached,
1569 })
1570 );
1571 assert_eq!(
1572 ToolExecutionContract::new(
1573 BTreeSet::from([ToolExecutionMode::Fast]),
1574 ToolExecutionMode::Fast,
1575 Some(streaming),
1576 None,
1577 ),
1578 Err(ToolExecutionContractError::UnexpectedStreamingPolicy)
1579 );
1580 assert_eq!(
1581 ToolExecutionContract::new(
1582 BTreeSet::from([ToolExecutionMode::Streaming]),
1583 ToolExecutionMode::Streaming,
1584 None,
1585 None,
1586 ),
1587 Err(ToolExecutionContractError::MissingStreamingPolicy)
1588 );
1589 assert_eq!(
1590 ToolExecutionContract::new(
1591 BTreeSet::from([ToolExecutionMode::Fast]),
1592 ToolExecutionMode::Fast,
1593 None,
1594 Some(detached),
1595 ),
1596 Err(ToolExecutionContractError::UnexpectedDetachedPolicy)
1597 );
1598 assert_eq!(
1599 ToolExecutionContract::new(
1600 BTreeSet::from([ToolExecutionMode::Detached]),
1601 ToolExecutionMode::Detached,
1602 None,
1603 None,
1604 ),
1605 Err(ToolExecutionContractError::MissingDetachedPolicy)
1606 );
1607 }
1608
1609 #[test]
1610 fn detached_resolution_carries_typed_runner_restart_and_idempotency() {
1611 let detached = DetachedToolExecutionPolicy::new(
1612 RunnerIdentity::new("homecore.security_scan", "v1").expect("valid runner"),
1613 RestartClass::NonResumable,
1614 IdempotencyScope::InteractionAndArguments,
1615 Duration::from_secs(10),
1616 )
1617 .expect("valid detached policy")
1618 .with_credential_scopes(["network"]);
1619 let contract = ToolExecutionContract::new(
1620 BTreeSet::from([ToolExecutionMode::Detached]),
1621 ToolExecutionMode::Detached,
1622 None,
1623 Some(detached.clone()),
1624 )
1625 .expect("valid detached contract");
1626 let deadlines =
1627 ToolDeadlineChain::new(vec![finite(ToolDeadlineOwner::CoreToolDispatch, 600)])
1628 .expect("valid upstream deadline");
1629
1630 let plan = contract
1631 .resolve_default(deadlines)
1632 .expect("default mode resolves");
1633
1634 assert_eq!(plan.mode(), ToolExecutionMode::Detached);
1635 assert_eq!(
1636 plan.deadlines().contributors(),
1637 &[
1638 finite(ToolDeadlineOwner::CoreToolDispatch, 600),
1639 finite(ToolDeadlineOwner::DetachedSubmission, 10),
1640 ]
1641 );
1642 assert_eq!(plan.kind(), &ResolvedExecutionKind::Detached(detached));
1643 }
1644
1645 #[test]
1646 fn mode_resolution_appends_its_own_absolute_deadline() {
1647 let upstream =
1648 ToolDeadlineChain::new(vec![finite(ToolDeadlineOwner::CoreToolDispatch, 600)])
1649 .expect("valid upstream deadline");
1650 let streaming =
1651 StreamingToolExecutionPolicy::new(Duration::from_secs(5), Duration::from_secs(60))
1652 .expect("valid streaming policy");
1653 let streaming_contract = ToolExecutionContract::new(
1654 BTreeSet::from([ToolExecutionMode::Streaming]),
1655 ToolExecutionMode::Streaming,
1656 Some(streaming),
1657 None,
1658 )
1659 .expect("valid streaming contract");
1660
1661 let streaming_plan = streaming_contract
1662 .resolve_default(upstream.clone())
1663 .expect("streaming mode resolves");
1664 assert_eq!(
1665 streaming_plan.deadlines().contributors(),
1666 &[
1667 finite(ToolDeadlineOwner::CoreToolDispatch, 600),
1668 finite(ToolDeadlineOwner::StreamingAbsolute, 60),
1669 ]
1670 );
1671
1672 let detached = DetachedToolExecutionPolicy::new(
1673 RunnerIdentity::new("homecore.security_scan", "v1").expect("valid runner"),
1674 RestartClass::NonResumable,
1675 IdempotencyScope::InteractionAndArguments,
1676 Duration::from_secs(10),
1677 )
1678 .expect("valid detached policy");
1679 let detached_contract = ToolExecutionContract::new(
1680 BTreeSet::from([ToolExecutionMode::Detached]),
1681 ToolExecutionMode::Detached,
1682 None,
1683 Some(detached),
1684 )
1685 .expect("valid detached contract");
1686
1687 let detached_plan = detached_contract
1688 .resolve_default(upstream)
1689 .expect("detached mode resolves");
1690 assert_eq!(
1691 detached_plan.deadlines().contributors(),
1692 &[
1693 finite(ToolDeadlineOwner::CoreToolDispatch, 600),
1694 finite(ToolDeadlineOwner::DetachedSubmission, 10),
1695 ]
1696 );
1697 }
1698
1699 #[test]
1700 fn advertised_contract_rejects_an_unadvertised_resolved_mode() {
1701 let upstream =
1702 ToolDeadlineChain::new(vec![finite(ToolDeadlineOwner::CoreToolDispatch, 600)])
1703 .expect("valid upstream deadline");
1704 let detached = DetachedToolExecutionPolicy::new(
1705 RunnerIdentity::new("dishonest.runner", "v1").expect("valid runner"),
1706 RestartClass::NonResumable,
1707 IdempotencyScope::ToolCall,
1708 Duration::from_secs(10),
1709 )
1710 .expect("valid detached policy");
1711 let detached_contract = ToolExecutionContract::new(
1712 BTreeSet::from([ToolExecutionMode::Detached]),
1713 ToolExecutionMode::Detached,
1714 None,
1715 Some(detached),
1716 )
1717 .expect("valid detached contract");
1718 let detached_plan = detached_contract
1719 .resolve_default(upstream)
1720 .expect("detached plan resolves");
1721
1722 assert_eq!(
1723 ToolExecutionContract::default().validate_resolved_plan(&detached_plan),
1724 Err(ToolExecutionContractError::RequestedModeUnsupported {
1725 requested_mode: ToolExecutionMode::Detached,
1726 })
1727 );
1728 }
1729
1730 #[test]
1731 fn advertised_contract_rejects_forged_mode_derived_facets() {
1732 let contract = ToolExecutionContract::default();
1733 let mut plan = contract
1734 .resolve_default(
1735 ToolDeadlineChain::new(vec![finite(ToolDeadlineOwner::CoreToolDispatch, 600)])
1736 .expect("valid deadline"),
1737 )
1738 .expect("fast plan resolves");
1739 plan.output_policy =
1740 ToolExecutionApplicability::Applicable(ToolOutputPolicy::DurableJobResult);
1741
1742 assert_eq!(
1743 contract.validate_resolved_plan(&plan),
1744 Err(ToolExecutionContractError::ResolvedPlanFacetMismatch {
1745 mode: ToolExecutionMode::Fast,
1746 facet: "output_policy",
1747 })
1748 );
1749 }
1750
1751 #[test]
1752 fn logical_binding_fingerprint_ignores_projection_arc_rebuild() {
1753 let tool = || {
1754 std::sync::Arc::new(crate::ToolDef::new(
1755 "stable_tool",
1756 "stable declaration",
1757 serde_json::json!({"type": "object", "properties": {}}),
1758 ))
1759 };
1760 let original = crate::ToolCatalogEntry::session_inline(tool(), true);
1761 let rebuilt = crate::ToolCatalogEntry::session_inline(tool(), true);
1762 let changed = crate::ToolCatalogEntry::session_inline(
1763 std::sync::Arc::new(crate::ToolDef::new(
1764 "stable_tool",
1765 "replacement declaration",
1766 serde_json::json!({"type": "object", "properties": {}}),
1767 )),
1768 true,
1769 );
1770
1771 assert_eq!(
1772 ephemeral_tool_catalog_binding_fingerprint(&original),
1773 ephemeral_tool_catalog_binding_fingerprint(&rebuilt),
1774 "equivalent projection allocations are one live logical binding"
1775 );
1776 assert_ne!(
1777 ephemeral_tool_catalog_binding_fingerprint(&original),
1778 ephemeral_tool_catalog_binding_fingerprint(&changed),
1779 "name reuse with a different declaration must be fenced"
1780 );
1781 }
1782
1783 #[test]
1784 fn resolution_context_rejects_replaced_or_reordered_upstream_deadlines() {
1785 let upstream = ToolDeadlineChain::new(vec![
1786 finite(ToolDeadlineOwner::CoreToolDispatch, 600),
1787 finite(ToolDeadlineOwner::Dispatcher, 30),
1788 ])
1789 .expect("valid upstream deadline");
1790 let context = ToolExecutionResolutionContext::new(upstream.clone());
1791 let valid_plan = ToolExecutionContract::default()
1792 .resolve_default(
1793 upstream
1794 .with_contributor(finite(ToolDeadlineOwner::ToolInternal, 10))
1795 .expect("valid extension"),
1796 )
1797 .expect("default contract resolves");
1798 context
1799 .validate_resolved_plan(&valid_plan)
1800 .expect("ordered extension is valid");
1801
1802 let replaced_plan = ToolExecutionContract::default()
1803 .resolve_default(
1804 ToolDeadlineChain::new(vec![
1805 finite(ToolDeadlineOwner::CoreToolDispatch, 600),
1806 finite(ToolDeadlineOwner::ToolInternal, 10),
1807 ])
1808 .expect("valid but replaced chain"),
1809 )
1810 .expect("default contract resolves");
1811 assert_eq!(
1812 context.validate_resolved_plan(&replaced_plan),
1813 Err(ToolExecutionResolutionError::DeadlineExtension(
1814 DeadlineChainExtensionError::ContributorMismatch {
1815 index: 1,
1816 expected: finite(ToolDeadlineOwner::Dispatcher, 30),
1817 actual: finite(ToolDeadlineOwner::ToolInternal, 10),
1818 }
1819 ))
1820 );
1821
1822 let shorter_plan = ToolExecutionContract::default()
1823 .resolve_default(
1824 ToolDeadlineChain::new(vec![finite(ToolDeadlineOwner::CoreToolDispatch, 600)])
1825 .expect("valid but shorter chain"),
1826 )
1827 .expect("default contract resolves");
1828 assert_eq!(
1829 context.validate_resolved_plan(&shorter_plan),
1830 Err(ToolExecutionResolutionError::DeadlineExtension(
1831 DeadlineChainExtensionError::ShorterThanUpstream {
1832 upstream_len: 2,
1833 resolved_len: 1,
1834 }
1835 ))
1836 );
1837 }
1838
1839 #[test]
1840 fn resolved_plan_exposes_explicit_mode_applicability_and_owner_witness() {
1841 let detached = DetachedToolExecutionPolicy::new(
1842 RunnerIdentity::new("homecore.security_scan", "v1").expect("valid runner"),
1843 RestartClass::NonResumable,
1844 IdempotencyScope::InteractionAndArguments,
1845 Duration::from_secs(10),
1846 )
1847 .expect("valid detached policy")
1848 .with_credential_scopes(["network"]);
1849 let contract = ToolExecutionContract::new(
1850 BTreeSet::from([ToolExecutionMode::Detached]),
1851 ToolExecutionMode::Detached,
1852 None,
1853 Some(detached),
1854 )
1855 .expect("valid detached contract");
1856 let witness = ToolExecutionOwnerWitness::new(
1857 "dynamic-composite:1",
1858 "security_scan",
1859 ephemeral_fingerprint("security_scan"),
1860 )
1861 .expect("valid owner witness");
1862 let plan = contract
1863 .resolve_default(
1864 ToolDeadlineChain::new(vec![finite(ToolDeadlineOwner::CoreToolDispatch, 600)])
1865 .expect("valid deadline"),
1866 )
1867 .expect("detached contract resolves")
1868 .with_owner_witness(witness.clone())
1869 .expect("first owner witness is accepted");
1870
1871 assert_eq!(
1872 plan.runner(),
1873 &ToolExecutionApplicability::Applicable(
1874 RunnerIdentity::new("homecore.security_scan", "v1").expect("valid runner")
1875 )
1876 );
1877 assert_eq!(
1878 plan.restart_class(),
1879 ToolExecutionApplicability::Applicable(RestartClass::NonResumable)
1880 );
1881 assert_eq!(
1882 plan.idempotency_scope(),
1883 ToolExecutionApplicability::Applicable(IdempotencyScope::InteractionAndArguments)
1884 );
1885 assert_eq!(
1886 plan.credential_context_refs(),
1887 &ToolExecutionApplicability::Applicable(vec![
1888 ToolCredentialContextRef::OwningProfile {
1889 required_scopes: BTreeSet::from(["network".to_string()]),
1890 },
1891 ])
1892 );
1893 assert_eq!(
1894 plan.output_policy(),
1895 &ToolExecutionApplicability::Applicable(ToolOutputPolicy::DurableJobResult)
1896 );
1897 assert_eq!(
1898 plan.progress_policy(),
1899 &ToolExecutionApplicability::Applicable(ToolProgressPolicy::DurableJobEvents)
1900 );
1901 assert_eq!(plan.owner_witness("dynamic-composite:1"), Some(&witness));
1902
1903 let fast = ToolExecutionContract::default()
1904 .resolve_default(
1905 ToolDeadlineChain::new(vec![finite(ToolDeadlineOwner::CoreToolDispatch, 600)])
1906 .expect("valid deadline"),
1907 )
1908 .expect("fast contract resolves");
1909 assert_eq!(fast.runner(), &ToolExecutionApplicability::NotApplicable);
1910 assert_eq!(
1911 fast.credential_context_refs(),
1912 &ToolExecutionApplicability::NotApplicable
1913 );
1914 assert_eq!(
1915 fast.output_policy(),
1916 &ToolExecutionApplicability::Applicable(ToolOutputPolicy::InlineTerminal)
1917 );
1918 assert_eq!(
1919 fast.progress_policy(),
1920 &ToolExecutionApplicability::NotApplicable
1921 );
1922 }
1923
1924 #[test]
1925 fn detached_contract_can_advertise_and_validate_call_resolved_restart_classes() {
1926 let policy = DetachedToolExecutionPolicy::new(
1927 RunnerIdentity::new("meerkat.monitor_script", "v1").expect("runner"),
1928 RestartClass::NonResumable,
1929 IdempotencyScope::ToolCall,
1930 Duration::from_secs(30),
1931 )
1932 .expect("policy");
1933 let contract = ToolExecutionContract::new(
1934 BTreeSet::from([ToolExecutionMode::Detached]),
1935 ToolExecutionMode::Detached,
1936 None,
1937 Some(policy),
1938 )
1939 .expect("contract")
1940 .with_detached_restart_classes(BTreeSet::from([
1941 RestartClass::Replayable,
1942 RestartClass::CheckpointResumable,
1943 RestartClass::NonResumable,
1944 ]))
1945 .expect("restart set");
1946 let deadlines =
1947 ToolDeadlineChain::new(vec![finite(ToolDeadlineOwner::CoreToolDispatch, 600)])
1948 .expect("deadlines");
1949 let resolved = contract
1950 .resolve_detached_with_restart_class(
1951 RestartClass::CheckpointResumable,
1952 deadlines.clone(),
1953 )
1954 .expect("resolved");
1955 assert_eq!(
1956 resolved.restart_class(),
1957 ToolExecutionApplicability::Applicable(RestartClass::CheckpointResumable)
1958 );
1959 contract
1960 .validate_resolved_plan(&resolved)
1961 .expect("resolved class remains within the advertised contract");
1962 assert_eq!(
1963 contract.resolve_detached_with_restart_class(RestartClass::Adoptable, deadlines),
1964 Err(
1965 ToolExecutionContractError::RequestedRestartClassUnsupported {
1966 restart_class: RestartClass::Adoptable
1967 }
1968 )
1969 );
1970 }
1971
1972 #[test]
1973 fn owner_witness_rejects_blank_owner_keys() {
1974 assert_eq!(
1975 ToolExecutionOwnerWitness::new(
1976 "authority:a",
1977 " ",
1978 ephemeral_fingerprint("blank-owner"),
1979 ),
1980 Err(ToolExecutionDeclarationError::EmptyOwnerWitnessKey)
1981 );
1982
1983 assert_eq!(
1984 ToolExecutionOwnerWitness::new(
1985 " ",
1986 "owner:a",
1987 ephemeral_fingerprint("blank-authority"),
1988 ),
1989 Err(ToolExecutionDeclarationError::EmptyOwnerWitnessAuthorityKey)
1990 );
1991
1992 let original = ToolExecutionOwnerWitness::new(
1993 "authority:a",
1994 "owner:a",
1995 ephemeral_fingerprint("original"),
1996 )
1997 .expect("valid witness");
1998 let nested = ToolExecutionOwnerWitness::new(
1999 "authority:b",
2000 "owner:b",
2001 ephemeral_fingerprint("nested"),
2002 )
2003 .expect("valid witness");
2004 let attempted = ToolExecutionOwnerWitness::new(
2005 "authority:a",
2006 "owner:c",
2007 ephemeral_fingerprint("attempted"),
2008 )
2009 .expect("valid witness");
2010 let plan = ToolExecutionContract::default()
2011 .resolve_default(
2012 ToolDeadlineChain::new(vec![finite(ToolDeadlineOwner::CoreToolDispatch, 600)])
2013 .expect("valid deadline"),
2014 )
2015 .expect("fast contract resolves")
2016 .with_owner_witness(original.clone())
2017 .expect("first witness is accepted")
2018 .with_owner_witness(nested.clone())
2019 .expect("a nested authority may append its own witness");
2020 assert_eq!(plan.owner_witness("authority:a"), Some(&original));
2021 assert_eq!(plan.owner_witness("authority:b"), Some(&nested));
2022 assert_eq!(
2023 plan.with_owner_witness(attempted.clone()),
2024 Err(ToolExecutionResolutionError::OwnerWitnessAlreadyAssigned {
2025 existing: Box::new(original),
2026 attempted: Box::new(attempted),
2027 })
2028 );
2029 }
2030}