Skip to main content

basis_host/
config.rs

1//! Where a served host's sessions come from, and what its client cannot say.
2//!
3//! [`SessionSource`] is the concrete host seam served adapters consume;
4//! [`SessionTemplate`] and [`Discovery`] are the operator-owned inputs used by
5//! basis's configured source.
6//!
7//! Nothing here answers a request. The handlers read this and never write it,
8//! which is why it is `Clone` and holds no lock: every closure in
9//! a protocol server gets its own copy.
10
11use std::path::PathBuf;
12
13use basis::{
14    BuiltinProvider, ContextConfig, Effort, McpServer, ModelSelector, PersistedSession,
15    PreparedRun, RunError, ShellAccess, SystemPrompt, ToolsConfig, hooks::HooksConfig,
16    mcp::McpConfig, skills::SkillsConfig, templates::TemplatesConfig,
17};
18
19/// Where a hosted session's [`PreparedRun`] comes from.
20///
21/// The same seam as [`prepare_with_session`](basis::run::prepare_with_session),
22/// at the host layer: a Rust process that already owns a mentra runtime —
23/// custom tools, its own store, a provider basis does not know — can serve it
24/// through an adapter instead of letting basis build one.
25///
26/// A source that builds its own runtime owns its tool authorizer, and an
27/// adapter that offers a mode picker **replaces it for the sessions it
28/// serves**: `basis-acp` installs [`PolicyGate`](crate::PolicyGate) on each
29/// live session, over whatever the runtime carries, because a read-only mode
30/// has to refuse where mentra treats the refusal as final. So the mode picker
31/// now works whatever a source's runtime authorizer does — and a source with a
32/// stricter one of its own does not keep it for those sessions. A posture that
33/// must survive belongs in a
34/// [`RuntimeBuilder::with_interceptor`](basis::RuntimeBuilder::with_interceptor)
35/// guard, which runs ahead of the authorizer and is not replaceable per
36/// session. Installing
37/// [`ApprovalGate`](basis::approval::ApprovalGate) — what basis's own source
38/// gets from the [`Runtime`](basis::Runtime) it builds — is still what a
39/// source wants for every *other* consumer of its runtime.
40#[async_trait::async_trait]
41pub trait SessionSource: Send + Sync + 'static {
42    /// Opens a conversation in `cwd`, for `session/new`, with the MCP servers
43    /// the client configured for this session.
44    async fn create(&self, cwd: PathBuf, mcp: Vec<McpServer>) -> Result<PreparedRun, RunError>;
45
46    /// Picks up the conversation persisted under `agent_id`, for
47    /// `session/load`. The default refuses, which is the honest answer for a
48    /// source whose sessions do not outlive the process.
49    async fn resume(
50        &self,
51        agent_id: &str,
52        cwd: PathBuf,
53        mcp: Vec<McpServer>,
54    ) -> Result<PreparedRun, RunError> {
55        let _ = (agent_id, cwd, mcp);
56        Err(RunError::NoSuchSession)
57    }
58
59    /// Whether this source can enumerate the conversations it has persisted.
60    ///
61    /// `session/list` is advertised and answered only when this is true. A
62    /// source that keeps no registry would otherwise report "no sessions" for
63    /// a workspace that has some, and a capability that answers wrongly is
64    /// worse than one that was never claimed — an unregistered method at least
65    /// says so, with `-32601`.
66    fn lists_sessions(&self) -> bool {
67        false
68    }
69
70    /// Every conversation persisted for `cwd`, most recently used first.
71    ///
72    /// The order is the source's to decide and is sent on unchanged: a list is
73    /// read to find the conversation you were just in, and a source knows what
74    /// "just" means for the store it keeps. basis's own answers from
75    /// [`store::list`](basis::store::list), which sorts by when a row was last
76    /// written.
77    ///
78    /// Only called when [`lists_sessions`](Self::lists_sessions) is true, so
79    /// the default is unreachable rather than a claim about anything.
80    async fn list_sessions(&self, cwd: PathBuf) -> Result<Vec<PersistedSession>, RunError> {
81        let _ = cwd;
82        Ok(Vec::new())
83    }
84
85    /// Whether this source can remove a conversation for good.
86    ///
87    /// Separate from [`lists_sessions`](Self::lists_sessions) because the two
88    /// are separate promises: a source can perfectly well enumerate a store it
89    /// has no authority to write to, and `session/delete` is advertised only
90    /// on the strength of this one. The rule is the same either way — a
91    /// capability that answers wrongly is worse than one never claimed.
92    fn deletes_sessions(&self) -> bool {
93        false
94    }
95
96    /// Removes the conversation persisted under `agent_id`, for
97    /// `session/delete`.
98    ///
99    /// No `cwd`, unlike [`create`](Self::create) and
100    /// [`list_sessions`](Self::list_sessions), because `session/delete` sends
101    /// none — and needs to send none: a store is indexed by conversation, so
102    /// the id is the whole of what identifies one.
103    ///
104    /// Only called when [`deletes_sessions`](Self::deletes_sessions) is true.
105    /// Deleting one that is not there must succeed: a client deletes by an id
106    /// it read from a list, and "it is gone" is the outcome either way.
107    async fn delete(&self, agent_id: &str) -> Result<(), RunError> {
108        let _ = agent_id;
109        Err(RunError::NoSuchSession)
110    }
111}
112
113/// Where every session's workspace discovery looks.
114///
115/// The defaults are basis's own — workspace files plus the user-global roots
116/// — and this is the operator's knob to say otherwise: a server that must not
117/// honor the developer's global `mcp.json` pins `McpConfig { global_dir:
118/// None, .. }` here, and an offline test pins every root the same way. It
119/// lives on the template because discovery is part of what the operator
120/// promised every session, not something a client can renegotiate per
121/// `session/new`.
122#[derive(Debug, Clone, Default)]
123pub struct Discovery {
124    /// Where context files (`AGENTS.md`) are discovered.
125    pub context: ContextConfig,
126    /// Where skills are discovered.
127    pub skills: SkillsConfig,
128    /// Where prompt templates are discovered.
129    pub templates: TemplatesConfig,
130    /// Where subprocess hooks are discovered.
131    pub hooks: HooksConfig,
132    /// Where declared subprocess tools are discovered.
133    pub tools: ToolsConfig,
134    /// Where MCP servers are discovered, before the client's own land on top.
135    pub mcp: McpConfig,
136}
137
138/// What the operator said and a client cannot say for itself: which provider
139/// and endpoint, which model, whether commands are granted, the product's own
140/// voice, how hard to think, and what to call the sessions.
141///
142/// The workspace is deliberately absent. Every session brings its own `cwd`
143/// on `session/new`, so there is nothing here for it to replace — where the
144/// old one-shot config had to carry a placeholder path because its type
145/// demanded one.
146#[derive(Debug, Clone, Default)]
147pub struct SessionTemplate {
148    pub(crate) provider: Option<BuiltinProvider>,
149    pub(crate) base_url: Option<String>,
150    pub(crate) model: Option<ModelSelector>,
151    pub(crate) shell: ShellAccess,
152    pub(crate) system_prompt: Option<SystemPrompt>,
153    pub(crate) effort: Option<Effort>,
154    pub(crate) session_name: Option<String>,
155    pub(crate) discovery: Discovery,
156    pub(crate) a2a_watch: A2aWatchPolicy,
157}
158
159/// Whether each workspace's A2A files are watched, and how often.
160#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
161pub(crate) enum A2aWatchPolicy {
162    /// Every [`basis::DEFAULT_A2A_WATCH_INTERVAL`].
163    #[default]
164    Default,
165    Every(std::time::Duration),
166    Off,
167}
168
169impl SessionTemplate {
170    pub fn new() -> Self {
171        Self::default()
172    }
173
174    /// Prefers `provider` over whichever API key the environment offers first.
175    pub fn with_provider(self, provider: BuiltinProvider) -> Self {
176        Self {
177            provider: Some(provider),
178            ..self
179        }
180    }
181
182    /// Points every session at an OpenAI-compatible endpoint.
183    pub fn with_base_url(self, base_url: impl Into<String>) -> Self {
184        Self {
185            base_url: Some(base_url.into()),
186            ..self
187        }
188    }
189
190    pub fn with_model(self, model: ModelSelector) -> Self {
191        Self {
192            model: Some(model),
193            ..self
194        }
195    }
196
197    /// Grants or denies command execution for every session (ADR-0013).
198    pub fn with_shell(self, shell: ShellAccess) -> Self {
199        Self { shell, ..self }
200    }
201
202    /// The host's say on top of each workspace's own context files.
203    pub fn with_system_prompt(self, system_prompt: SystemPrompt) -> Self {
204        Self {
205            system_prompt: Some(system_prompt),
206            ..self
207        }
208    }
209
210    /// How hard the model should think, where the provider supports it.
211    pub fn with_effort(self, effort: Effort) -> Self {
212        Self {
213            effort: Some(effort),
214            ..self
215        }
216    }
217
218    pub fn with_session_name(self, session_name: impl Into<String>) -> Self {
219        Self {
220            session_name: Some(session_name.into()),
221            ..self
222        }
223    }
224
225    /// Points every session's discovery somewhere else — see [`Discovery`].
226    pub fn with_discovery(self, discovery: Discovery) -> Self {
227        Self { discovery, ..self }
228    }
229
230    /// How often each workspace's A2A files are polled for a change
231    /// ([`Workspace::watch_a2a`](basis::Workspace::watch_a2a), ADR-0031 §4),
232    /// or `None` not to watch them at all.
233    ///
234    /// On by default, every [`basis::DEFAULT_A2A_WATCH_INTERVAL`]: a server
235    /// holds a workspace for as long as an editor has the repository open,
236    /// and an operator who adds an agent, rotates its credential or moves its
237    /// URL should not have to restart it to be heard.
238    pub fn with_a2a_watch(self, interval: Option<std::time::Duration>) -> Self {
239        Self {
240            a2a_watch: interval.map_or(A2aWatchPolicy::Off, A2aWatchPolicy::Every),
241            ..self
242        }
243    }
244
245    /// The interval each workspace's A2A files are polled at, if at all.
246    pub(crate) fn a2a_watch_interval(&self) -> Option<std::time::Duration> {
247        match self.a2a_watch {
248            A2aWatchPolicy::Default => Some(basis::DEFAULT_A2A_WATCH_INTERVAL),
249            A2aWatchPolicy::Every(interval) => Some(interval),
250            A2aWatchPolicy::Off => None,
251        }
252    }
253}