Skip to main content

layover_core/
report.rs

1//! What an agent writes about its own run.
2//!
3//! A run leaves three kinds of trace and they answer different questions. The [`RunRecord`] says
4//! *that* it happened — agent, outcome, duration, cost — and is what a list is built from. A
5//! [`HelpRequest`] says what got in the way. This says what the agent actually *did*, in its own
6//! words, and it is the only one of the three a person reads for content rather than for status.
7//!
8//! [`RunRecord`]: crate::run::RunRecord
9//! [`HelpRequest`]: crate::help::HelpRequest
10//!
11//! # Why the agent writes it rather than the Tower capturing output
12//!
13//! A transcript is not a report. It contains everything the agent thought, including the three
14//! approaches it abandoned, and reading one to find out what happened is slower than doing the
15//! work again. The Tower could persist transcripts and separately ask for a summary, but asking
16//! an agent to state its own conclusion has a second effect worth more than the storage: an agent
17//! that must write down what it concluded is an agent that has to decide what it concluded.
18//!
19//! # Why it is capped
20//!
21//! Reports accumulate at one per run and are read in lists. An unbounded body is a slow page and
22//! eventually a large disk, and an agent asked for "a report" with no limit will produce its
23//! transcript. The cap is generous enough for real prose and small enough that the intent is
24//! obvious.
25
26use jiff::Timestamp;
27use serde::{Deserialize, Serialize};
28
29use crate::agent::AgentName;
30use crate::flight::{ItineraryId, RunId};
31
32/// Longest a headline may be.
33///
34/// One line in a list. Anything longer is a body pretending to be a headline.
35pub const MAX_HEADLINE: usize = 160;
36
37/// Longest a body may be, in characters.
38///
39/// Roughly two thousand words. Past that an agent is pasting rather than reporting.
40pub const MAX_BODY: usize = 12_000;
41
42/// Most artefacts one report may name.
43pub const MAX_ARTIFACTS: usize = 32;
44
45/// What one run concluded.
46#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
47pub struct Report {
48    /// The run this describes.
49    pub run: RunId,
50    /// Which agent wrote it.
51    pub agent: AgentName,
52    /// The chain it belonged to.
53    pub itinerary: ItineraryId,
54    /// One line, for a list. What happened, not what was attempted.
55    pub headline: String,
56    /// The report itself, as the agent wrote it.
57    pub body: String,
58    /// What it produced or changed: paths, branch names, pull request identifiers.
59    ///
60    /// Separate from the body so a reader can find the output without reading the prose, and so
61    /// a later run can be told what an earlier one left behind.
62    #[serde(default, skip_serializing_if = "Vec::is_empty")]
63    pub artifacts: Vec<String>,
64    /// When it was written.
65    pub at: Timestamp,
66}
67
68impl Report {
69    /// Records a report, trimming anything past the caps.
70    ///
71    /// Truncates rather than rejecting. A report is the only account of a run that has already
72    /// happened and already cost money; throwing it away because it was too long would lose the
73    /// thing entirely to punish a formatting mistake.
74    #[must_use]
75    pub fn new(
76        run: RunId,
77        agent: AgentName,
78        itinerary: ItineraryId,
79        headline: impl Into<String>,
80        body: impl Into<String>,
81        at: Timestamp,
82    ) -> Self {
83        Self {
84            run,
85            agent,
86            itinerary,
87            headline: clamp(&headline.into(), MAX_HEADLINE),
88            body: clamp(&body.into(), MAX_BODY),
89            artifacts: Vec::new(),
90            at,
91        }
92    }
93
94    /// Names what the run produced.
95    #[must_use]
96    pub fn producing(mut self, artifacts: Vec<String>) -> Self {
97        self.artifacts = artifacts.into_iter().take(MAX_ARTIFACTS).collect();
98        self
99    }
100
101    /// Returns `true` when the agent had something to say beyond the headline.
102    #[must_use]
103    pub fn has_body(&self) -> bool {
104        !self.body.trim().is_empty()
105    }
106
107    /// Returns `true` when anything was trimmed to fit.
108    ///
109    /// Worth surfacing: a reader who can see the report was cut knows to look at the transcript
110    /// rather than assuming the agent stopped there.
111    #[must_use]
112    pub fn was_trimmed(&self) -> bool {
113        self.headline.ends_with('…') || self.body.ends_with('…')
114    }
115}
116
117/// Shortens text to `limit` characters, marking it when it had to.
118fn clamp(text: &str, limit: usize) -> String {
119    let trimmed = text.trim();
120    if trimmed.chars().count() <= limit {
121        return trimmed.to_owned();
122    }
123
124    let mut kept: String = trimmed.chars().take(limit.saturating_sub(1)).collect();
125    kept.push('…');
126    kept
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    fn at() -> Timestamp {
134        "2026-09-17T10:00:00Z".parse().expect("valid timestamp")
135    }
136
137    fn report(headline: &str, body: &str) -> Report {
138        Report::new(
139            RunId::generate(),
140            "developer".into(),
141            ItineraryId::generate(),
142            headline,
143            body,
144            at(),
145        )
146    }
147
148    #[test]
149    fn a_report_keeps_what_the_agent_wrote() {
150        let written = report(
151            "Fixed the proxy option mismatch",
152            "The three assertions were incompatible; changed the factory to take the resolved \
153             options rather than rebuilding them.",
154        );
155
156        assert_eq!(written.headline, "Fixed the proxy option mismatch");
157        assert!(written.has_body());
158        assert!(!written.was_trimmed());
159    }
160
161    #[test]
162    fn an_overlong_report_is_trimmed_rather_than_refused() {
163        // A report is the only account of a run that has already happened and already cost
164        // money. Throwing it away to punish a formatting mistake loses the thing entirely.
165        let written = report(&"x".repeat(MAX_HEADLINE + 50), &"y".repeat(MAX_BODY + 500));
166
167        assert_eq!(written.headline.chars().count(), MAX_HEADLINE);
168        assert_eq!(written.body.chars().count(), MAX_BODY);
169        assert!(written.was_trimmed());
170    }
171
172    #[test]
173    fn a_reader_can_tell_a_report_was_cut() {
174        // Otherwise it looks like the agent simply stopped there, and nobody goes looking for
175        // the rest.
176        assert!(report(&"x".repeat(MAX_HEADLINE + 1), "short").was_trimmed());
177        assert!(!report("short", "short").was_trimmed());
178    }
179
180    #[test]
181    fn artifacts_are_capped_but_the_report_survives() {
182        let many: Vec<String> = (0..MAX_ARTIFACTS + 10)
183            .map(|n| format!("file{n}.rs"))
184            .collect();
185        let written = report("did a lot", "body").producing(many);
186
187        assert_eq!(written.artifacts.len(), MAX_ARTIFACTS);
188        assert!(written.has_body());
189    }
190
191    #[test]
192    fn a_report_with_nothing_in_the_body_is_recognisable() {
193        // A headline alone is a legitimate report -- "nothing had changed" is a complete account
194        // of a follow-up run -- but a reader should not be offered an empty panel to open.
195        let written = report("Nothing had changed on the pull request", "   ");
196
197        assert!(!written.has_body());
198    }
199
200    #[test]
201    fn a_report_serialises_to_one_readable_line() {
202        let line = serde_json::to_string(&report("Fixed it", "Details here")).expect("serialises");
203
204        assert!(!line.contains('\n'));
205        assert!(line.contains(r#""headline":"Fixed it""#), "{line}");
206        assert!(line.contains(r#""at":"2026-09-17T10:00:00Z""#), "{line}");
207        assert!(!line.contains("artifacts"), "absent, not empty: {line}");
208    }
209
210    #[test]
211    fn a_report_round_trips() {
212        let written = report("Fixed it", "Details").producing(vec!["branch/fix-1".to_owned()]);
213        let line = serde_json::to_string(&written).expect("serialises");
214
215        assert_eq!(
216            serde_json::from_str::<Report>(&line).expect("deserialises"),
217            written
218        );
219    }
220}