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}
339
340impl RuntimeState {
341 pub fn new(model_id: &str) -> Self {
342 Self {
343 provider_capabilities: ProviderCapabilitySnapshot::from_model_id(model_id),
344 processes: Vec::new(),
345 timeline: Vec::new(),
346 builtin_tool_schema_tokens: 0,
347 ollama_context: None,
348 ollama_placement: None,
349 hinted_models: HashSet::new(),
350 offload_warned: HashSet::new(),
351 ollama_converged_num_ctx: std::collections::HashMap::new(),
352 run_started: None,
353 run_committed_tokens: 0,
354 truncation_recoveries: 0,
355 }
356 }
357
358 pub fn set_model(&mut self, model_id: &str) {
359 self.provider_capabilities = ProviderCapabilitySnapshot::from_model_id(model_id);
360 self.ollama_context = None;
363 self.ollama_placement = None;
364 self.timeline.push(RuntimeTimelineEvent {
365 kind: RuntimeTimelineKind::Provider,
366 message: format!("model set to {}", model_id),
367 });
368 }
369
370 pub fn record_signal(&mut self, signal: RuntimeSignal) {
371 self.timeline.push(RuntimeTimelineEvent {
372 kind: RuntimeTimelineKind::Signal,
373 message: format!("received {}", signal.as_str()),
374 });
375 }
376
377 pub fn register_process(&mut self, process: ManagedProcess) {
378 if let Some(existing) = self.processes.iter_mut().find(|p| p.pid == process.pid) {
379 *existing = process.clone();
380 } else {
381 self.processes.push(process.clone());
382 }
383 self.timeline.push(RuntimeTimelineEvent {
384 kind: RuntimeTimelineKind::Process,
385 message: format!("registered process {} ({})", process.pid, process.command),
386 });
387 }
388}
389
390impl Default for RuntimeState {
391 fn default() -> Self {
392 Self::new("ollama/unknown")
393 }
394}