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}
157
158impl SessionTemplate {
159 pub fn new() -> Self {
160 Self::default()
161 }
162
163 /// Prefers `provider` over whichever API key the environment offers first.
164 pub fn with_provider(self, provider: BuiltinProvider) -> Self {
165 Self {
166 provider: Some(provider),
167 ..self
168 }
169 }
170
171 /// Points every session at an OpenAI-compatible endpoint.
172 pub fn with_base_url(self, base_url: impl Into<String>) -> Self {
173 Self {
174 base_url: Some(base_url.into()),
175 ..self
176 }
177 }
178
179 pub fn with_model(self, model: ModelSelector) -> Self {
180 Self {
181 model: Some(model),
182 ..self
183 }
184 }
185
186 /// Grants or denies command execution for every session (ADR-0013).
187 pub fn with_shell(self, shell: ShellAccess) -> Self {
188 Self { shell, ..self }
189 }
190
191 /// The host's say on top of each workspace's own context files.
192 pub fn with_system_prompt(self, system_prompt: SystemPrompt) -> Self {
193 Self {
194 system_prompt: Some(system_prompt),
195 ..self
196 }
197 }
198
199 /// How hard the model should think, where the provider supports it.
200 pub fn with_effort(self, effort: Effort) -> Self {
201 Self {
202 effort: Some(effort),
203 ..self
204 }
205 }
206
207 pub fn with_session_name(self, session_name: impl Into<String>) -> Self {
208 Self {
209 session_name: Some(session_name.into()),
210 ..self
211 }
212 }
213
214 /// Points every session's discovery somewhere else — see [`Discovery`].
215 pub fn with_discovery(self, discovery: Discovery) -> Self {
216 Self { discovery, ..self }
217 }
218}