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    /// Allow this specific tool call once and ask again next time.
162    AllowOnce,
163    /// Allow this tool for the remainder of the session.
164    AllowSession,
165    /// Allow this tool permanently (persisted to disk).
166    AllowAlways,
167}
168
169// ─── Agent status ───────────────────────────────────────────────────────────────
170
171#[derive(Debug, Clone, Serialize, Deserialize)]
172pub enum AgentStatus {
173    Idle,
174    Thinking,
175    Working,
176    WaitingForApproval,
177    Done,
178    Error,
179}
180
181// ─── Model-related types ────────────────────────────────────────────────────────
182
183#[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// ─── Autonomy ───────────────────────────────────────────────────────────────────
200
201#[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// ─── Outcome ────────────────────────────────────────────────────────────────────
230
231#[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    /// Pre-formatted cost comparison against competitors (empty = no comparison).
238    /// Populated by the orchestrator at run completion so every surface can display it.
239    #[serde(default)]
240    pub cost_comparison: String,
241    /// Wall-clock duration captured at live run finish. Replays should display
242    /// this stored value instead of inventing a new elapsed time.
243    #[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// ─── THE EVENT MODEL (§3.14) — load-bearing contract ────────────────────────────
255
256/// Every surface renders from this stream; replay records from it.
257/// This enum is the contract that connects runtime ↔ surfaces ↔ replay.
258#[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    /// Opaque provider reasoning state. Surfaces should not render this as
282    /// assistant-visible text, but session persistence must keep it so
283    /// reasoning-mode providers can receive `reasoning_content` on the next
284    /// turn when they require it.
285    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    /// A compaction pass has just completed. Surfaces the before/after sizes
389    /// (in chars) and the path of the handoff doc, if any.
390    Compacted {
391        run: RunId,
392        before_chars: usize,
393        after_chars: usize,
394        handoff_path: Option<String>,
395    },
396    /// A newer version of Sparrow is available.
397    /// Surfaces should show a non-intrusive notification with update instructions.
398    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    /// Returns true for events that may be streamed to user-facing feeds.
410    ///
411    /// `ReasoningDelta` is provider-internal continuity state: the engine and
412    /// session stores consume it so reasoning-mode providers can receive the
413    /// required `reasoning_content` on the next turn, but exposing every delta
414    /// as NDJSON/WebSocket output floods users with opaque token fragments.
415    pub fn is_public(&self) -> bool {
416        !matches!(self, Self::ReasoningDelta { .. })
417    }
418}