Skip to main content

leviath_runtime/
persistence.rs

1//! Agent-state persistence: turning a live ECS agent into the on-disk snapshot
2//! the dashboard/API read (`meta.json` + `context.json` under the run directory).
3//!
4//! This module holds the **pure** serialization core - components that carry an
5//! agent's run identity and running token totals, plus functions that build the
6//! [`RunMeta`]/[`ContextSnapshot`] value types from an agent's live components.
7//! It does no I/O; the async write lane and the snapshot-dispatch system layer on
8//! top of these.
9
10use bevy_ecs::prelude::*;
11use leviath_core::RegionKind;
12use leviath_core::run_meta::{
13    ContextSnapshot, RegionEntrySnapshot, RegionSnapshot, RunMeta, RunStatus, StageRunStatus,
14};
15
16use crate::components::{AgentState, AgentStatus, ContextWindow};
17
18/// Static per-agent run metadata (the parts of [`RunMeta`] that don't change as
19/// the agent runs). Set once when the agent is spawned; the dynamic fields are
20/// filled from the live components at snapshot time.
21#[derive(Component, Clone)]
22pub struct RunMetadata {
23    /// The run's unique id (its directory name under the runs dir).
24    pub run_id: String,
25    /// The agent/blueprint name.
26    pub agent_name: String,
27    /// Absolute path to the agent manifest directory.
28    pub agent_path: String,
29    /// The task prompt.
30    pub task: String,
31    /// The resolved model label (provider/model), if known.
32    pub model: Option<String>,
33    /// Absolute working directory for tool execution.
34    pub workdir: String,
35    /// Total number of stages in the blueprint.
36    pub num_stages: usize,
37    /// When the run started (unix seconds).
38    pub started_at: i64,
39    /// Parent run id, for sub-agent runs.
40    pub parent_run_id: Option<String>,
41    /// Custom key-value metadata from the spawn request.
42    pub metadata: std::collections::HashMap<String, String>,
43    /// Webhook to POST on completion/error.
44    pub callback_url: Option<String>,
45    /// Optional shared secret for HMAC-SHA256 signing the webhook body.
46    pub callback_secret: Option<String>,
47    /// Short human-readable title (None until generated).
48    pub title: Option<String>,
49}
50
51/// Running token + tool-call totals accumulated across an agent's inferences, for
52/// the snapshot. Updated by the inference-collect system.
53#[derive(Component, Clone, Copy, Default, Debug, PartialEq, Eq)]
54pub struct TokenTotals {
55    /// Cumulative prompt tokens.
56    pub prompt_tokens: usize,
57    /// Cumulative completion tokens.
58    pub completion_tokens: usize,
59    /// Cumulative tokens read from provider cache.
60    pub cached_tokens: usize,
61    /// Cumulative tokens written to provider cache.
62    pub cache_write_tokens: usize,
63    /// Cumulative tool calls across all iterations.
64    pub tool_calls: usize,
65}
66
67/// Run-scoped productivity flags, mirrored into `meta.json` so an empty run can
68/// be recognized (and explained) from disk. Unlike [`StageProgress`], this is
69/// never reset on a stage transition - it describes the whole run.
70///
71/// [`StageProgress`]: crate::pipeline::StageProgress
72#[derive(Component, Clone, Default, Debug, PartialEq)]
73pub struct RunOutcomeFlags(pub leviath_core::run_meta::RunFlags);
74
75impl TokenTotals {
76    /// Add one inference response's usage to the running totals.
77    pub fn add_usage(&mut self, usage: &leviath_providers::TokenUsage) {
78        self.prompt_tokens += usage.prompt_tokens;
79        self.completion_tokens += usage.completion_tokens;
80        self.cached_tokens += usage.cached_tokens;
81        self.cache_write_tokens += usage.cache_write_tokens;
82    }
83}
84
85/// Map an agent's ECS status to the on-disk [`RunStatus`].
86pub fn run_status_from(status: &AgentStatus) -> RunStatus {
87    match status {
88        AgentStatus::Idle | AgentStatus::Active => RunStatus::Running,
89        AgentStatus::Waiting => RunStatus::WaitingInput,
90        AgentStatus::Complete => RunStatus::Complete,
91        AgentStatus::Error { .. } => RunStatus::Error,
92        AgentStatus::Cancelled => RunStatus::Cancelled,
93    }
94}
95
96/// Map an agent's ECS status to the on-disk per-stage [`StageRunStatus`] for the
97/// stage it is currently in. `Cancelled` has no stage-level equivalent, so it
98/// surfaces as `Error` (the stage stopped without completing).
99pub fn stage_status_from(status: &AgentStatus) -> StageRunStatus {
100    match status {
101        AgentStatus::Idle | AgentStatus::Active => StageRunStatus::Active,
102        AgentStatus::Waiting => StageRunStatus::WaitingInput,
103        AgentStatus::Complete => StageRunStatus::Complete,
104        AgentStatus::Error { .. } | AgentStatus::Cancelled => StageRunStatus::Error,
105    }
106}
107
108/// The stringified region kind used in snapshots (matches the dashboard reader).
109fn region_kind_str(kind: &RegionKind) -> &'static str {
110    match kind {
111        RegionKind::Pinned => "pinned",
112        RegionKind::Temporary => "temporary",
113        RegionKind::Clearable => "clearable",
114        RegionKind::SlidingWindow { .. } => "sliding",
115        RegionKind::Compacting { .. } => "compacting",
116        RegionKind::CompactHistory { .. } => "history",
117        RegionKind::HashMap { .. } => "hashmap",
118        RegionKind::Custom { .. } => "custom",
119    }
120}
121
122/// Build the full context snapshot (`context.json`) from a window. Pure over the
123/// window - no engine/entity. (Ported from the CLI's `build_context_snapshot`.)
124pub fn build_context_snapshot(window: &ContextWindow, stage_name: &str) -> ContextSnapshot {
125    let regions = window
126        .regions
127        .iter()
128        .map(|r| RegionSnapshot {
129            name: r.name.clone(),
130            kind: region_kind_str(&r.kind).to_string(),
131            current_tokens: r.current_tokens,
132            max_tokens: r.max_tokens,
133            entries: r
134                .content
135                .iter()
136                .enumerate()
137                .map(|(i, e)| RegionEntrySnapshot {
138                    content: e.content.clone(),
139                    tokens: e.tokens,
140                    kind: e.kind.clone(),
141                    metadata: e.metadata.clone(),
142                    key: e.key.clone(),
143                    // `None` when the region has no taint tracking (it is off,
144                    // or this is an older region): `Public`, which is what a
145                    // restore assumed anyway.
146                    taint: r
147                        .taint
148                        .as_ref()
149                        .and_then(|t| t.entry_taint(i))
150                        .unwrap_or_default(),
151                })
152                .collect(),
153        })
154        .collect();
155    ContextSnapshot {
156        stage_name: stage_name.to_string(),
157        total_tokens: window.current_tokens,
158        max_tokens: window.max_tokens,
159        regions,
160    }
161}
162
163/// Build the run metadata (`meta.json`) from an agent's live components, stamping
164/// `updated_at` with `now_secs`. `stage_index` is the agent's current stage
165/// position within its blueprint.
166#[allow(clippy::too_many_arguments)]
167pub fn build_run_meta(
168    md: &RunMetadata,
169    state: &AgentState,
170    totals: &TokenTotals,
171    flags: &RunOutcomeFlags,
172    stage_index: usize,
173    now_secs: i64,
174    depth: usize,
175    max_child_depth: usize,
176) -> RunMeta {
177    // `empty_output` is only meaningful once the run has stopped: a running
178    // agent that hasn't written anything *yet* is not an empty run.
179    let status = run_status_from(&state.status);
180    let mut flags = flags.0.clone();
181    flags.empty_output = matches!(
182        status,
183        RunStatus::Complete | RunStatus::Error | RunStatus::Cancelled
184    ) && flags.modified_file_count == 0;
185    RunMeta {
186        run_id: md.run_id.clone(),
187        agent_name: md.agent_name.clone(),
188        agent_path: md.agent_path.clone(),
189        task: md.task.clone(),
190        model: md.model.clone(),
191        pid: 0, // no per-run worker process in the shared world
192        status,
193        current_stage: state.current_stage.clone(),
194        stage_index,
195        num_stages: md.num_stages,
196        iteration: state.iteration,
197        prompt_tokens: totals.prompt_tokens,
198        completion_tokens: totals.completion_tokens,
199        cached_tokens: totals.cached_tokens,
200        cache_write_tokens: totals.cache_write_tokens,
201        tool_calls: totals.tool_calls,
202        workdir: md.workdir.clone(),
203        started_at: md.started_at,
204        updated_at: now_secs,
205        error: match &state.status {
206            AgentStatus::Error { message } => Some(message.clone()),
207            _ => None,
208        },
209        title: md.title.clone(),
210        metadata: md.metadata.clone(),
211        callback_url: md.callback_url.clone(),
212        callback_secret: md.callback_secret.clone(),
213        parent_run_id: md.parent_run_id.clone(),
214        // The tree links, so restart can rebuild the exact parent→children graph.
215        children: state.spawned_children_ids.clone(),
216        depth,
217        max_child_depth,
218        flags,
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use leviath_core::Region;
226    use leviath_providers::TokenUsage;
227
228    fn state(status: AgentStatus) -> AgentState {
229        AgentState {
230            agent_id: "a".to_string(),
231            current_stage: "plan".to_string(),
232            iteration: 4,
233            status,
234            spawned_children_ids: vec![],
235            pending_wait: None,
236            accepts_messages: true,
237        }
238    }
239
240    fn metadata() -> RunMetadata {
241        RunMetadata {
242            run_id: "run-1".to_string(),
243            agent_name: "coder".to_string(),
244            agent_path: "/agents/coder".to_string(),
245            task: "do it".to_string(),
246            model: Some("anthropic/claude".to_string()),
247            workdir: "/work".to_string(),
248            num_stages: 3,
249            started_at: 1000,
250            parent_run_id: Some("parent".to_string()),
251            metadata: std::collections::HashMap::from([("k".to_string(), "v".to_string())]),
252            callback_url: Some("http://cb".to_string()),
253            callback_secret: Some("sekret".to_string()),
254            title: Some("Do It".to_string()),
255        }
256    }
257
258    #[test]
259    fn status_mapping_covers_all_variants() {
260        assert_eq!(run_status_from(&AgentStatus::Idle), RunStatus::Running);
261        assert_eq!(run_status_from(&AgentStatus::Active), RunStatus::Running);
262        assert_eq!(
263            run_status_from(&AgentStatus::Waiting),
264            RunStatus::WaitingInput
265        );
266        assert_eq!(run_status_from(&AgentStatus::Complete), RunStatus::Complete);
267        assert_eq!(
268            run_status_from(&AgentStatus::Error {
269                message: "x".to_string()
270            }),
271            RunStatus::Error
272        );
273        assert_eq!(
274            run_status_from(&AgentStatus::Cancelled),
275            RunStatus::Cancelled
276        );
277    }
278
279    #[test]
280    fn stage_status_mapping_covers_all_variants() {
281        use leviath_core::run_meta::StageRunStatus;
282        assert_eq!(
283            stage_status_from(&AgentStatus::Idle),
284            StageRunStatus::Active
285        );
286        assert_eq!(
287            stage_status_from(&AgentStatus::Active),
288            StageRunStatus::Active
289        );
290        assert_eq!(
291            stage_status_from(&AgentStatus::Waiting),
292            StageRunStatus::WaitingInput
293        );
294        assert_eq!(
295            stage_status_from(&AgentStatus::Complete),
296            StageRunStatus::Complete
297        );
298        assert_eq!(
299            stage_status_from(&AgentStatus::Error {
300                message: "x".to_string()
301            }),
302            StageRunStatus::Error
303        );
304        assert_eq!(
305            stage_status_from(&AgentStatus::Cancelled),
306            StageRunStatus::Error
307        );
308    }
309
310    #[test]
311    fn token_totals_accumulate() {
312        let mut t = TokenTotals::default();
313        t.add_usage(&TokenUsage {
314            prompt_tokens: 10,
315            completion_tokens: 5,
316            total_tokens: 15,
317            cached_tokens: 2,
318            cache_write_tokens: 1,
319        });
320        t.add_usage(&TokenUsage {
321            prompt_tokens: 3,
322            completion_tokens: 4,
323            total_tokens: 7,
324            cached_tokens: 0,
325            cache_write_tokens: 0,
326        });
327        t.tool_calls = 6;
328        assert_eq!(t.prompt_tokens, 13);
329        assert_eq!(t.completion_tokens, 9);
330        assert_eq!(t.cached_tokens, 2);
331        assert_eq!(t.cache_write_tokens, 1);
332    }
333
334    #[test]
335    fn build_run_meta_fills_dynamic_and_static_fields() {
336        let md = metadata();
337        let totals = TokenTotals {
338            prompt_tokens: 100,
339            completion_tokens: 50,
340            cached_tokens: 10,
341            cache_write_tokens: 5,
342            tool_calls: 7,
343        };
344        let mut st = state(AgentStatus::Active);
345        st.spawned_children_ids = vec!["child-a".to_string(), "child-b".to_string()];
346        let meta = build_run_meta(
347            &md,
348            &st,
349            &totals,
350            &RunOutcomeFlags::default(),
351            1,
352            2000,
353            1,
354            4,
355        );
356
357        assert_eq!(meta.run_id, "run-1");
358        assert_eq!(meta.status, RunStatus::Running);
359        assert_eq!(meta.current_stage, "plan");
360        assert_eq!(meta.stage_index, 1);
361        assert_eq!(meta.iteration, 4);
362        assert_eq!(meta.prompt_tokens, 100);
363        assert_eq!(meta.tool_calls, 7);
364        assert_eq!(meta.updated_at, 2000);
365        assert_eq!(meta.parent_run_id.as_deref(), Some("parent"));
366        assert_eq!(meta.callback_url.as_deref(), Some("http://cb"));
367        assert_eq!(meta.callback_secret.as_deref(), Some("sekret"));
368        assert!(meta.error.is_none());
369        // The tree links are carried through from the agent's live state.
370        assert_eq!(
371            meta.children,
372            vec!["child-a".to_string(), "child-b".to_string()]
373        );
374        assert_eq!(meta.depth, 1);
375        assert_eq!(meta.max_child_depth, 4);
376    }
377
378    #[test]
379    fn build_run_meta_flags_empty_output_only_once_the_run_has_stopped() {
380        let mut flags = RunOutcomeFlags::default();
381        flags.0.gates_forced = 2;
382        // Still running with nothing written: not (yet) an empty run.
383        let running = build_run_meta(
384            &metadata(),
385            &state(AgentStatus::Active),
386            &TokenTotals::default(),
387            &flags,
388            0,
389            1000,
390            0,
391            0,
392        );
393        assert!(!running.flags.empty_output);
394        assert_eq!(running.flags.gates_forced, 2);
395
396        // Finished with nothing written: that is the #107 signature.
397        for status in [
398            AgentStatus::Complete,
399            AgentStatus::Cancelled,
400            AgentStatus::Error {
401                message: "x".to_string(),
402            },
403        ] {
404            let meta = build_run_meta(
405                &metadata(),
406                &state(status),
407                &TokenTotals::default(),
408                &flags,
409                0,
410                1000,
411                0,
412                0,
413            );
414            assert!(meta.flags.empty_output);
415        }
416
417        // Finished having written something: not empty.
418        let mut wrote = RunOutcomeFlags::default();
419        wrote.0.record_modification("src/a.rs");
420        let meta = build_run_meta(
421            &metadata(),
422            &state(AgentStatus::Complete),
423            &TokenTotals::default(),
424            &wrote,
425            0,
426            1000,
427            0,
428            0,
429        );
430        assert!(!meta.flags.empty_output);
431        assert_eq!(meta.flags.modified_files, vec!["src/a.rs".to_string()]);
432    }
433
434    #[test]
435    fn build_run_meta_carries_error_message() {
436        let meta = build_run_meta(
437            &metadata(),
438            &state(AgentStatus::Error {
439                message: "boom".to_string(),
440            }),
441            &TokenTotals::default(),
442            &RunOutcomeFlags::default(),
443            2,
444            3000,
445            0,
446            0,
447        );
448        assert_eq!(meta.status, RunStatus::Error);
449        assert_eq!(meta.error.as_deref(), Some("boom"));
450    }
451
452    #[test]
453    fn context_snapshot_captures_all_region_kinds() {
454        let mut w = ContextWindow::new(1000);
455        w.add_region(Region::new("pin".to_string(), RegionKind::Pinned, 100));
456        w.add_region(Region::new("tmp".to_string(), RegionKind::Temporary, 100));
457        w.add_region(Region::new("clr".to_string(), RegionKind::Clearable, 100));
458        w.add_region(Region::new(
459            "slide".to_string(),
460            RegionKind::SlidingWindow {
461                max_items: 5,
462                eviction_strategy: leviath_core::EvictionStrategy::PerItem,
463            },
464            100,
465        ));
466        w.add_region(Region::new(
467            "comp".to_string(),
468            RegionKind::Compacting {
469                threshold_tokens: 5,
470            },
471            100,
472        ));
473        w.add_region(Region::new(
474            "hist".to_string(),
475            RegionKind::CompactHistory {
476                source_region: "comp".to_string(),
477            },
478            100,
479        ));
480        w.add_region(Region::new(
481            "map".to_string(),
482            RegionKind::HashMap { max_entries: None },
483            100,
484        ));
485        w.add_region(Region::new(
486            "brain".to_string(),
487            RegionKind::Custom {
488                script: "b.rhai".to_string(),
489                persistent: false,
490            },
491            100,
492        ));
493        let _ = w.add_to_region("pin", "hello".to_string(), 3);
494        w.current_tokens = w.calculate_tokens();
495
496        let snap = build_context_snapshot(&w, "plan");
497
498        assert_eq!(snap.stage_name, "plan");
499        let kinds: Vec<&str> = snap.regions.iter().map(|r| r.kind.as_str()).collect();
500        assert_eq!(
501            kinds,
502            vec![
503                "pinned",
504                "temporary",
505                "clearable",
506                "sliding",
507                "compacting",
508                "history",
509                "hashmap",
510                "custom"
511            ]
512        );
513        // The pinned region's entry is captured.
514        let pin = snap.regions.iter().find(|r| r.name == "pin").unwrap();
515        assert_eq!(pin.entries.len(), 1);
516        assert_eq!(pin.entries[0].content, "hello");
517    }
518}