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}
50
51#[derive(Component, Clone, Copy, Default, Debug, PartialEq, Eq)]
54pub struct TokenTotals {
55 pub prompt_tokens: usize,
57 pub completion_tokens: usize,
59 pub cached_tokens: usize,
61 pub cache_write_tokens: usize,
63 pub tool_calls: usize,
65}
66
67#[derive(Component, Clone, Default, Debug, PartialEq)]
73pub struct RunOutcomeFlags(pub leviath_core::run_meta::RunFlags);
74
75impl TokenTotals {
76 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
85pub 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
96pub 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
108fn 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
122pub 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 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#[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 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, 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 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 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 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 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 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 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}