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