1mod builder;
6pub mod comms_impl;
7pub mod compact;
8mod extraction;
9mod hook_impl;
10#[cfg(test)]
11mod hooks_behavior_tests;
12mod runner;
13pub mod skills;
14mod state;
15#[cfg(test)]
16#[doc(hidden)]
17pub(crate) mod test_turn_state_handle;
18use crate::budget::Budget;
19use crate::comms::{
20 CommsCommand, CommsTrustMutation, CommsTrustMutationResult, EventStream, PeerDirectoryEntry,
21 PeerId, SendAndStreamError, SendError, SendReceipt, StreamError, StreamScope,
22 TrustedPeerDescriptor,
23};
24use crate::compact::SessionCompactionCadence;
25use crate::completion_feed::CompletionSeq;
26use crate::config::{AgentConfig, HookRunOverrides};
27use crate::error::AgentError;
28use crate::event::ExternalToolDelta;
29use crate::hooks::HookEngine;
30use crate::lifecycle::RunId;
31use crate::lifecycle::run_primitive::ProviderParamsOverride;
32use crate::ops::OperationId;
33use crate::ops_lifecycle::{OperationKind, OperationStatus, OperationTerminalOutcome};
34use crate::retry::RetryPolicy;
35use crate::schema::{CompiledSchema, SchemaError};
36use crate::session::Session;
37use crate::state::LoopState;
38#[cfg(target_arch = "wasm32")]
39use crate::tokio;
40use crate::tool_catalog::{
41 ToolCatalogCapabilities, ToolCatalogEntry, ToolCatalogMode, deferred_session_entry_count,
42 select_catalog_mode_from_snapshot,
43};
44use crate::tool_scope::ToolScope;
45use crate::turn_execution_authority::{
46 ContentShape, TurnPhase, TurnPrimitiveKind, TurnTerminalCauseKind, TurnTerminalOutcome,
47};
48use crate::types::{
49 AssistantBlock, BlockAssistantMessage, Message, OutputSchema, StopReason, ToolCallView,
50 ToolDef, ToolName, ToolNameSet, Usage,
51};
52use async_trait::async_trait;
53use serde::{Deserialize, Serialize};
54use std::collections::{BTreeMap, BTreeSet};
55use std::sync::Arc;
56
57pub use builder::{AgentBuildPolicyError, AgentBuilder, DefaultSystemPromptPolicy};
58pub use runner::{AgentControlStateError, AgentRunner, SnapshotProjectionError};
59
60#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
62#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
63pub trait AgentLlmClient: Send + Sync {
64 async fn stream_response(
66 &self,
67 messages: &[Message],
68 tools: &[Arc<ToolDef>],
69 max_tokens: u32,
70 temperature: Option<f32>,
71 provider_params: Option<&ProviderParamsOverride>,
72 ) -> Result<LlmStreamResult, AgentError>;
73
74 fn request_pressure(
81 &self,
82 _messages: &[Message],
83 _tools: &[Arc<ToolDef>],
84 _max_tokens: u32,
85 _temperature: Option<f32>,
86 _provider_params: Option<&ProviderParamsOverride>,
87 ) -> Result<Option<crate::ProviderRequestPressure>, AgentError> {
88 Ok(None)
89 }
90
91 fn provider(&self) -> crate::provider::Provider;
98
99 fn model(&self) -> &str;
105
106 fn prepare_model_fallback(&self, _failure: &AgentError) -> Option<AgentLlmFallbackSwitch> {
113 None
114 }
115
116 fn commit_model_fallback(
130 &self,
131 _previous_identity: &crate::SessionLlmIdentity,
132 target_identity: &crate::SessionLlmIdentity,
133 ) -> Result<(), AgentError> {
134 Err(AgentError::ConfigError(format!(
135 "LLM client proposed fallback target '{}:{}' without an activation implementation",
136 target_identity.provider.as_str(),
137 target_identity.model
138 )))
139 }
140
141 fn active_model_fallback_identity(&self) -> Option<crate::SessionLlmIdentity> {
148 None
149 }
150
151 fn compile_model_fallback_schema(
158 &self,
159 target_identity: &crate::SessionLlmIdentity,
160 _output_schema: &OutputSchema,
161 ) -> Result<CompiledSchema, AgentError> {
162 Err(AgentError::ConfigError(format!(
163 "LLM client cannot compile structured output for fallback target '{}:{}'",
164 target_identity.provider.as_str(),
165 target_identity.model
166 )))
167 }
168
169 fn begin_stream_output_observation(&self) {}
176
177 fn stream_output_observed(&self) -> bool {
184 false
185 }
186
187 fn stream_activity_count(&self) -> Option<u64> {
203 None
204 }
205
206 fn compile_schema(&self, output_schema: &OutputSchema) -> Result<CompiledSchema, SchemaError> {
212 Ok(CompiledSchema {
214 schema: output_schema.schema.as_value().clone(),
215 warnings: Vec::new(),
216 })
217 }
218}
219
220pub type AgentLlmClientDecorator =
226 Arc<dyn Fn(Arc<dyn AgentLlmClient>) -> Arc<dyn AgentLlmClient> + Send + Sync + 'static>;
227
228#[derive(Debug, Clone)]
230pub struct AgentLlmFallbackSkippedTarget {
231 pub identity: crate::SessionLlmIdentity,
232 pub reason: String,
233}
234
235#[derive(Debug, Clone)]
241pub struct AgentLlmFallbackSwitch {
242 pub previous_identity: crate::SessionLlmIdentity,
243 pub new_identity: crate::SessionLlmIdentity,
244 pub request_policy: crate::SessionLlmRequestPolicy,
245 pub target_profile: crate::ModelProfileWitness,
250 pub skipped_targets: Vec<AgentLlmFallbackSkippedTarget>,
251}
252
253pub struct StickyModelFallbackActivationProof {
269 previous_identity: crate::SessionLlmIdentity,
270 target_identity: crate::SessionLlmIdentity,
271 target_profile: crate::ModelProfileWitness,
272 target_capability_base_filter: crate::ToolFilter,
273 retry_attempt: u32,
274}
275
276impl StickyModelFallbackActivationProof {
277 fn new(
278 previous_identity: crate::SessionLlmIdentity,
279 target_identity: crate::SessionLlmIdentity,
280 target_profile: crate::ModelProfileWitness,
281 retry_attempt: u32,
282 ) -> Self {
283 let target_capability_base_filter = crate::capability_base_filter_for_image_tool_results(
284 target_profile.profile().image_tool_results,
285 );
286 Self {
287 previous_identity,
288 target_identity,
289 target_profile,
290 target_capability_base_filter,
291 retry_attempt,
292 }
293 }
294
295 pub fn previous_identity(&self) -> &crate::SessionLlmIdentity {
297 &self.previous_identity
298 }
299
300 pub fn target_identity(&self) -> &crate::SessionLlmIdentity {
302 &self.target_identity
303 }
304
305 pub fn target_profile(&self) -> &crate::ModelProfileWitness {
307 &self.target_profile
308 }
309
310 pub fn target_capability_base_filter(&self) -> &crate::ToolFilter {
312 &self.target_capability_base_filter
313 }
314
315 pub fn retry_attempt(&self) -> u32 {
317 self.retry_attempt
318 }
319}
320
321pub struct LlmStreamResult {
323 blocks: Vec<AssistantBlock>,
324 stop_reason: StopReason,
325 usage: Usage,
326}
327
328impl LlmStreamResult {
329 pub fn new(blocks: Vec<AssistantBlock>, stop_reason: StopReason, usage: Usage) -> Self {
330 Self {
331 blocks,
332 stop_reason,
333 usage,
334 }
335 }
336
337 pub fn blocks(&self) -> &[AssistantBlock] {
338 &self.blocks
339 }
340 pub fn stop_reason(&self) -> StopReason {
341 self.stop_reason
342 }
343 pub fn usage(&self) -> &Usage {
344 &self.usage
345 }
346
347 pub fn into_message(self) -> BlockAssistantMessage {
348 BlockAssistantMessage::new(self.blocks, self.stop_reason)
349 }
350
351 pub fn into_parts(self) -> (Vec<AssistantBlock>, StopReason, Usage) {
352 (self.blocks, self.stop_reason, self.usage)
353 }
354}
355
356#[derive(Debug, Clone, PartialEq, Eq)]
362pub struct AgentExecutionSnapshot {
363 pub loop_state: LoopState,
364 pub turn_phase: TurnPhase,
365 pub turn_terminal: bool,
371 pub active_run_id: Option<RunId>,
372 pub terminal_run_id: Option<RunId>,
373 pub primitive_kind: TurnPrimitiveKind,
374 pub admitted_content_shape: Option<ContentShape>,
375 pub vision_enabled: bool,
376 pub image_tool_results_enabled: bool,
377 pub tool_calls_pending: u32,
378 pub pending_operation_ids: Option<Vec<OperationId>>,
379 pub barrier_operation_ids: Vec<OperationId>,
380 pub has_barrier_ops: bool,
381 pub barrier_satisfied: bool,
382 pub boundary_count: u32,
383 pub cancel_after_boundary: bool,
384 pub terminal_outcome: TurnTerminalOutcome,
385 pub terminal_cause_kind: Option<TurnTerminalCauseKind>,
386 pub extraction_attempts: u32,
387 pub max_extraction_retries: u32,
388 pub applied_cursor: CompletionSeq,
389}
390
391#[derive(Debug, Clone, Default)]
395pub struct ExternalToolUpdate {
396 pub notices: Vec<ExternalToolDelta>,
398 pub pending: Vec<String>,
400}
401
402#[derive(Debug, Clone, PartialEq, Eq)]
411pub struct CancelAfterBoundaryCommand {
412 expected_run_id: RunId,
413}
414
415impl CancelAfterBoundaryCommand {
416 pub fn for_run(expected_run_id: RunId) -> Self {
418 Self { expected_run_id }
419 }
420
421 pub fn expected_run_id(&self) -> &RunId {
423 &self.expected_run_id
424 }
425}
426
427pub type CancelAfterBoundarySender = tokio::sync::mpsc::UnboundedSender<CancelAfterBoundaryCommand>;
434
435#[derive(Debug, Clone, Default, PartialEq, Eq)]
442pub struct ToolDispatchContext {
443 current_turn: Option<CurrentTurnContent>,
444 turn_metadata: BTreeMap<String, serde_json::Value>,
445 origin_session_id: Option<crate::types::SessionId>,
446 interaction_lineage_id: Option<crate::interaction::InteractionId>,
447 streaming: Option<crate::ToolStreamingDispatchContext>,
448}
449
450pub const TOOL_DISPATCH_OBJECTIVE_ID_KEY: &str = "meerkat.objective_id";
452
453impl ToolDispatchContext {
454 pub fn from_current_turn_input(input: &crate::types::ContentInput) -> Self {
455 let blocks = match input {
456 crate::types::ContentInput::Text(_) => None,
457 crate::types::ContentInput::Blocks(blocks) => Some(blocks.clone()),
458 };
459 Self {
460 current_turn: blocks.map(CurrentTurnContent::new),
461 turn_metadata: BTreeMap::new(),
462 origin_session_id: None,
463 interaction_lineage_id: None,
464 streaming: None,
465 }
466 }
467
468 pub fn from_run_input(input: &crate::types::RunInput) -> Self {
472 match input {
473 crate::types::RunInput::Content { content } => Self::from_current_turn_input(content),
474 crate::types::RunInput::PendingToolResults => Self::default(),
475 }
476 }
477
478 #[must_use]
479 pub fn with_turn_metadata(mut self, metadata: BTreeMap<String, serde_json::Value>) -> Self {
480 self.turn_metadata = metadata;
481 self
482 }
483
484 pub fn turn_metadata(&self, key: &str) -> Option<&serde_json::Value> {
485 self.turn_metadata.get(key)
486 }
487
488 pub fn current_turn(&self) -> Option<&CurrentTurnContent> {
489 self.current_turn.as_ref()
490 }
491
492 #[must_use]
497 pub fn with_runtime_identity(
498 mut self,
499 origin_session_id: crate::types::SessionId,
500 interaction_lineage_id: Option<crate::interaction::InteractionId>,
501 ) -> Self {
502 self.origin_session_id = Some(origin_session_id);
503 self.interaction_lineage_id = interaction_lineage_id;
504 self
505 }
506
507 pub fn origin_session_id(&self) -> Option<&crate::types::SessionId> {
508 self.origin_session_id.as_ref()
509 }
510
511 pub const fn interaction_lineage_id(&self) -> Option<crate::interaction::InteractionId> {
512 self.interaction_lineage_id
513 }
514
515 pub const fn streaming(&self) -> Option<&crate::ToolStreamingDispatchContext> {
521 self.streaming.as_ref()
522 }
523
524 pub(crate) fn with_streaming(mut self, streaming: crate::ToolStreamingDispatchContext) -> Self {
525 self.streaming = Some(streaming);
526 self
527 }
528
529 pub fn current_turn_image(
530 &self,
531 image_ref: CurrentTurnImageRef,
532 ) -> Option<&crate::types::ContentBlock> {
533 self.current_turn
534 .as_ref()
535 .and_then(|current_turn| current_turn.image(image_ref))
536 }
537}
538
539#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
554#[serde(transparent)]
555pub struct CurrentTurnImageRef(usize);
556
557impl std::fmt::Display for CurrentTurnImageRef {
558 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
559 std::fmt::Display::fmt(&self.0, f)
560 }
561}
562
563#[derive(Debug, Clone, PartialEq, Eq)]
565pub struct CurrentTurnContent {
566 blocks: Vec<crate::types::ContentBlock>,
567}
568
569impl CurrentTurnContent {
570 pub fn new(blocks: Vec<crate::types::ContentBlock>) -> Self {
571 Self { blocks }
572 }
573
574 pub fn blocks(&self) -> &[crate::types::ContentBlock] {
575 &self.blocks
576 }
577
578 pub fn image_ref(&self, n: usize) -> Option<CurrentTurnImageRef> {
582 self.images().nth(n).map(|_| CurrentTurnImageRef(n))
583 }
584
585 pub fn image(&self, image_ref: CurrentTurnImageRef) -> Option<&crate::types::ContentBlock> {
586 self.images().nth(image_ref.0)
587 }
588
589 fn images(&self) -> impl Iterator<Item = &crate::types::ContentBlock> {
590 self.blocks
591 .iter()
592 .filter(|block| matches!(block, crate::types::ContentBlock::Image { .. }))
593 }
594}
595
596#[derive(Debug, Clone, Serialize, Deserialize)]
603pub struct DetachedOpCompletion {
604 pub job_id: String,
606 pub kind: OperationKind,
608 pub status: OperationStatus,
610 pub terminal_outcome: Option<OperationTerminalOutcome>,
612 pub display_name: String,
614 pub detail: String,
616 pub elapsed_ms: Option<u64>,
618}
619
620#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
625pub struct DispatcherCapabilities {
626 pub ops_lifecycle: bool,
628}
629
630pub enum BindOutcome {
641 Bound(Arc<dyn AgentToolDispatcher>),
643 Skipped(Arc<dyn AgentToolDispatcher>),
646}
647
648impl BindOutcome {
649 pub fn into_dispatcher(self) -> Arc<dyn AgentToolDispatcher> {
651 match self {
652 Self::Bound(d) | Self::Skipped(d) => d,
653 }
654 }
655
656 pub fn was_bound(&self) -> bool {
658 matches!(self, Self::Bound(_))
659 }
660}
661
662#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
664#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
665pub trait AgentToolDispatcher: Send + Sync {
666 fn tools(&self) -> Arc<[Arc<ToolDef>]>;
668
669 fn tool_catalog_capabilities(&self) -> ToolCatalogCapabilities {
675 ToolCatalogCapabilities::default()
676 }
677
678 fn tool_catalog(&self) -> Arc<[ToolCatalogEntry]> {
684 self.tools()
685 .iter()
686 .map(|tool| ToolCatalogEntry::session_inline(Arc::clone(tool), true))
687 .collect::<Vec<_>>()
688 .into()
689 }
690
691 fn execution_binding_epoch(&self, _tool_name: &str) -> u64 {
697 0
698 }
699
700 fn execution_binding_fingerprint(
702 &self,
703 tool_name: &str,
704 ) -> Result<crate::EphemeralToolBindingFingerprint, crate::ToolExecutionResolutionError> {
705 let catalog = self.tool_catalog();
706 let entry = catalog
707 .iter()
708 .find(|entry| entry.tool.name == tool_name)
709 .ok_or_else(|| crate::ToolExecutionResolutionError::NotFound {
710 tool_name: tool_name.to_string(),
711 })?;
712 Ok(crate::ephemeral_tool_catalog_binding_fingerprint(entry)
713 .with_live_authority(0, self.execution_binding_epoch(tool_name)))
714 }
715
716 fn resolve_execution_plan(
723 &self,
724 call: ToolCallView<'_>,
725 _dispatch_context: &ToolDispatchContext,
726 resolution_context: &crate::ToolExecutionResolutionContext,
727 ) -> Result<crate::ResolvedToolExecutionPlan, crate::ToolExecutionResolutionError> {
728 let catalog = self.tool_catalog();
729 let entry = catalog
730 .iter()
731 .find(|entry| entry.tool.name == call.name)
732 .ok_or_else(|| crate::ToolExecutionResolutionError::NotFound {
733 tool_name: call.name.to_string(),
734 })?;
735 if let Some(reason) = entry.callability.unavailable_reason() {
736 return Err(crate::ToolExecutionResolutionError::Unavailable {
737 tool_name: call.name.to_string(),
738 reason,
739 });
740 }
741 entry
742 .execution
743 .resolve_default(resolution_context.deadlines().clone())
744 .map_err(crate::ToolExecutionResolutionError::from)
745 }
746
747 fn validate_resolved_execution_plan(
754 &self,
755 call: ToolCallView<'_>,
756 resolution_context: &crate::ToolExecutionResolutionContext,
757 plan: &crate::ResolvedToolExecutionPlan,
758 ) -> Result<(), crate::ToolExecutionResolutionError> {
759 resolution_context.validate_resolved_plan(plan)?;
760 let catalog = self.tool_catalog();
761 let entry = catalog
762 .iter()
763 .find(|entry| entry.tool.name == call.name)
764 .ok_or_else(|| crate::ToolExecutionResolutionError::NotFound {
765 tool_name: call.name.to_string(),
766 })?;
767 if let Some(reason) = entry.callability.unavailable_reason() {
768 return Err(crate::ToolExecutionResolutionError::Unavailable {
769 tool_name: call.name.to_string(),
770 reason,
771 });
772 }
773 entry
774 .execution
775 .validate_resolved_plan(plan)
776 .map_err(crate::ToolExecutionResolutionError::from)
777 }
778
779 fn pending_catalog_sources(&self) -> Arc<[String]> {
784 Arc::from([])
785 }
786
787 async fn dispatch(
793 &self,
794 call: ToolCallView<'_>,
795 ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError>;
796
797 async fn dispatch_with_context(
803 &self,
804 call: ToolCallView<'_>,
805 _context: &ToolDispatchContext,
806 ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
807 self.dispatch(call).await
808 }
809
810 async fn dispatch_resolved_with_context(
817 &self,
818 call: ToolCallView<'_>,
819 context: &ToolDispatchContext,
820 plan: &crate::ResolvedToolExecutionPlan,
821 ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
822 match plan.mode() {
823 crate::ToolExecutionMode::Fast => self.dispatch_with_context(call, context).await,
824 crate::ToolExecutionMode::Streaming | crate::ToolExecutionMode::Detached => {
825 Err(crate::error::ToolError::unavailable(
826 call.name,
827 crate::ToolUnavailableReason::ExecutionModeOwnerUnavailable,
828 ))
829 }
830 }
831 }
832
833 async fn poll_external_updates(&self) -> ExternalToolUpdate {
839 ExternalToolUpdate::default()
840 }
841
842 fn external_tool_surface_snapshot(&self) -> Option<crate::ExternalToolSurfaceSnapshot> {
848 None
849 }
850
851 fn capabilities(&self) -> DispatcherCapabilities {
853 DispatcherCapabilities::default()
854 }
855
856 fn bind_ops_lifecycle(
864 self: Arc<Self>,
865 _registry: Arc<dyn crate::ops_lifecycle::OpsLifecycleRegistry>,
866 _owner_bridge_session_id: crate::types::SessionId,
867 ) -> Result<BindOutcome, OpsLifecycleBindError> {
868 Err(OpsLifecycleBindError::Unsupported)
869 }
870
871 fn completion_enrichment(
876 &self,
877 ) -> Option<Arc<dyn crate::completion_feed::CompletionEnrichmentProvider>> {
878 None
879 }
880
881 fn bind_mcp_server_lifecycle_handle(
888 &self,
889 _handle: Arc<dyn crate::handles::McpServerLifecycleHandle>,
890 ) {
891 }
892
893 fn bind_external_tool_surface_handle(
900 &self,
901 _handle: Arc<dyn crate::handles::ExternalToolSurfaceHandle>,
902 ) {
903 }
904}
905
906pub fn resolve_tool_execution_plan_fenced<T: AgentToolDispatcher + ?Sized + 'static>(
913 dispatcher: &Arc<T>,
914 call: ToolCallView<'_>,
915 dispatch_context: &ToolDispatchContext,
916 resolution_context: &crate::ToolExecutionResolutionContext,
917) -> Result<crate::ResolvedToolExecutionPlan, crate::ToolExecutionResolutionError> {
918 let before = dispatcher.execution_binding_fingerprint(call.name)?;
919 let plan = dispatcher.resolve_execution_plan(call, dispatch_context, resolution_context)?;
920 if dispatcher.execution_binding_fingerprint(call.name)? != before {
921 return Err(crate::ToolExecutionResolutionError::Unavailable {
922 tool_name: call.name.to_string(),
923 reason: crate::ToolUnavailableReason::ExecutionOwnerChanged,
924 });
925 }
926 let witness = crate::ToolExecutionOwnerWitness::new("root-dispatcher", call.name, before)
927 .map_err(crate::ToolExecutionResolutionError::from)?;
928 plan.with_owner_witness(witness)?
929 .bind_root_dispatch(Arc::clone(dispatcher), call)
930}
931
932pub async fn dispatch_tool_execution_plan_fenced<T: AgentToolDispatcher + ?Sized + 'static>(
935 dispatcher: &Arc<T>,
936 call: ToolCallView<'_>,
937 context: &ToolDispatchContext,
938 plan: &crate::ResolvedToolExecutionPlan,
939) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
940 plan.validate_root_dispatch(dispatcher, call)?;
941 let witness = plan.owner_witness("root-dispatcher").ok_or_else(|| {
942 crate::error::ToolError::unavailable(
943 call.name,
944 crate::ToolUnavailableReason::ExecutionOwnerChanged,
945 )
946 })?;
947 if witness.binding_fingerprint() != &dispatcher.execution_binding_fingerprint(call.name)? {
948 return Err(crate::error::ToolError::unavailable(
949 call.name,
950 crate::ToolUnavailableReason::ExecutionOwnerChanged,
951 ));
952 }
953 match plan.kind() {
954 crate::ResolvedExecutionKind::Streaming(policy) => {
955 let absolute_timeout = plan
956 .deadlines()
957 .effective_timeout()
958 .unwrap_or_else(|| policy.absolute_timeout());
959 crate::streaming_tool::supervise_streaming_tool(
960 call.name,
961 policy.inactivity_timeout(),
962 absolute_timeout,
963 |streaming| {
964 let streaming_context = context.clone().with_streaming(streaming);
965 async move {
966 dispatcher
967 .dispatch_resolved_with_context(call, &streaming_context, plan)
968 .await
969 }
970 },
971 )
972 .await
973 }
974 crate::ResolvedExecutionKind::Fast | crate::ResolvedExecutionKind::Detached(_) => {
975 dispatcher
976 .dispatch_resolved_with_context(call, context, plan)
977 .await
978 }
979 }
980}
981
982pub fn select_tool_catalog_mode<T>(dispatcher: &T) -> ToolCatalogMode
984where
985 T: AgentToolDispatcher + ?Sized,
986{
987 let capabilities = dispatcher.tool_catalog_capabilities();
988 if !capabilities.exact_catalog {
989 return ToolCatalogMode::Inline;
990 }
991 let pending_sources = dispatcher.pending_catalog_sources();
992 let catalog = dispatcher.tool_catalog();
993 select_catalog_mode_from_snapshot(
994 capabilities.exact_catalog,
995 catalog.as_ref(),
996 pending_sources.as_ref(),
997 )
998}
999
1000pub fn should_compose_tool_catalog_control_plane<T>(dispatcher: &T) -> bool
1003where
1004 T: AgentToolDispatcher + ?Sized,
1005{
1006 let capabilities = dispatcher.tool_catalog_capabilities();
1007 if !capabilities.exact_catalog {
1008 return false;
1009 }
1010 if capabilities.may_require_catalog_control_plane {
1011 return true;
1012 }
1013
1014 let pending_sources = dispatcher.pending_catalog_sources();
1015 if !pending_sources.is_empty() {
1016 return true;
1017 }
1018
1019 let catalog = dispatcher.tool_catalog();
1020 deferred_session_entry_count(catalog.as_ref()) > 0
1021}
1022
1023#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
1025pub enum OpsLifecycleBindError {
1026 #[error("ops lifecycle binding is unsupported")]
1027 Unsupported,
1028 #[error("dispatcher has shared ownership and cannot be rebound")]
1029 SharedOwnership,
1030}
1031
1032pub struct FilteredToolDispatcher<T: AgentToolDispatcher + ?Sized> {
1039 inner: Arc<T>,
1040 allowed_tools: ToolNameSet,
1041 filtered_tools: Arc<[Arc<ToolDef>]>,
1043}
1044
1045impl<T: AgentToolDispatcher + ?Sized> FilteredToolDispatcher<T> {
1046 pub fn new<I, N>(inner: Arc<T>, allowed_tools: I) -> Self
1047 where
1048 I: IntoIterator<Item = N>,
1049 N: Into<ToolName>,
1050 {
1051 let allowed_set: ToolNameSet = allowed_tools
1052 .into_iter()
1053 .map(Into::into)
1054 .collect::<ToolNameSet>();
1055
1056 let filtered: Vec<Arc<ToolDef>> = if inner.tool_catalog_capabilities().exact_catalog {
1057 inner
1058 .tool_catalog()
1059 .iter()
1060 .filter(|entry| entry.currently_callable())
1061 .map(|entry| Arc::clone(&entry.tool))
1062 .filter(|t| allowed_set.contains(t.name.as_str()))
1063 .collect()
1064 } else {
1065 inner
1066 .tools()
1067 .iter()
1068 .filter(|t| allowed_set.contains(t.name.as_str()))
1069 .map(Arc::clone)
1070 .collect()
1071 };
1072
1073 Self {
1074 inner,
1075 allowed_tools: allowed_set,
1076 filtered_tools: filtered.into(),
1077 }
1078 }
1079}
1080
1081#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
1082#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
1083impl<T: AgentToolDispatcher + ?Sized + 'static> AgentToolDispatcher for FilteredToolDispatcher<T> {
1084 fn tools(&self) -> Arc<[Arc<ToolDef>]> {
1085 if self.inner.tool_catalog_capabilities().exact_catalog {
1086 return self
1087 .inner
1088 .tool_catalog()
1089 .iter()
1090 .filter(|entry| entry.currently_callable())
1091 .map(|entry| Arc::clone(&entry.tool))
1092 .filter(|tool| self.allowed_tools.contains(tool.name.as_str()))
1093 .collect::<Vec<_>>()
1094 .into();
1095 }
1096 Arc::clone(&self.filtered_tools)
1097 }
1098
1099 async fn dispatch(
1100 &self,
1101 call: ToolCallView<'_>,
1102 ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
1103 self.dispatch_with_context(call, &ToolDispatchContext::default())
1104 .await
1105 }
1106
1107 async fn dispatch_with_context(
1108 &self,
1109 call: ToolCallView<'_>,
1110 context: &ToolDispatchContext,
1111 ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
1112 if !self.allowed_tools.contains(call.name) {
1113 let inner_knows_tool = if self.inner.tool_catalog_capabilities().exact_catalog {
1114 self.inner
1115 .tool_catalog()
1116 .iter()
1117 .any(|entry| entry.tool.name == call.name)
1118 } else {
1119 self.inner.tools().iter().any(|tool| tool.name == call.name)
1120 };
1121 if !inner_knows_tool {
1122 return Err(crate::error::ToolError::not_found(call.name));
1123 }
1124 return Err(crate::error::ToolError::access_denied(call.name));
1125 }
1126 self.inner.dispatch_with_context(call, context).await
1127 }
1128
1129 async fn dispatch_resolved_with_context(
1130 &self,
1131 call: ToolCallView<'_>,
1132 context: &ToolDispatchContext,
1133 plan: &crate::ResolvedToolExecutionPlan,
1134 ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
1135 if !self.allowed_tools.contains(call.name) {
1136 let inner_knows_tool = if self.inner.tool_catalog_capabilities().exact_catalog {
1137 self.inner
1138 .tool_catalog()
1139 .iter()
1140 .any(|entry| entry.tool.name == call.name)
1141 } else {
1142 self.inner.tools().iter().any(|tool| tool.name == call.name)
1143 };
1144 if !inner_knows_tool {
1145 return Err(crate::error::ToolError::not_found(call.name));
1146 }
1147 return Err(crate::error::ToolError::access_denied(call.name));
1148 }
1149 self.inner
1150 .dispatch_resolved_with_context(call, context, plan)
1151 .await
1152 }
1153
1154 fn tool_catalog_capabilities(&self) -> ToolCatalogCapabilities {
1155 self.inner.tool_catalog_capabilities()
1156 }
1157
1158 fn tool_catalog(&self) -> Arc<[ToolCatalogEntry]> {
1159 if !self.inner.tool_catalog_capabilities().exact_catalog {
1160 return self
1161 .tools()
1162 .iter()
1163 .map(|tool| ToolCatalogEntry::session_inline(Arc::clone(tool), true))
1164 .collect::<Vec<_>>()
1165 .into();
1166 }
1167 self.inner
1168 .tool_catalog()
1169 .iter()
1170 .filter(|entry| self.allowed_tools.contains(entry.tool.name.as_str()))
1171 .cloned()
1172 .collect::<Vec<_>>()
1173 .into()
1174 }
1175
1176 fn execution_binding_fingerprint(
1177 &self,
1178 tool_name: &str,
1179 ) -> Result<crate::EphemeralToolBindingFingerprint, crate::ToolExecutionResolutionError> {
1180 let catalog = self.tool_catalog();
1181 let entry = catalog
1182 .iter()
1183 .find(|entry| entry.tool.name == tool_name)
1184 .ok_or_else(|| crate::ToolExecutionResolutionError::NotFound {
1185 tool_name: tool_name.to_string(),
1186 })?;
1187 let child = self.inner.execution_binding_fingerprint(tool_name)?;
1188 Ok(crate::ephemeral_tool_catalog_binding_fingerprint(entry)
1189 .with_live_authority(0, 0)
1190 .with_dependency(&child))
1191 }
1192
1193 fn resolve_execution_plan(
1194 &self,
1195 call: ToolCallView<'_>,
1196 dispatch_context: &ToolDispatchContext,
1197 resolution_context: &crate::ToolExecutionResolutionContext,
1198 ) -> Result<crate::ResolvedToolExecutionPlan, crate::ToolExecutionResolutionError> {
1199 if !self.allowed_tools.contains(call.name) {
1200 let inner_knows_tool = if self.inner.tool_catalog_capabilities().exact_catalog {
1201 self.inner
1202 .tool_catalog()
1203 .iter()
1204 .any(|entry| entry.tool.name == call.name)
1205 } else {
1206 self.inner.tools().iter().any(|tool| tool.name == call.name)
1207 };
1208 return Err(if inner_knows_tool {
1209 crate::ToolExecutionResolutionError::AccessDenied {
1210 tool_name: call.name.to_string(),
1211 }
1212 } else {
1213 crate::ToolExecutionResolutionError::NotFound {
1214 tool_name: call.name.to_string(),
1215 }
1216 });
1217 }
1218
1219 let catalog = self.tool_catalog();
1220 let entry = catalog
1221 .iter()
1222 .find(|entry| entry.tool.name == call.name)
1223 .ok_or_else(|| crate::ToolExecutionResolutionError::NotFound {
1224 tool_name: call.name.to_string(),
1225 })?;
1226 if let Some(reason) = entry.callability.unavailable_reason() {
1227 return Err(crate::ToolExecutionResolutionError::Unavailable {
1228 tool_name: call.name.to_string(),
1229 reason,
1230 });
1231 }
1232
1233 self.inner
1234 .resolve_execution_plan(call, dispatch_context, resolution_context)
1235 }
1236
1237 fn pending_catalog_sources(&self) -> Arc<[String]> {
1238 self.inner.pending_catalog_sources()
1239 }
1240
1241 async fn poll_external_updates(&self) -> ExternalToolUpdate {
1242 self.inner.poll_external_updates().await
1243 }
1244
1245 fn external_tool_surface_snapshot(&self) -> Option<crate::ExternalToolSurfaceSnapshot> {
1246 self.inner.external_tool_surface_snapshot()
1247 }
1248
1249 fn capabilities(&self) -> DispatcherCapabilities {
1250 self.inner.capabilities()
1251 }
1252
1253 fn bind_ops_lifecycle(
1254 self: Arc<Self>,
1255 registry: Arc<dyn crate::ops_lifecycle::OpsLifecycleRegistry>,
1256 owner_bridge_session_id: crate::types::SessionId,
1257 ) -> Result<BindOutcome, OpsLifecycleBindError> {
1258 let owned = Arc::try_unwrap(self).map_err(|_| OpsLifecycleBindError::SharedOwnership)?;
1259 if Arc::strong_count(&owned.inner) == 1 {
1260 let outcome = owned
1261 .inner
1262 .bind_ops_lifecycle(registry, owner_bridge_session_id)?;
1263 let bound = outcome.was_bound();
1264 let d = outcome.into_dispatcher();
1265 let allowed_tools = owned.allowed_tools.into_iter().collect::<Vec<_>>();
1266 Ok(if bound {
1267 BindOutcome::Bound(Arc::new(FilteredToolDispatcher::new(d, allowed_tools)))
1268 } else {
1269 BindOutcome::Skipped(Arc::new(FilteredToolDispatcher::new(d, allowed_tools)))
1270 })
1271 } else {
1272 Ok(BindOutcome::Skipped(Arc::new(FilteredToolDispatcher {
1273 inner: owned.inner,
1274 allowed_tools: owned.allowed_tools,
1275 filtered_tools: owned.filtered_tools,
1276 })))
1277 }
1278 }
1279
1280 fn completion_enrichment(
1281 &self,
1282 ) -> Option<Arc<dyn crate::completion_feed::CompletionEnrichmentProvider>> {
1283 self.inner.completion_enrichment()
1284 }
1285
1286 fn bind_mcp_server_lifecycle_handle(
1287 &self,
1288 handle: Arc<dyn crate::handles::McpServerLifecycleHandle>,
1289 ) {
1290 self.inner.bind_mcp_server_lifecycle_handle(handle);
1291 }
1292
1293 fn bind_external_tool_surface_handle(
1294 &self,
1295 handle: Arc<dyn crate::handles::ExternalToolSurfaceHandle>,
1296 ) {
1297 self.inner.bind_external_tool_surface_handle(handle);
1298 }
1299}
1300
1301#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
1303#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
1304pub trait AgentSessionStore: Send + Sync {
1305 async fn save(&self, session: &Session) -> Result<(), AgentError>;
1306 async fn load(&self, id: &str) -> Result<Option<Session>, AgentError>;
1307}
1308
1309#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1311pub enum InlinePeerNotificationPolicy {
1312 Always,
1314 Never,
1316 AtMost(usize),
1318}
1319
1320pub const DEFAULT_MAX_INLINE_PEER_NOTIFICATIONS: usize = 50;
1322
1323impl InlinePeerNotificationPolicy {
1324 pub fn try_from_raw(raw: Option<i32>) -> Result<Self, i32> {
1326 match raw {
1327 None => Ok(Self::AtMost(DEFAULT_MAX_INLINE_PEER_NOTIFICATIONS)),
1328 Some(-1) => Ok(Self::Always),
1329 Some(0) => Ok(Self::Never),
1330 Some(v) if v > 0 => Ok(Self::AtMost(v as usize)),
1331 Some(v) => Err(v),
1332 }
1333 }
1334}
1335
1336#[derive(Debug, thiserror::Error)]
1338pub enum CommsCapabilityError {
1339 #[error("comms capability not supported: {0}")]
1341 Unsupported(String),
1342}
1343
1344#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
1346#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
1347pub trait CommsRuntime: Send + Sync {
1348 fn peer_id(&self) -> Option<PeerId> {
1356 self.public_key()
1357 .as_deref()
1358 .and_then(|public_key| PeerId::parse(public_key).ok())
1359 }
1360
1361 fn public_key(&self) -> Option<String> {
1367 None
1368 }
1369
1370 fn public_key_bytes(&self) -> Option<[u8; 32]> {
1376 None
1377 }
1378
1379 fn comms_name(&self) -> Option<String> {
1385 None
1386 }
1387
1388 fn advertised_address(&self) -> Option<String> {
1394 None
1395 }
1396
1397 fn bridge_bootstrap_token(&self) -> Option<String> {
1400 None
1401 }
1402
1403 async fn apply_trust_mutation(
1408 &self,
1409 _mutation: CommsTrustMutation,
1410 ) -> Result<CommsTrustMutationResult, SendError> {
1411 Err(SendError::Unsupported(
1412 "apply_trust_mutation not supported for this CommsRuntime".to_string(),
1413 ))
1414 }
1415
1416 async fn install_generated_mob_trust_owner(
1423 &self,
1424 _owner: Arc<dyn std::any::Any + Send + Sync>,
1425 ) -> Result<(), SendError> {
1426 Err(SendError::Unsupported(
1427 "generated mob trust owner binding not supported for this CommsRuntime".to_string(),
1428 ))
1429 }
1430
1431 async fn validate_recovered_generated_mob_trust_owner(
1439 &self,
1440 _owner: Arc<dyn std::any::Any + Send + Sync>,
1441 ) -> Result<(), SendError> {
1442 Err(SendError::Unsupported(
1443 "recovered generated mob trust owner validation not supported for this CommsRuntime"
1444 .to_string(),
1445 ))
1446 }
1447
1448 async fn install_recovered_generated_mob_trust_owner(
1457 &self,
1458 _owner: Arc<dyn std::any::Any + Send + Sync>,
1459 ) -> Result<(), SendError> {
1460 Err(SendError::Unsupported(
1461 "recovered generated mob trust owner binding not supported for this CommsRuntime"
1462 .to_string(),
1463 ))
1464 }
1465
1466 fn host_acceptor_registration_payload(&self) -> Option<Arc<dyn std::any::Any + Send + Sync>> {
1479 None
1480 }
1481
1482 async fn add_private_trusted_peer(
1493 &self,
1494 _peer: TrustedPeerDescriptor,
1495 ) -> Result<(), SendError> {
1496 Err(SendError::Unsupported(
1497 "generated comms private trust mutation authority required".to_string(),
1498 ))
1499 }
1500
1501 async fn remove_private_trusted_peer(&self, _peer_id: &str) -> Result<bool, SendError> {
1506 Err(SendError::Unsupported(
1507 "generated comms private trust mutation authority required".to_string(),
1508 ))
1509 }
1510
1511 fn set_outbound_content_taint(
1529 &self,
1530 _taint: Option<crate::comms::SenderContentTaint>,
1531 ) -> Result<(), SendError> {
1532 Err(SendError::Unsupported(
1533 "outbound content-taint declaration not supported by this CommsRuntime".to_string(),
1534 ))
1535 }
1536
1537 async fn send(&self, _cmd: CommsCommand) -> Result<SendReceipt, SendError> {
1539 Err(SendError::Unsupported(
1540 "send not implemented for this CommsRuntime".to_string(),
1541 ))
1542 }
1543
1544 #[doc(hidden)]
1545 fn stream(&self, scope: StreamScope) -> Result<EventStream, StreamError> {
1546 let scope_desc = match scope {
1547 StreamScope::Session(session_id) => format!("session {session_id}"),
1548 StreamScope::Interaction(interaction_id) => format!("interaction {}", interaction_id.0),
1549 };
1550 Err(StreamError::NotFound(scope_desc))
1551 }
1552
1553 async fn peers(&self) -> Vec<PeerDirectoryEntry> {
1555 Vec::new()
1556 }
1557
1558 async fn peer_count(&self) -> usize {
1562 self.peers().await.len()
1563 }
1564
1565 #[doc(hidden)]
1566 async fn send_and_stream(
1567 &self,
1568 cmd: CommsCommand,
1569 ) -> Result<(SendReceipt, EventStream), SendAndStreamError> {
1570 let receipt = self.send(cmd).await?;
1571 Err(SendAndStreamError::StreamAttach {
1572 receipt,
1573 error: StreamError::Internal(
1574 "send_and_stream is not implemented for this runtime".to_string(),
1575 ),
1576 })
1577 }
1578
1579 async fn drain_messages(&self) -> Vec<String>;
1581 fn inbox_notify(&self) -> Arc<tokio::sync::Notify>;
1583 fn dismiss_received(&self) -> bool {
1585 false
1586 }
1587 fn event_injector(&self) -> Option<Arc<dyn crate::EventInjector>> {
1592 None
1593 }
1594
1595 #[doc(hidden)]
1597 fn interaction_event_injector(
1598 &self,
1599 ) -> Option<Arc<dyn crate::event_injector::SubscribableInjector>> {
1600 None
1601 }
1602
1603 async fn drain_inbox_interactions(&self) -> Vec<crate::interaction::InboxInteraction> {
1608 self.drain_messages()
1609 .await
1610 .into_iter()
1611 .map(|text| crate::interaction::InboxInteraction {
1612 objective_id: None,
1613 id: crate::interaction::InteractionId(uuid::Uuid::new_v4()),
1614 from_route: None,
1615 from: "unknown".into(),
1616 content: crate::interaction::InteractionContent::Message {
1617 body: text.clone(),
1618 blocks: None,
1619 },
1620 rendered_text: text,
1621 handling_mode: crate::types::HandlingMode::Queue,
1622 render_metadata: None,
1623 sender_taint: None,
1624 })
1625 .collect()
1626 }
1627
1628 fn interaction_subscriber(
1633 &self,
1634 _id: &crate::interaction::InteractionId,
1635 ) -> Option<tokio::sync::mpsc::Sender<crate::event::AgentEvent>> {
1636 None
1637 }
1638
1639 fn take_interaction_stream_sender(
1641 &self,
1642 _id: &crate::interaction::InteractionId,
1643 ) -> Option<tokio::sync::mpsc::Sender<crate::event::AgentEvent>> {
1644 self.interaction_subscriber(_id)
1645 }
1646
1647 fn mark_interaction_complete(&self, _id: &crate::interaction::InteractionId) {}
1653
1654 fn abandon_interaction_stream(
1659 &self,
1660 _id: &crate::interaction::InteractionId,
1661 _reason: crate::InteractionStreamAbandonReason,
1662 ) {
1663 }
1664
1665 fn peer_interaction_handle(
1671 &self,
1672 ) -> Option<std::sync::Arc<dyn crate::handles::PeerInteractionHandle>> {
1673 None
1674 }
1675
1676 fn peer_request_response_authority_handle(
1685 &self,
1686 ) -> Option<std::sync::Arc<dyn crate::handles::PeerInteractionHandle>> {
1687 None
1688 }
1689
1690 async fn drain_classified_inbox_interactions(
1698 &self,
1699 ) -> Result<Vec<crate::interaction::ClassifiedInboxInteraction>, CommsCapabilityError> {
1700 Err(CommsCapabilityError::Unsupported(
1701 "drain_classified_inbox_interactions".to_string(),
1702 ))
1703 }
1704
1705 async fn try_recv_classified_inbox_interaction(
1712 &self,
1713 ) -> Result<Option<crate::interaction::ClassifiedInboxInteraction>, CommsCapabilityError> {
1714 Err(CommsCapabilityError::Unsupported(
1715 "try_recv_classified_inbox_interaction".to_string(),
1716 ))
1717 }
1718
1719 async fn drain_peer_input_candidates(&self) -> Vec<crate::interaction::PeerInputCandidate> {
1726 self.drain_classified_inbox_interactions()
1727 .await
1728 .unwrap_or_default()
1729 }
1730
1731 async fn peer_ingress_queue_snapshot(
1736 &self,
1737 ) -> Result<crate::interaction::PeerIngressQueueSnapshot, CommsCapabilityError> {
1738 Err(CommsCapabilityError::Unsupported(
1739 "peer_ingress_queue_snapshot".to_string(),
1740 ))
1741 }
1742
1743 async fn peer_ingress_runtime_snapshot(
1748 &self,
1749 ) -> Result<crate::interaction::PeerIngressRuntimeSnapshot, CommsCapabilityError> {
1750 Err(CommsCapabilityError::Unsupported(
1751 "peer_ingress_runtime_snapshot".to_string(),
1752 ))
1753 }
1754
1755 async fn public_trusted_peer_projection_snapshot(
1762 &self,
1763 ) -> Result<Vec<crate::comms::TrustedPeerDescriptor>, CommsCapabilityError> {
1764 Err(CommsCapabilityError::Unsupported(
1765 "public_trusted_peer_projection_snapshot".to_string(),
1766 ))
1767 }
1768
1769 async fn trusted_peer_projection_snapshot_for_source(
1776 &self,
1777 _source_kind: crate::comms::GeneratedCommsTrustAuthoritySourceKind,
1778 ) -> Result<Vec<crate::comms::TrustedPeerDescriptor>, CommsCapabilityError> {
1779 Err(CommsCapabilityError::Unsupported(
1780 "trusted_peer_projection_snapshot_for_source".to_string(),
1781 ))
1782 }
1783
1784 fn actionable_input_notify(&self) -> Result<Arc<tokio::sync::Notify>, CommsCapabilityError> {
1789 Err(CommsCapabilityError::Unsupported(
1790 "actionable_input_notify".to_string(),
1791 ))
1792 }
1793
1794 async fn stage_declared_reply_endpoint(
1811 &self,
1812 _dest: PeerId,
1813 _signer_pubkey: [u8; 32],
1814 _declared_address: String,
1815 ) -> Result<(), SendError> {
1816 Err(SendError::Unsupported(
1817 "declared reply endpoint staging not supported".to_string(),
1818 ))
1819 }
1820
1821 async fn stage_correlated_reply_endpoint(
1835 &self,
1836 _dest: PeerId,
1837 _in_reply_to: crate::interaction::InteractionId,
1838 _signer_pubkey: [u8; 32],
1839 _declared_endpoint: crate::comms::PeerAddress,
1840 ) -> Result<(), SendError> {
1841 Err(SendError::Unsupported(
1842 "correlated reply endpoint staging not supported".to_string(),
1843 ))
1844 }
1845
1846 async fn unstage_correlated_reply_endpoint(
1850 &self,
1851 _dest: PeerId,
1852 _in_reply_to: crate::interaction::InteractionId,
1853 ) -> Result<(), SendError> {
1854 Err(SendError::Unsupported(
1855 "correlated reply endpoint cleanup not supported".to_string(),
1856 ))
1857 }
1858
1859 fn take_bridge_reply_waiter(
1871 &self,
1872 _in_reply_to: &crate::interaction::InteractionId,
1873 ) -> Option<tokio::sync::oneshot::Sender<crate::interaction::PeerInputCandidate>> {
1874 None
1875 }
1876
1877 fn has_bridge_reply_waiter(&self, _in_reply_to: &crate::interaction::InteractionId) -> bool {
1880 false
1881 }
1882}
1883
1884pub struct Agent<C, T, S>
1886where
1887 C: AgentLlmClient + ?Sized,
1888 T: AgentToolDispatcher + ?Sized,
1889 S: AgentSessionStore + ?Sized,
1890{
1891 config: AgentConfig,
1892 client: Arc<C>,
1893 tools: Arc<T>,
1894 tool_scope: ToolScope,
1895 store: Arc<S>,
1896 session: Session,
1897 budget: Budget,
1898 retry_policy: RetryPolicy,
1899 depth: u32,
1900 pub(super) comms_runtime: Option<Arc<dyn CommsRuntime>>,
1901 pub(super) hook_engine: Option<Arc<dyn HookEngine>>,
1902 pub(super) hook_run_overrides: HookRunOverrides,
1903 pub(crate) compactor: Option<Arc<dyn crate::compact::Compactor>>,
1905 pub(crate) compaction_curator: Option<Arc<dyn crate::compact::CompactionCurator>>,
1908 pub(crate) last_input_tokens: u64,
1910 pub(crate) compaction_cadence: SessionCompactionCadence,
1912 pub(crate) pending_compaction_boundary_index: Option<u64>,
1915 pub(crate) pending_compaction_request_pressure: Option<crate::ProviderRequestPressure>,
1917 pub(crate) post_compaction_pressure_check: Option<crate::ProviderRequestPressure>,
1920 pub(crate) memory_store: Option<Arc<dyn crate::memory::MemoryStore>>,
1922 pub(crate) compaction_commit_coordinator:
1925 Option<Arc<dyn crate::memory::CompactionCommitCoordinator>>,
1926 pub(crate) compaction_transaction: Option<CompactionTransaction>,
1931 pub(crate) in_flight_compaction_stage: Option<crate::memory::CompactionProjectionId>,
1936 pub(crate) skill_engine: Option<Arc<crate::skills::SkillRuntime>>,
1938 pub pending_skill_references: Option<Vec<crate::skills::SkillKey>>,
1941 pub(crate) event_tap: crate::event_tap::EventTap,
1943 pub(crate) transient_turn_context_state: crate::session::TransientTurnContextStateHandle,
1945 pub(crate) default_event_tx: Option<tokio::sync::mpsc::Sender<crate::event::AgentEvent>>,
1948 pub(crate) checkpointer: Option<Arc<dyn crate::SessionCheckpointer>>,
1953 pub(crate) latest_run_checkpoint_receipt: Option<crate::RunCheckpointReceipt>,
1959 pub(crate) blob_store: Option<Arc<dyn crate::BlobStore>>,
1961 pub(crate) terminal_error_detail: Option<String>,
1965 pub(crate) terminal_error_metadata: Option<crate::TurnErrorMetadata>,
1968 pub(crate) run_completed_hooks_applied: bool,
1970 pub(crate) run_completed_event_emitted: bool,
1973 #[allow(dead_code)] pub(crate) silent_comms_intents: Vec<String>,
1977 pub(crate) ops_lifecycle: Option<Arc<dyn crate::ops_lifecycle::OpsLifecycleRegistry>>,
1979 pub(crate) completion_feed: Option<Arc<dyn crate::completion_feed::CompletionFeed>>,
1981 pub(crate) epoch_cursor_state: Option<Arc<crate::runtime_epoch::EpochCursorState>>,
1983 pub(crate) applied_cursor: crate::completion_feed::CompletionSeq,
1985 pub(crate) completion_enrichment:
1987 Option<Arc<dyn crate::completion_feed::CompletionEnrichmentProvider>>,
1988 pub(crate) mob_authority_handle:
1993 Option<Arc<std::sync::RwLock<crate::service::MobToolAuthorityContext>>>,
1994 pub(crate) turn_state_handle: Option<Arc<dyn crate::TurnStateHandle>>,
1996 pub(crate) model_routing_handle: Option<Arc<dyn crate::handles::ModelRoutingHandle>>,
1999 pub(crate) sticky_model_fallback_commit_coordinator:
2003 Option<Arc<dyn crate::handles::StickyModelFallbackCommitCoordinator>>,
2004 pub(crate) pending_sticky_model_fallback_activation:
2007 Option<state::PendingStickyModelFallbackActivation>,
2008 pub(crate) pending_callback_async_ops: Option<Vec<crate::ops::AsyncOpRef>>,
2012 pub(crate) effective_model_registry: Option<Arc<crate::ModelRegistry>>,
2016 pub(crate) active_model_profile: Option<crate::ModelProfileWitness>,
2019 pub(crate) runtime_execution_kind_required: bool,
2021 pub(crate) runtime_execution_kind: Option<crate::lifecycle::RuntimeExecutionKind>,
2024 pub(crate) runtime_started_run_id: Option<crate::lifecycle::RunId>,
2029 pub(crate) runtime_terminal_failure_witness:
2034 Option<Result<crate::TurnErrorMetadata, crate::error::AgentError>>,
2035 pub(crate) active_transcript_identity: Option<crate::types::TranscriptMessageIdentity>,
2037 pub(crate) active_turn_request_contexts:
2042 Vec<crate::lifecycle::run_primitive::TurnRequestContext>,
2043 pub(crate) external_tool_surface_handle: Option<Arc<dyn crate::ExternalToolSurfaceHandle>>,
2046 pub(crate) auth_lease_handle: Option<crate::handles::GeneratedAuthLeaseHandle>,
2048 pub(crate) mcp_server_lifecycle_handle:
2052 Option<Arc<dyn crate::handles::McpServerLifecycleHandle>>,
2053 pub(crate) cancel_after_boundary_tx: CancelAfterBoundarySender,
2060 pub(crate) cancel_after_boundary_rx:
2068 tokio::sync::mpsc::UnboundedReceiver<CancelAfterBoundaryCommand>,
2069 pub(crate) model_defaults_resolver:
2072 Option<Arc<dyn crate::model_defaults::ModelOperationalDefaultsResolver>>,
2073 pub(crate) call_timeout_override: crate::config::CallTimeoutOverride,
2076 pub(crate) extraction_state: extraction::ExtractionState,
2078 pub(crate) last_hidden_deferred_catalog_names: BTreeSet<crate::types::ToolName>,
2080 pub(crate) last_pending_catalog_sources: BTreeSet<String>,
2082 pub(crate) tool_dispatch_context: ToolDispatchContext,
2084 pub(crate) turn_tool_dispatch_metadata: BTreeMap<String, serde_json::Value>,
2086 pub(crate) tools_config: crate::config::ToolsConfig,
2091}
2092
2093#[derive(Clone)]
2094pub(crate) struct CompactionRollbackState {
2095 pub(crate) rollback_session: Session,
2096 pub(crate) rollback_last_input_tokens: u64,
2097 pub(crate) rollback_compaction_cadence: SessionCompactionCadence,
2098}
2099
2100pub(crate) enum CompactionTransactionPhase {
2101 AwaitingRuntimeCommit(Box<CompactionRollbackState>),
2102 RuntimeCommitted { bookkeeping_complete: bool },
2103 AbortPending { cadence_persist_pending: bool },
2104}
2105
2106pub(crate) struct CompactionTransaction {
2107 pub(crate) phase: CompactionTransactionPhase,
2108 pub(crate) projections: Vec<crate::memory::CompactionProjectionId>,
2109}
2110
2111#[cfg(test)]
2112#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
2113mod tests {
2114 use super::{
2115 AgentToolDispatcher, CommsRuntime, DEFAULT_MAX_INLINE_PEER_NOTIFICATIONS,
2116 FilteredToolDispatcher, InlinePeerNotificationPolicy, ToolDispatchContext,
2117 };
2118 use crate::comms::{
2119 PeerAddress, PeerId, PeerName, PeerTransport, SendError, TrustedPeerDescriptor,
2120 };
2121 use crate::types::{ContentBlock, ContentInput, ToolCallView, ToolDef, ToolResult};
2122 use async_trait::async_trait;
2123 use serde_json::json;
2124 use std::sync::Arc;
2125 use tokio::sync::Notify;
2126
2127 struct NoopCommsRuntime {
2128 notify: Arc<Notify>,
2129 }
2130
2131 struct ContextAwareToolDispatcher;
2132
2133 struct ExactExecutionDispatcher {
2134 catalog: Arc<[crate::ToolCatalogEntry]>,
2135 }
2136
2137 struct HybridExecutionDispatcher {
2138 catalog: Arc<[crate::ToolCatalogEntry]>,
2139 }
2140
2141 struct StreamingExecutionDispatcher {
2142 catalog: Arc<[crate::ToolCatalogEntry]>,
2143 saw_streaming_context: Arc<std::sync::atomic::AtomicBool>,
2144 }
2145
2146 struct IdenticalMutationDispatcher {
2147 tool: ToolDef,
2148 epoch: std::sync::atomic::AtomicU64,
2149 mutate_on_resolve: bool,
2150 }
2151
2152 #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
2153 #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
2154 impl AgentToolDispatcher for ContextAwareToolDispatcher {
2155 fn tools(&self) -> Arc<[Arc<ToolDef>]> {
2156 Arc::from([Arc::new(ToolDef {
2157 name: "inspect_context".into(),
2158 description: "inspect context".to_string(),
2159 input_schema: json!({"type": "object"}),
2160 provenance: None,
2161 })])
2162 }
2163
2164 async fn dispatch(
2165 &self,
2166 call: ToolCallView<'_>,
2167 ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
2168 Ok(ToolResult::new(
2169 call.id.to_string(),
2170 json!({"saw_context_image": false}).to_string(),
2171 false,
2172 )
2173 .into())
2174 }
2175
2176 async fn dispatch_with_context(
2177 &self,
2178 call: ToolCallView<'_>,
2179 context: &ToolDispatchContext,
2180 ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
2181 let saw_context_image = context
2182 .current_turn()
2183 .and_then(|turn| turn.image_ref(0))
2184 .and_then(|image_ref| context.current_turn_image(image_ref))
2185 .is_some();
2186 Ok(ToolResult::new(
2187 call.id.to_string(),
2188 json!({"saw_context_image": saw_context_image}).to_string(),
2189 false,
2190 )
2191 .into())
2192 }
2193 }
2194
2195 #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
2196 #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
2197 impl AgentToolDispatcher for ExactExecutionDispatcher {
2198 fn tools(&self) -> Arc<[Arc<ToolDef>]> {
2199 self.catalog
2200 .iter()
2201 .filter(|entry| entry.currently_callable())
2202 .map(|entry| Arc::clone(&entry.tool))
2203 .collect::<Vec<_>>()
2204 .into()
2205 }
2206
2207 fn tool_catalog_capabilities(&self) -> crate::ToolCatalogCapabilities {
2208 crate::ToolCatalogCapabilities {
2209 exact_catalog: true,
2210 may_require_catalog_control_plane: false,
2211 }
2212 }
2213
2214 fn tool_catalog(&self) -> Arc<[crate::ToolCatalogEntry]> {
2215 Arc::clone(&self.catalog)
2216 }
2217
2218 async fn dispatch(
2219 &self,
2220 call: ToolCallView<'_>,
2221 ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
2222 Ok(ToolResult::new(call.id.to_string(), "ok".to_string(), false).into())
2223 }
2224 }
2225
2226 #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
2227 #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
2228 impl AgentToolDispatcher for HybridExecutionDispatcher {
2229 fn tools(&self) -> Arc<[Arc<ToolDef>]> {
2230 self.catalog
2231 .iter()
2232 .filter(|entry| entry.currently_callable())
2233 .map(|entry| Arc::clone(&entry.tool))
2234 .collect::<Vec<_>>()
2235 .into()
2236 }
2237
2238 fn tool_catalog_capabilities(&self) -> crate::ToolCatalogCapabilities {
2239 crate::ToolCatalogCapabilities {
2240 exact_catalog: true,
2241 may_require_catalog_control_plane: false,
2242 }
2243 }
2244
2245 fn tool_catalog(&self) -> Arc<[crate::ToolCatalogEntry]> {
2246 Arc::clone(&self.catalog)
2247 }
2248
2249 fn resolve_execution_plan(
2250 &self,
2251 call: ToolCallView<'_>,
2252 _dispatch_context: &ToolDispatchContext,
2253 resolution_context: &crate::ToolExecutionResolutionContext,
2254 ) -> Result<crate::ResolvedToolExecutionPlan, crate::ToolExecutionResolutionError> {
2255 let entry = self
2256 .catalog
2257 .iter()
2258 .find(|entry| entry.tool.name == call.name)
2259 .ok_or_else(|| crate::ToolExecutionResolutionError::NotFound {
2260 tool_name: call.name.to_string(),
2261 })?;
2262 let arguments: serde_json::Value =
2263 serde_json::from_str(call.args.get()).map_err(|error| {
2264 crate::ToolExecutionResolutionError::InvalidArguments {
2265 tool_name: call.name.to_string(),
2266 reason: error.to_string(),
2267 }
2268 })?;
2269 let mode = if arguments["run_detached"] == true {
2270 crate::ToolExecutionMode::Detached
2271 } else {
2272 crate::ToolExecutionMode::Fast
2273 };
2274 entry
2275 .execution
2276 .resolve(mode, resolution_context.deadlines().clone())
2277 .map_err(crate::ToolExecutionResolutionError::from)
2278 }
2279
2280 async fn dispatch(
2281 &self,
2282 call: ToolCallView<'_>,
2283 ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
2284 Ok(ToolResult::new(
2285 call.id.to_string(),
2286 json!({"owner": "filtered-hybrid-owner"}).to_string(),
2287 false,
2288 )
2289 .into())
2290 }
2291
2292 async fn dispatch_resolved_with_context(
2293 &self,
2294 call: ToolCallView<'_>,
2295 _context: &ToolDispatchContext,
2296 plan: &crate::ResolvedToolExecutionPlan,
2297 ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
2298 if plan.mode() != crate::ToolExecutionMode::Detached {
2299 return Err(crate::ToolError::execution_failed(
2300 "test detached owner received the wrong plan",
2301 ));
2302 }
2303 self.dispatch(call).await
2304 }
2305 }
2306
2307 #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
2308 #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
2309 impl AgentToolDispatcher for StreamingExecutionDispatcher {
2310 fn tools(&self) -> Arc<[Arc<ToolDef>]> {
2311 self.catalog
2312 .iter()
2313 .map(|entry| Arc::clone(&entry.tool))
2314 .collect::<Vec<_>>()
2315 .into()
2316 }
2317
2318 fn tool_catalog_capabilities(&self) -> crate::ToolCatalogCapabilities {
2319 crate::ToolCatalogCapabilities {
2320 exact_catalog: true,
2321 may_require_catalog_control_plane: false,
2322 }
2323 }
2324
2325 fn tool_catalog(&self) -> Arc<[crate::ToolCatalogEntry]> {
2326 Arc::clone(&self.catalog)
2327 }
2328
2329 async fn dispatch(
2330 &self,
2331 call: ToolCallView<'_>,
2332 ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
2333 Err(crate::ToolError::unavailable(
2334 call.name,
2335 crate::ToolUnavailableReason::ExecutionModeOwnerUnavailable,
2336 ))
2337 }
2338
2339 async fn dispatch_resolved_with_context(
2340 &self,
2341 call: ToolCallView<'_>,
2342 context: &ToolDispatchContext,
2343 plan: &crate::ResolvedToolExecutionPlan,
2344 ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
2345 if plan.mode() != crate::ToolExecutionMode::Streaming {
2346 return Err(crate::ToolError::execution_failed(
2347 "streaming owner received a non-streaming plan",
2348 ));
2349 }
2350 let streaming = context.streaming().ok_or_else(|| {
2351 crate::ToolError::unavailable(
2352 call.name,
2353 crate::ToolUnavailableReason::ExecutionModeOwnerUnavailable,
2354 )
2355 })?;
2356 streaming
2357 .progress()
2358 .try_report(
2359 crate::ToolProgressFrame::message("accepted through wrapper")
2360 .map_err(|error| crate::ToolError::other(error.to_string()))?,
2361 )
2362 .map_err(|error| crate::ToolError::other(error.to_string()))?;
2363 self.saw_streaming_context
2364 .store(true, std::sync::atomic::Ordering::SeqCst);
2365 Ok(ToolResult::new(call.id.to_string(), "stream complete".to_string(), false).into())
2366 }
2367 }
2368
2369 #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
2370 #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
2371 impl AgentToolDispatcher for IdenticalMutationDispatcher {
2372 fn tools(&self) -> Arc<[Arc<ToolDef>]> {
2373 Arc::from([Arc::new(self.tool.clone())])
2374 }
2375
2376 fn tool_catalog_capabilities(&self) -> crate::ToolCatalogCapabilities {
2377 crate::ToolCatalogCapabilities {
2378 exact_catalog: true,
2379 may_require_catalog_control_plane: false,
2380 }
2381 }
2382
2383 fn tool_catalog(&self) -> Arc<[crate::ToolCatalogEntry]> {
2384 Arc::from([crate::ToolCatalogEntry::session_inline(
2385 Arc::new(self.tool.clone()),
2386 true,
2387 )])
2388 }
2389
2390 fn execution_binding_epoch(&self, _tool_name: &str) -> u64 {
2391 self.epoch.load(std::sync::atomic::Ordering::SeqCst)
2392 }
2393
2394 fn resolve_execution_plan(
2395 &self,
2396 _call: ToolCallView<'_>,
2397 _dispatch_context: &ToolDispatchContext,
2398 resolution_context: &crate::ToolExecutionResolutionContext,
2399 ) -> Result<crate::ResolvedToolExecutionPlan, crate::ToolExecutionResolutionError> {
2400 let plan = crate::ToolExecutionContract::default()
2401 .resolve_default(resolution_context.deadlines().clone())
2402 .map_err(crate::ToolExecutionResolutionError::from)?;
2403 if self.mutate_on_resolve {
2404 self.epoch.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2405 }
2406 Ok(plan)
2407 }
2408
2409 async fn dispatch(
2410 &self,
2411 call: ToolCallView<'_>,
2412 ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
2413 Ok(ToolResult::new(call.id.to_string(), "ok".to_string(), false).into())
2414 }
2415 }
2416
2417 #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
2418 #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
2419 impl CommsRuntime for NoopCommsRuntime {
2420 async fn drain_messages(&self) -> Vec<String> {
2421 Vec::new()
2422 }
2423
2424 fn inbox_notify(&self) -> std::sync::Arc<Notify> {
2425 self.notify.clone()
2426 }
2427 }
2428
2429 #[tokio::test]
2430 async fn test_comms_runtime_trait_defaults_hide_unimplemented_features() {
2431 let runtime = NoopCommsRuntime {
2432 notify: Arc::new(Notify::new()),
2433 };
2434 assert!(<NoopCommsRuntime as CommsRuntime>::public_key(&runtime).is_none());
2435 let peer = TrustedPeerDescriptor {
2438 peer_id: PeerId::new(),
2439 name: PeerName::new("peer-a").expect("valid peer name"),
2440 address: PeerAddress::new(PeerTransport::Inproc, "peer-a"),
2441 pubkey: [0u8; 32],
2442 };
2443 let result =
2444 <NoopCommsRuntime as CommsRuntime>::add_private_trusted_peer(&runtime, peer).await;
2445 assert!(matches!(result, Err(SendError::Unsupported(_))));
2446 }
2447
2448 #[tokio::test]
2454 async fn test_comms_runtime_bridge_reply_defaults() {
2455 let runtime = NoopCommsRuntime {
2456 notify: Arc::new(Notify::new()),
2457 };
2458 let interaction_id = crate::interaction::InteractionId(uuid::Uuid::new_v4());
2459 assert!(
2460 <NoopCommsRuntime as CommsRuntime>::take_bridge_reply_waiter(&runtime, &interaction_id)
2461 .is_none()
2462 );
2463 assert!(
2464 !<NoopCommsRuntime as CommsRuntime>::has_bridge_reply_waiter(&runtime, &interaction_id)
2465 );
2466 let staged = <NoopCommsRuntime as CommsRuntime>::stage_declared_reply_endpoint(
2467 &runtime,
2468 PeerId::new(),
2469 [0x11u8; 32],
2470 "tcp://127.0.0.1:1".to_string(),
2471 )
2472 .await;
2473 assert!(matches!(staged, Err(SendError::Unsupported(_))));
2474 }
2475
2476 #[tokio::test]
2477 async fn filtered_tool_dispatcher_preserves_dispatch_context() {
2478 let dispatcher =
2479 FilteredToolDispatcher::new(Arc::new(ContextAwareToolDispatcher), ["inspect_context"]);
2480 let args = serde_json::value::RawValue::from_string("{}".to_string())
2481 .expect("empty object should be valid JSON");
2482 let call = ToolCallView {
2483 id: "ctx-1",
2484 name: "inspect_context",
2485 args: &args,
2486 };
2487 let context = ToolDispatchContext::from_current_turn_input(&ContentInput::Blocks(vec![
2488 ContentBlock::Image {
2489 media_type: "image/png".to_string(),
2490 data: "abc".into(),
2491 },
2492 ]));
2493
2494 let outcome = dispatcher
2495 .dispatch_with_context(call, &context)
2496 .await
2497 .expect("filtered wrapper should dispatch");
2498 let payload: serde_json::Value =
2499 serde_json::from_str(&outcome.result.text_content()).expect("tool result JSON");
2500 assert_eq!(payload["saw_context_image"], true);
2501 }
2502
2503 #[test]
2504 fn default_execution_plan_resolver_uses_exact_catalog_contract() {
2505 use crate::{
2506 DetachedToolExecutionPolicy, IdempotencyScope, RestartClass, RunnerIdentity,
2507 ToolDeadlineChain, ToolDeadlineContributor, ToolDeadlineOwner, ToolExecutionContract,
2508 ToolExecutionMode, ToolExecutionResolutionContext,
2509 };
2510 use std::collections::BTreeSet;
2511 use std::time::Duration;
2512
2513 let detached = DetachedToolExecutionPolicy::new(
2514 RunnerIdentity::new("homecore.security_scan", "v1").unwrap(),
2515 RestartClass::NonResumable,
2516 IdempotencyScope::InteractionAndArguments,
2517 Duration::from_secs(10),
2518 )
2519 .unwrap();
2520 let contract = ToolExecutionContract::new(
2521 BTreeSet::from([ToolExecutionMode::Detached]),
2522 ToolExecutionMode::Detached,
2523 None,
2524 Some(detached),
2525 )
2526 .unwrap();
2527 let dispatcher = ExactExecutionDispatcher {
2528 catalog: Arc::from([crate::ToolCatalogEntry::session_inline(
2529 Arc::new(ToolDef::new(
2530 "security_scan",
2531 "scan",
2532 json!({"type": "object"}),
2533 )),
2534 true,
2535 )
2536 .with_execution_contract(contract)]),
2537 };
2538 let args = serde_json::value::RawValue::from_string("{}".to_string()).unwrap();
2539 let call = ToolCallView {
2540 id: "call-1",
2541 name: "security_scan",
2542 args: &args,
2543 };
2544 let resolution = ToolExecutionResolutionContext::new(
2545 ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
2546 ToolDeadlineOwner::CoreToolDispatch,
2547 Duration::from_secs(600),
2548 )])
2549 .unwrap(),
2550 );
2551
2552 let plan = dispatcher
2553 .resolve_execution_plan(call, &ToolDispatchContext::default(), &resolution)
2554 .expect("declared plan resolves");
2555
2556 assert_eq!(plan.mode(), ToolExecutionMode::Detached);
2557 assert_eq!(
2558 plan.deadlines().effective_timeout(),
2559 Some(Duration::from_secs(10))
2560 );
2561 assert_eq!(
2562 plan.deadlines().winner().map(|winner| winner.owner()),
2563 Some(ToolDeadlineOwner::DetachedSubmission)
2564 );
2565 }
2566
2567 #[tokio::test]
2568 async fn default_resolved_dispatch_refuses_detached_plan_before_ordinary_dispatch() {
2569 use crate::{
2570 DetachedToolExecutionPolicy, IdempotencyScope, RestartClass, RunnerIdentity,
2571 ToolDeadlineChain, ToolDeadlineContributor, ToolDeadlineOwner, ToolExecutionContract,
2572 ToolExecutionMode, ToolExecutionResolutionContext,
2573 };
2574 use std::collections::BTreeSet;
2575 use std::time::Duration;
2576
2577 let detached = DetachedToolExecutionPolicy::new(
2578 RunnerIdentity::new("detached.owner", "v1").unwrap(),
2579 RestartClass::NonResumable,
2580 IdempotencyScope::ToolCall,
2581 Duration::from_secs(10),
2582 )
2583 .unwrap();
2584 let contract = ToolExecutionContract::new(
2585 BTreeSet::from([ToolExecutionMode::Detached]),
2586 ToolExecutionMode::Detached,
2587 None,
2588 Some(detached),
2589 )
2590 .unwrap();
2591 let dispatcher = ExactExecutionDispatcher {
2592 catalog: Arc::from([crate::ToolCatalogEntry::session_inline(
2593 Arc::new(ToolDef::new(
2594 "security_scan",
2595 "scan",
2596 json!({"type": "object"}),
2597 )),
2598 true,
2599 )
2600 .with_execution_contract(contract)]),
2601 };
2602 let args = serde_json::value::RawValue::from_string("{}".to_string()).unwrap();
2603 let call = ToolCallView {
2604 id: "detached-call",
2605 name: "security_scan",
2606 args: &args,
2607 };
2608 let context = ToolDispatchContext::default();
2609 let resolution = ToolExecutionResolutionContext::new(
2610 ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
2611 ToolDeadlineOwner::CoreToolDispatch,
2612 Duration::from_secs(600),
2613 )])
2614 .unwrap(),
2615 );
2616 let plan = dispatcher
2617 .resolve_execution_plan(call, &context, &resolution)
2618 .expect("detached plan resolves");
2619
2620 let error = dispatcher
2621 .dispatch_resolved_with_context(call, &context, &plan)
2622 .await
2623 .expect_err("the default dispatcher must not lower detached work to dispatch()");
2624
2625 assert!(matches!(
2626 error,
2627 crate::ToolError::Unavailable {
2628 reason: crate::ToolUnavailableReason::ExecutionModeOwnerUnavailable,
2629 ..
2630 }
2631 ));
2632 }
2633
2634 #[tokio::test]
2635 async fn fenced_streaming_dispatch_mints_context_and_filtered_wrapper_preserves_it() {
2636 use crate::{
2637 StreamingToolExecutionPolicy, ToolDeadlineChain, ToolDeadlineContributor,
2638 ToolDeadlineOwner, ToolExecutionContract, ToolExecutionMode,
2639 ToolExecutionResolutionContext,
2640 };
2641 use std::collections::BTreeSet;
2642 use std::time::Duration;
2643
2644 let contract = ToolExecutionContract::new(
2645 BTreeSet::from([ToolExecutionMode::Streaming]),
2646 ToolExecutionMode::Streaming,
2647 Some(
2648 StreamingToolExecutionPolicy::new(Duration::from_secs(5), Duration::from_secs(30))
2649 .unwrap(),
2650 ),
2651 None,
2652 )
2653 .unwrap();
2654 let saw_streaming_context = Arc::new(std::sync::atomic::AtomicBool::new(false));
2655 let owner = StreamingExecutionDispatcher {
2656 catalog: Arc::from([crate::ToolCatalogEntry::session_inline(
2657 Arc::new(ToolDef::new(
2658 "stream_scan",
2659 "stream scan",
2660 json!({"type": "object"}),
2661 )),
2662 true,
2663 )
2664 .with_execution_contract(contract)]),
2665 saw_streaming_context: Arc::clone(&saw_streaming_context),
2666 };
2667 let dispatcher = Arc::new(FilteredToolDispatcher::new(
2668 Arc::new(owner),
2669 ["stream_scan"],
2670 ));
2671 let filtered_catalog = dispatcher.tool_catalog();
2672 assert_eq!(
2673 filtered_catalog[0].execution.default_mode(),
2674 ToolExecutionMode::Streaming
2675 );
2676 let filtered_policy = filtered_catalog[0]
2677 .execution
2678 .streaming_policy()
2679 .expect("wrapper preserves the streaming registration");
2680 assert_eq!(filtered_policy.inactivity_timeout(), Duration::from_secs(5));
2681 assert_eq!(filtered_policy.absolute_timeout(), Duration::from_secs(30));
2682 let args = serde_json::value::RawValue::from_string("{}".to_string()).unwrap();
2683 let call = ToolCallView {
2684 id: "stream-call",
2685 name: "stream_scan",
2686 args: &args,
2687 };
2688 let context = ToolDispatchContext::default();
2689 assert!(
2690 context.streaming().is_none(),
2691 "callers cannot pre-mint the supervised streaming context"
2692 );
2693 let resolution = ToolExecutionResolutionContext::new(
2694 ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
2695 ToolDeadlineOwner::CoreToolDispatch,
2696 Duration::from_secs(60),
2697 )])
2698 .unwrap(),
2699 );
2700 let plan =
2701 crate::resolve_tool_execution_plan_fenced(&dispatcher, call, &context, &resolution)
2702 .expect("streaming plan resolves through wrapper");
2703
2704 let outcome =
2705 crate::dispatch_tool_execution_plan_fenced(&dispatcher, call, &context, &plan)
2706 .await
2707 .expect("streaming dispatch completes");
2708
2709 assert_eq!(outcome.result.text_content(), "stream complete");
2710 assert!(
2711 saw_streaming_context.load(std::sync::atomic::Ordering::SeqCst),
2712 "the wrapper must preserve the exact supervised context"
2713 );
2714 }
2715
2716 #[tokio::test]
2717 async fn declared_streaming_without_a_mode_owner_fails_closed_before_plain_dispatch() {
2718 use crate::{
2719 StreamingToolExecutionPolicy, ToolDeadlineChain, ToolDeadlineContributor,
2720 ToolDeadlineOwner, ToolExecutionContract, ToolExecutionMode,
2721 ToolExecutionResolutionContext,
2722 };
2723 use std::collections::BTreeSet;
2724 use std::time::Duration;
2725
2726 let contract = ToolExecutionContract::new(
2727 BTreeSet::from([ToolExecutionMode::Streaming]),
2728 ToolExecutionMode::Streaming,
2729 Some(
2730 StreamingToolExecutionPolicy::new(Duration::from_secs(5), Duration::from_secs(30))
2731 .unwrap(),
2732 ),
2733 None,
2734 )
2735 .unwrap();
2736 let dispatcher = Arc::new(ExactExecutionDispatcher {
2737 catalog: Arc::from([crate::ToolCatalogEntry::session_inline(
2738 Arc::new(ToolDef::new(
2739 "ownerless_stream",
2740 "ownerless",
2741 json!({"type": "object"}),
2742 )),
2743 true,
2744 )
2745 .with_execution_contract(contract)]),
2746 });
2747 let args = serde_json::value::RawValue::from_string("{}".to_string()).unwrap();
2748 let call = ToolCallView {
2749 id: "ownerless-call",
2750 name: "ownerless_stream",
2751 args: &args,
2752 };
2753 let context = ToolDispatchContext::default();
2754 let resolution = ToolExecutionResolutionContext::new(
2755 ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
2756 ToolDeadlineOwner::CoreToolDispatch,
2757 Duration::from_secs(60),
2758 )])
2759 .unwrap(),
2760 );
2761 let plan =
2762 crate::resolve_tool_execution_plan_fenced(&dispatcher, call, &context, &resolution)
2763 .expect("declaration resolves");
2764
2765 let error = crate::dispatch_tool_execution_plan_fenced(&dispatcher, call, &context, &plan)
2766 .await
2767 .expect_err("missing streaming owner must fail closed");
2768 assert!(matches!(
2769 error,
2770 crate::ToolError::Unavailable {
2771 reason: crate::ToolUnavailableReason::ExecutionModeOwnerUnavailable,
2772 ..
2773 }
2774 ));
2775 }
2776
2777 #[test]
2778 fn filtered_execution_plan_resolver_rejects_policy_denied_tool() {
2779 use crate::{
2780 ToolDeadlineChain, ToolDeadlineContributor, ToolDeadlineOwner,
2781 ToolExecutionResolutionContext, ToolExecutionResolutionError,
2782 };
2783 use std::time::Duration;
2784
2785 let dispatcher =
2786 FilteredToolDispatcher::new(Arc::new(ContextAwareToolDispatcher), Vec::<String>::new());
2787 let args = serde_json::value::RawValue::from_string("{}".to_string()).unwrap();
2788 let call = ToolCallView {
2789 id: "call-hidden",
2790 name: "inspect_context",
2791 args: &args,
2792 };
2793 let resolution = ToolExecutionResolutionContext::new(
2794 ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
2795 ToolDeadlineOwner::CoreToolDispatch,
2796 Duration::from_secs(600),
2797 )])
2798 .unwrap(),
2799 );
2800
2801 let error = dispatcher
2802 .resolve_execution_plan(call, &ToolDispatchContext::default(), &resolution)
2803 .expect_err("hidden tools must not resolve");
2804
2805 assert_eq!(
2806 error,
2807 ToolExecutionResolutionError::AccessDenied {
2808 tool_name: "inspect_context".to_string(),
2809 }
2810 );
2811 }
2812
2813 #[tokio::test]
2814 async fn filtered_execution_plan_forwards_hybrid_resolution_to_visible_owner() {
2815 use crate::{
2816 DetachedToolExecutionPolicy, IdempotencyScope, ResolvedExecutionKind, RestartClass,
2817 RunnerIdentity, ToolDeadlineChain, ToolDeadlineContributor, ToolDeadlineOwner,
2818 ToolExecutionContract, ToolExecutionMode, ToolExecutionResolutionContext,
2819 };
2820 use std::collections::BTreeSet;
2821 use std::time::Duration;
2822
2823 let detached = DetachedToolExecutionPolicy::new(
2824 RunnerIdentity::new("filtered-hybrid-owner", "v1").unwrap(),
2825 RestartClass::NonResumable,
2826 IdempotencyScope::InteractionAndArguments,
2827 Duration::from_secs(10),
2828 )
2829 .unwrap();
2830 let contract = ToolExecutionContract::new(
2831 BTreeSet::from([ToolExecutionMode::Fast, ToolExecutionMode::Detached]),
2832 ToolExecutionMode::Fast,
2833 None,
2834 Some(detached),
2835 )
2836 .unwrap();
2837 let dispatcher = FilteredToolDispatcher::new(
2838 Arc::new(HybridExecutionDispatcher {
2839 catalog: Arc::from([crate::ToolCatalogEntry::session_inline(
2840 Arc::new(ToolDef::new(
2841 "hybrid_scan",
2842 "filtered-hybrid-owner catalog",
2843 json!({"type": "object"}),
2844 )),
2845 true,
2846 )
2847 .with_execution_contract(contract)]),
2848 }),
2849 ["hybrid_scan"],
2850 );
2851 let args = serde_json::value::RawValue::from_string(r#"{"run_detached":true}"#.to_string())
2852 .unwrap();
2853 let call = ToolCallView {
2854 id: "call-hybrid",
2855 name: "hybrid_scan",
2856 args: &args,
2857 };
2858 let resolution = ToolExecutionResolutionContext::new(
2859 ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
2860 ToolDeadlineOwner::CoreToolDispatch,
2861 Duration::from_secs(600),
2862 )])
2863 .unwrap(),
2864 );
2865
2866 let catalog = dispatcher.tool_catalog();
2867 assert_eq!(catalog[0].execution.default_mode(), ToolExecutionMode::Fast);
2868 assert_eq!(catalog[0].tool.description, "filtered-hybrid-owner catalog");
2869
2870 let plan = dispatcher
2871 .resolve_execution_plan(call, &ToolDispatchContext::default(), &resolution)
2872 .expect("visible hybrid tool should delegate plan resolution");
2873 dispatcher
2874 .validate_resolved_execution_plan(call, &resolution, &plan)
2875 .expect("hybrid-selected advertised mode must validate");
2876 let ResolvedExecutionKind::Detached(policy) = plan.kind() else {
2877 panic!("hybrid resolver should select its non-default detached mode");
2878 };
2879 assert_eq!(policy.runner().name(), "filtered-hybrid-owner");
2880
2881 let outcome = dispatcher
2882 .dispatch_resolved_with_context(call, &ToolDispatchContext::default(), &plan)
2883 .await
2884 .expect("visible hybrid tool should preserve resolved dispatch");
2885 let payload: serde_json::Value =
2886 serde_json::from_str(&outcome.result.text_content()).unwrap();
2887 assert_eq!(payload["owner"], "filtered-hybrid-owner");
2888 }
2889
2890 #[test]
2891 fn root_validation_rejects_plan_outside_live_advertised_contract() {
2892 use crate::{
2893 DetachedToolExecutionPolicy, IdempotencyScope, RestartClass, RunnerIdentity,
2894 ToolDeadlineChain, ToolDeadlineContributor, ToolDeadlineOwner, ToolExecutionContract,
2895 ToolExecutionContractError, ToolExecutionMode, ToolExecutionResolutionContext,
2896 ToolExecutionResolutionError,
2897 };
2898 use std::collections::BTreeSet;
2899 use std::time::Duration;
2900
2901 let dispatcher = ExactExecutionDispatcher {
2902 catalog: Arc::from([crate::ToolCatalogEntry::session_inline(
2903 Arc::new(ToolDef::new(
2904 "fast_only",
2905 "fast only",
2906 json!({"type": "object"}),
2907 )),
2908 true,
2909 )]),
2910 };
2911 let detached = DetachedToolExecutionPolicy::new(
2912 RunnerIdentity::new("dishonest.owner", "v1").unwrap(),
2913 RestartClass::NonResumable,
2914 IdempotencyScope::ToolCall,
2915 Duration::from_secs(10),
2916 )
2917 .unwrap();
2918 let dishonest_contract = ToolExecutionContract::new(
2919 BTreeSet::from([ToolExecutionMode::Detached]),
2920 ToolExecutionMode::Detached,
2921 None,
2922 Some(detached),
2923 )
2924 .unwrap();
2925 let resolution = ToolExecutionResolutionContext::new(
2926 ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
2927 ToolDeadlineOwner::CoreToolDispatch,
2928 Duration::from_secs(600),
2929 )])
2930 .unwrap(),
2931 );
2932 let plan = dishonest_contract
2933 .resolve_default(resolution.deadlines().clone())
2934 .unwrap();
2935 let args = serde_json::value::RawValue::from_string("{}".to_string()).unwrap();
2936 let call = ToolCallView {
2937 id: "dishonest-plan",
2938 name: "fast_only",
2939 args: &args,
2940 };
2941
2942 assert_eq!(
2943 dispatcher.validate_resolved_execution_plan(call, &resolution, &plan),
2944 Err(ToolExecutionResolutionError::Contract(
2945 ToolExecutionContractError::RequestedModeUnsupported {
2946 requested_mode: ToolExecutionMode::Detached,
2947 }
2948 ))
2949 );
2950 }
2951
2952 #[tokio::test]
2953 async fn universal_root_fence_accepts_rebuilt_equivalent_catalog_arcs() {
2954 use crate::{
2955 ToolDeadlineChain, ToolDeadlineContributor, ToolDeadlineOwner,
2956 ToolExecutionResolutionContext,
2957 };
2958 use std::time::Duration;
2959
2960 let dispatcher: Arc<dyn AgentToolDispatcher> = Arc::new(IdenticalMutationDispatcher {
2961 tool: ToolDef::new("rebuilt", "rebuilt", json!({"type": "object"})),
2962 epoch: std::sync::atomic::AtomicU64::new(0),
2963 mutate_on_resolve: false,
2964 });
2965 let args = serde_json::value::RawValue::from_string("{}".to_string()).unwrap();
2966 let call = ToolCallView {
2967 id: "rebuilt-arcs",
2968 name: "rebuilt",
2969 args: &args,
2970 };
2971 let resolution = ToolExecutionResolutionContext::new(
2972 ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
2973 ToolDeadlineOwner::CoreToolDispatch,
2974 Duration::from_secs(600),
2975 )])
2976 .unwrap(),
2977 );
2978
2979 let plan = crate::resolve_tool_execution_plan_fenced(
2980 &dispatcher,
2981 call,
2982 &ToolDispatchContext::default(),
2983 &resolution,
2984 )
2985 .expect("equivalent rebuilt catalog projections resolve");
2986 crate::dispatch_tool_execution_plan_fenced(
2987 &dispatcher,
2988 call,
2989 &ToolDispatchContext::default(),
2990 &plan,
2991 )
2992 .await
2993 .expect("equivalent rebuilt catalog projections dispatch");
2994 }
2995
2996 #[tokio::test]
2997 async fn universal_root_fence_binds_canonical_call_identity() {
2998 use crate::{
2999 ToolDeadlineChain, ToolDeadlineContributor, ToolDeadlineOwner,
3000 ToolExecutionResolutionContext, ToolUnavailableReason,
3001 };
3002 use std::time::Duration;
3003
3004 let dispatcher: Arc<dyn AgentToolDispatcher> = Arc::new(IdenticalMutationDispatcher {
3005 tool: ToolDef::new("bound", "bound", json!({"type": "object"})),
3006 epoch: std::sync::atomic::AtomicU64::new(0),
3007 mutate_on_resolve: false,
3008 });
3009 let resolved_args =
3010 serde_json::value::RawValue::from_string(r#"{"a":1,"b":2}"#.to_string()).unwrap();
3011 let equivalent_args =
3012 serde_json::value::RawValue::from_string(r#"{ "b": 2, "a": 1 }"#.to_string()).unwrap();
3013 let changed_args =
3014 serde_json::value::RawValue::from_string(r#"{"a":1,"b":3}"#.to_string()).unwrap();
3015 let resolved_call = ToolCallView {
3016 id: "bound-call",
3017 name: "bound",
3018 args: &resolved_args,
3019 };
3020 let resolution = ToolExecutionResolutionContext::new(
3021 ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
3022 ToolDeadlineOwner::CoreToolDispatch,
3023 Duration::from_secs(600),
3024 )])
3025 .unwrap(),
3026 );
3027 let plan = crate::resolve_tool_execution_plan_fenced(
3028 &dispatcher,
3029 resolved_call,
3030 &ToolDispatchContext::default(),
3031 &resolution,
3032 )
3033 .unwrap();
3034
3035 crate::dispatch_tool_execution_plan_fenced(
3036 &dispatcher,
3037 ToolCallView {
3038 args: &equivalent_args,
3039 ..resolved_call
3040 },
3041 &ToolDispatchContext::default(),
3042 &plan,
3043 )
3044 .await
3045 .expect("canonical JSON-equivalent arguments preserve call identity");
3046
3047 let error = crate::dispatch_tool_execution_plan_fenced(
3048 &dispatcher,
3049 ToolCallView {
3050 args: &changed_args,
3051 ..resolved_call
3052 },
3053 &ToolDispatchContext::default(),
3054 &plan,
3055 )
3056 .await
3057 .expect_err("different arguments must not dispatch under the old plan");
3058 assert!(matches!(
3059 error,
3060 crate::ToolError::Unavailable {
3061 reason: ToolUnavailableReason::ExecutionOwnerChanged,
3062 ..
3063 }
3064 ));
3065 }
3066
3067 #[tokio::test]
3068 async fn universal_root_fence_rejects_fresh_dispatcher_reconstruction() {
3069 use crate::{
3070 ToolDeadlineChain, ToolDeadlineContributor, ToolDeadlineOwner,
3071 ToolExecutionResolutionContext, ToolUnavailableReason,
3072 };
3073 use std::time::Duration;
3074
3075 let make_dispatcher = || -> Arc<dyn AgentToolDispatcher> {
3076 Arc::new(IdenticalMutationDispatcher {
3077 tool: ToolDef::new("bound", "bound", json!({"type": "object"})),
3078 epoch: std::sync::atomic::AtomicU64::new(0),
3079 mutate_on_resolve: false,
3080 })
3081 };
3082 let original = make_dispatcher();
3083 let args = serde_json::value::RawValue::from_string("{}".to_string()).unwrap();
3084 let call = ToolCallView {
3085 id: "reconstructed",
3086 name: "bound",
3087 args: &args,
3088 };
3089 let resolution = ToolExecutionResolutionContext::new(
3090 ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
3091 ToolDeadlineOwner::CoreToolDispatch,
3092 Duration::from_secs(600),
3093 )])
3094 .unwrap(),
3095 );
3096 let plan = crate::resolve_tool_execution_plan_fenced(
3097 &original,
3098 call,
3099 &ToolDispatchContext::default(),
3100 &resolution,
3101 )
3102 .unwrap();
3103 let reconstructed = make_dispatcher();
3104
3105 let error = crate::dispatch_tool_execution_plan_fenced(
3106 &reconstructed,
3107 call,
3108 &ToolDispatchContext::default(),
3109 &plan,
3110 )
3111 .await
3112 .expect_err("fresh reconstruction must never reproduce ephemeral root authority");
3113 assert!(matches!(
3114 error,
3115 crate::ToolError::Unavailable {
3116 reason: ToolUnavailableReason::ExecutionOwnerChanged,
3117 ..
3118 }
3119 ));
3120 }
3121
3122 #[test]
3123 fn universal_root_fence_rejects_direct_identical_metadata_replacement() {
3124 use crate::{
3125 ToolDeadlineChain, ToolDeadlineContributor, ToolDeadlineOwner,
3126 ToolExecutionResolutionContext, ToolExecutionResolutionError, ToolUnavailableReason,
3127 };
3128 use std::time::Duration;
3129
3130 let dispatcher: Arc<dyn AgentToolDispatcher> = Arc::new(IdenticalMutationDispatcher {
3131 tool: ToolDef::new("moving", "identical metadata", json!({"type": "object"})),
3132 epoch: std::sync::atomic::AtomicU64::new(0),
3133 mutate_on_resolve: true,
3134 });
3135 let args = serde_json::value::RawValue::from_string("{}".to_string()).unwrap();
3136 let call = ToolCallView {
3137 id: "direct-identical-replacement",
3138 name: "moving",
3139 args: &args,
3140 };
3141 let resolution = ToolExecutionResolutionContext::new(
3142 ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
3143 ToolDeadlineOwner::CoreToolDispatch,
3144 Duration::from_secs(600),
3145 )])
3146 .unwrap(),
3147 );
3148
3149 assert!(matches!(
3150 crate::resolve_tool_execution_plan_fenced(
3151 &dispatcher,
3152 call,
3153 &ToolDispatchContext::default(),
3154 &resolution,
3155 ),
3156 Err(ToolExecutionResolutionError::Unavailable {
3157 reason: ToolUnavailableReason::ExecutionOwnerChanged,
3158 ..
3159 })
3160 ));
3161 }
3162
3163 #[test]
3164 fn filtered_wrapper_composes_inner_live_binding_epoch() {
3165 use crate::{
3166 ToolDeadlineChain, ToolDeadlineContributor, ToolDeadlineOwner,
3167 ToolExecutionResolutionContext, ToolExecutionResolutionError, ToolUnavailableReason,
3168 };
3169 use std::time::Duration;
3170
3171 let dispatcher: Arc<dyn AgentToolDispatcher> = Arc::new(FilteredToolDispatcher::new(
3172 Arc::new(IdenticalMutationDispatcher {
3173 tool: ToolDef::new("moving", "identical metadata", json!({"type": "object"})),
3174 epoch: std::sync::atomic::AtomicU64::new(0),
3175 mutate_on_resolve: true,
3176 }),
3177 ["moving"],
3178 ));
3179 let args = serde_json::value::RawValue::from_string("{}".to_string()).unwrap();
3180 let call = ToolCallView {
3181 id: "filtered-identical-replacement",
3182 name: "moving",
3183 args: &args,
3184 };
3185 let resolution = ToolExecutionResolutionContext::new(
3186 ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
3187 ToolDeadlineOwner::CoreToolDispatch,
3188 Duration::from_secs(600),
3189 )])
3190 .unwrap(),
3191 );
3192
3193 assert!(matches!(
3194 crate::resolve_tool_execution_plan_fenced(
3195 &dispatcher,
3196 call,
3197 &ToolDispatchContext::default(),
3198 &resolution,
3199 ),
3200 Err(ToolExecutionResolutionError::Unavailable {
3201 reason: ToolUnavailableReason::ExecutionOwnerChanged,
3202 ..
3203 })
3204 ));
3205 }
3206
3207 #[test]
3208 fn test_inline_peer_notification_policy_from_raw() {
3209 assert_eq!(
3210 InlinePeerNotificationPolicy::try_from_raw(None),
3211 Ok(InlinePeerNotificationPolicy::AtMost(
3212 DEFAULT_MAX_INLINE_PEER_NOTIFICATIONS
3213 ))
3214 );
3215 assert_eq!(
3216 InlinePeerNotificationPolicy::try_from_raw(Some(-1)),
3217 Ok(InlinePeerNotificationPolicy::Always)
3218 );
3219 assert_eq!(
3220 InlinePeerNotificationPolicy::try_from_raw(Some(0)),
3221 Ok(InlinePeerNotificationPolicy::Never)
3222 );
3223 assert_eq!(
3224 InlinePeerNotificationPolicy::try_from_raw(Some(25)),
3225 Ok(InlinePeerNotificationPolicy::AtMost(25))
3226 );
3227 assert_eq!(
3228 InlinePeerNotificationPolicy::try_from_raw(Some(-42)),
3229 Err(-42)
3230 );
3231 }
3232
3233 #[test]
3236 fn unit_002_detached_op_completion_has_no_operation_id() {
3237 use crate::agent::DetachedOpCompletion;
3238 use crate::ops_lifecycle::{OperationKind, OperationStatus};
3239
3240 let completion = DetachedOpCompletion {
3241 job_id: "j_test".into(),
3242 kind: OperationKind::BackgroundToolOp,
3243 status: OperationStatus::Completed,
3244 terminal_outcome: None,
3245 display_name: "test cmd".into(),
3246 detail: "ok".into(),
3247 elapsed_ms: None,
3248 };
3249 #[allow(clippy::unwrap_used)]
3250 let json = serde_json::to_value(&completion).unwrap();
3251 assert!(
3252 json.get("operation_id").is_none(),
3253 "operation_id must not appear in serialized DetachedOpCompletion (CONTRACT-003)"
3254 );
3255 assert!(
3256 json.get("job_id").is_some(),
3257 "job_id must be the app-facing control noun"
3258 );
3259 }
3260}