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