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