basis/run.rs
1//! One prompt against one workspace: the smallest complete thing basis does.
2//!
3//! This is the P1 acceptance surface from `docs/ARCHITECTURE.md` §6 —
4//! arbitrary prompts on arbitrary repos, in-process and as a subprocess. The
5//! binary is a thin shell over [`run`]; a Rust host calls it directly.
6//!
7//! Nothing here knows what the prompt is for. The mission arrives as data: the
8//! prompt itself, the workspace's own context files, and configuration.
9//!
10//! [`run`] answers the whole question — build a runtime, resolve a model, send
11//! the prompt — for the case where one prompt is the whole job. Everything in
12//! this module is a wrapper around [`Workspace`]: it opens
13//! one, mints a single run from it, and drops it when the run ends. A host
14//! sending more than one prompt at a repository should open the workspace
15//! itself and keep it, which is what the split of ADR-0010 is for.
16//!
17//! A host that already owns a mentra runtime skips to [`prepare_with_session`]
18//! and keeps its own.
19
20mod output;
21mod prepared;
22mod sink;
23mod turn;
24mod usage;
25
26use std::{path::PathBuf, sync::Arc, time::Duration};
27
28use mentra::{BuiltinProvider, ModelSelector, Session};
29use thiserror::Error;
30
31#[cfg(feature = "mcp")]
32use crate::mcp::{McpConfig, McpError};
33use crate::{
34 approval::Approver,
35 context::{ContextConfig, ContextError, WorkspaceContext},
36 event::RunOutcome,
37 hooks::HooksConfig,
38 provider::ProviderError,
39 shell::ShellAccess,
40 skills::SkillsConfig,
41 templates::TemplatesConfig,
42 workspace::{
43 DEFAULT_SESSION_NAME, RunSpec, Workspace, WorkspaceBuilder, load_templates,
44 resolved_workspace,
45 },
46};
47
48pub use output::{OutputReport, OutputSpec};
49pub use prepared::{LoadedSkill, PreparedRun, RunContext};
50pub use sink::{
51 CollectingSink, EventFanIn, EventSink, FnSink, MergedEvents, NullSink, TaggedEvent, TaggedSink,
52};
53pub use turn::TurnOptions;
54pub use usage::RunUsage;
55
56/// The signal a caller trips to stop a turn.
57///
58/// Re-exported rather than restated, and the reason is the one thing basis cannot
59/// wrap: a token is an *identity*, not a value. The turn holds one half and the
60/// caller the other, and a basis-owned copy would have to forward the trip to
61/// mentra's — a second object that can disagree with the first about whether
62/// the stop button was pressed. So this is a deliberate leak, like
63/// [`ModelSelector`] and [`BuiltinProvider`] on [`RunConfig`].
64///
65/// Re-exporting it is what makes the leak cheap. A host embedding `basis`
66/// should not have to add mentra to its own manifest — and pin the same
67/// version — to name a type basis's own API asks it for; a skew there fails to
68/// compile with no hint that two crates disagree about one struct. Hence the
69/// rule: every mentra type basis's surface makes a caller *name*, basis re-exports.
70///
71/// Two of them go on a turn and they mean different things —
72/// [`TurnOptions::cancel`] abandons it, [`TurnOptions::stop`] ends it
73/// gracefully.
74pub use mentra::runtime::CancellationToken;
75
76/// How hard the model should think before answering.
77///
78/// basis's own enum rather than a re-export, for the reason [`Event`](crate::Event) and
79/// [`TurnOptions`] are: the surface basis promises should not move when mentra's
80/// does. Provider adapters translate this semantic level to their own wire
81/// format. A provider or model that does not offer the requested level returns
82/// an error rather than silently lowering it.
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84#[non_exhaustive]
85pub enum Effort {
86 Low,
87 Medium,
88 High,
89 XHigh,
90 Max,
91}
92
93impl From<Effort> for mentra::provider::ReasoningEffort {
94 fn from(effort: Effort) -> Self {
95 match effort {
96 Effort::Low => Self::Low,
97 Effort::Medium => Self::Medium,
98 Effort::High => Self::High,
99 Effort::XHigh => Self::XHigh,
100 Effort::Max => Self::Max,
101 }
102 }
103}
104
105/// Everything a run needs. Task-specific behavior lives in `prompt` and in the
106/// workspace, never in this struct.
107///
108/// This conflates lifetimes, and [`split`](Self::split) is where the seam is:
109/// most of it describes a *workspace* — where to discover context, which MCP
110/// servers to connect — a couple of fields describe the *process*
111/// (`provider`, `base_url`, seeded into the private runtime's recipe per
112/// ADR-0018), and only a handful describe one run. A caller sending many
113/// prompts at one repository wants [`Workspace`] and a [`RunSpec`] each; a
114/// caller sending one wants this, and pays for the discovery once either way.
115#[derive(Debug, Clone)]
116pub struct RunConfig {
117 pub workspace: PathBuf,
118 pub prompt: String,
119 /// `None` auto-detects from the environment.
120 pub provider: Option<BuiltinProvider>,
121 /// An OpenAI-compatible endpoint to use instead of the provider's own
122 /// service. These endpoints use complete local replay instead of automatic
123 /// `previous_response_id` chaining. `None` falls back to `BASIS_BASE_URL` /
124 /// `OPENAI_BASE_URL`.
125 pub base_url: Option<String>,
126 pub model: ModelSelector,
127 pub context: ContextConfig,
128 pub skills: SkillsConfig,
129 /// Which MCP servers this run connects, and where to look for more.
130 #[cfg(feature = "mcp")]
131 pub mcp: McpConfig,
132 /// Where to look for prompt templates. Discovered, never executed here —
133 /// a template becomes a prompt only when something renders it.
134 pub templates: TemplatesConfig,
135 /// Where to look for subprocess hooks — external commands with a say over
136 /// each tool call.
137 pub hooks: HooksConfig,
138 /// Whether the agent may run commands. Granted by default; see ADR-0013.
139 pub shell: ShellAccess,
140 /// How hard the model should think. `None` leaves the provider's default;
141 /// unsupported provider/model levels fail instead of being downgraded.
142 pub effort: Option<Effort>,
143 /// Gives up on the run after this long.
144 ///
145 /// Unset by default, and unset for an unattended caller too. An attended
146 /// `basis spawn` has a person watching, who can tell "thinking hard" from
147 /// "stuck" in a way no timer can; a caller nobody is watching has to write
148 /// the bound down in advance, and with no scheduler shipped there is no
149 /// period for basis to guess one from (ADR-0014).
150 pub deadline: Option<Duration>,
151 /// Caps how many tool calls the run may make.
152 pub tool_budget: Option<usize>,
153 /// Caps the tokens the run may report using, input plus output.
154 ///
155 /// Soft by construction: usage is only known once a round has streamed in
156 /// full, so the round that crosses the line always finishes. This is the
157 /// bound that maps to money.
158 pub token_budget: Option<u64>,
159 pub session_name: String,
160}
161
162impl RunConfig {
163 pub fn new(workspace: impl Into<PathBuf>, prompt: impl Into<String>) -> Self {
164 Self {
165 workspace: workspace.into(),
166 prompt: prompt.into(),
167 provider: None,
168 base_url: None,
169 model: ModelSelector::NewestAvailable,
170 context: ContextConfig::default(),
171 skills: SkillsConfig::default(),
172 #[cfg(feature = "mcp")]
173 mcp: McpConfig::default(),
174 templates: TemplatesConfig::default(),
175 hooks: HooksConfig::default(),
176 // Granted, per ADR-0013, and from the enum's own default rather
177 // than from anything ambient: what a run may do is stated here, in
178 // the config, not read out of the environment behind the caller.
179 shell: ShellAccess::default(),
180 effort: None,
181 deadline: None,
182 tool_budget: None,
183 token_budget: None,
184 session_name: DEFAULT_SESSION_NAME.to_string(),
185 }
186 }
187
188 pub fn with_provider(self, provider: BuiltinProvider) -> Self {
189 Self {
190 provider: Some(provider),
191 ..self
192 }
193 }
194
195 /// Points the run at an OpenAI-compatible endpoint. A trailing `/v1` is
196 /// stripped during resolution — paste the URL a gateway publishes.
197 /// Compatible endpoints use complete local replay rather than automatic
198 /// `previous_response_id` chaining.
199 pub fn with_base_url(self, base_url: impl Into<String>) -> Self {
200 Self {
201 base_url: Some(base_url.into()),
202 ..self
203 }
204 }
205
206 pub fn with_model(self, model: ModelSelector) -> Self {
207 Self { model, ..self }
208 }
209
210 pub fn with_context(self, context: ContextConfig) -> Self {
211 Self { context, ..self }
212 }
213
214 pub fn with_skills(self, skills: SkillsConfig) -> Self {
215 Self { skills, ..self }
216 }
217
218 /// Sets which MCP servers the run connects.
219 ///
220 /// Servers arrive from three places — the caller's own list, the
221 /// workspace's `.mcp.json`, and the global one — and this is where the
222 /// first of those goes. See [`crate::mcp`] for the precedence.
223 #[cfg(feature = "mcp")]
224 pub fn with_mcp(self, mcp: McpConfig) -> Self {
225 Self { mcp, ..self }
226 }
227
228 pub fn with_templates(self, templates: TemplatesConfig) -> Self {
229 Self { templates, ..self }
230 }
231
232 /// Sets where subprocess hooks are discovered.
233 ///
234 /// A hook is an external command that gets a say over each tool call; see
235 /// [`crate::hooks`] for the wire contract and for what happens when one
236 /// breaks.
237 pub fn with_hooks(self, hooks: HooksConfig) -> Self {
238 Self { hooks, ..self }
239 }
240
241 /// Grants or denies command execution.
242 ///
243 /// Granted by default (ADR-0013). Denying is the read-only posture: it
244 /// shuts the command tools and nothing else, so it is a narrowing of what
245 /// this run does, never a claim about what the process could do.
246 pub fn with_shell(self, shell: ShellAccess) -> Self {
247 Self { shell, ..self }
248 }
249
250 /// Asks the model to think harder, where the provider supports it.
251 pub fn with_effort(self, effort: Effort) -> Self {
252 Self {
253 effort: Some(effort),
254 ..self
255 }
256 }
257
258 pub fn with_session_name(self, session_name: impl Into<String>) -> Self {
259 Self {
260 session_name: session_name.into(),
261 ..self
262 }
263 }
264
265 /// Gives up on the run after `deadline`.
266 ///
267 /// Every bound here is a *graceful* end rather than a discarded run: the
268 /// event stream closes the way it always does, and whatever the model
269 /// committed before the bound tripped is kept. That is what makes bounding
270 /// an unattended run safe to do — the alternative, throwing the work away
271 /// for being one round too long, would make callers reluctant to set one.
272 pub fn with_deadline(self, deadline: Duration) -> Self {
273 Self {
274 deadline: Some(deadline),
275 ..self
276 }
277 }
278
279 /// Caps how many tool calls the run may make.
280 pub fn with_tool_budget(self, tool_budget: usize) -> Self {
281 Self {
282 tool_budget: Some(tool_budget),
283 ..self
284 }
285 }
286
287 /// Caps the tokens the run may report using, input plus output.
288 ///
289 /// Soft: the round that crosses the line is allowed to finish, because
290 /// usage is only known once a round has streamed in full. The run ends at
291 /// that boundary keeping everything it committed, and says so —
292 /// [`Bound::TokenBudget`] on the report, exit `3` from the CLI — whether or
293 /// not the work it kept amounts to an answer.
294 pub fn with_token_budget(self, token_budget: u64) -> Self {
295 Self {
296 token_budget: Some(token_budget),
297 ..self
298 }
299 }
300
301 /// The bounds this config puts on every turn the run performs.
302 ///
303 /// Limits only. Cancellation and the graceful stop signal are per-call
304 /// things a caller holds a token for, not configuration, so they stay at
305 /// their defaults here and arrive through
306 /// [`send_with_options`](PreparedRun::send_with_options).
307 pub fn turn_options(&self) -> TurnOptions {
308 self.spec().turn_options()
309 }
310
311 /// The two halves this config conflates: what belongs to the workspace, and
312 /// what belongs to one run of it.
313 ///
314 /// Every function in this module is `config.split()` followed by an
315 /// `open().await` and a mint, so this is not a second description of the
316 /// mapping — it *is* the mapping, and it is public because it is also the
317 /// migration path. A caller that outgrows one-prompt-per-config keeps the
318 /// builder, opens it once, and mints a [`RunSpec`] per run.
319 pub fn split(&self) -> (WorkspaceBuilder, RunSpec) {
320 // The process half of this config seeds the private runtime's recipe;
321 // opening the builder builds it bound to the workspace path, which is
322 // exactly what the pre-ADR-0018 knobs did.
323 let mut runtime = crate::runtime::Runtime::builder();
324 if let Some(provider) = self.provider {
325 runtime = runtime.with_provider(provider);
326 }
327 if let Some(base_url) = &self.base_url {
328 runtime = runtime.with_base_url(base_url.clone());
329 }
330
331 #[allow(unused_mut, reason = "mutated only when the mcp feature is on")]
332 let mut builder = Workspace::builder(&self.workspace)
333 .with_runtime_builder(runtime)
334 .with_model(self.model.clone())
335 .with_context(self.context.clone())
336 .with_skills(self.skills.clone())
337 .with_templates(self.templates.clone())
338 .with_hooks(self.hooks.clone())
339 .with_shell(self.shell);
340
341 #[cfg(feature = "mcp")]
342 {
343 builder = builder.with_mcp(self.mcp.clone());
344 }
345
346 (builder, self.spec())
347 }
348
349 /// The per-run half alone, for the callers that need no runtime.
350 fn spec(&self) -> RunSpec {
351 RunSpec {
352 prompt: self.prompt.clone(),
353 session_name: self.session_name.clone(),
354 effort: self.effort,
355 deadline: self.deadline,
356 tool_budget: self.tool_budget,
357 token_budget: self.token_budget,
358 // A one-prompt run has no siblings to share an allowance with, so
359 // there is nothing for a pool to do here. A caller who wants one
360 // wants the `Workspace` shape (ADR-0010), where a pool is attached
361 // per `RunSpec`.
362 budget: None,
363 }
364 }
365}
366
367/// A bound that ended a run before its work did.
368///
369/// Separate from [`RunOutcome`] because the two answer different questions.
370/// "The model ran out of the time you gave it" and "the provider refused the
371/// request" call for different reactions, and a caller — the CLI's exit code,
372/// or a script driving many runs — should not have to read an error message to
373/// tell them apart (ADR-0015). The outcome says whether an answer arrived; this
374/// says whether an allowance is what ended the run, and the two are
375/// independent. [`Deadline`](Self::Deadline) and [`ToolBudget`](Self::ToolBudget)
376/// always arrive alongside [`RunOutcome::Error`], because no final message
377/// does; [`TokenBudget`](Self::TokenBudget) can arrive on a run that answered.
378#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
379#[serde(rename_all = "snake_case")]
380#[non_exhaustive]
381pub enum Bound {
382 /// [`RunConfig::with_deadline`] — the run took longer than it was given.
383 Deadline,
384 /// [`RunConfig::with_tool_budget`] — the run made all the calls it had.
385 ToolBudget,
386 /// [`RunConfig::with_token_budget`], or a [`BudgetPool`](crate::BudgetPool)
387 /// the run drew dry — it spent its allowance and was refused another round.
388 ///
389 /// The odd one out, and worth knowing why before branching on it. This
390 /// bound is *graceful*: mentra ends the run at a round boundary keeping
391 /// everything committed so far, exactly as if the model had finished. So a
392 /// run can report [`RunOutcome::Ok`] with an ordinary answer *and* this
393 /// bound, which is the honest description of "you got an answer, and the
394 /// allowance is why there is not more of one". Whether an answer arrives
395 /// comes down to what the last committed message was: prose, and the turn
396 /// succeeds; a tool result, and it fails owing a final message it never
397 /// got.
398 ///
399 /// Reportable at all only because mentra records the decision at the
400 /// boundary it makes it
401 /// ([`RunOptions::ended_early`](mentra::runtime::RunOptions::ended_early)).
402 /// Comparing usage against the budget afterwards would answer a different
403 /// question — what is true now, rather than what the runner decided on —
404 /// and a pooled run can cross the line without being the run that was
405 /// stopped by it.
406 TokenBudget,
407}
408
409/// What a completed run produced, alongside the sink it wrote to.
410#[derive(Debug)]
411pub struct RunReport<S> {
412 pub session_id: String,
413 pub model: String,
414 pub provider: String,
415 /// The assistant's final message, absent when the run failed — and absent
416 /// on a typed turn, where the answer is
417 /// [`OutputReport::value`] rather than prose.
418 pub final_message: Option<String>,
419 pub outcome: RunOutcome,
420 /// Which bound ended the run, when one did rather than the work.
421 ///
422 /// Neither field implies the other: a bounded run usually failed for want
423 /// of a final message, but see [`Bound::TokenBudget`], which a run that
424 /// answered can carry.
425 pub stopped_by: Option<Bound>,
426 /// What the run reported spending. Present whether it succeeded or not: a
427 /// turn that failed on its fourth round still spent the first three.
428 pub usage: RunUsage,
429 pub sink: S,
430}
431
432impl<S> RunReport<S> {
433 pub fn succeeded(&self) -> bool {
434 matches!(self.outcome, RunOutcome::Ok)
435 }
436}
437
438/// Anything that can go wrong opening a workspace, preparing a run, or driving
439/// one.
440///
441/// One error type across all three, rather than a `WorkspaceError` beside it:
442/// opening a workspace exists to prepare runs, and every failure listed here is
443/// a failure a caller of [`run`] has always been able to receive.
444#[derive(Debug, Error)]
445pub enum RunError {
446 #[error("prompt is empty")]
447 EmptyPrompt,
448
449 /// The shared allowance this turn draws on has nothing left.
450 ///
451 /// A decision rather than a failure of the work, which is why it is its own
452 /// variant: a caller fanning out over a [`BudgetPool`](crate::BudgetPool)
453 /// stops minting on this, where it would retry on a provider error. Raised
454 /// before the prompt is sent and before the stream opens, so the
455 /// conversation is left exactly as it was.
456 #[error("the shared token budget is spent: {spent} of {limit} tokens reported")]
457 BudgetExhausted { limit: u64, spent: u64 },
458
459 #[error("no session to resume")]
460 NoSuchSession,
461
462 #[error(transparent)]
463 Context(#[from] ContextError),
464
465 #[error(transparent)]
466 Provider(#[from] ProviderError),
467
468 #[error("runtime error: {0}")]
469 Runtime(#[from] mentra::error::RuntimeError),
470
471 /// A typed turn answered, but not in the shape that was asked for.
472 ///
473 /// Separate from [`Runtime`](Self::Runtime) because the two call for
474 /// different reactions and basis can tell them apart honestly: this one is
475 /// basis's own verdict. The typed path asks mentra for the raw payload and
476 /// deserializes it here, so a value that does not fit `T` is a schema or
477 /// prompt problem — retry with a clearer schema — while a provider failure
478 /// is not. The exchange stays in the session's transcript either way; see
479 /// [`PreparedRun::output`].
480 #[error("the run's output did not match the requested type: {0}")]
481 OutputMismatch(#[source] serde_json::Error),
482
483 #[error("failed to write an event: {0}")]
484 Sink(#[from] std::io::Error),
485
486 #[error("event forwarding task failed: {0}")]
487 Forwarder(#[from] tokio::task::JoinError),
488
489 #[error("failed to load skills: {0}")]
490 Skills(#[from] mentra::SkillLoadError),
491
492 #[error(transparent)]
493 #[cfg(feature = "mcp")]
494 Mcp(#[from] McpError),
495
496 #[error("failed to load prompt templates: {0}")]
497 Templates(#[from] crate::templates::TemplateError),
498
499 #[error("failed to load hooks: {0}")]
500 Hooks(#[from] crate::hooks::HookConfigError),
501}
502
503/// Runs one prompt to completion, streaming events into `sink`.
504///
505/// Consequential calls are approved by [`AllowAll`](crate::AllowAll), which is
506/// what a headless run needs: there is nobody to ask, and a question nothing
507/// answers is a hang. It asserts nothing about the run being confined — with
508/// commands on by default (ADR-0013) an unattended run carries its user's full
509/// authority, so an *attended* one is usually better served by
510/// [`run_with_approver`], and anything that needs a real boundary gets it from
511/// the OS.
512///
513/// A setup failure — no credential, unreachable model, unreadable workspace —
514/// is an `Err`. A failure *during* the turn is reported as
515/// [`RunOutcome::Error`] on an otherwise complete stream, because by then the
516/// events already emitted are worth keeping.
517///
518/// The session is dropped when this returns, and so is the workspace opened to
519/// hold it. For a conversation, keep the [`PreparedRun`] from [`prepare`] and
520/// call [`send`](PreparedRun::send) on it; for many conversations, keep a
521/// [`Workspace`].
522pub async fn run<S: EventSink>(config: RunConfig, sink: S) -> Result<RunReport<S>, RunError> {
523 prepare(config).await?.execute(sink).await
524}
525
526/// Runs one prompt, putting every consequential call to `approver`.
527///
528/// The approver is the whole of basis's approval story (ADR-0010):
529/// [`DenyAll`](crate::approval::DenyAll) for a run that may change nothing,
530/// the binary's terminal prompter for a person at a TTY, or a host's own — one
531/// that allows edits and denies the network, or asks a team over Slack. Note
532/// the contract it inherits: an approver that cannot answer must deny.
533pub async fn run_with_approver<S: EventSink, A: Approver>(
534 config: RunConfig,
535 sink: S,
536 approver: A,
537) -> Result<RunReport<S>, RunError> {
538 prepare(config)
539 .await?
540 .execute_with_approver(sink, approver)
541 .await
542}
543
544/// Resolves everything a run needs — context, credential, runtime, model,
545/// session — without sending the prompt.
546///
547/// One prompt, one workspace, opened and dropped around it.
548pub async fn prepare(config: RunConfig) -> Result<PreparedRun, RunError> {
549 if config.prompt.trim().is_empty() {
550 return Err(RunError::EmptyPrompt);
551 }
552
553 prepare_without_prompt(config).await
554}
555
556/// Builds a session with no prompt in hand yet.
557///
558/// What a protocol server needs: ACP's `session/new` opens a conversation
559/// before the user has typed anything, so the empty-prompt check that guards
560/// [`prepare`] would reject exactly the case that matters. Prompts arrive later
561/// through [`PreparedRun::send`], which does its own checking.
562pub async fn prepare_without_prompt(config: RunConfig) -> Result<PreparedRun, RunError> {
563 let (builder, spec) = config.split();
564
565 mint_carrying_workspace(builder, |workspace| workspace.prepare(spec)).await
566}
567
568/// Picks up a conversation a previous process left behind.
569///
570/// `agent_id` is [`PreparedRun::agent_id`], not the session id: mentra persists
571/// agents, and a session is one process's view of one. Resuming replays the
572/// transcript from the store, so the first turn after this already knows
573/// everything the last one did.
574///
575/// `config.prompt` may be empty here — a caller that resumes to inspect the
576/// history, or to send a prompt chosen later, has nothing to say yet.
577pub async fn resume(agent_id: &str, config: RunConfig) -> Result<PreparedRun, RunError> {
578 let (builder, spec) = config.split();
579
580 mint_carrying_workspace(builder, |workspace| workspace.resume(agent_id, spec)).await
581}
582
583/// Opens the builder and mints one run that carries the workspace.
584///
585/// The one resolution path for every free function above, and the load-bearing
586/// half is the carry: these functions hand back a [`PreparedRun`] and nothing
587/// else, so the run must be what keeps the workspace alive until the run ends
588/// — the module's own promise. A workspace dropped when this returns would
589/// take its hook registration and MCP connections with it *before the first
590/// turn is driven*: the dispatcher fails open for a directory no live
591/// workspace claims, so every `.basis/hooks.json` hook would be silently
592/// bypassed, and the minted roster would offer `mcp__*` tools whose servers
593/// were already torn down. See [`PreparedRun::with_workspace`].
594async fn mint_carrying_workspace(
595 builder: WorkspaceBuilder,
596 mint: impl FnOnce(&Workspace) -> Result<PreparedRun, RunError>,
597) -> Result<PreparedRun, RunError> {
598 let workspace = Arc::new(builder.open().await?);
599 let prepared = mint(&workspace)?;
600
601 Ok(prepared.with_workspace(workspace))
602}
603
604/// Prepares a run against a session the caller already built, so a host with
605/// its own runtime — custom tools, its own store, a provider basis does not
606/// know — still gets basis's context discovery and event stream.
607///
608/// The prompt in `config` may be empty. Once a session outlives a turn, a
609/// conversation with nothing said yet is a real state — it is what ACP's
610/// `session/new` opens — so the check belongs where a prompt is actually sent,
611/// which is [`PreparedRun::execute`] and [`PreparedRun::send`].
612///
613/// This is the one path that does not go through
614/// [`Workspace`], because there is no runtime for basis to
615/// build: the caller brought one. It still discovers what it can without
616/// touching that runtime.
617pub fn prepare_with_session(
618 session: Session,
619 config: &RunConfig,
620 provider: impl Into<String>,
621 model: impl Into<String>,
622) -> Result<PreparedRun, RunError> {
623 let context = WorkspaceContext::discover_with(&config.workspace, &config.context)?;
624 // Unlike skills, templates are registered on nothing — so basis can discover
625 // them here without touching a runtime it does not own.
626 let (templates_dirs, templates) = load_templates(&config.workspace, &config.templates)?;
627
628 Ok(PreparedRun::new(
629 session,
630 RunContext {
631 workspace: resolved_workspace(&config.workspace, &context),
632 prompt: config.prompt.clone(),
633 provider: provider.into(),
634 model: model.into(),
635 context,
636 // The caller owns the runtime, so it owns skill and MCP
637 // registration too.
638 skills_dirs: Vec::new(),
639 skills: Vec::new(),
640 templates_dirs,
641 templates,
642 mcp_files: Vec::new(),
643 mcp_servers: Vec::new(),
644 },
645 )
646 .with_bounds(config.turn_options()))
647}
648
649#[cfg(test)]
650mod tests {
651 use super::*;
652
653 #[test]
654 fn a_config_carries_no_task_specific_defaults() {
655 let config = RunConfig::new("/repo", "do the thing");
656
657 assert_eq!(config.provider, None);
658 assert!(matches!(config.model, ModelSelector::NewestAvailable));
659 assert_eq!(config.session_name, DEFAULT_SESSION_NAME);
660 }
661
662 #[test]
663 fn builders_return_new_values() {
664 let base = RunConfig::new("/repo", "prompt");
665 let derived = base
666 .clone()
667 .with_provider(BuiltinProvider::Anthropic)
668 .with_session_name("named");
669
670 assert_eq!(base.provider, None, "the original must be untouched");
671 assert_eq!(derived.provider, Some(BuiltinProvider::Anthropic));
672 assert_eq!(derived.session_name, "named");
673 }
674
675 #[test]
676 fn commands_are_available_unless_the_caller_says_otherwise() {
677 let config = RunConfig::new("/repo", "prompt");
678
679 assert_eq!(config.shell, ShellAccess::Granted);
680 assert!(config.shell.is_granted());
681 }
682
683 #[test]
684 fn denying_shell_returns_a_new_config() {
685 let base = RunConfig::new("/repo", "prompt");
686 let denied = base.clone().with_shell(ShellAccess::Denied);
687
688 assert_eq!(
689 base.shell,
690 ShellAccess::Granted,
691 "the original is untouched"
692 );
693 assert_eq!(denied.shell, ShellAccess::Denied);
694 }
695
696 #[test]
697 fn asking_for_no_effort_leaves_the_provider_default() {
698 let config = RunConfig::new("/repo", "prompt");
699
700 assert_eq!(config.effort, None);
701 assert_eq!(
702 config.clone().with_effort(Effort::High).effort,
703 Some(Effort::High)
704 );
705 assert_eq!(config.effort, None, "the original is untouched");
706 }
707
708 #[test]
709 fn every_lan_effort_maps_to_the_same_provider_level() {
710 use mentra::provider::ReasoningEffort;
711
712 for (effort, expected) in [
713 (Effort::Low, ReasoningEffort::Low),
714 (Effort::Medium, ReasoningEffort::Medium),
715 (Effort::High, ReasoningEffort::High),
716 (Effort::XHigh, ReasoningEffort::XHigh),
717 (Effort::Max, ReasoningEffort::Max),
718 ] {
719 assert_eq!(ReasoningEffort::from(effort), expected);
720 }
721 }
722
723 #[tokio::test]
724 async fn an_empty_prompt_is_rejected_before_any_provider_work() {
725 let config = RunConfig::new("/definitely/not/a/real/path", " \n ");
726
727 let error = prepare(config).await.expect_err("rejected");
728
729 // Reaching provider resolution or workspace validation would prove the
730 // check ran too late.
731 assert!(matches!(error, RunError::EmptyPrompt));
732 }
733
734 #[tokio::test]
735 async fn a_missing_workspace_fails_before_a_provider_is_needed() {
736 let config = RunConfig::new("/definitely/not/a/real/path", "hello");
737
738 let error = prepare(config).await.expect_err("rejected");
739
740 assert!(matches!(
741 error,
742 RunError::Context(ContextError::WorkspaceMissing { .. })
743 ));
744 }
745
746 #[test]
747 fn a_run_is_unbounded_unless_the_caller_asks_for_a_bound() {
748 let options = RunConfig::new("/repo", "prompt").turn_options();
749
750 // ADR-0014: with no scheduler shipped there is no period to default a
751 // deadline from, so bounding is explicit everywhere. An attended run
752 // has a person, and a timer that interrupted someone mid-thought would
753 // be a worse harness rather than a safer one.
754 assert_eq!(options.deadline, None);
755 assert_eq!(options.tool_budget, None);
756 assert_eq!(options.token_budget, None);
757 }
758
759 #[test]
760 fn every_bound_reaches_the_turn_as_configured() {
761 let options = RunConfig::new("/repo", "prompt")
762 .with_deadline(Duration::from_secs(3_600))
763 .with_tool_budget(12)
764 .with_token_budget(50_000)
765 .turn_options();
766
767 assert_eq!(options.deadline, Some(Duration::from_secs(3_600)));
768 assert_eq!(options.tool_budget, Some(12));
769 assert_eq!(options.token_budget, Some(50_000));
770 }
771
772 #[test]
773 fn bounding_a_config_returns_a_new_value() {
774 let base = RunConfig::new("/repo", "prompt");
775 let bounded = base.clone().with_deadline(Duration::from_secs(600));
776
777 assert_eq!(base.deadline, None, "the original must be untouched");
778 assert_eq!(bounded.deadline, Some(Duration::from_secs(600)));
779 }
780
781 #[test]
782 fn a_config_carries_no_stop_signal_of_its_own() {
783 // Cancellation belongs to whoever holds the token for one call, so a
784 // config that could carry one would be handing every turn built from
785 // it the same stop button.
786 let options = RunConfig::new("/repo", "prompt")
787 .with_deadline(Duration::from_secs(60))
788 .turn_options();
789
790 assert!(options.cancel.is_none());
791 assert!(options.stop.is_none());
792 }
793
794 #[test]
795 fn splitting_a_config_keeps_every_per_run_field() {
796 let (_, spec) = RunConfig::new("/repo", "prompt")
797 .with_session_name("named")
798 .with_effort(Effort::Max)
799 .with_deadline(Duration::from_secs(90))
800 .with_tool_budget(7)
801 .with_token_budget(1_000)
802 .split();
803
804 assert_eq!(spec.prompt, "prompt");
805 assert_eq!(spec.session_name, "named");
806 assert_eq!(spec.effort, Some(Effort::Max));
807 assert_eq!(spec.deadline, Some(Duration::from_secs(90)));
808 assert_eq!(spec.tool_budget, Some(7));
809 assert_eq!(spec.token_budget, Some(1_000));
810 }
811
812 #[tokio::test]
813 async fn splitting_a_config_keeps_every_workspace_field() {
814 // Checked by opening the builder rather than by reading fields, because
815 // the fields are private and because what matters is that the opened
816 // workspace behaves as the config asked. A missing workspace fails the
817 // same way through both paths — which it can only do if `split` carried
818 // the path and the context config across.
819 let config = RunConfig::new("/definitely/not/a/real/path", "hello")
820 .with_provider(BuiltinProvider::Anthropic)
821 .with_base_url("http://127.0.0.1:1/v1");
822 let (builder, _) = config.split();
823
824 assert!(matches!(
825 builder.open().await.expect_err("rejected"),
826 RunError::Context(ContextError::WorkspaceMissing { .. })
827 ));
828 }
829}