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}