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}
162
163#[derive(Debug, Clone, Serialize, Deserialize)]
166pub enum AgentStatus {
167 Idle,
168 Thinking,
169 Working,
170 WaitingForApproval,
171 Done,
172 Error,
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct TokenUsage {
179 pub input: u64,
180 pub output: u64,
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize)]
184pub enum StopReason {
185 EndTurn,
186 MaxTokens,
187 StopSequence(String),
188 ToolUse,
189 Refusal,
190 Error,
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
196#[serde(rename_all = "lowercase")]
197pub enum AutonomyLevel {
198 Supervised,
199 Trusted,
200 Autonomous,
201}
202
203impl AutonomyLevel {
204 pub fn as_float(&self) -> f64 {
205 match self {
206 AutonomyLevel::Supervised => 0.0,
207 AutonomyLevel::Trusted => 0.5,
208 AutonomyLevel::Autonomous => 1.0,
209 }
210 }
211
212 pub fn from_float(f: f64) -> Self {
213 if f >= 0.75 {
214 AutonomyLevel::Autonomous
215 } else if f >= 0.25 {
216 AutonomyLevel::Trusted
217 } else {
218 AutonomyLevel::Supervised
219 }
220 }
221}
222
223#[derive(Debug, Clone, Serialize, Deserialize)]
226pub struct OutcomeSummary {
227 pub status: String,
228 pub diffs: Vec<FileDiff>,
229 pub cost_usd: f64,
230 pub tokens: TokenUsage,
231 #[serde(default)]
234 pub cost_comparison: String,
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct FileDiff {
239 pub file: String,
240 pub plus: u32,
241 pub minus: u32,
242}
243
244#[derive(Debug, Clone, Serialize, Deserialize)]
249#[serde(tag = "type")]
250pub enum Event {
251 RunStarted {
252 run: RunId,
253 task: String,
254 agent: String,
255 },
256 RouteSelected {
257 run: RunId,
258 chain: Vec<String>,
259 context_window: u64,
260 },
261 ModelSwitched {
262 run: RunId,
263 from: String,
264 to: String,
265 reason: String,
266 },
267 ThinkingDelta {
268 run: RunId,
269 text: String,
270 },
271 ReasoningDelta {
276 run: RunId,
277 text: String,
278 },
279 Message {
280 run: RunId,
281 role: String,
282 text: String,
283 },
284 ToolUseProposed {
285 run: RunId,
286 id: String,
287 name: String,
288 args: serde_json::Value,
289 risk: RiskLevel,
290 },
291 ApprovalRequested {
292 run: RunId,
293 id: String,
294 summary: String,
295 #[serde(default, skip_serializing_if = "Option::is_none")]
296 tool: Option<String>,
297 #[serde(default, skip_serializing_if = "Option::is_none")]
298 risk: Option<String>,
299 },
300 ApprovalResolved {
301 run: RunId,
302 id: String,
303 decision: Decision,
304 },
305 ToolUseStarted {
306 run: RunId,
307 id: String,
308 },
309 ToolOutput {
310 run: RunId,
311 id: String,
312 blocks: Vec<Block>,
313 },
314 DiffProposed {
315 run: RunId,
316 file: String,
317 patch: String,
318 plus: u32,
319 minus: u32,
320 },
321 DiffApplied {
322 run: RunId,
323 file: String,
324 },
325 TestResult {
326 run: RunId,
327 passed: u32,
328 failed: u32,
329 detail: String,
330 },
331 AgentSpawned {
332 run: RunId,
333 role: String,
334 model: String,
335 },
336 AgentStatus {
337 run: RunId,
338 role: String,
339 status: AgentStatus,
340 note: String,
341 },
342 CheckpointCreated {
343 run: RunId,
344 id: CheckpointId,
345 label: String,
346 },
347 SkillLearned {
348 run: RunId,
349 name: String,
350 },
351 CostUpdate {
352 run: RunId,
353 usd: f64,
354 },
355 TokenUsage {
356 run: RunId,
357 input: u64,
358 output: u64,
359 },
360 TokenUsageEstimated {
361 run: RunId,
362 input: u64,
363 output: u64,
364 reason: String,
365 },
366 AutonomyChanged {
367 run: RunId,
368 level: AutonomyLevel,
369 },
370 RunFinished {
371 run: RunId,
372 outcome: OutcomeSummary,
373 },
374 Error {
375 run: RunId,
376 message: String,
377 },
378 Compacted {
381 run: RunId,
382 before_chars: usize,
383 after_chars: usize,
384 handoff_path: Option<String>,
385 },
386}
387
388impl Event {
389 pub fn is_public(&self) -> bool {
396 !matches!(self, Self::ReasoningDelta { .. })
397 }
398}