basis 0.8.1

The basis SDK: workspace discovery, run lifecycle, one event stream, and the two seams. No protocol, no transport, no TTY.
Documentation
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
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
//! One prompt against one workspace: the smallest complete thing basis does.
//!
//! This is the P1 acceptance surface from `docs/ARCHITECTURE.md` §6 —
//! arbitrary prompts on arbitrary repos, in-process and as a subprocess. The
//! binary is a thin shell over [`run`]; a Rust host calls it directly.
//!
//! Nothing here knows what the prompt is for. The mission arrives as data: the
//! prompt itself, the workspace's own context files, and configuration.
//!
//! [`run`] answers the whole question — build a runtime, resolve a model, send
//! the prompt — for the case where one prompt is the whole job. Everything in
//! this module is a wrapper around [`Workspace`]: it opens
//! one, mints a single run from it, and drops it when the run ends. A host
//! sending more than one prompt at a repository should open the workspace
//! itself and keep it, which is what the split of ADR-0010 is for.
//!
//! A host that already owns a mentra runtime skips to [`prepare_with_session`]
//! and keeps its own.

mod bounds;
mod output;
mod prepared;
mod sink;
mod turn;
mod usage;

use std::{
    path::{Path, PathBuf},
    sync::Arc,
};

use mentra::Session;

use crate::{
    approval::{AllowAll, Approver},
    context::{ContextConfig, WorkspaceContext},
    event::RunOutcome,
    templates::TemplatesConfig,
    workspace::{
        DEFAULT_SESSION_NAME, RunSpec, Workspace, WorkspaceBuilder, load_templates,
        resolved_workspace,
    },
};

pub use bounds::Bounds;
pub use output::{OutputReport, OutputSpec};
pub use prepared::{
    AgentEventTapGuard, Compacted, LoadedSkill, PreparedRun, PromptPart, RunContext,
};
pub use sink::{
    CollectingSink, EventFanIn, EventSink, FnSink, MergedEvents, NullSink, TaggedEvent, TaggedSink,
};
pub use turn::TurnOptions;
pub use usage::RunUsage;

/// Recoverability classification for a retained [`RunFailure`].
pub use mentra::error::ErrorCategory as RunFailureCategory;
/// Mentra's original typed terminal failure retained by [`RunReport`].
pub use mentra::error::RuntimeError as RunFailure;

/// The signal a caller trips to stop a turn.
///
/// Re-exported rather than restated, and the reason is the one thing basis cannot
/// wrap: a token is an *identity*, not a value. The turn holds one half and the
/// caller the other, and a basis-owned copy would have to forward the trip to
/// mentra's — a second object that can disagree with the first about whether
/// the stop button was pressed. So this is a deliberate leak, like
/// [`ModelSelector`](mentra::ModelSelector) and
/// [`BuiltinProvider`](mentra::BuiltinProvider) on
/// [`RuntimeBuilder`](crate::RuntimeBuilder).
///
/// Re-exporting it is what makes the leak cheap. A host embedding `basis`
/// should not have to add mentra to its own manifest — and pin the same
/// version — to name a type basis's own API asks it for; a skew there fails to
/// compile with no hint that two crates disagree about one struct. Hence the
/// rule: every mentra type basis's surface makes a caller *name*, basis re-exports.
///
/// Two of them go on a turn and they mean different things —
/// [`TurnOptions::cancel`] abandons it, [`TurnOptions::stop`] ends it
/// gracefully.
pub use mentra::runtime::CancellationToken;

/// How much provider-generated reasoning summary to request.
///
/// Re-exported because [`ReasoningOptions::summary`] makes a caller name it;
/// like the other complete reasoning types below, a Basis caller should not
/// need a separately version-pinned Mentra dependency to construct it.
pub use mentra::provider_core::ReasoningSummary;

/// The per-turn steering seam, whole: the trait a host implements and every
/// type its signatures make that host *name*.
///
/// Re-exported rather than wrapped, under the rule stated on
/// [`CancellationToken`] — a mentra type basis's surface asks a caller to
/// write is a name basis re-exports, or the caller pins mentra in their own
/// manifest to spell it. And like the token, a strategy is an *identity*
/// rather than a value: [`TurnOptions::with_round_strategy`] hands mentra the
/// caller's own `Arc`, so a basis-owned mirror of [`RoundDecision`] would be a
/// translation layer with opinions of its own — the opposite of a seam.
///
/// The set is exactly what implementing costs. [`RoundStrategy`] is the trait
/// (spelled with [`async_trait`](crate::async_trait), which basis already
/// re-exports); [`RoundContext`] and [`RoundBoundary`] are its question;
/// [`RoundDecision`], [`RoundAdjustment`], [`ReasoningChange`] and
/// [`RoundToolResult`] are its vocabulary for answering. The last three are
/// here because the vocabulary's own signatures demand them:
/// [`RoundDecision::inject`] takes [`ContentBlock`]s,
/// [`RoundAdjustment::with_model`] a [`ModelInfo`], and
/// [`ReasoningChange::Set`] a [`ReasoningOptions`] — whose `effort` field an
/// [`Effort`] converts into, so switching effort mid-run never names mentra's
/// own level enum.
pub use mentra::{
    ContentBlock, ModelInfo, ReasoningChange, ReasoningOptions, RoundAdjustment, RoundBoundary,
    RoundContext, RoundDecision, RoundStrategy, RoundToolResult,
};

/// Mentra's complete provider-neutral agent event, forwarded unchanged by
/// [`PreparedRun::register_agent_event_tap`].
///
/// Re-exported under the same rule as [`CancellationToken`]: this public
/// callback asks a Basis host to name the type, so the host should not need a
/// separately version-pinned Mentra dependency just to spell its signature.
pub use mentra::agent::AgentEvent;

/// How hard the model should think before answering.
///
/// basis's own enum rather than a re-export, for the reason [`Event`](crate::Event) and
/// [`TurnOptions`] are: the surface basis promises should not move when mentra's
/// does. Provider adapters translate this semantic level to their own wire
/// format. A provider or model that does not offer the requested level returns
/// an error rather than silently lowering it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum Effort {
    Low,
    Medium,
    High,
    XHigh,
    Max,
}

impl From<Effort> for mentra::provider::ReasoningEffort {
    fn from(effort: Effort) -> Self {
        match effort {
            Effort::Low => Self::Low,
            Effort::Medium => Self::Medium,
            Effort::High => Self::High,
            Effort::XHigh => Self::XHigh,
            Effort::Max => Self::Max,
        }
    }
}

/// The level a session is set to, read back as basis names it.
///
/// The direction [`PreparedRun::effort`](crate::PreparedRun::effort) needs, and
/// it is a `TryFrom` because mentra's enum is `#[non_exhaustive]` too: a level
/// added upstream that basis has no name for is not a level basis can report,
/// and answering `Low` — or `None`, which means *no level requested* — would
/// both be claims about a session that are simply untrue. The error carries the
/// mentra value so a caller that wants to render something can.
impl TryFrom<mentra::provider::ReasoningEffort> for Effort {
    type Error = mentra::provider::ReasoningEffort;

    fn try_from(effort: mentra::provider::ReasoningEffort) -> Result<Self, Self::Error> {
        use mentra::provider::ReasoningEffort as Upstream;

        match effort {
            Upstream::Low => Ok(Self::Low),
            Upstream::Medium => Ok(Self::Medium),
            Upstream::High => Ok(Self::High),
            Upstream::XHigh => Ok(Self::XHigh),
            Upstream::Max => Ok(Self::Max),
            unknown => Err(unknown),
        }
    }
}

/// A bound that ended a run before its work did.
///
/// Separate from [`RunOutcome`] because the two answer different questions.
/// "The model ran out of the time you gave it" and "the provider refused the
/// request" call for different reactions, and a caller — the CLI's exit code,
/// or a script driving many runs — should not have to read an error message to
/// tell them apart (ADR-0015). The outcome says whether an answer arrived; this
/// says whether an allowance is what ended the run, and the two are
/// independent. [`Deadline`](Self::Deadline) and [`ToolBudget`](Self::ToolBudget)
/// always arrive alongside [`RunOutcome::Error`], because no final message
/// does; [`TokenBudget`](Self::TokenBudget) can arrive on a run that answered.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Bound {
    /// [`RunSpec::with_deadline`] — the run took longer than it was given.
    Deadline,
    /// [`RunSpec::with_tool_budget`] — the run made all the calls it had.
    ToolBudget,
    /// [`RunSpec::with_token_budget`], or a [`BudgetPool`](crate::BudgetPool)
    /// the run drew dry — it spent its allowance and was refused another round.
    ///
    /// The odd one out, and worth knowing why before branching on it. This
    /// bound is *graceful*: mentra ends the run at a round boundary keeping
    /// everything committed so far, exactly as if the model had finished. So a
    /// run can report [`RunOutcome::Ok`] with an ordinary answer *and* this
    /// bound, which is the honest description of "you got an answer, and the
    /// allowance is why there is not more of one". Whether an answer arrives
    /// comes down to what the last committed message was: prose, and the turn
    /// succeeds; a tool result, and it fails owing a final message it never
    /// got.
    ///
    /// Reportable at all only because mentra records the decision at the
    /// boundary it makes it
    /// ([`RunOptions::ended_early`](mentra::runtime::RunOptions::ended_early)).
    /// Comparing usage against the budget afterwards would answer a different
    /// question — what is true now, rather than what the runner decided on —
    /// and a pooled run can cross the line without being the run that was
    /// stopped by it.
    TokenBudget,
}

/// What a completed run produced, alongside the sink it wrote to.
#[derive(Debug)]
pub struct RunReport<S> {
    pub session_id: String,
    pub model: String,
    pub provider: String,
    /// The assistant's final message, absent when the run failed — and absent
    /// on a typed turn, where the answer is
    /// [`OutputReport::value`] rather than prose.
    pub final_message: Option<String>,
    pub outcome: RunOutcome,
    /// Original typed runtime failure, before [`RunOutcome`] display/wire
    /// projection. `None` on success and on Basis-owned output-shape mismatch.
    pub failure: Option<RunFailure>,
    /// Which bound ended the run, when one did rather than the work.
    ///
    /// Neither field implies the other: a bounded run usually failed for want
    /// of a final message, but see [`Bound::TokenBudget`], which a run that
    /// answered can carry.
    pub stopped_by: Option<Bound>,
    /// What the run reported spending. Present whether it succeeded or not: a
    /// turn that failed on its fourth round still spent the first three.
    pub usage: RunUsage,
    pub sink: S,
}

impl<S> RunReport<S> {
    pub fn succeeded(&self) -> bool {
        matches!(self.outcome, RunOutcome::Ok)
    }
}

pub use crate::error::RunError;

/// Runs one prompt to completion, streaming events into `sink`.
///
/// Consequential calls are approved by [`AllowAll`], which is
/// what a headless run needs: there is nobody to ask, and a question nothing
/// answers is a hang. It asserts nothing about the run being confined — with
/// commands on by default (ADR-0013) an unattended run carries its user's full
/// authority, so an *attended* one is usually better served by
/// [`run_with_approver`], and anything that needs a real boundary gets it from
/// the OS.
///
/// A setup failure — no credential, unreachable model, unreadable workspace —
/// is an `Err`. A failure *during* the turn is reported as
/// [`RunOutcome::Error`] on an otherwise complete stream, because by then the
/// events already emitted are worth keeping.
///
/// The session is dropped when this returns, and so is the workspace opened to
/// hold it. For a conversation, open the [`Workspace`] yourself and keep the
/// run it mints — [`send`](PreparedRun::send) is what makes it one; for many
/// conversations, keep the workspace. Anything beyond a path and a prompt —
/// a model, an endpoint, a bound — is the same shape one call earlier:
/// `Workspace::builder(path)` and a [`RunSpec`].
pub async fn run<S: EventSink>(
    workspace: impl Into<PathBuf>,
    prompt: impl Into<String>,
    sink: S,
) -> Result<RunReport<S>, RunError> {
    run_with_approver(workspace, prompt, sink, AllowAll).await
}

/// Runs one prompt, putting every consequential call to `approver`.
///
/// The approver is the whole of basis's approval story (ADR-0010):
/// [`DenyAll`](crate::approval::DenyAll) for a run that may change nothing,
/// the binary's terminal prompter for a person at a TTY, or a host's own — one
/// that allows edits and denies the network, or asks a team over Slack. Note
/// the contract it inherits: an approver that cannot answer must deny.
pub async fn run_with_approver<S: EventSink, A: Approver>(
    workspace: impl Into<PathBuf>,
    prompt: impl Into<String>,
    sink: S,
    approver: A,
) -> Result<RunReport<S>, RunError> {
    let prompt = prompt.into();
    if prompt.trim().is_empty() {
        return Err(RunError::EmptyPrompt);
    }

    let mut run = mint_carrying_workspace(Workspace::builder(workspace.into()), |workspace| {
        workspace.prepare(prompt)
    })
    .await?;

    run.execute_with_approver(sink, approver).await
}

/// Opens the builder and mints one run that carries the workspace.
///
/// The one resolution path for every free function above, and the load-bearing
/// half is the carry: these functions hand back a [`PreparedRun`] and nothing
/// else, so the run must be what keeps the workspace alive until the run ends
/// — the module's own promise. A workspace dropped when this returns would
/// take its hook registration and MCP connections with it *before the first
/// turn is driven*: the dispatcher fails open for a directory no live
/// workspace claims, so every `.basis/hooks.json` hook would be silently
/// bypassed, and the minted roster would offer `mcp__*` tools whose servers
/// were already torn down. See [`PreparedRun::with_workspace`].
async fn mint_carrying_workspace(
    builder: WorkspaceBuilder,
    mint: impl FnOnce(&Workspace) -> Result<PreparedRun, RunError>,
) -> Result<PreparedRun, RunError> {
    let workspace = Arc::new(builder.open().await?);
    let prepared = mint(&workspace)?;

    Ok(prepared.with_workspace(workspace))
}

/// Prepares a run against a session the caller already built, so a host with
/// its own runtime — custom tools, its own store, a provider basis does not
/// know — still gets basis's context discovery and event stream.
///
/// The inputs are exactly what this path can honor, and every field of `spec`
/// is honored. The prompt may be empty, because once a session outlives a
/// turn a conversation with nothing said yet is a real state — it is what
/// ACP's `session/new` opens — so the check belongs where a prompt is
/// actually sent, which is [`PreparedRun::execute`] and [`PreparedRun::send`].
/// The bounds become the run's configured limits; an `effort` is applied to
/// the session in hand; a `session_name` renames it — unless it is the
/// default, which is left alone, because the caller named the session at mint
/// and asking for the default and saying nothing are the same request.
/// `context` says where discovery looks; templates are discovered with the
/// default configuration, there being no caller that has ever wanted
/// otherwise on this path.
///
/// This is the one path that does not go through
/// [`Workspace`], because there is no runtime for basis to
/// build: the caller brought one. It still discovers what it can without
/// touching that runtime — skills and MCP registration stay the caller's, who
/// owns the runtime they would register on.
///
/// **`#[doc(hidden)]`, not private.** This existed because `Workspace` could
/// not yet be handed a runtime, a provider, a tool roster, or a round
/// strategy — every knob a bring-your-own-runtime caller actually wanted was
/// missing from the assembler, so bypassing it was the only way to get one.
/// The assembler is open now: [`WorkspaceBuilder::with_runtime`] and
/// [`with_runtime_builder`](WorkspaceBuilder::with_runtime_builder) accept a
/// caller's own [`Runtime`](crate::Runtime) or recipe (ADR-0018, including a
/// [`RuntimeBuilder::with_provider_instance`](crate::RuntimeBuilder::with_provider_instance)
/// the host constructed itself), [`WorkspaceBuilder::with_tool_roster`] states
/// the model's roster (D3), and [`TurnOptions::with_round_strategy`] reaches a
/// live turn. Every caller with a real host to write against should reach for
/// those instead — the discovery this function does by hand
/// (`WorkspaceContext::discover_with`, default-only templates, no skills, no
/// MCP) is exactly what `WorkspaceBuilder::open` already does more completely,
/// once a workspace exists to open. What remains behind this name is the test
/// harness this crate's own suite drives sessions through — `basis-cli`'s
/// bridge tests among them — where a scripted [`Session`] needs basis's event
/// stream and context discovery without a real runtime underneath it, and the
/// signature this wave found already fits every caller that still needs it.
#[doc(hidden)]
pub fn prepare_with_session(
    session: Session,
    workspace: &Path,
    spec: impl Into<RunSpec>,
    context: &ContextConfig,
    provider: impl Into<String>,
    model: impl Into<String>,
) -> Result<PreparedRun, RunError> {
    let spec = spec.into();
    let mut session = session;
    if spec.session_name != DEFAULT_SESSION_NAME {
        session.set_name(spec.session_name.clone())?;
    }

    let discovered = WorkspaceContext::discover_with(workspace, context)?;
    // Unlike skills, templates are registered on nothing — so basis can discover
    // them here without touching a runtime it does not own.
    let (templates_dirs, templates) = load_templates(workspace, &TemplatesConfig::default())?;

    let mut prepared = PreparedRun::new(
        session,
        RunContext {
            workspace: resolved_workspace(workspace, &discovered),
            prompt: spec.prompt.clone(),
            provider: provider.into(),
            model: model.into(),
            context: discovered,
            // The caller owns the runtime, so it owns skill and MCP
            // registration too.
            skills_dirs: Vec::new(),
            skills: Vec::new(),
            templates_dirs,
            templates,
            mcp_files: Vec::new(),
            mcp_servers: Vec::new(),
        },
    )
    .with_bounds(spec.turn_options());

    if let Some(effort) = spec.effort {
        prepared.set_effort(Some(effort))?;
    }

    Ok(prepared)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::context::ContextError;

    #[test]
    fn every_lan_effort_maps_to_the_same_provider_level() {
        use mentra::provider::ReasoningEffort;

        for (effort, expected) in [
            (Effort::Low, ReasoningEffort::Low),
            (Effort::Medium, ReasoningEffort::Medium),
            (Effort::High, ReasoningEffort::High),
            (Effort::XHigh, ReasoningEffort::XHigh),
            (Effort::Max, ReasoningEffort::Max),
        ] {
            assert_eq!(ReasoningEffort::from(effort), expected);
        }
    }

    #[tokio::test]
    async fn an_empty_prompt_is_rejected_before_any_provider_work() {
        let error = run("/definitely/not/a/real/path", "   \n ", NullSink)
            .await
            .expect_err("rejected");

        // Reaching provider resolution or workspace validation would prove the
        // check ran too late.
        assert!(matches!(error, RunError::EmptyPrompt));
    }

    #[tokio::test]
    async fn a_missing_workspace_fails_before_a_provider_is_needed() {
        let error = run("/definitely/not/a/real/path", "hello", NullSink)
            .await
            .expect_err("rejected");

        assert!(matches!(
            error,
            RunError::Context(ContextError::WorkspaceMissing { .. })
        ));
    }
}