Skip to main content

leviath_cli/daemon/
client.rs

1//! Client-side helpers for talking to the shared-world daemon: building a spawn
2//! request from local inputs and exchanging it over the control socket. Shared by
3//! `lev run` (and reusable by other clients). The socket-path resolution + connect
4//! live in the binary; these cores are unit-testable against a fake socket server.
5
6use 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/// Resolve the local inputs of a spawn request: find the manifest, mint a run id
18/// from the agent's directory name, record the working directory, and resolve
19/// any dynamic `--<region>` flags (raw values, `@path` or literal) against the
20/// blueprint's declared caller-input regions.
21///
22/// `regions` maps a flag name to its raw value. An unknown region name (one the
23/// blueprint doesn't read as caller input) is a hard error here - fast, local
24/// typo protection before the daemon is contacted.
25#[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    // Validate + resolve region flags against the blueprint's caller-input regions.
45    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        // A top-level run (sub-agents/fan-out set this on the host side).
93        parent_run_id: None,
94    })
95}
96
97/// Send a resolved spawn request to the daemon and report the outcome, printing
98/// the new run id on success.
99pub 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    /// Write a manifest declaring a `criteria` caller-input region, returning its
172    /// path.
173    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        // `@path` was read and trimmed.
222        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        // A blueprint with zero caller-input regions: the error lists "(none)".
231        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        // `find_manifest` accepts a dir whose `agent.leviath` merely *exists*; when
273        // that entry is itself a directory, the client-side read fails (EISDIR).
274        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        // A declared region whose `@file` value can't be read → the error from
322        // read_region_value propagates out of resolve_spawn_args.
323        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    /// Bind a control listener at a fresh id under `dir` and serve one canned
368    /// response, returning the id clients connect to and the server task.
369    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        // A control id with no daemon bound to it.
428        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}