perf(judge): earlier check outputs shrink to a reminder before the next round
Measured on prod: 7 of 9 verdicts ran to the 12-check cap. Every round resends the whole history, and each check's output is bounded at 12 KB — so by the last round the judge was paying for ~144 KB of outputs it had already read, on top of up to 120 KB of evidence, and it paid that on every round. That is the quadratic term in a verdict's cost, and the reason a single blocked phase could empty a weekly plan. Before this round's results go in, every earlier tool result compacts to an 800-byte head plus a marker saying the rest was shown when the check ran. The round that just ran stays whole; a result already carrying the marker is left alone. The budget of checks is unchanged — each one is cheaper to remember, not fewer to run. Also: docs/NEXT-SESSION.md rewritten for the state as of today. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
This commit is contained in:
co-authored by
Claude Opus 5
parent
483de9f88a
commit
93a386e706
@@ -767,6 +767,9 @@ async fn judge_with_tools(
|
||||
content: Value::String(evidence),
|
||||
});
|
||||
}
|
||||
// Everything the judge has already read shrinks to a reminder before
|
||||
// this round's results go in full. See `compact_earlier_results`.
|
||||
compact_earlier_results(&mut messages);
|
||||
messages.push(ChatMessage {
|
||||
role: ChatRole::User,
|
||||
parts: results,
|
||||
@@ -775,6 +778,44 @@ async fn judge_with_tools(
|
||||
Err("evaluator exceeded its verification budget without reaching a verdict".into())
|
||||
}
|
||||
|
||||
/// How much of an earlier check's output stays in the history.
|
||||
///
|
||||
/// Enough to recognise the command and its outcome — a test summary line, a
|
||||
/// grep hit, an error — not enough to re-read the whole thing, which the judge
|
||||
/// already did in the round it arrived.
|
||||
const KEPT_OF_EARLIER_RESULT: usize = 800;
|
||||
|
||||
/// Shrink every tool result from EARLIER rounds to a short head.
|
||||
///
|
||||
/// The judge's history is resent whole on every round, and each check's
|
||||
/// output is bounded at `evaluator_tools::MAX_OUTPUT_BYTES` (12 KB). Measured
|
||||
/// on prod, 7 of 9 verdicts ran to the 12-check cap, so by the last round the
|
||||
/// history carried ~144 KB of outputs the judge had already read, on top of
|
||||
/// up to 120 KB of evidence — and every round paid for all of it again. That
|
||||
/// is the quadratic term in a verdict's cost, and it is why one blocked phase
|
||||
/// could empty a weekly plan.
|
||||
///
|
||||
/// The round that just ran keeps its results in full; only what came before
|
||||
/// is compacted, and it is compacted once — a result already carrying the
|
||||
/// marker is left alone. The judge's budget of checks is unchanged: this
|
||||
/// makes each check cheaper to remember, not fewer to run.
|
||||
fn compact_earlier_results(messages: &mut [cm_llm::ChatMessage]) {
|
||||
use cm_llm::ContentPart;
|
||||
const MARKER: &str = "\n[… output elided here — it was shown in full when this check ran]";
|
||||
for m in messages.iter_mut() {
|
||||
for part in m.parts.iter_mut() {
|
||||
if let ContentPart::ToolResult { content, .. } = part {
|
||||
if let Some(text) = content.as_str() {
|
||||
if text.len() > KEPT_OF_EARLIER_RESULT && !text.ends_with(MARKER) {
|
||||
let kept = head(text, KEPT_OF_EARLIER_RESULT);
|
||||
*content = Value::String(format!("{kept}{MARKER}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the model's reply into a verdict, failing closed.
|
||||
fn parse_verdict(model: &str, text: &str) -> Verdict {
|
||||
let trimmed = text.trim();
|
||||
@@ -925,6 +966,41 @@ pub async fn latest(
|
||||
|
||||
#[cfg(test)]
|
||||
mod cross_provider_tests {
|
||||
/// The quadratic term: earlier outputs resent whole every round.
|
||||
#[test]
|
||||
fn earlier_tool_results_shrink_and_the_latest_stays_whole() {
|
||||
use cm_llm::{ChatMessage, ChatRole, ContentPart};
|
||||
let big = "line of output\n".repeat(900); // ~13 KB
|
||||
let mut messages = vec![
|
||||
ChatMessage {
|
||||
role: ChatRole::User,
|
||||
parts: vec![ContentPart::text("judge this")],
|
||||
},
|
||||
ChatMessage {
|
||||
role: ChatRole::User,
|
||||
parts: vec![
|
||||
ContentPart::ToolResult { tool_use_id: "a".into(), content: Value::String(big.clone()) },
|
||||
ContentPart::ToolResult { tool_use_id: "b".into(), content: Value::String(big.clone()) },
|
||||
],
|
||||
},
|
||||
];
|
||||
compact_earlier_results(&mut messages);
|
||||
for part in &messages[1].parts {
|
||||
let ContentPart::ToolResult { content, .. } = part else { panic!() };
|
||||
let s = content.as_str().unwrap();
|
||||
assert!(s.len() < KEPT_OF_EARLIER_RESULT + 120, "not compacted: {} bytes", s.len());
|
||||
assert!(s.starts_with("line of output"), "the head survives");
|
||||
assert!(s.contains("elided"), "and says so");
|
||||
}
|
||||
// Idempotent: a second pass must not shrink the reminder further.
|
||||
let once: Vec<String> = messages[1].parts.iter().map(|p| serde_json::to_string(p).unwrap()).collect();
|
||||
compact_earlier_results(&mut messages);
|
||||
let twice: Vec<String> = messages[1].parts.iter().map(|p| serde_json::to_string(p).unwrap()).collect();
|
||||
assert_eq!(once, twice);
|
||||
// Plain text parts are untouched.
|
||||
assert!(matches!(&messages[0].parts[0], ContentPart::Text { text } if text == "judge this"));
|
||||
}
|
||||
|
||||
/// `LlmEvent::Usage` arrives on every provider call. It was matched by
|
||||
/// `Ok(_) => {}` and dropped, which is how two plan exhaustions happened
|
||||
/// with no row anywhere saying a judge token was spent.
|
||||
|
||||
Reference in New Issue
Block a user