Skip to main content

aither_core/llm/tool/
command.rs

1//! Typed record of a command run, carried by `ToolResult::Command`.
2//!
3//! The model reads the prose [`CommandResult::render_for_model`] renders from
4//! these fields; hosts read the fields and never parse that prose.
5
6use alloc::string::String;
7
8use askama::Template;
9
10/// A file a command's output was written to.
11#[derive(Debug, Clone, PartialEq, Eq)]
12#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
13pub struct StoredFile {
14    /// Location relative to the session sandbox (for example `outputs/x.txt`).
15    pub url: String,
16    /// Absolute filesystem path of the same file.
17    pub path: String,
18}
19
20/// One output stream of a command.
21#[derive(Debug, Clone, PartialEq, Eq)]
22#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
23#[cfg_attr(feature = "serde", serde(tag = "kind", rename_all = "snake_case"))]
24pub enum CommandStream {
25    /// Output small enough to keep in the result.
26    Inline(String),
27    /// Output written to a file.
28    Stored {
29        /// Where the full output is stored.
30        file: StoredFile,
31        /// What the model is shown (head/tail or folded view); empty when the
32        /// output has no text view.
33        preview: String,
34        /// Whether `preview` is partial.
35        truncated: bool,
36    },
37    /// Image output, always written to a file.
38    Image {
39        /// MIME type of the image.
40        media_type: String,
41        /// Where the image is stored.
42        file: StoredFile,
43    },
44}
45
46/// How a command run ended.
47#[derive(Debug, Clone, PartialEq, Eq)]
48#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
49#[cfg_attr(feature = "serde", serde(tag = "status", rename_all = "snake_case"))]
50pub enum CommandOutcome {
51    /// The command ran to completion.
52    Exited {
53        /// Process exit code.
54        code: i32,
55    },
56    /// A background task; its output is being written to `output`.
57    Running {
58        /// Identifier of the background task.
59        task_id: String,
60        /// File the background output is written to.
61        output: StoredFile,
62    },
63    /// The foreground run was killed at its time bound.
64    Killed {
65        /// Identifier of the killed run.
66        task_id: String,
67        /// Model-facing reason the run was killed.
68        notice: String,
69    },
70}
71
72/// Complete typed record of one command run.
73#[derive(Debug, Clone, PartialEq, Eq)]
74#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
75pub struct CommandResult {
76    /// Standard output.
77    pub stdout: CommandStream,
78    /// Standard error, when the command wrote any.
79    pub stderr: Option<CommandStream>,
80    /// How the run ended.
81    pub outcome: CommandOutcome,
82}
83
84#[derive(Template)]
85#[template(path = "command_stream.txt", escape = "none")]
86struct CommandStreamTemplate<'a> {
87    stream: &'a CommandStream,
88}
89
90#[derive(Template)]
91#[template(path = "command_result.txt", escape = "none")]
92struct CommandResultTemplate<'a> {
93    stdout: &'a str,
94    stderr: Option<&'a str>,
95    outcome: &'a CommandOutcome,
96}
97
98impl CommandStream {
99    /// Returns `true` when the stream holds no output.
100    #[must_use]
101    pub const fn is_empty(&self) -> bool {
102        matches!(self, Self::Inline(text) if text.is_empty())
103    }
104
105    /// Renders the stream as the newline-terminated block the model reads;
106    /// an empty stream renders as the empty string.
107    ///
108    /// # Panics
109    ///
110    /// Panics if the template fails to render, which formatting in-memory
111    /// strings cannot do.
112    #[must_use]
113    pub fn render_for_model(&self) -> String {
114        if self.is_empty() {
115            return String::new();
116        }
117        let mut block = CommandStreamTemplate { stream: self }
118            .render()
119            .expect("command stream template must render");
120        if !block.ends_with('\n') {
121            block.push('\n');
122        }
123        block
124    }
125}
126
127impl CommandResult {
128    /// Renders the prose the model reads.
129    ///
130    /// # Panics
131    ///
132    /// Panics if the template fails to render, which formatting in-memory
133    /// strings cannot do.
134    #[must_use]
135    pub fn render_for_model(&self) -> String {
136        let stdout = self.stdout.render_for_model();
137        let stderr = self
138            .stderr
139            .as_ref()
140            .map(CommandStream::render_for_model)
141            .filter(|block| !block.is_empty());
142        CommandResultTemplate {
143            stdout: &stdout,
144            stderr: stderr.as_deref(),
145            outcome: &self.outcome,
146        }
147        .render()
148        .expect("command result template must render")
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use alloc::string::ToString;
155
156    use super::*;
157
158    fn file() -> StoredFile {
159        StoredFile {
160            url: "outputs/x.txt".to_string(),
161            path: "/sandbox/outputs/x.txt".to_string(),
162        }
163    }
164
165    fn result(
166        stdout: CommandStream,
167        stderr: Option<CommandStream>,
168        outcome: CommandOutcome,
169    ) -> String {
170        CommandResult {
171            stdout,
172            stderr,
173            outcome,
174        }
175        .render_for_model()
176    }
177
178    #[test]
179    fn placeholder_only_when_both_streams_are_empty() {
180        assert_eq!(
181            result(
182                CommandStream::Inline(String::new()),
183                None,
184                CommandOutcome::Exited { code: 0 }
185            ),
186            "(no output)\n"
187        );
188        assert_eq!(
189            result(
190                CommandStream::Inline(String::new()),
191                Some(CommandStream::Inline("oops".to_string())),
192                CommandOutcome::Exited { code: 2 },
193            ),
194            "stderr:\noops\nExit code: 2.\n"
195        );
196    }
197
198    #[test]
199    fn stored_streams_state_url_and_absolute_path() {
200        let stored = CommandStream::Stored {
201            file: file(),
202            preview: "head".to_string(),
203            truncated: true,
204        };
205        assert_eq!(
206            result(stored, None, CommandOutcome::Exited { code: 0 }),
207            "head\n[Partial view: the full output is saved to outputs/x.txt (absolute path: /sandbox/outputs/x.txt). Use head/tail/grep/sed on that file to read the rest.]\n"
208        );
209        let image = CommandStream::Image {
210            media_type: "image/png".to_string(),
211            file: file(),
212        };
213        assert_eq!(
214            result(image, None, CommandOutcome::Exited { code: 0 }),
215            "[Image: image/png, saved to outputs/x.txt (absolute path: /sandbox/outputs/x.txt).]\n"
216        );
217    }
218
219    #[test]
220    fn running_and_killed_outcomes_render_their_facts() {
221        assert_eq!(
222            result(
223                CommandStream::Inline(String::new()),
224                None,
225                CommandOutcome::Running {
226                    task_id: "t1".to_string(),
227                    output: file(),
228                },
229            ),
230            "Started in background as task 't1'; output is being written to outputs/x.txt (absolute path: /sandbox/outputs/x.txt).\n"
231        );
232        assert_eq!(
233            result(
234                CommandStream::Inline("partial\n".to_string()),
235                None,
236                CommandOutcome::Killed {
237                    task_id: "t2".to_string(),
238                    notice: "Killed at the time bound.".to_string(),
239                },
240            ),
241            "partial\nKilled at the time bound.\nTask ID: 't2'.\n"
242        );
243    }
244}