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
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
//! A workspace opened once, minting runs cheaply.
//!
//! Everything a run needs but does not change divides in two, and basis once
//! kept both halves in one `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;
pub(crate) mod lifecycle;
mod profile;
mod roster;
mod spec;

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

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

pub use builder::WorkspaceBuilder;
pub use profile::RunProfile;
pub use roster::ToolRoster;
pub(crate) use spec::DEFAULT_SESSION_NAME;
pub use spec::RunSpec;

pub(crate) use builder::{load_templates, resolved_workspace};
use lifecycle::{MintPosture, ReuseLease, ReuseLifecycle};

#[cfg(feature = "mcp")]
use crate::mcp::connections::McpConnections;
use crate::{
    config::Config,
    context::WorkspaceContext,
    error::RunError,
    event::ContextFile,
    fingerprint::{self, Snapshot},
    hooks::HookRunner,
    memory::Memory,
    run::{Effort, LoadedSkill, PreparedRun, RunContext},
    runtime::{
        Runtime, RuntimeRecipe,
        dispatch::{self, HookRegistration},
    },
    shell::ShellAccess,
    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>,
    /// Present only for a generation opened from a reusable runtime recipe.
    reuse: Option<WorkspaceReuse>,
    /// Whether supported Basis APIs may mint more than one independent
    /// session from this workspace.
    mint_posture: MintPosture,
    model: ModelInfo,
    /// The reasoning effort a [`RunSpec`] gets when it asked for none, as
    /// `config.json` set it. `None` leaves the provider's own default, which
    /// is what every run had before there was a file to say otherwise.
    effort: Option<Effort>,
    /// What `config.json` said, kept so a host can report which file decided
    /// the model it is looking at.
    config: Config,
    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,
    /// The memories discovered at open, frontmatter only, name-ordered after
    /// shadowing. What the agent config's index block was rendered from.
    memories: Vec<Memory>,
    /// 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,
    /// The other half of the registry entry's `foreign_tools` cell, written by
    /// [`minted_agent`](Self::minted_agent) so `spawn` can read what this
    /// workspace's model is currently denied.
    foreign_tools: Arc<RwLock<BTreeSet<String>>>,
    #[cfg(feature = "mcp")]
    #[allow(dead_code, reason = "held for its Drop")]
    mcp_connections: McpConnections,
}

/// Everything a consumed discovery-off workspace needs to assemble its next
/// generation without consulting repository or home state again.
struct WorkspaceReuse {
    recipe: Box<RuntimeRecipe>,
    lifecycle: ReuseLifecycle,
    shell: ShellAccess,
}

impl WorkspaceReuse {
    fn new(recipe: Box<RuntimeRecipe>, shell: ShellAccess) -> Self {
        Self {
            recipe,
            lifecycle: ReuseLifecycle::unbound(),
            shell,
        }
    }
}

/// 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("fresh_only", &self.mint_posture.is_fresh_only())
            .field("config_files", &self.config.files)
            .field("context_files", &self.context.documents().len())
            .field("memories", &self.memories.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 [`run`](crate::run()) 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 reuse_lease = self.reuse_lease()?;
        self.mint_posture.claim()?;
        if let Some(model) = spec.profile.resolved_model() {
            validate_model_provider(model, &self.provider)?;
        }
        if spec.profile.has_extra_headers() && !self.runtime.has_ephemeral_history() {
            return Err(RunError::RunProfileHeadersRequireEphemeralHistory);
        }
        let model = spec
            .profile
            .resolved_model()
            .cloned()
            .unwrap_or_else(|| self.model.clone());
        let model_id = model.id.clone();
        let agent = self.minted_agent(&spec.profile);
        let context_snapshot = agent.system.clone();
        let mut session =
            self.runtime
                .mint(spec.session_name.clone(), model, agent, &self.identifier)?;
        if !spec.profile.decides_reasoning() {
            apply_effort(&mut session, spec.effort.or(self.effort))?;
        }

        Ok(self.minted(session, spec, model_id, context_snapshot, reuse_lease))
    }

    /// 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.
    ///
    /// mentra does not persist a model's context window
    /// (`Agent::from_loaded` always resumes at `None` — `set_model` is the
    /// only way back), so a resumed session starts with an unknown one. This
    /// reapplies this workspace's own resolved model exactly when the resumed
    /// conversation is still on it and this resume is not also changing
    /// reasoning — the same model [`prepare`](Self::prepare) would have minted
    /// — which restores mentra's own compaction threshold as well as
    /// [`PreparedRun::context_window`]. A reasoning-changing resume leaves the
    /// window unknown so it performs one persisted mutation rather than two. A conversation
    /// [`PreparedRun::set_model`] had already moved elsewhere keeps whatever
    /// that call left it at instead: basis has no window for a model it does
    /// not resolve, and forcing this one back would silently undo a choice
    /// the caller made.
    pub fn resume(
        &self,
        agent_id: &str,
        spec: impl Into<RunSpec>,
    ) -> Result<PreparedRun, RunError> {
        let spec = spec.into();
        let reuse_lease = self.reuse_lease()?;
        self.mint_posture.claim()?;
        if let Some(model) = spec.profile.resolved_model() {
            validate_model_provider(model, &self.provider)?;
        }
        if let Some(field) = spec.profile.unsupported_on_resume() {
            return Err(RunError::UnsupportedResumeProfile { field });
        }
        let legacy_effort = spec.effort.or(self.effort);
        let changes_reasoning = spec.profile.reasoning().is_some() || legacy_effort.is_some();
        if spec.profile.resolved_model().is_some() && changes_reasoning {
            return Err(RunError::NonAtomicResumeProfile);
        }

        let mut session = self.runtime.resume_minted(agent_id)?;
        let model = if let Some(model) = spec.profile.resolved_model() {
            session.set_model(model.clone())?;
            model.id.clone()
        } else if !changes_reasoning && session_on_resolved_model(&session, &self.model) {
            session.set_model(self.model.clone())?;
            self.model.id.clone()
        } else {
            session.metadata().model.clone()
        };

        if let Some(reasoning) = spec.profile.reasoning() {
            session.set_reasoning(reasoning.clone())?;
        } else {
            apply_effort(&mut session, legacy_effort)?;
        }

        // Mentra 0.22 exposes no resumed AgentConfig reader. The persisted
        // agent may carry a per-run system override that differs from this
        // workspace's current default, so substituting `self.agent.system`
        // would turn an unknown estimate into a confidently wrong one.
        Ok(self.minted(session, spec, model, None, reuse_lease))
    }

    /// 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 memories discovered at open, name-ordered, a workspace memory
    /// shadowing a global one of the same name.
    ///
    /// Frontmatter only: the index in the system prompt is what a memory
    /// costs by default, and the body stays on disk for the model — or a host
    /// showing its user what the agent remembers — to read on demand. See
    /// [`crate::memory`] for the convention.
    pub fn memories(&self) -> &[Memory] {
        &self.memories
    }

    /// 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
    }

    /// What `config.json` said about this workspace, and which file said it.
    ///
    /// The answers here are already *in force* — the model below is what they
    /// resolved to — so this is for the host that reports its own
    /// configuration, or that wants to hand the same value to a shared
    /// [`Runtime`]'s builder rather than read the files twice.
    pub fn config(&self) -> &Config {
        &self.config
    }

    /// The config files that took effect, most specific first.
    ///
    /// Reported the way `.mcp.json`'s and `.basis/tools.json`'s sources are,
    /// and for a milder version of their reason: what this file decides —
    /// which model, which provider — a run already names in its own header, so
    /// the question left is *which file said so*, and a repository that
    /// resolved a model nobody expected should be able to find out in one
    /// place.
    pub fn config_files(&self) -> &[ContextFile] {
        &self.config.files
    }

    /// 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
    }

    /// Binds this reusable generation's complete checkout-specific host-tool
    /// set before its one independent mint.
    ///
    /// Consuming makes every failure atomic to the caller: all names are
    /// validated against each other and the fresh runtime before the first
    /// registration; an unexpected late collision drops the entire generation
    /// rather than returning a partially bound workspace. An empty vector is
    /// meaningful and marks an explicitly tool-free checkout as bound.
    /// A bound tool must finish all work before its call returns; detached work
    /// owned only by the tool is outside the tracked reuse guarantee.
    pub fn bind_host_tools(
        self,
        tools: Vec<Box<dyn crate::tools::ExecutableTool>>,
    ) -> Result<Self, RunError> {
        let reuse = self.reuse.as_ref().ok_or(RunError::WorkspaceNotReusable)?;
        reuse.lifecycle.require_unbound()?;
        validate_reusable_host_tools(self.runtime.mentra_runtime_internal(), &tools)?;

        for tool in tools {
            self.runtime
                .mentra_runtime_internal()
                .try_register_tool(tool)?;
        }
        reuse.lifecycle.mark_bound()?;
        Ok(self)
    }

    /// Consumes a clean reusable generation and returns a newly built,
    /// prewarmed, unbound replacement.
    ///
    /// Rebuild first seals the generation. Any live run, observer guard,
    /// detached event forwarder, or prior raw Mentra escape refuses reuse and
    /// consumes this entry. Workspace registrations are dropped before Basis
    /// demands unique ownership of the old runtime; that runtime is dropped
    /// before the provider factory for the replacement is called. Drop alone
    /// never invokes this method or the recipe.
    ///
    /// This proves the lifecycle Basis owns: attached runs, observers, event
    /// forwarders, runtime registrations, and raw-access escapes. It does not
    /// claim to scrub Mentra team/background/spawn execution or host tools that
    /// detach their own work; strict reusable hosts exclude those names from
    /// their exact roster and await every custom-tool effect before returning.
    pub async fn rebuild_for_reuse(mut self) -> Result<Self, RunError> {
        let reuse = self.reuse.take().ok_or(RunError::WorkspaceNotReusable)?;
        reuse.lifecycle.seal_for_rebuild()?;

        #[cfg(feature = "mcp")]
        drop(self.mcp_connections);
        drop(self.declared_registration);
        drop(self.hook_registration);

        let old_runtime = match Arc::try_unwrap(self.runtime) {
            Ok(runtime) => runtime,
            Err(runtime) => {
                drop(runtime);
                return Err(RunError::ReusableRuntimeNotUnique);
            }
        };
        drop(old_runtime);

        let WorkspaceReuse {
            recipe,
            shell,
            lifecycle: _,
        } = reuse;

        let runtime = Arc::new(recipe.build_for(&self.path, shell, &[]).await?);
        validate_model_provider(&self.model, runtime.provider())?;

        let declared_registration = DeclaredTools::register(Arc::clone(&runtime), &self.root, &[])?;
        let foreign_tools = Arc::new(RwLock::new(BTreeSet::new()));
        let runner = runtime.interceptors().iter().cloned().fold(
            HookRunner::new(&self.path, Vec::new()),
            |runner, interceptor| runner.with_interceptor(interceptor),
        );
        let hook_registration = runtime.register_workspace(dispatch::WorkspaceGuardEntry {
            runner: Arc::new(runner),
            shell,
            root: self.root.clone(),
            shared: false,
            foreign_tools: Arc::clone(&foreign_tools),
        });

        #[cfg(feature = "mcp")]
        let mcp_connections = McpConnections::empty(Arc::clone(&runtime), &self.root);

        let mut agent = self.agent;
        agent.compaction.transcript_dir = runtime.transcripts_dir().to_path_buf();
        let provider = runtime.provider().to_string();
        let reuse = Some(WorkspaceReuse::new(recipe, shell));

        Ok(Self {
            path: self.path,
            root: self.root,
            runtime,
            reuse,
            mint_posture: MintPosture::new(true),
            model: self.model,
            effort: self.effort,
            config: self.config,
            provider,
            identifier: self.identifier,
            context: self.context,
            memories: self.memories,
            agent,
            skills_dirs: self.skills_dirs,
            skills: self.skills,
            templates_dirs: self.templates_dirs,
            templates: self.templates,
            mcp_files: Vec::new(),
            mcp_servers: Vec::new(),
            declared_tool_files: Vec::new(),
            declared_tools: Vec::new(),
            declared_registration,
            hook_registration,
            foreign_tools,
            #[cfg(feature = "mcp")]
            mcp_connections,
        })
    }

    /// 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.
    ///
    /// On a reusable workspace, calling this method permanently prevents the
    /// current generation from being rebuilt. The returned runtime can mint or
    /// retain state through handles Basis cannot count, so raw access and safe
    /// reuse are deliberately mutually exclusive for that generation.
    pub fn mentra_runtime(&self) -> &mentra::Runtime {
        if let Some(reuse) = &self.reuse {
            reuse.lifecycle.poison();
        }
        self.runtime.mentra_runtime_internal()
    }

    /// 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 rather than unregistered because
    /// these tools belong to a sibling that is still open and still serving
    /// them; what a *dropped* sibling registered is gone from the registry
    /// altogether, taken off with the claim it was held under.
    ///
    /// The same set is published to this workspace's dispatcher entry on the
    /// way out, because one more consumer needs it and cannot ask mentra:
    /// `spawn`, when a [`ChildSpec`](crate::ChildSpec) roster override
    /// replaces the child's cloned `ToolProfile`, has to put these names back
    /// or hand a delegated child the sibling tools its own parent is denied.
    /// Written here rather than at open so both readers see one snapshot —
    /// the config below freezes it for this mint, and the cell carries the
    /// same names to whatever that mint delegates.
    fn minted_agent(&self, profile: &RunProfile) -> AgentConfig {
        // A run profile replaces workspace defaults first. Foreign tools are
        // then denied against the live shared registry, so an exact roster can
        // narrow what the workspace offered but can never grant a sibling's
        // capability.
        let mut agent = profile.apply_to(self.agent.clone());
        let mut foreign = BTreeSet::new();

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

        #[cfg(feature = "mcp")]
        for descriptor in self.runtime.mentra_runtime_internal().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());
                foreign.insert(name.clone());
            }
        }

        *self
            .foreign_tools
            .write()
            .expect("foreign tool set poisoned") = foreign;

        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,
        model: String,
        context_snapshot: Option<String>,
        reuse_lease: Option<ReuseLease>,
    ) -> PreparedRun {
        let bounds = spec.turn_options();

        PreparedRun::new(
            session,
            RunContext {
                workspace: self.root.clone(),
                prompt: spec.prompt,
                provider: self.provider.clone(),
                model,
                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)
        // The runtime's answer to how patiently a failing provider is waited
        // out, for the same reason and in the same place: it describes the
        // provider connection this workspace borrows (ADR-0018), and mentra
        // takes it per run, so the mint is where a runtime-scoped knob becomes
        // a per-run option.
        .with_provider_retry(self.runtime.provider_retry())
        .with_context_snapshot(context_snapshot)
        .with_reuse_lease(reuse_lease)
    }

    fn reuse_lease(&self) -> Result<Option<ReuseLease>, RunError> {
        self.reuse
            .as_ref()
            .map(|reuse| reuse.lifecycle.lease_run())
            .transpose()
    }
}

fn validate_reusable_host_tools(
    runtime: &mentra::Runtime,
    tools: &[Box<dyn crate::tools::ExecutableTool>],
) -> Result<(), RunError> {
    let mut names = runtime
        .tools()
        .into_iter()
        .map(|descriptor| descriptor.provider.name)
        .collect::<BTreeSet<_>>();

    for tool in tools {
        let name = tool.descriptor().provider.name;
        validate_reusable_host_tool_name(&name)?;
        if !names.insert(name.clone()) {
            return Err(RunError::HostTool(mentra::tool::ToolNameCollision { name }));
        }
    }
    Ok(())
}

fn validate_reusable_host_tool_name(name: &str) -> Result<(), RunError> {
    let reason = if name.is_empty() {
        Some("a name cannot be empty")
    } else if name.len() > 64 {
        Some("a name cannot exceed 64 bytes")
    } else if name.starts_with("mcp__") {
        Some("the `mcp__` prefix is reserved for MCP bridges")
    } else if !name
        .bytes()
        .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
    {
        Some("a name may contain only ASCII letters, digits, `_`, and `-`")
    } else {
        None
    };

    match reason {
        Some(reason) => Err(RunError::ReusableHostToolName {
            name: name.to_string(),
            reason,
        }),
        None => Ok(()),
    }
}

/// Ensures host-resolved model metadata names the provider this workspace's
/// runtime actually registered.
///
/// Kept in the synchronous prepare/resume path so the refusal precedes mint,
/// session lookup, provider requests, and tool activity.
fn validate_model_provider(model: &ModelInfo, runtime_provider: &str) -> Result<(), RunError> {
    if model.provider.as_str() == runtime_provider {
        return Ok(());
    }

    Err(RunError::ResolvedModelProviderMismatch {
        model: model.id.clone(),
        model_provider: model.provider.as_str().to_string(),
        runtime_provider: runtime_provider.to_string(),
    })
}

/// Whether `session`'s live model is still the one this workspace resolved.
///
/// True immediately after [`Runtime::mint`](crate::runtime::Runtime::mint) —
/// a fresh session is always created on `model` — and after [`Workspace::resume`]
/// reapplies it; false when a resumed conversation had
/// [`PreparedRun::set_model`] move it somewhere else, which nothing here may
/// overwrite with a guess.
fn session_on_resolved_model(session: &Session, model: &ModelInfo) -> bool {
    session.metadata().model == model.id
}

/// Asks the model for a reasoning effort, when one was requested.
///
/// The spec's own answer first, then whatever `config.json` set for this
/// workspace: a flag or a `RunSpec` describes this invocation and the file
/// describes the repository, so the more specific one holds — the same
/// ordering every other key in that file follows.
///
/// `None` on both 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>();
    }

    /// A session freshly created on `model` is trivially on it — the case
    /// every `prepare` mint is in, and what makes `resume`'s own check of the
    /// same function correct: nothing distinguishes "just minted" from
    /// "resumed and still on the same model" once the session exists.
    #[test]
    fn a_freshly_created_session_is_on_its_own_model() {
        let mock = mentra::test::MockRuntime::builder()
            .model("gpt-5", "openai")
            .build()
            .expect("mock runtime builds");
        let model = mock.model();
        let session = mock
            .runtime()
            .create_session("s", model.clone())
            .expect("session");

        assert!(session_on_resolved_model(&session, &model));
    }

    /// The case `resume` must not paper over: a conversation `PreparedRun::set_model`
    /// already moved elsewhere is not this workspace's window to guess at.
    #[test]
    fn a_session_on_a_different_model_does_not_match() {
        let mock = mentra::test::MockRuntime::builder()
            .model("gpt-5", "openai")
            .build()
            .expect("mock runtime builds");
        let session = mock
            .runtime()
            .create_session("s", mock.model())
            .expect("session");
        let workspace_model = ModelInfo::new("gpt-6", "openai");

        assert!(!session_on_resolved_model(&session, &workspace_model));
    }
}