use std::collections::HashSet;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RuntimeSignal {
Interrupt,
Terminate,
Hangup,
}
impl RuntimeSignal {
pub fn as_str(self) -> &'static str {
match self {
RuntimeSignal::Interrupt => "interrupt",
RuntimeSignal::Terminate => "terminate",
RuntimeSignal::Hangup => "hangup",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeTimelineEvent {
pub kind: RuntimeTimelineKind,
pub message: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RuntimeTimelineKind {
Signal,
Process,
Tool,
Provider,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProviderCapabilitySnapshot {
pub provider: String,
pub model: String,
pub supports_tools: bool,
pub supports_vision: bool,
pub reasoning: String,
pub max_context_tokens: Option<usize>,
#[serde(default)]
pub max_output_tokens: Option<usize>,
}
impl ProviderCapabilitySnapshot {
pub fn from_model_id(model_id: &str) -> Self {
let (provider, model) = match model_id.split_once('/') {
Some((provider, model)) if !provider.is_empty() && !model.is_empty() => {
(provider.to_ascii_lowercase(), model.to_string())
},
_ => ("ollama".to_string(), model_id.to_string()),
};
let (supports_tools, supports_vision, reasoning) = match provider.as_str() {
"anthropic" => (true, true, "adaptive".to_string()),
"gemini" => (true, true, "thinking_level".to_string()),
"meta" => (true, true, "responses_effort".to_string()),
"ollama" => (true, false, "binary".to_string()),
_ => (true, false, "effort".to_string()),
};
let max_context_tokens = infer_static_context_window(&model);
let max_output_tokens = None;
Self {
provider,
model,
supports_tools,
supports_vision,
reasoning,
max_context_tokens,
max_output_tokens,
}
}
}
fn infer_static_context_window(model: &str) -> Option<usize> {
crate::models::catalog::lookup(model).context_window
}
pub fn infer_static_context_window_for_model_id(model_id: &str) -> Option<usize> {
let model = match model_id.split_once('/') {
Some((provider, model)) if !provider.is_empty() && !model.is_empty() => model,
_ => model_id,
};
infer_static_context_window(model)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ManagedProcessStatus {
Running,
Exited,
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ManagedProcess {
pub id: String,
pub pid: u32,
pub command: String,
pub cwd: Option<String>,
pub log_path: String,
pub detected_url: Option<String>,
pub status: ManagedProcessStatus,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ToolRunMetadata {
#[serde(default)]
pub detail: ToolMetadata,
pub line_count: Option<usize>,
pub byte_count: Option<usize>,
pub result_count: Option<usize>,
pub duration_secs: Option<f64>,
pub process: Option<ManagedProcess>,
#[serde(default)]
pub display_diff: Option<String>,
#[serde(default)]
pub diff_truncated: bool,
#[serde(default)]
pub lines_added: usize,
#[serde(default)]
pub lines_removed: usize,
#[serde(default)]
pub artifacts: Vec<ToolArtifact>,
#[serde(default)]
pub token_usage: Option<crate::models::TokenUsage>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolStatus {
Success,
Error,
Cancelled,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ToolMetadata {
#[default]
None,
ReadFile {
paths: Vec<String>,
line_count: usize,
byte_count: usize,
truncated: bool,
},
WriteFile {
path: String,
line_count: usize,
byte_count: usize,
created: Option<bool>,
},
ApplyPatch {
added: Vec<String>,
modified: Vec<String>,
deleted: Vec<String>,
renamed: Vec<(String, String)>,
fuzzy: bool,
},
DeleteFile {
path: String,
},
CreateDirectory {
path: String,
},
WebSearch {
queries: Vec<String>,
requested_count: usize,
result_count: usize,
sources: Vec<String>,
},
WebFetch {
url: String,
title: Option<String>,
line_count: usize,
byte_count: usize,
},
ExecuteCommand {
command: String,
working_dir: Option<String>,
exit_code: Option<i32>,
timed_out: bool,
background: bool,
stdout_lines: usize,
stderr_lines: usize,
detected_urls: Vec<String>,
pid: Option<u32>,
log_path: Option<String>,
#[serde(default)]
denied_by_sandbox: bool,
},
ComputerUse {
action: String,
params: Value,
},
Mcp {
server: String,
tool: String,
},
Subagent {
model_id: String,
#[serde(default)]
agent_id: String,
},
Tasks {
action: String,
completed: u32,
total: u32,
},
Questions {
answers: Vec<super::question::QuestionAnswer>,
#[serde(default)]
remembered: bool,
},
Plan {
path: String,
body: String,
#[serde(default)]
start: bool,
#[serde(default)]
fresh: bool,
#[serde(default)]
fork: bool,
#[serde(default)]
model: Option<String>,
},
Custom {
name: String,
data: Value,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ToolArtifact {
Image { data: String },
File { path: String },
Log { path: String },
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct OllamaContextInfo {
pub model_max: Option<usize>,
pub effective: Option<usize>,
pub source: Option<crate::models::adapters::ollama_sizing::NumCtxSource>,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct OllamaPlacement {
pub size_vram_bytes: u64,
pub total_bytes: u64,
}
impl OllamaPlacement {
pub fn offloaded(&self) -> bool {
self.size_vram_bytes < self.total_bytes
}
pub fn percent_on_cpu(&self) -> u8 {
if self.total_bytes == 0 {
return 0;
}
let on_cpu = self.total_bytes.saturating_sub(self.size_vram_bytes);
(on_cpu.saturating_mul(100) / self.total_bytes) as u8
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BackgroundAgent {
pub agent_id: String,
pub description: String,
pub started: std::time::SystemTime,
#[serde(default)]
pub activity: String,
#[serde(default)]
pub tokens: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RuntimeState {
pub provider_capabilities: ProviderCapabilitySnapshot,
#[serde(default)]
pub processes: Vec<ManagedProcess>,
#[serde(default)]
pub background_agents: Vec<BackgroundAgent>,
#[serde(default)]
pub timeline: Vec<RuntimeTimelineEvent>,
#[serde(default)]
pub builtin_tool_schema_tokens: usize,
#[serde(default)]
pub ollama_context: Option<OllamaContextInfo>,
#[serde(default)]
pub ollama_placement: Option<OllamaPlacement>,
#[serde(skip)]
pub hinted_models: HashSet<String>,
#[serde(skip)]
pub offload_warned: HashSet<String>,
#[serde(skip)]
pub calls_since_task_update: u32,
#[serde(skip)]
pub vision_warned: HashSet<String>,
#[serde(skip)]
pub ollama_converged_num_ctx: std::collections::HashMap<String, u32>,
#[serde(skip)]
pub run_started: Option<std::time::SystemTime>,
#[serde(skip)]
pub run_tokens: RunTokenCounter,
#[serde(skip)]
pub run_line_changes: RunLineChanges,
#[serde(skip)]
pub truncation_recoveries: u32,
#[serde(skip)]
pub empty_continuations: u32,
#[serde(skip)]
pub continue_recoveries: u32,
#[serde(skip)]
pub auto_compact_suppressed: bool,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct RunTokenCounter {
pub output_tokens: usize,
pub contains_estimate: bool,
}
impl RunTokenCounter {
pub fn add_provider(&mut self, tokens: usize) {
self.output_tokens = self.output_tokens.saturating_add(tokens);
}
pub fn add_estimate(&mut self, tokens: usize) {
self.output_tokens = self.output_tokens.saturating_add(tokens);
self.contains_estimate = true;
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct RunLineChanges {
pub added: usize,
pub removed: usize,
}
impl RunLineChanges {
pub fn add(&mut self, added: usize, removed: usize) {
self.added = self.added.saturating_add(added);
self.removed = self.removed.saturating_add(removed);
}
pub fn is_empty(&self) -> bool {
self.added == 0 && self.removed == 0
}
}
impl RuntimeState {
pub fn new(model_id: &str) -> Self {
Self {
provider_capabilities: ProviderCapabilitySnapshot::from_model_id(model_id),
processes: Vec::new(),
background_agents: Vec::new(),
timeline: Vec::new(),
builtin_tool_schema_tokens: 0,
ollama_context: None,
ollama_placement: None,
hinted_models: HashSet::new(),
offload_warned: HashSet::new(),
calls_since_task_update: 0,
vision_warned: HashSet::new(),
ollama_converged_num_ctx: std::collections::HashMap::new(),
run_started: None,
run_tokens: RunTokenCounter::default(),
run_line_changes: RunLineChanges::default(),
truncation_recoveries: 0,
empty_continuations: 0,
continue_recoveries: 0,
auto_compact_suppressed: false,
}
}
const MAX_TIMELINE_EVENTS: usize = 200;
fn push_timeline(&mut self, kind: RuntimeTimelineKind, message: String) {
self.timeline.push(RuntimeTimelineEvent { kind, message });
let len = self.timeline.len();
if len > Self::MAX_TIMELINE_EVENTS {
self.timeline.drain(0..len - Self::MAX_TIMELINE_EVENTS);
}
}
pub fn set_model(&mut self, model_id: &str) {
self.provider_capabilities = ProviderCapabilitySnapshot::from_model_id(model_id);
self.ollama_context = None;
self.ollama_placement = None;
self.auto_compact_suppressed = false;
self.push_timeline(
RuntimeTimelineKind::Provider,
format!("model set to {}", model_id),
);
}
pub fn record_signal(&mut self, signal: RuntimeSignal) {
self.push_timeline(
RuntimeTimelineKind::Signal,
format!("received {}", signal.as_str()),
);
}
pub fn register_process(&mut self, process: ManagedProcess) {
if let Some(existing) = self.processes.iter_mut().find(|p| p.pid == process.pid) {
*existing = process.clone();
} else {
self.processes.push(process.clone());
}
self.push_timeline(
RuntimeTimelineKind::Process,
format!("registered process {} ({})", process.pid, process.command),
);
}
}
impl Default for RuntimeState {
fn default() -> Self {
Self::new("ollama/unknown")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn static_context_windows_pin_the_known_matrix() {
for (id, want) in [
("openai/gpt-4.1", Some(400_000)),
("openai/gpt-5-mini", Some(400_000)),
("openai/gpt-5.6", Some(1_500_000)),
(
"meta/muse-spark-1.1",
Some(crate::constants::META_MUSE_SPARK_CONTEXT_WINDOW),
),
(
"meta/muse-spark-1.2",
Some(crate::constants::META_MUSE_SPARK_CONTEXT_WINDOW),
),
("anthropic/claude-sonnet-4-6", None),
("gemini/gemini-2.5-pro", None),
("openrouter/anthropic/claude-sonnet-4.5", None),
("openai/gpt-4o", None),
("ollama/qwen3-coder:30b", None),
("anthropic/claude-future-99", None),
("anthropic/nova-experimental", None),
] {
assert_eq!(
infer_static_context_window_for_model_id(id),
want,
"window for {id}"
);
}
}
#[test]
fn snapshot_limits_start_unknown_before_discovery() {
let snap = ProviderCapabilitySnapshot::from_model_id("anthropic/claude-fable-5");
assert_eq!(snap.max_context_tokens, None);
assert_eq!(snap.max_output_tokens, None);
let snap = ProviderCapabilitySnapshot::from_model_id("gemini/gemini-2.5-pro");
assert_eq!(snap.max_context_tokens, None);
assert_eq!(snap.max_output_tokens, None);
let snap = ProviderCapabilitySnapshot::from_model_id("openai/gpt-4o");
assert_eq!(snap.max_output_tokens, None);
}
#[test]
fn timeline_is_bounded_and_keeps_most_recent() {
let mut rt = RuntimeState::new("ollama/test");
for _ in 0..(RuntimeState::MAX_TIMELINE_EVENTS + 50) {
rt.record_signal(RuntimeSignal::Interrupt);
}
assert_eq!(rt.timeline.len(), RuntimeState::MAX_TIMELINE_EVENTS);
assert_eq!(
rt.timeline.last().map(|e| e.message.as_str()),
Some("received interrupt")
);
}
}