1use std::path::PathBuf;
10use std::time::Duration;
11
12use anyhow::Result;
13
14use crate::app::lifecycle::RuntimeLifecycle;
15use crate::cli::OutputFormat;
16use crate::effect::EffectRunner;
17use crate::engine::{
18 DriveExit, DrivePolicy, Engine, EngineHandle, Inbox, Observation, OnCancel, StepObserver,
19 StopWhen,
20};
21use crate::providers::ToolRegistry;
22use mermaid_domain::Config;
23use mermaid_domain::{Msg, RUN_EVENT_PROTOCOL_VERSION, RunEvent, State};
24use mermaid_model::models::MessageRole;
25
26#[derive(Debug, Default)]
28pub struct RunResult {
29 pub response: String,
30 pub reasoning: Option<String>,
31 pub total_tokens: usize,
32 pub errors: Vec<String>,
33 pub session_id: String,
36 pub structured_output: Option<serde_json::Value>,
39}
40
41#[derive(Debug, Default, Clone)]
46pub struct RunOptions {
47 pub no_execute: bool,
51 pub task_id: Option<String>,
54 pub cancel: Option<tokio_util::sync::CancellationToken>,
60 pub deadline: Option<Duration>,
63 pub stream_ndjson: bool,
68 pub seed: Option<mermaid_domain::ConversationHistory>,
72 pub output_schema: Option<serde_json::Value>,
77 pub event_tx: Option<tokio::sync::broadcast::Sender<mermaid_domain::RunEvent>>,
81 pub handle_tx: Option<tokio::sync::mpsc::Sender<EngineHandle<RunEvent>>>,
92 pub plan: bool,
95 pub plan_autoaccept: bool,
98}
99
100#[expect(
111 clippy::too_many_lines,
112 reason = "predates the lint; see .github/baselines/expect_budget.txt"
113)]
114pub async fn run_non_interactive_with(
115 mut config: Config,
116 cwd: PathBuf,
117 model_id: String,
118 prompt: String,
119 opts: RunOptions,
120) -> Result<RunResult> {
121 if opts.plan_autoaccept {
125 config.plan.auto_approve = true;
126 config.plan.post_approve = Some(mermaid_domain::PlanPostApprove::Start);
127 }
128
129 let plugin_assets = crate::app::plugin_assets::load();
133 for warning in crate::app::plugin_assets::apply(&mut config, &plugin_assets) {
134 eprintln!("mermaid: {warning}");
135 }
136 let providers = std::sync::Arc::new(crate::providers::ProviderFactory::new(config.clone()));
137 let tools = if opts.no_execute {
140 std::sync::Arc::new(ToolRegistry::new())
141 } else {
142 ToolRegistry::build(
143 &config,
144 crate::providers::TuiMode::Headless,
145 providers.clone(),
146 )
147 };
148 let (mut runner, mut msg_rx) =
149 EffectRunner::pair_from_with_task(cwd.clone(), providers, tools, opts.task_id.clone());
150 runner = runner.without_terminal_title();
151
152 let stream_ndjson = opts.stream_ndjson;
154 let event_model = model_id.clone();
155
156 let event_tx = match (opts.event_tx.clone(), opts.handle_tx.is_some()) {
162 (Some(tx), _) => Some(tx),
163 (None, true) => Some(tokio::sync::broadcast::channel(RUN_EVENT_BUS_CAPACITY).0),
164 (None, false) => None,
165 };
166
167 let mut state = State::new(
168 config.clone(),
169 cwd.clone(),
170 model_id,
171 chrono::Local::now(),
172 std::env::temp_dir(),
173 );
174 if let Some(history) = opts.seed.clone() {
180 state.seed_conversation(history);
181 }
182 state
183 .ui
184 .pending_msgs
185 .push_back(Msg::SessionProvenanceResolved(
186 crate::session::probe_session_provenance(&cwd),
187 ));
188 let session_id = state.session.conversation.id.clone();
189 let mut lifecycle = RuntimeLifecycle::new();
190
191 let (instructions, memory, skills) =
198 crate::app::instructions::load_project_context(&cwd, &config.memory);
199 state.instructions = instructions;
200 state.memory = memory;
201 state.skills = skills;
202 state.plugin_commands = plugin_assets.commands;
203
204 if !config.mcp_servers.is_empty() && !opts.no_execute {
210 runner.dispatch(mermaid_domain::Cmd::InitMcpServers(
211 config.mcp_servers.clone(),
212 ));
213 }
214
215 if !state.session.conversation.tasks.tasks.is_empty() {
218 runner.dispatch(mermaid_domain::Cmd::SyncTaskStore(
219 state.session.conversation.tasks.clone(),
220 ));
221 }
222
223 match crate::session::scratchpad::ensure(&cwd, &session_id) {
233 Ok(path) => state.session.scratchpad = Some(path),
234 Err(err) => tracing::warn!(%err, "scratchpad unavailable for this run"),
235 }
236
237 let started = RunEvent::SessionStarted {
240 protocol_version: RUN_EVENT_PROTOCOL_VERSION,
241 cli_version: env!("CARGO_PKG_VERSION").to_string(),
242 model: event_model,
243 task_id: opts.task_id.clone(),
244 session_id: session_id.clone(),
245 };
246 if stream_ndjson {
247 emit_run_event(&started);
248 }
249 if let Some(tx) = &event_tx {
250 let _ = tx.send(started);
251 }
252
253 if let Some((want, events)) = opts.handle_tx.as_ref().zip(event_tx.clone()) {
259 let _ = want.try_send(EngineHandle::new(runner.sender(), events));
260 }
261
262 let mut engine = Engine::new(state, runner).with_observer(RunStream {
265 stream_ndjson,
266 event_tx: event_tx.clone(),
267 });
268
269 if opts.plan {
277 engine.reduce(
278 chrono::Local::now(),
279 Msg::Slash(mermaid_domain::SlashCmd::Plan(None)),
280 );
281 }
282
283 engine.reduce(
285 chrono::Local::now(),
286 Msg::SubmitPrompt {
287 text: prompt,
288 attachment_ids: vec![],
289 },
290 );
291
292 let deadline = opts.deadline.unwrap_or(Duration::from_secs(20 * 60));
293 let cancel = opts.cancel.clone();
294 let policy = DrivePolicy {
295 stop: StopWhen::Settled,
296 cancel: cancel.clone(),
297 on_cancel: OnCancel::Unwind {
300 grace: CANCEL_GRACE,
301 },
302 deadline: Some(deadline),
303 };
304 let mut inbox = Inbox::new(&mut msg_rx).with_lifecycle(&mut lifecycle);
305
306 if engine.drive(&mut inbox, &policy).await == DriveExit::TimedOut {
307 let (_, runner, _) = engine.into_parts();
308 return timed_out(runner, "non-interactive run", deadline).await;
309 }
310
311 let mut result = build_result(engine.state());
312
313 if let Some(schema) = opts.output_schema.clone() {
315 let cancelled = cancel.as_ref().is_some_and(|t| t.is_cancelled());
316 if cancelled || engine.state().should_exit || result.response.is_empty() {
317 result
318 .errors
319 .push("output_schema: skipped (run ended without a final answer)".to_string());
320 } else {
321 let exit = run_formatting_turn(&mut engine, schema.clone(), &mut inbox, &policy).await;
322 if exit == DriveExit::TimedOut {
323 let (_, runner, _) = engine.into_parts();
324 return timed_out(runner, "output-schema formatting turn", deadline).await;
325 }
326 apply_schema_outcome(&mut result, engine.state(), &schema);
327 }
328 }
329
330 let (_, runner, _) = engine.into_parts();
331 runner.shutdown().await;
332 let terminal = RunEvent::Result {
336 response: result.response.clone(),
337 reasoning: result.reasoning.clone(),
338 total_tokens: result.total_tokens as u64,
339 errors: result.errors.clone(),
340 session_id: result.session_id.clone(),
341 structured_output: result.structured_output.clone(),
342 };
343 if stream_ndjson {
344 emit_run_event(&terminal);
345 }
346 if let Some(tx) = &event_tx {
347 let _ = tx.send(terminal);
348 }
349 Ok(result)
350}
351
352const RUN_EVENT_BUS_CAPACITY: usize = 1024;
356
357const CANCEL_GRACE: Duration = Duration::from_secs(15);
360
361struct RunStream {
367 stream_ndjson: bool,
368 event_tx: Option<tokio::sync::broadcast::Sender<RunEvent>>,
369}
370
371impl StepObserver for RunStream {
372 async fn observe(&mut self, obs: Observation<'_>) {
373 if let Msg::TransientStatus { text } = obs.msg {
378 eprintln!("{text}");
379 }
380 if !self.stream_ndjson && self.event_tx.is_none() {
381 return;
382 }
383 let Some(event) = RunEvent::from_msg(obs.msg) else {
384 return;
385 };
386 if self.stream_ndjson {
387 emit_run_event(&event);
388 }
389 if let Some(tx) = &self.event_tx {
390 let _ = tx.send(event);
391 }
392 }
393}
394
395async fn timed_out(runner: EffectRunner, what: &str, deadline: Duration) -> Result<RunResult> {
407 runner.shutdown().await;
408 Err(anyhow::anyhow!(
409 "{what} exceeded {} seconds",
410 deadline.as_secs()
411 ))
412}
413
414const FORMAT_PROMPT: &str = "Convert your final answer into a single JSON object that \
416conforms to the provided schema. Respond with only the JSON object - no prose, no code fences.";
417
418async fn run_formatting_turn<O: StepObserver>(
422 engine: &mut Engine<EffectRunner, O>,
423 schema: serde_json::Value,
424 inbox: &mut Inbox<'_>,
425 policy: &DrivePolicy,
426) -> DriveExit {
427 engine.state_mut().output_schema = Some(schema);
428 engine.reduce(
429 chrono::Local::now(),
430 Msg::SubmitPrompt {
431 text: FORMAT_PROMPT.to_string(),
432 attachment_ids: vec![],
433 },
434 );
435 engine.drive(inbox, policy).await
436}
437
438fn apply_schema_outcome(result: &mut RunResult, state: &State, schema: &serde_json::Value) {
444 let formatted = build_result(state);
446 result.total_tokens = formatted.total_tokens;
447 if formatted.response.is_empty() || formatted.response == result.response {
448 result
449 .errors
450 .push("output_schema: formatting turn produced no output".to_string());
451 return;
452 }
453 let text = strip_code_fences(&formatted.response);
454 result.response = text.to_string();
455 let parsed: serde_json::Value = match serde_json::from_str(text) {
456 Ok(v) => v,
457 Err(e) => {
458 result
459 .errors
460 .push(format!("output_schema: response is not valid JSON: {e}"));
461 return;
462 },
463 };
464 let validator = match jsonschema::validator_for(schema) {
465 Ok(v) => v,
466 Err(e) => {
467 result
468 .errors
469 .push(format!("output_schema: schema did not compile: {e}"));
470 return;
471 },
472 };
473 if let Some(err) = validator.iter_errors(&parsed).next() {
474 result
475 .errors
476 .push(format!("output_schema: response does not conform: {err}"));
477 return;
478 }
479 result.structured_output = Some(parsed);
480}
481
482fn strip_code_fences(text: &str) -> &str {
485 let t = text.trim();
486 let Some(rest) = t.strip_prefix("```") else {
487 return t;
488 };
489 let Some(rest) = rest.split_once('\n').map(|(_, r)| r) else {
490 return t;
491 };
492 match rest.strip_suffix("```") {
493 Some(inner) => inner.trim(),
494 None => t,
495 }
496}
497
498fn emit_run_event(event: &RunEvent) {
500 println!("{}", serde_json::to_string(event).unwrap_or_default());
501}
502
503fn build_result(state: &State) -> RunResult {
506 let mut out = RunResult {
507 total_tokens: state.session.cumulative_token_usage.total_tokens(),
508 session_id: state.session.conversation.id.clone(),
509 ..RunResult::default()
510 };
511
512 for msg in state.session.messages() {
513 for action in &msg.actions {
514 if let mermaid_domain::ActionResult::Error { error } = &action.result {
515 out.errors
516 .push(format!("{}: {}", action.action_type, error));
517 }
518 }
519 }
520
521 let messages = state.session.messages();
528 if let Some(last_idx) = messages
529 .iter()
530 .rposition(|m| m.role == MessageRole::Assistant)
531 {
532 let mut head_idx = last_idx;
533 while head_idx > 0
534 && messages[head_idx].kind == mermaid_model::models::ChatMessageKind::Continuation
535 && let Some(prev_idx) = messages[..head_idx]
536 .iter()
537 .rposition(|m| m.role == MessageRole::Assistant)
538 && matches!(
541 messages[prev_idx].kind,
542 mermaid_model::models::ChatMessageKind::Normal
543 | mermaid_model::models::ChatMessageKind::Continuation
544 )
545 && messages[prev_idx].tool_calls.is_none()
546 {
547 head_idx = prev_idx;
548 }
549 let mut response = String::new();
550 let mut reasoning: Option<String> = None;
551 for msg in messages[head_idx..=last_idx]
552 .iter()
553 .filter(|m| m.role == MessageRole::Assistant)
554 {
555 let skip = mermaid_model::utils::continuation_overlap(&response, &msg.content);
556 response.push_str(&msg.content[skip..]);
557 if let Some(t) = &msg.thinking {
558 match &mut reasoning {
559 Some(r) => {
560 r.push_str("\n\n");
561 r.push_str(t);
562 },
563 None => reasoning = Some(t.clone()),
564 }
565 }
566 }
567 out.response = response;
568 out.reasoning = reasoning;
569 }
570
571 out
572}
573
574#[must_use]
576pub fn format_result(result: &RunResult, format: OutputFormat) -> String {
577 match format {
578 OutputFormat::Text => {
579 if result.response.is_empty() && !result.errors.is_empty() {
580 result.errors.join("\n")
581 } else {
582 result.response.clone()
583 }
584 },
585 OutputFormat::Markdown => {
586 let mut out = result.response.clone();
587 if !result.errors.is_empty() {
588 out.push_str("\n\n---\n\n## Errors\n\n");
589 for e in &result.errors {
590 out.push_str(&format!("- {e}\n"));
591 }
592 }
593 out
594 },
595 OutputFormat::Json => {
596 let event = RunEvent::Result {
599 response: result.response.clone(),
600 reasoning: result.reasoning.clone(),
601 total_tokens: result.total_tokens as u64,
602 errors: result.errors.clone(),
603 session_id: result.session_id.clone(),
604 structured_output: result.structured_output.clone(),
605 };
606 serde_json::to_string_pretty(&event).unwrap_or_default()
607 },
608 OutputFormat::Ndjson => {
609 String::new()
611 },
612 }
613}
614
615#[cfg(test)]
616mod tests {
617 use super::build_result;
618
619 #[test]
620 fn build_result_joins_an_auto_continued_reply() {
621 use mermaid_model::models::{ChatMessage, ChatMessageKind};
622 let mut state = mermaid_domain::State::new(
623 mermaid_domain::Config::default(),
624 std::path::PathBuf::from("/tmp/p"),
625 "ollama/test".to_string(),
626 chrono::Local::now(),
627 std::path::PathBuf::from("/tmp"),
628 );
629 state
630 .session
631 .append(ChatMessage::user("audit the widget"), state.now);
632 state.session.append(
633 ChatMessage::assistant("part one covers the resolver internals"),
634 state.now,
635 );
636 let mut cont = ChatMessage::assistant("the resolver internals, part two the adapters.");
639 cont.kind = ChatMessageKind::Continuation;
640 state.session.append(cont, state.now);
641
642 let result = build_result(&state);
643 assert_eq!(
644 result.response, "part one covers the resolver internals, part two the adapters.",
645 "headless output joins the whole chain, echo trimmed"
646 );
647 }
648
649 #[test]
650 fn build_result_without_chain_takes_the_last_reply() {
651 use mermaid_model::models::ChatMessage;
652 let mut state = mermaid_domain::State::new(
653 mermaid_domain::Config::default(),
654 std::path::PathBuf::from("/tmp/p"),
655 "ollama/test".to_string(),
656 chrono::Local::now(),
657 std::path::PathBuf::from("/tmp"),
658 );
659 state.session.append(ChatMessage::user("first"), state.now);
660 state
661 .session
662 .append(ChatMessage::assistant("earlier reply"), state.now);
663 state.session.append(ChatMessage::user("second"), state.now);
664 state
665 .session
666 .append(ChatMessage::assistant("final reply"), state.now);
667 let result = build_result(&state);
668 assert_eq!(result.response, "final reply");
669 }
670
671 #[test]
672 fn strip_code_fences_unwraps_single_fence_only() {
673 use super::strip_code_fences;
674 assert_eq!(strip_code_fences("{\"a\":1}"), "{\"a\":1}");
675 assert_eq!(strip_code_fences("```json\n{\"a\":1}\n```"), "{\"a\":1}");
676 assert_eq!(strip_code_fences("```\n{\"a\":1}\n```"), "{\"a\":1}");
677 assert_eq!(
679 strip_code_fences("```json\n{\"a\":1}"),
680 "```json\n{\"a\":1}"
681 );
682 assert_eq!(strip_code_fences(" {\"a\":1} "), "{\"a\":1}");
683 }
684
685 fn schema_state(reply: &str) -> mermaid_domain::State {
686 use mermaid_model::models::ChatMessage;
687 let mut state = mermaid_domain::State::new(
688 mermaid_domain::Config::default(),
689 std::path::PathBuf::from("/tmp/p"),
690 "ollama/test".to_string(),
691 chrono::Local::now(),
692 std::path::PathBuf::from("/tmp"),
693 );
694 state.session.append(ChatMessage::user("q"), state.now);
695 state
696 .session
697 .append(ChatMessage::assistant("the plain answer"), state.now);
698 state
699 .session
700 .append(ChatMessage::user("format it"), state.now);
701 state
702 .session
703 .append(ChatMessage::assistant(reply), state.now);
704 state
705 }
706
707 fn base_result() -> super::RunResult {
708 super::RunResult {
709 response: "the plain answer".to_string(),
710 ..super::RunResult::default()
711 }
712 }
713
714 #[test]
715 fn schema_outcome_valid_json_sets_structured_output() {
716 let schema = serde_json::json!({
717 "type": "object",
718 "properties": {"answer": {"type": "integer"}},
719 "required": ["answer"],
720 });
721 let state = schema_state("```json\n{\"answer\": 42}\n```");
722 let mut result = base_result();
723 super::apply_schema_outcome(&mut result, &state, &schema);
724 assert_eq!(result.response, "{\"answer\": 42}");
725 assert_eq!(
726 result.structured_output,
727 Some(serde_json::json!({"answer": 42}))
728 );
729 assert!(result.errors.is_empty(), "{:?}", result.errors);
730 }
731
732 #[test]
733 fn schema_outcome_invalid_json_keeps_text_and_records() {
734 let schema = serde_json::json!({"type": "object"});
735 let state = schema_state("not json at all");
736 let mut result = base_result();
737 super::apply_schema_outcome(&mut result, &state, &schema);
738 assert_eq!(result.response, "not json at all");
739 assert!(result.structured_output.is_none());
740 assert!(
741 result.errors.iter().any(|e| e.contains("not valid JSON")),
742 "{:?}",
743 result.errors
744 );
745 }
746
747 #[test]
748 fn schema_outcome_nonconforming_json_records_reason() {
749 let schema = serde_json::json!({
750 "type": "object",
751 "properties": {"answer": {"type": "integer"}},
752 "required": ["answer"],
753 });
754 let state = schema_state("{\"wrong\": true}");
755 let mut result = base_result();
756 super::apply_schema_outcome(&mut result, &state, &schema);
757 assert!(result.structured_output.is_none());
758 assert!(
759 result.errors.iter().any(|e| e.contains("does not conform")),
760 "{:?}",
761 result.errors
762 );
763 }
764
765 #[test]
766 fn schema_outcome_no_new_reply_keeps_original() {
767 let schema = serde_json::json!({"type": "object"});
770 use mermaid_model::models::ChatMessage;
771 let mut state = mermaid_domain::State::new(
772 mermaid_domain::Config::default(),
773 std::path::PathBuf::from("/tmp/p"),
774 "ollama/test".to_string(),
775 chrono::Local::now(),
776 std::path::PathBuf::from("/tmp"),
777 );
778 state.session.append(ChatMessage::user("q"), state.now);
779 state
780 .session
781 .append(ChatMessage::assistant("the plain answer"), state.now);
782 let mut result = base_result();
783 super::apply_schema_outcome(&mut result, &state, &schema);
784 assert_eq!(result.response, "the plain answer");
785 assert!(result.structured_output.is_none());
786 assert!(
787 result
788 .errors
789 .iter()
790 .any(|e| e.contains("produced no output")),
791 "{:?}",
792 result.errors
793 );
794 }
795
796 #[test]
797 fn result_event_carries_structured_output_to_subscribers() {
798 let event = mermaid_domain::RunEvent::Result {
802 response: "done".to_string(),
803 reasoning: None,
804 total_tokens: 3,
805 errors: vec![],
806 session_id: "s".to_string(),
807 structured_output: None,
808 };
809 let wire = serde_json::to_string(&event).unwrap();
810 assert!(wire.contains("\"type\":\"result\""), "{wire}");
811 let (tx, mut rx) = tokio::sync::broadcast::channel::<mermaid_domain::RunEvent>(4);
812 tx.send(event.clone()).unwrap();
813 assert_eq!(rx.try_recv().unwrap(), event);
814 }
815
816 }