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}