1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
6pub struct RunId(pub String);
7
8#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
9pub struct CheckpointId(pub String);
10
11#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
12pub struct AgentId(pub String);
13
14impl RunId {
15 pub fn new() -> Self {
16 Self(uuid::Uuid::new_v4().to_string())
17 }
18}
19
20impl CheckpointId {
21 pub fn new() -> Self {
22 Self(uuid::Uuid::new_v4().to_string())
23 }
24}
25
26impl AgentId {
27 pub fn new() -> Self {
28 Self(uuid::Uuid::new_v4().to_string())
29 }
30}
31
32pub fn friendly_model_switch_reason(reason: &str) -> String {
33 let lower = reason.to_lowercase();
34 if lower.contains("ollama api error 404") && lower.contains("model") {
35 "modèle local indisponible".into()
36 } else if lower.contains("ollama") {
37 "provider local indisponible".into()
38 } else {
39 reason.to_string()
40 }
41}
42
43pub fn is_local_model_unavailable(reason: &str) -> bool {
44 matches!(
45 friendly_model_switch_reason(reason).as_str(),
46 "modèle local indisponible" | "provider local indisponible"
47 )
48}
49
50#[derive(Default)]
54pub struct ThinkStripper {
55 in_think: bool,
56 pending: String,
57 think_buf: String,
61}
62
63impl ThinkStripper {
64 pub fn new() -> Self {
65 Self::default()
66 }
67
68 pub fn feed(&mut self, delta: &str) -> String {
70 const OPEN: &str = "<think>";
71 const CLOSE: &str = "</think>";
72 self.pending.push_str(delta);
73 let mut out = String::new();
74 loop {
75 if !self.in_think {
76 if let Some(i) = self.pending.find(OPEN) {
77 out.push_str(&self.pending[..i]);
78 self.pending.replace_range(..i + OPEN.len(), "");
79 self.in_think = true;
80 self.think_buf.clear();
81 continue;
82 }
83 let keep = dangling_prefix(&self.pending, OPEN);
84 let emit_to = self.pending.len() - keep;
85 out.push_str(&self.pending[..emit_to]);
86 self.pending.replace_range(..emit_to, "");
87 break;
88 } else {
89 if let Some(i) = self.pending.find(CLOSE) {
90 self.pending.replace_range(..i + CLOSE.len(), "");
92 self.in_think = false;
93 self.think_buf.clear();
94 continue;
95 }
96 let keep = dangling_prefix(&self.pending, CLOSE);
97 let drop_to = self.pending.len() - keep;
98 self.think_buf.push_str(&self.pending[..drop_to]);
100 self.pending.replace_range(..drop_to, "");
101 break;
102 }
103 }
104 out
105 }
106
107 pub fn flush(&mut self) -> String {
110 let mut rest = std::mem::take(&mut self.pending);
111 if self.in_think {
112 let recovered = std::mem::take(&mut self.think_buf);
114 self.in_think = false;
115 format!("{}{}", recovered, rest)
116 } else {
117 self.think_buf.clear();
118 std::mem::take(&mut rest)
119 }
120 }
121}
122
123fn dangling_prefix(s: &str, tag: &str) -> usize {
126 let max = tag.len().saturating_sub(1).min(s.len());
127 for n in (1..=max).rev() {
128 if s.is_char_boundary(s.len() - n) && s[s.len() - n..] == tag[..n] {
129 return n;
130 }
131 }
132 0
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
138pub enum Block {
139 Text(String),
140 Json(serde_json::Value),
141 Image { data: Vec<u8>, mime: String },
142 Diff { file: String, patch: String },
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
148pub enum RiskLevel {
149 ReadOnly,
150 Mutating,
151 Exec,
152 Destructive,
153 Network,
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
157pub enum Decision {
158 Allow,
159 AskUser,
160 Deny,
161 AllowOnce,
163 AllowSession,
165 AllowAlways,
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize)]
172pub enum AgentStatus {
173 Idle,
174 Thinking,
175 Working,
176 WaitingForApproval,
177 Done,
178 Error,
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize)]
184pub struct TokenUsage {
185 pub input: u64,
186 pub output: u64,
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize)]
190pub enum StopReason {
191 EndTurn,
192 MaxTokens,
193 StopSequence(String),
194 ToolUse,
195 Refusal,
196 Error,
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
202#[serde(rename_all = "lowercase")]
203pub enum AutonomyLevel {
204 Supervised,
205 Trusted,
206 Autonomous,
207}
208
209impl AutonomyLevel {
210 pub fn as_float(&self) -> f64 {
211 match self {
212 AutonomyLevel::Supervised => 0.0,
213 AutonomyLevel::Trusted => 0.5,
214 AutonomyLevel::Autonomous => 1.0,
215 }
216 }
217
218 pub fn from_float(f: f64) -> Self {
219 if f >= 0.75 {
220 AutonomyLevel::Autonomous
221 } else if f >= 0.25 {
222 AutonomyLevel::Trusted
223 } else {
224 AutonomyLevel::Supervised
225 }
226 }
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize)]
232pub struct OutcomeSummary {
233 pub status: String,
234 pub diffs: Vec<FileDiff>,
235 pub cost_usd: f64,
236 pub tokens: TokenUsage,
237 #[serde(default)]
240 pub cost_comparison: String,
241 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub duration_ms: Option<u64>,
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize)]
248pub struct FileDiff {
249 pub file: String,
250 pub plus: u32,
251 pub minus: u32,
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize)]
259#[serde(tag = "type")]
260pub enum Event {
261 RunStarted {
262 run: RunId,
263 task: String,
264 agent: String,
265 },
266 RouteSelected {
267 run: RunId,
268 chain: Vec<String>,
269 context_window: u64,
270 },
271 ModelSwitched {
272 run: RunId,
273 from: String,
274 to: String,
275 reason: String,
276 },
277 ThinkingDelta {
278 run: RunId,
279 text: String,
280 },
281 ReasoningDelta {
286 run: RunId,
287 text: String,
288 },
289 Message {
290 run: RunId,
291 role: String,
292 text: String,
293 },
294 ToolUseProposed {
295 run: RunId,
296 id: String,
297 name: String,
298 args: serde_json::Value,
299 risk: RiskLevel,
300 },
301 ApprovalRequested {
302 run: RunId,
303 id: String,
304 summary: String,
305 #[serde(default, skip_serializing_if = "Option::is_none")]
306 tool: Option<String>,
307 #[serde(default, skip_serializing_if = "Option::is_none")]
308 risk: Option<String>,
309 },
310 ApprovalResolved {
311 run: RunId,
312 id: String,
313 decision: Decision,
314 },
315 ToolUseStarted {
316 run: RunId,
317 id: String,
318 },
319 ToolOutput {
320 run: RunId,
321 id: String,
322 blocks: Vec<Block>,
323 },
324 DiffProposed {
325 run: RunId,
326 file: String,
327 patch: String,
328 plus: u32,
329 minus: u32,
330 },
331 DiffApplied {
332 run: RunId,
333 file: String,
334 },
335 TestResult {
336 run: RunId,
337 passed: u32,
338 failed: u32,
339 detail: String,
340 },
341 AgentSpawned {
342 run: RunId,
343 role: String,
344 model: String,
345 },
346 AgentStatus {
347 run: RunId,
348 role: String,
349 status: AgentStatus,
350 note: String,
351 },
352 CheckpointCreated {
353 run: RunId,
354 id: CheckpointId,
355 label: String,
356 },
357 SkillLearned {
358 run: RunId,
359 name: String,
360 },
361 CostUpdate {
362 run: RunId,
363 usd: f64,
364 },
365 TokenUsage {
366 run: RunId,
367 input: u64,
368 output: u64,
369 },
370 TokenUsageEstimated {
371 run: RunId,
372 input: u64,
373 output: u64,
374 reason: String,
375 },
376 AutonomyChanged {
377 run: RunId,
378 level: AutonomyLevel,
379 },
380 RunFinished {
381 run: RunId,
382 outcome: OutcomeSummary,
383 },
384 Error {
385 run: RunId,
386 message: String,
387 },
388 Compacted {
391 run: RunId,
392 before_chars: usize,
393 after_chars: usize,
394 handoff_path: Option<String>,
395 },
396 UpdateAvailable {
399 current: String,
400 latest: String,
401 download_url: Option<String>,
402 crate_url: String,
403 release_url: String,
404 install_cmd: String,
405 },
406}
407
408impl Event {
409 pub fn is_public(&self) -> bool {
416 !matches!(self, Self::ReasoningDelta { .. })
417 }
418}