use std::{path::PathBuf, sync::Arc};
use mentra::{
ContentBlock, Session,
runtime::{EarlyEnd, RunOptions},
};
use serde::de::DeserializeOwned;
use serde_json::Value;
use tokio::sync::oneshot;
use self::forward::forward_events;
use super::{
Bound, EventSink, OutputReport, OutputSpec, RunError, RunReport, RunUsage, TurnOptions,
turn::{bounded, drawable},
};
use crate::{
approval::{AllowAll, Approver},
context::WorkspaceContext,
event::{ContextFile, EVENT_SCHEMA_VERSION, Event, RunOutcome, SkillSummary, TemplateSummary},
lifecycle::{LifecycleError, Supervisor, TaskHandle},
templates::Template,
workspace::Workspace,
};
mod forward;
#[derive(Debug, Clone)]
pub struct RunContext {
pub workspace: PathBuf,
pub prompt: String,
pub provider: String,
pub model: String,
pub context: WorkspaceContext,
pub skills_dirs: Vec<PathBuf>,
pub skills: Vec<LoadedSkill>,
pub templates_dirs: Vec<PathBuf>,
pub templates: Vec<Template>,
pub mcp_files: Vec<ContextFile>,
pub mcp_servers: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct LoadedSkill {
pub name: String,
pub description: String,
pub path: PathBuf,
}
pub struct PreparedRun {
session: Session,
run: RunContext,
bounds: TurnOptions,
workspace: Option<Arc<Workspace>>,
}
impl std::fmt::Debug for PreparedRun {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PreparedRun")
.field("session_id", &self.session.id().to_string())
.field("workspace", &self.run.workspace)
.field("provider", &self.run.provider)
.field("model", &self.run.model)
.field("context_files", &self.run.context.documents().len())
.field("skills", &self.run.skills.len())
.field("templates", &self.run.templates.len())
.field("mcp_servers", &self.run.mcp_servers.len())
.field("bounds", &self.bounds)
.finish_non_exhaustive()
}
}
impl PreparedRun {
pub fn new(session: Session, run: RunContext) -> Self {
Self {
session,
run,
bounds: TurnOptions::default(),
workspace: None,
}
}
pub fn with_bounds(self, bounds: TurnOptions) -> Self {
Self { bounds, ..self }
}
pub const fn bounds(&self) -> &TurnOptions {
&self.bounds
}
pub fn with_workspace(self, workspace: Arc<Workspace>) -> Self {
Self {
workspace: Some(workspace),
..self
}
}
pub fn workspace(&self) -> Option<&Arc<Workspace>> {
self.workspace.as_ref()
}
pub fn header(&self) -> Event {
header_for(&self.session.id().to_string(), &self.run)
}
pub fn session(&self) -> &Session {
&self.session
}
pub fn session_mut(&mut self) -> &mut Session {
&mut self.session
}
pub fn into_session(self) -> Session {
self.session
}
pub fn session_id(&self) -> String {
self.session.id().to_string()
}
pub fn agent_id(&self) -> &str {
self.session.agent_id()
}
pub fn history(&self) -> &[mentra::Message] {
self.session.history()
}
pub fn context(&self) -> &RunContext {
&self.run
}
pub async fn execute<S: EventSink>(&mut self, sink: S) -> Result<RunReport<S>, RunError> {
self.execute_with_approver(sink, AllowAll).await
}
pub async fn execute_with_approver<S: EventSink, A: Approver>(
&mut self,
sink: S,
approver: A,
) -> Result<RunReport<S>, RunError> {
self.execute_with_approver_and_options(sink, approver, TurnOptions::default())
.await
}
pub async fn execute_with_options<S: EventSink>(
&mut self,
sink: S,
options: TurnOptions,
) -> Result<RunReport<S>, RunError> {
self.execute_with_approver_and_options(sink, AllowAll, options)
.await
}
pub async fn execute_with_approver_and_options<S: EventSink, A: Approver>(
&mut self,
sink: S,
approver: A,
options: TurnOptions,
) -> Result<RunReport<S>, RunError> {
let prompt = self.run.prompt.clone();
self.turn(prompt, sink, approver, options).await
}
pub async fn spawn<S: EventSink, A: Approver>(
mut self,
supervisor: &Supervisor,
parent: Option<&TaskHandle>,
detached: bool,
sink: S,
approver: A,
) -> Result<TaskHandle, LifecycleError> {
supervisor
.spawn_cooperative(parent, detached, move |context| async move {
let (options, cancel) = TurnOptions::cancellable();
let cancellation = context.cancellation();
let execution = self.execute_with_approver_and_options(sink, approver, options);
tokio::pin!(execution);
let report = tokio::select! {
report = &mut execution => report,
() = cancellation.cancelled() => {
cancel.cancel();
execution.await
}
}
.map_err(|error| error.to_string())?;
match (report.outcome, report.final_message) {
(RunOutcome::Ok, Some(message)) => Ok(message.into_bytes()),
(RunOutcome::Ok, None) => {
Err("run finished successfully without a final message".to_string())
}
(RunOutcome::Error { message }, _) => Err(message),
}
})
.await
}
pub async fn send<S: EventSink, A: Approver>(
&mut self,
prompt: impl Into<String>,
sink: S,
approver: A,
) -> Result<RunReport<S>, RunError> {
self.turn(prompt.into(), sink, approver, TurnOptions::default())
.await
}
pub async fn send_with_options<S: EventSink, A: Approver>(
&mut self,
prompt: impl Into<String>,
sink: S,
approver: A,
options: TurnOptions,
) -> Result<RunReport<S>, RunError> {
self.turn(prompt.into(), sink, approver, options).await
}
pub async fn output<T: DeserializeOwned, S: EventSink, A: Approver>(
&mut self,
prompt: impl Into<String>,
spec: OutputSpec,
sink: S,
approver: A,
) -> Result<OutputReport<T, S>, RunError> {
self.typed_turn(prompt.into(), spec, sink, approver, TurnOptions::default())
.await
}
pub async fn output_with_options<T: DeserializeOwned, S: EventSink, A: Approver>(
&mut self,
prompt: impl Into<String>,
spec: OutputSpec,
sink: S,
approver: A,
options: TurnOptions,
) -> Result<OutputReport<T, S>, RunError> {
self.typed_turn(prompt.into(), spec, sink, approver, options)
.await
}
async fn turn<S: EventSink, A: Approver>(
&mut self,
prompt: String,
sink: S,
approver: A,
options: TurnOptions,
) -> Result<RunReport<S>, RunError> {
let options = bounded(options, &self.bounds);
drawable(&options)?;
let turn = self.begin(&prompt, sink, approver)?;
let run_options = options.into_run_options();
let observed = run_options.clone();
let result = self
.session
.append_turn_with_options(vec![ContentBlock::text(prompt)], run_options)
.await;
let ended = match &result {
Ok(message) => Ended::Answered(Some(message.text())),
Err(error) => Ended::Failed(error),
};
self.finish(turn, ended, &observed).await
}
async fn typed_turn<T: DeserializeOwned, S: EventSink, A: Approver>(
&mut self,
prompt: String,
spec: OutputSpec,
sink: S,
approver: A,
options: TurnOptions,
) -> Result<OutputReport<T, S>, RunError> {
let options = bounded(options, &self.bounds);
drawable(&options)?;
let turn = self.begin(&prompt, sink, approver)?;
let run_options = options.into_run_options();
let observed = run_options.clone();
let result = self
.session
.append_turn_to_output::<Value>(
vec![ContentBlock::text(prompt)],
run_options,
spec.into_terminal_spec(),
)
.await;
let typed = match result {
Ok(output) => Ok(serde_json::from_value::<T>(output.value)),
Err(error) => Err(error),
};
let ended = match &typed {
Ok(Ok(_)) => Ended::Answered(None),
Ok(Err(mismatch)) => Ended::Mismatched(mismatch),
Err(error) => Ended::Failed(error),
};
let report = self.finish(turn, ended, &observed).await?;
match typed {
Ok(Ok(value)) => Ok(OutputReport { value, report }),
Ok(Err(mismatch)) => Err(RunError::OutputMismatch(mismatch)),
Err(error) => Err(RunError::Runtime(error)),
}
}
fn begin<S: EventSink, A: Approver>(
&self,
prompt: &str,
sink: S,
approver: A,
) -> Result<Turn<S>, RunError> {
if prompt.trim().is_empty() {
return Err(RunError::EmptyPrompt);
}
let permissions = self.session.permission_handle();
let session_id = self.session.id().to_string();
let receiver = self.session.subscribe();
let mut sink = sink;
sink.emit(header_for(&session_id, &self.run))?;
let (done, done_rx) = oneshot::channel();
let forwarder = tokio::spawn(forward_events(
receiver,
sink,
done_rx,
approver,
permissions,
));
Ok(Turn {
session_id,
done,
forwarder,
})
}
async fn finish<S: EventSink>(
&self,
turn: Turn<S>,
ended: Ended<'_>,
observed: &RunOptions,
) -> Result<RunReport<S>, RunError> {
let Turn {
session_id,
done,
forwarder,
} = turn;
let _ = done.send(());
let (mut sink, usage) = forwarder.await?;
let (final_message, outcome, stopped_by) = match ended {
Ended::Answered(final_message) => {
(final_message, RunOutcome::Ok, ended_on(observed, None))
}
Ended::Failed(error) => (
None,
RunOutcome::Error {
message: error.to_string(),
},
ended_on(observed, Some(error)),
),
Ended::Mismatched(mismatch) => (
None,
RunOutcome::Error {
message: format!("output did not match the requested type: {mismatch}"),
},
None,
),
};
sink.emit(Event::RunFinished {
outcome: outcome.clone(),
stopped_by,
})?;
Ok(RunReport {
session_id,
model: self.run.model.clone(),
provider: self.run.provider.clone(),
final_message,
outcome,
stopped_by,
usage,
sink,
})
}
}
struct Turn<S> {
session_id: String,
done: oneshot::Sender<()>,
forwarder: tokio::task::JoinHandle<(S, RunUsage)>,
}
enum Ended<'a> {
Answered(Option<String>),
Failed(&'a mentra::error::RuntimeError),
Mismatched(&'a serde_json::Error),
}
fn ended_on(observed: &RunOptions, error: Option<&mentra::error::RuntimeError>) -> Option<Bound> {
match observed.ended_early() {
Some(EarlyEnd::TokenBudget) => Some(Bound::TokenBudget),
_ => error.and_then(tripped_bound),
}
}
fn tripped_bound(error: &mentra::error::RuntimeError) -> Option<Bound> {
match error {
mentra::error::RuntimeError::DeadlineExceeded => Some(Bound::Deadline),
mentra::error::RuntimeError::ToolBudgetExceeded(_) => Some(Bound::ToolBudget),
_ => None,
}
}
fn header_for(session_id: &str, run: &RunContext) -> Event {
Event::RunStarted {
schema: EVENT_SCHEMA_VERSION,
basis: env!("CARGO_PKG_VERSION").to_string(),
session_id: session_id.to_string(),
workspace: run.workspace.clone(),
model: run.model.clone(),
provider: run.provider.clone(),
context_files: run
.context
.documents()
.iter()
.map(|document| ContextFile {
path: document.path.clone(),
scope: document.scope.label(),
})
.collect(),
skills_dirs: run.skills_dirs.clone(),
skills: run
.skills
.iter()
.map(|skill| SkillSummary {
name: skill.name.clone(),
description: skill.description.clone(),
})
.collect(),
templates_dirs: run.templates_dirs.clone(),
templates: run
.templates
.iter()
.map(|template| TemplateSummary {
name: template.name.clone(),
description: template.description.clone(),
argument_hint: template.argument_hint.clone(),
})
.collect(),
mcp_files: run.mcp_files.clone(),
mcp_servers: run.mcp_servers.clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::context::{ContextDocument, ContextScope};
#[test]
fn the_header_lists_context_files_weakest_first() {
let context = WorkspaceContext::from_documents(vec![
ContextDocument {
path: PathBuf::from("/AGENTS.md"),
scope: ContextScope::Ancestor { depth: 2 },
content: "outer".to_string(),
},
ContextDocument {
path: PathBuf::from("/repo/AGENTS.md"),
scope: ContextScope::Workspace,
content: "inner".to_string(),
},
]);
let files: Vec<ContextFile> = context
.documents()
.iter()
.map(|document| ContextFile {
path: document.path.clone(),
scope: document.scope.label(),
})
.collect();
assert_eq!(files[0].scope, "ancestor:2");
assert_eq!(files[1].scope, "workspace");
}
#[test]
fn a_tripped_bound_is_told_apart_from_a_failed_run() {
use mentra::error::RuntimeError;
assert_eq!(
tripped_bound(&RuntimeError::DeadlineExceeded),
Some(Bound::Deadline)
);
assert_eq!(
tripped_bound(&RuntimeError::ToolBudgetExceeded(40)),
Some(Bound::ToolBudget)
);
assert_eq!(tripped_bound(&RuntimeError::EmptyAssistantResponse), None);
assert_eq!(tripped_bound(&RuntimeError::Cancelled), None);
}
fn recorded(end: EarlyEnd) -> RunOptions {
let slot = std::sync::OnceLock::new();
let _ = slot.set(end);
RunOptions {
early_end: std::sync::Arc::new(slot),
..RunOptions::default()
}
}
#[test]
fn a_run_that_answered_still_names_the_budget_that_ended_it() {
assert_eq!(
ended_on(&recorded(EarlyEnd::TokenBudget), None),
Some(Bound::TokenBudget)
);
}
#[test]
fn a_budget_that_ends_a_run_owing_an_answer_is_not_read_as_a_provider_failure() {
use mentra::error::RuntimeError;
assert_eq!(
ended_on(
&recorded(EarlyEnd::TokenBudget),
Some(&RuntimeError::EmptyAssistantResponse)
),
Some(Bound::TokenBudget)
);
}
#[test]
fn a_graceful_stop_is_not_reported_as_a_bound() {
use mentra::error::RuntimeError;
assert_eq!(ended_on(&recorded(EarlyEnd::StopRequested), None), None);
assert_eq!(
ended_on(
&recorded(EarlyEnd::StopRequested),
Some(&RuntimeError::EmptyAssistantResponse)
),
None
);
}
#[test]
fn a_run_that_recorded_nothing_is_classified_by_its_failure_alone() {
use mentra::error::RuntimeError;
assert_eq!(ended_on(&RunOptions::default(), None), None);
assert_eq!(
ended_on(
&RunOptions::default(),
Some(&RuntimeError::DeadlineExceeded)
),
Some(Bound::Deadline)
);
}
}