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}
242
243#[derive(Debug, Clone, Serialize, Deserialize)]
244pub struct FileDiff {
245 pub file: String,
246 pub plus: u32,
247 pub minus: u32,
248}
249
250#[derive(Debug, Clone, Serialize, Deserialize)]
255#[serde(tag = "type")]
256pub enum Event {
257 RunStarted {
258 run: RunId,
259 task: String,
260 agent: String,
261 },
262 RouteSelected {
263 run: RunId,
264 chain: Vec<String>,
265 context_window: u64,
266 },
267 ModelSwitched {
268 run: RunId,
269 from: String,
270 to: String,
271 reason: String,
272 },
273 ThinkingDelta {
274 run: RunId,
275 text: String,
276 },
277 ReasoningDelta {
282 run: RunId,
283 text: String,
284 },
285 Message {
286 run: RunId,
287 role: String,
288 text: String,
289 },
290 ToolUseProposed {
291 run: RunId,
292 id: String,
293 name: String,
294 args: serde_json::Value,
295 risk: RiskLevel,
296 },
297 ApprovalRequested {
298 run: RunId,
299 id: String,
300 summary: String,
301 #[serde(default, skip_serializing_if = "Option::is_none")]
302 tool: Option<String>,
303 #[serde(default, skip_serializing_if = "Option::is_none")]
304 risk: Option<String>,
305 },
306 ApprovalResolved {
307 run: RunId,
308 id: String,
309 decision: Decision,
310 },
311 ToolUseStarted {
312 run: RunId,
313 id: String,
314 },
315 ToolOutput {
316 run: RunId,
317 id: String,
318 blocks: Vec<Block>,
319 },
320 DiffProposed {
321 run: RunId,
322 file: String,
323 patch: String,
324 plus: u32,
325 minus: u32,
326 },
327 DiffApplied {
328 run: RunId,
329 file: String,
330 },
331 TestResult {
332 run: RunId,
333 passed: u32,
334 failed: u32,
335 detail: String,
336 },
337 AgentSpawned {
338 run: RunId,
339 role: String,
340 model: String,
341 },
342 AgentStatus {
343 run: RunId,
344 role: String,
345 status: AgentStatus,
346 note: String,
347 },
348 CheckpointCreated {
349 run: RunId,
350 id: CheckpointId,
351 label: String,
352 },
353 SkillLearned {
354 run: RunId,
355 name: String,
356 },
357 CostUpdate {
358 run: RunId,
359 usd: f64,
360 },
361 TokenUsage {
362 run: RunId,
363 input: u64,
364 output: u64,
365 },
366 TokenUsageEstimated {
367 run: RunId,
368 input: u64,
369 output: u64,
370 reason: String,
371 },
372 AutonomyChanged {
373 run: RunId,
374 level: AutonomyLevel,
375 },
376 RunFinished {
377 run: RunId,
378 outcome: OutcomeSummary,
379 },
380 Error {
381 run: RunId,
382 message: String,
383 },
384 Compacted {
387 run: RunId,
388 before_chars: usize,
389 after_chars: usize,
390 handoff_path: Option<String>,
391 },
392 UpdateAvailable {
395 current: String,
396 latest: String,
397 download_url: Option<String>,
398 crate_url: String,
399 release_url: String,
400 install_cmd: String,
401 },
402}
403
404impl Event {
405 pub fn is_public(&self) -> bool {
412 !matches!(self, Self::ReasoningDelta { .. })
413 }
414}