fix(engine): make model-step ceilings explicit

Remove the automatic 200-step default from interactive and exec assembly, including Fleet subprocess omission. Keep explicit positive limits and independent wall-clock/stream protections. Interpret the compatibility sentinel as no ceiling at TurnContext, including soft landing, counter saturation and diagnostics. Stop the sample configuration from reinstalling an implicit cap.

Validation: targeted rustfmt check, git diff --check and config.example.toml parse passed. Independent 13-file source review found no remaining actionable defect. Rust compilation, tests and fail-before verification: UNRUN; 0 tests executed because native qualification belongs to the release integrator. Existing regression coverage now crosses 200 productive rounds and follows production Fleet arguments through CLI resolution.
(cherry picked from commit 8241bd6309631b560b5c6435b02320211e6ad0f4)
This commit is contained in:
CodeWhale Bot committed 2026-09-08 17:52:06 -07:00
1 parent a7c0fb46e4
commit 194ff7332f
13 files changed
+120 -123

No files matched your search

+4 -5
View File
@@ -1125,11 +1125,10 @@ alternate_screen = "auto" # auto/always start on the alternate screen; never s
mouse_capture = true # true copies only transcript user/assistant text; false uses raw terminal selection/copy
terminal_probe_timeout_ms = 500 # optional startup terminal-mode timeout (100-5000ms)
stream_chunk_timeout_secs = 900 # optional SSE idle timeout per chunk (0 = default, 1-3600)
# R1 turn budgets. An agent loop with no finite bound can spend real money
# forever, so every one of these is finite by default and finite at its
# maximum. `0` is an invalid value that falls back to the default — it is
# never a "0 means unlimited" sentinel.
max_model_steps = 200 # model steps one turn may take (0 = default, 1-100000)
# Model steps are uncapped by default. Uncomment to install an explicit
# ceiling; omission or 0 means no step limit, positive values clamp to 1-100000.
# max_model_steps = 1000
# Wall-clock and stream budgets remain finite; 0 selects their default.
turn_wall_clock_secs = 3600 # cumulative wall clock for one turn, excluding time
# blocked on a human approval (0 = default, 30-86400)
stream_max_content_mb = 10 # per-step cap on streamed content (0 = default, 1-512)
+6 -8
View File
@@ -1792,10 +1792,9 @@ pub struct TuiConfig {
/// Per-SSE-chunk idle timeout in seconds. Defaults to 900 seconds when
/// omitted. `0` maps to the default; values clamp to `1..=3600`.
pub stream_chunk_timeout_secs: Option<u64>,
/// R1: ceiling on model steps in a single turn. Omitted or `0` resolve
/// to the finite default (200); explicit values clamp to
/// `1..=100_000`. There is deliberately no "unlimited" value — `0` is
/// an invalid setting, not a sentinel that disables the cap.
/// Optional ceiling on model steps in a single turn. Omitted or `0`
/// leaves model steps uncapped; explicit positive values clamp to
/// `1..=100_000`. Wall-clock and stream budgets remain independent.
pub max_model_steps: Option<u32>,
/// R1: cumulative wall-clock budget for a single turn, in seconds.
/// Omitted or `0` resolve to the finite default (3600); explicit values
@@ -7886,12 +7885,11 @@ impl Config {
raw.clamp(MIN_STREAM_CHUNK_TIMEOUT_SECS, MAX_STREAM_CHUNK_TIMEOUT_SECS)
}
/// R1: resolved ceiling on model steps in a single turn.
/// Resolved optional ceiling on model steps in a single turn.
///
/// Reads `[tui].max_model_steps`, falling back to the
/// `CODEWHALE_MAX_MODEL_STEPS` env var, then to the finite default.
/// `0` — from either source — is treated as an invalid value and
/// resolves to the default; it never means "unlimited".
/// `CODEWHALE_MAX_MODEL_STEPS` env var, then to the uncapped default.
/// `0` from either source also selects the uncapped default.
#[must_use]
pub fn max_model_steps(&self) -> u32 {
let raw = self
+4 -11
View File
@@ -318,7 +318,7 @@ pub struct EngineConfig {
pub translation_enabled: bool,
pub verbosity: Option<String>,
/// Maximum number of assistant steps before stopping. Ordinary interactive
/// hosts use [`UNBOUNDED_MODEL_STEPS`]; explicit test/embed callers may
/// hosts use [`DEFAULT_MODEL_STEPS`]; explicit test/embed callers may
/// still install a finite boundary.
pub max_steps: u32,
/// Maximum number of concurrently active subagents.
@@ -518,12 +518,8 @@ pub struct EngineConfig {
pub advisor_config: crate::tools::subagent::AdvisorConfig,
}
/// Default model-step ceiling for hosts that do not resolve one from
/// configuration (R1). Formerly `UNBOUNDED_MODEL_STEPS = u32::MAX`, which
/// made an unbounded agent loop the default everywhere. It is finite now:
/// progress/stationarity controls still live at the tool loop, but they are
/// no longer the *only* thing standing between a stuck loop and unbounded
/// spend. Overridable via `[tui].max_model_steps`; see
/// Uncapped model steps for hosts without an explicit configured ceiling.
/// Wall-clock and stream budgets are independent. See
/// [`turn_budget::resolve_max_model_steps`].
pub(crate) const DEFAULT_MODEL_STEPS: u32 = turn_budget::DEFAULT_MAX_MODEL_STEPS;
@@ -547,10 +543,7 @@ impl Default for EngineConfig {
instructions: Vec::new(),
project_context_pack_enabled: false,
translation_enabled: false,
// R1: every turn carries a finite model-step budget. Callers
// that need a different boundary set one explicitly; progress-
// based stationarity still belongs at the tool-loop layer, but
// it is no longer the only bound on spend.
// Callers opt into a finite model-step boundary explicitly.
max_steps: DEFAULT_MODEL_STEPS,
max_subagents: DEFAULT_MAX_SUBAGENTS,
max_admitted_subagents: DEFAULT_MAX_SUBAGENTS,
+15 -13
View File
@@ -602,17 +602,22 @@ async fn emergency_compaction_cancellation_drops_provider_and_never_mutates_cont
}
const REPRESENTATIVE_HANDOFF_RELAY: &str = "REPRESENTATIVE_HANDOFF_RELAY";
/// R1 inverted this: the ordinary engine default used to be
/// `UNBOUNDED_MODEL_STEPS = u32::MAX`, i.e. an agent loop with no finite
/// bound. The default is now finite and every host resolves from it.
#[test]
fn ordinary_engine_default_has_a_finite_step_budget() {
fn ordinary_engine_default_does_not_install_a_step_budget() {
assert_eq!(
DEFAULT_MODEL_STEPS,
crate::core::engine::turn_budget::DEFAULT_MAX_MODEL_STEPS
);
const { assert!(DEFAULT_MODEL_STEPS < u32::MAX) };
assert_eq!(EngineConfig::default().max_steps, DEFAULT_MODEL_STEPS);
let mut turn = TurnContext::new(EngineConfig::default().max_steps);
assert_eq!(turn.step_limit(), None);
assert_eq!(turn.stop_diagnostics.effective_max_steps, None);
// No substitute ceiling or overflow may stop an uncapped turn.
turn.step = u32::MAX - 1;
assert!(turn.next_step());
assert!(turn.next_step());
assert!(!turn.at_max_steps());
assert_eq!(turn.steps_used(), u32::MAX);
}
#[test]
@@ -7448,7 +7453,7 @@ async fn sandbox_escalation_fails_closed_when_the_posture_cannot_prompt() {
async fn productive_tool_results_do_not_hit_no_user_input_backstop() {
use crate::llm_client::mock::{MockLlmClient, canned};
const TOOL_ROUNDS: usize = 20;
const TOOL_ROUNDS: usize = 201;
const FINAL_ANSWER: &str = "All productive tool rounds completed.";
let workspace = tempdir().expect("tempdir");
@@ -7516,7 +7521,7 @@ async fn productive_tool_results_do_not_hit_no_user_input_backstop() {
assert_eq!(
mock.call_count(),
TOOL_ROUNDS + 1,
"the final provider request must follow tool round 20"
"productive work must finish beyond the former 200-step default"
);
assert!(
saw_final_answer,
@@ -23252,19 +23257,16 @@ async fn idle_engine_routes_child_approval_decisions_to_the_waiting_child() {
}
// ---------------------------------------------------------------------------
// R1: finite turn budgets. Each limit must fire, and each must be overridable.
// Explicit step limits and finite wall-clock/stream budgets remain enforceable.
// ---------------------------------------------------------------------------
#[test]
fn engine_config_defaults_carry_finite_turn_budgets() {
fn engine_config_defaults_keep_wall_clock_and_stream_budgets() {
use crate::core::engine::turn_budget;
let config = EngineConfig::default();
assert_eq!(config.max_steps, turn_budget::DEFAULT_MAX_MODEL_STEPS);
assert!(
config.max_steps < u32::MAX,
"the default model-step ceiling must be finite"
);
assert_eq!(TurnContext::new(config.max_steps).step_limit(), None);
assert_eq!(
config.turn_wall_clock,
std::time::Duration::from_secs(turn_budget::DEFAULT_TURN_WALL_CLOCK_SECS),
+19 -44
View File
@@ -1,26 +1,13 @@
//! Finite turn budgets (R1, v0.9.12 Phase 1).
//! Turn budgets shared by interactive hosts and headless execution.
//!
//! An agent loop with no finite bound can spend real money forever. Before
//! R1 the parent turn had exactly one hard ceiling — the per-step stream
//! caps in [`super::streaming`] — while the model-step ceiling defaulted to
//! `u32::MAX` at every production call site and no cumulative per-turn
//! wall-clock budget existed at all. This module is the single home for the
//! four bounds R1 makes finite:
//! Model steps are uncapped by default. Explicit positive limits still
//! apply; a fixed step counter is not a measure of useful progress.
//! Cumulative wall-clock and per-step stream budgets remain finite.
//!
//! 1. `max_steps` — model steps in one turn.
//! 2. The cumulative per-turn wall clock.
//! 3. `exec --max-turns` — the same step ceiling for headless runs.
//! 4. The per-step stream caps (content bytes, stream duration).
//!
//! ## No `0`-means-unlimited sentinel
//!
//! Every resolver here rejects `0` and falls back to the finite default
//! rather than reading it as "unlimited". That sentinel is exactly the bug
//! class R1 exists to close: a `0` that skips the cap check turns a
//! misconfiguration (or a typo) into an unbounded spend. There is also no
//! "unlimited" value at all — a caller who genuinely wants a turn to run
//! practically forever raises the knob to its documented maximum, which is
//! large but still finite and still terminates.
//! `EngineConfig` retains its integer representation for embedders:
//! `u32::MAX` represents no model-step limit. `TurnContext::step_limit`
//! resolves that representation to `None` before checking a ceiling or
//! emitting diagnostics. It is not a very large finite fallback.
//!
//! ## Honesty at the limit
//!
@@ -31,26 +18,15 @@
use std::time::{Duration, Instant};
/// Default ceiling on model steps within a single turn.
///
/// A "step" is one accepted provider response, so this bounds how many
/// billable requests one user message can trigger. 200 is far above what an
/// ordinary interactive turn spends and still finite: a runaway tool loop
/// stops here instead of spending until the operator notices.
pub const DEFAULT_MAX_MODEL_STEPS: u32 = 200;
/// No model-step limit unless the caller configures one. This is the
/// compatibility representation, not a ceiling checked at `u32::MAX`.
pub const DEFAULT_MAX_MODEL_STEPS: u32 = u32::MAX;
/// Smallest accepted model-step ceiling. One step still lets the model
/// answer once.
pub const MIN_MAX_MODEL_STEPS: u32 = 1;
/// Largest accepted model-step ceiling. Deliberately large enough to serve
/// as the "effectively unlimited" escape hatch while remaining finite, so
/// no configuration path can produce an unbounded loop.
/// Largest explicitly configured model-step ceiling.
pub const MAX_MAX_MODEL_STEPS: u32 = 100_000;
/// Default headless `exec --max-turns`. Same ceiling as the interactive
/// engine: a non-interactive run has nobody watching it, so it must not be
/// looser than the one a human is sitting in front of.
pub const DEFAULT_EXEC_MAX_TURNS: u32 = DEFAULT_MAX_MODEL_STEPS;
/// Default cumulative per-turn wall-clock budget, in seconds.
///
/// Measured across every model step of one turn, not per request. Time the
@@ -82,10 +58,10 @@ pub const MAX_STREAM_MAX_DURATION_SECS: u64 = 86_400;
/// Resolve a configured model-step ceiling.
///
/// `None` and `0` both resolve to [`DEFAULT_MAX_MODEL_STEPS`]: `0` is
/// treated as an invalid value, never as "unlimited". Positive values clamp
/// into `MIN_MAX_MODEL_STEPS..=MAX_MAX_MODEL_STEPS`, so even the largest
/// configurable turn terminates.
/// `None` and `0` select the uncapped default. Explicit positive values
/// clamp into `MIN_MAX_MODEL_STEPS..=MAX_MAX_MODEL_STEPS`. Resolve raw input
/// once: passing an already resolved default back as an explicit value
/// would incorrectly install the maximum configurable ceiling.
#[must_use]
pub fn resolve_max_model_steps(raw: Option<u32>) -> u32 {
match raw {
@@ -230,11 +206,10 @@ mod tests {
use super::*;
#[test]
fn model_step_defaults_are_finite_and_reject_the_zero_sentinel() {
fn model_step_defaults_do_not_install_a_ceiling() {
assert_eq!(resolve_max_model_steps(None), DEFAULT_MAX_MODEL_STEPS);
// `0` is invalid, not "unlimited" — the trap R1 exists to close.
assert_eq!(resolve_max_model_steps(Some(0)), DEFAULT_MAX_MODEL_STEPS);
const { assert!(DEFAULT_MAX_MODEL_STEPS < u32::MAX) };
assert_eq!(DEFAULT_MAX_MODEL_STEPS, u32::MAX);
const { assert!(MAX_MAX_MODEL_STEPS < u32::MAX) };
}
@@ -245,7 +220,7 @@ mod tests {
assert_eq!(
resolve_max_model_steps(Some(u32::MAX)),
MAX_MAX_MODEL_STEPS,
"the escape hatch is large but still finite"
"explicit positive overrides retain the configured ceiling"
);
}
+3 -2
View File
@@ -879,8 +879,9 @@ impl Engine {
// report. Savings proved out by the grok-style parity work (ops
// A1): a step-faithful harness ends mid-report far too often.
if !turn.stop_diagnostics.soft_landing_sent
&& turn.max_steps > 0
&& turn.steps_used() >= ((turn.max_steps as f32 * 0.8).floor() as u32).max(1)
&& let Some(step_limit) = turn.step_limit()
&& step_limit > 0
&& turn.steps_used() >= ((step_limit as f32 * 0.8).floor() as u32).max(1)
{
turn.stop_diagnostics.soft_landing_sent = true;
let notice = format!(
+13 -5
View File
@@ -53,7 +53,8 @@ pub struct TurnContext {
/// Current step in the turn (tool call iteration)
pub step: u32,
/// Maximum steps allowed
/// Configured steps, or `u32::MAX` for no limit. Use `step_limit` for
/// budget decisions; the counter saturates without stopping an uncapped turn.
pub max_steps: u32,
/// Which configured limit `max_steps` came from.
@@ -118,7 +119,7 @@ impl TurnContext {
budget_source,
budget_exhausted_final_report: false,
stop_diagnostics: crate::tool_inspection::TurnStopDiagnostics {
effective_max_steps: max_steps,
effective_max_steps: (max_steps != u32::MAX).then_some(max_steps),
step_budget_source: budget_source.key_label(),
..Default::default()
},
@@ -139,13 +140,20 @@ impl TurnContext {
/// Increment the step counter
pub fn next_step(&mut self) -> bool {
self.step += 1;
self.step <= self.max_steps
self.step = self.step.saturating_add(1);
self.step_limit().is_none_or(|limit| self.step <= limit)
}
/// A resolved integer default means no ceiling, including at counter
/// saturation. Explicit positive configuration is clamped before here.
#[must_use]
pub fn step_limit(&self) -> Option<u32> {
(self.max_steps != u32::MAX).then_some(self.max_steps)
}
/// Check if the turn has reached max steps
pub fn at_max_steps(&self) -> bool {
self.step >= self.max_steps
self.step_limit().is_some_and(|limit| self.step >= limit)
}
/// Model steps consumed so far (for soft-landing and reporting).
+3 -8
View File
@@ -11,15 +11,10 @@ use super::*;
/// Resolve the headless `exec` model-step ceiling.
///
/// R1: omitting `--max-turns` no longer means `u32::MAX`. A non-interactive
/// run has nobody watching it, so its default bound is the same finite
/// ceiling the interactive engine uses. Clap already rejects `--max-turns
/// 0`, so no "0 means unlimited" sentinel can reach here; an explicit value
/// is still clamped to the documented finite range.
/// Omission leaves model steps uncapped. Clap rejects `--max-turns 0`;
/// explicit positive values retain the documented finite range.
pub(crate) fn exec_max_steps(max_turns: Option<u32>) -> u32 {
crate::core::engine::turn_budget::resolve_max_model_steps(max_turns.or(Some(
crate::core::engine::turn_budget::DEFAULT_EXEC_MAX_TURNS,
)))
crate::core::engine::turn_budget::resolve_max_model_steps(max_turns)
}
#[allow(clippy::too_many_arguments)]
+35 -11
View File
@@ -1649,18 +1649,42 @@ mod tests {
#[test]
fn default_max_turns_does_not_add_a_hidden_worker_cap() {
use clap::Parser;
let exec = FleetExecConfig::default();
let joined = build_worker_exec_command("codewhale", &task("x"), &exec, None)
.args
.join(" ");
assert!(
!joined.contains("--max-turns"),
"the unbounded default must not become a subprocess cap: {joined}"
);
assert!(
!joined.contains("--max-tool-calls"),
"the unbounded default must not become a tool-call cap: {joined}"
);
let workspace = TempDir::new().unwrap();
for requested_steps in [None, Some(0), Some(13)] {
let mut task = task("x");
task.budget = requested_steps.map(|max_steps| FleetTaskBudget {
max_steps: Some(max_steps),
..FleetTaskBudget::default()
});
let spec = launch_spec(&task, workspace.path());
let cmd = build_worker_exec_command_with_launch_spec(
"codewhale",
&task,
&spec,
&exec,
None,
&[],
)
.expect("production worker command");
let cli = crate::Cli::try_parse_from(
std::iter::once("codewhale").chain(cmd.args.iter().map(String::as_str)),
)
.expect("production worker args must parse");
let Some(crate::Commands::Exec(args)) = cli.command else {
panic!("expected worker exec command");
};
let expected = requested_steps.filter(|steps| *steps > 0);
assert_eq!(args.max_turns, expected);
assert_eq!(args.max_tool_calls, None);
// Follow omission beyond argv through the real CLI resolver. This
// previously installed 200 despite correct unbounded launch args.
let turn = crate::core::turn::TurnContext::new(crate::exec_max_steps(args.max_turns));
assert_eq!(turn.step_limit(), expected);
assert_eq!(turn.stop_diagnostics.effective_max_steps, expected);
}
}
#[test]
+12 -6
View File
@@ -16950,11 +16950,8 @@ api_key = "test-only-key"
assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation);
}
/// R1: omitting `--max-turns` used to resolve to `u32::MAX`, i.e. a
/// headless run with no bound at all. It now resolves to the finite
/// default, and an explicit value still wins.
#[test]
fn exec_defaults_to_a_finite_headless_turn_cap() {
fn exec_only_installs_an_explicit_headless_turn_cap() {
let cli = parse_cli(&["codewhale", "exec", "--auto", "benchmark this"]);
let Some(Commands::Exec(args)) = cli.command else {
panic!("expected exec command");
@@ -16964,10 +16961,19 @@ api_key = "test-only-key"
let defaulted = exec_max_steps(args.max_turns);
assert_eq!(
defaulted,
crate::core::engine::turn_budget::DEFAULT_EXEC_MAX_TURNS
crate::core::engine::turn_budget::DEFAULT_MAX_MODEL_STEPS
);
assert_eq!(
crate::core::turn::TurnContext::new(defaulted).step_limit(),
None
);
assert!(defaulted < u32::MAX, "the headless default must be finite");
assert_eq!(exec_max_steps(Some(7)), 7);
let mut capped = crate::core::turn::TurnContext::new(exec_max_steps(Some(7)));
for _ in 0..7 {
capped.next_step();
}
assert!(capped.at_max_steps());
assert_eq!(capped.stop_diagnostics.effective_max_steps, Some(7));
assert_eq!(
exec_max_steps(Some(u32::MAX)),
crate::core::engine::turn_budget::MAX_MAX_MODEL_STEPS,
+3 -2
View File
@@ -63,7 +63,8 @@ pub struct TurnStopDiagnostics {
pub status: Option<crate::core::events::TurnOutcomeStatus>,
/// None means the precise runtime exit boundary was not observed.
pub reason: Option<TurnStopReason>,
pub effective_max_steps: u32,
/// None means the caller did not install a model-step ceiling.
pub effective_max_steps: Option<u32>,
pub step_budget_source: &'static str,
/// Existing zero-based scheduler step; transport retries do not advance it.
pub model_step_index: u32,
@@ -470,7 +471,7 @@ impl ToolInspectionSnapshot {
));
out.push_str(&format!("Step: {}\n", self.step));
if let Some(terminal) = &self.terminal {
out.push_str("Terminal diagnostics (observed facts; null means unknown):\n");
out.push_str("Terminal diagnostics (observed facts; effective_max_steps null means uncapped, other nulls mean unknown):\n");
if let Ok(json) = serde_json::to_string_pretty(terminal) {
out.push_str(&json);
out.push('\n');
+1 -6
View File
@@ -791,12 +791,7 @@ pub(crate) fn build_engine_config(app: &App, config: &Config) -> EngineConfig {
project_context_pack_enabled: config.project_context_pack_enabled(),
translation_enabled: app.translation_enabled,
verbosity: app.verbosity.clone(),
// R1: finite, not `u32::MAX`. The old comment argued a runaway is
// "human-noticeable", but an interactive session left running is
// exactly where an unbounded loop spends real money unattended.
// The default (200) is far above what a long multi-step plan needs;
// operators who want more raise `[tui].max_model_steps`, and the
// clamp keeps even the maximum finite.
// Only an explicit `[tui].max_model_steps` installs a step ceiling.
max_steps: config.max_model_steps(),
max_subagents,
max_admitted_subagents: config
+2 -2
View File
@@ -2284,7 +2284,7 @@ reasoning contract, and all four membership ids omit generic sampling fields.
- `tui.stream_chunk_timeout_secs` (int, optional, default `900`): per-SSE-chunk idle timeout for streamed model responses. Slow local or compatible servers can raise this with `/config stream_chunk_timeout_secs <seconds>`; `0` maps to the default and explicit values must be `1..=3600`. The legacy `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` env var is still honored when this key is omitted.
- `tui.header_items` (array of strings, optional, default `[]`): opt-in header chips. Set `header_items = ["tokens"]` under `[tui]` to show the session input, cache-hit, and output token counts. Narrow terminals elide the optional chip; wide terminals show it alongside context utilization.
- `tui.osc8_links` (bool, optional, default on for macOS/Linux, off for Windows): emit OSC 8 escape sequences around URLs in transcript output so supporting terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty, WezTerm, Alacritty, recent gnome-terminal/konsole) can open them with the terminal's link gesture—usually Cmd-click on macOS and Ctrl-click on Linux/Windows. Terminals without OSC 8 support render the plain label and ignore the escape. The escapes are emitted out-of-band (not inside buffer cells), so column corruption is not a concern; set `false` only for terminals that misrender the OSC 8 terminator itself. Windows legacy consoles default off; opt in with `true`.
- `tui.max_model_steps` (int, optional, default `200`): finite ceiling on model steps one turn may take. A "step" is one accepted provider response, so this bounds how many billable requests a single user message can trigger. Values are clamped to `1..=100000`; `0` (or absent) resolves to the default — there is no `0`-means-unlimited sentinel. At ~80% of the budget the model gets one soft-landing notice to stop exploring and write its final report; at exhaustion the turn ends `Failed` with `Maximum model steps reached before completion (limit: N)` (after one bounded final-report turn when the model still owes work). This is the ordinary interactive runaway guard. Active goal turns use `goal.max_steps` instead (default `1000`); raise that key to enlarge one goal pass. Explicit lower task or caller limits still apply. Multi-turn goal runs continue automatically (see the Goal loop section below).
- `tui.max_model_steps` (int, optional, default uncapped): optional model-step ceiling for one ordinary turn. Omission or `0` leaves model steps uncapped; explicit positive values are clamped to `1..=100000`. Headless `exec` and Fleet workers also have no implicit model-step ceiling; `exec --max-turns N` and positive worker budgets still apply. At ~80% of an explicit step budget the model gets one soft-landing notice; at exhaustion the turn ends `Failed` with `Maximum model steps reached before completion (limit: N)` after one bounded final-report response when needed. Cumulative wall-clock and per-stream limits remain independent. Active interactive goal turns use `goal.max_steps` instead (default `1000`); see the Goal loop section below.
- `tui.turn_wall_clock_secs` (int, optional, default `3600`): cumulative per-turn wall-clock budget in seconds, measured across every model step of one turn (not per request). Time blocked on a human approval is excluded. Clamped to `30..=86400` (24 hours is the documented ceiling); `0` resolves to the default. When exhausted the turn stops before authorizing another billable request with a message naming the limit and the key to raise.
- `transcript.prose_measure` (positive integer, optional, default absent = full width): wrap cap, in columns, for prose cells — user messages, assistant answers, and reasoning/thinking blocks — in the live transcript (#5436). Absent (or `0`) spends the full content width, consistent with tool/status cells and the #5322 wide-frame decision; the former 105-column prose rail is gone. Set a positive whole number (e.g. `prose_measure = 120` under `[transcript]`) to restore a bounded reading measure on ultrawide terminals. Narrow terminals always keep their content width — the cap clamps from above only. Tool, diff, and status cells never inherit this cap. Invalid values (negative or non-integer) are rejected at startup with a `transcript.prose_measure` config error. Resolved once per render pass, so the main transcript cache and the full-screen overlay always agree on the effective width.
- `hooks` (optional): lifecycle hooks configuration (see `config.example.toml`).
@@ -2379,7 +2379,7 @@ When an explicit backstop fires, the goal pauses with a status message naming
inspecting progress, or raise/disable the backstop.
`[goal] max_steps` governs one engine turn at a time: the ordinary interactive
ceiling (`max_steps`, default 200) is unchanged, and explicit per-invocation
turn has no implicit model-step ceiling. Explicit per-invocation
ceilings — `exec --max-turns N`, child-worker caps — always win over it. At
about 80% of the selected budget the model is told to land; at exhaustion it
gets one bounded final report and the turn classifies as budget-exhausted. An