1use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum RuntimeSignal {
15 Interrupt,
16 Terminate,
17 Hangup,
18}
19
20impl RuntimeSignal {
21 pub fn as_str(self) -> &'static str {
22 match self {
23 RuntimeSignal::Interrupt => "interrupt",
24 RuntimeSignal::Terminate => "terminate",
25 RuntimeSignal::Hangup => "hangup",
26 }
27 }
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct RuntimeTimelineEvent {
33 pub kind: RuntimeTimelineKind,
34 pub message: String,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum RuntimeTimelineKind {
40 Signal,
41 Process,
42 Tool,
43 Provider,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct ProviderCapabilitySnapshot {
49 pub provider: String,
50 pub model: String,
51 pub supports_tools: bool,
52 pub supports_vision: bool,
53 pub reasoning: String,
54 pub max_context_tokens: Option<usize>,
55}
56
57impl ProviderCapabilitySnapshot {
58 pub fn from_model_id(model_id: &str) -> Self {
63 let (provider, model) = match model_id.split_once('/') {
64 Some((provider, model)) if !provider.is_empty() && !model.is_empty() => {
65 (provider.to_ascii_lowercase(), model.to_string())
66 },
67 _ => ("ollama".to_string(), model_id.to_string()),
68 };
69
70 let (supports_tools, supports_vision, reasoning) = match provider.as_str() {
71 "anthropic" => (true, true, "adaptive".to_string()),
72 "gemini" => (true, true, "thinking_level".to_string()),
73 "ollama" => (true, false, "binary".to_string()),
74 _ => (true, false, "effort".to_string()),
75 };
76
77 let max_context_tokens = infer_static_context_window(&provider, &model);
78
79 Self {
80 provider,
81 model,
82 supports_tools,
83 supports_vision,
84 reasoning,
85 max_context_tokens,
86 }
87 }
88}
89
90fn infer_static_context_window(provider: &str, model: &str) -> Option<usize> {
91 let model = model.to_ascii_lowercase();
92 match provider {
93 "anthropic" => Some(200_000),
94 "gemini" => Some(1_000_000),
95 "openai" if model.contains("gpt-4.1") || model.contains("gpt-5") => Some(400_000),
96 "openrouter" if model.contains("claude") => Some(200_000),
97 _ => None,
98 }
99}
100
101pub fn infer_static_context_window_for_model_id(model_id: &str) -> Option<usize> {
102 let (provider, model) = match model_id.split_once('/') {
103 Some((provider, model)) if !provider.is_empty() && !model.is_empty() => {
104 (provider.to_ascii_lowercase(), model.to_string())
105 },
106 _ => ("ollama".to_string(), model_id.to_string()),
107 };
108 infer_static_context_window(&provider, &model)
109}
110
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114#[serde(rename_all = "snake_case")]
115pub enum ManagedProcessStatus {
116 Running,
117 Exited,
118 Unknown,
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123pub struct ManagedProcess {
124 pub id: String,
125 pub pid: u32,
126 pub command: String,
127 pub cwd: Option<String>,
128 pub log_path: String,
129 pub detected_url: Option<String>,
130 pub status: ManagedProcessStatus,
131}
132
133#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
135pub struct ToolRunMetadata {
136 #[serde(default)]
137 pub detail: ToolMetadata,
138 pub line_count: Option<usize>,
139 pub byte_count: Option<usize>,
140 pub result_count: Option<usize>,
141 pub duration_secs: Option<f64>,
142 pub process: Option<ManagedProcess>,
143 #[serde(default)]
144 pub artifacts: Vec<ToolArtifact>,
145}
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
149#[serde(rename_all = "snake_case")]
150pub enum ToolStatus {
151 Success,
152 Error,
153 Cancelled,
154}
155
156#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
158#[serde(tag = "kind", rename_all = "snake_case")]
159pub enum ToolMetadata {
160 #[default]
161 None,
162 ReadFile {
163 paths: Vec<String>,
164 line_count: usize,
165 byte_count: usize,
166 truncated: bool,
167 },
168 WriteFile {
169 path: String,
170 line_count: usize,
171 byte_count: usize,
172 created: Option<bool>,
173 },
174 EditFile {
175 path: String,
176 replacements: usize,
177 },
178 DeleteFile {
179 path: String,
180 },
181 CreateDirectory {
182 path: String,
183 },
184 WebSearch {
185 queries: Vec<String>,
186 requested_count: usize,
187 result_count: usize,
188 sources: Vec<String>,
189 },
190 WebFetch {
191 url: String,
192 title: Option<String>,
193 line_count: usize,
194 byte_count: usize,
195 },
196 ExecuteCommand {
197 command: String,
198 working_dir: Option<String>,
199 exit_code: Option<i32>,
200 timed_out: bool,
201 background: bool,
202 stdout_lines: usize,
203 stderr_lines: usize,
204 detected_urls: Vec<String>,
205 pid: Option<u32>,
206 log_path: Option<String>,
207 },
208 ComputerUse {
209 action: String,
210 params: Value,
211 },
212 Mcp {
213 server: String,
214 tool: String,
215 },
216 Subagent {
217 model_id: String,
218 },
219 Custom {
220 name: String,
221 data: Value,
222 },
223}
224
225#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
228#[serde(tag = "kind", rename_all = "snake_case")]
229pub enum ToolArtifact {
230 Image { data: String },
231 File { path: String },
232 Log { path: String },
233}
234
235#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct RuntimeState {
239 pub provider_capabilities: ProviderCapabilitySnapshot,
240 #[serde(default)]
241 pub processes: Vec<ManagedProcess>,
242 #[serde(default)]
243 pub timeline: Vec<RuntimeTimelineEvent>,
244}
245
246impl RuntimeState {
247 pub fn new(model_id: &str) -> Self {
248 Self {
249 provider_capabilities: ProviderCapabilitySnapshot::from_model_id(model_id),
250 processes: Vec::new(),
251 timeline: Vec::new(),
252 }
253 }
254
255 pub fn set_model(&mut self, model_id: &str) {
256 self.provider_capabilities = ProviderCapabilitySnapshot::from_model_id(model_id);
257 self.timeline.push(RuntimeTimelineEvent {
258 kind: RuntimeTimelineKind::Provider,
259 message: format!("model set to {}", model_id),
260 });
261 }
262
263 pub fn record_signal(&mut self, signal: RuntimeSignal) {
264 self.timeline.push(RuntimeTimelineEvent {
265 kind: RuntimeTimelineKind::Signal,
266 message: format!("received {}", signal.as_str()),
267 });
268 }
269
270 pub fn register_process(&mut self, process: ManagedProcess) {
271 if let Some(existing) = self.processes.iter_mut().find(|p| p.pid == process.pid) {
272 *existing = process.clone();
273 } else {
274 self.processes.push(process.clone());
275 }
276 self.timeline.push(RuntimeTimelineEvent {
277 kind: RuntimeTimelineKind::Process,
278 message: format!("registered process {} ({})", process.pid, process.command),
279 });
280 }
281}
282
283impl Default for RuntimeState {
284 fn default() -> Self {
285 Self::new("ollama/unknown")
286 }
287}