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
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
//! 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 `String` behind a value, or `None` for every other shape.
///
/// Deliberately stricter than `display()`: a block whose `type` is not a
/// string must not be coerced into one and then matched as if it were a tag.
fn string_value(value: &VmValue) -> Option<&str> {
match value {
VmValue::String(text) => Some(text.as_str()),
_ => None,
}
}
/// The user-facing prose carried by one message's `content`.
///
/// `content` is either a plain string or a list of typed content blocks, and
/// the block list is the case that bit us. Rendering it with `VmValue::display`
/// produced `[{signature: …, type: thinking}, {text: …, type: text}]`: not
/// JSON, not prose, and carrying both the model's private reasoning and the
/// opaque provider signature into the field every consumer reads as the
/// assistant's answer (#6254). Only text blocks cross this seam; everything
/// else is model-private or structural and belongs in the transcript, which
/// keeps the whole message.
fn visible_text_in_content(content: &VmValue) -> String {
match content {
VmValue::List(blocks) => blocks
.iter()
.filter_map(text_in_block)
.collect::<Vec<_>>()
.join("\n\n"),
VmValue::Dict(_) => text_in_block(content).unwrap_or_default().to_string(),
_ => content.display(),
}
}
/// The sanitized user-facing prose carried by one assistant message.
///
/// Live transports and terminal result projection must agree on this seam:
/// reasoning blocks, tool calls, and control-only sentinels stay durable but
/// do not become visible assistant output.
pub(crate) fn visible_assistant_text(message: &VmValue) -> Option<String> {
let role = dict_get(message, "role")
.map(VmValue::display)
.unwrap_or_default();
if role != "assistant" {
return None;
}
let visible = dict_get(message, "content")
.map(|content| {
crate::visible_text::sanitize_visible_assistant_text(
&visible_text_in_content(content),
false,
)
})
.unwrap_or_default();
(!visible.trim().is_empty()).then_some(visible)
}
/// The prose in one content block, or `None` when the block is not prose.
fn text_in_block(block: &VmValue) -> Option<&str> {
let block_type = dict_get(block, "type").and_then(string_value);
if !crate::llm::content::is_user_facing_text_block_type(block_type) {
return None;
}
dict_get(block, "text")
.and_then(string_value)
.filter(|text| !text.trim().is_empty())
}
/// The key the loop sets on an assistant turn it wrote for its own
/// bookkeeping rather than one the model produced.
///
/// The agent loop retires a vetoed closing draft by replacing it with a fixed
/// placeholder, so the turn count stays stable and the adjudicator's pending
/// directive still lands on the next round. That placeholder has to remain in
/// the transcript and reach the provider, which is exactly why it cannot be
/// distinguished by its content: it is an ordinary text block, and every
/// alternative encoding would drop it from the provider request. Writers set
/// this key. Two readers: the answer projection below, and the turn boundary
/// (`turn_boundary_directives`), which guarantees a directive follows the
/// placeholder before the next request.
pub(crate) const BOOKKEEPING_TURN_KEY: &str = "harn_bookkeeping_turn";
/// True when the loop wrote this turn for its own bookkeeping.
fn is_bookkeeping_turn(message: &VmValue) -> bool {
matches!(
dict_get(message, BOOKKEEPING_TURN_KEY),
Some(VmValue::Bool(true))
)
}
/// The most recent assistant message the MODEL produced with visible
/// (non-reasoning) text.
///
/// Bookkeeping turns are skipped rather than returned. A run whose closing
/// draft was vetoed therefore answers with the last draft that was actually
/// accepted, and a run where nothing was accepted answers with nothing at all
/// rather than handing a reader the placeholder as if the model had written
/// it.
/// True when this run has no answer to report BECAUSE the loop ended on the
/// round that withdrew one.
///
/// Two conditions, and both are load-bearing. There must be no answer, and the
/// LAST assistant turn must be the bookkeeping one. Asking only whether the
/// run holds a withdrawn turn somewhere is too wide: a run that was vetoed,
/// carried on, and later ended on a turn that happened to carry no prose would
/// be reported as withdrawn when it was nothing of the kind. Requiring the
/// withdrawal to be the trailing turn is what says the loop stopped on it.
pub(crate) fn answer_was_withdrawn(snapshot: &VmValue) -> bool {
if last_assistant_text(snapshot).is_some() {
return false;
}
let Some(messages_value) = dict_get(snapshot, "messages") else {
return false;
};
list_items(messages_value)
.iter()
.rev()
.find(|message| {
dict_get(message, "role")
.map(VmValue::display)
.unwrap_or_default()
== "assistant"
})
.is_some_and(is_bookkeeping_turn)
}
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() {
if is_bookkeeping_turn(msg) {
continue;
}
if let Some(visible) = visible_assistant_text(msg) {
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);
}
}