Skip to main content

meerkat_core/service/
mod.rs

1//! SessionService trait — canonical lifecycle abstraction.
2//!
3//! All surfaces (CLI, REST, MCP Server, JSON-RPC) route through `SessionService`.
4//! Implementations may be ephemeral (in-memory only) or persistent (backed by a store).
5
6pub 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::{TranscriptEditError, TranscriptReplacement};
31
32/// Controls whether `create_session()` should execute an initial turn.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum InitialTurnPolicy {
35    /// Run the initial turn immediately as part of session creation.
36    RunImmediately,
37    /// Register the session and return without running an initial turn.
38    ///
39    /// `CreateSessionRequest::deferred_prompt_policy` determines whether the
40    /// create-time prompt is discarded or staged for the first later turn.
41    Defer,
42}
43
44/// How a deferred create request treats its create-time prompt.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
46#[serde(rename_all = "snake_case")]
47pub enum DeferredPromptPolicy {
48    /// Register the session only; the caller will supply the first runtime input separately.
49    #[default]
50    Discard,
51    /// Persist the create-time prompt and merge it into the first later turn.
52    Stage,
53}
54
55/// Errors returned by `SessionService` methods.
56#[derive(Debug, thiserror::Error)]
57pub enum SessionError {
58    /// The requested session does not exist.
59    #[error("session not found: {id}")]
60    NotFound { id: SessionId },
61
62    /// A turn is already in progress on this session.
63    #[error("session is busy: {id}")]
64    Busy { id: SessionId },
65
66    /// The operation requires persistence but the `session-store` feature is disabled.
67    #[error("session persistence is disabled")]
68    PersistenceDisabled,
69
70    /// The operation requires compaction but the `session-compaction` feature is disabled.
71    #[error("session compaction is disabled")]
72    CompactionDisabled,
73
74    /// No turn is currently running on this session.
75    #[error("no turn running on session: {id}")]
76    NotRunning { id: SessionId },
77
78    /// A session store operation failed.
79    #[error("store error: {0}")]
80    Store(#[source] Box<dyn std::error::Error + Send + Sync>),
81
82    /// An agent-level error occurred during execution.
83    #[error("agent error: {0}")]
84    Agent(#[from] crate::error::AgentError),
85
86    /// The operation failed with structured error data for protocol surfaces.
87    #[error("{message}")]
88    FailedWithData {
89        message: String,
90        data: serde_json::Value,
91    },
92
93    /// The requested operation is not supported by this session service.
94    #[error("unsupported: {0}")]
95    Unsupported(String),
96}
97
98impl SessionError {
99    /// Return a stable error code string for wire formats.
100    pub fn code(&self) -> &'static str {
101        match self {
102            Self::NotFound { .. } => "SESSION_NOT_FOUND",
103            Self::Busy { .. } => "SESSION_BUSY",
104            Self::PersistenceDisabled => "SESSION_PERSISTENCE_DISABLED",
105            Self::CompactionDisabled => "SESSION_COMPACTION_DISABLED",
106            Self::NotRunning { .. } => "SESSION_NOT_RUNNING",
107            Self::Store(_) => "SESSION_STORE_ERROR",
108            Self::Unsupported(_) => "SESSION_UNSUPPORTED",
109            Self::Agent(_) => "AGENT_ERROR",
110            Self::FailedWithData { .. } => "SESSION_ERROR",
111        }
112    }
113
114    pub fn structured_data(&self) -> Option<serde_json::Value> {
115        match self {
116            Self::FailedWithData { data, .. } => Some(data.clone()),
117            _ => None,
118        }
119    }
120}
121
122/// Errors returned by session control-plane mutation methods.
123#[derive(Debug, thiserror::Error)]
124pub enum SessionControlError {
125    /// A lifecycle/session-store error occurred while handling the control request.
126    #[error(transparent)]
127    Session(#[from] SessionError),
128
129    /// The control request was malformed.
130    #[error("invalid system-context request: {message}")]
131    InvalidRequest { message: String },
132
133    /// The idempotency key was replayed with different request content.
134    #[error(
135        "system-context idempotency conflict on session {id}: key '{key}' already maps to different content"
136    )]
137    Conflict { id: SessionId, key: String },
138}
139
140impl SessionControlError {
141    /// Return a stable error code string for wire formats.
142    pub fn code(&self) -> &'static str {
143        match self {
144            Self::Session(err) => err.code(),
145            Self::InvalidRequest { .. } => "INVALID_PARAMS",
146            Self::Conflict { .. } => "SESSION_SYSTEM_CONTEXT_CONFLICT",
147        }
148    }
149}
150
151impl SystemContextStageError {
152    /// Convert a stage-time state conflict into a surface-level control error.
153    pub fn into_control_error(self, id: &SessionId) -> SessionControlError {
154        match self {
155            Self::InvalidRequest(message) => SessionControlError::InvalidRequest { message },
156            Self::Conflict { key, .. } => SessionControlError::Conflict {
157                id: id.clone(),
158                key,
159            },
160        }
161    }
162}
163
164/// Request to create a new session and run the first turn.
165#[derive(Debug)]
166pub struct CreateSessionRequest {
167    /// Model name (e.g. "claude-opus-4-6").
168    pub model: String,
169    /// Initial user prompt (text or multimodal).
170    pub prompt: ContentInput,
171    /// Optional normalized rendering metadata for the initial prompt.
172    pub render_metadata: Option<RenderMetadata>,
173    /// Optional system prompt override.
174    pub system_prompt: Option<String>,
175    /// Max tokens per LLM turn.
176    pub max_tokens: Option<u32>,
177    /// Channel for streaming events during the turn.
178    pub event_tx: Option<mpsc::Sender<EventEnvelope<AgentEvent>>>,
179    /// Canonical SkillKeys to resolve and inject for the first turn.
180    pub skill_references: Option<Vec<crate::skills::SkillKey>>,
181    /// Initial turn behavior for this session creation call.
182    pub initial_turn: InitialTurnPolicy,
183    /// How to treat `prompt` when `initial_turn == Defer`.
184    pub deferred_prompt_policy: DeferredPromptPolicy,
185    /// Optional extended build options for factory-backed builders.
186    pub build: Option<SessionBuildOptions>,
187    /// Optional key-value labels attached at session creation.
188    pub labels: Option<BTreeMap<String, String>>,
189}
190
191impl CreateSessionRequest {
192    /// Compose the existing service-level labels and build app-context into the
193    /// shared surface metadata contract.
194    #[must_use]
195    pub fn surface_metadata(&self) -> crate::SurfaceMetadata {
196        crate::SurfaceMetadata::from_optional_parts(
197            self.labels.clone(),
198            self.build
199                .as_ref()
200                .and_then(|build| build.app_context.clone()),
201        )
202    }
203}
204
205/// Optional build-time options used by factory-backed session builders.
206#[derive(Clone)]
207pub struct SessionBuildOptions {
208    pub provider: Option<Provider>,
209    pub self_hosted_server_id: Option<String>,
210    pub output_schema: Option<OutputSchema>,
211    pub structured_output_retries: u32,
212    pub hooks_override: HookRunOverrides,
213    pub comms_name: Option<String>,
214    pub peer_meta: Option<PeerMeta>,
215    pub resume_session: Option<Session>,
216    pub budget_limits: Option<BudgetLimits>,
217    pub provider_params: Option<serde_json::Value>,
218    pub external_tools: Option<Arc<dyn AgentToolDispatcher>>,
219    /// Serializable tool definitions used to reconstruct recoverable
220    /// surface-owned dispatchers during session resume/rebuild.
221    pub recoverable_tool_defs: Option<Vec<crate::ToolDef>>,
222    /// Blob store used to externalize durable image content and hydrate refs
223    /// back to bytes at execution seams.
224    pub blob_store_override: Option<Arc<dyn crate::BlobStore>>,
225    /// Opaque transport for an optional per-request LLM override.
226    ///
227    /// Factory builders may downcast this to their concrete client trait.
228    pub llm_client_override: Option<Arc<dyn std::any::Any + Send + Sync>>,
229    /// Optional wrapper applied to the final agent-facing LLM client.
230    ///
231    /// This is intentionally provider-agnostic and runs after raw clients are
232    /// adapted into [`AgentLlmClient`].
233    pub agent_llm_client_decorator: Option<crate::AgentLlmClientDecorator>,
234    // NOTE: ops_lifecycle_override was removed in Phase 3.
235    // Use runtime_build_mode instead.
236    pub override_builtins: ToolCategoryOverride,
237    pub override_shell: ToolCategoryOverride,
238    pub override_memory: ToolCategoryOverride,
239    /// Per-build override for the factory-level scheduler capability.
240    pub override_schedule: ToolCategoryOverride,
241    /// Per-build override for the factory-level WorkGraph capability.
242    pub override_workgraph: ToolCategoryOverride,
243    pub override_mob: ToolCategoryOverride,
244    /// Per-build override for assistant image generation visibility.
245    ///
246    /// `Inherit` means "visible when the session-owned image-generation
247    /// substrate is available"; `Disable` hides the tool even when wired.
248    pub override_image_generation: ToolCategoryOverride,
249    /// Per-build override for Meerkat-owned fallback web search visibility.
250    ///
251    /// `Inherit` keeps the fallback hidden. `Enable` explicitly exposes the
252    /// fallback when the active model lacks native provider web search.
253    pub override_web_search: ToolCategoryOverride,
254    /// Agent-facing scheduler tools supplied by the embedding surface.
255    ///
256    /// Scheduler remains surface-owned. This dispatcher only controls
257    /// tool visibility/composition for the built agent.
258    pub schedule_tools: Option<Arc<dyn AgentToolDispatcher>>,
259    /// Agent-facing WorkGraph tools supplied by the embedding surface.
260    pub workgraph_tools: Option<Arc<dyn AgentToolDispatcher>>,
261    pub preload_skills: Option<Vec<crate::skills::SkillKey>>,
262    pub realm_id: Option<String>,
263    pub instance_id: Option<String>,
264    pub backend: Option<String>,
265    pub config_generation: Option<u64>,
266    /// Realm-scoped auth binding (Phase 3 provider-auth redesign).
267    /// Flows into `AgentBuildConfig.auth_binding` via `FactoryAgentBuilder`.
268    pub auth_binding: Option<crate::AuthBindingRef>,
269    /// Whether this session runs as a keep-alive (long-running, interrupt-to-stop)
270    /// agent. Surfaces use this to decide blocking vs fire-and-return semantics.
271    pub keep_alive: bool,
272    /// Optional session checkpointer for keep-alive persistence.
273    pub checkpointer: Option<std::sync::Arc<dyn crate::checkpoint::SessionCheckpointer>>,
274    /// Comms intents that should be silently injected into the session
275    /// without triggering an LLM turn.
276    pub silent_comms_intents: Vec<String>,
277    /// Maximum peer-count threshold for inline peer lifecycle context injection.
278    ///
279    /// - `None`: use runtime default
280    /// - `0`: never inline peer lifecycle notifications
281    /// - `-1`: always inline peer lifecycle notifications
282    /// - `>0`: inline only when post-drain peer count is <= threshold
283    /// - `<-1`: invalid
284    pub max_inline_peer_notifications: Option<i32>,
285    /// Opaque application context passed through to custom `SessionAgentBuilder`
286    /// implementations. Not consumed by the standard build pipeline.
287    ///
288    /// Uses `Value` rather than `Box<RawValue>` because `SessionBuildOptions`
289    /// must be `Clone` and `Box<RawValue>` does not implement `Clone`.
290    /// Same tradeoff as `provider_params`.
291    pub app_context: Option<serde_json::Value>,
292    /// Additional instruction sections appended to the system prompt after skill
293    /// assembly, before tool instructions. Order preserved.
294    pub additional_instructions: Option<Vec<String>>,
295    /// Initial canonical session metadata entries applied before agent build.
296    ///
297    /// Used for surface-supplied runtime state such as session-local tool
298    /// visibility. The factory validates special keys before applying them.
299    pub initial_metadata_entries: BTreeMap<String, serde_json::Value>,
300    /// Environment variables injected into shell tool subprocesses for this agent.
301    /// Set by the application's `SessionAgentBuilder` — never by the LLM.
302    /// Values are not included in the agent's context window.
303    pub shell_env: Option<std::collections::HashMap<String, String>>,
304    /// Explicit call-timeout override at the build seam.
305    ///
306    /// - `Inherit` (default): defer to config override, then profile default
307    /// - `Disabled`: explicitly disable call timeout regardless of profile
308    /// - `Value(d)`: explicitly set call timeout to `d`
309    pub call_timeout_override: crate::CallTimeoutOverride,
310    /// Typed explicit-override intent for resumed-session merges.
311    ///
312    /// Surfaces set bits only for fields they can prove were explicitly
313    /// supplied by the caller. Resumed metadata then fills only the
314    /// non-explicit fields.
315    pub resume_override_mask: ResumeOverrideMask,
316    /// Late-binding mob tool factory, called inside `build_agent()` with
317    /// session-scoped args to produce the mob tool dispatcher.
318    ///
319    /// Surfaces that enable mob tools pass an `Arc<dyn MobToolsFactory>` here.
320    /// The factory calls [`MobToolsFactory::build_mob_tools`] during agent
321    /// construction with the session ID, ops lifecycle registry, and optional
322    /// comms runtime — then composes the result into the tool gateway.
323    pub mob_tools: Option<Arc<dyn MobToolsFactory>>,
324    /// Runtime build mode — determines how the factory resolves the ops lifecycle
325    /// registry and completion feed.
326    ///
327    /// - `SessionOwned(bindings)`: runtime-backed build with epoch-owned
328    ///   bindings. Factory validates `bindings.session_id == session.id()`.
329    /// - `StandaloneEphemeral`: factory creates local-only ephemeral bindings.
330    ///   Suitable for WASM, tests, embedded, and standalone surfaces.
331    pub runtime_build_mode: crate::runtime_epoch::RuntimeBuildMode,
332    /// Runtime-stamped metadata for an eager first turn.
333    ///
334    /// Session services only forward this carrier. They must not infer an
335    /// execution kind from runtime build mode.
336    pub initial_turn_metadata: Option<RuntimeTurnMetadata>,
337    /// Runtime-injected mob operator authority context.
338    ///
339    /// This is the only source of mob operator tool authority. Tool visibility
340    /// may depend on this context being present, but dispatch-time
341    /// authorization must still re-check the typed create/scope fields on
342    /// every operator call.
343    pub mob_tool_authority_context: Option<MobToolAuthorityContext>,
344}
345
346/// Opaque principal token carried through mob tool authority and provenance.
347///
348/// `meerkat-mob` may store or compare this token as an opaque blob, but it
349/// must not decode token structure, branch on token contents, or expand scope
350/// from it.
351#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
352pub struct OpaquePrincipalToken(String);
353
354impl OpaquePrincipalToken {
355    pub fn new(token: impl Into<String>) -> Self {
356        Self(token.into())
357    }
358
359    pub fn generated() -> Self {
360        Self(uuid::Uuid::new_v4().to_string())
361    }
362
363    pub fn as_str(&self) -> &str {
364        &self.0
365    }
366}
367
368impl std::fmt::Display for OpaquePrincipalToken {
369    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
370        f.write_str(self.as_str())
371    }
372}
373
374/// Runtime-supplied caller provenance carried alongside mob tool authority.
375///
376/// This is informational/projection-only data. It is not a second authority
377/// source and must never be used for policy expansion inside `meerkat-mob`.
378#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
379pub struct MobToolCallerProvenance {
380    #[serde(default, skip_serializing_if = "Option::is_none")]
381    caller_session_id: Option<crate::SessionId>,
382    #[serde(default, skip_serializing_if = "Option::is_none")]
383    caller_mob_id: Option<String>,
384    #[serde(default, skip_serializing_if = "Option::is_none")]
385    caller_member_id: Option<String>,
386}
387
388impl MobToolCallerProvenance {
389    pub fn new() -> Self {
390        Self::default()
391    }
392
393    pub fn with_session_id(mut self, session_id: crate::SessionId) -> Self {
394        self.caller_session_id = Some(session_id);
395        self
396    }
397
398    pub fn with_mob_id(mut self, mob_id: impl Into<String>) -> Self {
399        self.caller_mob_id = Some(mob_id.into());
400        self
401    }
402
403    pub fn with_member_id(mut self, member_id: impl Into<String>) -> Self {
404        self.caller_member_id = Some(member_id.into());
405        self
406    }
407
408    pub fn caller_session_id(&self) -> Option<&crate::SessionId> {
409        self.caller_session_id.as_ref()
410    }
411
412    pub fn caller_mob_id(&self) -> Option<&str> {
413        self.caller_mob_id.as_deref()
414    }
415
416    pub fn caller_member_id(&self) -> Option<&str> {
417        self.caller_member_id.as_deref()
418    }
419}
420
421/// Typed mob operator authority injected by the host/runtime.
422///
423/// This is capability-oriented only. It is not an identity or ownership
424/// model, and it must never be inferred from mob membership, session shape,
425/// `owner_session_id`, or profile flags.
426#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
427pub struct MobToolAuthorityContext {
428    principal_token: OpaquePrincipalToken,
429    can_create_mobs: bool,
430    #[serde(default)]
431    can_mutate_profiles: bool,
432    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
433    managed_mob_scope: BTreeSet<String>,
434    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
435    spawn_profile_scope: BTreeMap<String, BTreeSet<String>>,
436    #[serde(default, skip_serializing_if = "Option::is_none")]
437    caller_provenance: Option<MobToolCallerProvenance>,
438    #[serde(default, skip_serializing_if = "Option::is_none")]
439    audit_invocation_id: Option<String>,
440}
441
442impl MobToolAuthorityContext {
443    pub fn new(principal_token: OpaquePrincipalToken, can_create_mobs: bool) -> Self {
444        Self {
445            principal_token,
446            can_create_mobs,
447            can_mutate_profiles: can_create_mobs,
448            managed_mob_scope: BTreeSet::new(),
449            spawn_profile_scope: BTreeMap::new(),
450            caller_provenance: None,
451            audit_invocation_id: None,
452        }
453    }
454
455    pub fn create_only_generated() -> Self {
456        Self::new(OpaquePrincipalToken::generated(), true)
457    }
458
459    pub fn principal_token(&self) -> &OpaquePrincipalToken {
460        &self.principal_token
461    }
462
463    pub fn can_create_mobs(&self) -> bool {
464        self.can_create_mobs
465    }
466
467    pub fn can_mutate_profiles(&self) -> bool {
468        self.can_mutate_profiles
469    }
470
471    pub fn with_profile_mutation(mut self, allowed: bool) -> Self {
472        self.can_mutate_profiles = allowed;
473        self
474    }
475
476    pub fn managed_mob_scope(&self) -> &BTreeSet<String> {
477        &self.managed_mob_scope
478    }
479
480    pub fn caller_provenance(&self) -> Option<&MobToolCallerProvenance> {
481        self.caller_provenance.as_ref()
482    }
483
484    pub fn audit_invocation_id(&self) -> Option<&str> {
485        self.audit_invocation_id.as_deref()
486    }
487
488    pub fn can_manage_mob(&self, mob_id: &str) -> bool {
489        self.managed_mob_scope.contains(mob_id)
490    }
491
492    pub fn can_spawn_profile_in_mob(&self, mob_id: &str, profile: &str) -> bool {
493        self.can_manage_mob(mob_id)
494            || self
495                .spawn_profile_scope
496                .get(mob_id)
497                .is_some_and(|profiles| profiles.contains(profile))
498    }
499
500    pub fn can_spawn_any_profile_in_mob(&self, mob_id: &str) -> bool {
501        self.can_manage_mob(mob_id)
502            || self
503                .spawn_profile_scope
504                .get(mob_id)
505                .is_some_and(|profiles| !profiles.is_empty())
506    }
507
508    pub fn grant_manage_mob(mut self, mob_id: impl Into<String>) -> Self {
509        self.managed_mob_scope.insert(mob_id.into());
510        self
511    }
512
513    pub fn grant_spawn_profile_in_mob(
514        mut self,
515        mob_id: impl Into<String>,
516        profile: impl Into<String>,
517    ) -> Self {
518        self.spawn_profile_scope
519            .entry(mob_id.into())
520            .or_default()
521            .insert(profile.into());
522        self
523    }
524
525    pub fn grant_spawn_profiles_in_mob<I, S>(
526        mut self,
527        mob_id: impl Into<String>,
528        profiles: I,
529    ) -> Self
530    where
531        I: IntoIterator<Item = S>,
532        S: Into<String>,
533    {
534        self.spawn_profile_scope
535            .entry(mob_id.into())
536            .or_default()
537            .extend(profiles.into_iter().map(Into::into));
538        self
539    }
540
541    /// Grant management scope for a mob in-place (mutable borrow).
542    ///
543    /// Used by the turn executor when applying `SessionEffect::GrantManageMob`
544    /// effects from tool dispatch.
545    pub fn grant_manage_mob_in_place(&mut self, mob_id: String) {
546        self.managed_mob_scope.insert(mob_id);
547    }
548
549    pub fn with_managed_mob_scope<I, S>(mut self, mob_ids: I) -> Self
550    where
551        I: IntoIterator<Item = S>,
552        S: Into<String>,
553    {
554        self.managed_mob_scope = mob_ids.into_iter().map(Into::into).collect();
555        self
556    }
557
558    pub fn with_caller_provenance(mut self, caller_provenance: MobToolCallerProvenance) -> Self {
559        self.caller_provenance = Some(caller_provenance);
560        self
561    }
562
563    pub fn with_audit_invocation_id(mut self, audit_invocation_id: impl Into<String>) -> Self {
564        self.audit_invocation_id = Some(audit_invocation_id.into());
565        self
566    }
567}
568
569/// Shared host/runtime policy for explicit mob-operator enablement.
570///
571/// When a host/runtime build seam explicitly enables mob operator tools for a
572/// session, the default authority shape is create-only. Existing-mob scope
573/// must still be injected separately and explicitly.
574pub fn generated_create_only_mob_operator_authority(
575    enable_mob: ToolCategoryOverride,
576) -> Option<MobToolAuthorityContext> {
577    matches!(enable_mob, ToolCategoryOverride::Enable)
578        .then(MobToolAuthorityContext::create_only_generated)
579}
580
581/// Shared build-seam rule for mob operator access rehydration.
582///
583/// Explicit disable clears authority. Otherwise, persisted typed authority
584/// wins; if none exists, explicit mob enablement falls back to generated
585/// create-only authority.
586pub fn resolve_mob_operator_access(
587    enable_mob: ToolCategoryOverride,
588    persisted_authority_context: Option<MobToolAuthorityContext>,
589) -> (ToolCategoryOverride, Option<MobToolAuthorityContext>) {
590    if matches!(enable_mob, ToolCategoryOverride::Disable) {
591        return (ToolCategoryOverride::Disable, None);
592    }
593
594    let authority_context = persisted_authority_context
595        .or_else(|| generated_create_only_mob_operator_authority(enable_mob));
596    let override_mob = if authority_context.is_some() {
597        ToolCategoryOverride::Enable
598    } else {
599        enable_mob
600    };
601
602    (override_mob, authority_context)
603}
604
605/// Provider of a snapshot of currently visible tools.
606///
607/// Implemented by the agent's `ToolScope` holder to capture tool visibility
608/// at spawn time for inheritance by mob children.
609pub trait VisibleToolSnapshotProvider: Send + Sync {
610    /// Returns the tool definitions currently visible to the parent agent.
611    fn snapshot_visible_tools(&self) -> Vec<Arc<ToolDef>>;
612}
613
614/// Context for capturing a parent agent's tool scope snapshot.
615///
616/// `ParentOwned` carries a provider that can snapshot the parent's visible
617/// tools at child spawn time. `Standalone` means no parent scope is available
618/// (e.g. top-level agents, tests).
619pub enum MobToolSnapshotContext {
620    /// Parent agent owns a tool scope; snapshot available on demand.
621    ParentOwned(Arc<dyn VisibleToolSnapshotProvider>),
622    /// No parent scope available.
623    Standalone,
624}
625
626/// Session-scoped arguments passed to [`MobToolsFactory::build_mob_tools`].
627pub struct MobToolsBuildArgs {
628    /// Session ID of the agent being built.
629    pub session_id: crate::SessionId,
630    /// Model name of the owning agent — inherited by implicit mob helpers.
631    pub model: String,
632    /// Runtime-injected mob operator authority context.
633    ///
634    /// Tool visibility may depend on this context being present, but operator
635    /// dispatch must still re-check the typed create/scope fields on every
636    /// call.
637    pub authority_context: Option<MobToolAuthorityContext>,
638    /// Shared effective mob authority handle owned by the agent.
639    ///
640    /// Mob tools read from this handle for authorization checks. The agent
641    /// (turn owner) is the sole writer — it updates this handle via
642    /// `apply_session_effects` after merging tool-produced `SessionEffect`s.
643    /// If `None`, mob tools fall back to `authority_context` as a static snapshot.
644    pub effective_authority: Option<Arc<std::sync::RwLock<MobToolAuthorityContext>>>,
645    /// Comms name of the owning agent (for building `TrustedPeerDescriptor`).
646    pub comms_name: Option<String>,
647    /// Optional comms runtime for auto-wiring spawned members.
648    pub comms_runtime: Option<Arc<dyn crate::agent::CommsRuntime>>,
649    /// Context for capturing a snapshot of the parent agent's visible tools.
650    pub snapshot_context: MobToolSnapshotContext,
651}
652
653/// Factory trait for late-binding mob tool construction.
654///
655/// Implementations capture surface-specific state (e.g. `MobMcpState`) and
656/// receive session-scoped arguments from `build_agent()` at construction time.
657/// This avoids a cyclic dependency between the facade crate and `meerkat-mob-mcp`.
658#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
659#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
660pub trait MobToolsFactory: Send + Sync {
661    /// Build a mob tool dispatcher for the given session.
662    async fn build_mob_tools(
663        &self,
664        args: MobToolsBuildArgs,
665    ) -> Result<Arc<dyn AgentToolDispatcher>, Box<dyn std::error::Error + Send + Sync>>;
666}
667
668/// Typed explicit-override intent for resumed-session metadata merges.
669///
670/// This avoids trying to recover caller intent from flattened build config.
671#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
672pub struct ResumeOverrideMask {
673    pub model: bool,
674    pub provider: bool,
675    pub max_tokens: bool,
676    pub structured_output_retries: bool,
677    pub provider_params: bool,
678    pub auth_binding: bool,
679    pub override_builtins: bool,
680    pub override_shell: bool,
681    pub override_memory: bool,
682    pub override_schedule: bool,
683    pub override_workgraph: bool,
684    pub override_mob: bool,
685    pub override_image_generation: bool,
686    pub override_web_search: bool,
687    pub preload_skills: bool,
688    pub keep_alive: bool,
689    pub comms_name: bool,
690    pub peer_meta: bool,
691}
692
693impl SessionBuildOptions {
694    /// Apply the shared rehydration rule for mob operator access.
695    ///
696    /// This preserves exact persisted authority when available and otherwise
697    /// falls back to generated create-only authority for explicit mob
698    /// enablement.
699    pub fn apply_persisted_mob_operator_access(
700        &mut self,
701        enable_mob: ToolCategoryOverride,
702        persisted_authority_context: Option<MobToolAuthorityContext>,
703    ) {
704        let (override_mob, authority_context) =
705            resolve_mob_operator_access(enable_mob, persisted_authority_context);
706        self.override_mob = override_mob;
707        self.mob_tool_authority_context = authority_context;
708    }
709
710    /// Apply the shared host/runtime default for explicit mob operator
711    /// enablement.
712    ///
713    /// This keeps `override_mob` and the generated create-only authority
714    /// context aligned at the composition seam. Existing-mob scope must be
715    /// injected explicitly elsewhere; this helper never infers it.
716    pub fn apply_generated_create_only_mob_operator_access(
717        &mut self,
718        enable_mob: ToolCategoryOverride,
719    ) {
720        self.apply_persisted_mob_operator_access(enable_mob, None);
721    }
722}
723
724impl Default for SessionBuildOptions {
725    fn default() -> Self {
726        Self {
727            provider: None,
728            self_hosted_server_id: None,
729            output_schema: None,
730            structured_output_retries: 2,
731            hooks_override: HookRunOverrides::default(),
732            comms_name: None,
733            peer_meta: None,
734            resume_session: None,
735            // Phase 3 field — default None keeps the legacy flat path.
736            // Populated by surfaces that accept realm/binding inputs.
737            budget_limits: None,
738            provider_params: None,
739            external_tools: None,
740            recoverable_tool_defs: None,
741            blob_store_override: None,
742            llm_client_override: None,
743            agent_llm_client_decorator: None,
744            override_builtins: ToolCategoryOverride::Inherit,
745            override_shell: ToolCategoryOverride::Inherit,
746            override_memory: ToolCategoryOverride::Inherit,
747            override_schedule: ToolCategoryOverride::Inherit,
748            override_workgraph: ToolCategoryOverride::Inherit,
749            override_mob: ToolCategoryOverride::Inherit,
750            override_image_generation: ToolCategoryOverride::Inherit,
751            override_web_search: ToolCategoryOverride::Inherit,
752            schedule_tools: None,
753            workgraph_tools: None,
754            preload_skills: None,
755            realm_id: None,
756            instance_id: None,
757            backend: None,
758            config_generation: None,
759            auth_binding: None,
760            keep_alive: false,
761            checkpointer: None,
762            silent_comms_intents: Vec::new(),
763            max_inline_peer_notifications: None,
764            app_context: None,
765            additional_instructions: None,
766            initial_metadata_entries: BTreeMap::new(),
767            shell_env: None,
768            call_timeout_override: crate::CallTimeoutOverride::Inherit,
769            resume_override_mask: ResumeOverrideMask::default(),
770            mob_tools: None,
771            runtime_build_mode: crate::runtime_epoch::RuntimeBuildMode::StandaloneEphemeral,
772            initial_turn_metadata: None,
773            mob_tool_authority_context: None,
774        }
775    }
776}
777
778impl std::fmt::Debug for SessionBuildOptions {
779    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
780        f.debug_struct("SessionBuildOptions")
781            .field("provider", &self.provider)
782            .field("output_schema", &self.output_schema.is_some())
783            .field("structured_output_retries", &self.structured_output_retries)
784            .field("hooks_override", &self.hooks_override)
785            .field("comms_name", &self.comms_name)
786            .field("peer_meta", &self.peer_meta)
787            .field("resume_session", &self.resume_session.is_some())
788            .field("budget_limits", &self.budget_limits)
789            .field("provider_params", &self.provider_params.is_some())
790            .field("external_tools", &self.external_tools.is_some())
791            .field("recoverable_tool_defs", &self.recoverable_tool_defs)
792            .field("blob_store_override", &self.blob_store_override.is_some())
793            .field("llm_client_override", &self.llm_client_override.is_some())
794            .field(
795                "agent_llm_client_decorator",
796                &self.agent_llm_client_decorator.is_some(),
797            )
798            .field("override_builtins", &self.override_builtins)
799            .field("override_shell", &self.override_shell)
800            .field("override_memory", &self.override_memory)
801            .field("override_schedule", &self.override_schedule)
802            .field("override_workgraph", &self.override_workgraph)
803            .field("override_mob", &self.override_mob)
804            .field("schedule_tools", &self.schedule_tools.is_some())
805            .field("workgraph_tools", &self.workgraph_tools.is_some())
806            .field("preload_skills", &self.preload_skills)
807            .field("realm_id", &self.realm_id)
808            .field("instance_id", &self.instance_id)
809            .field("backend", &self.backend)
810            .field("config_generation", &self.config_generation)
811            .field("keep_alive", &self.keep_alive)
812            .field("checkpointer", &self.checkpointer.is_some())
813            .field("silent_comms_intents", &self.silent_comms_intents)
814            .field(
815                "max_inline_peer_notifications",
816                &self.max_inline_peer_notifications,
817            )
818            .field("app_context", &self.app_context.is_some())
819            .field("additional_instructions", &self.additional_instructions)
820            .field("initial_metadata_entries", &self.initial_metadata_entries)
821            .field("call_timeout_override", &self.call_timeout_override)
822            .field("resume_override_mask", &self.resume_override_mask)
823            .field("mob_tools", &self.mob_tools.is_some())
824            .field("runtime_build_mode", &self.runtime_build_mode)
825            .field(
826                "initial_turn_metadata",
827                &self.initial_turn_metadata.is_some(),
828            )
829            .field(
830                "mob_tool_authority_context",
831                &self.mob_tool_authority_context.is_some(),
832            )
833            .field("runtime_build_mode", &self.runtime_build_mode)
834            .finish()
835    }
836}
837
838/// Runtime/session semantic carrier for starting a turn.
839///
840/// The session service forwards this as one machine/composition-owned bundle.
841/// It must not split handling mode, tool overlays, context appends, or runtime
842/// metadata back into service-level request fields.
843#[derive(Debug)]
844pub struct StartTurnRuntimeSemantics {
845    /// Optional normalized rendering metadata for this turn prompt.
846    pub render_metadata: Option<RenderMetadata>,
847    /// Handling mode for this turn's ordinary content-bearing work.
848    ///
849    /// This is a **runtime-owned semantic**: the runtime routes Queue/Steer
850    /// before calling the executor. The session service passes this through
851    /// to the `SessionAgent` but does not act on it. Non-Queue handling
852    /// only works correctly on runtime-backed surfaces.
853    pub handling_mode: HandlingMode,
854    /// Canonical SkillKeys to resolve and inject for this turn.
855    pub skill_references: Option<Vec<crate::skills::SkillKey>>,
856    /// Optional per-turn flow tool overlay (ephemeral, non-persistent).
857    pub flow_tool_overlay: Option<TurnToolOverlay>,
858    /// Runtime-owned system-context appends that must be applied at this
859    /// turn boundary before the model run starts.
860    pub pre_turn_context_appends: Vec<PendingSystemContextAppend>,
861    /// Canonical runtime-authored typed appends for this turn.
862    ///
863    /// Provider prompt text is an internal projection derived from these
864    /// appends at the runtime/service boundary. These appends are the
865    /// authorship source used for transcript persistence.
866    pub typed_turn_appends: Vec<ConversationAppend>,
867    /// Canonical runtime-authored metadata for this turn.
868    ///
869    /// Runtime-backed callers populate this once at the machine boundary and
870    /// the session layer derives per-turn policy from this typed carrier
871    /// instead of re-inferring or dropping fields.
872    pub turn_metadata: Option<RuntimeTurnMetadata>,
873}
874
875impl Default for StartTurnRuntimeSemantics {
876    fn default() -> Self {
877        Self {
878            render_metadata: None,
879            handling_mode: HandlingMode::Queue,
880            skill_references: None,
881            flow_tool_overlay: None,
882            pre_turn_context_appends: Vec::new(),
883            typed_turn_appends: Vec::new(),
884            turn_metadata: None,
885        }
886    }
887}
888
889impl StartTurnRuntimeSemantics {
890    #[must_use]
891    pub fn new(
892        render_metadata: Option<RenderMetadata>,
893        handling_mode: HandlingMode,
894        skill_references: Option<Vec<crate::skills::SkillKey>>,
895        flow_tool_overlay: Option<TurnToolOverlay>,
896        pre_turn_context_appends: Vec<PendingSystemContextAppend>,
897        turn_metadata: Option<RuntimeTurnMetadata>,
898    ) -> Self {
899        Self {
900            render_metadata,
901            handling_mode,
902            skill_references,
903            flow_tool_overlay,
904            pre_turn_context_appends,
905            typed_turn_appends: Vec::new(),
906            turn_metadata,
907        }
908    }
909
910    #[must_use]
911    pub fn runtime_metadata(turn_metadata: RuntimeTurnMetadata) -> Self {
912        Self {
913            turn_metadata: Some(turn_metadata),
914            ..Self::default()
915        }
916    }
917
918    #[must_use]
919    pub fn with_typed_turn_appends(mut self, typed_turn_appends: Vec<ConversationAppend>) -> Self {
920        self.typed_turn_appends = typed_turn_appends;
921        self
922    }
923}
924
925/// Request to start a new turn on an existing session.
926#[derive(Debug)]
927pub struct StartTurnRequest {
928    /// User prompt for this turn (text or multimodal).
929    pub prompt: ContentInput,
930    /// Optional system prompt override for a deferred session's first turn.
931    ///
932    /// This is only supported before the session has any conversation history.
933    /// Materialized sessions with existing messages must reject it.
934    pub system_prompt: Option<String>,
935    /// Channel for streaming events during the turn.
936    pub event_tx: Option<mpsc::Sender<EventEnvelope<AgentEvent>>>,
937    /// Single runtime/session semantic carrier for this turn.
938    pub runtime: StartTurnRuntimeSemantics,
939}
940
941/// Request to append runtime system context to an existing session.
942#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
943pub struct AppendSystemContextRequest {
944    pub text: String,
945    #[serde(default, skip_serializing_if = "Option::is_none")]
946    pub source: Option<String>,
947    #[serde(default, skip_serializing_if = "Option::is_none")]
948    pub idempotency_key: Option<String>,
949}
950
951/// Result of appending runtime system context to a session.
952#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
953pub struct AppendSystemContextResult {
954    pub status: AppendSystemContextStatus,
955}
956
957/// Request to stage callback tool results for the next turn.
958#[derive(Debug, Clone, Serialize, Deserialize)]
959pub struct StageToolResultsRequest {
960    pub results: Vec<crate::ToolResult>,
961}
962
963/// Result of staging callback tool results for the next turn.
964#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
965pub struct StageToolResultsResult {
966    pub accepted_result_count: usize,
967}
968
969/// Outcome of an append-system-context request.
970#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
971#[serde(rename_all = "snake_case")]
972pub enum AppendSystemContextStatus {
973    Applied,
974    Staged,
975    Duplicate,
976}
977
978/// Ephemeral per-turn tool overlay for flow-dispatched turns.
979#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
980#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
981pub struct TurnToolOverlay {
982    /// Optional allow-list for this turn.
983    #[serde(default)]
984    pub allowed_tools: Option<Vec<String>>,
985    /// Optional deny-list for this turn.
986    #[serde(default)]
987    pub blocked_tools: Option<Vec<String>>,
988}
989
990/// Query parameters for listing sessions.
991#[derive(Debug, Default)]
992pub struct SessionQuery {
993    /// Maximum number of results.
994    pub limit: Option<usize>,
995    /// Offset for pagination.
996    pub offset: Option<usize>,
997    /// Filters sessions where all specified k/v pairs match.
998    pub labels: Option<BTreeMap<String, String>>,
999}
1000
1001/// Summary of a session (for list results).
1002///
1003/// Kept lightweight — no billing data. Use `read()` for full details.
1004#[derive(Debug, Clone, Serialize, Deserialize)]
1005pub struct SessionSummary {
1006    pub session_id: SessionId,
1007    pub created_at: SystemTime,
1008    pub updated_at: SystemTime,
1009    pub message_count: usize,
1010    pub total_tokens: u64,
1011    pub is_active: bool,
1012    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1013    pub labels: BTreeMap<String, String>,
1014}
1015
1016/// Detailed view of a session's state and history metadata.
1017#[derive(Debug, Clone, Serialize, Deserialize)]
1018pub struct SessionInfo {
1019    pub session_id: SessionId,
1020    pub created_at: SystemTime,
1021    pub updated_at: SystemTime,
1022    pub message_count: usize,
1023    pub is_active: bool,
1024    pub model: String,
1025    pub provider: Provider,
1026    pub last_assistant_text: Option<String>,
1027    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1028    pub labels: BTreeMap<String, String>,
1029}
1030
1031/// Billing/usage data for a session, returned separately from state.
1032#[derive(Debug, Clone, Serialize, Deserialize)]
1033pub struct SessionUsage {
1034    pub total_tokens: u64,
1035    pub usage: Usage,
1036}
1037
1038/// Combined session view (state + usage). Convenience wrapper used by
1039/// `SessionService::read()` to avoid requiring two calls.
1040#[derive(Debug, Clone, Serialize, Deserialize)]
1041pub struct SessionView {
1042    pub state: SessionInfo,
1043    pub billing: SessionUsage,
1044}
1045
1046impl SessionView {
1047    /// Convenience: session ID from the state.
1048    pub fn session_id(&self) -> &SessionId {
1049        &self.state.session_id
1050    }
1051}
1052
1053/// Query parameters for reading session history.
1054#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
1055pub struct SessionHistoryQuery {
1056    /// Number of messages to skip from the start of the transcript.
1057    pub offset: usize,
1058    /// Maximum number of messages to return.
1059    #[serde(default, skip_serializing_if = "Option::is_none")]
1060    pub limit: Option<usize>,
1061}
1062
1063/// Paginated transcript page for a session.
1064#[derive(Debug, Clone, Serialize, Deserialize)]
1065pub struct SessionHistoryPage {
1066    pub session_id: SessionId,
1067    pub message_count: usize,
1068    pub offset: usize,
1069    #[serde(default, skip_serializing_if = "Option::is_none")]
1070    pub limit: Option<usize>,
1071    pub has_more: bool,
1072    pub messages: Vec<Message>,
1073}
1074
1075impl SessionHistoryPage {
1076    /// Build a transcript page from the full ordered message list.
1077    pub fn from_messages(
1078        session_id: SessionId,
1079        messages: &[Message],
1080        query: SessionHistoryQuery,
1081    ) -> Self {
1082        let message_count = messages.len();
1083        let start = query.offset.min(message_count);
1084        let end = match query.limit {
1085            Some(limit) => start.saturating_add(limit).min(message_count),
1086            None => message_count,
1087        };
1088        Self {
1089            session_id,
1090            message_count,
1091            offset: start,
1092            limit: query.limit,
1093            has_more: end < message_count,
1094            messages: messages[start..end].to_vec(),
1095        }
1096    }
1097}
1098
1099/// Explicit behavior for transcript fork/edit requests when the source
1100/// session has active work.
1101#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1102#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1103#[serde(rename_all = "snake_case")]
1104pub enum TranscriptEditRunningBehavior {
1105    /// Reject the request while the source session is active.
1106    #[default]
1107    Reject,
1108}
1109
1110/// Request to fork a session at a transcript message index.
1111#[derive(Debug, Clone, Serialize, Deserialize)]
1112#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1113pub struct SessionForkAtRequest {
1114    pub message_index: usize,
1115    #[serde(default)]
1116    pub running_behavior: TranscriptEditRunningBehavior,
1117}
1118
1119/// Request to fork a session and apply a typed transcript replacement.
1120#[derive(Debug, Clone, Serialize, Deserialize)]
1121#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1122pub struct SessionForkReplaceRequest {
1123    pub message_index: usize,
1124    #[cfg_attr(feature = "schema", schemars(with = "serde_json::Value"))]
1125    pub replacement: TranscriptReplacement,
1126    #[serde(default)]
1127    pub running_behavior: TranscriptEditRunningBehavior,
1128}
1129
1130/// Result of creating an edited transcript branch.
1131#[derive(Debug, Clone, Serialize, Deserialize)]
1132#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1133pub struct SessionForkResult {
1134    #[cfg_attr(feature = "schema", schemars(with = "String"))]
1135    pub source_session_id: SessionId,
1136    #[cfg_attr(feature = "schema", schemars(with = "String"))]
1137    pub session_id: SessionId,
1138    pub message_count: usize,
1139    #[serde(default, skip_serializing_if = "Option::is_none")]
1140    pub session_ref: Option<String>,
1141}
1142
1143impl TranscriptEditError {
1144    /// Convert a typed edit validation failure into a surface-friendly session
1145    /// error without widening the `SessionService` error enum.
1146    pub fn into_session_error(self) -> SessionError {
1147        SessionError::Agent(crate::error::AgentError::ConfigError(self.to_string()))
1148    }
1149}
1150
1151/// Canonical session lifecycle abstraction.
1152///
1153/// All surfaces delegate to this trait. Implementations control persistence,
1154/// compaction, and event logging behavior.
1155#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
1156#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
1157pub trait SessionService: Send + Sync {
1158    /// Create a new session and run the first turn.
1159    async fn create_session(&self, req: CreateSessionRequest) -> Result<RunResult, SessionError>;
1160
1161    /// Start a new turn on an existing session.
1162    async fn start_turn(
1163        &self,
1164        id: &SessionId,
1165        req: StartTurnRequest,
1166    ) -> Result<RunResult, SessionError>;
1167
1168    /// Cancel an in-flight turn.
1169    ///
1170    /// Returns `NotRunning` if no turn is active.
1171    async fn interrupt(&self, id: &SessionId) -> Result<(), SessionError>;
1172
1173    /// Cancel an in-flight turn once it reaches the next boundary.
1174    ///
1175    /// Returns `NotRunning` if no turn is active. Unsupported by default.
1176    async fn cancel_after_boundary(&self, _id: &SessionId) -> Result<(), SessionError> {
1177        Err(SessionError::Unsupported(
1178            "cancel_after_boundary".to_string(),
1179        ))
1180    }
1181
1182    /// Replace the LLM client on a live session.
1183    ///
1184    /// Enables mid-session model/provider hot-swap without rebuilding the
1185    /// agent. The new client takes effect on the next turn. Returns
1186    /// `Unsupported` by default; session services that support live agents
1187    /// override this.
1188    async fn set_session_client(
1189        &self,
1190        _id: &SessionId,
1191        _client: std::sync::Arc<dyn crate::AgentLlmClient>,
1192    ) -> Result<(), SessionError> {
1193        Err(SessionError::Unsupported("set_session_client".to_string()))
1194    }
1195
1196    /// Atomically replace the live session client and the session's durable
1197    /// LLM identity.
1198    ///
1199    /// This is the canonical seam for materialized-session hot-swap semantics.
1200    /// Implementations should apply both updates together so future turns and
1201    /// resume/recovery see the same model/provider/provider_params identity.
1202    async fn hot_swap_session_llm_identity(
1203        &self,
1204        _id: &SessionId,
1205        _client: std::sync::Arc<dyn crate::AgentLlmClient>,
1206        _identity: SessionLlmIdentity,
1207        _request_policy: crate::SessionLlmRequestPolicy,
1208    ) -> Result<(), SessionError> {
1209        Err(SessionError::Unsupported(
1210            "hot_swap_session_llm_identity".to_string(),
1211        ))
1212    }
1213
1214    /// Replace the canonical tool visibility state carried by the live session.
1215    ///
1216    /// This seam is live-only and must not perform its own durable write. The
1217    /// caller owns any surrounding transactional persistence and rollback.
1218    async fn set_session_tool_visibility_state(
1219        &self,
1220        _id: &SessionId,
1221        _state: Option<crate::SessionToolVisibilityState>,
1222    ) -> Result<(), SessionError> {
1223        Err(SessionError::Unsupported(
1224            "set_session_tool_visibility_state".to_string(),
1225        ))
1226    }
1227
1228    /// Update the `keep_alive` flag on a live session's durable metadata.
1229    ///
1230    /// Called by the runtime when an explicit override changes the session's
1231    /// keep-alive intent so that subsequent inheriting calls observe the
1232    /// updated value. Returns `Unsupported` by default.
1233    async fn update_session_keep_alive(
1234        &self,
1235        _id: &SessionId,
1236        _keep_alive: bool,
1237    ) -> Result<(), SessionError> {
1238        Err(SessionError::Unsupported(
1239            "update_session_keep_alive".to_string(),
1240        ))
1241    }
1242
1243    /// Update the session's canonical mob operator authority context.
1244    ///
1245    /// This is the only supported seam for widening or narrowing exact mob
1246    /// management scope after session creation so recovery and live runtime
1247    /// state stay aligned.
1248    async fn update_session_mob_authority_context(
1249        &self,
1250        _id: &SessionId,
1251        _authority_context: Option<MobToolAuthorityContext>,
1252    ) -> Result<(), SessionError> {
1253        Err(SessionError::Unsupported(
1254            "update_session_mob_authority_context".to_string(),
1255        ))
1256    }
1257
1258    /// Whether a live in-memory session bridge currently exists for `id`.
1259    ///
1260    /// This is intentionally distinct from `list()` / `SessionSummary`:
1261    /// persisted-only summaries must not count as live, and idle live sessions
1262    /// must still count as live even when no turn is running.
1263    async fn has_live_session(&self, _id: &SessionId) -> Result<bool, SessionError> {
1264        Err(SessionError::Unsupported("has_live_session".to_string()))
1265    }
1266
1267    /// Stage an external tool visibility filter on a live session.
1268    ///
1269    /// Used to dynamically hide/show tools (e.g., `view_image`) after a
1270    /// model hot-swap changes capability support. Returns `Unsupported`
1271    /// by default.
1272    async fn set_session_tool_filter(
1273        &self,
1274        _id: &SessionId,
1275        _filter: crate::ToolFilter,
1276    ) -> Result<(), SessionError> {
1277        Err(SessionError::Unsupported(
1278            "set_session_tool_filter".to_string(),
1279        ))
1280    }
1281
1282    /// Read the current state of a session.
1283    async fn read(&self, id: &SessionId) -> Result<SessionView, SessionError>;
1284
1285    /// List sessions matching the query.
1286    async fn list(&self, query: SessionQuery) -> Result<Vec<SessionSummary>, SessionError>;
1287
1288    /// Archive (remove) a session.
1289    async fn archive(&self, id: &SessionId) -> Result<(), SessionError>;
1290
1291    /// Subscribe to session-wide events regardless of triggering interaction.
1292    ///
1293    /// Services that do not support this capability return `StreamError::NotFound`.
1294    async fn subscribe_session_events(&self, id: &SessionId) -> Result<EventStream, StreamError> {
1295        Err(StreamError::NotFound(format!("session {id}")))
1296    }
1297}
1298
1299/// Optional comms/control-plane extension for `SessionService`.
1300///
1301/// Base lifecycle operations stay on `SessionService`; advanced surfaces
1302/// (RPC/REST/mob orchestration) can use this trait when they need direct
1303/// access to comms runtime and injector handles.
1304#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
1305#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
1306pub trait SessionServiceCommsExt: SessionService {
1307    /// Get the comms runtime for a session, if available.
1308    async fn comms_runtime(
1309        &self,
1310        _session_id: &SessionId,
1311    ) -> Option<Arc<dyn crate::agent::CommsRuntime>> {
1312        None
1313    }
1314
1315    /// Get the event injector for a session, if available.
1316    async fn event_injector(
1317        &self,
1318        session_id: &SessionId,
1319    ) -> Option<Arc<dyn crate::EventInjector>> {
1320        self.comms_runtime(session_id)
1321            .await
1322            .and_then(|runtime| runtime.event_injector())
1323    }
1324
1325    /// Internal runtime seam for interaction-scoped injection.
1326    #[doc(hidden)]
1327    async fn interaction_event_injector(
1328        &self,
1329        session_id: &SessionId,
1330    ) -> Option<Arc<dyn crate::event_injector::SubscribableInjector>> {
1331        self.comms_runtime(session_id)
1332            .await
1333            .and_then(|runtime| runtime.interaction_event_injector())
1334    }
1335}
1336
1337/// Optional control-plane extension for `SessionService`.
1338///
1339/// Keeps the base lifecycle contract minimal while exposing first-class
1340/// session mutation operations shared across external surfaces.
1341#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
1342#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
1343pub trait SessionServiceControlExt: SessionService {
1344    /// Append runtime system context to a session.
1345    ///
1346    /// The request is idempotent per `(session_id, idempotency_key)`. When a
1347    /// turn is active, implementations may stage the append for application at
1348    /// the next LLM boundary rather than mutating in-flight request state.
1349    async fn append_system_context(
1350        &self,
1351        id: &SessionId,
1352        req: AppendSystemContextRequest,
1353    ) -> Result<AppendSystemContextResult, SessionControlError>;
1354
1355    /// Stage callback tool results for application on the next turn seam.
1356    ///
1357    /// Implementations must persist the staged results durably before a live
1358    /// session can observe them so a failed call never leaves hidden pending
1359    /// transcript mutations behind.
1360    async fn stage_tool_results(
1361        &self,
1362        id: &SessionId,
1363        req: StageToolResultsRequest,
1364    ) -> Result<StageToolResultsResult, SessionError> {
1365        let _ = (id, req);
1366        Err(SessionError::Unsupported("stage_tool_results".to_string()))
1367    }
1368}
1369
1370/// Optional history-read extension for `SessionService`.
1371///
1372/// Keeps the base lifecycle contract lightweight while allowing surfaces to
1373/// fetch full transcript contents when they explicitly opt in.
1374#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
1375#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
1376pub trait SessionServiceHistoryExt: SessionService {
1377    /// Read the committed transcript for a session.
1378    ///
1379    /// Implementations may return `PersistenceDisabled` if they cannot provide
1380    /// authoritative history for the requested lifecycle state.
1381    async fn read_history(
1382        &self,
1383        id: &SessionId,
1384        query: SessionHistoryQuery,
1385    ) -> Result<SessionHistoryPage, SessionError>;
1386}
1387
1388/// Optional typed transcript fork/edit extension for `SessionService`.
1389///
1390/// Implementations must create a new session identity for every operation.
1391/// Source history is never mutated in place.
1392#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
1393#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
1394pub trait SessionServiceTranscriptEditExt: SessionService {
1395    /// Fork a session at a message index.
1396    async fn fork_session_at(
1397        &self,
1398        id: &SessionId,
1399        req: SessionForkAtRequest,
1400    ) -> Result<SessionForkResult, SessionError> {
1401        let _ = (id, req);
1402        Err(SessionError::Unsupported("fork_session_at".to_string()))
1403    }
1404
1405    /// Fork a session and apply a typed transcript replacement.
1406    async fn fork_session_replace(
1407        &self,
1408        id: &SessionId,
1409        req: SessionForkReplaceRequest,
1410    ) -> Result<SessionForkResult, SessionError> {
1411        let _ = (id, req);
1412        Err(SessionError::Unsupported(
1413            "fork_session_replace".to_string(),
1414        ))
1415    }
1416}
1417
1418/// Extension trait for `Arc<dyn SessionService>` to allow calling methods directly.
1419impl dyn SessionService {
1420    /// Wrap self in an Arc.
1421    pub fn into_arc(self: Box<Self>) -> Arc<dyn SessionService> {
1422        Arc::from(self)
1423    }
1424}
1425
1426#[cfg(test)]
1427#[allow(
1428    clippy::unimplemented,
1429    clippy::unwrap_used,
1430    clippy::expect_used,
1431    clippy::panic
1432)]
1433mod tests {
1434    use super::*;
1435
1436    struct UnsupportedSessionService;
1437
1438    #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
1439    #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
1440    impl SessionService for UnsupportedSessionService {
1441        async fn create_session(
1442            &self,
1443            _req: CreateSessionRequest,
1444        ) -> Result<RunResult, SessionError> {
1445            unimplemented!()
1446        }
1447
1448        async fn start_turn(
1449            &self,
1450            _id: &SessionId,
1451            _req: StartTurnRequest,
1452        ) -> Result<RunResult, SessionError> {
1453            unimplemented!()
1454        }
1455
1456        async fn interrupt(&self, _id: &SessionId) -> Result<(), SessionError> {
1457            unimplemented!()
1458        }
1459
1460        async fn read(&self, _id: &SessionId) -> Result<SessionView, SessionError> {
1461            unimplemented!()
1462        }
1463
1464        async fn list(&self, _query: SessionQuery) -> Result<Vec<SessionSummary>, SessionError> {
1465            unimplemented!()
1466        }
1467
1468        async fn archive(&self, _id: &SessionId) -> Result<(), SessionError> {
1469            unimplemented!()
1470        }
1471    }
1472
1473    #[tokio::test]
1474    async fn has_live_session_defaults_to_unsupported() {
1475        let service = UnsupportedSessionService;
1476        let err = service
1477            .has_live_session(&SessionId::new())
1478            .await
1479            .expect_err("default implementation should fail loudly");
1480        assert!(matches!(err, SessionError::Unsupported(name) if name == "has_live_session"));
1481    }
1482
1483    #[test]
1484    fn grant_manage_mob_in_place_adds_mob_id() {
1485        let mut ctx = MobToolAuthorityContext::create_only_generated();
1486        ctx.grant_manage_mob_in_place("mob-1".into());
1487        assert!(ctx.managed_mob_scope.contains("mob-1"));
1488    }
1489
1490    #[test]
1491    fn grant_manage_mob_in_place_is_idempotent() {
1492        let mut ctx = MobToolAuthorityContext::create_only_generated();
1493        ctx.grant_manage_mob_in_place("mob-1".into());
1494        ctx.grant_manage_mob_in_place("mob-1".into());
1495        assert_eq!(ctx.managed_mob_scope.len(), 1);
1496    }
1497
1498    #[test]
1499    fn grant_manage_mob_in_place_accumulates() {
1500        let mut ctx = MobToolAuthorityContext::create_only_generated();
1501        ctx.grant_manage_mob_in_place("mob-1".into());
1502        ctx.grant_manage_mob_in_place("mob-2".into());
1503        assert!(ctx.managed_mob_scope.contains("mob-1"));
1504        assert!(ctx.managed_mob_scope.contains("mob-2"));
1505        assert_eq!(ctx.managed_mob_scope.len(), 2);
1506    }
1507
1508    #[test]
1509    fn spawn_profile_scope_allows_only_granted_profile_without_manage_scope() {
1510        let ctx = MobToolAuthorityContext::create_only_generated()
1511            .grant_spawn_profile_in_mob("mob-1", "investigator");
1512
1513        assert!(ctx.can_spawn_any_profile_in_mob("mob-1"));
1514        assert!(ctx.can_spawn_profile_in_mob("mob-1", "investigator"));
1515        assert!(!ctx.can_spawn_profile_in_mob("mob-1", "writer"));
1516        assert!(!ctx.can_manage_mob("mob-1"));
1517    }
1518
1519    struct MockSnapshotProvider {
1520        tools: Vec<Arc<ToolDef>>,
1521    }
1522
1523    impl VisibleToolSnapshotProvider for MockSnapshotProvider {
1524        fn snapshot_visible_tools(&self) -> Vec<Arc<ToolDef>> {
1525            self.tools.clone()
1526        }
1527    }
1528
1529    #[test]
1530    fn mob_tool_snapshot_context_standalone() {
1531        let ctx = MobToolSnapshotContext::Standalone;
1532        assert!(matches!(ctx, MobToolSnapshotContext::Standalone));
1533    }
1534
1535    #[test]
1536    fn mob_tool_snapshot_context_parent_owned_returns_tools() {
1537        let tools = vec![Arc::new(ToolDef {
1538            name: "test_tool".into(),
1539            description: "a test".to_string(),
1540            input_schema: serde_json::json!({"type": "object"}),
1541            provenance: None,
1542        })];
1543        let provider = Arc::new(MockSnapshotProvider { tools });
1544        let ctx = MobToolSnapshotContext::ParentOwned(provider);
1545        match ctx {
1546            MobToolSnapshotContext::ParentOwned(p) => {
1547                let snapshot = p.snapshot_visible_tools();
1548                assert_eq!(snapshot.len(), 1);
1549                assert_eq!(snapshot[0].name, "test_tool");
1550            }
1551            MobToolSnapshotContext::Standalone => panic!("expected ParentOwned"),
1552        }
1553    }
1554}