Skip to content
Merged
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
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,10 +172,11 @@ hi "..." # auto-detects cargo check+test, go build+test,

Automatic verification builds a **multi-stage pipeline** per project: `cargo check` then `cargo test`, `go build` then `go test`, `tsc` then `npm test` (when a tsconfig is present), `ruff check` then `pytest` (when ruff is configured), or `make test`. Repeat `--verify CMD` to replace detection with exact ordered stages. `--no-verify` produces an explicitly unverified outcome; a mutating one-shot still exits nonzero unless `--allow-unverified` is also given.

A `--max-steps` cap stops runaway tool loops. When it is not set explicitly,
the task contract selects 80 model calls for clearly read-only work, 120 for
recognized implementation work, and 200 for general or ambiguous turns. Each turn prints
`[N in · N out · N total · k/k ctx]`.
There is no per-turn step limit by default: runaway tool loops are ended by
the repeat/no-progress stall budgets, so long productive turns are never cut
off mid-flight. `--max-steps N` (or `/config steps <n>`) sets an optional hard
cap; a capped turn gets one final tool-free round to report where it left the
work before stopping. Each turn prints `[N in · N out · N total · k/k ctx]`.

`--max-tool-calls N` is a separate hard execution budget. Parallel batches
reserve the remaining budget before dispatch and return typed denials for the
Expand Down
1 change: 0 additions & 1 deletion crates/hi-agent/src/agent/explore_turn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,6 @@ impl crate::Agent {
},
loop_limits: crate::AgentLoopLimits {
max_steps: EXPLORE_MAX_STEPS,
max_steps_explicit: true,
max_parallel_tools: EXPLORE_MAX_PARALLEL_TOOLS,
// A read-only explorer's text output IS its answer — don't nudge it to
// keep going after it stops with text.
Expand Down
14 changes: 6 additions & 8 deletions crates/hi-agent/src/agent/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1699,12 +1699,10 @@ impl crate::Agent {
self.config.routing.temperature = temperature;
}

/// Human-readable live step-limit setting. `auto` uses the intent-aware
/// defaults; `off` uses no practical per-turn cap.
/// Human-readable live step-limit setting. `off` (the default) means no
/// per-turn cap.
pub fn max_steps_setting(&self) -> String {
if !self.config.loop_limits.max_steps_explicit {
"auto".to_string()
} else if self.config.loop_limits.max_steps == u32::MAX {
if self.config.loop_limits.max_steps == u32::MAX {
"off".to_string()
} else {
self.config.loop_limits.max_steps.to_string()
Expand All @@ -1718,12 +1716,12 @@ impl crate::Agent {
/// Set a fixed per-turn step cap, or disable the cap with `None`.
pub fn set_max_steps_limit(&mut self, limit: Option<u32>) {
self.config.loop_limits.max_steps = limit.unwrap_or(u32::MAX).max(1);
self.config.loop_limits.max_steps_explicit = true;
}

/// Restore intent-aware automatic step limits for subsequent turns.
/// Restore the default: no per-turn step cap (`auto` is an alias for `off`
/// now that the intent-aware implicit limits are gone).
pub fn set_max_steps_auto(&mut self) {
self.config.loop_limits.max_steps_explicit = false;
self.config.loop_limits.max_steps = u32::MAX;
}

pub fn rsi_status(&self) -> (&'static str, &'static str, Option<bool>) {
Expand Down
80 changes: 21 additions & 59 deletions crates/hi-agent/src/agent/turn/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,9 @@
use crate::heuristics::humanize_count;
use hi_ai::{RateLimitBucket, RateLimitState};

use crate::steering::{EvidenceTracker, ReviewIntent};
use crate::steering::EvidenceTracker;
use crate::{
EffectiveModelRoute, ReviewStatus, TaskContract, TaskIntent, ToolCallEntry, TurnAttribution,
TurnTelemetry,
EffectiveModelRoute, ReviewStatus, TaskContract, ToolCallEntry, TurnAttribution, TurnTelemetry,
};

use super::progress::{ProgressTracker, ToolProgressLabel};
Expand Down Expand Up @@ -75,25 +74,15 @@ pub(super) fn build_turn_telemetry(
}
}

pub(super) fn effective_max_steps_for_turn(
config: &crate::AgentConfig,
contract_intent: TaskIntent,
read_only_intent: Option<ReviewIntent>,
implementation_intent: Option<crate::steering::ImplementationIntent>,
) -> u32 {
if config.loop_limits.max_steps_explicit {
return config.loop_limits.max_steps.max(1);
}
// Intent-aware per-turn cap, regardless of `long_horizon`. A long-horizon goal
// spans many turns (each advancing one sub-goal), so each turn gets the normal
// per-intent budget — not a flat 200 that would also apply when no goal is set.
if contract_intent == TaskIntent::ReadOnly || read_only_intent.is_some() {
80
} else if implementation_intent.is_some() {
120
} else {
200
}
/// The per-turn model-call cap. There is no implicit cap: unless a cap was
/// deliberately configured (`--max-steps`, `/config steps <n>`, or an internal
/// subagent budget), turns run until the model stops or the stall machinery
/// ends them. Runaway-loop protection is the repeat/no-progress/silent-continue
/// budgets, not a step ceiling — a ceiling mostly punished long *productive*
/// turns, and its size depended on intent classification, so a misclassified
/// turn died early.
pub(super) fn effective_max_steps_for_turn(config: &crate::AgentConfig) -> u32 {
config.loop_limits.max_steps.max(1)
}

pub(super) fn task_needs_repository_context(task: &str, contract: &TaskContract) -> bool {
Expand Down Expand Up @@ -358,7 +347,6 @@ pub(super) fn format_rate_limit_reset(seconds: u64) -> String {
#[cfg(test)]
mod step_cap_tests {
use super::*;
use crate::steering::ImplementationIntent;
use hi_tools::{FileChange, FileChangeKind};

fn cfg(long_horizon: bool) -> crate::AgentConfig {
Expand All @@ -367,10 +355,6 @@ mod step_cap_tests {
long_horizon,
..crate::AgentSubagents::default()
},
loop_limits: crate::AgentLoopLimits {
max_steps_explicit: false,
..crate::AgentLoopLimits::default()
},
..Default::default()
}
}
Expand Down Expand Up @@ -403,47 +387,25 @@ mod step_cap_tests {
}

#[test]
fn max_steps_is_intent_aware_even_with_long_horizon() {
// Decoupled from `long_horizon`: each turn gets its per-intent cap whether
// or not a long-horizon goal is active (the goal spans many turns).
fn no_implicit_step_cap_by_default() {
// Removing the intent-aware defaults means a turn with no configured
// cap is unbounded regardless of intent classification or horizon.
for lh in [false, true] {
assert_eq!(
effective_max_steps_for_turn(
&cfg(lh),
TaskIntent::Mutation,
None,
Some(ImplementationIntent::default())
),
120,
"implementation intent (long_horizon={lh})"
);
assert_eq!(
effective_max_steps_for_turn(
&cfg(lh),
TaskIntent::ReadOnly,
Some(ReviewIntent::Security),
None
),
80,
"read-only intent (long_horizon={lh})"
);
assert_eq!(
effective_max_steps_for_turn(&cfg(lh), TaskIntent::Mutation, None, None),
200,
"no intent (long_horizon={lh})"
effective_max_steps_for_turn(&cfg(lh)),
u32::MAX,
"default is uncapped (long_horizon={lh})"
);
}
}

#[test]
fn explicit_max_steps_always_wins() {
fn configured_max_steps_is_honored_and_zero_is_clamped() {
let mut c = cfg(true);
c.loop_limits.max_steps_explicit = true;
c.loop_limits.max_steps = 42;
assert_eq!(
effective_max_steps_for_turn(&c, TaskIntent::ReadOnly, None, None),
42
);
assert_eq!(effective_max_steps_for_turn(&c), 42);
c.loop_limits.max_steps = 0;
assert_eq!(effective_max_steps_for_turn(&c), 1);
}

#[test]
Expand Down
7 changes: 1 addition & 6 deletions crates/hi-agent/src/agent/turn/loop_.rs
Original file line number Diff line number Diff line change
Expand Up @@ -477,12 +477,7 @@ impl crate::Agent {
};
// Mid-turn LSP + affected cargo check state (dedupes packages across batches).
let fast_feedback = super::fast_feedback::FastFeedbackState::default();
let max_steps = effective_max_steps_for_turn(
&self.config,
task_contract.intent,
read_only_intent,
implementation_intent,
);
let max_steps = effective_max_steps_for_turn(&self.config);
let max_parallel_tools = self.config.loop_limits.max_parallel_tools.max(1);
let steps = 0u32;
let empty_retries = 0u32;
Expand Down
54 changes: 48 additions & 6 deletions crates/hi-agent/src/agent/turn/model_round.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ use crate::{MAX_TOOL_PROTOCOL_RETRIES, TRUNCATED_TOOL_CALL_NUDGE, TRUNCATION_NUD
use super::helpers::{build_turn_telemetry, effective_model_route};
use super::phase::TurnPhase;
use super::progress::{
NO_PROGRESS_FINAL_ANSWER_NUDGE, ProgressKind, ProgressTracker, forced_final_answer_is_unusable,
no_progress_signature_for_calls,
AWAITING_BACKGROUND_REASON, NO_PROGRESS_FINAL_ANSWER_NUDGE, ProgressKind, ProgressTracker,
STEP_LIMIT_WRAP_UP_NUDGE, forced_final_answer_is_unusable, no_progress_signature_for_calls,
};
use super::retry::{
INCOMPLETE_STATUS, ReviewRepairState, TurnRetryState,
Expand Down Expand Up @@ -69,6 +69,7 @@ pub(super) struct ModelRoundState<'a> {
pub sched_serial_runs: &'a mut u32,
pub tool_schema_tokens: &'a mut u64,
pub ended_at_cap: &'a mut bool,
pub cap_wrap_up_requested: &'a mut bool,
pub prev_call_sig: &'a mut Option<Vec<(String, String)>>,
pub retry_state: &'a mut TurnRetryState,
pub request_max_tokens_override: &'a mut Option<u32>,
Expand Down Expand Up @@ -195,6 +196,7 @@ impl crate::Agent {
let sched_serial_runs = *state.sched_serial_runs;
let mut tool_schema_tokens = *state.tool_schema_tokens;
let ended_at_cap = *state.ended_at_cap;
let mut cap_wrap_up_requested = *state.cap_wrap_up_requested;
let mut prev_call_sig = std::mem::take(state.prev_call_sig);
let mut retry_state = std::mem::take(state.retry_state);
let mut request_max_tokens_override = std::mem::take(state.request_max_tokens_override);
Expand Down Expand Up @@ -225,8 +227,21 @@ impl crate::Agent {

let result = async {
self.set_turn_phase(TurnPhase::Model);
// Reaching the cap grants ONE tool-free wrap-up round so the model can
// report where it left the work, instead of the turn dying mid-flight
// with no final answer. The sticky flag makes the second hit terminal.
let mut request_cap_wrap_up = false;
if steps >= max_steps {
return Ok(ModelRoundControl::BreakInner(true));
if cap_wrap_up_requested {
return Ok(ModelRoundControl::BreakInner(true));
}
cap_wrap_up_requested = true;
request_cap_wrap_up = true;
ui.nudge(&format!(
"reached step limit ({max_steps}); asking for a final wrap-up before stopping"
));
self.messages
.push_nudge(NudgeKind::Continue, STEP_LIMIT_WRAP_UP_NUDGE);
}
steps += 1;

Expand Down Expand Up @@ -372,6 +387,7 @@ impl crate::Agent {
let tool_mode = if request_text_tool_fallback
|| request_text_answer
|| request_no_progress_final_answer
|| request_cap_wrap_up
{
ToolMode::ChatOnly
} else if force_tools_next && self.config.routing.tool_mode == ToolMode::Auto {
Expand All @@ -382,6 +398,7 @@ impl crate::Agent {
let tool_availability_mode = if request_text_tool_fallback
|| request_text_answer
|| request_no_progress_final_answer
|| request_cap_wrap_up
{
ToolMode::ChatOnly
} else if read_only_intent.is_some()
Expand Down Expand Up @@ -669,7 +686,7 @@ impl crate::Agent {
}

let calls: Vec<(String, String, String)> =
if request_text_answer || request_no_progress_final_answer {
if request_text_answer || request_no_progress_final_answer || request_cap_wrap_up {
Vec::new()
} else {
completion
Expand All @@ -695,6 +712,7 @@ impl crate::Agent {
let calls = if calls.is_empty()
&& !request_text_answer
&& !request_no_progress_final_answer
&& !request_cap_wrap_up
{
let full_text: String = completion
.content
Expand Down Expand Up @@ -1229,11 +1247,33 @@ If the task is already complete, stop and give your final recap."
.join("\n");
let has_text = !assistant_text.trim().is_empty();

if request_cap_wrap_up {
// Whatever the model reports here is accepted as the wrap-up — the
// turn ends at the cap either way, so no usability gating applies.
if has_text {
if buffer_read_only_review_text || !streamed_assistant_text {
let text_to_emit = if buffered_assistant_text.is_empty() {
assistant_text.as_str()
} else {
buffered_assistant_text.as_str()
};
self.emit_assistant_text(ui, text_to_emit);
ui.assistant_end();
}
progress_tracker.record(ProgressKind::Weak, "step-limit wrap-up report", None);
}
self.messages
.push_assistant_text_only(std::mem::take(&mut completion.content));
return Ok(ModelRoundControl::BreakInner(true));
}

if request_no_progress_final_answer {
let background_status_answer =
progress_tracker.last_progress_reason == AWAITING_BACKGROUND_REASON;
let unusable = forced_final_answer_is_unusable(
&assistant_text,
self.goals.plan_incomplete(),
);
self.goals.plan_incomplete() && !background_status_answer,
) && !(background_status_answer && has_text);
if has_text && (buffer_read_only_review_text || !streamed_assistant_text) {
let text_to_emit = if buffered_assistant_text.is_empty() {
assistant_text.as_str()
Expand Down Expand Up @@ -1309,6 +1349,7 @@ If the task is already complete, stop and give your final recap."
&mut force_tools_next,
&mut force_text_answer_next,
&mut text_tool_fallback_next,
&mut stalled_repeating,
&mut stalled_unfinished,
&mut buffered_assistant_text,
buffer_read_only_review_text,
Expand Down Expand Up @@ -1366,6 +1407,7 @@ If the task is already complete, stop and give your final recap."
*state.sched_serial_runs = sched_serial_runs;
*state.tool_schema_tokens = tool_schema_tokens;
*state.ended_at_cap = ended_at_cap;
*state.cap_wrap_up_requested = cap_wrap_up_requested;
*state.prev_call_sig = prev_call_sig;
*state.retry_state = retry_state;
*state.request_max_tokens_override = request_max_tokens_override;
Expand Down
22 changes: 22 additions & 0 deletions crates/hi-agent/src/agent/turn/progress.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,21 @@ use crate::steering::{
pub(super) const PROGRESS_EVENT_LIMIT: usize = 20;
pub(super) const NO_PROGRESS_FINAL_ANSWER_NUDGE_THRESHOLD: u32 = 2;
pub(super) const NO_PROGRESS_FINAL_ANSWER_NUDGE: &str = "You have not made new progress after repeated tool-use nudges. Stop using tools now and give the best final answer from the evidence already in the conversation. If the task cannot be completed from that evidence, say exactly what is missing.";
/// Sent when a turn reaches its configured step cap: one final tool-free round
/// so the model reports where it left the work instead of the turn dying
/// mid-flight with no answer. Only a deliberately set cap (`--max-steps`,
/// `/config steps <n>`, or an internal subagent budget) can trigger this —
/// there is no implicit per-turn step limit.
pub(super) const STEP_LIMIT_WRAP_UP_NUDGE: &str = "You have reached this turn's step limit. Stop using tools now. In a short final answer, report what you completed, what remains unfinished, and the exact state you are leaving the work in (files changed, checks not yet run). Do not claim the task is complete unless it actually is; the user can raise or remove the limit with /config steps.";
/// Progress reason shared between the waiting-round recovery (Steer) and the
/// final-answer acceptance paths: it marks the turn as blocked only on live
/// background work, so a status answer is a valid terminal outcome.
pub(super) const AWAITING_BACKGROUND_REASON: &str = "background process is still running";
/// Consecutive waiting rounds (only polls/status probes of a still-running
/// background process) tolerated before the turn is steered to end with a
/// status report. Three rounds ≈ one launch check plus two follow-ups — enough
/// to catch a fast finish without funding an open-ended babysitting loop.
pub(super) const WAITING_ROUND_BUDGET: u32 = 3;

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum ProgressKind {
Expand Down Expand Up @@ -60,6 +75,13 @@ pub(super) struct ProgressTracker {
pub(super) forced_final_answer_attempts: u32,
pub(super) last_progress_reason: String,
pub(super) last_stall_reason: String,
/// Consecutive tool rounds that only watched still-running background
/// work (see [`WAITING_ROUND_BUDGET`]). Reset by any non-waiting round.
pub(super) waiting_rounds: u32,
/// Sticky once the waiting budget is spent: the turn is blocked on live
/// background work, so plan-continue nudges are suppressed and a status
/// answer ends the turn. Cleared by a round that does real work.
pub(super) awaiting_background: bool,
pub(super) events: Vec<ProgressEvent>,
}

Expand Down
1 change: 1 addition & 0 deletions crates/hi-agent/src/agent/turn/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ impl TurnState {
stalled_repeating: &mut self.flags.stalled_repeating,
stalled_unfinished: &mut self.flags.stalled_unfinished,
ended_at_cap: &mut self.flags.ended_at_cap,
cap_wrap_up_requested: &mut self.flags.cap_wrap_up_requested,
prev_added_no_evidence: &mut self.prev_added_no_evidence,
turn_start: &mut self.turn_start,
context_generation_seen: &mut self.context_generation_seen,
Expand Down
Loading
Loading