Skip to main content

sparrow_core/
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 Default for RunId {
21    fn default() -> Self {
22        Self::new()
23    }
24}
25
26impl CheckpointId {
27    pub fn new() -> Self {
28        Self(uuid::Uuid::new_v4().to_string())
29    }
30}
31
32impl Default for CheckpointId {
33    fn default() -> Self {
34        Self::new()
35    }
36}
37
38impl AgentId {
39    pub fn new() -> Self {
40        Self(uuid::Uuid::new_v4().to_string())
41    }
42}
43
44impl Default for AgentId {
45    fn default() -> Self {
46        Self::new()
47    }
48}
49
50pub fn friendly_model_switch_reason(reason: &str) -> String {
51    let lower = reason.to_lowercase();
52    if lower.contains("ollama api error 404") && lower.contains("model") {
53        "modèle local indisponible".into()
54    } else if lower.contains("ollama") {
55        "provider local indisponible".into()
56    } else {
57        reason.to_string()
58    }
59}
60
61pub fn is_local_model_unavailable(reason: &str) -> bool {
62    matches!(
63        friendly_model_switch_reason(reason).as_str(),
64        "modèle local indisponible" | "provider local indisponible"
65    )
66}
67
68/// Streaming filter that strips `<think>…</think>` reasoning blocks emitted by
69/// reasoning models (minimax, deepseek-r1, qwq…) so surfaces show the answer,
70/// not the chain-of-thought. Handles tags split across streamed deltas.
71#[derive(Default)]
72pub struct ThinkStripper {
73    in_think: bool,
74    pending: String,
75    /// Content seen inside the current (unclosed) think block, kept so that if
76    /// the block NEVER closes (model didn't emit </think>) we can recover it on
77    /// flush rather than silently swallowing the whole answer (fail-open).
78    think_buf: String,
79}
80
81impl ThinkStripper {
82    pub fn new() -> Self {
83        Self::default()
84    }
85
86    /// Feed a streamed delta; returns the portion that should be displayed.
87    pub fn feed(&mut self, delta: &str) -> String {
88        const OPEN: &str = "<think>";
89        const CLOSE: &str = "</think>";
90        self.pending.push_str(delta);
91        let mut out = String::new();
92        loop {
93            if !self.in_think {
94                if let Some(i) = self.pending.find(OPEN) {
95                    out.push_str(&self.pending[..i]);
96                    self.pending.replace_range(..i + OPEN.len(), "");
97                    self.in_think = true;
98                    self.think_buf.clear();
99                    continue;
100                }
101                let keep = dangling_prefix(&self.pending, OPEN);
102                let emit_to = self.pending.len() - keep;
103                out.push_str(&self.pending[..emit_to]);
104                self.pending.replace_range(..emit_to, "");
105                break;
106            } else {
107                if let Some(i) = self.pending.find(CLOSE) {
108                    // Real closed think block → discard its content.
109                    self.pending.replace_range(..i + CLOSE.len(), "");
110                    self.in_think = false;
111                    self.think_buf.clear();
112                    continue;
113                }
114                let keep = dangling_prefix(&self.pending, CLOSE);
115                let drop_to = self.pending.len() - keep;
116                // Stash dropped think content for fail-open recovery.
117                self.think_buf.push_str(&self.pending[..drop_to]);
118                self.pending.replace_range(..drop_to, "");
119                break;
120            }
121        }
122        out
123    }
124
125    /// Flush remaining buffered text. If a think block was opened but never
126    /// closed, recover its content (the model likely put the answer there).
127    pub fn flush(&mut self) -> String {
128        let mut rest = std::mem::take(&mut self.pending);
129        if self.in_think {
130            // Unclosed think → show what we stashed (fail-open).
131            let recovered = std::mem::take(&mut self.think_buf);
132            self.in_think = false;
133            format!("{}{}", recovered, rest)
134        } else {
135            self.think_buf.clear();
136            std::mem::take(&mut rest)
137        }
138    }
139}
140
141/// Length (bytes) of the trailing portion of `s` that is a prefix of `tag`,
142/// so a tag split across deltas isn't emitted prematurely. ASCII tags only.
143fn dangling_prefix(s: &str, tag: &str) -> usize {
144    let max = tag.len().saturating_sub(1).min(s.len());
145    for n in (1..=max).rev() {
146        if s.is_char_boundary(s.len() - n) && s[s.len() - n..] == tag[..n] {
147            return n;
148        }
149    }
150    0
151}
152
153// ─── Content blocks ─────────────────────────────────────────────────────────────
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub enum Block {
157    Text(String),
158    Json(serde_json::Value),
159    Image { data: Vec<u8>, mime: String },
160    Diff { file: String, patch: String },
161}
162
163// ─── Tool use types ─────────────────────────────────────────────────────────────
164
165#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
166pub enum RiskLevel {
167    ReadOnly,
168    Mutating,
169    Exec,
170    Destructive,
171    Network,
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
175pub enum Decision {
176    Allow,
177    AskUser,
178    Deny,
179    /// Allow this specific tool call once and ask again next time.
180    AllowOnce,
181    /// Allow this tool for the remainder of the session.
182    AllowSession,
183    /// Allow this tool permanently (persisted to disk).
184    AllowAlways,
185}
186
187// ─── Agent status ───────────────────────────────────────────────────────────────
188
189#[derive(Debug, Clone, Serialize, Deserialize)]
190pub enum AgentStatus {
191    Idle,
192    Thinking,
193    Working,
194    WaitingForApproval,
195    Done,
196    Error,
197}
198
199// ─── Model-related types ────────────────────────────────────────────────────────
200
201#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct TokenUsage {
203    pub input: u64,
204    pub output: u64,
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize)]
208pub enum StopReason {
209    EndTurn,
210    MaxTokens,
211    StopSequence(String),
212    ToolUse,
213    Refusal,
214    Error,
215}
216
217// ─── Autonomy ───────────────────────────────────────────────────────────────────
218
219#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
220#[serde(rename_all = "lowercase")]
221pub enum AutonomyLevel {
222    Supervised,
223    Trusted,
224    Autonomous,
225}
226
227impl AutonomyLevel {
228    pub fn as_float(&self) -> f64 {
229        match self {
230            AutonomyLevel::Supervised => 0.0,
231            AutonomyLevel::Trusted => 0.5,
232            AutonomyLevel::Autonomous => 1.0,
233        }
234    }
235
236    pub fn from_float(f: f64) -> Self {
237        if f >= 0.75 {
238            AutonomyLevel::Autonomous
239        } else if f >= 0.25 {
240            AutonomyLevel::Trusted
241        } else {
242            AutonomyLevel::Supervised
243        }
244    }
245}
246
247// ─── Outcome ────────────────────────────────────────────────────────────────────
248
249#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct OutcomeSummary {
251    pub status: String,
252    pub diffs: Vec<FileDiff>,
253    pub cost_usd: f64,
254    pub tokens: TokenUsage,
255    /// Pre-formatted cost comparison against competitors (empty = no comparison).
256    /// Populated by the orchestrator at run completion so every surface can display it.
257    #[serde(default)]
258    pub cost_comparison: String,
259    /// Wall-clock duration captured at live run finish. Replays should display
260    /// this stored value instead of inventing a new elapsed time.
261    #[serde(default, skip_serializing_if = "Option::is_none")]
262    pub duration_ms: Option<u64>,
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct FileDiff {
267    pub file: String,
268    pub plus: u32,
269    pub minus: u32,
270}
271
272// ─── THE EVENT MODEL (§3.14) — load-bearing contract ────────────────────────────
273
274/// Every surface renders from this stream; replay records from it.
275/// This enum is the contract that connects runtime ↔ surfaces ↔ replay.
276#[derive(Debug, Clone, Serialize, Deserialize)]
277#[serde(tag = "type")]
278pub enum Event {
279    RunStarted {
280        run: RunId,
281        task: String,
282        agent: String,
283    },
284    RouteSelected {
285        run: RunId,
286        chain: Vec<String>,
287        context_window: u64,
288    },
289    ModelSwitched {
290        run: RunId,
291        from: String,
292        to: String,
293        reason: String,
294    },
295    ThinkingDelta {
296        run: RunId,
297        text: String,
298    },
299    /// Opaque provider reasoning state. Surfaces should not render this as
300    /// assistant-visible text, but session persistence must keep it so
301    /// reasoning-mode providers can receive `reasoning_content` on the next
302    /// turn when they require it.
303    ReasoningDelta {
304        run: RunId,
305        text: String,
306    },
307    Message {
308        run: RunId,
309        role: String,
310        text: String,
311    },
312    ToolUseProposed {
313        run: RunId,
314        id: String,
315        name: String,
316        args: serde_json::Value,
317        risk: RiskLevel,
318    },
319    ApprovalRequested {
320        run: RunId,
321        id: String,
322        summary: String,
323        #[serde(default, skip_serializing_if = "Option::is_none")]
324        tool: Option<String>,
325        #[serde(default, skip_serializing_if = "Option::is_none")]
326        risk: Option<String>,
327    },
328    ApprovalResolved {
329        run: RunId,
330        id: String,
331        decision: Decision,
332    },
333    ToolUseStarted {
334        run: RunId,
335        id: String,
336    },
337    ToolOutput {
338        run: RunId,
339        id: String,
340        blocks: Vec<Block>,
341        /// True when the tool reported a failure (ToolResult::is_error).
342        /// Additive + defaulted so old recordings/replays still deserialize.
343        #[serde(default)]
344        is_error: bool,
345    },
346    DiffProposed {
347        run: RunId,
348        file: String,
349        patch: String,
350        plus: u32,
351        minus: u32,
352    },
353    DiffApplied {
354        run: RunId,
355        file: String,
356    },
357    TestResult {
358        run: RunId,
359        passed: u32,
360        failed: u32,
361        detail: String,
362    },
363    AgentSpawned {
364        run: RunId,
365        role: String,
366        model: String,
367    },
368    AgentStatus {
369        run: RunId,
370        role: String,
371        status: AgentStatus,
372        note: String,
373    },
374    CheckpointCreated {
375        run: RunId,
376        id: CheckpointId,
377        label: String,
378    },
379    SkillLearned {
380        run: RunId,
381        name: String,
382    },
383    CostUpdate {
384        run: RunId,
385        usd: f64,
386    },
387    TokenUsage {
388        run: RunId,
389        input: u64,
390        output: u64,
391    },
392    TokenUsageEstimated {
393        run: RunId,
394        input: u64,
395        output: u64,
396        reason: String,
397    },
398    AutonomyChanged {
399        run: RunId,
400        level: AutonomyLevel,
401    },
402    RunFinished {
403        run: RunId,
404        outcome: OutcomeSummary,
405    },
406    Error {
407        run: RunId,
408        message: String,
409    },
410    /// A compaction pass has just completed. Surfaces the before/after sizes
411    /// (in chars) and the path of the handoff doc, if any.
412    Compacted {
413        run: RunId,
414        before_chars: usize,
415        after_chars: usize,
416        handoff_path: Option<String>,
417    },
418    /// A newer version of Sparrow is available.
419    /// Surfaces should show a non-intrusive notification with update instructions.
420    UpdateAvailable {
421        current: String,
422        latest: String,
423        download_url: Option<String>,
424        crate_url: String,
425        release_url: String,
426        install_cmd: String,
427    },
428}
429
430impl Event {
431    /// Returns true for events that may be streamed to user-facing feeds.
432    ///
433    /// `ReasoningDelta` is provider-internal continuity state: the engine and
434    /// session stores consume it so reasoning-mode providers can receive the
435    /// required `reasoning_content` on the next turn, but exposing every delta
436    /// as NDJSON/WebSocket output floods users with opaque token fragments.
437    pub fn is_public(&self) -> bool {
438        !matches!(self, Self::ReasoningDelta { .. })
439    }
440}