Skip to main content

sparrow/
event.rs

1use serde::{Deserialize, Serialize};
2
3// ─── Core identifiers ───────────────────────────────────────────────────────────
4
5#[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/// Streaming filter that strips `<think>…</think>` reasoning blocks emitted by
51/// reasoning models (minimax, deepseek-r1, qwq…) so surfaces show the answer,
52/// not the chain-of-thought. Handles tags split across streamed deltas.
53#[derive(Default)]
54pub struct ThinkStripper {
55    in_think: bool,
56    pending: String,
57    /// Content seen inside the current (unclosed) think block, kept so that if
58    /// the block NEVER closes (model didn't emit </think>) we can recover it on
59    /// flush rather than silently swallowing the whole answer (fail-open).
60    think_buf: String,
61}
62
63impl ThinkStripper {
64    pub fn new() -> Self {
65        Self::default()
66    }
67
68    /// Feed a streamed delta; returns the portion that should be displayed.
69    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                    // Real closed think block → discard its content.
91                    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                // Stash dropped think content for fail-open recovery.
99                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    /// Flush remaining buffered text. If a think block was opened but never
108    /// closed, recover its content (the model likely put the answer there).
109    pub fn flush(&mut self) -> String {
110        let mut rest = std::mem::take(&mut self.pending);
111        if self.in_think {
112            // Unclosed think → show what we stashed (fail-open).
113            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
123/// Length (bytes) of the trailing portion of `s` that is a prefix of `tag`,
124/// so a tag split across deltas isn't emitted prematurely. ASCII tags only.
125fn 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// ─── Content blocks ─────────────────────────────────────────────────────────────
136
137#[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// ─── Tool use types ─────────────────────────────────────────────────────────────
146
147#[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// ─── Agent status ───────────────────────────────────────────────────────────────
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub enum AgentStatus {
167    Idle,
168    Thinking,
169    Working,
170    WaitingForApproval,
171    Done,
172    Error,
173}
174
175// ─── Model-related types ────────────────────────────────────────────────────────
176
177#[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// ─── Autonomy ───────────────────────────────────────────────────────────────────
194
195#[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// ─── Outcome ────────────────────────────────────────────────────────────────────
224
225#[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    /// Pre-formatted cost comparison against competitors (empty = no comparison).
232    /// Populated by the orchestrator at run completion so every surface can display it.
233    #[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// ─── THE EVENT MODEL (§3.14) — load-bearing contract ────────────────────────────
245
246/// Every surface renders from this stream; replay records from it.
247/// This enum is the contract that connects runtime ↔ surfaces ↔ replay.
248#[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    /// Opaque provider reasoning state. Surfaces should not render this as
272    /// assistant-visible text, but session persistence must keep it so
273    /// reasoning-mode providers can receive `reasoning_content` on the next
274    /// turn when they require it.
275    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    /// A compaction pass has just completed. Surfaces the before/after sizes
379    /// (in chars) and the path of the handoff doc, if any.
380    Compacted {
381        run: RunId,
382        before_chars: usize,
383        after_chars: usize,
384        handoff_path: Option<String>,
385    },
386}
387
388impl Event {
389    /// Returns true for events that may be streamed to user-facing feeds.
390    ///
391    /// `ReasoningDelta` is provider-internal continuity state: the engine and
392    /// session stores consume it so reasoning-mode providers can receive the
393    /// required `reasoning_content` on the next turn, but exposing every delta
394    /// as NDJSON/WebSocket output floods users with opaque token fragments.
395    pub fn is_public(&self) -> bool {
396        !matches!(self, Self::ReasoningDelta { .. })
397    }
398}