1use std::sync::Arc;
25
26use bevy_ecs::prelude::*;
27use bevy_ecs::query::QueryFilter;
28use leviath_providers::ProviderError;
29use tokio::runtime::Handle;
30use tokio::sync::Notify;
31use tokio::sync::mpsc::{UnboundedSender, unbounded_channel};
32use tokio::task::JoinHandle;
33
34use crate::components::{AgentMessage, AgentState, AgentStatus};
35use crate::inference_pool::{InferencePoolConfig, InferencePools};
36use crate::persistence_bridge::persistence_worker;
37use crate::pipeline::{
38 AwaitingCompaction, AwaitingInference, AwaitingTools, AwaitingTransitionChoice,
39 AwaitingTransitionResponse, CompactionResults, InferenceResults, InferenceStage, MessageIntake,
40 PersistenceStage, ProcessResponse, Providers, ReadyForTools, ReadyForTransition, ReadyToInfer,
41 ResolveTransition, ToolResults, ToolService, ToolServiceRes, ToolStage, TransitionResults,
42 abort_terminal_work, check_workspace_health, collect_compaction, collect_inference,
43 collect_tools, collect_transition_choice, deliver_messages, detect_stuck_stage,
44 dispatch_compaction, dispatch_edge_compact, dispatch_inference, dispatch_persistence,
45 dispatch_tools, dispatch_transition_choice, enforce_max_iterations, fail_stalled_dispatch,
46 fail_wedged_runs, gate_requires_children, handle_empty_response, poll_dynamic_tool_refresh,
47 process_response, reflect_interaction_status, refresh_advertised_tools,
48 require_context_regions, resolve_transition, sync_tool_stages,
49};
50use crate::providers::ProviderRegistry;
51use crate::tool_bridge::ToolLane;
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65struct Fingerprint {
66 markers: [usize; 12],
68 agents: u64,
71}
72
73const MAX_TICK_FAILURES_PER_ROUND: usize = 8;
78
79fn tick_schedule() -> Schedule {
87 let mut schedule = Schedule::default();
88 schedule.set_executor(bevy_ecs::schedule::SingleThreadedExecutor::new());
91 schedule
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum TickOutcome {
97 Clean,
99 AgentFailed,
102 Unattributed,
105}
106
107#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
109pub struct AgentCounts {
110 pub active: usize,
112 pub waiting: usize,
114 pub paused: usize,
116 pub idle: usize,
118 pub terminal: usize,
120}
121
122impl std::fmt::Display for AgentCounts {
123 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124 write!(
125 f,
126 "active={} waiting={} paused={} idle={} terminal={}",
127 self.active, self.waiting, self.paused, self.idle, self.terminal
128 )
129 }
130}
131
132#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct LaneSnapshot {
135 pub agents: AgentCounts,
137 pub inference: Vec<crate::inference_pool::PoolOccupancy>,
139 pub tools_busy: usize,
141 pub tools_queued: usize,
143 pub tools_parked: usize,
145 pub tools_workers: usize,
147 pub tools_saturated: bool,
149}
150
151impl LaneSnapshot {
152 #[must_use]
155 pub fn is_under_pressure(&self) -> bool {
156 self.tools_saturated
157 || (self.agents.active > 0 && self.inference.iter().any(|p| p.is_full()))
158 }
159
160 #[must_use]
162 pub fn inference_summary(&self) -> String {
163 if self.inference.is_empty() {
164 return "none".to_string();
165 }
166 self.inference
167 .iter()
168 .map(ToString::to_string)
169 .collect::<Vec<_>>()
170 .join(" ")
171 }
172}
173
174pub struct PipelineWorld {
176 world: World,
177 schedule: Schedule,
178 wake: Arc<Notify>,
179 shutdown: Arc<Notify>,
180 msg_tx: UnboundedSender<AgentMessage>,
181 tool_lane: Arc<ToolLane>,
183 _tool_task: JoinHandle<()>,
187 persist_task: Option<JoinHandle<()>>,
191}
192
193impl PipelineWorld {
194 pub fn new(
203 providers: ProviderRegistry,
204 tool_service: Arc<dyn ToolService>,
205 pool_config: InferencePoolConfig,
206 tool_concurrency: usize,
207 runs_dir: Option<std::path::PathBuf>,
208 runtime: Handle,
209 ) -> Self {
210 bevy_tasks::ComputeTaskPool::get_or_init(bevy_tasks::TaskPool::default);
215
216 let wake = Arc::new(Notify::new());
217 let shutdown = Arc::new(Notify::new());
218
219 let (inf_tx, inf_rx) = unbounded_channel();
220 let (trans_tx, trans_rx) = unbounded_channel();
221 let (compact_tx, compact_rx) = unbounded_channel();
222 let (tool_job_tx, tool_job_rx) = unbounded_channel();
223 let (tool_res_tx, tool_res_rx) = unbounded_channel();
224 let (persist_tx, persist_rx) = unbounded_channel();
225 let (msg_tx, msg_rx) = unbounded_channel();
226 let (ip_tx, ip_rx) = unbounded_channel();
227 let (gp_tx, gp_rx) = unbounded_channel();
228 let (cs_tx, cs_rx) = unbounded_channel();
229 let (title_tx, title_rx) = unbounded_channel();
230
231 let tool_stats = Arc::new(crate::tool_bridge::ToolLaneStats::new(tool_concurrency));
232 let tool_lane = ToolLane::new(
233 runtime.clone(),
234 tool_res_tx,
235 wake.clone(),
236 tool_concurrency,
237 tool_stats.clone(),
238 );
239 let tool_task = tool_lane.serve(tool_job_rx);
240 let persist_task = runtime.spawn(persistence_worker(runs_dir, persist_rx));
244 let ip_runtime = runtime.clone();
245 let gp_runtime = runtime.clone();
246
247 let mut world = World::new();
248 world.insert_resource(Providers(providers));
249 world.insert_resource(InferenceStage {
250 pools: Arc::new(InferencePools::new(pool_config).with_wake(wake.clone())),
254 outcomes: inf_tx,
255 transition_outcomes: trans_tx,
256 compaction_outcomes: compact_tx,
257 content_summary_outcomes: cs_tx,
258 wake: wake.clone(),
259 runtime,
260 exact_token_counting: false,
261 });
262 world.insert_resource(crate::context_transform::ContentSummaryResults(cs_rx));
263 world.insert_resource(crate::title::TitleSink(title_tx));
264 world.insert_resource(crate::title::TitleResults(title_rx));
265 world.insert_resource(crate::interaction_points::InteractionPointStage {
266 outcomes: ip_tx,
267 wake: wake.clone(),
268 runtime: ip_runtime,
269 });
270 world.insert_resource(crate::interaction_points::InteractionPointResults(ip_rx));
271 world.insert_resource(crate::gate_prompt::GatePromptStage {
272 outcomes: gp_tx,
273 wake: wake.clone(),
274 runtime: gp_runtime,
275 });
276 world.insert_resource(crate::gate_prompt::GatePromptResults(gp_rx));
277 world.insert_resource(InferenceResults(inf_rx));
278 world.insert_resource(TransitionResults(trans_rx));
279 world.insert_resource(CompactionResults(compact_rx));
280 world.insert_resource(ToolServiceRes(tool_service));
281 world.insert_resource(ToolStage::new(tool_job_tx, tool_stats));
282 world.insert_resource(ToolResults(tool_res_rx));
283 world.insert_resource(PersistenceStage(persist_tx));
284 world.insert_resource(MessageIntake(msg_rx));
285 world.insert_resource(crate::telemetry::Telemetry(std::sync::Arc::new(
288 leviath_core::telemetry::NoopSink,
289 )));
290
291 let mut schedule = tick_schedule();
294 schedule.add_systems(
295 (
296 abort_terminal_work,
301 deliver_messages,
302 collect_compaction,
303 crate::context_transform::collect_content_summary,
306 crate::context_transform::dispatch_content_summary,
307 dispatch_edge_compact,
310 dispatch_compaction,
311 enforce_max_iterations,
313 detect_stuck_stage,
317 check_workspace_health,
320 poll_dynamic_tool_refresh,
324 refresh_advertised_tools,
325 (crate::pipeline::rotate_open_circuits, dispatch_inference).chain(),
331 collect_inference,
332 crate::fanout::fan_out_split,
334 process_response,
335 crate::gate_prompt::collect_gate_prompt,
338 dispatch_tools,
339 collect_tools,
340 crate::interaction_points::collect_interaction_point,
343 )
344 .chain(),
345 );
346 schedule.add_systems(
347 (
348 handle_empty_response,
349 gate_requires_children,
351 require_context_regions,
354 crate::interaction_points::gate_interaction_points,
357 crate::interaction_points::dispatch_interaction_point,
358 resolve_transition,
359 dispatch_transition_choice,
360 collect_transition_choice,
361 crate::fanout::fan_out_collect,
363 crate::telemetry::observe_lifecycle,
368 sync_tool_stages,
369 crate::title::collect_title,
373 crate::title::dispatch_title,
374 fail_stalled_dispatch,
380 reflect_interaction_status,
384 fail_wedged_runs,
391 dispatch_persistence,
392 )
393 .chain()
394 .after(crate::interaction_points::collect_interaction_point),
395 );
396
397 Self {
398 world,
399 schedule,
400 wake,
401 shutdown,
402 msg_tx,
403 tool_lane,
404 _tool_task: tool_task,
405 persist_task: Some(persist_task),
406 }
407 }
408
409 pub fn world_mut(&mut self) -> &mut World {
417 &mut self.world
418 }
419
420 pub fn world(&self) -> &World {
422 &self.world
423 }
424
425 pub fn set_exact_token_counting(&mut self, enabled: bool) {
429 self.world
433 .resource_mut::<crate::pipeline::InferenceStage>()
434 .exact_token_counting = enabled;
435 }
436
437 pub fn insert_interaction_hub(&mut self, hub: crate::interaction_hub::InteractionHub) {
443 hub.attach_wake(self.wake.clone());
444 self.world.insert_resource(hub);
445 }
446
447 pub fn spawn_agent(&mut self, bundle: impl Bundle) -> Entity {
450 let e = self.world.spawn(bundle).id();
451 self.wake.notify_one();
452 e
453 }
454
455 pub fn spawn_from_blueprint(
459 &mut self,
460 agent_id: String,
461 blueprint: leviath_core::Blueprint,
462 task: &str,
463 stages: Vec<crate::pipeline::ResolvedStage>,
464 global_hints: leviath_core::config::PromptHints,
465 ) -> Result<Entity, String> {
466 let e = crate::pipeline::spawn_agent(
467 &mut self.world,
468 agent_id,
469 blueprint,
470 task,
471 stages,
472 global_hints,
473 )?;
474 self.wake.notify_one();
475 Ok(e)
476 }
477
478 pub fn send_message(&self, msg: AgentMessage) -> Result<(), ProviderError> {
481 self.msg_tx
482 .send(msg)
483 .map_err(|e| ProviderError::Other(format!("world message channel closed: {e}")))?;
484 self.wake.notify_one();
485 Ok(())
486 }
487
488 pub fn wake_handle(&self) -> Arc<Notify> {
491 self.wake.clone()
492 }
493
494 pub fn shutdown(&self) {
496 self.shutdown.notify_one();
497 }
498
499 pub fn shutdown_handle(&self) -> Arc<Notify> {
502 self.shutdown.clone()
503 }
504
505 pub async fn flush_and_stop(&mut self) {
519 self.shutdown.notify_one();
521 self.run_to_fixed_point();
524 self.world.remove_resource::<PersistenceStage>();
527 if let Some(task) = self.persist_task.take() {
529 let _ = task.await;
530 }
531 self.world
535 .resource::<crate::telemetry::Telemetry>()
536 .0
537 .force_flush();
538 }
539
540 pub fn open_circuits(&self) -> Vec<crate::pipeline::ProviderCircuitState> {
549 let Some(circuits) = self
550 .world
551 .get_resource::<crate::pipeline::ProviderCircuits>()
552 else {
553 return Vec::new();
554 };
555 let policy = self
556 .world
557 .get_resource::<crate::pipeline::CircuitPolicy>()
558 .copied()
559 .unwrap_or_default();
560 circuits.open_circuits(chrono::Utc::now().timestamp(), &policy)
561 }
562
563 pub fn lane_snapshot(&self) -> LaneSnapshot {
566 let mut agents = AgentCounts::default();
567 for state in self
568 .world
569 .iter_entities()
570 .filter_map(|e| e.get::<AgentState>())
571 {
572 match state.status {
573 AgentStatus::Active => agents.active += 1,
574 AgentStatus::Waiting => agents.waiting += 1,
575 AgentStatus::Paused => agents.paused += 1,
576 AgentStatus::Idle => agents.idle += 1,
577 AgentStatus::Complete | AgentStatus::Error { .. } | AgentStatus::Cancelled => {
580 agents.terminal += 1
581 }
582 }
583 }
584 let tools = self.world.resource::<ToolStage>().stats.clone();
585 LaneSnapshot {
586 agents,
587 inference: self.world.resource::<InferenceStage>().pools.occupancy(),
588 tools_busy: tools.busy(),
589 tools_queued: tools.queued(),
590 tools_parked: tools.parked(),
591 tools_workers: tools.workers(),
592 tools_saturated: tools.is_saturated(),
593 }
594 }
595
596 pub fn relieve_tool_lane(&self, extra: usize) -> usize {
602 self.tool_lane.relieve(extra)
603 }
604
605 pub fn agent_status(&self, entity: Entity) -> Option<AgentStatus> {
607 self.world
608 .get::<AgentState>(entity)
609 .map(|s| s.status.clone())
610 }
611
612 pub fn set_status(&mut self, entity: Entity, status: AgentStatus) -> bool {
617 let Some(mut state) = self.world.get_mut::<AgentState>(entity) else {
618 return false;
619 };
620 state.status = status;
621 self.wake.notify_one();
622 true
623 }
624
625 pub fn pause(&mut self, entity: Entity) -> bool {
632 match self.agent_status(entity) {
633 Some(AgentStatus::Active | AgentStatus::Idle) => {
634 self.set_status(entity, AgentStatus::Paused)
635 }
636 _ => false,
637 }
638 }
639
640 pub fn resume(&mut self, entity: Entity) -> bool {
643 match self.agent_status(entity) {
644 Some(AgentStatus::Paused | AgentStatus::Idle) => {
645 self.set_status(entity, AgentStatus::Active)
646 }
647 _ => false,
648 }
649 }
650
651 pub fn cancel(&mut self, entity: Entity) -> bool {
653 self.set_status(entity, AgentStatus::Cancelled)
654 }
655
656 pub fn tick(&mut self) -> TickOutcome {
666 let Err(panicked) = run_isolated(&mut self.schedule, &mut self.world) else {
667 return self.fail_agents_panicked_in_parallel();
671 };
672 let message = panic_status_message(&panicked.message);
673 match panicked.entity {
674 Some(entity) if self.set_status(entity, AgentStatus::Error { message }) => {
675 tracing::error!(
676 ?entity,
677 panic = %panicked.message,
678 "a pipeline system panicked; failing that agent - the daemon and every \
679 other run keep going"
680 );
681 TickOutcome::AgentFailed
682 }
683 _ => {
684 tracing::error!(
685 panic = %panicked.message,
686 "a pipeline system panicked outside any agent's scope; the daemon survived \
687 (an agent may be wedged - cancel it via `lev cancel <run-id>`)"
688 );
689 TickOutcome::Unattributed
690 }
691 }
692 }
693
694 fn fail_agents_panicked_in_parallel(&mut self) -> TickOutcome {
703 let mut query = self
704 .world
705 .query::<(Entity, &crate::tick_scope::PanickedInParallel)>();
706 let failed: Vec<(Entity, String)> = query
707 .iter(&self.world)
708 .map(|(entity, p)| (entity, p.message.clone()))
709 .collect();
710 if failed.is_empty() {
711 return TickOutcome::Clean;
712 }
713 for (entity, message) in failed {
714 self.world
715 .entity_mut(entity)
716 .remove::<crate::tick_scope::PanickedInParallel>();
717 let status = AgentStatus::Error {
718 message: panic_status_message(&message),
719 };
720 let _ = self.set_status(entity, status);
722 }
723 TickOutcome::AgentFailed
724 }
725
726 #[cfg(test)]
728 pub(crate) fn add_test_system<M>(
729 &mut self,
730 system: impl bevy_ecs::schedule::IntoScheduleConfigs<bevy_ecs::system::ScheduleSystem, M>,
734 ) {
735 self.schedule.add_systems(system);
736 }
737
738 fn count<F: QueryFilter>(&mut self) -> usize {
739 let mut q = self.world.query_filtered::<(), F>();
740 q.iter(&self.world).count()
741 }
742
743 fn agent_digest(&mut self) -> u64 {
754 use std::hash::{Hash, Hasher};
755 let mut query = self.world.query::<(
756 Entity,
757 &AgentState,
758 Option<&crate::pipeline::StageCursor>,
759 Option<&crate::pipeline::StageProgress>,
760 )>();
761 query
762 .iter(&self.world)
763 .map(|(entity, state, cursor, progress)| {
764 let mut hasher = std::collections::hash_map::DefaultHasher::new();
765 entity.to_bits().hash(&mut hasher);
766 state.status.hash(&mut hasher);
767 state.current_stage.hash(&mut hasher);
768 state.iteration.hash(&mut hasher);
769 cursor.map(|c| c.index).hash(&mut hasher);
770 progress
771 .map(|p| {
772 (
773 p.iterations,
774 p.total_tool_calls,
775 p.modifying_tool_calls,
776 p.gate_reentries,
777 p.stuck_fired,
778 )
779 })
780 .hash(&mut hasher);
781 hasher.finish()
782 })
783 .fold(0, |acc, digest| acc ^ digest)
784 }
785
786 fn fingerprint(&mut self) -> Fingerprint {
788 let markers = [
789 self.count::<With<ReadyToInfer>>(),
790 self.count::<With<AwaitingInference>>(),
791 self.count::<With<ProcessResponse>>(),
792 self.count::<With<ReadyForTools>>(),
793 self.count::<With<ReadyForTransition>>(),
794 self.count::<With<ResolveTransition>>(),
795 self.count::<With<AwaitingTools>>(),
796 self.count::<With<AwaitingTransitionChoice>>(),
797 self.count::<With<AwaitingTransitionResponse>>(),
798 self.count::<With<AwaitingCompaction>>(),
799 self.count::<With<crate::title::PendingTitle>>(),
800 self.count::<With<crate::title::AwaitingTitle>>(),
801 ];
802 Fingerprint {
803 markers,
804 agents: self.agent_digest(),
805 }
806 }
807
808 fn has_async_inflight(&mut self) -> bool {
811 self.count::<With<AwaitingInference>>() > 0
812 || self.count::<With<AwaitingTools>>() > 0
813 || self.count::<With<AwaitingTransitionResponse>>() > 0
814 || self.count::<With<AwaitingCompaction>>() > 0
815 || self.count::<With<crate::title::AwaitingTitle>>() > 0
816 }
817
818 pub fn run_to_fixed_point(&mut self) {
821 let mut prev = self.fingerprint();
822 let mut failures = 0;
823 loop {
824 let outcome = self.tick();
825 match outcome {
826 TickOutcome::Clean => {}
827 TickOutcome::AgentFailed if failures < MAX_TICK_FAILURES_PER_ROUND => {
834 failures += 1;
835 }
836 TickOutcome::AgentFailed | TickOutcome::Unattributed => break,
842 }
843 let now = self.fingerprint();
844 if now == prev && outcome == TickOutcome::Clean {
849 break;
850 }
851 prev = now;
852 }
853 }
854
855 pub async fn run_until_idle(&mut self, max_waits: usize) {
861 self.run_to_fixed_point();
862 let mut waits = 0;
863 while self.has_async_inflight() && waits < max_waits {
864 self.wake.notified().await;
865 waits += 1;
866 self.run_to_fixed_point();
867 }
868 }
869
870 pub async fn run(&mut self) {
874 loop {
875 self.run_to_fixed_point();
876 tokio::select! {
877 _ = self.wake.notified() => {}
878 _ = self.shutdown.notified() => return,
879 }
880 }
881 }
882}
883
884fn panic_status_message(panic: &str) -> String {
888 format!("internal error: a pipeline system panicked: {panic}")
889}
890
891struct TickPanic {
893 entity: Option<Entity>,
896 message: String,
898}
899
900fn run_isolated(schedule: &mut Schedule, world: &mut World) -> Result<(), TickPanic> {
907 crate::tick_scope::clear();
910 match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| schedule.run(world))) {
911 Ok(()) => Ok(()),
912 Err(payload) => {
913 reset_executor(schedule);
914 Err(TickPanic {
915 entity: crate::tick_scope::current(),
916 message: leviath_core::panic_message(payload.as_ref()),
917 })
918 }
919 }
920}
921
922fn reset_executor(schedule: &mut Schedule) {
941 schedule.set_executor(bevy_ecs::schedule::SingleThreadedExecutor::new());
942}
943
944#[cfg(test)]
945mod tests {
946 use super::*;
947
948 use crate::test_support::{PANIC_HOOK_LOCK, hints};
951
952 fn with_silent_panics<T>(f: impl FnOnce() -> T) -> T {
955 let _hook_guard = PANIC_HOOK_LOCK
956 .lock()
957 .unwrap_or_else(std::sync::PoisonError::into_inner);
958 let prev_hook = std::panic::take_hook();
959 std::panic::set_hook(Box::new(|_| {}));
960 let out = f();
961 std::panic::set_hook(prev_hook);
962 out
963 }
964
965 #[test]
966 fn run_isolated_catches_a_system_panic_and_reports_the_agent() {
967 fn ok_system() {}
968 fn boom_system() {
969 panic!("simulated system panic");
970 }
971 fn boom_on_agent_system() {
974 crate::tick_scope::enter(
975 Entity::from_raw_u32(41)
976 .expect("a small literal index is always a valid entity id"),
977 );
978 panic!("agent-scoped panic");
979 }
980 let mut world = World::new();
981
982 let mut ok = tick_schedule();
984 ok.add_systems(ok_system);
985 assert!(run_isolated(&mut ok, &mut world).is_ok());
986
987 let mut bad = tick_schedule();
990 bad.add_systems(boom_system);
991 let err = with_silent_panics(|| run_isolated(&mut bad, &mut world))
992 .expect_err("the panic must be caught");
993 assert_eq!(err.entity, None);
994 assert_eq!(err.message, "simulated system panic");
995
996 let mut blamed = tick_schedule();
998 blamed.add_systems(boom_on_agent_system);
999 let err = with_silent_panics(|| run_isolated(&mut blamed, &mut world))
1000 .expect_err("the panic must be caught");
1001 assert_eq!(
1002 err.entity,
1003 Some(
1004 Entity::from_raw_u32(41)
1005 .expect("a small literal index is always a valid entity id")
1006 )
1007 );
1008 assert_eq!(err.message, "agent-scoped panic");
1009
1010 assert!(run_isolated(&mut ok, &mut world).is_ok());
1012 assert_eq!(crate::tick_scope::current(), None);
1013 }
1014
1015 use crate::components::{AgentState, ContextWindow, InferenceConfig};
1016 use crate::pipeline::{
1017 AgentBlueprint, MessageIntake, StageCursor, StageInference, StageInferences, StageProgress,
1018 StageSetup, StageSetups, VisitCounts,
1019 };
1020 use crate::tool_bridge::BoxedToolExec;
1021 use leviath_core::{Region, RegionKind};
1022 use leviath_providers::{
1023 FinishReason, InferenceRequest, InferenceResponse, ModelCapabilities, Provider, TokenUsage,
1024 ToolCall,
1025 };
1026 use std::sync::Mutex;
1027
1028 struct Script {
1030 responses: Mutex<std::collections::VecDeque<InferenceResponse>>,
1031 }
1032
1033 #[async_trait::async_trait]
1034 impl Provider for Script {
1035 async fn infer(
1036 &self,
1037 _req: InferenceRequest,
1038 ) -> leviath_providers::Result<InferenceResponse> {
1039 let next = self.responses.lock().unwrap().pop_front();
1040 next.ok_or_else(|| ProviderError::Other("script exhausted".to_string()))
1041 }
1042 async fn count_tokens(&self, _t: &str, _m: &str) -> usize {
1043 1
1044 }
1045 fn max_context_tokens(&self, _m: &str) -> usize {
1046 100_000
1047 }
1048 fn name(&self) -> &str {
1049 "script"
1050 }
1051 fn capabilities(&self, _m: &str) -> ModelCapabilities {
1052 ModelCapabilities::default()
1053 }
1054 }
1055
1056 fn text(content: &str) -> InferenceResponse {
1057 InferenceResponse {
1058 content: content.to_string(),
1059 tool_calls: vec![],
1060 tokens_used: TokenUsage {
1061 prompt_tokens: 1,
1062 completion_tokens: 1,
1063 total_tokens: 2,
1064 cached_tokens: 0,
1065 cache_write_tokens: 0,
1066 },
1067 finish_reason: FinishReason::Complete,
1068 }
1069 }
1070
1071 fn with_tool(id: &str, name: &str) -> InferenceResponse {
1072 let mut r = text("");
1073 r.tool_calls.push(ToolCall {
1074 id: id.to_string(),
1075 name: name.to_string(),
1076 arguments: serde_json::json!({}),
1077 thought_signature: None,
1078 });
1079 r
1080 }
1081
1082 struct EchoTools;
1084 impl ToolService for EchoTools {
1085 fn exec_for(
1086 &self,
1087 _entity: Entity,
1088 calls: Vec<ToolCall>,
1089 _progress: crate::pipeline::ToolProgress,
1090 ) -> BoxedToolExec {
1091 Box::new(move || {
1092 Box::pin(async move {
1093 calls
1094 .into_iter()
1095 .map(|c| (c.id, "ok".to_string()))
1096 .collect()
1097 })
1098 })
1099 }
1100 }
1101
1102 fn window() -> ContextWindow {
1103 let mut w = ContextWindow::new(10_000);
1104 w.add_region(Region::new("sys".to_string(), RegionKind::Pinned, 2000));
1105 w.add_region(Region::new(
1106 "conversation".to_string(),
1107 RegionKind::Clearable,
1108 10_000,
1109 ));
1110 w.add_region(Region::new(
1111 "tool_results".to_string(),
1112 RegionKind::Temporary,
1113 5000,
1114 ));
1115 w
1116 }
1117
1118 fn agent_state() -> AgentState {
1119 AgentState {
1120 agent_id: "a".to_string(),
1121 current_stage: "s".to_string(),
1122 iteration: 0,
1123 status: AgentStatus::Active,
1124 spawned_children_ids: vec![],
1125 pending_wait: None,
1126 accepts_messages: true,
1127 }
1128 }
1129
1130 fn stage(model: &str) -> StageInference {
1137 StageInference {
1138 provider_name: "script".to_string(),
1139 model: model.to_string(),
1140 tools: ["do", "read"]
1141 .iter()
1142 .map(|n| leviath_providers::Tool {
1143 name: (*n).to_string(),
1144 description: String::new(),
1145 parameters: serde_json::json!({}),
1146 })
1147 .collect(),
1148 tool_filter: None,
1149 fallbacks: Vec::new(),
1150 }
1151 }
1152
1153 fn setup() -> StageSetup {
1154 StageSetup {
1155 inference_config: InferenceConfig {
1156 temperature: None,
1157 max_output_tokens: None,
1158 extra_params: Default::default(),
1159 batch_tool_hint: false,
1160 shell_hint: false,
1161 request_timeout_secs: None,
1162 },
1163 routing: None,
1164 accepts_messages: true,
1165 context_layout: None,
1166 system_prompt: None,
1167 }
1168 }
1169
1170 fn blueprint() -> leviath_core::Blueprint {
1171 let layout = leviath_core::layout::ContextLayout::new(
1172 vec![leviath_core::layout::RegionDefinition::new(
1173 "conversation".to_string(),
1174 RegionKind::Clearable,
1175 10_000,
1176 )],
1177 12_000,
1178 );
1179 let s = leviath_core::Stage::new(
1180 "s".to_string(),
1181 leviath_core::blueprint::ModelConfig::new("script".to_string(), "m".to_string()),
1182 );
1183 leviath_core::Blueprint::new("t".to_string(), "d".to_string(), vec![s], layout)
1184 }
1185
1186 fn spawn(world: &mut PipelineWorld) -> Entity {
1188 world.spawn_agent((
1189 AgentBlueprint(blueprint()),
1190 StageCursor { index: 0 },
1191 agent_state(),
1192 crate::components::MessageInbox::default(),
1193 StageProgress::default(),
1194 StageInferences(vec![stage("m")]),
1195 StageSetups(vec![setup()]),
1196 VisitCounts::default(),
1197 window(),
1198 stage("m"),
1199 setup().inference_config,
1200 ReadyToInfer,
1201 ))
1202 }
1203
1204 fn build_world(providers: ProviderRegistry) -> PipelineWorld {
1205 PipelineWorld::new(
1208 providers,
1209 Arc::new(EchoTools),
1210 InferencePoolConfig::new(),
1211 1,
1212 None,
1213 Handle::current(),
1214 )
1215 }
1216
1217 #[tokio::test]
1218 async fn open_circuits_reports_nothing_without_the_breaker() {
1219 let world = build_world(ProviderRegistry::new());
1222 assert!(world.open_circuits().is_empty());
1223 }
1224
1225 #[tokio::test]
1226 async fn open_circuits_reports_a_tripped_provider() {
1227 let mut world = build_world(ProviderRegistry::new());
1228 let policy = crate::pipeline::CircuitPolicy {
1229 failures_before_open: 1,
1230 cooldown_secs: 300,
1231 };
1232 let mut circuits = crate::pipeline::ProviderCircuits::default();
1233 circuits.record_failure(
1234 "openrouter",
1235 leviath_providers::UnavailableReason::CreditsExhausted,
1236 chrono::Utc::now().timestamp(),
1237 &policy,
1238 );
1239 world.world_mut().insert_resource(circuits);
1240 world.world_mut().insert_resource(policy);
1241
1242 let open = world.open_circuits();
1243 assert_eq!(open.len(), 1);
1244 assert_eq!(open[0].provider, "openrouter");
1245 assert_eq!(
1246 open[0].reason,
1247 leviath_providers::UnavailableReason::CreditsExhausted
1248 );
1249 }
1250
1251 #[tokio::test]
1252 async fn open_circuits_falls_back_to_the_default_policy() {
1253 let mut world = build_world(ProviderRegistry::new());
1256 let default_policy = crate::pipeline::CircuitPolicy::default();
1257 let mut circuits = crate::pipeline::ProviderCircuits::default();
1258 for _ in 0..default_policy.failures_before_open {
1259 circuits.record_failure(
1260 "openrouter",
1261 leviath_providers::UnavailableReason::AuthFailed,
1262 chrono::Utc::now().timestamp(),
1263 &default_policy,
1264 );
1265 }
1266 world.world_mut().insert_resource(circuits);
1267
1268 assert_eq!(world.open_circuits().len(), 1);
1269 }
1270
1271 #[tokio::test]
1272 async fn set_exact_token_counting_toggles_the_stage_flag() {
1273 let mut world = build_world(ProviderRegistry::new());
1274 assert!(
1276 !world
1277 .world()
1278 .resource::<crate::pipeline::InferenceStage>()
1279 .exact_token_counting
1280 );
1281 world.set_exact_token_counting(true);
1282 assert!(
1283 world
1284 .world()
1285 .resource::<crate::pipeline::InferenceStage>()
1286 .exact_token_counting
1287 );
1288 }
1289
1290 #[tokio::test]
1291 async fn run_to_fixed_point_survives_a_panicking_system() {
1292 fn boom_system() {
1295 panic!("simulated system panic");
1296 }
1297 let mut world = build_world(ProviderRegistry::new());
1298 world.add_test_system(boom_system);
1299 with_silent_panics(|| world.run_to_fixed_point());
1301 }
1302
1303 #[tokio::test]
1304 async fn a_panic_on_the_compute_pool_is_attributed_to_its_agent() {
1305 fn boom_in_parallel(
1311 agents: Query<(Entity, &AgentState)>,
1312 par_commands: bevy_ecs::system::ParallelCommands,
1313 ) {
1314 agents.par_iter().for_each(|(entity, state)| {
1315 if state.status != AgentStatus::Active {
1316 return; }
1318 crate::tick_scope::clear();
1321 crate::tick_scope::run_agent_parallel(entity, &par_commands, &mut || {
1322 panic!("blew up on the compute pool");
1323 });
1324 });
1325 }
1326
1327 let mut world = build_world(ProviderRegistry::new());
1328 let entity = spawn(&mut world);
1329 world.add_test_system(boom_in_parallel);
1330 with_silent_panics(|| world.run_to_fixed_point());
1331
1332 let status = world.agent_status(entity);
1333 assert!(
1334 matches!(status, Some(AgentStatus::Error { ref message })
1335 if message.contains("a pipeline system panicked")
1336 && message.contains("blew up on the compute pool")),
1337 "got: {status:?}"
1338 );
1339 assert!(
1341 world
1342 .world()
1343 .entity(entity)
1344 .get::<crate::tick_scope::PanickedInParallel>()
1345 .is_none(),
1346 "the marker must be drained once acted on"
1347 );
1348 }
1349
1350 #[tokio::test]
1351 async fn a_panicking_system_fails_its_agent_instead_of_looping_forever() {
1352 static VICTIM: std::sync::Mutex<Option<Entity>> = std::sync::Mutex::new(None);
1358 static PANICS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
1359
1360 fn boom_on_active_agent(agents: Query<(Entity, &AgentState)>) {
1361 let Some((entity, _)) = agents
1364 .iter()
1365 .find(|(_, state)| state.status == AgentStatus::Active)
1366 else {
1367 return; };
1369 crate::tick_scope::enter(entity);
1370 *VICTIM
1371 .lock()
1372 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(entity);
1373 PANICS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1374 panic!("blew up on this agent");
1375 }
1376
1377 let mut world = build_world(ProviderRegistry::new());
1378 let entity = spawn(&mut world);
1379 world.add_test_system(boom_on_active_agent);
1380 with_silent_panics(|| world.run_to_fixed_point());
1381
1382 let victim = VICTIM
1383 .lock()
1384 .unwrap_or_else(std::sync::PoisonError::into_inner)
1385 .take();
1386 assert_eq!(victim, Some(entity), "the system saw the spawned agent");
1387 let status = world.agent_status(entity);
1388 assert!(
1389 matches!(status, Some(AgentStatus::Error { ref message })
1390 if message.contains("a pipeline system panicked")
1391 && message.contains("blew up on this agent")),
1392 "got: {status:?}"
1393 );
1394 assert!(
1396 PANICS.load(std::sync::atomic::Ordering::SeqCst) <= MAX_TICK_FAILURES_PER_ROUND + 1,
1397 "the panic budget must stop the round"
1398 );
1399 }
1400
1401 fn registry_with(responses: Vec<InferenceResponse>) -> ProviderRegistry {
1402 let mut r = ProviderRegistry::new();
1403 r.register(
1404 "script".to_string(),
1405 Arc::new(Script {
1406 responses: Mutex::new(responses.into_iter().collect()),
1407 }),
1408 );
1409 r
1410 }
1411
1412 #[tokio::test]
1413 async fn an_agent_whose_provider_is_missing_wedges_at_iteration_zero() {
1414 let mut world = build_world(ProviderRegistry::new());
1421 let e = spawn(&mut world);
1422
1423 world.run_until_idle(30).await;
1424
1425 let state = world.world().get::<AgentState>(e).expect("the agent");
1428 assert_eq!(state.iteration, 0, "not a single inference happened");
1429 assert_eq!(state.status, AgentStatus::Active);
1430 let stall = world
1431 .world()
1432 .get::<crate::pipeline::DispatchStall>(e)
1433 .expect("the decline is recorded");
1434 assert_eq!(stall.reason, crate::pipeline::StallReason::ProviderMissing);
1435
1436 let past =
1440 chrono::Utc::now().timestamp() - crate::pipeline::DEFAULT_STALL_TIMEOUT_SECS as i64 - 1;
1441 world
1442 .world_mut()
1443 .get_mut::<crate::pipeline::DispatchStall>(e)
1444 .expect("the stall record")
1445 .since = past;
1446 world.run_to_fixed_point();
1447
1448 let status = world.agent_status(e);
1449 assert!(
1450 matches!(status, Some(AgentStatus::Error { ref message })
1451 if message.contains("script") && message.contains("not configured")),
1452 "got: {status:?}"
1453 );
1454 assert!(
1455 world.world().get::<ReadyToInfer>(e).is_none(),
1456 "and it is out of the dispatch systems"
1457 );
1458 }
1459
1460 #[tokio::test]
1469 async fn a_run_nothing_can_drive_is_failed_rather_than_left_running() {
1470 let mut world = build_world(registry_with(vec![]));
1471 world
1472 .world_mut()
1473 .insert_resource(crate::pipeline::WedgeTimeout(60));
1474 let e = spawn(&mut world);
1475
1476 world.world_mut().entity_mut(e).remove::<ReadyToInfer>();
1480 world.run_to_fixed_point();
1481
1482 assert_eq!(
1484 world.agent_status(e),
1485 Some(AgentStatus::Active),
1486 "not failed while it is still inside the grace period"
1487 );
1488 let since = world
1489 .world()
1490 .get::<crate::pipeline::Wedged>(e)
1491 .expect("the wedge is recorded")
1492 .since;
1493
1494 world
1496 .world_mut()
1497 .get_mut::<crate::pipeline::Wedged>(e)
1498 .expect("the wedge record")
1499 .since = since - 61;
1500 world.run_to_fixed_point();
1501
1502 let status = world.agent_status(e);
1503 assert!(
1504 matches!(status, Some(AgentStatus::Error { ref message })
1505 if message.contains("never move again")),
1506 "got: {status:?}"
1507 );
1508 }
1509
1510 #[tokio::test]
1511 async fn agent_completes_after_nudges_exhausted() {
1512 let mut world = build_world(registry_with(vec![
1517 text("thinking"),
1518 text("still"),
1519 text("more"),
1520 text("final"),
1521 ]));
1522 let e = spawn(&mut world);
1523
1524 world.run_until_idle(30).await;
1525
1526 assert_eq!(world.agent_status(e), Some(AgentStatus::Complete));
1527 }
1528
1529 #[tokio::test]
1530 async fn agent_nudge_max_bounds_the_loop_end_to_end() {
1531 let mut world = build_world(registry_with(vec![text("thinking"), text("final")]));
1536 let mut bp = blueprint();
1537 bp.nudge = Some(leviath_core::NudgeConfig {
1538 max: Some(1),
1539 ..Default::default()
1540 });
1541 let e = world.spawn_agent((
1542 AgentBlueprint(bp),
1543 StageCursor { index: 0 },
1544 agent_state(),
1545 crate::components::MessageInbox::default(),
1546 StageProgress::default(),
1547 StageInferences(vec![stage("m")]),
1548 StageSetups(vec![setup()]),
1549 VisitCounts::default(),
1550 window(),
1551 stage("m"),
1552 setup().inference_config,
1553 ReadyToInfer,
1554 ));
1555
1556 world.run_until_idle(30).await;
1557
1558 assert_eq!(world.agent_status(e), Some(AgentStatus::Complete));
1559 }
1560
1561 #[tokio::test]
1562 async fn agent_runs_tools_then_completes() {
1563 let mut world = build_world(registry_with(vec![with_tool("c1", "do"), text("done")]));
1566 let e = spawn(&mut world);
1567
1568 world.run_until_idle(20).await;
1569
1570 assert_eq!(world.agent_status(e), Some(AgentStatus::Complete));
1571 assert!(
1574 world
1575 .world()
1576 .get::<ContextWindow>(e)
1577 .unwrap()
1578 .get_region("conversation")
1579 .unwrap()
1580 .current_tokens
1581 > 0
1582 );
1583 }
1584
1585 #[tokio::test]
1586 async fn insert_interaction_hub_installs_resource_and_attaches_wake() {
1587 use crate::dynamic_interaction::InteractionBackend;
1588 use crate::interaction_hub::InteractionHub;
1589 let mut world = build_world(registry_with(vec![]));
1590 let hub = InteractionHub::new();
1591 world.insert_interaction_hub(hub.clone());
1592
1593 assert!(world.world().get_resource::<InteractionHub>().is_some());
1595
1596 let backend = hub.backend_for("x");
1599 let asking = tokio::spawn(async move {
1600 backend
1601 .ask(leviath_core::interaction::InteractionRequest::free_text(
1602 "q", "p", "s", true,
1603 ))
1604 .await
1605 });
1606 for _ in 0..8 {
1607 tokio::task::yield_now().await;
1608 }
1609 world.wake_handle().notified().await;
1610 hub.cancel("q");
1611 let _ = asking.await;
1612 }
1613
1614 #[tokio::test]
1615 async fn provider_error_marks_agent_error() {
1616 let mut world = build_world(registry_with(vec![]));
1618 let e = spawn(&mut world);
1619
1620 world.run_until_idle(20).await;
1621
1622 assert_eq!(
1623 std::mem::discriminant(&world.agent_status(e).unwrap()),
1624 std::mem::discriminant(&AgentStatus::Error {
1625 message: String::new()
1626 })
1627 );
1628 }
1629
1630 #[tokio::test]
1631 async fn send_message_reaches_the_agent_inbox() {
1632 let mut world = build_world(registry_with(vec![]));
1635 let e = spawn(&mut world);
1636 world.run_until_idle(20).await;
1638
1639 world
1640 .send_message(AgentMessage {
1641 agent_id: "a".to_string(),
1642 content: "hello".to_string(),
1643 target_region: Some("conversation".to_string()),
1644 })
1645 .unwrap();
1646 world.tick(); assert!(
1649 world
1650 .world()
1651 .get::<ContextWindow>(e)
1652 .unwrap()
1653 .get_region("conversation")
1654 .unwrap()
1655 .current_tokens
1656 > 0
1657 );
1658 }
1659
1660 #[tokio::test]
1661 async fn run_returns_on_shutdown() {
1662 let mut world = build_world(registry_with(vec![text("done")]));
1663 spawn(&mut world);
1664 world.shutdown(); world.run().await;
1667 }
1668
1669 #[tokio::test]
1670 async fn run_wakes_then_shuts_down() {
1671 let mut world = build_world(registry_with(vec![
1674 text("t1"),
1675 text("t2"),
1676 text("t3"),
1677 text("t4"),
1678 ]));
1679 spawn(&mut world);
1680 let wake = world.wake_handle();
1681 let shutdown = world.shutdown_handle();
1682 let handle = tokio::spawn(async move { world.run().await });
1683
1684 wake.notify_one();
1685 tokio::task::yield_now().await;
1686 shutdown.notify_one();
1687
1688 handle.await.unwrap(); }
1690
1691 #[tokio::test]
1692 async fn send_message_errors_when_intake_dropped() {
1693 let mut world = build_world(registry_with(vec![]));
1694 let removed = world.world_mut().remove_resource::<MessageIntake>();
1696 drop(removed);
1697
1698 let err = world.send_message(AgentMessage {
1699 agent_id: "a".to_string(),
1700 content: "x".to_string(),
1701 target_region: None,
1702 });
1703 assert!(err.is_err());
1704 }
1705
1706 #[tokio::test]
1707 async fn script_provider_metadata_is_exercised() {
1708 let p = Script {
1710 responses: Mutex::new(std::collections::VecDeque::new()),
1711 };
1712 assert_eq!(p.name(), "script");
1713 assert_eq!(p.count_tokens("t", "m").await, 1);
1714 assert_eq!(p.max_context_tokens("m"), 100_000);
1715 let _ = p.capabilities("m");
1716 }
1717
1718 #[tokio::test]
1719 async fn agent_status_is_none_for_unknown_entity() {
1720 let world = build_world(registry_with(vec![]));
1721 assert_eq!(
1722 world.agent_status(
1723 Entity::from_raw_u32(999)
1724 .expect("a small literal index is always a valid entity id")
1725 ),
1726 None
1727 );
1728 }
1729
1730 #[tokio::test]
1731 async fn paused_agent_does_not_progress_until_resumed() {
1732 let mut world = build_world(registry_with(vec![
1733 text("t1"),
1734 text("t2"),
1735 text("t3"),
1736 text("t4"),
1737 ]));
1738 let e = spawn(&mut world);
1739 assert!(world.pause(e));
1740
1741 world.run_until_idle(30).await;
1742 assert_eq!(world.agent_status(e), Some(AgentStatus::Paused));
1744
1745 assert!(world.resume(e));
1746 world.run_until_idle(30).await;
1747 assert_eq!(world.agent_status(e), Some(AgentStatus::Complete));
1748 }
1749
1750 #[tokio::test]
1751 async fn pause_refuses_waiting_and_terminal_agents() {
1752 let mut world = build_world(registry_with(vec![text("t1")]));
1753 let e = spawn(&mut world);
1754
1755 world.set_status(e, AgentStatus::Waiting);
1758 assert!(!world.pause(e));
1759 assert_eq!(world.agent_status(e), Some(AgentStatus::Waiting));
1760
1761 world.set_status(e, AgentStatus::Cancelled);
1762 assert!(!world.pause(e));
1763 assert_eq!(world.agent_status(e), Some(AgentStatus::Cancelled));
1764 }
1765
1766 #[tokio::test]
1767 async fn resume_refuses_agents_that_are_not_paused_or_idle() {
1768 let mut world = build_world(registry_with(vec![text("t1")]));
1769 let e = spawn(&mut world);
1770
1771 world.set_status(e, AgentStatus::Active);
1773 assert!(!world.resume(e));
1774
1775 world.set_status(e, AgentStatus::Waiting);
1776 assert!(!world.resume(e));
1777 assert_eq!(world.agent_status(e), Some(AgentStatus::Waiting));
1778
1779 world.set_status(e, AgentStatus::Complete);
1780 assert!(!world.resume(e));
1781 assert_eq!(world.agent_status(e), Some(AgentStatus::Complete));
1782 }
1783
1784 #[tokio::test]
1785 async fn resume_nudges_an_idle_agent_active() {
1786 let mut world = build_world(registry_with(vec![text("t1")]));
1787 let e = spawn(&mut world);
1788 world.set_status(e, AgentStatus::Idle);
1789 assert!(world.resume(e));
1790 assert_eq!(world.agent_status(e), Some(AgentStatus::Active));
1791 }
1792
1793 #[tokio::test]
1794 async fn cancelled_agent_stops_progressing() {
1795 let mut world = build_world(registry_with(vec![with_tool("c1", "do"), text("done")]));
1796 let e = spawn(&mut world);
1797 assert!(world.cancel(e));
1798
1799 world.run_until_idle(20).await;
1800
1801 assert_eq!(world.agent_status(e), Some(AgentStatus::Cancelled));
1802 }
1803
1804 #[tokio::test]
1805 async fn status_ops_return_false_for_unknown_entity() {
1806 let mut world = build_world(registry_with(vec![]));
1807 assert!(!world.pause(
1808 Entity::from_raw_u32(999).expect("a small literal index is always a valid entity id")
1809 ));
1810 assert!(!world.resume(
1811 Entity::from_raw_u32(999).expect("a small literal index is always a valid entity id")
1812 ));
1813 assert!(!world.cancel(
1814 Entity::from_raw_u32(999).expect("a small literal index is always a valid entity id")
1815 ));
1816 }
1817
1818 #[tokio::test]
1819 async fn spawn_from_blueprint_builds_a_runnable_agent() {
1820 let mut world = build_world(registry_with(vec![with_tool("c1", "do"), text("done")]));
1822 let e = world
1823 .spawn_from_blueprint(
1824 "agent-1".to_string(),
1825 blueprint(),
1826 "do the task",
1827 vec![crate::pipeline::ResolvedStage {
1828 provider_name: "script".to_string(),
1829 model: "m".to_string(),
1830 tools: vec![],
1831 fallbacks: Vec::new(),
1832 }],
1833 hints(true),
1834 )
1835 .unwrap();
1836
1837 world.run_until_idle(20).await;
1838
1839 assert_eq!(world.agent_status(e), Some(AgentStatus::Complete));
1840 }
1841
1842 #[tokio::test]
1843 async fn persists_agent_snapshot_to_runs_dir() {
1844 let dir = tempfile::tempdir().unwrap();
1847 let mut world = PipelineWorld::new(
1848 registry_with(vec![with_tool("c1", "do"), text("done")]),
1849 Arc::new(EchoTools),
1850 InferencePoolConfig::new(),
1851 1,
1852 Some(dir.path().to_path_buf()),
1853 Handle::current(),
1854 );
1855 world.spawn_agent((
1856 AgentBlueprint(blueprint()),
1857 StageCursor { index: 0 },
1858 agent_state(),
1859 crate::components::MessageInbox::default(),
1860 StageProgress::default(),
1861 StageInferences(vec![stage("m")]),
1862 StageSetups(vec![setup()]),
1863 VisitCounts::default(),
1864 window(),
1865 stage("m"),
1866 setup().inference_config,
1867 crate::persistence::RunMetadata {
1868 run_id: "run-42".to_string(),
1869 agent_name: "a".to_string(),
1870 agent_path: "/p".to_string(),
1871 task: "t".to_string(),
1872 model: None,
1873 workdir: std::env::temp_dir().to_string_lossy().to_string(),
1875 num_stages: 1,
1876 started_at: 0,
1877 parent_run_id: None,
1878 metadata: std::collections::HashMap::new(),
1879 callback_url: None,
1880 callback_secret: None,
1881 title: None,
1882 unattended: false,
1883 read_paths: None,
1884 },
1885 crate::persistence::TokenTotals::default(),
1886 crate::pipeline::PersistWatermark::default(),
1887 ReadyToInfer,
1888 ));
1889
1890 world.run_until_idle(20).await;
1891
1892 let meta_path = dir.path().join("run-42").join("meta.json");
1898 let mut meta = None;
1899 for _ in 0..200 {
1900 if let Ok(text) = std::fs::read_to_string(&meta_path)
1901 && let Ok(m) = serde_json::from_str::<leviath_core::run_meta::RunMeta>(&text)
1902 && m.status == leviath_core::run_meta::RunStatus::Complete
1903 {
1904 meta = Some(m);
1905 break;
1906 }
1907 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1908 }
1909
1910 let meta = meta.expect("final Complete snapshot flushed to disk");
1911 assert_eq!(meta.run_id, "run-42");
1912 assert!(dir.path().join("run-42").join("context.json").exists());
1913 }
1914
1915 #[tokio::test]
1916 async fn a_panicked_agent_is_recorded_as_errored_on_disk() {
1917 fn boom_on_active_agent(agents: Query<(Entity, &AgentState)>) {
1922 let Some((entity, _)) = agents
1923 .iter()
1924 .find(|(_, state)| state.status == AgentStatus::Active)
1925 else {
1926 return; };
1928 crate::tick_scope::enter(entity);
1929 panic!("exploded mid-stage");
1930 }
1931
1932 let dir = tempfile::tempdir().unwrap();
1933 let mut world = PipelineWorld::new(
1934 registry_with(vec![]),
1935 Arc::new(EchoTools),
1936 InferencePoolConfig::new(),
1937 1,
1938 Some(dir.path().to_path_buf()),
1939 Handle::current(),
1940 );
1941 world.spawn_agent((
1942 AgentBlueprint(blueprint()),
1943 StageCursor { index: 0 },
1944 agent_state(),
1945 crate::components::MessageInbox::default(),
1946 StageProgress::default(),
1947 StageInferences(vec![stage("m")]),
1948 StageSetups(vec![setup()]),
1949 VisitCounts::default(),
1950 window(),
1951 stage("m"),
1952 setup().inference_config,
1953 crate::persistence::RunMetadata {
1954 run_id: "run-boom".to_string(),
1955 agent_name: "a".to_string(),
1956 agent_path: "/p".to_string(),
1957 task: "t".to_string(),
1958 model: None,
1959 workdir: "/w".to_string(),
1960 num_stages: 1,
1961 started_at: 0,
1962 parent_run_id: None,
1963 metadata: std::collections::HashMap::new(),
1964 callback_url: None,
1965 callback_secret: None,
1966 title: None,
1967 unattended: false,
1968 read_paths: None,
1969 },
1970 crate::persistence::TokenTotals::default(),
1971 crate::pipeline::PersistWatermark::default(),
1972 ReadyToInfer,
1973 ));
1974 world.add_test_system(boom_on_active_agent);
1975 with_silent_panics(|| world.run_to_fixed_point());
1976
1977 let meta_path = dir.path().join("run-boom").join("meta.json");
1978 let mut meta = None;
1979 for _ in 0..200 {
1980 if let Ok(text) = std::fs::read_to_string(&meta_path)
1981 && let Ok(m) = serde_json::from_str::<leviath_core::run_meta::RunMeta>(&text)
1982 && m.status == leviath_core::run_meta::RunStatus::Error
1983 {
1984 meta = Some(m);
1985 break;
1986 }
1987 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1988 }
1989 let meta = meta.expect("the panicked run must be persisted as errored");
1990 let error = meta.error.unwrap_or_default();
1991 assert!(error.contains("a pipeline system panicked"), "got: {error}");
1992 assert!(error.contains("exploded mid-stage"), "got: {error}");
1993 }
1994
1995 fn interactive_blueprint() -> leviath_core::Blueprint {
1998 use leviath_core::blueprint::{InteractionPoint, InteractionStyle, StageMode};
1999 let layout = leviath_core::layout::ContextLayout::new(
2000 vec![leviath_core::layout::RegionDefinition::new(
2001 "conversation".to_string(),
2002 RegionKind::Clearable,
2003 10_000,
2004 )],
2005 12_000,
2006 );
2007 let mut s = leviath_core::Stage::new(
2008 "plan".to_string(),
2009 leviath_core::blueprint::ModelConfig::new("script".to_string(), "m".to_string()),
2010 );
2011 s.mode = StageMode::InteractivePoints {
2012 points: vec![InteractionPoint {
2013 name: "plan_approval".to_string(),
2014 prompt: "Approve?".to_string(),
2015 required: true,
2016 unattended: leviath_core::blueprint::UnattendedPolicy::AutoApprove,
2017 style: InteractionStyle::MultipleChoice,
2018 options: vec!["Approve".to_string(), "Abort".to_string()],
2019 directives: std::collections::HashMap::new(),
2020 abort_options: vec!["Abort".to_string()],
2021 edit_options: vec![],
2022 document_region: None,
2023 }],
2024 };
2025 leviath_core::Blueprint::new("t".to_string(), "d".to_string(), vec![s], layout)
2026 }
2027
2028 #[tokio::test]
2029 async fn persists_interaction_point_when_a_live_agent_blocks() {
2030 let dir = tempfile::tempdir().unwrap();
2036 let mut world = PipelineWorld::new(
2037 registry_with(vec![with_tool("c1", "read"), text("## Plan\n1. do it")]),
2038 Arc::new(EchoTools),
2039 InferencePoolConfig::new(),
2040 1,
2041 Some(dir.path().to_path_buf()),
2042 Handle::current(),
2043 );
2044 world.insert_interaction_hub(crate::interaction_hub::InteractionHub::new());
2045 let e = world.spawn_agent((
2046 AgentBlueprint(interactive_blueprint()),
2047 StageCursor { index: 0 },
2048 agent_state(),
2049 crate::components::MessageInbox::default(),
2050 StageProgress::default(),
2051 StageInferences(vec![stage("m")]),
2052 StageSetups(vec![setup()]),
2053 VisitCounts::default(),
2054 window(),
2055 stage("m"),
2056 setup().inference_config,
2057 crate::persistence::RunMetadata {
2058 run_id: "run-ip".to_string(),
2059 agent_name: "a".to_string(),
2060 agent_path: "/p".to_string(),
2061 task: "t".to_string(),
2062 model: None,
2063 workdir: std::env::temp_dir().to_string_lossy().to_string(),
2065 num_stages: 1,
2066 started_at: 0,
2067 parent_run_id: None,
2068 metadata: std::collections::HashMap::new(),
2069 callback_url: None,
2070 callback_secret: None,
2071 title: None,
2072 unattended: false,
2073 read_paths: None,
2074 },
2075 crate::persistence::TokenTotals::default(),
2076 crate::pipeline::PersistWatermark::default(),
2077 ReadyToInfer,
2078 ));
2079
2080 world.run_until_idle(30).await;
2081 for _ in 0..50 {
2087 if world.agent_status(e) == Some(AgentStatus::Waiting) {
2088 break;
2089 }
2090 tokio::task::yield_now().await;
2091 world.run_to_fixed_point();
2092 }
2093 assert_eq!(world.agent_status(e), Some(AgentStatus::Waiting));
2094
2095 let path = dir.path().join("run-ip").join("interactions.json");
2098 let mut sidecar = None;
2099 for _ in 0..200 {
2100 if let Ok(t) = std::fs::read_to_string(&path)
2101 && let Ok(s) =
2102 serde_json::from_str::<crate::interaction_points::InteractionPointState>(&t)
2103 {
2104 sidecar = Some(s);
2105 break;
2106 }
2107 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
2108 }
2109 let s = sidecar.expect("interaction-point sidecar flushed to disk");
2110 assert_eq!(s.cursor, 0);
2111 assert_eq!(s.round, 0);
2112 assert_eq!(s.body, "## Plan\n1. do it");
2113 }
2114
2115 #[tokio::test]
2116 async fn flush_and_stop_drains_queued_snapshots() {
2117 let dir = tempfile::tempdir().unwrap();
2121 let mut world = PipelineWorld::new(
2122 registry_with(vec![with_tool("c1", "do"), text("done")]),
2123 Arc::new(EchoTools),
2124 InferencePoolConfig::new(),
2125 1,
2126 Some(dir.path().to_path_buf()),
2127 Handle::current(),
2128 );
2129 world.spawn_agent((
2130 AgentBlueprint(blueprint()),
2131 StageCursor { index: 0 },
2132 agent_state(),
2133 crate::components::MessageInbox::default(),
2134 StageProgress::default(),
2135 StageInferences(vec![stage("m")]),
2136 StageSetups(vec![setup()]),
2137 VisitCounts::default(),
2138 window(),
2139 stage("m"),
2140 setup().inference_config,
2141 crate::persistence::RunMetadata {
2142 run_id: "run-flush".to_string(),
2143 agent_name: "a".to_string(),
2144 agent_path: "/p".to_string(),
2145 task: "t".to_string(),
2146 model: None,
2147 workdir: std::env::temp_dir().to_string_lossy().to_string(),
2149 num_stages: 1,
2150 started_at: 0,
2151 parent_run_id: None,
2152 metadata: std::collections::HashMap::new(),
2153 callback_url: None,
2154 callback_secret: None,
2155 title: None,
2156 unattended: false,
2157 read_paths: None,
2158 },
2159 crate::persistence::TokenTotals::default(),
2160 crate::pipeline::PersistWatermark::default(),
2161 ReadyToInfer,
2162 ));
2163
2164 world.run_until_idle(20).await;
2165 world.flush_and_stop().await;
2166
2167 let meta_path = dir.path().join("run-flush").join("meta.json");
2169 let text = std::fs::read_to_string(&meta_path).expect("meta.json flushed on stop");
2170 let meta: leviath_core::run_meta::RunMeta = serde_json::from_str(&text).unwrap();
2171 assert_eq!(meta.run_id, "run-flush");
2172 assert_eq!(meta.status, leviath_core::run_meta::RunStatus::Complete);
2173
2174 world.flush_and_stop().await;
2176 assert!(meta_path.exists());
2177 }
2178
2179 #[tokio::test]
2180 async fn in_memory_world_runs_and_flushes_without_touching_disk() {
2181 let dir = tempfile::tempdir().unwrap();
2187 let mut world = PipelineWorld::new(
2188 registry_with(vec![with_tool("c1", "do"), text("done")]),
2189 Arc::new(EchoTools),
2190 InferencePoolConfig::new(),
2191 1,
2192 None,
2193 Handle::current(),
2194 );
2195 let entity = world.spawn_agent((
2196 AgentBlueprint(blueprint()),
2197 StageCursor { index: 0 },
2198 agent_state(),
2199 crate::components::MessageInbox::default(),
2200 StageProgress::default(),
2201 StageInferences(vec![stage("m")]),
2202 StageSetups(vec![setup()]),
2203 VisitCounts::default(),
2204 window(),
2205 stage("m"),
2206 setup().inference_config,
2207 crate::persistence::RunMetadata {
2208 run_id: "run-inmem".to_string(),
2209 agent_name: "a".to_string(),
2210 agent_path: "/p".to_string(),
2211 task: "t".to_string(),
2212 model: None,
2213 workdir: dir.path().to_string_lossy().to_string(),
2214 num_stages: 1,
2215 started_at: 0,
2216 parent_run_id: None,
2217 metadata: std::collections::HashMap::new(),
2218 callback_url: None,
2219 callback_secret: None,
2220 title: None,
2221 unattended: false,
2222 read_paths: None,
2223 },
2224 crate::persistence::TokenTotals::default(),
2225 crate::pipeline::PersistWatermark::default(),
2226 ReadyToInfer,
2227 ));
2228
2229 world.run_until_idle(20).await;
2230 world.flush_and_stop().await;
2231
2232 assert_eq!(world.agent_status(entity), Some(AgentStatus::Complete));
2233 assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
2234 }
2235
2236 #[tokio::test]
2237 async fn world_init_and_restore_needs_no_daemon_infra() {
2238 use leviath_core::region::EntryKind;
2243 use leviath_core::run_meta::{ContextSnapshot, RegionEntrySnapshot, RegionSnapshot};
2244
2245 let dir = tempfile::tempdir().unwrap();
2246 let mut world = PipelineWorld::new(
2247 registry_with(vec![text("unused")]),
2248 Arc::new(EchoTools),
2249 InferencePoolConfig::new(),
2250 1,
2251 Some(dir.path().to_path_buf()),
2252 Handle::current(),
2253 );
2254 let entity = world.spawn_agent((
2255 AgentBlueprint(blueprint()),
2256 StageCursor { index: 0 },
2257 agent_state(),
2258 crate::components::MessageInbox::default(),
2259 StageProgress::default(),
2260 StageInferences(vec![stage("m")]),
2261 StageSetups(vec![setup()]),
2262 VisitCounts::default(),
2263 window(),
2264 stage("m"),
2265 setup().inference_config,
2266 crate::persistence::TokenTotals::default(),
2267 ));
2268
2269 let snapshot = ContextSnapshot {
2270 stage_name: "s0".to_string(),
2271 total_tokens: 4,
2272 max_tokens: 10_000,
2273 regions: vec![RegionSnapshot {
2274 name: "conversation".to_string(),
2275 kind: "clearable".to_string(),
2276 current_tokens: 4,
2277 max_tokens: 10_000,
2278 entries: vec![RegionEntrySnapshot {
2279 content: "restored turn".to_string(),
2280 tokens: 4,
2281 kind: EntryKind::UserMessage,
2282 metadata: None,
2283 key: None,
2284 taint: Default::default(),
2285 }],
2286 }],
2287 };
2288 crate::restore::restore_agent(
2289 world.world_mut(),
2290 entity,
2291 &snapshot,
2292 0,
2293 3,
2294 crate::persistence::TokenTotals::default(),
2295 );
2296
2297 let state = world
2298 .world()
2299 .get::<crate::components::AgentState>(entity)
2300 .unwrap();
2301 assert_eq!(state.status, AgentStatus::Active);
2302 assert_eq!(state.iteration, 3);
2303 let win = world
2304 .world()
2305 .get::<crate::components::ContextWindow>(entity)
2306 .unwrap();
2307 assert_eq!(
2308 win.get_region("conversation").unwrap().content[0].content,
2309 "restored turn"
2310 );
2311 }
2312
2313 #[tokio::test]
2314 async fn spawn_from_blueprint_errors_on_oversized_system_prompt() {
2315 let mut world = build_world(registry_with(vec![]));
2316 let layout = leviath_core::layout::ContextLayout::new(
2319 vec![leviath_core::layout::RegionDefinition::new(
2320 "task".to_string(),
2321 RegionKind::Pinned,
2322 50,
2323 )],
2324 1000,
2325 );
2326 let mut s = leviath_core::Stage::new(
2327 "s".to_string(),
2328 leviath_core::blueprint::ModelConfig::new("script".to_string(), "m".to_string()),
2329 );
2330 s.config.insert(
2331 "system_prompt".to_string(),
2332 serde_json::Value::String("x".repeat(100_000)),
2333 );
2334 let bp = leviath_core::Blueprint::new("t".to_string(), "d".to_string(), vec![s], layout);
2335
2336 let err = world.spawn_from_blueprint(
2337 "a".to_string(),
2338 bp,
2339 "task",
2340 vec![crate::pipeline::ResolvedStage {
2341 provider_name: "script".to_string(),
2342 model: "m".to_string(),
2343 tools: vec![],
2344 fallbacks: Vec::new(),
2345 }],
2346 hints(true),
2347 );
2348 assert!(err.is_err());
2349 }
2350
2351 #[tokio::test]
2352 async fn wake_handle_and_run_until_idle_bound_are_exposed() {
2353 let mut world = build_world(registry_with(vec![with_tool("c1", "do"), text("done")]));
2357 let _ = world.wake_handle();
2358 let e = spawn(&mut world);
2359 world.run_until_idle(0).await; world.run_until_idle(20).await;
2362 assert_eq!(world.agent_status(e), Some(AgentStatus::Complete));
2363 }
2364}