Skip to main content

aether_evals/agents/
transcript.rs

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    /// 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.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}