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::{
31    TranscriptEditError, TranscriptReplacement, TranscriptRewriteCommit, TranscriptRewriteReason,
32    TranscriptRewriteSelection,
33};
34
35/// Controls whether `create_session()` should execute an initial turn.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum InitialTurnPolicy {
38    /// Run the initial turn immediately as part of session creation.
39    RunImmediately,
40    /// Register the session and return without running an initial turn.
41    ///
42    /// `CreateSessionRequest::deferred_prompt_policy` determines whether the
43    /// create-time prompt is discarded or staged for the first later turn.
44    Defer,
45}
46
47/// How a deferred create request treats its create-time prompt.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
49#[serde(rename_all = "snake_case")]
50pub enum DeferredPromptPolicy {
51    /// Register the session only; the caller will supply the first runtime input separately.
52    #[default]
53    Discard,
54    /// Persist the create-time prompt and merge it into the first later turn.
55    Stage,
56}
57
58/// Errors returned by `SessionService` methods.
59#[derive(Debug, thiserror::Error)]
60pub enum SessionError {
61    /// The requested session does not exist.
62    #[error("session not found: {id}")]
63    NotFound { id: SessionId },
64
65    /// A turn is already in progress on this session.
66    #[error("session is busy: {id}")]
67    Busy { id: SessionId },
68
69    /// The operation requires persistence but the `session-store` feature is disabled.
70    #[error("session persistence is disabled")]
71    PersistenceDisabled,
72
73    /// The operation requires compaction but the `session-compaction` feature is disabled.
74    #[error("session compaction is disabled")]
75    CompactionDisabled,
76
77    /// No turn is currently running on this session.
78    #[error("no turn running on session: {id}")]
79    NotRunning { id: SessionId },
80
81    /// A session store operation failed.
82    #[error("store error: {0}")]
83    Store(#[source] Box<dyn std::error::Error + Send + Sync>),
84
85    /// An agent-level error occurred during execution.
86    #[error("agent error: {0}")]
87    Agent(#[from] crate::error::AgentError),
88
89    /// The operation failed with structured error data for protocol surfaces.
90    #[error("{message}")]
91    FailedWithData {
92        message: String,
93        data: serde_json::Value,
94    },
95
96    /// The requested operation is not supported by this session service.
97    #[error("unsupported: {0}")]
98    Unsupported(String),
99}
100
101impl SessionError {
102    /// Return a stable error code string for wire formats.
103    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/// Errors returned by session control-plane mutation methods.
126#[derive(Debug, thiserror::Error)]
127pub enum SessionControlError {
128    /// A lifecycle/session-store error occurred while handling the control request.
129    #[error(transparent)]
130    Session(#[from] SessionError),
131
132    /// The control request was malformed.
133    #[error("invalid system-context request: {message}")]
134    InvalidRequest { message: String },
135
136    /// The idempotency key was replayed with different request content.
137    #[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    /// Return a stable error code string for wire formats.
145    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    /// Convert a stage-time state conflict into a surface-level control error.
156    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/// Request to create a new session and run the first turn.
168#[derive(Debug)]
169pub struct CreateSessionRequest {
170    /// Model name (e.g. "claude-opus-4-6").
171    pub model: String,
172    /// Initial user prompt (text or multimodal).
173    pub prompt: ContentInput,
174    /// Optional normalized rendering metadata for the initial prompt.
175    pub render_metadata: Option<RenderMetadata>,
176    /// Optional system prompt override.
177    pub system_prompt: Option<String>,
178    /// Max tokens per LLM turn.
179    pub max_tokens: Option<u32>,
180    /// Channel for streaming events during the turn.
181    pub event_tx: Option<mpsc::Sender<EventEnvelope<AgentEvent>>>,
182    /// Canonical SkillKeys to resolve and inject for the first turn.
183    pub skill_references: Option<Vec<crate::skills::SkillKey>>,
184    /// Initial turn behavior for this session creation call.
185    pub initial_turn: InitialTurnPolicy,
186    /// How to treat `prompt` when `initial_turn == Defer`.
187    pub deferred_prompt_policy: DeferredPromptPolicy,
188    /// Optional extended build options for factory-backed builders.
189    pub build: Option<SessionBuildOptions>,
190    /// Optional key-value labels attached at session creation.
191    pub labels: Option<BTreeMap<String, String>>,
192}
193
194impl CreateSessionRequest {
195    /// Compose the existing service-level labels and build app-context into the
196    /// shared surface metadata contract.
197    #[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/// Optional build-time options used by factory-backed session builders.
209#[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    /// Serializable tool definitions used to reconstruct recoverable
223    /// surface-owned dispatchers during session resume/rebuild.
224    pub recoverable_tool_defs: Option<Vec<crate::ToolDef>>,
225    /// Blob store used to externalize durable image content and hydrate refs
226    /// back to bytes at execution seams.
227    pub blob_store_override: Option<Arc<dyn crate::BlobStore>>,
228    /// Opaque transport for an optional per-request LLM override.
229    ///
230    /// Factory builders may downcast this to their concrete client trait.
231    pub llm_client_override: Option<Arc<dyn std::any::Any + Send + Sync>>,
232    /// Optional wrapper applied to the final agent-facing LLM client.
233    ///
234    /// This is intentionally provider-agnostic and runs after raw clients are
235    /// adapted into [`AgentLlmClient`].
236    pub agent_llm_client_decorator: Option<crate::AgentLlmClientDecorator>,
237    // NOTE: ops_lifecycle_override was removed in Phase 3.
238    // Use runtime_build_mode instead.
239    pub override_builtins: ToolCategoryOverride,
240    pub override_shell: ToolCategoryOverride,
241    pub override_memory: ToolCategoryOverride,
242    /// Per-build override for the factory-level scheduler capability.
243    pub override_schedule: ToolCategoryOverride,
244    /// Per-build override for the factory-level WorkGraph capability.
245    pub override_workgraph: ToolCategoryOverride,
246    pub override_mob: ToolCategoryOverride,
247    /// Per-build override for assistant image generation visibility.
248    ///
249    /// `Inherit` means "visible when the session-owned image-generation
250    /// substrate is available"; `Disable` hides the tool even when wired.
251    pub override_image_generation: ToolCategoryOverride,
252    /// Per-build override for Meerkat-owned fallback web search visibility.
253    ///
254    /// `Inherit` keeps the fallback hidden. `Enable` explicitly exposes the
255    /// fallback when the active model lacks native provider web search.
256    pub override_web_search: ToolCategoryOverride,
257    /// Agent-facing scheduler tools supplied by the embedding surface.
258    ///
259    /// Scheduler remains surface-owned. This dispatcher only controls
260    /// tool visibility/composition for the built agent.
261    pub schedule_tools: Option<Arc<dyn AgentToolDispatcher>>,
262    /// Agent-facing WorkGraph tools supplied by the embedding surface.
263    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    /// Realm-scoped auth binding (Phase 3 provider-auth redesign).
270    /// Flows into `AgentBuildConfig.auth_binding` via `FactoryAgentBuilder`.
271    pub auth_binding: Option<crate::AuthBindingRef>,
272    /// Whether this session runs as a keep-alive (long-running, interrupt-to-stop)
273    /// agent. Surfaces use this to decide blocking vs fire-and-return semantics.
274    pub keep_alive: bool,
275    /// Optional session checkpointer for keep-alive persistence.
276    pub checkpointer: Option<std::sync::Arc<dyn crate::checkpoint::SessionCheckpointer>>,
277    /// Comms intents that should be silently injected into the session
278    /// without triggering an LLM turn.
279    pub silent_comms_intents: Vec<String>,
280    /// Maximum peer-count threshold for inline peer lifecycle context injection.
281    ///
282    /// - `None`: use runtime default
283    /// - `0`: never inline peer lifecycle notifications
284    /// - `-1`: always inline peer lifecycle notifications
285    /// - `>0`: inline only when post-drain peer count is <= threshold
286    /// - `<-1`: invalid
287    pub max_inline_peer_notifications: Option<i32>,
288    /// Opaque application context passed through to custom `SessionAgentBuilder`
289    /// implementations. Not consumed by the standard build pipeline.
290    ///
291    /// Uses `Value` rather than `Box<RawValue>` because `SessionBuildOptions`
292    /// must be `Clone` and `Box<RawValue>` does not implement `Clone`.
293    /// Same tradeoff as `provider_params`.
294    pub app_context: Option<serde_json::Value>,
295    /// Additional instruction sections appended to the system prompt after skill
296    /// assembly, before tool instructions. Order preserved.
297    pub additional_instructions: Option<Vec<String>>,
298    /// Initial canonical session metadata entries applied before agent build.
299    ///
300    /// Used for surface-supplied runtime state such as session-local tool
301    /// visibility. The factory validates special keys before applying them.
302    pub initial_metadata_entries: BTreeMap<String, serde_json::Value>,
303    /// Environment variables injected into shell tool subprocesses for this agent.
304    /// Set by the application's `SessionAgentBuilder` — never by the LLM.
305    /// Values are not included in the agent's context window.
306    pub shell_env: Option<std::collections::HashMap<String, String>>,
307    /// Explicit call-timeout override at the build seam.
308    ///
309    /// - `Inherit` (default): defer to config override, then profile default
310    /// - `Disabled`: explicitly disable call timeout regardless of profile
311    /// - `Value(d)`: explicitly set call timeout to `d`
312    pub call_timeout_override: crate::CallTimeoutOverride,
313    /// Typed explicit-override intent for resumed-session merges.
314    ///
315    /// Surfaces set bits only for fields they can prove were explicitly
316    /// supplied by the caller. Resumed metadata then fills only the
317    /// non-explicit fields.
318    pub resume_override_mask: ResumeOverrideMask,
319    /// Late-binding mob tool factory, called inside `build_agent()` with
320    /// session-scoped args to produce the mob tool dispatcher.
321    ///
322    /// Surfaces that enable mob tools pass an `Arc<dyn MobToolsFactory>` here.
323    /// The factory calls [`MobToolsFactory::build_mob_tools`] during agent
324    /// construction with the session ID, ops lifecycle registry, and optional
325    /// comms runtime — then composes the result into the tool gateway.
326    pub mob_tools: Option<Arc<dyn MobToolsFactory>>,
327    /// Runtime build mode — determines how the factory resolves the ops lifecycle
328    /// registry and completion feed.
329    ///
330    /// - `SessionOwned(bindings)`: runtime-backed build with epoch-owned
331    ///   bindings. Factory validates `bindings.session_id == session.id()`.
332    /// - `StandaloneEphemeral`: factory creates local-only ephemeral bindings.
333    ///   Suitable for WASM, tests, embedded, and standalone surfaces.
334    pub runtime_build_mode: crate::runtime_epoch::RuntimeBuildMode,
335    /// Runtime-stamped metadata for an eager first turn.
336    ///
337    /// Session services only forward this carrier. They must not infer an
338    /// execution kind from runtime build mode.
339    pub initial_turn_metadata: Option<RuntimeTurnMetadata>,
340    /// Runtime-injected mob operator authority context.
341    ///
342    /// This is the only source of mob operator tool authority. Tool visibility
343    /// may depend on this context being present, but dispatch-time
344    /// authorization must still re-check the typed create/scope fields on
345    /// every operator call.
346    pub mob_tool_authority_context: Option<MobToolAuthorityContext>,
347}
348
349/// Opaque principal token carried through mob tool authority and provenance.
350///
351/// `meerkat-mob` may store or compare this token as an opaque blob, but it
352/// must not decode token structure, branch on token contents, or expand scope
353/// from it.
354#[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/// Runtime-supplied caller provenance carried alongside mob tool authority.
378///
379/// This is informational/projection-only data. It is not a second authority
380/// source and must never be used for policy expansion inside `meerkat-mob`.
381#[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/// Typed mob operator authority injected by the host/runtime.
425///
426/// This is capability-oriented only. It is not an identity or ownership
427/// model, and it must never be inferred from mob membership, session shape,
428/// `owner_session_id`, or profile flags.
429#[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    /// Grant management scope for a mob in-place (mutable borrow).
545    ///
546    /// Used by the turn executor when applying `SessionEffect::GrantManageMob`
547    /// effects from tool dispatch.
548    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
572/// Shared host/runtime policy for explicit mob-operator enablement.
573///
574/// When a host/runtime build seam explicitly enables mob operator tools for a
575/// session, the default authority shape is create-only. Existing-mob scope
576/// must still be injected separately and explicitly.
577pub 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
584/// Shared build-seam rule for mob operator access rehydration.
585///
586/// Explicit disable clears authority. Otherwise, persisted typed authority
587/// wins; if none exists, explicit mob enablement falls back to generated
588/// create-only authority.
589pub 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
608/// Provider of a snapshot of currently visible tools.
609///
610/// Implemented by the agent's `ToolScope` holder to capture tool visibility
611/// at spawn time for inheritance by mob children.
612pub trait VisibleToolSnapshotProvider: Send + Sync {
613    /// Returns the tool definitions currently visible to the parent agent.
614    fn snapshot_visible_tools(&self) -> Vec<Arc<ToolDef>>;
615}
616
617/// Context for capturing a parent agent's tool scope snapshot.
618///
619/// `ParentOwned` carries a provider that can snapshot the parent's visible
620/// tools at child spawn time. `Standalone` means no parent scope is available
621/// (e.g. top-level agents, tests).
622pub enum MobToolSnapshotContext {
623    /// Parent agent owns a tool scope; snapshot available on demand.
624    ParentOwned(Arc<dyn VisibleToolSnapshotProvider>),
625    /// No parent scope available.
626    Standalone,
627}
628
629/// Session-scoped arguments passed to [`MobToolsFactory::build_mob_tools`].
630pub struct MobToolsBuildArgs {
631    /// Session ID of the agent being built.
632    pub session_id: crate::SessionId,
633    /// Model name of the owning agent — inherited by implicit mob helpers.
634    pub model: String,
635    /// Runtime-injected mob operator authority context.
636    ///
637    /// Tool visibility may depend on this context being present, but operator
638    /// dispatch must still re-check the typed create/scope fields on every
639    /// call.
640    pub authority_context: Option<MobToolAuthorityContext>,
641    /// Shared effective mob authority handle owned by the agent.
642    ///
643    /// Mob tools read from this handle for authorization checks. The agent
644    /// (turn owner) is the sole writer — it updates this handle via
645    /// `apply_session_effects` after merging tool-produced `SessionEffect`s.
646    /// If `None`, mob tools fall back to `authority_context` as a static snapshot.
647    pub effective_authority: Option<Arc<std::sync::RwLock<MobToolAuthorityContext>>>,
648    /// Comms name of the owning agent (for building `TrustedPeerDescriptor`).
649    pub comms_name: Option<String>,
650    /// Optional comms runtime for auto-wiring spawned members.
651    pub comms_runtime: Option<Arc<dyn crate::agent::CommsRuntime>>,
652    /// Context for capturing a snapshot of the parent agent's visible tools.
653    pub snapshot_context: MobToolSnapshotContext,
654}
655
656/// Factory trait for late-binding mob tool construction.
657///
658/// Implementations capture surface-specific state (e.g. `MobMcpState`) and
659/// receive session-scoped arguments from `build_agent()` at construction time.
660/// This avoids a cyclic dependency between the facade crate and `meerkat-mob-mcp`.
661#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
662#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
663pub trait MobToolsFactory: Send + Sync {
664    /// Build a mob tool dispatcher for the given session.
665    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/// Typed explicit-override intent for resumed-session metadata merges.
672///
673/// This avoids trying to recover caller intent from flattened build config.
674#[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    /// Apply the shared rehydration rule for mob operator access.
698    ///
699    /// This preserves exact persisted authority when available and otherwise
700    /// falls back to generated create-only authority for explicit mob
701    /// enablement.
702    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    /// Apply the shared host/runtime default for explicit mob operator
714    /// enablement.
715    ///
716    /// This keeps `override_mob` and the generated create-only authority
717    /// context aligned at the composition seam. Existing-mob scope must be
718    /// injected explicitly elsewhere; this helper never infers it.
719    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            // Phase 3 field — default None keeps the legacy flat path.
739            // Populated by surfaces that accept realm/binding inputs.
740            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/// Runtime/session semantic carrier for starting a turn.
842///
843/// The session service forwards this as one machine/composition-owned bundle.
844/// It must not split handling mode, tool overlays, context appends, or runtime
845/// metadata back into service-level request fields.
846#[derive(Debug)]
847pub struct StartTurnRuntimeSemantics {
848    /// Optional normalized rendering metadata for this turn prompt.
849    pub render_metadata: Option<RenderMetadata>,
850    /// Handling mode for this turn's ordinary content-bearing work.
851    ///
852    /// This is a **runtime-owned semantic**: the runtime routes Queue/Steer
853    /// before calling the executor. The session service passes this through
854    /// to the `SessionAgent` but does not act on it. Non-Queue handling
855    /// only works correctly on runtime-backed surfaces.
856    pub handling_mode: HandlingMode,
857    /// Canonical SkillKeys to resolve and inject for this turn.
858    pub skill_references: Option<Vec<crate::skills::SkillKey>>,
859    /// Optional per-turn flow tool overlay (ephemeral, non-persistent).
860    pub flow_tool_overlay: Option<TurnToolOverlay>,
861    /// Runtime-owned system-context appends that must be applied at this
862    /// turn boundary before the model run starts.
863    pub pre_turn_context_appends: Vec<PendingSystemContextAppend>,
864    /// Canonical runtime-authored typed appends for this turn.
865    ///
866    /// Provider prompt text is an internal projection derived from these
867    /// appends at the runtime/service boundary. These appends are the
868    /// authorship source used for transcript persistence.
869    pub typed_turn_appends: Vec<ConversationAppend>,
870    /// Canonical runtime-authored metadata for this turn.
871    ///
872    /// Runtime-backed callers populate this once at the machine boundary and
873    /// the session layer derives per-turn policy from this typed carrier
874    /// instead of re-inferring or dropping fields.
875    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/// Request to start a new turn on an existing session.
929#[derive(Debug)]
930pub struct StartTurnRequest {
931    /// User prompt for this turn (text or multimodal).
932    pub prompt: ContentInput,
933    /// Optional system prompt override for a deferred session's first turn.
934    ///
935    /// This is only supported before the session has any conversation history.
936    /// Materialized sessions with existing messages must reject it.
937    pub system_prompt: Option<String>,
938    /// Channel for streaming events during the turn.
939    pub event_tx: Option<mpsc::Sender<EventEnvelope<AgentEvent>>>,
940    /// Single runtime/session semantic carrier for this turn.
941    pub runtime: StartTurnRuntimeSemantics,
942}
943
944/// Request to append runtime system context to an existing session.
945#[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/// Result of appending runtime system context to a session.
955#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
956pub struct AppendSystemContextResult {
957    pub status: AppendSystemContextStatus,
958}
959
960/// Request to stage callback tool results for the next turn.
961#[derive(Debug, Clone, Serialize, Deserialize)]
962pub struct StageToolResultsRequest {
963    pub results: Vec<crate::ToolResult>,
964}
965
966/// Result of staging callback tool results for the next turn.
967#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
968pub struct StageToolResultsResult {
969    pub accepted_result_count: usize,
970}
971
972/// Outcome of an append-system-context request.
973#[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/// Ephemeral per-turn tool overlay for flow-dispatched turns.
982#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
983#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
984pub struct TurnToolOverlay {
985    /// Optional allow-list for this turn.
986    #[serde(default)]
987    pub allowed_tools: Option<Vec<String>>,
988    /// Optional deny-list for this turn.
989    #[serde(default)]
990    pub blocked_tools: Option<Vec<String>>,
991    /// Tool-dispatch metadata visible only to dispatchers for this turn.
992    #[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    /// Return a caller-safe overlay by dropping runtime-owned dispatch metadata.
999    pub fn without_dispatch_context(mut self) -> Self {
1000        self.dispatch_context.clear();
1001        self
1002    }
1003}
1004
1005/// Public caller-safe per-turn tool overlay.
1006#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1007#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1008pub struct PublicTurnToolOverlay {
1009    /// Optional allow-list for this turn.
1010    #[serde(default)]
1011    pub allowed_tools: Option<Vec<String>>,
1012    /// Optional deny-list for this turn.
1013    #[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/// Query parameters for listing sessions.
1028#[derive(Debug, Default)]
1029pub struct SessionQuery {
1030    /// Maximum number of results.
1031    pub limit: Option<usize>,
1032    /// Offset for pagination.
1033    pub offset: Option<usize>,
1034    /// Filters sessions where all specified k/v pairs match.
1035    pub labels: Option<BTreeMap<String, String>>,
1036}
1037
1038/// Summary of a session (for list results).
1039///
1040/// Kept lightweight — no billing data. Use `read()` for full details.
1041#[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/// Detailed view of a session's state and history metadata.
1054#[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/// Billing/usage data for a session, returned separately from state.
1069#[derive(Debug, Clone, Serialize, Deserialize)]
1070pub struct SessionUsage {
1071    pub total_tokens: u64,
1072    pub usage: Usage,
1073}
1074
1075/// Combined session view (state + usage). Convenience wrapper used by
1076/// `SessionService::read()` to avoid requiring two calls.
1077#[derive(Debug, Clone, Serialize, Deserialize)]
1078pub struct SessionView {
1079    pub state: SessionInfo,
1080    pub billing: SessionUsage,
1081}
1082
1083impl SessionView {
1084    /// Convenience: session ID from the state.
1085    pub fn session_id(&self) -> &SessionId {
1086        &self.state.session_id
1087    }
1088}
1089
1090/// Query parameters for reading session history.
1091#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
1092pub struct SessionHistoryQuery {
1093    /// Number of messages to skip from the start of the transcript.
1094    pub offset: usize,
1095    /// Maximum number of messages to return.
1096    #[serde(default, skip_serializing_if = "Option::is_none")]
1097    pub limit: Option<usize>,
1098}
1099
1100/// Paginated transcript page for a session.
1101#[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    /// Build a transcript page from the full ordered message list.
1114    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/// Query parameters for reading a retained transcript revision body.
1137#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1138pub struct SessionTranscriptRevisionQuery {
1139    pub revision: String,
1140    /// Number of messages to skip from the start of the revision transcript.
1141    pub offset: usize,
1142    /// Maximum number of messages to return.
1143    #[serde(default, skip_serializing_if = "Option::is_none")]
1144    pub limit: Option<usize>,
1145}
1146
1147/// Paginated transcript page for a retained immutable revision.
1148#[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    /// Build a transcript page from a retained revision body.
1163    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/// Explicit behavior for transcript fork/edit requests when the source
1191/// session has active work.
1192#[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    /// Reject the request while the source session is active.
1197    #[default]
1198    Reject,
1199}
1200
1201/// Request to fork a session at a transcript message index.
1202#[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/// Request to fork a session and apply a typed transcript replacement.
1211#[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/// Result of creating an edited transcript branch.
1222#[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/// Request to rewrite the current transcript head without changing SessionId.
1235#[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/// Request to restore the current transcript head to a retained revision body.
1251#[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/// Result of committing a same-session transcript rewrite.
1265#[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    /// Convert a typed edit validation failure into a surface-friendly session
1278    /// error without widening the `SessionService` error enum.
1279    pub fn into_session_error(self) -> SessionError {
1280        SessionError::Agent(crate::error::AgentError::ConfigError(self.to_string()))
1281    }
1282}
1283
1284/// Canonical session lifecycle abstraction.
1285///
1286/// All surfaces delegate to this trait. Implementations control persistence,
1287/// compaction, and event logging behavior.
1288#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
1289#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
1290pub trait SessionService: Send + Sync {
1291    /// Create a new session and run the first turn.
1292    async fn create_session(&self, req: CreateSessionRequest) -> Result<RunResult, SessionError>;
1293
1294    /// Start a new turn on an existing session.
1295    async fn start_turn(
1296        &self,
1297        id: &SessionId,
1298        req: StartTurnRequest,
1299    ) -> Result<RunResult, SessionError>;
1300
1301    /// Cancel an in-flight turn.
1302    ///
1303    /// Returns `NotRunning` if no turn is active.
1304    async fn interrupt(&self, id: &SessionId) -> Result<(), SessionError>;
1305
1306    /// Cancel an in-flight turn once it reaches the next boundary.
1307    ///
1308    /// Returns `NotRunning` if no turn is active. Unsupported by default.
1309    async fn cancel_after_boundary(&self, _id: &SessionId) -> Result<(), SessionError> {
1310        Err(SessionError::Unsupported(
1311            "cancel_after_boundary".to_string(),
1312        ))
1313    }
1314
1315    /// Replace the LLM client on a live session.
1316    ///
1317    /// Enables mid-session model/provider hot-swap without rebuilding the
1318    /// agent. The new client takes effect on the next turn. Returns
1319    /// `Unsupported` by default; session services that support live agents
1320    /// override this.
1321    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    /// Atomically replace the live session client and the session's durable
1330    /// LLM identity.
1331    ///
1332    /// This is the canonical seam for materialized-session hot-swap semantics.
1333    /// Implementations should apply both updates together so future turns and
1334    /// resume/recovery see the same model/provider/provider_params identity.
1335    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    /// Replace the canonical tool visibility state carried by the live session.
1348    ///
1349    /// This seam is live-only and must not perform its own durable write. The
1350    /// caller owns any surrounding transactional persistence and rollback.
1351    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    /// Update the `keep_alive` flag on a live session's durable metadata.
1362    ///
1363    /// Called by the runtime when an explicit override changes the session's
1364    /// keep-alive intent so that subsequent inheriting calls observe the
1365    /// updated value. Returns `Unsupported` by default.
1366    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    /// Update the session's canonical mob operator authority context.
1377    ///
1378    /// This is the only supported seam for widening or narrowing exact mob
1379    /// management scope after session creation so recovery and live runtime
1380    /// state stay aligned.
1381    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    /// Whether a live in-memory session bridge currently exists for `id`.
1392    ///
1393    /// This is intentionally distinct from `list()` / `SessionSummary`:
1394    /// persisted-only summaries must not count as live, and idle live sessions
1395    /// must still count as live even when no turn is running.
1396    async fn has_live_session(&self, _id: &SessionId) -> Result<bool, SessionError> {
1397        Err(SessionError::Unsupported("has_live_session".to_string()))
1398    }
1399
1400    /// Stage an external tool visibility filter on a live session.
1401    ///
1402    /// Used to dynamically hide/show tools (e.g., `view_image`) after a
1403    /// model hot-swap changes capability support. Returns `Unsupported`
1404    /// by default.
1405    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    /// Read the current state of a session.
1416    async fn read(&self, id: &SessionId) -> Result<SessionView, SessionError>;
1417
1418    /// List sessions matching the query.
1419    async fn list(&self, query: SessionQuery) -> Result<Vec<SessionSummary>, SessionError>;
1420
1421    /// Archive (remove) a session.
1422    async fn archive(&self, id: &SessionId) -> Result<(), SessionError>;
1423
1424    /// Subscribe to session-wide events regardless of triggering interaction.
1425    ///
1426    /// Services that do not support this capability return `StreamError::NotFound`.
1427    async fn subscribe_session_events(&self, id: &SessionId) -> Result<EventStream, StreamError> {
1428        Err(StreamError::NotFound(format!("session {id}")))
1429    }
1430}
1431
1432/// Optional comms/control-plane extension for `SessionService`.
1433///
1434/// Base lifecycle operations stay on `SessionService`; advanced surfaces
1435/// (RPC/REST/mob orchestration) can use this trait when they need direct
1436/// access to comms runtime and injector handles.
1437#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
1438#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
1439pub trait SessionServiceCommsExt: SessionService {
1440    /// Get the comms runtime for a session, if available.
1441    async fn comms_runtime(
1442        &self,
1443        _session_id: &SessionId,
1444    ) -> Option<Arc<dyn crate::agent::CommsRuntime>> {
1445        None
1446    }
1447
1448    /// Get the event injector for a session, if available.
1449    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    /// Internal runtime seam for interaction-scoped injection.
1459    #[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/// Optional control-plane extension for `SessionService`.
1471///
1472/// Keeps the base lifecycle contract minimal while exposing first-class
1473/// session mutation operations shared across external surfaces.
1474#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
1475#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
1476pub trait SessionServiceControlExt: SessionService {
1477    /// Append runtime system context to a session.
1478    ///
1479    /// The request is idempotent per `(session_id, idempotency_key)`. When a
1480    /// turn is active, implementations may stage the append for application at
1481    /// the next LLM boundary rather than mutating in-flight request state.
1482    async fn append_system_context(
1483        &self,
1484        id: &SessionId,
1485        req: AppendSystemContextRequest,
1486    ) -> Result<AppendSystemContextResult, SessionControlError>;
1487
1488    /// Stage callback tool results for application on the next turn seam.
1489    ///
1490    /// Implementations must persist the staged results durably before a live
1491    /// session can observe them so a failed call never leaves hidden pending
1492    /// transcript mutations behind.
1493    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/// Optional history-read extension for `SessionService`.
1504///
1505/// Keeps the base lifecycle contract lightweight while allowing surfaces to
1506/// fetch full transcript contents when they explicitly opt in.
1507#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
1508#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
1509pub trait SessionServiceHistoryExt: SessionService {
1510    /// Read the committed transcript for a session.
1511    ///
1512    /// Implementations may return `PersistenceDisabled` if they cannot provide
1513    /// authoritative history for the requested lifecycle state.
1514    async fn read_history(
1515        &self,
1516        id: &SessionId,
1517        query: SessionHistoryQuery,
1518    ) -> Result<SessionHistoryPage, SessionError>;
1519
1520    /// Read a retained immutable transcript revision body.
1521    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/// Optional typed transcript fork/edit extension for `SessionService`.
1534///
1535/// Implementations must route transcript surgery through typed semantic edit
1536/// paths. Fork operations create new `SessionId`s; rewrite operations keep the
1537/// `SessionId` stable while advancing the session transcript head.
1538#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
1539#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
1540pub trait SessionServiceTranscriptEditExt: SessionService {
1541    /// Fork a session at a message index.
1542    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    /// Fork a session and apply a typed transcript replacement.
1552    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    /// Commit a typed same-session transcript rewrite.
1564    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    /// Restore the current transcript head to a retained immutable revision.
1576    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
1588/// Extension trait for `Arc<dyn SessionService>` to allow calling methods directly.
1589impl dyn SessionService {
1590    /// Wrap self in an Arc.
1591    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}