Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 21 additions & 12 deletions crates/hi-agent/src/agent/trio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
//! 1. **Plan** — one bounded chat-only call to `planner_model` (same as
//! `/goal` decomposition) produces a lightweight plan string. If no
//! planner is configured, the prompt itself is the plan.
//! 2. **Execute** — the session model runs a normal `run_turn` with the plan
//! + prompt as input. Full tools are available (unlike trio's
//! 2. **Execute** — the session model runs a normal `run_turn` with the
//! plan + prompt as input. Full tools are available (unlike trio's
//! tool-restricted executor phase — we keep the existing tool surface).
//! 3. **Review** — one bounded chat-only call to `skeptic_model` (same
//! fail-open gate as `/goal team`) reviews the turn's diff. `Approve`
Expand Down Expand Up @@ -104,11 +104,7 @@ impl crate::Agent {
/// Run the trio reviewer side-call. Returns the verdict (Approve/Object/
/// Unavailable). Uses `skeptic_model` if configured, else the session
/// model. Fail-open: errors → Approve. Books usage; records no history.
pub async fn trio_review(
&mut self,
prompt: &str,
plan: &str,
) -> SkepticVerdict {
pub async fn trio_review(&mut self, prompt: &str, plan: &str) -> SkepticVerdict {
let model = self
.config
.skeptic_model
Expand Down Expand Up @@ -164,14 +160,30 @@ impl crate::Agent {
Err(err) => {
self.add_side_error_usage(&err);
// Fail-open: a reviewer error can't wedge the loop.
self.note_trio_review_unavailable();
return SkepticVerdict::Unavailable(format!("reviewer error: {err:#}"));
}
};
self.add_side_usage(completion.usage);
if text.trim().is_empty() {
text = content_text(&completion.content);
}
parse_trio_verdict(&text)
let verdict = parse_trio_verdict(&text);
if matches!(verdict, SkepticVerdict::Unavailable(_)) {
self.note_trio_review_unavailable();
}
verdict
}

/// Fail-open review stays fail-open, but a skipped review must be
/// visible: an approval nobody performed is how fabricated success
/// survives. Booked into the skeptic telemetry the report already carries.
fn note_trio_review_unavailable(&mut self) {
self.last_turn_telemetry.skeptic_unavailable_count = self
.last_turn_telemetry
.skeptic_unavailable_count
.saturating_add(1);
self.last_turn_telemetry.skeptic_last_status = Some(crate::SkepticStatus::Unavailable);
}
}

Expand Down Expand Up @@ -230,10 +242,7 @@ mod tests {
parse_trio_verdict("APPROVE\nlooks good"),
SkepticVerdict::Approve
);
assert_eq!(
parse_trio_verdict("**APPROVE**"),
SkepticVerdict::Approve
);
assert_eq!(parse_trio_verdict("**APPROVE**"), SkepticVerdict::Approve);
}

#[test]
Expand Down
31 changes: 28 additions & 3 deletions crates/hi-agent/src/agent/turn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ use crate::{
fn build_turn_telemetry(
effective_max_steps: u32,
verify_rounds: u32,
repeated_verify_failures: u32,
recovery_retries: u32,
repeat_nudges: u32,
continue_nudges: u32,
Expand All @@ -89,6 +90,7 @@ fn build_turn_telemetry(
TurnTelemetry {
effective_max_steps,
verify_rounds,
repeated_verify_failures,
recovery_retries,
repeat_nudges,
continue_nudges,
Expand Down Expand Up @@ -929,6 +931,10 @@ impl crate::Agent {
let mut context_generation_seen = self.runtime.context_generation();
let mut indexed_ledger_revision = self.runtime.ledger().revision();
self.last_task_contract = Some(task_contract.clone());
// Checkers (verify-failure nudges, independent review) get the task's
// requirements verbatim — the derived contract alone can't expose
// specification-relative failures.
let requirements_digest = task_contract.requirements_digest(&context_task);
self.refresh_system_message();
let implementation_candidate = if structurally_read_only_subagent {
None
Expand Down Expand Up @@ -1383,6 +1389,7 @@ impl crate::Agent {
self.last_turn_telemetry = build_turn_telemetry(
max_steps,
verifier.round(),
verifier.repeated_failure_count(),
empty_retries,
repeat_nudges,
continue_total_nudges,
Expand Down Expand Up @@ -1572,6 +1579,7 @@ impl crate::Agent {
self.last_turn_telemetry = build_turn_telemetry(
max_steps,
verifier.round(),
verifier.repeated_failure_count(),
empty_retries,
repeat_nudges,
continue_total_nudges,
Expand Down Expand Up @@ -1712,6 +1720,7 @@ impl crate::Agent {
self.last_turn_telemetry = build_turn_telemetry(
max_steps,
verifier.round(),
verifier.repeated_failure_count(),
empty_retries,
repeat_nudges,
continue_total_nudges,
Expand Down Expand Up @@ -3918,6 +3927,7 @@ If the task is already complete, stop and give your final recap."
// check, and reports for those error turns need the stages that
// actually ran.
self.last_turn_telemetry.verification_executions = verifier.executions().to_vec();
self.last_turn_telemetry.repeated_verify_failures = verifier.repeated_failure_count();
match outcome {
VerifyOutcome::NotRun => {
if self.last_verify == Some(false) {
Expand Down Expand Up @@ -3993,7 +4003,7 @@ If the task is already complete, stop and give your final recap."
let instructions = self.task_context.as_deref().unwrap_or("(none)");
let stages = verifier.stages_summary().unwrap_or_else(|| "(none)".into());
let context = format!(
"Task contract:\n{contract}\n\nScoped instructions and relevant repository context:\n{instructions}\n\nChanged files:\n{}\n\nDeterministic verification: PASSED\nStages: {stages}\nVerified workspace revision: {}\n\nComplete bounded turn diff:\n{diff}",
"Verbatim task requirements:\n{requirements_digest}\n\nTask contract:\n{contract}\n\nScoped instructions and relevant repository context:\n{instructions}\n\nChanged files:\n{}\n\nDeterministic verification: PASSED\nStages: {stages}\nVerified workspace revision: {}\n\nComplete bounded turn diff:\n{diff}",
current_files.join("\n"),
verified_digest,
);
Expand Down Expand Up @@ -4054,6 +4064,7 @@ If the task is already complete, stop and give your final recap."
stage,
output,
round,
repeated,
} => {
ui.status(&format!("✗ {} failed; iterating", stage.name));
self.last_verify = Some(false);
Expand Down Expand Up @@ -4098,9 +4109,22 @@ If the task is already complete, stop and give your final recap."
lines.join("\n")
)
};
// Paper-informed escalation (arXiv:2607.09510): repairing
// a misdiagnosed cause is the costliest failure behavior —
// when the failure signature didn't change, demand a
// validated diagnosis instead of another blind patch.
let followup = if repeated {
"This is the SAME failure as the previous verify round — your last \
change did not alter the outcome. Do not edit again yet. First state \
a diagnosis and validate it with a discriminating probe (run the \
failing command with narrowed scope, add a focused assertion or \
print, or read the exact failing code path), and only then apply a \
fix for the validated cause."
} else {
"If a previous fix didn't work, reconsider rather than repeat it."
};
let nudge_body = format!(
"{cause_section}Verification stage `{}` failed (`{}`).\n\nOutput:\n{}\n\n{} \
If a previous fix didn't work, reconsider rather than repeat it.",
"{cause_section}Task requirements:\n{requirements_digest}\n\nVerification stage `{}` failed (`{}`).\n\nOutput:\n{}\n\n{} {followup}",
stage.name, stage.command, output, guidance
);
// Replace the previous verify nudge instead of accumulating.
Expand Down Expand Up @@ -4193,6 +4217,7 @@ If the task is already complete, stop and give your final recap."
self.last_turn_telemetry = build_turn_telemetry(
max_steps,
verifier.round(),
verifier.repeated_failure_count(),
empty_retries,
repeat_nudges,
continue_total_nudges,
Expand Down
16 changes: 6 additions & 10 deletions crates/hi-agent/src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -205,12 +205,11 @@ fn parse_trio_args(rest: &str) -> (u8, String) {
let rest = rest.trim();
if let Some(after) = rest.strip_prefix("--rounds") {
let after = after.trim();
if let Some((n_str, prompt)) = after.split_once(char::is_whitespace) {
if let Ok(n) = n_str.trim().parse::<u8>() {
if n > 0 {
return (n, prompt.trim().to_string());
}
}
if let Some((n_str, prompt)) = after.split_once(char::is_whitespace)
&& let Ok(n) = n_str.trim().parse::<u8>()
&& n > 0
{
return (n, prompt.trim().to_string());
}
// `--rounds` with no valid number + prompt — fall through to treating
// the whole thing as a prompt (the flag is optional).
Expand Down Expand Up @@ -296,10 +295,7 @@ pub enum LoopArg {
/// model executes it, and the reviewer model reviews the diff before
/// approving or sending it back for revision. Stops when approved or
/// `max_rounds` is hit. No persistent goal state — it's a transient loop.
Trio {
prompt: String,
max_rounds: u8,
},
Trio { prompt: String, max_rounds: u8 },
/// Anything unparseable (bad interval / missing prompt / bad id).
Invalid(String),
}
Expand Down
6 changes: 5 additions & 1 deletion crates/hi-agent/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,12 @@ use {
},
};

pub use agent::skeptic::SkepticVerdict;
pub use decision::{Decision, DecisionLog};
pub use goal::{
DEFAULT_SUBGOAL_RETRIES, GOAL_CONTINUE_PROMPT, GOAL_DRIVE_STALL_LIMIT, Goal, GoalStatus,
SkepticStatus, SubGoal,
};
pub use agent::skeptic::SkepticVerdict;

/// Crate version (from Cargo.toml).
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
Expand Down Expand Up @@ -129,6 +129,9 @@ pub struct TurnTelemetry {
pub effective_max_steps: u32,
/// How many verify rounds ran this turn (0 = verify off or skipped).
pub verify_rounds: u32,
/// How many verify rounds re-failed with the same failure signature as
/// their predecessor — repair attempts that did not change the outcome.
pub repeated_verify_failures: u32,
/// Times a content-less / malformed response was silently re-sampled
/// (recovery sampling). 0 on a clean turn.
pub recovery_retries: u32,
Expand Down Expand Up @@ -219,6 +222,7 @@ impl Default for TurnTelemetry {
Self {
effective_max_steps: 0,
verify_rounds: 0,
repeated_verify_failures: 0,
recovery_retries: 0,
repeat_nudges: 0,
continue_nudges: 0,
Expand Down
12 changes: 11 additions & 1 deletion crates/hi-agent/src/steering/implementation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ fn tool_result_is_failure(output: &str) -> bool {
trimmed.starts_with("Error:")
|| trimmed.starts_with("⚠ refused:")
|| trimmed.contains("[exit code ")
|| trimmed.contains("[timed out after ")
|| trimmed.contains("[timed out")
}

fn filesystem_mutation_result_landed(name: &str, output: &str) -> bool {
Expand Down Expand Up @@ -485,6 +485,16 @@ mod tests {
args,
"[no output]"
));
assert!(implementation_tool_result_landed_mutation(
"bash",
args,
"[no output — command succeeded (exit 0)]"
));
assert!(!implementation_tool_result_landed_mutation(
"bash",
args,
"[timed out — process killed]"
));
}

#[test]
Expand Down
64 changes: 64 additions & 0 deletions crates/hi-agent/src/task_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,33 @@ impl TaskContract {
self.intent = TaskIntent::Mutation;
}

/// Compact requirement digest for checkers (verify-failure nudges, the
/// independent review): the derived acceptance sentences plus a verbatim
/// prompt excerpt. Supplying the task's requirements is what lifts a
/// checker's failure-detection recall — a derived contract alone misses
/// specification-relative failures. Bounded to ~1.5 KB.
pub fn requirements_digest(&self, prompt: &str) -> String {
const MAX_ACCEPTANCE_LINES: usize = 8;
const MAX_ACCEPTANCE_LINE_CHARS: usize = 200;
const MAX_PROMPT_CHARS: usize = 700;
let mut digest = String::new();
for sentence in self.acceptance_text.iter().take(MAX_ACCEPTANCE_LINES) {
digest.push_str("- ");
digest.push_str(truncate_chars(sentence, MAX_ACCEPTANCE_LINE_CHARS));
digest.push('\n');
}
if !digest.is_empty() {
digest.push('\n');
}
digest.push_str("Task (verbatim, may be truncated): ");
let excerpt = truncate_chars(prompt.trim(), MAX_PROMPT_CHARS);
digest.push_str(excerpt);
if excerpt.len() < prompt.trim().len() {
digest.push('…');
}
digest
}

pub fn requires_review(
&self,
policy: ReviewPolicy,
Expand Down Expand Up @@ -311,6 +338,14 @@ fn referenced_paths(prompt: &str) -> Vec<String> {
paths.into_iter().collect()
}

/// Clip to at most `max` characters on a char boundary.
fn truncate_chars(text: &str, max: usize) -> &str {
match text.char_indices().nth(max) {
Some((byte, _)) => &text[..byte],
None => text,
}
}

fn acceptance_text(prompt: &str) -> Vec<String> {
prompt
.split(['\n', '.'])
Expand Down Expand Up @@ -507,6 +542,35 @@ mod tests {
}
}

#[test]
fn requirements_digest_bounds_and_content() {
let prompt = "Fix the parser. It must handle empty input without panicking. \
The CLI should print a warning on malformed lines.";
let contract = TaskContract::derive(prompt, VerificationMode::Auto);
let digest = contract.requirements_digest(prompt);
assert!(
digest.contains("must handle empty input"),
"acceptance sentence present: {digest}"
);
assert!(
digest.contains("should print a warning"),
"second acceptance sentence present: {digest}"
);
assert!(
digest.contains("Task (verbatim, may be truncated): Fix the parser."),
"verbatim excerpt present: {digest}"
);

// A huge prompt with no acceptance keywords still yields a bounded,
// non-empty digest, truncated on a char boundary (multibyte-safe).
let long = "é".repeat(5_000);
let plain = TaskContract::derive(&long, VerificationMode::Auto);
let digest = plain.requirements_digest(&long);
assert!(!digest.is_empty());
assert!(digest.chars().count() < 800, "bounded: {}", digest.len());
assert!(digest.ends_with('…'), "signals truncation: {digest}");
}

#[test]
fn explicit_requests_expect_mutation() {
for prompt in [
Expand Down
Loading