1use std::collections::HashSet;
9
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13#[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#[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#[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 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#[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#[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#[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 #[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 #[serde(default)]
159 pub token_usage: Option<crate::models::TokenUsage>,
160}
161
162#[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#[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 #[serde(default)]
237 agent_id: String,
238 },
239 Custom {
240 name: String,
241 data: Value,
242 },
243}
244
245#[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#[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#[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 pub fn offloaded(&self) -> bool {
279 self.size_vram_bytes < self.total_bytes
280 }
281
282 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#[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 #[serde(default)]
308 pub builtin_tool_schema_tokens: usize,
309 #[serde(default)]
312 pub ollama_context: Option<OllamaContextInfo>,
313 #[serde(default)]
316 pub ollama_placement: Option<OllamaPlacement>,
317 #[serde(skip)]
320 pub hinted_models: HashSet<String>,
321 #[serde(skip)]
324 pub offload_warned: HashSet<String>,
325 #[serde(skip)]
330 pub ollama_converged_num_ctx: std::collections::HashMap<String, u32>,
331 #[serde(skip)]
337 pub run_started: Option<std::time::SystemTime>,
338 #[serde(skip)]
342 pub run_committed_tokens: usize,
343 #[serde(skip)]
348 pub truncation_recoveries: u32,
349 #[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 const MAX_TIMELINE_EVENTS: usize = 200;
382
383 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 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 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 assert_eq!(
445 rt.timeline.last().map(|e| e.message.as_str()),
446 Some("received interrupt")
447 );
448 }
449}