use mermaid_model::utils::{join_logged, spawn_guarded};
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::mpsc;
use super::*;
#[expect(clippy::too_many_arguments)]
#[expect(
clippy::too_many_lines,
reason = "predates the lint; see .github/baselines/expect_budget.txt"
)]
pub(super) async fn dispatch_execute_tool(
msg_tx: MsgSender,
tools: Option<Arc<ToolRegistry>>,
workdir: PathBuf,
turn: TurnId,
call_id: mermaid_domain::ToolCallId,
source: mermaid_model::models::tool_call::ToolCall,
token: tokio_util::sync::CancellationToken,
background: tokio_util::sync::CancellationToken,
web_bytes: Arc<std::sync::atomic::AtomicUsize>,
config: Arc<mermaid_domain::Config>,
model_id: String,
task_id: Option<String>,
session_id: String,
message_index: usize,
scratchpad: Option<PathBuf>,
safety_mode: mermaid_runtime::SafetyMode,
plan_file: Option<PathBuf>,
plan_permissions: mermaid_domain::PlanPermissions,
context_percent: Option<u8>,
intent: Option<String>,
classifier: Option<Arc<dyn crate::providers::AutoClassifier>>,
approval: Option<crate::providers::ApprovalBroker>,
questions: Option<crate::providers::QuestionBroker>,
tasks: crate::providers::TaskBroker,
) {
let _ = msg_tx.send(Msg::ToolStarted { turn, call_id }).await;
let Some(registry) = tools else {
let _ = msg_tx
.send(Msg::ToolFinished {
turn,
call_id,
outcome: mermaid_domain::ToolOutcome::error(
"EffectRunner has no ToolRegistry bound",
0.0,
),
})
.await;
return;
};
let (tool_key, args) = if source.function.name.starts_with("mcp__") {
let rest = &source.function.name[5..];
if let Some((server, tool)) = rest.split_once("__") {
(
"mcp_proxy",
serde_json::json!({
"server_name": server,
"tool_name": tool,
"arguments": source.function.arguments.clone(),
}),
)
} else {
let _ = msg_tx
.send(Msg::ToolFinished {
turn,
call_id,
outcome: mermaid_domain::ToolOutcome::error(
format!("invalid MCP tool name: {}", source.function.name),
0.0,
),
})
.await;
return;
}
} else {
(
source.function.name.as_str(),
source.function.arguments.clone(),
)
};
let tool_run_id =
start_runtime_tool_run(task_id.as_deref(), turn, call_id, tool_key, &args).await;
let Some(tool) = registry.get(tool_key) else {
let outcome = mermaid_domain::ToolOutcome::error(format!("unknown tool: {tool_key}"), 0.0);
finish_runtime_tool_run(tool_run_id.as_deref(), &outcome);
let _ = msg_tx
.send(Msg::ToolFinished {
turn,
call_id,
outcome,
})
.await;
return;
};
let (progress_tx, mut progress_rx) = mpsc::channel(16);
let relay_tx = msg_tx.clone();
let relay_token = token.clone();
let progress_relay = spawn_guarded(async move {
loop {
let event = tokio::select! {
biased;
_ = relay_token.cancelled() => break,
ev = progress_rx.recv() => match ev {
Some(ev) => ev,
None => break,
},
};
if relay_tx
.send(Msg::ToolProgress {
turn,
call_id,
event,
})
.await
.is_err()
{
break;
}
}
});
let mut ctx = ExecContext::new(
token,
progress_tx,
call_id,
turn,
workdir,
config,
model_id,
task_id,
Some(session_id),
Some(message_index as i64),
safety_mode,
intent,
classifier,
approval,
questions,
Some(tasks.clone()),
);
ctx.background = background;
ctx.web_bytes = web_bytes;
ctx.plan_file = plan_file;
ctx.plan_permissions = plan_permissions;
ctx.context_percent = context_percent;
ctx.notify = Some(msg_tx.clone());
ctx.scratchpad = scratchpad;
let before_payload = serde_json::json!({
"turn_id": turn.0,
"call_id": call_id.0,
"tool": tool_key,
"arguments": args,
});
let gate = run_plugin_hooks_gated("before_tool_use", before_payload).await;
if !gate.context.is_empty() {
let texts = gate
.context
.iter()
.map(|t| mermaid_model::utils::redact_secrets(t))
.collect();
let _ = msg_tx.send(Msg::HookContext { turn, texts }).await;
}
if let Some((plugin, reason)) = gate.deny {
drop(ctx);
let reason = mermaid_model::utils::redact_secrets(&reason);
let outcome = mermaid_domain::ToolOutcome::error(
format!("Denied by plugin hook ({plugin}): {reason}"),
0.0,
);
finish_runtime_tool_run(tool_run_id.as_deref(), &outcome);
join_logged(progress_relay.take(), "tool_progress_relay").await;
let _ = msg_tx
.send(Msg::ToolFinished {
turn,
call_id,
outcome,
})
.await;
return;
}
let args = gate.updated_input.unwrap_or(args);
let outcome = tool.execute(args, ctx).await;
if !source.function.name.starts_with("task_") {
let (action, target) = mermaid_domain::display_info_for(&mermaid_domain::PendingToolCall {
call_id,
source: source.clone(),
});
tasks
.record_evidence(mermaid_domain::EvidenceEntry {
tool: action,
target,
status: tool_status_label(outcome.status).to_string(),
})
.await;
}
let after_payload = serde_json::json!({
"turn_id": turn.0,
"call_id": call_id.0,
"tool": tool_key,
"status": tool_status_label(outcome.status),
"summary": &outcome.summary,
});
fire_plugin_hooks("after_tool_use", after_payload).await;
finish_runtime_tool_run(tool_run_id.as_deref(), &outcome);
join_logged(progress_relay.take(), "tool_progress_relay").await;
let _ = msg_tx
.send(Msg::ToolFinished {
turn,
call_id,
outcome,
})
.await;
}
pub(super) async fn start_runtime_tool_run(
task_id: Option<&str>,
turn: TurnId,
call_id: mermaid_domain::ToolCallId,
tool_name: &str,
args: &serde_json::Value,
) -> Option<String> {
let task_id = task_id.map(str::to_string);
let tool_name = tool_name.to_string();
let args_json = redacted_json_string(args);
tokio::task::spawn_blocking(move || {
mermaid_runtime::RuntimeStore::open_default()
.and_then(|store| {
store.tool_runs().start(mermaid_runtime::NewToolRun {
id: None,
task_id,
turn_id: Some(turn.0.to_string()),
call_id: Some(call_id.0.to_string()),
tool_name,
args_json,
})
})
.map(|record| record.id)
.ok()
})
.await
.ok()
.flatten()
}
pub(super) fn finish_runtime_tool_run(
tool_run_id: Option<&str>,
outcome: &mermaid_domain::ToolOutcome,
) {
let Some(tool_run_id) = tool_run_id else {
return;
};
let tool_run_id = tool_run_id.to_string();
let status = tool_status_label(outcome.status).to_string();
let output_json = redacted_json_string(&serde_json::json!({
"status": tool_status_label(outcome.status),
"summary": &outcome.summary,
"model_content": &outcome.model_content,
"error": &outcome.error,
"metadata": &outcome.metadata,
"artifacts": &outcome.artifacts,
"duration_secs": outcome.duration_secs,
}));
tokio::task::spawn_blocking(move || {
if let Ok(store) = mermaid_runtime::RuntimeStore::open_default() {
let _ = store
.tool_runs()
.finish(&tool_run_id, &status, output_json.as_deref());
}
});
}
pub(super) fn redacted_json_string(value: &serde_json::Value) -> Option<String> {
let mut redacted = value.clone();
mermaid_model::utils::redact_json(&mut redacted);
serde_json::to_string(&redacted).ok()
}
pub(super) fn tool_status_label(status: mermaid_domain::ToolStatus) -> &'static str {
match status {
mermaid_domain::ToolStatus::Success => "success",
mermaid_domain::ToolStatus::Error => "error",
mermaid_domain::ToolStatus::Cancelled => "cancelled",
}
}
pub(super) fn runtime_model_info_text(model: &str) -> String {
let snapshot = mermaid_domain::runtime::ProviderCapabilitySnapshot::from_model_id(model);
let mut lines = vec![
format!("Model info: {}", model),
format!("- provider: {}", snapshot.provider),
format!("- model: {}", snapshot.model),
format!("- supports tools: {}", snapshot.supports_tools),
format!("- supports vision: {}", snapshot.supports_vision),
format!("- reasoning: {}", snapshot.reasoning),
format!(
"- context limit: {}",
snapshot
.max_context_tokens
.map(|value: usize| value.to_string())
.unwrap_or_else(|| "unknown".to_string())
),
];
if let Ok(store) = mermaid_runtime::RuntimeStore::open_default()
&& let Ok(probes) = store
.provider_probes()
.list(Some(&snapshot.provider), Some(&snapshot.model))
&& !probes.is_empty()
{
lines.push(String::new());
lines.push("Cached provider reality records:".to_string());
for probe in probes {
lines.push(format!(
"- {} = {} ({})",
probe.capability_key, probe.capability_value, probe.confidence
));
}
}
lines.join("\n")
}
pub(super) async fn dispatch_pull_ollama_model(tx: MsgSender, model: String) {
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
let mut cmd = Command::new("ollama");
cmd.arg("pull")
.arg(&model)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true);
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => {
let _ = tx
.send(Msg::ModelPullProgress(format!(
"ollama pull failed to start: {e}"
)))
.await;
return;
},
};
let reader_handle = child.stdout.take().map(|stdout| {
let tx_inner = tx.clone();
tokio::spawn(async move {
let mut reader = BufReader::new(stdout).lines();
while let Ok(Some(line)) = reader.next_line().await {
let _ = tx_inner.send(Msg::ModelPullProgress(line)).await;
}
})
});
match child.wait().await {
Ok(status) if status.success() => {
let _ = tx.send(Msg::ModelPullFinished { model }).await;
},
Ok(status) => {
let _ = tx
.send(Msg::ModelPullProgress(format!(
"ollama pull exited with status {}",
status.code().unwrap_or(-1)
)))
.await;
},
Err(e) => {
let _ = tx
.send(Msg::ModelPullProgress(format!(
"ollama pull wait error: {e}"
)))
.await;
},
}
if let Some(handle) = reader_handle {
join_logged(handle, "ollama_pull_reader").await;
}
}
pub(super) async fn dispatch_init_mcp_servers(
configs: std::collections::HashMap<String, mermaid_domain::McpServerConfig>,
tx: tokio::sync::mpsc::Sender<Msg>,
) {
if configs.is_empty() {
return;
}
crate::mcp::manager_ref::mark_init_started();
let manager = std::sync::Arc::new(crate::mcp::McpServerManager::new(&configs));
crate::mcp::manager_ref::set_manager(manager.clone());
let mut join = tokio::task::JoinSet::new();
for (name, config) in configs {
let manager = manager.clone();
let tx = tx.clone();
join.spawn(async move {
let msg = match manager.start_server(&name, &config).await {
Ok(tools) => Msg::McpServerReady { name, tools },
Err(e) => Msg::McpServerErrored {
name,
reason: e.to_string(),
},
};
let _ = tx.send(msg).await;
});
}
while join.join_next().await.is_some() {}
crate::mcp::manager_ref::mark_init_complete();
}
pub(super) async fn dispatch_read_clipboard(tx: MsgSender) {
use mermaid_domain::ClipboardRead;
enum Outcome {
Image { bytes: Vec<u8>, format: String },
Text(String),
Empty,
Error(String),
}
let outcome = tokio::task::spawn_blocking(|| {
if crate::clipboard::has_image() {
match crate::clipboard::read_image_bytes() {
Ok((bytes, format)) => Outcome::Image { bytes, format },
Err(e) => Outcome::Error(format!("Clipboard image read failed: {e}")),
}
} else {
match crate::clipboard::read_text() {
Ok(t) if !t.is_empty() => Outcome::Text(t),
Ok(_) => Outcome::Empty,
Err(e) => Outcome::Error(format!("Clipboard empty / read failed: {e}")),
}
}
})
.await
.unwrap_or_else(|e| Outcome::Error(format!("clipboard spawn_blocking: {e}")));
let msg = match outcome {
Outcome::Image { bytes, format } => {
Msg::ClipboardRead(ClipboardRead::Image { bytes, format })
},
Outcome::Text(text) => Msg::ClipboardRead(ClipboardRead::Text(text)),
Outcome::Empty => Msg::ClipboardRead(ClipboardRead::Empty),
Outcome::Error(text) => Msg::ClipboardRead(ClipboardRead::Error(text)),
};
let _ = tx.send(msg).await;
}
pub(super) async fn dispatch_probe_vision(
model_id: String,
warn: bool,
providers: Option<Arc<ProviderFactory>>,
tx: MsgSender,
) {
let supports_vision = match providers {
Some(factory) => match factory.resolve(&model_id).await {
Ok(provider) => provider.supports_vision().await,
Err(_) => None,
},
None => None,
};
let _ = tx
.send(Msg::ProviderVisionResolved {
model_id,
supports_vision,
warn,
})
.await;
}
pub(super) async fn discover_available_models(
providers: Option<Arc<ProviderFactory>>,
) -> Vec<mermaid_domain::state::ModelChoice> {
use mermaid_domain::state::ModelChoice;
let Some(factory) = providers else {
return Vec::new();
};
let config = factory.config();
let mut out: Vec<ModelChoice> = Vec::new();
if let Some(names) = list_ollama_models_readonly(config).await {
for name in names {
out.push(ModelChoice {
id: format!("ollama/{name}"),
group: "Local (Ollama)".to_string(),
detail: "runs on this machine".to_string(),
ready: true,
});
}
}
for catalog in crate::providers::discovery::provider_catalogs(config).await {
let Some(models) = catalog.models else {
continue;
};
for id in models {
out.push(ModelChoice {
id: format!("{}/{}", catalog.provider.name, id),
group: catalog.provider.name.clone(),
detail: String::new(),
ready: true,
});
}
}
out.sort_by(|a, b| {
let local = |c: &ModelChoice| u8::from(c.group != "Local (Ollama)");
local(a)
.cmp(&local(b))
.then_with(|| a.group.cmp(&b.group))
.then_with(|| a.id.cmp(&b.id))
});
out.dedup_by(|a, b| a.id == b.id);
out
}
pub(super) async fn list_ollama_models_readonly(
config: &mermaid_domain::Config,
) -> Option<Vec<String>> {
use mermaid_model::models::adapters::ollama::OllamaAdapter;
use mermaid_model::models::{BackendConfig, Model};
let backend = BackendConfig {
ollama_url: format!("{}:{}", config.ollama.host, config.ollama.port),
timeout_secs: 5,
max_idle_per_host: 2,
ollama_autostart: false,
};
match OllamaAdapter::new("__list__", Arc::new(backend)).await {
Ok(adapter) => adapter.list_models().await.ok(),
Err(_) => None,
}
}
pub(super) async fn dispatch_copy_to_clipboard(text: String, tx: MsgSender) {
let char_count = text.chars().count();
let result = tokio::task::spawn_blocking(move || crate::clipboard::write_text(&text))
.await
.unwrap_or_else(|e| Err(anyhow::anyhow!("clipboard spawn_blocking: {e}")));
let msg = match result {
Ok(()) => Msg::Toast {
text: format!("copied {char_count} chars to clipboard"),
},
Err(e) => Msg::TransientStatus {
text: format!("Copy failed: {e}"),
},
};
let _ = tx.send(msg).await;
}
pub(super) fn classify_error_for_ui(
e: &mermaid_model::models::ModelError,
) -> mermaid_model::models::UserFacingError {
use mermaid_model::models::{ErrorCategory, ModelError, UserFacingError};
match e {
ModelError::Backend(b) => UserFacingError {
summary: "Backend error".to_string(),
message: b.to_string(),
suggestion: "Check the provider endpoint / API key.".to_string(),
category: ErrorCategory::Connection,
recoverable: true,
},
ModelError::Authentication(msg) => UserFacingError {
summary: "Auth error".to_string(),
message: msg.clone(),
suggestion: "Set the env var the provider expects.".to_string(),
category: ErrorCategory::Auth,
recoverable: false,
},
ModelError::RateLimit {
retry_after,
message,
} => UserFacingError {
summary: "Rate limited".to_string(),
message: message.clone().unwrap_or_else(|| {
"The provider rejected the request with 429 (too many requests).".to_string()
}),
suggestion: match retry_after {
Some(secs) => format!("The provider asked to retry after {secs}s."),
None => "Retry shortly; if it persists, check your plan's quota.".to_string(),
},
category: ErrorCategory::Temporary,
recoverable: true,
},
ModelError::StreamError(msg) => UserFacingError {
summary: "Stream error".to_string(),
message: msg.clone(),
suggestion: "Retry the request.".to_string(),
category: ErrorCategory::Connection,
recoverable: true,
},
other => UserFacingError {
summary: "Model error".to_string(),
message: other.to_string(),
suggestion: String::new(),
category: ErrorCategory::Internal,
recoverable: false,
},
}
}