Skip to main content

harness_loop/
acceptance.rs

1//! What "done" means, checked before the loop says so.
2//!
3//! An agent loop ends when the model stops asking for tools. That is the right
4//! rule and it answers the wrong question: it tells you the model believes it
5//! is finished, not that the work happened. A run that narrates a plan and
6//! stops looks exactly like a run that did the job.
7//!
8//! `harness-loop-engine` already draws this distinction — its maker/checker
9//! split is the same discipline — but only if you adopt that whole runtime.
10//! Most code uses a bare [`crate::AgentLoop`], and there the question wasn't
11//! asked at all. An [`Acceptance`] is that question, on the loop everyone
12//! actually uses: consulted before `Outcome::Done`, and when it says no, the
13//! reason goes back to the model and the loop carries on.
14//!
15//! Keep them cheap and deterministic where you can — a file either exists or
16//! it doesn't, and that costs no tokens. A second agent as judge is possible
17//! (the host supplies it) but it's the expensive end, not the default.
18
19use async_trait::async_trait;
20use harness_core::{Context, World};
21
22/// The answer to "is this actually done?".
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct Verdict {
25    pub passed: bool,
26    /// Why. When it did not pass this goes back to the model verbatim, so
27    /// write it as an instruction it can act on, not as a complaint.
28    pub reason: String,
29}
30
31impl Verdict {
32    pub fn passed() -> Self {
33        Self {
34            passed: true,
35            reason: String::new(),
36        }
37    }
38    pub fn failed(reason: impl Into<String>) -> Self {
39        Self {
40            passed: false,
41            reason: reason.into(),
42        }
43    }
44}
45
46/// A condition the run must satisfy before the loop reports success.
47#[async_trait]
48pub trait Acceptance: Send + Sync + 'static {
49    /// Short name, for logs.
50    fn name(&self) -> &str;
51
52    /// Consulted when the model has stopped asking for tools. `ctx` carries the
53    /// task and the whole transcript; `world` is the filesystem and friends.
54    async fn check(&self, ctx: &Context, world: &World) -> Verdict;
55
56    /// The files whose contents *define* this check.
57    ///
58    /// Declaring them seals them: the loop digests each one before the model's
59    /// first turn and again before accepting a pass, and a difference fails the
60    /// run regardless of the verdict. This exists because a gate the agent can
61    /// edit is not a gate — the standard failure is a model that cannot make a
62    /// test pass widening the test until it does.
63    ///
64    /// Relative paths resolve against `world.repo.root`. Empty by default: some
65    /// runs are *supposed* to rewrite their tests, and a check cannot know
66    /// which kind of run it is in. Seal what must not move. See [`crate::seal`]
67    /// for exactly what the seal does and does not enforce.
68    fn seals(&self) -> Vec<std::path::PathBuf> {
69        Vec::new()
70    }
71}
72
73/// The cheapest check there is: the turn produced *something*.
74///
75/// This is the shape that prompted the whole idea — no tool calls, no text,
76/// just reasoning the model narrated to itself and then stopped. Left
77/// unchecked, that monologue gets handed back as the answer.
78pub struct NonEmptyAnswer;
79
80#[async_trait]
81impl Acceptance for NonEmptyAnswer {
82    fn name(&self) -> &str {
83        "non-empty-answer"
84    }
85
86    async fn check(&self, ctx: &Context, _world: &World) -> Verdict {
87        let said_something = ctx
88            .history
89            .last()
90            .filter(|t| t.role == harness_core::TurnRole::Assistant)
91            .is_some_and(|t| {
92                t.blocks.iter().any(|b| match b {
93                    harness_core::Block::Text(s) => !s.trim().is_empty(),
94                    _ => false,
95                })
96            });
97        if said_something {
98            Verdict::passed()
99        } else {
100            Verdict::failed(
101                "You ended that turn without answering and without calling a tool — you only \
102                 thought about it. Nothing you described has actually been done yet. Carry on \
103                 now: call the tools you need, and when the work is really finished, say so.",
104            )
105        }
106    }
107}
108
109/// Every named path must exist under the workspace root, and be non-empty.
110///
111/// "Convert this PDF to Word" has an objective answer that costs no tokens to
112/// check: is there a .docx, and does it have bytes in it?
113pub struct FilesExist {
114    paths: Vec<String>,
115}
116
117impl FilesExist {
118    pub fn new<I, S>(paths: I) -> Self
119    where
120        I: IntoIterator<Item = S>,
121        S: Into<String>,
122    {
123        Self {
124            paths: paths.into_iter().map(Into::into).collect(),
125        }
126    }
127}
128
129#[async_trait]
130impl Acceptance for FilesExist {
131    fn name(&self) -> &str {
132        "files-exist"
133    }
134
135    async fn check(&self, _ctx: &Context, world: &World) -> Verdict {
136        let root = &world.repo.root;
137        let missing: Vec<&str> = self
138            .paths
139            .iter()
140            .filter(|p| {
141                let full = root.join(p);
142                // Present but empty is the same failure as absent: a 0-byte
143                // file is what a half-finished write leaves behind.
144                !std::fs::metadata(&full)
145                    .map(|m| m.len() > 0)
146                    .unwrap_or(false)
147            })
148            .map(String::as_str)
149            .collect();
150        if missing.is_empty() {
151            Verdict::passed()
152        } else {
153            Verdict::failed(format!(
154                "These files were expected but are missing or empty: {}. \
155                 The work is not done — create them properly, then say so.",
156                missing.join(", ")
157            ))
158        }
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use harness_core::{Block, Turn, TurnRole};
166
167    fn ctx_with(blocks: Vec<Block>) -> Context {
168        let mut c = Context::new(harness_core::Task {
169            description: "do it".into(),
170            source: None,
171            deadline: None,
172        });
173        c.history.push(Turn {
174            role: TurnRole::Assistant,
175            blocks,
176        });
177        c
178    }
179
180    #[tokio::test]
181    async fn thinking_out_loud_is_not_an_answer() {
182        let world = harness_context::default_world(".");
183
184        let only_reasoning = ctx_with(vec![Block::Reasoning("I'll write the docx next".into())]);
185        let v = NonEmptyAnswer.check(&only_reasoning, &world).await;
186        assert!(!v.passed);
187        assert!(v.reason.contains("Carry on"), "the reason is actionable");
188
189        // Whitespace is not text either.
190        let blank = ctx_with(vec![Block::Text("   \n".into())]);
191        assert!(!NonEmptyAnswer.check(&blank, &world).await.passed);
192
193        let answered = ctx_with(vec![Block::Text("Done — resume.docx is written.".into())]);
194        assert!(NonEmptyAnswer.check(&answered, &world).await.passed);
195    }
196
197    #[tokio::test]
198    async fn a_promised_file_has_to_be_there_and_have_bytes_in_it() {
199        let dir = std::env::temp_dir().join(format!("acceptance-{}", std::process::id()));
200        std::fs::create_dir_all(&dir).unwrap();
201        let world = harness_context::default_world(&dir);
202        let ctx = ctx_with(vec![Block::Text("all done!".into())]);
203
204        let check = FilesExist::new(["out.docx"]);
205        let v = check.check(&ctx, &world).await;
206        assert!(!v.passed, "absent");
207        assert!(v.reason.contains("out.docx"), "names what's missing: {v:?}");
208
209        // Touched but empty is still not done.
210        std::fs::write(dir.join("out.docx"), b"").unwrap();
211        assert!(!check.check(&ctx, &world).await.passed, "empty");
212
213        std::fs::write(dir.join("out.docx"), b"PK\x03\x04").unwrap();
214        assert!(check.check(&ctx, &world).await.passed);
215
216        let _ = std::fs::remove_dir_all(&dir);
217    }
218}