Files
codex/codex-rs/tui/src/goal_display.rs
T
Eric Traut 0d344aca9b goal: pause continuation loops on usage limits and blockers (#23094)
Addresses #22833, #22245, #23067

## Why
`/goal` can keep synthesizing turns even when the next turn cannot make
meaningful progress. Hard usage exhaustion can replay failing turns, and
repeated permission or external-resource blockers can keep burning
tokens while waiting for user or system intervention.

## What changed
- Add resumable `blocked` and `usageLimited` goal states. As with
`paused`, goal continuation stops with these states.
- Move to `usageLimited` after usage-limit failures.
- Allow the built-in `update_goal` tool to set `blocked` only under
explicit repeated-impasse guidance. Updated goal continuation prompt to
specify that agent should use `blocked` only when it has made at least
three attempts to get past an impasse.

Most of the files touched by this PR are because of the small app server
protocol update.

## Validation

I manually reproduced a number of situations where an agent can run into
a true impasse and verified that it properly enters `blocked` state. I
then resumed and verified that it once again entered `blocked` state
several turns later if the impasse still exists.

I also manually reproduced the usage-limit condition by creating a
simulated responses API endpoint that returns 429 errors with the
appropriate error message. Verified that the goal runtime properly moves
the goal into `usageLimited` state and TUI UI updates appropriately.
Verified that `/goal resume` resumes (and immediately goes back into
`ussageLImited` state if appropriate).


## Follow-up PRs

Small changes will be needed to the GUI clients to properly handle the
two new states.
2026-05-18 11:28:53 -07:00

110 lines
3.6 KiB
Rust

use crate::status::format_tokens_compact;
use codex_app_server_protocol::ThreadGoal;
use codex_app_server_protocol::ThreadGoalStatus;
pub(crate) fn format_goal_elapsed_seconds(seconds: i64) -> String {
let seconds = seconds.max(0) as u64;
if seconds < 60 {
return format!("{seconds}s");
}
let minutes = seconds / 60;
if minutes < 60 {
return format!("{minutes}m");
}
let hours = minutes / 60;
let remaining_minutes = minutes % 60;
if hours >= 24 {
let days = hours / 24;
let remaining_hours = hours % 24;
return format!("{days}d {remaining_hours}h {remaining_minutes}m");
}
if remaining_minutes == 0 {
format!("{hours}h")
} else {
format!("{hours}h {remaining_minutes}m")
}
}
pub(crate) fn goal_status_label(status: ThreadGoalStatus) -> &'static str {
match status {
ThreadGoalStatus::Active => "active",
ThreadGoalStatus::Paused => "paused",
ThreadGoalStatus::Blocked => "blocked",
ThreadGoalStatus::UsageLimited => "usage limited",
ThreadGoalStatus::BudgetLimited => "limited by budget",
ThreadGoalStatus::Complete => "complete",
}
}
pub(crate) fn goal_usage_summary(goal: &ThreadGoal) -> String {
let mut parts = vec![format!("Objective: {}", goal.objective)];
if goal.time_used_seconds > 0 {
parts.push(format!(
"Time: {}.",
format_goal_elapsed_seconds(goal.time_used_seconds)
));
}
if let Some(token_budget) = goal.token_budget {
parts.push(format!(
"Tokens: {}/{}.",
format_tokens_compact(goal.tokens_used),
format_tokens_compact(token_budget)
));
}
parts.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
use codex_app_server_protocol::ThreadGoal;
use codex_app_server_protocol::ThreadGoalStatus;
use pretty_assertions::assert_eq;
#[test]
fn format_goal_elapsed_seconds_is_compact() {
assert_eq!(format_goal_elapsed_seconds(/*seconds*/ 0), "0s");
assert_eq!(format_goal_elapsed_seconds(/*seconds*/ 59), "59s");
assert_eq!(format_goal_elapsed_seconds(/*seconds*/ 60), "1m");
assert_eq!(format_goal_elapsed_seconds(30 * 60), "30m");
assert_eq!(format_goal_elapsed_seconds(90 * 60), "1h 30m");
assert_eq!(format_goal_elapsed_seconds(2 * 60 * 60), "2h");
let just_before_one_day = 24 * 60 * 60 - 1;
assert_eq!(format_goal_elapsed_seconds(just_before_one_day), "23h 59m");
let one_day = 24 * 60 * 60;
assert_eq!(format_goal_elapsed_seconds(one_day), "1d 0h 0m");
let almost_three_days = 2 * 24 * 60 * 60 + 23 * 60 * 60 + 42 * 60;
assert_eq!(format_goal_elapsed_seconds(almost_three_days), "2d 23h 42m");
}
fn test_thread_goal(token_budget: Option<i64>, tokens_used: i64) -> ThreadGoal {
ThreadGoal {
thread_id: "thread-1".to_string(),
objective: "Complete the task described in ../gameboy-long-running-prompt5.txt"
.to_string(),
status: ThreadGoalStatus::BudgetLimited,
token_budget,
tokens_used,
time_used_seconds: 120,
created_at: 0,
updated_at: 0,
}
}
#[test]
fn goal_usage_summary_formats_time_and_budgeted_tokens() {
assert_eq!(
goal_usage_summary(&test_thread_goal(
/*token_budget*/ Some(50_000),
/*tokens_used*/ 63_876,
)),
"Objective: Complete the task described in ../gameboy-long-running-prompt5.txt Time: 2m. Tokens: 63.9K/50K."
);
}
}