Remove default agent turn cap and preserve queued tool status

Allow new provider runs to continue without an arbitrary lifetime turn budget while preserving explicit limits, checkpoint compatibility, and bounded retries. Keep pending tool proposals visibly queued until execution status arrives, and show cancellation only when supported by actual state.

Validation: formatting, inline test-layout checks, both presubmit Clippy commands, 325 targeted regressions, and 54 core tests after final cleanup passed.
This commit is contained in:
2026-09-11 05:45:19 -05:00
parent 2d581f4c01
commit 73d6839ee9
5 changed files with 113 additions and 33 deletions
+24 -14
View File
@@ -90,14 +90,15 @@ impl From<&str> for ProviderRequestProfile {
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProviderRunLimits {
pub max_model_turns: u32,
/// Optional lifetime budget. Interactive runs continue until completion or cancellation.
pub max_model_turns: Option<u32>,
pub max_model_retries_per_turn: u32,
}
impl Default for ProviderRunLimits {
fn default() -> Self {
Self {
max_model_turns: 100,
max_model_turns: None,
// Three retries means the initial call plus three delayed retries;
// the fourth failure is surfaced to the user.
max_model_retries_per_turn: 3,
@@ -453,7 +454,7 @@ impl ProviderRun {
profile: impl Into<ProviderRequestProfile>,
mut limits: ProviderRunLimits,
) -> Self {
limits.max_model_turns = limits.max_model_turns.max(1);
limits.max_model_turns = limits.max_model_turns.map(|limit| limit.max(1));
Self {
id: id.into(),
epoch: RunEpoch::default(),
@@ -562,13 +563,15 @@ impl ProviderRun {
if self.profile.as_str().is_empty() {
return Err(invalid("request profile must not be empty".to_string()));
}
if self.limits.max_model_turns == 0 {
if self.limits.max_model_turns == Some(0) {
return Err(invalid("model-turn limit must be at least one".to_string()));
}
if self.model_turns > self.limits.max_model_turns {
if let Some(limit) = self.limits.max_model_turns
&& self.model_turns > limit
{
return Err(invalid(format!(
"model-turn counter {} exceeds limit {}",
self.model_turns, self.limits.max_model_turns
"model-turn counter {} exceeds limit {limit}",
self.model_turns
)));
}
if self.epoch.get() < u64::from(self.model_turns) {
@@ -595,7 +598,7 @@ impl ProviderRun {
match &self.state {
ProviderRunState::ReadyToCallModel => {}
ProviderRunState::AwaitingModel { call } => {
if self.model_turns >= self.limits.max_model_turns {
if self.model_turn_limit_reached() {
return Err(invalid(
"awaiting a model call after reaching the model-turn limit".to_string(),
));
@@ -724,9 +727,7 @@ impl ProviderRun {
"retry-limit failure lacks a recoverable source".to_string(),
));
}
ProviderRunFailureKind::TurnLimitExceeded
if self.model_turns < self.limits.max_model_turns =>
{
ProviderRunFailureKind::TurnLimitExceeded if !self.model_turn_limit_reached() => {
return Err(invalid(
"turn-limit failure occurred before reaching the limit".to_string(),
));
@@ -864,17 +865,26 @@ impl ProviderRun {
matches!(self.state, ProviderRunState::ReadyToCallModel).then(|| self.current_work_id())
}
fn model_turn_limit_reached(&self) -> bool {
self.limits
.max_model_turns
.is_some_and(|limit| self.model_turns >= limit)
}
pub fn next_step(&mut self) -> Result<Option<ProviderRunStep>, ProviderRunProtocolError> {
loop {
match self.state.clone() {
ProviderRunState::ReadyToCallModel => {
if self.model_turns >= self.limits.max_model_turns {
if let Some(limit) = self
.limits
.max_model_turns
.filter(|limit| self.model_turns >= *limit)
{
self.state = ProviderRunState::Failed {
failure: ProviderRunFailure {
kind: ProviderRunFailureKind::TurnLimitExceeded,
message: format!(
"provider run reached its {} model-turn limit",
self.limits.max_model_turns
"provider run reached its {limit} model-turn limit"
),
source: None,
},
@@ -154,7 +154,7 @@ fn stale_model_completion_is_rejected_without_mutation() {
#[test]
fn model_retries_reuse_work_identity_and_stop_at_the_budget() {
let mut run = run_with_limits(ProviderRunLimits {
max_model_turns: 5,
max_model_turns: Some(5),
max_model_retries_per_turn: 1,
});
let call = next_model_call(&mut run);
@@ -752,7 +752,7 @@ fn failure_while_tools_are_pending_records_correlated_errors_before_terminal_sta
#[test]
fn turn_limit_cannot_finish_successfully_after_tools_require_another_model_turn() {
let mut run = run_with_limits(ProviderRunLimits {
max_model_turns: 1,
max_model_turns: Some(1),
max_model_retries_per_turn: 0,
});
let batch = accept_tool_turn(
@@ -1384,3 +1384,45 @@ fn restore_normalization_commits_a_fully_resolved_batch() {
} if tool_use_id == "resolved"
));
}
#[test]
fn default_run_continues_past_one_hundred_tool_turns_and_restores() {
let mut run = run();
for index in 0..150 {
let id = format!("poll-{index}");
let batch = accept_tool_turn(
&mut run,
tool_turn(
vec![tool_call(&id, "read_shell_command_output")],
&["read_shell_command_output"],
),
);
run.complete_tool(&batch.work_id, successful_result(&id, "still running"))
.unwrap();
run.commit_tool_batch(&batch.work_id).unwrap();
if index == 99 {
run = serde_json::from_str(&serde_json::to_string(&run).unwrap()).unwrap();
run.validate_restored_state().unwrap();
}
}
assert_eq!(run.model_turns(), 150);
let call = next_model_call(&mut run);
run.accept_model_turn(&call.work_id, text_turn("Finished"))
.unwrap();
assert_eq!(run.next_step().unwrap(), None);
assert!(matches!(
run.state(),
ProviderRunState::AwaitingDriver { .. }
));
}
#[test]
fn serialized_numeric_turn_budgets_remain_explicit_limits() {
let limits: ProviderRunLimits = serde_json::from_value(json!({
"max_model_turns": 100,
"max_model_retries_per_turn": 3
}))
.unwrap();
assert_eq!(limits.max_model_turns, Some(100));
assert_eq!(ProviderRunLimits::default().max_model_turns, None);
}