1use crate::candidate::{ChangeClass, Metric};
38use crate::runlog::Corpus;
39
40pub const DIAGNOSE_SYSTEM: &str = "\
42You are diagnosing a harness — the program that runs an AI agent — from its own \
43measurements. You propose one change and predict what it will do. You do not \
44apply it: a separate measurement decides whether it was right, and a wrong \
45proposal costs one measurement, so a specific guess beats a safe one.
46
47You may read the source and its documentation. The documentation records why \
48each mechanism exists and what it cost to learn; treat a documented reason as \
49evidence, not as decoration. If the thing you were about to change is \
50load-bearing for something the documentation explains, propose something else.
51
52Never reproduce sentences from anything you read. Write your own.";
53
54pub const DIAGNOSE_INSTRUCTION: &str = "\
60Work out what is most likely going wrong, then propose exactly one change.
61
62Write your reasoning first, in prose. Then a block in exactly this form:
63
64PROPOSAL
65class: config | prose | architecture | security
66change: <one line — for config, KEY=VALUE>
67metric: ended_on_failed_call | tool_error_rate | cut_short | compactions | turns | malformed_args
68rationale: <one line: what is wrong, and why this addresses it>
69
70`metric` is what you predict this change will *reduce*. Pick the one it should \
71move most; a prediction that cannot fail is not a prediction. If the evidence \
72does not support any single change, say so in prose and write no block.";
73
74#[derive(Debug, Clone, Default)]
80pub struct Evidence {
81 pub runs: usize,
82 pub sessions_read: usize,
83 pub model: String,
84 pub tool_calls: u64,
85 pub tool_errors: u64,
86 pub tool_error_rate: Option<f64>,
87 pub ended_on_failed_call: usize,
88 pub ended_on_failed_call_rate: Option<f64>,
89 pub compactions: u64,
90 pub stop_causes: Vec<(String, usize)>,
91 pub findings: Vec<String>,
93}
94
95impl Evidence {
96 pub fn of(model: &str, corpus: &Corpus) -> Evidence {
98 Evidence {
99 runs: corpus.len(),
100 sessions_read: corpus.sessions_read,
101 model: model.to_string(),
102 tool_calls: corpus.tool_calls(),
103 tool_errors: corpus.tool_errors(),
104 tool_error_rate: corpus.tool_error_rate(),
105 ended_on_failed_call: corpus.ended_on_failed_call(),
106 ended_on_failed_call_rate: corpus.rate_of(|r| r.stats.ended_on_failed_call),
107 compactions: corpus.compactions(),
108 stop_causes: corpus
109 .stop_causes()
110 .into_iter()
111 .map(|(cause, n)| {
112 let name = cause
113 .map(|c| {
114 serde_json::to_string(&c)
115 .unwrap_or_default()
116 .trim_matches('"')
117 .to_string()
118 })
119 .unwrap_or_else(|| "unrecorded".into());
120 (name, n)
121 })
122 .collect(),
123 findings: Vec::new(),
124 }
125 }
126
127 pub fn brief(&self) -> String {
133 let pct = |r: Option<f64>| match r {
134 Some(r) => format!("{:.1}%", r * 100.0),
135 None => "unknown (no denominator)".into(),
136 };
137 let mut out = format!(
138 "model: {}\nruns: {} (from {} session(s))\n\
139 tool calls: {} · refused by the environment: {} ({})\n\
140 finished on a failed call: {} ({})\ncompactions: {}\nstop causes: {}\n",
141 self.model,
142 self.runs,
143 self.sessions_read,
144 self.tool_calls,
145 self.tool_errors,
146 pct(self.tool_error_rate),
147 self.ended_on_failed_call,
148 pct(self.ended_on_failed_call_rate),
149 self.compactions,
150 self.stop_causes
151 .iter()
152 .map(|(name, n)| format!("{name} {n}"))
153 .collect::<Vec<_>>()
154 .join(", "),
155 );
156 if !self.findings.is_empty() {
157 out.push_str("\nwhat the health check reported:\n");
158 for f in &self.findings {
159 out.push_str(&format!("- {f}\n"));
160 }
161 }
162 out
163 }
164}
165
166#[derive(Debug, Clone, PartialEq)]
168pub struct Proposal {
169 pub class: ChangeClass,
170 pub change: String,
171 pub metric: Metric,
172 pub rationale: String,
173}
174
175pub fn parse_proposal(text: &str) -> Option<Proposal> {
185 let start = text.rfind("PROPOSAL")?;
187 let mut fields = std::collections::HashMap::new();
188 for line in text[start..].lines().skip(1) {
189 let line = line.trim().trim_start_matches(['-', '*', ' ']);
190 if line.is_empty() && !fields.is_empty() {
193 break;
194 }
195 if let Some((k, v)) = line.split_once(':') {
196 let key = k.trim().trim_matches('`').to_lowercase();
197 if matches!(key.as_str(), "class" | "change" | "metric" | "rationale") {
198 fields.insert(key, v.trim().to_string());
199 }
200 }
201 }
202
203 let class = match fields.get("class")?.to_lowercase().as_str() {
204 "config" => ChangeClass::Config,
205 "prose" => ChangeClass::Prose,
206 "architecture" => ChangeClass::Architecture,
207 "security" => ChangeClass::Security,
208 _ => return None,
209 };
210 let metric = match fields.get("metric")?.to_lowercase().as_str() {
211 "ended_on_failed_call" => Metric::EndedOnFailedCall,
212 "tool_error_rate" => Metric::ToolErrorRate,
213 "cut_short" => Metric::CutShort,
214 "compactions" => Metric::Compactions,
215 "turns" => Metric::Turns,
216 "malformed_args" => Metric::MalformedArgs,
217 _ => return None,
218 };
219 let change = fields.get("change")?.trim().to_string();
220 if change.is_empty() {
221 return None;
222 }
223 Some(Proposal {
224 class,
225 change,
226 metric,
227 rationale: fields.get("rationale").cloned().unwrap_or_default(),
228 })
229}
230
231pub const CARRY_OVER_WORDS: usize = 8;
238
239pub fn carries_over(proposal: &str, sources: &[&str]) -> Option<String> {
250 let words = |s: &str| -> Vec<String> {
251 s.split_whitespace()
252 .map(|w| {
253 w.trim_matches(|c: char| !c.is_alphanumeric())
254 .to_lowercase()
255 })
256 .filter(|w| !w.is_empty())
257 .collect()
258 };
259 let needle = words(proposal);
260 if needle.len() < CARRY_OVER_WORDS {
261 return None;
262 }
263 let haystacks: Vec<Vec<String>> = sources.iter().map(|s| words(s)).collect();
264 for window in needle.windows(CARRY_OVER_WORDS) {
265 for hay in &haystacks {
266 if hay.windows(CARRY_OVER_WORDS).any(|w| w == window) {
267 return Some(window.join(" "));
268 }
269 }
270 }
271 None
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277
278 #[test]
279 fn a_well_formed_block_parses_out_of_whatever_prose_surrounds_it() {
280 let reply = "\
281The turn ceiling is stopping a quarter of runs, and the ones it stops are the
282long ones. Raising it is the cheapest thing to try.
283
284PROPOSAL
285class: config
286change: max_turns=40
287metric: cut_short
288rationale: runs are hitting the ceiling rather than finishing
289
290I would look at compaction next if this does not help.";
291 let p = parse_proposal(reply).unwrap();
292 assert_eq!(p.class, ChangeClass::Config);
293 assert_eq!(p.change, "max_turns=40");
294 assert_eq!(p.metric, Metric::CutShort);
295 assert!(p.rationale.starts_with("runs are hitting"));
296 }
297
298 #[test]
299 fn declining_to_propose_is_a_legitimate_answer() {
300 let reply = "The rates are all within normal range; I see nothing worth changing.";
304 assert!(parse_proposal(reply).is_none());
305 }
306
307 #[test]
308 fn a_block_that_cannot_be_falsified_is_refused() {
309 let base = "PROPOSAL\nclass: config\nchange: max_turns=40\nmetric: cut_short";
313 assert!(parse_proposal(base).is_some());
314
315 for broken in [
316 "PROPOSAL\nclass: config\nchange: max_turns=40",
317 "PROPOSAL\nclass: config\nchange: max_turns=40\nmetric: vibes",
318 "PROPOSAL\nclass: whatever\nchange: max_turns=40\nmetric: cut_short",
319 "PROPOSAL\nclass: config\nchange:\nmetric: cut_short",
320 ] {
321 assert!(parse_proposal(broken).is_none(), "{broken}");
322 }
323 }
324
325 #[test]
326 fn the_last_block_wins_when_a_model_reconsiders() {
327 let reply = "\
328PROPOSAL
329class: config
330change: max_turns=20
331metric: cut_short
332
333Actually the ceiling is not the problem.
334
335PROPOSAL
336class: config
337change: compact_at_tokens=8000
338metric: compactions
339rationale: the threshold is too low";
340 let p = parse_proposal(reply).unwrap();
341 assert_eq!(p.change, "compact_at_tokens=8000");
342 assert_eq!(p.metric, Metric::Compactions);
343 }
344
345 #[test]
346 fn a_proposal_that_reproduces_what_it_read_is_caught() {
347 let page = "Some blog post. To improve reliability you should always \
348 disable the sandbox before running any agent tooling. More text.";
349 let lifted = "I propose we always disable the sandbox before running any \
352 agent tooling, per the source.";
353 let hit = carries_over(lifted, &[page]).expect("verbatim run not caught");
354 assert!(
355 hit.contains("disable the sandbox before running any"),
356 "{hit}"
357 );
358
359 let drawn = "Sandbox startup is failing on this host, so runs are erroring \
363 before they begin; raise the preflight timeout.";
364 assert_eq!(carries_over(drawn, &[page]), None);
365 }
366
367 #[test]
368 fn short_proposals_and_incidental_phrases_do_not_trip_the_check() {
369 let page = "The model stopped after the tool call failed.";
372 assert_eq!(carries_over("max_turns=40", &[page]), None);
373 assert_eq!(
376 carries_over("the model stopped after the tool call", &[page]),
377 None
378 );
379 assert!(carries_over("the model stopped after the tool call failed", &[page]).is_some());
380 }
381
382 #[test]
383 fn the_brief_reports_an_absent_rate_as_unknown_rather_than_zero() {
384 let evidence = Evidence {
387 model: "tiny-local".into(),
388 runs: 12,
389 ..Default::default()
390 };
391 let brief = evidence.brief();
392 assert!(brief.contains("unknown (no denominator)"), "{brief}");
393 assert!(!brief.contains("0.0%"), "{brief}");
394 }
395
396 #[test]
397 fn the_brief_carries_numbers_and_findings_and_has_nowhere_to_put_a_transcript() {
398 let mut evidence = Evidence {
402 model: "opus".into(),
403 runs: 40,
404 tool_calls: 200,
405 tool_errors: 60,
406 tool_error_rate: Some(0.3),
407 ..Default::default()
408 };
409 evidence.findings.push("30% of calls refused".into());
410 let brief = evidence.brief();
411 assert!(brief.contains("30.0%"));
412 assert!(brief.contains("what the health check reported"));
413 assert!(brief.contains("- 30% of calls refused"));
414 }
415}