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 drain_peer_input_candidates(&self) -> Vec<crate::interaction::PeerInputCandidate> {
1712 self.drain_classified_inbox_interactions()
1713 .await
1714 .unwrap_or_default()
1715 }
1716
1717 async fn peer_ingress_queue_snapshot(
1722 &self,
1723 ) -> Result<crate::interaction::PeerIngressQueueSnapshot, CommsCapabilityError> {
1724 Err(CommsCapabilityError::Unsupported(
1725 "peer_ingress_queue_snapshot".to_string(),
1726 ))
1727 }
1728
1729 async fn peer_ingress_runtime_snapshot(
1734 &self,
1735 ) -> Result<crate::interaction::PeerIngressRuntimeSnapshot, CommsCapabilityError> {
1736 Err(CommsCapabilityError::Unsupported(
1737 "peer_ingress_runtime_snapshot".to_string(),
1738 ))
1739 }
1740
1741 async fn public_trusted_peer_projection_snapshot(
1748 &self,
1749 ) -> Result<Vec<crate::comms::TrustedPeerDescriptor>, CommsCapabilityError> {
1750 Err(CommsCapabilityError::Unsupported(
1751 "public_trusted_peer_projection_snapshot".to_string(),
1752 ))
1753 }
1754
1755 async fn trusted_peer_projection_snapshot_for_source(
1762 &self,
1763 _source_kind: crate::comms::GeneratedCommsTrustAuthoritySourceKind,
1764 ) -> Result<Vec<crate::comms::TrustedPeerDescriptor>, CommsCapabilityError> {
1765 Err(CommsCapabilityError::Unsupported(
1766 "trusted_peer_projection_snapshot_for_source".to_string(),
1767 ))
1768 }
1769
1770 fn actionable_input_notify(&self) -> Result<Arc<tokio::sync::Notify>, CommsCapabilityError> {
1775 Err(CommsCapabilityError::Unsupported(
1776 "actionable_input_notify".to_string(),
1777 ))
1778 }
1779
1780 async fn stage_declared_reply_endpoint(
1797 &self,
1798 _dest: PeerId,
1799 _signer_pubkey: [u8; 32],
1800 _declared_address: String,
1801 ) -> Result<(), SendError> {
1802 Err(SendError::Unsupported(
1803 "declared reply endpoint staging not supported".to_string(),
1804 ))
1805 }
1806
1807 async fn stage_correlated_reply_endpoint(
1821 &self,
1822 _dest: PeerId,
1823 _in_reply_to: crate::interaction::InteractionId,
1824 _signer_pubkey: [u8; 32],
1825 _declared_endpoint: crate::comms::PeerAddress,
1826 ) -> Result<(), SendError> {
1827 Err(SendError::Unsupported(
1828 "correlated reply endpoint staging not supported".to_string(),
1829 ))
1830 }
1831
1832 async fn unstage_correlated_reply_endpoint(
1836 &self,
1837 _dest: PeerId,
1838 _in_reply_to: crate::interaction::InteractionId,
1839 ) -> Result<(), SendError> {
1840 Err(SendError::Unsupported(
1841 "correlated reply endpoint cleanup not supported".to_string(),
1842 ))
1843 }
1844
1845 fn take_bridge_reply_waiter(
1857 &self,
1858 _in_reply_to: &crate::interaction::InteractionId,
1859 ) -> Option<tokio::sync::oneshot::Sender<crate::interaction::PeerInputCandidate>> {
1860 None
1861 }
1862
1863 fn has_bridge_reply_waiter(&self, _in_reply_to: &crate::interaction::InteractionId) -> bool {
1866 false
1867 }
1868}
1869
1870pub struct Agent<C, T, S>
1872where
1873 C: AgentLlmClient + ?Sized,
1874 T: AgentToolDispatcher + ?Sized,
1875 S: AgentSessionStore + ?Sized,
1876{
1877 config: AgentConfig,
1878 client: Arc<C>,
1879 tools: Arc<T>,
1880 tool_scope: ToolScope,
1881 store: Arc<S>,
1882 session: Session,
1883 budget: Budget,
1884 retry_policy: RetryPolicy,
1885 depth: u32,
1886 pub(super) comms_runtime: Option<Arc<dyn CommsRuntime>>,
1887 pub(super) hook_engine: Option<Arc<dyn HookEngine>>,
1888 pub(super) hook_run_overrides: HookRunOverrides,
1889 pub(crate) compactor: Option<Arc<dyn crate::compact::Compactor>>,
1891 pub(crate) compaction_curator: Option<Arc<dyn crate::compact::CompactionCurator>>,
1894 pub(crate) last_input_tokens: u64,
1896 pub(crate) compaction_cadence: SessionCompactionCadence,
1898 pub(crate) pending_compaction_boundary_index: Option<u64>,
1901 pub(crate) pending_compaction_request_pressure: Option<crate::ProviderRequestPressure>,
1903 pub(crate) post_compaction_pressure_check: Option<crate::ProviderRequestPressure>,
1906 pub(crate) memory_store: Option<Arc<dyn crate::memory::MemoryStore>>,
1908 pub(crate) compaction_commit_coordinator:
1911 Option<Arc<dyn crate::memory::CompactionCommitCoordinator>>,
1912 pub(crate) compaction_transaction: Option<CompactionTransaction>,
1917 pub(crate) in_flight_compaction_stage: Option<crate::memory::CompactionProjectionId>,
1922 pub(crate) skill_engine: Option<Arc<crate::skills::SkillRuntime>>,
1924 pub pending_skill_references: Option<Vec<crate::skills::SkillKey>>,
1927 pub(crate) event_tap: crate::event_tap::EventTap,
1929 pub(crate) transient_turn_context_state: crate::session::TransientTurnContextStateHandle,
1931 pub(crate) default_event_tx: Option<tokio::sync::mpsc::Sender<crate::event::AgentEvent>>,
1934 pub(crate) checkpointer: Option<Arc<dyn crate::SessionCheckpointer>>,
1939 pub(crate) latest_run_checkpoint_receipt: Option<crate::RunCheckpointReceipt>,
1945 pub(crate) blob_store: Option<Arc<dyn crate::BlobStore>>,
1947 pub(crate) terminal_error_detail: Option<String>,
1951 pub(crate) terminal_error_metadata: Option<crate::TurnErrorMetadata>,
1954 pub(crate) run_completed_hooks_applied: bool,
1956 pub(crate) run_completed_event_emitted: bool,
1959 #[allow(dead_code)] pub(crate) silent_comms_intents: Vec<String>,
1963 pub(crate) ops_lifecycle: Option<Arc<dyn crate::ops_lifecycle::OpsLifecycleRegistry>>,
1965 pub(crate) completion_feed: Option<Arc<dyn crate::completion_feed::CompletionFeed>>,
1967 pub(crate) epoch_cursor_state: Option<Arc<crate::runtime_epoch::EpochCursorState>>,
1969 pub(crate) applied_cursor: crate::completion_feed::CompletionSeq,
1971 pub(crate) completion_enrichment:
1973 Option<Arc<dyn crate::completion_feed::CompletionEnrichmentProvider>>,
1974 pub(crate) mob_authority_handle:
1979 Option<Arc<std::sync::RwLock<crate::service::MobToolAuthorityContext>>>,
1980 pub(crate) turn_state_handle: Option<Arc<dyn crate::TurnStateHandle>>,
1982 pub(crate) model_routing_handle: Option<Arc<dyn crate::handles::ModelRoutingHandle>>,
1985 pub(crate) sticky_model_fallback_commit_coordinator:
1989 Option<Arc<dyn crate::handles::StickyModelFallbackCommitCoordinator>>,
1990 pub(crate) pending_sticky_model_fallback_activation:
1993 Option<state::PendingStickyModelFallbackActivation>,
1994 pub(crate) pending_callback_async_ops: Option<Vec<crate::ops::AsyncOpRef>>,
1998 pub(crate) effective_model_registry: Option<Arc<crate::ModelRegistry>>,
2002 pub(crate) active_model_profile: Option<crate::ModelProfileWitness>,
2005 pub(crate) runtime_execution_kind_required: bool,
2007 pub(crate) runtime_execution_kind: Option<crate::lifecycle::RuntimeExecutionKind>,
2010 pub(crate) runtime_started_run_id: Option<crate::lifecycle::RunId>,
2015 pub(crate) runtime_terminal_failure_witness:
2020 Option<Result<crate::TurnErrorMetadata, crate::error::AgentError>>,
2021 pub(crate) active_transcript_identity: Option<crate::types::TranscriptMessageIdentity>,
2023 pub(crate) active_turn_request_contexts:
2028 Vec<crate::lifecycle::run_primitive::TurnRequestContext>,
2029 pub(crate) external_tool_surface_handle: Option<Arc<dyn crate::ExternalToolSurfaceHandle>>,
2032 pub(crate) auth_lease_handle: Option<crate::handles::GeneratedAuthLeaseHandle>,
2034 pub(crate) mcp_server_lifecycle_handle:
2038 Option<Arc<dyn crate::handles::McpServerLifecycleHandle>>,
2039 pub(crate) cancel_after_boundary_tx: CancelAfterBoundarySender,
2046 pub(crate) cancel_after_boundary_rx:
2054 tokio::sync::mpsc::UnboundedReceiver<CancelAfterBoundaryCommand>,
2055 pub(crate) model_defaults_resolver:
2058 Option<Arc<dyn crate::model_defaults::ModelOperationalDefaultsResolver>>,
2059 pub(crate) call_timeout_override: crate::config::CallTimeoutOverride,
2062 pub(crate) extraction_state: extraction::ExtractionState,
2064 pub(crate) last_hidden_deferred_catalog_names: BTreeSet<crate::types::ToolName>,
2066 pub(crate) last_pending_catalog_sources: BTreeSet<String>,
2068 pub(crate) tool_dispatch_context: ToolDispatchContext,
2070 pub(crate) turn_tool_dispatch_metadata: BTreeMap<String, serde_json::Value>,
2072 pub(crate) tools_config: crate::config::ToolsConfig,
2077}
2078
2079#[derive(Clone)]
2080pub(crate) struct CompactionRollbackState {
2081 pub(crate) rollback_session: Session,
2082 pub(crate) rollback_last_input_tokens: u64,
2083 pub(crate) rollback_compaction_cadence: SessionCompactionCadence,
2084}
2085
2086pub(crate) enum CompactionTransactionPhase {
2087 AwaitingRuntimeCommit(Box<CompactionRollbackState>),
2088 RuntimeCommitted { bookkeeping_complete: bool },
2089 AbortPending { cadence_persist_pending: bool },
2090}
2091
2092pub(crate) struct CompactionTransaction {
2093 pub(crate) phase: CompactionTransactionPhase,
2094 pub(crate) projections: Vec<crate::memory::CompactionProjectionId>,
2095}
2096
2097#[cfg(test)]
2098#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
2099mod tests {
2100 use super::{
2101 AgentToolDispatcher, CommsRuntime, DEFAULT_MAX_INLINE_PEER_NOTIFICATIONS,
2102 FilteredToolDispatcher, InlinePeerNotificationPolicy, ToolDispatchContext,
2103 };
2104 use crate::comms::{
2105 PeerAddress, PeerId, PeerName, PeerTransport, SendError, TrustedPeerDescriptor,
2106 };
2107 use crate::types::{ContentBlock, ContentInput, ToolCallView, ToolDef, ToolResult};
2108 use async_trait::async_trait;
2109 use serde_json::json;
2110 use std::sync::Arc;
2111 use tokio::sync::Notify;
2112
2113 struct NoopCommsRuntime {
2114 notify: Arc<Notify>,
2115 }
2116
2117 struct ContextAwareToolDispatcher;
2118
2119 struct ExactExecutionDispatcher {
2120 catalog: Arc<[crate::ToolCatalogEntry]>,
2121 }
2122
2123 struct HybridExecutionDispatcher {
2124 catalog: Arc<[crate::ToolCatalogEntry]>,
2125 }
2126
2127 struct StreamingExecutionDispatcher {
2128 catalog: Arc<[crate::ToolCatalogEntry]>,
2129 saw_streaming_context: Arc<std::sync::atomic::AtomicBool>,
2130 }
2131
2132 struct IdenticalMutationDispatcher {
2133 tool: ToolDef,
2134 epoch: std::sync::atomic::AtomicU64,
2135 mutate_on_resolve: bool,
2136 }
2137
2138 #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
2139 #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
2140 impl AgentToolDispatcher for ContextAwareToolDispatcher {
2141 fn tools(&self) -> Arc<[Arc<ToolDef>]> {
2142 Arc::from([Arc::new(ToolDef {
2143 name: "inspect_context".into(),
2144 description: "inspect context".to_string(),
2145 input_schema: json!({"type": "object"}),
2146 provenance: None,
2147 })])
2148 }
2149
2150 async fn dispatch(
2151 &self,
2152 call: ToolCallView<'_>,
2153 ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
2154 Ok(ToolResult::new(
2155 call.id.to_string(),
2156 json!({"saw_context_image": false}).to_string(),
2157 false,
2158 )
2159 .into())
2160 }
2161
2162 async fn dispatch_with_context(
2163 &self,
2164 call: ToolCallView<'_>,
2165 context: &ToolDispatchContext,
2166 ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
2167 let saw_context_image = context
2168 .current_turn()
2169 .and_then(|turn| turn.image_ref(0))
2170 .and_then(|image_ref| context.current_turn_image(image_ref))
2171 .is_some();
2172 Ok(ToolResult::new(
2173 call.id.to_string(),
2174 json!({"saw_context_image": saw_context_image}).to_string(),
2175 false,
2176 )
2177 .into())
2178 }
2179 }
2180
2181 #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
2182 #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
2183 impl AgentToolDispatcher for ExactExecutionDispatcher {
2184 fn tools(&self) -> Arc<[Arc<ToolDef>]> {
2185 self.catalog
2186 .iter()
2187 .filter(|entry| entry.currently_callable())
2188 .map(|entry| Arc::clone(&entry.tool))
2189 .collect::<Vec<_>>()
2190 .into()
2191 }
2192
2193 fn tool_catalog_capabilities(&self) -> crate::ToolCatalogCapabilities {
2194 crate::ToolCatalogCapabilities {
2195 exact_catalog: true,
2196 may_require_catalog_control_plane: false,
2197 }
2198 }
2199
2200 fn tool_catalog(&self) -> Arc<[crate::ToolCatalogEntry]> {
2201 Arc::clone(&self.catalog)
2202 }
2203
2204 async fn dispatch(
2205 &self,
2206 call: ToolCallView<'_>,
2207 ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
2208 Ok(ToolResult::new(call.id.to_string(), "ok".to_string(), false).into())
2209 }
2210 }
2211
2212 #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
2213 #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
2214 impl AgentToolDispatcher for HybridExecutionDispatcher {
2215 fn tools(&self) -> Arc<[Arc<ToolDef>]> {
2216 self.catalog
2217 .iter()
2218 .filter(|entry| entry.currently_callable())
2219 .map(|entry| Arc::clone(&entry.tool))
2220 .collect::<Vec<_>>()
2221 .into()
2222 }
2223
2224 fn tool_catalog_capabilities(&self) -> crate::ToolCatalogCapabilities {
2225 crate::ToolCatalogCapabilities {
2226 exact_catalog: true,
2227 may_require_catalog_control_plane: false,
2228 }
2229 }
2230
2231 fn tool_catalog(&self) -> Arc<[crate::ToolCatalogEntry]> {
2232 Arc::clone(&self.catalog)
2233 }
2234
2235 fn resolve_execution_plan(
2236 &self,
2237 call: ToolCallView<'_>,
2238 _dispatch_context: &ToolDispatchContext,
2239 resolution_context: &crate::ToolExecutionResolutionContext,
2240 ) -> Result<crate::ResolvedToolExecutionPlan, crate::ToolExecutionResolutionError> {
2241 let entry = self
2242 .catalog
2243 .iter()
2244 .find(|entry| entry.tool.name == call.name)
2245 .ok_or_else(|| crate::ToolExecutionResolutionError::NotFound {
2246 tool_name: call.name.to_string(),
2247 })?;
2248 let arguments: serde_json::Value =
2249 serde_json::from_str(call.args.get()).map_err(|error| {
2250 crate::ToolExecutionResolutionError::InvalidArguments {
2251 tool_name: call.name.to_string(),
2252 reason: error.to_string(),
2253 }
2254 })?;
2255 let mode = if arguments["run_detached"] == true {
2256 crate::ToolExecutionMode::Detached
2257 } else {
2258 crate::ToolExecutionMode::Fast
2259 };
2260 entry
2261 .execution
2262 .resolve(mode, resolution_context.deadlines().clone())
2263 .map_err(crate::ToolExecutionResolutionError::from)
2264 }
2265
2266 async fn dispatch(
2267 &self,
2268 call: ToolCallView<'_>,
2269 ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
2270 Ok(ToolResult::new(
2271 call.id.to_string(),
2272 json!({"owner": "filtered-hybrid-owner"}).to_string(),
2273 false,
2274 )
2275 .into())
2276 }
2277
2278 async fn dispatch_resolved_with_context(
2279 &self,
2280 call: ToolCallView<'_>,
2281 _context: &ToolDispatchContext,
2282 plan: &crate::ResolvedToolExecutionPlan,
2283 ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
2284 if plan.mode() != crate::ToolExecutionMode::Detached {
2285 return Err(crate::ToolError::execution_failed(
2286 "test detached owner received the wrong plan",
2287 ));
2288 }
2289 self.dispatch(call).await
2290 }
2291 }
2292
2293 #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
2294 #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
2295 impl AgentToolDispatcher for StreamingExecutionDispatcher {
2296 fn tools(&self) -> Arc<[Arc<ToolDef>]> {
2297 self.catalog
2298 .iter()
2299 .map(|entry| Arc::clone(&entry.tool))
2300 .collect::<Vec<_>>()
2301 .into()
2302 }
2303
2304 fn tool_catalog_capabilities(&self) -> crate::ToolCatalogCapabilities {
2305 crate::ToolCatalogCapabilities {
2306 exact_catalog: true,
2307 may_require_catalog_control_plane: false,
2308 }
2309 }
2310
2311 fn tool_catalog(&self) -> Arc<[crate::ToolCatalogEntry]> {
2312 Arc::clone(&self.catalog)
2313 }
2314
2315 async fn dispatch(
2316 &self,
2317 call: ToolCallView<'_>,
2318 ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
2319 Err(crate::ToolError::unavailable(
2320 call.name,
2321 crate::ToolUnavailableReason::ExecutionModeOwnerUnavailable,
2322 ))
2323 }
2324
2325 async fn dispatch_resolved_with_context(
2326 &self,
2327 call: ToolCallView<'_>,
2328 context: &ToolDispatchContext,
2329 plan: &crate::ResolvedToolExecutionPlan,
2330 ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
2331 if plan.mode() != crate::ToolExecutionMode::Streaming {
2332 return Err(crate::ToolError::execution_failed(
2333 "streaming owner received a non-streaming plan",
2334 ));
2335 }
2336 let streaming = context.streaming().ok_or_else(|| {
2337 crate::ToolError::unavailable(
2338 call.name,
2339 crate::ToolUnavailableReason::ExecutionModeOwnerUnavailable,
2340 )
2341 })?;
2342 streaming
2343 .progress()
2344 .try_report(
2345 crate::ToolProgressFrame::message("accepted through wrapper")
2346 .map_err(|error| crate::ToolError::other(error.to_string()))?,
2347 )
2348 .map_err(|error| crate::ToolError::other(error.to_string()))?;
2349 self.saw_streaming_context
2350 .store(true, std::sync::atomic::Ordering::SeqCst);
2351 Ok(ToolResult::new(call.id.to_string(), "stream complete".to_string(), false).into())
2352 }
2353 }
2354
2355 #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
2356 #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
2357 impl AgentToolDispatcher for IdenticalMutationDispatcher {
2358 fn tools(&self) -> Arc<[Arc<ToolDef>]> {
2359 Arc::from([Arc::new(self.tool.clone())])
2360 }
2361
2362 fn tool_catalog_capabilities(&self) -> crate::ToolCatalogCapabilities {
2363 crate::ToolCatalogCapabilities {
2364 exact_catalog: true,
2365 may_require_catalog_control_plane: false,
2366 }
2367 }
2368
2369 fn tool_catalog(&self) -> Arc<[crate::ToolCatalogEntry]> {
2370 Arc::from([crate::ToolCatalogEntry::session_inline(
2371 Arc::new(self.tool.clone()),
2372 true,
2373 )])
2374 }
2375
2376 fn execution_binding_epoch(&self, _tool_name: &str) -> u64 {
2377 self.epoch.load(std::sync::atomic::Ordering::SeqCst)
2378 }
2379
2380 fn resolve_execution_plan(
2381 &self,
2382 _call: ToolCallView<'_>,
2383 _dispatch_context: &ToolDispatchContext,
2384 resolution_context: &crate::ToolExecutionResolutionContext,
2385 ) -> Result<crate::ResolvedToolExecutionPlan, crate::ToolExecutionResolutionError> {
2386 let plan = crate::ToolExecutionContract::default()
2387 .resolve_default(resolution_context.deadlines().clone())
2388 .map_err(crate::ToolExecutionResolutionError::from)?;
2389 if self.mutate_on_resolve {
2390 self.epoch.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2391 }
2392 Ok(plan)
2393 }
2394
2395 async fn dispatch(
2396 &self,
2397 call: ToolCallView<'_>,
2398 ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
2399 Ok(ToolResult::new(call.id.to_string(), "ok".to_string(), false).into())
2400 }
2401 }
2402
2403 #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
2404 #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
2405 impl CommsRuntime for NoopCommsRuntime {
2406 async fn drain_messages(&self) -> Vec<String> {
2407 Vec::new()
2408 }
2409
2410 fn inbox_notify(&self) -> std::sync::Arc<Notify> {
2411 self.notify.clone()
2412 }
2413 }
2414
2415 #[tokio::test]
2416 async fn test_comms_runtime_trait_defaults_hide_unimplemented_features() {
2417 let runtime = NoopCommsRuntime {
2418 notify: Arc::new(Notify::new()),
2419 };
2420 assert!(<NoopCommsRuntime as CommsRuntime>::public_key(&runtime).is_none());
2421 let peer = TrustedPeerDescriptor {
2424 peer_id: PeerId::new(),
2425 name: PeerName::new("peer-a").expect("valid peer name"),
2426 address: PeerAddress::new(PeerTransport::Inproc, "peer-a"),
2427 pubkey: [0u8; 32],
2428 };
2429 let result =
2430 <NoopCommsRuntime as CommsRuntime>::add_private_trusted_peer(&runtime, peer).await;
2431 assert!(matches!(result, Err(SendError::Unsupported(_))));
2432 }
2433
2434 #[tokio::test]
2440 async fn test_comms_runtime_bridge_reply_defaults() {
2441 let runtime = NoopCommsRuntime {
2442 notify: Arc::new(Notify::new()),
2443 };
2444 let interaction_id = crate::interaction::InteractionId(uuid::Uuid::new_v4());
2445 assert!(
2446 <NoopCommsRuntime as CommsRuntime>::take_bridge_reply_waiter(&runtime, &interaction_id)
2447 .is_none()
2448 );
2449 assert!(
2450 !<NoopCommsRuntime as CommsRuntime>::has_bridge_reply_waiter(&runtime, &interaction_id)
2451 );
2452 let staged = <NoopCommsRuntime as CommsRuntime>::stage_declared_reply_endpoint(
2453 &runtime,
2454 PeerId::new(),
2455 [0x11u8; 32],
2456 "tcp://127.0.0.1:1".to_string(),
2457 )
2458 .await;
2459 assert!(matches!(staged, Err(SendError::Unsupported(_))));
2460 }
2461
2462 #[tokio::test]
2463 async fn filtered_tool_dispatcher_preserves_dispatch_context() {
2464 let dispatcher =
2465 FilteredToolDispatcher::new(Arc::new(ContextAwareToolDispatcher), ["inspect_context"]);
2466 let args = serde_json::value::RawValue::from_string("{}".to_string())
2467 .expect("empty object should be valid JSON");
2468 let call = ToolCallView {
2469 id: "ctx-1",
2470 name: "inspect_context",
2471 args: &args,
2472 };
2473 let context = ToolDispatchContext::from_current_turn_input(&ContentInput::Blocks(vec![
2474 ContentBlock::Image {
2475 media_type: "image/png".to_string(),
2476 data: "abc".into(),
2477 },
2478 ]));
2479
2480 let outcome = dispatcher
2481 .dispatch_with_context(call, &context)
2482 .await
2483 .expect("filtered wrapper should dispatch");
2484 let payload: serde_json::Value =
2485 serde_json::from_str(&outcome.result.text_content()).expect("tool result JSON");
2486 assert_eq!(payload["saw_context_image"], true);
2487 }
2488
2489 #[test]
2490 fn default_execution_plan_resolver_uses_exact_catalog_contract() {
2491 use crate::{
2492 DetachedToolExecutionPolicy, IdempotencyScope, RestartClass, RunnerIdentity,
2493 ToolDeadlineChain, ToolDeadlineContributor, ToolDeadlineOwner, ToolExecutionContract,
2494 ToolExecutionMode, ToolExecutionResolutionContext,
2495 };
2496 use std::collections::BTreeSet;
2497 use std::time::Duration;
2498
2499 let detached = DetachedToolExecutionPolicy::new(
2500 RunnerIdentity::new("homecore.security_scan", "v1").unwrap(),
2501 RestartClass::NonResumable,
2502 IdempotencyScope::InteractionAndArguments,
2503 Duration::from_secs(10),
2504 )
2505 .unwrap();
2506 let contract = ToolExecutionContract::new(
2507 BTreeSet::from([ToolExecutionMode::Detached]),
2508 ToolExecutionMode::Detached,
2509 None,
2510 Some(detached),
2511 )
2512 .unwrap();
2513 let dispatcher = ExactExecutionDispatcher {
2514 catalog: Arc::from([crate::ToolCatalogEntry::session_inline(
2515 Arc::new(ToolDef::new(
2516 "security_scan",
2517 "scan",
2518 json!({"type": "object"}),
2519 )),
2520 true,
2521 )
2522 .with_execution_contract(contract)]),
2523 };
2524 let args = serde_json::value::RawValue::from_string("{}".to_string()).unwrap();
2525 let call = ToolCallView {
2526 id: "call-1",
2527 name: "security_scan",
2528 args: &args,
2529 };
2530 let resolution = ToolExecutionResolutionContext::new(
2531 ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
2532 ToolDeadlineOwner::CoreToolDispatch,
2533 Duration::from_secs(600),
2534 )])
2535 .unwrap(),
2536 );
2537
2538 let plan = dispatcher
2539 .resolve_execution_plan(call, &ToolDispatchContext::default(), &resolution)
2540 .expect("declared plan resolves");
2541
2542 assert_eq!(plan.mode(), ToolExecutionMode::Detached);
2543 assert_eq!(
2544 plan.deadlines().effective_timeout(),
2545 Some(Duration::from_secs(10))
2546 );
2547 assert_eq!(
2548 plan.deadlines().winner().map(|winner| winner.owner()),
2549 Some(ToolDeadlineOwner::DetachedSubmission)
2550 );
2551 }
2552
2553 #[tokio::test]
2554 async fn default_resolved_dispatch_refuses_detached_plan_before_ordinary_dispatch() {
2555 use crate::{
2556 DetachedToolExecutionPolicy, IdempotencyScope, RestartClass, RunnerIdentity,
2557 ToolDeadlineChain, ToolDeadlineContributor, ToolDeadlineOwner, ToolExecutionContract,
2558 ToolExecutionMode, ToolExecutionResolutionContext,
2559 };
2560 use std::collections::BTreeSet;
2561 use std::time::Duration;
2562
2563 let detached = DetachedToolExecutionPolicy::new(
2564 RunnerIdentity::new("detached.owner", "v1").unwrap(),
2565 RestartClass::NonResumable,
2566 IdempotencyScope::ToolCall,
2567 Duration::from_secs(10),
2568 )
2569 .unwrap();
2570 let contract = ToolExecutionContract::new(
2571 BTreeSet::from([ToolExecutionMode::Detached]),
2572 ToolExecutionMode::Detached,
2573 None,
2574 Some(detached),
2575 )
2576 .unwrap();
2577 let dispatcher = ExactExecutionDispatcher {
2578 catalog: Arc::from([crate::ToolCatalogEntry::session_inline(
2579 Arc::new(ToolDef::new(
2580 "security_scan",
2581 "scan",
2582 json!({"type": "object"}),
2583 )),
2584 true,
2585 )
2586 .with_execution_contract(contract)]),
2587 };
2588 let args = serde_json::value::RawValue::from_string("{}".to_string()).unwrap();
2589 let call = ToolCallView {
2590 id: "detached-call",
2591 name: "security_scan",
2592 args: &args,
2593 };
2594 let context = ToolDispatchContext::default();
2595 let resolution = ToolExecutionResolutionContext::new(
2596 ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
2597 ToolDeadlineOwner::CoreToolDispatch,
2598 Duration::from_secs(600),
2599 )])
2600 .unwrap(),
2601 );
2602 let plan = dispatcher
2603 .resolve_execution_plan(call, &context, &resolution)
2604 .expect("detached plan resolves");
2605
2606 let error = dispatcher
2607 .dispatch_resolved_with_context(call, &context, &plan)
2608 .await
2609 .expect_err("the default dispatcher must not lower detached work to dispatch()");
2610
2611 assert!(matches!(
2612 error,
2613 crate::ToolError::Unavailable {
2614 reason: crate::ToolUnavailableReason::ExecutionModeOwnerUnavailable,
2615 ..
2616 }
2617 ));
2618 }
2619
2620 #[tokio::test]
2621 async fn fenced_streaming_dispatch_mints_context_and_filtered_wrapper_preserves_it() {
2622 use crate::{
2623 StreamingToolExecutionPolicy, ToolDeadlineChain, ToolDeadlineContributor,
2624 ToolDeadlineOwner, ToolExecutionContract, ToolExecutionMode,
2625 ToolExecutionResolutionContext,
2626 };
2627 use std::collections::BTreeSet;
2628 use std::time::Duration;
2629
2630 let contract = ToolExecutionContract::new(
2631 BTreeSet::from([ToolExecutionMode::Streaming]),
2632 ToolExecutionMode::Streaming,
2633 Some(
2634 StreamingToolExecutionPolicy::new(Duration::from_secs(5), Duration::from_secs(30))
2635 .unwrap(),
2636 ),
2637 None,
2638 )
2639 .unwrap();
2640 let saw_streaming_context = Arc::new(std::sync::atomic::AtomicBool::new(false));
2641 let owner = StreamingExecutionDispatcher {
2642 catalog: Arc::from([crate::ToolCatalogEntry::session_inline(
2643 Arc::new(ToolDef::new(
2644 "stream_scan",
2645 "stream scan",
2646 json!({"type": "object"}),
2647 )),
2648 true,
2649 )
2650 .with_execution_contract(contract)]),
2651 saw_streaming_context: Arc::clone(&saw_streaming_context),
2652 };
2653 let dispatcher = Arc::new(FilteredToolDispatcher::new(
2654 Arc::new(owner),
2655 ["stream_scan"],
2656 ));
2657 let filtered_catalog = dispatcher.tool_catalog();
2658 assert_eq!(
2659 filtered_catalog[0].execution.default_mode(),
2660 ToolExecutionMode::Streaming
2661 );
2662 let filtered_policy = filtered_catalog[0]
2663 .execution
2664 .streaming_policy()
2665 .expect("wrapper preserves the streaming registration");
2666 assert_eq!(filtered_policy.inactivity_timeout(), Duration::from_secs(5));
2667 assert_eq!(filtered_policy.absolute_timeout(), Duration::from_secs(30));
2668 let args = serde_json::value::RawValue::from_string("{}".to_string()).unwrap();
2669 let call = ToolCallView {
2670 id: "stream-call",
2671 name: "stream_scan",
2672 args: &args,
2673 };
2674 let context = ToolDispatchContext::default();
2675 assert!(
2676 context.streaming().is_none(),
2677 "callers cannot pre-mint the supervised streaming context"
2678 );
2679 let resolution = ToolExecutionResolutionContext::new(
2680 ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
2681 ToolDeadlineOwner::CoreToolDispatch,
2682 Duration::from_secs(60),
2683 )])
2684 .unwrap(),
2685 );
2686 let plan =
2687 crate::resolve_tool_execution_plan_fenced(&dispatcher, call, &context, &resolution)
2688 .expect("streaming plan resolves through wrapper");
2689
2690 let outcome =
2691 crate::dispatch_tool_execution_plan_fenced(&dispatcher, call, &context, &plan)
2692 .await
2693 .expect("streaming dispatch completes");
2694
2695 assert_eq!(outcome.result.text_content(), "stream complete");
2696 assert!(
2697 saw_streaming_context.load(std::sync::atomic::Ordering::SeqCst),
2698 "the wrapper must preserve the exact supervised context"
2699 );
2700 }
2701
2702 #[tokio::test]
2703 async fn declared_streaming_without_a_mode_owner_fails_closed_before_plain_dispatch() {
2704 use crate::{
2705 StreamingToolExecutionPolicy, ToolDeadlineChain, ToolDeadlineContributor,
2706 ToolDeadlineOwner, ToolExecutionContract, ToolExecutionMode,
2707 ToolExecutionResolutionContext,
2708 };
2709 use std::collections::BTreeSet;
2710 use std::time::Duration;
2711
2712 let contract = ToolExecutionContract::new(
2713 BTreeSet::from([ToolExecutionMode::Streaming]),
2714 ToolExecutionMode::Streaming,
2715 Some(
2716 StreamingToolExecutionPolicy::new(Duration::from_secs(5), Duration::from_secs(30))
2717 .unwrap(),
2718 ),
2719 None,
2720 )
2721 .unwrap();
2722 let dispatcher = Arc::new(ExactExecutionDispatcher {
2723 catalog: Arc::from([crate::ToolCatalogEntry::session_inline(
2724 Arc::new(ToolDef::new(
2725 "ownerless_stream",
2726 "ownerless",
2727 json!({"type": "object"}),
2728 )),
2729 true,
2730 )
2731 .with_execution_contract(contract)]),
2732 });
2733 let args = serde_json::value::RawValue::from_string("{}".to_string()).unwrap();
2734 let call = ToolCallView {
2735 id: "ownerless-call",
2736 name: "ownerless_stream",
2737 args: &args,
2738 };
2739 let context = ToolDispatchContext::default();
2740 let resolution = ToolExecutionResolutionContext::new(
2741 ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
2742 ToolDeadlineOwner::CoreToolDispatch,
2743 Duration::from_secs(60),
2744 )])
2745 .unwrap(),
2746 );
2747 let plan =
2748 crate::resolve_tool_execution_plan_fenced(&dispatcher, call, &context, &resolution)
2749 .expect("declaration resolves");
2750
2751 let error = crate::dispatch_tool_execution_plan_fenced(&dispatcher, call, &context, &plan)
2752 .await
2753 .expect_err("missing streaming owner must fail closed");
2754 assert!(matches!(
2755 error,
2756 crate::ToolError::Unavailable {
2757 reason: crate::ToolUnavailableReason::ExecutionModeOwnerUnavailable,
2758 ..
2759 }
2760 ));
2761 }
2762
2763 #[test]
2764 fn filtered_execution_plan_resolver_rejects_policy_denied_tool() {
2765 use crate::{
2766 ToolDeadlineChain, ToolDeadlineContributor, ToolDeadlineOwner,
2767 ToolExecutionResolutionContext, ToolExecutionResolutionError,
2768 };
2769 use std::time::Duration;
2770
2771 let dispatcher =
2772 FilteredToolDispatcher::new(Arc::new(ContextAwareToolDispatcher), Vec::<String>::new());
2773 let args = serde_json::value::RawValue::from_string("{}".to_string()).unwrap();
2774 let call = ToolCallView {
2775 id: "call-hidden",
2776 name: "inspect_context",
2777 args: &args,
2778 };
2779 let resolution = ToolExecutionResolutionContext::new(
2780 ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
2781 ToolDeadlineOwner::CoreToolDispatch,
2782 Duration::from_secs(600),
2783 )])
2784 .unwrap(),
2785 );
2786
2787 let error = dispatcher
2788 .resolve_execution_plan(call, &ToolDispatchContext::default(), &resolution)
2789 .expect_err("hidden tools must not resolve");
2790
2791 assert_eq!(
2792 error,
2793 ToolExecutionResolutionError::AccessDenied {
2794 tool_name: "inspect_context".to_string(),
2795 }
2796 );
2797 }
2798
2799 #[tokio::test]
2800 async fn filtered_execution_plan_forwards_hybrid_resolution_to_visible_owner() {
2801 use crate::{
2802 DetachedToolExecutionPolicy, IdempotencyScope, ResolvedExecutionKind, RestartClass,
2803 RunnerIdentity, ToolDeadlineChain, ToolDeadlineContributor, ToolDeadlineOwner,
2804 ToolExecutionContract, ToolExecutionMode, ToolExecutionResolutionContext,
2805 };
2806 use std::collections::BTreeSet;
2807 use std::time::Duration;
2808
2809 let detached = DetachedToolExecutionPolicy::new(
2810 RunnerIdentity::new("filtered-hybrid-owner", "v1").unwrap(),
2811 RestartClass::NonResumable,
2812 IdempotencyScope::InteractionAndArguments,
2813 Duration::from_secs(10),
2814 )
2815 .unwrap();
2816 let contract = ToolExecutionContract::new(
2817 BTreeSet::from([ToolExecutionMode::Fast, ToolExecutionMode::Detached]),
2818 ToolExecutionMode::Fast,
2819 None,
2820 Some(detached),
2821 )
2822 .unwrap();
2823 let dispatcher = FilteredToolDispatcher::new(
2824 Arc::new(HybridExecutionDispatcher {
2825 catalog: Arc::from([crate::ToolCatalogEntry::session_inline(
2826 Arc::new(ToolDef::new(
2827 "hybrid_scan",
2828 "filtered-hybrid-owner catalog",
2829 json!({"type": "object"}),
2830 )),
2831 true,
2832 )
2833 .with_execution_contract(contract)]),
2834 }),
2835 ["hybrid_scan"],
2836 );
2837 let args = serde_json::value::RawValue::from_string(r#"{"run_detached":true}"#.to_string())
2838 .unwrap();
2839 let call = ToolCallView {
2840 id: "call-hybrid",
2841 name: "hybrid_scan",
2842 args: &args,
2843 };
2844 let resolution = ToolExecutionResolutionContext::new(
2845 ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
2846 ToolDeadlineOwner::CoreToolDispatch,
2847 Duration::from_secs(600),
2848 )])
2849 .unwrap(),
2850 );
2851
2852 let catalog = dispatcher.tool_catalog();
2853 assert_eq!(catalog[0].execution.default_mode(), ToolExecutionMode::Fast);
2854 assert_eq!(catalog[0].tool.description, "filtered-hybrid-owner catalog");
2855
2856 let plan = dispatcher
2857 .resolve_execution_plan(call, &ToolDispatchContext::default(), &resolution)
2858 .expect("visible hybrid tool should delegate plan resolution");
2859 dispatcher
2860 .validate_resolved_execution_plan(call, &resolution, &plan)
2861 .expect("hybrid-selected advertised mode must validate");
2862 let ResolvedExecutionKind::Detached(policy) = plan.kind() else {
2863 panic!("hybrid resolver should select its non-default detached mode");
2864 };
2865 assert_eq!(policy.runner().name(), "filtered-hybrid-owner");
2866
2867 let outcome = dispatcher
2868 .dispatch_resolved_with_context(call, &ToolDispatchContext::default(), &plan)
2869 .await
2870 .expect("visible hybrid tool should preserve resolved dispatch");
2871 let payload: serde_json::Value =
2872 serde_json::from_str(&outcome.result.text_content()).unwrap();
2873 assert_eq!(payload["owner"], "filtered-hybrid-owner");
2874 }
2875
2876 #[test]
2877 fn root_validation_rejects_plan_outside_live_advertised_contract() {
2878 use crate::{
2879 DetachedToolExecutionPolicy, IdempotencyScope, RestartClass, RunnerIdentity,
2880 ToolDeadlineChain, ToolDeadlineContributor, ToolDeadlineOwner, ToolExecutionContract,
2881 ToolExecutionContractError, ToolExecutionMode, ToolExecutionResolutionContext,
2882 ToolExecutionResolutionError,
2883 };
2884 use std::collections::BTreeSet;
2885 use std::time::Duration;
2886
2887 let dispatcher = ExactExecutionDispatcher {
2888 catalog: Arc::from([crate::ToolCatalogEntry::session_inline(
2889 Arc::new(ToolDef::new(
2890 "fast_only",
2891 "fast only",
2892 json!({"type": "object"}),
2893 )),
2894 true,
2895 )]),
2896 };
2897 let detached = DetachedToolExecutionPolicy::new(
2898 RunnerIdentity::new("dishonest.owner", "v1").unwrap(),
2899 RestartClass::NonResumable,
2900 IdempotencyScope::ToolCall,
2901 Duration::from_secs(10),
2902 )
2903 .unwrap();
2904 let dishonest_contract = ToolExecutionContract::new(
2905 BTreeSet::from([ToolExecutionMode::Detached]),
2906 ToolExecutionMode::Detached,
2907 None,
2908 Some(detached),
2909 )
2910 .unwrap();
2911 let resolution = ToolExecutionResolutionContext::new(
2912 ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
2913 ToolDeadlineOwner::CoreToolDispatch,
2914 Duration::from_secs(600),
2915 )])
2916 .unwrap(),
2917 );
2918 let plan = dishonest_contract
2919 .resolve_default(resolution.deadlines().clone())
2920 .unwrap();
2921 let args = serde_json::value::RawValue::from_string("{}".to_string()).unwrap();
2922 let call = ToolCallView {
2923 id: "dishonest-plan",
2924 name: "fast_only",
2925 args: &args,
2926 };
2927
2928 assert_eq!(
2929 dispatcher.validate_resolved_execution_plan(call, &resolution, &plan),
2930 Err(ToolExecutionResolutionError::Contract(
2931 ToolExecutionContractError::RequestedModeUnsupported {
2932 requested_mode: ToolExecutionMode::Detached,
2933 }
2934 ))
2935 );
2936 }
2937
2938 #[tokio::test]
2939 async fn universal_root_fence_accepts_rebuilt_equivalent_catalog_arcs() {
2940 use crate::{
2941 ToolDeadlineChain, ToolDeadlineContributor, ToolDeadlineOwner,
2942 ToolExecutionResolutionContext,
2943 };
2944 use std::time::Duration;
2945
2946 let dispatcher: Arc<dyn AgentToolDispatcher> = Arc::new(IdenticalMutationDispatcher {
2947 tool: ToolDef::new("rebuilt", "rebuilt", json!({"type": "object"})),
2948 epoch: std::sync::atomic::AtomicU64::new(0),
2949 mutate_on_resolve: false,
2950 });
2951 let args = serde_json::value::RawValue::from_string("{}".to_string()).unwrap();
2952 let call = ToolCallView {
2953 id: "rebuilt-arcs",
2954 name: "rebuilt",
2955 args: &args,
2956 };
2957 let resolution = ToolExecutionResolutionContext::new(
2958 ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
2959 ToolDeadlineOwner::CoreToolDispatch,
2960 Duration::from_secs(600),
2961 )])
2962 .unwrap(),
2963 );
2964
2965 let plan = crate::resolve_tool_execution_plan_fenced(
2966 &dispatcher,
2967 call,
2968 &ToolDispatchContext::default(),
2969 &resolution,
2970 )
2971 .expect("equivalent rebuilt catalog projections resolve");
2972 crate::dispatch_tool_execution_plan_fenced(
2973 &dispatcher,
2974 call,
2975 &ToolDispatchContext::default(),
2976 &plan,
2977 )
2978 .await
2979 .expect("equivalent rebuilt catalog projections dispatch");
2980 }
2981
2982 #[tokio::test]
2983 async fn universal_root_fence_binds_canonical_call_identity() {
2984 use crate::{
2985 ToolDeadlineChain, ToolDeadlineContributor, ToolDeadlineOwner,
2986 ToolExecutionResolutionContext, ToolUnavailableReason,
2987 };
2988 use std::time::Duration;
2989
2990 let dispatcher: Arc<dyn AgentToolDispatcher> = Arc::new(IdenticalMutationDispatcher {
2991 tool: ToolDef::new("bound", "bound", json!({"type": "object"})),
2992 epoch: std::sync::atomic::AtomicU64::new(0),
2993 mutate_on_resolve: false,
2994 });
2995 let resolved_args =
2996 serde_json::value::RawValue::from_string(r#"{"a":1,"b":2}"#.to_string()).unwrap();
2997 let equivalent_args =
2998 serde_json::value::RawValue::from_string(r#"{ "b": 2, "a": 1 }"#.to_string()).unwrap();
2999 let changed_args =
3000 serde_json::value::RawValue::from_string(r#"{"a":1,"b":3}"#.to_string()).unwrap();
3001 let resolved_call = ToolCallView {
3002 id: "bound-call",
3003 name: "bound",
3004 args: &resolved_args,
3005 };
3006 let resolution = ToolExecutionResolutionContext::new(
3007 ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
3008 ToolDeadlineOwner::CoreToolDispatch,
3009 Duration::from_secs(600),
3010 )])
3011 .unwrap(),
3012 );
3013 let plan = crate::resolve_tool_execution_plan_fenced(
3014 &dispatcher,
3015 resolved_call,
3016 &ToolDispatchContext::default(),
3017 &resolution,
3018 )
3019 .unwrap();
3020
3021 crate::dispatch_tool_execution_plan_fenced(
3022 &dispatcher,
3023 ToolCallView {
3024 args: &equivalent_args,
3025 ..resolved_call
3026 },
3027 &ToolDispatchContext::default(),
3028 &plan,
3029 )
3030 .await
3031 .expect("canonical JSON-equivalent arguments preserve call identity");
3032
3033 let error = crate::dispatch_tool_execution_plan_fenced(
3034 &dispatcher,
3035 ToolCallView {
3036 args: &changed_args,
3037 ..resolved_call
3038 },
3039 &ToolDispatchContext::default(),
3040 &plan,
3041 )
3042 .await
3043 .expect_err("different arguments must not dispatch under the old plan");
3044 assert!(matches!(
3045 error,
3046 crate::ToolError::Unavailable {
3047 reason: ToolUnavailableReason::ExecutionOwnerChanged,
3048 ..
3049 }
3050 ));
3051 }
3052
3053 #[tokio::test]
3054 async fn universal_root_fence_rejects_fresh_dispatcher_reconstruction() {
3055 use crate::{
3056 ToolDeadlineChain, ToolDeadlineContributor, ToolDeadlineOwner,
3057 ToolExecutionResolutionContext, ToolUnavailableReason,
3058 };
3059 use std::time::Duration;
3060
3061 let make_dispatcher = || -> Arc<dyn AgentToolDispatcher> {
3062 Arc::new(IdenticalMutationDispatcher {
3063 tool: ToolDef::new("bound", "bound", json!({"type": "object"})),
3064 epoch: std::sync::atomic::AtomicU64::new(0),
3065 mutate_on_resolve: false,
3066 })
3067 };
3068 let original = make_dispatcher();
3069 let args = serde_json::value::RawValue::from_string("{}".to_string()).unwrap();
3070 let call = ToolCallView {
3071 id: "reconstructed",
3072 name: "bound",
3073 args: &args,
3074 };
3075 let resolution = ToolExecutionResolutionContext::new(
3076 ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
3077 ToolDeadlineOwner::CoreToolDispatch,
3078 Duration::from_secs(600),
3079 )])
3080 .unwrap(),
3081 );
3082 let plan = crate::resolve_tool_execution_plan_fenced(
3083 &original,
3084 call,
3085 &ToolDispatchContext::default(),
3086 &resolution,
3087 )
3088 .unwrap();
3089 let reconstructed = make_dispatcher();
3090
3091 let error = crate::dispatch_tool_execution_plan_fenced(
3092 &reconstructed,
3093 call,
3094 &ToolDispatchContext::default(),
3095 &plan,
3096 )
3097 .await
3098 .expect_err("fresh reconstruction must never reproduce ephemeral root authority");
3099 assert!(matches!(
3100 error,
3101 crate::ToolError::Unavailable {
3102 reason: ToolUnavailableReason::ExecutionOwnerChanged,
3103 ..
3104 }
3105 ));
3106 }
3107
3108 #[test]
3109 fn universal_root_fence_rejects_direct_identical_metadata_replacement() {
3110 use crate::{
3111 ToolDeadlineChain, ToolDeadlineContributor, ToolDeadlineOwner,
3112 ToolExecutionResolutionContext, ToolExecutionResolutionError, ToolUnavailableReason,
3113 };
3114 use std::time::Duration;
3115
3116 let dispatcher: Arc<dyn AgentToolDispatcher> = Arc::new(IdenticalMutationDispatcher {
3117 tool: ToolDef::new("moving", "identical metadata", json!({"type": "object"})),
3118 epoch: std::sync::atomic::AtomicU64::new(0),
3119 mutate_on_resolve: true,
3120 });
3121 let args = serde_json::value::RawValue::from_string("{}".to_string()).unwrap();
3122 let call = ToolCallView {
3123 id: "direct-identical-replacement",
3124 name: "moving",
3125 args: &args,
3126 };
3127 let resolution = ToolExecutionResolutionContext::new(
3128 ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
3129 ToolDeadlineOwner::CoreToolDispatch,
3130 Duration::from_secs(600),
3131 )])
3132 .unwrap(),
3133 );
3134
3135 assert!(matches!(
3136 crate::resolve_tool_execution_plan_fenced(
3137 &dispatcher,
3138 call,
3139 &ToolDispatchContext::default(),
3140 &resolution,
3141 ),
3142 Err(ToolExecutionResolutionError::Unavailable {
3143 reason: ToolUnavailableReason::ExecutionOwnerChanged,
3144 ..
3145 })
3146 ));
3147 }
3148
3149 #[test]
3150 fn filtered_wrapper_composes_inner_live_binding_epoch() {
3151 use crate::{
3152 ToolDeadlineChain, ToolDeadlineContributor, ToolDeadlineOwner,
3153 ToolExecutionResolutionContext, ToolExecutionResolutionError, ToolUnavailableReason,
3154 };
3155 use std::time::Duration;
3156
3157 let dispatcher: Arc<dyn AgentToolDispatcher> = Arc::new(FilteredToolDispatcher::new(
3158 Arc::new(IdenticalMutationDispatcher {
3159 tool: ToolDef::new("moving", "identical metadata", json!({"type": "object"})),
3160 epoch: std::sync::atomic::AtomicU64::new(0),
3161 mutate_on_resolve: true,
3162 }),
3163 ["moving"],
3164 ));
3165 let args = serde_json::value::RawValue::from_string("{}".to_string()).unwrap();
3166 let call = ToolCallView {
3167 id: "filtered-identical-replacement",
3168 name: "moving",
3169 args: &args,
3170 };
3171 let resolution = ToolExecutionResolutionContext::new(
3172 ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
3173 ToolDeadlineOwner::CoreToolDispatch,
3174 Duration::from_secs(600),
3175 )])
3176 .unwrap(),
3177 );
3178
3179 assert!(matches!(
3180 crate::resolve_tool_execution_plan_fenced(
3181 &dispatcher,
3182 call,
3183 &ToolDispatchContext::default(),
3184 &resolution,
3185 ),
3186 Err(ToolExecutionResolutionError::Unavailable {
3187 reason: ToolUnavailableReason::ExecutionOwnerChanged,
3188 ..
3189 })
3190 ));
3191 }
3192
3193 #[test]
3194 fn test_inline_peer_notification_policy_from_raw() {
3195 assert_eq!(
3196 InlinePeerNotificationPolicy::try_from_raw(None),
3197 Ok(InlinePeerNotificationPolicy::AtMost(
3198 DEFAULT_MAX_INLINE_PEER_NOTIFICATIONS
3199 ))
3200 );
3201 assert_eq!(
3202 InlinePeerNotificationPolicy::try_from_raw(Some(-1)),
3203 Ok(InlinePeerNotificationPolicy::Always)
3204 );
3205 assert_eq!(
3206 InlinePeerNotificationPolicy::try_from_raw(Some(0)),
3207 Ok(InlinePeerNotificationPolicy::Never)
3208 );
3209 assert_eq!(
3210 InlinePeerNotificationPolicy::try_from_raw(Some(25)),
3211 Ok(InlinePeerNotificationPolicy::AtMost(25))
3212 );
3213 assert_eq!(
3214 InlinePeerNotificationPolicy::try_from_raw(Some(-42)),
3215 Err(-42)
3216 );
3217 }
3218
3219 #[test]
3222 fn unit_002_detached_op_completion_has_no_operation_id() {
3223 use crate::agent::DetachedOpCompletion;
3224 use crate::ops_lifecycle::{OperationKind, OperationStatus};
3225
3226 let completion = DetachedOpCompletion {
3227 job_id: "j_test".into(),
3228 kind: OperationKind::BackgroundToolOp,
3229 status: OperationStatus::Completed,
3230 terminal_outcome: None,
3231 display_name: "test cmd".into(),
3232 detail: "ok".into(),
3233 elapsed_ms: None,
3234 };
3235 #[allow(clippy::unwrap_used)]
3236 let json = serde_json::to_value(&completion).unwrap();
3237 assert!(
3238 json.get("operation_id").is_none(),
3239 "operation_id must not appear in serialized DetachedOpCompletion (CONTRACT-003)"
3240 );
3241 assert!(
3242 json.get("job_id").is_some(),
3243 "job_id must be the app-facing control noun"
3244 );
3245 }
3246}