harness_loop/
acceptance.rs1use async_trait::async_trait;
20use harness_core::{Context, World};
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct Verdict {
25 pub passed: bool,
26 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#[async_trait]
48pub trait Acceptance: Send + Sync + 'static {
49 fn name(&self) -> &str;
51
52 async fn check(&self, ctx: &Context, world: &World) -> Verdict;
55
56 fn seals(&self) -> Vec<std::path::PathBuf> {
69 Vec::new()
70 }
71}
72
73pub 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
109pub 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 !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 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 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}