basis 0.4.2

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
//! A workspace opened once, minting runs cheaply.
//!
//! Everything a run needs but does not change divides in two, and until now basis
//! kept both halves in one [`RunConfig`](crate::RunConfig) and re-resolved the
//! lot for every prompt. ADR-0010 named the cost: a twenty-agent fan-out read
//! `AGENTS.md` twenty times, resolved the model twenty times, and opened twenty
//! copies of every MCP server the workspace configures.
//!
//! So:
//!
//! - **[`WorkspaceBuilder::open`]** settles what belongs to the workspace —
//!   context documents, the resolved model, skills, templates, hooks, MCP
//!   connections, the command posture, the approval gate.
//!   It is `async` and it does real I/O, once.
//! - **[`Workspace::prepare`]** mints one run from a [`RunSpec`]. It is *not*
//!   `async`, which is the honest signal that nothing is discovered, resolved,
//!   or connected here: a session is spawned on the runtime that already
//!   exists, and that is all.
//!
//! What belongs to the *process* rather than to either — the provider and
//! credential, the history store, the host's interceptors — is a third thing,
//! [`Runtime`] (ADR-0018). A workspace borrows one through an
//! `Arc`, and a host opening many workspaces builds it once.
//!
//! ```no_run
//! # async fn example() -> Result<(), basis::RunError> {
//! use basis::{CollectingSink, Workspace};
//!
//! let workspace = Workspace::open("/repo").await?;
//!
//! // Two runs, one discovery, driven together.
//! let mut first = workspace.prepare("what does this repo do?")?;
//! let mut second = workspace.prepare("what is not tested?")?;
//! let (a, b) = tokio::join!(
//!     first.execute(CollectingSink::default()),
//!     second.execute(CollectingSink::default()),
//! );
//! # let _ = (a?, b?);
//! # Ok(())
//! # }
//! ```
//!
//! The free functions in [`crate::run`](mod@crate::run) — `run`, `prepare`,
//! `resume` and the rest — are thin wrappers that open a workspace, mint one
//! run from it, and drop the workspace when the run ends. There is one
//! resolution path, and this is it.

mod builder;
mod spec;

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

use mentra::{ModelInfo, Session, agent::AgentConfig, provider::ReasoningOptions};

pub use builder::WorkspaceBuilder;
pub use spec::RunSpec;

pub(crate) use builder::{load_templates, resolved_workspace};
pub(crate) use spec::DEFAULT_SESSION_NAME;

#[cfg(feature = "mcp")]
use crate::mcp::connections::McpConnections;
use crate::{
    context::WorkspaceContext,
    event::ContextFile,
    fingerprint::{self, Snapshot},
    run::{Effort, LoadedSkill, PreparedRun, RunContext, RunError},
    runtime::{Runtime, dispatch::HookRegistration},
    templates::Template,
    tools::declared::DeclaredTools,
};

/// One workspace, resolved: the runtime it borrows, the model, and everything
/// discovered on disk, ready to mint runs from.
///
/// Held by reference by every run it mints, so a host keeps one per repository
/// for as long as it wants to send prompts at it. Dropping it does not end the
/// runs already minted — a [`PreparedRun`] owns its session — but the MCP
/// connections go with it, and so does the runtime when this held the last
/// `Arc`.
///
/// `Send` and `Sync`: the runtime is shared through `Arc`s and creates
/// sessions from `&self`, so concurrent minting from one workspace needs no
/// lock of basis's own.
pub struct Workspace {
    /// The path the caller asked for. What the agent is scoped to, and what
    /// policy roots are built from.
    path: PathBuf,
    /// The same directory as discovery resolved it, symlinks followed. What
    /// the run header reports, so `workspace` and `context_files` name one
    /// place.
    root: PathBuf,
    runtime: Arc<Runtime>,
    model: ModelInfo,
    provider: String,
    /// [`store::runtime_identifier`](crate::store::runtime_identifier) for
    /// `path`, computed once: what this workspace's conversations are (or, on
    /// a shared runtime, should be — see [`WorkspaceBuilder::open`]) tagged
    /// with.
    identifier: String,
    context: WorkspaceContext,
    /// Built once from the context, cloned per run: none of its inputs vary.
    agent: AgentConfig,
    skills_dirs: Vec<PathBuf>,
    skills: Vec<LoadedSkill>,
    templates_dirs: Vec<PathBuf>,
    templates: Vec<Template>,
    mcp_files: Vec<ContextFile>,
    mcp_servers: Vec<String>,
    declared_tool_files: Vec<ContextFile>,
    declared_tools: Vec<String>,
    /// Keeps this workspace's declared tools claimed on the runtime's single
    /// registry; releases the claims on drop.
    declared_registration: DeclaredTools,
    /// Keeps this workspace's hooks and guards registered on the runtime's
    /// dispatcher; deregisters on drop.
    #[allow(dead_code, reason = "held for its Drop")]
    hook_registration: HookRegistration,
    #[cfg(feature = "mcp")]
    #[allow(dead_code, reason = "held for its Drop")]
    mcp_connections: McpConnections,
}

/// Hand-written because neither the runtime nor the registration is `Debug`
/// material, and because the context documents hold whole files — a derived
/// impl would dump them.
impl std::fmt::Debug for Workspace {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Workspace")
            .field("root", &self.root)
            .field("provider", &self.provider)
            .field("model", &self.model.id)
            .field("context_files", &self.context.documents().len())
            .field("skills", &self.skills.len())
            .field("templates", &self.templates.len())
            .field("mcp_servers", &self.mcp_servers)
            .field("declared_tools", &self.declared_tools)
            .finish_non_exhaustive()
    }
}

impl Workspace {
    /// Opens `path` with basis's defaults: a private runtime with the provider
    /// auto-detected from the environment, the newest model it offers, and
    /// every convention discovered where convention says to look.
    ///
    /// [`builder`](Self::builder) is the same call with the knobs exposed —
    /// including [`with_runtime`](WorkspaceBuilder::with_runtime), for the
    /// host that opens many workspaces on one [`Runtime`].
    pub async fn open(path: impl Into<PathBuf>) -> Result<Self, RunError> {
        Self::builder(path).open().await
    }

    /// Configures a workspace before opening it.
    pub fn builder(path: impl Into<PathBuf>) -> WorkspaceBuilder {
        WorkspaceBuilder::new(path)
    }

    /// Mints a run: a fresh conversation against this workspace.
    ///
    /// Synchronous, and deliberately so — everything expensive already
    /// happened. What this does is spawn a session on the existing runtime and
    /// hand back the [`PreparedRun`] that drives it.
    ///
    /// The spec's prompt may be empty. 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 emptiness check belongs where a prompt is
    /// actually sent, which is [`PreparedRun::execute`] and
    /// [`PreparedRun::send`]. (The free [`prepare`](crate::run::prepare) keeps
    /// its own up-front check, because a one-shot caller that passed nothing
    /// wants to hear about it before a session exists.)
    pub fn prepare(&self, spec: impl Into<RunSpec>) -> Result<PreparedRun, RunError> {
        let spec = spec.into();
        let mut session = self.runtime.mint(
            spec.session_name.clone(),
            self.model.clone(),
            self.minted_agent(),
            &self.identifier,
        )?;
        apply_effort(&mut session, spec.effort)?;

        Ok(self.minted(session, spec))
    }

    /// Picks up a conversation a previous process left behind.
    ///
    /// `agent_id` is [`PreparedRun::agent_id`], not the session id: mentra
    /// persists agents, and a session is one process's view of one. Resuming
    /// replays the transcript from the store, so the first turn after this
    /// already knows everything the last one did.
    ///
    /// The workspace has to be the one the conversation belongs to. Nothing
    /// here checks that — mentra's store is keyed by agent, not by path — so
    /// resuming an agent under a workspace it never ran in gives it that
    /// workspace's context and tools alongside its own history.
    pub fn resume(
        &self,
        agent_id: &str,
        spec: impl Into<RunSpec>,
    ) -> Result<PreparedRun, RunError> {
        let spec = spec.into();
        let mut session = self.runtime.resume_minted(agent_id)?;
        apply_effort(&mut session, spec.effort)?;

        Ok(self.minted(session, spec))
    }

    /// A cheap stand-in for everything in this workspace a run could see.
    ///
    /// The utility ADR-0014 kept when `watch` was deleted, on the type its
    /// ledger row promised it to. The semantics are [`crate::fingerprint`]'s
    /// verbatim: a digest over `git ls-files` plus `HEAD`, `stat` only, and
    /// every uncertain answer resolving to *changed* rather than unchanged.
    ///
    /// Fingerprints the workspace **as it is now**, not as it was when the
    /// workspace was opened — that is the whole point, since a caller's loop
    /// asks it repeatedly against one long-lived workspace.
    ///
    /// Blocking: it spawns `git` and stats files. An async caller belongs on a
    /// blocking thread — `tokio::task::spawn_blocking`, or the equivalent.
    pub fn fingerprint(&self) -> Snapshot {
        fingerprint::snapshot(&self.root)
    }

    /// The path this workspace was opened with, which is what its runs are
    /// scoped to.
    pub fn path(&self) -> &Path {
        &self.path
    }

    /// The same directory as discovery resolved it, symlinks followed. What the
    /// run header reports, and what [`fingerprint`](Self::fingerprint) reads.
    pub fn root(&self) -> &Path {
        &self.root
    }

    /// The model every run from this workspace uses, resolved once.
    pub fn model(&self) -> &str {
        &self.model.id
    }

    pub fn provider(&self) -> &str {
        &self.provider
    }

    /// The context documents discovered at open, weakest precedence first.
    pub fn context(&self) -> &WorkspaceContext {
        &self.context
    }

    /// The skills this workspace registered on the runtime, after layering.
    ///
    /// Only what *this* workspace registered. The registry itself is the
    /// runtime's and additive, so on a shared runtime a run may also be able
    /// to `load_skill` what a sibling workspace registered — an accepted
    /// consequence of sharing (see [`WorkspaceBuilder::open`]).
    pub fn skills(&self) -> &[LoadedSkill] {
        &self.skills
    }

    /// The prompt templates this workspace defines, after layering,
    /// name-ordered. Over ACP these become the client's commands.
    pub fn templates(&self) -> &[Template] {
        &self.templates
    }

    /// The MCP servers connected at open, by the names that took effect —
    /// which is the configured name unless another workspace on the shared
    /// runtime already held it, in which case it carries a deterministic
    /// suffix. Names only: nothing here echoes a command or a credential.
    pub fn mcp_servers(&self) -> &[String] {
        &self.mcp_servers
    }

    /// The tools this workspace's manifests declared, by name, after layering
    /// — this workspace's own first, name-ordered within each manifest.
    ///
    /// Names only, for [`mcp_servers`](Self::mcp_servers)'s reason: nothing
    /// here echoes a command or a credential.
    pub fn declared_tools(&self) -> &[String] {
        &self.declared_tools
    }

    /// The tool manifests that took effect, most specific first.
    ///
    /// A file that says which programs the model may run is the last thing that
    /// should apply invisibly, which is why discovery reports its sources the
    /// way `.mcp.json`'s does.
    pub fn declared_tool_files(&self) -> &[ContextFile] {
        &self.declared_tool_files
    }

    /// The mentra runtime the runs are minted on, for a host that wants
    /// mentra's own surface — the task board, teams, the store — alongside
    /// basis's.
    ///
    /// The same bargain as [`PreparedRun::session`]: basis does not hide mentra,
    /// and reaching past basis's surface is a supported thing to do rather than
    /// a workaround. Renamed from `runtime()` when ADR-0018 gave basis a
    /// `Runtime` of its own, so the name says whose surface comes back.
    pub fn mentra_runtime(&self) -> &mentra::Runtime {
        self.runtime.mentra_runtime()
    }

    /// The agent config this mint offers the model: the one built at open, with
    /// every tool on the shared registry that belongs to another workspace
    /// hidden — bridged `mcp__*` tools, and tools a sibling's
    /// `.basis/tools.json` declared.
    ///
    /// Per mint rather than per open, because the shared registry moves as
    /// sibling workspaces come and go, and a roster is honest only about the
    /// registry it was minted against. Hidden, not unregistered — the registry
    /// is single and has no unregister — which also keeps a dropped sibling's
    /// stale entries inert.
    fn minted_agent(&self) -> AgentConfig {
        let mut agent = self.agent.clone();

        for name in self
            .runtime
            .foreign_declared_tools(self.declared_registration.root())
        {
            agent.tool_profile.hidden_tools.insert(name);
        }

        #[cfg(feature = "mcp")]
        for descriptor in self.runtime.mentra_runtime().tools() {
            let name = &descriptor.provider.name;
            if let Some((server, _)) = mentra::mcp::parse_mcp_tool_name(name)
                && !self.mcp_servers.iter().any(|own| own == server)
            {
                agent.tool_profile.hidden_tools.insert(name.clone());
            }
        }

        agent
    }

    /// Wraps a freshly created or resumed session in the run context this
    /// workspace describes.
    ///
    /// Shared by [`prepare`](Self::prepare) and [`resume`](Self::resume) so the
    /// two cannot disagree about what a run from this workspace reports.
    fn minted(&self, session: Session, spec: RunSpec) -> PreparedRun {
        let bounds = spec.turn_options();

        PreparedRun::new(
            session,
            RunContext {
                workspace: self.root.clone(),
                prompt: spec.prompt,
                provider: self.provider.clone(),
                model: self.model.id.clone(),
                context: self.context.clone(),
                skills_dirs: self.skills_dirs.clone(),
                skills: self.skills.clone(),
                templates_dirs: self.templates_dirs.clone(),
                templates: self.templates.clone(),
                mcp_files: self.mcp_files.clone(),
                mcp_servers: self.mcp_servers.clone(),
            },
        )
        .with_bounds(bounds)
        // What lets an approver on this run judge a call by how far it reaches
        // rather than by its name. Attached here, at the one place both
        // `prepare` and `resume` go through, so the two cannot disagree about
        // whether a run's requests carry it.
        .with_side_effect_levels(self.runtime.side_effect_levels())
    }
}

/// Asks the model for a reasoning effort, when one was requested.
///
/// `None` leaves the session untouched instead of sending a default nobody
/// asked for. Mentra's provider adapter validates the requested level and maps
/// it to that API's wire format.
fn apply_effort(session: &mut Session, effort: Option<Effort>) -> Result<(), RunError> {
    let Some(effort) = effort else {
        return Ok(());
    };

    session.set_reasoning(Some(ReasoningOptions {
        effort: Some(effort.into()),
        summary: None,
    }))?;

    Ok(())
}

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

    /// Concurrent minting is the point of the split, and it holds only if a
    /// workspace can be shared across tasks and threads. Asserted at compile
    /// time so a future field that is neither cannot slip in unnoticed.
    #[test]
    fn a_workspace_can_be_shared_across_tasks() {
        const fn assert_send_sync<T: Send + Sync>() {}

        assert_send_sync::<Workspace>();
        assert_send_sync::<RunSpec>();
    }
}