basis 0.12.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
//! 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::{AllowAll, 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_with_approver(CollectingSink::default(), AllowAll),
//!     second.execute_with_approver(CollectingSink::default(), AllowAll),
//! );
//! # 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 lifecycle;
mod profile;
mod roster;
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 profile::RunProfile;
pub use roster::ToolRoster;
pub(crate) use spec::DEFAULT_SESSION_NAME;
pub use spec::RunSpec;

pub(crate) use builder::load_templates;
use lifecycle::MintPosture;

#[cfg(feature = "mcp")]
use crate::mcp::connections::McpConnections;
use crate::{
    config::Config,
    context::WorkspaceContext,
    error::RunError,
    event::ContextFile,
    fingerprint::{self, Snapshot},
    memory::Memory,
    run::{Effort, LoadedSkill, PreparedRun, RunContext},
    runtime::{HookChainHold, Runtime, SessionScope, agents::AgentTools},
    skills::SkillRoots,
    templates::Template,
    tools::{declared::DeclaredTools, host::WorkspaceHostTools},
};

/// 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.
///
/// # What a workspace's guards do and do not cover
///
/// Everything a repository says about itself — its `.basis/hooks.json` chain,
/// its `ShellAccess` posture, its `.git` carve-out, its memory roots — reaches
/// a run through the session this workspace mints: the policy is stated per
/// session, and the hook chain is registered for this workspace's own
/// [`ToolAudience`](mentra::tool::ToolAudience), which every session minted or
/// resumed here carries and every delegated child inherits.
///
/// **A session this workspace did not mint carries none of it, even in this
/// directory.** An agent a host creates for itself through
/// [`mentra_runtime`](Self::mentra_runtime), or drives through
/// [`run::prepare_with_session`](crate::run::prepare_with_session), has no tool
/// audience at all, and mentra never consults an audience-scoped registration
/// for such an agent — so a `.basis/hooks.json` deny hook guarding this
/// repository is *not* asked about that agent's calls, and its policy is the
/// runtime's rather than this workspace's, however its base directory is
/// spelled. basis routed on the call's working directory before mentra could
/// scope a registration, and that is what changed. The two things a host can
/// do about it: register a
/// [`RuntimeBuilder::with_interceptor`](crate::RuntimeBuilder::with_interceptor)
/// guard, which is global and therefore judges every session on the runtime;
/// or mint through [`prepare`](Self::prepare) and [`resume`](Self::resume),
/// which is what puts a run inside this workspace's guards in the first place.
pub struct Workspace {
    /// The directory this workspace is scoped to: absolute and canonical,
    /// resolved exactly once by [`WorkspaceBuilder::open`] and never derived
    /// again.
    ///
    /// One field rather than two — the requested spelling and the resolved
    /// one — because everything downstream has to agree: the agent's base
    /// directory, the runtime's policy roots, the hook runner's directory, the
    /// store identifier and the run header's `workspace` all take this value, and a
    /// second spelling kept beside it is only an opportunity for two of them
    /// to name different directories.
    root: PathBuf,
    runtime: Arc<Runtime>,
    /// The live scope every session minted here runs in: this workspace's
    /// identity, the policy carrying its `.git` carve-out, shell posture and
    /// memory roots, and the tool audience its own bridged and declared tools
    /// are registered under.
    ///
    /// Held rather than re-derived per mint because none of its inputs vary
    /// after an open, and restated on every mint *and* resume because mentra
    /// persists none of it — a resume that said nothing would inherit the
    /// runtime's policy, which on a shared runtime is nobody's, and resolve in
    /// no audience at all.
    scope: SessionScope,
    /// 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,
    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,
    /// The skills roots this open put on the runtime, held for as long as this
    /// workspace is: their paths are what a run reports, and the hold is what
    /// takes them back off a shared runtime on drop.
    skills_registration: SkillRoots,
    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 and registered for its own audience; releases both on drop.
    #[allow(dead_code, reason = "held for its Drop")]
    declared_registration: DeclaredTools,
    /// The native tools the host supplied for this workspace, by name, in the
    /// order it supplied them.
    host_tools: Vec<String>,
    /// Keeps those tools claimed on that same ledger and registered for that
    /// same audience; releases both on drop.
    #[allow(dead_code, reason = "held for its Drop")]
    host_tool_registration: WorkspaceHostTools,
    /// This workspace's share of the interception chain live for its tool
    /// audience. Dropping it is what stops a dropped workspace being consulted
    /// — and, when it was the last holder, what takes the chain off the
    /// runtime.
    ///
    /// One hold for both seams: mentra 0.26 takes a chain as
    /// `ExecutionHookParticipant`s and hands back one registration whose
    /// snapshot is retained across a whole call, so a workspace cannot be
    /// consulted before a tool and gone after it. And a *share* rather than
    /// the registration itself, because one directory is one audience and a
    /// second live open of this root joins this chain rather than adding a
    /// second one behind the same audience
    /// ([`Runtime::register_hook_chain`](crate::runtime::Runtime::register_hook_chain)).
    #[allow(dead_code, reason = "held for its Drop")]
    hooks: HookChainHold,
    #[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("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)
            .field("host_tools", &self.host_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_with_approver`] and
    /// [`PreparedRun::send_with_options`]. (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();
        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 tools = self.agent_tools(&agent);
        let mut session =
            self.runtime
                .mint(spec.session_name.clone(), model, agent, &self.scope)?;
        // After the mint, because the agent id is what the ledger is keyed by
        // and the mint is what makes one. Nothing has run yet: mentra creates
        // the agent here and the first provider request is a `PreparedRun`
        // away, so no call of this session can reach a guard before its answer
        // is in.
        let row = self
            .runtime
            .agents()
            .record_for_workspace(session.agent_id(), tools);
        if !spec.profile.decides_reasoning() {
            apply_effort(&mut session, spec.effort.or(self.effort))?;
        }

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

    /// 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.
    ///
    /// Resuming is also where a "…for this session" approval answer dies
    /// (see [`ApprovalDecision`](crate::ApprovalDecision)). A *new* one needs
    /// nothing basis does about it: mentra 0.27 remembers it into
    /// `PermissionRuleScope::Process` (mentra#53), a rung owned by the live
    /// session that gave it and never written to the runtime store, so
    /// `resume_session_with_options` already hands back a session with a
    /// fresh handle and an empty rung, whatever the agent id. A row a
    /// pre-0.12 basis binary remembered into the durable `Session` scope
    /// instead is a different matter — mentra still loads and matches those —
    /// so the attach still clears that one scope for exactly that legacy
    /// case, and a corrupt or unwritable `rules.json` fails the resume with
    /// [`RunError::SessionRulesNotCleared`](crate::RunError) naming the file
    /// to repair or delete. Project- and global-scope rules are durable by
    /// definition and are unaffected either way.
    ///
    /// The workspace has to be the one the conversation belongs to, and this
    /// checks it: mentra's store is keyed by agent rather than by path, so an
    /// id alone cannot say where its conversation ran, and a resume restates
    /// *this* workspace's policy, tool audience and persisted-row tag onto
    /// whatever it is given. Under another repository's conversation those are
    /// the wrong `.git` carve-out, the wrong shell posture and the wrong
    /// roster, while the agent stays based in its own directory — which
    /// mentra's file tools always allow writes under. So an agent based
    /// somewhere other than [`root`](Self::root) is refused with
    /// [`RunError::WorkspaceMismatch`](crate::RunError), before the resume
    /// touches it at all. A caller that means "one of mine" takes the id from
    /// [`store::list`](crate::store::list) for this workspace.
    ///
    /// 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();
        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 effort = spec.effort.or(self.effort);
        let changes_reasoning = 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, &self.root, &self.scope)?;
        // Restated here for the reason the policy and the audience above are:
        // mentra persists none of it. What *is* persisted is the tool profile,
        // and `SessionResumeOptions` carries no replacement — so this
        // conversation's own roster is still the one its first mint froze,
        // possibly in another process, before this workspace's siblings
        // existed. The ledger is the half basis can bring up to date, and both
        // readers of it are the better for it: a child delegated from a
        // resumed session inherits a hidden set computed now rather than then,
        // and the MCP ownership guard judges this session against the servers
        // *this* open configured rather than the ones its first mint saw.
        let row = self
            .runtime
            .agents()
            .record_for_workspace(agent_id, self.resumed_tools(&session));
        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()
        };

        apply_effort(&mut session, effort)?;

        // The resumed agent's own prompt, not this workspace's current
        // default: the persisted agent may carry a per-run system override,
        // and substituting what this workspace would have minted would make
        // `PreparedRun::estimated_context_tokens` confidently wrong rather
        // than merely approximate. `Session::config` reads the configuration
        // the resume loaded, which is exactly the one this run will send.
        let context_snapshot = session.config().system.clone();

        Ok(self
            .minted(session, spec, model, context_snapshot)
            .with_agent_row(row))
    }

    /// 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 directory this workspace's runs are scoped to.
    ///
    /// The same value as [`root`](Self::root), and deliberately so: a caller
    /// that opened a relative or symlinked spelling gets back the directory
    /// that spelling named, not the spelling. Both names are kept because
    /// hosts use both.
    pub fn path(&self) -> &Path {
        &self.root
    }

    /// The workspace root: absolute, symlinks followed, resolved once at
    /// [`open`](Self::open). What the run header reports, what the agent is
    /// based in, what the runtime's policy roots are built from, and what
    /// [`fingerprint`](Self::fingerprint) reads — one directory under one
    /// spelling, so `workspace` and `context_files` can never disagree.
    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
    }

    /// Every skill a run from this workspace could reach at open, after
    /// layering, name-ordered.
    ///
    /// On a private runtime that is exactly this workspace's four roots. On a
    /// shared one (ADR-0018) the registry is the runtime's and additive, so it
    /// is this workspace's roots *and* whatever a sibling workspace open at the
    /// time had registered — which is what a run can actually `load_skill`, and
    /// therefore the honest answer to what this reports.
    /// [`LoadedSkill::root`](crate::run::LoadedSkill::root) is how to tell the
    /// two apart: a root under [`root`](Self::root) is this repository's.
    ///
    /// A snapshot, taken once at open like everything else here. A sibling that
    /// opens afterwards adds skills this list does not name, and one that drops
    /// takes its own away — since mentra 0.24 a workspace hands its roots back
    /// when it goes, so a shared runtime no longer accumulates the skills of
    /// every repository a host has ever opened on it.
    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 native tools the host supplied for this workspace
    /// ([`WorkspaceBuilder::with_tool`](crate::WorkspaceBuilder::with_tool)),
    /// by name, in the order it supplied them.
    ///
    /// Names only, for [`mcp_servers`](Self::mcp_servers)'s reason. Reported
    /// beside [`declared_tools`](Self::declared_tools) because the two are one
    /// question for anything looking at a run — *what did this open put within
    /// the model's reach that basis did not?* — and the answer is not
    /// otherwise readable: these are registered for this workspace's audience,
    /// which mentra's own registry readers do not walk.
    pub fn host_tools(&self) -> &[String] {
        &self.host_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
    }
    /// 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
    /// the run profile applied, and with every `mcp__*` name whose server this
    /// workspace does not own hidden.
    ///
    /// **A workspace in another directory needs no hiding here.** Its bridged
    /// and declared tools are registered for its own
    /// [`ToolAudience`](mentra::tool::ToolAudience) and this session resolves
    /// in its own, so mentra's ladder — exact agent, then matching audience,
    /// then global — reports a foreign name as hidden rather than visible,
    /// whether the model was offered it or guessed it. That holds for a
    /// delegated child too, which inherits its parent's audience with the
    /// runtime handle it is spawned from, and it is the reason a
    /// [`ChildSpec`](crate::ChildSpec) roster override can no longer reach a
    /// sibling's capability by replacing the profile.
    ///
    /// **Two `mcp__*` cases the ladder cannot express, and this can.** A second
    /// live open of *this* directory shares this audience — one directory is
    /// one identity — so its bridged tools resolve `Visible` here; and a host
    /// tool registered globally under an `mcp__`-shaped name is visible to
    /// every audience by the rule that makes globals global. Neither is a
    /// server this workspace configured, so neither is offered.
    /// [`Runtime::foreign_mcp_tools`](crate::runtime::Runtime::foreign_mcp_tools)
    /// is the whole rule.
    ///
    /// Per mint rather than per open, because the shared registry moves as
    /// siblings come and go, and a roster is honest only about the registry it
    /// was minted against.
    ///
    /// The set is also recorded against the minted agent's id
    /// ([`crate::runtime::agents`]), because one more reader 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.
    ///
    /// **What a roster cannot be, and what covers the rest.** It is a
    /// snapshot: a sibling that opens *after* this mint is not in it, a resume
    /// restates no profile at all (mentra persists the config and
    /// `SessionResumeOptions` carries no replacement), and a sibling caught
    /// between claiming its server name and recording what bridged under it
    /// has no names to hide yet. So hiding decides what the model is *told*,
    /// and what it may actually *run* is decided per call, live, by
    /// [`ForeignToolGuard`](crate::runtime::agents::ForeignToolGuard) in this
    /// workspace's own interception chain — which reads the server list this
    /// open configured rather than any snapshot of the registry. A name that
    /// slips into a roster is still a name that cannot be called.
    fn minted_agent(&self, profile: &RunProfile) -> AgentConfig {
        let mut agent = profile.apply_to(self.agent.clone());

        #[cfg(feature = "mcp")]
        agent
            .tool_profile
            .hidden_tools
            .extend(self.runtime.foreign_mcp_tools(&self.mcp_servers));

        // The third case an audience cannot express, and the one that is not
        // `mcp__`-shaped: a native tool a *sibling open of this directory*
        // supplied. It is registered for the audience both opens share, so
        // mentra resolves it here as readily as for the open that supplied it.
        agent.tool_profile.hidden_tools.extend(
            self.runtime
                .foreign_native_tools(&self.root, &self.host_tools),
        );

        agent
    }

    /// What a freshly minted agent may see and use, for the ledger every
    /// session this workspace makes is recorded in
    /// ([`crate::runtime::agents`]).
    ///
    /// Read off the config that was just built rather than recomputed, so the
    /// set `spawn` re-threads into a narrowed child is exactly the set the
    /// live parent was minted with — a second derivation could only be an
    /// opportunity for the two to disagree.
    fn agent_tools(&self, agent: &AgentConfig) -> AgentTools {
        AgentTools {
            hidden: agent.tool_profile.hidden_tools.clone(),
            #[cfg(feature = "mcp")]
            mcp_servers: self.mcp_servers.clone(),
            host_tools: self.host_tools.clone(),
        }
    }

    /// The same, for a conversation this workspace has picked back up.
    ///
    /// The persisted roster is where the hidden half starts, because that is
    /// what the resumed session's model is actually working from and a child
    /// must not be handed more than its parent has. What is added is what a
    /// resume cannot restate onto the agent itself: the `mcp__*` names that
    /// are foreign *now* — a sibling that opened while this conversation was
    /// on disk — and, for the same reason, the native tools such a sibling
    /// supplied.
    fn resumed_tools(&self, session: &Session) -> AgentTools {
        let mut hidden = session.config().tool_profile.hidden_tools.clone();

        #[cfg(feature = "mcp")]
        hidden.extend(self.runtime.foreign_mcp_tools(&self.mcp_servers));

        hidden.extend(
            self.runtime
                .foreign_native_tools(&self.root, &self.host_tools),
        );

        AgentTools {
            hidden,
            #[cfg(feature = "mcp")]
            mcp_servers: self.mcp_servers.clone(),
            host_tools: self.host_tools.clone(),
        }
    }

    /// 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>,
    ) -> 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_registration.dirs().to_vec(),
                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_retry_policy(self.runtime.retry_policy())
        .with_context_snapshot(context_snapshot)
    }
}

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