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    /// A question has been put to a person and the run is waiting.
100    ///
101    /// Emitted *before* the channel is asked, so a surface watching the stream
102    /// sees the question while there is still something to answer. A run
103    /// stopped at one must not look like a run that is working.
104    ConsultationAsked {
105        node: String,
106        /// Which consultation this is within the run, counting from zero — the
107        /// same number a cassette matches by.
108        index: usize,
109        question: String,
110        #[serde(default, skip_serializing_if = "Vec::is_empty")]
111        choices: Vec<String>,
112    },
113    ConsultationAnswered {
114        node: String,
115        index: usize,
116        answer: String,
117    },
118    StateWritten {
119        node: String,
120        field: String,
121    },
122    Verified {
123        node: String,
124        verifier: String,
125        outcome: VerifyOutcome,
126    },
127    Checkpoint {
128        node: String,
129        label: String,
130    },
131    BranchTaken {
132        node: String,
133        /// `then` or `else`.
134        arm: String,
135    },
136    LoopIteration {
137        node: String,
138        iteration: u32,
139    },
140    MapIteration {
141        node: String,
142        index: usize,
143        total: usize,
144    },
145    Emitted {
146        node: String,
147        output: String,
148    },
149    RunFinished {
150        steps: u32,
151        usage: Usage,
152    },
153    RunFailed {
154        reason: String,
155    },
156    /// The run stopped at a resumable checkpoint and can be continued.
157    ///
158    /// Distinct from `runFinished` because without it a stopped run and a run
159    /// that finished having produced nothing look identical in a record, and
160    /// the check that every declared output was emitted would have to be
161    /// skipped on a guess. This is that guess made explicit: it is the only
162    /// thing that suppresses the check, and a reader can see it was suppressed.
163    RunStopped {
164        node: String,
165        label: String,
166    },
167}
168
169impl RunEvent {
170    /// One line, for a terminal.
171    pub fn to_line(&self) -> String {
172        match self {
173            RunEvent::RunStarted { agent, provider } => {
174                format!("run {agent} (provider: {provider})")
175            }
176            RunEvent::NodeStarted { node, kind } => format!("  {node}  {kind}"),
177            RunEvent::ModelCall {
178                model,
179                response_type,
180                usage,
181                ..
182            } => format!(
183                "        model {model} -> {response_type} ({} in, {} out)",
184                usage.input_tokens, usage.output_tokens
185            ),
186            RunEvent::ToolCall { tool, effects, .. } => {
187                format!("        tool {tool} [{}]", effects.join(", "))
188            }
189            RunEvent::AgentCall { agent, .. } => format!("        agent {agent}"),
190            RunEvent::ApprovalRequested {
191                effects, reason, ..
192            } => {
193                format!(
194                    "        approval needed for [{}]: {reason}",
195                    effects.join(", ")
196                )
197            }
198            RunEvent::ConsultationAsked {
199                question, choices, ..
200            } => {
201                if choices.is_empty() {
202                    format!("        asking a person: {question}")
203                } else {
204                    format!(
205                        "        asking a person: {question} [{}]",
206                        choices.join(" | ")
207                    )
208                }
209            }
210            RunEvent::ConsultationAnswered { answer, .. } => {
211                format!("        a person answered: {answer}")
212            }
213            RunEvent::ApprovalDecided { allowed, .. } => {
214                format!(
215                    "        approval {}",
216                    if *allowed { "granted" } else { "denied" }
217                )
218            }
219            RunEvent::StateWritten { field, .. } => format!("        state.{field} written"),
220            RunEvent::Verified {
221                verifier, outcome, ..
222            } => format!("        verify {verifier}: {}", outcome.describe()),
223            RunEvent::Checkpoint { label, .. } => format!("        checkpoint \"{label}\""),
224            RunEvent::BranchTaken { arm, .. } => format!("        branch: {arm}"),
225            RunEvent::LoopIteration { iteration, .. } => format!("        iteration {iteration}"),
226            RunEvent::MapIteration { index, total, .. } => {
227                format!("        element {}/{total}", index + 1)
228            }
229            RunEvent::Emitted { output, .. } => format!("        emit {output}"),
230            RunEvent::RunFinished { steps, usage } => {
231                format!("done: {steps} step(s), {} token(s)", usage.total())
232            }
233            RunEvent::RunFailed { reason } => format!("failed: {reason}"),
234            RunEvent::RunStopped { label, .. } => {
235                format!("stopped at \"{label}\"; resume to continue")
236            }
237        }
238    }
239
240    /// One JSON object, for piping into another tool.
241    pub fn to_json_line(&self) -> String {
242        serde_json::to_string(self).expect("events are always serializable")
243    }
244}
245
246/// Where a run's observable output goes.
247///
248/// Two channels, and the difference between them is the point.
249///
250/// [`emit`](EventSink::emit) carries the **event stream**: the record of what
251/// the run did. It is ordered, it is timestamp-free, and replaying a cassette
252/// reproduces it byte for byte, which is what lets a test assert on it.
253///
254/// [`delta`](EventSink::delta) and [`settled`](EventSink::settled) carry the
255/// **live channel**: text as a model produces it. None of it is a record of
256/// anything. How an answer arrived over the wire is a property of the
257/// connection, not of the run, so a delta is never an event, never recorded in
258/// a cassette, and never asserted on. Both default to discarding, so a sink
259/// that only wants the record gets exactly the record.
260pub trait EventSink {
261    fn emit(&mut self, event: RunEvent);
262
263    /// A fragment of a model's answer, as it arrives.
264    ///
265    /// Called only on a live call against a provider that streams. Fragments
266    /// for one node arrive in order and concatenate to the answer's text; a
267    /// watcher that shows them is showing something that may yet be thrown
268    /// away, which is what [`settled`](EventSink::settled) is for.
269    fn delta(&mut self, node: &str, text: &str) {
270        let _ = (node, text);
271    }
272
273    /// No more deltas for this node, and whether the text became the answer.
274    ///
275    /// `kept` is false when the response was discarded — the answer was cut
276    /// off, or it did not match its declared type — so a watcher can strike
277    /// what it showed instead of leaving a half-finished answer on screen
278    /// looking like a result. Called only after at least one delta.
279    fn settled(&mut self, node: &str, kept: bool) {
280        let _ = (node, kept);
281    }
282}
283
284/// Keeps every event, for tests and for the run report.
285#[derive(Debug, Default)]
286pub struct CollectingSink {
287    pub events: Vec<RunEvent>,
288}
289
290impl EventSink for CollectingSink {
291    fn emit(&mut self, event: RunEvent) {
292        self.events.push(event);
293    }
294}
295
296/// Discards events.
297pub struct NullSink;
298
299impl EventSink for NullSink {
300    fn emit(&mut self, _event: RunEvent) {}
301}
302
303/// Collects events and also hands each to a callback, for live output.
304///
305/// Events only. Deltas are discarded, because a callback that took both would
306/// have to decide which stream it was being handed on every call. A watcher
307/// that wants the live text implements [`EventSink`] directly and overrides
308/// [`EventSink::delta`].
309pub struct TeeSink<F: FnMut(&RunEvent)> {
310    pub events: Vec<RunEvent>,
311    callback: F,
312}
313
314impl<F: FnMut(&RunEvent)> TeeSink<F> {
315    pub fn new(callback: F) -> TeeSink<F> {
316        TeeSink {
317            events: Vec::new(),
318            callback,
319        }
320    }
321}
322
323impl<F: FnMut(&RunEvent)> EventSink for TeeSink<F> {
324    fn emit(&mut self, event: RunEvent) {
325        (self.callback)(&event);
326        self.events.push(event);
327    }
328}
329
330/// A value produced by the run, ready to be written out.
331#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
332#[serde(rename_all = "camelCase")]
333pub struct Artifact {
334    pub name: String,
335    /// The artifact content type, e.g. `markdown`.
336    pub content_type: String,
337    pub value: Value,
338}
339
340impl Artifact {
341    /// The bytes to write to disk.
342    ///
343    /// Prose types are written as-is; anything else as canonical JSON, because
344    /// writing a JSON-quoted markdown document to a `.md` file would be useless.
345    pub fn to_bytes(&self) -> Vec<u8> {
346        match (&self.value, self.content_type.as_str()) {
347            (Value::String(text), "markdown" | "text") => text.clone().into_bytes(),
348            (value, _) => {
349                let mut json = serde_json::to_string_pretty(value)
350                    .expect("artifact values are always serializable");
351                json.push('\n');
352                json.into_bytes()
353            }
354        }
355    }
356
357    /// Conventional file extension for this content type.
358    pub fn extension(&self) -> &'static str {
359        match self.content_type.as_str() {
360            "markdown" => "md",
361            "text" => "txt",
362            _ => "json",
363        }
364    }
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370    use serde_json::json;
371
372    #[test]
373    fn events_round_trip_through_json() {
374        let event = RunEvent::ModelCall {
375            node: "n0".into(),
376            model: "test".into(),
377            response_type: "markdown".into(),
378            usage: Usage {
379                input_tokens: 1,
380                output_tokens: 2,
381                cache_read_tokens: 0,
382            },
383        };
384        let parsed: RunEvent = serde_json::from_str(&event.to_json_line()).unwrap();
385        assert_eq!(parsed, event);
386    }
387
388    #[test]
389    fn markdown_artifacts_are_written_as_prose() {
390        let artifact = Artifact {
391            name: "report".into(),
392            content_type: "markdown".into(),
393            value: json!("# Title\n\nBody"),
394        };
395        assert_eq!(artifact.to_bytes(), b"# Title\n\nBody");
396        assert_eq!(artifact.extension(), "md");
397    }
398
399    #[test]
400    fn structured_artifacts_are_written_as_json() {
401        let artifact = Artifact {
402            name: "data".into(),
403            content_type: "json".into(),
404            value: json!({"a": 1}),
405        };
406        let text = String::from_utf8(artifact.to_bytes()).unwrap();
407        assert!(text.starts_with('{'));
408        assert!(text.ends_with("}\n"));
409        assert_eq!(artifact.extension(), "json");
410    }
411
412    #[test]
413    fn the_collecting_sink_preserves_order() {
414        let mut sink = CollectingSink::default();
415        sink.emit(RunEvent::RunStarted {
416            agent: "a".into(),
417            provider: "p".into(),
418        });
419        sink.emit(RunEvent::RunFinished {
420            steps: 1,
421            usage: Usage::default(),
422        });
423        assert_eq!(sink.events.len(), 2);
424        assert!(matches!(sink.events[0], RunEvent::RunStarted { .. }));
425    }
426
427    #[test]
428    fn a_delta_is_not_an_event() {
429        // The property the whole design rests on: what a watcher sees live
430        // leaves no trace in the stream a replay has to reproduce.
431        let mut sink = CollectingSink::default();
432        sink.delta("n0", "half an ans");
433        sink.delta("n0", "wer");
434        sink.settled("n0", true);
435        assert!(
436            sink.events.is_empty(),
437            "deltas leaked into the event stream: {:?}",
438            sink.events
439        );
440    }
441}