Skip to main content

ingot_runtime/
events.rs

1//! The normalised event stream.
2//!
3//! Events carry no timestamps and no wall-clock durations. That is deliberate:
4//! replaying the same cassette produces the same event sequence byte for byte,
5//! which is what makes an event stream assertable in a test rather than merely
6//! inspectable by a human.
7
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11use crate::provider::Usage;
12
13/// What a `verify` node actually did.
14///
15/// Three states rather than a boolean, because "the check passed" and "there was
16/// no check" are different facts and a boolean can only tell you one of them.
17/// Agent IR records a verifier's name and signature and carries no way to
18/// execute one, so a backend that cannot perform a check says so here instead of
19/// reporting a pass nothing earned.
20///
21/// See [Runtime 0.2 ยง1](../../../specs/runtime/v0.2.md).
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "camelCase")]
24pub enum VerifyOutcome {
25    /// The backend has no implementation for this verifier. Not a failure: the
26    /// property is simply unchecked, and the run says so.
27    NotPerformed,
28    Passed,
29    Failed,
30}
31
32impl VerifyOutcome {
33    pub fn describe(self) -> &'static str {
34        match self {
35            VerifyOutcome::NotPerformed => "not performed",
36            VerifyOutcome::Passed => "passed",
37            VerifyOutcome::Failed => "FAILED",
38        }
39    }
40
41    /// Whether this outcome is a check that ran and said no.
42    ///
43    /// Deliberately not `!passed`: a check that never ran has not failed, and
44    /// treating it as a failure would be the mirror of the bug this type exists
45    /// to fix.
46    pub fn is_failure(self) -> bool {
47        matches!(self, VerifyOutcome::Failed)
48    }
49}
50
51/// One line of the run record.
52///
53/// `rename_all_fields` is load-bearing and easy to lose: on an enum,
54/// `rename_all` renames the *variants*, not their fields. Without the second
55/// attribute `response_type` serialised as `response_type` while every
56/// specification example and the second backend said `responseType` โ€” a
57/// divergence no single-implementation test could see, and the first thing the
58/// conformance suite found.
59#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
60#[serde(
61    tag = "event",
62    rename_all = "camelCase",
63    rename_all_fields = "camelCase"
64)]
65pub enum RunEvent {
66    RunStarted {
67        agent: String,
68        provider: String,
69    },
70    NodeStarted {
71        node: String,
72        kind: String,
73    },
74    ModelCall {
75        node: String,
76        model: String,
77        response_type: String,
78        usage: Usage,
79    },
80    ToolCall {
81        node: String,
82        tool: String,
83        effects: Vec<String>,
84    },
85    AgentCall {
86        node: String,
87        agent: String,
88    },
89    /// The compiler inserted an approval gate; this is the runtime asking.
90    ApprovalRequested {
91        node: String,
92        effects: Vec<String>,
93        reason: String,
94    },
95    ApprovalDecided {
96        node: String,
97        allowed: bool,
98    },
99    StateWritten {
100        node: String,
101        field: String,
102    },
103    Verified {
104        node: String,
105        verifier: String,
106        outcome: VerifyOutcome,
107    },
108    Checkpoint {
109        node: String,
110        label: String,
111    },
112    BranchTaken {
113        node: String,
114        /// `then` or `else`.
115        arm: String,
116    },
117    LoopIteration {
118        node: String,
119        iteration: u32,
120    },
121    MapIteration {
122        node: String,
123        index: usize,
124        total: usize,
125    },
126    Emitted {
127        node: String,
128        output: String,
129    },
130    RunFinished {
131        steps: u32,
132        usage: Usage,
133    },
134    RunFailed {
135        reason: String,
136    },
137    /// The run stopped at a resumable checkpoint and can be continued.
138    ///
139    /// Distinct from `runFinished` because without it a stopped run and a run
140    /// that finished having produced nothing look identical in a record, and
141    /// the check that every declared output was emitted would have to be
142    /// skipped on a guess. This is that guess made explicit: it is the only
143    /// thing that suppresses the check, and a reader can see it was suppressed.
144    RunStopped {
145        node: String,
146        label: String,
147    },
148}
149
150impl RunEvent {
151    /// One line, for a terminal.
152    pub fn to_line(&self) -> String {
153        match self {
154            RunEvent::RunStarted { agent, provider } => {
155                format!("run {agent} (provider: {provider})")
156            }
157            RunEvent::NodeStarted { node, kind } => format!("  {node}  {kind}"),
158            RunEvent::ModelCall {
159                model,
160                response_type,
161                usage,
162                ..
163            } => format!(
164                "        model {model} -> {response_type} ({} in, {} out)",
165                usage.input_tokens, usage.output_tokens
166            ),
167            RunEvent::ToolCall { tool, effects, .. } => {
168                format!("        tool {tool} [{}]", effects.join(", "))
169            }
170            RunEvent::AgentCall { agent, .. } => format!("        agent {agent}"),
171            RunEvent::ApprovalRequested {
172                effects, reason, ..
173            } => {
174                format!(
175                    "        approval needed for [{}]: {reason}",
176                    effects.join(", ")
177                )
178            }
179            RunEvent::ApprovalDecided { allowed, .. } => {
180                format!(
181                    "        approval {}",
182                    if *allowed { "granted" } else { "denied" }
183                )
184            }
185            RunEvent::StateWritten { field, .. } => format!("        state.{field} written"),
186            RunEvent::Verified {
187                verifier, outcome, ..
188            } => format!("        verify {verifier}: {}", outcome.describe()),
189            RunEvent::Checkpoint { label, .. } => format!("        checkpoint \"{label}\""),
190            RunEvent::BranchTaken { arm, .. } => format!("        branch: {arm}"),
191            RunEvent::LoopIteration { iteration, .. } => format!("        iteration {iteration}"),
192            RunEvent::MapIteration { index, total, .. } => {
193                format!("        element {}/{total}", index + 1)
194            }
195            RunEvent::Emitted { output, .. } => format!("        emit {output}"),
196            RunEvent::RunFinished { steps, usage } => {
197                format!("done: {steps} step(s), {} token(s)", usage.total())
198            }
199            RunEvent::RunFailed { reason } => format!("failed: {reason}"),
200            RunEvent::RunStopped { label, .. } => {
201                format!("stopped at \"{label}\"; resume to continue")
202            }
203        }
204    }
205
206    /// One JSON object, for piping into another tool.
207    pub fn to_json_line(&self) -> String {
208        serde_json::to_string(self).expect("events are always serializable")
209    }
210}
211
212/// Where a run's observable output goes.
213///
214/// Two channels, and the difference between them is the point.
215///
216/// [`emit`](EventSink::emit) carries the **event stream**: the record of what
217/// the run did. It is ordered, it is timestamp-free, and replaying a cassette
218/// reproduces it byte for byte, which is what lets a test assert on it.
219///
220/// [`delta`](EventSink::delta) and [`settled`](EventSink::settled) carry the
221/// **live channel**: text as a model produces it. None of it is a record of
222/// anything. How an answer arrived over the wire is a property of the
223/// connection, not of the run, so a delta is never an event, never recorded in
224/// a cassette, and never asserted on. Both default to discarding, so a sink
225/// that only wants the record gets exactly the record.
226pub trait EventSink {
227    fn emit(&mut self, event: RunEvent);
228
229    /// A fragment of a model's answer, as it arrives.
230    ///
231    /// Called only on a live call against a provider that streams. Fragments
232    /// for one node arrive in order and concatenate to the answer's text; a
233    /// watcher that shows them is showing something that may yet be thrown
234    /// away, which is what [`settled`](EventSink::settled) is for.
235    fn delta(&mut self, node: &str, text: &str) {
236        let _ = (node, text);
237    }
238
239    /// No more deltas for this node, and whether the text became the answer.
240    ///
241    /// `kept` is false when the response was discarded โ€” the answer was cut
242    /// off, or it did not match its declared type โ€” so a watcher can strike
243    /// what it showed instead of leaving a half-finished answer on screen
244    /// looking like a result. Called only after at least one delta.
245    fn settled(&mut self, node: &str, kept: bool) {
246        let _ = (node, kept);
247    }
248}
249
250/// Keeps every event, for tests and for the run report.
251#[derive(Debug, Default)]
252pub struct CollectingSink {
253    pub events: Vec<RunEvent>,
254}
255
256impl EventSink for CollectingSink {
257    fn emit(&mut self, event: RunEvent) {
258        self.events.push(event);
259    }
260}
261
262/// Discards events.
263pub struct NullSink;
264
265impl EventSink for NullSink {
266    fn emit(&mut self, _event: RunEvent) {}
267}
268
269/// Collects events and also hands each to a callback, for live output.
270///
271/// Events only. Deltas are discarded, because a callback that took both would
272/// have to decide which stream it was being handed on every call. A watcher
273/// that wants the live text implements [`EventSink`] directly and overrides
274/// [`EventSink::delta`].
275pub struct TeeSink<F: FnMut(&RunEvent)> {
276    pub events: Vec<RunEvent>,
277    callback: F,
278}
279
280impl<F: FnMut(&RunEvent)> TeeSink<F> {
281    pub fn new(callback: F) -> TeeSink<F> {
282        TeeSink {
283            events: Vec::new(),
284            callback,
285        }
286    }
287}
288
289impl<F: FnMut(&RunEvent)> EventSink for TeeSink<F> {
290    fn emit(&mut self, event: RunEvent) {
291        (self.callback)(&event);
292        self.events.push(event);
293    }
294}
295
296/// A value produced by the run, ready to be written out.
297#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
298#[serde(rename_all = "camelCase")]
299pub struct Artifact {
300    pub name: String,
301    /// The artifact content type, e.g. `markdown`.
302    pub content_type: String,
303    pub value: Value,
304}
305
306impl Artifact {
307    /// The bytes to write to disk.
308    ///
309    /// Prose types are written as-is; anything else as canonical JSON, because
310    /// writing a JSON-quoted markdown document to a `.md` file would be useless.
311    pub fn to_bytes(&self) -> Vec<u8> {
312        match (&self.value, self.content_type.as_str()) {
313            (Value::String(text), "markdown" | "text") => text.clone().into_bytes(),
314            (value, _) => {
315                let mut json = serde_json::to_string_pretty(value)
316                    .expect("artifact values are always serializable");
317                json.push('\n');
318                json.into_bytes()
319            }
320        }
321    }
322
323    /// Conventional file extension for this content type.
324    pub fn extension(&self) -> &'static str {
325        match self.content_type.as_str() {
326            "markdown" => "md",
327            "text" => "txt",
328            _ => "json",
329        }
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use serde_json::json;
337
338    #[test]
339    fn events_round_trip_through_json() {
340        let event = RunEvent::ModelCall {
341            node: "n0".into(),
342            model: "test".into(),
343            response_type: "markdown".into(),
344            usage: Usage {
345                input_tokens: 1,
346                output_tokens: 2,
347                cache_read_tokens: 0,
348            },
349        };
350        let parsed: RunEvent = serde_json::from_str(&event.to_json_line()).unwrap();
351        assert_eq!(parsed, event);
352    }
353
354    #[test]
355    fn markdown_artifacts_are_written_as_prose() {
356        let artifact = Artifact {
357            name: "report".into(),
358            content_type: "markdown".into(),
359            value: json!("# Title\n\nBody"),
360        };
361        assert_eq!(artifact.to_bytes(), b"# Title\n\nBody");
362        assert_eq!(artifact.extension(), "md");
363    }
364
365    #[test]
366    fn structured_artifacts_are_written_as_json() {
367        let artifact = Artifact {
368            name: "data".into(),
369            content_type: "json".into(),
370            value: json!({"a": 1}),
371        };
372        let text = String::from_utf8(artifact.to_bytes()).unwrap();
373        assert!(text.starts_with('{'));
374        assert!(text.ends_with("}\n"));
375        assert_eq!(artifact.extension(), "json");
376    }
377
378    #[test]
379    fn the_collecting_sink_preserves_order() {
380        let mut sink = CollectingSink::default();
381        sink.emit(RunEvent::RunStarted {
382            agent: "a".into(),
383            provider: "p".into(),
384        });
385        sink.emit(RunEvent::RunFinished {
386            steps: 1,
387            usage: Usage::default(),
388        });
389        assert_eq!(sink.events.len(), 2);
390        assert!(matches!(sink.events[0], RunEvent::RunStarted { .. }));
391    }
392
393    #[test]
394    fn a_delta_is_not_an_event() {
395        // The property the whole design rests on: what a watcher sees live
396        // leaves no trace in the stream a replay has to reproduce.
397        let mut sink = CollectingSink::default();
398        sink.delta("n0", "half an ans");
399        sink.delta("n0", "wer");
400        sink.settled("n0", true);
401        assert!(
402            sink.events.is_empty(),
403            "deltas leaked into the event stream: {:?}",
404            sink.events
405        );
406    }
407}