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}
232
233#[derive(Debug, Clone, Serialize, Deserialize)]
234pub struct FileDiff {
235    pub file: String,
236    pub plus: u32,
237    pub minus: u32,
238}
239
240// ─── THE EVENT MODEL (§3.14) — load-bearing contract ────────────────────────────
241
242/// Every surface renders from this stream; replay records from it.
243/// This enum is the contract that connects runtime ↔ surfaces ↔ replay.
244#[derive(Debug, Clone, Serialize, Deserialize)]
245#[serde(tag = "type")]
246pub enum Event {
247    RunStarted {
248        run: RunId,
249        task: String,
250        agent: String,
251    },
252    RouteSelected {
253        run: RunId,
254        chain: Vec<String>,
255        context_window: u64,
256    },
257    ModelSwitched {
258        run: RunId,
259        from: String,
260        to: String,
261        reason: String,
262    },
263    ThinkingDelta {
264        run: RunId,
265        text: String,
266    },
267    /// Opaque provider reasoning state. Surfaces should not render this as
268    /// assistant-visible text, but session persistence must keep it so
269    /// reasoning-mode providers can receive `reasoning_content` on the next
270    /// turn when they require it.
271    ReasoningDelta {
272        run: RunId,
273        text: String,
274    },
275    Message {
276        run: RunId,
277        role: String,
278        text: String,
279    },
280    ToolUseProposed {
281        run: RunId,
282        id: String,
283        name: String,
284        args: serde_json::Value,
285        risk: RiskLevel,
286    },
287    ApprovalRequested {
288        run: RunId,
289        id: String,
290        summary: String,
291    },
292    ApprovalResolved {
293        run: RunId,
294        id: String,
295        decision: Decision,
296    },
297    ToolUseStarted {
298        run: RunId,
299        id: String,
300    },
301    ToolOutput {
302        run: RunId,
303        id: String,
304        blocks: Vec<Block>,
305    },
306    DiffProposed {
307        run: RunId,
308        file: String,
309        patch: String,
310        plus: u32,
311        minus: u32,
312    },
313    DiffApplied {
314        run: RunId,
315        file: String,
316    },
317    TestResult {
318        run: RunId,
319        passed: u32,
320        failed: u32,
321        detail: String,
322    },
323    AgentSpawned {
324        run: RunId,
325        role: String,
326        model: String,
327    },
328    AgentStatus {
329        run: RunId,
330        role: String,
331        status: AgentStatus,
332        note: String,
333    },
334    CheckpointCreated {
335        run: RunId,
336        id: CheckpointId,
337        label: String,
338    },
339    SkillLearned {
340        run: RunId,
341        name: String,
342    },
343    CostUpdate {
344        run: RunId,
345        usd: f64,
346    },
347    TokenUsage {
348        run: RunId,
349        input: u64,
350        output: u64,
351    },
352    TokenUsageEstimated {
353        run: RunId,
354        input: u64,
355        output: u64,
356        reason: String,
357    },
358    AutonomyChanged {
359        run: RunId,
360        level: AutonomyLevel,
361    },
362    RunFinished {
363        run: RunId,
364        outcome: OutcomeSummary,
365    },
366    Error {
367        run: RunId,
368        message: String,
369    },
370    /// A compaction pass has just completed. Surfaces the before/after sizes
371    /// (in chars) and the path of the handoff doc, if any.
372    Compacted {
373        run: RunId,
374        before_chars: usize,
375        after_chars: usize,
376        handoff_path: Option<String>,
377    },
378}
379
380impl Event {
381    /// Returns true for events that may be streamed to user-facing feeds.
382    ///
383    /// `ReasoningDelta` is provider-internal continuity state: the engine and
384    /// session stores consume it so reasoning-mode providers can receive the
385    /// required `reasoning_content` on the next turn, but exposing every delta
386    /// as NDJSON/WebSocket output floods users with opaque token fragments.
387    pub fn is_public(&self) -> bool {
388        !matches!(self, Self::ReasoningDelta { .. })
389    }
390}