mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 17:10:50 -10:00
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:
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"))
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
))
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user