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};
11
12/// External lifecycle signal observed by the app shell.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum RuntimeSignal {
16    Interrupt,
17    Terminate,
18    Hangup,
19}
20
21impl RuntimeSignal {
22    pub fn as_str(self) -> &'static str {
23        match self {
24            RuntimeSignal::Interrupt => "interrupt",
25            RuntimeSignal::Terminate => "terminate",
26            RuntimeSignal::Hangup => "hangup",
27        }
28    }
29}
30
31/// Runtime event recorded in state for observability / replay tooling.
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct RuntimeTimelineEvent {
34    pub kind: RuntimeTimelineKind,
35    pub message: String,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum RuntimeTimelineKind {
41    Signal,
42    Process,
43    Tool,
44    Provider,
45}
46
47/// Normalized provider capability snapshot exposed in app state.
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49pub struct ProviderCapabilitySnapshot {
50    pub provider: String,
51    pub model: String,
52    pub supports_tools: bool,
53    pub supports_vision: bool,
54    pub reasoning: String,
55    pub max_context_tokens: Option<usize>,
56    /// Per-response output ceiling, when known (static table at model-switch
57    /// time; refreshed live via `ProviderContextResolved`).
58    #[serde(default)]
59    pub max_output_tokens: Option<usize>,
60}
61
62impl ProviderCapabilitySnapshot {
63    /// Conservative static snapshot used before a provider has been
64    /// resolved. This is intentionally cheap and side-effect free so
65    /// the reducer can update it on `/model` without touching network
66    /// or credential state.
67    pub fn from_model_id(model_id: &str) -> Self {
68        let (provider, model) = match model_id.split_once('/') {
69            Some((provider, model)) if !provider.is_empty() && !model.is_empty() => {
70                (provider.to_ascii_lowercase(), model.to_string())
71            },
72            _ => ("ollama".to_string(), model_id.to_string()),
73        };
74
75        let (supports_tools, supports_vision, reasoning) = match provider.as_str() {
76            "anthropic" => (true, true, "adaptive".to_string()),
77            "gemini" => (true, true, "thinking_level".to_string()),
78            "meta" => (true, true, "responses_effort".to_string()),
79            "ollama" => (true, false, "binary".to_string()),
80            _ => (true, false, "effort".to_string()),
81        };
82
83        // Meta's muse-spark rides the catalog like the gpt rows (its /v1/models
84        // exposes no limits) — no provider special-case here.
85        let max_context_tokens = infer_static_context_window(&model);
86        // Output ceilings start unknown everywhere and are refreshed live via
87        // `ProviderContextResolved` (for meta, from the provider's documented
88        // capabilities after the first resolve — same one-turn delay as
89        // anthropic/gemini).
90        let max_output_tokens = None;
91
92        Self {
93            provider,
94            model,
95            supports_tools,
96            supports_vision,
97            reasoning,
98            max_context_tokens,
99            max_output_tokens,
100        }
101    }
102}
103
104fn infer_static_context_window(model: &str) -> Option<usize> {
105    // Per-model documented windows from the capability catalog — ONLY for
106    // providers whose API exposes no limits (OpenAI's gpt rows). Providers
107    // with a models endpoint (Anthropic, Gemini, Ollama, most OpenAI-compat)
108    // resolve live via `resolve_context_window`; `None` here means "unknown
109    // until discovery", never a guessed fallback.
110    crate::models::catalog::lookup(model).context_window
111}
112
113pub fn infer_static_context_window_for_model_id(model_id: &str) -> Option<usize> {
114    let model = match model_id.split_once('/') {
115        Some((provider, model)) if !provider.is_empty() && !model.is_empty() => model,
116        _ => model_id,
117    };
118    infer_static_context_window(model)
119}
120
121/// Tool-run value types moved to `mermaid_model::tool_run` — re-exported here so
122/// `domain::ToolRunMetadata` and its siblings keep resolving unchanged.
123pub use mermaid_model::tool_run::{
124    ManagedProcess, ManagedProcessStatus, OllamaContextInfo, OllamaPlacement, ToolArtifact,
125    ToolMetadata, ToolRunMetadata, ToolStatus, WebSearchFailure,
126};
127
128/// A subagent detached from its turn via Ctrl+B: still running in a
129/// spawned task, no longer blocking the parent. Rows render in the live
130/// agent panel until `Msg::BackgroundAgentFinished` removes them (and the
131/// child's report arrives as a queued message).
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133pub struct BackgroundAgent {
134    pub agent_id: String,
135    pub description: String,
136    pub started: std::time::SystemTime,
137    #[serde(default)]
138    pub activity: String,
139    #[serde(default)]
140    pub tokens: usize,
141}
142
143/// Runtime state that is not part of the chat transcript sent to a
144/// model, but is useful for UI, slash commands, and debugging.
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct RuntimeState {
147    pub provider_capabilities: ProviderCapabilitySnapshot,
148    #[serde(default)]
149    pub processes: Vec<ManagedProcess>,
150    /// Subagents detached from their turn via Ctrl+B, newest last.
151    #[serde(default)]
152    pub background_agents: Vec<BackgroundAgent>,
153    #[serde(default)]
154    pub timeline: Vec<RuntimeTimelineEvent>,
155    /// Estimated token cost of the built-in tool schemas the effect runner
156    /// appends to every model request during dispatch. The reducer's
157    /// `/context` preview builds an MCP-only request and can't see these, so
158    /// the runner reports the figure via `Msg::BuiltinToolSchemaTokens` and
159    /// `/context` folds it in to match what dispatch actually decides.
160    #[serde(default)]
161    pub builtin_tool_schema_tokens: usize,
162    /// Resolved Ollama context window for the active model (`None` until the
163    /// first turn probes it, or for non-Ollama providers).
164    #[serde(default)]
165    pub ollama_context: Option<OllamaContextInfo>,
166    /// Post-turn `/api/ps` memory placement for the active model (`None` until a
167    /// turn probes it). Volatile, so it's tracked separately from the window.
168    #[serde(default)]
169    pub ollama_placement: Option<OllamaPlacement>,
170    /// Models we've already shown the proactive auto-fit hint for this session.
171    /// Session-only (not persisted) so the gentle reminder reappears each launch.
172    #[serde(skip)]
173    pub hinted_models: HashSet<String>,
174    /// Models we've already warned about VRAM offload this session. Session-only,
175    /// so the once-per-session warning behaves like the auto-fit hint.
176    #[serde(skip)]
177    pub offload_warned: HashSet<String>,
178    /// Model-call cycles since the task checklist last changed, while a task
179    /// sits in_progress. Drives the staleness nudge (see `push_call_model`);
180    /// session-only, reset by every `Msg::TasksUpdated`.
181    #[serde(skip)]
182    pub calls_since_task_update: u32,
183    /// Plan-mode doom-loop breaker, armed by the FIRST plan-policy denial of
184    /// the current stretch (a read-heavy Ground phase alone must never trip
185    /// it). While armed, `push_plan_reminder` counts model calls; at the
186    /// threshold the tail reminder escalates to a corrective. Disarmed by a
187    /// successful plan write (`write_file`/`apply_patch` — the only Edit that
188    /// can succeed under the plan floor) and cleared on plan entry/exit.
189    /// Session-only.
190    #[serde(skip)]
191    pub plan_thrash_armed: bool,
192    /// Model calls since the arming denial (see `plan_thrash_armed`).
193    #[serde(skip)]
194    pub plan_calls_since_denial: u32,
195    /// Models we've already shown the no-vision-model notice for this session.
196    /// Session-only (not persisted), so the one-shot warning behaves like the
197    /// auto-fit hint and offload warning.
198    #[serde(skip)]
199    pub vision_warned: HashSet<String>,
200    /// Auto-converge: per-model `num_ctx` that the post-turn `/api/ps` check
201    /// found fits VRAM, keyed by model id. Session-only (not persisted) because
202    /// it depends on whatever else is using VRAM right now; re-derived each
203    /// session. Read by `build_chat_request` below a user override.
204    #[serde(skip)]
205    pub ollama_converged_num_ctx: std::collections::HashMap<String, u32>,
206    /// When the current user interaction began. One "turn" in Mermaid is a single
207    /// model call + its tools; an agentic run spans many such turns (each tool
208    /// follow-up mints a fresh `TurnId`). This anchors the spinner's elapsed timer
209    /// to the *whole* run so it doesn't reset to 0 at every tool step. Set on
210    /// submit, read only while generating/executing tools. Session-only.
211    #[serde(skip)]
212    pub run_started: Option<std::time::SystemTime>,
213    /// Output tokens committed in *completed* phases of the current run
214    /// (parent turns, subagents, mid-run compactions), so the spinner's token
215    /// counter accumulates across tool steps instead of resetting each model
216    /// call. The live phase's char-based estimate is added on top at render
217    /// time.
218    #[serde(skip)]
219    pub run_tokens: RunTokenCounter,
220    /// Lines added/removed by file-mutating tools (write_file, apply_patch)
221    /// across the whole run, summed from each outcome's exact metadata counts
222    /// so the end-of-run summary can show `+N/-M` without the user totting up
223    /// per-call diffs. Reset on submit alongside `run_tokens`. Session-only.
224    #[serde(skip)]
225    pub run_line_changes: RunLineChanges,
226    /// Consecutive auto-compact-and-continue recoveries in the current run after a
227    /// context-window truncation. Bounded by `settings.compaction.max_truncation_recoveries`
228    /// (0 = uncapped) and reset whenever the run makes progress, so it caps only
229    /// no-progress thrashing on a too-small window. Session-only.
230    #[serde(skip)]
231    pub truncation_recoveries: u32,
232    /// Consecutive turns in the current run that produced no visible output (no
233    /// assistant text and no tool calls) — even if the model spent the turn on
234    /// hidden reasoning. Under `MAX_EMPTY_CONTINUATIONS` the run auto-retries the
235    /// model call so a stalled turn isn't left silent; at the cap it stops with a
236    /// hint. Reset on a fresh run and whenever a turn makes progress. Session-only.
237    #[serde(skip)]
238    pub empty_continuations: u32,
239    /// Consecutive auto-continuations in the current run after a response hit
240    /// the provider's per-response OUTPUT cap with window room to spare
241    /// (compaction can't help those — the reply is continued in a fresh turn
242    /// instead). Bounded by `MAX_OUTPUT_CONTINUATIONS`; reset whenever a turn
243    /// ends any other way. Session-only.
244    #[serde(skip)]
245    pub continue_recoveries: u32,
246    /// Auto-threshold compaction failed, so it is paused until a compaction
247    /// succeeds, the user runs `/compact`, or the conversation is switched.
248    /// Without this, a summarizer that keeps failing (e.g. a model that can't
249    /// produce the required checkpoint structure) silently retries — and pays
250    /// for — a draft + review model call on every subsequent turn. Session-only.
251    #[serde(skip)]
252    pub auto_compact_suppressed: bool,
253}
254
255/// Output tokens generated by the current run, with provenance. Real
256/// provider counts (completion + reasoning) are the norm; a phase whose
257/// provider reported no usage falls back to a chars/4 estimate and taints
258/// the whole counter, so the run summary can mark the number `~`.
259#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
260pub struct RunTokenCounter {
261    pub output_tokens: usize,
262    pub contains_estimate: bool,
263}
264
265impl RunTokenCounter {
266    pub fn add_provider(&mut self, tokens: usize) {
267        self.output_tokens = self.output_tokens.saturating_add(tokens);
268    }
269
270    pub fn add_estimate(&mut self, tokens: usize) {
271        self.output_tokens = self.output_tokens.saturating_add(tokens);
272        self.contains_estimate = true;
273    }
274}
275
276/// Lines added/removed by file mutations in the current run.
277#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
278pub struct RunLineChanges {
279    pub added: usize,
280    pub removed: usize,
281}
282
283impl RunLineChanges {
284    pub fn add(&mut self, added: usize, removed: usize) {
285        self.added = self.added.saturating_add(added);
286        self.removed = self.removed.saturating_add(removed);
287    }
288
289    pub fn is_empty(&self) -> bool {
290        self.added == 0 && self.removed == 0
291    }
292}
293
294impl RuntimeState {
295    pub fn new(model_id: &str) -> Self {
296        Self {
297            provider_capabilities: ProviderCapabilitySnapshot::from_model_id(model_id),
298            processes: Vec::new(),
299            background_agents: Vec::new(),
300            timeline: Vec::new(),
301            builtin_tool_schema_tokens: 0,
302            ollama_context: None,
303            ollama_placement: None,
304            hinted_models: HashSet::new(),
305            offload_warned: HashSet::new(),
306            calls_since_task_update: 0,
307            plan_thrash_armed: false,
308            plan_calls_since_denial: 0,
309            vision_warned: HashSet::new(),
310            ollama_converged_num_ctx: std::collections::HashMap::new(),
311            run_started: None,
312            run_tokens: RunTokenCounter::default(),
313            run_line_changes: RunLineChanges::default(),
314            truncation_recoveries: 0,
315            empty_continuations: 0,
316            continue_recoveries: 0,
317            auto_compact_suppressed: false,
318        }
319    }
320
321    /// Cap on `timeline` length. It's a recent-activity log for `/runtime` and
322    /// the serialized snapshot, not an audit trail, so the oldest events are
323    /// trimmed — otherwise it grows monotonically for the session's life (and
324    /// it's `#[serde(default)]`, so it would also bloat every saved snapshot).
325    const MAX_TIMELINE_EVENTS: usize = 200;
326
327    /// Append a timeline event, trimming the oldest so the log stays bounded.
328    fn push_timeline(&mut self, kind: RuntimeTimelineKind, message: String) {
329        self.timeline.push(RuntimeTimelineEvent { kind, message });
330        let len = self.timeline.len();
331        if len > Self::MAX_TIMELINE_EVENTS {
332            self.timeline.drain(0..len - Self::MAX_TIMELINE_EVENTS);
333        }
334    }
335
336    pub fn set_model(&mut self, model_id: &str) {
337        self.provider_capabilities = ProviderCapabilitySnapshot::from_model_id(model_id);
338        // New model → the resolved window + placement no longer apply; re-probed
339        // next turn.
340        self.ollama_context = None;
341        self.ollama_placement = None;
342        // The pause is model-scoped: a summarizer that couldn't produce the
343        // checkpoint structure says nothing about the newly selected model,
344        // and switching models is the natural user reaction to the failure.
345        self.auto_compact_suppressed = false;
346        self.push_timeline(
347            RuntimeTimelineKind::Provider,
348            format!("model set to {}", model_id),
349        );
350    }
351
352    pub fn record_signal(&mut self, signal: RuntimeSignal) {
353        self.push_timeline(
354            RuntimeTimelineKind::Signal,
355            format!("received {}", signal.as_str()),
356        );
357    }
358
359    pub fn register_process(&mut self, process: ManagedProcess) {
360        if let Some(existing) = self.processes.iter_mut().find(|p| p.pid == process.pid) {
361            *existing = process.clone();
362        } else {
363            self.processes.push(process.clone());
364        }
365        self.push_timeline(
366            RuntimeTimelineKind::Process,
367            format!("registered process {} ({})", process.pid, process.command),
368        );
369    }
370}
371
372impl Default for RuntimeState {
373    fn default() -> Self {
374        Self::new("ollama/unknown")
375    }
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381
382    #[test]
383    fn static_context_windows_pin_the_known_matrix() {
384        // ONLY the OpenAI gpt rows and Meta's muse-spark keep static windows
385        // (their /v1/models expose no limits). Everything else is None —
386        // unknown until live discovery via `resolve_context_window` fills it.
387        for (id, want) in [
388            ("openai/gpt-4.1", Some(400_000)),
389            ("openai/gpt-5-mini", Some(400_000)),
390            ("openai/gpt-5.6", Some(1_500_000)),
391            (
392                "meta/muse-spark-1.1",
393                Some(crate::constants::META_MUSE_SPARK_CONTEXT_WINDOW),
394            ),
395            (
396                "meta/muse-spark-1.2",
397                Some(crate::constants::META_MUSE_SPARK_CONTEXT_WINDOW),
398            ),
399            ("anthropic/claude-sonnet-4-6", None),
400            ("gemini/gemini-2.5-pro", None),
401            ("openrouter/anthropic/claude-sonnet-4.5", None),
402            ("openai/gpt-4o", None),
403            ("ollama/qwen3-coder:30b", None),
404            ("anthropic/claude-future-99", None),
405            ("anthropic/nova-experimental", None),
406        ] {
407            assert_eq!(
408                infer_static_context_window_for_model_id(id),
409                want,
410                "window for {id}"
411            );
412        }
413    }
414
415    #[test]
416    fn snapshot_limits_start_unknown_before_discovery() {
417        // Pre-discovery snapshots carry no window/ceiling for providers
418        // with a limits endpoint — `ProviderContextResolved` refreshes them
419        // on the first turn. No static pins to rot.
420        let snap = ProviderCapabilitySnapshot::from_model_id("anthropic/claude-fable-5");
421        assert_eq!(snap.max_context_tokens, None);
422        assert_eq!(snap.max_output_tokens, None);
423        let snap = ProviderCapabilitySnapshot::from_model_id("gemini/gemini-2.5-pro");
424        assert_eq!(snap.max_context_tokens, None);
425        assert_eq!(snap.max_output_tokens, None);
426        let snap = ProviderCapabilitySnapshot::from_model_id("openai/gpt-4o");
427        assert_eq!(snap.max_output_tokens, None);
428    }
429
430    #[test]
431    fn timeline_is_bounded_and_keeps_most_recent() {
432        let mut rt = RuntimeState::new("ollama/test");
433        // `new` may seed an initial event; push well past the cap and confirm
434        // the log is trimmed to the most recent window rather than growing.
435        for _ in 0..(RuntimeState::MAX_TIMELINE_EVENTS + 50) {
436            rt.record_signal(RuntimeSignal::Interrupt);
437        }
438        assert_eq!(rt.timeline.len(), RuntimeState::MAX_TIMELINE_EVENTS);
439        // The newest event is retained (front-trim keeps the tail).
440        assert_eq!(
441            rt.timeline.last().map(|e| e.message.as_str()),
442            Some("received interrupt")
443        );
444    }
445}