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}