1use 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#[derive(Component, Clone)]
22pub struct RunMetadata {
23 pub run_id: String,
25 pub agent_name: String,
27 pub agent_path: String,
29 pub task: String,
31 pub model: Option<String>,
33 pub workdir: String,
35 pub num_stages: usize,
37 pub started_at: i64,
39 pub parent_run_id: Option<String>,
41 pub metadata: std::collections::HashMap<String, String>,
43 pub callback_url: Option<String>,
45 pub callback_secret: Option<String>,
47 pub title: Option<String>,
49 pub unattended: bool,
58 pub read_paths: Option<leviath_core::run_meta::ReadPathGrantCounts>,
64}
65
66#[derive(Component, Clone, Copy, Default, Debug, PartialEq, Eq)]
69pub struct TokenTotals {
70 pub prompt_tokens: usize,
72 pub completion_tokens: usize,
74 pub cached_tokens: usize,
76 pub cache_write_tokens: usize,
78 pub tool_calls: usize,
80}
81
82#[derive(Component, Clone, Default, Debug, PartialEq)]
88pub struct RunOutcomeFlags(pub leviath_core::run_meta::RunFlags);
89
90impl RunOutcomeFlags {
91 pub fn for_blueprint(bp: &leviath_core::Blueprint) -> Self {
103 Self(leviath_core::run_meta::RunFlags {
104 no_output_tools: !bp.stages.iter().any(stage_can_modify),
105 ..Default::default()
106 })
107 }
108}
109
110fn stage_can_modify(stage: &leviath_core::Stage) -> bool {
125 stage.available_tools.iter().any(|t| {
126 let canonical = leviath_tools::canonical_tool_name(t);
127 leviath_core::blueprint::MODIFYING_TOOLS.contains(&canonical)
128 || stage
129 .transitions
130 .iter()
131 .flat_map(|edges| edges.values())
132 .filter_map(|edge| edge.gate.as_ref())
133 .any(|gate| {
134 gate.tools
135 .iter()
136 .any(|extra| leviath_tools::canonical_tool_name(extra) == canonical)
137 })
138 })
139}
140
141impl TokenTotals {
142 pub fn add_usage(&mut self, usage: &leviath_providers::TokenUsage) {
144 self.prompt_tokens += usage.prompt_tokens;
145 self.completion_tokens += usage.completion_tokens;
146 self.cached_tokens += usage.cached_tokens;
147 self.cache_write_tokens += usage.cache_write_tokens;
148 }
149}
150
151pub fn is_empty_output(status: &AgentStatus, flags: &leviath_core::run_meta::RunFlags) -> bool {
164 matches!(
165 run_status_from(status),
166 RunStatus::Complete | RunStatus::Error | RunStatus::Cancelled
167 ) && flags.modified_file_count == 0
168 && !flags.no_output_tools
169}
170
171pub fn run_status_from(status: &AgentStatus) -> RunStatus {
173 match status {
174 AgentStatus::Idle | AgentStatus::Active => RunStatus::Running,
175 AgentStatus::Paused => RunStatus::Paused,
176 AgentStatus::Waiting => RunStatus::WaitingInput,
177 AgentStatus::Complete => RunStatus::Complete,
178 AgentStatus::Error { .. } => RunStatus::Error,
179 AgentStatus::Cancelled => RunStatus::Cancelled,
180 }
181}
182
183pub fn stage_status_from(status: &AgentStatus) -> StageRunStatus {
187 match status {
188 AgentStatus::Idle | AgentStatus::Active | AgentStatus::Paused => StageRunStatus::Active,
190 AgentStatus::Waiting => StageRunStatus::WaitingInput,
191 AgentStatus::Complete => StageRunStatus::Complete,
192 AgentStatus::Error { .. } | AgentStatus::Cancelled => StageRunStatus::Error,
193 }
194}
195
196fn region_kind_str(kind: &RegionKind) -> &'static str {
198 match kind {
199 RegionKind::Pinned => "pinned",
200 RegionKind::Temporary => "temporary",
201 RegionKind::Clearable => "clearable",
202 RegionKind::SlidingWindow { .. } => "sliding",
203 RegionKind::Compacting { .. } => "compacting",
204 RegionKind::CompactHistory { .. } => "history",
205 RegionKind::HashMap { .. } => "hashmap",
206 RegionKind::Custom { .. } => "custom",
207 }
208}
209
210pub fn build_context_snapshot(window: &ContextWindow, stage_name: &str) -> ContextSnapshot {
213 let regions = window
214 .regions
215 .iter()
216 .map(|r| RegionSnapshot {
217 name: r.name.clone(),
218 kind: region_kind_str(&r.kind).to_string(),
219 current_tokens: r.current_tokens,
220 max_tokens: r.max_tokens,
221 entries: r
222 .content
223 .iter()
224 .enumerate()
225 .map(|(i, e)| RegionEntrySnapshot {
226 content: e.content.clone(),
227 tokens: e.tokens,
228 kind: e.kind.clone(),
229 metadata: e.metadata.clone(),
230 key: e.key.clone(),
231 taint: r
235 .taint
236 .as_ref()
237 .and_then(|t| t.entry_taint(i))
238 .unwrap_or_default(),
239 })
240 .collect(),
241 })
242 .collect();
243 ContextSnapshot {
244 stage_name: stage_name.to_string(),
245 total_tokens: window.current_tokens,
246 max_tokens: window.max_tokens,
247 regions,
248 }
249}
250
251#[allow(clippy::too_many_arguments)]
262pub fn build_run_meta(
263 md: &RunMetadata,
264 state: &AgentState,
265 totals: &TokenTotals,
266 flags: &RunOutcomeFlags,
267 stage_index: usize,
268 now_secs: i64,
269 last_progress_at: Option<i64>,
270 depth: usize,
271 max_child_depth: usize,
272) -> RunMeta {
273 let status = run_status_from(&state.status);
274 let mut flags = flags.0.clone();
275 flags.empty_output = is_empty_output(&state.status, &flags);
276 RunMeta {
277 run_id: md.run_id.clone(),
278 agent_name: md.agent_name.clone(),
279 agent_path: md.agent_path.clone(),
280 task: md.task.clone(),
281 model: md.model.clone(),
282 pid: 0, status,
284 current_stage: state.current_stage.clone(),
285 stage_index,
286 num_stages: md.num_stages,
287 iteration: state.iteration,
288 prompt_tokens: totals.prompt_tokens,
289 completion_tokens: totals.completion_tokens,
290 cached_tokens: totals.cached_tokens,
291 cache_write_tokens: totals.cache_write_tokens,
292 tool_calls: totals.tool_calls,
293 workdir: md.workdir.clone(),
294 started_at: md.started_at,
295 updated_at: now_secs,
296 last_progress_at,
297 error: match &state.status {
298 AgentStatus::Error { message } => Some(message.clone()),
299 _ => None,
300 },
301 title: md.title.clone(),
302 metadata: md.metadata.clone(),
303 callback_url: md.callback_url.clone(),
304 callback_secret: md.callback_secret.clone(),
305 parent_run_id: md.parent_run_id.clone(),
306 children: state.spawned_children_ids.clone(),
308 depth,
309 max_child_depth,
310 flags,
311 yolo: md.unattended,
312 read_paths: md.read_paths,
313 }
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319 use leviath_core::Region;
320 use leviath_providers::TokenUsage;
321
322 fn state(status: AgentStatus) -> AgentState {
323 AgentState {
324 agent_id: "a".to_string(),
325 current_stage: "plan".to_string(),
326 iteration: 4,
327 status,
328 spawned_children_ids: vec![],
329 pending_wait: None,
330 accepts_messages: true,
331 }
332 }
333
334 fn metadata() -> RunMetadata {
335 RunMetadata {
336 run_id: "run-1".to_string(),
337 agent_name: "coder".to_string(),
338 agent_path: "/agents/coder".to_string(),
339 task: "do it".to_string(),
340 model: Some("anthropic/claude".to_string()),
341 workdir: "/work".to_string(),
342 num_stages: 3,
343 started_at: 1000,
344 parent_run_id: Some("parent".to_string()),
345 metadata: std::collections::HashMap::from([("k".to_string(), "v".to_string())]),
346 callback_url: Some("http://cb".to_string()),
347 callback_secret: Some("sekret".to_string()),
348 title: Some("Do It".to_string()),
349 unattended: false,
350 read_paths: None,
351 }
352 }
353
354 fn stage_with(tools: &[&str], gate_tools: Option<&[&str]>) -> leviath_core::Stage {
358 let mut stage = leviath_core::Stage::new(
359 "s".to_string(),
360 leviath_core::blueprint::ModelConfig::new("p".to_string(), "m".to_string()),
361 );
362 stage.available_tools = tools.iter().map(|t| (*t).to_string()).collect();
363 stage.transitions = gate_tools.map(|extra| {
364 let gate = (!extra.is_empty()).then(|| leviath_core::blueprint::TransitionGate {
365 require_modifications: true,
366 tools: extra.iter().map(|t| (*t).to_string()).collect(),
367 ..Default::default()
368 });
369 std::collections::HashMap::from([(
370 "next".to_string(),
371 leviath_core::blueprint::TransitionEdge {
372 target: "next".to_string(),
373 condition: leviath_core::blueprint::TransitionCondition::Always,
374 hint: None,
375 transform: leviath_core::blueprint::EdgeTransform::Direct,
376 gate,
377 stuck: None,
378 },
379 )])
380 });
381 stage
382 }
383
384 fn blueprint_of(stages: Vec<leviath_core::Stage>) -> leviath_core::Blueprint {
385 leviath_core::Blueprint::new(
386 "bp".to_string(),
387 "d".to_string(),
388 stages,
389 leviath_core::ContextLayout::new(vec![], 1000),
390 )
391 }
392
393 fn no_output_tools(stages: Vec<leviath_core::Stage>) -> bool {
394 RunOutcomeFlags::for_blueprint(&blueprint_of(stages))
395 .0
396 .no_output_tools
397 }
398
399 #[test]
400 fn for_blueprint_asks_whether_any_stage_could_have_written() {
401 assert!(no_output_tools(vec![]));
403 assert!(no_output_tools(vec![stage_with(
406 &["read_file", "spawn_agent", "context_write"],
407 None
408 )]));
409 assert!(no_output_tools(vec![stage_with(&["bash"], None)]));
413 assert!(!no_output_tools(vec![stage_with(&["write_file"], None)]));
415 assert!(!no_output_tools(vec![stage_with(&["edit_file"], None)]));
416 assert!(!no_output_tools(vec![
418 stage_with(&["read_file"], None),
419 stage_with(&["write_file"], None),
420 ]));
421 }
422
423 #[test]
424 fn for_blueprint_honors_a_gate_declaring_its_own_write_tool() {
425 assert!(!no_output_tools(vec![stage_with(
428 &["mcp__fs__put"],
429 Some(&["mcp__fs__put"])
430 )]));
431 assert!(no_output_tools(vec![stage_with(
433 &["read_file"],
434 Some(&["mcp__fs__put"])
435 )]));
436 assert!(no_output_tools(vec![stage_with(&["read_file"], Some(&[]))]));
438 assert!(no_output_tools(vec![stage_with(
440 &["mcp__fs__put"],
441 Some(&["mcp__other__put"])
442 )]));
443 }
444
445 #[test]
446 fn is_empty_output_needs_a_stopped_run_that_could_have_written() {
447 let nothing = leviath_core::run_meta::RunFlags::default();
448 assert!(!is_empty_output(&AgentStatus::Active, ¬hing));
450 assert!(!is_empty_output(&AgentStatus::Idle, ¬hing));
451 assert!(!is_empty_output(&AgentStatus::Paused, ¬hing));
452 assert!(!is_empty_output(&AgentStatus::Waiting, ¬hing));
453 for status in [
455 AgentStatus::Complete,
456 AgentStatus::Cancelled,
457 AgentStatus::Error {
458 message: "x".to_string(),
459 },
460 ] {
461 assert!(is_empty_output(&status, ¬hing));
462 }
463 let mut wrote = leviath_core::run_meta::RunFlags::default();
465 wrote.record_modification("src/a.rs");
466 assert!(!is_empty_output(&AgentStatus::Complete, &wrote));
467 let incapable = leviath_core::run_meta::RunFlags {
469 no_output_tools: true,
470 ..Default::default()
471 };
472 assert!(!is_empty_output(&AgentStatus::Complete, &incapable));
473 }
474
475 #[test]
476 fn status_mapping_covers_all_variants() {
477 assert_eq!(run_status_from(&AgentStatus::Idle), RunStatus::Running);
478 assert_eq!(run_status_from(&AgentStatus::Active), RunStatus::Running);
479 assert_eq!(run_status_from(&AgentStatus::Paused), RunStatus::Paused);
480 assert_eq!(
481 run_status_from(&AgentStatus::Waiting),
482 RunStatus::WaitingInput
483 );
484 assert_eq!(run_status_from(&AgentStatus::Complete), RunStatus::Complete);
485 assert_eq!(
486 run_status_from(&AgentStatus::Error {
487 message: "x".to_string()
488 }),
489 RunStatus::Error
490 );
491 assert_eq!(
492 run_status_from(&AgentStatus::Cancelled),
493 RunStatus::Cancelled
494 );
495 }
496
497 #[test]
498 fn stage_status_mapping_covers_all_variants() {
499 use leviath_core::run_meta::StageRunStatus;
500 assert_eq!(
501 stage_status_from(&AgentStatus::Idle),
502 StageRunStatus::Active
503 );
504 assert_eq!(
505 stage_status_from(&AgentStatus::Active),
506 StageRunStatus::Active
507 );
508 assert_eq!(
509 stage_status_from(&AgentStatus::Paused),
510 StageRunStatus::Active
511 );
512 assert_eq!(
513 stage_status_from(&AgentStatus::Waiting),
514 StageRunStatus::WaitingInput
515 );
516 assert_eq!(
517 stage_status_from(&AgentStatus::Complete),
518 StageRunStatus::Complete
519 );
520 assert_eq!(
521 stage_status_from(&AgentStatus::Error {
522 message: "x".to_string()
523 }),
524 StageRunStatus::Error
525 );
526 assert_eq!(
527 stage_status_from(&AgentStatus::Cancelled),
528 StageRunStatus::Error
529 );
530 }
531
532 #[test]
533 fn token_totals_accumulate() {
534 let mut t = TokenTotals::default();
535 t.add_usage(&TokenUsage {
536 prompt_tokens: 10,
537 completion_tokens: 5,
538 total_tokens: 15,
539 cached_tokens: 2,
540 cache_write_tokens: 1,
541 });
542 t.add_usage(&TokenUsage {
543 prompt_tokens: 3,
544 completion_tokens: 4,
545 total_tokens: 7,
546 cached_tokens: 0,
547 cache_write_tokens: 0,
548 });
549 t.tool_calls = 6;
550 assert_eq!(t.prompt_tokens, 13);
551 assert_eq!(t.completion_tokens, 9);
552 assert_eq!(t.cached_tokens, 2);
553 assert_eq!(t.cache_write_tokens, 1);
554 }
555
556 #[test]
557 fn build_run_meta_fills_dynamic_and_static_fields() {
558 let md = metadata();
559 let totals = TokenTotals {
560 prompt_tokens: 100,
561 completion_tokens: 50,
562 cached_tokens: 10,
563 cache_write_tokens: 5,
564 tool_calls: 7,
565 };
566 let mut st = state(AgentStatus::Active);
567 st.spawned_children_ids = vec!["child-a".to_string(), "child-b".to_string()];
568 let meta = build_run_meta(
569 &md,
570 &st,
571 &totals,
572 &RunOutcomeFlags::default(),
573 1,
574 2000,
575 Some(1900),
576 1,
577 4,
578 );
579
580 assert_eq!(meta.run_id, "run-1");
581 assert_eq!(meta.status, RunStatus::Running);
582 assert_eq!(meta.current_stage, "plan");
583 assert_eq!(meta.stage_index, 1);
584 assert_eq!(meta.iteration, 4);
585 assert_eq!(meta.prompt_tokens, 100);
586 assert_eq!(meta.tool_calls, 7);
587 assert_eq!(meta.updated_at, 2000);
588 assert_eq!(meta.last_progress_at, Some(1900));
591 assert_eq!(meta.parent_run_id.as_deref(), Some("parent"));
592 assert_eq!(meta.callback_url.as_deref(), Some("http://cb"));
593 assert_eq!(meta.callback_secret.as_deref(), Some("sekret"));
594 assert!(meta.error.is_none());
595 assert_eq!(
597 meta.children,
598 vec!["child-a".to_string(), "child-b".to_string()]
599 );
600 assert_eq!(meta.depth, 1);
601 assert_eq!(meta.max_child_depth, 4);
602 assert!(!meta.yolo);
604 }
605
606 #[test]
609 fn build_run_meta_records_an_unattended_run() {
610 let mut md = metadata();
611 md.unattended = true;
612 let meta = build_run_meta(
613 &md,
614 &state(AgentStatus::Active),
615 &TokenTotals::default(),
616 &RunOutcomeFlags::default(),
617 1,
618 2000,
619 None,
620 1,
621 4,
622 );
623 assert!(meta.yolo);
624 }
625
626 #[test]
627 fn build_run_meta_flags_empty_output_only_once_the_run_has_stopped() {
628 let mut flags = RunOutcomeFlags::default();
629 flags.0.gates_forced = 2;
630 let running = build_run_meta(
632 &metadata(),
633 &state(AgentStatus::Active),
634 &TokenTotals::default(),
635 &flags,
636 0,
637 1000,
638 None,
639 0,
640 0,
641 );
642 assert!(!running.flags.empty_output);
643 assert_eq!(running.flags.gates_forced, 2);
644
645 for status in [
647 AgentStatus::Complete,
648 AgentStatus::Cancelled,
649 AgentStatus::Error {
650 message: "x".to_string(),
651 },
652 ] {
653 let meta = build_run_meta(
654 &metadata(),
655 &state(status),
656 &TokenTotals::default(),
657 &flags,
658 0,
659 1000,
660 None,
661 0,
662 0,
663 );
664 assert!(meta.flags.empty_output);
665 }
666
667 let mut wrote = RunOutcomeFlags::default();
669 wrote.0.record_modification("src/a.rs");
670 let meta = build_run_meta(
671 &metadata(),
672 &state(AgentStatus::Complete),
673 &TokenTotals::default(),
674 &wrote,
675 0,
676 1000,
677 None,
678 0,
679 0,
680 );
681 assert!(!meta.flags.empty_output);
682 assert_eq!(meta.flags.modified_files, vec!["src/a.rs".to_string()]);
683
684 let mut incapable = RunOutcomeFlags::default();
687 incapable.0.no_output_tools = true;
688 let meta = build_run_meta(
689 &metadata(),
690 &state(AgentStatus::Complete),
691 &TokenTotals::default(),
692 &incapable,
693 0,
694 1000,
695 None,
696 0,
697 0,
698 );
699 assert!(!meta.flags.empty_output);
700 assert!(meta.flags.no_output_tools);
701 }
702
703 #[test]
704 fn build_run_meta_carries_error_message() {
705 let meta = build_run_meta(
706 &metadata(),
707 &state(AgentStatus::Error {
708 message: "boom".to_string(),
709 }),
710 &TokenTotals::default(),
711 &RunOutcomeFlags::default(),
712 2,
713 3000,
714 None,
715 0,
716 0,
717 );
718 assert_eq!(meta.status, RunStatus::Error);
719 assert_eq!(meta.error.as_deref(), Some("boom"));
720 }
721
722 #[test]
723 fn context_snapshot_captures_all_region_kinds() {
724 let mut w = ContextWindow::new(1000);
725 w.add_region(Region::new("pin".to_string(), RegionKind::Pinned, 100));
726 w.add_region(Region::new("tmp".to_string(), RegionKind::Temporary, 100));
727 w.add_region(Region::new("clr".to_string(), RegionKind::Clearable, 100));
728 w.add_region(Region::new(
729 "slide".to_string(),
730 RegionKind::SlidingWindow {
731 max_items: 5,
732 eviction_strategy: leviath_core::EvictionStrategy::PerItem,
733 },
734 100,
735 ));
736 w.add_region(Region::new(
737 "comp".to_string(),
738 RegionKind::Compacting {
739 threshold_tokens: 5,
740 },
741 100,
742 ));
743 w.add_region(Region::new(
744 "hist".to_string(),
745 RegionKind::CompactHistory {
746 source_region: "comp".to_string(),
747 },
748 100,
749 ));
750 w.add_region(Region::new(
751 "map".to_string(),
752 RegionKind::HashMap { max_entries: None },
753 100,
754 ));
755 w.add_region(Region::new(
756 "brain".to_string(),
757 RegionKind::Custom {
758 script: "b.rhai".to_string(),
759 persistent: false,
760 },
761 100,
762 ));
763 let _ = w.add_to_region("pin", "hello".to_string(), 3);
764 w.current_tokens = w.calculate_tokens();
765
766 let snap = build_context_snapshot(&w, "plan");
767
768 assert_eq!(snap.stage_name, "plan");
769 let kinds: Vec<&str> = snap.regions.iter().map(|r| r.kind.as_str()).collect();
770 assert_eq!(
771 kinds,
772 vec![
773 "pinned",
774 "temporary",
775 "clearable",
776 "sliding",
777 "compacting",
778 "history",
779 "hashmap",
780 "custom"
781 ]
782 );
783 let pin = snap.regions.iter().find(|r| r.name == "pin").unwrap();
785 assert_eq!(pin.entries.len(), 1);
786 assert_eq!(pin.entries[0].content, "hello");
787 }
788}