Skip to main content

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}