aether_evals/evals/
task.rs1use super::diff::GitDiff;
2use super::transcript::{Transcript, format_transcript};
3use super::workspace::Workspace;
4use crate::EvalRunError;
5use crate::agents::{Agent, AgentConfig, RunError, is_terminal};
6use std::fmt::{Debug, Write as _};
7use thiserror::Error;
8use tokio::sync::mpsc;
9
10pub struct Task {
11 prompt: String,
12 workspace: Workspace,
13}
14
15pub struct TaskRun {
16 prompt: String,
17 workspace: Workspace,
18 transcript: Transcript,
19}
20
21#[derive(Error)]
22#[error("{error}")]
23pub struct TaskRunError {
24 run: TaskRun,
25 #[source]
26 error: EvalRunError,
27}
28
29impl Task {
30 pub fn new(prompt: impl Into<String>, workspace: Workspace) -> Self {
31 Self { prompt: prompt.into(), workspace }
32 }
33
34 pub fn prompt(&self) -> &str {
35 &self.prompt
36 }
37
38 pub fn workspace(&self) -> &Workspace {
39 &self.workspace
40 }
41
42 #[tracing::instrument(skip(agent, self))]
43 pub async fn run<T: Agent>(self, agent: &T) -> Result<TaskRun, TaskRunError> {
44 let Task { prompt, workspace } = self;
45 let (tx, mut rx) = mpsc::channel(100);
46 let agent_task = agent.run(
47 AgentConfig {
48 workspace_root: workspace.root_path(),
49 relative_cwd: workspace.relative_cwd(),
50 task_prompt: &prompt,
51 },
52 tx,
53 );
54
55 let message_task = async {
56 let mut messages = Vec::new();
57 while let Some(message) = rx.recv().await {
58 let is_done = is_terminal(&message);
59 messages.push(message);
60 if is_done {
61 break;
62 }
63 }
64 messages
65 };
66
67 let (run_result, messages) = tokio::join!(agent_task, message_task);
68 let run = TaskRun::new(prompt, workspace, Transcript::new(messages));
69 match run_result {
70 Ok(()) => Ok(run),
71 Err(error) => Err(TaskRunError::new(run, error)),
72 }
73 }
74}
75
76impl TaskRun {
77 pub(crate) fn new(prompt: String, workspace: Workspace, transcript: Transcript) -> Self {
78 Self { prompt, workspace, transcript }
79 }
80
81 pub fn prompt(&self) -> &str {
82 &self.prompt
83 }
84
85 pub fn workspace(&self) -> &Workspace {
86 &self.workspace
87 }
88
89 pub fn transcript(&self) -> &Transcript {
90 &self.transcript
91 }
92
93 pub fn into_workspace(self) -> Workspace {
94 self.workspace
95 }
96
97 pub fn failure_context(&self) -> String {
98 let mut summary = String::new();
99 let _ = writeln!(summary, "Task failure context");
100 let _ = writeln!(summary, "Workspace: {}", self.workspace.path().display());
101 summary.push_str("Prompt:\n");
102 push_indented(&mut summary, &self.prompt, 2);
103 summary.push('\n');
104
105 let (agent_diff, reference_diff) = self.workspace.capture_git_diffs();
106 if let Some(diff) = &agent_diff {
107 summary.push_str("Agent diff summary:\n");
108 push_diff_stats(&mut summary, diff);
109 summary.push('\n');
110 }
111
112 if let Some(diff) = &reference_diff {
113 summary.push_str("Reference diff summary:\n");
114 push_diff_stats(&mut summary, diff);
115 summary.push('\n');
116 }
117
118 summary.push_str("Agent messages:\n");
119 if self.transcript.messages().is_empty() {
120 summary.push_str(" none\n");
121 } else {
122 push_indented(&mut summary, &format_transcript(self.transcript.messages()), 2);
123 }
124
125 summary
126 }
127}
128
129impl TaskRunError {
130 fn new(run: TaskRun, error: RunError) -> Self {
131 Self { run, error: EvalRunError::from(error) }
132 }
133
134 pub fn run(&self) -> &TaskRun {
135 &self.run
136 }
137
138 pub fn error(&self) -> &EvalRunError {
139 &self.error
140 }
141
142 pub fn into_parts(self) -> (TaskRun, EvalRunError) {
143 (self.run, self.error)
144 }
145}
146
147impl Debug for TaskRunError {
148 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149 formatter.debug_struct("TaskRunError").field("error", &self.error).finish_non_exhaustive()
150 }
151}
152
153impl From<TaskRunError> for EvalRunError {
154 fn from(error: TaskRunError) -> Self {
155 error.error
156 }
157}
158
159fn push_indented(output: &mut String, value: &str, spaces: usize) {
160 let indentation = " ".repeat(spaces);
161 for line in value.lines() {
162 output.push_str(&indentation);
163 output.push_str(line);
164 output.push('\n');
165 }
166}
167
168fn push_diff_stats(output: &mut String, diff: &GitDiff) {
169 let _ = writeln!(output, " Files changed: {}", diff.stats.files_changed);
170 let _ = writeln!(output, " Lines added: {}", diff.stats.lines_added);
171 let _ = writeln!(output, " Lines removed: {}", diff.stats.lines_removed);
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177 use crate::{FakeAgent, RunError};
178 use aether_core::events::AgentMessage;
179 use std::sync::{Arc, Mutex};
180 use tokio::sync::mpsc::Sender;
181
182 #[tokio::test]
183 async fn task_run_contains_prompt_workspace_and_transcript() {
184 let run = Task::new("do the thing", Workspace::empty().unwrap())
185 .run(&FakeAgent::with_tool_call("bash", "success"))
186 .await
187 .unwrap();
188
189 assert_eq!(run.prompt(), "do the thing");
190 assert!(run.workspace().path().exists());
191 assert!(run.transcript().tool_called("bash"));
192 assert!(matches!(run.transcript().messages().last(), Some(AgentMessage::Done)));
193 }
194
195 #[tokio::test]
196 async fn task_run_passes_raw_prompt_without_scaffolding() {
197 let agent = CapturingAgent::default();
198 Task::new("do the thing", Workspace::empty().unwrap()).run(&agent).await.unwrap();
199
200 assert_eq!(agent.captured_task_prompt(), Some("do the thing".to_string()));
201 }
202
203 #[derive(Default)]
204 struct CapturingAgent {
205 task_prompt: Arc<Mutex<Option<String>>>,
206 }
207
208 impl CapturingAgent {
209 fn captured_task_prompt(&self) -> Option<String> {
210 self.task_prompt.lock().unwrap().clone()
211 }
212 }
213
214 impl Agent for CapturingAgent {
215 async fn run(&self, config: AgentConfig<'_>, tx: Sender<AgentMessage>) -> Result<(), RunError> {
216 *self.task_prompt.lock().unwrap() = Some(config.task_prompt.to_string());
217 tx.send(AgentMessage::Done).await.map_err(|e| RunError::ChannelSendFailed(e.to_string()))
218 }
219 }
220}