1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
//! Projections of a finished agent session into its terminal result.
//!
//! `result.tools`, `result.trace`, and `result.visible_text` all describe one
//! run, and they disagreed when each was assembled from a different source.
//! The trace rollup in particular was fed by events nothing ever emitted, so
//! it reported no tool activity for runs whose transcript held the calls
//! (#5997). Deriving every projection here, from the session that owns the
//! facts, is what keeps them in step.
use super::trace::AgentLoopFacts;
use crate::value::VmValue;
use super::agent_session_host::{dict_get, list_items};
/// Distinct entries in first-appearance order.
///
/// `successful_tools` accumulates one entry per successful call, so a loop
/// that ran the same tool five times lists it five times. `tools_used` is the
/// set of tools the run reached, in the order it first reached them.
fn distinct_in_order(names: &[String]) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
names
.iter()
.filter(|name| seen.insert(name.as_str()))
.cloned()
.collect()
}
/// Build the loop and tool facts the terminal trace summary publishes, from
/// the same session state that produces `result.tools`.
///
/// `total_duration_ms` stays `None`: nothing measures loop wall time today
/// (`started_at` is an id, not a clock reading), and a zero would read as an
/// instantaneous run rather than an absent measurement.
pub(crate) fn terminal_loop_facts(
canonical_status: &str,
iterations: i64,
successful_tools: &[String],
rejected_tools: &[String],
) -> AgentLoopFacts {
AgentLoopFacts {
status: canonical_status.to_string(),
iterations: usize::try_from(iterations).unwrap_or(0),
total_duration_ms: None,
tool_executions: successful_tools.len(),
tool_rejections: rejected_tools.len(),
tools_used: distinct_in_order(successful_tools),
}
}
/// The most recent assistant message with visible (non-reasoning) text.
pub(crate) fn last_assistant_text(snapshot: &VmValue) -> Option<String> {
let messages_value = dict_get(snapshot, "messages")?;
let messages = list_items(messages_value);
for msg in messages.iter().rev() {
let role = dict_get(msg, "role")
.map(|v| v.display())
.unwrap_or_default();
if role == "assistant" {
let visible = dict_get(msg, "content")
.map(|v| crate::visible_text::sanitize_visible_assistant_text(&v.display(), false))
.unwrap_or_default();
if !visible.trim().is_empty() {
return Some(visible);
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tools_used_collapses_repeats_and_keeps_first_use_order() {
let successful = vec!["read".to_string(), "write".to_string(), "read".to_string()];
let facts = terminal_loop_facts("done", 3, &successful, &["explode".to_string()]);
assert_eq!(facts.tools_used, vec!["read", "write"]);
// Executions count calls, not distinct tools.
assert_eq!(facts.tool_executions, 3);
assert_eq!(facts.tool_rejections, 1);
assert_eq!(facts.iterations, 3);
assert_eq!(facts.status, "done");
}
/// A zero here would be indistinguishable from a run that finished
/// instantly; nothing measures loop wall time yet.
#[test]
fn loop_duration_is_absent_rather_than_zero() {
let facts = terminal_loop_facts("done", 1, &[], &[]);
assert_eq!(facts.total_duration_ms, None);
}
#[test]
fn a_negative_iteration_count_does_not_wrap() {
let facts = terminal_loop_facts("failed", -1, &[], &[]);
assert_eq!(facts.iterations, 0);
}
}