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, CoreRenderable, RuntimeTurnMetadata};
11use crate::session::{PendingSystemContextAppend, SystemContextStageError};
12use crate::time_compat::SystemTime;
13#[cfg(target_arch = "wasm32")]
14use crate::tokio;
15use crate::types::{ContentInput, HandlingMode, Message, RunResult, SessionId, ToolDef, Usage};
16use crate::{
17    AgentToolDispatcher, BudgetLimits, HookRunOverrides, OutputSchema, PeerMeta, Provider, Session,
18    SessionLlmIdentity, ToolCategoryOverride,
19};
20use crate::{EventStream, StreamError};
21use async_trait::async_trait;
22use serde::{Deserialize, Serialize};
23use std::collections::BTreeMap;
24use std::collections::BTreeSet;
25use std::sync::{Arc, LazyLock, RwLock};
26use tokio::sync::mpsc;
27
28pub use crate::session::{
29    TranscriptEditError, TranscriptReplacement, TranscriptRewriteCommit, TranscriptRewriteReason,
30    TranscriptRewriteSelection,
31};
32
33/// Controls whether `create_session()` should execute an initial turn.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum InitialTurnPolicy {
36    /// Run the initial turn immediately as part of session creation.
37    RunImmediately,
38    /// Register the session and return without running an initial turn.
39    ///
40    /// `CreateSessionRequest::deferred_prompt_policy` determines whether the
41    /// create-time prompt is discarded or staged for the first later turn.
42    Defer,
43}
44
45/// How a deferred create request treats its create-time prompt.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
47#[serde(rename_all = "snake_case")]
48pub enum DeferredPromptPolicy {
49    /// Register the session only; the caller will supply the first runtime input separately.
50    #[default]
51    Discard,
52    /// Persist the create-time prompt and merge it into the first later turn.
53    Stage,
54}
55
56/// Errors returned by `SessionService` methods.
57#[derive(Debug, thiserror::Error)]
58pub enum SessionError {
59    /// The requested session does not exist.
60    #[error("session not found: {id}")]
61    NotFound { id: SessionId },
62
63    /// A turn is already in progress on this session.
64    #[error("session is busy: {id}")]
65    Busy { id: SessionId },
66
67    /// The operation requires persistence but the `session-store` feature is disabled.
68    #[error("session persistence is disabled")]
69    PersistenceDisabled,
70
71    /// The operation requires compaction but the `session-compaction` feature is disabled.
72    #[error("session compaction is disabled")]
73    CompactionDisabled,
74
75    /// No turn is currently running on this session.
76    #[error("no turn running on session: {id}")]
77    NotRunning { id: SessionId },
78
79    /// A session store operation failed.
80    #[error("store error: {0}")]
81    Store(#[source] Box<dyn std::error::Error + Send + Sync>),
82
83    /// An agent-level error occurred during execution.
84    #[error("agent error: {0}")]
85    Agent(#[from] crate::error::AgentError),
86
87    /// The operation failed with structured error data for protocol surfaces.
88    #[error("{message}")]
89    FailedWithData {
90        message: String,
91        data: serde_json::Value,
92    },
93
94    /// The requested operation is not supported by this session service.
95    #[error("unsupported: {0}")]
96    Unsupported(String),
97}
98
99impl SessionError {
100    /// Return a stable error code string for wire formats.
101    pub fn code(&self) -> &'static str {
102        match self {
103            Self::NotFound { .. } => "SESSION_NOT_FOUND",
104            Self::Busy { .. } => "SESSION_BUSY",
105            Self::PersistenceDisabled => "SESSION_PERSISTENCE_DISABLED",
106            Self::CompactionDisabled => "SESSION_COMPACTION_DISABLED",
107            Self::NotRunning { .. } => "SESSION_NOT_RUNNING",
108            Self::Store(_) => "SESSION_STORE_ERROR",
109            Self::Unsupported(_) => "SESSION_UNSUPPORTED",
110            Self::Agent(_) => "AGENT_ERROR",
111            Self::FailedWithData { .. } => "SESSION_ERROR",
112        }
113    }
114
115    pub fn structured_data(&self) -> Option<serde_json::Value> {
116        match self {
117            Self::FailedWithData { data, .. } => Some(data.clone()),
118            _ => None,
119        }
120    }
121}
122
123/// Errors returned by session control-plane mutation methods.
124#[derive(Debug, thiserror::Error)]
125pub enum SessionControlError {
126    /// A lifecycle/session-store error occurred while handling the control request.
127    #[error(transparent)]
128    Session(#[from] SessionError),
129
130    /// The control request was malformed.
131    #[error("invalid system-context request: {message}")]
132    InvalidRequest { message: String },
133
134    /// The idempotency key was replayed with different request content.
135    #[error(
136        "system-context idempotency conflict on session {id}: key '{key}' already maps to different content"
137    )]
138    Conflict { id: SessionId, key: String },
139}
140
141impl SessionControlError {
142    /// Return a stable error code string for wire formats.
143    pub fn code(&self) -> &'static str {
144        match self {
145            Self::Session(err) => err.code(),
146            Self::InvalidRequest { .. } => "INVALID_PARAMS",
147            Self::Conflict { .. } => "SESSION_SYSTEM_CONTEXT_CONFLICT",
148        }
149    }
150}
151
152impl SystemContextStageError {
153    /// Convert a stage-time state conflict into a surface-level control error.
154    pub fn into_control_error(self, id: &SessionId) -> SessionControlError {
155        match self {
156            Self::InvalidRequest(message) => SessionControlError::InvalidRequest { message },
157            Self::Conflict { key, .. } => SessionControlError::Conflict {
158                id: id.clone(),
159                key,
160            },
161        }
162    }
163}
164
165/// Request to create a new session and run the first turn.
166///
167/// Initial-turn runtime semantics (render metadata, skill references,
168/// handling mode, tool overlays, …) have exactly ONE carrier:
169/// `SessionBuildOptions::initial_turn_metadata` (a
170/// [`crate::lifecycle::run_primitive::RuntimeTurnMetadata`]). The former
171/// request-level `render_metadata` / `skill_references` duplicates are gone
172/// (dogma K10) — callers populate the typed carrier in `build`.
173#[derive(Debug)]
174pub struct CreateSessionRequest {
175    /// Model name (a catalog or custom model id).
176    pub model: String,
177    /// Initial user prompt (text or multimodal).
178    pub prompt: ContentInput,
179    /// Host-attached injected context for the first turn (default empty).
180    ///
181    /// Each entry materializes as a separate typed injected-context
182    /// user-channel message ([`crate::types::TranscriptUserRole::InjectedContext`])
183    /// immediately BEFORE the first turn's user message, in order. Injected
184    /// context is excluded from semantic-memory indexing and never satisfies
185    /// the transcript-continuity save-guard.
186    pub injected_context: Vec<ContentInput>,
187    /// Typed per-request system-prompt policy (Inherit/Set/Disable).
188    pub system_prompt: crate::config::SystemPromptOverride,
189    /// Max tokens per LLM turn.
190    pub max_tokens: Option<u32>,
191    /// Channel for streaming events during the turn.
192    pub event_tx: Option<mpsc::Sender<EventEnvelope<AgentEvent>>>,
193    /// Initial turn behavior for this session creation call.
194    pub initial_turn: InitialTurnPolicy,
195    /// How to treat `prompt` when `initial_turn == Defer`.
196    pub deferred_prompt_policy: DeferredPromptPolicy,
197    /// Optional extended build options for factory-backed builders.
198    pub build: Option<SessionBuildOptions>,
199    /// Optional key-value labels attached at session creation.
200    pub labels: Option<BTreeMap<String, String>>,
201}
202
203impl CreateSessionRequest {
204    /// Compose the existing service-level labels and build app-context into the
205    /// shared surface metadata contract.
206    #[must_use]
207    pub fn surface_metadata(&self) -> crate::SurfaceMetadata {
208        crate::SurfaceMetadata::from_optional_parts(
209            self.labels.clone(),
210            self.build
211                .as_ref()
212                .and_then(|build| build.app_context.clone()),
213        )
214    }
215}
216
217/// Optional build-time options used by factory-backed session builders.
218#[derive(Clone)]
219pub struct SessionBuildOptions {
220    pub provider: Option<Provider>,
221    pub self_hosted_server_id: Option<String>,
222    /// Caller-scoped custom model registry entries (e.g. mob-definition
223    /// `[models.<id>]` tables), merged into the effective `ModelRegistry`
224    /// for this build via `ModelRegistry::from_config_with_models`.
225    pub custom_models: BTreeMap<String, crate::config::CustomModelConfig>,
226    /// Configured default provider for `Auto` image-generation targets.
227    ///
228    /// When set, the image-generation planner resolves
229    /// `ImageGenerationTargetPreference::Auto` against this provider instead
230    /// of inferring a provider from the session's effective text model.
231    pub image_generation_provider: Option<Provider>,
232    /// Per-build auto-compaction threshold override (tokens).
233    ///
234    /// `NonZeroU64` is the typed fail-closed carrier: a zero threshold is
235    /// rejected at ingress instead of disabling compaction by accident. When
236    /// set, this wins over both the global config knob and model-aware
237    /// context-window scaling.
238    pub auto_compact_threshold_override: Option<std::num::NonZeroU64>,
239    pub output_schema: Option<OutputSchema>,
240    /// Structured-output retry budget *intent*. `None` inherits the canonical
241    /// default ([`crate::config::default_structured_output_retries`]); the
242    /// `AgentFactory` seam resolves it. Surfaces must not fabricate a default.
243    pub structured_output_retries: Option<u32>,
244    pub hooks_override: HookRunOverrides,
245    pub comms_name: Option<String>,
246    pub peer_meta: Option<PeerMeta>,
247    pub resume_session: Option<Session>,
248    pub budget_limits: Option<BudgetLimits>,
249    /// Typed explicit provider parameter overrides for this build (K2:
250    /// the JSON bag is retired; surfaces parse fail-closed at their ingress).
251    pub provider_params: Option<crate::lifecycle::run_primitive::ProviderParamsOverride>,
252    pub external_tools: Option<Arc<dyn AgentToolDispatcher>>,
253    /// Declarative MCP server configs for this build. The factory
254    /// materializes them into a session-owned MCP router composed with
255    /// `external_tools` and builtins, constructed against the build mode's
256    /// canonical external-tool surface authority (no test-code ephemeral
257    /// handle incantation required from embedders).
258    pub mcp_servers: Vec<crate::mcp_config::McpServerConfig>,
259    /// Serializable tool definitions used to reconstruct recoverable
260    /// surface-owned dispatchers during session resume/rebuild.
261    pub recoverable_tool_defs: Option<Vec<crate::ToolDef>>,
262    /// Blob store used to externalize durable image content and hydrate refs
263    /// back to bytes at execution seams.
264    pub blob_store_override: Option<Arc<dyn crate::BlobStore>>,
265    /// Opaque transport for an optional per-request LLM override.
266    ///
267    /// Factory builders may downcast this to their concrete client trait.
268    pub llm_client_override: Option<Arc<dyn std::any::Any + Send + Sync>>,
269    /// Optional wrapper applied to the final agent-facing LLM client.
270    ///
271    /// This is intentionally provider-agnostic and runs after raw clients are
272    /// adapted into [`AgentLlmClient`].
273    pub agent_llm_client_decorator: Option<crate::AgentLlmClientDecorator>,
274    // NOTE: ops_lifecycle_override was removed in Phase 3.
275    // Use runtime_build_mode instead.
276    pub override_builtins: ToolCategoryOverride,
277    pub override_shell: ToolCategoryOverride,
278    /// Per-build override for the factory-level comms tooling capability.
279    pub override_comms: ToolCategoryOverride,
280    pub override_memory: ToolCategoryOverride,
281    /// Per-build override for the factory-level scheduler capability.
282    pub override_schedule: ToolCategoryOverride,
283    /// Per-build override for the factory-level WorkGraph capability.
284    pub override_workgraph: ToolCategoryOverride,
285    pub override_mob: ToolCategoryOverride,
286    /// Per-build override for assistant image generation visibility.
287    ///
288    /// `Inherit` means "visible when the session-owned image-generation
289    /// substrate is available"; `Disable` hides the tool even when wired.
290    pub override_image_generation: ToolCategoryOverride,
291    /// Per-build override for Meerkat-owned fallback web search visibility.
292    ///
293    /// `Inherit` keeps the fallback hidden. `Enable` explicitly exposes the
294    /// fallback when the active model lacks native provider web search.
295    pub override_web_search: ToolCategoryOverride,
296    /// Agent-facing scheduler tools supplied by the embedding surface.
297    ///
298    /// Scheduler remains surface-owned. This dispatcher only controls
299    /// tool visibility/composition for the built agent.
300    pub schedule_tools: Option<Arc<dyn AgentToolDispatcher>>,
301    /// Agent-facing WorkGraph tools supplied by the embedding surface.
302    pub workgraph_tools: Option<Arc<dyn AgentToolDispatcher>>,
303    pub preload_skills: Option<Vec<crate::skills::SkillKey>>,
304    pub realm_id: Option<crate::RealmId>,
305    pub instance_id: Option<String>,
306    /// Typed realm-pinned session-store backend for this build.
307    ///
308    /// Carries the single typed owner ([`RecoveryBackendKind`]) rather than a
309    /// bare backend string, so a recovery-environment hint cannot
310    /// silently become durable identity: any raw backend string is parsed
311    /// fail-closed at its ingress boundary before it reaches this field.
312    pub backend: Option<crate::session_recovery::RecoveryBackendKind>,
313    pub config_generation: Option<u64>,
314    /// Realm-scoped auth binding (Phase 3 provider-auth redesign).
315    /// Flows into `AgentBuildConfig.auth_binding` via `FactoryAgentBuilder`.
316    pub auth_binding: Option<crate::AuthBindingRef>,
317    /// Typed durable mob-member identity. Set by the mob runtime when building a
318    /// member's session; flows into `AgentBuildConfig.mob_member_binding` and is
319    /// persisted onto `SessionMetadata.mob_member_binding`.
320    pub mob_member_binding: Option<crate::MobMemberBinding>,
321    /// Whether this session runs as a keep-alive (long-running, interrupt-to-stop)
322    /// agent. Surfaces use this to decide blocking vs fire-and-return semantics.
323    pub keep_alive: bool,
324    /// Optional session checkpointer for keep-alive persistence.
325    pub checkpointer: Option<std::sync::Arc<dyn crate::checkpoint::SessionCheckpointer>>,
326    /// Comms intents that should be silently injected into the session
327    /// without triggering an LLM turn.
328    pub silent_comms_intents: Vec<String>,
329    /// Maximum peer-count threshold for inline peer lifecycle context injection.
330    ///
331    /// - `None`: use runtime default
332    /// - `0`: never inline peer lifecycle notifications
333    /// - `-1`: always inline peer lifecycle notifications
334    /// - `>0`: inline only when post-drain peer count is <= threshold
335    /// - `<-1`: invalid
336    pub max_inline_peer_notifications: Option<i32>,
337    /// Opaque application context passed through to custom `SessionAgentBuilder`
338    /// implementations. Not consumed by the standard build pipeline.
339    ///
340    /// Uses `Value` rather than `Box<RawValue>` because `SessionBuildOptions`
341    /// must be `Clone` and `Box<RawValue>` does not implement `Clone`.
342    pub app_context: Option<serde_json::Value>,
343    /// Additional instruction sections appended to the system prompt after skill
344    /// assembly, before tool instructions. Order preserved.
345    pub additional_instructions: Option<Vec<String>>,
346    /// Initial canonical session metadata entries applied before agent build.
347    ///
348    /// Used for surface-supplied runtime state such as session-local tool
349    /// visibility. The factory validates special keys before applying them.
350    pub initial_metadata_entries: BTreeMap<String, serde_json::Value>,
351    /// Session-local initial tool-visibility filter applied at agent build.
352    ///
353    /// A typed carrier (not a metadata-string side channel) that rides the
354    /// build options so it survives deferred-session materialization, where the
355    /// `AgentBuildConfig` is reconstructed from these options.
356    pub initial_tool_filter: Option<crate::tool_scope::ToolFilter>,
357    /// Per-launch call-level tool access policy (existing
358    /// [`crate::ops::ToolAccessPolicy`] vocabulary). Flows into
359    /// `AgentBuildConfig.tool_access_policy`, where the factory resolves it
360    /// into the sealed execution form and gates the final composed dispatcher.
361    /// `Inherit` must be resolved by the spawn chain before build; an
362    /// unresolved `Inherit` fails the build closed.
363    pub tool_access_policy: Option<crate::ops::ToolAccessPolicy>,
364    /// Environment variables injected into shell tool subprocesses for this agent.
365    /// Set by the application's `SessionAgentBuilder` — never by the LLM.
366    /// Values are not included in the agent's context window.
367    pub shell_env: Option<std::collections::HashMap<String, String>>,
368    /// Explicit call-timeout override at the build seam.
369    ///
370    /// - `Inherit` (default): defer to config override, then profile default
371    /// - `Disabled`: explicitly disable call timeout regardless of profile
372    /// - `Value(d)`: explicitly set call timeout to `d`
373    pub call_timeout_override: crate::CallTimeoutOverride,
374    /// Typed explicit-override intent for resumed-session merges.
375    ///
376    /// Surfaces set bits only for fields they can prove were explicitly
377    /// supplied by the caller. Resumed metadata then fills only the
378    /// non-explicit fields.
379    pub resume_override_mask: ResumeOverrideMask,
380    /// Late-binding mob tool factory, called inside `build_agent()` with
381    /// session-scoped args to produce the mob tool dispatcher.
382    ///
383    /// Surfaces that enable mob tools pass an `Arc<dyn MobToolsFactory>` here.
384    /// The factory calls [`MobToolsFactory::build_mob_tools`] during agent
385    /// construction only after generated mob authority is present, then
386    /// composes the result into the tool gateway.
387    pub mob_tools: Option<Arc<dyn MobToolsFactory>>,
388    /// Runtime build mode — determines how the factory resolves the ops lifecycle
389    /// registry and completion feed.
390    ///
391    /// - `SessionOwned(bindings)`: runtime-backed build with epoch-owned
392    ///   bindings. Factory validates `bindings.session_id == session.id()`.
393    /// - `StandaloneEphemeral`: factory creates local-only ephemeral bindings.
394    ///   Suitable for WASM, tests, embedded, and standalone surfaces.
395    pub runtime_build_mode: crate::runtime_epoch::RuntimeBuildMode,
396    /// Runtime-stamped metadata for an eager first turn.
397    ///
398    /// Session services only forward this carrier. They must not infer an
399    /// execution kind from runtime build mode.
400    pub initial_turn_metadata: Option<RuntimeTurnMetadata>,
401    /// Runtime-injected mob operator authority context.
402    ///
403    /// This is the only source of mob operator tool authority. Tool visibility
404    /// may depend on this context being present, but dispatch-time
405    /// authorization must still re-check the typed create/scope fields on
406    /// every operator call.
407    pub mob_tool_authority_context: Option<MobToolAuthorityContext>,
408}
409
410/// Opaque principal token carried through mob tool authority and provenance.
411///
412/// `meerkat-mob` may store or compare this token as an opaque blob, but it
413/// must not decode token structure, branch on token contents, or expand scope
414/// from it.
415#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
416pub struct OpaquePrincipalToken(String);
417
418impl OpaquePrincipalToken {
419    pub fn new(token: impl Into<String>) -> Self {
420        Self(token.into())
421    }
422
423    pub fn generated() -> Self {
424        Self(uuid::Uuid::new_v4().to_string())
425    }
426
427    pub fn as_str(&self) -> &str {
428        &self.0
429    }
430}
431
432impl std::fmt::Display for OpaquePrincipalToken {
433    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
434        f.write_str(self.as_str())
435    }
436}
437
438/// Runtime-supplied caller provenance carried alongside mob tool authority.
439///
440/// This is informational/projection-only data. It is not a second authority
441/// source and must never be used for policy expansion inside `meerkat-mob`.
442#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
443pub struct MobToolCallerProvenance {
444    #[serde(default, skip_serializing_if = "Option::is_none")]
445    caller_session_id: Option<crate::SessionId>,
446    #[serde(default, skip_serializing_if = "Option::is_none")]
447    caller_mob_id: Option<String>,
448    #[serde(default, skip_serializing_if = "Option::is_none")]
449    caller_member_id: Option<String>,
450}
451
452impl MobToolCallerProvenance {
453    pub fn new() -> Self {
454        Self::default()
455    }
456
457    pub fn with_session_id(mut self, session_id: crate::SessionId) -> Self {
458        self.caller_session_id = Some(session_id);
459        self
460    }
461
462    pub fn with_mob_id(mut self, mob_id: impl Into<String>) -> Self {
463        self.caller_mob_id = Some(mob_id.into());
464        self
465    }
466
467    pub fn with_member_id(mut self, member_id: impl Into<String>) -> Self {
468        self.caller_member_id = Some(member_id.into());
469        self
470    }
471
472    pub fn caller_session_id(&self) -> Option<&crate::SessionId> {
473        self.caller_session_id.as_ref()
474    }
475
476    pub fn caller_mob_id(&self) -> Option<&str> {
477        self.caller_mob_id.as_deref()
478    }
479
480    pub fn caller_member_id(&self) -> Option<&str> {
481        self.caller_member_id.as_deref()
482    }
483}
484
485/// Typed mob operator authority injected by the host/runtime.
486///
487/// This is capability-oriented only. It is not an identity or ownership
488/// model, and it must never be inferred from mob membership, session shape,
489/// `owner_session_id`, or profile flags.
490#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
491pub struct MobToolAuthorityContext {
492    #[serde(skip)]
493    generated_authority_seal: MobToolAuthorityContextSeal,
494    principal_token: OpaquePrincipalToken,
495    can_create_mobs: bool,
496    #[serde(default)]
497    can_mutate_profiles: bool,
498    #[serde(default)]
499    can_run_adaptive_packs: bool,
500    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
501    managed_mob_scope: BTreeSet<String>,
502    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
503    spawn_profile_scope: BTreeMap<String, BTreeSet<String>>,
504    #[serde(default, skip_serializing_if = "Option::is_none")]
505    caller_provenance: Option<MobToolCallerProvenance>,
506    #[serde(default, skip_serializing_if = "Option::is_none")]
507    audit_invocation_id: Option<String>,
508}
509
510#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
511struct MobToolAuthorityContextSeal {
512    generated: bool,
513}
514
515#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
516struct MobToolAuthorityCapabilities {
517    can_create_mobs: bool,
518    can_mutate_profiles: bool,
519    can_run_adaptive_packs: bool,
520}
521
522impl MobToolAuthorityContextSeal {
523    const fn generated() -> Self {
524        Self { generated: true }
525    }
526}
527
528static EMPTY_MOB_AUTHORITY_MANAGED_SCOPE: LazyLock<BTreeSet<String>> = LazyLock::new(BTreeSet::new);
529static EMPTY_MOB_AUTHORITY_SPAWN_SCOPE: LazyLock<BTreeMap<String, BTreeSet<String>>> =
530    LazyLock::new(BTreeMap::new);
531static UNTRUSTED_MOB_AUTHORITY_PRINCIPAL: LazyLock<OpaquePrincipalToken> =
532    LazyLock::new(|| OpaquePrincipalToken::new("untrusted-mob-authority-context"));
533
534impl MobToolAuthorityContext {
535    #[cfg_attr(
536        not(any(test, meerkat_internal_generated_authority_bridge)),
537        allow(dead_code)
538    )]
539    fn from_generated_parts(
540        principal_token: OpaquePrincipalToken,
541        capabilities: MobToolAuthorityCapabilities,
542        managed_mob_scope: BTreeSet<String>,
543        spawn_profile_scope: BTreeMap<String, BTreeSet<String>>,
544        caller_provenance: Option<MobToolCallerProvenance>,
545        audit_invocation_id: Option<String>,
546    ) -> Result<Self, String> {
547        if principal_token.as_str().trim().is_empty() {
548            return Err("generated mob tool authority requires a non-empty principal token".into());
549        }
550        Ok(Self {
551            generated_authority_seal: MobToolAuthorityContextSeal::generated(),
552            principal_token,
553            can_create_mobs: capabilities.can_create_mobs,
554            can_mutate_profiles: capabilities.can_mutate_profiles,
555            can_run_adaptive_packs: capabilities.can_run_adaptive_packs,
556            managed_mob_scope,
557            spawn_profile_scope,
558            caller_provenance,
559            audit_invocation_id,
560        })
561    }
562
563    #[cfg(test)]
564    #[allow(clippy::expect_used, clippy::too_many_arguments)]
565    pub(crate) fn generated_for_test(
566        principal_token: OpaquePrincipalToken,
567        can_create_mobs: bool,
568        can_mutate_profiles: bool,
569        can_run_adaptive_packs: bool,
570        managed_mob_scope: BTreeSet<String>,
571        spawn_profile_scope: BTreeMap<String, BTreeSet<String>>,
572        caller_provenance: Option<MobToolCallerProvenance>,
573        audit_invocation_id: Option<String>,
574    ) -> Self {
575        Self::from_generated_parts(
576            principal_token,
577            MobToolAuthorityCapabilities {
578                can_create_mobs,
579                can_mutate_profiles,
580                can_run_adaptive_packs,
581            },
582            managed_mob_scope,
583            spawn_profile_scope,
584            caller_provenance,
585            audit_invocation_id,
586        )
587        .expect("test mob authority context must be generated-shape valid")
588    }
589
590    /// True only for contexts minted by the generated authority bridge.
591    ///
592    /// Serde intentionally cannot preserve this marker. Any stored or
593    /// deserialized context is a compatibility projection until a generated
594    /// authority seam restores it.
595    pub fn is_generated_authority_context(&self) -> bool {
596        self.generated_authority_seal.generated
597    }
598
599    pub fn principal_token(&self) -> &OpaquePrincipalToken {
600        if self.is_generated_authority_context() {
601            &self.principal_token
602        } else {
603            &UNTRUSTED_MOB_AUTHORITY_PRINCIPAL
604        }
605    }
606
607    pub fn can_create_mobs(&self) -> bool {
608        self.is_generated_authority_context() && self.can_create_mobs
609    }
610
611    pub fn can_mutate_profiles(&self) -> bool {
612        self.is_generated_authority_context() && self.can_mutate_profiles
613    }
614
615    pub fn can_run_adaptive_packs(&self) -> bool {
616        self.is_generated_authority_context() && self.can_run_adaptive_packs
617    }
618
619    pub fn managed_mob_scope(&self) -> &BTreeSet<String> {
620        if self.is_generated_authority_context() {
621            &self.managed_mob_scope
622        } else {
623            &EMPTY_MOB_AUTHORITY_MANAGED_SCOPE
624        }
625    }
626
627    pub fn spawn_profile_scope(&self) -> &BTreeMap<String, BTreeSet<String>> {
628        if self.is_generated_authority_context() {
629            &self.spawn_profile_scope
630        } else {
631            &EMPTY_MOB_AUTHORITY_SPAWN_SCOPE
632        }
633    }
634
635    pub fn caller_provenance(&self) -> Option<&MobToolCallerProvenance> {
636        self.is_generated_authority_context()
637            .then_some(self.caller_provenance.as_ref())
638            .flatten()
639    }
640
641    pub fn audit_invocation_id(&self) -> Option<&str> {
642        self.is_generated_authority_context()
643            .then_some(self.audit_invocation_id.as_deref())
644            .flatten()
645    }
646
647    pub fn can_manage_mob(&self, mob_id: &str) -> bool {
648        self.managed_mob_scope().contains(mob_id)
649    }
650
651    /// Raw, atomic observation: whether the operator holds a non-empty
652    /// spawn-profile scope for `mob_id`. This is one of the two pure facts the
653    /// spawn-tool shell feeds to MobMachine's `ResolveSpawnToolAdmission`
654    /// (alongside [`Self::can_manage_mob`]); the machine — not the shell —
655    /// composes the disjunction into the Allow/Deny verdict. This is NOT a
656    /// pre-reduced admission conclusion.
657    pub fn spawn_profile_scope_present(&self, mob_id: &str) -> bool {
658        self.spawn_profile_scope()
659            .get(mob_id)
660            .is_some_and(|profiles| !profiles.is_empty())
661    }
662
663    /// Raw, atomic observation: whether the operator's spawn-profile scope SET
664    /// for `mob_id` CONTAINS `profile`. This is a pure per-profile
665    /// set-membership fact — it does NOT OR in [`Self::can_manage_mob`]. The
666    /// spawn-member shell feeds this raw fact (alongside the separate
667    /// `manage_scope_present` observation) to MobMachine's
668    /// `ResolveSpawnMemberAdmission`; the machine — not the shell — composes the
669    /// `manage_scope_present || profile_scope_contains` disjunction into the
670    /// Allow/Deny verdict. This is a RAW per-profile set-membership fact, NOT a
671    /// pre-reduced admission conclusion (it does not OR in manage scope).
672    pub fn spawn_profile_scope_contains(&self, mob_id: &str, profile: &str) -> bool {
673        self.spawn_profile_scope()
674            .get(mob_id)
675            .is_some_and(|profiles| profiles.contains(profile))
676    }
677}
678
679#[cfg(all(meerkat_internal_generated_authority_bridge, not(test)))]
680#[allow(improper_ctypes_definitions, unsafe_code)]
681unsafe extern "Rust" {
682    #[link_name = concat!(
683        "__meerkat_runtime_generated_authority_bridge_token_is_valid_v1_mob_operator_authority_",
684        env!("MEERKAT_GENERATED_AUTHORITY_BRIDGE_SYMBOL_SUFFIX")
685    )]
686    fn runtime_mob_operator_authority_generated_authority_bridge_token_is_valid(
687        token: &(dyn std::any::Any + Send + Sync),
688    ) -> bool;
689}
690
691#[cfg(all(meerkat_internal_generated_authority_bridge, not(test)))]
692#[doc(hidden)]
693#[allow(improper_ctypes_definitions, unsafe_code)]
694#[allow(clippy::too_many_arguments)]
695#[unsafe(export_name = concat!(
696    "__meerkat_core_runtime_generated_mob_tool_authority_context_build_v1_",
697    env!("MEERKAT_GENERATED_AUTHORITY_BRIDGE_SYMBOL_SUFFIX")
698))]
699pub(crate) extern "Rust" fn runtime_generated_mob_tool_authority_context_build(
700    token: &'static (dyn std::any::Any + Send + Sync),
701    principal_token: OpaquePrincipalToken,
702    can_create_mobs: bool,
703    can_mutate_profiles: bool,
704    can_run_adaptive_packs: bool,
705    managed_mob_scope: BTreeSet<String>,
706    spawn_profile_scope: BTreeMap<String, BTreeSet<String>>,
707    caller_provenance: Option<MobToolCallerProvenance>,
708    audit_invocation_id: Option<String>,
709) -> Result<MobToolAuthorityContext, String> {
710    #[allow(unsafe_code)]
711    let valid =
712        unsafe { runtime_mob_operator_authority_generated_authority_bridge_token_is_valid(token) };
713    if !valid {
714        return Err(
715            "mob tool authority context requires the generated runtime bridge token".into(),
716        );
717    }
718    MobToolAuthorityContext::from_generated_parts(
719        principal_token,
720        MobToolAuthorityCapabilities {
721            can_create_mobs,
722            can_mutate_profiles,
723            can_run_adaptive_packs,
724        },
725        managed_mob_scope,
726        spawn_profile_scope,
727        caller_provenance,
728        audit_invocation_id,
729    )
730}
731
732/// Opaque parent/composition authority for snapshots of currently visible tools.
733///
734/// This type is intentionally constructed only by the AgentFactory bridge. Mob
735/// tools may read the parent snapshot and request inherited visibility handoffs
736/// from this object, but downstream crates cannot implement a fake provider to
737/// mint `InheritedToolVisibilityAuthority`.
738#[derive(Clone)]
739pub struct ParentToolCompositionAuthority {
740    inner: Arc<ParentToolCompositionAuthorityInner>,
741}
742
743struct ParentToolCompositionAuthorityInner {
744    tool_scope: RwLock<Option<crate::ToolScope>>,
745}
746
747impl ParentToolCompositionAuthority {
748    #[cfg_attr(not(meerkat_internal_agent_factory_build), allow(dead_code))]
749    fn new_from_agent_factory_composition() -> Self {
750        Self {
751            inner: Arc::new(ParentToolCompositionAuthorityInner {
752                tool_scope: RwLock::new(None),
753            }),
754        }
755    }
756
757    #[cfg_attr(not(meerkat_internal_agent_factory_build), allow(dead_code))]
758    fn set_tool_scope_from_agent_factory_composition(&self, tool_scope: &crate::ToolScope) {
759        let mut guard = self
760            .inner
761            .tool_scope
762            .write()
763            .unwrap_or_else(std::sync::PoisonError::into_inner);
764        *guard = Some(tool_scope.clone());
765    }
766
767    fn current_tool_scope(&self) -> Option<crate::ToolScope> {
768        self.inner
769            .tool_scope
770            .read()
771            .unwrap_or_else(std::sync::PoisonError::into_inner)
772            .clone()
773    }
774
775    fn map_visible_tools_error(
776        err: crate::tool_scope::ToolScopeApplyError,
777    ) -> crate::tool_scope::ToolScopeStageError {
778        match err {
779            crate::tool_scope::ToolScopeApplyError::LockPoisoned => {
780                crate::tool_scope::ToolScopeStageError::LockPoisoned
781            }
782            crate::tool_scope::ToolScopeApplyError::Owner { message } => {
783                crate::tool_scope::ToolScopeStageError::Owner { message }
784            }
785            crate::tool_scope::ToolScopeApplyError::InjectedFailure => {
786                crate::tool_scope::ToolScopeStageError::Owner {
787                    message: err.to_string(),
788                }
789            }
790        }
791    }
792
793    fn visible_tool_snapshot_result(
794        &self,
795    ) -> Result<Vec<Arc<ToolDef>>, crate::tool_scope::ToolScopeStageError> {
796        let tool_scope = self.current_tool_scope().ok_or_else(|| {
797            crate::tool_scope::ToolScopeStageError::Owner {
798                message: "parent tool scope is unavailable".to_string(),
799            }
800        })?;
801        tool_scope
802            .visible_tools_result()
803            .map(|tools| tools.iter().cloned().collect())
804            .map_err(Self::map_visible_tools_error)
805    }
806
807    /// Returns the tool definitions currently visible to the parent agent.
808    pub fn snapshot_visible_tools(&self) -> Vec<Arc<ToolDef>> {
809        self.visible_tool_snapshot_result().unwrap_or_default()
810    }
811
812    /// Authorize an inherited child visibility filter from this parent-owned
813    /// composition snapshot.
814    pub fn authorize_inherited_tool_visibility(
815        &self,
816        filter: crate::tool_scope::ToolFilter,
817    ) -> Result<crate::InheritedToolVisibilityAuthority, crate::tool_scope::ToolScopeStageError>
818    {
819        let tools = self.visible_tool_snapshot_result()?;
820        let tool_defs = tools
821            .iter()
822            .map(|tool| tool.as_ref().clone())
823            .collect::<Vec<_>>();
824        let witnesses = crate::tool_scope::filter_witnesses_for_tool_defs(&tool_defs, &filter);
825        crate::tool_scope::validate_witnessed_filter_authority(&filter, &witnesses)?;
826        Ok(
827            crate::InheritedToolVisibilityAuthority::from_generated_composition_authority(
828                filter, witnesses,
829            ),
830        )
831    }
832
833    /// Authorize a child allow-filter derived from the current visible parent
834    /// tool set plus optional allow/deny overlays.
835    pub fn authorize_inherited_tool_visibility_with_overlays(
836        &self,
837        allow_overlay: Option<&std::collections::HashSet<String>>,
838        deny_overlay: Option<&std::collections::HashSet<String>>,
839    ) -> Result<crate::InheritedToolVisibilityAuthority, crate::tool_scope::ToolScopeStageError>
840    {
841        let tools = self.visible_tool_snapshot_result()?;
842        let mut names = tools
843            .iter()
844            .map(|tool| tool.name.as_str().to_string())
845            .collect::<std::collections::HashSet<_>>();
846        if let Some(allow) = allow_overlay {
847            names = names.intersection(allow).cloned().collect();
848        }
849        if let Some(deny) = deny_overlay {
850            for name in deny {
851                names.remove(name);
852            }
853        }
854        let filter = crate::tool_scope::ToolFilter::Allow(names.into_iter().collect());
855        let tool_defs = tools
856            .iter()
857            .map(|tool| tool.as_ref().clone())
858            .collect::<Vec<_>>();
859        let witnesses = crate::tool_scope::filter_witnesses_for_tool_defs(&tool_defs, &filter);
860        crate::tool_scope::validate_witnessed_filter_authority(&filter, &witnesses)?;
861        Ok(
862            crate::InheritedToolVisibilityAuthority::from_generated_composition_authority(
863                filter, witnesses,
864            ),
865        )
866    }
867}
868
869#[cfg(all(meerkat_internal_agent_factory_build, not(test)))]
870#[allow(improper_ctypes_definitions, unsafe_code)]
871unsafe extern "Rust" {
872    #[link_name = concat!(
873        "__meerkat_agent_factory_policy_bridge_token_is_valid_v1_",
874        env!("MEERKAT_AGENT_FACTORY_POLICY_BRIDGE_SYMBOL_SUFFIX")
875    )]
876    fn agent_factory_parent_tool_composition_bridge_token_is_valid(
877        factory_bridge_token: &(dyn std::any::Any + Send + Sync),
878    ) -> bool;
879}
880
881#[cfg(all(meerkat_internal_agent_factory_build, not(test)))]
882fn validate_agent_factory_parent_tool_composition_bridge_token(
883    token: &(dyn std::any::Any + Send + Sync),
884) -> Result<(), String> {
885    #[allow(unsafe_code)]
886    let is_valid = unsafe { agent_factory_parent_tool_composition_bridge_token_is_valid(token) };
887    if is_valid {
888        Ok(())
889    } else {
890        Err("parent tool composition authority requires the AgentFactory bridge token".into())
891    }
892}
893
894#[cfg(all(meerkat_internal_agent_factory_build, not(test)))]
895#[doc(hidden)]
896#[allow(improper_ctypes_definitions, unsafe_code)]
897#[unsafe(export_name = concat!(
898    "__meerkat_agent_factory_parent_tool_composition_authority_new_v1_",
899    env!("MEERKAT_AGENT_FACTORY_POLICY_BRIDGE_SYMBOL_SUFFIX")
900))]
901pub(crate) extern "Rust" fn agent_factory_parent_tool_composition_authority_new(
902    token: &'static (dyn std::any::Any + Send + Sync),
903) -> Result<ParentToolCompositionAuthority, String> {
904    validate_agent_factory_parent_tool_composition_bridge_token(token)?;
905    Ok(ParentToolCompositionAuthority::new_from_agent_factory_composition())
906}
907
908#[cfg(all(meerkat_internal_agent_factory_build, not(test)))]
909#[doc(hidden)]
910#[allow(improper_ctypes_definitions, unsafe_code)]
911#[unsafe(export_name = concat!(
912    "__meerkat_agent_factory_parent_tool_composition_authority_set_tool_scope_v1_",
913    env!("MEERKAT_AGENT_FACTORY_POLICY_BRIDGE_SYMBOL_SUFFIX")
914))]
915pub(crate) extern "Rust" fn agent_factory_parent_tool_composition_authority_set_tool_scope(
916    token: &'static (dyn std::any::Any + Send + Sync),
917    authority: &ParentToolCompositionAuthority,
918    tool_scope: &crate::ToolScope,
919) -> Result<(), String> {
920    validate_agent_factory_parent_tool_composition_bridge_token(token)?;
921    authority.set_tool_scope_from_agent_factory_composition(tool_scope);
922    Ok(())
923}
924
925/// Context for capturing a parent agent's tool scope snapshot.
926///
927/// `ParentOwned` carries an authority that can snapshot the parent's visible
928/// tools at child spawn time. `Standalone` means no parent scope is available
929/// (e.g. top-level agents, tests).
930#[derive(Clone)]
931pub enum MobToolSnapshotContext {
932    /// Parent agent owns a tool scope; snapshot available on demand.
933    ParentOwned(ParentToolCompositionAuthority),
934    /// No parent scope available.
935    Standalone,
936}
937
938/// Session-scoped arguments passed to [`MobToolsFactory::build_mob_tools`].
939pub struct MobToolsBuildArgs {
940    /// Session ID of the agent being built.
941    pub session_id: crate::SessionId,
942    /// Model name of the owning agent — inherited by implicit mob helpers.
943    pub model: String,
944    /// Runtime-injected mob operator authority context.
945    ///
946    /// The session factory only calls [`MobToolsFactory::build_mob_tools`]
947    /// after generated authority has supplied this context. Operator dispatch
948    /// must still re-check the typed create/scope fields on every call.
949    pub authority_context: Option<MobToolAuthorityContext>,
950    /// Shared effective mob authority handle owned by the agent.
951    ///
952    /// Mob tools read from this handle for authorization checks. The agent
953    /// (turn owner) is the sole writer — it updates this handle via
954    /// `apply_session_effects` after merging tool-produced `SessionEffect`s.
955    /// Mob tools fall back to `authority_context` as a static snapshot if this
956    /// shared read handle is absent.
957    pub effective_authority: Option<Arc<std::sync::RwLock<MobToolAuthorityContext>>>,
958    /// Comms name of the owning agent (for building `TrustedPeerDescriptor`).
959    pub comms_name: Option<String>,
960    /// Optional comms runtime for auto-wiring spawned members.
961    pub comms_runtime: Option<Arc<dyn crate::agent::CommsRuntime>>,
962    /// Context for capturing a snapshot of the parent agent's visible tools.
963    pub snapshot_context: MobToolSnapshotContext,
964}
965
966/// Factory trait for late-binding mob tool construction.
967///
968/// Implementations capture surface-specific state (e.g. `MobMcpState`) and
969/// receive session-scoped arguments from `build_agent()` at construction time.
970/// This avoids a cyclic dependency between the facade crate and `meerkat-mob-mcp`.
971#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
972#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
973pub trait MobToolsFactory: Send + Sync {
974    /// Build a mob tool dispatcher for the given session.
975    async fn build_mob_tools(
976        &self,
977        args: MobToolsBuildArgs,
978    ) -> Result<Arc<dyn AgentToolDispatcher>, Box<dyn std::error::Error + Send + Sync>>;
979}
980
981/// Typed explicit-override intent for resumed-session metadata merges.
982///
983/// This avoids trying to recover caller intent from flattened build config.
984#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
985pub struct ResumeOverrideMask {
986    pub model: bool,
987    pub provider: bool,
988    pub max_tokens: bool,
989    pub structured_output_retries: bool,
990    pub provider_params: bool,
991    pub auth_binding: bool,
992    pub override_builtins: bool,
993    pub override_shell: bool,
994    pub override_comms: bool,
995    pub override_memory: bool,
996    pub override_schedule: bool,
997    pub override_workgraph: bool,
998    pub override_mob: bool,
999    pub override_image_generation: bool,
1000    pub override_web_search: bool,
1001    pub preload_skills: bool,
1002    pub keep_alive: bool,
1003    pub comms_name: bool,
1004    pub peer_meta: bool,
1005    /// Explicit per-launch tool access policy supplied by the caller.
1006    pub tool_access_policy: bool,
1007}
1008
1009impl SessionBuildOptions {
1010    /// Apply the shared rehydration rule for mob operator access.
1011    ///
1012    /// Serialized authority contexts are compatibility projections. They do
1013    /// not carry behavior authority and are dropped at build seams unless a
1014    /// generated authority seal is still present in memory. Recovered
1015    /// metadata-only `Enable` is display intent, not behavior authority; it
1016    /// must not force the runtime seam to mint fresh operator access.
1017    pub fn apply_persisted_mob_operator_access(
1018        &mut self,
1019        enable_mob: ToolCategoryOverride,
1020        persisted_authority_context: Option<MobToolAuthorityContext>,
1021    ) {
1022        if matches!(enable_mob, ToolCategoryOverride::Disable) {
1023            self.override_mob = ToolCategoryOverride::Disable;
1024            self.mob_tool_authority_context = None;
1025            return;
1026        }
1027
1028        let generated_authority_context = persisted_authority_context
1029            .filter(MobToolAuthorityContext::is_generated_authority_context);
1030        let has_generated_authority = generated_authority_context.is_some();
1031        self.override_mob = if has_generated_authority {
1032            ToolCategoryOverride::Enable
1033        } else {
1034            ToolCategoryOverride::Inherit
1035        };
1036        self.mob_tool_authority_context = generated_authority_context;
1037    }
1038
1039    /// Apply the compatibility mirror for explicit mob operator enablement.
1040    ///
1041    /// Core does not synthesize behavior authority. Runtime/facade build seams
1042    /// must rehydrate or mint `MobToolAuthorityContext` through generated
1043    /// MeerkatMachine authority before mob operator tools can mount.
1044    pub fn apply_generated_create_only_mob_operator_access(
1045        &mut self,
1046        enable_mob: ToolCategoryOverride,
1047    ) {
1048        self.override_mob = enable_mob;
1049        self.mob_tool_authority_context = None;
1050    }
1051}
1052
1053impl Default for SessionBuildOptions {
1054    fn default() -> Self {
1055        Self {
1056            provider: None,
1057            self_hosted_server_id: None,
1058            custom_models: BTreeMap::new(),
1059            image_generation_provider: None,
1060            auto_compact_threshold_override: None,
1061            output_schema: None,
1062            structured_output_retries: None,
1063            hooks_override: HookRunOverrides::default(),
1064            comms_name: None,
1065            peer_meta: None,
1066            resume_session: None,
1067            // Phase 3 field — default None keeps the legacy flat path.
1068            // Populated by surfaces that accept realm/binding inputs.
1069            budget_limits: None,
1070            provider_params: None,
1071            external_tools: None,
1072            mcp_servers: Vec::new(),
1073            recoverable_tool_defs: None,
1074            blob_store_override: None,
1075            llm_client_override: None,
1076            agent_llm_client_decorator: None,
1077            override_builtins: ToolCategoryOverride::Inherit,
1078            override_shell: ToolCategoryOverride::Inherit,
1079            override_comms: ToolCategoryOverride::Inherit,
1080            override_memory: ToolCategoryOverride::Inherit,
1081            override_schedule: ToolCategoryOverride::Inherit,
1082            override_workgraph: ToolCategoryOverride::Inherit,
1083            override_mob: ToolCategoryOverride::Inherit,
1084            override_image_generation: ToolCategoryOverride::Inherit,
1085            override_web_search: ToolCategoryOverride::Inherit,
1086            schedule_tools: None,
1087            workgraph_tools: None,
1088            preload_skills: None,
1089            realm_id: None,
1090            instance_id: None,
1091            backend: None,
1092            config_generation: None,
1093            auth_binding: None,
1094            mob_member_binding: None,
1095            keep_alive: false,
1096            checkpointer: None,
1097            silent_comms_intents: Vec::new(),
1098            max_inline_peer_notifications: None,
1099            app_context: None,
1100            additional_instructions: None,
1101            initial_metadata_entries: BTreeMap::new(),
1102            initial_tool_filter: None,
1103            tool_access_policy: None,
1104            shell_env: None,
1105            call_timeout_override: crate::CallTimeoutOverride::Inherit,
1106            resume_override_mask: ResumeOverrideMask::default(),
1107            mob_tools: None,
1108            runtime_build_mode: crate::runtime_epoch::RuntimeBuildMode::StandaloneEphemeral,
1109            initial_turn_metadata: None,
1110            mob_tool_authority_context: None,
1111        }
1112    }
1113}
1114
1115impl std::fmt::Debug for SessionBuildOptions {
1116    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1117        f.debug_struct("SessionBuildOptions")
1118            .field("provider", &self.provider)
1119            .field("custom_models", &self.custom_models.keys())
1120            .field("image_generation_provider", &self.image_generation_provider)
1121            .field(
1122                "auto_compact_threshold_override",
1123                &self.auto_compact_threshold_override,
1124            )
1125            .field("output_schema", &self.output_schema.is_some())
1126            .field("structured_output_retries", &self.structured_output_retries)
1127            .field("hooks_override", &self.hooks_override)
1128            .field("comms_name", &self.comms_name)
1129            .field("peer_meta", &self.peer_meta)
1130            .field("resume_session", &self.resume_session.is_some())
1131            .field("budget_limits", &self.budget_limits)
1132            .field("provider_params", &self.provider_params.is_some())
1133            .field("external_tools", &self.external_tools.is_some())
1134            .field("recoverable_tool_defs", &self.recoverable_tool_defs)
1135            .field("blob_store_override", &self.blob_store_override.is_some())
1136            .field("llm_client_override", &self.llm_client_override.is_some())
1137            .field(
1138                "agent_llm_client_decorator",
1139                &self.agent_llm_client_decorator.is_some(),
1140            )
1141            .field("override_builtins", &self.override_builtins)
1142            .field("override_shell", &self.override_shell)
1143            .field("override_comms", &self.override_comms)
1144            .field("override_memory", &self.override_memory)
1145            .field("override_schedule", &self.override_schedule)
1146            .field("override_workgraph", &self.override_workgraph)
1147            .field("override_mob", &self.override_mob)
1148            .field("schedule_tools", &self.schedule_tools.is_some())
1149            .field("workgraph_tools", &self.workgraph_tools.is_some())
1150            .field("preload_skills", &self.preload_skills)
1151            .field("realm_id", &self.realm_id)
1152            .field("instance_id", &self.instance_id)
1153            .field("backend", &self.backend)
1154            .field("config_generation", &self.config_generation)
1155            .field("keep_alive", &self.keep_alive)
1156            .field("checkpointer", &self.checkpointer.is_some())
1157            .field("silent_comms_intents", &self.silent_comms_intents)
1158            .field(
1159                "max_inline_peer_notifications",
1160                &self.max_inline_peer_notifications,
1161            )
1162            .field("app_context", &self.app_context.is_some())
1163            .field("additional_instructions", &self.additional_instructions)
1164            .field("initial_metadata_entries", &self.initial_metadata_entries)
1165            .field("initial_tool_filter", &self.initial_tool_filter.is_some())
1166            .field("tool_access_policy", &self.tool_access_policy)
1167            .field("call_timeout_override", &self.call_timeout_override)
1168            .field("resume_override_mask", &self.resume_override_mask)
1169            .field("mob_tools", &self.mob_tools.is_some())
1170            .field("runtime_build_mode", &self.runtime_build_mode)
1171            .field(
1172                "initial_turn_metadata",
1173                &self.initial_turn_metadata.is_some(),
1174            )
1175            .field(
1176                "mob_tool_authority_context",
1177                &self.mob_tool_authority_context.is_some(),
1178            )
1179            .field("runtime_build_mode", &self.runtime_build_mode)
1180            .finish()
1181    }
1182}
1183
1184/// Runtime/session semantic carrier for starting a turn.
1185///
1186/// The session service forwards this as one machine/composition-owned bundle.
1187/// It must not split handling mode, tool overlays, context appends, or runtime
1188/// metadata back into service-level request fields.
1189#[derive(Debug)]
1190pub struct StartTurnRuntimeSemantics {
1191    /// Handling mode for this turn's ordinary content-bearing work.
1192    ///
1193    /// This is a **runtime-owned semantic**: the runtime routes Queue/Steer
1194    /// before calling the executor. The session service passes this through
1195    /// to the `SessionAgent` but does not act on it. Non-Queue handling
1196    /// only works correctly on runtime-backed surfaces.
1197    pub handling_mode: HandlingMode,
1198    /// Optional per-turn tool overlay (ephemeral, non-persistent).
1199    pub turn_tool_overlay: Option<TurnToolOverlay>,
1200    /// Runtime-owned system-context appends that must be applied at this
1201    /// turn boundary before the model run starts.
1202    pub pre_turn_context_appends: Vec<PendingSystemContextAppend>,
1203    /// Canonical runtime-authored typed appends for this turn.
1204    ///
1205    /// Provider prompt text is an internal projection derived from these
1206    /// appends at the runtime/service boundary. These appends are the
1207    /// authorship source used for transcript persistence.
1208    pub typed_turn_appends: Vec<ConversationAppend>,
1209    /// Canonical runtime-authored metadata for this turn.
1210    ///
1211    /// Runtime-backed callers populate this once at the machine boundary and
1212    /// the session layer derives per-turn policy from this typed carrier
1213    /// instead of re-inferring or dropping fields. Render metadata and skill
1214    /// references for the turn live ONLY here — there are no flat duplicates
1215    /// on this request shape.
1216    pub turn_metadata: Option<RuntimeTurnMetadata>,
1217}
1218
1219impl Default for StartTurnRuntimeSemantics {
1220    fn default() -> Self {
1221        Self {
1222            handling_mode: HandlingMode::Queue,
1223            turn_tool_overlay: None,
1224            pre_turn_context_appends: Vec::new(),
1225            typed_turn_appends: Vec::new(),
1226            turn_metadata: None,
1227        }
1228    }
1229}
1230
1231impl StartTurnRuntimeSemantics {
1232    #[must_use]
1233    pub fn new(
1234        handling_mode: HandlingMode,
1235        turn_tool_overlay: Option<TurnToolOverlay>,
1236        pre_turn_context_appends: Vec<PendingSystemContextAppend>,
1237        turn_metadata: Option<RuntimeTurnMetadata>,
1238    ) -> Self {
1239        Self {
1240            handling_mode,
1241            turn_tool_overlay,
1242            pre_turn_context_appends,
1243            typed_turn_appends: Vec::new(),
1244            turn_metadata,
1245        }
1246    }
1247
1248    #[must_use]
1249    pub fn runtime_metadata(turn_metadata: RuntimeTurnMetadata) -> Self {
1250        Self {
1251            turn_metadata: Some(turn_metadata),
1252            ..Self::default()
1253        }
1254    }
1255
1256    #[must_use]
1257    pub fn with_typed_turn_appends(mut self, typed_turn_appends: Vec<ConversationAppend>) -> Self {
1258        self.typed_turn_appends = typed_turn_appends;
1259        self
1260    }
1261}
1262
1263/// Request to start a new turn on an existing session.
1264#[derive(Debug)]
1265pub struct StartTurnRequest {
1266    /// User prompt for this turn (text or multimodal).
1267    pub prompt: ContentInput,
1268    /// Host-attached injected context for this turn (default empty).
1269    ///
1270    /// Each entry materializes as a separate typed injected-context
1271    /// user-channel message ([`crate::types::TranscriptUserRole::InjectedContext`])
1272    /// immediately BEFORE the turn's user message, in order. When the runtime
1273    /// authors the turn (`runtime.typed_turn_appends` non-empty), injected
1274    /// context must arrive as `InjectedContext`-role appends instead — the
1275    /// agent run ingress fails closed if both carriers are populated.
1276    pub injected_context: Vec<ContentInput>,
1277    /// Optional system prompt override for a deferred session's first turn.
1278    ///
1279    /// This is only supported before the session has any conversation history.
1280    /// Materialized sessions with existing messages must reject it.
1281    pub system_prompt: Option<String>,
1282    /// Channel for streaming events during the turn.
1283    pub event_tx: Option<mpsc::Sender<EventEnvelope<AgentEvent>>>,
1284    /// Single runtime/session semantic carrier for this turn.
1285    pub runtime: StartTurnRuntimeSemantics,
1286}
1287
1288/// Request to append runtime system context to an existing session.
1289// Cannot derive `Eq`: the typed `peer_response_terminal` fact carries a
1290// `serde_json::Value` render payload, which is `PartialEq` but not `Eq`.
1291#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1292pub struct AppendSystemContextRequest {
1293    /// Typed renderable content to append.
1294    ///
1295    /// This is the single owner of the append body. Surfaces parse their
1296    /// inbound payload into a [`CoreRenderable`] at the ingress boundary; the
1297    /// stringly `text` field that previously flattened the body here is gone.
1298    /// Consumers that need the plain-text projection call
1299    /// [`CoreRenderable::render_text`].
1300    pub content: CoreRenderable,
1301    #[serde(default, skip_serializing_if = "Option::is_none")]
1302    pub source: Option<String>,
1303    #[serde(default, skip_serializing_if = "Option::is_none")]
1304    pub idempotency_key: Option<String>,
1305    /// Typed provenance: whether this append is a transient runtime steer.
1306    ///
1307    /// The producer of a runtime-steer append sets
1308    /// [`crate::session::SystemContextSource::RuntimeSteer`]; the default
1309    /// (`Normal`) covers every durable append. This typed marker is the
1310    /// canonical replacement for the retired `runtime:steer:` string prefix.
1311    #[serde(
1312        default,
1313        skip_serializing_if = "crate::session::SystemContextSource::is_normal"
1314    )]
1315    pub source_kind: crate::session::SystemContextSource,
1316    /// Typed terminal-peer-response fact this append carries, when the append
1317    /// projects a [`crate::handles::PeerResponseTerminalFact`]. The producer
1318    /// stamps the typed fact here; realtime/live consumers read it directly
1319    /// instead of re-parsing the flattened prompt `text`/`source` string.
1320    #[serde(default, skip_serializing_if = "Option::is_none")]
1321    pub peer_response_terminal: Option<crate::handles::PeerResponseTerminalFact>,
1322}
1323
1324impl AppendSystemContextRequest {
1325    /// Build a plain-text system-context append.
1326    ///
1327    /// Text-only convenience for the common surface case (CLI/REST/RPC inject
1328    /// a bare string). Richer producers construct the request directly with a
1329    /// multimodal / system-notice [`CoreRenderable`].
1330    #[must_use]
1331    pub fn from_text(text: impl Into<String>) -> Self {
1332        Self {
1333            content: CoreRenderable::text(text),
1334            source: None,
1335            idempotency_key: None,
1336            source_kind: crate::session::SystemContextSource::Normal,
1337            peer_response_terminal: None,
1338        }
1339    }
1340
1341    /// Plain-text projection of the typed append content.
1342    #[must_use]
1343    pub fn text(&self) -> String {
1344        self.content.render_text()
1345    }
1346}
1347
1348/// Result of appending runtime system context to a session.
1349#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1350pub struct AppendSystemContextResult {
1351    pub status: AppendSystemContextStatus,
1352}
1353
1354/// Request to stage callback tool results for the next turn.
1355#[derive(Debug, Clone, Serialize, Deserialize)]
1356pub struct StageToolResultsRequest {
1357    pub results: Vec<crate::ToolResult>,
1358}
1359
1360/// Result of staging callback tool results for the next turn.
1361#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1362pub struct StageToolResultsResult {
1363    pub accepted_result_count: usize,
1364}
1365
1366/// Outcome of an append-system-context request.
1367#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1368#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1369#[serde(rename_all = "snake_case")]
1370pub enum AppendSystemContextStatus {
1371    Applied,
1372    Staged,
1373    Duplicate,
1374}
1375
1376/// Ephemeral per-turn tool overlay for flow-dispatched turns.
1377#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1378#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1379pub struct TurnToolOverlay {
1380    /// Optional allow-list for this turn.
1381    #[serde(default)]
1382    pub allowed_tools: Option<Vec<crate::types::ToolName>>,
1383    /// Optional deny-list for this turn.
1384    #[serde(default)]
1385    pub blocked_tools: Option<Vec<crate::types::ToolName>>,
1386    /// Tool-dispatch metadata visible only to dispatchers for this turn.
1387    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
1388    #[cfg_attr(feature = "schema", schemars(skip))]
1389    pub dispatch_context: std::collections::BTreeMap<String, serde_json::Value>,
1390}
1391
1392impl TurnToolOverlay {
1393    /// Return a caller-safe overlay by dropping runtime-owned dispatch metadata.
1394    pub fn without_dispatch_context(mut self) -> Self {
1395        self.dispatch_context.clear();
1396        self
1397    }
1398}
1399
1400/// Public caller-safe per-turn tool overlay.
1401#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1402#[serde(deny_unknown_fields)]
1403#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1404pub struct PublicTurnToolOverlay {
1405    /// Optional allow-list for this turn.
1406    #[serde(default)]
1407    pub allowed_tools: Option<Vec<crate::types::ToolName>>,
1408    /// Optional deny-list for this turn.
1409    #[serde(default)]
1410    pub blocked_tools: Option<Vec<crate::types::ToolName>>,
1411}
1412
1413impl From<PublicTurnToolOverlay> for TurnToolOverlay {
1414    fn from(value: PublicTurnToolOverlay) -> Self {
1415        Self {
1416            allowed_tools: value.allowed_tools,
1417            blocked_tools: value.blocked_tools,
1418            dispatch_context: BTreeMap::new(),
1419        }
1420    }
1421}
1422
1423/// Query parameters for listing sessions.
1424#[derive(Debug, Default)]
1425pub struct SessionQuery {
1426    /// Maximum number of results.
1427    pub limit: Option<usize>,
1428    /// Offset for pagination.
1429    pub offset: Option<usize>,
1430    /// Filters sessions where all specified k/v pairs match.
1431    pub labels: Option<BTreeMap<String, String>>,
1432}
1433
1434/// Summary of a session (for list results).
1435///
1436/// Kept lightweight — no billing data. Use `read()` for full details.
1437#[derive(Debug, Clone, Serialize, Deserialize)]
1438pub struct SessionSummary {
1439    pub session_id: SessionId,
1440    pub created_at: SystemTime,
1441    pub updated_at: SystemTime,
1442    pub message_count: usize,
1443    pub total_tokens: u64,
1444    pub is_active: bool,
1445    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1446    pub labels: BTreeMap<String, String>,
1447}
1448
1449/// Detailed view of a session's state and history metadata.
1450#[derive(Debug, Clone, Serialize, Deserialize)]
1451pub struct SessionInfo {
1452    pub session_id: SessionId,
1453    pub created_at: SystemTime,
1454    pub updated_at: SystemTime,
1455    pub message_count: usize,
1456    pub is_active: bool,
1457    pub model: String,
1458    pub provider: Provider,
1459    pub last_assistant_text: Option<String>,
1460    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1461    pub labels: BTreeMap<String, String>,
1462}
1463
1464/// Billing/usage data for a session, returned separately from state.
1465#[derive(Debug, Clone, Serialize, Deserialize)]
1466pub struct SessionUsage {
1467    pub total_tokens: u64,
1468    pub usage: Usage,
1469}
1470
1471/// Combined session view (state + usage). Convenience wrapper used by
1472/// `SessionService::read()` to avoid requiring two calls.
1473#[derive(Debug, Clone, Serialize, Deserialize)]
1474pub struct SessionView {
1475    pub state: SessionInfo,
1476    pub billing: SessionUsage,
1477}
1478
1479impl SessionView {
1480    /// Convenience: session ID from the state.
1481    pub fn session_id(&self) -> &SessionId {
1482        &self.state.session_id
1483    }
1484}
1485
1486/// Query parameters for reading session history.
1487#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
1488pub struct SessionHistoryQuery {
1489    /// Number of messages to skip from the start of the transcript.
1490    pub offset: usize,
1491    /// Maximum number of messages to return.
1492    #[serde(default, skip_serializing_if = "Option::is_none")]
1493    pub limit: Option<usize>,
1494}
1495
1496/// Paginated transcript page for a session.
1497#[derive(Debug, Clone, Serialize, Deserialize)]
1498pub struct SessionHistoryPage {
1499    pub session_id: SessionId,
1500    pub message_count: usize,
1501    pub offset: usize,
1502    #[serde(default, skip_serializing_if = "Option::is_none")]
1503    pub limit: Option<usize>,
1504    pub has_more: bool,
1505    pub messages: Vec<Message>,
1506}
1507
1508impl SessionHistoryPage {
1509    /// Build a transcript page from the full ordered message list.
1510    pub fn from_messages(
1511        session_id: SessionId,
1512        messages: &[Message],
1513        query: SessionHistoryQuery,
1514    ) -> Self {
1515        let message_count = messages.len();
1516        let start = query.offset.min(message_count);
1517        let end = match query.limit {
1518            Some(limit) => start.saturating_add(limit).min(message_count),
1519            None => message_count,
1520        };
1521        Self {
1522            session_id,
1523            message_count,
1524            offset: start,
1525            limit: query.limit,
1526            has_more: end < message_count,
1527            messages: messages[start..end].to_vec(),
1528        }
1529    }
1530}
1531
1532/// Query parameters for reading a retained transcript revision body.
1533#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1534pub struct SessionTranscriptRevisionQuery {
1535    pub revision: String,
1536    /// Number of messages to skip from the start of the revision transcript.
1537    pub offset: usize,
1538    /// Maximum number of messages to return.
1539    #[serde(default, skip_serializing_if = "Option::is_none")]
1540    pub limit: Option<usize>,
1541}
1542
1543/// Paginated transcript page for a retained immutable revision.
1544#[derive(Debug, Clone, Serialize, Deserialize)]
1545pub struct SessionTranscriptRevisionPage {
1546    pub session_id: SessionId,
1547    pub revision: String,
1548    pub head_revision: String,
1549    pub message_count: usize,
1550    pub offset: usize,
1551    #[serde(default, skip_serializing_if = "Option::is_none")]
1552    pub limit: Option<usize>,
1553    pub has_more: bool,
1554    pub messages: Vec<Message>,
1555}
1556
1557impl SessionTranscriptRevisionPage {
1558    /// Build a transcript page from a retained revision body.
1559    pub fn from_messages(
1560        session_id: SessionId,
1561        revision: String,
1562        head_revision: String,
1563        messages: &[Message],
1564        offset: usize,
1565        limit: Option<usize>,
1566    ) -> Self {
1567        let message_count = messages.len();
1568        let start = offset.min(message_count);
1569        let end = match limit {
1570            Some(limit) => start.saturating_add(limit).min(message_count),
1571            None => message_count,
1572        };
1573        Self {
1574            session_id,
1575            revision,
1576            head_revision,
1577            message_count,
1578            offset: start,
1579            limit,
1580            has_more: end < message_count,
1581            messages: messages[start..end].to_vec(),
1582        }
1583    }
1584}
1585
1586/// Query parameters for listing retained transcript revision commits.
1587#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
1588pub struct SessionTranscriptRevisionListQuery {
1589    /// Maximum number of commit entries to return.
1590    #[serde(default, skip_serializing_if = "Option::is_none")]
1591    pub limit: Option<usize>,
1592    /// Number of commit entries to skip from the start of the commit log.
1593    #[serde(default, skip_serializing_if = "Option::is_none")]
1594    pub offset: Option<usize>,
1595}
1596
1597/// One transcript rewrite commit projected for revision-list reads.
1598///
1599/// Mirrors the typed [`TranscriptRewriteCommit`] owner: `reason` is the
1600/// [`Display`](std::fmt::Display) projection of the typed
1601/// [`TranscriptRewriteReason`], never a separately-owned string.
1602#[derive(Debug, Clone, Serialize, Deserialize)]
1603pub struct SessionTranscriptRevisionListEntry {
1604    /// Revision the commit produced.
1605    pub revision: String,
1606    /// Revision the commit was applied against.
1607    pub parent_revision: String,
1608    /// Actor recorded on the commit, when supplied.
1609    #[serde(default, skip_serializing_if = "Option::is_none")]
1610    pub actor: Option<String>,
1611    /// Display projection of the typed rewrite reason.
1612    pub reason: String,
1613    /// Commit timestamp.
1614    pub committed_at: SystemTime,
1615}
1616
1617/// Ordered (oldest-first) transcript revision commit list for a session.
1618#[derive(Debug, Clone, Serialize, Deserialize)]
1619pub struct SessionTranscriptRevisionList {
1620    /// Revision commits in append order, sliced per the list query.
1621    pub entries: Vec<SessionTranscriptRevisionListEntry>,
1622    /// Current transcript head revision.
1623    pub head_revision: String,
1624}
1625
1626/// Explicit behavior for transcript fork/edit requests when the source
1627/// session has active work.
1628#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1629#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1630#[serde(rename_all = "snake_case")]
1631pub enum TranscriptEditRunningBehavior {
1632    /// Reject the request while the source session is active.
1633    #[default]
1634    Reject,
1635}
1636
1637/// Request to fork a session at a transcript message index.
1638#[derive(Debug, Clone, Serialize, Deserialize)]
1639#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1640pub struct SessionForkAtRequest {
1641    pub message_index: usize,
1642    #[serde(default)]
1643    pub running_behavior: TranscriptEditRunningBehavior,
1644}
1645
1646/// Request to fork a session and apply a typed transcript replacement.
1647#[derive(Debug, Clone, Serialize, Deserialize)]
1648#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1649pub struct SessionForkReplaceRequest {
1650    pub message_index: usize,
1651    #[cfg_attr(feature = "schema", schemars(with = "serde_json::Value"))]
1652    pub replacement: TranscriptReplacement,
1653    #[serde(default)]
1654    pub running_behavior: TranscriptEditRunningBehavior,
1655}
1656
1657/// Result of creating an edited transcript branch.
1658#[derive(Debug, Clone, Serialize, Deserialize)]
1659#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1660pub struct SessionForkResult {
1661    #[cfg_attr(feature = "schema", schemars(with = "String"))]
1662    pub source_session_id: SessionId,
1663    #[cfg_attr(feature = "schema", schemars(with = "String"))]
1664    pub session_id: SessionId,
1665    pub message_count: usize,
1666    #[serde(default, skip_serializing_if = "Option::is_none")]
1667    pub session_ref: Option<String>,
1668}
1669
1670/// Request to rewrite the current transcript head without changing SessionId.
1671#[derive(Debug, Clone, Serialize, Deserialize)]
1672#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1673pub struct SessionTranscriptRewriteRequest {
1674    pub selection: TranscriptRewriteSelection,
1675    #[cfg_attr(feature = "schema", schemars(with = "Vec<serde_json::Value>"))]
1676    pub replacement: Vec<Message>,
1677    pub reason: TranscriptRewriteReason,
1678    #[serde(default, skip_serializing_if = "Option::is_none")]
1679    pub actor: Option<String>,
1680    #[serde(default, skip_serializing_if = "Option::is_none")]
1681    pub expected_parent_revision: Option<String>,
1682    #[serde(default)]
1683    pub running_behavior: TranscriptEditRunningBehavior,
1684}
1685
1686/// Request to restore the current transcript head to a retained revision body.
1687#[derive(Debug, Clone, Serialize, Deserialize)]
1688#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1689pub struct SessionTranscriptRestoreRevisionRequest {
1690    pub revision: String,
1691    pub reason: TranscriptRewriteReason,
1692    #[serde(default, skip_serializing_if = "Option::is_none")]
1693    pub actor: Option<String>,
1694    #[serde(default, skip_serializing_if = "Option::is_none")]
1695    pub expected_parent_revision: Option<String>,
1696    #[serde(default)]
1697    pub running_behavior: TranscriptEditRunningBehavior,
1698}
1699
1700/// Result of committing a same-session transcript rewrite.
1701#[derive(Debug, Clone, Serialize, Deserialize)]
1702#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1703pub struct SessionTranscriptRewriteResult {
1704    #[cfg_attr(feature = "schema", schemars(with = "String"))]
1705    pub session_id: SessionId,
1706    pub parent_revision: String,
1707    pub revision: String,
1708    pub message_count: usize,
1709    pub commit: TranscriptRewriteCommit,
1710}
1711
1712impl TranscriptEditError {
1713    /// Convert a typed edit validation failure into a surface-friendly session
1714    /// error without widening the `SessionService` error enum.
1715    pub fn into_session_error(self) -> SessionError {
1716        SessionError::Agent(crate::error::AgentError::ConfigError(self.to_string()))
1717    }
1718}
1719
1720/// Canonical session lifecycle abstraction.
1721///
1722/// All surfaces delegate to this trait. Implementations control persistence,
1723/// compaction, and event logging behavior.
1724#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
1725#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
1726pub trait SessionService: Send + Sync {
1727    /// Create a new session and run the first turn.
1728    async fn create_session(&self, req: CreateSessionRequest) -> Result<RunResult, SessionError>;
1729
1730    /// Start a new turn on an existing session.
1731    async fn start_turn(
1732        &self,
1733        id: &SessionId,
1734        req: StartTurnRequest,
1735    ) -> Result<RunResult, SessionError>;
1736
1737    /// Cancel an in-flight turn.
1738    ///
1739    /// Returns `NotRunning` if no turn is active.
1740    async fn interrupt(&self, id: &SessionId) -> Result<(), SessionError>;
1741
1742    /// Cancel an in-flight turn once it reaches the next boundary.
1743    ///
1744    /// Returns `NotRunning` if no turn is active. Unsupported by default.
1745    async fn cancel_after_boundary(&self, _id: &SessionId) -> Result<(), SessionError> {
1746        Err(SessionError::Unsupported(
1747            "cancel_after_boundary".to_string(),
1748        ))
1749    }
1750
1751    /// Replace the LLM client on a live session.
1752    ///
1753    /// Enables mid-session model/provider hot-swap without rebuilding the
1754    /// agent. The new client takes effect on the next turn. Returns
1755    /// `Unsupported` by default; session services that support live agents
1756    /// override this.
1757    async fn set_session_client(
1758        &self,
1759        _id: &SessionId,
1760        _client: std::sync::Arc<dyn crate::AgentLlmClient>,
1761    ) -> Result<(), SessionError> {
1762        Err(SessionError::Unsupported("set_session_client".to_string()))
1763    }
1764
1765    /// Atomically replace the live session client and the session's durable
1766    /// LLM identity.
1767    ///
1768    /// This is the canonical seam for materialized-session hot-swap semantics.
1769    /// Implementations should apply both updates together so future turns and
1770    /// resume/recovery see the same model/provider/provider_params identity.
1771    async fn hot_swap_session_llm_identity(
1772        &self,
1773        _id: &SessionId,
1774        _client: std::sync::Arc<dyn crate::AgentLlmClient>,
1775        _identity: SessionLlmIdentity,
1776        _request_policy: crate::SessionLlmRequestPolicy,
1777    ) -> Result<(), SessionError> {
1778        Err(SessionError::Unsupported(
1779            "hot_swap_session_llm_identity".to_string(),
1780        ))
1781    }
1782
1783    /// Replace the canonical tool visibility state carried by the live session.
1784    ///
1785    /// This seam is live-only and must not perform its own durable write. The
1786    /// caller owns any surrounding transactional persistence and rollback.
1787    async fn set_session_tool_visibility_state(
1788        &self,
1789        _id: &SessionId,
1790        _state: Option<crate::SessionToolVisibilityState>,
1791    ) -> Result<(), SessionError> {
1792        Err(SessionError::Unsupported(
1793            "set_session_tool_visibility_state".to_string(),
1794        ))
1795    }
1796
1797    /// Update the session's canonical mob operator authority context.
1798    ///
1799    /// This is the only supported seam for widening or narrowing exact mob
1800    /// management scope after session creation so recovery and live runtime
1801    /// state stay aligned.
1802    async fn update_session_mob_authority_context(
1803        &self,
1804        _id: &SessionId,
1805        _authority_context: Option<MobToolAuthorityContext>,
1806    ) -> Result<(), SessionError> {
1807        Err(SessionError::Unsupported(
1808            "update_session_mob_authority_context".to_string(),
1809        ))
1810    }
1811
1812    /// Whether a live in-memory session bridge currently exists for `id`.
1813    ///
1814    /// This is intentionally distinct from `list()` / `SessionSummary`:
1815    /// persisted-only summaries must not count as live, and idle live sessions
1816    /// must still count as live even when no turn is running.
1817    async fn has_live_session(&self, _id: &SessionId) -> Result<bool, SessionError> {
1818        Err(SessionError::Unsupported("has_live_session".to_string()))
1819    }
1820
1821    /// Stage an external tool visibility filter on a live session.
1822    ///
1823    /// Used to dynamically hide/show tools (e.g., `view_image`) after a
1824    /// model hot-swap changes capability support. Returns `Unsupported`
1825    /// by default.
1826    async fn set_session_tool_filter(
1827        &self,
1828        _id: &SessionId,
1829        _filter: crate::ToolFilter,
1830    ) -> Result<(), SessionError> {
1831        Err(SessionError::Unsupported(
1832            "set_session_tool_filter".to_string(),
1833        ))
1834    }
1835
1836    /// Read the current state of a session.
1837    async fn read(&self, id: &SessionId) -> Result<SessionView, SessionError>;
1838
1839    /// List sessions matching the query.
1840    async fn list(&self, query: SessionQuery) -> Result<Vec<SessionSummary>, SessionError>;
1841
1842    /// Archive (remove) a session.
1843    async fn archive(&self, id: &SessionId) -> Result<(), SessionError>;
1844
1845    /// Subscribe to session-wide events regardless of triggering interaction.
1846    ///
1847    /// Services that do not support this capability return `StreamError::NotFound`.
1848    async fn subscribe_session_events(&self, id: &SessionId) -> Result<EventStream, StreamError> {
1849        Err(StreamError::NotFound(format!("session {id}")))
1850    }
1851
1852    /// Record a typed live-adapter terminal error against a session.
1853    ///
1854    /// A live (realtime) channel can terminalize for reasons that are not
1855    /// themselves Meerkat run outcomes (connection lost, provider error, auth
1856    /// failure, local config rejection). The live projection surface must route
1857    /// the typed [`LiveAdapterErrorCode`] cause here instead of laundering it
1858    /// into a warning log and an `Ok(())` — the fault is then observable on the
1859    /// session's owned event stream rather than only in tracing.
1860    ///
1861    /// Returns `Unsupported` by default; runtime-backed services that own a
1862    /// live session event stream override this. Non-runtime impls inherit the
1863    /// graceful default.
1864    async fn record_live_terminal_error(
1865        &self,
1866        _id: &SessionId,
1867        _cause: crate::live_adapter::LiveAdapterErrorCode,
1868    ) -> Result<(), SessionError> {
1869        Err(SessionError::Unsupported(
1870            "record_live_terminal_error".to_string(),
1871        ))
1872    }
1873
1874    /// Record a live transport output-audio delivery degradation against a
1875    /// session.
1876    ///
1877    /// K16: when a live transport drops queued output-audio packets (e.g.
1878    /// WebRTC RTP pacing-queue backpressure), the dropped-delivery fact must
1879    /// be observable on the session's owned event stream — not a
1880    /// transport-local counter with no live reader. `dropped` carries the
1881    /// cumulative drop count for the channel.
1882    ///
1883    /// Returns `Unsupported` by default; runtime-backed services that own a
1884    /// live session event stream override this.
1885    async fn record_live_output_audio_degraded(
1886        &self,
1887        _id: &SessionId,
1888        _dropped: u64,
1889    ) -> Result<(), SessionError> {
1890        Err(SessionError::Unsupported(
1891            "record_live_output_audio_degraded".to_string(),
1892        ))
1893    }
1894}
1895
1896/// Optional comms/control-plane extension for `SessionService`.
1897///
1898/// Base lifecycle operations stay on `SessionService`; advanced surfaces
1899/// (RPC/REST/mob orchestration) can use this trait when they need direct
1900/// access to comms runtime and injector handles.
1901#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
1902#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
1903pub trait SessionServiceCommsExt: SessionService {
1904    /// Get the comms runtime for a session, if available.
1905    async fn comms_runtime(
1906        &self,
1907        _session_id: &SessionId,
1908    ) -> Option<Arc<dyn crate::agent::CommsRuntime>> {
1909        None
1910    }
1911
1912    /// Get the event injector for a session, if available.
1913    async fn event_injector(
1914        &self,
1915        session_id: &SessionId,
1916    ) -> Option<Arc<dyn crate::EventInjector>> {
1917        self.comms_runtime(session_id)
1918            .await
1919            .and_then(|runtime| runtime.event_injector())
1920    }
1921
1922    /// Internal runtime seam for interaction-scoped injection.
1923    #[doc(hidden)]
1924    async fn interaction_event_injector(
1925        &self,
1926        session_id: &SessionId,
1927    ) -> Option<Arc<dyn crate::event_injector::SubscribableInjector>> {
1928        self.comms_runtime(session_id)
1929            .await
1930            .and_then(|runtime| runtime.interaction_event_injector())
1931    }
1932}
1933
1934/// Optional control-plane extension for `SessionService`.
1935///
1936/// Keeps the base lifecycle contract minimal while exposing first-class
1937/// session mutation operations shared across external surfaces.
1938#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
1939#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
1940pub trait SessionServiceControlExt: SessionService {
1941    /// Append runtime system context to a session.
1942    ///
1943    /// The request is idempotent per `(session_id, idempotency_key)`. When a
1944    /// turn is active, implementations may stage the append for application at
1945    /// the next LLM boundary rather than mutating in-flight request state.
1946    async fn append_system_context(
1947        &self,
1948        id: &SessionId,
1949        req: AppendSystemContextRequest,
1950    ) -> Result<AppendSystemContextResult, SessionControlError>;
1951
1952    /// Stage callback tool results for application on the next turn seam.
1953    ///
1954    /// Implementations must persist the staged results durably before a live
1955    /// session can observe them so a failed call never leaves hidden pending
1956    /// transcript mutations behind.
1957    async fn stage_tool_results(
1958        &self,
1959        id: &SessionId,
1960        req: StageToolResultsRequest,
1961    ) -> Result<StageToolResultsResult, SessionError> {
1962        let _ = (id, req);
1963        Err(SessionError::Unsupported("stage_tool_results".to_string()))
1964    }
1965}
1966
1967/// Optional history-read extension for `SessionService`.
1968///
1969/// Keeps the base lifecycle contract lightweight while allowing surfaces to
1970/// fetch full transcript contents when they explicitly opt in.
1971#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
1972#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
1973pub trait SessionServiceHistoryExt: SessionService {
1974    /// Read the committed transcript for a session.
1975    ///
1976    /// Implementations may return `PersistenceDisabled` if they cannot provide
1977    /// authoritative history for the requested lifecycle state.
1978    async fn read_history(
1979        &self,
1980        id: &SessionId,
1981        query: SessionHistoryQuery,
1982    ) -> Result<SessionHistoryPage, SessionError>;
1983
1984    /// Read a retained immutable transcript revision body.
1985    async fn read_transcript_revision(
1986        &self,
1987        id: &SessionId,
1988        query: SessionTranscriptRevisionQuery,
1989    ) -> Result<SessionTranscriptRevisionPage, SessionError> {
1990        let _ = (id, query);
1991        Err(SessionError::Unsupported(
1992            "read_transcript_revision".to_string(),
1993        ))
1994    }
1995
1996    /// List retained transcript revision commits (oldest first) together with
1997    /// the current head revision.
1998    async fn list_transcript_revisions(
1999        &self,
2000        id: &SessionId,
2001        query: SessionTranscriptRevisionListQuery,
2002    ) -> Result<SessionTranscriptRevisionList, SessionError> {
2003        let _ = (id, query);
2004        Err(SessionError::Unsupported(
2005            "list_transcript_revisions".to_string(),
2006        ))
2007    }
2008}
2009
2010/// Optional typed transcript fork/edit extension for `SessionService`.
2011///
2012/// Implementations must route transcript surgery through typed semantic edit
2013/// paths. Fork operations create new `SessionId`s; rewrite operations keep the
2014/// `SessionId` stable while advancing the session transcript head.
2015#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
2016#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
2017pub trait SessionServiceTranscriptEditExt: SessionService {
2018    /// Fork a session at a message index.
2019    async fn fork_session_at(
2020        &self,
2021        id: &SessionId,
2022        req: SessionForkAtRequest,
2023    ) -> Result<SessionForkResult, SessionError> {
2024        let _ = (id, req);
2025        Err(SessionError::Unsupported("fork_session_at".to_string()))
2026    }
2027
2028    /// Fork a session and apply a typed transcript replacement.
2029    async fn fork_session_replace(
2030        &self,
2031        id: &SessionId,
2032        req: SessionForkReplaceRequest,
2033    ) -> Result<SessionForkResult, SessionError> {
2034        let _ = (id, req);
2035        Err(SessionError::Unsupported(
2036            "fork_session_replace".to_string(),
2037        ))
2038    }
2039
2040    /// Commit a typed same-session transcript rewrite.
2041    async fn rewrite_session_transcript(
2042        &self,
2043        id: &SessionId,
2044        req: SessionTranscriptRewriteRequest,
2045    ) -> Result<SessionTranscriptRewriteResult, SessionError> {
2046        let _ = (id, req);
2047        Err(SessionError::Unsupported(
2048            "rewrite_session_transcript".to_string(),
2049        ))
2050    }
2051
2052    /// Restore the current transcript head to a retained immutable revision.
2053    async fn restore_session_transcript_revision(
2054        &self,
2055        id: &SessionId,
2056        req: SessionTranscriptRestoreRevisionRequest,
2057    ) -> Result<SessionTranscriptRewriteResult, SessionError> {
2058        let _ = (id, req);
2059        Err(SessionError::Unsupported(
2060            "restore_session_transcript_revision".to_string(),
2061        ))
2062    }
2063}
2064
2065/// Extension trait for `Arc<dyn SessionService>` to allow calling methods directly.
2066impl dyn SessionService {
2067    /// Wrap self in an Arc.
2068    pub fn into_arc(self: Box<Self>) -> Arc<dyn SessionService> {
2069        Arc::from(self)
2070    }
2071}
2072
2073#[cfg(test)]
2074#[allow(
2075    clippy::unimplemented,
2076    clippy::unwrap_used,
2077    clippy::expect_used,
2078    clippy::panic
2079)]
2080mod tests {
2081    use super::*;
2082
2083    struct UnsupportedSessionService;
2084
2085    #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
2086    #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
2087    impl SessionService for UnsupportedSessionService {
2088        async fn create_session(
2089            &self,
2090            _req: CreateSessionRequest,
2091        ) -> Result<RunResult, SessionError> {
2092            unimplemented!()
2093        }
2094
2095        async fn start_turn(
2096            &self,
2097            _id: &SessionId,
2098            _req: StartTurnRequest,
2099        ) -> Result<RunResult, SessionError> {
2100            unimplemented!()
2101        }
2102
2103        async fn interrupt(&self, _id: &SessionId) -> Result<(), SessionError> {
2104            unimplemented!()
2105        }
2106
2107        async fn read(&self, _id: &SessionId) -> Result<SessionView, SessionError> {
2108            unimplemented!()
2109        }
2110
2111        async fn list(&self, _query: SessionQuery) -> Result<Vec<SessionSummary>, SessionError> {
2112            unimplemented!()
2113        }
2114
2115        async fn archive(&self, _id: &SessionId) -> Result<(), SessionError> {
2116            unimplemented!()
2117        }
2118    }
2119
2120    #[tokio::test]
2121    async fn has_live_session_defaults_to_unsupported() {
2122        let service = UnsupportedSessionService;
2123        let err = service
2124            .has_live_session(&SessionId::new())
2125            .await
2126            .expect_err("default implementation should fail loudly");
2127        assert!(matches!(err, SessionError::Unsupported(name) if name == "has_live_session"));
2128    }
2129
2130    #[test]
2131    fn spawn_profile_scope_allows_only_granted_profile_without_manage_scope() {
2132        let ctx = MobToolAuthorityContext::generated_for_test(
2133            OpaquePrincipalToken::new("generated-test"),
2134            false,
2135            false,
2136            false,
2137            BTreeSet::new(),
2138            BTreeMap::from([(
2139                "mob-1".to_string(),
2140                BTreeSet::from(["investigator".to_string()]),
2141            )]),
2142            None,
2143            None,
2144        );
2145
2146        assert!(ctx.spawn_profile_scope_present("mob-1"));
2147        assert!(ctx.spawn_profile_scope_contains("mob-1", "investigator"));
2148        assert!(!ctx.spawn_profile_scope_contains("mob-1", "writer"));
2149        assert!(!ctx.can_manage_mob("mob-1"));
2150    }
2151
2152    #[test]
2153    fn deserialized_mob_tool_authority_context_is_projection_only() {
2154        let ctx = MobToolAuthorityContext::generated_for_test(
2155            OpaquePrincipalToken::new("generated-test"),
2156            true,
2157            true,
2158            false,
2159            BTreeSet::from(["mob-1".to_string()]),
2160            BTreeMap::from([(
2161                "mob-1".to_string(),
2162                BTreeSet::from(["investigator".to_string()]),
2163            )]),
2164            Some(MobToolCallerProvenance::default().with_mob_id("mob-1")),
2165            Some("audit-1".to_string()),
2166        );
2167
2168        let restored: MobToolAuthorityContext =
2169            serde_json::from_value(serde_json::to_value(ctx).unwrap()).unwrap();
2170
2171        assert!(!restored.is_generated_authority_context());
2172        assert!(!restored.can_create_mobs());
2173        assert!(!restored.can_mutate_profiles());
2174        assert!(!restored.can_manage_mob("mob-1"));
2175        assert!(!restored.spawn_profile_scope_present("mob-1"));
2176        assert!(restored.caller_provenance().is_none());
2177        assert!(restored.audit_invocation_id().is_none());
2178    }
2179
2180    #[test]
2181    fn persisted_mob_enable_without_generated_seal_does_not_become_override() {
2182        let ctx = MobToolAuthorityContext::generated_for_test(
2183            OpaquePrincipalToken::new("generated-test"),
2184            true,
2185            true,
2186            false,
2187            BTreeSet::from(["mob-1".to_string()]),
2188            BTreeMap::new(),
2189            None,
2190            None,
2191        );
2192        let projected: MobToolAuthorityContext =
2193            serde_json::from_value(serde_json::to_value(ctx).unwrap()).unwrap();
2194
2195        let mut build = SessionBuildOptions::default();
2196        build.apply_persisted_mob_operator_access(ToolCategoryOverride::Enable, Some(projected));
2197
2198        assert_eq!(build.override_mob, ToolCategoryOverride::Inherit);
2199        assert!(build.mob_tool_authority_context.is_none());
2200    }
2201
2202    #[test]
2203    fn explicit_mob_enable_records_create_only_runtime_handoff_intent() {
2204        let mut build = SessionBuildOptions::default();
2205
2206        build.apply_generated_create_only_mob_operator_access(ToolCategoryOverride::Enable);
2207
2208        assert_eq!(build.override_mob, ToolCategoryOverride::Enable);
2209        assert!(build.mob_tool_authority_context.is_none());
2210    }
2211
2212    #[test]
2213    fn mob_tool_snapshot_context_standalone() {
2214        let ctx = MobToolSnapshotContext::Standalone;
2215        assert!(matches!(ctx, MobToolSnapshotContext::Standalone));
2216    }
2217
2218    #[test]
2219    fn mob_tool_snapshot_context_parent_owned_returns_tools() {
2220        let tools = Arc::<[Arc<ToolDef>]>::from(vec![Arc::new(ToolDef {
2221            name: "test_tool".into(),
2222            description: "a test".to_string(),
2223            input_schema: serde_json::json!({"type": "object"}),
2224            provenance: None,
2225        })]);
2226        let tool_scope = crate::ToolScope::new(tools);
2227        let authority = ParentToolCompositionAuthority::new_from_agent_factory_composition();
2228        authority.set_tool_scope_from_agent_factory_composition(&tool_scope);
2229        let ctx = MobToolSnapshotContext::ParentOwned(authority);
2230        match ctx {
2231            MobToolSnapshotContext::ParentOwned(p) => {
2232                let snapshot = p.snapshot_visible_tools();
2233                assert_eq!(snapshot.len(), 1);
2234                assert_eq!(snapshot[0].name, "test_tool");
2235            }
2236            MobToolSnapshotContext::Standalone => panic!("expected ParentOwned"),
2237        }
2238    }
2239}