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, gate_requires_children,
46 handle_empty_response, poll_dynamic_tool_refresh, process_response, reflect_interaction_status,
47 refresh_advertised_tools, require_context_regions, resolve_transition, sync_tool_stages,
48};
49use crate::providers::ProviderRegistry;
50use crate::tool_bridge::spawn_tool_pool;
51
52type Fingerprint = [usize; 12];
55
56const MAX_TICK_FAILURES_PER_ROUND: usize = 8;
61
62fn tick_schedule() -> Schedule {
70 let mut schedule = Schedule::default();
71 schedule.set_executor(bevy_ecs::schedule::SingleThreadedExecutor::new());
74 schedule
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum TickOutcome {
80 Clean,
82 AgentFailed,
85 Unattributed,
88}
89
90pub struct PipelineWorld {
92 world: World,
93 schedule: Schedule,
94 wake: Arc<Notify>,
95 shutdown: Arc<Notify>,
96 msg_tx: UnboundedSender<AgentMessage>,
97 _tool_tasks: Vec<JoinHandle<()>>,
101 persist_task: Option<JoinHandle<()>>,
105}
106
107impl PipelineWorld {
108 pub fn new(
112 providers: ProviderRegistry,
113 tool_service: Arc<dyn ToolService>,
114 pool_config: InferencePoolConfig,
115 tool_concurrency: usize,
116 runs_dir: std::path::PathBuf,
117 runtime: Handle,
118 ) -> Self {
119 bevy_tasks::ComputeTaskPool::get_or_init(bevy_tasks::TaskPool::default);
124
125 let wake = Arc::new(Notify::new());
126 let shutdown = Arc::new(Notify::new());
127
128 let (inf_tx, inf_rx) = unbounded_channel();
129 let (trans_tx, trans_rx) = unbounded_channel();
130 let (compact_tx, compact_rx) = unbounded_channel();
131 let (tool_job_tx, tool_job_rx) = unbounded_channel();
132 let (tool_res_tx, tool_res_rx) = unbounded_channel();
133 let (persist_tx, persist_rx) = unbounded_channel();
134 let (msg_tx, msg_rx) = unbounded_channel();
135 let (ip_tx, ip_rx) = unbounded_channel();
136 let (gp_tx, gp_rx) = unbounded_channel();
137 let (cs_tx, cs_rx) = unbounded_channel();
138 let (title_tx, title_rx) = unbounded_channel();
139
140 let tool_tasks = spawn_tool_pool(
141 &runtime,
142 tool_job_rx,
143 tool_res_tx,
144 wake.clone(),
145 tool_concurrency,
146 );
147 let persist_task = runtime.spawn(persistence_worker(runs_dir, persist_rx));
151 let ip_runtime = runtime.clone();
152 let gp_runtime = runtime.clone();
153
154 let mut world = World::new();
155 world.insert_resource(Providers(providers));
156 world.insert_resource(InferenceStage {
157 pools: Arc::new(InferencePools::new(pool_config)),
158 outcomes: inf_tx,
159 transition_outcomes: trans_tx,
160 compaction_outcomes: compact_tx,
161 content_summary_outcomes: cs_tx,
162 wake: wake.clone(),
163 runtime,
164 exact_token_counting: false,
165 });
166 world.insert_resource(crate::context_transform::ContentSummaryResults(cs_rx));
167 world.insert_resource(crate::title::TitleSink(title_tx));
168 world.insert_resource(crate::title::TitleResults(title_rx));
169 world.insert_resource(crate::interaction_points::InteractionPointStage {
170 outcomes: ip_tx,
171 wake: wake.clone(),
172 runtime: ip_runtime,
173 });
174 world.insert_resource(crate::interaction_points::InteractionPointResults(ip_rx));
175 world.insert_resource(crate::gate_prompt::GatePromptStage {
176 outcomes: gp_tx,
177 wake: wake.clone(),
178 runtime: gp_runtime,
179 });
180 world.insert_resource(crate::gate_prompt::GatePromptResults(gp_rx));
181 world.insert_resource(InferenceResults(inf_rx));
182 world.insert_resource(TransitionResults(trans_rx));
183 world.insert_resource(CompactionResults(compact_rx));
184 world.insert_resource(ToolServiceRes(tool_service));
185 world.insert_resource(ToolStage(tool_job_tx));
186 world.insert_resource(ToolResults(tool_res_rx));
187 world.insert_resource(PersistenceStage(persist_tx));
188 world.insert_resource(MessageIntake(msg_rx));
189 world.insert_resource(crate::telemetry::Telemetry(std::sync::Arc::new(
192 leviath_core::telemetry::NoopSink,
193 )));
194
195 let mut schedule = tick_schedule();
198 schedule.add_systems(
199 (
200 abort_terminal_work,
205 deliver_messages,
206 collect_compaction,
207 crate::context_transform::collect_content_summary,
210 crate::context_transform::dispatch_content_summary,
211 dispatch_edge_compact,
214 dispatch_compaction,
215 enforce_max_iterations,
217 detect_stuck_stage,
221 check_workspace_health,
224 poll_dynamic_tool_refresh,
228 refresh_advertised_tools,
229 dispatch_inference,
230 collect_inference,
231 crate::fanout::fan_out_split,
233 process_response,
234 crate::gate_prompt::collect_gate_prompt,
237 dispatch_tools,
238 collect_tools,
239 crate::interaction_points::collect_interaction_point,
242 )
243 .chain(),
244 );
245 schedule.add_systems(
246 (
247 handle_empty_response,
248 gate_requires_children,
250 require_context_regions,
253 crate::interaction_points::gate_interaction_points,
256 crate::interaction_points::dispatch_interaction_point,
257 resolve_transition,
258 dispatch_transition_choice,
259 collect_transition_choice,
260 crate::fanout::fan_out_collect,
262 crate::telemetry::observe_lifecycle,
267 sync_tool_stages,
268 crate::title::collect_title,
272 crate::title::dispatch_title,
273 reflect_interaction_status,
277 dispatch_persistence,
278 )
279 .chain()
280 .after(crate::interaction_points::collect_interaction_point),
281 );
282
283 Self {
284 world,
285 schedule,
286 wake,
287 shutdown,
288 msg_tx,
289 _tool_tasks: tool_tasks,
290 persist_task: Some(persist_task),
291 }
292 }
293
294 pub fn world_mut(&mut self) -> &mut World {
297 &mut self.world
298 }
299
300 pub fn world(&self) -> &World {
302 &self.world
303 }
304
305 pub fn set_exact_token_counting(&mut self, enabled: bool) {
309 self.world
313 .resource_mut::<crate::pipeline::InferenceStage>()
314 .exact_token_counting = enabled;
315 }
316
317 pub fn insert_interaction_hub(&mut self, hub: crate::interaction_hub::InteractionHub) {
323 hub.attach_wake(self.wake.clone());
324 self.world.insert_resource(hub);
325 }
326
327 pub fn spawn_agent(&mut self, bundle: impl Bundle) -> Entity {
330 let e = self.world.spawn(bundle).id();
331 self.wake.notify_one();
332 e
333 }
334
335 pub fn spawn_from_blueprint(
339 &mut self,
340 agent_id: String,
341 blueprint: leviath_core::Blueprint,
342 task: &str,
343 stages: Vec<crate::pipeline::ResolvedStage>,
344 global_batch_tool_hint: bool,
345 ) -> Result<Entity, String> {
346 let e = crate::pipeline::spawn_agent(
347 &mut self.world,
348 agent_id,
349 blueprint,
350 task,
351 stages,
352 global_batch_tool_hint,
353 )?;
354 self.wake.notify_one();
355 Ok(e)
356 }
357
358 pub fn send_message(&self, msg: AgentMessage) -> Result<(), ProviderError> {
361 self.msg_tx
362 .send(msg)
363 .map_err(|e| ProviderError::Other(format!("world message channel closed: {e}")))?;
364 self.wake.notify_one();
365 Ok(())
366 }
367
368 pub fn wake_handle(&self) -> Arc<Notify> {
371 self.wake.clone()
372 }
373
374 pub fn shutdown(&self) {
376 self.shutdown.notify_one();
377 }
378
379 pub fn shutdown_handle(&self) -> Arc<Notify> {
382 self.shutdown.clone()
383 }
384
385 pub async fn flush_and_stop(&mut self) {
399 self.shutdown.notify_one();
401 self.run_to_fixed_point();
404 self.world.remove_resource::<PersistenceStage>();
407 if let Some(task) = self.persist_task.take() {
409 let _ = task.await;
410 }
411 self.world
415 .resource::<crate::telemetry::Telemetry>()
416 .0
417 .force_flush();
418 }
419
420 pub fn agent_status(&self, entity: Entity) -> Option<AgentStatus> {
422 self.world
423 .get::<AgentState>(entity)
424 .map(|s| s.status.clone())
425 }
426
427 pub fn set_status(&mut self, entity: Entity, status: AgentStatus) -> bool {
432 let Some(mut state) = self.world.get_mut::<AgentState>(entity) else {
433 return false;
434 };
435 state.status = status;
436 self.wake.notify_one();
437 true
438 }
439
440 pub fn pause(&mut self, entity: Entity) -> bool {
443 self.set_status(entity, AgentStatus::Idle)
444 }
445
446 pub fn resume(&mut self, entity: Entity) -> bool {
448 self.set_status(entity, AgentStatus::Active)
449 }
450
451 pub fn cancel(&mut self, entity: Entity) -> bool {
453 self.set_status(entity, AgentStatus::Cancelled)
454 }
455
456 pub fn tick(&mut self) -> TickOutcome {
466 let Err(panicked) = run_isolated(&mut self.schedule, &mut self.world) else {
467 return self.fail_agents_panicked_in_parallel();
471 };
472 let message = panic_status_message(&panicked.message);
473 match panicked.entity {
474 Some(entity) if self.set_status(entity, AgentStatus::Error { message }) => {
475 tracing::error!(
476 ?entity,
477 panic = %panicked.message,
478 "a pipeline system panicked; failing that agent - the daemon and every \
479 other run keep going"
480 );
481 TickOutcome::AgentFailed
482 }
483 _ => {
484 tracing::error!(
485 panic = %panicked.message,
486 "a pipeline system panicked outside any agent's scope; the daemon survived \
487 (an agent may be wedged - cancel it via `lev cancel <run-id>`)"
488 );
489 TickOutcome::Unattributed
490 }
491 }
492 }
493
494 fn fail_agents_panicked_in_parallel(&mut self) -> TickOutcome {
503 let mut query = self
504 .world
505 .query::<(Entity, &crate::tick_scope::PanickedInParallel)>();
506 let failed: Vec<(Entity, String)> = query
507 .iter(&self.world)
508 .map(|(entity, p)| (entity, p.message.clone()))
509 .collect();
510 if failed.is_empty() {
511 return TickOutcome::Clean;
512 }
513 for (entity, message) in failed {
514 self.world
515 .entity_mut(entity)
516 .remove::<crate::tick_scope::PanickedInParallel>();
517 let status = AgentStatus::Error {
518 message: panic_status_message(&message),
519 };
520 let _ = self.set_status(entity, status);
522 }
523 TickOutcome::AgentFailed
524 }
525
526 #[cfg(test)]
528 pub(crate) fn add_test_system<M>(
529 &mut self,
530 system: impl bevy_ecs::schedule::IntoScheduleConfigs<bevy_ecs::system::ScheduleSystem, M>,
534 ) {
535 self.schedule.add_systems(system);
536 }
537
538 fn count<F: QueryFilter>(&mut self) -> usize {
539 let mut q = self.world.query_filtered::<(), F>();
540 q.iter(&self.world).count()
541 }
542
543 fn fingerprint(&mut self) -> Fingerprint {
545 [
546 self.count::<With<ReadyToInfer>>(),
547 self.count::<With<AwaitingInference>>(),
548 self.count::<With<ProcessResponse>>(),
549 self.count::<With<ReadyForTools>>(),
550 self.count::<With<ReadyForTransition>>(),
551 self.count::<With<ResolveTransition>>(),
552 self.count::<With<AwaitingTools>>(),
553 self.count::<With<AwaitingTransitionChoice>>(),
554 self.count::<With<AwaitingTransitionResponse>>(),
555 self.count::<With<AwaitingCompaction>>(),
556 self.count::<With<crate::title::PendingTitle>>(),
557 self.count::<With<crate::title::AwaitingTitle>>(),
558 ]
559 }
560
561 fn has_async_inflight(&mut self) -> bool {
564 self.count::<With<AwaitingInference>>() > 0
565 || self.count::<With<AwaitingTools>>() > 0
566 || self.count::<With<AwaitingTransitionResponse>>() > 0
567 || self.count::<With<AwaitingCompaction>>() > 0
568 || self.count::<With<crate::title::AwaitingTitle>>() > 0
569 }
570
571 pub fn run_to_fixed_point(&mut self) {
574 let mut prev = self.fingerprint();
575 let mut failures = 0;
576 loop {
577 let outcome = self.tick();
578 match outcome {
579 TickOutcome::Clean => {}
580 TickOutcome::AgentFailed if failures < MAX_TICK_FAILURES_PER_ROUND => {
587 failures += 1;
588 }
589 TickOutcome::AgentFailed | TickOutcome::Unattributed => break,
595 }
596 let now = self.fingerprint();
597 if now == prev && outcome == TickOutcome::Clean {
602 break;
603 }
604 prev = now;
605 }
606 }
607
608 pub async fn run_until_idle(&mut self, max_waits: usize) {
614 self.run_to_fixed_point();
615 let mut waits = 0;
616 while self.has_async_inflight() && waits < max_waits {
617 self.wake.notified().await;
618 waits += 1;
619 self.run_to_fixed_point();
620 }
621 }
622
623 pub async fn run(&mut self) {
627 loop {
628 self.run_to_fixed_point();
629 tokio::select! {
630 _ = self.wake.notified() => {}
631 _ = self.shutdown.notified() => return,
632 }
633 }
634 }
635}
636
637fn panic_status_message(panic: &str) -> String {
641 format!("internal error: a pipeline system panicked: {panic}")
642}
643
644struct TickPanic {
646 entity: Option<Entity>,
649 message: String,
651}
652
653fn run_isolated(schedule: &mut Schedule, world: &mut World) -> Result<(), TickPanic> {
660 crate::tick_scope::clear();
663 match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| schedule.run(world))) {
664 Ok(()) => Ok(()),
665 Err(payload) => {
666 reset_executor(schedule);
667 Err(TickPanic {
668 entity: crate::tick_scope::current(),
669 message: leviath_core::panic_message(payload.as_ref()),
670 })
671 }
672 }
673}
674
675fn reset_executor(schedule: &mut Schedule) {
694 schedule.set_executor(bevy_ecs::schedule::SingleThreadedExecutor::new());
695}
696
697#[cfg(test)]
698mod tests {
699 use super::*;
700
701 use crate::test_support::PANIC_HOOK_LOCK;
704
705 fn with_silent_panics<T>(f: impl FnOnce() -> T) -> T {
708 let _hook_guard = PANIC_HOOK_LOCK
709 .lock()
710 .unwrap_or_else(std::sync::PoisonError::into_inner);
711 let prev_hook = std::panic::take_hook();
712 std::panic::set_hook(Box::new(|_| {}));
713 let out = f();
714 std::panic::set_hook(prev_hook);
715 out
716 }
717
718 #[test]
719 fn run_isolated_catches_a_system_panic_and_reports_the_agent() {
720 fn ok_system() {}
721 fn boom_system() {
722 panic!("simulated system panic");
723 }
724 fn boom_on_agent_system() {
727 crate::tick_scope::enter(
728 Entity::from_raw_u32(41)
729 .expect("a small literal index is always a valid entity id"),
730 );
731 panic!("agent-scoped panic");
732 }
733 let mut world = World::new();
734
735 let mut ok = tick_schedule();
737 ok.add_systems(ok_system);
738 assert!(run_isolated(&mut ok, &mut world).is_ok());
739
740 let mut bad = tick_schedule();
743 bad.add_systems(boom_system);
744 let err = with_silent_panics(|| run_isolated(&mut bad, &mut world))
745 .expect_err("the panic must be caught");
746 assert_eq!(err.entity, None);
747 assert_eq!(err.message, "simulated system panic");
748
749 let mut blamed = tick_schedule();
751 blamed.add_systems(boom_on_agent_system);
752 let err = with_silent_panics(|| run_isolated(&mut blamed, &mut world))
753 .expect_err("the panic must be caught");
754 assert_eq!(
755 err.entity,
756 Some(
757 Entity::from_raw_u32(41)
758 .expect("a small literal index is always a valid entity id")
759 )
760 );
761 assert_eq!(err.message, "agent-scoped panic");
762
763 assert!(run_isolated(&mut ok, &mut world).is_ok());
765 assert_eq!(crate::tick_scope::current(), None);
766 }
767
768 use crate::components::{AgentState, ContextWindow, InferenceConfig};
769 use crate::pipeline::{
770 AgentBlueprint, MessageIntake, StageCursor, StageInference, StageInferences, StageProgress,
771 StageSetup, StageSetups, VisitCounts,
772 };
773 use crate::tool_bridge::BoxedToolExec;
774 use leviath_core::{Region, RegionKind};
775 use leviath_providers::{
776 FinishReason, InferenceRequest, InferenceResponse, ModelCapabilities, Provider, TokenUsage,
777 ToolCall,
778 };
779 use std::sync::Mutex;
780
781 struct Script {
783 responses: Mutex<std::collections::VecDeque<InferenceResponse>>,
784 }
785
786 #[async_trait::async_trait]
787 impl Provider for Script {
788 async fn infer(
789 &self,
790 _req: InferenceRequest,
791 ) -> leviath_providers::Result<InferenceResponse> {
792 let next = self.responses.lock().unwrap().pop_front();
793 next.ok_or_else(|| ProviderError::Other("script exhausted".to_string()))
794 }
795 async fn count_tokens(&self, _t: &str, _m: &str) -> usize {
796 1
797 }
798 fn max_context_tokens(&self, _m: &str) -> usize {
799 100_000
800 }
801 fn name(&self) -> &str {
802 "script"
803 }
804 fn capabilities(&self, _m: &str) -> ModelCapabilities {
805 ModelCapabilities::default()
806 }
807 }
808
809 fn text(content: &str) -> InferenceResponse {
810 InferenceResponse {
811 content: content.to_string(),
812 tool_calls: vec![],
813 tokens_used: TokenUsage {
814 prompt_tokens: 1,
815 completion_tokens: 1,
816 total_tokens: 2,
817 cached_tokens: 0,
818 cache_write_tokens: 0,
819 },
820 finish_reason: FinishReason::Complete,
821 }
822 }
823
824 fn with_tool(id: &str, name: &str) -> InferenceResponse {
825 let mut r = text("");
826 r.tool_calls.push(ToolCall {
827 id: id.to_string(),
828 name: name.to_string(),
829 arguments: serde_json::json!({}),
830 thought_signature: None,
831 });
832 r
833 }
834
835 struct EchoTools;
837 impl ToolService for EchoTools {
838 fn exec_for(&self, _entity: Entity, calls: Vec<ToolCall>) -> BoxedToolExec {
839 Box::new(move || {
840 Box::pin(async move {
841 calls
842 .into_iter()
843 .map(|c| (c.id, "ok".to_string()))
844 .collect()
845 })
846 })
847 }
848 }
849
850 fn window() -> ContextWindow {
851 let mut w = ContextWindow::new(10_000);
852 w.add_region(Region::new("sys".to_string(), RegionKind::Pinned, 2000));
853 w.add_region(Region::new(
854 "conversation".to_string(),
855 RegionKind::Clearable,
856 10_000,
857 ));
858 w.add_region(Region::new(
859 "tool_results".to_string(),
860 RegionKind::Temporary,
861 5000,
862 ));
863 w
864 }
865
866 fn agent_state() -> AgentState {
867 AgentState {
868 agent_id: "a".to_string(),
869 current_stage: "s".to_string(),
870 iteration: 0,
871 status: AgentStatus::Active,
872 spawned_children_ids: vec![],
873 pending_wait: None,
874 accepts_messages: true,
875 }
876 }
877
878 fn stage(model: &str) -> StageInference {
885 StageInference {
886 provider_name: "script".to_string(),
887 model: model.to_string(),
888 tools: ["do", "read"]
889 .iter()
890 .map(|n| leviath_providers::Tool {
891 name: (*n).to_string(),
892 description: String::new(),
893 parameters: serde_json::json!({}),
894 })
895 .collect(),
896 tool_filter: None,
897 }
898 }
899
900 fn setup() -> StageSetup {
901 StageSetup {
902 inference_config: InferenceConfig {
903 temperature: None,
904 max_output_tokens: None,
905 extra_params: Default::default(),
906 batch_tool_hint: false,
907 request_timeout_secs: None,
908 },
909 routing: None,
910 accepts_messages: true,
911 context_layout: None,
912 system_prompt: None,
913 }
914 }
915
916 fn blueprint() -> leviath_core::Blueprint {
917 let layout = leviath_core::layout::ContextLayout::new(
918 vec![leviath_core::layout::RegionDefinition::new(
919 "conversation".to_string(),
920 RegionKind::Clearable,
921 10_000,
922 )],
923 12_000,
924 );
925 let s = leviath_core::Stage::new(
926 "s".to_string(),
927 leviath_core::blueprint::ModelConfig::new("script".to_string(), "m".to_string()),
928 );
929 leviath_core::Blueprint::new("t".to_string(), "d".to_string(), vec![s], layout)
930 }
931
932 fn spawn(world: &mut PipelineWorld) -> Entity {
934 world.spawn_agent((
935 AgentBlueprint(blueprint()),
936 StageCursor { index: 0 },
937 agent_state(),
938 crate::components::MessageInbox::default(),
939 StageProgress::default(),
940 StageInferences(vec![stage("m")]),
941 StageSetups(vec![setup()]),
942 VisitCounts::default(),
943 window(),
944 stage("m"),
945 setup().inference_config,
946 ReadyToInfer,
947 ))
948 }
949
950 fn build_world(providers: ProviderRegistry) -> PipelineWorld {
951 PipelineWorld::new(
954 providers,
955 Arc::new(EchoTools),
956 InferencePoolConfig::new(),
957 1,
958 std::env::temp_dir(),
959 Handle::current(),
960 )
961 }
962
963 #[tokio::test]
964 async fn set_exact_token_counting_toggles_the_stage_flag() {
965 let mut world = build_world(ProviderRegistry::new());
966 assert!(
968 !world
969 .world()
970 .resource::<crate::pipeline::InferenceStage>()
971 .exact_token_counting
972 );
973 world.set_exact_token_counting(true);
974 assert!(
975 world
976 .world()
977 .resource::<crate::pipeline::InferenceStage>()
978 .exact_token_counting
979 );
980 }
981
982 #[tokio::test]
983 async fn run_to_fixed_point_survives_a_panicking_system() {
984 fn boom_system() {
987 panic!("simulated system panic");
988 }
989 let mut world = build_world(ProviderRegistry::new());
990 world.add_test_system(boom_system);
991 with_silent_panics(|| world.run_to_fixed_point());
993 }
994
995 #[tokio::test]
996 async fn a_panic_on_the_compute_pool_is_attributed_to_its_agent() {
997 fn boom_in_parallel(
1003 agents: Query<(Entity, &AgentState)>,
1004 par_commands: bevy_ecs::system::ParallelCommands,
1005 ) {
1006 agents.par_iter().for_each(|(entity, state)| {
1007 if state.status != AgentStatus::Active {
1008 return; }
1010 crate::tick_scope::clear();
1013 crate::tick_scope::run_agent_parallel(entity, &par_commands, &mut || {
1014 panic!("blew up on the compute pool");
1015 });
1016 });
1017 }
1018
1019 let mut world = build_world(ProviderRegistry::new());
1020 let entity = spawn(&mut world);
1021 world.add_test_system(boom_in_parallel);
1022 with_silent_panics(|| world.run_to_fixed_point());
1023
1024 let status = world.agent_status(entity);
1025 assert!(
1026 matches!(status, Some(AgentStatus::Error { ref message })
1027 if message.contains("a pipeline system panicked")
1028 && message.contains("blew up on the compute pool")),
1029 "got: {status:?}"
1030 );
1031 assert!(
1033 world
1034 .world()
1035 .entity(entity)
1036 .get::<crate::tick_scope::PanickedInParallel>()
1037 .is_none(),
1038 "the marker must be drained once acted on"
1039 );
1040 }
1041
1042 #[tokio::test]
1043 async fn a_panicking_system_fails_its_agent_instead_of_looping_forever() {
1044 static VICTIM: std::sync::Mutex<Option<Entity>> = std::sync::Mutex::new(None);
1050 static PANICS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
1051
1052 fn boom_on_active_agent(agents: Query<(Entity, &AgentState)>) {
1053 let Some((entity, _)) = agents
1056 .iter()
1057 .find(|(_, state)| state.status == AgentStatus::Active)
1058 else {
1059 return; };
1061 crate::tick_scope::enter(entity);
1062 *VICTIM
1063 .lock()
1064 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(entity);
1065 PANICS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1066 panic!("blew up on this agent");
1067 }
1068
1069 let mut world = build_world(ProviderRegistry::new());
1070 let entity = spawn(&mut world);
1071 world.add_test_system(boom_on_active_agent);
1072 with_silent_panics(|| world.run_to_fixed_point());
1073
1074 let victim = VICTIM
1075 .lock()
1076 .unwrap_or_else(std::sync::PoisonError::into_inner)
1077 .take();
1078 assert_eq!(victim, Some(entity), "the system saw the spawned agent");
1079 let status = world.agent_status(entity);
1080 assert!(
1081 matches!(status, Some(AgentStatus::Error { ref message })
1082 if message.contains("a pipeline system panicked")
1083 && message.contains("blew up on this agent")),
1084 "got: {status:?}"
1085 );
1086 assert!(
1088 PANICS.load(std::sync::atomic::Ordering::SeqCst) <= MAX_TICK_FAILURES_PER_ROUND + 1,
1089 "the panic budget must stop the round"
1090 );
1091 }
1092
1093 fn registry_with(responses: Vec<InferenceResponse>) -> ProviderRegistry {
1094 let mut r = ProviderRegistry::new();
1095 r.register(
1096 "script".to_string(),
1097 Arc::new(Script {
1098 responses: Mutex::new(responses.into_iter().collect()),
1099 }),
1100 );
1101 r
1102 }
1103
1104 #[tokio::test]
1105 async fn agent_completes_after_nudges_exhausted() {
1106 let mut world = build_world(registry_with(vec![
1111 text("thinking"),
1112 text("still"),
1113 text("more"),
1114 text("final"),
1115 ]));
1116 let e = spawn(&mut world);
1117
1118 world.run_until_idle(30).await;
1119
1120 assert_eq!(world.agent_status(e), Some(AgentStatus::Complete));
1121 }
1122
1123 #[tokio::test]
1124 async fn agent_runs_tools_then_completes() {
1125 let mut world = build_world(registry_with(vec![with_tool("c1", "do"), text("done")]));
1128 let e = spawn(&mut world);
1129
1130 world.run_until_idle(20).await;
1131
1132 assert_eq!(world.agent_status(e), Some(AgentStatus::Complete));
1133 assert!(
1136 world
1137 .world()
1138 .get::<ContextWindow>(e)
1139 .unwrap()
1140 .get_region("conversation")
1141 .unwrap()
1142 .current_tokens
1143 > 0
1144 );
1145 }
1146
1147 #[tokio::test]
1148 async fn insert_interaction_hub_installs_resource_and_attaches_wake() {
1149 use crate::dynamic_interaction::InteractionBackend;
1150 use crate::interaction_hub::InteractionHub;
1151 let mut world = build_world(registry_with(vec![]));
1152 let hub = InteractionHub::new();
1153 world.insert_interaction_hub(hub.clone());
1154
1155 assert!(world.world().get_resource::<InteractionHub>().is_some());
1157
1158 let backend = hub.backend_for("x");
1161 let asking = tokio::spawn(async move {
1162 backend
1163 .ask(leviath_core::interaction::InteractionRequest::free_text(
1164 "q", "p", "s", true,
1165 ))
1166 .await
1167 });
1168 for _ in 0..8 {
1169 tokio::task::yield_now().await;
1170 }
1171 world.wake_handle().notified().await;
1172 hub.cancel("q");
1173 let _ = asking.await;
1174 }
1175
1176 #[tokio::test]
1177 async fn provider_error_marks_agent_error() {
1178 let mut world = build_world(registry_with(vec![]));
1180 let e = spawn(&mut world);
1181
1182 world.run_until_idle(20).await;
1183
1184 assert_eq!(
1185 std::mem::discriminant(&world.agent_status(e).unwrap()),
1186 std::mem::discriminant(&AgentStatus::Error {
1187 message: String::new()
1188 })
1189 );
1190 }
1191
1192 #[tokio::test]
1193 async fn send_message_reaches_the_agent_inbox() {
1194 let mut world = build_world(registry_with(vec![]));
1197 let e = spawn(&mut world);
1198 world.run_until_idle(20).await;
1200
1201 world
1202 .send_message(AgentMessage {
1203 agent_id: "a".to_string(),
1204 content: "hello".to_string(),
1205 target_region: Some("conversation".to_string()),
1206 priority: 0,
1207 })
1208 .unwrap();
1209 world.tick(); assert!(
1212 world
1213 .world()
1214 .get::<ContextWindow>(e)
1215 .unwrap()
1216 .get_region("conversation")
1217 .unwrap()
1218 .current_tokens
1219 > 0
1220 );
1221 }
1222
1223 #[tokio::test]
1224 async fn run_returns_on_shutdown() {
1225 let mut world = build_world(registry_with(vec![text("done")]));
1226 spawn(&mut world);
1227 world.shutdown(); world.run().await;
1230 }
1231
1232 #[tokio::test]
1233 async fn run_wakes_then_shuts_down() {
1234 let mut world = build_world(registry_with(vec![
1237 text("t1"),
1238 text("t2"),
1239 text("t3"),
1240 text("t4"),
1241 ]));
1242 spawn(&mut world);
1243 let wake = world.wake_handle();
1244 let shutdown = world.shutdown_handle();
1245 let handle = tokio::spawn(async move { world.run().await });
1246
1247 wake.notify_one();
1248 tokio::task::yield_now().await;
1249 shutdown.notify_one();
1250
1251 handle.await.unwrap(); }
1253
1254 #[tokio::test]
1255 async fn send_message_errors_when_intake_dropped() {
1256 let mut world = build_world(registry_with(vec![]));
1257 let removed = world.world_mut().remove_resource::<MessageIntake>();
1259 drop(removed);
1260
1261 let err = world.send_message(AgentMessage {
1262 agent_id: "a".to_string(),
1263 content: "x".to_string(),
1264 target_region: None,
1265 priority: 0,
1266 });
1267 assert!(err.is_err());
1268 }
1269
1270 #[tokio::test]
1271 async fn script_provider_metadata_is_exercised() {
1272 let p = Script {
1274 responses: Mutex::new(std::collections::VecDeque::new()),
1275 };
1276 assert_eq!(p.name(), "script");
1277 assert_eq!(p.count_tokens("t", "m").await, 1);
1278 assert_eq!(p.max_context_tokens("m"), 100_000);
1279 let _ = p.capabilities("m");
1280 }
1281
1282 #[tokio::test]
1283 async fn agent_status_is_none_for_unknown_entity() {
1284 let world = build_world(registry_with(vec![]));
1285 assert_eq!(
1286 world.agent_status(
1287 Entity::from_raw_u32(999)
1288 .expect("a small literal index is always a valid entity id")
1289 ),
1290 None
1291 );
1292 }
1293
1294 #[tokio::test]
1295 async fn paused_agent_does_not_progress_until_resumed() {
1296 let mut world = build_world(registry_with(vec![
1297 text("t1"),
1298 text("t2"),
1299 text("t3"),
1300 text("t4"),
1301 ]));
1302 let e = spawn(&mut world);
1303 assert!(world.pause(e));
1304
1305 world.run_until_idle(30).await;
1306 assert_eq!(world.agent_status(e), Some(AgentStatus::Idle));
1308
1309 assert!(world.resume(e));
1310 world.run_until_idle(30).await;
1311 assert_eq!(world.agent_status(e), Some(AgentStatus::Complete));
1312 }
1313
1314 #[tokio::test]
1315 async fn cancelled_agent_stops_progressing() {
1316 let mut world = build_world(registry_with(vec![with_tool("c1", "do"), text("done")]));
1317 let e = spawn(&mut world);
1318 assert!(world.cancel(e));
1319
1320 world.run_until_idle(20).await;
1321
1322 assert_eq!(world.agent_status(e), Some(AgentStatus::Cancelled));
1323 }
1324
1325 #[tokio::test]
1326 async fn status_ops_return_false_for_unknown_entity() {
1327 let mut world = build_world(registry_with(vec![]));
1328 assert!(!world.pause(
1329 Entity::from_raw_u32(999).expect("a small literal index is always a valid entity id")
1330 ));
1331 assert!(!world.resume(
1332 Entity::from_raw_u32(999).expect("a small literal index is always a valid entity id")
1333 ));
1334 assert!(!world.cancel(
1335 Entity::from_raw_u32(999).expect("a small literal index is always a valid entity id")
1336 ));
1337 }
1338
1339 #[tokio::test]
1340 async fn spawn_from_blueprint_builds_a_runnable_agent() {
1341 let mut world = build_world(registry_with(vec![with_tool("c1", "do"), text("done")]));
1343 let e = world
1344 .spawn_from_blueprint(
1345 "agent-1".to_string(),
1346 blueprint(),
1347 "do the task",
1348 vec![crate::pipeline::ResolvedStage {
1349 provider_name: "script".to_string(),
1350 model: "m".to_string(),
1351 tools: vec![],
1352 }],
1353 true,
1354 )
1355 .unwrap();
1356
1357 world.run_until_idle(20).await;
1358
1359 assert_eq!(world.agent_status(e), Some(AgentStatus::Complete));
1360 }
1361
1362 #[tokio::test]
1363 async fn persists_agent_snapshot_to_runs_dir() {
1364 let dir = tempfile::tempdir().unwrap();
1367 let mut world = PipelineWorld::new(
1368 registry_with(vec![with_tool("c1", "do"), text("done")]),
1369 Arc::new(EchoTools),
1370 InferencePoolConfig::new(),
1371 1,
1372 dir.path().to_path_buf(),
1373 Handle::current(),
1374 );
1375 world.spawn_agent((
1376 AgentBlueprint(blueprint()),
1377 StageCursor { index: 0 },
1378 agent_state(),
1379 crate::components::MessageInbox::default(),
1380 StageProgress::default(),
1381 StageInferences(vec![stage("m")]),
1382 StageSetups(vec![setup()]),
1383 VisitCounts::default(),
1384 window(),
1385 stage("m"),
1386 setup().inference_config,
1387 crate::persistence::RunMetadata {
1388 run_id: "run-42".to_string(),
1389 agent_name: "a".to_string(),
1390 agent_path: "/p".to_string(),
1391 task: "t".to_string(),
1392 model: None,
1393 workdir: std::env::temp_dir().to_string_lossy().to_string(),
1395 num_stages: 1,
1396 started_at: 0,
1397 parent_run_id: None,
1398 metadata: std::collections::HashMap::new(),
1399 callback_url: None,
1400 callback_secret: None,
1401 title: None,
1402 },
1403 crate::persistence::TokenTotals::default(),
1404 crate::pipeline::PersistWatermark::default(),
1405 ReadyToInfer,
1406 ));
1407
1408 world.run_until_idle(20).await;
1409
1410 let meta_path = dir.path().join("run-42").join("meta.json");
1416 let mut meta = None;
1417 for _ in 0..200 {
1418 if let Ok(text) = std::fs::read_to_string(&meta_path)
1419 && let Ok(m) = serde_json::from_str::<leviath_core::run_meta::RunMeta>(&text)
1420 && m.status == leviath_core::run_meta::RunStatus::Complete
1421 {
1422 meta = Some(m);
1423 break;
1424 }
1425 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1426 }
1427
1428 let meta = meta.expect("final Complete snapshot flushed to disk");
1429 assert_eq!(meta.run_id, "run-42");
1430 assert!(dir.path().join("run-42").join("context.json").exists());
1431 }
1432
1433 #[tokio::test]
1434 async fn a_panicked_agent_is_recorded_as_errored_on_disk() {
1435 fn boom_on_active_agent(agents: Query<(Entity, &AgentState)>) {
1440 let Some((entity, _)) = agents
1441 .iter()
1442 .find(|(_, state)| state.status == AgentStatus::Active)
1443 else {
1444 return; };
1446 crate::tick_scope::enter(entity);
1447 panic!("exploded mid-stage");
1448 }
1449
1450 let dir = tempfile::tempdir().unwrap();
1451 let mut world = PipelineWorld::new(
1452 registry_with(vec![]),
1453 Arc::new(EchoTools),
1454 InferencePoolConfig::new(),
1455 1,
1456 dir.path().to_path_buf(),
1457 Handle::current(),
1458 );
1459 world.spawn_agent((
1460 AgentBlueprint(blueprint()),
1461 StageCursor { index: 0 },
1462 agent_state(),
1463 crate::components::MessageInbox::default(),
1464 StageProgress::default(),
1465 StageInferences(vec![stage("m")]),
1466 StageSetups(vec![setup()]),
1467 VisitCounts::default(),
1468 window(),
1469 stage("m"),
1470 setup().inference_config,
1471 crate::persistence::RunMetadata {
1472 run_id: "run-boom".to_string(),
1473 agent_name: "a".to_string(),
1474 agent_path: "/p".to_string(),
1475 task: "t".to_string(),
1476 model: None,
1477 workdir: "/w".to_string(),
1478 num_stages: 1,
1479 started_at: 0,
1480 parent_run_id: None,
1481 metadata: std::collections::HashMap::new(),
1482 callback_url: None,
1483 callback_secret: None,
1484 title: None,
1485 },
1486 crate::persistence::TokenTotals::default(),
1487 crate::pipeline::PersistWatermark::default(),
1488 ReadyToInfer,
1489 ));
1490 world.add_test_system(boom_on_active_agent);
1491 with_silent_panics(|| world.run_to_fixed_point());
1492
1493 let meta_path = dir.path().join("run-boom").join("meta.json");
1494 let mut meta = None;
1495 for _ in 0..200 {
1496 if let Ok(text) = std::fs::read_to_string(&meta_path)
1497 && let Ok(m) = serde_json::from_str::<leviath_core::run_meta::RunMeta>(&text)
1498 && m.status == leviath_core::run_meta::RunStatus::Error
1499 {
1500 meta = Some(m);
1501 break;
1502 }
1503 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1504 }
1505 let meta = meta.expect("the panicked run must be persisted as errored");
1506 let error = meta.error.unwrap_or_default();
1507 assert!(error.contains("a pipeline system panicked"), "got: {error}");
1508 assert!(error.contains("exploded mid-stage"), "got: {error}");
1509 }
1510
1511 fn interactive_blueprint() -> leviath_core::Blueprint {
1514 use leviath_core::blueprint::{InteractionPoint, InteractionStyle, StageMode};
1515 let layout = leviath_core::layout::ContextLayout::new(
1516 vec![leviath_core::layout::RegionDefinition::new(
1517 "conversation".to_string(),
1518 RegionKind::Clearable,
1519 10_000,
1520 )],
1521 12_000,
1522 );
1523 let mut s = leviath_core::Stage::new(
1524 "plan".to_string(),
1525 leviath_core::blueprint::ModelConfig::new("script".to_string(), "m".to_string()),
1526 );
1527 s.mode = StageMode::InteractivePoints {
1528 points: vec![InteractionPoint {
1529 name: "plan_approval".to_string(),
1530 prompt: "Approve?".to_string(),
1531 required: true,
1532 style: InteractionStyle::MultipleChoice,
1533 options: vec!["Approve".to_string(), "Abort".to_string()],
1534 directives: std::collections::HashMap::new(),
1535 abort_options: vec!["Abort".to_string()],
1536 edit_options: vec![],
1537 document_region: None,
1538 }],
1539 };
1540 leviath_core::Blueprint::new("t".to_string(), "d".to_string(), vec![s], layout)
1541 }
1542
1543 #[tokio::test]
1544 async fn persists_interaction_point_when_a_live_agent_blocks() {
1545 let dir = tempfile::tempdir().unwrap();
1551 let mut world = PipelineWorld::new(
1552 registry_with(vec![with_tool("c1", "read"), text("## Plan\n1. do it")]),
1553 Arc::new(EchoTools),
1554 InferencePoolConfig::new(),
1555 1,
1556 dir.path().to_path_buf(),
1557 Handle::current(),
1558 );
1559 world.insert_interaction_hub(crate::interaction_hub::InteractionHub::new());
1560 let e = world.spawn_agent((
1561 AgentBlueprint(interactive_blueprint()),
1562 StageCursor { index: 0 },
1563 agent_state(),
1564 crate::components::MessageInbox::default(),
1565 StageProgress::default(),
1566 StageInferences(vec![stage("m")]),
1567 StageSetups(vec![setup()]),
1568 VisitCounts::default(),
1569 window(),
1570 stage("m"),
1571 setup().inference_config,
1572 crate::persistence::RunMetadata {
1573 run_id: "run-ip".to_string(),
1574 agent_name: "a".to_string(),
1575 agent_path: "/p".to_string(),
1576 task: "t".to_string(),
1577 model: None,
1578 workdir: std::env::temp_dir().to_string_lossy().to_string(),
1580 num_stages: 1,
1581 started_at: 0,
1582 parent_run_id: None,
1583 metadata: std::collections::HashMap::new(),
1584 callback_url: None,
1585 callback_secret: None,
1586 title: None,
1587 },
1588 crate::persistence::TokenTotals::default(),
1589 crate::pipeline::PersistWatermark::default(),
1590 ReadyToInfer,
1591 ));
1592
1593 world.run_until_idle(30).await;
1594 for _ in 0..50 {
1600 if world.agent_status(e) == Some(AgentStatus::Waiting) {
1601 break;
1602 }
1603 tokio::task::yield_now().await;
1604 world.run_to_fixed_point();
1605 }
1606 assert_eq!(world.agent_status(e), Some(AgentStatus::Waiting));
1607
1608 let path = dir.path().join("run-ip").join("interactions.json");
1611 let mut sidecar = None;
1612 for _ in 0..200 {
1613 if let Ok(t) = std::fs::read_to_string(&path)
1614 && let Ok(s) =
1615 serde_json::from_str::<crate::interaction_points::InteractionPointState>(&t)
1616 {
1617 sidecar = Some(s);
1618 break;
1619 }
1620 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1621 }
1622 let s = sidecar.expect("interaction-point sidecar flushed to disk");
1623 assert_eq!(s.cursor, 0);
1624 assert_eq!(s.round, 0);
1625 assert_eq!(s.body, "## Plan\n1. do it");
1626 }
1627
1628 #[tokio::test]
1629 async fn flush_and_stop_drains_queued_snapshots() {
1630 let dir = tempfile::tempdir().unwrap();
1634 let mut world = PipelineWorld::new(
1635 registry_with(vec![with_tool("c1", "do"), text("done")]),
1636 Arc::new(EchoTools),
1637 InferencePoolConfig::new(),
1638 1,
1639 dir.path().to_path_buf(),
1640 Handle::current(),
1641 );
1642 world.spawn_agent((
1643 AgentBlueprint(blueprint()),
1644 StageCursor { index: 0 },
1645 agent_state(),
1646 crate::components::MessageInbox::default(),
1647 StageProgress::default(),
1648 StageInferences(vec![stage("m")]),
1649 StageSetups(vec![setup()]),
1650 VisitCounts::default(),
1651 window(),
1652 stage("m"),
1653 setup().inference_config,
1654 crate::persistence::RunMetadata {
1655 run_id: "run-flush".to_string(),
1656 agent_name: "a".to_string(),
1657 agent_path: "/p".to_string(),
1658 task: "t".to_string(),
1659 model: None,
1660 workdir: std::env::temp_dir().to_string_lossy().to_string(),
1662 num_stages: 1,
1663 started_at: 0,
1664 parent_run_id: None,
1665 metadata: std::collections::HashMap::new(),
1666 callback_url: None,
1667 callback_secret: None,
1668 title: None,
1669 },
1670 crate::persistence::TokenTotals::default(),
1671 crate::pipeline::PersistWatermark::default(),
1672 ReadyToInfer,
1673 ));
1674
1675 world.run_until_idle(20).await;
1676 world.flush_and_stop().await;
1677
1678 let meta_path = dir.path().join("run-flush").join("meta.json");
1680 let text = std::fs::read_to_string(&meta_path).expect("meta.json flushed on stop");
1681 let meta: leviath_core::run_meta::RunMeta = serde_json::from_str(&text).unwrap();
1682 assert_eq!(meta.run_id, "run-flush");
1683 assert_eq!(meta.status, leviath_core::run_meta::RunStatus::Complete);
1684
1685 world.flush_and_stop().await;
1687 assert!(meta_path.exists());
1688 }
1689
1690 #[tokio::test]
1691 async fn world_init_and_restore_needs_no_daemon_infra() {
1692 use leviath_core::region::EntryKind;
1697 use leviath_core::run_meta::{ContextSnapshot, RegionEntrySnapshot, RegionSnapshot};
1698
1699 let dir = tempfile::tempdir().unwrap();
1700 let mut world = PipelineWorld::new(
1701 registry_with(vec![text("unused")]),
1702 Arc::new(EchoTools),
1703 InferencePoolConfig::new(),
1704 1,
1705 dir.path().to_path_buf(),
1706 Handle::current(),
1707 );
1708 let entity = world.spawn_agent((
1709 AgentBlueprint(blueprint()),
1710 StageCursor { index: 0 },
1711 agent_state(),
1712 crate::components::MessageInbox::default(),
1713 StageProgress::default(),
1714 StageInferences(vec![stage("m")]),
1715 StageSetups(vec![setup()]),
1716 VisitCounts::default(),
1717 window(),
1718 stage("m"),
1719 setup().inference_config,
1720 crate::persistence::TokenTotals::default(),
1721 ));
1722
1723 let snapshot = ContextSnapshot {
1724 stage_name: "s0".to_string(),
1725 total_tokens: 4,
1726 max_tokens: 10_000,
1727 regions: vec![RegionSnapshot {
1728 name: "conversation".to_string(),
1729 kind: "clearable".to_string(),
1730 current_tokens: 4,
1731 max_tokens: 10_000,
1732 entries: vec![RegionEntrySnapshot {
1733 content: "restored turn".to_string(),
1734 tokens: 4,
1735 kind: EntryKind::UserMessage,
1736 metadata: None,
1737 key: None,
1738 taint: Default::default(),
1739 }],
1740 }],
1741 };
1742 crate::restore::restore_agent(
1743 world.world_mut(),
1744 entity,
1745 &snapshot,
1746 0,
1747 3,
1748 crate::persistence::TokenTotals::default(),
1749 );
1750
1751 let state = world
1752 .world()
1753 .get::<crate::components::AgentState>(entity)
1754 .unwrap();
1755 assert_eq!(state.status, AgentStatus::Active);
1756 assert_eq!(state.iteration, 3);
1757 let win = world
1758 .world()
1759 .get::<crate::components::ContextWindow>(entity)
1760 .unwrap();
1761 assert_eq!(
1762 win.get_region("conversation").unwrap().content[0].content,
1763 "restored turn"
1764 );
1765 }
1766
1767 #[tokio::test]
1768 async fn spawn_from_blueprint_errors_on_oversized_system_prompt() {
1769 let mut world = build_world(registry_with(vec![]));
1770 let layout = leviath_core::layout::ContextLayout::new(
1773 vec![leviath_core::layout::RegionDefinition::new(
1774 "task".to_string(),
1775 RegionKind::Pinned,
1776 50,
1777 )],
1778 1000,
1779 );
1780 let mut s = leviath_core::Stage::new(
1781 "s".to_string(),
1782 leviath_core::blueprint::ModelConfig::new("script".to_string(), "m".to_string()),
1783 );
1784 s.config.insert(
1785 "system_prompt".to_string(),
1786 serde_json::Value::String("x".repeat(100_000)),
1787 );
1788 let bp = leviath_core::Blueprint::new("t".to_string(), "d".to_string(), vec![s], layout);
1789
1790 let err = world.spawn_from_blueprint(
1791 "a".to_string(),
1792 bp,
1793 "task",
1794 vec![crate::pipeline::ResolvedStage {
1795 provider_name: "script".to_string(),
1796 model: "m".to_string(),
1797 tools: vec![],
1798 }],
1799 true,
1800 );
1801 assert!(err.is_err());
1802 }
1803
1804 #[tokio::test]
1805 async fn wake_handle_and_run_until_idle_bound_are_exposed() {
1806 let mut world = build_world(registry_with(vec![with_tool("c1", "do"), text("done")]));
1810 let _ = world.wake_handle();
1811 let e = spawn(&mut world);
1812 world.run_until_idle(0).await; world.run_until_idle(20).await;
1815 assert_eq!(world.agent_status(e), Some(AgentStatus::Complete));
1816 }
1817}