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;
7use alloc::vec::Vec;
8
9use askama::Template;
10
11/// A file a command's output was written to.
12#[derive(Debug, Clone, PartialEq, Eq)]
13#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
14pub struct StoredFile {
15    /// Location relative to the session sandbox (for example `outputs/x.txt`).
16    pub url: String,
17    /// Absolute filesystem path of the same file.
18    pub path: String,
19}
20
21/// One output stream of a command.
22#[derive(Debug, Clone, PartialEq, Eq)]
23#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
24#[cfg_attr(feature = "serde", serde(tag = "kind", rename_all = "snake_case"))]
25pub enum CommandStream {
26    /// Output small enough to keep in the result.
27    Inline {
28        /// The output text.
29        text: String,
30    },
31    /// Output written to a file.
32    Stored {
33        /// Where the full output is stored.
34        file: StoredFile,
35        /// What the model is shown (head/tail or folded view); empty when the
36        /// output has no text view.
37        preview: String,
38        /// Whether `preview` is partial.
39        truncated: bool,
40    },
41    /// Image output, always written to a file.
42    Image {
43        /// MIME type of the image.
44        media_type: String,
45        /// Where the image is stored.
46        file: StoredFile,
47    },
48}
49
50/// How a command run ended.
51#[derive(Debug, Clone, PartialEq, Eq)]
52#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
53#[cfg_attr(feature = "serde", serde(tag = "status", rename_all = "snake_case"))]
54pub enum CommandOutcome {
55    /// The command ran to completion.
56    Exited {
57        /// Process exit code.
58        code: i32,
59    },
60    /// A background task; its output is being written to `output`.
61    Running {
62        /// Identifier of the background task.
63        task_id: String,
64        /// File the background output is written to.
65        output: StoredFile,
66    },
67    /// The foreground run was killed at its time bound.
68    Killed {
69        /// Identifier of the killed run.
70        task_id: String,
71        /// Model-facing reason the run was killed.
72        notice: String,
73    },
74}
75
76/// What was unavailable during an [`Interruption`].
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
79#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
80pub enum InterruptionCause {
81    /// The machine was asleep.
82    Asleep,
83    /// The machine had no route to the network.
84    Offline,
85}
86
87/// A span of a command's run during which the machine was asleep or offline.
88///
89/// The times are wall-clock times of day in the machine's local time zone,
90/// exactly as the model reads them.
91#[derive(Debug, Clone, PartialEq, Eq)]
92#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
93pub struct Interruption {
94    /// What was unavailable.
95    pub cause: InterruptionCause,
96    /// When the span began, or when the command started if it began earlier.
97    pub from: String,
98    /// When the span ended, or when the command ended if it ended earlier.
99    pub to: String,
100}
101
102/// Renders `interruptions` as one line each; the empty string when there are
103/// none.
104///
105/// # Panics
106///
107/// Panics if the template fails to render, which formatting in-memory
108/// strings cannot do.
109#[must_use]
110pub fn render_interruptions(interruptions: &[Interruption]) -> String {
111    InterruptionsTemplate { interruptions }
112        .render()
113        .expect("interruptions template must render")
114}
115
116/// Complete typed record of one command run.
117#[derive(Debug, Clone, PartialEq, Eq)]
118#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
119pub struct CommandResult {
120    /// Standard output.
121    pub stdout: CommandStream,
122    /// Standard error, when the command wrote any.
123    pub stderr: Option<CommandStream>,
124    /// How the run ended.
125    pub outcome: CommandOutcome,
126    /// Spans of the run during which the machine was asleep or offline.
127    #[cfg_attr(feature = "serde", serde(default))]
128    pub interruptions: Vec<Interruption>,
129}
130
131#[derive(Template)]
132#[template(path = "command_interruptions.txt", escape = "none")]
133struct InterruptionsTemplate<'a> {
134    interruptions: &'a [Interruption],
135}
136
137#[derive(Template)]
138#[template(path = "command_stream.txt", escape = "none")]
139struct CommandStreamTemplate<'a> {
140    stream: &'a CommandStream,
141}
142
143#[derive(Template)]
144#[template(path = "command_result.txt", escape = "none")]
145struct CommandResultTemplate<'a> {
146    stdout: &'a str,
147    stderr: Option<&'a str>,
148    outcome: &'a CommandOutcome,
149    interruptions: &'a str,
150}
151
152impl CommandStream {
153    /// Returns `true` when the stream holds no output.
154    #[must_use]
155    pub const fn is_empty(&self) -> bool {
156        matches!(self, Self::Inline { text } if text.is_empty())
157    }
158
159    /// Renders the stream as the newline-terminated block the model reads;
160    /// an empty stream renders as the empty string.
161    ///
162    /// # Panics
163    ///
164    /// Panics if the template fails to render, which formatting in-memory
165    /// strings cannot do.
166    #[must_use]
167    pub fn render_for_model(&self) -> String {
168        if self.is_empty() {
169            return String::new();
170        }
171        let mut block = CommandStreamTemplate { stream: self }
172            .render()
173            .expect("command stream template must render");
174        if !block.ends_with('\n') {
175            block.push('\n');
176        }
177        block
178    }
179}
180
181impl CommandResult {
182    /// Renders the prose the model reads.
183    ///
184    /// # Panics
185    ///
186    /// Panics if the template fails to render, which formatting in-memory
187    /// strings cannot do.
188    #[must_use]
189    pub fn render_for_model(&self) -> String {
190        let stdout = self.stdout.render_for_model();
191        let stderr = self
192            .stderr
193            .as_ref()
194            .map(CommandStream::render_for_model)
195            .filter(|block| !block.is_empty());
196        CommandResultTemplate {
197            stdout: &stdout,
198            stderr: stderr.as_deref(),
199            outcome: &self.outcome,
200            interruptions: &render_interruptions(&self.interruptions),
201        }
202        .render()
203        .expect("command result template must render")
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use alloc::string::ToString;
210
211    use super::*;
212
213    fn file() -> StoredFile {
214        StoredFile {
215            url: "outputs/x.txt".to_string(),
216            path: "/sandbox/outputs/x.txt".to_string(),
217        }
218    }
219
220    fn result(
221        stdout: CommandStream,
222        stderr: Option<CommandStream>,
223        outcome: CommandOutcome,
224    ) -> String {
225        CommandResult {
226            stdout,
227            stderr,
228            outcome,
229            interruptions: Vec::new(),
230        }
231        .render_for_model()
232    }
233
234    #[test]
235    fn placeholder_only_when_both_streams_are_empty() {
236        assert_eq!(
237            result(
238                CommandStream::Inline {
239                    text: String::new()
240                },
241                None,
242                CommandOutcome::Exited { code: 0 }
243            ),
244            "(no output)\n"
245        );
246        assert_eq!(
247            result(
248                CommandStream::Inline {
249                    text: String::new()
250                },
251                Some(CommandStream::Inline {
252                    text: "oops".to_string()
253                }),
254                CommandOutcome::Exited { code: 2 },
255            ),
256            "stderr:\noops\nExit code: 2.\n"
257        );
258    }
259
260    #[test]
261    fn stored_streams_state_url_and_absolute_path() {
262        let stored = CommandStream::Stored {
263            file: file(),
264            preview: "head".to_string(),
265            truncated: true,
266        };
267        assert_eq!(
268            result(stored, None, CommandOutcome::Exited { code: 0 }),
269            "head\n[Partial view: the full output is saved to outputs/x.txt (absolute path: /sandbox/outputs/x.txt). Use head/tail/rg on that file to read the rest.]\n"
270        );
271        let image = CommandStream::Image {
272            media_type: "image/png".to_string(),
273            file: file(),
274        };
275        assert_eq!(
276            result(image, None, CommandOutcome::Exited { code: 0 }),
277            "[Image: image/png, saved to outputs/x.txt (absolute path: /sandbox/outputs/x.txt).]\n"
278        );
279    }
280
281    #[test]
282    fn running_and_killed_outcomes_render_their_facts() {
283        assert_eq!(
284            result(
285                CommandStream::Inline {
286                    text: String::new()
287                },
288                None,
289                CommandOutcome::Running {
290                    task_id: "t1".to_string(),
291                    output: file(),
292                },
293            ),
294            "Started in background as task 't1'; output is being written to outputs/x.txt (absolute path: /sandbox/outputs/x.txt).\n"
295        );
296        assert_eq!(
297            result(
298                CommandStream::Inline {
299                    text: "partial\n".to_string()
300                },
301                None,
302                CommandOutcome::Killed {
303                    task_id: "t2".to_string(),
304                    notice: "Killed at the time bound.".to_string(),
305                },
306            ),
307            "partial\nKilled at the time bound.\nTask ID: 't2'.\n"
308        );
309    }
310
311    #[test]
312    fn interruptions_render_one_line_each_after_the_outcome() {
313        let command = CommandResult {
314            stdout: CommandStream::Inline {
315                text: "out\n".to_string(),
316            },
317            stderr: None,
318            outcome: CommandOutcome::Exited { code: 1 },
319            interruptions: alloc::vec![
320                Interruption {
321                    cause: InterruptionCause::Asleep,
322                    from: "14:02:11".to_string(),
323                    to: "14:31:40".to_string(),
324                },
325                Interruption {
326                    cause: InterruptionCause::Offline,
327                    from: "14:31:40".to_string(),
328                    to: "14:32:05".to_string(),
329                },
330            ],
331        };
332        assert_eq!(
333            command.render_for_model(),
334            "out\nExit code: 1.\nThe machine was asleep from 14:02:11 to 14:31:40 while this ran.\nThe network was offline from 14:31:40 to 14:32:05 while this ran.\n"
335        );
336    }
337}