mod output;
mod prepared;
mod sink;
mod turn;
mod usage;
use std::{path::PathBuf, sync::Arc, time::Duration};
use mentra::{BuiltinProvider, ModelSelector, Session};
use thiserror::Error;
#[cfg(feature = "mcp")]
use crate::mcp::{McpConfig, McpError};
use crate::{
approval::Approver,
context::{ContextConfig, ContextError, WorkspaceContext},
event::RunOutcome,
hooks::HooksConfig,
provider::ProviderError,
shell::ShellAccess,
skills::SkillsConfig,
templates::TemplatesConfig,
workspace::{
DEFAULT_SESSION_NAME, RunSpec, Workspace, WorkspaceBuilder, load_templates,
resolved_workspace,
},
};
pub use output::{OutputReport, OutputSpec};
pub use prepared::{LoadedSkill, PreparedRun, RunContext};
pub use sink::{
CollectingSink, EventFanIn, EventSink, FnSink, MergedEvents, NullSink, TaggedEvent, TaggedSink,
};
pub use turn::TurnOptions;
pub use usage::RunUsage;
pub use mentra::runtime::CancellationToken;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Effort {
Low,
Medium,
High,
XHigh,
Max,
}
impl From<Effort> for mentra::provider::ReasoningEffort {
fn from(effort: Effort) -> Self {
match effort {
Effort::Low => Self::Low,
Effort::Medium => Self::Medium,
Effort::High => Self::High,
Effort::XHigh => Self::XHigh,
Effort::Max => Self::Max,
}
}
}
#[derive(Debug, Clone)]
pub struct RunConfig {
pub workspace: PathBuf,
pub prompt: String,
pub provider: Option<BuiltinProvider>,
pub base_url: Option<String>,
pub model: ModelSelector,
pub context: ContextConfig,
pub skills: SkillsConfig,
#[cfg(feature = "mcp")]
pub mcp: McpConfig,
pub templates: TemplatesConfig,
pub hooks: HooksConfig,
pub shell: ShellAccess,
pub effort: Option<Effort>,
pub deadline: Option<Duration>,
pub tool_budget: Option<usize>,
pub token_budget: Option<u64>,
pub session_name: String,
}
impl RunConfig {
pub fn new(workspace: impl Into<PathBuf>, prompt: impl Into<String>) -> Self {
Self {
workspace: workspace.into(),
prompt: prompt.into(),
provider: None,
base_url: None,
model: ModelSelector::NewestAvailable,
context: ContextConfig::default(),
skills: SkillsConfig::default(),
#[cfg(feature = "mcp")]
mcp: McpConfig::default(),
templates: TemplatesConfig::default(),
hooks: HooksConfig::default(),
shell: ShellAccess::default(),
effort: None,
deadline: None,
tool_budget: None,
token_budget: None,
session_name: DEFAULT_SESSION_NAME.to_string(),
}
}
pub fn with_provider(self, provider: BuiltinProvider) -> Self {
Self {
provider: Some(provider),
..self
}
}
pub fn with_base_url(self, base_url: impl Into<String>) -> Self {
Self {
base_url: Some(base_url.into()),
..self
}
}
pub fn with_model(self, model: ModelSelector) -> Self {
Self { model, ..self }
}
pub fn with_context(self, context: ContextConfig) -> Self {
Self { context, ..self }
}
pub fn with_skills(self, skills: SkillsConfig) -> Self {
Self { skills, ..self }
}
#[cfg(feature = "mcp")]
pub fn with_mcp(self, mcp: McpConfig) -> Self {
Self { mcp, ..self }
}
pub fn with_templates(self, templates: TemplatesConfig) -> Self {
Self { templates, ..self }
}
pub fn with_hooks(self, hooks: HooksConfig) -> Self {
Self { hooks, ..self }
}
pub fn with_shell(self, shell: ShellAccess) -> Self {
Self { shell, ..self }
}
pub fn with_effort(self, effort: Effort) -> Self {
Self {
effort: Some(effort),
..self
}
}
pub fn with_session_name(self, session_name: impl Into<String>) -> Self {
Self {
session_name: session_name.into(),
..self
}
}
pub fn with_deadline(self, deadline: Duration) -> Self {
Self {
deadline: Some(deadline),
..self
}
}
pub fn with_tool_budget(self, tool_budget: usize) -> Self {
Self {
tool_budget: Some(tool_budget),
..self
}
}
pub fn with_token_budget(self, token_budget: u64) -> Self {
Self {
token_budget: Some(token_budget),
..self
}
}
pub fn turn_options(&self) -> TurnOptions {
self.spec().turn_options()
}
pub fn split(&self) -> (WorkspaceBuilder, RunSpec) {
let mut runtime = crate::runtime::Runtime::builder();
if let Some(provider) = self.provider {
runtime = runtime.with_provider(provider);
}
if let Some(base_url) = &self.base_url {
runtime = runtime.with_base_url(base_url.clone());
}
#[allow(unused_mut, reason = "mutated only when the mcp feature is on")]
let mut builder = Workspace::builder(&self.workspace)
.with_runtime_builder(runtime)
.with_model(self.model.clone())
.with_context(self.context.clone())
.with_skills(self.skills.clone())
.with_templates(self.templates.clone())
.with_hooks(self.hooks.clone())
.with_shell(self.shell);
#[cfg(feature = "mcp")]
{
builder = builder.with_mcp(self.mcp.clone());
}
(builder, self.spec())
}
fn spec(&self) -> RunSpec {
RunSpec {
prompt: self.prompt.clone(),
session_name: self.session_name.clone(),
effort: self.effort,
deadline: self.deadline,
tool_budget: self.tool_budget,
token_budget: self.token_budget,
budget: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Bound {
Deadline,
ToolBudget,
TokenBudget,
}
#[derive(Debug)]
pub struct RunReport<S> {
pub session_id: String,
pub model: String,
pub provider: String,
pub final_message: Option<String>,
pub outcome: RunOutcome,
pub stopped_by: Option<Bound>,
pub usage: RunUsage,
pub sink: S,
}
impl<S> RunReport<S> {
pub fn succeeded(&self) -> bool {
matches!(self.outcome, RunOutcome::Ok)
}
}
#[derive(Debug, Error)]
pub enum RunError {
#[error("prompt is empty")]
EmptyPrompt,
#[error("the shared token budget is spent: {spent} of {limit} tokens reported")]
BudgetExhausted { limit: u64, spent: u64 },
#[error("no session to resume")]
NoSuchSession,
#[error(transparent)]
Context(#[from] ContextError),
#[error(transparent)]
Provider(#[from] ProviderError),
#[error("runtime error: {0}")]
Runtime(#[from] mentra::error::RuntimeError),
#[error("the run's output did not match the requested type: {0}")]
OutputMismatch(#[source] serde_json::Error),
#[error("failed to write an event: {0}")]
Sink(#[from] std::io::Error),
#[error("event forwarding task failed: {0}")]
Forwarder(#[from] tokio::task::JoinError),
#[error("failed to load skills: {0}")]
Skills(#[from] mentra::SkillLoadError),
#[error(transparent)]
#[cfg(feature = "mcp")]
Mcp(#[from] McpError),
#[error("failed to load prompt templates: {0}")]
Templates(#[from] crate::templates::TemplateError),
#[error("failed to load hooks: {0}")]
Hooks(#[from] crate::hooks::HookConfigError),
}
pub async fn run<S: EventSink>(config: RunConfig, sink: S) -> Result<RunReport<S>, RunError> {
prepare(config).await?.execute(sink).await
}
pub async fn run_with_approver<S: EventSink, A: Approver>(
config: RunConfig,
sink: S,
approver: A,
) -> Result<RunReport<S>, RunError> {
prepare(config)
.await?
.execute_with_approver(sink, approver)
.await
}
pub async fn prepare(config: RunConfig) -> Result<PreparedRun, RunError> {
if config.prompt.trim().is_empty() {
return Err(RunError::EmptyPrompt);
}
prepare_without_prompt(config).await
}
pub async fn prepare_without_prompt(config: RunConfig) -> Result<PreparedRun, RunError> {
let (builder, spec) = config.split();
mint_carrying_workspace(builder, |workspace| workspace.prepare(spec)).await
}
pub async fn resume(agent_id: &str, config: RunConfig) -> Result<PreparedRun, RunError> {
let (builder, spec) = config.split();
mint_carrying_workspace(builder, |workspace| workspace.resume(agent_id, spec)).await
}
async fn mint_carrying_workspace(
builder: WorkspaceBuilder,
mint: impl FnOnce(&Workspace) -> Result<PreparedRun, RunError>,
) -> Result<PreparedRun, RunError> {
let workspace = Arc::new(builder.open().await?);
let prepared = mint(&workspace)?;
Ok(prepared.with_workspace(workspace))
}
pub fn prepare_with_session(
session: Session,
config: &RunConfig,
provider: impl Into<String>,
model: impl Into<String>,
) -> Result<PreparedRun, RunError> {
let context = WorkspaceContext::discover_with(&config.workspace, &config.context)?;
let (templates_dirs, templates) = load_templates(&config.workspace, &config.templates)?;
Ok(PreparedRun::new(
session,
RunContext {
workspace: resolved_workspace(&config.workspace, &context),
prompt: config.prompt.clone(),
provider: provider.into(),
model: model.into(),
context,
skills_dirs: Vec::new(),
skills: Vec::new(),
templates_dirs,
templates,
mcp_files: Vec::new(),
mcp_servers: Vec::new(),
},
)
.with_bounds(config.turn_options()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_config_carries_no_task_specific_defaults() {
let config = RunConfig::new("/repo", "do the thing");
assert_eq!(config.provider, None);
assert!(matches!(config.model, ModelSelector::NewestAvailable));
assert_eq!(config.session_name, DEFAULT_SESSION_NAME);
}
#[test]
fn builders_return_new_values() {
let base = RunConfig::new("/repo", "prompt");
let derived = base
.clone()
.with_provider(BuiltinProvider::Anthropic)
.with_session_name("named");
assert_eq!(base.provider, None, "the original must be untouched");
assert_eq!(derived.provider, Some(BuiltinProvider::Anthropic));
assert_eq!(derived.session_name, "named");
}
#[test]
fn commands_are_available_unless_the_caller_says_otherwise() {
let config = RunConfig::new("/repo", "prompt");
assert_eq!(config.shell, ShellAccess::Granted);
assert!(config.shell.is_granted());
}
#[test]
fn denying_shell_returns_a_new_config() {
let base = RunConfig::new("/repo", "prompt");
let denied = base.clone().with_shell(ShellAccess::Denied);
assert_eq!(
base.shell,
ShellAccess::Granted,
"the original is untouched"
);
assert_eq!(denied.shell, ShellAccess::Denied);
}
#[test]
fn asking_for_no_effort_leaves_the_provider_default() {
let config = RunConfig::new("/repo", "prompt");
assert_eq!(config.effort, None);
assert_eq!(
config.clone().with_effort(Effort::High).effort,
Some(Effort::High)
);
assert_eq!(config.effort, None, "the original is untouched");
}
#[test]
fn every_lan_effort_maps_to_the_same_provider_level() {
use mentra::provider::ReasoningEffort;
for (effort, expected) in [
(Effort::Low, ReasoningEffort::Low),
(Effort::Medium, ReasoningEffort::Medium),
(Effort::High, ReasoningEffort::High),
(Effort::XHigh, ReasoningEffort::XHigh),
(Effort::Max, ReasoningEffort::Max),
] {
assert_eq!(ReasoningEffort::from(effort), expected);
}
}
#[tokio::test]
async fn an_empty_prompt_is_rejected_before_any_provider_work() {
let config = RunConfig::new("/definitely/not/a/real/path", " \n ");
let error = prepare(config).await.expect_err("rejected");
assert!(matches!(error, RunError::EmptyPrompt));
}
#[tokio::test]
async fn a_missing_workspace_fails_before_a_provider_is_needed() {
let config = RunConfig::new("/definitely/not/a/real/path", "hello");
let error = prepare(config).await.expect_err("rejected");
assert!(matches!(
error,
RunError::Context(ContextError::WorkspaceMissing { .. })
));
}
#[test]
fn a_run_is_unbounded_unless_the_caller_asks_for_a_bound() {
let options = RunConfig::new("/repo", "prompt").turn_options();
assert_eq!(options.deadline, None);
assert_eq!(options.tool_budget, None);
assert_eq!(options.token_budget, None);
}
#[test]
fn every_bound_reaches_the_turn_as_configured() {
let options = RunConfig::new("/repo", "prompt")
.with_deadline(Duration::from_secs(3_600))
.with_tool_budget(12)
.with_token_budget(50_000)
.turn_options();
assert_eq!(options.deadline, Some(Duration::from_secs(3_600)));
assert_eq!(options.tool_budget, Some(12));
assert_eq!(options.token_budget, Some(50_000));
}
#[test]
fn bounding_a_config_returns_a_new_value() {
let base = RunConfig::new("/repo", "prompt");
let bounded = base.clone().with_deadline(Duration::from_secs(600));
assert_eq!(base.deadline, None, "the original must be untouched");
assert_eq!(bounded.deadline, Some(Duration::from_secs(600)));
}
#[test]
fn a_config_carries_no_stop_signal_of_its_own() {
let options = RunConfig::new("/repo", "prompt")
.with_deadline(Duration::from_secs(60))
.turn_options();
assert!(options.cancel.is_none());
assert!(options.stop.is_none());
}
#[test]
fn splitting_a_config_keeps_every_per_run_field() {
let (_, spec) = RunConfig::new("/repo", "prompt")
.with_session_name("named")
.with_effort(Effort::Max)
.with_deadline(Duration::from_secs(90))
.with_tool_budget(7)
.with_token_budget(1_000)
.split();
assert_eq!(spec.prompt, "prompt");
assert_eq!(spec.session_name, "named");
assert_eq!(spec.effort, Some(Effort::Max));
assert_eq!(spec.deadline, Some(Duration::from_secs(90)));
assert_eq!(spec.tool_budget, Some(7));
assert_eq!(spec.token_budget, Some(1_000));
}
#[tokio::test]
async fn splitting_a_config_keeps_every_workspace_field() {
let config = RunConfig::new("/definitely/not/a/real/path", "hello")
.with_provider(BuiltinProvider::Anthropic)
.with_base_url("http://127.0.0.1:1/v1");
let (builder, _) = config.split();
assert!(matches!(
builder.open().await.expect_err("rejected"),
RunError::Context(ContextError::WorkspaceMissing { .. })
));
}
}