mirror of
https://github.com/openai/codex.git
synced 2026-10-28 18:58:46 +00:00
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.
229 lines
7.0 KiB
Rust
229 lines
7.0 KiB
Rust
//! Helpers for mapping thread-goal state into the compact status-line indicator.
|
|
|
|
use codex_app_server_protocol::ThreadGoal as AppThreadGoal;
|
|
use codex_app_server_protocol::ThreadGoalStatus as AppThreadGoalStatus;
|
|
use std::time::Instant;
|
|
|
|
use crate::bottom_pane::GoalStatusIndicator;
|
|
use crate::goal_display::format_goal_elapsed_seconds;
|
|
use crate::status::format_tokens_compact;
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub(super) struct GoalStatusState {
|
|
goal: AppThreadGoal,
|
|
observed_at: Instant,
|
|
}
|
|
|
|
impl GoalStatusState {
|
|
pub(super) fn new(goal: AppThreadGoal, observed_at: Instant) -> Self {
|
|
Self { goal, observed_at }
|
|
}
|
|
|
|
pub(super) fn is_active(&self) -> bool {
|
|
self.goal.status == AppThreadGoalStatus::Active
|
|
}
|
|
|
|
pub(super) fn indicator(
|
|
&self,
|
|
now: Instant,
|
|
active_turn_started_at: Option<Instant>,
|
|
) -> Option<GoalStatusIndicator> {
|
|
let mut goal = self.goal.clone();
|
|
if goal.status == AppThreadGoalStatus::Active
|
|
&& let Some(active_turn_started_at) = active_turn_started_at
|
|
{
|
|
let baseline = self.observed_at.max(active_turn_started_at);
|
|
let active_seconds = now.saturating_duration_since(baseline).as_secs();
|
|
goal.time_used_seconds = goal
|
|
.time_used_seconds
|
|
.saturating_add(i64::try_from(active_seconds).unwrap_or(i64::MAX));
|
|
}
|
|
goal_status_indicator_from_app_goal(&goal)
|
|
}
|
|
}
|
|
|
|
pub(super) fn goal_status_indicator_from_app_goal(
|
|
goal: &AppThreadGoal,
|
|
) -> Option<GoalStatusIndicator> {
|
|
match goal.status {
|
|
AppThreadGoalStatus::Active => Some(GoalStatusIndicator::Active {
|
|
usage: active_goal_usage(goal.token_budget, goal.tokens_used, goal.time_used_seconds),
|
|
}),
|
|
AppThreadGoalStatus::Paused => Some(GoalStatusIndicator::Paused),
|
|
AppThreadGoalStatus::Blocked => Some(GoalStatusIndicator::Blocked),
|
|
AppThreadGoalStatus::UsageLimited => Some(GoalStatusIndicator::UsageLimited),
|
|
AppThreadGoalStatus::BudgetLimited => Some(GoalStatusIndicator::BudgetLimited {
|
|
usage: stopped_goal_budget_usage(goal.token_budget, goal.tokens_used),
|
|
}),
|
|
AppThreadGoalStatus::Complete => Some(GoalStatusIndicator::Complete {
|
|
usage: Some(completed_goal_usage(
|
|
goal.token_budget,
|
|
goal.tokens_used,
|
|
goal.time_used_seconds,
|
|
)),
|
|
}),
|
|
}
|
|
}
|
|
|
|
fn active_goal_usage(
|
|
token_budget: Option<i64>,
|
|
tokens_used: i64,
|
|
time_used_seconds: i64,
|
|
) -> Option<String> {
|
|
if let Some(token_budget) = token_budget {
|
|
return Some(format!(
|
|
"{} / {}",
|
|
format_tokens_compact(tokens_used),
|
|
format_tokens_compact(token_budget)
|
|
));
|
|
}
|
|
|
|
Some(format_goal_elapsed_seconds(time_used_seconds))
|
|
}
|
|
|
|
fn stopped_goal_budget_usage(token_budget: Option<i64>, tokens_used: i64) -> Option<String> {
|
|
token_budget.map(|token_budget| {
|
|
format!(
|
|
"{} / {} tokens",
|
|
format_tokens_compact(tokens_used),
|
|
format_tokens_compact(token_budget)
|
|
)
|
|
})
|
|
}
|
|
|
|
fn completed_goal_usage(
|
|
token_budget: Option<i64>,
|
|
tokens_used: i64,
|
|
time_used_seconds: i64,
|
|
) -> String {
|
|
if token_budget.is_some() {
|
|
return format!("{} tokens", format_tokens_compact(tokens_used));
|
|
}
|
|
|
|
format_goal_elapsed_seconds(time_used_seconds)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::GoalStatusState;
|
|
use super::active_goal_usage;
|
|
use super::completed_goal_usage;
|
|
use super::stopped_goal_budget_usage;
|
|
use crate::bottom_pane::GoalStatusIndicator;
|
|
use codex_app_server_protocol::ThreadGoal as AppThreadGoal;
|
|
use codex_app_server_protocol::ThreadGoalStatus as AppThreadGoalStatus;
|
|
use std::time::Duration;
|
|
use std::time::Instant;
|
|
|
|
#[test]
|
|
fn active_goal_usage_prefers_token_budget() {
|
|
assert_eq!(
|
|
active_goal_usage(
|
|
Some(50_000),
|
|
/*tokens_used*/ 12_500,
|
|
/*time_used_seconds*/ 90
|
|
),
|
|
Some("12.5K / 50K".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn active_goal_usage_reports_time_without_budget() {
|
|
assert_eq!(
|
|
active_goal_usage(
|
|
/*token_budget*/ None, /*tokens_used*/ 12_500,
|
|
/*time_used_seconds*/ 120,
|
|
),
|
|
Some("2m".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn stopped_goal_budget_usage_reports_budgeted_tokens() {
|
|
assert_eq!(
|
|
stopped_goal_budget_usage(Some(50_000), /*tokens_used*/ 63_876),
|
|
Some("63.9K / 50K tokens".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn stopped_goal_budget_usage_omits_unbudgeted_usage() {
|
|
assert_eq!(
|
|
stopped_goal_budget_usage(/*token_budget*/ None, /*tokens_used*/ 12_500),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn completed_goal_usage_reports_tokens_when_budgeted() {
|
|
assert_eq!(
|
|
completed_goal_usage(
|
|
Some(50_000),
|
|
/*tokens_used*/ 40_000,
|
|
/*time_used_seconds*/ 120,
|
|
),
|
|
"40K tokens".to_string()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn completed_goal_usage_reports_time_without_token_budget() {
|
|
assert_eq!(
|
|
completed_goal_usage(
|
|
/*token_budget*/ None, /*tokens_used*/ 40_000,
|
|
/*time_used_seconds*/ 36_720,
|
|
),
|
|
"10h 12m".to_string()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn active_goal_status_includes_current_turn_elapsed_time() {
|
|
let observed_at = Instant::now();
|
|
let state = active_goal_state(observed_at, /*time_used_seconds*/ 60);
|
|
|
|
assert_eq!(
|
|
state.indicator(
|
|
observed_at + Duration::from_secs(60),
|
|
Some(observed_at - Duration::from_secs(120)),
|
|
),
|
|
Some(GoalStatusIndicator::Active {
|
|
usage: Some("2m".to_string())
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn active_goal_status_does_not_count_idle_time_before_turn_start() {
|
|
let observed_at = Instant::now();
|
|
let active_turn_started_at = observed_at + Duration::from_secs(120);
|
|
let state = active_goal_state(observed_at, /*time_used_seconds*/ 60);
|
|
|
|
assert_eq!(
|
|
state.indicator(
|
|
active_turn_started_at + Duration::from_secs(60),
|
|
Some(active_turn_started_at),
|
|
),
|
|
Some(GoalStatusIndicator::Active {
|
|
usage: Some("2m".to_string())
|
|
})
|
|
);
|
|
}
|
|
|
|
fn active_goal_state(observed_at: Instant, time_used_seconds: i64) -> GoalStatusState {
|
|
GoalStatusState::new(
|
|
AppThreadGoal {
|
|
thread_id: "thread".to_string(),
|
|
objective: "do the thing".to_string(),
|
|
status: AppThreadGoalStatus::Active,
|
|
token_budget: None,
|
|
tokens_used: 0,
|
|
time_used_seconds,
|
|
created_at: 1,
|
|
updated_at: 1,
|
|
},
|
|
observed_at,
|
|
)
|
|
}
|
|
}
|