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