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    /// Whether this run is unattended (launched with `--yolo`).
50    ///
51    /// Recorded on the agent so anything holding the world can ask. Two things
52    /// need it: the sub-agent and fan-out spawners, which pass it down so a
53    /// child of an unattended run is unattended too, and `meta.json`, so a
54    /// daemon restart resumes the run the way it was launched. Both used to
55    /// hardcode "attended", which stranded unattended runs on prompts no one was
56    /// there to answer.
57    pub unattended: bool,
58    /// How much of the blueprint's `[read_paths]` the config granted, resolved
59    /// once at spawn (see [`ReadPathGrantCounts`]). `None` when the blueprint
60    /// declares none, which is nearly every agent.
61    ///
62    /// [`ReadPathGrantCounts`]: leviath_core::run_meta::ReadPathGrantCounts
63    pub read_paths: Option<leviath_core::run_meta::ReadPathGrantCounts>,
64    /// The output shape the caller asked for at launch, if they overrode the
65    /// blueprint's. Held so it reaches `meta.json` and survives a restart; the
66    /// resolved per-stage shape lives on `StageInference`/`StageSetup`.
67    pub output_request: Option<leviath_core::output::OutputSpec>,
68}
69
70/// Running token + tool-call totals accumulated across an agent's inferences, for
71/// the snapshot. Updated by the inference-collect system.
72#[derive(Component, Clone, Copy, Default, Debug, PartialEq, Eq)]
73pub struct TokenTotals {
74    /// Cumulative prompt tokens.
75    pub prompt_tokens: usize,
76    /// Cumulative completion tokens.
77    pub completion_tokens: usize,
78    /// Cumulative tokens read from provider cache.
79    pub cached_tokens: usize,
80    /// Cumulative tokens written to provider cache.
81    pub cache_write_tokens: usize,
82    /// Cumulative tool calls across all iterations.
83    pub tool_calls: usize,
84}
85
86/// Run-scoped productivity flags, mirrored into `meta.json` so an empty run can
87/// be recognized (and explained) from disk. Unlike [`StageProgress`], this is
88/// never reset on a stage transition - it describes the whole run.
89///
90/// [`StageProgress`]: crate::pipeline::StageProgress
91#[derive(Component, Clone, Default, Debug, PartialEq)]
92pub struct RunOutcomeFlags(pub leviath_core::run_meta::RunFlags);
93
94impl RunOutcomeFlags {
95    /// Seed a fresh run's flags from the blueprint it is about to run.
96    ///
97    /// Every counter starts at zero; the one thing decided here is
98    /// [`no_output_tools`], which is fixed for the run's lifetime and so is
99    /// answered once rather than re-derived on every persist tick.
100    ///
101    /// Judged across *every* stage, not only the ones the run reaches: a run
102    /// cancelled in the first stage of an agent that writes files really did
103    /// produce nothing, and should still say so.
104    ///
105    /// [`no_output_tools`]: leviath_core::run_meta::RunFlags::no_output_tools
106    pub fn for_blueprint(bp: &leviath_core::Blueprint) -> Self {
107        Self(leviath_core::run_meta::RunFlags {
108            no_output_tools: !bp.stages.iter().any(stage_can_modify),
109            ..Default::default()
110        })
111    }
112}
113
114/// The final output an agent has submitted, held on the agent entity until the
115/// persistence lane copies it into `meta.json`.
116///
117/// Absent until `submit_output` is called, and replaced (not appended to) by a
118/// later call: an agent that submits twice meant the second one. The stage name
119/// travels inside so the enforcement gate can tell "this stage submitted" from
120/// "an earlier one did".
121#[derive(Component, Clone, Debug, PartialEq)]
122pub struct FinalOutput(pub leviath_core::output::FinalOutput);
123
124/// Whether `stage` advertises a tool whose writes the framework would record:
125/// a built-in [`MODIFYING_TOOLS`] name, or one that this stage's own outgoing
126/// transition gates name (the declared escape hatch for agents whose writes go
127/// through MCP or script tools).
128///
129/// Deliberately the same test the transition gate applies in `gate_blocks`, so
130/// a gated stage and the run's flags cannot disagree about what "can modify"
131/// means.
132/// `shell` is absent from both: an agent can edit through `sed -i` without the
133/// framework seeing it, so shell capability is real but unverifiable - which
134/// is exactly why such a run should still be reported as empty rather than
135/// excused.
136///
137/// [`MODIFYING_TOOLS`]: leviath_core::blueprint::MODIFYING_TOOLS
138fn stage_can_modify(stage: &leviath_core::Stage) -> bool {
139    stage.available_tools.iter().any(|t| {
140        let canonical = leviath_tools::canonical_tool_name(t);
141        leviath_core::blueprint::MODIFYING_TOOLS.contains(&canonical)
142            || stage
143                .transitions
144                .iter()
145                .flat_map(|edges| edges.values())
146                .filter_map(|edge| edge.gate.as_ref())
147                .any(|gate| {
148                    gate.tools
149                        .iter()
150                        .any(|extra| leviath_tools::canonical_tool_name(extra) == canonical)
151                })
152    })
153}
154
155impl TokenTotals {
156    /// Add one inference response's usage to the running totals.
157    pub fn add_usage(&mut self, usage: &leviath_providers::TokenUsage) {
158        self.prompt_tokens += usage.prompt_tokens;
159        self.completion_tokens += usage.completion_tokens;
160        self.cached_tokens += usage.cached_tokens;
161        self.cache_write_tokens += usage.cache_write_tokens;
162    }
163}
164
165/// Whether a run in `status` carrying `flags` stopped with nothing to show for
166/// itself.
167///
168/// Four things have to hold. The run has to have *stopped* - an agent that
169/// hasn't written anything yet is not an empty run, it is a busy one. It has to
170/// have modified nothing. It must not have submitted a final output, which is
171/// producing something even when no file changed. And its blueprint has to have
172/// offered a way to modify something, or the question does not apply to it (see
173/// [`no_output_tools`](leviath_core::run_meta::RunFlags::no_output_tools)).
174///
175/// One definition, called by both `meta.json` and the run listing, so what an
176/// operator reads in `lev ps` and what a harness reads off disk cannot drift
177/// apart.
178pub fn is_empty_output(status: &AgentStatus, flags: &leviath_core::run_meta::RunFlags) -> bool {
179    matches!(
180        run_status_from(status),
181        RunStatus::Complete | RunStatus::Error | RunStatus::Cancelled
182    ) && flags.modified_file_count == 0
183        && !flags.produced_output
184        && !flags.no_output_tools
185}
186
187/// Map an agent's ECS status to the on-disk [`RunStatus`].
188pub fn run_status_from(status: &AgentStatus) -> RunStatus {
189    match status {
190        AgentStatus::Idle | AgentStatus::Active => RunStatus::Running,
191        AgentStatus::Paused => RunStatus::Paused,
192        AgentStatus::Waiting => RunStatus::WaitingInput,
193        AgentStatus::Complete => RunStatus::Complete,
194        AgentStatus::Error { .. } => RunStatus::Error,
195        AgentStatus::Cancelled => RunStatus::Cancelled,
196    }
197}
198
199/// Map an agent's ECS status to the on-disk per-stage [`StageRunStatus`] for the
200/// stage it is currently in. `Cancelled` has no stage-level equivalent, so it
201/// surfaces as `Error` (the stage stopped without completing).
202pub fn stage_status_from(status: &AgentStatus) -> StageRunStatus {
203    match status {
204        // A paused agent's current stage is still mid-flight, not a new stage state.
205        AgentStatus::Idle | AgentStatus::Active | AgentStatus::Paused => StageRunStatus::Active,
206        AgentStatus::Waiting => StageRunStatus::WaitingInput,
207        AgentStatus::Complete => StageRunStatus::Complete,
208        AgentStatus::Error { .. } | AgentStatus::Cancelled => StageRunStatus::Error,
209    }
210}
211
212/// The stringified region kind used in snapshots (matches the dashboard reader).
213fn region_kind_str(kind: &RegionKind) -> &'static str {
214    match kind {
215        RegionKind::Pinned => "pinned",
216        RegionKind::Temporary => "temporary",
217        RegionKind::Clearable => "clearable",
218        RegionKind::SlidingWindow { .. } => "sliding",
219        RegionKind::Compacting { .. } => "compacting",
220        RegionKind::CompactHistory { .. } => "history",
221        RegionKind::HashMap { .. } => "hashmap",
222        RegionKind::Checklist => "checklist",
223        RegionKind::Custom { .. } => "custom",
224    }
225}
226
227/// Build the full context snapshot (`context.json`) from a window. Pure over the
228/// window - no engine/entity. (Ported from the CLI's `build_context_snapshot`.)
229pub fn build_context_snapshot(window: &ContextWindow, stage_name: &str) -> ContextSnapshot {
230    let regions = window
231        .regions
232        .iter()
233        .map(|r| RegionSnapshot {
234            name: r.name.clone(),
235            kind: region_kind_str(&r.kind).to_string(),
236            current_tokens: r.current_tokens,
237            max_tokens: r.max_tokens,
238            entries: r
239                .content
240                .iter()
241                .enumerate()
242                .map(|(i, e)| RegionEntrySnapshot {
243                    content: e.content.clone(),
244                    tokens: e.tokens,
245                    kind: e.kind.clone(),
246                    metadata: e.metadata.clone(),
247                    key: e.key.clone(),
248                    // `None` when the region has no taint tracking (it is off,
249                    // or this is an older region): `Public`, which is what a
250                    // restore assumed anyway.
251                    taint: r
252                        .taint
253                        .as_ref()
254                        .and_then(|t| t.entry_taint(i))
255                        .unwrap_or_default(),
256                })
257                .collect(),
258        })
259        .collect();
260    ContextSnapshot {
261        stage_name: stage_name.to_string(),
262        total_tokens: window.current_tokens,
263        max_tokens: window.max_tokens,
264        regions,
265    }
266}
267
268/// The agent components `meta.json` is built from.
269///
270/// Held apart from [`RunPosition`] because these are read off the entity while
271/// the position is stamped onto it: one is what the agent *is*, the other is
272/// where it has got to.
273pub struct RunMetaSources<'a> {
274    /// The run's immutable metadata, fixed at spawn.
275    pub md: &'a RunMetadata,
276    /// The agent's live state.
277    pub state: &'a AgentState,
278    /// Token totals accumulated so far.
279    pub totals: &'a TokenTotals,
280    /// Outcome flags the blueprint's shape decides.
281    pub flags: &'a RunOutcomeFlags,
282    /// The submitted answer, when the run has produced one.
283    pub final_output: Option<&'a FinalOutput>,
284}
285
286/// Where the run has got to, and when.
287pub struct RunPosition {
288    /// Index of the stage the agent is in.
289    pub stage_index: usize,
290    /// The moment `updated_at` is stamped with.
291    pub now_secs: i64,
292    /// When the run last actually moved, as distinct from last being touched.
293    pub last_progress_at: Option<i64>,
294    /// How deep in the sub-agent tree this run sits.
295    pub depth: usize,
296    /// How deep the tree may go.
297    pub max_child_depth: usize,
298}
299
300/// Build the run metadata (`meta.json`) from an agent's live components, stamping
301/// `updated_at` with `now_secs`. `stage_index` is the agent's current stage
302/// position within its blueprint.
303///
304/// `last_progress_at` is the caller's separate record of when the run last
305/// actually moved, which is not the same as `now_secs`: this is called on the
306/// heartbeat too, and a heartbeat write must advance `updated_at` while leaving
307/// the progress stamp where it was. Taken as a plain `Option` rather than the
308/// watermark it comes from so this stays a data mapper with no dependency on the
309/// persistence pipeline.
310pub fn build_run_meta(sources: RunMetaSources<'_>, at: RunPosition) -> RunMeta {
311    let RunMetaSources {
312        md,
313        state,
314        totals,
315        flags,
316        final_output,
317    } = sources;
318    let RunPosition {
319        stage_index,
320        now_secs,
321        last_progress_at,
322        depth,
323        max_child_depth,
324    } = at;
325    let status = run_status_from(&state.status);
326    let mut flags = flags.0.clone();
327    // Having submitted an output is itself production, so this is settled before
328    // the emptiness verdict rather than after it.
329    flags.produced_output = final_output.is_some();
330    flags.empty_output = is_empty_output(&state.status, &flags);
331    RunMeta {
332        run_id: md.run_id.clone(),
333        agent_name: md.agent_name.clone(),
334        agent_path: md.agent_path.clone(),
335        task: md.task.clone(),
336        model: md.model.clone(),
337        pid: 0, // no per-run worker process in the shared world; see RunMeta::pid
338        status,
339        current_stage: state.current_stage.clone(),
340        stage_index,
341        num_stages: md.num_stages,
342        iteration: state.iteration,
343        prompt_tokens: totals.prompt_tokens,
344        completion_tokens: totals.completion_tokens,
345        cached_tokens: totals.cached_tokens,
346        cache_write_tokens: totals.cache_write_tokens,
347        tool_calls: totals.tool_calls,
348        workdir: md.workdir.clone(),
349        started_at: md.started_at,
350        updated_at: now_secs,
351        last_progress_at,
352        error: match &state.status {
353            AgentStatus::Error { message } => Some(message.clone()),
354            _ => None,
355        },
356        title: md.title.clone(),
357        metadata: md.metadata.clone(),
358        callback_url: md.callback_url.clone(),
359        callback_secret: md.callback_secret.clone(),
360        parent_run_id: md.parent_run_id.clone(),
361        // The tree links, so restart can rebuild the exact parent→children graph.
362        children: state.spawned_children_ids.clone(),
363        depth,
364        max_child_depth,
365        flags,
366        yolo: md.unattended,
367        read_paths: md.read_paths,
368        final_output: final_output.map(|o| o.0.descriptor()),
369        output_request: md.output_request.clone(),
370    }
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376    use leviath_core::Region;
377    use leviath_providers::TokenUsage;
378
379    fn state(status: AgentStatus) -> AgentState {
380        AgentState {
381            agent_id: "a".to_string(),
382            current_stage: "plan".to_string(),
383            iteration: 4,
384            status,
385            spawned_children_ids: vec![],
386            pending_wait: None,
387            accepts_messages: true,
388        }
389    }
390
391    fn metadata() -> RunMetadata {
392        RunMetadata {
393            run_id: "run-1".to_string(),
394            agent_name: "coder".to_string(),
395            agent_path: "/agents/coder".to_string(),
396            task: "do it".to_string(),
397            model: Some("anthropic/claude".to_string()),
398            workdir: "/work".to_string(),
399            num_stages: 3,
400            started_at: 1000,
401            parent_run_id: Some("parent".to_string()),
402            metadata: std::collections::HashMap::from([("k".to_string(), "v".to_string())]),
403            callback_url: Some("http://cb".to_string()),
404            callback_secret: Some("sekret".to_string()),
405            title: Some("Do It".to_string()),
406            unattended: false,
407            read_paths: None,
408            output_request: None,
409        }
410    }
411
412    /// A stage advertising `tools`, with `gate_tools` named by the gate on its
413    /// single outgoing edge. `gate_tools: None` gives the stage no transitions
414    /// at all, which is the other half of the `Option` the scan walks.
415    fn stage_with(tools: &[&str], gate_tools: Option<&[&str]>) -> leviath_core::Stage {
416        let mut stage = leviath_core::Stage::new(
417            "s".to_string(),
418            leviath_core::blueprint::ModelConfig::new("p".to_string(), "m".to_string()),
419        );
420        stage.available_tools = tools.iter().map(|t| (*t).to_string()).collect();
421        stage.transitions = gate_tools.map(|extra| {
422            let gate = (!extra.is_empty()).then(|| leviath_core::blueprint::TransitionGate {
423                require_modifications: true,
424                tools: extra.iter().map(|t| (*t).to_string()).collect(),
425                ..Default::default()
426            });
427            std::collections::HashMap::from([(
428                "next".to_string(),
429                leviath_core::blueprint::TransitionEdge {
430                    target: "next".to_string(),
431                    condition: leviath_core::blueprint::TransitionCondition::Always,
432                    hint: None,
433                    transform: leviath_core::blueprint::EdgeTransform::Direct,
434                    gate,
435                    stuck: None,
436                },
437            )])
438        });
439        stage
440    }
441
442    fn blueprint_of(stages: Vec<leviath_core::Stage>) -> leviath_core::Blueprint {
443        leviath_core::Blueprint::new(
444            "bp".to_string(),
445            "d".to_string(),
446            stages,
447            leviath_core::ContextLayout::new(vec![], 1000),
448        )
449    }
450
451    fn no_output_tools(stages: Vec<leviath_core::Stage>) -> bool {
452        RunOutcomeFlags::for_blueprint(&blueprint_of(stages))
453            .0
454            .no_output_tools
455    }
456
457    #[test]
458    fn for_blueprint_asks_whether_any_stage_could_have_written() {
459        // A blueprint with no stages at all offers nothing.
460        assert!(no_output_tools(vec![]));
461        // Read-only, and the sub-agent tools a router would use: nothing the
462        // framework tracks as a file change. This is the issue #192 case.
463        assert!(no_output_tools(vec![stage_with(
464            &["read_file", "spawn_agent", "context_write"],
465            None
466        )]));
467        // `shell` confers no tracked write: an agent editing through `sed -i`
468        // leaves no record, so silence from it stays suspicious rather than
469        // excused. The alias resolves, so `bash` is judged as `shell`.
470        assert!(no_output_tools(vec![stage_with(&["bash"], None)]));
471        // A built-in modifying tool, under either name.
472        assert!(!no_output_tools(vec![stage_with(&["write_file"], None)]));
473        assert!(!no_output_tools(vec![stage_with(&["edit_file"], None)]));
474        // Only one stage needs it.
475        assert!(!no_output_tools(vec![
476            stage_with(&["read_file"], None),
477            stage_with(&["write_file"], None),
478        ]));
479    }
480
481    #[test]
482    fn for_blueprint_honors_a_gate_declaring_its_own_write_tool() {
483        // An MCP/script write tool the stage advertises AND a gate names is a
484        // tracked write - the same escape hatch `stage_modifying_tools` gives.
485        assert!(!no_output_tools(vec![stage_with(
486            &["mcp__fs__put"],
487            Some(&["mcp__fs__put"])
488        )]));
489        // Declared by the gate but never advertised: the stage cannot call it.
490        assert!(no_output_tools(vec![stage_with(
491            &["read_file"],
492            Some(&["mcp__fs__put"])
493        )]));
494        // Transitions present, but no gate on the edge.
495        assert!(no_output_tools(vec![stage_with(&["read_file"], Some(&[]))]));
496        // A gate that names a tool unrelated to what the stage advertises.
497        assert!(no_output_tools(vec![stage_with(
498            &["mcp__fs__put"],
499            Some(&["mcp__other__put"])
500        )]));
501    }
502
503    #[test]
504    fn is_empty_output_needs_a_stopped_run_that_could_have_written() {
505        let nothing = leviath_core::run_meta::RunFlags::default();
506        // Running: it has not finished not-writing yet.
507        assert!(!is_empty_output(&AgentStatus::Active, &nothing));
508        assert!(!is_empty_output(&AgentStatus::Idle, &nothing));
509        assert!(!is_empty_output(&AgentStatus::Paused, &nothing));
510        assert!(!is_empty_output(&AgentStatus::Waiting, &nothing));
511        // Every way of stopping counts.
512        for status in [
513            AgentStatus::Complete,
514            AgentStatus::Cancelled,
515            AgentStatus::Error {
516                message: "x".to_string(),
517            },
518        ] {
519            assert!(is_empty_output(&status, &nothing));
520        }
521        // Wrote something.
522        let mut wrote = leviath_core::run_meta::RunFlags::default();
523        wrote.record_modification("src/a.rs");
524        assert!(!is_empty_output(&AgentStatus::Complete, &wrote));
525        // Had nothing to write with.
526        let incapable = leviath_core::run_meta::RunFlags {
527            no_output_tools: true,
528            ..Default::default()
529        };
530        assert!(!is_empty_output(&AgentStatus::Complete, &incapable));
531    }
532
533    #[test]
534    fn status_mapping_covers_all_variants() {
535        assert_eq!(run_status_from(&AgentStatus::Idle), RunStatus::Running);
536        assert_eq!(run_status_from(&AgentStatus::Active), RunStatus::Running);
537        assert_eq!(run_status_from(&AgentStatus::Paused), RunStatus::Paused);
538        assert_eq!(
539            run_status_from(&AgentStatus::Waiting),
540            RunStatus::WaitingInput
541        );
542        assert_eq!(run_status_from(&AgentStatus::Complete), RunStatus::Complete);
543        assert_eq!(
544            run_status_from(&AgentStatus::Error {
545                message: "x".to_string()
546            }),
547            RunStatus::Error
548        );
549        assert_eq!(
550            run_status_from(&AgentStatus::Cancelled),
551            RunStatus::Cancelled
552        );
553    }
554
555    #[test]
556    fn stage_status_mapping_covers_all_variants() {
557        use leviath_core::run_meta::StageRunStatus;
558        assert_eq!(
559            stage_status_from(&AgentStatus::Idle),
560            StageRunStatus::Active
561        );
562        assert_eq!(
563            stage_status_from(&AgentStatus::Active),
564            StageRunStatus::Active
565        );
566        assert_eq!(
567            stage_status_from(&AgentStatus::Paused),
568            StageRunStatus::Active
569        );
570        assert_eq!(
571            stage_status_from(&AgentStatus::Waiting),
572            StageRunStatus::WaitingInput
573        );
574        assert_eq!(
575            stage_status_from(&AgentStatus::Complete),
576            StageRunStatus::Complete
577        );
578        assert_eq!(
579            stage_status_from(&AgentStatus::Error {
580                message: "x".to_string()
581            }),
582            StageRunStatus::Error
583        );
584        assert_eq!(
585            stage_status_from(&AgentStatus::Cancelled),
586            StageRunStatus::Error
587        );
588    }
589
590    #[test]
591    fn token_totals_accumulate() {
592        let mut t = TokenTotals::default();
593        t.add_usage(&TokenUsage {
594            prompt_tokens: 10,
595            completion_tokens: 5,
596            total_tokens: 15,
597            cached_tokens: 2,
598            cache_write_tokens: 1,
599        });
600        t.add_usage(&TokenUsage {
601            prompt_tokens: 3,
602            completion_tokens: 4,
603            total_tokens: 7,
604            cached_tokens: 0,
605            cache_write_tokens: 0,
606        });
607        t.tool_calls = 6;
608        assert_eq!(t.prompt_tokens, 13);
609        assert_eq!(t.completion_tokens, 9);
610        assert_eq!(t.cached_tokens, 2);
611        assert_eq!(t.cache_write_tokens, 1);
612    }
613
614    #[test]
615    fn build_run_meta_fills_dynamic_and_static_fields() {
616        let md = metadata();
617        let totals = TokenTotals {
618            prompt_tokens: 100,
619            completion_tokens: 50,
620            cached_tokens: 10,
621            cache_write_tokens: 5,
622            tool_calls: 7,
623        };
624        let mut st = state(AgentStatus::Active);
625        st.spawned_children_ids = vec!["child-a".to_string(), "child-b".to_string()];
626        let meta = build_run_meta(
627            RunMetaSources {
628                md: &md,
629                state: &st,
630                totals: &totals,
631                flags: &RunOutcomeFlags::default(),
632                final_output: None,
633            },
634            RunPosition {
635                stage_index: 1,
636                now_secs: 2000,
637                last_progress_at: Some(1900),
638                depth: 1,
639                max_child_depth: 4,
640            },
641        );
642
643        assert_eq!(meta.run_id, "run-1");
644        assert_eq!(meta.status, RunStatus::Running);
645        assert_eq!(meta.current_stage, "plan");
646        assert_eq!(meta.stage_index, 1);
647        assert_eq!(meta.iteration, 4);
648        assert_eq!(meta.prompt_tokens, 100);
649        assert_eq!(meta.tool_calls, 7);
650        assert_eq!(meta.updated_at, 2000);
651        // The two stamps are independent: this snapshot was written at 2000, and
652        // the run last moved at 1900. A heartbeat write is exactly that shape.
653        assert_eq!(meta.last_progress_at, Some(1900));
654        assert_eq!(meta.parent_run_id.as_deref(), Some("parent"));
655        assert_eq!(meta.callback_url.as_deref(), Some("http://cb"));
656        assert_eq!(meta.callback_secret.as_deref(), Some("sekret"));
657        assert!(meta.error.is_none());
658        // The tree links are carried through from the agent's live state.
659        assert_eq!(
660            meta.children,
661            vec!["child-a".to_string(), "child-b".to_string()]
662        );
663        assert_eq!(meta.depth, 1);
664        assert_eq!(meta.max_child_depth, 4);
665        // Attended by default, so an ordinary run is never written as unattended.
666        assert!(!meta.yolo);
667    }
668
669    /// The snapshot carries `unattended` through to `meta.json`, which is what a
670    /// daemon restart reads back to resume the run the way it was launched.
671    #[test]
672    fn build_run_meta_records_an_unattended_run() {
673        let mut md = metadata();
674        md.unattended = true;
675        let meta = build_run_meta(
676            RunMetaSources {
677                md: &md,
678                state: &state(AgentStatus::Active),
679                totals: &TokenTotals::default(),
680                flags: &RunOutcomeFlags::default(),
681                final_output: None,
682            },
683            RunPosition {
684                stage_index: 1,
685                now_secs: 2000,
686                last_progress_at: None,
687                depth: 1,
688                max_child_depth: 4,
689            },
690        );
691        assert!(meta.yolo);
692    }
693
694    #[test]
695    fn build_run_meta_flags_empty_output_only_once_the_run_has_stopped() {
696        let mut flags = RunOutcomeFlags::default();
697        flags.0.gates_forced = 2;
698        // Still running with nothing written: not (yet) an empty run.
699        let running = build_run_meta(
700            RunMetaSources {
701                md: &metadata(),
702                state: &state(AgentStatus::Active),
703                totals: &TokenTotals::default(),
704                flags: &flags,
705                final_output: None,
706            },
707            RunPosition {
708                stage_index: 0,
709                now_secs: 1000,
710                last_progress_at: None,
711                depth: 0,
712                max_child_depth: 0,
713            },
714        );
715        assert!(!running.flags.empty_output);
716        assert_eq!(running.flags.gates_forced, 2);
717
718        // Finished with nothing written: that is the #107 signature.
719        for status in [
720            AgentStatus::Complete,
721            AgentStatus::Cancelled,
722            AgentStatus::Error {
723                message: "x".to_string(),
724            },
725        ] {
726            let meta = build_run_meta(
727                RunMetaSources {
728                    md: &metadata(),
729                    state: &state(status),
730                    totals: &TokenTotals::default(),
731                    flags: &flags,
732                    final_output: None,
733                },
734                RunPosition {
735                    stage_index: 0,
736                    now_secs: 1000,
737                    last_progress_at: None,
738                    depth: 0,
739                    max_child_depth: 0,
740                },
741            );
742            assert!(meta.flags.empty_output);
743        }
744
745        // Finished having written something: not empty.
746        let mut wrote = RunOutcomeFlags::default();
747        wrote.0.record_modification("src/a.rs");
748        let meta = build_run_meta(
749            RunMetaSources {
750                md: &metadata(),
751                state: &state(AgentStatus::Complete),
752                totals: &TokenTotals::default(),
753                flags: &wrote,
754                final_output: None,
755            },
756            RunPosition {
757                stage_index: 0,
758                now_secs: 1000,
759                last_progress_at: None,
760                depth: 0,
761                max_child_depth: 0,
762            },
763        );
764        assert!(!meta.flags.empty_output);
765        assert_eq!(meta.flags.modified_files, vec!["src/a.rs".to_string()]);
766
767        // Finished having written nothing, with nothing to write *with*: the
768        // framework has no basis to call this empty, so it doesn't (issue #192).
769        let mut incapable = RunOutcomeFlags::default();
770        incapable.0.no_output_tools = true;
771        let meta = build_run_meta(
772            RunMetaSources {
773                md: &metadata(),
774                state: &state(AgentStatus::Complete),
775                totals: &TokenTotals::default(),
776                flags: &incapable,
777                final_output: None,
778            },
779            RunPosition {
780                stage_index: 0,
781                now_secs: 1000,
782                last_progress_at: None,
783                depth: 0,
784                max_child_depth: 0,
785            },
786        );
787        assert!(!meta.flags.empty_output);
788        assert!(meta.flags.no_output_tools);
789    }
790
791    #[test]
792    fn build_run_meta_carries_error_message() {
793        let meta = build_run_meta(
794            RunMetaSources {
795                md: &metadata(),
796                state: &state(AgentStatus::Error {
797                    message: "boom".to_string(),
798                }),
799                totals: &TokenTotals::default(),
800                flags: &RunOutcomeFlags::default(),
801                final_output: None,
802            },
803            RunPosition {
804                stage_index: 2,
805                now_secs: 3000,
806                last_progress_at: None,
807                depth: 0,
808                max_child_depth: 0,
809            },
810        );
811        assert_eq!(meta.status, RunStatus::Error);
812        assert_eq!(meta.error.as_deref(), Some("boom"));
813    }
814
815    /// A submitted output reaches `meta.json` and settles the emptiness verdict.
816    ///
817    /// The second half is the point: an agent whose whole deliverable is its
818    /// answer modifies no files, and before `produced_output` existed every one
819    /// of its successful runs was reported `complete (no output)`.
820    #[test]
821    fn build_run_meta_carries_a_submitted_output_and_clears_the_empty_verdict() {
822        let submitted = FinalOutput(leviath_core::output::FinalOutput::new(
823            "Renamed two helpers and updated their callers.",
824            Some("markdown".to_string()),
825            "summary".to_string(),
826            1234,
827        ));
828        let meta = build_run_meta(
829            RunMetaSources {
830                md: &metadata(),
831                state: &state(AgentStatus::Complete),
832                totals: &TokenTotals::default(),
833                flags: &RunOutcomeFlags::default(),
834                final_output: Some(&submitted),
835            },
836            RunPosition {
837                stage_index: 0,
838                now_secs: 1000,
839                last_progress_at: None,
840                depth: 0,
841                max_child_depth: 0,
842            },
843        );
844        let carried = meta.final_output.expect("the submission reached meta.json");
845        // The descriptor, not the bytes: `meta.json` is parsed for every run on
846        // every listing, so the answer itself lives in a sidecar beside it.
847        assert_eq!(
848            carried.bytes,
849            "Renamed two helpers and updated their callers.".len()
850        );
851        assert_eq!(carried.format.as_deref(), Some("markdown"));
852        assert_eq!(carried.stage, "summary");
853        assert!(meta.flags.produced_output);
854        // Modified nothing, yet produced something: not an empty run.
855        assert!(!meta.flags.empty_output);
856    }
857
858    /// The same run without the submission is still judged empty, so the clause
859    /// above is doing the work rather than some other condition.
860    #[test]
861    fn a_run_that_submits_nothing_is_still_judged_empty() {
862        let meta = build_run_meta(
863            RunMetaSources {
864                md: &metadata(),
865                state: &state(AgentStatus::Complete),
866                totals: &TokenTotals::default(),
867                flags: &RunOutcomeFlags::default(),
868                final_output: None,
869            },
870            RunPosition {
871                stage_index: 0,
872                now_secs: 1000,
873                last_progress_at: None,
874                depth: 0,
875                max_child_depth: 0,
876            },
877        );
878        assert!(meta.final_output.is_none());
879        assert!(!meta.flags.produced_output);
880        assert!(meta.flags.empty_output);
881    }
882
883    #[test]
884    fn context_snapshot_captures_all_region_kinds() {
885        let mut w = ContextWindow::new(1000);
886        w.add_region(Region::new("pin".to_string(), RegionKind::Pinned, 100));
887        w.add_region(Region::new("tmp".to_string(), RegionKind::Temporary, 100));
888        w.add_region(Region::new("clr".to_string(), RegionKind::Clearable, 100));
889        w.add_region(Region::new(
890            "slide".to_string(),
891            RegionKind::SlidingWindow {
892                max_items: 5,
893                eviction_strategy: leviath_core::EvictionStrategy::PerItem,
894            },
895            100,
896        ));
897        w.add_region(Region::new(
898            "comp".to_string(),
899            RegionKind::Compacting {
900                threshold_tokens: 5,
901            },
902            100,
903        ));
904        w.add_region(Region::new(
905            "hist".to_string(),
906            RegionKind::CompactHistory {
907                source_region: "comp".to_string(),
908            },
909            100,
910        ));
911        w.add_region(Region::new(
912            "map".to_string(),
913            RegionKind::HashMap { max_entries: None },
914            100,
915        ));
916        w.add_region(Region::new(
917            "brain".to_string(),
918            RegionKind::Custom {
919                script: "b.rhai".to_string(),
920                persistent: false,
921            },
922            100,
923        ));
924        w.add_region(Region::new("todos".to_string(), RegionKind::Checklist, 100));
925        let _ = w.add_to_region("pin", "hello".to_string(), 3);
926        w.current_tokens = w.calculate_tokens();
927
928        let snap = build_context_snapshot(&w, "plan");
929
930        assert_eq!(snap.stage_name, "plan");
931        let kinds: Vec<&str> = snap.regions.iter().map(|r| r.kind.as_str()).collect();
932        assert_eq!(
933            kinds,
934            vec![
935                "pinned",
936                "temporary",
937                "clearable",
938                "sliding",
939                "compacting",
940                "history",
941                "hashmap",
942                "custom",
943                "checklist"
944            ]
945        );
946        // The pinned region's entry is captured.
947        let pin = snap.regions.iter().find(|r| r.name == "pin").unwrap();
948        assert_eq!(pin.entries.len(), 1);
949        assert_eq!(pin.entries[0].content, "hello");
950    }
951}