Make a goal's token budget an optional hard stop (#6013)

[goal] enforce_token_budget opts a per-goal token_budget into a
terminal BudgetLimit stop in decide_continuation; default false keeps
the advisory-telemetry behavior byte-for-byte. The host mirrors the
stop as a durable pause with GoalPauseReason::BudgetLimit so the
cross-turn re-arm cannot resurrect an over-budget goal.

Only the token budget is enforceable: goal records carry no
time_budget field, so a time-enforcement flag would gate nothing.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
CodeWhale BotandDevin committed 2026-09-14 22:31:19 -07:00
1 parent 4c9ef8cb92
commit 1c21a6bc71
13 files changed
+152 -6

No files matched your search

@@ -293,6 +293,10 @@ pub(in crate::commands) fn workflow_settings(app: &App) -> CommandResult {
"max_continuations = {} · automatic continuation passes before a goal pauses; 0 = unlimited (completion, blocked, or you stop it)",
app.goal_max_continuations
),
format!(
"enforce_token_budget = {} · true = a goal's token budget is a hard stop; false = advisory telemetry",
on(app.goal_enforce_token_budget)
),
];
CommandResult::message(lines.join("\n"))
}
+18
View File
@@ -2091,6 +2091,14 @@ pub struct GoalConfig {
/// inside a provider turn.
#[serde(default)]
pub continuation_delay_seconds: Option<u64>,
/// Make a goal's `token_budget` a hard stop instead of advisory telemetry
/// (#6013). `false`/`None` preserves current behavior: crossing the budget
/// logs and continues. `true` stops the run with `BudgetLimit` once
/// `tokens_used >= token_budget`; goals created without a token budget
/// stay unbounded either way.
#[serde(default)]
pub enforce_token_budget: Option<bool>,
}
/// Reasoning-only recovery controls (`[reasoning_only]` table in config.toml).
@@ -7550,6 +7558,16 @@ impl Config {
}
}
/// Whether a goal's `token_budget` is a hard stop (#6013). Default `false`
/// keeps the advisory/telemetry behavior.
#[must_use]
pub fn goal_enforce_token_budget(&self) -> bool {
self.goal
.as_ref()
.and_then(|goal| goal.enforce_token_budget)
.unwrap_or(false)
}
/// Quiet period between successful interactive goal turns (#5508).
/// Absent/zero keeps the existing immediate-continuation behavior.
#[must_use]
+11
View File
@@ -618,6 +618,8 @@ fn goal_max_continuations_loads_from_goal_table() -> Result<()> {
);
assert_eq!(config.goal_max_continuations(), 0);
assert_eq!(config.goal_continuation_delay_seconds(), 0);
// enforce_token_budget defaults off: budgets stay advisory (#6013).
assert!(!config.goal_enforce_token_budget());
// Explicit backstop override.
let config: Config = toml::from_str(
@@ -638,6 +640,15 @@ max_continuations = 0
"#,
)?;
assert_eq!(config.goal_max_continuations(), 0);
// Opt a set token budget into a hard stop (#6013).
let config: Config = toml::from_str(
r#"
[goal]
enforce_token_budget = true
"#,
)?;
assert!(config.goal_enforce_token_budget());
assert_eq!(config.goal_continuation_delay_seconds(), 0);
// Bound accidental giant cadences; this remains a turn loop, not a
+17 -2
View File
@@ -409,6 +409,10 @@ pub struct EngineConfig {
/// immediately; positive values opt coordinator goals into a cancellable
/// quiet period (#5508).
pub goal_continuation_delay_seconds: u64,
/// Whether a goal's `token_budget` is a hard stop (`BudgetLimit`) instead
/// of advisory telemetry. Resolved from `[goal] enforce_token_budget`;
/// default `false` (#6013).
pub goal_enforce_token_budget: bool,
/// Maximum number of automatic re-requests when the model returns only
/// reasoning without any answer or tool call. Defaults to 2.
/// Resolved from `[reasoning_only] max_reprompts` in config.toml.
@@ -587,6 +591,7 @@ impl Default for EngineConfig {
goal_status: GoalStatus::Active,
goal_max_continuations: crate::goal_loop::DEFAULT_MAX_GOAL_CONTINUATIONS,
goal_continuation_delay_seconds: 0,
goal_enforce_token_budget: false,
reasoning_only_max_reprompts: crate::config::DEFAULT_REASONING_ONLY_REPROMPTS,
// `None` means "use the built-in nudge". Storing the default text
// here instead would make an operator's explicit empty string
@@ -3986,9 +3991,12 @@ impl Engine {
continuations: snapshot.continuation_count,
},
// Unbounded like grokbuild (agent-call cap) and kimicode swarm
// (turnBudget per-task, resumable): token/time are telemetry only,
// only Completed/Blocked/ContinuationLimit pause the loop.
// (turnBudget per-task, resumable): token/time are telemetry only
// unless `[goal] enforce_token_budget` opts a set budget into a
// hard stop (#6013); otherwise only Completed/Blocked/
// ContinuationLimit pause the loop.
crate::goal_loop::GoalBudget::unbounded()
.with_enforced_token_budget(self.config.goal_enforce_token_budget)
.with_max_continuations(self.config.goal_max_continuations),
);
@@ -4018,6 +4026,13 @@ impl Engine {
),
GoalPauseReason::Backoff,
),
crate::goal_loop::StopReason::BudgetLimit => (
"Goal paused: the goal's token budget was reached and \
[goal] enforce_token_budget makes that a hard stop; \
raise the budget or resume to continue."
.to_string(),
GoalPauseReason::BudgetLimit,
),
crate::goal_loop::StopReason::Completed
| crate::goal_loop::StopReason::Blocked => {
return GoalContinuationAction::Inactive;
+1
View File
@@ -201,6 +201,7 @@ impl Engine {
crate::goal_loop::GoalBudget {
token_budget: snapshot.token_budget.map(u64::from),
time_budget_seconds: None,
enforce_token_budget: self.config.goal_enforce_token_budget,
max_continuations: self.config.goal_max_continuations,
},
))
+1
View File
@@ -5362,6 +5362,7 @@ impl Engine {
crate::goal_loop::GoalBudget {
token_budget: snapshot.token_budget.map(u64::from),
time_budget_seconds: None,
enforce_token_budget: self.config.goal_enforce_token_budget,
max_continuations: self.config.goal_max_continuations,
},
);
+1
View File
@@ -577,6 +577,7 @@ pub(crate) async fn run_exec_agent(
goal_status: crate::tools::goal::GoalStatus::Active,
goal_max_continuations: execution_config.goal_max_continuations(),
goal_continuation_delay_seconds: execution_config.goal_continuation_delay_seconds(),
goal_enforce_token_budget: execution_config.goal_enforce_token_budget(),
reasoning_only_max_reprompts: execution_config.reasoning_only_max_reprompts(),
reasoning_only_reprompt_message: Some(
execution_config
+76 -4
View File
@@ -88,6 +88,10 @@ pub enum StopReason {
/// Continuation circuit-breaker tripped (too many continuations without a
/// terminal signal).
ContinuationLimit,
/// The goal's token budget was reached while `[goal] enforce_token_budget`
/// opted the budget into a hard stop (#6013). Default-off: without the
/// opt-in the budget stays advisory telemetry and never produces this.
BudgetLimit,
}
/// Accumulated, durable progress for a goal run. Mirrors the fields wired by
@@ -107,6 +111,11 @@ pub struct GoalProgress {
pub struct GoalBudget {
pub token_budget: Option<u64>,
pub time_budget_seconds: Option<u64>,
/// Whether `token_budget` stops the run when reached (#6013). `false`
/// keeps the default advisory behavior: crossing the budget logs and
/// continues. There is no time-budget enforcement because goals carry no
/// `time_budget` field — only `token_budget` can gate.
pub enforce_token_budget: bool,
/// Safety backstop on automatic continuation passes (#5052). `0` disables
/// the backstop: only terminal status stops the run.
pub max_continuations: u32,
@@ -119,6 +128,7 @@ impl GoalBudget {
Self {
token_budget: None,
time_budget_seconds: None,
enforce_token_budget: false,
max_continuations: DEFAULT_MAX_GOAL_CONTINUATIONS,
}
}
@@ -129,10 +139,20 @@ impl GoalBudget {
Self {
token_budget: Some(token_budget),
time_budget_seconds: None,
enforce_token_budget: false,
max_continuations: DEFAULT_MAX_GOAL_CONTINUATIONS,
}
}
/// Opt the token budget into a hard stop (`[goal] enforce_token_budget`).
/// With no `token_budget` set this is a no-op by construction — there is
/// no ceiling to reach.
#[must_use]
pub const fn with_enforced_token_budget(mut self, enforce: bool) -> Self {
self.enforce_token_budget = enforce;
self
}
/// Override the continuation backstop (`0` = unlimited until terminal
/// status).
#[must_use]
@@ -173,14 +193,21 @@ pub fn decide_continuation(
GoalRunStatus::Active => {}
}
// 2. Token/time budgets are advisory only (unbounded). They are
// visible in the Goal chip + /cost but never pause the loop — like
// grokbuild's agent-call budget and kimicode swarm's per-task
// turnBudget with resume. Log if we are over budget, then continue.
// 2. Token budget: advisory telemetry by default, or a hard stop when
// `[goal] enforce_token_budget` opts in (#6013). Time stays advisory —
// goals carry no time budget to enforce against.
if budget
.token_budget
.is_some_and(|limit| progress.tokens_used >= limit)
{
if budget.enforce_token_budget {
tracing::info!(
tokens_used = progress.tokens_used,
token_budget = ?budget.token_budget,
"goal token budget reached; stopping ([goal] enforce_token_budget)"
);
return ContinuationDecision::Stop(StopReason::BudgetLimit);
}
tracing::debug!(
tokens_used = progress.tokens_used,
token_budget = ?budget.token_budget,
@@ -318,6 +345,7 @@ mod tests {
let budget = GoalBudget {
token_budget: Some(1000),
time_budget_seconds: Some(600),
enforce_token_budget: false,
max_continuations: DEFAULT_MAX_GOAL_CONTINUATIONS,
};
assert_eq!(
@@ -413,6 +441,48 @@ mod tests {
);
}
#[test]
fn enforced_token_budget_stops_the_run() {
let progress = GoalProgress {
tokens_used: 1000,
continuations: 1,
..GoalProgress::default()
};
let budget = GoalBudget::with_token_budget(1000).with_enforced_token_budget(true);
assert_eq!(
decide_continuation(GoalRunStatus::Active, progress, budget),
ContinuationDecision::Stop(StopReason::BudgetLimit),
"enforce_token_budget makes an exhausted token budget terminal"
);
assert!(!is_success(StopReason::BudgetLimit));
}
#[test]
fn enforce_flag_without_a_token_budget_cannot_stop() {
// A goal created without `token_budget` has no ceiling to reach — the
// flag must not invent one.
let progress = GoalProgress {
tokens_used: u64::MAX / 2,
continuations: 1,
..GoalProgress::default()
};
let budget = GoalBudget::unbounded().with_enforced_token_budget(true);
assert_eq!(
decide_continuation(GoalRunStatus::Active, progress, budget),
ContinuationDecision::Continue
);
}
#[test]
fn enforced_budget_still_yields_to_a_terminal_status() {
let budget = GoalBudget::with_token_budget(1000).with_enforced_token_budget(true);
assert_eq!(
decide_continuation(GoalRunStatus::Completed, GoalProgress::default(), budget),
ContinuationDecision::Stop(StopReason::Completed),
"a clean completion outranks the budget gate"
);
}
#[test]
fn token_budget_is_advisory_not_terminal() {
let progress = GoalProgress {
@@ -438,6 +508,7 @@ mod tests {
let budget = GoalBudget {
token_budget: None,
time_budget_seconds: Some(600),
enforce_token_budget: false,
max_continuations: DEFAULT_MAX_GOAL_CONTINUATIONS,
};
assert_eq!(
@@ -454,6 +525,7 @@ mod tests {
let budget = GoalBudget {
token_budget: Some(1_000_000),
time_budget_seconds: Some(86_400),
enforce_token_budget: false,
max_continuations: DEFAULT_MAX_GOAL_CONTINUATIONS,
};
assert_eq!(
+16
View File
@@ -5841,6 +5841,21 @@ impl RuntimeThreadManager {
);
goal.status = codewhale_protocol::ThreadGoalStatus::Paused;
goal.updated_at = chrono::Utc::now().timestamp();
} else if self.read_config().goal_enforce_token_budget()
&& goal
.token_budget
.is_some_and(|budget| goal.tokens_used >= budget)
{
// The engine stops its own continuation at an enforced token
// budget (#6013); the host mirrors that as a durable pause so
// the cross-turn re-arm does not resurrect the spend.
tracing::info!(
"goal for {thread_id} reached its enforced token budget ({}); pausing",
goal.tokens_used
);
goal.status = codewhale_protocol::ThreadGoalStatus::Paused;
goal.pause_reason = Some(codewhale_protocol::GoalPauseReason::BudgetLimit);
goal.updated_at = chrono::Utc::now().timestamp();
} else if !update_goal_available {
tracing::info!(
"goal for {thread_id} stays parked: the finished turn's catalog lacked update_goal"
@@ -10151,6 +10166,7 @@ impl RuntimeThreadManager {
goal_status,
goal_max_continuations: cfg.goal_max_continuations(),
goal_continuation_delay_seconds: cfg.goal_continuation_delay_seconds(),
goal_enforce_token_budget: cfg.goal_enforce_token_budget(),
reasoning_only_max_reprompts: cfg.reasoning_only_max_reprompts(),
reasoning_only_reprompt_message: Some(
cfg.reasoning_only_reprompt_message().to_string(),
+2
View File
@@ -9286,6 +9286,7 @@ async fn host_goal_loop_kickoff_arms_one_continuation_and_parks_at_engine_cap()
max_continuations: Some(2),
continuation_delay_seconds: None,
max_steps: None,
enforce_token_budget: None,
}),
..Config::default()
};
@@ -9708,6 +9709,7 @@ async fn model_created_goal_persists_through_adopted_revision() -> Result<()> {
max_continuations: None,
continuation_delay_seconds: Some(3600),
max_steps: None,
enforce_token_budget: None,
}),
..Config::default()
},
+3
View File
@@ -1673,6 +1673,9 @@ pub struct App {
pub workflow_config: codewhale_config::WorkflowConfigToml,
/// Effective `[goal] max_continuations` backstop; `0` means unlimited.
pub goal_max_continuations: u32,
/// Effective `[goal] enforce_token_budget`; `true` makes a goal's token
/// budget a hard stop instead of advisory telemetry (#6013).
pub goal_enforce_token_budget: bool,
/// Typed engine lifecycle state for the cancellable between-turn wait.
pub goal_continuation_waiting: bool,
/// Effective explicit/managed filesystem scope captured at startup. The
+1
View File
@@ -847,6 +847,7 @@ impl App {
workspace,
workflow_config: config.workflow_config(),
goal_max_continuations: config.goal_max_continuations(),
goal_enforce_token_budget: config.goal_enforce_token_budget(),
goal_continuation_waiting: false,
configured_sandbox_mode: config.sandbox_mode.clone(),
configured_sandbox_network: config.sandbox_network_access,
+1
View File
@@ -985,6 +985,7 @@ pub(crate) fn build_engine_config(app: &App, config: &Config) -> EngineConfig {
goal_status: app.goal.status,
goal_max_continuations: config.goal_max_continuations(),
goal_continuation_delay_seconds: config.goal_continuation_delay_seconds(),
goal_enforce_token_budget: config.goal_enforce_token_budget(),
reasoning_only_max_reprompts: config.reasoning_only_max_reprompts(),
reasoning_only_reprompt_message: Some(config.reasoning_only_reprompt_message().to_string()),
locale_tag: app.ui_locale.tag().to_string(),