1use std::collections::VecDeque;
20use std::sync::Arc;
21
22use bevy_ecs::prelude::*;
23use leviath_core::blueprint::{FanOutConfig, StageMode, WorkerFailurePolicy};
24
25use crate::components::{
26 AgentState, AgentStatus, ContextWindow, InferenceResult, ParentRef, SubAgentChildren,
27};
28use crate::pipeline::{AgentBlueprint, ProcessResponse, ResolveTransition, StageCursor};
29
30const DEFAULT_FANOUT_DEPTH: usize = 3;
32
33#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Default)]
35pub struct WorkItem {
36 #[serde(default)]
38 pub id: String,
39 #[serde(default)]
41 pub context: serde_json::Value,
42}
43
44#[expect(
47 clippy::string_slice,
48 reason = "`s` and `e` come from `find`/`rfind` on the ASCII '[' and ']', so both are char \
49 boundaries and the inclusive range ends on the last byte of ']'"
50)]
51pub fn parse_work_items(content: &str) -> Result<Vec<WorkItem>, String> {
52 let trimmed = content.trim();
53 let slice = match (trimmed.find('['), trimmed.rfind(']')) {
54 (Some(s), Some(e)) if e > s => &trimmed[s..=e],
55 _ => return Err("split output is not a JSON array".to_string()),
56 };
57 serde_json::from_str(slice)
58 .map_err(|e| format!("split output is not a valid JSON array of work items: {e}"))
59}
60
61pub trait FanOutSpawner: Send + Sync {
67 fn spawn_worker(
70 &self,
71 world: &mut World,
72 parent: Entity,
73 config: &FanOutConfig,
74 item_id: &str,
75 item_context: &serde_json::Value,
76 ) -> Result<Entity, String>;
77}
78
79#[derive(Resource, Clone)]
82pub struct FanOutSpawnerRes(pub Arc<dyn FanOutSpawner>);
83
84struct ActiveWorker {
88 item_id: String,
89 entity: Entity,
90 run_id: String,
91}
92
93#[derive(Component)]
96pub struct FanOutWaiting {
97 config: FanOutConfig,
98 max_workers: usize,
99 pending: VecDeque<WorkItem>,
100 active: Vec<ActiveWorker>,
101 summaries: Vec<(String, String)>,
102 failures: Vec<(String, String)>,
103}
104
105#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
110pub struct FanOutState {
111 pub config: FanOutConfig,
113 pub max_workers: usize,
115 pub pending: Vec<WorkItem>,
117 pub active: Vec<(String, String)>,
119 pub summaries: Vec<(String, String)>,
121 pub failures: Vec<(String, String)>,
123}
124
125impl FanOutWaiting {
126 pub(crate) fn to_state(&self) -> FanOutState {
128 FanOutState {
129 config: self.config.clone(),
130 max_workers: self.max_workers,
131 pending: self.pending.iter().cloned().collect(),
132 active: self
133 .active
134 .iter()
135 .map(|w| (w.item_id.clone(), w.run_id.clone()))
136 .collect(),
137 summaries: self.summaries.clone(),
138 failures: self.failures.clone(),
139 }
140 }
141}
142
143pub fn restore_fan_out_waiting(
149 world: &mut World,
150 parent: Entity,
151 state: FanOutState,
152 resolve: &dyn Fn(&str) -> Option<Entity>,
153) {
154 let mut active = Vec::new();
155 let mut failures = state.failures;
156 for (item_id, run_id) in state.active {
157 match resolve(&run_id) {
158 Some(entity) => active.push(ActiveWorker {
159 item_id,
160 entity,
161 run_id,
162 }),
163 None => failures.push((item_id, "worker did not reload after restart".to_string())),
164 }
165 }
166 world.entity_mut(parent).insert(FanOutWaiting {
167 config: state.config,
168 max_workers: state.max_workers,
169 pending: state.pending.into_iter().collect(),
170 active,
171 summaries: state.summaries,
172 failures,
173 });
174}
175
176pub fn fan_out_split(world: &mut World) {
182 crate::tick_scope::clear();
183 let mut candidates: Vec<(Entity, String, FanOutConfig)> = Vec::new();
184 {
185 let mut q = world.query_filtered::<(
186 Entity,
187 &AgentState,
188 &AgentBlueprint,
189 &StageCursor,
190 &InferenceResult,
191 ), With<ProcessResponse>>();
192 for (entity, state, bp, cursor, infer) in q.iter(world) {
193 if state.status != AgentStatus::Active {
194 continue;
195 }
196 if let StageMode::FanOut { config } = &bp.0.stages[cursor.index].mode {
197 candidates.push((entity, infer.response.clone(), config.clone()));
198 }
199 }
200 }
201
202 for (parent, response, config) in candidates {
203 crate::tick_scope::enter(parent);
204 world
205 .entity_mut(parent)
206 .remove::<ProcessResponse>()
207 .remove::<InferenceResult>();
208 match parse_work_items(&response) {
209 Ok(items) => {
210 let max_workers = config.max_workers.max(1);
211 world.entity_mut(parent).insert(FanOutWaiting {
212 config,
213 max_workers,
214 pending: items.into_iter().collect(),
215 active: Vec::new(),
216 summaries: Vec::new(),
217 failures: Vec::new(),
218 });
219 set_status(world, parent, AgentStatus::Waiting);
220 }
221 Err(message) => {
222 set_status(
223 world,
224 parent,
225 AgentStatus::Error {
226 message: format!("fan_out split failed: {message}"),
227 },
228 );
229 }
230 }
231 }
232}
233
234pub fn fan_out_collect(world: &mut World) {
239 crate::tick_scope::clear();
240 let parents: Vec<Entity> = {
241 let mut q = world.query_filtered::<Entity, With<FanOutWaiting>>();
242 q.iter(world).collect()
243 };
244
245 for parent in parents {
246 crate::tick_scope::enter(parent);
247 if !matches!(agent_status(world, parent), Some(AgentStatus::Waiting)) {
250 world.entity_mut(parent).remove::<FanOutWaiting>();
251 continue;
252 }
253 let mut w = world
256 .entity_mut(parent)
257 .take::<FanOutWaiting>()
258 .expect("a Waiting fan-out parent still holds FanOutWaiting");
259
260 let mut still_active = Vec::with_capacity(w.active.len());
262 for aw in std::mem::take(&mut w.active) {
263 match worker_terminal_result(world, aw.entity) {
264 Some(Ok(content)) => w.summaries.push((aw.item_id, content)),
265 Some(Err(message)) => w.failures.push((aw.item_id, message)),
266 None => still_active.push(aw),
267 }
268 }
269 w.active = still_active;
270
271 while w.active.len() < w.max_workers {
273 let Some(item) = w.pending.pop_front() else {
274 break;
275 };
276 match start_worker(world, parent, &w.config, &item) {
277 Ok(child) => {
278 let run_id = world
280 .get::<crate::persistence::RunMetadata>(child)
281 .map(|m| m.run_id.clone())
282 .unwrap_or_default();
283 w.active.push(ActiveWorker {
284 item_id: item.id,
285 entity: child,
286 run_id,
287 });
288 }
289 Err(message) => w.failures.push((item.id, message)),
290 }
291 }
292
293 if w.active.is_empty() && w.pending.is_empty() {
295 finish_fan_out(world, parent, w);
296 } else {
297 world.entity_mut(parent).insert(w);
298 }
299 }
300}
301
302fn finish_fan_out(world: &mut World, parent: Entity, w: FanOutWaiting) {
304 if !w.failures.is_empty() && w.config.on_worker_failure == WorkerFailurePolicy::FailAll {
305 set_status(
306 world,
307 parent,
308 AgentStatus::Error {
309 message: format!(
310 "fan_out: {} worker(s) failed (on_worker_failure = fail_all)",
311 w.failures.len()
312 ),
313 },
314 );
315 return;
316 }
317
318 let report = build_report(&w.summaries, &w.failures);
319 inject_conversation(world, parent, &report);
320
321 set_status(world, parent, AgentStatus::Active);
324 match w.config.merge_stage.as_deref().and_then(|name| {
325 world
326 .get::<AgentBlueprint>(parent)
327 .and_then(|bp| bp.0.stages.iter().position(|s| s.name == name))
328 }) {
329 Some(idx) => crate::pipeline::force_transition(world, parent, idx),
330 None => {
331 world.entity_mut(parent).insert(ResolveTransition);
332 }
333 }
334}
335
336fn start_worker(
339 world: &mut World,
340 parent: Entity,
341 config: &FanOutConfig,
342 item: &WorkItem,
343) -> Result<Entity, String> {
344 let max_depth = world
345 .get::<SubAgentChildren>(parent)
346 .map(|k| k.max_child_depth)
347 .or_else(|| {
348 world
349 .get::<AgentBlueprint>(parent)
350 .and_then(|bp| bp.0.max_child_depth)
351 })
352 .unwrap_or(DEFAULT_FANOUT_DEPTH);
353 let parent_depth = world.get::<ParentRef>(parent).map_or(0, |p| p.depth);
354 let child_depth = parent_depth + 1;
355 if child_depth > max_depth {
356 return Err(format!(
357 "fan-out worker depth limit ({max_depth}) reached; not spawning"
358 ));
359 }
360
361 let spawner = world
362 .get_resource::<FanOutSpawnerRes>()
363 .map(|r| r.0.clone())
364 .ok_or_else(|| "no fan-out spawner installed".to_string())?;
365 let child = spawner.spawn_worker(world, parent, config, &item.id, &item.context)?;
366
367 let parent_agent_id = world
368 .get::<AgentState>(parent)
369 .map(|s| s.agent_id.clone())
370 .unwrap_or_default();
371 world.entity_mut(child).insert(ParentRef {
372 parent_entity: parent,
373 parent_agent_id,
374 depth: child_depth,
375 });
376 match world.get_mut::<SubAgentChildren>(parent) {
377 Some(mut kids) => kids.children.push(child),
378 None => {
379 world.entity_mut(parent).insert(SubAgentChildren {
380 children: vec![child],
381 max_child_depth: max_depth,
382 });
383 }
384 }
385 let worker_id = world
389 .get::<crate::persistence::RunMetadata>(child)
390 .expect("a fan-out worker always has run metadata")
391 .run_id
392 .clone();
393 world
394 .get_mut::<AgentState>(parent)
395 .expect("a fan-out parent always has AgentState")
396 .spawned_children_ids
397 .push(worker_id);
398 crate::context_transform::apply_context_transforms(world, parent, child);
401 Ok(child)
402}
403
404fn worker_terminal_result(world: &World, worker: Entity) -> Option<Result<String, String>> {
408 match agent_status(world, worker) {
409 None => Some(Err("worker vanished".to_string())),
410 Some(AgentStatus::Complete) => {
411 let content = world
412 .get::<InferenceResult>(worker)
413 .map(|r| r.response.clone())
414 .unwrap_or_default();
415 Some(Ok(content))
416 }
417 Some(AgentStatus::Error { message }) => Some(Err(message)),
418 Some(AgentStatus::Cancelled) => Some(Err("worker cancelled".to_string())),
419 Some(_) => None,
420 }
421}
422
423fn build_report(summaries: &[(String, String)], failures: &[(String, String)]) -> String {
425 let mut report = format!(
426 "[fan_out results: {} succeeded, {} failed]\n",
427 summaries.len(),
428 failures.len()
429 );
430 for (id, content) in summaries {
431 report.push_str(&format!("\n## worker {id}\n{content}\n"));
432 }
433 for (id, err) in failures {
434 report.push_str(&format!("\n## worker {id} FAILED\n{err}\n"));
435 }
436 report
437}
438
439fn inject_conversation(world: &mut World, parent: Entity, text: &str) {
441 if let Some(mut window) = world.get_mut::<ContextWindow>(parent) {
442 let tokens = leviath_core::estimate_tokens(text);
443 let _ = window.add_typed_entry(
444 "conversation",
445 leviath_core::EntryKind::UserMessage,
446 text.to_string(),
447 tokens,
448 );
449 }
450}
451
452fn agent_status(world: &World, entity: Entity) -> Option<AgentStatus> {
454 world.get::<AgentState>(entity).map(|s| s.status.clone())
455}
456
457fn set_status(world: &mut World, entity: Entity, status: AgentStatus) {
459 if let Some(mut state) = world.get_mut::<AgentState>(entity) {
460 state.status = status;
461 }
462}
463
464#[cfg(test)]
465mod tests {
466 use super::*;
467 use crate::components::{InferenceConfig, ToolResultRoutingComponent};
468 use crate::pipeline::{
469 ReadyToInfer, StageInference, StageInferences, StageProgress, StageSetup, StageSetups,
470 VisitCounts,
471 };
472 use leviath_core::blueprint::{ModelConfig, Stage};
473 use leviath_core::layout::{ContextLayout, RegionDefinition};
474 use leviath_core::{Blueprint, Region, RegionKind};
475 use std::collections::HashSet;
476
477 struct TestSpawner {
480 fail: HashSet<String>,
481 }
482
483 impl TestSpawner {
484 fn ok() -> Arc<dyn FanOutSpawner> {
485 Arc::new(TestSpawner {
486 fail: HashSet::new(),
487 })
488 }
489 fn refusing(ids: &[&str]) -> Arc<dyn FanOutSpawner> {
490 Arc::new(TestSpawner {
491 fail: ids.iter().map(|s| s.to_string()).collect(),
492 })
493 }
494 }
495
496 impl FanOutSpawner for TestSpawner {
497 fn spawn_worker(
498 &self,
499 world: &mut World,
500 _parent: Entity,
501 _config: &FanOutConfig,
502 item_id: &str,
503 _item_context: &serde_json::Value,
504 ) -> Result<Entity, String> {
505 if self.fail.contains(item_id) {
506 return Err(format!("spawn refused for '{item_id}'"));
507 }
508 Ok(world
509 .spawn((
510 AgentState {
511 agent_id: format!("worker-{item_id}"),
512 current_stage: "w".to_string(),
513 iteration: 0,
514 status: AgentStatus::Active,
515 spawned_children_ids: vec![],
516 pending_wait: None,
517 accepts_messages: true,
518 },
519 crate::persistence::RunMetadata {
522 run_id: format!("run-{item_id}"),
523 agent_name: "worker".to_string(),
524 agent_path: String::new(),
525 task: String::new(),
526 model: None,
527 workdir: String::new(),
528 num_stages: 1,
529 started_at: 0,
530 parent_run_id: None,
531 metadata: std::collections::HashMap::new(),
532 callback_url: None,
533 callback_secret: None,
534 title: None,
535 },
536 ))
537 .id())
538 }
539 }
540
541 fn cfg(merge: Option<&str>, max_workers: usize, policy: WorkerFailurePolicy) -> FanOutConfig {
542 FanOutConfig {
543 worker_agent: None,
544 worker_stage: Some("w".to_string()),
545 worker_query: None,
546 merge_stage: merge.map(String::from),
547 max_workers,
548 on_worker_failure: policy,
549 split_prompt: "split".to_string(),
550 }
551 }
552
553 fn window() -> ContextWindow {
554 let mut w = ContextWindow::new(12_000);
555 w.add_region(Region::new(
556 "conversation".to_string(),
557 RegionKind::Clearable,
558 10_000,
559 ));
560 w
561 }
562
563 fn stage_inf() -> StageInference {
564 StageInference {
565 provider_name: "script".to_string(),
566 model: "m".to_string(),
567 tools: vec![],
568 tool_filter: None,
569 }
570 }
571
572 fn setup() -> StageSetup {
573 StageSetup {
574 inference_config: InferenceConfig {
575 temperature: None,
576 max_output_tokens: None,
577 extra_params: Default::default(),
578 batch_tool_hint: false,
579 request_timeout_secs: None,
580 },
581 routing: None,
582 accepts_messages: true,
583 context_layout: None,
584 system_prompt: None,
585 }
586 }
587
588 fn fanout_blueprint(config: FanOutConfig) -> Blueprint {
590 let layout = ContextLayout::new(
591 vec![RegionDefinition::new(
592 "conversation".to_string(),
593 RegionKind::Clearable,
594 10_000,
595 )],
596 12_000,
597 );
598 let mut s0 = Stage::new(
599 "fan".to_string(),
600 ModelConfig::new("script".to_string(), "m".to_string()),
601 );
602 s0.mode = StageMode::FanOut { config };
603 let s1 = Stage::new(
604 "merge".to_string(),
605 ModelConfig::new("script".to_string(), "m".to_string()),
606 );
607 Blueprint::new("t".to_string(), "d".to_string(), vec![s0, s1], layout)
608 }
609
610 fn parent_state() -> AgentState {
611 AgentState {
612 agent_id: "parent".to_string(),
613 current_stage: "fan".to_string(),
614 iteration: 0,
615 status: AgentStatus::Active,
616 spawned_children_ids: vec![],
617 pending_wait: None,
618 accepts_messages: true,
619 }
620 }
621
622 fn spawn_parent(world: &mut World, bp: Blueprint, response: &str) -> Entity {
625 world
626 .spawn((
627 AgentBlueprint(bp),
628 StageCursor { index: 0 },
629 parent_state(),
630 StageProgress::default(),
631 StageInferences(vec![stage_inf(), stage_inf()]),
632 StageSetups(vec![setup(), setup()]),
633 VisitCounts::default(),
634 window(),
635 InferenceResult {
636 response: response.to_string(),
637 tool_calls: vec![],
638 tokens_used: 0,
639 timestamp: 0,
640 },
641 ProcessResponse,
642 ))
643 .id()
644 }
645
646 fn install(world: &mut World, spawner: Arc<dyn FanOutSpawner>) {
647 world.insert_resource(FanOutSpawnerRes(spawner));
648 }
649
650 fn status_of(world: &World, e: Entity) -> AgentStatus {
651 world.get::<AgentState>(e).unwrap().status.clone()
652 }
653
654 fn assert_errored(world: &World, e: Entity) {
657 assert_eq!(
658 std::mem::discriminant(&status_of(world, e)),
659 std::mem::discriminant(&AgentStatus::Error {
660 message: String::new()
661 })
662 );
663 }
664
665 fn complete_worker(world: &mut World, worker: Entity, content: &str) {
666 set_status(world, worker, AgentStatus::Complete);
667 world.entity_mut(worker).insert(InferenceResult {
668 response: content.to_string(),
669 tool_calls: vec![],
670 tokens_used: 0,
671 timestamp: 0,
672 });
673 }
674
675 #[test]
678 fn parse_work_items_handles_array_prose_and_errors() {
679 let ok = parse_work_items(r#"[{"id":"a"},{"id":"b","context":{"k":1}}]"#).unwrap();
680 assert_eq!(ok.len(), 2);
681 assert_eq!(ok[0].id, "a");
682 assert_eq!(ok[1].context["k"], 1);
683 assert_eq!(parse_work_items("[{}]").unwrap()[0].id, "");
685 assert_eq!(
687 parse_work_items("Here you go:\n```json\n[{\"id\":\"x\"}]\n```")
688 .unwrap()
689 .len(),
690 1
691 );
692 assert!(parse_work_items("no array here").is_err());
694 assert!(parse_work_items("]nope[").is_err());
696 assert!(parse_work_items("[not json]").is_err());
698 }
699
700 #[test]
703 fn split_parks_a_fanout_stage_and_consumes_the_response() {
704 let mut world = World::new();
705 let e = spawn_parent(
706 &mut world,
707 fanout_blueprint(cfg(Some("merge"), 2, WorkerFailurePolicy::Continue)),
708 r#"[{"id":"a"},{"id":"b"}]"#,
709 );
710 fan_out_split(&mut world);
711 assert!(world.get::<FanOutWaiting>(e).is_some());
712 assert_eq!(status_of(&world, e), AgentStatus::Waiting);
713 assert!(world.get::<ProcessResponse>(e).is_none());
715 assert!(world.get::<InferenceResult>(e).is_none());
716 let w = world.get::<FanOutWaiting>(e).unwrap();
717 assert_eq!(w.pending.len(), 2);
718 }
719
720 #[test]
721 fn split_errors_on_non_array_output() {
722 let mut world = World::new();
723 let e = spawn_parent(
724 &mut world,
725 fanout_blueprint(cfg(None, 2, WorkerFailurePolicy::Continue)),
726 "definitely not a json array",
727 );
728 fan_out_split(&mut world);
729 assert!(world.get::<FanOutWaiting>(e).is_none());
730 assert_errored(&world, e);
731 }
732
733 #[test]
734 fn split_skips_non_active_and_non_fanout_agents() {
735 let mut world = World::new();
737 let e = spawn_parent(
738 &mut world,
739 fanout_blueprint(cfg(None, 2, WorkerFailurePolicy::Continue)),
740 "[]",
741 );
742 set_status(&mut world, e, AgentStatus::Idle);
743 fan_out_split(&mut world);
744 assert!(world.get::<ProcessResponse>(e).is_some());
745 assert!(world.get::<FanOutWaiting>(e).is_none());
746
747 let layout = ContextLayout::new(
749 vec![RegionDefinition::new(
750 "conversation".to_string(),
751 RegionKind::Clearable,
752 10_000,
753 )],
754 12_000,
755 );
756 let s = Stage::new(
757 "plain".to_string(),
758 ModelConfig::new("script".to_string(), "m".to_string()),
759 );
760 let bp = Blueprint::new("t".to_string(), "d".to_string(), vec![s], layout);
761 let e2 = spawn_parent(&mut world, bp, "[]");
762 fan_out_split(&mut world);
763 assert!(world.get::<ProcessResponse>(e2).is_some());
764 }
765
766 #[test]
769 fn collect_starts_workers_then_merges_on_completion() {
770 let mut world = World::new();
771 install(&mut world, TestSpawner::ok());
772 let e = spawn_parent(
773 &mut world,
774 fanout_blueprint(cfg(Some("merge"), 2, WorkerFailurePolicy::Continue)),
775 r#"[{"id":"a"},{"id":"b"}]"#,
776 );
777 fan_out_split(&mut world);
778 fan_out_collect(&mut world);
779 let kids = world.get::<SubAgentChildren>(e).unwrap().children.clone();
781 assert_eq!(kids.len(), 2);
782 assert!(world.get::<FanOutWaiting>(e).is_some());
783 for k in &kids {
785 assert_eq!(world.get::<ParentRef>(*k).unwrap().depth, 1);
786 }
787
788 for k in &kids {
790 complete_worker(&mut world, *k, "fixed it");
791 }
792 fan_out_collect(&mut world);
793 assert!(world.get::<FanOutWaiting>(e).is_none());
794 assert_eq!(status_of(&world, e), AgentStatus::Active);
795 assert_eq!(world.get::<StageCursor>(e).unwrap().index, 1);
796 assert!(world.get::<ReadyToInfer>(e).is_some());
797 assert!(
799 world
800 .get::<ContextWindow>(e)
801 .unwrap()
802 .get_region("conversation")
803 .unwrap()
804 .current_tokens
805 > 0
806 );
807 }
808
809 #[test]
810 fn collect_respects_max_workers_and_stages_pending() {
811 let mut world = World::new();
812 install(&mut world, TestSpawner::ok());
813 let e = spawn_parent(
814 &mut world,
815 fanout_blueprint(cfg(Some("merge"), 1, WorkerFailurePolicy::Continue)),
816 r#"[{"id":"a"},{"id":"b"}]"#,
817 );
818 fan_out_split(&mut world);
819 fan_out_collect(&mut world);
820 assert_eq!(world.get::<SubAgentChildren>(e).unwrap().children.len(), 1);
822 let first = world.get::<SubAgentChildren>(e).unwrap().children[0];
823 fan_out_collect(&mut world);
826 assert_eq!(world.get::<SubAgentChildren>(e).unwrap().children.len(), 1);
827 assert!(world.get::<FanOutWaiting>(e).is_some());
828 complete_worker(&mut world, first, "one");
829 fan_out_collect(&mut world);
830 assert_eq!(world.get::<SubAgentChildren>(e).unwrap().children.len(), 2);
832 let second = world.get::<SubAgentChildren>(e).unwrap().children[1];
833 complete_worker(&mut world, second, "two");
834 fan_out_collect(&mut world);
835 assert!(world.get::<FanOutWaiting>(e).is_none());
836 assert_eq!(world.get::<StageCursor>(e).unwrap().index, 1);
837 }
838
839 #[test]
840 fn fan_out_state_roundtrips_and_unresolved_workers_become_failures() {
841 let mut world = World::new();
842 install(&mut world, TestSpawner::ok());
843 let e = spawn_parent(
844 &mut world,
845 fanout_blueprint(cfg(Some("merge"), 2, WorkerFailurePolicy::Continue)),
846 r#"[{"id":"a"},{"id":"b"}]"#,
847 );
848 fan_out_split(&mut world);
849 fan_out_collect(&mut world); let state = world.get::<FanOutWaiting>(e).unwrap().to_state();
853 assert_eq!(state.active.len(), 2);
854 assert!(state.active.iter().all(|(_id, run_id)| !run_id.is_empty()));
855
856 let by_run: std::collections::HashMap<String, Entity> = world
858 .get::<SubAgentChildren>(e)
859 .unwrap()
860 .children
861 .iter()
862 .filter_map(|&c| {
863 world
864 .get::<crate::persistence::RunMetadata>(c)
865 .map(|m| (m.run_id.clone(), c))
866 })
867 .collect();
868 let fresh = world.spawn_empty().id();
869 restore_fan_out_waiting(&mut world, fresh, state.clone(), &|rid| {
870 by_run.get(rid).copied()
871 });
872 assert_eq!(
873 world
874 .get::<FanOutWaiting>(fresh)
875 .unwrap()
876 .to_state()
877 .active
878 .len(),
879 2
880 );
881
882 let orphaned = world.spawn_empty().id();
885 restore_fan_out_waiting(&mut world, orphaned, state, &|_| None);
886 let s = world.get::<FanOutWaiting>(orphaned).unwrap().to_state();
887 assert!(s.active.is_empty());
888 assert_eq!(s.failures.len(), 2);
889 }
890
891 #[test]
892 fn collect_fail_all_marks_parent_error() {
893 let mut world = World::new();
894 install(&mut world, TestSpawner::ok());
895 let e = spawn_parent(
896 &mut world,
897 fanout_blueprint(cfg(Some("merge"), 2, WorkerFailurePolicy::FailAll)),
898 r#"[{"id":"a"}]"#,
899 );
900 fan_out_split(&mut world);
901 fan_out_collect(&mut world);
902 let worker = world.get::<SubAgentChildren>(e).unwrap().children[0];
903 set_status(
904 &mut world,
905 worker,
906 AgentStatus::Error {
907 message: "boom".to_string(),
908 },
909 );
910 fan_out_collect(&mut world);
911 assert_errored(&world, e);
912 assert_eq!(world.get::<StageCursor>(e).unwrap().index, 0); }
914
915 #[test]
916 fn collect_continue_reports_failures_and_proceeds_without_merge() {
917 let mut world = World::new();
918 install(&mut world, TestSpawner::ok());
919 let e = spawn_parent(
921 &mut world,
922 fanout_blueprint(cfg(None, 2, WorkerFailurePolicy::Continue)),
923 r#"[{"id":"a"},{"id":"b"}]"#,
924 );
925 fan_out_split(&mut world);
926 fan_out_collect(&mut world);
927 let kids = world.get::<SubAgentChildren>(e).unwrap().children.clone();
928 set_status(
929 &mut world,
930 kids[0],
931 AgentStatus::Error {
932 message: "worker a died".to_string(),
933 },
934 );
935 complete_worker(&mut world, kids[1], "b ok");
936 fan_out_collect(&mut world);
937 assert!(world.get::<FanOutWaiting>(e).is_none());
938 assert!(world.get::<crate::pipeline::ResolveTransition>(e).is_some());
939 assert_eq!(world.get::<StageCursor>(e).unwrap().index, 0);
940 }
941
942 #[test]
943 fn collect_finishes_immediately_when_there_are_no_work_items() {
944 let mut world = World::new();
945 install(&mut world, TestSpawner::ok());
946 let e = spawn_parent(
947 &mut world,
948 fanout_blueprint(cfg(Some("merge"), 2, WorkerFailurePolicy::Continue)),
949 "[]",
950 );
951 fan_out_split(&mut world);
952 fan_out_collect(&mut world);
953 assert!(world.get::<SubAgentChildren>(e).is_none());
955 assert!(world.get::<FanOutWaiting>(e).is_none());
956 assert_eq!(world.get::<StageCursor>(e).unwrap().index, 1);
957 }
958
959 #[test]
960 fn collect_merge_stage_not_found_falls_through_to_transition() {
961 let mut world = World::new();
962 install(&mut world, TestSpawner::ok());
963 let e = spawn_parent(
964 &mut world,
965 fanout_blueprint(cfg(Some("ghost"), 2, WorkerFailurePolicy::Continue)),
966 "[]",
967 );
968 fan_out_split(&mut world);
969 fan_out_collect(&mut world);
970 assert!(world.get::<crate::pipeline::ResolveTransition>(e).is_some());
972 assert_eq!(world.get::<StageCursor>(e).unwrap().index, 0);
973 }
974
975 #[test]
976 fn collect_abandons_a_cancelled_parent() {
977 let mut world = World::new();
978 install(&mut world, TestSpawner::ok());
979 let e = spawn_parent(
980 &mut world,
981 fanout_blueprint(cfg(Some("merge"), 2, WorkerFailurePolicy::Continue)),
982 r#"[{"id":"a"}]"#,
983 );
984 fan_out_split(&mut world);
985 set_status(&mut world, e, AgentStatus::Cancelled);
986 fan_out_collect(&mut world);
987 assert!(world.get::<FanOutWaiting>(e).is_none());
988 assert_eq!(status_of(&world, e), AgentStatus::Cancelled);
989 }
990
991 #[test]
992 fn collect_without_a_spawner_records_failures() {
993 let mut world = World::new();
995 let e = spawn_parent(
996 &mut world,
997 fanout_blueprint(cfg(Some("merge"), 2, WorkerFailurePolicy::Continue)),
998 r#"[{"id":"a"}]"#,
999 );
1000 fan_out_split(&mut world);
1001 fan_out_collect(&mut world);
1002 assert!(world.get::<FanOutWaiting>(e).is_none());
1004 assert_eq!(world.get::<StageCursor>(e).unwrap().index, 1);
1005 }
1006
1007 #[test]
1008 fn collect_spawner_error_becomes_a_failure() {
1009 let mut world = World::new();
1010 install(&mut world, TestSpawner::refusing(&["a"]));
1011 let e = spawn_parent(
1012 &mut world,
1013 fanout_blueprint(cfg(None, 2, WorkerFailurePolicy::FailAll)),
1014 r#"[{"id":"a"}]"#,
1015 );
1016 fan_out_split(&mut world);
1017 fan_out_collect(&mut world);
1018 assert_errored(&world, e);
1020 }
1021
1022 #[test]
1025 fn start_worker_enforces_depth_cap() {
1026 let mut world = World::new();
1027 install(&mut world, TestSpawner::ok());
1028 let mut bp = fanout_blueprint(cfg(None, 2, WorkerFailurePolicy::Continue));
1029 bp.max_child_depth = Some(3);
1030 let e = spawn_parent(&mut world, bp, r#"[{"id":"deep"}]"#);
1031 world.entity_mut(e).insert(ParentRef {
1033 parent_entity: Entity::from_raw_u32(999)
1034 .expect("a small literal index is always a valid entity id"),
1035 parent_agent_id: "root".to_string(),
1036 depth: 3,
1037 });
1038 fan_out_split(&mut world);
1039 fan_out_collect(&mut world);
1040 assert!(world.get::<SubAgentChildren>(e).is_none());
1042 assert!(world.get::<FanOutWaiting>(e).is_none());
1043 }
1044
1045 #[test]
1046 fn start_worker_uses_existing_subagentchildren_cap_and_appends() {
1047 let mut world = World::new();
1048 install(&mut world, TestSpawner::ok());
1049 let e = spawn_parent(
1050 &mut world,
1051 fanout_blueprint(cfg(Some("merge"), 2, WorkerFailurePolicy::Continue)),
1052 r#"[{"id":"a"}]"#,
1053 );
1054 world.entity_mut(e).insert(SubAgentChildren {
1056 children: vec![
1057 Entity::from_raw_u32(1000)
1058 .expect("a small literal index is always a valid entity id"),
1059 ],
1060 max_child_depth: 9,
1061 });
1062 fan_out_split(&mut world);
1063 fan_out_collect(&mut world);
1064 let kids = world.get::<SubAgentChildren>(e).unwrap();
1065 assert_eq!(kids.max_child_depth, 9);
1066 assert_eq!(kids.children.len(), 2); }
1068
1069 #[test]
1072 fn worker_terminal_result_covers_every_status() {
1073 let mut world = World::new();
1074 let complete = world
1075 .spawn((
1076 parent_state(),
1077 InferenceResult {
1078 response: "done text".to_string(),
1079 tool_calls: vec![],
1080 tokens_used: 0,
1081 timestamp: 0,
1082 },
1083 ))
1084 .id();
1085 set_status(&mut world, complete, AgentStatus::Complete);
1086 assert_eq!(
1087 worker_terminal_result(&world, complete),
1088 Some(Ok("done text".to_string()))
1089 );
1090
1091 let complete_no_infer = world.spawn(parent_state()).id();
1092 set_status(&mut world, complete_no_infer, AgentStatus::Complete);
1093 assert_eq!(
1094 worker_terminal_result(&world, complete_no_infer),
1095 Some(Ok(String::new()))
1096 );
1097
1098 let errored = world.spawn(parent_state()).id();
1099 set_status(
1100 &mut world,
1101 errored,
1102 AgentStatus::Error {
1103 message: "x".to_string(),
1104 },
1105 );
1106 assert_eq!(
1107 worker_terminal_result(&world, errored),
1108 Some(Err("x".to_string()))
1109 );
1110
1111 let cancelled = world.spawn(parent_state()).id();
1112 set_status(&mut world, cancelled, AgentStatus::Cancelled);
1113 assert!(worker_terminal_result(&world, cancelled).is_some_and(|r| r.is_err()));
1114
1115 let running = world.spawn(parent_state()).id(); assert_eq!(worker_terminal_result(&world, running), None);
1117
1118 assert!(
1119 worker_terminal_result(
1120 &world,
1121 Entity::from_raw_u32(4242)
1122 .expect("a small literal index is always a valid entity id")
1123 )
1124 .is_some_and(|r| r.is_err())
1125 );
1126 }
1127
1128 #[test]
1129 fn build_report_lists_successes_and_failures() {
1130 let report = build_report(
1131 &[("a".to_string(), "ok-a".to_string())],
1132 &[("b".to_string(), "boom".to_string())],
1133 );
1134 assert!(report.contains("1 succeeded, 1 failed"));
1135 assert!(report.contains("## worker a\nok-a"));
1136 assert!(report.contains("## worker b FAILED\nboom"));
1137 }
1138
1139 #[test]
1140 fn inject_conversation_is_a_noop_without_a_window() {
1141 let mut world = World::new();
1142 let has_window = world.spawn(window()).id();
1143 inject_conversation(&mut world, has_window, "hello");
1144 assert!(
1145 world
1146 .get::<ContextWindow>(has_window)
1147 .unwrap()
1148 .get_region("conversation")
1149 .unwrap()
1150 .current_tokens
1151 > 0
1152 );
1153 let no_window = world.spawn(parent_state()).id();
1155 inject_conversation(&mut world, no_window, "hello");
1156 }
1157
1158 #[test]
1159 fn set_status_is_a_noop_for_a_missing_agent() {
1160 let mut world = World::new();
1161 set_status(
1162 &mut world,
1163 Entity::from_raw_u32(77).expect("a small literal index is always a valid entity id"),
1164 AgentStatus::Complete,
1165 );
1166 assert_eq!(
1167 agent_status(
1168 &world,
1169 Entity::from_raw_u32(77)
1170 .expect("a small literal index is always a valid entity id")
1171 ),
1172 None
1173 );
1174 }
1175
1176 #[test]
1179 fn force_transition_applies_routing_and_handles_despawn_and_overflow() {
1180 use crate::pipeline::force_transition;
1181 let mut world = World::new();
1183 let mut setups = vec![setup(), setup()];
1184 setups[1].routing = Some(leviath_core::ToolResultRouting::default());
1185 let e = world
1186 .spawn((
1187 AgentBlueprint(fanout_blueprint(cfg(
1188 Some("merge"),
1189 2,
1190 WorkerFailurePolicy::Continue,
1191 ))),
1192 StageCursor { index: 0 },
1193 parent_state(),
1194 StageProgress::default(),
1195 StageInferences(vec![stage_inf(), stage_inf()]),
1196 StageSetups(setups),
1197 VisitCounts::default(),
1198 window(),
1199 ))
1200 .id();
1201 force_transition(&mut world, e, 1);
1202 assert!(world.get::<ToolResultRoutingComponent>(e).is_some());
1203 assert!(world.get::<ReadyToInfer>(e).is_some());
1204
1205 force_transition(
1207 &mut world,
1208 Entity::from_raw_u32(9191).expect("a small literal index is always a valid entity id"),
1209 1,
1210 );
1211 }
1212
1213 #[test]
1214 fn force_transition_marks_error_on_prompt_overflow() {
1215 use crate::pipeline::force_transition;
1216 let layout = ContextLayout::new(
1218 vec![RegionDefinition::new(
1219 "task".to_string(),
1220 RegionKind::Pinned,
1221 20,
1222 )],
1223 1000,
1224 );
1225 let mut s0 = Stage::new(
1226 "fan".to_string(),
1227 ModelConfig::new("script".to_string(), "m".to_string()),
1228 );
1229 s0.mode = StageMode::FanOut {
1230 config: cfg(Some("merge"), 2, WorkerFailurePolicy::Continue),
1231 };
1232 let mut s1 = Stage::new(
1233 "merge".to_string(),
1234 ModelConfig::new("script".to_string(), "m".to_string()),
1235 );
1236 s1.config.insert(
1237 "system_prompt".to_string(),
1238 serde_json::Value::String("x".repeat(10_000)),
1239 );
1240 let bp = Blueprint::new("t".to_string(), "d".to_string(), vec![s0, s1], layout);
1241
1242 let mut setups = vec![setup(), setup()];
1243 setups[1].system_prompt = Some("x".repeat(10_000));
1244 let mut w = ContextWindow::new(1000);
1245 w.add_region(Region::new("task".to_string(), RegionKind::Pinned, 20));
1246 let (mut world, e) = world_with(bp, setups, w);
1247 force_transition(&mut world, e, 1);
1248 assert_errored(&world, e);
1249 }
1250
1251 fn world_with(bp: Blueprint, setups: Vec<StageSetup>, w: ContextWindow) -> (World, Entity) {
1253 let mut world = World::new();
1254 let e = world
1255 .spawn((
1256 AgentBlueprint(bp),
1257 StageCursor { index: 0 },
1258 parent_state(),
1259 StageProgress::default(),
1260 StageInferences(vec![stage_inf(), stage_inf()]),
1261 StageSetups(setups),
1262 VisitCounts::default(),
1263 w,
1264 ))
1265 .id();
1266 (world, e)
1267 }
1268}