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(&self, _identity: &crate::SessionLlmIdentity) {}
102
103 fn active_capability_base_filter(&self) -> crate::ToolFilter {
109 crate::ToolFilter::All
110 }
111
112 fn active_max_output_tokens(&self) -> Option<u32> {
116 None
117 }
118
119 fn begin_stream_output_observation(&self) {}
126
127 fn stream_output_observed(&self) -> bool {
134 false
135 }
136
137 fn compile_schema(&self, output_schema: &OutputSchema) -> Result<CompiledSchema, SchemaError> {
143 Ok(CompiledSchema {
145 schema: output_schema.schema.as_value().clone(),
146 warnings: Vec::new(),
147 })
148 }
149}
150
151pub type AgentLlmClientDecorator =
157 Arc<dyn Fn(Arc<dyn AgentLlmClient>) -> Arc<dyn AgentLlmClient> + Send + Sync + 'static>;
158
159#[derive(Debug, Clone)]
161pub struct AgentLlmFallbackSkippedTarget {
162 pub identity: crate::SessionLlmIdentity,
163 pub reason: String,
164}
165
166#[derive(Debug, Clone)]
172pub struct AgentLlmFallbackSwitch {
173 pub previous_identity: crate::SessionLlmIdentity,
174 pub new_identity: crate::SessionLlmIdentity,
175 pub request_policy: crate::SessionLlmRequestPolicy,
176 pub capability_base_filter: crate::ToolFilter,
177 pub context_window: Option<u32>,
178 pub max_output_tokens: Option<u32>,
179 pub skipped_targets: Vec<AgentLlmFallbackSkippedTarget>,
180}
181
182pub struct LlmStreamResult {
184 blocks: Vec<AssistantBlock>,
185 stop_reason: StopReason,
186 usage: Usage,
187}
188
189impl LlmStreamResult {
190 pub fn new(blocks: Vec<AssistantBlock>, stop_reason: StopReason, usage: Usage) -> Self {
191 Self {
192 blocks,
193 stop_reason,
194 usage,
195 }
196 }
197
198 pub fn blocks(&self) -> &[AssistantBlock] {
199 &self.blocks
200 }
201 pub fn stop_reason(&self) -> StopReason {
202 self.stop_reason
203 }
204 pub fn usage(&self) -> &Usage {
205 &self.usage
206 }
207
208 pub fn into_message(self) -> BlockAssistantMessage {
209 BlockAssistantMessage::new(self.blocks, self.stop_reason)
210 }
211
212 pub fn into_parts(self) -> (Vec<AssistantBlock>, StopReason, Usage) {
213 (self.blocks, self.stop_reason, self.usage)
214 }
215}
216
217#[derive(Debug, Clone, PartialEq, Eq)]
223pub struct AgentExecutionSnapshot {
224 pub loop_state: LoopState,
225 pub turn_phase: TurnPhase,
226 pub turn_terminal: bool,
232 pub active_run_id: Option<RunId>,
233 pub primitive_kind: TurnPrimitiveKind,
234 pub admitted_content_shape: Option<ContentShape>,
235 pub vision_enabled: bool,
236 pub image_tool_results_enabled: bool,
237 pub tool_calls_pending: u32,
238 pub pending_operation_ids: Option<Vec<OperationId>>,
239 pub barrier_operation_ids: Vec<OperationId>,
240 pub has_barrier_ops: bool,
241 pub barrier_satisfied: bool,
242 pub boundary_count: u32,
243 pub cancel_after_boundary: bool,
244 pub terminal_outcome: TurnTerminalOutcome,
245 pub terminal_cause_kind: Option<TurnTerminalCauseKind>,
246 pub extraction_attempts: u32,
247 pub max_extraction_retries: u32,
248 pub applied_cursor: CompletionSeq,
249}
250
251#[derive(Debug, Clone, Default)]
255pub struct ExternalToolUpdate {
256 pub notices: Vec<ExternalToolDelta>,
258 pub pending: Vec<String>,
260}
261
262#[derive(Debug, Clone, Copy, PartialEq, Eq)]
271pub struct CancelAfterBoundaryCommand;
272
273pub type CancelAfterBoundarySender = tokio::sync::mpsc::UnboundedSender<CancelAfterBoundaryCommand>;
280
281#[derive(Debug, Clone, Default, PartialEq, Eq)]
288pub struct ToolDispatchContext {
289 current_turn: Option<CurrentTurnContent>,
290 turn_metadata: BTreeMap<String, serde_json::Value>,
291}
292
293impl ToolDispatchContext {
294 pub fn from_current_turn_input(input: &crate::types::ContentInput) -> Self {
295 let blocks = match input {
296 crate::types::ContentInput::Text(_) => None,
297 crate::types::ContentInput::Blocks(blocks) => Some(blocks.clone()),
298 };
299 Self {
300 current_turn: blocks.map(CurrentTurnContent::new),
301 turn_metadata: BTreeMap::new(),
302 }
303 }
304
305 pub fn from_run_input(input: &crate::types::RunInput) -> Self {
309 match input {
310 crate::types::RunInput::Content { content } => Self::from_current_turn_input(content),
311 crate::types::RunInput::PendingToolResults => Self::default(),
312 }
313 }
314
315 #[must_use]
316 pub fn with_turn_metadata(mut self, metadata: BTreeMap<String, serde_json::Value>) -> Self {
317 self.turn_metadata = metadata;
318 self
319 }
320
321 pub fn turn_metadata(&self, key: &str) -> Option<&serde_json::Value> {
322 self.turn_metadata.get(key)
323 }
324
325 pub fn current_turn(&self) -> Option<&CurrentTurnContent> {
326 self.current_turn.as_ref()
327 }
328
329 pub fn current_turn_image(
330 &self,
331 image_ref: CurrentTurnImageRef,
332 ) -> Option<&crate::types::ContentBlock> {
333 self.current_turn
334 .as_ref()
335 .and_then(|current_turn| current_turn.image(image_ref))
336 }
337}
338
339#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
354#[serde(transparent)]
355pub struct CurrentTurnImageRef(usize);
356
357impl std::fmt::Display for CurrentTurnImageRef {
358 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
359 std::fmt::Display::fmt(&self.0, f)
360 }
361}
362
363#[derive(Debug, Clone, PartialEq, Eq)]
365pub struct CurrentTurnContent {
366 blocks: Vec<crate::types::ContentBlock>,
367}
368
369impl CurrentTurnContent {
370 pub fn new(blocks: Vec<crate::types::ContentBlock>) -> Self {
371 Self { blocks }
372 }
373
374 pub fn blocks(&self) -> &[crate::types::ContentBlock] {
375 &self.blocks
376 }
377
378 pub fn image_ref(&self, n: usize) -> Option<CurrentTurnImageRef> {
382 self.images().nth(n).map(|_| CurrentTurnImageRef(n))
383 }
384
385 pub fn image(&self, image_ref: CurrentTurnImageRef) -> Option<&crate::types::ContentBlock> {
386 self.images().nth(image_ref.0)
387 }
388
389 fn images(&self) -> impl Iterator<Item = &crate::types::ContentBlock> {
390 self.blocks
391 .iter()
392 .filter(|block| matches!(block, crate::types::ContentBlock::Image { .. }))
393 }
394}
395
396#[derive(Debug, Clone, Serialize, Deserialize)]
403pub struct DetachedOpCompletion {
404 pub job_id: String,
406 pub kind: OperationKind,
408 pub status: OperationStatus,
410 pub terminal_outcome: Option<OperationTerminalOutcome>,
412 pub display_name: String,
414 pub detail: String,
416 pub elapsed_ms: Option<u64>,
418}
419
420#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
425pub struct DispatcherCapabilities {
426 pub ops_lifecycle: bool,
428}
429
430pub enum BindOutcome {
441 Bound(Arc<dyn AgentToolDispatcher>),
443 Skipped(Arc<dyn AgentToolDispatcher>),
446}
447
448impl BindOutcome {
449 pub fn into_dispatcher(self) -> Arc<dyn AgentToolDispatcher> {
451 match self {
452 Self::Bound(d) | Self::Skipped(d) => d,
453 }
454 }
455
456 pub fn was_bound(&self) -> bool {
458 matches!(self, Self::Bound(_))
459 }
460}
461
462#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
464#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
465pub trait AgentToolDispatcher: Send + Sync {
466 fn tools(&self) -> Arc<[Arc<ToolDef>]>;
468
469 fn tool_catalog_capabilities(&self) -> ToolCatalogCapabilities {
475 ToolCatalogCapabilities::default()
476 }
477
478 fn tool_catalog(&self) -> Arc<[ToolCatalogEntry]> {
484 self.tools()
485 .iter()
486 .map(|tool| ToolCatalogEntry::session_inline(Arc::clone(tool), true))
487 .collect::<Vec<_>>()
488 .into()
489 }
490
491 fn pending_catalog_sources(&self) -> Arc<[String]> {
496 Arc::from([])
497 }
498
499 async fn dispatch(
505 &self,
506 call: ToolCallView<'_>,
507 ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError>;
508
509 async fn dispatch_with_context(
515 &self,
516 call: ToolCallView<'_>,
517 _context: &ToolDispatchContext,
518 ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
519 self.dispatch(call).await
520 }
521
522 async fn poll_external_updates(&self) -> ExternalToolUpdate {
528 ExternalToolUpdate::default()
529 }
530
531 fn external_tool_surface_snapshot(&self) -> Option<crate::ExternalToolSurfaceSnapshot> {
537 None
538 }
539
540 fn capabilities(&self) -> DispatcherCapabilities {
542 DispatcherCapabilities::default()
543 }
544
545 fn bind_ops_lifecycle(
553 self: Arc<Self>,
554 _registry: Arc<dyn crate::ops_lifecycle::OpsLifecycleRegistry>,
555 _owner_bridge_session_id: crate::types::SessionId,
556 ) -> Result<BindOutcome, OpsLifecycleBindError> {
557 Err(OpsLifecycleBindError::Unsupported)
558 }
559
560 fn completion_enrichment(
565 &self,
566 ) -> Option<Arc<dyn crate::completion_feed::CompletionEnrichmentProvider>> {
567 None
568 }
569
570 fn bind_mcp_server_lifecycle_handle(
577 &self,
578 _handle: Arc<dyn crate::handles::McpServerLifecycleHandle>,
579 ) {
580 }
581
582 fn bind_external_tool_surface_handle(
589 &self,
590 _handle: Arc<dyn crate::handles::ExternalToolSurfaceHandle>,
591 ) {
592 }
593}
594
595pub fn select_tool_catalog_mode<T>(dispatcher: &T) -> ToolCatalogMode
597where
598 T: AgentToolDispatcher + ?Sized,
599{
600 let capabilities = dispatcher.tool_catalog_capabilities();
601 if !capabilities.exact_catalog {
602 return ToolCatalogMode::Inline;
603 }
604 let pending_sources = dispatcher.pending_catalog_sources();
605 let catalog = dispatcher.tool_catalog();
606 select_catalog_mode_from_snapshot(
607 capabilities.exact_catalog,
608 catalog.as_ref(),
609 pending_sources.as_ref(),
610 )
611}
612
613pub fn should_compose_tool_catalog_control_plane<T>(dispatcher: &T) -> bool
616where
617 T: AgentToolDispatcher + ?Sized,
618{
619 let capabilities = dispatcher.tool_catalog_capabilities();
620 if !capabilities.exact_catalog {
621 return false;
622 }
623 if capabilities.may_require_catalog_control_plane {
624 return true;
625 }
626
627 let pending_sources = dispatcher.pending_catalog_sources();
628 if !pending_sources.is_empty() {
629 return true;
630 }
631
632 let catalog = dispatcher.tool_catalog();
633 deferred_session_entry_count(catalog.as_ref()) > 0
634}
635
636#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
638pub enum OpsLifecycleBindError {
639 #[error("ops lifecycle binding is unsupported")]
640 Unsupported,
641 #[error("dispatcher has shared ownership and cannot be rebound")]
642 SharedOwnership,
643}
644
645pub struct FilteredToolDispatcher<T: AgentToolDispatcher + ?Sized> {
652 inner: Arc<T>,
653 allowed_tools: ToolNameSet,
654 filtered_tools: Arc<[Arc<ToolDef>]>,
656}
657
658impl<T: AgentToolDispatcher + ?Sized> FilteredToolDispatcher<T> {
659 pub fn new<I, N>(inner: Arc<T>, allowed_tools: I) -> Self
660 where
661 I: IntoIterator<Item = N>,
662 N: Into<ToolName>,
663 {
664 let allowed_set: ToolNameSet = allowed_tools
665 .into_iter()
666 .map(Into::into)
667 .collect::<ToolNameSet>();
668
669 let filtered: Vec<Arc<ToolDef>> = if inner.tool_catalog_capabilities().exact_catalog {
670 inner
671 .tool_catalog()
672 .iter()
673 .filter(|entry| entry.currently_callable())
674 .map(|entry| Arc::clone(&entry.tool))
675 .filter(|t| allowed_set.contains(t.name.as_str()))
676 .collect()
677 } else {
678 inner
679 .tools()
680 .iter()
681 .filter(|t| allowed_set.contains(t.name.as_str()))
682 .map(Arc::clone)
683 .collect()
684 };
685
686 Self {
687 inner,
688 allowed_tools: allowed_set,
689 filtered_tools: filtered.into(),
690 }
691 }
692}
693
694#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
695#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
696impl<T: AgentToolDispatcher + ?Sized + 'static> AgentToolDispatcher for FilteredToolDispatcher<T> {
697 fn tools(&self) -> Arc<[Arc<ToolDef>]> {
698 if self.inner.tool_catalog_capabilities().exact_catalog {
699 return self
700 .inner
701 .tool_catalog()
702 .iter()
703 .filter(|entry| entry.currently_callable())
704 .map(|entry| Arc::clone(&entry.tool))
705 .filter(|tool| self.allowed_tools.contains(tool.name.as_str()))
706 .collect::<Vec<_>>()
707 .into();
708 }
709 Arc::clone(&self.filtered_tools)
710 }
711
712 async fn dispatch(
713 &self,
714 call: ToolCallView<'_>,
715 ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
716 self.dispatch_with_context(call, &ToolDispatchContext::default())
717 .await
718 }
719
720 async fn dispatch_with_context(
721 &self,
722 call: ToolCallView<'_>,
723 context: &ToolDispatchContext,
724 ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
725 if !self.allowed_tools.contains(call.name) {
726 let inner_knows_tool = if self.inner.tool_catalog_capabilities().exact_catalog {
727 self.inner
728 .tool_catalog()
729 .iter()
730 .any(|entry| entry.tool.name == call.name)
731 } else {
732 self.inner.tools().iter().any(|tool| tool.name == call.name)
733 };
734 if !inner_knows_tool {
735 return Err(crate::error::ToolError::not_found(call.name));
736 }
737 return Err(crate::error::ToolError::access_denied(call.name));
738 }
739 self.inner.dispatch_with_context(call, context).await
740 }
741
742 fn tool_catalog_capabilities(&self) -> ToolCatalogCapabilities {
743 self.inner.tool_catalog_capabilities()
744 }
745
746 fn tool_catalog(&self) -> Arc<[ToolCatalogEntry]> {
747 if !self.inner.tool_catalog_capabilities().exact_catalog {
748 return self
749 .tools()
750 .iter()
751 .map(|tool| ToolCatalogEntry::session_inline(Arc::clone(tool), true))
752 .collect::<Vec<_>>()
753 .into();
754 }
755 self.inner
756 .tool_catalog()
757 .iter()
758 .filter(|entry| self.allowed_tools.contains(entry.tool.name.as_str()))
759 .cloned()
760 .collect::<Vec<_>>()
761 .into()
762 }
763
764 fn pending_catalog_sources(&self) -> Arc<[String]> {
765 self.inner.pending_catalog_sources()
766 }
767
768 async fn poll_external_updates(&self) -> ExternalToolUpdate {
769 self.inner.poll_external_updates().await
770 }
771
772 fn external_tool_surface_snapshot(&self) -> Option<crate::ExternalToolSurfaceSnapshot> {
773 self.inner.external_tool_surface_snapshot()
774 }
775
776 fn capabilities(&self) -> DispatcherCapabilities {
777 self.inner.capabilities()
778 }
779
780 fn bind_ops_lifecycle(
781 self: Arc<Self>,
782 registry: Arc<dyn crate::ops_lifecycle::OpsLifecycleRegistry>,
783 owner_bridge_session_id: crate::types::SessionId,
784 ) -> Result<BindOutcome, OpsLifecycleBindError> {
785 let owned = Arc::try_unwrap(self).map_err(|_| OpsLifecycleBindError::SharedOwnership)?;
786 if Arc::strong_count(&owned.inner) == 1 {
787 let outcome = owned
788 .inner
789 .bind_ops_lifecycle(registry, owner_bridge_session_id)?;
790 let bound = outcome.was_bound();
791 let d = outcome.into_dispatcher();
792 let allowed_tools = owned.allowed_tools.into_iter().collect::<Vec<_>>();
793 Ok(if bound {
794 BindOutcome::Bound(Arc::new(FilteredToolDispatcher::new(d, allowed_tools)))
795 } else {
796 BindOutcome::Skipped(Arc::new(FilteredToolDispatcher::new(d, allowed_tools)))
797 })
798 } else {
799 Ok(BindOutcome::Skipped(Arc::new(FilteredToolDispatcher {
800 inner: owned.inner,
801 allowed_tools: owned.allowed_tools,
802 filtered_tools: owned.filtered_tools,
803 })))
804 }
805 }
806
807 fn completion_enrichment(
808 &self,
809 ) -> Option<Arc<dyn crate::completion_feed::CompletionEnrichmentProvider>> {
810 self.inner.completion_enrichment()
811 }
812}
813
814#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
816#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
817pub trait AgentSessionStore: Send + Sync {
818 async fn save(&self, session: &Session) -> Result<(), AgentError>;
819 async fn load(&self, id: &str) -> Result<Option<Session>, AgentError>;
820}
821
822#[derive(Debug, Clone, Copy, PartialEq, Eq)]
824pub enum InlinePeerNotificationPolicy {
825 Always,
827 Never,
829 AtMost(usize),
831}
832
833pub const DEFAULT_MAX_INLINE_PEER_NOTIFICATIONS: usize = 50;
835
836impl InlinePeerNotificationPolicy {
837 pub fn try_from_raw(raw: Option<i32>) -> Result<Self, i32> {
839 match raw {
840 None => Ok(Self::AtMost(DEFAULT_MAX_INLINE_PEER_NOTIFICATIONS)),
841 Some(-1) => Ok(Self::Always),
842 Some(0) => Ok(Self::Never),
843 Some(v) if v > 0 => Ok(Self::AtMost(v as usize)),
844 Some(v) => Err(v),
845 }
846 }
847}
848
849#[derive(Debug, thiserror::Error)]
851pub enum CommsCapabilityError {
852 #[error("comms capability not supported: {0}")]
854 Unsupported(String),
855}
856
857#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
859#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
860pub trait CommsRuntime: Send + Sync {
861 fn peer_id(&self) -> Option<PeerId> {
869 self.public_key()
870 .as_deref()
871 .and_then(|public_key| PeerId::parse(public_key).ok())
872 }
873
874 fn public_key(&self) -> Option<String> {
880 None
881 }
882
883 fn public_key_bytes(&self) -> Option<[u8; 32]> {
889 None
890 }
891
892 fn comms_name(&self) -> Option<String> {
898 None
899 }
900
901 fn advertised_address(&self) -> Option<String> {
907 None
908 }
909
910 fn bridge_bootstrap_token(&self) -> Option<String> {
913 None
914 }
915
916 async fn apply_trust_mutation(
921 &self,
922 _mutation: CommsTrustMutation,
923 ) -> Result<CommsTrustMutationResult, SendError> {
924 Err(SendError::Unsupported(
925 "apply_trust_mutation not supported for this CommsRuntime".to_string(),
926 ))
927 }
928
929 async fn install_generated_mob_trust_owner(
936 &self,
937 _owner: Arc<dyn std::any::Any + Send + Sync>,
938 ) -> Result<(), SendError> {
939 Err(SendError::Unsupported(
940 "generated mob trust owner binding not supported for this CommsRuntime".to_string(),
941 ))
942 }
943
944 async fn validate_recovered_generated_mob_trust_owner(
952 &self,
953 _owner: Arc<dyn std::any::Any + Send + Sync>,
954 ) -> Result<(), SendError> {
955 Err(SendError::Unsupported(
956 "recovered generated mob trust owner validation not supported for this CommsRuntime"
957 .to_string(),
958 ))
959 }
960
961 async fn install_recovered_generated_mob_trust_owner(
970 &self,
971 _owner: Arc<dyn std::any::Any + Send + Sync>,
972 ) -> Result<(), SendError> {
973 Err(SendError::Unsupported(
974 "recovered generated mob trust owner binding not supported for this CommsRuntime"
975 .to_string(),
976 ))
977 }
978
979 async fn add_private_trusted_peer(
990 &self,
991 _peer: TrustedPeerDescriptor,
992 ) -> Result<(), SendError> {
993 Err(SendError::Unsupported(
994 "generated comms private trust mutation authority required".to_string(),
995 ))
996 }
997
998 async fn remove_private_trusted_peer(&self, _peer_id: &str) -> Result<bool, SendError> {
1003 Err(SendError::Unsupported(
1004 "generated comms private trust mutation authority required".to_string(),
1005 ))
1006 }
1007
1008 fn set_outbound_content_taint(
1026 &self,
1027 _taint: Option<crate::comms::SenderContentTaint>,
1028 ) -> Result<(), SendError> {
1029 Err(SendError::Unsupported(
1030 "outbound content-taint declaration not supported by this CommsRuntime".to_string(),
1031 ))
1032 }
1033
1034 async fn send(&self, _cmd: CommsCommand) -> Result<SendReceipt, SendError> {
1036 Err(SendError::Unsupported(
1037 "send not implemented for this CommsRuntime".to_string(),
1038 ))
1039 }
1040
1041 #[doc(hidden)]
1042 fn stream(&self, scope: StreamScope) -> Result<EventStream, StreamError> {
1043 let scope_desc = match scope {
1044 StreamScope::Session(session_id) => format!("session {session_id}"),
1045 StreamScope::Interaction(interaction_id) => format!("interaction {}", interaction_id.0),
1046 };
1047 Err(StreamError::NotFound(scope_desc))
1048 }
1049
1050 async fn peers(&self) -> Vec<PeerDirectoryEntry> {
1052 Vec::new()
1053 }
1054
1055 async fn peer_count(&self) -> usize {
1059 self.peers().await.len()
1060 }
1061
1062 #[doc(hidden)]
1063 async fn send_and_stream(
1064 &self,
1065 cmd: CommsCommand,
1066 ) -> Result<(SendReceipt, EventStream), SendAndStreamError> {
1067 let receipt = self.send(cmd).await?;
1068 Err(SendAndStreamError::StreamAttach {
1069 receipt,
1070 error: StreamError::Internal(
1071 "send_and_stream is not implemented for this runtime".to_string(),
1072 ),
1073 })
1074 }
1075
1076 async fn drain_messages(&self) -> Vec<String>;
1078 fn inbox_notify(&self) -> Arc<tokio::sync::Notify>;
1080 fn dismiss_received(&self) -> bool {
1082 false
1083 }
1084 fn event_injector(&self) -> Option<Arc<dyn crate::EventInjector>> {
1089 None
1090 }
1091
1092 #[doc(hidden)]
1094 fn interaction_event_injector(
1095 &self,
1096 ) -> Option<Arc<dyn crate::event_injector::SubscribableInjector>> {
1097 None
1098 }
1099
1100 async fn drain_inbox_interactions(&self) -> Vec<crate::interaction::InboxInteraction> {
1105 self.drain_messages()
1106 .await
1107 .into_iter()
1108 .map(|text| crate::interaction::InboxInteraction {
1109 id: crate::interaction::InteractionId(uuid::Uuid::new_v4()),
1110 from_route: None,
1111 from: "unknown".into(),
1112 content: crate::interaction::InteractionContent::Message {
1113 body: text.clone(),
1114 blocks: None,
1115 },
1116 rendered_text: text,
1117 handling_mode: crate::types::HandlingMode::Queue,
1118 render_metadata: None,
1119 sender_taint: None,
1120 })
1121 .collect()
1122 }
1123
1124 fn interaction_subscriber(
1129 &self,
1130 _id: &crate::interaction::InteractionId,
1131 ) -> Option<tokio::sync::mpsc::Sender<crate::event::AgentEvent>> {
1132 None
1133 }
1134
1135 fn take_interaction_stream_sender(
1137 &self,
1138 _id: &crate::interaction::InteractionId,
1139 ) -> Option<tokio::sync::mpsc::Sender<crate::event::AgentEvent>> {
1140 self.interaction_subscriber(_id)
1141 }
1142
1143 fn mark_interaction_complete(&self, _id: &crate::interaction::InteractionId) {}
1149
1150 fn peer_interaction_handle(
1156 &self,
1157 ) -> Option<std::sync::Arc<dyn crate::handles::PeerInteractionHandle>> {
1158 None
1159 }
1160
1161 fn peer_request_response_authority_handle(
1170 &self,
1171 ) -> Option<std::sync::Arc<dyn crate::handles::PeerInteractionHandle>> {
1172 None
1173 }
1174
1175 async fn drain_classified_inbox_interactions(
1183 &self,
1184 ) -> Result<Vec<crate::interaction::ClassifiedInboxInteraction>, CommsCapabilityError> {
1185 Err(CommsCapabilityError::Unsupported(
1186 "drain_classified_inbox_interactions".to_string(),
1187 ))
1188 }
1189
1190 async fn drain_peer_input_candidates(&self) -> Vec<crate::interaction::PeerInputCandidate> {
1197 self.drain_classified_inbox_interactions()
1198 .await
1199 .unwrap_or_default()
1200 }
1201
1202 async fn peer_ingress_queue_snapshot(
1207 &self,
1208 ) -> Result<crate::interaction::PeerIngressQueueSnapshot, CommsCapabilityError> {
1209 Err(CommsCapabilityError::Unsupported(
1210 "peer_ingress_queue_snapshot".to_string(),
1211 ))
1212 }
1213
1214 async fn peer_ingress_runtime_snapshot(
1219 &self,
1220 ) -> Result<crate::interaction::PeerIngressRuntimeSnapshot, CommsCapabilityError> {
1221 Err(CommsCapabilityError::Unsupported(
1222 "peer_ingress_runtime_snapshot".to_string(),
1223 ))
1224 }
1225
1226 async fn public_trusted_peer_projection_snapshot(
1233 &self,
1234 ) -> Result<Vec<crate::comms::TrustedPeerDescriptor>, CommsCapabilityError> {
1235 Err(CommsCapabilityError::Unsupported(
1236 "public_trusted_peer_projection_snapshot".to_string(),
1237 ))
1238 }
1239
1240 async fn trusted_peer_projection_snapshot_for_source(
1247 &self,
1248 _source_kind: crate::comms::GeneratedCommsTrustAuthoritySourceKind,
1249 ) -> Result<Vec<crate::comms::TrustedPeerDescriptor>, CommsCapabilityError> {
1250 Err(CommsCapabilityError::Unsupported(
1251 "trusted_peer_projection_snapshot_for_source".to_string(),
1252 ))
1253 }
1254
1255 fn actionable_input_notify(&self) -> Result<Arc<tokio::sync::Notify>, CommsCapabilityError> {
1260 Err(CommsCapabilityError::Unsupported(
1261 "actionable_input_notify".to_string(),
1262 ))
1263 }
1264}
1265
1266pub struct Agent<C, T, S>
1268where
1269 C: AgentLlmClient + ?Sized,
1270 T: AgentToolDispatcher + ?Sized,
1271 S: AgentSessionStore + ?Sized,
1272{
1273 config: AgentConfig,
1274 client: Arc<C>,
1275 tools: Arc<T>,
1276 tool_scope: ToolScope,
1277 store: Arc<S>,
1278 session: Session,
1279 budget: Budget,
1280 retry_policy: RetryPolicy,
1281 depth: u32,
1282 pub(super) comms_runtime: Option<Arc<dyn CommsRuntime>>,
1283 pub(super) hook_engine: Option<Arc<dyn HookEngine>>,
1284 pub(super) hook_run_overrides: HookRunOverrides,
1285 pub(crate) compactor: Option<Arc<dyn crate::compact::Compactor>>,
1287 pub(crate) compaction_curator: Option<Arc<dyn crate::compact::CompactionCurator>>,
1290 pub(crate) last_input_tokens: u64,
1292 pub(crate) compaction_cadence: SessionCompactionCadence,
1294 pub(crate) memory_store: Option<Arc<dyn crate::memory::MemoryStore>>,
1296 pub(crate) skill_engine: Option<Arc<crate::skills::SkillRuntime>>,
1298 pub pending_skill_references: Option<Vec<crate::skills::SkillKey>>,
1301 pub(crate) event_tap: crate::event_tap::EventTap,
1303 pub(crate) system_context_state:
1305 Arc<std::sync::Mutex<crate::session::SessionSystemContextState>>,
1306 pub(crate) default_event_tx: Option<tokio::sync::mpsc::Sender<crate::event::AgentEvent>>,
1309 pub(crate) checkpointer: Option<Arc<dyn crate::checkpoint::SessionCheckpointer>>,
1315 pub(crate) blob_store: Option<Arc<dyn crate::BlobStore>>,
1317 pub(crate) terminal_error_detail: Option<String>,
1321 pub(crate) run_completed_hooks_applied: bool,
1323 pub(crate) run_completed_event_emitted: bool,
1326 #[allow(dead_code)] pub(crate) silent_comms_intents: Vec<String>,
1330 pub(crate) ops_lifecycle: Option<Arc<dyn crate::ops_lifecycle::OpsLifecycleRegistry>>,
1332 pub(crate) completion_feed: Option<Arc<dyn crate::completion_feed::CompletionFeed>>,
1334 pub(crate) epoch_cursor_state: Option<Arc<crate::runtime_epoch::EpochCursorState>>,
1336 pub(crate) applied_cursor: crate::completion_feed::CompletionSeq,
1338 pub(crate) completion_enrichment:
1340 Option<Arc<dyn crate::completion_feed::CompletionEnrichmentProvider>>,
1341 pub(crate) mob_authority_handle:
1346 Option<Arc<std::sync::RwLock<crate::service::MobToolAuthorityContext>>>,
1347 pub(crate) turn_state_handle: Option<Arc<dyn crate::TurnStateHandle>>,
1349 pub(crate) runtime_execution_kind_required: bool,
1351 pub(crate) runtime_execution_kind: Option<crate::lifecycle::RuntimeExecutionKind>,
1354 pub(crate) active_transcript_identity: Option<crate::types::TranscriptMessageIdentity>,
1356 pub(crate) external_tool_surface_handle: Option<Arc<dyn crate::ExternalToolSurfaceHandle>>,
1359 pub(crate) auth_lease_handle: Option<crate::handles::GeneratedAuthLeaseHandle>,
1361 pub(crate) mcp_server_lifecycle_handle:
1365 Option<Arc<dyn crate::handles::McpServerLifecycleHandle>>,
1366 pub(crate) cancel_after_boundary_tx: CancelAfterBoundarySender,
1373 pub(crate) cancel_after_boundary_rx:
1381 tokio::sync::mpsc::UnboundedReceiver<CancelAfterBoundaryCommand>,
1382 pub(crate) model_defaults_resolver:
1385 Option<Arc<dyn crate::model_defaults::ModelOperationalDefaultsResolver>>,
1386 pub(crate) call_timeout_override: crate::config::CallTimeoutOverride,
1389 pub(crate) extraction_state: extraction::ExtractionState,
1391 pub(crate) last_hidden_deferred_catalog_names: BTreeSet<crate::types::ToolName>,
1393 pub(crate) last_pending_catalog_sources: BTreeSet<String>,
1395 pub(crate) tool_dispatch_context: ToolDispatchContext,
1397 pub(crate) turn_tool_dispatch_metadata: BTreeMap<String, serde_json::Value>,
1399 pub(crate) tools_config: crate::config::ToolsConfig,
1404}
1405
1406#[cfg(test)]
1407#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
1408mod tests {
1409 use super::{
1410 AgentToolDispatcher, CommsRuntime, DEFAULT_MAX_INLINE_PEER_NOTIFICATIONS,
1411 FilteredToolDispatcher, InlinePeerNotificationPolicy, ToolDispatchContext,
1412 };
1413 use crate::comms::{
1414 PeerAddress, PeerId, PeerName, PeerTransport, SendError, TrustedPeerDescriptor,
1415 };
1416 use crate::types::{ContentBlock, ContentInput, ToolCallView, ToolDef, ToolResult};
1417 use async_trait::async_trait;
1418 use serde_json::json;
1419 use std::sync::Arc;
1420 use tokio::sync::Notify;
1421
1422 struct NoopCommsRuntime {
1423 notify: Arc<Notify>,
1424 }
1425
1426 struct ContextAwareToolDispatcher;
1427
1428 #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
1429 #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
1430 impl AgentToolDispatcher for ContextAwareToolDispatcher {
1431 fn tools(&self) -> Arc<[Arc<ToolDef>]> {
1432 Arc::from([Arc::new(ToolDef {
1433 name: "inspect_context".into(),
1434 description: "inspect context".to_string(),
1435 input_schema: json!({"type": "object"}),
1436 provenance: None,
1437 })])
1438 }
1439
1440 async fn dispatch(
1441 &self,
1442 call: ToolCallView<'_>,
1443 ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
1444 Ok(ToolResult::new(
1445 call.id.to_string(),
1446 json!({"saw_context_image": false}).to_string(),
1447 false,
1448 )
1449 .into())
1450 }
1451
1452 async fn dispatch_with_context(
1453 &self,
1454 call: ToolCallView<'_>,
1455 context: &ToolDispatchContext,
1456 ) -> Result<crate::ops::ToolDispatchOutcome, crate::error::ToolError> {
1457 let saw_context_image = context
1458 .current_turn()
1459 .and_then(|turn| turn.image_ref(0))
1460 .and_then(|image_ref| context.current_turn_image(image_ref))
1461 .is_some();
1462 Ok(ToolResult::new(
1463 call.id.to_string(),
1464 json!({"saw_context_image": saw_context_image}).to_string(),
1465 false,
1466 )
1467 .into())
1468 }
1469 }
1470
1471 #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
1472 #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
1473 impl CommsRuntime for NoopCommsRuntime {
1474 async fn drain_messages(&self) -> Vec<String> {
1475 Vec::new()
1476 }
1477
1478 fn inbox_notify(&self) -> std::sync::Arc<Notify> {
1479 self.notify.clone()
1480 }
1481 }
1482
1483 #[tokio::test]
1484 async fn test_comms_runtime_trait_defaults_hide_unimplemented_features() {
1485 let runtime = NoopCommsRuntime {
1486 notify: Arc::new(Notify::new()),
1487 };
1488 assert!(<NoopCommsRuntime as CommsRuntime>::public_key(&runtime).is_none());
1489 let peer = TrustedPeerDescriptor {
1492 peer_id: PeerId::new(),
1493 name: PeerName::new("peer-a").expect("valid peer name"),
1494 address: PeerAddress::new(PeerTransport::Inproc, "peer-a"),
1495 pubkey: [0u8; 32],
1496 };
1497 let result =
1498 <NoopCommsRuntime as CommsRuntime>::add_private_trusted_peer(&runtime, peer).await;
1499 assert!(matches!(result, Err(SendError::Unsupported(_))));
1500 }
1501
1502 #[tokio::test]
1503 async fn filtered_tool_dispatcher_preserves_dispatch_context() {
1504 let dispatcher =
1505 FilteredToolDispatcher::new(Arc::new(ContextAwareToolDispatcher), ["inspect_context"]);
1506 let args = serde_json::value::RawValue::from_string("{}".to_string())
1507 .expect("empty object should be valid JSON");
1508 let call = ToolCallView {
1509 id: "ctx-1",
1510 name: "inspect_context",
1511 args: &args,
1512 };
1513 let context = ToolDispatchContext::from_current_turn_input(&ContentInput::Blocks(vec![
1514 ContentBlock::Image {
1515 media_type: "image/png".to_string(),
1516 data: "abc".into(),
1517 },
1518 ]));
1519
1520 let outcome = dispatcher
1521 .dispatch_with_context(call, &context)
1522 .await
1523 .expect("filtered wrapper should dispatch");
1524 let payload: serde_json::Value =
1525 serde_json::from_str(&outcome.result.text_content()).expect("tool result JSON");
1526 assert_eq!(payload["saw_context_image"], true);
1527 }
1528
1529 #[test]
1530 fn test_inline_peer_notification_policy_from_raw() {
1531 assert_eq!(
1532 InlinePeerNotificationPolicy::try_from_raw(None),
1533 Ok(InlinePeerNotificationPolicy::AtMost(
1534 DEFAULT_MAX_INLINE_PEER_NOTIFICATIONS
1535 ))
1536 );
1537 assert_eq!(
1538 InlinePeerNotificationPolicy::try_from_raw(Some(-1)),
1539 Ok(InlinePeerNotificationPolicy::Always)
1540 );
1541 assert_eq!(
1542 InlinePeerNotificationPolicy::try_from_raw(Some(0)),
1543 Ok(InlinePeerNotificationPolicy::Never)
1544 );
1545 assert_eq!(
1546 InlinePeerNotificationPolicy::try_from_raw(Some(25)),
1547 Ok(InlinePeerNotificationPolicy::AtMost(25))
1548 );
1549 assert_eq!(
1550 InlinePeerNotificationPolicy::try_from_raw(Some(-42)),
1551 Err(-42)
1552 );
1553 }
1554
1555 #[test]
1558 fn unit_002_detached_op_completion_has_no_operation_id() {
1559 use crate::agent::DetachedOpCompletion;
1560 use crate::ops_lifecycle::{OperationKind, OperationStatus};
1561
1562 let completion = DetachedOpCompletion {
1563 job_id: "j_test".into(),
1564 kind: OperationKind::BackgroundToolOp,
1565 status: OperationStatus::Completed,
1566 terminal_outcome: None,
1567 display_name: "test cmd".into(),
1568 detail: "ok".into(),
1569 elapsed_ms: None,
1570 };
1571 #[allow(clippy::unwrap_used)]
1572 let json = serde_json::to_value(&completion).unwrap();
1573 assert!(
1574 json.get("operation_id").is_none(),
1575 "operation_id must not appear in serialized DetachedOpCompletion (CONTRACT-003)"
1576 );
1577 assert!(
1578 json.get("job_id").is_some(),
1579 "job_id must be the app-facing control noun"
1580 );
1581 }
1582}