1use std::sync::Arc;
9
10use leviath_providers::Tool;
11use leviath_runtime::ProviderRegistry;
12use leviath_runtime::host::WorldHost;
13use leviath_runtime::inference_pool::InferencePoolConfig;
14use leviath_runtime::interaction_hub::InteractionHub;
15use leviath_runtime::world::PipelineWorld;
16use tokio::runtime::Handle;
17use tokio::sync::Mutex;
18
19use leviath_runtime::fanout::FanOutSpawnerRes;
20
21use crate::commands::run::session::build_provider_registry_from_config;
22use crate::config::Config;
23use crate::daemon::fanout_spawner::DaemonFanOutSpawner;
24use crate::daemon::spawn::build_agent;
25use crate::daemon::tool_service::CliToolService;
26use crate::tools::ToolRegistry;
27
28pub fn control_address() -> Option<leviath_runtime::control_socket::ControlId> {
32 control_dir().map(|dir| leviath_runtime::control_socket::control_id(&dir))
33}
34
35pub fn control_dir() -> Option<std::path::PathBuf> {
40 leviath_core::paths::data_dir()
41}
42
43pub const CURRENT_BUILD: &str = env!("LEVIATH_BUILD");
48
49pub fn build_marker_path() -> Option<std::path::PathBuf> {
52 leviath_core::paths::data_dir().map(|d| d.join("daemon.build"))
53}
54
55pub fn write_build_marker() {
58 build_marker_path().into_iter().for_each(|path| {
62 let _ = path.parent().map(std::fs::create_dir_all);
63 let _ = std::fs::write(&path, CURRENT_BUILD);
64 });
65}
66
67pub fn read_build_marker() -> Option<String> {
69 build_marker_path()
70 .and_then(|path| std::fs::read_to_string(path).ok())
71 .map(|s| s.trim().to_string())
72}
73
74pub fn daemon_build_is_stale(recorded: Option<&str>) -> bool {
78 recorded != Some(CURRENT_BUILD)
79}
80
81pub async fn setup_daemon_host(
85 config: Config,
86 runs_dir: std::path::PathBuf,
87 runtime: Handle,
88) -> WorldHost {
89 crate::daemon::script_host::set_local_network_allowed(config.security.allow_local_network);
94 let providers = build_provider_registry_from_config(&config);
95 let registry = ToolRegistry::build(std::env::temp_dir(), &config).await;
98 let mcp_pool = crate::daemon::mcp_pool::McpPool::for_daemon_with(
104 registry.mcp.clone(),
105 &config.mcp_servers,
106 config.security.credential_store,
107 config.security.allow_env_vars.clone(),
108 );
109 mcp_pool.warm_recovered(&runs_dir).await;
110 build_host(
111 config,
112 providers,
113 runs_dir,
114 registry.mcp,
115 registry.mcp_tool_defs,
116 mcp_pool,
117 runtime,
118 || chrono::Utc::now().timestamp(),
119 )
120}
121
122fn make_reaper(tool_service: Arc<CliToolService>) -> leviath_runtime::host::Reaper {
127 Box::new(move |_world, entity| tool_service.reap(entity))
128}
129
130#[allow(clippy::too_many_arguments)]
135pub fn build_host(
136 config: Config,
137 providers: ProviderRegistry,
138 runs_dir: std::path::PathBuf,
139 shared_mcp: Arc<Mutex<leviath_mcp::ToolExecutor>>,
140 mcp_tool_defs: Vec<Tool>,
141 mcp_pool: Arc<crate::daemon::mcp_pool::McpPool>,
142 runtime: Handle,
143 now_secs: fn() -> i64,
144) -> WorldHost {
145 let hub = InteractionHub::new();
146 hub.set_timeout_secs(config.limits.interaction_timeout_secs);
150 let tool_service = Arc::new(CliToolService::new());
151 let pool_config =
155 InferencePoolConfig::new().with_default(config.limits.max_concurrent_inferences);
156 let mut world = PipelineWorld::new(
157 providers,
158 tool_service.clone(),
159 pool_config,
160 config.limits.max_concurrent_tools,
161 Some(runs_dir.clone()),
162 runtime,
163 );
164 world.set_exact_token_counting(config.limits.exact_token_counting);
166 world
169 .world_mut()
170 .insert_resource(leviath_runtime::pipeline::StallTimeout(
171 config.limits.stall_timeout_secs,
172 ));
173 world
177 .world_mut()
178 .insert_resource(leviath_runtime::pipeline::WedgeTimeout(
179 config.limits.wedge_timeout_secs,
180 ));
181 world
185 .world_mut()
186 .insert_resource(leviath_runtime::pipeline::CircuitPolicy {
187 failures_before_open: config.limits.provider_failures_before_open,
188 cooldown_secs: config.limits.provider_circuit_cooldown_secs,
189 });
190 world
191 .world_mut()
192 .init_resource::<leviath_runtime::pipeline::ProviderCircuits>();
193 world.insert_interaction_hub(hub.clone());
196 let mut host = WorldHost::with_interactions(world, hub.clone());
197 host.set_dead_cycles_before_relief(config.limits.dead_cycles_before_relief);
200 host.set_finished_retention_secs(config.limits.finished_retention_secs);
203 let subagent_tx = host.subagent_sender();
206
207 let reloaded = crate::daemon::recovery::reload_persisted_agents(
211 host.world_mut(),
212 tool_service.as_ref(),
213 &config,
214 shared_mcp.clone(),
215 &mcp_tool_defs,
216 &hub,
217 &runs_dir,
218 now_secs(),
219 &subagent_tx,
220 );
221 for (run_id, entity) in reloaded {
222 host.register(run_id, entity);
223 }
224
225 let reloader = std::sync::Arc::new(crate::daemon::config_reload::ConfigReloader::new(
232 Config::config_path(),
233 config.clone(),
234 ));
235
236 let fanout_spawner = DaemonFanOutSpawner {
240 config: reloader.clone(),
241 shared_mcp: shared_mcp.clone(),
242 mcp_tool_defs: mcp_tool_defs.clone(),
243 mcp_pool: mcp_pool.clone(),
244 hub: hub.clone(),
245 subagent_tx: subagent_tx.clone(),
246 tool_service: tool_service.clone(),
247 agents_dir: leviath_core::paths::agents_dir(),
248 now_secs,
249 };
250 host.world_mut()
251 .world_mut()
252 .insert_resource(FanOutSpawnerRes(Arc::new(fanout_spawner)));
253
254 let policy = crate::commands::policy::load_policy().unwrap_or_default();
258 host.world_mut()
259 .world_mut()
260 .insert_resource(leviath_runtime::pipeline::PolicyGate(policy));
261
262 host.world_mut()
265 .world_mut()
266 .insert_resource(leviath_runtime::title::TitleSettings(config.title.clone()));
267
268 let script_checker =
271 crate::daemon::gate_rules::build_gate_script_checker(&crate::commands::policy::rules_dir());
272 host.world_mut()
273 .world_mut()
274 .insert_resource(leviath_runtime::pipeline::GateScriptRules(script_checker));
275
276 if let Some(built) = leviath_telemetry::build_sink(&config.observability) {
282 host.world_mut()
283 .world_mut()
284 .insert_resource(leviath_runtime::telemetry::Telemetry(built.sink));
285 if let Some(layer) = built.log_layer {
286 crate::logging::install_otel_layer(layer);
287 }
288 }
289
290 let reload_tools = tool_service.clone();
294 let reload_reloader = reloader.clone();
295 let reload_mcp = shared_mcp.clone();
296 let reload_defs = mcp_tool_defs.clone();
297 let reload_hub = hub.clone();
298 let reload_tx = subagent_tx.clone();
299 let reload_runs = runs_dir.clone();
300 host.set_reloader(Box::new(move |world, run_id| {
301 let reload_config = reload_reloader.current();
304 crate::daemon::recovery::reload_run(
305 world,
306 reload_tools.as_ref(),
307 &reload_config,
308 reload_mcp.clone(),
309 &reload_defs,
310 &reload_hub,
311 run_id,
312 &reload_runs,
313 now_secs(),
314 &reload_tx,
315 )
316 }));
317
318 let terminate_runs = runs_dir.clone();
324 host.set_force_terminator(Box::new(move |run_id| {
325 crate::runstate::force_cancel_in(&terminate_runs.join(run_id), now_secs()).found_run()
326 }));
327
328 host.set_reaper(make_reaper(tool_service.clone()));
333
334 let pp_pool = mcp_pool.clone();
343 let pp_agents_dir = leviath_core::paths::agents_dir();
344 host.set_spawn_preprocessor(Box::new(move |args| {
345 let pool = pp_pool.clone();
346 let blueprint_path = args.blueprint_path.clone();
347 let agents_dir = pp_agents_dir.clone();
348 Box::pin(async move {
349 warm_blueprint_mcp(&pool, &blueprint_path).await;
350 warm_fanout_worker_mcp(&pool, &blueprint_path, agents_dir.as_deref()).await;
351 })
352 }));
353
354 let spawn_pool = mcp_pool.clone();
358 let spawn_runs_dir = runs_dir.clone();
359 let spawn_reloader = reloader.clone();
360 host.set_spawner(Box::new(move |world, args| {
361 write_placeholder_meta(&spawn_runs_dir, args);
368 let defs = per_agent_mcp_defs(&spawn_pool, &mcp_tool_defs, &args.blueprint_path);
369 let config = spawn_reloader.current();
373 let built = build_agent(
374 world.world_mut(),
375 tool_service.as_ref(),
376 &config,
377 shared_mcp.clone(),
378 &defs,
379 &hub,
380 args,
381 now_secs(),
382 subagent_tx.clone(),
383 );
384 if let Err(message) = &built {
389 crate::runstate::force_error_in(
390 &spawn_runs_dir.join(&args.run_id),
391 message,
392 now_secs(),
393 );
394 }
395 built
396 }));
397 host
398}
399
400fn write_placeholder_meta(runs_dir: &std::path::Path, args: &leviath_runtime::host::SpawnArgs) {
417 let agent_name = args
421 .run_id
422 .rsplitn(3, '-')
423 .nth(2)
424 .unwrap_or(&args.run_id)
425 .to_string();
426 let meta = leviath_core::run_meta::RunMeta::new(
427 args.run_id.clone(),
428 agent_name,
429 args.blueprint_path.clone(),
430 args.task.clone(),
431 None,
432 args.workdir.clone(),
433 0,
434 );
435 if let Err(e) = crate::runstate::create_run_in(&runs_dir.join(&args.run_id), &meta) {
436 tracing::warn!(run_id = %args.run_id, error = %e, "could not pre-create run directory");
437 }
438}
439
440async fn warm_blueprint_mcp(pool: &crate::daemon::mcp_pool::McpPool, blueprint_path: &str) {
444 if let Ok(toml) = std::fs::read_to_string(blueprint_path) {
445 for server in crate::daemon::mcp_pool::parse_blueprint_mcp_servers(&toml) {
446 pool.ensure(&server).await;
447 }
448 }
449}
450
451async fn warm_fanout_worker_mcp(
458 pool: &crate::daemon::mcp_pool::McpPool,
459 blueprint_path: &str,
460 agents_dir: Option<&std::path::Path>,
461) {
462 let Ok(content) = std::fs::read_to_string(blueprint_path) else {
463 return;
464 };
465 let Ok(blueprint) = leviath_core::manifest::parse_manifest(&content) else {
466 return;
467 };
468 for stage in &blueprint.stages {
469 let leviath_core::blueprint::StageMode::FanOut { config } = &stage.mode else {
470 continue;
471 };
472 if config.worker_stage.is_some() {
474 continue;
475 }
476 let Ok((resolve_path, _)) = crate::daemon::fanout_spawner::resolve_worker_source(
477 config,
478 blueprint_path,
479 agents_dir,
480 ) else {
481 continue;
482 };
483 let Ok(manifest) = crate::commands::run::manifest::find_manifest(&resolve_path) else {
484 continue;
485 };
486 if let Ok(worker_toml) = std::fs::read_to_string(&manifest) {
487 for server in crate::daemon::mcp_pool::parse_blueprint_mcp_servers(&worker_toml) {
488 pool.ensure(&server).await;
489 }
490 }
491 }
492}
493
494fn per_agent_mcp_defs(
499 pool: &crate::daemon::mcp_pool::McpPool,
500 global: &[Tool],
501 blueprint_path: &str,
502) -> Vec<Tool> {
503 let mut defs = global.to_vec();
504 if let Ok(toml) = std::fs::read_to_string(blueprint_path) {
505 let servers = crate::daemon::mcp_pool::parse_blueprint_mcp_servers(&toml);
506 defs.extend(pool.cached_defs_for(&servers));
507 }
508 defs
509}
510
511#[cfg(test)]
512mod tests {
513 use super::*;
514 use leviath_runtime::components::AgentStatus;
515 use leviath_runtime::host::{ControlOp, SpawnArgs};
516 use tokio::sync::oneshot;
517
518 fn config_with_anthropic_key() -> Config {
521 let mut config = Config::default();
522 config.providers.anthropic_api_key = Some("test-key".to_string());
523 config
524 }
525
526 #[tokio::test]
527 async fn make_reaper_delegates_to_tool_service_reap() {
528 let tool_service = Arc::new(CliToolService::new());
531 let mut world = PipelineWorld::new(
532 ProviderRegistry::new(),
533 tool_service.clone(),
534 InferencePoolConfig::new(),
535 1,
536 None,
537 Handle::current(),
538 );
539 let mut reaper = make_reaper(tool_service.clone());
540 let entity = bevy_ecs::entity::Entity::from_raw_u32(1)
543 .expect("a small literal index is always a valid entity id");
544 reaper(&mut world, entity);
545 assert!(tool_service.take(entity).is_none());
546 }
547
548 struct FakeProvider;
549 #[async_trait::async_trait]
550 impl leviath_providers::Provider for FakeProvider {
551 async fn infer(
552 &self,
553 _r: leviath_providers::InferenceRequest,
554 ) -> leviath_providers::Result<leviath_providers::InferenceResponse> {
555 Err(leviath_providers::ProviderError::Other("test".to_string()))
556 }
557 async fn count_tokens(&self, _t: &str, _m: &str) -> usize {
558 1
559 }
560 fn max_context_tokens(&self, _m: &str) -> usize {
561 1000
562 }
563 fn name(&self) -> &str {
564 "fake"
565 }
566 fn capabilities(&self, _m: &str) -> leviath_providers::ModelCapabilities {
567 leviath_providers::ModelCapabilities::default()
568 }
569 }
570
571 #[test]
572 fn control_address_is_derived_from_leviath_home() {
573 let a = temp_env::with_var("LEVIATH_HOME", Some("/tmp/leviath-home-a"), control_address)
574 .unwrap();
575 let b = temp_env::with_var("LEVIATH_HOME", Some("/tmp/leviath-home-b"), control_address)
576 .unwrap();
577 assert_ne!(a, b);
579 #[cfg(unix)]
581 {
582 assert!(a.ends_with(".leviath/control.sock"));
583 assert!(a.starts_with("/tmp/leviath-home-a"));
584 }
585 }
586
587 #[tokio::test]
588 async fn setup_daemon_host_builds_a_working_host() {
589 let runs = tempfile::tempdir().unwrap();
594 let mut host = setup_daemon_host(
595 config_with_anthropic_key(),
596 runs.path().to_path_buf(),
597 Handle::current(),
598 )
599 .await;
600
601 let dir = tempfile::tempdir().unwrap();
604 let manifest = dir.path().join("agent.leviath");
605 std::fs::write(&manifest, crate::test_support::inline_coder_manifest()).unwrap();
606 let (reply, rx) = oneshot::channel();
607 host.handle(ControlOp::Spawn {
608 args: Box::new(SpawnArgs {
609 run_id: "run-s".to_string(),
610 blueprint_path: manifest.to_string_lossy().to_string(),
611 task: "t".to_string(),
612 regions: Default::default(),
613 model: None,
614 workdir: std::env::temp_dir().to_string_lossy().to_string(),
615 metadata: Default::default(),
616 callback_url: None,
617 callback_secret: None,
618 yolo: false,
619 no_seed_commands: false,
620 allow: Vec::new(),
621 max_depth: None,
622 parent_run_id: None,
623 }),
624 reply,
625 });
626 assert_eq!(rx.await.unwrap(), Ok("run-s".to_string()));
627 }
628
629 #[tokio::test]
635 async fn spawner_records_the_failure_in_the_run_dir_it_staked_out() {
636 let runs = tempfile::tempdir().unwrap();
637 let mut host = setup_daemon_host(
638 Config::default(),
639 runs.path().to_path_buf(),
640 Handle::current(),
641 )
642 .await;
643 let (reply, rx) = oneshot::channel();
644 host.handle(ControlOp::Spawn {
645 args: Box::new(SpawnArgs {
646 run_id: "my-agent-1234-ab12".to_string(),
649 blueprint_path: "/no/such/agent.leviath".to_string(),
650 task: "t".to_string(),
651 workdir: std::env::temp_dir().to_string_lossy().to_string(),
652 ..Default::default()
653 }),
654 reply,
655 });
656 assert!(rx.await.unwrap().is_err());
657
658 let meta = crate::runstate::read_meta_from(&runs.path().join("my-agent-1234-ab12"))
659 .expect("a failed spawn still leaves meta.json behind");
660 assert_eq!(meta.status, leviath_core::run_meta::RunStatus::Error);
662 assert!(
663 meta.error
664 .is_some_and(|e| e.contains("/no/such/agent.leviath")),
665 "and it says what went wrong"
666 );
667 assert_eq!(meta.task, "t");
668 assert_eq!(meta.agent_name, "my-agent");
670 }
671
672 #[test]
673 fn placeholder_meta_falls_back_to_the_whole_run_id_as_the_agent_name() {
674 let runs = tempfile::tempdir().unwrap();
675 let args = SpawnArgs {
676 run_id: "odd".to_string(),
678 task: "t".to_string(),
679 ..Default::default()
680 };
681 write_placeholder_meta(runs.path(), &args);
682 let meta = crate::runstate::read_meta_from(&runs.path().join("odd")).unwrap();
683 assert_eq!(meta.agent_name, "odd");
684 }
685
686 #[test]
687 fn placeholder_meta_failure_is_logged_not_fatal() {
688 crate::test_support::with_tracing(|| {
691 let dir = tempfile::tempdir().unwrap();
692 let blocker = dir.path().join("not-a-dir");
693 std::fs::write(&blocker, "x").unwrap();
694 let args = SpawnArgs {
695 run_id: "blocked".to_string(),
696 ..Default::default()
697 };
698 write_placeholder_meta(&blocker.join("runs"), &args);
699 assert!(
700 crate::runstate::read_meta_from(&blocker.join("runs").join("blocked")).is_err()
701 );
702 });
703 }
704
705 #[tokio::test]
715 async fn spawner_writes_the_placeholder_under_the_hosts_runs_dir() {
716 let runs = tempfile::tempdir().unwrap();
717 let global = crate::runstate::runs_dir();
721 let global_before = run_ids_in(&global);
722
723 let mut host = setup_daemon_host(
724 Config::default(),
725 runs.path().to_path_buf(),
726 Handle::current(),
727 )
728 .await;
729 let (reply, rx) = oneshot::channel();
730 host.handle(ControlOp::Spawn {
731 args: Box::new(SpawnArgs {
732 run_id: "isolation-1234-ab12".to_string(),
736 blueprint_path: "/no/such/agent.leviath".to_string(),
737 task: "t".to_string(),
738 workdir: std::env::temp_dir().to_string_lossy().to_string(),
739 ..Default::default()
740 }),
741 reply,
742 });
743 assert!(rx.await.unwrap().is_err(), "the spawn itself fails");
744
745 assert!(
746 crate::runstate::read_meta_from(&runs.path().join("isolation-1234-ab12")).is_ok(),
747 "the placeholder lands in the host's configured runs dir"
748 );
749 assert_eq!(
750 run_ids_in(&global),
751 global_before,
752 "spawning through a host must not write into the home-resolved runs dir"
753 );
754 }
755
756 #[tokio::test]
762 async fn cancelling_an_unreloadable_run_terminates_it_on_disk() {
763 let runs = tempfile::tempdir().unwrap();
764 let mut host = setup_daemon_host(
765 Config::default(),
766 runs.path().to_path_buf(),
767 Handle::current(),
768 )
769 .await;
770
771 let run_dir = runs.path().join("gone-1234-ab12");
775 let meta = leviath_core::run_meta::RunMeta::new(
776 "gone-1234-ab12".to_string(),
777 "gone".to_string(),
778 "/no/such/dir/agent.leviath".to_string(),
780 "t".to_string(),
781 None,
782 std::env::temp_dir().to_string_lossy().to_string(),
783 1,
784 );
785 crate::runstate::create_run_in(&run_dir, &meta).unwrap();
786 assert!(
787 !crate::runstate::is_terminal_status(
788 &crate::runstate::read_meta_from(&run_dir).unwrap().status
789 ),
790 "the run starts out looking live"
791 );
792
793 let (reply, rx) = oneshot::channel();
794 host.handle(ControlOp::Cancel {
795 run_id: "gone-1234-ab12".to_string(),
796 reply,
797 });
798 assert!(rx.await.unwrap(), "the cancel reports that it applied");
799 assert_eq!(
800 crate::runstate::read_meta_from(&run_dir).unwrap().status,
801 leviath_core::run_meta::RunStatus::Cancelled,
802 "and it reached disk, so nothing shows the run as live any more"
803 );
804
805 let (reply, rx) = oneshot::channel();
807 host.handle(ControlOp::Cancel {
808 run_id: "no-such-run".to_string(),
809 reply,
810 });
811 assert!(!rx.await.unwrap());
812 }
813
814 fn run_ids_in(dir: &std::path::Path) -> std::collections::BTreeSet<String> {
817 std::fs::read_dir(dir)
818 .into_iter()
819 .flatten()
820 .flatten()
821 .map(|e| e.file_name().to_string_lossy().into_owned())
822 .collect()
823 }
824
825 #[test]
826 fn run_ids_in_lists_entries_and_tolerates_a_missing_dir() {
827 let dir = tempfile::tempdir().unwrap();
828 std::fs::create_dir_all(dir.path().join("run-one")).unwrap();
829 std::fs::create_dir_all(dir.path().join("run-two")).unwrap();
830 assert_eq!(
831 run_ids_in(dir.path()),
832 ["run-one".to_string(), "run-two".to_string()]
833 .into_iter()
834 .collect()
835 );
836 assert!(run_ids_in(&dir.path().join("nope")).is_empty());
838 }
839
840 fn stub_server_py() -> (tempfile::TempDir, std::path::PathBuf) {
844 let dir = tempfile::tempdir().unwrap();
845 let path = dir.path().join("stub.py");
846 std::fs::write(
847 &path,
848 r#"
849import sys, json
850def respond(i, r):
851 sys.stdout.write(json.dumps({"jsonrpc":"2.0","id":i,"result":r})+"\n"); sys.stdout.flush()
852for line in sys.stdin:
853 line=line.strip()
854 if not line: continue
855 req=json.loads(line); m=req.get("method",""); i=req.get("id")
856 if m=="initialize": respond(i,{"capabilities":{"tools":{"listChanged":True}},"protocolVersion":"2024-11-05"})
857 elif m=="notifications/initialized": pass
858 elif m=="tools/list": respond(i,{"tools":[{"name":"stub_search","description":"s","inputSchema":{"type":"object","properties":{}}}]})
859 elif m=="tools/call": respond(i,{"content":[{"type":"text","text":"ok"}],"isError":False})
860 else: respond(i,{})
861"#,
862 )
863 .unwrap();
864 (dir, path)
865 }
866
867 fn blueprint_with_mcp(dir: &std::path::Path, stub_py: &std::path::Path) -> std::path::PathBuf {
870 let manifest = dir.join("agent.leviath");
871 std::fs::write(
872 &manifest,
873 format!(
874 r#"
875[agent]
876name = "mcpagent"
877entry_stage = "work"
878
879[[mcp_servers]]
880name = "search"
881command = "python3"
882args = ['{}']
883
884[stages.work]
885mode = "autonomous"
886model = {{ provider = "fake", model = "m" }}
887available_tools = ["stub_search"]
888system_prompt = "use stub_search"
889
890[context.regions]
891task = {{ kind = "pinned", max_tokens = 200, seed = {{ caller_input = "task" }} }}
892"#,
893 stub_py.to_string_lossy()
894 ),
895 )
896 .unwrap();
897 manifest
898 }
899
900 fn empty_pool() -> crate::daemon::mcp_pool::McpPool {
901 crate::daemon::mcp_pool::McpPool::new(
902 Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
903 Default::default(),
904 )
905 }
906
907 #[tokio::test]
908 async fn warm_blueprint_mcp_connects_declared_servers() {
909 let (_stub_dir, stub) = stub_server_py();
910 let dir = tempfile::tempdir().unwrap();
911 let manifest = blueprint_with_mcp(dir.path(), &stub);
912 let pool = empty_pool();
913 warm_blueprint_mcp(&pool, &manifest.to_string_lossy()).await;
914 let servers = crate::daemon::mcp_pool::parse_blueprint_mcp_servers(
916 &std::fs::read_to_string(&manifest).unwrap(),
917 );
918 let defs = pool.cached_defs_for(&servers);
919 assert_eq!(defs.len(), 1);
920 assert_eq!(defs[0].name, "stub_search");
921 }
922
923 #[tokio::test]
924 async fn warm_blueprint_mcp_missing_manifest_is_noop() {
925 let pool = empty_pool();
926 warm_blueprint_mcp(&pool, "/no/such/agent.leviath").await;
928 }
929
930 fn parent_with_fanout_worker_agent(
933 dir: &std::path::Path,
934 worker_source: &str,
935 ) -> std::path::PathBuf {
936 let manifest = dir.join("parent.leviath");
937 std::fs::write(
938 &manifest,
939 format!(
940 "[agent]\nname = \"parent\"\n\n\
941 [stages.main]\nmode = \"autonomous\"\n\n\
942 [stages.parallel]\nmode = \"fan_out\"\nworker_agent = '{worker_source}'\nsplit_prompt = \"go\"\n"
943 ),
944 )
945 .unwrap();
946 manifest
947 }
948
949 #[tokio::test]
950 async fn warm_fanout_worker_mcp_prewarms_worker_agent_servers() {
951 let (_stub_dir, stub) = stub_server_py();
952 let worker_dir = tempfile::tempdir().unwrap();
954 blueprint_with_mcp(worker_dir.path(), &stub);
955 let parent_dir = tempfile::tempdir().unwrap();
957 let parent = parent_with_fanout_worker_agent(
958 parent_dir.path(),
959 &worker_dir.path().to_string_lossy(),
960 );
961 let pool = empty_pool();
962 warm_fanout_worker_mcp(&pool, &parent.to_string_lossy(), None).await;
963 let servers = crate::daemon::mcp_pool::parse_blueprint_mcp_servers(
966 &std::fs::read_to_string(worker_dir.path().join("agent.leviath")).unwrap(),
967 );
968 let defs = pool.cached_defs_for(&servers);
969 assert_eq!(defs.len(), 1);
970 assert_eq!(defs[0].name, "stub_search");
971 }
972
973 #[tokio::test]
974 async fn warm_fanout_worker_mcp_skips_and_tolerates_every_arm() {
975 let pool = empty_pool();
976 warm_fanout_worker_mcp(&pool, "/no/such/parent.leviath", None).await;
978 let dir = tempfile::tempdir().unwrap();
980 let bad = dir.path().join("bad.leviath");
981 std::fs::write(&bad, "not : valid : toml").unwrap();
982 warm_fanout_worker_mcp(&pool, &bad.to_string_lossy(), None).await;
983 let plain = dir.path().join("plain.leviath");
985 std::fs::write(
986 &plain,
987 "[agent]\nname = \"p\"\n\n[stages.main]\nmode = \"autonomous\"\n",
988 )
989 .unwrap();
990 warm_fanout_worker_mcp(&pool, &plain.to_string_lossy(), None).await;
991 let ws = dir.path().join("ws.leviath");
993 std::fs::write(
994 &ws,
995 "[agent]\nname = \"p\"\n\n\
996 [stages.parallel]\nmode = \"fan_out\"\nworker_stage = \"w\"\nsplit_prompt = \"go\"\n\n\
997 [stages.w]\nmode = \"autonomous\"\nallow_as_worker = true\n",
998 )
999 .unwrap();
1000 warm_fanout_worker_mcp(&pool, &ws.to_string_lossy(), None).await;
1001 let wq = dir.path().join("wq.leviath");
1003 std::fs::write(
1004 &wq,
1005 "[agent]\nname = \"p\"\n\n\
1006 [stages.parallel]\nmode = \"fan_out\"\nworker_query = \"x\"\nsplit_prompt = \"go\"\n",
1007 )
1008 .unwrap();
1009 warm_fanout_worker_mcp(&pool, &wq.to_string_lossy(), None).await;
1010 let miss = parent_with_fanout_worker_agent(dir.path(), "/no/such/worker/xyz");
1012 warm_fanout_worker_mcp(&pool, &miss.to_string_lossy(), None).await;
1013 let worker_dir = tempfile::tempdir().unwrap();
1016 std::fs::write(
1017 worker_dir.path().join("agent.leviath"),
1018 "[agent]\nname = \"w\"\n\n[stages.main]\nmode = \"autonomous\"\n",
1019 )
1020 .unwrap();
1021 let noservers =
1022 parent_with_fanout_worker_agent(dir.path(), &worker_dir.path().to_string_lossy());
1023 warm_fanout_worker_mcp(&pool, &noservers.to_string_lossy(), None).await;
1024 let dir_manifest = tempfile::tempdir().unwrap();
1028 std::fs::create_dir(dir_manifest.path().join("agent.leviath")).unwrap();
1029 let unreadable =
1030 parent_with_fanout_worker_agent(dir.path(), &dir_manifest.path().to_string_lossy());
1031 warm_fanout_worker_mcp(&pool, &unreadable.to_string_lossy(), None).await;
1032 }
1033
1034 #[test]
1035 fn per_agent_mcp_defs_appends_declared_and_falls_back_to_global() {
1036 let (_stub_dir, stub) = stub_server_py();
1037 let dir = tempfile::tempdir().unwrap();
1038 let manifest = blueprint_with_mcp(dir.path(), &stub);
1039 let pool = empty_pool();
1040 let servers = crate::daemon::mcp_pool::parse_blueprint_mcp_servers(
1043 &std::fs::read_to_string(&manifest).unwrap(),
1044 );
1045 pool.seed(
1046 &servers[0],
1047 vec![Tool {
1048 name: "stub_search".into(),
1049 description: String::new(),
1050 parameters: serde_json::json!({}),
1051 }],
1052 );
1053 let global = vec![Tool {
1054 name: "global_tool".into(),
1055 description: String::new(),
1056 parameters: serde_json::json!({}),
1057 }];
1058 let defs = per_agent_mcp_defs(&pool, &global, &manifest.to_string_lossy());
1059 let names: Vec<&str> = defs.iter().map(|t| t.name.as_str()).collect();
1060 assert_eq!(names, vec!["global_tool", "stub_search"]);
1061 let only_global = per_agent_mcp_defs(&pool, &global, "/no/such/x");
1063 assert_eq!(only_global.len(), 1);
1064 assert_eq!(only_global[0].name, "global_tool");
1065 }
1066
1067 #[tokio::test]
1068 async fn build_host_seeds_global_mcp_servers() {
1069 let config = Config {
1071 mcp_servers: vec![leviath_mcp::MCPServerConfig::stdio(
1072 "global-srv",
1073 "python3",
1074 vec!["-c".to_string(), "pass".to_string()],
1075 )],
1076 ..Config::default()
1077 };
1078 let runs = tempfile::tempdir().unwrap();
1079 let _host = build_host(
1080 config,
1081 ProviderRegistry::new(),
1082 runs.path().to_path_buf(),
1083 Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1084 Vec::new(),
1085 crate::daemon::mcp_pool::McpPool::for_daemon(
1086 Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1087 &[],
1088 ),
1089 Handle::current(),
1090 || 0,
1091 );
1092 }
1093
1094 #[tokio::test]
1095 async fn build_host_installs_the_configured_telemetry_sink() {
1096 let config = Config {
1098 observability: leviath_core::config::ObservabilityConfig {
1099 enabled: true,
1100 exporter: leviath_core::config::TelemetryExporterKind::Stdout,
1101 endpoint: None,
1102 service_name: None,
1103 },
1104 ..Config::default()
1105 };
1106 let runs = tempfile::tempdir().unwrap();
1107 let mut host = build_host(
1108 config,
1109 ProviderRegistry::new(),
1110 runs.path().to_path_buf(),
1111 Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1112 Vec::new(),
1113 crate::daemon::mcp_pool::McpPool::for_daemon(
1114 Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1115 &[],
1116 ),
1117 Handle::current(),
1118 || 0,
1119 );
1120 assert!(
1121 host.world_mut()
1122 .world_mut()
1123 .get_resource::<leviath_runtime::telemetry::Telemetry>()
1124 .is_some()
1125 );
1126 }
1127
1128 #[tokio::test(flavor = "multi_thread")]
1129 async fn build_host_with_otlp_also_installs_the_log_layer() {
1130 let config = Config {
1136 observability: leviath_core::config::ObservabilityConfig {
1137 enabled: true,
1138 exporter: leviath_core::config::TelemetryExporterKind::Otlp,
1139 endpoint: Some("http://127.0.0.1:9".to_string()),
1140 service_name: Some("leviath-test".to_string()),
1141 },
1142 ..Config::default()
1143 };
1144 let runs = tempfile::tempdir().unwrap();
1145 let mut host = build_host(
1146 config,
1147 ProviderRegistry::new(),
1148 runs.path().to_path_buf(),
1149 Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1150 Vec::new(),
1151 crate::daemon::mcp_pool::McpPool::for_daemon(
1152 Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1153 &[],
1154 ),
1155 Handle::current(),
1156 || 0,
1157 );
1158 assert!(
1159 host.world_mut()
1160 .world_mut()
1161 .get_resource::<leviath_runtime::telemetry::Telemetry>()
1162 .is_some()
1163 );
1164 }
1165
1166 #[tokio::test]
1167 async fn serve_runs_spawn_preprocessor_for_per_agent_mcp() {
1168 let (_stub_dir, stub) = stub_server_py();
1172 let agent_dir = tempfile::tempdir().unwrap();
1173 let manifest = blueprint_with_mcp(agent_dir.path(), &stub);
1174 let mut providers = ProviderRegistry::new();
1176 providers.register("fake".to_string(), Arc::new(FakeProvider));
1177 let runs = tempfile::tempdir().unwrap();
1178 let mut host = build_host(
1179 Config::default(),
1180 providers,
1181 runs.path().to_path_buf(),
1182 Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1183 Vec::new(),
1184 crate::daemon::mcp_pool::McpPool::for_daemon(
1185 Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1186 &[],
1187 ),
1188 Handle::current(),
1189 || 0,
1190 );
1191 let (ctl_tx, ctl_rx) = tokio::sync::mpsc::unbounded_channel();
1192 let (reply, reply_rx) = oneshot::channel();
1193 ctl_tx
1194 .send(ControlOp::Spawn {
1195 args: Box::new(SpawnArgs {
1196 run_id: "run-mcp".to_string(),
1197 blueprint_path: manifest.to_string_lossy().to_string(),
1198 task: "t".to_string(),
1199 regions: Default::default(),
1200 model: None,
1201 workdir: std::env::temp_dir().to_string_lossy().to_string(),
1202 metadata: Default::default(),
1203 callback_url: None,
1204 callback_secret: None,
1205 yolo: false,
1206 no_seed_commands: false,
1207 allow: Vec::new(),
1208 max_depth: None,
1209 parent_run_id: None,
1210 }),
1211 reply,
1212 })
1213 .unwrap();
1214 drop(ctl_tx);
1216 host.serve(ctl_rx).await;
1217 assert_eq!(reply_rx.await.unwrap(), Ok("run-mcp".to_string()));
1218 }
1219
1220 #[tokio::test]
1221 async fn fake_provider_methods_are_exercised() {
1222 use leviath_providers::Provider;
1223 let p = FakeProvider;
1224 assert_eq!(p.name(), "fake");
1225 assert_eq!(p.count_tokens("t", "m").await, 1);
1226 assert_eq!(p.max_context_tokens("m"), 1000);
1227 let _ = p.capabilities("m");
1228 assert!(
1229 p.infer(leviath_providers::InferenceRequest {
1230 system: vec![],
1231 messages: vec![],
1232 model: "m".to_string(),
1233 max_tokens: 1,
1234 temperature: 0.0,
1235 tools: vec![],
1236 extra: serde_json::Value::Null,
1237 request_timeout_secs: None,
1238 })
1239 .await
1240 .is_err()
1241 );
1242 }
1243
1244 #[tokio::test]
1245 async fn build_host_spawns_agents_through_the_installed_spawner() {
1246 let dir = tempfile::tempdir().unwrap();
1247 let manifest = dir.path().join("agent.leviath");
1248 std::fs::write(&manifest, crate::test_support::inline_coder_manifest()).unwrap();
1249
1250 let mut registry = ProviderRegistry::new();
1251 registry.register("anthropic".to_string(), Arc::new(FakeProvider));
1252 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1253
1254 let runs = tempfile::tempdir().unwrap();
1255 let mut host = build_host(
1256 Config::default(),
1257 registry,
1258 runs.path().to_path_buf(),
1259 mcp,
1260 vec![],
1261 crate::daemon::mcp_pool::McpPool::for_daemon(
1262 Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1263 &[],
1264 ),
1265 Handle::current(),
1266 || 100,
1267 );
1268
1269 let (reply, rx) = oneshot::channel();
1271 host.handle(ControlOp::Spawn {
1272 args: Box::new(SpawnArgs {
1273 run_id: "run-1".to_string(),
1274 blueprint_path: manifest.to_string_lossy().to_string(),
1275 task: "do it".to_string(),
1276 regions: Default::default(),
1277 model: None,
1278 workdir: std::env::temp_dir().to_string_lossy().to_string(),
1279 metadata: Default::default(),
1280 callback_url: None,
1281 callback_secret: None,
1282 yolo: false,
1283 no_seed_commands: false,
1284 allow: Vec::new(),
1285 max_depth: None,
1286 parent_run_id: None,
1287 }),
1288 reply,
1289 });
1290 assert_eq!(rx.await.unwrap(), Ok("run-1".to_string()));
1291
1292 let (reply, rx) = oneshot::channel();
1294 host.handle(ControlOp::Status {
1295 run_id: "run-1".to_string(),
1296 reply,
1297 });
1298 assert_eq!(rx.await.unwrap(), Some(AgentStatus::Active));
1299 }
1300
1301 #[tokio::test]
1302 async fn build_host_reloads_and_registers_persisted_runs() {
1303 let agent = tempfile::tempdir().unwrap();
1306 let manifest = agent.path().join("agent.leviath");
1307 std::fs::write(&manifest, crate::test_support::inline_coder_manifest()).unwrap();
1308
1309 let runs = tempfile::tempdir().unwrap();
1310 let run_dir = runs.path().join("resumed");
1311 std::fs::create_dir_all(&run_dir).unwrap();
1312 let meta = leviath_core::run_meta::RunMeta {
1313 run_id: "resumed".to_string(),
1314 agent_name: "coder".to_string(),
1315 agent_path: manifest.to_string_lossy().to_string(),
1316 task: "resume".to_string(),
1317 model: None,
1318 pid: 0,
1319 status: leviath_core::run_meta::RunStatus::Running,
1320 current_stage: "implement".to_string(),
1321 stage_index: 0,
1322 num_stages: 1,
1323 iteration: 2,
1324 prompt_tokens: 0,
1325 completion_tokens: 0,
1326 cached_tokens: 0,
1327 cache_write_tokens: 0,
1328 tool_calls: 0,
1329 workdir: std::env::temp_dir().to_string_lossy().to_string(),
1330 started_at: 1,
1331 updated_at: 1,
1332 last_progress_at: None,
1333 error: None,
1334 title: None,
1335 metadata: Default::default(),
1336 callback_url: None,
1337 callback_secret: None,
1338 parent_run_id: None,
1339 children: Vec::new(),
1340 depth: 0,
1341 max_child_depth: 0,
1342 flags: Default::default(),
1343 yolo: false,
1344 read_paths: None,
1345 };
1346 std::fs::write(
1347 run_dir.join("meta.json"),
1348 serde_json::to_string(&meta).unwrap(),
1349 )
1350 .unwrap();
1351
1352 let mut registry = ProviderRegistry::new();
1353 registry.register("anthropic".to_string(), Arc::new(FakeProvider));
1354 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1355 let mut host = build_host(
1356 Config::default(),
1357 registry,
1358 runs.path().to_path_buf(),
1359 mcp,
1360 vec![],
1361 crate::daemon::mcp_pool::McpPool::for_daemon(
1362 Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1363 &[],
1364 ),
1365 Handle::current(),
1366 || 100,
1367 );
1368
1369 let (reply, rx) = oneshot::channel();
1371 host.handle(ControlOp::Status {
1372 run_id: "resumed".to_string(),
1373 reply,
1374 });
1375 assert_eq!(rx.await.unwrap(), Some(AgentStatus::Active));
1376 }
1377
1378 #[tokio::test]
1379 async fn build_host_installs_a_reloader_that_pages_in_unloaded_runs() {
1380 let agent = tempfile::tempdir().unwrap();
1384 let manifest = agent.path().join("agent.leviath");
1385 std::fs::write(&manifest, crate::test_support::inline_coder_manifest()).unwrap();
1386
1387 let runs = tempfile::tempdir().unwrap();
1388 let mut registry = ProviderRegistry::new();
1389 registry.register("anthropic".to_string(), Arc::new(FakeProvider));
1390 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1391 let mut host = build_host(
1392 Config::default(),
1393 registry,
1394 runs.path().to_path_buf(),
1395 mcp,
1396 vec![],
1397 crate::daemon::mcp_pool::McpPool::for_daemon(
1398 Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1399 &[],
1400 ),
1401 Handle::current(),
1402 || 100,
1403 );
1404
1405 let run_dir = runs.path().join("late");
1408 std::fs::create_dir_all(&run_dir).unwrap();
1409 let meta = leviath_core::run_meta::RunMeta {
1410 run_id: "late".to_string(),
1411 agent_name: "coder".to_string(),
1412 agent_path: manifest.to_string_lossy().to_string(),
1413 task: "page me in".to_string(),
1414 model: None,
1415 pid: 0,
1416 status: leviath_core::run_meta::RunStatus::Running,
1417 current_stage: "implement".to_string(),
1418 stage_index: 0,
1419 num_stages: 1,
1420 iteration: 1,
1421 prompt_tokens: 0,
1422 completion_tokens: 0,
1423 cached_tokens: 0,
1424 cache_write_tokens: 0,
1425 tool_calls: 0,
1426 workdir: std::env::temp_dir().to_string_lossy().to_string(),
1427 started_at: 1,
1428 updated_at: 1,
1429 last_progress_at: None,
1430 error: None,
1431 title: None,
1432 metadata: Default::default(),
1433 callback_url: None,
1434 callback_secret: None,
1435 parent_run_id: None,
1436 children: Vec::new(),
1437 depth: 0,
1438 max_child_depth: 0,
1439 flags: Default::default(),
1440 yolo: false,
1441 read_paths: None,
1442 };
1443 std::fs::write(
1444 run_dir.join("meta.json"),
1445 serde_json::to_string(&meta).unwrap(),
1446 )
1447 .unwrap();
1448
1449 let (reply, rx) = oneshot::channel();
1451 host.handle(ControlOp::Status {
1452 run_id: "late".to_string(),
1453 reply,
1454 });
1455 assert_eq!(rx.await.unwrap(), None);
1456
1457 let (reply, rx) = oneshot::channel();
1459 host.handle(ControlOp::Cancel {
1460 run_id: "late".to_string(),
1461 reply,
1462 });
1463 assert!(rx.await.unwrap());
1464 }
1465
1466 #[test]
1467 fn daemon_build_is_stale_compares_against_current_build() {
1468 assert!(daemon_build_is_stale(None), "missing marker is stale");
1469 assert!(
1470 daemon_build_is_stale(Some("some-other-build")),
1471 "a different build is stale"
1472 );
1473 assert!(
1474 !daemon_build_is_stale(Some(CURRENT_BUILD)),
1475 "the current build is not stale"
1476 );
1477 }
1478
1479 #[test]
1480 fn build_marker_round_trips_and_is_current() {
1481 let dir = tempfile::tempdir().unwrap();
1482 temp_env::with_var("LEVIATH_HOME", Some(dir.path()), || {
1483 assert!(read_build_marker().is_none());
1485 assert!(daemon_build_is_stale(read_build_marker().as_deref()));
1486
1487 write_build_marker();
1488 let path = build_marker_path().unwrap();
1489 assert!(path.exists());
1490 assert_eq!(read_build_marker().as_deref(), Some(CURRENT_BUILD));
1491 assert!(!daemon_build_is_stale(read_build_marker().as_deref()));
1493 });
1494 }
1495}