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::{AgentRunner, SnapshotProjectionError, SystemContextStateError};
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 provider(&self) -> crate::provider::Provider;
81
82 fn model(&self) -> &str;
88
89 fn prepare_model_fallback(&self, _failure: &AgentError) -> Option<AgentLlmFallbackSwitch> {
96 None
97 }
98
99 fn commit_model_fallback(
113 &self,
114 _previous_identity: &crate::SessionLlmIdentity,
115 target_identity: &crate::SessionLlmIdentity,
116 ) -> Result<(), AgentError> {
117 Err(AgentError::ConfigError(format!(
118 "LLM client proposed fallback target '{}:{}' without an activation implementation",
119 target_identity.provider.as_str(),
120 target_identity.model
121 )))
122 }
123
124 fn active_model_fallback_identity(&self) -> Option<crate::SessionLlmIdentity> {
131 None
132 }
133
134 fn compile_model_fallback_schema(
141 &self,
142 target_identity: &crate::SessionLlmIdentity,
143 _output_schema: &OutputSchema,
144 ) -> Result<CompiledSchema, AgentError> {
145 Err(AgentError::ConfigError(format!(
146 "LLM client cannot compile structured output for fallback target '{}:{}'",
147 target_identity.provider.as_str(),
148 target_identity.model
149 )))
150 }
151
152 fn begin_stream_output_observation(&self) {}
159
160 fn stream_output_observed(&self) -> bool {
167 false
168 }
169
170 fn stream_activity_count(&self) -> Option<u64> {
186 None
187 }
188
189 fn compile_schema(&self, output_schema: &OutputSchema) -> Result<CompiledSchema, SchemaError> {
195 Ok(CompiledSchema {
197 schema: output_schema.schema.as_value().clone(),
198 warnings: Vec::new(),
199 })
200 }
201}
202
203pub type AgentLlmClientDecorator =
209 Arc<dyn Fn(Arc<dyn AgentLlmClient>) -> Arc<dyn AgentLlmClient> + Send + Sync + 'static>;
210
211#[derive(Debug, Clone)]
213pub struct AgentLlmFallbackSkippedTarget {
214 pub identity: crate::SessionLlmIdentity,
215 pub reason: String,
216}
217
218#[derive(Debug, Clone)]
224pub struct AgentLlmFallbackSwitch {
225 pub previous_identity: crate::SessionLlmIdentity,
226 pub new_identity: crate::SessionLlmIdentity,
227 pub request_policy: crate::SessionLlmRequestPolicy,
228 pub target_profile: crate::ModelProfileWitness,
233 pub skipped_targets: Vec<AgentLlmFallbackSkippedTarget>,
234}
235
236pub struct StickyModelFallbackActivationProof {
252 previous_identity: crate::SessionLlmIdentity,
253 target_identity: crate::SessionLlmIdentity,
254 target_profile: crate::ModelProfileWitness,
255 target_capability_base_filter: crate::ToolFilter,
256 retry_attempt: u32,
257}
258
259impl StickyModelFallbackActivationProof {
260 fn new(
261 previous_identity: crate::SessionLlmIdentity,
262 target_identity: crate::SessionLlmIdentity,
263 target_profile: crate::ModelProfileWitness,
264 retry_attempt: u32,
265 ) -> Self {
266 let target_capability_base_filter = crate::capability_base_filter_for_image_tool_results(
267 target_profile.profile().image_tool_results,
268 );
269 Self {
270 previous_identity,
271 target_identity,
272 target_profile,
273 target_capability_base_filter,
274 retry_attempt,
275 }
276 }
277
278 pub fn previous_identity(&self) -> &crate::SessionLlmIdentity {
280 &self.previous_identity
281 }
282
283 pub fn target_identity(&self) -> &crate::SessionLlmIdentity {
285 &self.target_identity
286 }
287
288 pub fn target_profile(&self) -> &crate::ModelProfileWitness {
290 &self.target_profile
291 }
292
293 pub fn target_capability_base_filter(&self) -> &crate::ToolFilter {
295 &self.target_capability_base_filter
296 }
297
298 pub fn retry_attempt(&self) -> u32 {
300 self.retry_attempt
301 }
302}
303
304pub struct LlmStreamResult {
306 blocks: Vec<AssistantBlock>,
307 stop_reason: StopReason,
308 usage: Usage,
309}
310
311impl LlmStreamResult {
312 pub fn new(blocks: Vec<AssistantBlock>, stop_reason: StopReason, usage: Usage) -> Self {
313 Self {
314 blocks,
315 stop_reason,
316 usage,
317 }
318 }
319
320 pub fn blocks(&self) -> &[AssistantBlock] {
321 &self.blocks
322 }
323 pub fn stop_reason(&self) -> StopReason {
324 self.stop_reason
325 }
326 pub fn usage(&self) -> &Usage {
327 &self.usage
328 }
329
330 pub fn into_message(self) -> BlockAssistantMessage {
331 BlockAssistantMessage::new(self.blocks, self.stop_reason)
332 }
333
334 pub fn into_parts(self) -> (Vec<AssistantBlock>, StopReason, Usage) {
335 (self.blocks, self.stop_reason, self.usage)
336 }
337}
338
339#[derive(Debug, Clone, PartialEq, Eq)]
345pub struct AgentExecutionSnapshot {
346 pub loop_state: LoopState,
347 pub turn_phase: TurnPhase,
348 pub turn_terminal: bool,
354 pub active_run_id: Option<RunId>,
355 pub terminal_run_id: Option<RunId>,
356 pub primitive_kind: TurnPrimitiveKind,
357 pub admitted_content_shape: Option<ContentShape>,
358 pub vision_enabled: bool,
359 pub image_tool_results_enabled: bool,
360 pub tool_calls_pending: u32,
361 pub pending_operation_ids: Option<Vec<OperationId>>,
362 pub barrier_operation_ids: Vec<OperationId>,
363 pub has_barrier_ops: bool,
364 pub barrier_satisfied: bool,
365 pub boundary_count: u32,
366 pub cancel_after_boundary: bool,
367 pub terminal_outcome: TurnTerminalOutcome,
368 pub terminal_cause_kind: Option<TurnTerminalCauseKind>,
369 pub extraction_attempts: u32,
370 pub max_extraction_retries: u32,
371 pub applied_cursor: CompletionSeq,
372}
373
374#[derive(Debug, Clone, Default)]
378pub struct ExternalToolUpdate {
379 pub notices: Vec<ExternalToolDelta>,
381 pub pending: Vec<String>,
383}
384
385#[derive(Debug, Clone, PartialEq, Eq)]
394pub struct CancelAfterBoundaryCommand {
395 expected_run_id: RunId,
396}
397
398impl CancelAfterBoundaryCommand {
399 pub fn for_run(expected_run_id: RunId) -> Self {
401 Self { expected_run_id }
402 }
403
404 pub fn expected_run_id(&self) -> &RunId {
406 &self.expected_run_id
407 }
408}
409
410pub type CancelAfterBoundarySender = tokio::sync::mpsc::UnboundedSender<CancelAfterBoundaryCommand>;
417
418#[derive(Debug, Clone, Default, PartialEq, Eq)]
425pub struct ToolDispatchContext {
426 current_turn: Option<CurrentTurnContent>,
427 turn_metadata: BTreeMap<String, serde_json::Value>,
428 origin_session_id: Option<crate::types::SessionId>,
429 interaction_lineage_id: Option<crate::interaction::InteractionId>,
430 streaming: Option<crate::ToolStreamingDispatchContext>,
431}
432
433pub const TOOL_DISPATCH_OBJECTIVE_ID_KEY: &str = "meerkat.objective_id";
435
436impl ToolDispatchContext {
437 pub fn from_current_turn_input(input: &crate::types::ContentInput) -> Self {
438 let blocks = match input {
439 crate::types::ContentInput::Text(_) => None,
440 crate::types::ContentInput::Blocks(blocks) => Some(blocks.clone()),
441 };
442 Self {
443 current_turn: blocks.map(CurrentTurnContent::new),
444 turn_metadata: BTreeMap::new(),
445 origin_session_id: None,
446 interaction_lineage_id: None,
447 streaming: None,
448 }
449 }
450
451 pub fn from_run_input(input: &crate::types::RunInput) -> Self {
455 match input {
456 crate::types::RunInput::Content { content } => Self::from_current_turn_input(content),
457 crate::types::RunInput::PendingToolResults => Self::default(),
458 }
459 }
460
461 #[must_use]
462 pub fn with_turn_metadata(mut self, metadata: BTreeMap<String, serde_json::Value>) -> Self {
463 self.turn_metadata = metadata;
464 self
465 }
466
467 pub fn turn_metadata(&self, key: &str) -> Option<&serde_json::Value> {
468 self.turn_metadata.get(key)
469 }
470
471 pub fn current_turn(&self) -> Option<&CurrentTurnContent> {
472 self.current_turn.as_ref()
473 }
474
475 #[must_use]
480 pub fn with_runtime_identity(
481 mut self,
482 origin_session_id: crate::types::SessionId,
483 interaction_lineage_id: Option<crate::interaction::InteractionId>,
484 ) -> Self {
485 self.origin_session_id = Some(origin_session_id);
486 self.interaction_lineage_id = interaction_lineage_id;
487 self
488 }
489
490 pub fn origin_session_id(&self) -> Option<&crate::types::SessionId> {
491 self.origin_session_id.as_ref()
492 }
493
494 pub const fn interaction_lineage_id(&self) -> Option<crate::interaction::InteractionId> {
495 self.interaction_lineage_id
496 }
497
498 pub const fn streaming(&self) -> Option<&crate::ToolStreamingDispatchContext> {
504 self.streaming.as_ref()
505 }
506
507 pub(crate) fn with_streaming(mut self, streaming: crate::ToolStreamingDispatchContext) -> Self {
508 self.streaming = Some(streaming);
509 self
510 }
511
512 pub fn current_turn_image(
513 &self,
514 image_ref: CurrentTurnImageRef,
515 ) -> Option<&crate::types::ContentBlock> {
516 self.current_turn
517 .as_ref()
518 .and_then(|current_turn| current_turn.image(image_ref))
519 }
520}
521
522#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
537#[serde(transparent)]
538pub struct CurrentTurnImageRef(usize);
539
540impl std::fmt::Display for CurrentTurnImageRef {
541 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
542 std::fmt::Display::fmt(&self.0, f)
543 }
544}
545
546#[derive(Debug, Clone, PartialEq, Eq)]
548pub struct CurrentTurnContent {
549 blocks: Vec<crate::types::ContentBlock>,
550}
551
552impl CurrentTurnContent {
553 pub fn new(blocks: Vec<crate::types::ContentBlock>) -> Self {
554 Self { blocks }
555 }
556
557 pub fn blocks(&self) -> &[crate::types::ContentBlock] {
558 &self.blocks
559 }
560
561 pub fn image_ref(&self, n: usize) -> Option<CurrentTurnImageRef> {
565 self.images().nth(n).map(|_| CurrentTurnImageRef(n))
566 }
567
568 pub fn image(&self, image_ref: CurrentTurnImageRef) -> Option<&crate::types::ContentBlock> {
569 self.images().nth(image_ref.0)
570 }
571
572 fn images(&self) -> impl Iterator<Item = &crate::types::ContentBlock> {
573 self.blocks
574 .iter()
575 .filter(|block| matches!(block, crate::types::ContentBlock::Image { .. }))
576 }
577}
578
579#[derive(Debug, Clone, Serialize, Deserialize)]
586pub struct DetachedOpCompletion {
587 pub job_id: String,
589 pub kind: OperationKind,
591 pub status: OperationStatus,
593 pub terminal_outcome: Option<OperationTerminalOutcome>,
595 pub display_name: String,
597 pub detail: String,
599 pub elapsed_ms: Option<u64>,
601}
602
603#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
608pub struct DispatcherCapabilities {
609 pub ops_lifecycle: bool,
611}
612
613pub enum BindOutcome {
624 Bound(Arc<dyn AgentToolDispatcher>),
626 Skipped(Arc<dyn AgentToolDispatcher>),
629}
630
631impl BindOutcome {
632 pub fn into_dispatcher(self) -> Arc<dyn AgentToolDispatcher> {
634 match self {
635 Self::Bound(d) | Self::Skipped(d) => d,
636 }
637 }
638
639 pub fn was_bound(&self) -> bool {
641 matches!(self, Self::Bound(_))
642 }
643}
644
645#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
647#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
648pub trait AgentToolDispatcher: Send + Sync {
649 fn tools(&self) -> Arc<[Arc<ToolDef>]>;
651
652 fn tool_catalog_capabilities(&self) -> ToolCatalogCapabilities {
658 ToolCatalogCapabilities::default()
659 }
660
661 fn tool_catalog(&self) -> Arc<[ToolCatalogEntry]> {
667 self.tools()
668 .iter()
669 .map(|tool| ToolCatalogEntry::session_inline(Arc::clone(tool), true))
670 .collect::<Vec<_>>()
671 .into()
672 }
673
674 fn execution_binding_epoch(&self, _tool_name: &str) -> u64 {
680 0
681 }
682
683 fn execution_binding_fingerprint(
685 &self,
686 tool_name: &str,
687 ) -> Result<crate::EphemeralToolBindingFingerprint, crate::ToolExecutionResolutionError> {
688 let catalog = self.tool_catalog();
689 let entry = catalog
690 .iter()
691 .find(|entry| entry.tool.name == tool_name)
692 .ok_or_else(|| crate::ToolExecutionResolutionError::NotFound {
693 tool_name: tool_name.to_string(),
694 })?;
695 Ok(crate::ephemeral_tool_catalog_binding_fingerprint(entry)
696 .with_live_authority(0, self.execution_binding_epoch(tool_name)))
697 }
698
699 fn resolve_execution_plan(
706 &self,
707 call: ToolCallView<'_>,
708 _dispatch_context: &ToolDispatchContext,
709 resolution_context: &crate::ToolExecutionResolutionContext,
710 ) -> Result<crate::ResolvedToolExecutionPlan, crate::ToolExecutionResolutionError> {
711 let catalog = self.tool_catalog();
712 let entry = catalog
713 .iter()
714 .find(|entry| entry.tool.name == call.name)
715 .ok_or_else(|| crate::ToolExecutionResolutionError::NotFound {
716 tool_name: call.name.to_string(),
717 })?;
718 if let Some(reason) = entry.callability.unavailable_reason() {
719 return Err(crate::ToolExecutionResolutionError::Unavailable {
720 tool_name: call.name.to_string(),
721 reason,
722 });
723 }
724 entry
725 .execution
726 .resolve_default(resolution_context.deadlines().clone())
727 .map_err(crate::ToolExecutionResolutionError::from)
728 }
729
730 fn validate_resolved_execution_plan(
737 &self,
738 call: ToolCallView<'_>,
739 resolution_context: &crate::ToolExecutionResolutionContext,
740 plan: &crate::ResolvedToolExecutionPlan,
741 ) -> Result<(), crate::ToolExecutionResolutionError> {
742 resolution_context.validate_resolved_plan(plan)?;
743 let catalog = self.tool_catalog();
744 let entry = catalog
745 .iter()
746 .find(|entry| entry.tool.name == call.name)
747 .ok_or_else(|| crate::ToolExecutionResolutionError::NotFound {
748 tool_name: call.name.to_string(),
749 })?;
750 if let Some(reason) = entry.callability.unavailable_reason() {
751 return Err(crate::ToolExecutionResolutionError::Unavailable {
752 tool_name: call.name.to_string(),
753 reason,
754 });
755 }
756 entry
757 .execution
758 .validate_resolved_plan(plan)
759 .map_err(crate::ToolExecutionResolutionError::from)
760 }
761
762 fn pending_catalog_sources(&self) -> Arc<[String]> {
767 Arc::from([])
768 }
769
770 async fn dispatch(
776 &self,
777 call: ToolCallView<'_>,
778 ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError>;
779
780 async fn dispatch_with_context(
786 &self,
787 call: ToolCallView<'_>,
788 _context: &ToolDispatchContext,
789 ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
790 self.dispatch(call).await
791 }
792
793 async fn dispatch_resolved_with_context(
800 &self,
801 call: ToolCallView<'_>,
802 context: &ToolDispatchContext,
803 plan: &crate::ResolvedToolExecutionPlan,
804 ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
805 match plan.mode() {
806 crate::ToolExecutionMode::Fast => self.dispatch_with_context(call, context).await,
807 crate::ToolExecutionMode::Streaming | crate::ToolExecutionMode::Detached => {
808 Err(crate::error::ToolError::unavailable(
809 call.name,
810 crate::ToolUnavailableReason::ExecutionModeOwnerUnavailable,
811 ))
812 }
813 }
814 }
815
816 async fn poll_external_updates(&self) -> ExternalToolUpdate {
822 ExternalToolUpdate::default()
823 }
824
825 fn external_tool_surface_snapshot(&self) -> Option<crate::ExternalToolSurfaceSnapshot> {
831 None
832 }
833
834 fn capabilities(&self) -> DispatcherCapabilities {
836 DispatcherCapabilities::default()
837 }
838
839 fn bind_ops_lifecycle(
847 self: Arc<Self>,
848 _registry: Arc<dyn crate::ops_lifecycle::OpsLifecycleRegistry>,
849 _owner_bridge_session_id: crate::types::SessionId,
850 ) -> Result<BindOutcome, OpsLifecycleBindError> {
851 Err(OpsLifecycleBindError::Unsupported)
852 }
853
854 fn completion_enrichment(
859 &self,
860 ) -> Option<Arc<dyn crate::completion_feed::CompletionEnrichmentProvider>> {
861 None
862 }
863
864 fn bind_mcp_server_lifecycle_handle(
871 &self,
872 _handle: Arc<dyn crate::handles::McpServerLifecycleHandle>,
873 ) {
874 }
875
876 fn bind_external_tool_surface_handle(
883 &self,
884 _handle: Arc<dyn crate::handles::ExternalToolSurfaceHandle>,
885 ) {
886 }
887}
888
889pub fn resolve_tool_execution_plan_fenced<T: AgentToolDispatcher + ?Sized + 'static>(
896 dispatcher: &Arc<T>,
897 call: ToolCallView<'_>,
898 dispatch_context: &ToolDispatchContext,
899 resolution_context: &crate::ToolExecutionResolutionContext,
900) -> Result<crate::ResolvedToolExecutionPlan, crate::ToolExecutionResolutionError> {
901 let before = dispatcher.execution_binding_fingerprint(call.name)?;
902 let plan = dispatcher.resolve_execution_plan(call, dispatch_context, resolution_context)?;
903 if dispatcher.execution_binding_fingerprint(call.name)? != before {
904 return Err(crate::ToolExecutionResolutionError::Unavailable {
905 tool_name: call.name.to_string(),
906 reason: crate::ToolUnavailableReason::ExecutionOwnerChanged,
907 });
908 }
909 let witness = crate::ToolExecutionOwnerWitness::new("root-dispatcher", call.name, before)
910 .map_err(crate::ToolExecutionResolutionError::from)?;
911 plan.with_owner_witness(witness)?
912 .bind_root_dispatch(Arc::clone(dispatcher), call)
913}
914
915pub async fn dispatch_tool_execution_plan_fenced<T: AgentToolDispatcher + ?Sized + 'static>(
918 dispatcher: &Arc<T>,
919 call: ToolCallView<'_>,
920 context: &ToolDispatchContext,
921 plan: &crate::ResolvedToolExecutionPlan,
922) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
923 plan.validate_root_dispatch(dispatcher, call)?;
924 let witness = plan.owner_witness("root-dispatcher").ok_or_else(|| {
925 crate::error::ToolError::unavailable(
926 call.name,
927 crate::ToolUnavailableReason::ExecutionOwnerChanged,
928 )
929 })?;
930 if witness.binding_fingerprint() != &dispatcher.execution_binding_fingerprint(call.name)? {
931 return Err(crate::error::ToolError::unavailable(
932 call.name,
933 crate::ToolUnavailableReason::ExecutionOwnerChanged,
934 ));
935 }
936 match plan.kind() {
937 crate::ResolvedExecutionKind::Streaming(policy) => {
938 let absolute_timeout = plan
939 .deadlines()
940 .effective_timeout()
941 .unwrap_or_else(|| policy.absolute_timeout());
942 crate::streaming_tool::supervise_streaming_tool(
943 call.name,
944 policy.inactivity_timeout(),
945 absolute_timeout,
946 |streaming| {
947 let streaming_context = context.clone().with_streaming(streaming);
948 async move {
949 dispatcher
950 .dispatch_resolved_with_context(call, &streaming_context, plan)
951 .await
952 }
953 },
954 )
955 .await
956 }
957 crate::ResolvedExecutionKind::Fast | crate::ResolvedExecutionKind::Detached(_) => {
958 dispatcher
959 .dispatch_resolved_with_context(call, context, plan)
960 .await
961 }
962 }
963}
964
965pub fn select_tool_catalog_mode<T>(dispatcher: &T) -> ToolCatalogMode
967where
968 T: AgentToolDispatcher + ?Sized,
969{
970 let capabilities = dispatcher.tool_catalog_capabilities();
971 if !capabilities.exact_catalog {
972 return ToolCatalogMode::Inline;
973 }
974 let pending_sources = dispatcher.pending_catalog_sources();
975 let catalog = dispatcher.tool_catalog();
976 select_catalog_mode_from_snapshot(
977 capabilities.exact_catalog,
978 catalog.as_ref(),
979 pending_sources.as_ref(),
980 )
981}
982
983pub fn should_compose_tool_catalog_control_plane<T>(dispatcher: &T) -> bool
986where
987 T: AgentToolDispatcher + ?Sized,
988{
989 let capabilities = dispatcher.tool_catalog_capabilities();
990 if !capabilities.exact_catalog {
991 return false;
992 }
993 if capabilities.may_require_catalog_control_plane {
994 return true;
995 }
996
997 let pending_sources = dispatcher.pending_catalog_sources();
998 if !pending_sources.is_empty() {
999 return true;
1000 }
1001
1002 let catalog = dispatcher.tool_catalog();
1003 deferred_session_entry_count(catalog.as_ref()) > 0
1004}
1005
1006#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
1008pub enum OpsLifecycleBindError {
1009 #[error("ops lifecycle binding is unsupported")]
1010 Unsupported,
1011 #[error("dispatcher has shared ownership and cannot be rebound")]
1012 SharedOwnership,
1013}
1014
1015pub struct FilteredToolDispatcher<T: AgentToolDispatcher + ?Sized> {
1022 inner: Arc<T>,
1023 allowed_tools: ToolNameSet,
1024 filtered_tools: Arc<[Arc<ToolDef>]>,
1026}
1027
1028impl<T: AgentToolDispatcher + ?Sized> FilteredToolDispatcher<T> {
1029 pub fn new<I, N>(inner: Arc<T>, allowed_tools: I) -> Self
1030 where
1031 I: IntoIterator<Item = N>,
1032 N: Into<ToolName>,
1033 {
1034 let allowed_set: ToolNameSet = allowed_tools
1035 .into_iter()
1036 .map(Into::into)
1037 .collect::<ToolNameSet>();
1038
1039 let filtered: Vec<Arc<ToolDef>> = if inner.tool_catalog_capabilities().exact_catalog {
1040 inner
1041 .tool_catalog()
1042 .iter()
1043 .filter(|entry| entry.currently_callable())
1044 .map(|entry| Arc::clone(&entry.tool))
1045 .filter(|t| allowed_set.contains(t.name.as_str()))
1046 .collect()
1047 } else {
1048 inner
1049 .tools()
1050 .iter()
1051 .filter(|t| allowed_set.contains(t.name.as_str()))
1052 .map(Arc::clone)
1053 .collect()
1054 };
1055
1056 Self {
1057 inner,
1058 allowed_tools: allowed_set,
1059 filtered_tools: filtered.into(),
1060 }
1061 }
1062}
1063
1064#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
1065#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
1066impl<T: AgentToolDispatcher + ?Sized + 'static> AgentToolDispatcher for FilteredToolDispatcher<T> {
1067 fn tools(&self) -> Arc<[Arc<ToolDef>]> {
1068 if self.inner.tool_catalog_capabilities().exact_catalog {
1069 return self
1070 .inner
1071 .tool_catalog()
1072 .iter()
1073 .filter(|entry| entry.currently_callable())
1074 .map(|entry| Arc::clone(&entry.tool))
1075 .filter(|tool| self.allowed_tools.contains(tool.name.as_str()))
1076 .collect::<Vec<_>>()
1077 .into();
1078 }
1079 Arc::clone(&self.filtered_tools)
1080 }
1081
1082 async fn dispatch(
1083 &self,
1084 call: ToolCallView<'_>,
1085 ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
1086 self.dispatch_with_context(call, &ToolDispatchContext::default())
1087 .await
1088 }
1089
1090 async fn dispatch_with_context(
1091 &self,
1092 call: ToolCallView<'_>,
1093 context: &ToolDispatchContext,
1094 ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
1095 if !self.allowed_tools.contains(call.name) {
1096 let inner_knows_tool = if self.inner.tool_catalog_capabilities().exact_catalog {
1097 self.inner
1098 .tool_catalog()
1099 .iter()
1100 .any(|entry| entry.tool.name == call.name)
1101 } else {
1102 self.inner.tools().iter().any(|tool| tool.name == call.name)
1103 };
1104 if !inner_knows_tool {
1105 return Err(crate::error::ToolError::not_found(call.name));
1106 }
1107 return Err(crate::error::ToolError::access_denied(call.name));
1108 }
1109 self.inner.dispatch_with_context(call, context).await
1110 }
1111
1112 async fn dispatch_resolved_with_context(
1113 &self,
1114 call: ToolCallView<'_>,
1115 context: &ToolDispatchContext,
1116 plan: &crate::ResolvedToolExecutionPlan,
1117 ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
1118 if !self.allowed_tools.contains(call.name) {
1119 let inner_knows_tool = if self.inner.tool_catalog_capabilities().exact_catalog {
1120 self.inner
1121 .tool_catalog()
1122 .iter()
1123 .any(|entry| entry.tool.name == call.name)
1124 } else {
1125 self.inner.tools().iter().any(|tool| tool.name == call.name)
1126 };
1127 if !inner_knows_tool {
1128 return Err(crate::error::ToolError::not_found(call.name));
1129 }
1130 return Err(crate::error::ToolError::access_denied(call.name));
1131 }
1132 self.inner
1133 .dispatch_resolved_with_context(call, context, plan)
1134 .await
1135 }
1136
1137 fn tool_catalog_capabilities(&self) -> ToolCatalogCapabilities {
1138 self.inner.tool_catalog_capabilities()
1139 }
1140
1141 fn tool_catalog(&self) -> Arc<[ToolCatalogEntry]> {
1142 if !self.inner.tool_catalog_capabilities().exact_catalog {
1143 return self
1144 .tools()
1145 .iter()
1146 .map(|tool| ToolCatalogEntry::session_inline(Arc::clone(tool), true))
1147 .collect::<Vec<_>>()
1148 .into();
1149 }
1150 self.inner
1151 .tool_catalog()
1152 .iter()
1153 .filter(|entry| self.allowed_tools.contains(entry.tool.name.as_str()))
1154 .cloned()
1155 .collect::<Vec<_>>()
1156 .into()
1157 }
1158
1159 fn execution_binding_fingerprint(
1160 &self,
1161 tool_name: &str,
1162 ) -> Result<crate::EphemeralToolBindingFingerprint, crate::ToolExecutionResolutionError> {
1163 let catalog = self.tool_catalog();
1164 let entry = catalog
1165 .iter()
1166 .find(|entry| entry.tool.name == tool_name)
1167 .ok_or_else(|| crate::ToolExecutionResolutionError::NotFound {
1168 tool_name: tool_name.to_string(),
1169 })?;
1170 let child = self.inner.execution_binding_fingerprint(tool_name)?;
1171 Ok(crate::ephemeral_tool_catalog_binding_fingerprint(entry)
1172 .with_live_authority(0, 0)
1173 .with_dependency(&child))
1174 }
1175
1176 fn resolve_execution_plan(
1177 &self,
1178 call: ToolCallView<'_>,
1179 dispatch_context: &ToolDispatchContext,
1180 resolution_context: &crate::ToolExecutionResolutionContext,
1181 ) -> Result<crate::ResolvedToolExecutionPlan, crate::ToolExecutionResolutionError> {
1182 if !self.allowed_tools.contains(call.name) {
1183 let inner_knows_tool = if self.inner.tool_catalog_capabilities().exact_catalog {
1184 self.inner
1185 .tool_catalog()
1186 .iter()
1187 .any(|entry| entry.tool.name == call.name)
1188 } else {
1189 self.inner.tools().iter().any(|tool| tool.name == call.name)
1190 };
1191 return Err(if inner_knows_tool {
1192 crate::ToolExecutionResolutionError::AccessDenied {
1193 tool_name: call.name.to_string(),
1194 }
1195 } else {
1196 crate::ToolExecutionResolutionError::NotFound {
1197 tool_name: call.name.to_string(),
1198 }
1199 });
1200 }
1201
1202 let catalog = self.tool_catalog();
1203 let entry = catalog
1204 .iter()
1205 .find(|entry| entry.tool.name == call.name)
1206 .ok_or_else(|| crate::ToolExecutionResolutionError::NotFound {
1207 tool_name: call.name.to_string(),
1208 })?;
1209 if let Some(reason) = entry.callability.unavailable_reason() {
1210 return Err(crate::ToolExecutionResolutionError::Unavailable {
1211 tool_name: call.name.to_string(),
1212 reason,
1213 });
1214 }
1215
1216 self.inner
1217 .resolve_execution_plan(call, dispatch_context, resolution_context)
1218 }
1219
1220 fn pending_catalog_sources(&self) -> Arc<[String]> {
1221 self.inner.pending_catalog_sources()
1222 }
1223
1224 async fn poll_external_updates(&self) -> ExternalToolUpdate {
1225 self.inner.poll_external_updates().await
1226 }
1227
1228 fn external_tool_surface_snapshot(&self) -> Option<crate::ExternalToolSurfaceSnapshot> {
1229 self.inner.external_tool_surface_snapshot()
1230 }
1231
1232 fn capabilities(&self) -> DispatcherCapabilities {
1233 self.inner.capabilities()
1234 }
1235
1236 fn bind_ops_lifecycle(
1237 self: Arc<Self>,
1238 registry: Arc<dyn crate::ops_lifecycle::OpsLifecycleRegistry>,
1239 owner_bridge_session_id: crate::types::SessionId,
1240 ) -> Result<BindOutcome, OpsLifecycleBindError> {
1241 let owned = Arc::try_unwrap(self).map_err(|_| OpsLifecycleBindError::SharedOwnership)?;
1242 if Arc::strong_count(&owned.inner) == 1 {
1243 let outcome = owned
1244 .inner
1245 .bind_ops_lifecycle(registry, owner_bridge_session_id)?;
1246 let bound = outcome.was_bound();
1247 let d = outcome.into_dispatcher();
1248 let allowed_tools = owned.allowed_tools.into_iter().collect::<Vec<_>>();
1249 Ok(if bound {
1250 BindOutcome::Bound(Arc::new(FilteredToolDispatcher::new(d, allowed_tools)))
1251 } else {
1252 BindOutcome::Skipped(Arc::new(FilteredToolDispatcher::new(d, allowed_tools)))
1253 })
1254 } else {
1255 Ok(BindOutcome::Skipped(Arc::new(FilteredToolDispatcher {
1256 inner: owned.inner,
1257 allowed_tools: owned.allowed_tools,
1258 filtered_tools: owned.filtered_tools,
1259 })))
1260 }
1261 }
1262
1263 fn completion_enrichment(
1264 &self,
1265 ) -> Option<Arc<dyn crate::completion_feed::CompletionEnrichmentProvider>> {
1266 self.inner.completion_enrichment()
1267 }
1268
1269 fn bind_mcp_server_lifecycle_handle(
1270 &self,
1271 handle: Arc<dyn crate::handles::McpServerLifecycleHandle>,
1272 ) {
1273 self.inner.bind_mcp_server_lifecycle_handle(handle);
1274 }
1275
1276 fn bind_external_tool_surface_handle(
1277 &self,
1278 handle: Arc<dyn crate::handles::ExternalToolSurfaceHandle>,
1279 ) {
1280 self.inner.bind_external_tool_surface_handle(handle);
1281 }
1282}
1283
1284#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
1286#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
1287pub trait AgentSessionStore: Send + Sync {
1288 async fn save(&self, session: &Session) -> Result<(), AgentError>;
1289 async fn load(&self, id: &str) -> Result<Option<Session>, AgentError>;
1290}
1291
1292#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1294pub enum InlinePeerNotificationPolicy {
1295 Always,
1297 Never,
1299 AtMost(usize),
1301}
1302
1303pub const DEFAULT_MAX_INLINE_PEER_NOTIFICATIONS: usize = 50;
1305
1306impl InlinePeerNotificationPolicy {
1307 pub fn try_from_raw(raw: Option<i32>) -> Result<Self, i32> {
1309 match raw {
1310 None => Ok(Self::AtMost(DEFAULT_MAX_INLINE_PEER_NOTIFICATIONS)),
1311 Some(-1) => Ok(Self::Always),
1312 Some(0) => Ok(Self::Never),
1313 Some(v) if v > 0 => Ok(Self::AtMost(v as usize)),
1314 Some(v) => Err(v),
1315 }
1316 }
1317}
1318
1319#[derive(Debug, thiserror::Error)]
1321pub enum CommsCapabilityError {
1322 #[error("comms capability not supported: {0}")]
1324 Unsupported(String),
1325}
1326
1327#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
1329#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
1330pub trait CommsRuntime: Send + Sync {
1331 fn peer_id(&self) -> Option<PeerId> {
1339 self.public_key()
1340 .as_deref()
1341 .and_then(|public_key| PeerId::parse(public_key).ok())
1342 }
1343
1344 fn public_key(&self) -> Option<String> {
1350 None
1351 }
1352
1353 fn public_key_bytes(&self) -> Option<[u8; 32]> {
1359 None
1360 }
1361
1362 fn comms_name(&self) -> Option<String> {
1368 None
1369 }
1370
1371 fn advertised_address(&self) -> Option<String> {
1377 None
1378 }
1379
1380 fn bridge_bootstrap_token(&self) -> Option<String> {
1383 None
1384 }
1385
1386 async fn apply_trust_mutation(
1391 &self,
1392 _mutation: CommsTrustMutation,
1393 ) -> Result<CommsTrustMutationResult, SendError> {
1394 Err(SendError::Unsupported(
1395 "apply_trust_mutation not supported for this CommsRuntime".to_string(),
1396 ))
1397 }
1398
1399 async fn install_generated_mob_trust_owner(
1406 &self,
1407 _owner: Arc<dyn std::any::Any + Send + Sync>,
1408 ) -> Result<(), SendError> {
1409 Err(SendError::Unsupported(
1410 "generated mob trust owner binding not supported for this CommsRuntime".to_string(),
1411 ))
1412 }
1413
1414 async fn validate_recovered_generated_mob_trust_owner(
1422 &self,
1423 _owner: Arc<dyn std::any::Any + Send + Sync>,
1424 ) -> Result<(), SendError> {
1425 Err(SendError::Unsupported(
1426 "recovered generated mob trust owner validation not supported for this CommsRuntime"
1427 .to_string(),
1428 ))
1429 }
1430
1431 async fn install_recovered_generated_mob_trust_owner(
1440 &self,
1441 _owner: Arc<dyn std::any::Any + Send + Sync>,
1442 ) -> Result<(), SendError> {
1443 Err(SendError::Unsupported(
1444 "recovered generated mob trust owner binding not supported for this CommsRuntime"
1445 .to_string(),
1446 ))
1447 }
1448
1449 fn host_acceptor_registration_payload(&self) -> Option<Arc<dyn std::any::Any + Send + Sync>> {
1462 None
1463 }
1464
1465 async fn add_private_trusted_peer(
1476 &self,
1477 _peer: TrustedPeerDescriptor,
1478 ) -> Result<(), SendError> {
1479 Err(SendError::Unsupported(
1480 "generated comms private trust mutation authority required".to_string(),
1481 ))
1482 }
1483
1484 async fn remove_private_trusted_peer(&self, _peer_id: &str) -> Result<bool, SendError> {
1489 Err(SendError::Unsupported(
1490 "generated comms private trust mutation authority required".to_string(),
1491 ))
1492 }
1493
1494 fn set_outbound_content_taint(
1512 &self,
1513 _taint: Option<crate::comms::SenderContentTaint>,
1514 ) -> Result<(), SendError> {
1515 Err(SendError::Unsupported(
1516 "outbound content-taint declaration not supported by this CommsRuntime".to_string(),
1517 ))
1518 }
1519
1520 async fn send(&self, _cmd: CommsCommand) -> Result<SendReceipt, SendError> {
1522 Err(SendError::Unsupported(
1523 "send not implemented for this CommsRuntime".to_string(),
1524 ))
1525 }
1526
1527 #[doc(hidden)]
1528 fn stream(&self, scope: StreamScope) -> Result<EventStream, StreamError> {
1529 let scope_desc = match scope {
1530 StreamScope::Session(session_id) => format!("session {session_id}"),
1531 StreamScope::Interaction(interaction_id) => format!("interaction {}", interaction_id.0),
1532 };
1533 Err(StreamError::NotFound(scope_desc))
1534 }
1535
1536 async fn peers(&self) -> Vec<PeerDirectoryEntry> {
1538 Vec::new()
1539 }
1540
1541 async fn peer_count(&self) -> usize {
1545 self.peers().await.len()
1546 }
1547
1548 #[doc(hidden)]
1549 async fn send_and_stream(
1550 &self,
1551 cmd: CommsCommand,
1552 ) -> Result<(SendReceipt, EventStream), SendAndStreamError> {
1553 let receipt = self.send(cmd).await?;
1554 Err(SendAndStreamError::StreamAttach {
1555 receipt,
1556 error: StreamError::Internal(
1557 "send_and_stream is not implemented for this runtime".to_string(),
1558 ),
1559 })
1560 }
1561
1562 async fn drain_messages(&self) -> Vec<String>;
1564 fn inbox_notify(&self) -> Arc<tokio::sync::Notify>;
1566 fn dismiss_received(&self) -> bool {
1568 false
1569 }
1570 fn event_injector(&self) -> Option<Arc<dyn crate::EventInjector>> {
1575 None
1576 }
1577
1578 #[doc(hidden)]
1580 fn interaction_event_injector(
1581 &self,
1582 ) -> Option<Arc<dyn crate::event_injector::SubscribableInjector>> {
1583 None
1584 }
1585
1586 async fn drain_inbox_interactions(&self) -> Vec<crate::interaction::InboxInteraction> {
1591 self.drain_messages()
1592 .await
1593 .into_iter()
1594 .map(|text| crate::interaction::InboxInteraction {
1595 objective_id: None,
1596 id: crate::interaction::InteractionId(uuid::Uuid::new_v4()),
1597 from_route: None,
1598 from: "unknown".into(),
1599 content: crate::interaction::InteractionContent::Message {
1600 body: text.clone(),
1601 blocks: None,
1602 },
1603 rendered_text: text,
1604 handling_mode: crate::types::HandlingMode::Queue,
1605 render_metadata: None,
1606 sender_taint: None,
1607 })
1608 .collect()
1609 }
1610
1611 fn interaction_subscriber(
1616 &self,
1617 _id: &crate::interaction::InteractionId,
1618 ) -> Option<tokio::sync::mpsc::Sender<crate::event::AgentEvent>> {
1619 None
1620 }
1621
1622 fn take_interaction_stream_sender(
1624 &self,
1625 _id: &crate::interaction::InteractionId,
1626 ) -> Option<tokio::sync::mpsc::Sender<crate::event::AgentEvent>> {
1627 self.interaction_subscriber(_id)
1628 }
1629
1630 fn mark_interaction_complete(&self, _id: &crate::interaction::InteractionId) {}
1636
1637 fn abandon_interaction_stream(
1642 &self,
1643 _id: &crate::interaction::InteractionId,
1644 _reason: crate::InteractionStreamAbandonReason,
1645 ) {
1646 }
1647
1648 fn peer_interaction_handle(
1654 &self,
1655 ) -> Option<std::sync::Arc<dyn crate::handles::PeerInteractionHandle>> {
1656 None
1657 }
1658
1659 fn peer_request_response_authority_handle(
1668 &self,
1669 ) -> Option<std::sync::Arc<dyn crate::handles::PeerInteractionHandle>> {
1670 None
1671 }
1672
1673 async fn drain_classified_inbox_interactions(
1681 &self,
1682 ) -> Result<Vec<crate::interaction::ClassifiedInboxInteraction>, CommsCapabilityError> {
1683 Err(CommsCapabilityError::Unsupported(
1684 "drain_classified_inbox_interactions".to_string(),
1685 ))
1686 }
1687
1688 async fn drain_peer_input_candidates(&self) -> Vec<crate::interaction::PeerInputCandidate> {
1695 self.drain_classified_inbox_interactions()
1696 .await
1697 .unwrap_or_default()
1698 }
1699
1700 async fn peer_ingress_queue_snapshot(
1705 &self,
1706 ) -> Result<crate::interaction::PeerIngressQueueSnapshot, CommsCapabilityError> {
1707 Err(CommsCapabilityError::Unsupported(
1708 "peer_ingress_queue_snapshot".to_string(),
1709 ))
1710 }
1711
1712 async fn peer_ingress_runtime_snapshot(
1717 &self,
1718 ) -> Result<crate::interaction::PeerIngressRuntimeSnapshot, CommsCapabilityError> {
1719 Err(CommsCapabilityError::Unsupported(
1720 "peer_ingress_runtime_snapshot".to_string(),
1721 ))
1722 }
1723
1724 async fn public_trusted_peer_projection_snapshot(
1731 &self,
1732 ) -> Result<Vec<crate::comms::TrustedPeerDescriptor>, CommsCapabilityError> {
1733 Err(CommsCapabilityError::Unsupported(
1734 "public_trusted_peer_projection_snapshot".to_string(),
1735 ))
1736 }
1737
1738 async fn trusted_peer_projection_snapshot_for_source(
1745 &self,
1746 _source_kind: crate::comms::GeneratedCommsTrustAuthoritySourceKind,
1747 ) -> Result<Vec<crate::comms::TrustedPeerDescriptor>, CommsCapabilityError> {
1748 Err(CommsCapabilityError::Unsupported(
1749 "trusted_peer_projection_snapshot_for_source".to_string(),
1750 ))
1751 }
1752
1753 fn actionable_input_notify(&self) -> Result<Arc<tokio::sync::Notify>, CommsCapabilityError> {
1758 Err(CommsCapabilityError::Unsupported(
1759 "actionable_input_notify".to_string(),
1760 ))
1761 }
1762
1763 async fn stage_declared_reply_endpoint(
1780 &self,
1781 _dest: PeerId,
1782 _signer_pubkey: [u8; 32],
1783 _declared_address: String,
1784 ) -> Result<(), SendError> {
1785 Err(SendError::Unsupported(
1786 "declared reply endpoint staging not supported".to_string(),
1787 ))
1788 }
1789
1790 async fn stage_correlated_reply_endpoint(
1804 &self,
1805 _dest: PeerId,
1806 _in_reply_to: crate::interaction::InteractionId,
1807 _signer_pubkey: [u8; 32],
1808 _declared_endpoint: crate::comms::PeerAddress,
1809 ) -> Result<(), SendError> {
1810 Err(SendError::Unsupported(
1811 "correlated reply endpoint staging not supported".to_string(),
1812 ))
1813 }
1814
1815 async fn unstage_correlated_reply_endpoint(
1819 &self,
1820 _dest: PeerId,
1821 _in_reply_to: crate::interaction::InteractionId,
1822 ) -> Result<(), SendError> {
1823 Err(SendError::Unsupported(
1824 "correlated reply endpoint cleanup not supported".to_string(),
1825 ))
1826 }
1827
1828 fn take_bridge_reply_waiter(
1840 &self,
1841 _in_reply_to: &crate::interaction::InteractionId,
1842 ) -> Option<tokio::sync::oneshot::Sender<crate::interaction::PeerInputCandidate>> {
1843 None
1844 }
1845
1846 fn has_bridge_reply_waiter(&self, _in_reply_to: &crate::interaction::InteractionId) -> bool {
1849 false
1850 }
1851}
1852
1853pub struct Agent<C, T, S>
1855where
1856 C: AgentLlmClient + ?Sized,
1857 T: AgentToolDispatcher + ?Sized,
1858 S: AgentSessionStore + ?Sized,
1859{
1860 config: AgentConfig,
1861 client: Arc<C>,
1862 tools: Arc<T>,
1863 tool_scope: ToolScope,
1864 store: Arc<S>,
1865 session: Session,
1866 budget: Budget,
1867 retry_policy: RetryPolicy,
1868 depth: u32,
1869 pub(super) comms_runtime: Option<Arc<dyn CommsRuntime>>,
1870 pub(super) hook_engine: Option<Arc<dyn HookEngine>>,
1871 pub(super) hook_run_overrides: HookRunOverrides,
1872 pub(crate) compactor: Option<Arc<dyn crate::compact::Compactor>>,
1874 pub(crate) compaction_curator: Option<Arc<dyn crate::compact::CompactionCurator>>,
1877 pub(crate) last_input_tokens: u64,
1879 pub(crate) compaction_cadence: SessionCompactionCadence,
1881 pub(crate) memory_store: Option<Arc<dyn crate::memory::MemoryStore>>,
1883 pub(crate) compaction_commit_coordinator:
1886 Option<Arc<dyn crate::memory::CompactionCommitCoordinator>>,
1887 pub(crate) compaction_transaction: Option<CompactionTransaction>,
1892 pub(crate) in_flight_compaction_stage: Option<crate::memory::CompactionProjectionId>,
1897 pub(crate) skill_engine: Option<Arc<crate::skills::SkillRuntime>>,
1899 pub pending_skill_references: Option<Vec<crate::skills::SkillKey>>,
1902 pub(crate) event_tap: crate::event_tap::EventTap,
1904 pub(crate) system_context_state: crate::session::SystemContextStateHandle,
1906 pub(crate) default_event_tx: Option<tokio::sync::mpsc::Sender<crate::event::AgentEvent>>,
1909 pub(crate) checkpointer: Option<Arc<dyn crate::checkpoint::SessionCheckpointer>>,
1915 pub(crate) blob_store: Option<Arc<dyn crate::BlobStore>>,
1917 pub(crate) terminal_error_detail: Option<String>,
1921 pub(crate) terminal_error_metadata: Option<crate::TurnErrorMetadata>,
1924 pub(crate) run_completed_hooks_applied: bool,
1926 pub(crate) run_completed_event_emitted: bool,
1929 #[allow(dead_code)] pub(crate) silent_comms_intents: Vec<String>,
1933 pub(crate) ops_lifecycle: Option<Arc<dyn crate::ops_lifecycle::OpsLifecycleRegistry>>,
1935 pub(crate) completion_feed: Option<Arc<dyn crate::completion_feed::CompletionFeed>>,
1937 pub(crate) epoch_cursor_state: Option<Arc<crate::runtime_epoch::EpochCursorState>>,
1939 pub(crate) applied_cursor: crate::completion_feed::CompletionSeq,
1941 pub(crate) completion_enrichment:
1943 Option<Arc<dyn crate::completion_feed::CompletionEnrichmentProvider>>,
1944 pub(crate) mob_authority_handle:
1949 Option<Arc<std::sync::RwLock<crate::service::MobToolAuthorityContext>>>,
1950 pub(crate) turn_state_handle: Option<Arc<dyn crate::TurnStateHandle>>,
1952 pub(crate) model_routing_handle: Option<Arc<dyn crate::handles::ModelRoutingHandle>>,
1955 pub(crate) sticky_model_fallback_commit_coordinator:
1959 Option<Arc<dyn crate::handles::StickyModelFallbackCommitCoordinator>>,
1960 pub(crate) pending_sticky_model_fallback_activation:
1963 Option<state::PendingStickyModelFallbackActivation>,
1964 pub(crate) pending_callback_async_ops: Option<Vec<crate::ops::AsyncOpRef>>,
1968 pub(crate) effective_model_registry: Option<Arc<crate::ModelRegistry>>,
1972 pub(crate) active_model_profile: Option<crate::ModelProfileWitness>,
1975 pub(crate) runtime_execution_kind_required: bool,
1977 pub(crate) runtime_execution_kind: Option<crate::lifecycle::RuntimeExecutionKind>,
1980 pub(crate) runtime_started_run_id: Option<crate::lifecycle::RunId>,
1985 pub(crate) runtime_terminal_failure_witness:
1990 Option<Result<crate::TurnErrorMetadata, crate::error::AgentError>>,
1991 pub(crate) active_transcript_identity: Option<crate::types::TranscriptMessageIdentity>,
1993 pub(crate) external_tool_surface_handle: Option<Arc<dyn crate::ExternalToolSurfaceHandle>>,
1996 pub(crate) auth_lease_handle: Option<crate::handles::GeneratedAuthLeaseHandle>,
1998 pub(crate) mcp_server_lifecycle_handle:
2002 Option<Arc<dyn crate::handles::McpServerLifecycleHandle>>,
2003 pub(crate) cancel_after_boundary_tx: CancelAfterBoundarySender,
2010 pub(crate) cancel_after_boundary_rx:
2018 tokio::sync::mpsc::UnboundedReceiver<CancelAfterBoundaryCommand>,
2019 pub(crate) model_defaults_resolver:
2022 Option<Arc<dyn crate::model_defaults::ModelOperationalDefaultsResolver>>,
2023 pub(crate) call_timeout_override: crate::config::CallTimeoutOverride,
2026 pub(crate) extraction_state: extraction::ExtractionState,
2028 pub(crate) last_hidden_deferred_catalog_names: BTreeSet<crate::types::ToolName>,
2030 pub(crate) last_pending_catalog_sources: BTreeSet<String>,
2032 pub(crate) tool_dispatch_context: ToolDispatchContext,
2034 pub(crate) turn_tool_dispatch_metadata: BTreeMap<String, serde_json::Value>,
2036 pub(crate) tools_config: crate::config::ToolsConfig,
2041}
2042
2043#[derive(Clone)]
2044pub(crate) struct CompactionRollbackState {
2045 pub(crate) rollback_session: Session,
2046 pub(crate) rollback_last_input_tokens: u64,
2047 pub(crate) rollback_compaction_cadence: SessionCompactionCadence,
2048}
2049
2050pub(crate) enum CompactionTransactionPhase {
2051 AwaitingRuntimeCommit(Box<CompactionRollbackState>),
2052 RuntimeCommitted { bookkeeping_complete: bool },
2053 AbortPending { cadence_persist_pending: bool },
2054}
2055
2056pub(crate) struct CompactionTransaction {
2057 pub(crate) phase: CompactionTransactionPhase,
2058 pub(crate) projections: Vec<crate::memory::CompactionProjectionId>,
2059}
2060
2061#[cfg(test)]
2062#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
2063mod tests {
2064 use super::{
2065 AgentToolDispatcher, CommsRuntime, DEFAULT_MAX_INLINE_PEER_NOTIFICATIONS,
2066 FilteredToolDispatcher, InlinePeerNotificationPolicy, ToolDispatchContext,
2067 };
2068 use crate::comms::{
2069 PeerAddress, PeerId, PeerName, PeerTransport, SendError, TrustedPeerDescriptor,
2070 };
2071 use crate::types::{ContentBlock, ContentInput, ToolCallView, ToolDef, ToolResult};
2072 use async_trait::async_trait;
2073 use serde_json::json;
2074 use std::sync::Arc;
2075 use tokio::sync::Notify;
2076
2077 struct NoopCommsRuntime {
2078 notify: Arc<Notify>,
2079 }
2080
2081 struct ContextAwareToolDispatcher;
2082
2083 struct ExactExecutionDispatcher {
2084 catalog: Arc<[crate::ToolCatalogEntry]>,
2085 }
2086
2087 struct HybridExecutionDispatcher {
2088 catalog: Arc<[crate::ToolCatalogEntry]>,
2089 }
2090
2091 struct StreamingExecutionDispatcher {
2092 catalog: Arc<[crate::ToolCatalogEntry]>,
2093 saw_streaming_context: Arc<std::sync::atomic::AtomicBool>,
2094 }
2095
2096 struct IdenticalMutationDispatcher {
2097 tool: ToolDef,
2098 epoch: std::sync::atomic::AtomicU64,
2099 mutate_on_resolve: bool,
2100 }
2101
2102 #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
2103 #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
2104 impl AgentToolDispatcher for ContextAwareToolDispatcher {
2105 fn tools(&self) -> Arc<[Arc<ToolDef>]> {
2106 Arc::from([Arc::new(ToolDef {
2107 name: "inspect_context".into(),
2108 description: "inspect context".to_string(),
2109 input_schema: json!({"type": "object"}),
2110 provenance: None,
2111 })])
2112 }
2113
2114 async fn dispatch(
2115 &self,
2116 call: ToolCallView<'_>,
2117 ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
2118 Ok(ToolResult::new(
2119 call.id.to_string(),
2120 json!({"saw_context_image": false}).to_string(),
2121 false,
2122 )
2123 .into())
2124 }
2125
2126 async fn dispatch_with_context(
2127 &self,
2128 call: ToolCallView<'_>,
2129 context: &ToolDispatchContext,
2130 ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
2131 let saw_context_image = context
2132 .current_turn()
2133 .and_then(|turn| turn.image_ref(0))
2134 .and_then(|image_ref| context.current_turn_image(image_ref))
2135 .is_some();
2136 Ok(ToolResult::new(
2137 call.id.to_string(),
2138 json!({"saw_context_image": saw_context_image}).to_string(),
2139 false,
2140 )
2141 .into())
2142 }
2143 }
2144
2145 #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
2146 #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
2147 impl AgentToolDispatcher for ExactExecutionDispatcher {
2148 fn tools(&self) -> Arc<[Arc<ToolDef>]> {
2149 self.catalog
2150 .iter()
2151 .filter(|entry| entry.currently_callable())
2152 .map(|entry| Arc::clone(&entry.tool))
2153 .collect::<Vec<_>>()
2154 .into()
2155 }
2156
2157 fn tool_catalog_capabilities(&self) -> crate::ToolCatalogCapabilities {
2158 crate::ToolCatalogCapabilities {
2159 exact_catalog: true,
2160 may_require_catalog_control_plane: false,
2161 }
2162 }
2163
2164 fn tool_catalog(&self) -> Arc<[crate::ToolCatalogEntry]> {
2165 Arc::clone(&self.catalog)
2166 }
2167
2168 async fn dispatch(
2169 &self,
2170 call: ToolCallView<'_>,
2171 ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
2172 Ok(ToolResult::new(call.id.to_string(), "ok".to_string(), false).into())
2173 }
2174 }
2175
2176 #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
2177 #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
2178 impl AgentToolDispatcher for HybridExecutionDispatcher {
2179 fn tools(&self) -> Arc<[Arc<ToolDef>]> {
2180 self.catalog
2181 .iter()
2182 .filter(|entry| entry.currently_callable())
2183 .map(|entry| Arc::clone(&entry.tool))
2184 .collect::<Vec<_>>()
2185 .into()
2186 }
2187
2188 fn tool_catalog_capabilities(&self) -> crate::ToolCatalogCapabilities {
2189 crate::ToolCatalogCapabilities {
2190 exact_catalog: true,
2191 may_require_catalog_control_plane: false,
2192 }
2193 }
2194
2195 fn tool_catalog(&self) -> Arc<[crate::ToolCatalogEntry]> {
2196 Arc::clone(&self.catalog)
2197 }
2198
2199 fn resolve_execution_plan(
2200 &self,
2201 call: ToolCallView<'_>,
2202 _dispatch_context: &ToolDispatchContext,
2203 resolution_context: &crate::ToolExecutionResolutionContext,
2204 ) -> Result<crate::ResolvedToolExecutionPlan, crate::ToolExecutionResolutionError> {
2205 let entry = self
2206 .catalog
2207 .iter()
2208 .find(|entry| entry.tool.name == call.name)
2209 .ok_or_else(|| crate::ToolExecutionResolutionError::NotFound {
2210 tool_name: call.name.to_string(),
2211 })?;
2212 let arguments: serde_json::Value =
2213 serde_json::from_str(call.args.get()).map_err(|error| {
2214 crate::ToolExecutionResolutionError::InvalidArguments {
2215 tool_name: call.name.to_string(),
2216 reason: error.to_string(),
2217 }
2218 })?;
2219 let mode = if arguments["run_detached"] == true {
2220 crate::ToolExecutionMode::Detached
2221 } else {
2222 crate::ToolExecutionMode::Fast
2223 };
2224 entry
2225 .execution
2226 .resolve(mode, resolution_context.deadlines().clone())
2227 .map_err(crate::ToolExecutionResolutionError::from)
2228 }
2229
2230 async fn dispatch(
2231 &self,
2232 call: ToolCallView<'_>,
2233 ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
2234 Ok(ToolResult::new(
2235 call.id.to_string(),
2236 json!({"owner": "filtered-hybrid-owner"}).to_string(),
2237 false,
2238 )
2239 .into())
2240 }
2241
2242 async fn dispatch_resolved_with_context(
2243 &self,
2244 call: ToolCallView<'_>,
2245 _context: &ToolDispatchContext,
2246 plan: &crate::ResolvedToolExecutionPlan,
2247 ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
2248 if plan.mode() != crate::ToolExecutionMode::Detached {
2249 return Err(crate::ToolError::execution_failed(
2250 "test detached owner received the wrong plan",
2251 ));
2252 }
2253 self.dispatch(call).await
2254 }
2255 }
2256
2257 #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
2258 #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
2259 impl AgentToolDispatcher for StreamingExecutionDispatcher {
2260 fn tools(&self) -> Arc<[Arc<ToolDef>]> {
2261 self.catalog
2262 .iter()
2263 .map(|entry| Arc::clone(&entry.tool))
2264 .collect::<Vec<_>>()
2265 .into()
2266 }
2267
2268 fn tool_catalog_capabilities(&self) -> crate::ToolCatalogCapabilities {
2269 crate::ToolCatalogCapabilities {
2270 exact_catalog: true,
2271 may_require_catalog_control_plane: false,
2272 }
2273 }
2274
2275 fn tool_catalog(&self) -> Arc<[crate::ToolCatalogEntry]> {
2276 Arc::clone(&self.catalog)
2277 }
2278
2279 async fn dispatch(
2280 &self,
2281 call: ToolCallView<'_>,
2282 ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
2283 Err(crate::ToolError::unavailable(
2284 call.name,
2285 crate::ToolUnavailableReason::ExecutionModeOwnerUnavailable,
2286 ))
2287 }
2288
2289 async fn dispatch_resolved_with_context(
2290 &self,
2291 call: ToolCallView<'_>,
2292 context: &ToolDispatchContext,
2293 plan: &crate::ResolvedToolExecutionPlan,
2294 ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
2295 if plan.mode() != crate::ToolExecutionMode::Streaming {
2296 return Err(crate::ToolError::execution_failed(
2297 "streaming owner received a non-streaming plan",
2298 ));
2299 }
2300 let streaming = context.streaming().ok_or_else(|| {
2301 crate::ToolError::unavailable(
2302 call.name,
2303 crate::ToolUnavailableReason::ExecutionModeOwnerUnavailable,
2304 )
2305 })?;
2306 streaming
2307 .progress()
2308 .try_report(
2309 crate::ToolProgressFrame::message("accepted through wrapper")
2310 .map_err(|error| crate::ToolError::other(error.to_string()))?,
2311 )
2312 .map_err(|error| crate::ToolError::other(error.to_string()))?;
2313 self.saw_streaming_context
2314 .store(true, std::sync::atomic::Ordering::SeqCst);
2315 Ok(ToolResult::new(call.id.to_string(), "stream complete".to_string(), false).into())
2316 }
2317 }
2318
2319 #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
2320 #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
2321 impl AgentToolDispatcher for IdenticalMutationDispatcher {
2322 fn tools(&self) -> Arc<[Arc<ToolDef>]> {
2323 Arc::from([Arc::new(self.tool.clone())])
2324 }
2325
2326 fn tool_catalog_capabilities(&self) -> crate::ToolCatalogCapabilities {
2327 crate::ToolCatalogCapabilities {
2328 exact_catalog: true,
2329 may_require_catalog_control_plane: false,
2330 }
2331 }
2332
2333 fn tool_catalog(&self) -> Arc<[crate::ToolCatalogEntry]> {
2334 Arc::from([crate::ToolCatalogEntry::session_inline(
2335 Arc::new(self.tool.clone()),
2336 true,
2337 )])
2338 }
2339
2340 fn execution_binding_epoch(&self, _tool_name: &str) -> u64 {
2341 self.epoch.load(std::sync::atomic::Ordering::SeqCst)
2342 }
2343
2344 fn resolve_execution_plan(
2345 &self,
2346 _call: ToolCallView<'_>,
2347 _dispatch_context: &ToolDispatchContext,
2348 resolution_context: &crate::ToolExecutionResolutionContext,
2349 ) -> Result<crate::ResolvedToolExecutionPlan, crate::ToolExecutionResolutionError> {
2350 let plan = crate::ToolExecutionContract::default()
2351 .resolve_default(resolution_context.deadlines().clone())
2352 .map_err(crate::ToolExecutionResolutionError::from)?;
2353 if self.mutate_on_resolve {
2354 self.epoch.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2355 }
2356 Ok(plan)
2357 }
2358
2359 async fn dispatch(
2360 &self,
2361 call: ToolCallView<'_>,
2362 ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
2363 Ok(ToolResult::new(call.id.to_string(), "ok".to_string(), false).into())
2364 }
2365 }
2366
2367 #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
2368 #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
2369 impl CommsRuntime for NoopCommsRuntime {
2370 async fn drain_messages(&self) -> Vec<String> {
2371 Vec::new()
2372 }
2373
2374 fn inbox_notify(&self) -> std::sync::Arc<Notify> {
2375 self.notify.clone()
2376 }
2377 }
2378
2379 #[tokio::test]
2380 async fn test_comms_runtime_trait_defaults_hide_unimplemented_features() {
2381 let runtime = NoopCommsRuntime {
2382 notify: Arc::new(Notify::new()),
2383 };
2384 assert!(<NoopCommsRuntime as CommsRuntime>::public_key(&runtime).is_none());
2385 let peer = TrustedPeerDescriptor {
2388 peer_id: PeerId::new(),
2389 name: PeerName::new("peer-a").expect("valid peer name"),
2390 address: PeerAddress::new(PeerTransport::Inproc, "peer-a"),
2391 pubkey: [0u8; 32],
2392 };
2393 let result =
2394 <NoopCommsRuntime as CommsRuntime>::add_private_trusted_peer(&runtime, peer).await;
2395 assert!(matches!(result, Err(SendError::Unsupported(_))));
2396 }
2397
2398 #[tokio::test]
2404 async fn test_comms_runtime_bridge_reply_defaults() {
2405 let runtime = NoopCommsRuntime {
2406 notify: Arc::new(Notify::new()),
2407 };
2408 let interaction_id = crate::interaction::InteractionId(uuid::Uuid::new_v4());
2409 assert!(
2410 <NoopCommsRuntime as CommsRuntime>::take_bridge_reply_waiter(&runtime, &interaction_id)
2411 .is_none()
2412 );
2413 assert!(
2414 !<NoopCommsRuntime as CommsRuntime>::has_bridge_reply_waiter(&runtime, &interaction_id)
2415 );
2416 let staged = <NoopCommsRuntime as CommsRuntime>::stage_declared_reply_endpoint(
2417 &runtime,
2418 PeerId::new(),
2419 [0x11u8; 32],
2420 "tcp://127.0.0.1:1".to_string(),
2421 )
2422 .await;
2423 assert!(matches!(staged, Err(SendError::Unsupported(_))));
2424 }
2425
2426 #[tokio::test]
2427 async fn filtered_tool_dispatcher_preserves_dispatch_context() {
2428 let dispatcher =
2429 FilteredToolDispatcher::new(Arc::new(ContextAwareToolDispatcher), ["inspect_context"]);
2430 let args = serde_json::value::RawValue::from_string("{}".to_string())
2431 .expect("empty object should be valid JSON");
2432 let call = ToolCallView {
2433 id: "ctx-1",
2434 name: "inspect_context",
2435 args: &args,
2436 };
2437 let context = ToolDispatchContext::from_current_turn_input(&ContentInput::Blocks(vec![
2438 ContentBlock::Image {
2439 media_type: "image/png".to_string(),
2440 data: "abc".into(),
2441 },
2442 ]));
2443
2444 let outcome = dispatcher
2445 .dispatch_with_context(call, &context)
2446 .await
2447 .expect("filtered wrapper should dispatch");
2448 let payload: serde_json::Value =
2449 serde_json::from_str(&outcome.result.text_content()).expect("tool result JSON");
2450 assert_eq!(payload["saw_context_image"], true);
2451 }
2452
2453 #[test]
2454 fn default_execution_plan_resolver_uses_exact_catalog_contract() {
2455 use crate::{
2456 DetachedToolExecutionPolicy, IdempotencyScope, RestartClass, RunnerIdentity,
2457 ToolDeadlineChain, ToolDeadlineContributor, ToolDeadlineOwner, ToolExecutionContract,
2458 ToolExecutionMode, ToolExecutionResolutionContext,
2459 };
2460 use std::collections::BTreeSet;
2461 use std::time::Duration;
2462
2463 let detached = DetachedToolExecutionPolicy::new(
2464 RunnerIdentity::new("homecore.security_scan", "v1").unwrap(),
2465 RestartClass::NonResumable,
2466 IdempotencyScope::InteractionAndArguments,
2467 Duration::from_secs(10),
2468 )
2469 .unwrap();
2470 let contract = ToolExecutionContract::new(
2471 BTreeSet::from([ToolExecutionMode::Detached]),
2472 ToolExecutionMode::Detached,
2473 None,
2474 Some(detached),
2475 )
2476 .unwrap();
2477 let dispatcher = ExactExecutionDispatcher {
2478 catalog: Arc::from([crate::ToolCatalogEntry::session_inline(
2479 Arc::new(ToolDef::new(
2480 "security_scan",
2481 "scan",
2482 json!({"type": "object"}),
2483 )),
2484 true,
2485 )
2486 .with_execution_contract(contract)]),
2487 };
2488 let args = serde_json::value::RawValue::from_string("{}".to_string()).unwrap();
2489 let call = ToolCallView {
2490 id: "call-1",
2491 name: "security_scan",
2492 args: &args,
2493 };
2494 let resolution = ToolExecutionResolutionContext::new(
2495 ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
2496 ToolDeadlineOwner::CoreToolDispatch,
2497 Duration::from_secs(600),
2498 )])
2499 .unwrap(),
2500 );
2501
2502 let plan = dispatcher
2503 .resolve_execution_plan(call, &ToolDispatchContext::default(), &resolution)
2504 .expect("declared plan resolves");
2505
2506 assert_eq!(plan.mode(), ToolExecutionMode::Detached);
2507 assert_eq!(
2508 plan.deadlines().effective_timeout(),
2509 Some(Duration::from_secs(10))
2510 );
2511 assert_eq!(
2512 plan.deadlines().winner().map(|winner| winner.owner()),
2513 Some(ToolDeadlineOwner::DetachedSubmission)
2514 );
2515 }
2516
2517 #[tokio::test]
2518 async fn default_resolved_dispatch_refuses_detached_plan_before_ordinary_dispatch() {
2519 use crate::{
2520 DetachedToolExecutionPolicy, IdempotencyScope, RestartClass, RunnerIdentity,
2521 ToolDeadlineChain, ToolDeadlineContributor, ToolDeadlineOwner, ToolExecutionContract,
2522 ToolExecutionMode, ToolExecutionResolutionContext,
2523 };
2524 use std::collections::BTreeSet;
2525 use std::time::Duration;
2526
2527 let detached = DetachedToolExecutionPolicy::new(
2528 RunnerIdentity::new("detached.owner", "v1").unwrap(),
2529 RestartClass::NonResumable,
2530 IdempotencyScope::ToolCall,
2531 Duration::from_secs(10),
2532 )
2533 .unwrap();
2534 let contract = ToolExecutionContract::new(
2535 BTreeSet::from([ToolExecutionMode::Detached]),
2536 ToolExecutionMode::Detached,
2537 None,
2538 Some(detached),
2539 )
2540 .unwrap();
2541 let dispatcher = ExactExecutionDispatcher {
2542 catalog: Arc::from([crate::ToolCatalogEntry::session_inline(
2543 Arc::new(ToolDef::new(
2544 "security_scan",
2545 "scan",
2546 json!({"type": "object"}),
2547 )),
2548 true,
2549 )
2550 .with_execution_contract(contract)]),
2551 };
2552 let args = serde_json::value::RawValue::from_string("{}".to_string()).unwrap();
2553 let call = ToolCallView {
2554 id: "detached-call",
2555 name: "security_scan",
2556 args: &args,
2557 };
2558 let context = ToolDispatchContext::default();
2559 let resolution = ToolExecutionResolutionContext::new(
2560 ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
2561 ToolDeadlineOwner::CoreToolDispatch,
2562 Duration::from_secs(600),
2563 )])
2564 .unwrap(),
2565 );
2566 let plan = dispatcher
2567 .resolve_execution_plan(call, &context, &resolution)
2568 .expect("detached plan resolves");
2569
2570 let error = dispatcher
2571 .dispatch_resolved_with_context(call, &context, &plan)
2572 .await
2573 .expect_err("the default dispatcher must not lower detached work to dispatch()");
2574
2575 assert!(matches!(
2576 error,
2577 crate::ToolError::Unavailable {
2578 reason: crate::ToolUnavailableReason::ExecutionModeOwnerUnavailable,
2579 ..
2580 }
2581 ));
2582 }
2583
2584 #[tokio::test]
2585 async fn fenced_streaming_dispatch_mints_context_and_filtered_wrapper_preserves_it() {
2586 use crate::{
2587 StreamingToolExecutionPolicy, ToolDeadlineChain, ToolDeadlineContributor,
2588 ToolDeadlineOwner, ToolExecutionContract, ToolExecutionMode,
2589 ToolExecutionResolutionContext,
2590 };
2591 use std::collections::BTreeSet;
2592 use std::time::Duration;
2593
2594 let contract = ToolExecutionContract::new(
2595 BTreeSet::from([ToolExecutionMode::Streaming]),
2596 ToolExecutionMode::Streaming,
2597 Some(
2598 StreamingToolExecutionPolicy::new(Duration::from_secs(5), Duration::from_secs(30))
2599 .unwrap(),
2600 ),
2601 None,
2602 )
2603 .unwrap();
2604 let saw_streaming_context = Arc::new(std::sync::atomic::AtomicBool::new(false));
2605 let owner = StreamingExecutionDispatcher {
2606 catalog: Arc::from([crate::ToolCatalogEntry::session_inline(
2607 Arc::new(ToolDef::new(
2608 "stream_scan",
2609 "stream scan",
2610 json!({"type": "object"}),
2611 )),
2612 true,
2613 )
2614 .with_execution_contract(contract)]),
2615 saw_streaming_context: Arc::clone(&saw_streaming_context),
2616 };
2617 let dispatcher = Arc::new(FilteredToolDispatcher::new(
2618 Arc::new(owner),
2619 ["stream_scan"],
2620 ));
2621 let filtered_catalog = dispatcher.tool_catalog();
2622 assert_eq!(
2623 filtered_catalog[0].execution.default_mode(),
2624 ToolExecutionMode::Streaming
2625 );
2626 let filtered_policy = filtered_catalog[0]
2627 .execution
2628 .streaming_policy()
2629 .expect("wrapper preserves the streaming registration");
2630 assert_eq!(filtered_policy.inactivity_timeout(), Duration::from_secs(5));
2631 assert_eq!(filtered_policy.absolute_timeout(), Duration::from_secs(30));
2632 let args = serde_json::value::RawValue::from_string("{}".to_string()).unwrap();
2633 let call = ToolCallView {
2634 id: "stream-call",
2635 name: "stream_scan",
2636 args: &args,
2637 };
2638 let context = ToolDispatchContext::default();
2639 assert!(
2640 context.streaming().is_none(),
2641 "callers cannot pre-mint the supervised streaming context"
2642 );
2643 let resolution = ToolExecutionResolutionContext::new(
2644 ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
2645 ToolDeadlineOwner::CoreToolDispatch,
2646 Duration::from_secs(60),
2647 )])
2648 .unwrap(),
2649 );
2650 let plan =
2651 crate::resolve_tool_execution_plan_fenced(&dispatcher, call, &context, &resolution)
2652 .expect("streaming plan resolves through wrapper");
2653
2654 let outcome =
2655 crate::dispatch_tool_execution_plan_fenced(&dispatcher, call, &context, &plan)
2656 .await
2657 .expect("streaming dispatch completes");
2658
2659 assert_eq!(outcome.result.text_content(), "stream complete");
2660 assert!(
2661 saw_streaming_context.load(std::sync::atomic::Ordering::SeqCst),
2662 "the wrapper must preserve the exact supervised context"
2663 );
2664 }
2665
2666 #[tokio::test]
2667 async fn declared_streaming_without_a_mode_owner_fails_closed_before_plain_dispatch() {
2668 use crate::{
2669 StreamingToolExecutionPolicy, ToolDeadlineChain, ToolDeadlineContributor,
2670 ToolDeadlineOwner, ToolExecutionContract, ToolExecutionMode,
2671 ToolExecutionResolutionContext,
2672 };
2673 use std::collections::BTreeSet;
2674 use std::time::Duration;
2675
2676 let contract = ToolExecutionContract::new(
2677 BTreeSet::from([ToolExecutionMode::Streaming]),
2678 ToolExecutionMode::Streaming,
2679 Some(
2680 StreamingToolExecutionPolicy::new(Duration::from_secs(5), Duration::from_secs(30))
2681 .unwrap(),
2682 ),
2683 None,
2684 )
2685 .unwrap();
2686 let dispatcher = Arc::new(ExactExecutionDispatcher {
2687 catalog: Arc::from([crate::ToolCatalogEntry::session_inline(
2688 Arc::new(ToolDef::new(
2689 "ownerless_stream",
2690 "ownerless",
2691 json!({"type": "object"}),
2692 )),
2693 true,
2694 )
2695 .with_execution_contract(contract)]),
2696 });
2697 let args = serde_json::value::RawValue::from_string("{}".to_string()).unwrap();
2698 let call = ToolCallView {
2699 id: "ownerless-call",
2700 name: "ownerless_stream",
2701 args: &args,
2702 };
2703 let context = ToolDispatchContext::default();
2704 let resolution = ToolExecutionResolutionContext::new(
2705 ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
2706 ToolDeadlineOwner::CoreToolDispatch,
2707 Duration::from_secs(60),
2708 )])
2709 .unwrap(),
2710 );
2711 let plan =
2712 crate::resolve_tool_execution_plan_fenced(&dispatcher, call, &context, &resolution)
2713 .expect("declaration resolves");
2714
2715 let error = crate::dispatch_tool_execution_plan_fenced(&dispatcher, call, &context, &plan)
2716 .await
2717 .expect_err("missing streaming owner must fail closed");
2718 assert!(matches!(
2719 error,
2720 crate::ToolError::Unavailable {
2721 reason: crate::ToolUnavailableReason::ExecutionModeOwnerUnavailable,
2722 ..
2723 }
2724 ));
2725 }
2726
2727 #[test]
2728 fn filtered_execution_plan_resolver_rejects_policy_denied_tool() {
2729 use crate::{
2730 ToolDeadlineChain, ToolDeadlineContributor, ToolDeadlineOwner,
2731 ToolExecutionResolutionContext, ToolExecutionResolutionError,
2732 };
2733 use std::time::Duration;
2734
2735 let dispatcher =
2736 FilteredToolDispatcher::new(Arc::new(ContextAwareToolDispatcher), Vec::<String>::new());
2737 let args = serde_json::value::RawValue::from_string("{}".to_string()).unwrap();
2738 let call = ToolCallView {
2739 id: "call-hidden",
2740 name: "inspect_context",
2741 args: &args,
2742 };
2743 let resolution = ToolExecutionResolutionContext::new(
2744 ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
2745 ToolDeadlineOwner::CoreToolDispatch,
2746 Duration::from_secs(600),
2747 )])
2748 .unwrap(),
2749 );
2750
2751 let error = dispatcher
2752 .resolve_execution_plan(call, &ToolDispatchContext::default(), &resolution)
2753 .expect_err("hidden tools must not resolve");
2754
2755 assert_eq!(
2756 error,
2757 ToolExecutionResolutionError::AccessDenied {
2758 tool_name: "inspect_context".to_string(),
2759 }
2760 );
2761 }
2762
2763 #[tokio::test]
2764 async fn filtered_execution_plan_forwards_hybrid_resolution_to_visible_owner() {
2765 use crate::{
2766 DetachedToolExecutionPolicy, IdempotencyScope, ResolvedExecutionKind, RestartClass,
2767 RunnerIdentity, ToolDeadlineChain, ToolDeadlineContributor, ToolDeadlineOwner,
2768 ToolExecutionContract, ToolExecutionMode, ToolExecutionResolutionContext,
2769 };
2770 use std::collections::BTreeSet;
2771 use std::time::Duration;
2772
2773 let detached = DetachedToolExecutionPolicy::new(
2774 RunnerIdentity::new("filtered-hybrid-owner", "v1").unwrap(),
2775 RestartClass::NonResumable,
2776 IdempotencyScope::InteractionAndArguments,
2777 Duration::from_secs(10),
2778 )
2779 .unwrap();
2780 let contract = ToolExecutionContract::new(
2781 BTreeSet::from([ToolExecutionMode::Fast, ToolExecutionMode::Detached]),
2782 ToolExecutionMode::Fast,
2783 None,
2784 Some(detached),
2785 )
2786 .unwrap();
2787 let dispatcher = FilteredToolDispatcher::new(
2788 Arc::new(HybridExecutionDispatcher {
2789 catalog: Arc::from([crate::ToolCatalogEntry::session_inline(
2790 Arc::new(ToolDef::new(
2791 "hybrid_scan",
2792 "filtered-hybrid-owner catalog",
2793 json!({"type": "object"}),
2794 )),
2795 true,
2796 )
2797 .with_execution_contract(contract)]),
2798 }),
2799 ["hybrid_scan"],
2800 );
2801 let args = serde_json::value::RawValue::from_string(r#"{"run_detached":true}"#.to_string())
2802 .unwrap();
2803 let call = ToolCallView {
2804 id: "call-hybrid",
2805 name: "hybrid_scan",
2806 args: &args,
2807 };
2808 let resolution = ToolExecutionResolutionContext::new(
2809 ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
2810 ToolDeadlineOwner::CoreToolDispatch,
2811 Duration::from_secs(600),
2812 )])
2813 .unwrap(),
2814 );
2815
2816 let catalog = dispatcher.tool_catalog();
2817 assert_eq!(catalog[0].execution.default_mode(), ToolExecutionMode::Fast);
2818 assert_eq!(catalog[0].tool.description, "filtered-hybrid-owner catalog");
2819
2820 let plan = dispatcher
2821 .resolve_execution_plan(call, &ToolDispatchContext::default(), &resolution)
2822 .expect("visible hybrid tool should delegate plan resolution");
2823 dispatcher
2824 .validate_resolved_execution_plan(call, &resolution, &plan)
2825 .expect("hybrid-selected advertised mode must validate");
2826 let ResolvedExecutionKind::Detached(policy) = plan.kind() else {
2827 panic!("hybrid resolver should select its non-default detached mode");
2828 };
2829 assert_eq!(policy.runner().name(), "filtered-hybrid-owner");
2830
2831 let outcome = dispatcher
2832 .dispatch_resolved_with_context(call, &ToolDispatchContext::default(), &plan)
2833 .await
2834 .expect("visible hybrid tool should preserve resolved dispatch");
2835 let payload: serde_json::Value =
2836 serde_json::from_str(&outcome.result.text_content()).unwrap();
2837 assert_eq!(payload["owner"], "filtered-hybrid-owner");
2838 }
2839
2840 #[test]
2841 fn root_validation_rejects_plan_outside_live_advertised_contract() {
2842 use crate::{
2843 DetachedToolExecutionPolicy, IdempotencyScope, RestartClass, RunnerIdentity,
2844 ToolDeadlineChain, ToolDeadlineContributor, ToolDeadlineOwner, ToolExecutionContract,
2845 ToolExecutionContractError, ToolExecutionMode, ToolExecutionResolutionContext,
2846 ToolExecutionResolutionError,
2847 };
2848 use std::collections::BTreeSet;
2849 use std::time::Duration;
2850
2851 let dispatcher = ExactExecutionDispatcher {
2852 catalog: Arc::from([crate::ToolCatalogEntry::session_inline(
2853 Arc::new(ToolDef::new(
2854 "fast_only",
2855 "fast only",
2856 json!({"type": "object"}),
2857 )),
2858 true,
2859 )]),
2860 };
2861 let detached = DetachedToolExecutionPolicy::new(
2862 RunnerIdentity::new("dishonest.owner", "v1").unwrap(),
2863 RestartClass::NonResumable,
2864 IdempotencyScope::ToolCall,
2865 Duration::from_secs(10),
2866 )
2867 .unwrap();
2868 let dishonest_contract = ToolExecutionContract::new(
2869 BTreeSet::from([ToolExecutionMode::Detached]),
2870 ToolExecutionMode::Detached,
2871 None,
2872 Some(detached),
2873 )
2874 .unwrap();
2875 let resolution = ToolExecutionResolutionContext::new(
2876 ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
2877 ToolDeadlineOwner::CoreToolDispatch,
2878 Duration::from_secs(600),
2879 )])
2880 .unwrap(),
2881 );
2882 let plan = dishonest_contract
2883 .resolve_default(resolution.deadlines().clone())
2884 .unwrap();
2885 let args = serde_json::value::RawValue::from_string("{}".to_string()).unwrap();
2886 let call = ToolCallView {
2887 id: "dishonest-plan",
2888 name: "fast_only",
2889 args: &args,
2890 };
2891
2892 assert_eq!(
2893 dispatcher.validate_resolved_execution_plan(call, &resolution, &plan),
2894 Err(ToolExecutionResolutionError::Contract(
2895 ToolExecutionContractError::RequestedModeUnsupported {
2896 requested_mode: ToolExecutionMode::Detached,
2897 }
2898 ))
2899 );
2900 }
2901
2902 #[tokio::test]
2903 async fn universal_root_fence_accepts_rebuilt_equivalent_catalog_arcs() {
2904 use crate::{
2905 ToolDeadlineChain, ToolDeadlineContributor, ToolDeadlineOwner,
2906 ToolExecutionResolutionContext,
2907 };
2908 use std::time::Duration;
2909
2910 let dispatcher: Arc<dyn AgentToolDispatcher> = Arc::new(IdenticalMutationDispatcher {
2911 tool: ToolDef::new("rebuilt", "rebuilt", json!({"type": "object"})),
2912 epoch: std::sync::atomic::AtomicU64::new(0),
2913 mutate_on_resolve: false,
2914 });
2915 let args = serde_json::value::RawValue::from_string("{}".to_string()).unwrap();
2916 let call = ToolCallView {
2917 id: "rebuilt-arcs",
2918 name: "rebuilt",
2919 args: &args,
2920 };
2921 let resolution = ToolExecutionResolutionContext::new(
2922 ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
2923 ToolDeadlineOwner::CoreToolDispatch,
2924 Duration::from_secs(600),
2925 )])
2926 .unwrap(),
2927 );
2928
2929 let plan = crate::resolve_tool_execution_plan_fenced(
2930 &dispatcher,
2931 call,
2932 &ToolDispatchContext::default(),
2933 &resolution,
2934 )
2935 .expect("equivalent rebuilt catalog projections resolve");
2936 crate::dispatch_tool_execution_plan_fenced(
2937 &dispatcher,
2938 call,
2939 &ToolDispatchContext::default(),
2940 &plan,
2941 )
2942 .await
2943 .expect("equivalent rebuilt catalog projections dispatch");
2944 }
2945
2946 #[tokio::test]
2947 async fn universal_root_fence_binds_canonical_call_identity() {
2948 use crate::{
2949 ToolDeadlineChain, ToolDeadlineContributor, ToolDeadlineOwner,
2950 ToolExecutionResolutionContext, ToolUnavailableReason,
2951 };
2952 use std::time::Duration;
2953
2954 let dispatcher: Arc<dyn AgentToolDispatcher> = Arc::new(IdenticalMutationDispatcher {
2955 tool: ToolDef::new("bound", "bound", json!({"type": "object"})),
2956 epoch: std::sync::atomic::AtomicU64::new(0),
2957 mutate_on_resolve: false,
2958 });
2959 let resolved_args =
2960 serde_json::value::RawValue::from_string(r#"{"a":1,"b":2}"#.to_string()).unwrap();
2961 let equivalent_args =
2962 serde_json::value::RawValue::from_string(r#"{ "b": 2, "a": 1 }"#.to_string()).unwrap();
2963 let changed_args =
2964 serde_json::value::RawValue::from_string(r#"{"a":1,"b":3}"#.to_string()).unwrap();
2965 let resolved_call = ToolCallView {
2966 id: "bound-call",
2967 name: "bound",
2968 args: &resolved_args,
2969 };
2970 let resolution = ToolExecutionResolutionContext::new(
2971 ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
2972 ToolDeadlineOwner::CoreToolDispatch,
2973 Duration::from_secs(600),
2974 )])
2975 .unwrap(),
2976 );
2977 let plan = crate::resolve_tool_execution_plan_fenced(
2978 &dispatcher,
2979 resolved_call,
2980 &ToolDispatchContext::default(),
2981 &resolution,
2982 )
2983 .unwrap();
2984
2985 crate::dispatch_tool_execution_plan_fenced(
2986 &dispatcher,
2987 ToolCallView {
2988 args: &equivalent_args,
2989 ..resolved_call
2990 },
2991 &ToolDispatchContext::default(),
2992 &plan,
2993 )
2994 .await
2995 .expect("canonical JSON-equivalent arguments preserve call identity");
2996
2997 let error = crate::dispatch_tool_execution_plan_fenced(
2998 &dispatcher,
2999 ToolCallView {
3000 args: &changed_args,
3001 ..resolved_call
3002 },
3003 &ToolDispatchContext::default(),
3004 &plan,
3005 )
3006 .await
3007 .expect_err("different arguments must not dispatch under the old plan");
3008 assert!(matches!(
3009 error,
3010 crate::ToolError::Unavailable {
3011 reason: ToolUnavailableReason::ExecutionOwnerChanged,
3012 ..
3013 }
3014 ));
3015 }
3016
3017 #[tokio::test]
3018 async fn universal_root_fence_rejects_fresh_dispatcher_reconstruction() {
3019 use crate::{
3020 ToolDeadlineChain, ToolDeadlineContributor, ToolDeadlineOwner,
3021 ToolExecutionResolutionContext, ToolUnavailableReason,
3022 };
3023 use std::time::Duration;
3024
3025 let make_dispatcher = || -> Arc<dyn AgentToolDispatcher> {
3026 Arc::new(IdenticalMutationDispatcher {
3027 tool: ToolDef::new("bound", "bound", json!({"type": "object"})),
3028 epoch: std::sync::atomic::AtomicU64::new(0),
3029 mutate_on_resolve: false,
3030 })
3031 };
3032 let original = make_dispatcher();
3033 let args = serde_json::value::RawValue::from_string("{}".to_string()).unwrap();
3034 let call = ToolCallView {
3035 id: "reconstructed",
3036 name: "bound",
3037 args: &args,
3038 };
3039 let resolution = ToolExecutionResolutionContext::new(
3040 ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
3041 ToolDeadlineOwner::CoreToolDispatch,
3042 Duration::from_secs(600),
3043 )])
3044 .unwrap(),
3045 );
3046 let plan = crate::resolve_tool_execution_plan_fenced(
3047 &original,
3048 call,
3049 &ToolDispatchContext::default(),
3050 &resolution,
3051 )
3052 .unwrap();
3053 let reconstructed = make_dispatcher();
3054
3055 let error = crate::dispatch_tool_execution_plan_fenced(
3056 &reconstructed,
3057 call,
3058 &ToolDispatchContext::default(),
3059 &plan,
3060 )
3061 .await
3062 .expect_err("fresh reconstruction must never reproduce ephemeral root authority");
3063 assert!(matches!(
3064 error,
3065 crate::ToolError::Unavailable {
3066 reason: ToolUnavailableReason::ExecutionOwnerChanged,
3067 ..
3068 }
3069 ));
3070 }
3071
3072 #[test]
3073 fn universal_root_fence_rejects_direct_identical_metadata_replacement() {
3074 use crate::{
3075 ToolDeadlineChain, ToolDeadlineContributor, ToolDeadlineOwner,
3076 ToolExecutionResolutionContext, ToolExecutionResolutionError, ToolUnavailableReason,
3077 };
3078 use std::time::Duration;
3079
3080 let dispatcher: Arc<dyn AgentToolDispatcher> = Arc::new(IdenticalMutationDispatcher {
3081 tool: ToolDef::new("moving", "identical metadata", json!({"type": "object"})),
3082 epoch: std::sync::atomic::AtomicU64::new(0),
3083 mutate_on_resolve: true,
3084 });
3085 let args = serde_json::value::RawValue::from_string("{}".to_string()).unwrap();
3086 let call = ToolCallView {
3087 id: "direct-identical-replacement",
3088 name: "moving",
3089 args: &args,
3090 };
3091 let resolution = ToolExecutionResolutionContext::new(
3092 ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
3093 ToolDeadlineOwner::CoreToolDispatch,
3094 Duration::from_secs(600),
3095 )])
3096 .unwrap(),
3097 );
3098
3099 assert!(matches!(
3100 crate::resolve_tool_execution_plan_fenced(
3101 &dispatcher,
3102 call,
3103 &ToolDispatchContext::default(),
3104 &resolution,
3105 ),
3106 Err(ToolExecutionResolutionError::Unavailable {
3107 reason: ToolUnavailableReason::ExecutionOwnerChanged,
3108 ..
3109 })
3110 ));
3111 }
3112
3113 #[test]
3114 fn filtered_wrapper_composes_inner_live_binding_epoch() {
3115 use crate::{
3116 ToolDeadlineChain, ToolDeadlineContributor, ToolDeadlineOwner,
3117 ToolExecutionResolutionContext, ToolExecutionResolutionError, ToolUnavailableReason,
3118 };
3119 use std::time::Duration;
3120
3121 let dispatcher: Arc<dyn AgentToolDispatcher> = Arc::new(FilteredToolDispatcher::new(
3122 Arc::new(IdenticalMutationDispatcher {
3123 tool: ToolDef::new("moving", "identical metadata", json!({"type": "object"})),
3124 epoch: std::sync::atomic::AtomicU64::new(0),
3125 mutate_on_resolve: true,
3126 }),
3127 ["moving"],
3128 ));
3129 let args = serde_json::value::RawValue::from_string("{}".to_string()).unwrap();
3130 let call = ToolCallView {
3131 id: "filtered-identical-replacement",
3132 name: "moving",
3133 args: &args,
3134 };
3135 let resolution = ToolExecutionResolutionContext::new(
3136 ToolDeadlineChain::new(vec![ToolDeadlineContributor::finite(
3137 ToolDeadlineOwner::CoreToolDispatch,
3138 Duration::from_secs(600),
3139 )])
3140 .unwrap(),
3141 );
3142
3143 assert!(matches!(
3144 crate::resolve_tool_execution_plan_fenced(
3145 &dispatcher,
3146 call,
3147 &ToolDispatchContext::default(),
3148 &resolution,
3149 ),
3150 Err(ToolExecutionResolutionError::Unavailable {
3151 reason: ToolUnavailableReason::ExecutionOwnerChanged,
3152 ..
3153 })
3154 ));
3155 }
3156
3157 #[test]
3158 fn test_inline_peer_notification_policy_from_raw() {
3159 assert_eq!(
3160 InlinePeerNotificationPolicy::try_from_raw(None),
3161 Ok(InlinePeerNotificationPolicy::AtMost(
3162 DEFAULT_MAX_INLINE_PEER_NOTIFICATIONS
3163 ))
3164 );
3165 assert_eq!(
3166 InlinePeerNotificationPolicy::try_from_raw(Some(-1)),
3167 Ok(InlinePeerNotificationPolicy::Always)
3168 );
3169 assert_eq!(
3170 InlinePeerNotificationPolicy::try_from_raw(Some(0)),
3171 Ok(InlinePeerNotificationPolicy::Never)
3172 );
3173 assert_eq!(
3174 InlinePeerNotificationPolicy::try_from_raw(Some(25)),
3175 Ok(InlinePeerNotificationPolicy::AtMost(25))
3176 );
3177 assert_eq!(
3178 InlinePeerNotificationPolicy::try_from_raw(Some(-42)),
3179 Err(-42)
3180 );
3181 }
3182
3183 #[test]
3186 fn unit_002_detached_op_completion_has_no_operation_id() {
3187 use crate::agent::DetachedOpCompletion;
3188 use crate::ops_lifecycle::{OperationKind, OperationStatus};
3189
3190 let completion = DetachedOpCompletion {
3191 job_id: "j_test".into(),
3192 kind: OperationKind::BackgroundToolOp,
3193 status: OperationStatus::Completed,
3194 terminal_outcome: None,
3195 display_name: "test cmd".into(),
3196 detail: "ok".into(),
3197 elapsed_ms: None,
3198 };
3199 #[allow(clippy::unwrap_used)]
3200 let json = serde_json::to_value(&completion).unwrap();
3201 assert!(
3202 json.get("operation_id").is_none(),
3203 "operation_id must not appear in serialized DetachedOpCompletion (CONTRACT-003)"
3204 );
3205 assert!(
3206 json.get("job_id").is_some(),
3207 "job_id must be the app-facing control noun"
3208 );
3209 }
3210}