use std::path::PathBuf;
use std::time::Duration;
use anyhow::Result;
use tokio::time::timeout;
use crate::app::Config;
use crate::app::lifecycle::RuntimeLifecycle;
use crate::cli::OutputFormat;
use crate::domain::{Msg, RUN_EVENT_PROTOCOL_VERSION, RunEvent, State, TurnState, update};
use crate::effect::EffectRunner;
use crate::models::MessageRole;
use crate::providers::ToolRegistry;
#[derive(Debug, Default)]
pub struct RunResult {
pub response: String,
pub reasoning: Option<String>,
pub total_tokens: usize,
pub errors: Vec<String>,
pub session_id: String,
pub structured_output: Option<serde_json::Value>,
}
#[derive(Debug, Default, Clone)]
pub struct RunOptions {
pub no_execute: bool,
pub task_id: Option<String>,
pub cancel: Option<tokio_util::sync::CancellationToken>,
pub deadline: Option<Duration>,
pub stream_ndjson: bool,
pub seed: Option<crate::session::ConversationHistory>,
pub output_schema: Option<serde_json::Value>,
pub event_tx: Option<tokio::sync::broadcast::Sender<crate::domain::RunEvent>>,
pub plan: bool,
pub plan_autoaccept: bool,
}
pub async fn run_non_interactive_with(
mut config: Config,
cwd: PathBuf,
model_id: String,
prompt: String,
opts: RunOptions,
) -> Result<RunResult> {
if opts.plan_autoaccept {
config.plan.auto_approve = true;
config.plan.post_approve = Some(crate::app::PlanPostApprove::Start);
}
let plugin_assets = crate::app::plugin_assets::load();
for warning in crate::app::plugin_assets::apply(&mut config, &plugin_assets) {
eprintln!("mermaid: {warning}");
}
let providers = std::sync::Arc::new(crate::providers::ProviderFactory::new(config.clone()));
let tools = if opts.no_execute {
std::sync::Arc::new(ToolRegistry::new())
} else {
ToolRegistry::build(
&config,
crate::providers::TuiMode::Headless,
providers.clone(),
)
};
let (mut runner, mut msg_rx) =
EffectRunner::pair_from_with_task(cwd.clone(), providers, tools, opts.task_id.clone());
runner = runner.without_terminal_title();
let stream_ndjson = opts.stream_ndjson;
let event_model = model_id.clone();
let mut state = State::new(config.clone(), cwd.clone(), model_id, chrono::Local::now());
if let Some(history) = opts.seed.clone() {
state.seed_conversation(history);
}
crate::app::stamp_session_provenance(&mut state, &cwd);
let session_id = state.session.conversation.id.clone();
let mut lifecycle = RuntimeLifecycle::new();
let (instructions, memory, skills) =
crate::app::instructions::load_project_context(&cwd, &config.memory);
state.instructions = instructions;
state.memory = memory;
state.skills = skills;
state.plugin_commands = plugin_assets.commands;
if !config.mcp_servers.is_empty() && !opts.no_execute {
runner.dispatch(crate::domain::Cmd::InitMcpServers(
config.mcp_servers.clone(),
));
}
if !state.session.conversation.tasks.tasks.is_empty() {
runner.dispatch(crate::domain::Cmd::SyncTaskStore(
state.session.conversation.tasks.clone(),
));
}
match crate::session::scratchpad::ensure(&cwd, &session_id) {
Ok(path) => state.session.scratchpad = Some(path),
Err(err) => tracing::warn!(%err, "scratchpad unavailable for this run"),
}
let started = RunEvent::SessionStarted {
protocol_version: RUN_EVENT_PROTOCOL_VERSION,
cli_version: env!("CARGO_PKG_VERSION").to_string(),
model: event_model,
task_id: opts.task_id.clone(),
session_id: session_id.clone(),
};
if stream_ndjson {
emit_run_event(&started);
}
if let Some(tx) = &opts.event_tx {
let _ = tx.send(started);
}
if opts.plan {
state.now = chrono::Local::now();
let (new_state, cmds) = update(state, Msg::Slash(crate::domain::SlashCmd::Plan(None)));
state = new_state;
for cmd in cmds {
runner.dispatch(cmd);
}
}
let seed = Msg::SubmitPrompt {
text: prompt,
attachment_ids: vec![],
};
state.now = chrono::Local::now();
let (new_state, cmds) = update(state, seed);
state = new_state;
for cmd in cmds {
runner.dispatch(cmd);
}
let deadline = opts.deadline.unwrap_or(Duration::from_secs(20 * 60));
let cancel = opts.cancel.clone();
let final_state = timeout(
deadline,
drive_to_idle(
state,
&mut runner,
&mut msg_rx,
&mut lifecycle,
cancel.as_ref(),
stream_ndjson,
opts.event_tx.as_ref(),
),
)
.await
.map_err(|_| {
anyhow::anyhow!(
"non-interactive run exceeded {} seconds",
deadline.as_secs()
)
})?;
let mut result = build_result(&final_state);
if let Some(schema) = opts.output_schema.clone() {
let cancelled = cancel.as_ref().is_some_and(|t| t.is_cancelled());
if cancelled || final_state.should_exit || result.response.is_empty() {
result
.errors
.push("output_schema: skipped (run ended without a final answer)".to_string());
} else {
let final_state = timeout(
deadline,
run_formatting_turn(
final_state,
schema.clone(),
&mut runner,
&mut msg_rx,
&mut lifecycle,
cancel.as_ref(),
stream_ndjson,
opts.event_tx.as_ref(),
),
)
.await
.map_err(|_| {
anyhow::anyhow!(
"output-schema formatting turn exceeded {} seconds",
deadline.as_secs()
)
})?;
apply_schema_outcome(&mut result, &final_state, &schema);
}
}
runner.shutdown().await;
let terminal = RunEvent::Result {
response: result.response.clone(),
reasoning: result.reasoning.clone(),
total_tokens: result.total_tokens as u64,
errors: result.errors.clone(),
session_id: result.session_id.clone(),
structured_output: result.structured_output.clone(),
};
if stream_ndjson {
emit_run_event(&terminal);
}
if let Some(tx) = &opts.event_tx {
let _ = tx.send(terminal);
}
Ok(result)
}
async fn drive_to_idle(
mut state: State,
runner: &mut EffectRunner,
msg_rx: &mut tokio::sync::mpsc::Receiver<Msg>,
lifecycle: &mut RuntimeLifecycle,
cancel: Option<&tokio_util::sync::CancellationToken>,
stream_ndjson: bool,
event_tx: Option<&tokio::sync::broadcast::Sender<RunEvent>>,
) -> State {
const CANCEL_GRACE: Duration = Duration::from_secs(15);
let mut cancel_deadline: Option<tokio::time::Instant> = None;
loop {
let idle = matches!(state.turn, TurnState::Idle);
if drive_should_stop(
idle,
state.ui.queued_messages.is_empty(),
cancel_deadline.is_some(),
) {
break;
}
let msg = tokio::select! {
m = msg_rx.recv() => match m {
Some(m) => m,
None => break,
},
s = lifecycle.next_msg() => match s {
Some(s) => s,
None => continue,
},
_ = async {
match &cancel {
Some(token) => token.cancelled().await,
None => std::future::pending().await,
}
}, if cancel.is_some() && cancel_deadline.is_none() => {
cancel_deadline = Some(tokio::time::Instant::now() + CANCEL_GRACE);
Msg::CancelTurn
},
_ = tokio::time::sleep_until(
cancel_deadline
.unwrap_or_else(|| tokio::time::Instant::now() + Duration::from_secs(86_400)),
), if cancel_deadline.is_some() => {
tracing::warn!("cancelled run did not unwind within grace; hard-stopping");
break;
},
};
if let Msg::TransientStatus { text } = &msg {
eprintln!("{text}");
}
if (stream_ndjson || event_tx.is_some())
&& let Some(event) = RunEvent::from_msg(&msg)
{
if stream_ndjson {
emit_run_event(&event);
}
if let Some(tx) = event_tx {
let _ = tx.send(event);
}
}
state.now = chrono::Local::now();
let (new_state, cmds) = update(state, msg);
state = new_state;
for cmd in cmds {
runner.dispatch(cmd);
}
if state.should_exit {
break;
}
}
state
}
const FORMAT_PROMPT: &str = "Convert your final answer into a single JSON object that \
conforms to the provided schema. Respond with only the JSON object - no prose, no code fences.";
#[allow(clippy::too_many_arguments)]
async fn run_formatting_turn(
mut state: State,
schema: serde_json::Value,
runner: &mut EffectRunner,
msg_rx: &mut tokio::sync::mpsc::Receiver<Msg>,
lifecycle: &mut RuntimeLifecycle,
cancel: Option<&tokio_util::sync::CancellationToken>,
stream_ndjson: bool,
event_tx: Option<&tokio::sync::broadcast::Sender<RunEvent>>,
) -> State {
state.output_schema = Some(schema);
state.now = chrono::Local::now();
let (new_state, cmds) = update(
state,
Msg::SubmitPrompt {
text: FORMAT_PROMPT.to_string(),
attachment_ids: vec![],
},
);
state = new_state;
for cmd in cmds {
runner.dispatch(cmd);
}
drive_to_idle(
state,
runner,
msg_rx,
lifecycle,
cancel,
stream_ndjson,
event_tx,
)
.await
}
fn apply_schema_outcome(result: &mut RunResult, state: &State, schema: &serde_json::Value) {
let formatted = build_result(state);
result.total_tokens = formatted.total_tokens;
if formatted.response.is_empty() || formatted.response == result.response {
result
.errors
.push("output_schema: formatting turn produced no output".to_string());
return;
}
let text = strip_code_fences(&formatted.response);
result.response = text.to_string();
let parsed: serde_json::Value = match serde_json::from_str(text) {
Ok(v) => v,
Err(e) => {
result
.errors
.push(format!("output_schema: response is not valid JSON: {e}"));
return;
},
};
let validator = match jsonschema::validator_for(schema) {
Ok(v) => v,
Err(e) => {
result
.errors
.push(format!("output_schema: schema did not compile: {e}"));
return;
},
};
if let Some(err) = validator.iter_errors(&parsed).next() {
result
.errors
.push(format!("output_schema: response does not conform: {err}"));
return;
}
result.structured_output = Some(parsed);
}
fn strip_code_fences(text: &str) -> &str {
let t = text.trim();
let Some(rest) = t.strip_prefix("```") else {
return t;
};
let Some(rest) = rest.split_once('\n').map(|(_, r)| r) else {
return t;
};
match rest.strip_suffix("```") {
Some(inner) => inner.trim(),
None => t,
}
}
fn emit_run_event(event: &RunEvent) {
println!("{}", serde_json::to_string(event).unwrap_or_default());
}
fn build_result(state: &State) -> RunResult {
let mut out = RunResult {
total_tokens: state.session.cumulative_token_usage.total_tokens(),
session_id: state.session.conversation.id.clone(),
..RunResult::default()
};
for msg in state.session.messages() {
for action in &msg.actions {
if let crate::domain::ActionResult::Error { error } = &action.result {
out.errors
.push(format!("{}: {}", action.action_type, error));
}
}
}
let messages = state.session.messages();
if let Some(last_idx) = messages
.iter()
.rposition(|m| m.role == MessageRole::Assistant)
{
let mut head_idx = last_idx;
while head_idx > 0
&& messages[head_idx].kind == crate::models::ChatMessageKind::Continuation
&& let Some(prev_idx) = messages[..head_idx]
.iter()
.rposition(|m| m.role == MessageRole::Assistant)
&& matches!(
messages[prev_idx].kind,
crate::models::ChatMessageKind::Normal
| crate::models::ChatMessageKind::Continuation
)
&& messages[prev_idx].tool_calls.is_none()
{
head_idx = prev_idx;
}
let mut response = String::new();
let mut reasoning: Option<String> = None;
for msg in messages[head_idx..=last_idx]
.iter()
.filter(|m| m.role == MessageRole::Assistant)
{
let skip = crate::utils::continuation_overlap(&response, &msg.content);
response.push_str(&msg.content[skip..]);
if let Some(t) = &msg.thinking {
match &mut reasoning {
Some(r) => {
r.push_str("\n\n");
r.push_str(t);
},
None => reasoning = Some(t.clone()),
}
}
}
out.response = response;
out.reasoning = reasoning;
}
out
}
pub fn format_result(result: &RunResult, format: OutputFormat) -> String {
match format {
OutputFormat::Text => {
if result.response.is_empty() && !result.errors.is_empty() {
result.errors.join("\n")
} else {
result.response.clone()
}
},
OutputFormat::Markdown => {
let mut out = result.response.clone();
if !result.errors.is_empty() {
out.push_str("\n\n---\n\n## Errors\n\n");
for e in &result.errors {
out.push_str(&format!("- {}\n", e));
}
}
out
},
OutputFormat::Json => {
let event = RunEvent::Result {
response: result.response.clone(),
reasoning: result.reasoning.clone(),
total_tokens: result.total_tokens as u64,
errors: result.errors.clone(),
session_id: result.session_id.clone(),
structured_output: result.structured_output.clone(),
};
serde_json::to_string_pretty(&event).unwrap_or_default()
},
OutputFormat::Ndjson => {
String::new()
},
}
}
fn drive_should_stop(idle: bool, queue_empty: bool, cancelling: bool) -> bool {
idle && (queue_empty || cancelling)
}
#[cfg(test)]
mod tests {
use super::{build_result, drive_should_stop};
#[test]
fn build_result_joins_an_auto_continued_reply() {
use crate::models::{ChatMessage, ChatMessageKind};
let mut state = crate::domain::State::new(
crate::app::Config::default(),
std::path::PathBuf::from("/tmp/p"),
"ollama/test".to_string(),
chrono::Local::now(),
);
state
.session
.append(ChatMessage::user("audit the widget"), state.now);
state.session.append(
ChatMessage::assistant("part one covers the resolver internals"),
state.now,
);
let mut cont = ChatMessage::assistant("the resolver internals, part two the adapters.");
cont.kind = ChatMessageKind::Continuation;
state.session.append(cont, state.now);
let result = build_result(&state);
assert_eq!(
result.response, "part one covers the resolver internals, part two the adapters.",
"headless output joins the whole chain, echo trimmed"
);
}
#[test]
fn build_result_without_chain_takes_the_last_reply() {
use crate::models::ChatMessage;
let mut state = crate::domain::State::new(
crate::app::Config::default(),
std::path::PathBuf::from("/tmp/p"),
"ollama/test".to_string(),
chrono::Local::now(),
);
state.session.append(ChatMessage::user("first"), state.now);
state
.session
.append(ChatMessage::assistant("earlier reply"), state.now);
state.session.append(ChatMessage::user("second"), state.now);
state
.session
.append(ChatMessage::assistant("final reply"), state.now);
let result = build_result(&state);
assert_eq!(result.response, "final reply");
}
#[test]
fn strip_code_fences_unwraps_single_fence_only() {
use super::strip_code_fences;
assert_eq!(strip_code_fences("{\"a\":1}"), "{\"a\":1}");
assert_eq!(strip_code_fences("```json\n{\"a\":1}\n```"), "{\"a\":1}");
assert_eq!(strip_code_fences("```\n{\"a\":1}\n```"), "{\"a\":1}");
assert_eq!(
strip_code_fences("```json\n{\"a\":1}"),
"```json\n{\"a\":1}"
);
assert_eq!(strip_code_fences(" {\"a\":1} "), "{\"a\":1}");
}
fn schema_state(reply: &str) -> crate::domain::State {
use crate::models::ChatMessage;
let mut state = crate::domain::State::new(
crate::app::Config::default(),
std::path::PathBuf::from("/tmp/p"),
"ollama/test".to_string(),
chrono::Local::now(),
);
state.session.append(ChatMessage::user("q"), state.now);
state
.session
.append(ChatMessage::assistant("the plain answer"), state.now);
state
.session
.append(ChatMessage::user("format it"), state.now);
state
.session
.append(ChatMessage::assistant(reply), state.now);
state
}
fn base_result() -> super::RunResult {
super::RunResult {
response: "the plain answer".to_string(),
..super::RunResult::default()
}
}
#[test]
fn schema_outcome_valid_json_sets_structured_output() {
let schema = serde_json::json!({
"type": "object",
"properties": {"answer": {"type": "integer"}},
"required": ["answer"],
});
let state = schema_state("```json\n{\"answer\": 42}\n```");
let mut result = base_result();
super::apply_schema_outcome(&mut result, &state, &schema);
assert_eq!(result.response, "{\"answer\": 42}");
assert_eq!(
result.structured_output,
Some(serde_json::json!({"answer": 42}))
);
assert!(result.errors.is_empty(), "{:?}", result.errors);
}
#[test]
fn schema_outcome_invalid_json_keeps_text_and_records() {
let schema = serde_json::json!({"type": "object"});
let state = schema_state("not json at all");
let mut result = base_result();
super::apply_schema_outcome(&mut result, &state, &schema);
assert_eq!(result.response, "not json at all");
assert!(result.structured_output.is_none());
assert!(
result.errors.iter().any(|e| e.contains("not valid JSON")),
"{:?}",
result.errors
);
}
#[test]
fn schema_outcome_nonconforming_json_records_reason() {
let schema = serde_json::json!({
"type": "object",
"properties": {"answer": {"type": "integer"}},
"required": ["answer"],
});
let state = schema_state("{\"wrong\": true}");
let mut result = base_result();
super::apply_schema_outcome(&mut result, &state, &schema);
assert!(result.structured_output.is_none());
assert!(
result.errors.iter().any(|e| e.contains("does not conform")),
"{:?}",
result.errors
);
}
#[test]
fn schema_outcome_no_new_reply_keeps_original() {
let schema = serde_json::json!({"type": "object"});
use crate::models::ChatMessage;
let mut state = crate::domain::State::new(
crate::app::Config::default(),
std::path::PathBuf::from("/tmp/p"),
"ollama/test".to_string(),
chrono::Local::now(),
);
state.session.append(ChatMessage::user("q"), state.now);
state
.session
.append(ChatMessage::assistant("the plain answer"), state.now);
let mut result = base_result();
super::apply_schema_outcome(&mut result, &state, &schema);
assert_eq!(result.response, "the plain answer");
assert!(result.structured_output.is_none());
assert!(
result
.errors
.iter()
.any(|e| e.contains("produced no output")),
"{:?}",
result.errors
);
}
#[test]
fn result_event_carries_structured_output_to_subscribers() {
let event = crate::domain::RunEvent::Result {
response: "done".to_string(),
reasoning: None,
total_tokens: 3,
errors: vec![],
session_id: "s".to_string(),
structured_output: None,
};
let wire = serde_json::to_string(&event).unwrap();
assert!(wire.contains("\"type\":\"result\""), "{wire}");
let (tx, mut rx) = tokio::sync::broadcast::channel::<crate::domain::RunEvent>(4);
tx.send(event.clone()).unwrap();
assert_eq!(rx.try_recv().unwrap(), event);
}
#[test]
fn drive_keeps_running_until_idle() {
assert!(!drive_should_stop(false, true, false));
assert!(!drive_should_stop(false, true, true));
assert!(!drive_should_stop(false, false, true));
}
#[test]
fn drive_stops_when_idle_and_drained() {
assert!(drive_should_stop(true, true, false));
}
#[test]
fn drive_keeps_draining_queue_when_not_cancelling() {
assert!(!drive_should_stop(true, false, false));
}
#[test]
fn cancel_stops_at_idle_even_with_queued_messages() {
assert!(drive_should_stop(true, false, true));
}
}