mermaid_model/tool_run.rs
1//! Value types describing one tool run — pure data, no runtime.
2//!
3//! These carry facts rather than presentation strings: tool output still holds
4//! the provider-facing text that goes back to the model, while this module
5//! holds the metadata the UI and future commands consume without scraping it.
6//!
7//! They live in `mermaid-model` rather than in `domain` because `ChatMessage`
8//! embeds them (through `ActionDisplay`), and a wire type reaching up into the
9//! MVU layer for its own field types was the last cycle standing between the
10//! model layer and its own crate.
11
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14
15/// Background process status tracked by Mermaid after launching a
16/// command in `execute_command(mode="background")`.
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum ManagedProcessStatus {
20 Running,
21 Exited,
22 Unknown,
23}
24
25/// Registry record for a background process Mermaid started.
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct ManagedProcess {
28 pub id: String,
29 pub pid: u32,
30 pub command: String,
31 pub cwd: Option<String>,
32 pub log_path: String,
33 pub detected_url: Option<String>,
34 pub status: ManagedProcessStatus,
35}
36
37/// Structured metadata extracted from a completed tool run.
38#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
39pub struct ToolRunMetadata {
40 #[serde(default)]
41 pub detail: ToolMetadata,
42 pub line_count: Option<usize>,
43 pub byte_count: Option<usize>,
44 pub result_count: Option<usize>,
45 pub duration_secs: Option<f64>,
46 pub process: Option<ManagedProcess>,
47 /// User-facing display diff for file mutations. This is captured
48 /// at tool execution time so whole-file writes can compare against
49 /// the pre-write contents even after the file has been overwritten.
50 #[serde(default)]
51 pub display_diff: Option<String>,
52 #[serde(default)]
53 pub diff_truncated: bool,
54 /// Exact line-change counts for file mutations. Carried separately from
55 /// `display_diff` because that string is capped at
56 /// `MAX_DISPLAY_DIFF_LINES` — recounting it would undercount large
57 /// writes. `handle_tool_finished` folds these into the per-run totals
58 /// behind the end-of-run `+N/-M` summary.
59 #[serde(default)]
60 pub lines_added: usize,
61 #[serde(default)]
62 pub lines_removed: usize,
63 #[serde(default)]
64 pub artifacts: Vec<ToolArtifact>,
65 /// Provider token usage the tool itself consumed (today: a subagent's
66 /// cumulative child-session usage). `handle_tool_finished` folds it into
67 /// the parent session's totals so the footer and the end-of-run summary
68 /// count the whole tree, not just the parent's own model calls.
69 #[serde(default)]
70 pub token_usage: Option<crate::models::TokenUsage>,
71 /// This call wrote the plan file while planning — the FACT the doom-loop
72 /// breaker disarms on.
73 ///
74 /// Recorded at the boundary that actually knows it (the policy gate
75 /// approved the write, or the file mutator targeted the plan path) rather
76 /// than inferred from the tool name. Inferring it missed the shell
77 /// spelling entirely: the escalated corrective tells the model "a shell
78 /// redirect writing ONLY that file works too", and when the model complied
79 /// the breaker stayed armed and kept re-injecting "the plan file does not
80 /// exist until you write it" at a model that had just written it.
81 #[serde(default)]
82 pub plan_file_written: bool,
83}
84
85/// Tool outcome status independent of how the result is rendered.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(rename_all = "snake_case")]
88pub enum ToolStatus {
89 Success,
90 Error,
91 Cancelled,
92}
93
94/// Typed metadata produced by a specific tool implementation.
95#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
96#[serde(tag = "kind", rename_all = "snake_case")]
97pub enum ToolMetadata {
98 #[default]
99 None,
100 ReadFile {
101 paths: Vec<String>,
102 line_count: usize,
103 byte_count: usize,
104 truncated: bool,
105 },
106 WriteFile {
107 path: String,
108 line_count: usize,
109 byte_count: usize,
110 created: Option<bool>,
111 },
112 ApplyPatch {
113 added: Vec<String>,
114 modified: Vec<String>,
115 deleted: Vec<String>,
116 renamed: Vec<(String, String)>,
117 fuzzy: bool,
118 },
119 DeleteFile {
120 path: String,
121 },
122 CreateDirectory {
123 path: String,
124 },
125 WebSearch {
126 queries: Vec<String>,
127 requested_count: usize,
128 result_count: usize,
129 sources: Vec<String>,
130 #[serde(default)]
131 backend: String,
132 #[serde(default)]
133 succeeded_queries: usize,
134 #[serde(default)]
135 failed_queries: usize,
136 #[serde(default)]
137 partial: bool,
138 #[serde(default)]
139 truncated: bool,
140 #[serde(default, skip_serializing_if = "Vec::is_empty")]
141 failures: Vec<WebSearchFailure>,
142 },
143 WebFetch {
144 /// Sanitized originally requested URL.
145 url: String,
146 #[serde(default)]
147 final_url: Option<String>,
148 #[serde(default)]
149 status: Option<u16>,
150 #[serde(default)]
151 error_kind: Option<String>,
152 #[serde(default)]
153 media_type: Option<String>,
154 #[serde(default)]
155 charset: Option<String>,
156 #[serde(default)]
157 backend: String,
158 #[serde(default)]
159 extraction: String,
160 title: Option<String>,
161 line_count: usize,
162 byte_count: usize,
163 #[serde(default)]
164 source_byte_count: usize,
165 /// Bytes in the extracted page before the 30 KiB rendered envelope is
166 /// applied. This may exceed the bytes retained in a bounded snapshot;
167 /// `truncated` records that distinction.
168 #[serde(default)]
169 output_byte_count: usize,
170 #[serde(default)]
171 truncated: bool,
172 #[serde(default)]
173 pattern: Option<String>,
174 #[serde(default)]
175 context_lines: Option<usize>,
176 #[serde(default)]
177 match_count: Option<usize>,
178 #[serde(default)]
179 snapshot_id: Option<String>,
180 },
181 ExecuteCommand {
182 command: String,
183 working_dir: Option<String>,
184 exit_code: Option<i32>,
185 timed_out: bool,
186 background: bool,
187 stdout_lines: usize,
188 stderr_lines: usize,
189 detected_urls: Vec<String>,
190 pid: Option<u32>,
191 log_path: Option<String>,
192 /// The command was terminated by the OS sandbox (e.g. it tried to
193 /// reach the network under `--no-network`). Additive; `#[serde(default)]`
194 /// keeps older recordings/rows deserializable.
195 #[serde(default)]
196 denied_by_sandbox: bool,
197 },
198 ComputerUse {
199 action: String,
200 params: Value,
201 },
202 Mcp {
203 server: String,
204 tool: String,
205 },
206 Subagent {
207 model_id: String,
208 /// Continuation handle: pass back via the `agent` tool's `agent_id`
209 /// arg to send a follow-up prompt to this child with its context
210 /// intact. Empty on recordings from before continuations existed.
211 #[serde(default)]
212 agent_id: String,
213 },
214 /// The task checklist tools (`task_create` / `task_update` / `task_list`).
215 /// `action` is the wire tool suffix ("create" / "update" / "list");
216 /// counts are over visible (non-deleted) tasks after the call.
217 Tasks {
218 action: String,
219 completed: u32,
220 total: u32,
221 },
222 /// `ask_user_question` resolved with answers. Kept structured so the
223 /// transcript can replay each question → answer pair rather than a bare
224 /// duration.
225 Questions {
226 answers: Vec<crate::question::QuestionAnswer>,
227 /// The answers came from remembered cross-session preferences
228 /// (`memoryKey`) rather than a live prompt.
229 #[serde(default)]
230 remembered: bool,
231 },
232 /// `exit_plan_mode` resolved with an APPROVED plan: the transcript
233 /// renders the plan body as a markdown block, and `handle_tool_finished`
234 /// keys the post-approval mechanics (clear `session.plan`, seed the
235 /// checklist, optionally auto-submit) on this variant. A
236 /// request-for-changes outcome carries no metadata.
237 Plan {
238 /// Plan-file path as shown to the user (project-relative).
239 path: String,
240 /// The approved plan text, re-read from disk at approval time.
241 body: String,
242 /// True when the user chose to start implementing immediately.
243 #[serde(default)]
244 start: bool,
245 /// Execution begins in a FRESH conversation seeded with the handoff
246 /// preamble + plan (clear-context execute, or a fresh-session
247 /// handoff). The exploration context is left behind on disk.
248 #[serde(default)]
249 fresh: bool,
250 /// Handoff variant that copies the transcript into a new
251 /// conversation before starting (mutually exclusive with `fresh`).
252 #[serde(default)]
253 fork: bool,
254 /// Handoff: switch the session to this model for execution.
255 #[serde(default)]
256 model: Option<String>,
257 },
258 Custom {
259 name: String,
260 data: Value,
261 },
262}
263
264/// One failed item from an ordered web-search batch. The index preserves its
265/// relationship to the input without copying potentially sensitive query text
266/// into telemetry; `error` is redacted before construction.
267#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
268pub struct WebSearchFailure {
269 /// Zero-based index into the original `queries` array.
270 pub query_index: usize,
271 /// Secret-redacted, byte-bounded backend failure detail.
272 pub error: String,
273}
274
275/// Non-text artifact produced by a tool. Images are base64 strings to
276/// match the existing chat-message storage format.
277#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
278#[serde(tag = "kind", rename_all = "snake_case")]
279pub enum ToolArtifact {
280 Image { data: String },
281 File { path: String },
282 Log { path: String },
283}
284
285/// The resolved Ollama context window for the active model, reported by the
286/// effect runner after the first turn. Drives the `/context` display and the
287/// truncation quick-fix. `model_max` is the probed architectural window;
288/// `effective` is the `num_ctx` we actually send.
289#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
290pub struct OllamaContextInfo {
291 pub model_max: Option<usize>,
292 pub effective: Option<usize>,
293 pub source: Option<crate::models::adapters::ollama_sizing::NumCtxSource>,
294}
295
296/// Post-turn memory placement of the loaded Ollama model, from `/api/ps`.
297/// `total_bytes` is weights + KV + buffers; `size_vram_bytes` is the part
298/// resident in VRAM. Volatile (changes when the model reloads), so it lives
299/// outside the quasi-static [`OllamaContextInfo`].
300#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
301pub struct OllamaPlacement {
302 pub size_vram_bytes: u64,
303 pub total_bytes: u64,
304}
305
306impl OllamaPlacement {
307 /// True when the model didn't fully fit VRAM and spilled to CPU/RAM (slow).
308 pub fn offloaded(&self) -> bool {
309 self.size_vram_bytes < self.total_bytes
310 }
311
312 /// Rough percentage of the model running on CPU/RAM (0–100). Integer math;
313 /// `0` when the footprint is unknown or fully resident.
314 pub fn percent_on_cpu(&self) -> u8 {
315 if self.total_bytes == 0 {
316 return 0;
317 }
318 let on_cpu = self.total_bytes.saturating_sub(self.size_vram_bytes);
319 (on_cpu.saturating_mul(100) / self.total_bytes) as u8
320 }
321}