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    /// Where each session's workspace gets its `config.json` answers. None by
137    /// default: a host embedding basis reads no configuration file it did not
138    /// name. [`ConfigLookup::Discover`](basis::ConfigLookup::Discover) is the
139    /// CLI's convention — each session's `cwd` and the global directory
140    /// [`context`](Self::context) names.
141    pub config: basis::ConfigLookup,
142}
143
144/// What the operator said and a client cannot say for itself: which provider
145/// and endpoint, which model, whether commands are granted, the product's own
146/// voice, how hard to think, and what to call the sessions.
147///
148/// The workspace is deliberately absent. Every session brings its own `cwd`
149/// on `session/new`, so there is nothing here for it to replace — where the
150/// old one-shot config had to carry a placeholder path because its type
151/// demanded one.
152#[derive(Debug, Clone, Default)]
153pub struct SessionTemplate {
154    pub(crate) provider: Option<BuiltinProvider>,
155    pub(crate) base_url: Option<String>,
156    pub(crate) model: Option<ModelSelector>,
157    pub(crate) shell: ShellAccess,
158    pub(crate) system_prompt: Option<SystemPrompt>,
159    pub(crate) effort: Option<Effort>,
160    pub(crate) session_name: Option<String>,
161    pub(crate) discovery: Discovery,
162    pub(crate) a2a_watch: A2aWatchPolicy,
163}
164
165/// Whether each workspace's A2A files are watched, and how often.
166#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
167pub(crate) enum A2aWatchPolicy {
168    /// Every [`basis::DEFAULT_A2A_WATCH_INTERVAL`].
169    #[default]
170    Default,
171    Every(std::time::Duration),
172    Off,
173}
174
175impl SessionTemplate {
176    pub fn new() -> Self {
177        Self::default()
178    }
179
180    /// Prefers `provider` over whichever API key the environment offers first.
181    pub fn with_provider(self, provider: BuiltinProvider) -> Self {
182        Self {
183            provider: Some(provider),
184            ..self
185        }
186    }
187
188    /// Points every session at an OpenAI-compatible endpoint.
189    pub fn with_base_url(self, base_url: impl Into<String>) -> Self {
190        Self {
191            base_url: Some(base_url.into()),
192            ..self
193        }
194    }
195
196    pub fn with_model(self, model: ModelSelector) -> Self {
197        Self {
198            model: Some(model),
199            ..self
200        }
201    }
202
203    /// Grants or denies command execution for every session (ADR-0013).
204    pub fn with_shell(self, shell: ShellAccess) -> Self {
205        Self { shell, ..self }
206    }
207
208    /// The host's say on top of each workspace's own context files.
209    pub fn with_system_prompt(self, system_prompt: SystemPrompt) -> Self {
210        Self {
211            system_prompt: Some(system_prompt),
212            ..self
213        }
214    }
215
216    /// How hard the model should think, where the provider supports it.
217    pub fn with_effort(self, effort: Effort) -> Self {
218        Self {
219            effort: Some(effort),
220            ..self
221        }
222    }
223
224    pub fn with_session_name(self, session_name: impl Into<String>) -> Self {
225        Self {
226            session_name: Some(session_name.into()),
227            ..self
228        }
229    }
230
231    /// Points every session's discovery somewhere else — see [`Discovery`].
232    pub fn with_discovery(self, discovery: Discovery) -> Self {
233        Self { discovery, ..self }
234    }
235
236    /// How often each workspace's A2A files are polled for a change
237    /// ([`Workspace::watch_a2a`](basis::Workspace::watch_a2a), ADR-0031 §4),
238    /// or `None` not to watch them at all.
239    ///
240    /// On by default, every [`basis::DEFAULT_A2A_WATCH_INTERVAL`]: a server
241    /// holds a workspace for as long as an editor has the repository open,
242    /// and an operator who adds an agent, rotates its credential or moves its
243    /// URL should not have to restart it to be heard.
244    pub fn with_a2a_watch(self, interval: Option<std::time::Duration>) -> Self {
245        Self {
246            a2a_watch: interval.map_or(A2aWatchPolicy::Off, A2aWatchPolicy::Every),
247            ..self
248        }
249    }
250
251    /// The interval each workspace's A2A files are polled at, if at all.
252    pub(crate) fn a2a_watch_interval(&self) -> Option<std::time::Duration> {
253        match self.a2a_watch {
254            A2aWatchPolicy::Default => Some(basis::DEFAULT_A2A_WATCH_INTERVAL),
255            A2aWatchPolicy::Every(interval) => Some(interval),
256            A2aWatchPolicy::Off => None,
257        }
258    }
259}