Skip to main content

basis/workspace/
roster.rs

1//! Which tools the model is offered — the workspace's own knob over what used
2//! to be two constants and no host input (decision D3).
3//!
4//! [`ToolRoster::default`] is byte-identical to what every workspace has
5//! always done: `spawn`'s replaced doors and the intrinsics basis has never
6//! surfaced, hidden, and everything else mentra registers, offered. The two
7//! constructors map straight onto mentra's own `ToolProfile`:
8//! [`hide`](ToolRoster::hide) extends the denylist, [`only`](ToolRoster::only)
9//! replaces it with an allow-list. Neither touches what is *registered* on the
10//! runtime — hidden or un-allowed is a roster fact, never a capability fact
11//! (see [`crate::workspace::builder`]'s module docs for why that distinction
12//! matters to `spawn`).
13//!
14//! # What a roster is not
15//!
16//! A roster is the *base* `ToolProfile` an opened workspace carries in its
17//! `AgentConfig`, and two things sit outside it entirely:
18//!
19//! - **A sibling workspace's tools.** On a shared runtime one registry serves
20//!   every open repository, and a roster says nothing about whose tool a name
21//!   belongs to. It does not have to: a workspace's bridged `mcp__*` tools and
22//!   its declared tools are registered for that workspace's own
23//!   [`ToolAudience`](mentra::tool::ToolAudience), and mentra resolves a name
24//!   held only by a foreign audience as hidden rather than visible. So a
25//!   sibling's tool is out of reach whether or not a roster mentions it, and
26//!   whether the model was offered the name or guessed it. A roster is about
27//!   what *this* workspace offers of what it can reach. The two `mcp__*` cases
28//!   an audience cannot express — a second live open of the *same* directory,
29//!   and a host global under an `mcp__`-shaped name — are hidden by name at
30//!   every mint instead, and that hiding composes with whatever roster is set
31//!   here rather than being part of it
32//!   ([`Workspace::minted_agent`](super::Workspace)).
33//! - **The rendered prompt.** Whatever a workspace's `AGENTS.md`, `CLAUDE.md`
34//!   or memory files ([`crate::memory`]) put in the system prompt is a
35//!   property of [`WorkspaceContext`](crate::context::WorkspaceContext) and
36//!   [`crate::memory::index_block`], assembled entirely independently of the
37//!   roster and rendered by `agent_config` regardless of what it decided. A
38//!   roster that hides every file tool still ships a prompt — and a memory
39//!   index — that may tell the model to go read one; the prompt does not know,
40//!   and does not ask, what the roster allows.
41
42use std::collections::BTreeSet;
43
44use mentra::agent::ToolProfile;
45
46/// Which tools the model is offered, for
47/// [`WorkspaceBuilder::with_tool_roster`](super::WorkspaceBuilder::with_tool_roster).
48///
49/// A thin wrapper over mentra's `ToolProfile` rather than a re-export of it:
50/// [`ToolRoster::default`] is the one construction this crate has an opinion
51/// about (today's exact hidden set), and wrapping it is what lets that default
52/// live beside the constant it is built from instead of at every call site
53/// that wants "basis's usual roster, plus...".
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct ToolRoster(ToolProfile);
56
57impl Default for ToolRoster {
58    /// What every workspace has offered before this type existed: `spawn`'s
59    /// two replaced doors (`REPLACED_TOOLS`) and the intrinsics basis has
60    /// never deliberately surfaced (`UNSURFACED_TOOLS`), hidden; everything
61    /// else mentra's runtime registers — the file tools, `load_skill`,
62    /// `compact`, and whatever a host's own tools or an MCP server add —
63    /// offered.
64    ///
65    /// Pinned by `the_default_roster_is_exactly_this` and its neighbors in
66    /// `workspace::builder::tests`: this has to keep producing that exact set
67    /// for those tests to mean anything.
68    fn default() -> Self {
69        Self(ToolProfile::hide(hidden_tools()))
70    }
71}
72
73impl ToolRoster {
74    /// [`ToolRoster::default`]'s set, plus `names`.
75    ///
76    /// Extends rather than replaces, so narrowing a roster is additive: a
77    /// caller hiding one more tool never has to restate `spawn`'s replaced
78    /// doors or the unsurfaced intrinsics to keep them hidden too. Call
79    /// [`only`](Self::only) for a denylist built from nothing instead of from
80    /// basis's own.
81    pub fn hide(names: impl IntoIterator<Item = impl Into<String>>) -> Self {
82        let mut hidden: BTreeSet<String> = hidden_tools().map(str::to_string).collect();
83        hidden.extend(names.into_iter().map(Into::into));
84
85        Self(ToolProfile::hide(hidden))
86    }
87
88    /// Offers exactly `names`, mapped straight to mentra's own
89    /// `ToolProfile::only` — an allow-list rather than [`hide`](Self::hide)'s
90    /// denylist, and basis states no opinion of its own on top of it.
91    ///
92    /// Three honest caveats, each pinned by a test beside this one:
93    ///
94    /// - **Cannot itself un-register the file tools — that is a runtime
95    ///   decision, not a roster one.** A roster is per-workspace; which file
96    ///   tools a runtime carries at all is fixed once, per-runtime, at
97    ///   [`RuntimeBuilder::with_file_tools`](crate::RuntimeBuilder::with_file_tools)
98    ///   — basis's own default of `Split`, or a host's `Batched`, `Both`, or
99    ///   `None`. `only` cannot reach across and change that from a workspace.
100    ///   What it *can* do, and does, is stop *offering* whatever the runtime
101    ///   carries: a set that names none of
102    ///   `read`/`ls`/`grep`/`glob`/`write`/`edit` makes `ToolProfile::allows`
103    ///   refuse every one of them for this workspace's agents, exactly as it
104    ///   refuses any other name left out — see
105    ///   `only_hides_the_file_tools_but_cannot_reach_the_runtimes_profile`. A
106    ///   host that wants the tools gone from the registry too — so a sibling
107    ///   workspace sharing this runtime, or mentra's own APIs reached through
108    ///   [`Runtime::mentra_runtime`](crate::Runtime::mentra_runtime), cannot
109    ///   find them either — builds that runtime with
110    ///   `with_file_tools(FileToolProfile::None)` in the first place; there is
111    ///   no way to reach the same effect from a roster alone.
112    /// - **Does not imply `spawn`.** A set that omits
113    ///   [`crate::tools::SPAWN`] is a legitimate roster — a knowledge agent
114    ///   with no delegation and no command door is a real shape — but it is
115    ///   also a model with no route to a subagent or a shell command at all.
116    ///   Name it if the agent needs either.
117    /// - **Does not imply `load_skill`.** Skills are basis's own on-demand
118    ///   convention; a set that omits it is a model that cannot load one, no
119    ///   matter how many are registered.
120    pub fn only(names: impl IntoIterator<Item = impl Into<String>>) -> Self {
121        Self(ToolProfile::only(
122            names.into_iter().map(Into::into).collect::<Vec<_>>(),
123        ))
124    }
125
126    /// The mentra profile this roster resolved to, for
127    /// [`WorkspaceBuilder::open`](super::WorkspaceBuilder::open) to place on
128    /// the `AgentConfig` it builds.
129    pub(crate) fn into_profile(self) -> ToolProfile {
130        self.0
131    }
132
133    /// The same profile, borrowed — what a child policy's preview reads to
134    /// describe an overriding roster without consuming the spec that carries
135    /// it (`crate::tools::spawn::child`).
136    pub(crate) fn as_profile(&self) -> &ToolProfile {
137        &self.0
138    }
139}
140
141/// Every name basis takes off the model's roster by default: what `spawn`
142/// replaced, and what basis has never surfaced.
143///
144/// Two constants rather than one list because the two carry different
145/// arguments and a reader deserves to know which applies to a given name.
146fn hidden_tools() -> impl Iterator<Item = &'static str> {
147    REPLACED_TOOLS.into_iter().chain(UNSURFACED_TOOLS)
148}
149
150/// The tools `spawn` replaces, by the names mentra registers them under.
151const REPLACED_TOOLS: [&str; 3] = ["shell", "background_run", "task"];
152
153/// What mentra registers that basis has never deliberately offered.
154///
155/// Registration is mentra's default posture — `register_tools` walks every
156/// intrinsic variant it has — so a name reaching the model here is the absence
157/// of a decision rather than one. Each of these fails a different way, and none
158/// of the failures is visible to the person running the agent:
159///
160/// - **`team_spawn` and its six siblings are delegation by another name.** A
161///   second door for *hand work to something else, read back a summary* is
162///   exactly what ADR-0016 removed `task` for: two names arriving at one
163///   approval gate, and two namespaces of remembered rules, for a question an
164///   operator asks once. Nothing in basis mints a team, reads a teammate inbox,
165///   or renders a `team_request`, so the door does not even lead where its
166///   description says. `docs/REDESIGN.md` has recorded these as awaiting a
167///   concrete use case since Phase D; reachable-by-accident is not the
168///   deliberate surfacing that row is waiting for.
169/// - **`idle` is that surface's exit.** Its whole effect is
170///   `Agent::request_idle`, which mentra's orchestrator reads as
171///   `should_end_turn` — a yield *back to the teammate loop* basis never
172///   starts. On a basis run the model calling it ends its own turn mid-task
173///   and the caller reads a short answer with no error in it.
174/// - **`task_create` and the other four write a board nothing reads.** basis
175///   surfaces no task board — not on the event stream, not over ACP, not in
176///   the CLI — so a model that files, claims and updates work items gets
177///   plausible success back from every call and nothing observable happens.
178///   Confident bookkeeping into a void is worse than no bookkeeping, because
179///   it reads to the model as coordination.
180/// - **`check_background` reports on a tool that is hidden.** The only thing it
181///   can report on is `background_run`, which left the roster with ADR-0016's
182///   two other doors, so it can answer nothing but "no such task".
183/// - **`memory_pin`, `memory_forget` and `memory_search` reach a store basis
184///   has decided against (D2, wave 1).** basis's memory is a file convention
185///   (`crate::memory`); mentra's engine — recall injection included, switched
186///   off in `agent_config` beside this list — is not it. A model pinning facts
187///   into a store nothing surfaces is the task-board failure again: plausible
188///   success, and nothing the person running the agent can see.
189///
190/// Deliberately still offered, and each for a reason: `load_skill`, because
191/// on-demand skills are basis's own convention and that tool is how a skill is
192/// loaded; and `compact`, because a model that can see its context filling
193/// should be able to act on it (that the *user* has no matching control is a
194/// separate gap, and hiding this would not close it).
195const UNSURFACED_TOOLS: [&str; 17] = [
196    "check_background",
197    "idle",
198    "task_create",
199    "task_claim",
200    "task_update",
201    "task_list",
202    "task_get",
203    "team_spawn",
204    "team_send",
205    "team_read_inbox",
206    "team_broadcast",
207    "team_request",
208    "team_respond",
209    "team_list_requests",
210    "memory_pin",
211    "memory_forget",
212    "memory_search",
213];
214
215#[cfg(test)]
216mod tests;