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
57/// The cheapest check there is: the turn produced *something*.
58///
59/// This is the shape that prompted the whole idea — no tool calls, no text,
60/// just reasoning the model narrated to itself and then stopped. Left
61/// unchecked, that monologue gets handed back as the answer.
62pub struct NonEmptyAnswer;
63
64#[async_trait]
65impl Acceptance for NonEmptyAnswer {
66    fn name(&self) -> &str {
67        "non-empty-answer"
68    }
69
70    async fn check(&self, ctx: &Context, _world: &World) -> Verdict {
71        let said_something = ctx
72            .history
73            .last()
74            .filter(|t| t.role == harness_core::TurnRole::Assistant)
75            .is_some_and(|t| {
76                t.blocks.iter().any(|b| match b {
77                    harness_core::Block::Text(s) => !s.trim().is_empty(),
78                    _ => false,
79                })
80            });
81        if said_something {
82            Verdict::passed()
83        } else {
84            Verdict::failed(
85                "You ended that turn without answering and without calling a tool — you only \
86                 thought about it. Nothing you described has actually been done yet. Carry on \
87                 now: call the tools you need, and when the work is really finished, say so.",
88            )
89        }
90    }
91}
92
93/// Every named path must exist under the workspace root, and be non-empty.
94///
95/// "Convert this PDF to Word" has an objective answer that costs no tokens to
96/// check: is there a .docx, and does it have bytes in it?
97pub struct FilesExist {
98    paths: Vec<String>,
99}
100
101impl FilesExist {
102    pub fn new<I, S>(paths: I) -> Self
103    where
104        I: IntoIterator<Item = S>,
105        S: Into<String>,
106    {
107        Self {
108            paths: paths.into_iter().map(Into::into).collect(),
109        }
110    }
111}
112
113#[async_trait]
114impl Acceptance for FilesExist {
115    fn name(&self) -> &str {
116        "files-exist"
117    }
118
119    async fn check(&self, _ctx: &Context, world: &World) -> Verdict {
120        let root = &world.repo.root;
121        let missing: Vec<&str> = self
122            .paths
123            .iter()
124            .filter(|p| {
125                let full = root.join(p);
126                // Present but empty is the same failure as absent: a 0-byte
127                // file is what a half-finished write leaves behind.
128                !std::fs::metadata(&full)
129                    .map(|m| m.len() > 0)
130                    .unwrap_or(false)
131            })
132            .map(String::as_str)
133            .collect();
134        if missing.is_empty() {
135            Verdict::passed()
136        } else {
137            Verdict::failed(format!(
138                "These files were expected but are missing or empty: {}. \
139                 The work is not done — create them properly, then say so.",
140                missing.join(", ")
141            ))
142        }
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use harness_core::{Block, Turn, TurnRole};
150
151    fn ctx_with(blocks: Vec<Block>) -> Context {
152        let mut c = Context::new(harness_core::Task {
153            description: "do it".into(),
154            source: None,
155            deadline: None,
156        });
157        c.history.push(Turn {
158            role: TurnRole::Assistant,
159            blocks,
160        });
161        c
162    }
163
164    #[tokio::test]
165    async fn thinking_out_loud_is_not_an_answer() {
166        let world = harness_context::default_world(".");
167
168        let only_reasoning = ctx_with(vec![Block::Reasoning("I'll write the docx next".into())]);
169        let v = NonEmptyAnswer.check(&only_reasoning, &world).await;
170        assert!(!v.passed);
171        assert!(v.reason.contains("Carry on"), "the reason is actionable");
172
173        // Whitespace is not text either.
174        let blank = ctx_with(vec![Block::Text("   \n".into())]);
175        assert!(!NonEmptyAnswer.check(&blank, &world).await.passed);
176
177        let answered = ctx_with(vec![Block::Text("Done — resume.docx is written.".into())]);
178        assert!(NonEmptyAnswer.check(&answered, &world).await.passed);
179    }
180
181    #[tokio::test]
182    async fn a_promised_file_has_to_be_there_and_have_bytes_in_it() {
183        let dir = std::env::temp_dir().join(format!("acceptance-{}", std::process::id()));
184        std::fs::create_dir_all(&dir).unwrap();
185        let world = harness_context::default_world(&dir);
186        let ctx = ctx_with(vec![Block::Text("all done!".into())]);
187
188        let check = FilesExist::new(["out.docx"]);
189        let v = check.check(&ctx, &world).await;
190        assert!(!v.passed, "absent");
191        assert!(v.reason.contains("out.docx"), "names what's missing: {v:?}");
192
193        // Touched but empty is still not done.
194        std::fs::write(dir.join("out.docx"), b"").unwrap();
195        assert!(!check.check(&ctx, &world).await.passed, "empty");
196
197        std::fs::write(dir.join("out.docx"), b"PK\x03\x04").unwrap();
198        assert!(check.check(&ctx, &world).await.passed);
199
200        let _ = std::fs::remove_dir_all(&dir);
201    }
202}