Skip to main content

oris_kernel/kernel/
runtime_effect.rs

1//! Runtime effect capture: canonical side-effect types and sink for the kernel.
2//!
3//! All side effects produced during execution are represented as [RuntimeEffect] and
4//! logged to the active context via [EffectSink]. This ensures zero uncaptured
5//! side effects leak into the execution state (replay and verification can rely on
6//! a complete effect stream).
7
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11use crate::kernel::identity::RunId;
12
13/// A single runtime side effect that the kernel (or adapters) must capture.
14///
15/// Every LLM call, tool call, state write, and interrupt raise is recorded as one
16/// of these variants so that execution is fully auditable and replay-safe.
17#[derive(Clone, Debug, Serialize, Deserialize)]
18pub enum RuntimeEffect {
19    /// An LLM was invoked (provider + input).
20    LLMCall { provider: String, input: Value },
21    /// A tool was invoked (tool name + input).
22    ToolCall { tool: String, input: Value },
23    /// State was written (e.g. after a step; step_id and payload).
24    StateWrite {
25        step_id: Option<String>,
26        payload: Value,
27    },
28    /// An interrupt was raised (e.g. human-in-the-loop; value for resolver).
29    InterruptRaise { value: Value },
30}
31
32/// Sink for recording runtime effects for the active thread/run.
33///
34/// The kernel driver (and any code that produces side effects) should log every
35/// [RuntimeEffect] through this trait so that nothing is uncaptured. Implementations
36/// may append to a thread-local buffer, a run-scoped log, or a no-op for tests.
37pub trait EffectSink: Send + Sync {
38    /// Records one runtime effect for the given run.
39    fn record(&self, run_id: &RunId, effect: &RuntimeEffect);
40}
41
42/// Effect sink that discards all effects (e.g. when capture is not needed).
43#[derive(Debug, Default)]
44pub struct NoopEffectSink;
45
46impl EffectSink for NoopEffectSink {
47    fn record(&self, _run_id: &RunId, _effect: &RuntimeEffect) {}
48}
49
50#[cfg(test)]
51mod tests {
52    use super::*;
53    use std::sync::atomic::{AtomicUsize, Ordering};
54
55    #[test]
56    fn runtime_effect_llm_call_roundtrip() {
57        let e = RuntimeEffect::LLMCall {
58            provider: "openai".to_string(),
59            input: serde_json::json!({"model": "gpt-4"}),
60        };
61        let j = serde_json::to_value(&e).unwrap();
62        let _: RuntimeEffect = serde_json::from_value(j).unwrap();
63    }
64
65    #[test]
66    fn noop_effect_sink_accepts_all() {
67        let sink = NoopEffectSink;
68        let run_id: RunId = "test-run".into();
69        sink.record(
70            &run_id,
71            &RuntimeEffect::StateWrite {
72                step_id: None,
73                payload: serde_json::json!(null),
74            },
75        );
76    }
77
78    #[test]
79    fn effect_sink_can_count() {
80        struct CountSink(AtomicUsize);
81        impl EffectSink for CountSink {
82            fn record(&self, _: &RunId, _: &RuntimeEffect) {
83                self.0.fetch_add(1, Ordering::Relaxed);
84            }
85        }
86        let sink = CountSink(AtomicUsize::new(0));
87        let run_id: RunId = "test-run".into();
88        sink.record(
89            &run_id,
90            &RuntimeEffect::ToolCall {
91                tool: "t".into(),
92                input: serde_json::json!(()),
93            },
94        );
95        sink.record(
96            &run_id,
97            &RuntimeEffect::InterruptRaise {
98                value: serde_json::json!(true),
99            },
100        );
101        assert_eq!(sink.0.load(Ordering::Relaxed), 2);
102    }
103}