1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
//! What can go wrong, named once at the root.
//!
//! [`RunError`] is the crate's universal error — opening a workspace,
//! preparing a run, driving one — and it used to live in
//! [`run`](mod@crate::run), where history put it. Four of that module's in-edges
//! (the store, the event mapping, the runtime, the budget) imported nothing
//! from `run` *but* this type, which manufactured four of the crate's import
//! cycles out of one name. At the root, an error is something every module
//! may name without owing the run module anything; `run` re-exports it, so
//! `basis::run::RunError` still reads.
use thiserror::Error;
#[cfg(feature = "mcp")]
use crate::mcp::McpError;
use crate::{context::ContextError, provider::ProviderError};
/// Anything that can go wrong opening a workspace, preparing a run, or driving
/// one.
///
/// One error type across all three, rather than a `WorkspaceError` beside it:
/// opening a workspace exists to prepare runs, and every failure listed here is
/// a failure a caller of [`run`](crate::run()) has always been able to receive.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum RunError {
#[error("prompt is empty")]
EmptyPrompt,
/// The shared allowance this turn draws on has nothing left.
///
/// A decision rather than a failure of the work, which is why it is its own
/// variant: a caller fanning out over a [`BudgetPool`](crate::BudgetPool)
/// stops minting on this, where it would retry on a provider error. Raised
/// before the prompt is sent and before the stream opens, so the
/// conversation is left exactly as it was.
#[error("the shared token budget is spent: {spent} of {limit} tokens reported")]
BudgetExhausted { limit: u64, spent: u64 },
#[error("no session to resume")]
NoSuchSession,
/// The directory named for this runtime's conversations holds a basis
/// ≤0.6 store — mentra's SQLite database — which this build neither links
/// nor migrates (ADR-0023).
///
/// basis's own words rather than mentra's: the upstream file store
/// detects the same file and names its `store-sqlite` cargo feature,
/// which is advice for a mentra embedder, not for the person whose
/// conversations are in the file. Raised before any file store is opened
/// in the directory, because an empty store beside the database would
/// read as every conversation being lost. See
/// [`store`](crate::store)'s module docs for where the check runs.
#[error(
"'{}' holds conversations from basis 0.6 or earlier (runtime.sqlite, a SQLite \
database); this build persists conversations as plain files and the database is \
not migrated. To continue an old conversation, use basis 0.6. To start new work \
here, point the store somewhere fresh (`RuntimeBuilder::with_store_dir`; for the \
CLI, `BASIS_DATA_DIR`) or move the old store directory aside",
dir.display()
)]
LegacyStore {
/// The store directory holding the pre-0.7 database.
dir: std::path::PathBuf,
},
/// Resuming a conversation could not clear a pre-0.12 "…for this
/// session" approval rule the conversation's `rules.json` still carries,
/// so the resume is refused rather than run with a grant or refusal a
/// person gave to an earlier, unrelated session.
///
/// mentra 0.27's process-scoped rung (mentra#53) means a *new*
/// "…for this session" answer never touches the store — but a row an
/// older basis binary remembered into the durable `Session` scope before
/// this floor still sits in `rules.json`, and mentra still loads and
/// matches `Session`-scope rows exactly as it always has. The clear
/// reads `rules.json` before it can filter, so one corrupt, truncated, or
/// unwritable file fails **every** resume against that store until the
/// file is repaired or deleted. Fresh conversations, and any store no
/// pre-0.12 binary ever wrote to, are unaffected.
#[error(
"resuming `{agent_id}` could not clear a legacy for-this-session approval rule \
({error}); every resume on this store will fail until its rules.json — the \
path in the error above — is repaired or deleted (deleting it costs only \
remembered approval answers, never history)"
)]
SessionRulesNotCleared {
/// The conversation whose resume was refused.
agent_id: String,
/// The store's own failure, naming the file it could not read or
/// rewrite.
#[source]
error: mentra::error::RuntimeError,
},
/// [`Workspace::resume`](crate::Workspace::resume) was handed a
/// conversation that belongs to a different workspace.
///
/// mentra's store is keyed by agent, not by path, so an id alone says
/// nothing about where its conversation ran — and everything a resume
/// restates is *this* workspace's: the policy carrying its `.git`
/// carve-out and shell posture, the tool audience deciding which of the
/// registry's tools it can see, the persisted-row tag. Stamping those onto
/// another repository's conversation would run it under a posture nobody
/// chose for it while its agent stayed based in its own directory — which
/// mentra's file tools always allow writes under. So the binding is
/// checked against the persisted agent's own base directory, and a
/// mismatch is refused before the session is handed out.
///
/// A host that means "resume one of mine" takes the id from
/// [`store::list`](crate::store::list) for its own workspace, which is
/// where a client got it anyway.
#[error(
"conversation `{agent_id}` belongs to the workspace at {} and cannot be resumed \
under the one at {}",
agent_workspace.display(),
workspace.display()
)]
WorkspaceMismatch {
/// The conversation whose resume was refused.
agent_id: String,
/// The workspace that tried to resume it.
workspace: std::path::PathBuf,
/// The directory the persisted agent is actually based in.
agent_workspace: std::path::PathBuf,
},
#[error(transparent)]
Config(#[from] crate::config::ConfigError),
#[error(transparent)]
Context(#[from] ContextError),
/// Host-resolved model metadata names a provider other than the runtime's.
///
/// Raised while opening a workspace, applying a per-run profile, or
/// switching an attached [`PreparedRun`](crate::PreparedRun), before model
/// catalogue, model request, or tool activity. The mismatch cannot be
/// repaired by looking up the id: provider identity is part of the host's
/// resolved contract.
#[error(
"resolved model `{model}` belongs to provider `{model_provider}`, but the runtime uses \
`{runtime_provider}`"
)]
ResolvedModelProviderMismatch {
/// The host-resolved model id.
model: String,
/// The provider named by the model metadata.
model_provider: String,
/// The provider registered on the runtime.
runtime_provider: String,
},
/// Complete provider request options contain one or more extra headers,
/// but this runtime can persist its Mentra agent configs.
///
/// Header names and values are deliberately absent: either can itself be
/// sensitive. Use an explicitly ephemeral runtime for request-scoped
/// credentials, or configure durable connection credentials on the
/// provider instead.
#[error(
"run profile request headers require a runtime built with \
RuntimeBuilder::with_ephemeral_history"
)]
RunProfileHeadersRequireEphemeralHistory,
/// A [`RunProfile`](crate::RunProfile) field Mentra cannot change on an
/// already persisted agent.
///
/// Refused before the session is looked up or resumed, rather than
/// projecting the supported subset and silently dropping part of the
/// host's contract. Resolved model metadata and the dedicated reasoning
/// override are each supported alone through Mentra's exact session
/// setters; every other field is named here when present.
#[error("run profile field `{field}` cannot be applied while resuming a session")]
UnsupportedResumeProfile {
/// The first unsupported field in deterministic profile order.
field: &'static str,
},
/// A resumed profile model would require separately persisting both model
/// and reasoning changes, because the profile or an effective legacy
/// effort also changes reasoning.
///
/// Mentra 0.23 exposes one setter for each but no atomic combined update.
/// Refused before session lookup so a failed second write can never leave
/// half of the host's profile in force.
#[error(
"a resumed run profile cannot change model and reasoning together; \
apply only one persisted override"
)]
NonAtomicResumeProfile,
/// Discovery was disabled on a builder borrowing a shared runtime.
///
/// Mentra's runtime-global skill loader can be changed after an `Arc` is
/// borrowed, and its model-visible descriptions are read on every round.
/// No one-time inspection can therefore prove that a shared runtime stays
/// discovery-free. Gate 1a's fresh-only lifecycle fails closed before
/// runtime acquisition, model resolution, provider requests, workspace
/// tool registration, or interception; use
/// [`WorkspaceBuilder::with_runtime_builder`](crate::WorkspaceBuilder::with_runtime_builder)
/// so opening privately constructs the runtime it owns.
#[error(
"discovery-disabled workspaces cannot borrow a shared runtime; supply a fresh private \
runtime recipe with WorkspaceBuilder::with_runtime_builder"
)]
DiscoveryDisabledSharedRuntime,
/// Fresh-only ownership was requested with a borrowed runtime.
#[error(
"fresh-only workspaces cannot borrow a shared runtime; supply a fresh private runtime \
recipe with WorkspaceBuilder::with_runtime_builder"
)]
FreshOnlySharedRuntime,
/// The workspace's one independent mint/resume attempt was already used.
#[error(
"this fresh-only workspace has already attempted its one independent prepare or resume; \
open a new workspace with a fresh private runtime to try again"
)]
FreshOnlyRunAlreadyAttempted,
#[error(transparent)]
Provider(#[from] ProviderError),
#[error("runtime error: {0}")]
Runtime(#[from] mentra::error::RuntimeError),
/// A typed turn answered, but not in the shape that was asked for.
///
/// Separate from [`Runtime`](Self::Runtime) because the two call for
/// different reactions and basis can tell them apart honestly: this one is
/// basis's own verdict. The typed path asks mentra for the raw payload and
/// deserializes it here, so a value that does not fit `T` is a schema or
/// prompt problem — retry with a clearer schema — while a provider failure
/// is not. The exchange stays in the session's transcript either way; see
/// [`PreparedRun::output`](crate::PreparedRun::output), which delivers this
/// inside an [`OutputFailure`](crate::OutputFailure) so the report the turn
/// earned comes with it.
#[error("the run's output did not match the requested type: {0}")]
OutputMismatch(#[source] serde_json::Error),
#[error("failed to write an event: {0}")]
Sink(#[from] std::io::Error),
#[error("event forwarding task failed: {0}")]
Forwarder(#[from] tokio::task::JoinError),
#[error("failed to load skills: {0}")]
Skills(#[from] mentra::SkillLoadError),
#[error(transparent)]
#[cfg(feature = "mcp")]
Mcp(#[from] McpError),
#[error("failed to load prompt templates: {0}")]
Templates(#[from] crate::templates::TemplateError),
#[error("failed to load memories: {0}")]
Memory(#[from] crate::memory::MemoryError),
/// The blocking thread [`WorkspaceBuilder::open`](crate::WorkspaceBuilder::open)
/// runs memory discovery on (roots, per-file reads, `canonicalize`)
/// panicked or was cancelled before it returned (whole-wave review, G7).
///
/// Not `#[from]`: [`Forwarder`](Self::Forwarder) already claims
/// `tokio::task::JoinError` for the event-forwarding task, and thiserror
/// cannot generate two `From` impls for one source type on one enum — so
/// this is built by hand at the one call site that needs it.
#[error("memory discovery failed: {0}")]
MemoryDiscovery(#[source] tokio::task::JoinError),
#[error("failed to load hooks: {0}")]
Hooks(#[from] crate::hooks::HookConfigError),
/// Two live opens of one workspace present different interception chains.
///
/// One directory is one tool audience, and a workspace registers its
/// chain for that audience — so a second live open of the same root either
/// **joins** the registration already there, which needs the two chains to
/// be the same, or would put a second complete chain behind one audience.
/// The second is not a middle ground: mentra would walk both for either
/// open's calls, spawning every subprocess hook twice per call and feeding
/// a non-idempotent rewrite its own output, and the first open's sessions
/// would be judged by a chain their caller never configured. So identical
/// chains join and different ones are refused here.
///
/// A host that genuinely needs two hook configurations for one directory
/// needs two [`Runtime`](crate::Runtime)s. A host whose two opens differ
/// only in their supplied MCP servers — `basis-host`'s deliberate shape —
/// never meets this: the hooks come from one discovery configuration and
/// are equal.
#[error(
"the workspace at '{}' is already open on this runtime with a different hook \
configuration; two live opens of one directory share one interception chain, so they \
must configure the same one",
root.display()
)]
WorkspaceGuardConflict { root: std::path::PathBuf },
#[error("failed to load declared tools: {0}")]
Tools(#[from] crate::tools::declared::DeclaredToolError),
/// A command target name that cannot be routed on (ADR-0021).
///
/// Dormant: with `with_command_target` withdrawn, nothing can put a name
/// in front of the validation that raises this, so `build` currently has
/// no target to refuse. The variant keeps its slot in this
/// `#[non_exhaustive]` enum for the day a registration seam returns.
#[error("`{name}` cannot be a command target name: {reason}")]
CommandTarget { name: String, reason: String },
/// A host tool a *workspace* was given
/// ([`WorkspaceBuilder::with_tool`](crate::WorkspaceBuilder::with_tool))
/// under a name no tool may wear: empty, longer than a provider accepts,
/// outside the charset, or `mcp__`-prefixed, which is how mentra names a
/// bridged server's tool.
///
/// The rules are the declared binding's, called rather than restated —
/// what may be a tool name does not depend on which binding put it there
/// (`crate::tools::host`).
#[error("the host tool `{name}` supplied to this workspace {reason}")]
WorkspaceHostToolName { name: String, reason: String },
/// The same tool, under a name that is well formed but not available.
///
/// Apart from [`WorkspaceHostToolName`](Self::WorkspaceHostToolName)
/// because the two carry different vocabularies and one sentence cannot
/// host both: a malformed name *is* something ("has an empty name"), and
/// an unavailable one is refused *because* of something else that is true.
/// The declared binding splits its own refusals the same way
/// (`DeclaredToolError::Invalid` beside `NameTaken`).
///
/// Four things can make a name unavailable: something this runtime already
/// answers to globally (`spawn`, a mentra builtin, a
/// [`RuntimeBuilder::with_tool`](crate::RuntimeBuilder::with_tool)
/// global); another repository open on the same runtime; another live open
/// of *this* directory; or the tool itself, if its descriptor named one
/// thing when the name was claimed and another when it was registered.
///
/// The third is the one a host meets by accident. One directory is one
/// tool audience, so two live opens of it share a namespace, and a native
/// tool is compiled code closing over whatever the host had when it
/// supplied it — there is no way to tell two of them apart, and joining
/// would serve the second open the first one's closure. A host that
/// genuinely needs its own native tools per open of one directory needs
/// one [`Runtime`](crate::Runtime) per open; a declaration, which is data,
/// joins instead.
///
/// A sibling open that supplies *nothing* meets no error at all, and must
/// not: it is refused nothing because it asked for nothing. What keeps it
/// out of the tool it never supplied is not this variant but the pair that
/// covers every shared-audience binding — hidden from its roster at the
/// mint, and refused at the call by the guard in its own chain.
///
/// Either variant refuses the open rather than registering part of a set.
#[error("the host tool `{name}` cannot be registered for this workspace because {reason}")]
WorkspaceHostToolNameTaken { name: String, reason: String },
/// A host tool ([`RuntimeBuilder::with_tool`](crate::RuntimeBuilder::with_tool))
/// whose name collides with one basis already registered — `spawn`, a
/// mentra builtin, or an earlier host tool on the same builder (decision
/// D5d).
///
/// mentra's registry is a map and its plain `with_tool` *replaces*, so
/// without this a host tool named `spawn` would silently take over the
/// name and inherit every rule an operator ever wrote about commands and
/// delegation. Raised by `build`, after basis's own registrations exist to
/// collide against, rather than a silent swap.
#[error("a host tool could not be registered: {0}")]
HostTool(#[from] mentra::tool::ToolNameCollision),
}
impl RunError {
/// Whether this failure means another holder already has the conversation open.
pub fn is_open_elsewhere(&self) -> bool {
matches!(
self,
Self::Runtime(mentra::error::RuntimeError::LeaseUnavailable(_))
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn open_elsewhere_identifies_only_a_lease_conflict() {
let conflict = RunError::Runtime(mentra::error::RuntimeError::LeaseUnavailable(
"already leased".to_string(),
));
assert!(conflict.is_open_elsewhere());
assert!(!RunError::EmptyPrompt.is_open_elsewhere());
}
}