pub struct RuntimeBuilder { /* private fields */ }Expand description
How a runtime is built.
Named a builder because it is one: filled in, then consumed by
build — or embedded in a
WorkspaceBuilder via
with_runtime_builder,
where Workspace::open builds it bound to the
workspace’s own path. Fields are private because one of them is a
credential. with_* returns a new value, so a host can keep a
half-configured builder and finish it differently per runtime.
Implementations§
Source§impl RuntimeBuilder
impl RuntimeBuilder
Sourcepub fn with_command_timeout(self, timeout: Duration) -> Self
pub fn with_command_timeout(self, timeout: Duration) -> Self
How long a command may run before it is killed.
Two minutes by default, which suits the commands a harness usually runs and does not suit the ones that build software. A host whose agent runs container builds, test suites, or archives needs to say so: past the limit the process is killed mid-stream, and what reaches the caller is truncated output with no error in it — a build that looks like it failed silently rather than one that was stopped.
Clamped by mentra’s ceiling for the runtime’s policy; asking for longer than that grants the ceiling rather than failing, because a host that asked for patience should not get less than the default for asking.
Sourcepub fn with_command_environment(
self,
name: impl Into<String>,
value: impl Into<String>,
) -> Self
pub fn with_command_environment( self, name: impl Into<String>, value: impl Into<String>, ) -> Self
Adds one fixed environment value to every process this runtime spawns.
Mentra clears the ambient environment before running a model command, so a host must state execution context explicitly. A later call with the same name replaces the earlier value. Debug output names variables but redacts values.
Every process is meant literally, and it did not used to be: a
command through spawn received these pairs and
a declared tool’s program did not, so a host that had told the runtime
where its service lived watched .basis/tools.json tools fail at the
far end asking for a variable the runtime was holding. Both get them
now. A declared tool’s own env block still wins for a name they share,
because that is the tool’s own statement about itself
(crate::tools::declared).
Runtime-scoped, so on a shared runtime every workspace’s commands see
the same pairs. A host that wants two concurrently driven workspaces to
carry different identities gives each its own runtime through
WorkspaceBuilder::with_runtime_builder,
which is what the local task service does.
Sourcepub fn with_command_target(
self,
name: impl Into<String>,
executor: impl RuntimeExecutor + 'static,
) -> Self
pub fn with_command_target( self, name: impl Into<String>, executor: impl RuntimeExecutor + 'static, ) -> Self
Registers an executor this runtime’s commands can be routed to by name.
ADR-0021. spawn is still the model’s one door, and where a command
runs is a dimension of a call through it rather than a second tool:
!@<name> <command> reaches the executor registered here under name,
and a command with no @ reaches the local one exactly as before. The
case this exists for is basis running inside a Linux container on a
macOS build machine, where cargo test belongs in the container and
xcodebuild does not exist there at all.
basis ships no executors and claims nothing about what one reaches.
The host writes it — SSH to a forced command, docker exec, an agent
on a build box — and a target is exactly as trusted as that code.
docs/targets.md has the worked pattern, what the executor receives,
and the honesty this cannot be written without: routing a command
elsewhere is not confinement, and nothing here may be described as a
sandbox (ADR-0013).
What the executor is handed is a CommandRequest with this runtime’s
fixed command environment already merged, a timeout mentra has already
clamped, and the target name still on it, so one executor registered
under two names can tell which it was called as. The cwd is
advisory: it is a path in this process’s filesystem, and what it
means on the far side is the executor’s to decide.
The trait and everything an implementation of it names are re-exported
as crate::runtime’s executor types, so a host writes one against
basis alone and never adds mentra to its own manifest.
A later call with the same name replaces the earlier one, the same rule
with_command_environment follows.
Names are [A-Za-z0-9_-]+ and may not be local, which is the wire
word for here; a name that breaks either rule is a
RunError::CommandTarget from build rather than a
panic here, because a host reading its targets out of its own
configuration should be able to report a bad one the way it reports
every other bad setting.
Runtime-scoped, for ADR-0018’s reason and one of its own: a target that changed per repository would be a different machine per repository, which is not a thing a repository knows.
Source§impl RuntimeBuilder
impl RuntimeBuilder
Sourcepub fn with_store_dir(self, dir: impl Into<PathBuf>) -> Self
pub fn with_store_dir(self, dir: impl Into<PathBuf>) -> Self
Keeps this runtime’s conversations in dir rather than in the
machine-wide default.
Unset, mentra chooses, and what it chooses is keyed by the process’s
current directory rather than by any workspace basis opened — so a host
that opens two workspaces from one place writes both histories to one
place, and a test suite writes into the user’s real data directory
whatever temp directory it opened. Two callers want to say
otherwise: a host that keeps basis’s history inside its own application
data, and a test that wants no persistent side effect at all. Both are
asking the same question — where — so that is what this takes.
with_ephemeral_history answers it with
nowhere, and is the last word between the two: whichever was called
last decides.
§What lands in the directory
dir is the store’s root, and since 0.7 what fills it is plain
files, no database (ADR-0023): agents/<id>/ holding an agent.json,
a state.json, a transcript.jsonl and a leaf, plus a rules.json
and a runs.jsonl beside them — mentra’s own file-store layout,
readable with grep and jq. Compaction snapshots go in a
transcripts/ sibling under the same root, so this one call moves
both or neither. Nothing is created until the first write, and
pointing this at
store::default_directory is
exactly the default. store::list_in is how
the same conversations are read back, and it is pointed at the same
directory.
§A directory from basis 0.6 is refused, not adopted
basis ≤0.6 kept conversations in a runtime.sqlite in this same
directory, and this build neither links SQLite nor migrates
(ADR-0023’s E2 precedent). Naming a directory that still holds one
fails build with
RunError::LegacyStore rather than
starting an empty store beside it, which would read as every
conversation having vanished. The ways forward are in the message:
basis 0.6 to continue an old conversation, or this knob pointed
somewhere fresh — BASIS_DATA_DIR for the CLI — to start new work.
§Not the store itself
Though mentra’s RuntimeBuilder::with_store would take one.
RuntimeStore is a composition of nine traits, and under the
rule written on CancellationToken — every
mentra type basis’s surface makes a caller name, basis re-exports — that
shape would cost the re-export of all nine plus the record types they
pass. What it would buy is reachable without it: between this and
with_ephemeral_history a caller
already picks durable-here or nowhere-at-all without naming a mentra
type. A caller that genuinely wants its own backend still has one, on
Runtime::mentra_runtime’s side of the bargain: build the mentra
runtime and drive it directly.
Deliberately not a per-run knob: a run describes an invocation, and
where a machine keeps its history is not something an invocation
decides. A one-shot caller that needs it opens the
Workspace itself and hands
WorkspaceBuilder::with_runtime_builder
a recipe, which is the documented migration path.
Sourcepub fn with_ephemeral_history(self) -> Self
pub fn with_ephemeral_history(self) -> Self
Keeps this runtime’s conversations in memory, and nowhere else.
The sibling of with_store_dir, for the caller
whose answer to where is nowhere. mentra’s in-memory store backs it:
nothing is written, no tool output is spilled, no directory is
created, and dropping the Runtime takes the history with it.
One file is still written, and only if a conversation gets long enough to be summarized: mentra persists a compaction snapshot before it replaces a prefix of the transcript, and does that without consulting the store. basis files those under the operating system’s temp directory, unique per runtime — never the user’s data directory and never the workspace.
Nothing survives the process. While the runtime lives a conversation
behaves as it always does — Workspace::resume
finds an agent this runtime minted, because the store lives exactly as
long as the runtime does. Past that edge there is nothing to find: a
later process cannot resume one of these by agent id, a second runtime
gets its own empty store, and
store::list_in has no file to read whichever
directory it is pointed at, so session/list over ACP reports nothing.
There is no flush and no export — a host that might want a transcript
later wants with_store_dir now.
Who asks for it. A test suite, which otherwise writes to the real database under the user’s data directory. And a host whose conversations are genuinely disposable — a request-scoped run inside a server, a one-shot classifier — where keeping a transcript is a cost and a disclosure rather than a feature.
Setting this and with_store_dir is not an
error: they write one field, so the last call wins — the same rule as
every single-valued knob on this builder, and what makes the
half-configured builder this type advertises usable.
Source§impl RuntimeBuilder
impl RuntimeBuilder
Sourcepub fn with_provider(self, provider: BuiltinProvider) -> Self
pub fn with_provider(self, provider: BuiltinProvider) -> Self
Names the provider basis resolves the credential and the models
against — one of the three knobs crate::provider’s resolution
reads, beside with_base_url and
with_api_key. Like them it cannot sit beside a
either with_provider_instance or
with_registered_provider: the
supplied provider already answers what this chooses, so
build refuses the pair by name rather than ranking it.
Sourcepub fn with_provider_instance<P>(self, provider: P) -> Selfwhere
P: Provider + 'static,
pub fn with_provider_instance<P>(self, provider: P) -> Selfwhere
P: Provider + 'static,
Runs this runtime on a provider the host constructed, instead of one basis resolves.
mentra’s own seam, surfaced: an implementation of
Provider — a vendor SDK already living in the
host’s process, a gateway spoken to in a shape basis has no preset
for, a scripted provider in a test — is registered under the id its
own descriptor reports. Every workspace on this runtime resolves
models against it and streams turns through it, and
Runtime::provider reports its id.
An instance is an answer, not a preference. With one supplied,
crate::provider’s resolution never runs: no environment variable
is read, no credential is looked up, and build stays as offline as
ever. The knobs resolution reads therefore cannot sit beside one —
with_provider,
with_base_url and
with_api_key are each refused at
build with
ProviderError::AmbiguousProviderSource,
whichever order they were called in — a named refusal, the same
posture as the unattributed credential, because a silent priority is a
knob that silently stopped working. A config.json’s provider and
base_url yield instead (with_config fills
emptiness, and the question is no longer empty), and
with_wire has nothing left to say: it is read
only under a base URL, and the instance speaks whatever wire it
implements.
A later call replaces the earlier instance — the one-value rule every single-valued knob here follows.
The trait is re-exported at the crate root, and everything an
implementation touches as crate::runtime’s provider-authoring
re-exports, so a host writes one against basis alone.
Sourcepub fn with_registered_provider<P>(self, provider: P) -> Selfwhere
P: Provider + 'static,
pub fn with_registered_provider<P>(self, provider: P) -> Selfwhere
P: Provider + 'static,
Runs this runtime on a low-level provider-core implementation the host constructed, instead of one basis resolves.
This is mentra’s registered-provider seam, surfaced without another
adapter: a customized
provider_core::responses::ResponsesProvider
or
provider_core::anthropic::AnthropicProvider
goes straight through mentra’s own bridge. It is distinct from
with_provider_instance, whose
Provider is mentra’s higher-level runtime trait.
A host that needs the concrete Responses session after construction may
clone the provider before passing one clone here. ResponsesProvider’s
clone shares its session state, so opening a WebSocket through the
retained clone prewarms the same connection the registered clone’s real
runs use; basis neither wraps that clone in a second provider nor
reimplements mentra’s bridge.
Like a runtime-level instance, a registered provider is an answer, not a
preference. Provider resolution and environment lookup are skipped, and
with_provider,
with_base_url, and
with_api_key are refused beside it at build time.
Calling either host-provider method again replaces the earlier answer.
Sourcepub fn with_reusable_registered_provider<P, Make, MakeError, Warm, WarmFuture, WarmError>(
self,
provider_id: impl Into<ProviderId>,
make: Make,
warm: Warm,
) -> Selfwhere
P: Provider + Clone + 'static,
Make: Fn() -> Result<P, MakeError> + Send + Sync + 'static,
MakeError: Error + Send + Sync + 'static,
Warm: Fn(P) -> WarmFuture + Send + Sync + 'static,
WarmFuture: Future<Output = Result<(), WarmError>> + Send + 'static,
WarmError: Error + Send + Sync + 'static,
pub fn with_reusable_registered_provider<P, Make, MakeError, Warm, WarmFuture, WarmError>(
self,
provider_id: impl Into<ProviderId>,
make: Make,
warm: Warm,
) -> Selfwhere
P: Provider + Clone + 'static,
Make: Fn() -> Result<P, MakeError> + Send + Sync + 'static,
MakeError: Error + Send + Sync + 'static,
Warm: Fn(P) -> WarmFuture + Send + Sync + 'static,
WarmFuture: Future<Output = Result<(), WarmError>> + Send + 'static,
WarmError: Error + Send + Sync + 'static,
Supplies the repeatable registered-provider generation used when a private runtime is consumed and rebuilt for reuse.
provider_id fixes the provider identity before any factory or warm
activity, so a workspace can reject mismatched resolved model metadata
without calling either closure. Every generated provider must report
that same id or the generation is dropped before build and warm.
make creates exactly one fresh provider for each runtime. Basis takes
one ordinary Clone for warm, moves the other clone through
Mentra’s registered-provider seam, completes the runtime build, and
only then calls and awaits warm. This ordering matters for connection
prewarm: the clone and the installed provider share one newly created
session scope, while no warm side effect occurs for a runtime that did
not build. A factory or warm failure returns no runtime.
This method is a recipe input, not a synchronous-build variant. Finish
with into_reusable_recipe; calling
build would have no honest way to await warm and is
refused. The builder must also use explicit ephemeral history and may
not contain one-shot host tools.
The host-provider setters are one single-valued question. Calling this
after with_provider_instance or
with_registered_provider replaces
it; calling either one-shot method afterward replaces this factory and
makes conversion to a reusable recipe fail.
Sourcepub fn with_base_url(self, base_url: impl Into<String>) -> Self
pub fn with_base_url(self, base_url: impl Into<String>) -> Self
Points the runtime at an OpenAI-compatible endpoint.
Paste the URL the server publishes. A trailing /v1 is stripped during
resolution, because every gateway advertises itself with one — that is
the form the OpenAI SDKs take — and mentra’s transports append their
own v1/…; without the strip the published URL would produce
/v1/v1/… and a 404 that names nothing.
The endpoint is spoken to in chat/completions, which is what
“OpenAI-compatible” means in the wild: Ollama, LM Studio, vLLM,
llama.cpp, and the gateways in front of them serve that wire and not
OpenAI’s own v1/responses. A proxy that does serve Responses is
reached by saying with_wire(Wire::Responses), and
such an endpoint then uses complete local replay rather than automatic
previous_response_id chaining.
Beside either host-provider seam this is refused at
build: an instance reaches its endpoint itself, so a
base URL next to one has nowhere left to point.
Sourcepub fn with_api_key(self, api_key: impl Into<String>) -> Self
pub fn with_api_key(self, api_key: impl Into<String>) -> Self
Supplies the provider credential directly, instead of having basis read it from the environment.
A host whose key lives in a vault, a keychain, or a token it just
exchanged should not have to export an environment variable for basis to
find it again. Unset by default, which is the behavior every existing
caller has: the key is looked up by the variable names the ecosystem
already uses (see crate::provider).
A key with no with_provider and no
with_base_url is refused rather than guessed
at — with nothing to attribute it to, basis would be picking a service to
send someone’s credential to.
Beside either host-provider seam this is refused at
build for the same reason: an instance authenticates
itself, so a key basis cannot hand it is a credential on its way to
being ignored.
Sourcepub fn with_model(self, model: ModelSelector) -> Self
pub fn with_model(self, model: ModelSelector) -> Self
Sets the model resolution policy: what every workspace on this runtime
resolves unless it overrides with
WorkspaceBuilder::with_model.
A policy rather than a resolved model, because resolution needs the
provider and may need the network, and both are workspace-open facts:
the resolved id stays a Workspace fact (ADR-0018).
Sourcepub fn with_config(self, config: &Config) -> Self
pub fn with_config(self, config: &Config) -> Self
Fills in what a config.json said, wherever this builder has not been
told otherwise.
The provider, the endpoint and the model policy are this builder’s
three answers that a Config can also give, and it
gives them from a file rather than from the process’s arguments — so
they go below every with_* call above and above the environment,
which build consults only for what nothing has
answered. A host calling with_provider and then this keeps its
provider; a host calling them the other way round keeps it too, because
what this reads is emptiness rather than order.
effort is not here because a runtime has no effort: it is a per-turn
request, and Workspace applies the file’s answer
as the default for a RunSpec that asked
for none.
A workspace file cannot reach base_url. Config refuses to
carry one from a repository at all (see crate::config), so what
arrives here is always the user’s own — this method needs no rule of
its own to keep that true.
Workspace::open calls this for the private
runtime it builds, so the one-repository host gets it without asking. A
host building a shared Runtime states its own process facts and
calls this itself if it wants a file to speak for them.
A host-supplied provider leaves the file’s provider and base_url
unread. with_provider_instance and
with_registered_provider answer the
question those keys answer, and this method only ever fills emptiness —
so they yield silently where an explicit builder call is refused by
name. The model policy still arrives: which model is asked for is
orthogonal to who answers.
Sourcepub fn with_provider_retry(self, provider_retry: ProviderRetry) -> Self
pub fn with_provider_retry(self, provider_retry: ProviderRetry) -> Self
How patiently every run minted on this runtime waits out a provider that is failing transiently.
mentra retries a transient provider error on a doubling backoff and gives up when the budget runs out. Its default — five retries after the initial call, from 500ms, capped at 5s — permits six calls and waits about 12.5 seconds before the run fails, which is tuned for a provider that hiccups and not for one that is rate-limiting you: a gateway’s 429 routinely names a window longer than that, so the whole schedule elapses inside a limit that was never going to lift, and the caller reads a provider failure where the honest answer was wait.
What a host knows that basis cannot is how long its own caller will hold still. An interactive editor session should fail fast, because somebody is watching a cursor blink; a chat bot whose turn already takes eight minutes can afford to spend one of them waiting, and would far rather do that than hand back an error the user has to re-ask. That is the judgement this knob is for, and it is why the number is the host’s rather than a constant here.
Runtime-scoped (ADR-0018) because it describes the connection to the
provider — the same kind of fact as the credential and the base URL
beside it. Every run Workspace mints on this
runtime carries it as its default; an exceptional turn can override it
with TurnOptions::with_provider_retry.
It also reaches every subagent a run delegates to through
spawn: a child that reset to the default would
be a delegated run quietly less patient than the run that delegated it,
against the same rate limit.
Unset is exactly mentra’s default, so a host that never calls this gets
the behavior it has always had. Takes mentra’s own
ProviderRetry rather than a basis type — see the re-export in
crate::runtime for why there is only one spelling of this policy.
Not a deadline. TurnOptions::with_deadline
still bounds the whole turn, and a generous schedule inside a short
deadline is bounded by the deadline. Set both, and set them knowingly.
Sets the waits, not the count. mentra keeps how many retries follow
the initial call on RunOptions::retry_budget, so widening the
schedule alone still gives up after five retries (six calls) —
with_provider_retry_budget is the
other half, and the rate-limit case above needs both.
Sourcepub fn with_provider_retry_budget(self, budget: usize) -> Self
pub fn with_provider_retry_budget(self, budget: usize) -> Self
How many times a run minted here retries a transient provider error before giving up. Five by default.
The count half of with_provider_retry,
separate because mentra keeps the two apart: the schedule is a value
with a type, the count is a bare number on each run’s options. They are
two knobs here rather than one because they are genuinely two questions
— how long between retries and how many retries — and because the
commonest adjustment is this one alone, which should not require
constructing a ProviderRetry to express.
Worth doing the arithmetic before choosing: with the default schedule the waits double from 500ms to a 5s ceiling, so raising the count from five to eight reaches about 27 seconds in total — still short of the minute a rate-limit window usually wants. Widening the schedule is what makes a larger count worth having.
Runtime-scoped by default and inherited by delegated runs, exactly as
the schedule is; a turn may override the count with
TurnOptions::with_retry_budget.
Sourcepub fn with_responses_transport(self, transport: ResponsesTransport) -> Self
pub fn with_responses_transport(self, transport: ResponsesTransport) -> Self
Which transport mentra streams the Responses wire format over.
Passed straight through to mentra, which owns both transports and the choice between them. Unset, mentra picks, and what it picks is HTTP+SSE — the transport every basis run has ever used.
Who asks for it: a host driving basis against an endpoint where the websocket transport is the point rather than an option — lower per-turn setup on a long conversation, or a gateway that only offers it. Nothing else in basis selects a transport, so before this method a host that wanted one had to build the mentra runtime itself and give up basis’s own surface to get it.
Two ways this can disappoint, and neither is basis’s to soften.
Selecting ResponsesTransport::WebSocket needs basis’s
responses-websocket feature, which forwards to mentra’s, which
forwards to mentra-provider’s and compiles the websocket client back
in. It is off by default — the default build links no websocket stack
— and without it the choice is accepted here and fails at request
time, loudly, which is mentra’s stance rather than a silent fallback
to HTTP+SSE: a host that asked for a transport should learn it did not
get one, not discover later that its traffic went the other way. The
second is the provider: not every one serves websockets — Anthropic and
Gemini report that they do not — and such a provider refuses an
explicit WebSocket at its first request, naming itself, for the same
reason and with the same loudness.
Read back through Runtime::mentra_runtime().responses_transport(),
for a host that reports its own configuration.
Sourcepub fn with_wire(self, wire: Wire) -> Self
pub fn with_wire(self, wire: Wire) -> Self
Which request format the endpoint behind
with_base_url is spoken to in.
Wire::ChatCompletions by default, and that default is the point: an
operator who pastes a base URL has pasted Ollama, LM Studio, vLLM,
llama.cpp, or a gateway in front of one of them, and every one of those
serves chat/completions alone. OpenAI’s own v1/responses is served
by OpenAI — reached through the openai preset with no base URL at all
— and by a handful of proxies that forward to it.
So this exists for those proxies, and for nothing else. Without it the new default would not be a default but a removal: a Responses-speaking gateway was reachable by base URL before, and one word here keeps it reachable rather than sending its operator off to build a mentra runtime by hand. Choosing wrong is not subtle — the wrong wire is a 404 on the first turn.
Read only when a base URL is set. A provider preset carries the
wire its vendor speaks, so calling this without
with_base_url says nothing: basis will not
talk chat/completions to Anthropic because a builder asked.
Source§impl RuntimeBuilder
impl RuntimeBuilder
Sourcepub fn with_file_tools(self, file_tools: FileToolProfile) -> Self
pub fn with_file_tools(self, file_tools: FileToolProfile) -> Self
Which builtin file tools this runtime offers the model.
FileToolProfile::Split by default, which is not mentra’s default.
The roster is the model’s API, and this is the one place basis writes
it. mentra’s Batched profile registers a single files tool whose
input is an operations array over nine variants —
read/list/search/create/set/replace/insert/move/delete
— so reading one file means picking a branch out of a nine-way oneOf
and nesting the path inside an array of objects. Split registers the
six names every model in this class was trained on: read, ls,
grep, glob, write, edit. Same workspace engine underneath, same
policy, same hook points — a different surface presented to the one
consumer that cannot be given a migration note.
Two of the differences are capability rather than shape.
mentra’s grep carries glob, ignore_case, literal, context and
multiline; the batched search op hardcodes all five to their
defaults, so a case-insensitive search scoped to *.rs is not
expressible through files at all. And glob — find files whose path
matches a pattern — has no batched equivalent, so under Batched a
model that wants one reaches for a shell command instead, which is a
tool call that goes to the approver in place of a read that would not
have.
Who wants Batched back. A host whose .basis/hooks.json matchers
or whose operators’ remembered rules name files: both key on the
exact tool name, so under Split a "tools": ["files"] entry stops
matching and nothing errors — the same silent-stop ADR-0016’s
shell → spawn note describes. Choosing Batched keeps the roster
those were written against, unchanged, for as long as the host needs to
rewrite them. That is a migration path rather than an opinion; the
opinion is the default. Both exists too, and costs the model both
surfaces in its context for one engine.
Runtime-scoped (ADR-0018) because the roster is a property of the mentra runtime’s registry, which is fixed at build: every workspace on this runtime, and every subagent, is offered the same set.
Sourcepub fn with_tool_result_policy(
self,
tool_result_policy: ToolResultPolicy,
) -> Self
pub fn with_tool_result_policy( self, tool_result_policy: ToolResultPolicy, ) -> Self
Sets the limits applied to completed tool results before the next provider request.
This is intentionally narrower than Mentra’s RuntimePolicy: Basis
continues to derive filesystem, command, timeout, and process posture.
Only the result byte limit, physical-line limit, and spill posture from
tool_result_policy replace that derived policy’s corresponding
values. If this method is never called, existing Mentra defaults remain
untouched.
Sourcepub fn with_interceptor(self, interceptor: impl Interceptor + 'static) -> Self
pub fn with_interceptor(self, interceptor: impl Interceptor + 'static) -> Self
Gives the host’s own code a say over each tool call, on every workspace this runtime carries.
The in-process binding of ADR-0012’s interception contract, and the
sibling of WorkspaceBuilder::with_hooks:
same vocabulary — allow, deny with a reason, modify with a replacement
input — and the same chain. What it buys is the case a subprocess
answers badly, because the judgement needs something the embedding
program is already holding: the vault handle, the token it just
exchanged, the policy it parsed at startup. Redacting a credential out
of a tool’s input is the worked example.
Runtime-scoped because host scope is runtime scope (ADR-0018): the
chain has always run host interceptors → global hooks → workspace
hooks, and this is the registration point that matches the first slot.
Appends, so a host may register several; they are consulted in the
order registered, and before any subprocess hook. The rule is that
the further a participant is from the workspace’s own data, the earlier
it speaks — an interceptor is compiled into this program, while
.basis/hooks.json came with a repository — and since the first refusal
short-circuits, that is what lets the host’s own guard stop a
repository’s program from being spawned at all. It is not a claim of
precedence: a hook still sees, and can still refuse, whatever an
interceptor rewrote.
Fail-closed carries over unchanged: an interceptor that returns an error or panics denies the call, and says which one it was.
Sourcepub fn with_tool<T>(self, tool: T) -> Selfwhere
T: ExecutableTool + 'static,
pub fn with_tool<T>(self, tool: T) -> Selfwhere
T: ExecutableTool + 'static,
Registers a tool the host implements, in the embedding program’s own
process — mentra’s ExecutableTool, not a crate::tools::declared
manifest entry.
The gap this closes: .basis/tools.json gives a workspace’s own repo a
tool, wrapping a subprocess that speaks JSON over stdio and sees
nothing beyond that JSON — no session, no caller identity, nothing the
host knows about the call it is answering. A host tool runs in the same
process as the code that is driving the run, so it can close over
whatever context that code already has (a client handle, a connection,
which conversation this is) instead of receiving it, or failing to.
Registered on the runtime (ADR-0018’s host scope), so — like spawn —
it is visible to every workspace and every subagent this runtime opens,
not to one session. A host that wants a tool visible to only some
workspaces still needs one runtime per audience; there is no per-
workspace host-tool registration yet.
A name basis or an earlier host tool already answers to is refused,
not replaced (decision D5d). build claims host
tools only after spawn, the builtins, and everything else basis
registers unconditionally already exist on the runtime, with mentra’s
try_register_tool rather than its plain with_tool — a host tool
named spawn fails the build naming the collision
(RunError::HostTool) instead of quietly
taking over the name and every rule an operator ever wrote about
commands and delegation.
Sourcepub fn with_delegation_depth(self, depth: usize) -> Self
pub fn with_delegation_depth(self, depth: usize) -> Self
How many levels of delegation spawn will start before refusing, on
every workspace this runtime carries (decision D9).
crate::tools::DEFAULT_DELEGATION_DEPTH (two) unless a caller says
otherwise here — the smallest bound that leaves delegation
compositional (a subagent may split its own work once) while keeping
runaway recursion structurally impossible rather than merely unlikely.
The root run is depth 0, so the deepest agent that can still delegate
is one less than this value.
The guard’s shape does not move with the number: it is still basis’s own ledger (mentra’s floor is name-specific and does not fire for a registered tool), and it still refuses in the preview, so a remembered allow-rule cannot lift whatever floor is set here.
Sourcepub fn with_child_policy<F>(self, policy: F) -> Self
pub fn with_child_policy<F>(self, policy: F) -> Self
Decides who a delegated child is, per delegation (decision D4).
spawn has always minted a subagent as an exact clone of its parent —
same roster, same model, same system prompt — and unset, it still
does, byte for byte. A policy makes the clone a default instead of
the only shape: consulted with what spawn knows about the delegation
(ChildContext — the child’s prompt, the parent’s agent id, the
workspace directory), it answers which of those three inherited facts
to override (ChildSpec), and ChildSpec::inherit is today’s
behavior exactly. Cheap triage beside a full fixer is the shape this
exists for: a prompt-prefix convention routed to a narrowed roster and
a cheaper model, with everything else inherited —
examples/child_policy.rs runs it.
Runtime-scoped, like the depth floor above and for the same reason:
spawn is registered on the runtime, every workspace and every
subagent on it shares the one instance, so the policy is consulted at
every depth — a child’s own delegations answer to it too, which is how
a host confines a whole chain rather than one generation.
Three facts and no more travel through a spec, deliberately. Bounds
stay on the run options a child already inherits (deadline, budgets,
cancellation, the shared token counter) — a second spelling here would
be a second bounds system. The depth floor is checked before the
policy runs, so no override lifts it. And the approver sees what the
policy decided: a delegation with overrides carries an additive
child key in its preview, so a remembered rule can match on what the
child will be, while an inherit answer leaves the preview byte-
identical to a policy-free runtime’s. ChildSpec’s module docs
carry the rest, including why a system-prompt override is
replace-wholesale with no append.
The policy should be a pure function of its context: it is consulted once for the preview and once at execution, and one that answers differently between the two shows the approver a child it will not spawn.
Sourcepub fn into_reusable_recipe(self) -> Result<RuntimeRecipe, RunError>
pub fn into_reusable_recipe(self) -> Result<RuntimeRecipe, RunError>
Converts this one-use builder into a repeatable private-runtime recipe.
Conversion is deliberately fallible. A concrete host provider and a
host tool are values Mentra consumes; Basis cannot synthesize a second
instance without changing their public APIs or guessing that a clone is
safe. Durable or implicit history also survives a runtime drop, which
defeats the fresh-state promise. A reusable recipe therefore requires
with_reusable_registered_provider,
no with_tool calls, and an explicit
with_ephemeral_history posture.
All other builder values are immutable scalars or Arc-backed host policy and can be replayed without cloning request-local state. Provider creation and warming do not happen during conversion; each later build performs them once.
Sourcepub fn build(self) -> Result<Runtime, RunError>
pub fn build(self) -> Result<Runtime, RunError>
Builds the workspace-agnostic runtime: the substrate an N-repository
host hands to every WorkspaceBuilder::with_runtime.
Synchronous, and deliberately so: nothing here needs the network. The
provider is resolved (credential lookup, no request), the mentra
runtime is assembled, and that is all — MCP servers are a workspace
concern and are connected by Workspace::open,
never here.
Per-workspace file confinement needs no policy roots: mentra’s builtin
file tools always allow paths under the calling agent’s own base_dir,
which basis sets per workspace. What this policy grants is command
execution — shell and background on, workspace-bounded’s timeouts — and
a workspace that says ShellAccess::Denied is enforced per-workspace
by the runtime’s hook dispatcher instead of by this shared policy.
Trait Implementations§
Source§impl Debug for RuntimeBuilder
Hand-written so a supplied credential cannot reach a log through a
{:?}. Everything else is printed as it is; the command environment names
its variables and redacts their values.
impl Debug for RuntimeBuilder
Hand-written so a supplied credential cannot reach a log through a
{:?}. Everything else is printed as it is; the command environment names
its variables and redacts their values.