Skip to content

Commit b62dd68

Browse files
authored
Merge pull request #64 from sanil-23/fix/judge-mechanical-failure-continuable
fix(adaptive): a mechanically broken run cannot be judged terminal
2 parents 5e9600d + e94946a commit b62dd68

3 files changed

Lines changed: 113 additions & 6 deletions

File tree

crates/adaptive/src/closing/judge.rs

Lines changed: 108 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,14 @@ pub struct Evidence<'a> {
7474
/// diff, a list of files, whatever the host counts as proof. Empty is
7575
/// honest; a fabricated summary is not.
7676
pub changed: String,
77+
/// The runner's own report that the run broke — a node that errored, a
78+
/// script that exited nonzero, a deadline. `None` when the run completed
79+
/// on its own terms, whatever it achieved.
80+
///
81+
/// Carried separately from the outcome because it decides something the
82+
/// model may not: whether another attempt is worth making. See
83+
/// [`judge`]'s downgrade.
84+
pub failed: Option<String>,
7785
}
7886

7987
impl Evidence<'_> {
@@ -183,15 +191,35 @@ pub async fn judge(
183191

184192
let answer = ask(caps, conn, Tier::Judge, SYSTEM, &user).await?;
185193
let satisfied = answer["satisfied"].as_bool().unwrap_or(false);
186-
Ok(Verdict {
187-
satisfied,
194+
let blocker = if satisfied {
188195
// A satisfied verdict has no blocker whatever the model wrote in the
189196
// field; the two disagreeing is a state nothing downstream can read.
190-
blocker: if satisfied {
191-
Blocker::None
197+
Blocker::None
198+
} else {
199+
let claimed = Blocker::parse(answer["blocker"].as_str().unwrap_or_default());
200+
// The one place the loop overrules the judge, and it does so on a
201+
// fact rather than an opinion: the RUNNER said the run broke. A
202+
// mechanical break is the most fixable thing an episode can hit —
203+
// rewrite the script, correct the flag — so calling it terminal
204+
// spends the remaining attempts on nothing. The prompt says this
205+
// too; saying it is not enough, because a model that misreads it
206+
// ends the episode and no later round can undo that.
207+
//
208+
// Needs-input and external-wait survive: both are terminal because
209+
// something OUTSIDE the loop must move, which a broken run does not
210+
// change.
211+
if evidence.failed.is_some()
212+
&& !claimed.continuable()
213+
&& !matches!(claimed, Blocker::NeedsInput | Blocker::ExternalWait)
214+
{
215+
Blocker::GoalNotMet
192216
} else {
193-
Blocker::parse(answer["blocker"].as_str().unwrap_or_default())
194-
},
217+
claimed
218+
}
219+
};
220+
Ok(Verdict {
221+
satisfied,
222+
blocker,
195223
gap: answer["gap"].as_str().unwrap_or_default().to_string(),
196224
attributed_to: answer["attributed_to"]
197225
.as_str()
@@ -263,6 +291,79 @@ fn without_a_model(evidence: &Evidence<'_>) -> Option<Verdict> {
263291
#[cfg(test)]
264292
mod tests {
265293
use super::*;
294+
295+
/// A provider that answers the judge with a fixed blocker.
296+
struct Says(&'static str);
297+
298+
#[async_trait::async_trait]
299+
impl tinyflows::caps::LlmProvider for Says {
300+
async fn complete(
301+
&self,
302+
_request: serde_json::Value,
303+
_conn: Option<&str>,
304+
) -> tinyflows::error::Result<serde_json::Value> {
305+
Ok(serde_json::json!({
306+
"satisfied": false,
307+
"blocker": self.0,
308+
"gap": "nothing was fetched",
309+
}))
310+
}
311+
}
312+
313+
async fn verdict_for(blocker: &'static str, failed: Option<String>) -> Verdict {
314+
let outcome = tinyflows::engine::RunOutcome {
315+
// Non-empty: the mechanical pre-judge must not settle this one,
316+
// because the point is what the MODEL's answer becomes.
317+
output: serde_json::json!({ "nodes": { "fetch": { "json": 1 } } }),
318+
pending_approvals: Vec::new(),
319+
cancelled: false,
320+
};
321+
let diagnosis = Diagnosis::default();
322+
let evidence = Evidence {
323+
outcome: &outcome,
324+
diagnosis: &diagnosis,
325+
changed: String::new(),
326+
failed,
327+
};
328+
let caps = tinyflows::caps::Capabilities {
329+
llm: std::sync::Arc::new(Says(blocker)),
330+
..tinyflows::caps::mock::mock_capabilities()
331+
};
332+
judge(&Goal::new("do the thing"), &evidence, &caps, None)
333+
.await
334+
.expect("judged")
335+
}
336+
337+
#[tokio::test]
338+
async fn a_mechanically_broken_run_cannot_be_called_terminal() {
339+
// Field observation: a shell step exited nonzero, the judge answered
340+
// `missing_evidence`, and the episode ended with two of its three
341+
// attempts unused — when rewriting the script was the whole fix.
342+
// The prompt says mechanical failures are goal_not_met; a model that
343+
// misreads it must not get to end the episode anyway.
344+
let verdict = verdict_for("missing_evidence", Some("script exited 5".into())).await;
345+
assert_eq!(verdict.blocker, Blocker::GoalNotMet);
346+
assert!(verdict.blocker.continuable());
347+
}
348+
349+
#[tokio::test]
350+
async fn a_run_that_completed_keeps_the_judges_terminal_verdict() {
351+
// No mechanical failure: the judge is the authority on whether
352+
// another attempt could help, and this downgrade must not become a
353+
// blanket refusal to ever stand down.
354+
let verdict = verdict_for("missing_evidence", None).await;
355+
assert_eq!(verdict.blocker, Blocker::MissingEvidence);
356+
}
357+
358+
#[tokio::test]
359+
async fn a_broken_run_still_waiting_on_a_person_stays_terminal() {
360+
// NeedsInput and ExternalWait survive the downgrade: both mean
361+
// something OUTSIDE the loop must move, which a broken run does not
362+
// change.
363+
let verdict = verdict_for("needs_input", Some("script exited 5".into())).await;
364+
assert_eq!(verdict.blocker, Blocker::NeedsInput);
365+
}
366+
266367
use serde_json::json;
267368
use tinyflows::diagnostics::{HiddenError, NeverRan, NullBinding};
268369

@@ -279,6 +380,7 @@ mod tests {
279380
outcome: o,
280381
diagnosis: d,
281382
changed: String::new(),
383+
failed: None,
282384
}
283385
}
284386

crates/adaptive/src/closing/repair.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,7 @@ mod tests {
290290
outcome: &out,
291291
diagnosis: &d,
292292
changed: "wrote report.md".into(),
293+
failed: None,
293294
};
294295
assert!(!graph_is_suspect(&verdict(""), &evidence));
295296
}
@@ -302,6 +303,7 @@ mod tests {
302303
outcome: &out,
303304
diagnosis: &d,
304305
changed: String::new(),
306+
failed: None,
305307
};
306308
assert!(graph_is_suspect(&verdict("summarise"), &evidence));
307309
}
@@ -320,6 +322,7 @@ mod tests {
320322
outcome: &out,
321323
diagnosis: &d,
322324
changed: String::new(),
325+
failed: None,
323326
};
324327
assert!(graph_is_suspect(&verdict(""), &evidence));
325328
}
@@ -345,6 +348,7 @@ mod tests {
345348
outcome: &out,
346349
diagnosis: &d,
347350
changed: String::new(),
351+
failed: None,
348352
};
349353
assert!(!graph_is_suspect(&verdict(""), &evidence));
350354
}

crates/adaptive/src/execute/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ impl Ran {
139139
outcome: &self.outcome,
140140
diagnosis: &self.diagnosis,
141141
changed: self.changed.clone(),
142+
failed: self.failed.clone(),
142143
}
143144
}
144145
}

0 commit comments

Comments
 (0)