1use std::path::PathBuf;
10use std::time::Duration;
11
12use anyhow::Result;
13use tokio::time::timeout;
14
15use crate::app::Config;
16use crate::app::lifecycle::RuntimeLifecycle;
17use crate::cli::OutputFormat;
18use crate::domain::{Msg, RUN_EVENT_PROTOCOL_VERSION, RunEvent, State, TurnState, update};
19use crate::effect::EffectRunner;
20use crate::models::MessageRole;
21use crate::providers::ToolRegistry;
22
23#[derive(Debug, Default)]
25pub struct RunResult {
26 pub response: String,
27 pub reasoning: Option<String>,
28 pub total_tokens: usize,
29 pub errors: Vec<String>,
30 pub session_id: String,
33 pub structured_output: Option<serde_json::Value>,
36}
37
38#[derive(Debug, Default, Clone)]
43pub struct RunOptions {
44 pub no_execute: bool,
48 pub task_id: Option<String>,
51 pub cancel: Option<tokio_util::sync::CancellationToken>,
57 pub deadline: Option<Duration>,
60 pub stream_ndjson: bool,
65 pub seed: Option<crate::session::ConversationHistory>,
69 pub output_schema: Option<serde_json::Value>,
74 pub event_tx: Option<tokio::sync::broadcast::Sender<crate::domain::RunEvent>>,
78 pub plan: bool,
81 pub plan_autoaccept: bool,
84}
85
86pub async fn run_non_interactive_with(
89 mut config: Config,
90 cwd: PathBuf,
91 model_id: String,
92 prompt: String,
93 opts: RunOptions,
94) -> Result<RunResult> {
95 if opts.plan_autoaccept {
99 config.plan.auto_approve = true;
100 config.plan.post_approve = Some(crate::app::PlanPostApprove::Start);
101 }
102
103 let plugin_assets = crate::app::plugin_assets::load();
107 for warning in crate::app::plugin_assets::apply(&mut config, &plugin_assets) {
108 eprintln!("mermaid: {warning}");
109 }
110 let providers = std::sync::Arc::new(crate::providers::ProviderFactory::new(config.clone()));
111 let tools = if opts.no_execute {
114 std::sync::Arc::new(ToolRegistry::new())
115 } else {
116 ToolRegistry::build(
117 &config,
118 crate::providers::TuiMode::Headless,
119 providers.clone(),
120 )
121 };
122 let (mut runner, mut msg_rx) =
123 EffectRunner::pair_from_with_task(cwd.clone(), providers, tools, opts.task_id.clone());
124 runner = runner.without_terminal_title();
125
126 let stream_ndjson = opts.stream_ndjson;
128 let event_model = model_id.clone();
129
130 let mut state = State::new(config.clone(), cwd.clone(), model_id, chrono::Local::now());
131 if let Some(history) = opts.seed.clone() {
137 state.seed_conversation(history);
138 }
139 crate::app::stamp_session_provenance(&mut state, &cwd);
140 let session_id = state.session.conversation.id.clone();
141 let mut lifecycle = RuntimeLifecycle::new();
142
143 let (instructions, memory, skills) =
150 crate::app::instructions::load_project_context(&cwd, &config.memory);
151 state.instructions = instructions;
152 state.memory = memory;
153 state.skills = skills;
154 state.plugin_commands = plugin_assets.commands;
155
156 if !config.mcp_servers.is_empty() && !opts.no_execute {
162 runner.dispatch(crate::domain::Cmd::InitMcpServers(
163 config.mcp_servers.clone(),
164 ));
165 }
166
167 if !state.session.conversation.tasks.tasks.is_empty() {
170 runner.dispatch(crate::domain::Cmd::SyncTaskStore(
171 state.session.conversation.tasks.clone(),
172 ));
173 }
174
175 match crate::session::scratchpad::ensure(&cwd, &session_id) {
185 Ok(path) => state.session.scratchpad = Some(path),
186 Err(err) => tracing::warn!(%err, "scratchpad unavailable for this run"),
187 }
188
189 let started = RunEvent::SessionStarted {
192 protocol_version: RUN_EVENT_PROTOCOL_VERSION,
193 cli_version: env!("CARGO_PKG_VERSION").to_string(),
194 model: event_model,
195 task_id: opts.task_id.clone(),
196 session_id: session_id.clone(),
197 };
198 if stream_ndjson {
199 emit_run_event(&started);
200 }
201 if let Some(tx) = &opts.event_tx {
202 let _ = tx.send(started);
203 }
204
205 if opts.plan {
209 state.now = chrono::Local::now();
210 let (new_state, cmds) = update(state, Msg::Slash(crate::domain::SlashCmd::Plan(None)));
211 state = new_state;
212 for cmd in cmds {
213 runner.dispatch(cmd);
214 }
215 }
216
217 let seed = Msg::SubmitPrompt {
219 text: prompt,
220 attachment_ids: vec![],
221 };
222 state.now = chrono::Local::now();
224 let (new_state, cmds) = update(state, seed);
225 state = new_state;
226 for cmd in cmds {
227 runner.dispatch(cmd);
228 }
229
230 let deadline = opts.deadline.unwrap_or(Duration::from_secs(20 * 60));
231 let cancel = opts.cancel.clone();
232
233 let final_state = timeout(
234 deadline,
235 drive_to_idle(
236 state,
237 &mut runner,
238 &mut msg_rx,
239 &mut lifecycle,
240 cancel.as_ref(),
241 stream_ndjson,
242 opts.event_tx.as_ref(),
243 ),
244 )
245 .await
246 .map_err(|_| {
247 anyhow::anyhow!(
248 "non-interactive run exceeded {} seconds",
249 deadline.as_secs()
250 )
251 })?;
252
253 let mut result = build_result(&final_state);
254
255 if let Some(schema) = opts.output_schema.clone() {
257 let cancelled = cancel.as_ref().is_some_and(|t| t.is_cancelled());
258 if cancelled || final_state.should_exit || result.response.is_empty() {
259 result
260 .errors
261 .push("output_schema: skipped (run ended without a final answer)".to_string());
262 } else {
263 let final_state = timeout(
264 deadline,
265 run_formatting_turn(
266 final_state,
267 schema.clone(),
268 &mut runner,
269 &mut msg_rx,
270 &mut lifecycle,
271 cancel.as_ref(),
272 stream_ndjson,
273 opts.event_tx.as_ref(),
274 ),
275 )
276 .await
277 .map_err(|_| {
278 anyhow::anyhow!(
279 "output-schema formatting turn exceeded {} seconds",
280 deadline.as_secs()
281 )
282 })?;
283 apply_schema_outcome(&mut result, &final_state, &schema);
284 }
285 }
286
287 runner.shutdown().await;
288 let terminal = RunEvent::Result {
292 response: result.response.clone(),
293 reasoning: result.reasoning.clone(),
294 total_tokens: result.total_tokens as u64,
295 errors: result.errors.clone(),
296 session_id: result.session_id.clone(),
297 structured_output: result.structured_output.clone(),
298 };
299 if stream_ndjson {
300 emit_run_event(&terminal);
301 }
302 if let Some(tx) = &opts.event_tx {
303 let _ = tx.send(terminal);
304 }
305 Ok(result)
306}
307
308async fn drive_to_idle(
312 mut state: State,
313 runner: &mut EffectRunner,
314 msg_rx: &mut tokio::sync::mpsc::Receiver<Msg>,
315 lifecycle: &mut RuntimeLifecycle,
316 cancel: Option<&tokio_util::sync::CancellationToken>,
317 stream_ndjson: bool,
318 event_tx: Option<&tokio::sync::broadcast::Sender<RunEvent>>,
319) -> State {
320 const CANCEL_GRACE: Duration = Duration::from_secs(15);
323 let mut cancel_deadline: Option<tokio::time::Instant> = None;
327 loop {
328 let idle = matches!(state.turn, TurnState::Idle);
329 if drive_should_stop(
330 idle,
331 state.ui.queued_messages.is_empty(),
332 cancel_deadline.is_some(),
333 ) {
334 break;
335 }
336 let msg = tokio::select! {
337 m = msg_rx.recv() => match m {
338 Some(m) => m,
339 None => break,
340 },
341 s = lifecycle.next_msg() => match s {
342 Some(s) => s,
343 None => continue,
344 },
345 _ = async {
346 match &cancel {
347 Some(token) => token.cancelled().await,
348 None => std::future::pending().await,
349 }
350 }, if cancel.is_some() && cancel_deadline.is_none() => {
351 cancel_deadline = Some(tokio::time::Instant::now() + CANCEL_GRACE);
352 Msg::CancelTurn
353 },
354 _ = tokio::time::sleep_until(
357 cancel_deadline
358 .unwrap_or_else(|| tokio::time::Instant::now() + Duration::from_secs(86_400)),
359 ), if cancel_deadline.is_some() => {
360 tracing::warn!("cancelled run did not unwind within grace; hard-stopping");
361 break;
362 },
363 };
364 if let Msg::TransientStatus { text } = &msg {
369 eprintln!("{text}");
370 }
371 if (stream_ndjson || event_tx.is_some())
375 && let Some(event) = RunEvent::from_msg(&msg)
376 {
377 if stream_ndjson {
378 emit_run_event(&event);
379 }
380 if let Some(tx) = event_tx {
381 let _ = tx.send(event);
382 }
383 }
384 state.now = chrono::Local::now();
385 let (new_state, cmds) = update(state, msg);
386 state = new_state;
387 for cmd in cmds {
388 runner.dispatch(cmd);
389 }
390 if state.should_exit {
391 break;
392 }
393 }
394 state
395}
396
397const FORMAT_PROMPT: &str = "Convert your final answer into a single JSON object that \
399conforms to the provided schema. Respond with only the JSON object - no prose, no code fences.";
400
401#[allow(clippy::too_many_arguments)]
405async fn run_formatting_turn(
406 mut state: State,
407 schema: serde_json::Value,
408 runner: &mut EffectRunner,
409 msg_rx: &mut tokio::sync::mpsc::Receiver<Msg>,
410 lifecycle: &mut RuntimeLifecycle,
411 cancel: Option<&tokio_util::sync::CancellationToken>,
412 stream_ndjson: bool,
413 event_tx: Option<&tokio::sync::broadcast::Sender<RunEvent>>,
414) -> State {
415 state.output_schema = Some(schema);
416 state.now = chrono::Local::now();
417 let (new_state, cmds) = update(
418 state,
419 Msg::SubmitPrompt {
420 text: FORMAT_PROMPT.to_string(),
421 attachment_ids: vec![],
422 },
423 );
424 state = new_state;
425 for cmd in cmds {
426 runner.dispatch(cmd);
427 }
428 drive_to_idle(
429 state,
430 runner,
431 msg_rx,
432 lifecycle,
433 cancel,
434 stream_ndjson,
435 event_tx,
436 )
437 .await
438}
439
440fn apply_schema_outcome(result: &mut RunResult, state: &State, schema: &serde_json::Value) {
446 let formatted = build_result(state);
448 result.total_tokens = formatted.total_tokens;
449 if formatted.response.is_empty() || formatted.response == result.response {
450 result
451 .errors
452 .push("output_schema: formatting turn produced no output".to_string());
453 return;
454 }
455 let text = strip_code_fences(&formatted.response);
456 result.response = text.to_string();
457 let parsed: serde_json::Value = match serde_json::from_str(text) {
458 Ok(v) => v,
459 Err(e) => {
460 result
461 .errors
462 .push(format!("output_schema: response is not valid JSON: {e}"));
463 return;
464 },
465 };
466 let validator = match jsonschema::validator_for(schema) {
467 Ok(v) => v,
468 Err(e) => {
469 result
470 .errors
471 .push(format!("output_schema: schema did not compile: {e}"));
472 return;
473 },
474 };
475 if let Some(err) = validator.iter_errors(&parsed).next() {
476 result
477 .errors
478 .push(format!("output_schema: response does not conform: {err}"));
479 return;
480 }
481 result.structured_output = Some(parsed);
482}
483
484fn strip_code_fences(text: &str) -> &str {
487 let t = text.trim();
488 let Some(rest) = t.strip_prefix("```") else {
489 return t;
490 };
491 let Some(rest) = rest.split_once('\n').map(|(_, r)| r) else {
492 return t;
493 };
494 match rest.strip_suffix("```") {
495 Some(inner) => inner.trim(),
496 None => t,
497 }
498}
499
500fn emit_run_event(event: &RunEvent) {
502 println!("{}", serde_json::to_string(event).unwrap_or_default());
503}
504
505fn build_result(state: &State) -> RunResult {
508 let mut out = RunResult {
509 total_tokens: state.session.cumulative_token_usage.total_tokens(),
510 session_id: state.session.conversation.id.clone(),
511 ..RunResult::default()
512 };
513
514 for msg in state.session.messages() {
515 for action in &msg.actions {
516 if let crate::domain::ActionResult::Error { error } = &action.result {
517 out.errors
518 .push(format!("{}: {}", action.action_type, error));
519 }
520 }
521 }
522
523 let messages = state.session.messages();
530 if let Some(last_idx) = messages
531 .iter()
532 .rposition(|m| m.role == MessageRole::Assistant)
533 {
534 let mut head_idx = last_idx;
535 while head_idx > 0
536 && messages[head_idx].kind == crate::models::ChatMessageKind::Continuation
537 && let Some(prev_idx) = messages[..head_idx]
538 .iter()
539 .rposition(|m| m.role == MessageRole::Assistant)
540 && matches!(
543 messages[prev_idx].kind,
544 crate::models::ChatMessageKind::Normal
545 | crate::models::ChatMessageKind::Continuation
546 )
547 && messages[prev_idx].tool_calls.is_none()
548 {
549 head_idx = prev_idx;
550 }
551 let mut response = String::new();
552 let mut reasoning: Option<String> = None;
553 for msg in messages[head_idx..=last_idx]
554 .iter()
555 .filter(|m| m.role == MessageRole::Assistant)
556 {
557 let skip = crate::utils::continuation_overlap(&response, &msg.content);
558 response.push_str(&msg.content[skip..]);
559 if let Some(t) = &msg.thinking {
560 match &mut reasoning {
561 Some(r) => {
562 r.push_str("\n\n");
563 r.push_str(t);
564 },
565 None => reasoning = Some(t.clone()),
566 }
567 }
568 }
569 out.response = response;
570 out.reasoning = reasoning;
571 }
572
573 out
574}
575
576pub fn format_result(result: &RunResult, format: OutputFormat) -> String {
578 match format {
579 OutputFormat::Text => {
580 if result.response.is_empty() && !result.errors.is_empty() {
581 result.errors.join("\n")
582 } else {
583 result.response.clone()
584 }
585 },
586 OutputFormat::Markdown => {
587 let mut out = result.response.clone();
588 if !result.errors.is_empty() {
589 out.push_str("\n\n---\n\n## Errors\n\n");
590 for e in &result.errors {
591 out.push_str(&format!("- {}\n", e));
592 }
593 }
594 out
595 },
596 OutputFormat::Json => {
597 let event = RunEvent::Result {
600 response: result.response.clone(),
601 reasoning: result.reasoning.clone(),
602 total_tokens: result.total_tokens as u64,
603 errors: result.errors.clone(),
604 session_id: result.session_id.clone(),
605 structured_output: result.structured_output.clone(),
606 };
607 serde_json::to_string_pretty(&event).unwrap_or_default()
608 },
609 OutputFormat::Ndjson => {
610 String::new()
612 },
613 }
614}
615
616fn drive_should_stop(idle: bool, queue_empty: bool, cancelling: bool) -> bool {
623 idle && (queue_empty || cancelling)
624}
625
626#[cfg(test)]
627mod tests {
628 use super::{build_result, drive_should_stop};
629
630 #[test]
631 fn build_result_joins_an_auto_continued_reply() {
632 use crate::models::{ChatMessage, ChatMessageKind};
633 let mut state = crate::domain::State::new(
634 crate::app::Config::default(),
635 std::path::PathBuf::from("/tmp/p"),
636 "ollama/test".to_string(),
637 chrono::Local::now(),
638 );
639 state
640 .session
641 .append(ChatMessage::user("audit the widget"), state.now);
642 state.session.append(
643 ChatMessage::assistant("part one covers the resolver internals"),
644 state.now,
645 );
646 let mut cont = ChatMessage::assistant("the resolver internals, part two the adapters.");
649 cont.kind = ChatMessageKind::Continuation;
650 state.session.append(cont, state.now);
651
652 let result = build_result(&state);
653 assert_eq!(
654 result.response, "part one covers the resolver internals, part two the adapters.",
655 "headless output joins the whole chain, echo trimmed"
656 );
657 }
658
659 #[test]
660 fn build_result_without_chain_takes_the_last_reply() {
661 use crate::models::ChatMessage;
662 let mut state = crate::domain::State::new(
663 crate::app::Config::default(),
664 std::path::PathBuf::from("/tmp/p"),
665 "ollama/test".to_string(),
666 chrono::Local::now(),
667 );
668 state.session.append(ChatMessage::user("first"), state.now);
669 state
670 .session
671 .append(ChatMessage::assistant("earlier reply"), state.now);
672 state.session.append(ChatMessage::user("second"), state.now);
673 state
674 .session
675 .append(ChatMessage::assistant("final reply"), state.now);
676 let result = build_result(&state);
677 assert_eq!(result.response, "final reply");
678 }
679
680 #[test]
681 fn strip_code_fences_unwraps_single_fence_only() {
682 use super::strip_code_fences;
683 assert_eq!(strip_code_fences("{\"a\":1}"), "{\"a\":1}");
684 assert_eq!(strip_code_fences("```json\n{\"a\":1}\n```"), "{\"a\":1}");
685 assert_eq!(strip_code_fences("```\n{\"a\":1}\n```"), "{\"a\":1}");
686 assert_eq!(
688 strip_code_fences("```json\n{\"a\":1}"),
689 "```json\n{\"a\":1}"
690 );
691 assert_eq!(strip_code_fences(" {\"a\":1} "), "{\"a\":1}");
692 }
693
694 fn schema_state(reply: &str) -> crate::domain::State {
695 use crate::models::ChatMessage;
696 let mut state = crate::domain::State::new(
697 crate::app::Config::default(),
698 std::path::PathBuf::from("/tmp/p"),
699 "ollama/test".to_string(),
700 chrono::Local::now(),
701 );
702 state.session.append(ChatMessage::user("q"), state.now);
703 state
704 .session
705 .append(ChatMessage::assistant("the plain answer"), state.now);
706 state
707 .session
708 .append(ChatMessage::user("format it"), state.now);
709 state
710 .session
711 .append(ChatMessage::assistant(reply), state.now);
712 state
713 }
714
715 fn base_result() -> super::RunResult {
716 super::RunResult {
717 response: "the plain answer".to_string(),
718 ..super::RunResult::default()
719 }
720 }
721
722 #[test]
723 fn schema_outcome_valid_json_sets_structured_output() {
724 let schema = serde_json::json!({
725 "type": "object",
726 "properties": {"answer": {"type": "integer"}},
727 "required": ["answer"],
728 });
729 let state = schema_state("```json\n{\"answer\": 42}\n```");
730 let mut result = base_result();
731 super::apply_schema_outcome(&mut result, &state, &schema);
732 assert_eq!(result.response, "{\"answer\": 42}");
733 assert_eq!(
734 result.structured_output,
735 Some(serde_json::json!({"answer": 42}))
736 );
737 assert!(result.errors.is_empty(), "{:?}", result.errors);
738 }
739
740 #[test]
741 fn schema_outcome_invalid_json_keeps_text_and_records() {
742 let schema = serde_json::json!({"type": "object"});
743 let state = schema_state("not json at all");
744 let mut result = base_result();
745 super::apply_schema_outcome(&mut result, &state, &schema);
746 assert_eq!(result.response, "not json at all");
747 assert!(result.structured_output.is_none());
748 assert!(
749 result.errors.iter().any(|e| e.contains("not valid JSON")),
750 "{:?}",
751 result.errors
752 );
753 }
754
755 #[test]
756 fn schema_outcome_nonconforming_json_records_reason() {
757 let schema = serde_json::json!({
758 "type": "object",
759 "properties": {"answer": {"type": "integer"}},
760 "required": ["answer"],
761 });
762 let state = schema_state("{\"wrong\": true}");
763 let mut result = base_result();
764 super::apply_schema_outcome(&mut result, &state, &schema);
765 assert!(result.structured_output.is_none());
766 assert!(
767 result.errors.iter().any(|e| e.contains("does not conform")),
768 "{:?}",
769 result.errors
770 );
771 }
772
773 #[test]
774 fn schema_outcome_no_new_reply_keeps_original() {
775 let schema = serde_json::json!({"type": "object"});
778 use crate::models::ChatMessage;
779 let mut state = crate::domain::State::new(
780 crate::app::Config::default(),
781 std::path::PathBuf::from("/tmp/p"),
782 "ollama/test".to_string(),
783 chrono::Local::now(),
784 );
785 state.session.append(ChatMessage::user("q"), state.now);
786 state
787 .session
788 .append(ChatMessage::assistant("the plain answer"), state.now);
789 let mut result = base_result();
790 super::apply_schema_outcome(&mut result, &state, &schema);
791 assert_eq!(result.response, "the plain answer");
792 assert!(result.structured_output.is_none());
793 assert!(
794 result
795 .errors
796 .iter()
797 .any(|e| e.contains("produced no output")),
798 "{:?}",
799 result.errors
800 );
801 }
802
803 #[test]
804 fn result_event_carries_structured_output_to_subscribers() {
805 let event = crate::domain::RunEvent::Result {
809 response: "done".to_string(),
810 reasoning: None,
811 total_tokens: 3,
812 errors: vec![],
813 session_id: "s".to_string(),
814 structured_output: None,
815 };
816 let wire = serde_json::to_string(&event).unwrap();
817 assert!(wire.contains("\"type\":\"result\""), "{wire}");
818 let (tx, mut rx) = tokio::sync::broadcast::channel::<crate::domain::RunEvent>(4);
819 tx.send(event.clone()).unwrap();
820 assert_eq!(rx.try_recv().unwrap(), event);
821 }
822
823 #[test]
824 fn drive_keeps_running_until_idle() {
825 assert!(!drive_should_stop(false, true, false));
827 assert!(!drive_should_stop(false, true, true));
828 assert!(!drive_should_stop(false, false, true));
829 }
830
831 #[test]
832 fn drive_stops_when_idle_and_drained() {
833 assert!(drive_should_stop(true, true, false));
835 }
836
837 #[test]
838 fn drive_keeps_draining_queue_when_not_cancelling() {
839 assert!(!drive_should_stop(true, false, false));
842 }
843
844 #[test]
845 fn cancel_stops_at_idle_even_with_queued_messages() {
846 assert!(drive_should_stop(true, false, true));
849 }
850}