1pub mod transport;
7
8use crate::event::AgentEvent;
9use crate::event::EventEnvelope;
10use crate::lifecycle::run_primitive::{ConversationAppend, RuntimeTurnMetadata};
11use crate::session::{PendingSystemContextAppend, SystemContextStageError};
12use crate::time_compat::SystemTime;
13#[cfg(target_arch = "wasm32")]
14use crate::tokio;
15use crate::types::{
16 ContentInput, HandlingMode, Message, RenderMetadata, RunResult, SessionId, ToolDef, Usage,
17};
18use crate::{
19 AgentToolDispatcher, BudgetLimits, HookRunOverrides, OutputSchema, PeerMeta, Provider, Session,
20 SessionLlmIdentity, ToolCategoryOverride,
21};
22use crate::{EventStream, StreamError};
23use async_trait::async_trait;
24use serde::{Deserialize, Serialize};
25use std::collections::BTreeMap;
26use std::collections::BTreeSet;
27use std::sync::Arc;
28use tokio::sync::mpsc;
29
30pub use crate::session::{
31 TranscriptEditError, TranscriptReplacement, TranscriptRewriteCommit, TranscriptRewriteReason,
32 TranscriptRewriteSelection,
33};
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum InitialTurnPolicy {
38 RunImmediately,
40 Defer,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
49#[serde(rename_all = "snake_case")]
50pub enum DeferredPromptPolicy {
51 #[default]
53 Discard,
54 Stage,
56}
57
58#[derive(Debug, thiserror::Error)]
60pub enum SessionError {
61 #[error("session not found: {id}")]
63 NotFound { id: SessionId },
64
65 #[error("session is busy: {id}")]
67 Busy { id: SessionId },
68
69 #[error("session persistence is disabled")]
71 PersistenceDisabled,
72
73 #[error("session compaction is disabled")]
75 CompactionDisabled,
76
77 #[error("no turn running on session: {id}")]
79 NotRunning { id: SessionId },
80
81 #[error("store error: {0}")]
83 Store(#[source] Box<dyn std::error::Error + Send + Sync>),
84
85 #[error("agent error: {0}")]
87 Agent(#[from] crate::error::AgentError),
88
89 #[error("{message}")]
91 FailedWithData {
92 message: String,
93 data: serde_json::Value,
94 },
95
96 #[error("unsupported: {0}")]
98 Unsupported(String),
99}
100
101impl SessionError {
102 pub fn code(&self) -> &'static str {
104 match self {
105 Self::NotFound { .. } => "SESSION_NOT_FOUND",
106 Self::Busy { .. } => "SESSION_BUSY",
107 Self::PersistenceDisabled => "SESSION_PERSISTENCE_DISABLED",
108 Self::CompactionDisabled => "SESSION_COMPACTION_DISABLED",
109 Self::NotRunning { .. } => "SESSION_NOT_RUNNING",
110 Self::Store(_) => "SESSION_STORE_ERROR",
111 Self::Unsupported(_) => "SESSION_UNSUPPORTED",
112 Self::Agent(_) => "AGENT_ERROR",
113 Self::FailedWithData { .. } => "SESSION_ERROR",
114 }
115 }
116
117 pub fn structured_data(&self) -> Option<serde_json::Value> {
118 match self {
119 Self::FailedWithData { data, .. } => Some(data.clone()),
120 _ => None,
121 }
122 }
123}
124
125#[derive(Debug, thiserror::Error)]
127pub enum SessionControlError {
128 #[error(transparent)]
130 Session(#[from] SessionError),
131
132 #[error("invalid system-context request: {message}")]
134 InvalidRequest { message: String },
135
136 #[error(
138 "system-context idempotency conflict on session {id}: key '{key}' already maps to different content"
139 )]
140 Conflict { id: SessionId, key: String },
141}
142
143impl SessionControlError {
144 pub fn code(&self) -> &'static str {
146 match self {
147 Self::Session(err) => err.code(),
148 Self::InvalidRequest { .. } => "INVALID_PARAMS",
149 Self::Conflict { .. } => "SESSION_SYSTEM_CONTEXT_CONFLICT",
150 }
151 }
152}
153
154impl SystemContextStageError {
155 pub fn into_control_error(self, id: &SessionId) -> SessionControlError {
157 match self {
158 Self::InvalidRequest(message) => SessionControlError::InvalidRequest { message },
159 Self::Conflict { key, .. } => SessionControlError::Conflict {
160 id: id.clone(),
161 key,
162 },
163 }
164 }
165}
166
167#[derive(Debug)]
169pub struct CreateSessionRequest {
170 pub model: String,
172 pub prompt: ContentInput,
174 pub render_metadata: Option<RenderMetadata>,
176 pub system_prompt: Option<String>,
178 pub max_tokens: Option<u32>,
180 pub event_tx: Option<mpsc::Sender<EventEnvelope<AgentEvent>>>,
182 pub skill_references: Option<Vec<crate::skills::SkillKey>>,
184 pub initial_turn: InitialTurnPolicy,
186 pub deferred_prompt_policy: DeferredPromptPolicy,
188 pub build: Option<SessionBuildOptions>,
190 pub labels: Option<BTreeMap<String, String>>,
192}
193
194impl CreateSessionRequest {
195 #[must_use]
198 pub fn surface_metadata(&self) -> crate::SurfaceMetadata {
199 crate::SurfaceMetadata::from_optional_parts(
200 self.labels.clone(),
201 self.build
202 .as_ref()
203 .and_then(|build| build.app_context.clone()),
204 )
205 }
206}
207
208#[derive(Clone)]
210pub struct SessionBuildOptions {
211 pub provider: Option<Provider>,
212 pub self_hosted_server_id: Option<String>,
213 pub output_schema: Option<OutputSchema>,
214 pub structured_output_retries: u32,
215 pub hooks_override: HookRunOverrides,
216 pub comms_name: Option<String>,
217 pub peer_meta: Option<PeerMeta>,
218 pub resume_session: Option<Session>,
219 pub budget_limits: Option<BudgetLimits>,
220 pub provider_params: Option<serde_json::Value>,
221 pub external_tools: Option<Arc<dyn AgentToolDispatcher>>,
222 pub recoverable_tool_defs: Option<Vec<crate::ToolDef>>,
225 pub blob_store_override: Option<Arc<dyn crate::BlobStore>>,
228 pub llm_client_override: Option<Arc<dyn std::any::Any + Send + Sync>>,
232 pub agent_llm_client_decorator: Option<crate::AgentLlmClientDecorator>,
237 pub override_builtins: ToolCategoryOverride,
240 pub override_shell: ToolCategoryOverride,
241 pub override_memory: ToolCategoryOverride,
242 pub override_schedule: ToolCategoryOverride,
244 pub override_workgraph: ToolCategoryOverride,
246 pub override_mob: ToolCategoryOverride,
247 pub override_image_generation: ToolCategoryOverride,
252 pub override_web_search: ToolCategoryOverride,
257 pub schedule_tools: Option<Arc<dyn AgentToolDispatcher>>,
262 pub workgraph_tools: Option<Arc<dyn AgentToolDispatcher>>,
264 pub preload_skills: Option<Vec<crate::skills::SkillKey>>,
265 pub realm_id: Option<String>,
266 pub instance_id: Option<String>,
267 pub backend: Option<String>,
268 pub config_generation: Option<u64>,
269 pub auth_binding: Option<crate::AuthBindingRef>,
272 pub keep_alive: bool,
275 pub checkpointer: Option<std::sync::Arc<dyn crate::checkpoint::SessionCheckpointer>>,
277 pub silent_comms_intents: Vec<String>,
280 pub max_inline_peer_notifications: Option<i32>,
288 pub app_context: Option<serde_json::Value>,
295 pub additional_instructions: Option<Vec<String>>,
298 pub initial_metadata_entries: BTreeMap<String, serde_json::Value>,
303 pub shell_env: Option<std::collections::HashMap<String, String>>,
307 pub call_timeout_override: crate::CallTimeoutOverride,
313 pub resume_override_mask: ResumeOverrideMask,
319 pub mob_tools: Option<Arc<dyn MobToolsFactory>>,
327 pub runtime_build_mode: crate::runtime_epoch::RuntimeBuildMode,
335 pub initial_turn_metadata: Option<RuntimeTurnMetadata>,
340 pub mob_tool_authority_context: Option<MobToolAuthorityContext>,
347}
348
349#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
355pub struct OpaquePrincipalToken(String);
356
357impl OpaquePrincipalToken {
358 pub fn new(token: impl Into<String>) -> Self {
359 Self(token.into())
360 }
361
362 pub fn generated() -> Self {
363 Self(uuid::Uuid::new_v4().to_string())
364 }
365
366 pub fn as_str(&self) -> &str {
367 &self.0
368 }
369}
370
371impl std::fmt::Display for OpaquePrincipalToken {
372 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
373 f.write_str(self.as_str())
374 }
375}
376
377#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
382pub struct MobToolCallerProvenance {
383 #[serde(default, skip_serializing_if = "Option::is_none")]
384 caller_session_id: Option<crate::SessionId>,
385 #[serde(default, skip_serializing_if = "Option::is_none")]
386 caller_mob_id: Option<String>,
387 #[serde(default, skip_serializing_if = "Option::is_none")]
388 caller_member_id: Option<String>,
389}
390
391impl MobToolCallerProvenance {
392 pub fn new() -> Self {
393 Self::default()
394 }
395
396 pub fn with_session_id(mut self, session_id: crate::SessionId) -> Self {
397 self.caller_session_id = Some(session_id);
398 self
399 }
400
401 pub fn with_mob_id(mut self, mob_id: impl Into<String>) -> Self {
402 self.caller_mob_id = Some(mob_id.into());
403 self
404 }
405
406 pub fn with_member_id(mut self, member_id: impl Into<String>) -> Self {
407 self.caller_member_id = Some(member_id.into());
408 self
409 }
410
411 pub fn caller_session_id(&self) -> Option<&crate::SessionId> {
412 self.caller_session_id.as_ref()
413 }
414
415 pub fn caller_mob_id(&self) -> Option<&str> {
416 self.caller_mob_id.as_deref()
417 }
418
419 pub fn caller_member_id(&self) -> Option<&str> {
420 self.caller_member_id.as_deref()
421 }
422}
423
424#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
430pub struct MobToolAuthorityContext {
431 principal_token: OpaquePrincipalToken,
432 can_create_mobs: bool,
433 #[serde(default)]
434 can_mutate_profiles: bool,
435 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
436 managed_mob_scope: BTreeSet<String>,
437 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
438 spawn_profile_scope: BTreeMap<String, BTreeSet<String>>,
439 #[serde(default, skip_serializing_if = "Option::is_none")]
440 caller_provenance: Option<MobToolCallerProvenance>,
441 #[serde(default, skip_serializing_if = "Option::is_none")]
442 audit_invocation_id: Option<String>,
443}
444
445impl MobToolAuthorityContext {
446 pub fn new(principal_token: OpaquePrincipalToken, can_create_mobs: bool) -> Self {
447 Self {
448 principal_token,
449 can_create_mobs,
450 can_mutate_profiles: can_create_mobs,
451 managed_mob_scope: BTreeSet::new(),
452 spawn_profile_scope: BTreeMap::new(),
453 caller_provenance: None,
454 audit_invocation_id: None,
455 }
456 }
457
458 pub fn create_only_generated() -> Self {
459 Self::new(OpaquePrincipalToken::generated(), true)
460 }
461
462 pub fn principal_token(&self) -> &OpaquePrincipalToken {
463 &self.principal_token
464 }
465
466 pub fn can_create_mobs(&self) -> bool {
467 self.can_create_mobs
468 }
469
470 pub fn can_mutate_profiles(&self) -> bool {
471 self.can_mutate_profiles
472 }
473
474 pub fn with_profile_mutation(mut self, allowed: bool) -> Self {
475 self.can_mutate_profiles = allowed;
476 self
477 }
478
479 pub fn managed_mob_scope(&self) -> &BTreeSet<String> {
480 &self.managed_mob_scope
481 }
482
483 pub fn caller_provenance(&self) -> Option<&MobToolCallerProvenance> {
484 self.caller_provenance.as_ref()
485 }
486
487 pub fn audit_invocation_id(&self) -> Option<&str> {
488 self.audit_invocation_id.as_deref()
489 }
490
491 pub fn can_manage_mob(&self, mob_id: &str) -> bool {
492 self.managed_mob_scope.contains(mob_id)
493 }
494
495 pub fn can_spawn_profile_in_mob(&self, mob_id: &str, profile: &str) -> bool {
496 self.can_manage_mob(mob_id)
497 || self
498 .spawn_profile_scope
499 .get(mob_id)
500 .is_some_and(|profiles| profiles.contains(profile))
501 }
502
503 pub fn can_spawn_any_profile_in_mob(&self, mob_id: &str) -> bool {
504 self.can_manage_mob(mob_id)
505 || self
506 .spawn_profile_scope
507 .get(mob_id)
508 .is_some_and(|profiles| !profiles.is_empty())
509 }
510
511 pub fn grant_manage_mob(mut self, mob_id: impl Into<String>) -> Self {
512 self.managed_mob_scope.insert(mob_id.into());
513 self
514 }
515
516 pub fn grant_spawn_profile_in_mob(
517 mut self,
518 mob_id: impl Into<String>,
519 profile: impl Into<String>,
520 ) -> Self {
521 self.spawn_profile_scope
522 .entry(mob_id.into())
523 .or_default()
524 .insert(profile.into());
525 self
526 }
527
528 pub fn grant_spawn_profiles_in_mob<I, S>(
529 mut self,
530 mob_id: impl Into<String>,
531 profiles: I,
532 ) -> Self
533 where
534 I: IntoIterator<Item = S>,
535 S: Into<String>,
536 {
537 self.spawn_profile_scope
538 .entry(mob_id.into())
539 .or_default()
540 .extend(profiles.into_iter().map(Into::into));
541 self
542 }
543
544 pub fn grant_manage_mob_in_place(&mut self, mob_id: String) {
549 self.managed_mob_scope.insert(mob_id);
550 }
551
552 pub fn with_managed_mob_scope<I, S>(mut self, mob_ids: I) -> Self
553 where
554 I: IntoIterator<Item = S>,
555 S: Into<String>,
556 {
557 self.managed_mob_scope = mob_ids.into_iter().map(Into::into).collect();
558 self
559 }
560
561 pub fn with_caller_provenance(mut self, caller_provenance: MobToolCallerProvenance) -> Self {
562 self.caller_provenance = Some(caller_provenance);
563 self
564 }
565
566 pub fn with_audit_invocation_id(mut self, audit_invocation_id: impl Into<String>) -> Self {
567 self.audit_invocation_id = Some(audit_invocation_id.into());
568 self
569 }
570}
571
572pub fn generated_create_only_mob_operator_authority(
578 enable_mob: ToolCategoryOverride,
579) -> Option<MobToolAuthorityContext> {
580 matches!(enable_mob, ToolCategoryOverride::Enable)
581 .then(MobToolAuthorityContext::create_only_generated)
582}
583
584pub fn resolve_mob_operator_access(
590 enable_mob: ToolCategoryOverride,
591 persisted_authority_context: Option<MobToolAuthorityContext>,
592) -> (ToolCategoryOverride, Option<MobToolAuthorityContext>) {
593 if matches!(enable_mob, ToolCategoryOverride::Disable) {
594 return (ToolCategoryOverride::Disable, None);
595 }
596
597 let authority_context = persisted_authority_context
598 .or_else(|| generated_create_only_mob_operator_authority(enable_mob));
599 let override_mob = if authority_context.is_some() {
600 ToolCategoryOverride::Enable
601 } else {
602 enable_mob
603 };
604
605 (override_mob, authority_context)
606}
607
608pub trait VisibleToolSnapshotProvider: Send + Sync {
613 fn snapshot_visible_tools(&self) -> Vec<Arc<ToolDef>>;
615}
616
617pub enum MobToolSnapshotContext {
623 ParentOwned(Arc<dyn VisibleToolSnapshotProvider>),
625 Standalone,
627}
628
629pub struct MobToolsBuildArgs {
631 pub session_id: crate::SessionId,
633 pub model: String,
635 pub authority_context: Option<MobToolAuthorityContext>,
641 pub effective_authority: Option<Arc<std::sync::RwLock<MobToolAuthorityContext>>>,
648 pub comms_name: Option<String>,
650 pub comms_runtime: Option<Arc<dyn crate::agent::CommsRuntime>>,
652 pub snapshot_context: MobToolSnapshotContext,
654}
655
656#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
662#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
663pub trait MobToolsFactory: Send + Sync {
664 async fn build_mob_tools(
666 &self,
667 args: MobToolsBuildArgs,
668 ) -> Result<Arc<dyn AgentToolDispatcher>, Box<dyn std::error::Error + Send + Sync>>;
669}
670
671#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
675pub struct ResumeOverrideMask {
676 pub model: bool,
677 pub provider: bool,
678 pub max_tokens: bool,
679 pub structured_output_retries: bool,
680 pub provider_params: bool,
681 pub auth_binding: bool,
682 pub override_builtins: bool,
683 pub override_shell: bool,
684 pub override_memory: bool,
685 pub override_schedule: bool,
686 pub override_workgraph: bool,
687 pub override_mob: bool,
688 pub override_image_generation: bool,
689 pub override_web_search: bool,
690 pub preload_skills: bool,
691 pub keep_alive: bool,
692 pub comms_name: bool,
693 pub peer_meta: bool,
694}
695
696impl SessionBuildOptions {
697 pub fn apply_persisted_mob_operator_access(
703 &mut self,
704 enable_mob: ToolCategoryOverride,
705 persisted_authority_context: Option<MobToolAuthorityContext>,
706 ) {
707 let (override_mob, authority_context) =
708 resolve_mob_operator_access(enable_mob, persisted_authority_context);
709 self.override_mob = override_mob;
710 self.mob_tool_authority_context = authority_context;
711 }
712
713 pub fn apply_generated_create_only_mob_operator_access(
720 &mut self,
721 enable_mob: ToolCategoryOverride,
722 ) {
723 self.apply_persisted_mob_operator_access(enable_mob, None);
724 }
725}
726
727impl Default for SessionBuildOptions {
728 fn default() -> Self {
729 Self {
730 provider: None,
731 self_hosted_server_id: None,
732 output_schema: None,
733 structured_output_retries: 2,
734 hooks_override: HookRunOverrides::default(),
735 comms_name: None,
736 peer_meta: None,
737 resume_session: None,
738 budget_limits: None,
741 provider_params: None,
742 external_tools: None,
743 recoverable_tool_defs: None,
744 blob_store_override: None,
745 llm_client_override: None,
746 agent_llm_client_decorator: None,
747 override_builtins: ToolCategoryOverride::Inherit,
748 override_shell: ToolCategoryOverride::Inherit,
749 override_memory: ToolCategoryOverride::Inherit,
750 override_schedule: ToolCategoryOverride::Inherit,
751 override_workgraph: ToolCategoryOverride::Inherit,
752 override_mob: ToolCategoryOverride::Inherit,
753 override_image_generation: ToolCategoryOverride::Inherit,
754 override_web_search: ToolCategoryOverride::Inherit,
755 schedule_tools: None,
756 workgraph_tools: None,
757 preload_skills: None,
758 realm_id: None,
759 instance_id: None,
760 backend: None,
761 config_generation: None,
762 auth_binding: None,
763 keep_alive: false,
764 checkpointer: None,
765 silent_comms_intents: Vec::new(),
766 max_inline_peer_notifications: None,
767 app_context: None,
768 additional_instructions: None,
769 initial_metadata_entries: BTreeMap::new(),
770 shell_env: None,
771 call_timeout_override: crate::CallTimeoutOverride::Inherit,
772 resume_override_mask: ResumeOverrideMask::default(),
773 mob_tools: None,
774 runtime_build_mode: crate::runtime_epoch::RuntimeBuildMode::StandaloneEphemeral,
775 initial_turn_metadata: None,
776 mob_tool_authority_context: None,
777 }
778 }
779}
780
781impl std::fmt::Debug for SessionBuildOptions {
782 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
783 f.debug_struct("SessionBuildOptions")
784 .field("provider", &self.provider)
785 .field("output_schema", &self.output_schema.is_some())
786 .field("structured_output_retries", &self.structured_output_retries)
787 .field("hooks_override", &self.hooks_override)
788 .field("comms_name", &self.comms_name)
789 .field("peer_meta", &self.peer_meta)
790 .field("resume_session", &self.resume_session.is_some())
791 .field("budget_limits", &self.budget_limits)
792 .field("provider_params", &self.provider_params.is_some())
793 .field("external_tools", &self.external_tools.is_some())
794 .field("recoverable_tool_defs", &self.recoverable_tool_defs)
795 .field("blob_store_override", &self.blob_store_override.is_some())
796 .field("llm_client_override", &self.llm_client_override.is_some())
797 .field(
798 "agent_llm_client_decorator",
799 &self.agent_llm_client_decorator.is_some(),
800 )
801 .field("override_builtins", &self.override_builtins)
802 .field("override_shell", &self.override_shell)
803 .field("override_memory", &self.override_memory)
804 .field("override_schedule", &self.override_schedule)
805 .field("override_workgraph", &self.override_workgraph)
806 .field("override_mob", &self.override_mob)
807 .field("schedule_tools", &self.schedule_tools.is_some())
808 .field("workgraph_tools", &self.workgraph_tools.is_some())
809 .field("preload_skills", &self.preload_skills)
810 .field("realm_id", &self.realm_id)
811 .field("instance_id", &self.instance_id)
812 .field("backend", &self.backend)
813 .field("config_generation", &self.config_generation)
814 .field("keep_alive", &self.keep_alive)
815 .field("checkpointer", &self.checkpointer.is_some())
816 .field("silent_comms_intents", &self.silent_comms_intents)
817 .field(
818 "max_inline_peer_notifications",
819 &self.max_inline_peer_notifications,
820 )
821 .field("app_context", &self.app_context.is_some())
822 .field("additional_instructions", &self.additional_instructions)
823 .field("initial_metadata_entries", &self.initial_metadata_entries)
824 .field("call_timeout_override", &self.call_timeout_override)
825 .field("resume_override_mask", &self.resume_override_mask)
826 .field("mob_tools", &self.mob_tools.is_some())
827 .field("runtime_build_mode", &self.runtime_build_mode)
828 .field(
829 "initial_turn_metadata",
830 &self.initial_turn_metadata.is_some(),
831 )
832 .field(
833 "mob_tool_authority_context",
834 &self.mob_tool_authority_context.is_some(),
835 )
836 .field("runtime_build_mode", &self.runtime_build_mode)
837 .finish()
838 }
839}
840
841#[derive(Debug)]
847pub struct StartTurnRuntimeSemantics {
848 pub render_metadata: Option<RenderMetadata>,
850 pub handling_mode: HandlingMode,
857 pub skill_references: Option<Vec<crate::skills::SkillKey>>,
859 pub flow_tool_overlay: Option<TurnToolOverlay>,
861 pub pre_turn_context_appends: Vec<PendingSystemContextAppend>,
864 pub typed_turn_appends: Vec<ConversationAppend>,
870 pub turn_metadata: Option<RuntimeTurnMetadata>,
876}
877
878impl Default for StartTurnRuntimeSemantics {
879 fn default() -> Self {
880 Self {
881 render_metadata: None,
882 handling_mode: HandlingMode::Queue,
883 skill_references: None,
884 flow_tool_overlay: None,
885 pre_turn_context_appends: Vec::new(),
886 typed_turn_appends: Vec::new(),
887 turn_metadata: None,
888 }
889 }
890}
891
892impl StartTurnRuntimeSemantics {
893 #[must_use]
894 pub fn new(
895 render_metadata: Option<RenderMetadata>,
896 handling_mode: HandlingMode,
897 skill_references: Option<Vec<crate::skills::SkillKey>>,
898 flow_tool_overlay: Option<TurnToolOverlay>,
899 pre_turn_context_appends: Vec<PendingSystemContextAppend>,
900 turn_metadata: Option<RuntimeTurnMetadata>,
901 ) -> Self {
902 Self {
903 render_metadata,
904 handling_mode,
905 skill_references,
906 flow_tool_overlay,
907 pre_turn_context_appends,
908 typed_turn_appends: Vec::new(),
909 turn_metadata,
910 }
911 }
912
913 #[must_use]
914 pub fn runtime_metadata(turn_metadata: RuntimeTurnMetadata) -> Self {
915 Self {
916 turn_metadata: Some(turn_metadata),
917 ..Self::default()
918 }
919 }
920
921 #[must_use]
922 pub fn with_typed_turn_appends(mut self, typed_turn_appends: Vec<ConversationAppend>) -> Self {
923 self.typed_turn_appends = typed_turn_appends;
924 self
925 }
926}
927
928#[derive(Debug)]
930pub struct StartTurnRequest {
931 pub prompt: ContentInput,
933 pub system_prompt: Option<String>,
938 pub event_tx: Option<mpsc::Sender<EventEnvelope<AgentEvent>>>,
940 pub runtime: StartTurnRuntimeSemantics,
942}
943
944#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
946pub struct AppendSystemContextRequest {
947 pub text: String,
948 #[serde(default, skip_serializing_if = "Option::is_none")]
949 pub source: Option<String>,
950 #[serde(default, skip_serializing_if = "Option::is_none")]
951 pub idempotency_key: Option<String>,
952}
953
954#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
956pub struct AppendSystemContextResult {
957 pub status: AppendSystemContextStatus,
958}
959
960#[derive(Debug, Clone, Serialize, Deserialize)]
962pub struct StageToolResultsRequest {
963 pub results: Vec<crate::ToolResult>,
964}
965
966#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
968pub struct StageToolResultsResult {
969 pub accepted_result_count: usize,
970}
971
972#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
974#[serde(rename_all = "snake_case")]
975pub enum AppendSystemContextStatus {
976 Applied,
977 Staged,
978 Duplicate,
979}
980
981#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
983#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
984pub struct TurnToolOverlay {
985 #[serde(default)]
987 pub allowed_tools: Option<Vec<String>>,
988 #[serde(default)]
990 pub blocked_tools: Option<Vec<String>>,
991 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
993 #[cfg_attr(feature = "schema", schemars(skip))]
994 pub dispatch_context: std::collections::BTreeMap<String, serde_json::Value>,
995}
996
997impl TurnToolOverlay {
998 pub fn without_dispatch_context(mut self) -> Self {
1000 self.dispatch_context.clear();
1001 self
1002 }
1003}
1004
1005#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1007#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1008pub struct PublicTurnToolOverlay {
1009 #[serde(default)]
1011 pub allowed_tools: Option<Vec<String>>,
1012 #[serde(default)]
1014 pub blocked_tools: Option<Vec<String>>,
1015}
1016
1017impl From<PublicTurnToolOverlay> for TurnToolOverlay {
1018 fn from(value: PublicTurnToolOverlay) -> Self {
1019 Self {
1020 allowed_tools: value.allowed_tools,
1021 blocked_tools: value.blocked_tools,
1022 dispatch_context: BTreeMap::new(),
1023 }
1024 }
1025}
1026
1027#[derive(Debug, Default)]
1029pub struct SessionQuery {
1030 pub limit: Option<usize>,
1032 pub offset: Option<usize>,
1034 pub labels: Option<BTreeMap<String, String>>,
1036}
1037
1038#[derive(Debug, Clone, Serialize, Deserialize)]
1042pub struct SessionSummary {
1043 pub session_id: SessionId,
1044 pub created_at: SystemTime,
1045 pub updated_at: SystemTime,
1046 pub message_count: usize,
1047 pub total_tokens: u64,
1048 pub is_active: bool,
1049 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1050 pub labels: BTreeMap<String, String>,
1051}
1052
1053#[derive(Debug, Clone, Serialize, Deserialize)]
1055pub struct SessionInfo {
1056 pub session_id: SessionId,
1057 pub created_at: SystemTime,
1058 pub updated_at: SystemTime,
1059 pub message_count: usize,
1060 pub is_active: bool,
1061 pub model: String,
1062 pub provider: Provider,
1063 pub last_assistant_text: Option<String>,
1064 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1065 pub labels: BTreeMap<String, String>,
1066}
1067
1068#[derive(Debug, Clone, Serialize, Deserialize)]
1070pub struct SessionUsage {
1071 pub total_tokens: u64,
1072 pub usage: Usage,
1073}
1074
1075#[derive(Debug, Clone, Serialize, Deserialize)]
1078pub struct SessionView {
1079 pub state: SessionInfo,
1080 pub billing: SessionUsage,
1081}
1082
1083impl SessionView {
1084 pub fn session_id(&self) -> &SessionId {
1086 &self.state.session_id
1087 }
1088}
1089
1090#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
1092pub struct SessionHistoryQuery {
1093 pub offset: usize,
1095 #[serde(default, skip_serializing_if = "Option::is_none")]
1097 pub limit: Option<usize>,
1098}
1099
1100#[derive(Debug, Clone, Serialize, Deserialize)]
1102pub struct SessionHistoryPage {
1103 pub session_id: SessionId,
1104 pub message_count: usize,
1105 pub offset: usize,
1106 #[serde(default, skip_serializing_if = "Option::is_none")]
1107 pub limit: Option<usize>,
1108 pub has_more: bool,
1109 pub messages: Vec<Message>,
1110}
1111
1112impl SessionHistoryPage {
1113 pub fn from_messages(
1115 session_id: SessionId,
1116 messages: &[Message],
1117 query: SessionHistoryQuery,
1118 ) -> Self {
1119 let message_count = messages.len();
1120 let start = query.offset.min(message_count);
1121 let end = match query.limit {
1122 Some(limit) => start.saturating_add(limit).min(message_count),
1123 None => message_count,
1124 };
1125 Self {
1126 session_id,
1127 message_count,
1128 offset: start,
1129 limit: query.limit,
1130 has_more: end < message_count,
1131 messages: messages[start..end].to_vec(),
1132 }
1133 }
1134}
1135
1136#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1138pub struct SessionTranscriptRevisionQuery {
1139 pub revision: String,
1140 pub offset: usize,
1142 #[serde(default, skip_serializing_if = "Option::is_none")]
1144 pub limit: Option<usize>,
1145}
1146
1147#[derive(Debug, Clone, Serialize, Deserialize)]
1149pub struct SessionTranscriptRevisionPage {
1150 pub session_id: SessionId,
1151 pub revision: String,
1152 pub head_revision: String,
1153 pub message_count: usize,
1154 pub offset: usize,
1155 #[serde(default, skip_serializing_if = "Option::is_none")]
1156 pub limit: Option<usize>,
1157 pub has_more: bool,
1158 pub messages: Vec<Message>,
1159}
1160
1161impl SessionTranscriptRevisionPage {
1162 pub fn from_messages(
1164 session_id: SessionId,
1165 revision: String,
1166 head_revision: String,
1167 messages: &[Message],
1168 offset: usize,
1169 limit: Option<usize>,
1170 ) -> Self {
1171 let message_count = messages.len();
1172 let start = offset.min(message_count);
1173 let end = match limit {
1174 Some(limit) => start.saturating_add(limit).min(message_count),
1175 None => message_count,
1176 };
1177 Self {
1178 session_id,
1179 revision,
1180 head_revision,
1181 message_count,
1182 offset: start,
1183 limit,
1184 has_more: end < message_count,
1185 messages: messages[start..end].to_vec(),
1186 }
1187 }
1188}
1189
1190#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1193#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1194#[serde(rename_all = "snake_case")]
1195pub enum TranscriptEditRunningBehavior {
1196 #[default]
1198 Reject,
1199}
1200
1201#[derive(Debug, Clone, Serialize, Deserialize)]
1203#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1204pub struct SessionForkAtRequest {
1205 pub message_index: usize,
1206 #[serde(default)]
1207 pub running_behavior: TranscriptEditRunningBehavior,
1208}
1209
1210#[derive(Debug, Clone, Serialize, Deserialize)]
1212#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1213pub struct SessionForkReplaceRequest {
1214 pub message_index: usize,
1215 #[cfg_attr(feature = "schema", schemars(with = "serde_json::Value"))]
1216 pub replacement: TranscriptReplacement,
1217 #[serde(default)]
1218 pub running_behavior: TranscriptEditRunningBehavior,
1219}
1220
1221#[derive(Debug, Clone, Serialize, Deserialize)]
1223#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1224pub struct SessionForkResult {
1225 #[cfg_attr(feature = "schema", schemars(with = "String"))]
1226 pub source_session_id: SessionId,
1227 #[cfg_attr(feature = "schema", schemars(with = "String"))]
1228 pub session_id: SessionId,
1229 pub message_count: usize,
1230 #[serde(default, skip_serializing_if = "Option::is_none")]
1231 pub session_ref: Option<String>,
1232}
1233
1234#[derive(Debug, Clone, Serialize, Deserialize)]
1236#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1237pub struct SessionTranscriptRewriteRequest {
1238 pub selection: TranscriptRewriteSelection,
1239 #[cfg_attr(feature = "schema", schemars(with = "Vec<serde_json::Value>"))]
1240 pub replacement: Vec<Message>,
1241 pub reason: TranscriptRewriteReason,
1242 #[serde(default, skip_serializing_if = "Option::is_none")]
1243 pub actor: Option<String>,
1244 #[serde(default, skip_serializing_if = "Option::is_none")]
1245 pub expected_parent_revision: Option<String>,
1246 #[serde(default)]
1247 pub running_behavior: TranscriptEditRunningBehavior,
1248}
1249
1250#[derive(Debug, Clone, Serialize, Deserialize)]
1252#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1253pub struct SessionTranscriptRestoreRevisionRequest {
1254 pub revision: String,
1255 pub reason: TranscriptRewriteReason,
1256 #[serde(default, skip_serializing_if = "Option::is_none")]
1257 pub actor: Option<String>,
1258 #[serde(default, skip_serializing_if = "Option::is_none")]
1259 pub expected_parent_revision: Option<String>,
1260 #[serde(default)]
1261 pub running_behavior: TranscriptEditRunningBehavior,
1262}
1263
1264#[derive(Debug, Clone, Serialize, Deserialize)]
1266#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1267pub struct SessionTranscriptRewriteResult {
1268 #[cfg_attr(feature = "schema", schemars(with = "String"))]
1269 pub session_id: SessionId,
1270 pub parent_revision: String,
1271 pub revision: String,
1272 pub message_count: usize,
1273 pub commit: TranscriptRewriteCommit,
1274}
1275
1276impl TranscriptEditError {
1277 pub fn into_session_error(self) -> SessionError {
1280 SessionError::Agent(crate::error::AgentError::ConfigError(self.to_string()))
1281 }
1282}
1283
1284#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
1289#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
1290pub trait SessionService: Send + Sync {
1291 async fn create_session(&self, req: CreateSessionRequest) -> Result<RunResult, SessionError>;
1293
1294 async fn start_turn(
1296 &self,
1297 id: &SessionId,
1298 req: StartTurnRequest,
1299 ) -> Result<RunResult, SessionError>;
1300
1301 async fn interrupt(&self, id: &SessionId) -> Result<(), SessionError>;
1305
1306 async fn cancel_after_boundary(&self, _id: &SessionId) -> Result<(), SessionError> {
1310 Err(SessionError::Unsupported(
1311 "cancel_after_boundary".to_string(),
1312 ))
1313 }
1314
1315 async fn set_session_client(
1322 &self,
1323 _id: &SessionId,
1324 _client: std::sync::Arc<dyn crate::AgentLlmClient>,
1325 ) -> Result<(), SessionError> {
1326 Err(SessionError::Unsupported("set_session_client".to_string()))
1327 }
1328
1329 async fn hot_swap_session_llm_identity(
1336 &self,
1337 _id: &SessionId,
1338 _client: std::sync::Arc<dyn crate::AgentLlmClient>,
1339 _identity: SessionLlmIdentity,
1340 _request_policy: crate::SessionLlmRequestPolicy,
1341 ) -> Result<(), SessionError> {
1342 Err(SessionError::Unsupported(
1343 "hot_swap_session_llm_identity".to_string(),
1344 ))
1345 }
1346
1347 async fn set_session_tool_visibility_state(
1352 &self,
1353 _id: &SessionId,
1354 _state: Option<crate::SessionToolVisibilityState>,
1355 ) -> Result<(), SessionError> {
1356 Err(SessionError::Unsupported(
1357 "set_session_tool_visibility_state".to_string(),
1358 ))
1359 }
1360
1361 async fn update_session_keep_alive(
1367 &self,
1368 _id: &SessionId,
1369 _keep_alive: bool,
1370 ) -> Result<(), SessionError> {
1371 Err(SessionError::Unsupported(
1372 "update_session_keep_alive".to_string(),
1373 ))
1374 }
1375
1376 async fn update_session_mob_authority_context(
1382 &self,
1383 _id: &SessionId,
1384 _authority_context: Option<MobToolAuthorityContext>,
1385 ) -> Result<(), SessionError> {
1386 Err(SessionError::Unsupported(
1387 "update_session_mob_authority_context".to_string(),
1388 ))
1389 }
1390
1391 async fn has_live_session(&self, _id: &SessionId) -> Result<bool, SessionError> {
1397 Err(SessionError::Unsupported("has_live_session".to_string()))
1398 }
1399
1400 async fn set_session_tool_filter(
1406 &self,
1407 _id: &SessionId,
1408 _filter: crate::ToolFilter,
1409 ) -> Result<(), SessionError> {
1410 Err(SessionError::Unsupported(
1411 "set_session_tool_filter".to_string(),
1412 ))
1413 }
1414
1415 async fn read(&self, id: &SessionId) -> Result<SessionView, SessionError>;
1417
1418 async fn list(&self, query: SessionQuery) -> Result<Vec<SessionSummary>, SessionError>;
1420
1421 async fn archive(&self, id: &SessionId) -> Result<(), SessionError>;
1423
1424 async fn subscribe_session_events(&self, id: &SessionId) -> Result<EventStream, StreamError> {
1428 Err(StreamError::NotFound(format!("session {id}")))
1429 }
1430}
1431
1432#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
1438#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
1439pub trait SessionServiceCommsExt: SessionService {
1440 async fn comms_runtime(
1442 &self,
1443 _session_id: &SessionId,
1444 ) -> Option<Arc<dyn crate::agent::CommsRuntime>> {
1445 None
1446 }
1447
1448 async fn event_injector(
1450 &self,
1451 session_id: &SessionId,
1452 ) -> Option<Arc<dyn crate::EventInjector>> {
1453 self.comms_runtime(session_id)
1454 .await
1455 .and_then(|runtime| runtime.event_injector())
1456 }
1457
1458 #[doc(hidden)]
1460 async fn interaction_event_injector(
1461 &self,
1462 session_id: &SessionId,
1463 ) -> Option<Arc<dyn crate::event_injector::SubscribableInjector>> {
1464 self.comms_runtime(session_id)
1465 .await
1466 .and_then(|runtime| runtime.interaction_event_injector())
1467 }
1468}
1469
1470#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
1475#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
1476pub trait SessionServiceControlExt: SessionService {
1477 async fn append_system_context(
1483 &self,
1484 id: &SessionId,
1485 req: AppendSystemContextRequest,
1486 ) -> Result<AppendSystemContextResult, SessionControlError>;
1487
1488 async fn stage_tool_results(
1494 &self,
1495 id: &SessionId,
1496 req: StageToolResultsRequest,
1497 ) -> Result<StageToolResultsResult, SessionError> {
1498 let _ = (id, req);
1499 Err(SessionError::Unsupported("stage_tool_results".to_string()))
1500 }
1501}
1502
1503#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
1508#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
1509pub trait SessionServiceHistoryExt: SessionService {
1510 async fn read_history(
1515 &self,
1516 id: &SessionId,
1517 query: SessionHistoryQuery,
1518 ) -> Result<SessionHistoryPage, SessionError>;
1519
1520 async fn read_transcript_revision(
1522 &self,
1523 id: &SessionId,
1524 query: SessionTranscriptRevisionQuery,
1525 ) -> Result<SessionTranscriptRevisionPage, SessionError> {
1526 let _ = (id, query);
1527 Err(SessionError::Unsupported(
1528 "read_transcript_revision".to_string(),
1529 ))
1530 }
1531}
1532
1533#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
1539#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
1540pub trait SessionServiceTranscriptEditExt: SessionService {
1541 async fn fork_session_at(
1543 &self,
1544 id: &SessionId,
1545 req: SessionForkAtRequest,
1546 ) -> Result<SessionForkResult, SessionError> {
1547 let _ = (id, req);
1548 Err(SessionError::Unsupported("fork_session_at".to_string()))
1549 }
1550
1551 async fn fork_session_replace(
1553 &self,
1554 id: &SessionId,
1555 req: SessionForkReplaceRequest,
1556 ) -> Result<SessionForkResult, SessionError> {
1557 let _ = (id, req);
1558 Err(SessionError::Unsupported(
1559 "fork_session_replace".to_string(),
1560 ))
1561 }
1562
1563 async fn rewrite_session_transcript(
1565 &self,
1566 id: &SessionId,
1567 req: SessionTranscriptRewriteRequest,
1568 ) -> Result<SessionTranscriptRewriteResult, SessionError> {
1569 let _ = (id, req);
1570 Err(SessionError::Unsupported(
1571 "rewrite_session_transcript".to_string(),
1572 ))
1573 }
1574
1575 async fn restore_session_transcript_revision(
1577 &self,
1578 id: &SessionId,
1579 req: SessionTranscriptRestoreRevisionRequest,
1580 ) -> Result<SessionTranscriptRewriteResult, SessionError> {
1581 let _ = (id, req);
1582 Err(SessionError::Unsupported(
1583 "restore_session_transcript_revision".to_string(),
1584 ))
1585 }
1586}
1587
1588impl dyn SessionService {
1590 pub fn into_arc(self: Box<Self>) -> Arc<dyn SessionService> {
1592 Arc::from(self)
1593 }
1594}
1595
1596#[cfg(test)]
1597#[allow(
1598 clippy::unimplemented,
1599 clippy::unwrap_used,
1600 clippy::expect_used,
1601 clippy::panic
1602)]
1603mod tests {
1604 use super::*;
1605
1606 struct UnsupportedSessionService;
1607
1608 #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
1609 #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
1610 impl SessionService for UnsupportedSessionService {
1611 async fn create_session(
1612 &self,
1613 _req: CreateSessionRequest,
1614 ) -> Result<RunResult, SessionError> {
1615 unimplemented!()
1616 }
1617
1618 async fn start_turn(
1619 &self,
1620 _id: &SessionId,
1621 _req: StartTurnRequest,
1622 ) -> Result<RunResult, SessionError> {
1623 unimplemented!()
1624 }
1625
1626 async fn interrupt(&self, _id: &SessionId) -> Result<(), SessionError> {
1627 unimplemented!()
1628 }
1629
1630 async fn read(&self, _id: &SessionId) -> Result<SessionView, SessionError> {
1631 unimplemented!()
1632 }
1633
1634 async fn list(&self, _query: SessionQuery) -> Result<Vec<SessionSummary>, SessionError> {
1635 unimplemented!()
1636 }
1637
1638 async fn archive(&self, _id: &SessionId) -> Result<(), SessionError> {
1639 unimplemented!()
1640 }
1641 }
1642
1643 #[tokio::test]
1644 async fn has_live_session_defaults_to_unsupported() {
1645 let service = UnsupportedSessionService;
1646 let err = service
1647 .has_live_session(&SessionId::new())
1648 .await
1649 .expect_err("default implementation should fail loudly");
1650 assert!(matches!(err, SessionError::Unsupported(name) if name == "has_live_session"));
1651 }
1652
1653 #[test]
1654 fn grant_manage_mob_in_place_adds_mob_id() {
1655 let mut ctx = MobToolAuthorityContext::create_only_generated();
1656 ctx.grant_manage_mob_in_place("mob-1".into());
1657 assert!(ctx.managed_mob_scope.contains("mob-1"));
1658 }
1659
1660 #[test]
1661 fn grant_manage_mob_in_place_is_idempotent() {
1662 let mut ctx = MobToolAuthorityContext::create_only_generated();
1663 ctx.grant_manage_mob_in_place("mob-1".into());
1664 ctx.grant_manage_mob_in_place("mob-1".into());
1665 assert_eq!(ctx.managed_mob_scope.len(), 1);
1666 }
1667
1668 #[test]
1669 fn grant_manage_mob_in_place_accumulates() {
1670 let mut ctx = MobToolAuthorityContext::create_only_generated();
1671 ctx.grant_manage_mob_in_place("mob-1".into());
1672 ctx.grant_manage_mob_in_place("mob-2".into());
1673 assert!(ctx.managed_mob_scope.contains("mob-1"));
1674 assert!(ctx.managed_mob_scope.contains("mob-2"));
1675 assert_eq!(ctx.managed_mob_scope.len(), 2);
1676 }
1677
1678 #[test]
1679 fn spawn_profile_scope_allows_only_granted_profile_without_manage_scope() {
1680 let ctx = MobToolAuthorityContext::create_only_generated()
1681 .grant_spawn_profile_in_mob("mob-1", "investigator");
1682
1683 assert!(ctx.can_spawn_any_profile_in_mob("mob-1"));
1684 assert!(ctx.can_spawn_profile_in_mob("mob-1", "investigator"));
1685 assert!(!ctx.can_spawn_profile_in_mob("mob-1", "writer"));
1686 assert!(!ctx.can_manage_mob("mob-1"));
1687 }
1688
1689 struct MockSnapshotProvider {
1690 tools: Vec<Arc<ToolDef>>,
1691 }
1692
1693 impl VisibleToolSnapshotProvider for MockSnapshotProvider {
1694 fn snapshot_visible_tools(&self) -> Vec<Arc<ToolDef>> {
1695 self.tools.clone()
1696 }
1697 }
1698
1699 #[test]
1700 fn mob_tool_snapshot_context_standalone() {
1701 let ctx = MobToolSnapshotContext::Standalone;
1702 assert!(matches!(ctx, MobToolSnapshotContext::Standalone));
1703 }
1704
1705 #[test]
1706 fn mob_tool_snapshot_context_parent_owned_returns_tools() {
1707 let tools = vec![Arc::new(ToolDef {
1708 name: "test_tool".into(),
1709 description: "a test".to_string(),
1710 input_schema: serde_json::json!({"type": "object"}),
1711 provenance: None,
1712 })];
1713 let provider = Arc::new(MockSnapshotProvider { tools });
1714 let ctx = MobToolSnapshotContext::ParentOwned(provider);
1715 match ctx {
1716 MobToolSnapshotContext::ParentOwned(p) => {
1717 let snapshot = p.snapshot_visible_tools();
1718 assert_eq!(snapshot.len(), 1);
1719 assert_eq!(snapshot[0].name, "test_tool");
1720 }
1721 MobToolSnapshotContext::Standalone => panic!("expected ParentOwned"),
1722 }
1723 }
1724}