返回 CodeWhale
agent_roster.rs
根目录 / crates / tui / src / agent_roster.rs
1 //! Receipts-only projection of every agent that ran this session (#5479).
2 //!
3 //! Separated from roster glyphs and terminal rendering so engine events,
4 //! protocol parity, and headless audit records can track worker receipts
5 //! without depending on TUI layout modules.
6 //!
7 //! ## The truth rule
8 //!
9 //! Every number here is an `Option`, and `None` renders as `—`, never as `0`.
10 //! The distinction is the whole point: "this worker reported 96,300 input
11 //! tokens" and "no usage receipt exists for this worker" are different facts,
12 //! and a rail that prints `0` for the second one is lying in the direction that
13 //! makes Codewhale look cheap. Nothing here estimates, derives a token count
14 //! from text, or back-fills a missing receipt — values come from
15 //! `AgentRunUsage`, which is populated from immutable per-response route
16 //! audits, or they are absent.
17 //!
18 //! A finished agent keeps the numbers it finished with: rows are built from the
19 //! retained worker record, never recomputed from live state.
20
21 use serde::{Deserialize, Serialize};
22
23 use crate::tools::subagent::{AgentWorkerRecord, AgentWorkerStatus};
24
25 /// What a row is doing, collapsed to one glanceable state.
26 ///
27 /// Deliberately coarser than `AgentWorkerStatus`: the rail needs a glyph and
28 /// a sort rank, not the full lifecycle. The precise status stays on the row.
29 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
30 #[serde(rename_all = "snake_case")]
31 pub enum RosterState {
32 Running,
33 Waiting,
34 /// Settled because the parent's turn ended before this child did (#5906).
35 ///
36 /// Distinct from `Waiting`, which means a person can answer it. Nothing
37 /// will answer a parked husk; it is continued with `resume_from` or
38 /// dismissed with `cancel`.
39 Parked,
40 Done,
41 Failed,
42 Cancelled,
43 }
44
45 impl RosterState {
46 #[must_use]
47 pub const fn is_terminal(self) -> bool {
48 matches!(self, Self::Done | Self::Failed | Self::Cancelled)
49 }
50
51 #[must_use]
52 pub const fn as_str(self) -> &'static str {
53 match self {
54 Self::Running => "running",
55 Self::Waiting => "waiting",
56 Self::Parked => "parked",
57 Self::Done => "done",
58 Self::Failed => "failed",
59 Self::Cancelled => "cancelled",
60 }
61 }
62
63 /// Single-width glyph. Filled = attention, hollow = at rest.
64 #[must_use]
65 pub const fn glyph(self) -> &'static str {
66 match self {
67 Self::Running => "●",
68 Self::Waiting => "◐",
69 // Hollow, dotted: at rest but not finished.
70 Self::Parked => "◌",
71 Self::Done => "○",
72 Self::Failed => "✗",
73 Self::Cancelled => "⊘",
74 }
75 }
76
77 /// Row state for one retained record.
78 ///
79 /// `parked_at_turn_end` is checked first and outranks the worker status:
80 /// a parked child settles as `WaitingForUser` or `Interrupted` like any
81 /// other, and only this flag separates it from a child that really asked
82 /// (#5906).
83 #[must_use]
84 pub const fn from_record(record: &AgentWorkerRecord) -> Self {
85 if record.parked_at_turn_end {
86 return Self::Parked;
87 }
88 Self::from_worker(record.status)
89 }
90
91 #[must_use]
92 pub const fn from_worker(status: AgentWorkerStatus) -> Self {
93 match status {
94 AgentWorkerStatus::Queued
95 | AgentWorkerStatus::Starting
96 | AgentWorkerStatus::Running
97 | AgentWorkerStatus::ModelWait
98 | AgentWorkerStatus::RunningTool => Self::Running,
99 AgentWorkerStatus::WaitingForUser => Self::Waiting,
100 AgentWorkerStatus::Completed => Self::Done,
101 AgentWorkerStatus::Failed => Self::Failed,
102 AgentWorkerStatus::Cancelled | AgentWorkerStatus::Interrupted => Self::Cancelled,
103 }
104 }
105 }
106
107 /// One agent's row. Every optional field means "no receipt", not "zero".
108 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109 pub struct AgentRosterRow {
110 pub worker_id: String,
111 /// Session name, else role, else the fleet type — whichever the user named.
112 pub display_name: String,
113 pub model: String,
114 pub state: RosterState,
115 pub status: AgentWorkerStatus,
116 /// The agent's outcome, current step or last tool, in one line. `None`
117 /// when the worker has not reported an event yet.
118 pub activity: Option<String>,
119 /// What a settled worker produced, in full (its result, or its error). The
120 /// row shows a one-line headline; the focused view shows this, so the
121 /// answer is never only available cut off (#6565). `None` while live.
122 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub outcome: Option<String>,
124 /// Wall time: elapsed for a live agent, final duration for a finished one.
125 pub millis: Option<u64>,
126 pub input_tokens: Option<u64>,
127 pub output_tokens: Option<u64>,
128 pub cost_microusd: Option<u64>,
129 pub steps_taken: u32,
130 /// Set when this agent was spawned by another; the parent aggregates it.
131 pub parent_run_id: Option<String>,
132 pub run_id: String,
133 }
134
135 impl AgentRosterRow {
136 /// `n/m done` for a workflow parent, from its children's terminal states.
137 #[must_use]
138 pub fn workflow_progress(&self, rows: &[Self]) -> Option<(usize, usize)> {
139 let children = rows
140 .iter()
141 .filter(|row| row.parent_run_id.as_deref() == Some(self.run_id.as_str()))
142 .collect::<Vec<_>>();
143 if children.is_empty() {
144 return None;
145 }
146 let done = children
147 .iter()
148 .filter(|row| row.state.is_terminal())
149 .count();
150 Some((done, children.len()))
151 }
152 }
153
154 /// Build the roster from retained worker records.
155 ///
156 /// `now_ms` is passed in rather than read from the clock so the projection is a
157 /// pure function — the caller supplies the same instant it renders with, and
158 /// tests get deterministic elapsed values.
159 #[must_use]
160 pub fn build_agent_roster(records: &[AgentWorkerRecord], now_ms: u64) -> Vec<AgentRosterRow> {
161 let mut rows: Vec<AgentRosterRow> = records
162 .iter()
163 .map(|record| row_from_record(record, now_ms))
164 .collect();
165 // Oldest first: the rail is a history of the session, and a list that
166 // reorders itself as agents finish is unreadable while you are watching it.
167 rows.sort_by(|a, b| a.worker_id.cmp(&b.worker_id));
168 rows.sort_by_key(|row| creation_key(records, &row.worker_id));
169 // ...except parked husks, which sink below everything still live or
170 // answerable (#5906). They are the one class of row the operator is not
171 // meant to scan past to find real work, and the sort is stable so the
172 // history order survives inside each group.
173 rows.sort_by_key(|row| row.state == RosterState::Parked);
174 rows
175 }
176
177 fn creation_key(records: &[AgentWorkerRecord], worker_id: &str) -> u64 {
178 records
179 .iter()
180 .find(|record| record.spec.worker_id == worker_id)
181 .map_or(u64::MAX, |record| record.created_at_ms)
182 }
183
184 #[must_use]
185 pub fn row_from_record(record: &AgentWorkerRecord, now_ms: u64) -> AgentRosterRow {
186 let state = RosterState::from_record(record);
187 AgentRosterRow {
188 worker_id: record.spec.worker_id.clone(),
189 display_name: display_name(record),
190 model: record.spec.model.clone(),
191 state,
192 status: record.status,
193 activity: activity_line(record),
194 outcome: settled_outcome(record).map(str::to_string),
195 millis: wall_millis(record, now_ms),
196 input_tokens: record.usage.input_tokens,
197 output_tokens: record.usage.output_tokens,
198 cost_microusd: record.usage.cost_microusd,
199 steps_taken: record.steps_taken,
200 parent_run_id: record.parent_run_id.clone(),
201 run_id: record.spec.run_id.clone(),
202 }
203 }
204
205 #[must_use]
206 pub fn display_name(record: &AgentWorkerRecord) -> String {
207 record
208 .spec
209 .session_name
210 .clone()
211 .or_else(|| {
212 record
213 .spec
214 .child_route
215 .as_ref()
216 .and_then(|route| route.resolved_profile_id.clone())
217 .filter(|profile| !profile.trim().is_empty())
218 })
219 .or_else(|| record.spec.role.clone())
220 .unwrap_or_else(|| record.spec.agent_type.as_str().to_string())
221 }
222
223 /// The agent's outcome, current step or last tool, in one line.
224 ///
225 /// A settled worker leads with what it produced (#6565): a finished one shows
226 /// the headline of its result, and a failed or cancelled one its error, not
227 /// the last tool it happened to call. A live worker's preference order is
228 /// most-specific-first: the newest event naming a tool, then the newest event
229 /// carrying a message, then the worker's latest message.
230 #[must_use]
231 pub fn activity_line(record: &AgentWorkerRecord) -> Option<String> {
232 if let Some(headline) = settled_outcome(record).and_then(result_headline) {
233 return Some(one_line(&headline));
234 }
235 let from_events = record.events.iter().rev().find_map(|event| {
236 event
237 .tool_name
238 .as_ref()
239 .map(|tool| match event.step {
240 Some(step) => format!("step {step} · {tool}"),
241 None => tool.clone(),
242 })
243 .or_else(|| event.message.clone())
244 });
245 from_events
246 .or_else(|| record.latest_message.clone())
247 .or_else(|| record.result_summary.clone())
248 .map(|line| one_line(&line))
249 }
250
251 /// What a settled worker produced, in full: its result when it finished, its
252 /// error (else its partial result) when it failed or was stopped. `None` while
253 /// it is live or parked.
254 #[must_use]
255 pub fn settled_outcome(record: &AgentWorkerRecord) -> Option<&str> {
256 let outcome = match RosterState::from_record(record) {
257 RosterState::Done => record.result_summary.as_deref(),
258 RosterState::Failed | RosterState::Cancelled => {
259 record.error.as_deref().or(record.result_summary.as_deref())
260 }
261 RosterState::Running | RosterState::Waiting | RosterState::Parked => None,
262 };
263 outcome.filter(|text| !text.trim().is_empty())
264 }
265
266 /// The line of an agent's result that says what it found (#6565).
267 ///
268 /// A report usually opens with scaffolding: a `## Summary` heading, a rule, a
269 /// code fence, or the machine-readable completion envelope. Showing its first
270 /// line showed the scaffolding and hid the answer. This skips blank lines, the
271 /// `<codewhale:…>` envelope, headings, rule-only lines and fence markers, and
272 /// returns the first sentence of the first line of prose. A result that is
273 /// only headings still yields its first heading, without the `#` marks.
274 /// `None` when nothing is left.
275 ///
276 /// The headline is not bounded here; a one-row surface bounds it with
277 /// [`one_line`], which marks the cut with `…`. The full result stays one Enter
278 /// away in the agent's focused view.
279 #[must_use]
280 pub fn result_headline(text: &str) -> Option<String> {
281 let is_rule = |line: &str| {
282 line.chars().filter(|c| !c.is_whitespace()).count() >= 3
283 && line
284 .chars()
285 .all(|c| matches!(c, '-' | '*' | '_' | '=') || c.is_whitespace())
286 };
287 let is_fence = |line: &str| line.starts_with("```") || line.starts_with("~~~");
288 let candidates = || {
289 text.lines()
290 .map(str::trim)
291 .filter(|line| !line.is_empty() && !line.starts_with("<codewhale:"))
292 .filter(|line| !is_rule(line) && !is_fence(line))
293 };
294 let line = match candidates().find(|line| !line.starts_with('#')) {
295 Some(line) => line,
296 None => candidates().next()?.trim_start_matches('#').trim(),
297 };
298 let sentence = first_sentence(line).trim();
299 (!sentence.is_empty()).then(|| sentence.to_string())
300 }
301
302 /// Up to and including the first sentence terminator followed by a space, so
303 /// `v0.10.1` or `foo.rs` inside a line does not end the sentence. CJK full
304 /// stops end it wherever they fall.
305 fn first_sentence(line: &str) -> &str {
306 let mut chars = line.char_indices().peekable();
307 while let Some((index, c)) = chars.next() {
308 let end = index + c.len_utf8();
309 if matches!(c, '。' | '!' | '?') {
310 return &line[..end];
311 }
312 if matches!(c, '.' | '!' | '?')
313 && chars.peek().is_some_and(|(_, next)| next.is_whitespace())
314 {
315 return &line[..end];
316 }
317 }
318 line
319 }
320
321 /// Collapse to a single line and bound it. Rail rows are one row.
322 #[must_use]
323 pub fn one_line(text: &str) -> String {
324 const MAX_CHARS: usize = 72;
325 let flattened = text.split_whitespace().collect::<Vec<_>>().join(" ");
326 if flattened.chars().count() <= MAX_CHARS {
327 return flattened;
328 }
329 let kept = flattened.chars().take(MAX_CHARS - 1).collect::<String>();
330 format!("{kept}…")
331 }
332
333 /// Elapsed for a live agent, final duration for a finished one.
334 ///
335 /// `None` when the worker has no start timestamp — a queued worker has not
336 /// started, and reporting `0s` would imply it had.
337 #[must_use]
338 pub fn wall_millis(record: &AgentWorkerRecord, now_ms: u64) -> Option<u64> {
339 let started = record.started_at_ms?;
340 let end = record.completed_at_ms.unwrap_or(now_ms);
341 Some(end.saturating_sub(started))
342 }
343
344 /// `3m 29s`, `12s`, `450ms`. Compact because it shares a row.
345 #[must_use]
346 pub fn format_duration(millis: u64) -> String {
347 if millis < 1_000 {
348 return format!("{millis}ms");
349 }
350 let seconds = millis / 1_000;
351 if seconds < 60 {
352 return format!("{seconds}s");
353 }
354 let minutes = seconds / 60;
355 let rest = seconds % 60;
356 if minutes < 60 {
357 return format!("{minutes}m {rest}s");
358 }
359 format!("{}h {}m", minutes / 60, minutes % 60)
360 }
361
362 /// `96.3k`, `1.2M`, `812`. Never rounds a real count to zero.
363 #[must_use]
364 pub fn format_tokens(tokens: u64) -> String {
365 if tokens < 1_000 {
366 return tokens.to_string();
367 }
368 if tokens < 1_000_000 {
369 return format!("{:.1}k", tokens as f64 / 1_000.0);
370 }
371 format!("{:.1}M", tokens as f64 / 1_000_000.0)
372 }
373
374 /// Usage totals across the roster, for a footer line.
375 ///
376 /// Returns `None` for a field when *no* row reported it — summing absent
377 /// receipts into `0` would restate the same lie the per-row rule forbids.
378 #[must_use]
379 pub fn roster_totals(rows: &[AgentRosterRow]) -> (Option<u64>, Option<u64>) {
380 fn total(values: impl Iterator<Item = Option<u64>>) -> Option<u64> {
381 let reported: Vec<u64> = values.flatten().collect();
382 (!reported.is_empty()).then(|| reported.into_iter().fold(0u64, u64::saturating_add))
383 }
384 (
385 total(rows.iter().map(|row| row.input_tokens)),
386 total(rows.iter().map(|row| row.output_tokens)),
387 )
388 }
389
390 /// True when at least one row reported a usage receipt. Callers use this to
391 /// label the totals line honestly ("partial receipts") instead of implying the
392 /// number covers every agent.
393 #[must_use]
394 pub fn all_rows_have_usage(rows: &[AgentRosterRow]) -> bool {
395 !rows.is_empty()
396 && rows
397 .iter()
398 .all(|row| row.input_tokens.is_some() || row.output_tokens.is_some())
399 }
400
400 lines RUST