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}
155
156#[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#[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#[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#[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#[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 pub fn offloaded(&self) -> bool {
268 self.size_vram_bytes < self.total_bytes
269 }
270
271 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#[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 #[serde(default)]
297 pub builtin_tool_schema_tokens: usize,
298 #[serde(default)]
301 pub ollama_context: Option<OllamaContextInfo>,
302 #[serde(default)]
305 pub ollama_placement: Option<OllamaPlacement>,
306 #[serde(skip)]
309 pub hinted_models: HashSet<String>,
310 #[serde(skip)]
313 pub offload_warned: HashSet<String>,
314 #[serde(skip)]
319 pub ollama_converged_num_ctx: std::collections::HashMap<String, u32>,
320 #[serde(skip)]
326 pub run_started: Option<std::time::SystemTime>,
327 #[serde(skip)]
331 pub run_committed_tokens: usize,
332 #[serde(skip)]
337 pub truncation_recoveries: u32,
338 #[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 const MAX_TIMELINE_EVENTS: usize = 200;
371
372 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 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 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 assert_eq!(
434 rt.timeline.last().map(|e| e.message.as_str()),
435 Some("received interrupt")
436 );
437 }
438}