1use super::{AgentRunResult, RunError};
2use crate::EvalRunError;
3use aether_core::events::{AgentEvent, ContextEvent, ContextUsage, ToolEvent};
4use futures::{Stream, StreamExt};
5use std::fmt::Debug;
6use thiserror::Error;
7
8pub struct Transcript {
9 events: Vec<AgentEvent>,
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(events: Vec<AgentEvent>) -> Self {
27 Self { events }
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(event) => {
36 transcript.add(event);
37 }
38 Err(error) => return Err(TranscriptError::new(transcript, error)),
39 }
40 }
41 Ok(transcript)
42 }
43
44 pub fn add(&mut self, event: AgentEvent) {
45 self.events.push(event);
46 }
47
48 pub fn events(&self) -> &[AgentEvent] {
49 &self.events
50 }
51
52 pub fn all_tool_calls(&self) -> impl Iterator<Item = ToolCall<'_>> + '_ {
53 self.events.iter().filter_map(|event| match event {
54 AgentEvent::Tool(ToolEvent::Result { result, .. }) => {
55 Some(ToolCall { name: &result.name, arguments: &result.arguments })
56 }
57 AgentEvent::Tool(ToolEvent::Error { 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.events
80 .iter()
81 .rev()
82 .find_map(|event| match event {
83 AgentEvent::Context(ContextEvent::UsageUpdated { 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<AgentEvent>> for Transcript {
97 fn from(events: Vec<AgentEvent>) -> Self {
98 Self::new(events)
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(event: &AgentEvent) -> bool {
133 event.turn_outcome().is_some()
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139 use crate::{Agent, FakeAgent, Task};
140 use aether_core::events::TurnEvent;
141 use llm::{ToolCallRequest, ToolCallResult};
142
143 #[tokio::test]
144 async fn transcript_from_stream() {
145 let agent = FakeAgent::with_tool_call("bash", "success");
146 let stream = agent.run(Task::new("do the thing"));
147 let transcript = Transcript::from_stream(stream).await.unwrap();
148
149 assert!(transcript.tool_called("bash"));
150 assert!(matches!(transcript.events().last(), Some(AgentEvent::Turn(TurnEvent::Ended { .. }))));
151 }
152
153 #[test]
154 fn tool_call_count_counts_matching_tool_calls() {
155 let transcript = transcript_with_events(vec![tool_call("bash"), tool_call("read"), tool_result("bash")]);
156
157 assert!(transcript.tool_called("bash"));
158 assert!(!transcript.tool_called("read"));
159 assert!(!transcript.tool_called("write"));
160 assert_eq!(transcript.tool_call_count("bash"), 1);
161 assert_eq!(transcript.tool_call_count("read"), 0);
162 }
163
164 #[test]
165 fn tool_call_arguments_json_parses_arguments() {
166 let call = ToolCall { name: "bash", arguments: r#"{"command":"pwd"}"# };
167
168 assert_eq!(call.arguments_json().unwrap(), serde_json::json!({ "command": "pwd" }));
169 }
170
171 #[test]
172 fn tool_call_arguments_json_returns_error_for_invalid_json() {
173 let call = ToolCall { name: "bash", arguments: "not json" };
174
175 assert!(call.arguments_json().is_err());
176 }
177
178 #[test]
179 fn usage_returns_zeroed_summary_when_no_context_usage() {
180 let transcript = transcript_with_events(vec![tool_call("bash")]);
181 let usage = transcript.usage();
182 assert_eq!(usage, ContextUsage::default());
183 }
184
185 #[test]
186 fn usage_extracts_final_cumulative_totals() {
187 let transcript = transcript_with_events(vec![
188 context_usage(100, 10, 1000, 100, 0, 0),
189 context_usage(200, 20, 3000, 600, 50, 0),
190 ]);
191
192 let usage = transcript.usage();
193 assert_eq!(usage.input_tokens, 200);
194 assert_eq!(usage.output_tokens, 20);
195 assert_eq!(usage.cache_read_tokens, Some(50));
196 assert_eq!(usage.total_input_tokens, 3000);
197 assert_eq!(usage.total_output_tokens, 600);
198 assert_eq!(usage.usage_ratio, Some(0.5));
199 assert_eq!(usage.context_limit, Some(200_000));
200 }
201
202 #[test]
203 fn total_tokens_sums_input_and_output() {
204 let transcript = transcript_with_events(vec![context_usage(0, 0, 3000, 600, 0, 0)]);
205
206 assert_eq!(transcript.usage().total_tokens(), 3600);
207 }
208
209 fn context_usage(
210 input_tokens: u32,
211 output_tokens: u32,
212 total_input: u64,
213 total_output: u64,
214 cache_read: u32,
215 reasoning: u32,
216 ) -> AgentEvent {
217 AgentEvent::Context(ContextEvent::UsageUpdated {
218 usage: ContextUsage {
219 usage_ratio: Some(0.5),
220 context_limit: Some(200_000),
221 input_tokens,
222 output_tokens,
223 cache_read_tokens: Some(cache_read),
224 reasoning_tokens: Some(reasoning),
225 total_input_tokens: total_input,
226 total_output_tokens: total_output,
227 ..Default::default()
228 },
229 })
230 }
231
232 fn transcript_with_events(events: Vec<AgentEvent>) -> Transcript {
233 Transcript::new(events)
234 }
235
236 fn tool_call(name: &str) -> AgentEvent {
237 AgentEvent::Tool(ToolEvent::Call {
238 request: ToolCallRequest { id: name.to_string(), name: name.to_string(), arguments: "{}".to_string() },
239 })
240 }
241
242 fn tool_result(name: &str) -> AgentEvent {
243 AgentEvent::Tool(ToolEvent::Result {
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 })
252 }
253}