1use super::{AgentRunResult, RunError};
2use crate::EvalRunError;
3use aether_core::events::{AgentMessage, ContextUsage};
4use futures::{Stream, StreamExt};
5use std::fmt::Debug;
6use thiserror::Error;
7
8pub struct Transcript {
9 messages: Vec<AgentMessage>,
10}
11
12pub struct ToolCall<'a> {
13 pub name: &'a str,
14 pub arguments: &'a str,
15}
16
17#[derive(Error)]
18#[error("{error}")]
19pub struct TranscriptError {
20 transcript: Transcript,
21 #[source]
22 error: EvalRunError,
23}
24
25impl Transcript {
26 pub fn new(messages: Vec<AgentMessage>) -> Self {
27 Self { messages }
28 }
29
30 pub async fn from_stream<T: Stream<Item = AgentRunResult>>(stream: T) -> Result<Self, TranscriptError> {
31 let mut transcript = Self::default();
32 futures::pin_mut!(stream);
33 while let Some(result) = stream.next().await {
34 match result {
35 Ok(message) => {
36 transcript.add(message);
37 }
38 Err(error) => return Err(TranscriptError::new(transcript, error)),
39 }
40 }
41 Ok(transcript)
42 }
43
44 pub fn add(&mut self, message: AgentMessage) {
45 self.messages.push(message);
46 }
47
48 pub fn messages(&self) -> &[AgentMessage] {
49 &self.messages
50 }
51
52 pub fn all_tool_calls(&self) -> impl Iterator<Item = ToolCall<'_>> + '_ {
53 self.messages.iter().filter_map(|message| match message {
54 AgentMessage::ToolResult { result, .. } => {
55 Some(ToolCall { name: &result.name, arguments: &result.arguments })
56 }
57 AgentMessage::ToolError { error, .. } => {
58 Some(ToolCall { name: &error.name, arguments: error.arguments.as_deref().unwrap_or("") })
59 }
60 _ => None,
61 })
62 }
63
64 pub fn tool_calls<'a>(&'a self, name: &'a str) -> impl Iterator<Item = ToolCall<'a>> + 'a {
65 self.all_tool_calls().filter(move |call| call.name == name)
66 }
67
68 pub fn tool_called(&self, name: &str) -> bool {
69 self.tool_calls(name).next().is_some()
70 }
71
72 pub fn tool_call_count(&self, name: &str) -> usize {
73 self.tool_calls(name).count()
74 }
75
76 pub fn usage(&self) -> ContextUsage {
79 self.messages
80 .iter()
81 .rev()
82 .find_map(|msg| match msg {
83 AgentMessage::ContextUsageUpdate { usage } => Some(usage.clone()),
84 _ => None,
85 })
86 .unwrap_or_default()
87 }
88}
89
90impl Default for Transcript {
91 fn default() -> Self {
92 Self::new(Vec::new())
93 }
94}
95
96impl From<Vec<AgentMessage>> for Transcript {
97 fn from(messages: Vec<AgentMessage>) -> Self {
98 Self::new(messages)
99 }
100}
101
102impl ToolCall<'_> {
103 pub fn arguments_json(&self) -> Result<serde_json::Value, serde_json::Error> {
104 serde_json::from_str(self.arguments)
105 }
106}
107
108impl TranscriptError {
109 fn new(transcript: Transcript, error: RunError) -> Self {
110 Self { transcript, error: EvalRunError::from(error) }
111 }
112
113 pub fn transcript(&self) -> &Transcript {
114 &self.transcript
115 }
116
117 pub fn error(&self) -> &EvalRunError {
118 &self.error
119 }
120
121 pub fn into_parts(self) -> (Transcript, EvalRunError) {
122 (self.transcript, self.error)
123 }
124}
125
126impl Debug for TranscriptError {
127 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128 formatter.debug_struct("TranscriptError").field("error", &self.error).finish_non_exhaustive()
129 }
130}
131
132pub(crate) fn is_terminal(message: &AgentMessage) -> bool {
133 matches!(message, AgentMessage::Done | AgentMessage::Error { .. } | AgentMessage::Cancelled { .. })
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139 use crate::{Agent, FakeAgent, Task};
140 use llm::{ToolCallRequest, ToolCallResult};
141
142 #[tokio::test]
143 async fn transcript_from_stream() {
144 let agent = FakeAgent::with_tool_call("bash", "success");
145 let stream = agent.run(Task::new("do the thing"));
146 let transcript = Transcript::from_stream(stream).await.unwrap();
147
148 assert!(transcript.tool_called("bash"));
149 assert!(matches!(transcript.messages().last(), Some(AgentMessage::Done)));
150 }
151
152 #[test]
153 fn tool_call_count_counts_matching_tool_calls() {
154 let transcript = transcript_with_messages(vec![tool_call("bash"), tool_call("read"), tool_result("bash")]);
155
156 assert!(transcript.tool_called("bash"));
157 assert!(!transcript.tool_called("read"));
158 assert!(!transcript.tool_called("write"));
159 assert_eq!(transcript.tool_call_count("bash"), 1);
160 assert_eq!(transcript.tool_call_count("read"), 0);
161 }
162
163 #[test]
164 fn tool_call_arguments_json_parses_arguments() {
165 let call = ToolCall { name: "bash", arguments: r#"{"command":"pwd"}"# };
166
167 assert_eq!(call.arguments_json().unwrap(), serde_json::json!({ "command": "pwd" }));
168 }
169
170 #[test]
171 fn tool_call_arguments_json_returns_error_for_invalid_json() {
172 let call = ToolCall { name: "bash", arguments: "not json" };
173
174 assert!(call.arguments_json().is_err());
175 }
176
177 #[test]
178 fn usage_returns_zeroed_summary_when_no_context_usage() {
179 let transcript = transcript_with_messages(vec![tool_call("bash")]);
180 let usage = transcript.usage();
181 assert_eq!(usage, ContextUsage::default());
182 }
183
184 #[test]
185 fn usage_extracts_final_cumulative_totals() {
186 let transcript = transcript_with_messages(vec![
187 context_usage(100, 10, 1000, 100, 0, 0),
188 context_usage(200, 20, 3000, 600, 50, 0),
189 ]);
190
191 let usage = transcript.usage();
192 assert_eq!(usage.input_tokens, 200);
193 assert_eq!(usage.output_tokens, 20);
194 assert_eq!(usage.cache_read_tokens, Some(50));
195 assert_eq!(usage.total_input_tokens, 3000);
196 assert_eq!(usage.total_output_tokens, 600);
197 assert_eq!(usage.usage_ratio, Some(0.5));
198 assert_eq!(usage.context_limit, Some(200_000));
199 }
200
201 #[test]
202 fn total_tokens_sums_input_and_output() {
203 let transcript = transcript_with_messages(vec![context_usage(0, 0, 3000, 600, 0, 0)]);
204
205 assert_eq!(transcript.usage().total_tokens(), 3600);
206 }
207
208 fn context_usage(
209 input_tokens: u32,
210 output_tokens: u32,
211 total_input: u64,
212 total_output: u64,
213 cache_read: u32,
214 reasoning: u32,
215 ) -> AgentMessage {
216 AgentMessage::ContextUsageUpdate {
217 usage: ContextUsage {
218 usage_ratio: Some(0.5),
219 context_limit: Some(200_000),
220 input_tokens,
221 output_tokens,
222 cache_read_tokens: Some(cache_read),
223 reasoning_tokens: Some(reasoning),
224 total_input_tokens: total_input,
225 total_output_tokens: total_output,
226 ..Default::default()
227 },
228 }
229 }
230
231 fn transcript_with_messages(messages: Vec<AgentMessage>) -> Transcript {
232 Transcript::new(messages)
233 }
234
235 fn tool_call(name: &str) -> AgentMessage {
236 AgentMessage::ToolCall {
237 request: ToolCallRequest { id: name.to_string(), name: name.to_string(), arguments: "{}".to_string() },
238 model_name: "test".to_string(),
239 }
240 }
241
242 fn tool_result(name: &str) -> AgentMessage {
243 AgentMessage::ToolResult {
244 result: ToolCallResult {
245 id: name.to_string(),
246 name: name.to_string(),
247 arguments: "{}".to_string(),
248 result: "ok".to_string(),
249 },
250 result_meta: None,
251 model_name: "test".to_string(),
252 }
253 }
254}