basis-host 0.12.4

Host-side basis machinery shared by protocol and terminal adapters.
Documentation
//! Where a served host's sessions come from, and what its client cannot say.
//!
//! [`SessionSource`] is the concrete host seam served adapters consume;
//! [`SessionTemplate`] and [`Discovery`] are the operator-owned inputs used by
//! basis's configured source.
//!
//! Nothing here answers a request. The handlers read this and never write it,
//! which is why it is `Clone` and holds no lock: every closure in
//! a protocol server gets its own copy.

use std::path::PathBuf;

use basis::{
    BuiltinProvider, ContextConfig, Effort, McpServer, ModelSelector, PersistedSession,
    PreparedRun, RunError, ShellAccess, SystemPrompt, ToolsConfig, hooks::HooksConfig,
    mcp::McpConfig, skills::SkillsConfig, templates::TemplatesConfig,
};

/// Where a hosted session's [`PreparedRun`] comes from.
///
/// The same seam as [`prepare_with_session`](basis::run::prepare_with_session),
/// at the host layer: a Rust process that already owns a mentra runtime —
/// custom tools, its own store, a provider basis does not know — can serve it
/// through an adapter instead of letting basis build one.
///
/// A source that builds its own runtime owns its tool authorizer, and an
/// adapter that offers a mode picker **replaces it for the sessions it
/// serves**: `basis-acp` installs [`PolicyGate`](crate::PolicyGate) on each
/// live session, over whatever the runtime carries, because a read-only mode
/// has to refuse where mentra treats the refusal as final. So the mode picker
/// now works whatever a source's runtime authorizer does — and a source with a
/// stricter one of its own does not keep it for those sessions. A posture that
/// must survive belongs in a
/// [`RuntimeBuilder::with_interceptor`](basis::RuntimeBuilder::with_interceptor)
/// guard, which runs ahead of the authorizer and is not replaceable per
/// session. Installing
/// [`ApprovalGate`](basis::approval::ApprovalGate) — what basis's own source
/// gets from the [`Runtime`](basis::Runtime) it builds — is still what a
/// source wants for every *other* consumer of its runtime.
#[async_trait::async_trait]
pub trait SessionSource: Send + Sync + 'static {
    /// Opens a conversation in `cwd`, for `session/new`, with the MCP servers
    /// the client configured for this session.
    async fn create(&self, cwd: PathBuf, mcp: Vec<McpServer>) -> Result<PreparedRun, RunError>;

    /// Picks up the conversation persisted under `agent_id`, for
    /// `session/load`. The default refuses, which is the honest answer for a
    /// source whose sessions do not outlive the process.
    async fn resume(
        &self,
        agent_id: &str,
        cwd: PathBuf,
        mcp: Vec<McpServer>,
    ) -> Result<PreparedRun, RunError> {
        let _ = (agent_id, cwd, mcp);
        Err(RunError::NoSuchSession)
    }

    /// Whether this source can enumerate the conversations it has persisted.
    ///
    /// `session/list` is advertised and answered only when this is true. A
    /// source that keeps no registry would otherwise report "no sessions" for
    /// a workspace that has some, and a capability that answers wrongly is
    /// worse than one that was never claimed — an unregistered method at least
    /// says so, with `-32601`.
    fn lists_sessions(&self) -> bool {
        false
    }

    /// Every conversation persisted for `cwd`, most recently used first.
    ///
    /// The order is the source's to decide and is sent on unchanged: a list is
    /// read to find the conversation you were just in, and a source knows what
    /// "just" means for the store it keeps. basis's own answers from
    /// [`store::list`](basis::store::list), which sorts by when a row was last
    /// written.
    ///
    /// Only called when [`lists_sessions`](Self::lists_sessions) is true, so
    /// the default is unreachable rather than a claim about anything.
    async fn list_sessions(&self, cwd: PathBuf) -> Result<Vec<PersistedSession>, RunError> {
        let _ = cwd;
        Ok(Vec::new())
    }

    /// Whether this source can remove a conversation for good.
    ///
    /// Separate from [`lists_sessions`](Self::lists_sessions) because the two
    /// are separate promises: a source can perfectly well enumerate a store it
    /// has no authority to write to, and `session/delete` is advertised only
    /// on the strength of this one. The rule is the same either way — a
    /// capability that answers wrongly is worse than one never claimed.
    fn deletes_sessions(&self) -> bool {
        false
    }

    /// Removes the conversation persisted under `agent_id`, for
    /// `session/delete`.
    ///
    /// No `cwd`, unlike [`create`](Self::create) and
    /// [`list_sessions`](Self::list_sessions), because `session/delete` sends
    /// none — and needs to send none: a store is indexed by conversation, so
    /// the id is the whole of what identifies one.
    ///
    /// Only called when [`deletes_sessions`](Self::deletes_sessions) is true.
    /// Deleting one that is not there must succeed: a client deletes by an id
    /// it read from a list, and "it is gone" is the outcome either way.
    async fn delete(&self, agent_id: &str) -> Result<(), RunError> {
        let _ = agent_id;
        Err(RunError::NoSuchSession)
    }
}

/// Where every session's workspace discovery looks.
///
/// The defaults are basis's own — workspace files plus the user-global roots
/// — and this is the operator's knob to say otherwise: a server that must not
/// honor the developer's global `mcp.json` pins `McpConfig { global_dir:
/// None, .. }` here, and an offline test pins every root the same way. It
/// lives on the template because discovery is part of what the operator
/// promised every session, not something a client can renegotiate per
/// `session/new`.
#[derive(Debug, Clone, Default)]
pub struct Discovery {
    /// Where context files (`AGENTS.md`) are discovered.
    pub context: ContextConfig,
    /// Where skills are discovered.
    pub skills: SkillsConfig,
    /// Where prompt templates are discovered.
    pub templates: TemplatesConfig,
    /// Where subprocess hooks are discovered.
    pub hooks: HooksConfig,
    /// Where declared subprocess tools are discovered.
    pub tools: ToolsConfig,
    /// Where MCP servers are discovered, before the client's own land on top.
    pub mcp: McpConfig,
}

/// What the operator said and a client cannot say for itself: which provider
/// and endpoint, which model, whether commands are granted, the product's own
/// voice, how hard to think, and what to call the sessions.
///
/// The workspace is deliberately absent. Every session brings its own `cwd`
/// on `session/new`, so there is nothing here for it to replace — where the
/// old one-shot config had to carry a placeholder path because its type
/// demanded one.
#[derive(Debug, Clone, Default)]
pub struct SessionTemplate {
    pub(crate) provider: Option<BuiltinProvider>,
    pub(crate) base_url: Option<String>,
    pub(crate) model: Option<ModelSelector>,
    pub(crate) shell: ShellAccess,
    pub(crate) system_prompt: Option<SystemPrompt>,
    pub(crate) effort: Option<Effort>,
    pub(crate) session_name: Option<String>,
    pub(crate) discovery: Discovery,
}

impl SessionTemplate {
    pub fn new() -> Self {
        Self::default()
    }

    /// Prefers `provider` over whichever API key the environment offers first.
    pub fn with_provider(self, provider: BuiltinProvider) -> Self {
        Self {
            provider: Some(provider),
            ..self
        }
    }

    /// Points every session at an OpenAI-compatible endpoint.
    pub fn with_base_url(self, base_url: impl Into<String>) -> Self {
        Self {
            base_url: Some(base_url.into()),
            ..self
        }
    }

    pub fn with_model(self, model: ModelSelector) -> Self {
        Self {
            model: Some(model),
            ..self
        }
    }

    /// Grants or denies command execution for every session (ADR-0013).
    pub fn with_shell(self, shell: ShellAccess) -> Self {
        Self { shell, ..self }
    }

    /// The host's say on top of each workspace's own context files.
    pub fn with_system_prompt(self, system_prompt: SystemPrompt) -> Self {
        Self {
            system_prompt: Some(system_prompt),
            ..self
        }
    }

    /// How hard the model should think, where the provider supports it.
    pub fn with_effort(self, effort: Effort) -> Self {
        Self {
            effort: Some(effort),
            ..self
        }
    }

    pub fn with_session_name(self, session_name: impl Into<String>) -> Self {
        Self {
            session_name: Some(session_name.into()),
            ..self
        }
    }

    /// Points every session's discovery somewhere else — see [`Discovery`].
    pub fn with_discovery(self, discovery: Discovery) -> Self {
        Self { discovery, ..self }
    }
}