Skip to main content

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}