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