leviath_cli/commands/result.rs
1//! `lev result <run-id>` - print what an agent handed back.
2//!
3//! There was no way to read a finished run's answer from the command line. The
4//! run's logs were on disk and `lev ps` reported its status, but the thing the
5//! agent actually concluded lived nowhere a shell could reach it - the only
6//! surface serving it was `GET /api/agents/{id}/result`, which needed a running
7//! `lev serve`.
8//!
9//! Read-only and daemon-free: everything comes from the run's `meta.json`, so
10//! this answers for a run that finished last week as readily as one that
11//! finished a second ago.
12
13use clap::Args;
14
15/// Arguments for `lev result`.
16#[derive(Args, Debug)]
17pub struct ResultArgs {
18 /// The run whose final output to print.
19 pub run_id: String,
20
21 /// Print the output and its metadata as JSON.
22 #[arg(long)]
23 pub json: bool,
24
25 /// Print only the answer itself, with no heading and no trailing summary -
26 /// what a shell pipeline wants.
27 #[arg(long)]
28 pub raw: bool,
29}
30
31/// Execute `lev result`.
32pub async fn execute(args: ResultArgs) -> anyhow::Result<()> {
33 let meta = crate::runstate::read_meta(&args.run_id)
34 .map_err(|e| anyhow::anyhow!("no run '{}': {e}", args.run_id))?;
35 // `meta.json` says whether there is an answer and how big; the bytes are in
36 // the sidecar beside it.
37 let output = crate::runstate::read_final_output(&args.run_id);
38 match render(&args.run_id, output.as_ref(), args.json, args.raw) {
39 Some(out) => {
40 print!("{out}");
41 Ok(())
42 }
43 // A missing answer is a failure exit rather than empty output, so
44 // `lev result <id> > answer.txt` in a script does not silently write an
45 // empty file and carry on.
46 None => anyhow::bail!(
47 "run '{}' produced no final output (status: {}). Only an agent that calls \
48 `submit_output` has an answer to show; see `lev ps` for what it did.",
49 args.run_id,
50 meta.status
51 ),
52 }
53}
54
55/// Render the answer, or `None` when the run never gave one. Pure, so the
56/// formatting is directly testable.
57fn render(
58 run_id: &str,
59 output: Option<&leviath_core::FinalOutput>,
60 json: bool,
61 raw: bool,
62) -> Option<String> {
63 let output = output?;
64 if json {
65 // The whole record, not just the content: a caller parsing this wants
66 // the format label too, and whether the answer was cut short.
67 return Some(format!(
68 "{}\n",
69 serde_json::to_string_pretty(output).expect("a final output always serializes")
70 ));
71 }
72 if raw {
73 // Content only. A trailing newline is added when the answer lacks one,
74 // so the shell prompt does not end up glued to the last line.
75 return Some(match output.content.ends_with('\n') {
76 true => output.content.clone(),
77 false => format!("{}\n", output.content),
78 });
79 }
80
81 let mut out = String::new();
82 let shape = output
83 .format
84 .as_deref()
85 .map(|f| format!(" ({f})"))
86 .unwrap_or_default();
87 out.push_str(&format!(
88 "Final output{shape} from run '{run_id}', stage '{}':\n\n",
89 output.stage
90 ));
91 out.push_str(&output.content);
92 if !output.content.ends_with('\n') {
93 out.push('\n');
94 }
95 if output.truncated {
96 out.push_str(
97 "\n[truncated: the agent's answer exceeded the size limit and was cut short]\n",
98 );
99 }
100 if !output.artifacts.is_empty() {
101 out.push_str(&format!("\nFiles produced ({}):\n", output.artifacts.len()));
102 for path in &output.artifacts {
103 out.push_str(&format!(" {path}\n"));
104 }
105 }
106 Some(out)
107}
108
109#[cfg(test)]
110mod tests;