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/// 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}