Skip to main content

aether_evals/agents/
transcript.rs

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    /// Returns the aggregated usage from the final `ContextUsageUpdate`, or a
77    /// zeroed summary if no usage was recorded.
78    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}