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}
155
156/// Tool outcome status independent of how the result is rendered.
157#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
158#[serde(rename_all = "snake_case")]
159pub enum ToolStatus {
160    Success,
161    Error,
162    Cancelled,
163}
164
165/// Typed metadata produced by a specific tool implementation.
166#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
167#[serde(tag = "kind", rename_all = "snake_case")]
168pub enum ToolMetadata {
169    #[default]
170    None,
171    ReadFile {
172        paths: Vec<String>,
173        line_count: usize,
174        byte_count: usize,
175        truncated: bool,
176    },
177    WriteFile {
178        path: String,
179        line_count: usize,
180        byte_count: usize,
181        created: Option<bool>,
182    },
183    EditFile {
184        path: String,
185        replacements: usize,
186    },
187    DeleteFile {
188        path: String,
189    },
190    CreateDirectory {
191        path: String,
192    },
193    WebSearch {
194        queries: Vec<String>,
195        requested_count: usize,
196        result_count: usize,
197        sources: Vec<String>,
198    },
199    WebFetch {
200        url: String,
201        title: Option<String>,
202        line_count: usize,
203        byte_count: usize,
204    },
205    ExecuteCommand {
206        command: String,
207        working_dir: Option<String>,
208        exit_code: Option<i32>,
209        timed_out: bool,
210        background: bool,
211        stdout_lines: usize,
212        stderr_lines: usize,
213        detected_urls: Vec<String>,
214        pid: Option<u32>,
215        log_path: Option<String>,
216    },
217    ComputerUse {
218        action: String,
219        params: Value,
220    },
221    Mcp {
222        server: String,
223        tool: String,
224    },
225    Subagent {
226        model_id: String,
227    },
228    Custom {
229        name: String,
230        data: Value,
231    },
232}
233
234/// Non-text artifact produced by a tool. Images are base64 strings to
235/// match the existing chat-message storage format.
236#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
237#[serde(tag = "kind", rename_all = "snake_case")]
238pub enum ToolArtifact {
239    Image { data: String },
240    File { path: String },
241    Log { path: String },
242}
243
244/// The resolved Ollama context window for the active model, reported by the
245/// effect runner after the first turn. Drives the `/context` display and the
246/// truncation quick-fix. `model_max` is the probed architectural window;
247/// `effective` is the `num_ctx` we actually send.
248#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
249pub struct OllamaContextInfo {
250    pub model_max: Option<usize>,
251    pub effective: Option<usize>,
252    pub source: Option<crate::models::adapters::ollama_sizing::NumCtxSource>,
253}
254
255/// Post-turn memory placement of the loaded Ollama model, from `/api/ps`.
256/// `total_bytes` is weights + KV + buffers; `size_vram_bytes` is the part
257/// resident in VRAM. Volatile (changes when the model reloads), so it lives
258/// outside the quasi-static [`OllamaContextInfo`].
259#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
260pub struct OllamaPlacement {
261    pub size_vram_bytes: u64,
262    pub total_bytes: u64,
263}
264
265impl OllamaPlacement {
266    /// True when the model didn't fully fit VRAM and spilled to CPU/RAM (slow).
267    pub fn offloaded(&self) -> bool {
268        self.size_vram_bytes < self.total_bytes
269    }
270
271    /// Rough percentage of the model running on CPU/RAM (0–100). Integer math;
272    /// `0` when the footprint is unknown or fully resident.
273    pub fn percent_on_cpu(&self) -> u8 {
274        if self.total_bytes == 0 {
275            return 0;
276        }
277        let on_cpu = self.total_bytes.saturating_sub(self.size_vram_bytes);
278        (on_cpu.saturating_mul(100) / self.total_bytes) as u8
279    }
280}
281
282/// Runtime state that is not part of the chat transcript sent to a
283/// model, but is useful for UI, slash commands, and debugging.
284#[derive(Debug, Clone, Serialize, Deserialize)]
285pub struct RuntimeState {
286    pub provider_capabilities: ProviderCapabilitySnapshot,
287    #[serde(default)]
288    pub processes: Vec<ManagedProcess>,
289    #[serde(default)]
290    pub timeline: Vec<RuntimeTimelineEvent>,
291    /// Estimated token cost of the built-in tool schemas the effect runner
292    /// appends to every model request during dispatch. The reducer's
293    /// `/context` preview builds an MCP-only request and can't see these, so
294    /// the runner reports the figure via `Msg::BuiltinToolSchemaTokens` and
295    /// `/context` folds it in to match what dispatch actually decides.
296    #[serde(default)]
297    pub builtin_tool_schema_tokens: usize,
298    /// Resolved Ollama context window for the active model (`None` until the
299    /// first turn probes it, or for non-Ollama providers).
300    #[serde(default)]
301    pub ollama_context: Option<OllamaContextInfo>,
302    /// Post-turn `/api/ps` memory placement for the active model (`None` until a
303    /// turn probes it). Volatile, so it's tracked separately from the window.
304    #[serde(default)]
305    pub ollama_placement: Option<OllamaPlacement>,
306    /// Models we've already shown the proactive auto-fit hint for this session.
307    /// Session-only (not persisted) so the gentle reminder reappears each launch.
308    #[serde(skip)]
309    pub hinted_models: HashSet<String>,
310    /// Models we've already warned about VRAM offload this session. Session-only,
311    /// so the once-per-session warning behaves like the auto-fit hint.
312    #[serde(skip)]
313    pub offload_warned: HashSet<String>,
314    /// Auto-converge: per-model `num_ctx` that the post-turn `/api/ps` check
315    /// found fits VRAM, keyed by model id. Session-only (not persisted) because
316    /// it depends on whatever else is using VRAM right now; re-derived each
317    /// session. Read by `build_chat_request` below a user override.
318    #[serde(skip)]
319    pub ollama_converged_num_ctx: std::collections::HashMap<String, u32>,
320    /// When the current user interaction began. One "turn" in Mermaid is a single
321    /// model call + its tools; an agentic run spans many such turns (each tool
322    /// follow-up mints a fresh `TurnId`). This anchors the spinner's elapsed timer
323    /// to the *whole* run so it doesn't reset to 0 at every tool step. Set on
324    /// submit, read only while generating/executing tools. Session-only.
325    #[serde(skip)]
326    pub run_started: Option<std::time::SystemTime>,
327    /// Estimated tokens generated in *completed* phases of the current run, so the
328    /// spinner's token counter accumulates across tool steps instead of resetting
329    /// each model call. The live phase's estimate is added on top at render time.
330    #[serde(skip)]
331    pub run_committed_tokens: usize,
332    /// Consecutive auto-compact-and-continue recoveries in the current run after a
333    /// context-window truncation. Bounded by `settings.compaction.max_truncation_recoveries`
334    /// (0 = uncapped) and reset whenever the run makes progress, so it caps only
335    /// no-progress thrashing on a too-small window. Session-only.
336    #[serde(skip)]
337    pub truncation_recoveries: u32,
338    /// Consecutive turns in the current run that produced no visible output (no
339    /// assistant text and no tool calls) — even if the model spent the turn on
340    /// hidden reasoning. Under `MAX_EMPTY_CONTINUATIONS` the run auto-retries the
341    /// model call so a stalled turn isn't left silent; at the cap it stops with a
342    /// hint. Reset on a fresh run and whenever a turn makes progress. Session-only.
343    #[serde(skip)]
344    pub empty_continuations: u32,
345}
346
347impl RuntimeState {
348    pub fn new(model_id: &str) -> Self {
349        Self {
350            provider_capabilities: ProviderCapabilitySnapshot::from_model_id(model_id),
351            processes: Vec::new(),
352            timeline: Vec::new(),
353            builtin_tool_schema_tokens: 0,
354            ollama_context: None,
355            ollama_placement: None,
356            hinted_models: HashSet::new(),
357            offload_warned: HashSet::new(),
358            ollama_converged_num_ctx: std::collections::HashMap::new(),
359            run_started: None,
360            run_committed_tokens: 0,
361            truncation_recoveries: 0,
362            empty_continuations: 0,
363        }
364    }
365
366    /// Cap on `timeline` length. It's a recent-activity log for `/runtime` and
367    /// the serialized snapshot, not an audit trail, so the oldest events are
368    /// trimmed — otherwise it grows monotonically for the session's life (and
369    /// it's `#[serde(default)]`, so it would also bloat every saved snapshot).
370    const MAX_TIMELINE_EVENTS: usize = 200;
371
372    /// Append a timeline event, trimming the oldest so the log stays bounded.
373    fn push_timeline(&mut self, kind: RuntimeTimelineKind, message: String) {
374        self.timeline.push(RuntimeTimelineEvent { kind, message });
375        let len = self.timeline.len();
376        if len > Self::MAX_TIMELINE_EVENTS {
377            self.timeline.drain(0..len - Self::MAX_TIMELINE_EVENTS);
378        }
379    }
380
381    pub fn set_model(&mut self, model_id: &str) {
382        self.provider_capabilities = ProviderCapabilitySnapshot::from_model_id(model_id);
383        // New model → the resolved window + placement no longer apply; re-probed
384        // next turn.
385        self.ollama_context = None;
386        self.ollama_placement = None;
387        self.push_timeline(
388            RuntimeTimelineKind::Provider,
389            format!("model set to {}", model_id),
390        );
391    }
392
393    pub fn record_signal(&mut self, signal: RuntimeSignal) {
394        self.push_timeline(
395            RuntimeTimelineKind::Signal,
396            format!("received {}", signal.as_str()),
397        );
398    }
399
400    pub fn register_process(&mut self, process: ManagedProcess) {
401        if let Some(existing) = self.processes.iter_mut().find(|p| p.pid == process.pid) {
402            *existing = process.clone();
403        } else {
404            self.processes.push(process.clone());
405        }
406        self.push_timeline(
407            RuntimeTimelineKind::Process,
408            format!("registered process {} ({})", process.pid, process.command),
409        );
410    }
411}
412
413impl Default for RuntimeState {
414    fn default() -> Self {
415        Self::new("ollama/unknown")
416    }
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422
423    #[test]
424    fn timeline_is_bounded_and_keeps_most_recent() {
425        let mut rt = RuntimeState::new("ollama/test");
426        // `new` may seed an initial event; push well past the cap and confirm
427        // the log is trimmed to the most recent window rather than growing.
428        for _ in 0..(RuntimeState::MAX_TIMELINE_EVENTS + 50) {
429            rt.record_signal(RuntimeSignal::Interrupt);
430        }
431        assert_eq!(rt.timeline.len(), RuntimeState::MAX_TIMELINE_EVENTS);
432        // The newest event is retained (front-trim keeps the tail).
433        assert_eq!(
434            rt.timeline.last().map(|e| e.message.as_str()),
435            Some("received interrupt")
436        );
437    }
438}