Skip to main content

behest_runtime/
state.rs

1//! Event-sourced run state reconstruction.
2//!
3//! [`RunState`] is a materialized view built by folding [`AgentEvent`] records.
4//! It supports replayable state reconstruction from event logs and batch
5//! reload of multiple runs.
6
7use chrono::{DateTime, Utc};
8use serde_json::Value;
9
10use behest_provider::{FinishReason, ModelName, ProviderId, TokenUsage};
11
12use super::event::AgentEvent;
13use super::run::{RunId, RunRecord, RunStatus};
14use super::store::RunEventRecord;
15
16/// Projected state of a run, reconstructed from its event log.
17///
18/// Unlike [`RunRecord`] which is a static metadata snapshot,
19/// `RunState` is a fully materialized view derived by folding every
20/// recorded event via [`apply`](Self::apply). This enables replay-based
21/// state recovery and batch inspection of run progress without scanning
22/// raw events.
23#[derive(Debug, Clone)]
24pub struct RunState {
25    /// Run identifier.
26    pub run_id: RunId,
27    /// Session this run belongs to.
28    pub session_id: uuid::Uuid,
29    /// Current status.
30    pub status: RunStatus,
31    /// Provider used for model calls.
32    pub provider: ProviderId,
33    /// Model used for generation.
34    pub model: ModelName,
35    /// Run metadata.
36    pub metadata: Value,
37    /// Current iteration (0 before first model call).
38    pub iteration: usize,
39    /// Aggregated token usage across all provider calls.
40    pub total_usage: TokenUsage,
41    /// Finish reason from the last model response.
42    pub last_finish: Option<FinishReason>,
43    /// Error message if the run failed.
44    pub last_error: Option<String>,
45    /// Number of events folded into this state.
46    pub event_count: usize,
47    /// When the run was created.
48    pub created_at: DateTime<Utc>,
49    /// When the last event was recorded.
50    pub updated_at: DateTime<Utc>,
51}
52
53impl RunState {
54    /// Creates a [`RunState`] by folding the run record metadata with its
55    /// event log.
56    ///
57    /// The `record` provides static metadata (provider, model, created_at)
58    /// while `events` drive the dynamic state projection by applying each
59    /// event in order via [`apply`](Self::apply).
60    #[must_use]
61    pub fn create(record: &RunRecord, events: &[RunEventRecord]) -> Self {
62        let updated_at = events.last().map_or(record.updated_at, |e| e.timestamp);
63
64        let mut state = RunState {
65            run_id: record.id,
66            session_id: record.session_id,
67            status: RunStatus::Pending,
68            provider: record.provider.clone(),
69            model: record.model.clone(),
70            metadata: record.metadata.clone(),
71            iteration: 0,
72            total_usage: TokenUsage::new(0, 0),
73            last_finish: None,
74            last_error: None,
75            event_count: events.len(),
76            created_at: record.created_at,
77            updated_at,
78        };
79
80        for event_record in events {
81            state.apply(&event_record.event);
82        }
83
84        state
85    }
86
87    /// Incrementally updates the projected state by applying a single event.
88    ///
89    /// Handles all [`AgentEvent`] variants: `RunStarted` sets provider/model,
90    /// `ModelStarted` advances iteration, `UsageRecorded` accumulates tokens,
91    /// terminal events set the final status, while informational events
92    /// only update the timestamp.
93    pub fn apply(&mut self, event: &AgentEvent) {
94        match event {
95            AgentEvent::RunStarted(e) => {
96                self.status = RunStatus::SessionLoaded;
97                // RunStarted carries provider/model for self-contained reconstruction.
98                self.provider = e.provider.clone();
99                self.model = e.model.clone();
100                self.updated_at = e.timestamp;
101            }
102            AgentEvent::ModelStarted(e) => {
103                self.iteration = e.iteration;
104                self.status = RunStatus::CallingModel;
105                self.updated_at = e.timestamp;
106            }
107            AgentEvent::UsageRecorded(e) => {
108                self.total_usage = self.total_usage.merge(e.usage);
109                self.updated_at = e.timestamp;
110            }
111            AgentEvent::CacheMetrics(e) => {
112                self.updated_at = e.timestamp;
113            }
114            AgentEvent::RunCompleted(e) => {
115                self.status = RunStatus::Completed;
116                self.last_finish = Some(e.finish_reason.clone());
117                self.updated_at = e.timestamp;
118            }
119            AgentEvent::RunFailed(e) => {
120                self.status = RunStatus::Failed;
121                self.last_error = Some(e.error.clone());
122                self.updated_at = e.timestamp;
123            }
124            AgentEvent::RunCancelled(e) => {
125                self.status = RunStatus::Cancelled;
126                self.updated_at = e.timestamp;
127            }
128            AgentEvent::ContextBuilt(e) => {
129                self.updated_at = e.timestamp;
130            }
131            AgentEvent::TextDelta(e) => {
132                self.updated_at = e.timestamp;
133            }
134            AgentEvent::ToolCallStarted(e) => {
135                self.updated_at = e.timestamp;
136            }
137            AgentEvent::ToolCallDelta(e) => {
138                self.updated_at = e.timestamp;
139            }
140            AgentEvent::ToolCallCompleted(e) => {
141                self.updated_at = e.timestamp;
142            }
143            AgentEvent::ToolExecutionStarted(e) => {
144                self.updated_at = e.timestamp;
145            }
146            AgentEvent::ToolExecutionFinished(e) => {
147                self.updated_at = e.timestamp;
148            }
149            AgentEvent::AssistantMessageCommitted(e) | AgentEvent::ToolMessageCommitted(e) => {
150                self.updated_at = e.timestamp;
151            }
152            AgentEvent::DoomLoopDetected(e) => {
153                self.updated_at = e.timestamp;
154            }
155            AgentEvent::CompactionCircuitOpened(e) => {
156                self.updated_at = e.timestamp;
157            }
158        }
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::super::event::{
165        ModelStarted as ModelStartedEvent, RunCompleted as RunCompletedEvent,
166        RunFailed as RunFailedEvent, RunStarted as RunStartedEvent,
167        UsageRecorded as UsageRecordedEvent,
168    };
169    use super::*;
170
171    fn make_record() -> RunRecord {
172        RunRecord::new(
173            RunId::new(),
174            uuid::Uuid::new_v4(),
175            ProviderId::new("test-provider"),
176            ModelName::new("test-model"),
177            Value::Null,
178            None,
179        )
180    }
181
182    #[test]
183    fn rebuilds_completed_run_from_events() {
184        let record = make_record();
185        let run_id = record.id;
186        let session_id = record.session_id;
187
188        let events = vec![
189            RunEventRecord::new(
190                0,
191                run_id,
192                AgentEvent::RunStarted(RunStartedEvent {
193                    run_id,
194                    session_id,
195                    provider: record.provider.clone(),
196                    model: record.model.clone(),
197                    timestamp: Utc::now(),
198                }),
199            ),
200            RunEventRecord::new(
201                1,
202                run_id,
203                AgentEvent::ModelStarted(ModelStartedEvent {
204                    run_id,
205                    provider: record.provider.clone(),
206                    model: record.model.clone(),
207                    iteration: 1,
208                    timestamp: Utc::now(),
209                }),
210            ),
211            RunEventRecord::new(
212                2,
213                run_id,
214                AgentEvent::UsageRecorded(UsageRecordedEvent {
215                    run_id,
216                    usage: TokenUsage::new(100, 50),
217                    timestamp: Utc::now(),
218                }),
219            ),
220            RunEventRecord::new(
221                3,
222                run_id,
223                AgentEvent::RunCompleted(RunCompletedEvent {
224                    run_id,
225                    finish_reason: FinishReason::Stop,
226                    iterations: 1,
227                    timestamp: Utc::now(),
228                }),
229            ),
230        ];
231
232        let state = RunState::create(&record, &events);
233
234        assert_eq!(state.run_id, run_id);
235        assert_eq!(state.session_id, session_id);
236        assert_eq!(state.status, RunStatus::Completed);
237        assert_eq!(state.provider, record.provider);
238        assert_eq!(state.model, record.model);
239        assert_eq!(state.iteration, 1);
240        assert_eq!(state.total_usage, TokenUsage::new(100, 50));
241        assert_eq!(state.last_finish, Some(FinishReason::Stop));
242        assert!(state.last_error.is_none());
243        assert_eq!(state.event_count, 4);
244    }
245
246    #[test]
247    fn rebuilds_failed_run_from_events() {
248        let record = make_record();
249        let run_id = record.id;
250        let session_id = record.session_id;
251
252        let events = vec![
253            RunEventRecord::new(
254                0,
255                run_id,
256                AgentEvent::RunStarted(RunStartedEvent {
257                    run_id,
258                    session_id,
259                    provider: record.provider.clone(),
260                    model: record.model.clone(),
261                    timestamp: Utc::now(),
262                }),
263            ),
264            RunEventRecord::new(
265                1,
266                run_id,
267                AgentEvent::RunFailed(RunFailedEvent {
268                    run_id,
269                    error: "something broke".to_string(),
270                    timestamp: Utc::now(),
271                }),
272            ),
273        ];
274
275        let state = RunState::create(&record, &events);
276
277        assert_eq!(state.status, RunStatus::Failed);
278        assert_eq!(state.last_error, Some("something broke".to_string()));
279        assert!(state.last_finish.is_none());
280    }
281
282    #[test]
283    fn empty_events_returns_pending() {
284        let record = make_record();
285        let state = RunState::create(&record, &[]);
286
287        assert_eq!(state.status, RunStatus::Pending);
288        assert_eq!(state.iteration, 0);
289        assert_eq!(state.event_count, 0);
290    }
291}