1use std::collections::HashMap;
7
8use anyhow::bail;
9use leviath_core::layout::RegionSeed;
10use leviath_runtime::control_socket::{ControlClient, ControlResponse};
11use leviath_runtime::host::SpawnArgs;
12
13use crate::commands::run::manifest::find_manifest;
14use crate::commands::run::session::read_region_value;
15use crate::runstate::new_run_id;
16
17#[allow(clippy::too_many_arguments)]
26pub fn resolve_spawn_args(
27 path: &str,
28 task: &str,
29 model: Option<String>,
30 workdir: &str,
31 yolo: bool,
32 allow: Vec<String>,
33 max_depth: Option<usize>,
34 regions: HashMap<String, String>,
35 no_seed_commands: bool,
36) -> anyhow::Result<SpawnArgs> {
37 let manifest = find_manifest(path)?;
38 let agent_name = manifest
39 .parent()
40 .and_then(|p| p.file_name())
41 .and_then(|n| n.to_str())
42 .unwrap_or("agent");
43
44 let resolved_regions = if regions.is_empty() {
46 HashMap::new()
47 } else {
48 let content = std::fs::read_to_string(&manifest)
49 .map_err(|e| anyhow::anyhow!("read manifest '{}': {e}", manifest.display()))?;
50 let blueprint = leviath_core::manifest::parse_manifest(&content)
51 .map_err(|e| anyhow::anyhow!("parse manifest: {e}"))?;
52 let declared: Vec<String> = blueprint
53 .context_layout
54 .regions
55 .iter()
56 .filter_map(|r| match &r.seed {
57 Some(RegionSeed::CallerInput { name }) => Some(name.clone()),
58 _ => None,
59 })
60 .collect();
61 let mut out = HashMap::new();
62 for (name, raw) in regions {
63 if !declared.contains(&name) {
64 bail!(
65 "unknown region '--{name}'; this agent's caller-input regions are: {}",
66 if declared.is_empty() {
67 "(none)".to_string()
68 } else {
69 declared.join(", ")
70 }
71 );
72 }
73 out.insert(name, read_region_value(&raw)?);
74 }
75 out
76 };
77
78 Ok(SpawnArgs {
79 run_id: new_run_id(agent_name),
80 blueprint_path: manifest.to_string_lossy().to_string(),
81 task: task.to_string(),
82 regions: resolved_regions,
83 model,
84 workdir: workdir.to_string(),
85 metadata: Default::default(),
86 callback_url: None,
87 callback_secret: None,
88 yolo,
89 no_seed_commands,
90 allow,
91 max_depth,
92 parent_run_id: None,
94 })
95}
96
97pub async fn send_spawn(client: &ControlClient, spawn_args: SpawnArgs) -> anyhow::Result<()> {
100 match client.spawn(spawn_args).await {
101 Ok(ControlResponse::Spawned { run_id }) => {
102 println!("spawned {run_id}");
103 Ok(())
104 }
105 Ok(ControlResponse::Error { message }) => bail!("spawn failed: {message}"),
106 Ok(other) => bail!("unexpected daemon response: {other:?}"),
107 Err(e) => bail!("the leviath daemon is not reachable ({e}); start it with `lev daemon`"),
108 }
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114 use leviath_runtime::control_socket::{ControlId, bind_control_listener, control_id};
115 use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
116 use tokio::task::JoinHandle;
117
118 fn write_manifest(dir: &std::path::Path) -> std::path::PathBuf {
119 std::fs::write(
120 dir.join("agent.leviath"),
121 crate::test_support::inline_coder_manifest(),
122 )
123 .unwrap();
124 dir.join("agent.leviath")
125 }
126
127 #[test]
128 fn resolve_spawn_args_finds_manifest_and_builds_request() {
129 let dir = tempfile::tempdir().unwrap();
130 let agent_dir = dir.path().join("my-agent");
131 std::fs::create_dir_all(&agent_dir).unwrap();
132 let manifest = write_manifest(&agent_dir);
133
134 let args = resolve_spawn_args(
135 manifest.to_str().unwrap(),
136 "do it",
137 Some("m".to_string()),
138 "/work",
139 false,
140 Vec::new(),
141 None,
142 HashMap::new(),
143 false,
144 )
145 .unwrap();
146 assert!(args.run_id.contains("my-agent"));
147 assert_eq!(args.task, "do it");
148 assert_eq!(args.model.as_deref(), Some("m"));
149 assert_eq!(args.blueprint_path, manifest.to_string_lossy());
150 assert_eq!(args.workdir, "/work");
151 }
152
153 #[test]
154 fn resolve_spawn_args_errors_on_missing_manifest() {
155 assert!(
156 resolve_spawn_args(
157 "/no/such/agent",
158 "t",
159 None,
160 "/work",
161 false,
162 Vec::new(),
163 None,
164 HashMap::new(),
165 false,
166 )
167 .is_err()
168 );
169 }
170
171 fn write_region_manifest(dir: &std::path::Path) -> std::path::PathBuf {
174 std::fs::create_dir_all(dir).unwrap();
175 std::fs::write(
176 dir.join("agent.leviath"),
177 r#"
178[agent]
179name = "reviewer"
180
181[stages.main]
182mode = "autonomous"
183
184[stages.main.model]
185provider = "anthropic"
186model = "claude-sonnet-5"
187
188[context.regions]
189task = { kind = "pinned", max_tokens = 4000, seed = "task_input" }
190criteria = { kind = "pinned", max_tokens = 2000, seed = "input" }
191conversation = { kind = "sliding_window", max_items = 20, max_tokens = 10000 }
192"#,
193 )
194 .unwrap();
195 dir.join("agent.leviath")
196 }
197
198 #[test]
199 fn resolve_spawn_args_resolves_declared_region_and_reads_at_path() {
200 let dir = tempfile::tempdir().unwrap();
201 let manifest = write_region_manifest(&dir.path().join("reviewer"));
202 let policy = dir.path().join("policy.md");
203 std::fs::write(&policy, " focus on safety ").unwrap();
204
205 let regions = HashMap::from([(
206 "criteria".to_string(),
207 format!("@{}", policy.to_string_lossy()),
208 )]);
209 let args = resolve_spawn_args(
210 manifest.to_str().unwrap(),
211 "review it",
212 None,
213 "/work",
214 false,
215 Vec::new(),
216 None,
217 regions,
218 false,
219 )
220 .unwrap();
221 assert_eq!(
223 args.regions.get("criteria").map(String::as_str),
224 Some("focus on safety")
225 );
226 }
227
228 #[test]
229 fn resolve_spawn_args_unknown_region_reports_none_when_no_caller_inputs() {
230 let dir = tempfile::tempdir().unwrap();
232 let agent_dir = dir.path().join("noinput");
233 std::fs::create_dir_all(&agent_dir).unwrap();
234 std::fs::write(
235 agent_dir.join("agent.leviath"),
236 r#"
237[agent]
238name = "noinput"
239
240[stages.main]
241mode = "autonomous"
242
243[stages.main.model]
244provider = "anthropic"
245model = "claude-sonnet-5"
246
247[context.regions]
248data = { kind = "pinned", max_tokens = 2000 }
249conversation = { kind = "sliding_window", max_items = 20, max_tokens = 10000 }
250"#,
251 )
252 .unwrap();
253 let manifest = agent_dir.join("agent.leviath");
254 let regions = HashMap::from([("foo".to_string(), "x".to_string())]);
255 let err = resolve_spawn_args(
256 manifest.to_str().unwrap(),
257 "t",
258 None,
259 "/work",
260 false,
261 Vec::new(),
262 None,
263 regions,
264 false,
265 )
266 .unwrap_err();
267 assert!(err.to_string().contains("(none)"), "got: {err}");
268 }
269
270 #[test]
271 fn resolve_spawn_args_manifest_read_error_surfaces() {
272 let dir = tempfile::tempdir().unwrap();
275 let agent_dir = dir.path().join("dirmanifest");
276 std::fs::create_dir_all(agent_dir.join("agent.leviath")).unwrap();
277 let regions = HashMap::from([("x".to_string(), "y".to_string())]);
278 let err = resolve_spawn_args(
279 agent_dir.to_str().unwrap(),
280 "t",
281 None,
282 "/work",
283 false,
284 Vec::new(),
285 None,
286 regions,
287 false,
288 )
289 .unwrap_err();
290 assert!(err.to_string().contains("read manifest"), "got: {err}");
291 }
292
293 #[test]
294 fn resolve_spawn_args_manifest_parse_error_surfaces() {
295 let dir = tempfile::tempdir().unwrap();
296 let agent_dir = dir.path().join("badtoml");
297 std::fs::create_dir_all(&agent_dir).unwrap();
298 std::fs::write(
299 agent_dir.join("agent.leviath"),
300 "this is : not = valid toml [[[",
301 )
302 .unwrap();
303 let regions = HashMap::from([("x".to_string(), "y".to_string())]);
304 let err = resolve_spawn_args(
305 agent_dir.join("agent.leviath").to_str().unwrap(),
306 "t",
307 None,
308 "/work",
309 false,
310 Vec::new(),
311 None,
312 regions,
313 false,
314 )
315 .unwrap_err();
316 assert!(err.to_string().contains("parse manifest"), "got: {err}");
317 }
318
319 #[test]
320 fn resolve_spawn_args_region_value_bad_file_errors() {
321 let dir = tempfile::tempdir().unwrap();
324 let manifest = write_region_manifest(&dir.path().join("reviewer"));
325 let regions = HashMap::from([("criteria".to_string(), "@/no/such/file.md".to_string())]);
326 let err = resolve_spawn_args(
327 manifest.to_str().unwrap(),
328 "review it",
329 None,
330 "/work",
331 false,
332 Vec::new(),
333 None,
334 regions,
335 false,
336 )
337 .unwrap_err();
338 assert!(
339 err.to_string().contains("Failed to read region file"),
340 "got: {err}"
341 );
342 }
343
344 #[test]
345 fn resolve_spawn_args_rejects_unknown_region_flag() {
346 let dir = tempfile::tempdir().unwrap();
347 let manifest = write_region_manifest(&dir.path().join("reviewer"));
348 let regions = HashMap::from([("bogus".to_string(), "x".to_string())]);
349 let err = resolve_spawn_args(
350 manifest.to_str().unwrap(),
351 "review it",
352 None,
353 "/work",
354 false,
355 Vec::new(),
356 None,
357 regions,
358 false,
359 )
360 .unwrap_err();
361 assert!(
362 err.to_string().contains("unknown region '--bogus'"),
363 "got: {err}"
364 );
365 }
366
367 fn fake_daemon(
370 dir: &std::path::Path,
371 response_line: &'static str,
372 ) -> (ControlId, JoinHandle<()>) {
373 let id = control_id(dir);
374 let mut listener = bind_control_listener(&id).unwrap();
375 let handle = tokio::spawn(async move {
376 let stream = listener
377 .accept()
378 .await
379 .expect("accept succeeds")
380 .expect("our own connection is admitted");
381 let (read_half, mut write_half) = tokio::io::split(stream);
382 let mut lines = BufReader::new(read_half).lines();
383 let _request = lines.next_line().await.unwrap();
384 write_half
385 .write_all(response_line.as_bytes())
386 .await
387 .unwrap();
388 write_half.write_all(b"\n").await.unwrap();
389 });
390 (id, handle)
391 }
392
393 async fn send(response_line: &'static str) -> anyhow::Result<()> {
394 let dir = tempfile::tempdir().unwrap();
395 let (id, server) = fake_daemon(dir.path(), response_line);
396 let result = send_spawn(&ControlClient::new(id), SpawnArgs::default()).await;
397 server.await.unwrap();
398 result
399 }
400
401 #[tokio::test]
402 async fn send_spawn_reports_success() {
403 assert!(
404 send(r#"{"result":"spawned","run_id":"run-9"}"#)
405 .await
406 .is_ok()
407 );
408 }
409
410 #[tokio::test]
411 async fn send_spawn_reports_daemon_error() {
412 let err = send(r#"{"result":"error","message":"boom"}"#)
413 .await
414 .unwrap_err();
415 assert!(err.to_string().contains("boom"));
416 }
417
418 #[tokio::test]
419 async fn send_spawn_reports_unexpected_response() {
420 let err = send(r#"{"result":"ok","ok":true}"#).await.unwrap_err();
421 assert!(err.to_string().contains("unexpected"));
422 }
423
424 #[tokio::test]
425 async fn send_spawn_errors_when_daemon_absent() {
426 let dir = tempfile::tempdir().unwrap();
427 let id = control_id(&dir.path().join("no-daemon"));
429 let err = send_spawn(&ControlClient::new(id), SpawnArgs::default())
430 .await
431 .unwrap_err();
432 assert!(err.to_string().contains("not reachable"));
433 }
434}