Skip to main content

mermaid_cli/domain/
runtime.rs

1//! Runtime metadata shared by the reducer, recorder, and renderer.
2//!
3//! These types deliberately carry facts rather than presentation
4//! strings. Tool output still contains the provider-facing text that
5//! goes back into the model, while this module holds the metadata the
6//! UI and future commands can consume without scraping that text.
7
8use std::collections::HashSet;
9
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13/// External lifecycle signal observed by the app shell.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum RuntimeSignal {
17    Interrupt,
18    Terminate,
19    Hangup,
20}
21
22impl RuntimeSignal {
23    pub fn as_str(self) -> &'static str {
24        match self {
25            RuntimeSignal::Interrupt => "interrupt",
26            RuntimeSignal::Terminate => "terminate",
27            RuntimeSignal::Hangup => "hangup",
28        }
29    }
30}
31
32/// Runtime event recorded in state for observability / replay tooling.
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub struct RuntimeTimelineEvent {
35    pub kind: RuntimeTimelineKind,
36    pub message: String,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "snake_case")]
41pub enum RuntimeTimelineKind {
42    Signal,
43    Process,
44    Tool,
45    Provider,
46}
47
48/// Normalized provider capability snapshot exposed in app state.
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub struct ProviderCapabilitySnapshot {
51    pub provider: String,
52    pub model: String,
53    pub supports_tools: bool,
54    pub supports_vision: bool,
55    pub reasoning: String,
56    pub max_context_tokens: Option<usize>,
57}
58
59impl ProviderCapabilitySnapshot {
60    /// Conservative static snapshot used before a provider has been
61    /// resolved. This is intentionally cheap and side-effect free so
62    /// the reducer can update it on `/model` without touching network
63    /// or credential state.
64    pub fn from_model_id(model_id: &str) -> Self {
65        let (provider, model) = match model_id.split_once('/') {
66            Some((provider, model)) if !provider.is_empty() && !model.is_empty() => {
67                (provider.to_ascii_lowercase(), model.to_string())
68            },
69            _ => ("ollama".to_string(), model_id.to_string()),
70        };
71
72        let (supports_tools, supports_vision, reasoning) = match provider.as_str() {
73            "anthropic" => (true, true, "adaptive".to_string()),
74            "gemini" => (true, true, "thinking_level".to_string()),
75            "ollama" => (true, false, "binary".to_string()),
76            _ => (true, false, "effort".to_string()),
77        };
78
79        let max_context_tokens = infer_static_context_window(&provider, &model);
80
81        Self {
82            provider,
83            model,
84            supports_tools,
85            supports_vision,
86            reasoning,
87            max_context_tokens,
88        }
89    }
90}
91
92fn infer_static_context_window(provider: &str, model: &str) -> Option<usize> {
93    let model = model.to_ascii_lowercase();
94    match provider {
95        "anthropic" => Some(200_000),
96        "gemini" => Some(1_000_000),
97        "openai" if model.contains("gpt-4.1") || model.contains("gpt-5") => Some(400_000),
98        "openrouter" if model.contains("claude") => Some(200_000),
99        _ => None,
100    }
101}
102
103pub fn infer_static_context_window_for_model_id(model_id: &str) -> Option<usize> {
104    let (provider, model) = match model_id.split_once('/') {
105        Some((provider, model)) if !provider.is_empty() && !model.is_empty() => {
106            (provider.to_ascii_lowercase(), model.to_string())
107        },
108        _ => ("ollama".to_string(), model_id.to_string()),
109    };
110    infer_static_context_window(&provider, &model)
111}
112
113/// Background process status tracked by Mermaid after launching a
114/// command in `execute_command(mode="background")`.
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
116#[serde(rename_all = "snake_case")]
117pub enum ManagedProcessStatus {
118    Running,
119    Exited,
120    Unknown,
121}
122
123/// Registry record for a background process Mermaid started.
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125pub struct ManagedProcess {
126    pub id: String,
127    pub pid: u32,
128    pub command: String,
129    pub cwd: Option<String>,
130    pub log_path: String,
131    pub detected_url: Option<String>,
132    pub status: ManagedProcessStatus,
133}
134
135/// Structured metadata extracted from a completed tool run.
136#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
137pub struct ToolRunMetadata {
138    #[serde(default)]
139    pub detail: ToolMetadata,
140    pub line_count: Option<usize>,
141    pub byte_count: Option<usize>,
142    pub result_count: Option<usize>,
143    pub duration_secs: Option<f64>,
144    pub process: Option<ManagedProcess>,
145    /// User-facing display diff for file mutations. This is captured
146    /// at tool execution time so whole-file writes can compare against
147    /// the pre-write contents even after the file has been overwritten.
148    #[serde(default)]
149    pub display_diff: Option<String>,
150    #[serde(default)]
151    pub diff_truncated: bool,
152    #[serde(default)]
153    pub artifacts: Vec<ToolArtifact>,
154    /// Provider token usage the tool itself consumed (today: a subagent's
155    /// cumulative child-session usage). `handle_tool_finished` folds it into
156    /// the parent session's totals so the footer and the end-of-run summary
157    /// count the whole tree, not just the parent's own model calls.
158    #[serde(default)]
159    pub token_usage: Option<crate::models::TokenUsage>,
160}
161
162/// Tool outcome status independent of how the result is rendered.
163#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
164#[serde(rename_all = "snake_case")]
165pub enum ToolStatus {
166    Success,
167    Error,
168    Cancelled,
169}
170
171/// Typed metadata produced by a specific tool implementation.
172#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
173#[serde(tag = "kind", rename_all = "snake_case")]
174pub enum ToolMetadata {
175    #[default]
176    None,
177    ReadFile {
178        paths: Vec<String>,
179        line_count: usize,
180        byte_count: usize,
181        truncated: bool,
182    },
183    WriteFile {
184        path: String,
185        line_count: usize,
186        byte_count: usize,
187        created: Option<bool>,
188    },
189    EditFile {
190        path: String,
191        replacements: usize,
192    },
193    DeleteFile {
194        path: String,
195    },
196    CreateDirectory {
197        path: String,
198    },
199    WebSearch {
200        queries: Vec<String>,
201        requested_count: usize,
202        result_count: usize,
203        sources: Vec<String>,
204    },
205    WebFetch {
206        url: String,
207        title: Option<String>,
208        line_count: usize,
209        byte_count: usize,
210    },
211    ExecuteCommand {
212        command: String,
213        working_dir: Option<String>,
214        exit_code: Option<i32>,
215        timed_out: bool,
216        background: bool,
217        stdout_lines: usize,
218        stderr_lines: usize,
219        detected_urls: Vec<String>,
220        pid: Option<u32>,
221        log_path: Option<String>,
222    },
223    ComputerUse {
224        action: String,
225        params: Value,
226    },
227    Mcp {
228        server: String,
229        tool: String,
230    },
231    Subagent {
232        model_id: String,
233        /// Continuation handle: pass back via the `agent` tool's `agent_id`
234        /// arg to send a follow-up prompt to this child with its context
235        /// intact. Empty on recordings from before continuations existed.
236        #[serde(default)]
237        agent_id: String,
238    },
239    Custom {
240        name: String,
241        data: Value,
242    },
243}
244
245/// Non-text artifact produced by a tool. Images are base64 strings to
246/// match the existing chat-message storage format.
247#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
248#[serde(tag = "kind", rename_all = "snake_case")]
249pub enum ToolArtifact {
250    Image { data: String },
251    File { path: String },
252    Log { path: String },
253}
254
255/// The resolved Ollama context window for the active model, reported by the
256/// effect runner after the first turn. Drives the `/context` display and the
257/// truncation quick-fix. `model_max` is the probed architectural window;
258/// `effective` is the `num_ctx` we actually send.
259#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
260pub struct OllamaContextInfo {
261    pub model_max: Option<usize>,
262    pub effective: Option<usize>,
263    pub source: Option<crate::models::adapters::ollama_sizing::NumCtxSource>,
264}
265
266/// Post-turn memory placement of the loaded Ollama model, from `/api/ps`.
267/// `total_bytes` is weights + KV + buffers; `size_vram_bytes` is the part
268/// resident in VRAM. Volatile (changes when the model reloads), so it lives
269/// outside the quasi-static [`OllamaContextInfo`].
270#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
271pub struct OllamaPlacement {
272    pub size_vram_bytes: u64,
273    pub total_bytes: u64,
274}
275
276impl OllamaPlacement {
277    /// True when the model didn't fully fit VRAM and spilled to CPU/RAM (slow).
278    pub fn offloaded(&self) -> bool {
279        self.size_vram_bytes < self.total_bytes
280    }
281
282    /// Rough percentage of the model running on CPU/RAM (0–100). Integer math;
283    /// `0` when the footprint is unknown or fully resident.
284    pub fn percent_on_cpu(&self) -> u8 {
285        if self.total_bytes == 0 {
286            return 0;
287        }
288        let on_cpu = self.total_bytes.saturating_sub(self.size_vram_bytes);
289        (on_cpu.saturating_mul(100) / self.total_bytes) as u8
290    }
291}
292
293/// Runtime state that is not part of the chat transcript sent to a
294/// model, but is useful for UI, slash commands, and debugging.
295#[derive(Debug, Clone, Serialize, Deserialize)]
296pub struct RuntimeState {
297    pub provider_capabilities: ProviderCapabilitySnapshot,
298    #[serde(default)]
299    pub processes: Vec<ManagedProcess>,
300    #[serde(default)]
301    pub timeline: Vec<RuntimeTimelineEvent>,
302    /// Estimated token cost of the built-in tool schemas the effect runner
303    /// appends to every model request during dispatch. The reducer's
304    /// `/context` preview builds an MCP-only request and can't see these, so
305    /// the runner reports the figure via `Msg::BuiltinToolSchemaTokens` and
306    /// `/context` folds it in to match what dispatch actually decides.
307    #[serde(default)]
308    pub builtin_tool_schema_tokens: usize,
309    /// Resolved Ollama context window for the active model (`None` until the
310    /// first turn probes it, or for non-Ollama providers).
311    #[serde(default)]
312    pub ollama_context: Option<OllamaContextInfo>,
313    /// Post-turn `/api/ps` memory placement for the active model (`None` until a
314    /// turn probes it). Volatile, so it's tracked separately from the window.
315    #[serde(default)]
316    pub ollama_placement: Option<OllamaPlacement>,
317    /// Models we've already shown the proactive auto-fit hint for this session.
318    /// Session-only (not persisted) so the gentle reminder reappears each launch.
319    #[serde(skip)]
320    pub hinted_models: HashSet<String>,
321    /// Models we've already warned about VRAM offload this session. Session-only,
322    /// so the once-per-session warning behaves like the auto-fit hint.
323    #[serde(skip)]
324    pub offload_warned: HashSet<String>,
325    /// Auto-converge: per-model `num_ctx` that the post-turn `/api/ps` check
326    /// found fits VRAM, keyed by model id. Session-only (not persisted) because
327    /// it depends on whatever else is using VRAM right now; re-derived each
328    /// session. Read by `build_chat_request` below a user override.
329    #[serde(skip)]
330    pub ollama_converged_num_ctx: std::collections::HashMap<String, u32>,
331    /// When the current user interaction began. One "turn" in Mermaid is a single
332    /// model call + its tools; an agentic run spans many such turns (each tool
333    /// follow-up mints a fresh `TurnId`). This anchors the spinner's elapsed timer
334    /// to the *whole* run so it doesn't reset to 0 at every tool step. Set on
335    /// submit, read only while generating/executing tools. Session-only.
336    #[serde(skip)]
337    pub run_started: Option<std::time::SystemTime>,
338    /// Estimated tokens generated in *completed* phases of the current run, so the
339    /// spinner's token counter accumulates across tool steps instead of resetting
340    /// each model call. The live phase's estimate is added on top at render time.
341    #[serde(skip)]
342    pub run_committed_tokens: usize,
343    /// Consecutive auto-compact-and-continue recoveries in the current run after a
344    /// context-window truncation. Bounded by `settings.compaction.max_truncation_recoveries`
345    /// (0 = uncapped) and reset whenever the run makes progress, so it caps only
346    /// no-progress thrashing on a too-small window. Session-only.
347    #[serde(skip)]
348    pub truncation_recoveries: u32,
349    /// Consecutive turns in the current run that produced no visible output (no
350    /// assistant text and no tool calls) — even if the model spent the turn on
351    /// hidden reasoning. Under `MAX_EMPTY_CONTINUATIONS` the run auto-retries the
352    /// model call so a stalled turn isn't left silent; at the cap it stops with a
353    /// hint. Reset on a fresh run and whenever a turn makes progress. Session-only.
354    #[serde(skip)]
355    pub empty_continuations: u32,
356}
357
358impl RuntimeState {
359    pub fn new(model_id: &str) -> Self {
360        Self {
361            provider_capabilities: ProviderCapabilitySnapshot::from_model_id(model_id),
362            processes: Vec::new(),
363            timeline: Vec::new(),
364            builtin_tool_schema_tokens: 0,
365            ollama_context: None,
366            ollama_placement: None,
367            hinted_models: HashSet::new(),
368            offload_warned: HashSet::new(),
369            ollama_converged_num_ctx: std::collections::HashMap::new(),
370            run_started: None,
371            run_committed_tokens: 0,
372            truncation_recoveries: 0,
373            empty_continuations: 0,
374        }
375    }
376
377    /// Cap on `timeline` length. It's a recent-activity log for `/runtime` and
378    /// the serialized snapshot, not an audit trail, so the oldest events are
379    /// trimmed — otherwise it grows monotonically for the session's life (and
380    /// it's `#[serde(default)]`, so it would also bloat every saved snapshot).
381    const MAX_TIMELINE_EVENTS: usize = 200;
382
383    /// Append a timeline event, trimming the oldest so the log stays bounded.
384    fn push_timeline(&mut self, kind: RuntimeTimelineKind, message: String) {
385        self.timeline.push(RuntimeTimelineEvent { kind, message });
386        let len = self.timeline.len();
387        if len > Self::MAX_TIMELINE_EVENTS {
388            self.timeline.drain(0..len - Self::MAX_TIMELINE_EVENTS);
389        }
390    }
391
392    pub fn set_model(&mut self, model_id: &str) {
393        self.provider_capabilities = ProviderCapabilitySnapshot::from_model_id(model_id);
394        // New model → the resolved window + placement no longer apply; re-probed
395        // next turn.
396        self.ollama_context = None;
397        self.ollama_placement = None;
398        self.push_timeline(
399            RuntimeTimelineKind::Provider,
400            format!("model set to {}", model_id),
401        );
402    }
403
404    pub fn record_signal(&mut self, signal: RuntimeSignal) {
405        self.push_timeline(
406            RuntimeTimelineKind::Signal,
407            format!("received {}", signal.as_str()),
408        );
409    }
410
411    pub fn register_process(&mut self, process: ManagedProcess) {
412        if let Some(existing) = self.processes.iter_mut().find(|p| p.pid == process.pid) {
413            *existing = process.clone();
414        } else {
415            self.processes.push(process.clone());
416        }
417        self.push_timeline(
418            RuntimeTimelineKind::Process,
419            format!("registered process {} ({})", process.pid, process.command),
420        );
421    }
422}
423
424impl Default for RuntimeState {
425    fn default() -> Self {
426        Self::new("ollama/unknown")
427    }
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433
434    #[test]
435    fn timeline_is_bounded_and_keeps_most_recent() {
436        let mut rt = RuntimeState::new("ollama/test");
437        // `new` may seed an initial event; push well past the cap and confirm
438        // the log is trimmed to the most recent window rather than growing.
439        for _ in 0..(RuntimeState::MAX_TIMELINE_EVENTS + 50) {
440            rt.record_signal(RuntimeSignal::Interrupt);
441        }
442        assert_eq!(rt.timeline.len(), RuntimeState::MAX_TIMELINE_EVENTS);
443        // The newest event is retained (front-trim keeps the tail).
444        assert_eq!(
445            rt.timeline.last().map(|e| e.message.as_str()),
446            Some("received interrupt")
447        );
448    }
449}