Skip to main content

aether_evals/evals/
task.rs

1use 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}