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