Skip to main content

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;