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.
This commit is contained in:
Eric Traut
2026-05-18 11:28:53 -07:00
committed by GitHub
Unverified
parent d32cb2c6ac
commit 0d344aca9b
32 changed files with 837 additions and 35 deletions
+166 -1
View File
@@ -8629,6 +8629,57 @@ async fn budget_limited_accounting_steers_active_turn_without_aborting() -> anyh
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn usage_limit_runtime_stops_active_goal_and_prevents_idle_continuation() -> anyhow::Result<()>
{
let (sess, tc, _rx, _codex_home) = make_goal_session_and_context_with_rx().await;
sess.set_thread_goal(
tc.as_ref(),
SetGoalRequest {
objective: Some("Keep improving the benchmark".to_string()),
status: None,
token_budget: Some(Some(50)),
},
)
.await?;
sess.goal_runtime_apply(GoalRuntimeEvent::TurnStarted {
turn_context: tc.as_ref(),
token_usage: TokenUsage::default(),
})
.await?;
sess.spawn_task(
Arc::clone(&tc),
Vec::new(),
NeverEndingTask {
kind: TaskKind::Regular,
listen_to_cancellation_token: false,
},
)
.await;
set_total_token_usage(&sess, post_goal_token_usage()).await;
sess.goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached {
turn_context: tc.as_ref(),
})
.await?;
let state_db = goal_test_state_db(sess.as_ref()).await?;
let goal = state_db
.thread_goals()
.get_thread_goal(sess.conversation_id)
.await?
.expect("goal should remain persisted after usage limiting");
assert_eq!(codex_state::ThreadGoalStatus::UsageLimited, goal.status);
assert_eq!(70, goal.tokens_used);
sess.abort_all_tasks(TurnAbortReason::Replaced).await;
sess.goal_runtime_apply(GoalRuntimeEvent::MaybeContinueIfIdle)
.await?;
assert!(sess.active_turn.lock().await.is_none());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn external_goal_mutation_accounts_active_turn_before_status_change() -> anyhow::Result<()> {
let (sess, tc, _rx, _codex_home) = make_goal_session_and_context_with_rx().await;
@@ -9550,7 +9601,121 @@ async fn update_goal_tool_rejects_pausing_goal() {
};
assert_eq!(
output,
"update_goal can only mark the existing goal complete; pause, resume, and budget-limited status changes are controlled by the user or system"
"update_goal can only mark the existing goal complete or blocked; pause, resume, budget-limited, and usage-limited status changes are controlled by the user or system"
);
let goal = session
.get_thread_goal()
.await
.expect("read thread goal")
.expect("goal should still exist");
assert_eq!(goal.status, ThreadGoalStatus::Active);
}
#[tokio::test]
async fn update_goal_tool_marks_goal_blocked() {
let (session, turn_context, _rx, _codex_home) = make_goal_session_and_context_with_rx().await;
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
let create_handler = CreateGoalHandler;
let update_handler = UpdateGoalHandler;
create_handler
.handle(ToolInvocation {
session: Arc::clone(&session),
turn: Arc::clone(&turn_context),
cancellation_token: CancellationToken::new(),
tracker: Arc::clone(&tracker),
call_id: "create-goal".to_string(),
tool_name: codex_tools::ToolName::plain("create_goal"),
source: ToolCallSource::Direct,
payload: ToolPayload::Function {
arguments: serde_json::json!({
"objective": "Keep the watcher alive",
"token_budget": 123,
})
.to_string(),
},
})
.await
.expect("initial create_goal should succeed");
update_handler
.handle(ToolInvocation {
session: Arc::clone(&session),
turn: Arc::clone(&turn_context),
cancellation_token: CancellationToken::new(),
tracker,
call_id: "block-goal".to_string(),
tool_name: codex_tools::ToolName::plain("update_goal"),
source: ToolCallSource::Direct,
payload: ToolPayload::Function {
arguments: serde_json::json!({
"status": "blocked",
})
.to_string(),
},
})
.await
.expect("update_goal should mark the goal blocked");
let goal = session
.get_thread_goal()
.await
.expect("read thread goal")
.expect("goal should still exist");
assert_eq!(goal.status, ThreadGoalStatus::Blocked);
}
#[tokio::test]
async fn update_goal_tool_rejects_usage_limited_goal() {
let (session, turn_context, _rx, _codex_home) = make_goal_session_and_context_with_rx().await;
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
let create_handler = CreateGoalHandler;
let update_handler = UpdateGoalHandler;
create_handler
.handle(ToolInvocation {
session: Arc::clone(&session),
turn: Arc::clone(&turn_context),
cancellation_token: CancellationToken::new(),
tracker: Arc::clone(&tracker),
call_id: "create-goal".to_string(),
tool_name: codex_tools::ToolName::plain("create_goal"),
source: ToolCallSource::Direct,
payload: ToolPayload::Function {
arguments: serde_json::json!({
"objective": "Keep the watcher alive",
})
.to_string(),
},
})
.await
.expect("initial create_goal should succeed");
let response = update_handler
.handle(ToolInvocation {
session: Arc::clone(&session),
turn: Arc::clone(&turn_context),
cancellation_token: CancellationToken::new(),
tracker,
call_id: "usage-limit-goal".to_string(),
tool_name: codex_tools::ToolName::plain("update_goal"),
source: ToolCallSource::Direct,
payload: ToolPayload::Function {
arguments: serde_json::json!({
"status": "usageLimited",
})
.to_string(),
},
})
.await;
let Err(FunctionCallError::RespondToModel(output)) = response else {
panic!("expected update_goal to reject usage-limiting a goal");
};
assert_eq!(
output,
"update_goal can only mark the existing goal complete or blocked; pause, resume, budget-limited, and usage-limited status changes are controlled by the user or system"
);
let goal = session
+34 -2
View File
@@ -20,6 +20,7 @@ use crate::compact_remote_v2::run_inline_remote_auto_compact_task as run_inline_
use crate::connectors;
use crate::context::ContextualUserFragment;
use crate::feedback_tags;
use crate::goals::GoalRuntimeEvent;
use crate::hook_runtime::PendingInputHookDisposition;
use crate::hook_runtime::emit_hook_completed_events;
use crate::hook_runtime::inspect_pending_input;
@@ -162,7 +163,16 @@ pub(crate) async fn run_turn(
let pre_sampling_compact =
match run_pre_sampling_compact(&sess, &turn_context, &mut client_session).await {
Ok(pre_sampling_compact) => pre_sampling_compact,
Err(_) => {
Err(err) => {
if err.to_codex_protocol_error() == CodexErrorInfo::UsageLimitExceeded
&& let Err(err) = sess
.goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached {
turn_context: turn_context.as_ref(),
})
.await
{
warn!("failed to usage-limit active goal after usage-limit error: {err}");
}
error!("Failed to run pre-sampling compact");
return None;
}
@@ -517,7 +527,20 @@ pub(crate) async fn run_turn(
.await
{
Ok(reset_client_session) => reset_client_session,
Err(_) => return None,
Err(err) => {
if err.to_codex_protocol_error() == CodexErrorInfo::UsageLimitExceeded
&& let Err(err) = sess
.goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached {
turn_context: turn_context.as_ref(),
})
.await
{
warn!(
"failed to usage-limit active goal after usage-limit error: {err}"
);
}
return None;
}
};
if reset_client_session {
client_session.reset_websocket_session();
@@ -673,6 +696,15 @@ pub(crate) async fn run_turn(
}
Err(e) => {
info!("Turn error: {e:#}");
if e.to_codex_protocol_error() == CodexErrorInfo::UsageLimitExceeded
&& let Err(err) = sess
.goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached {
turn_context: turn_context.as_ref(),
})
.await
{
warn!("failed to usage-limit active goal after usage-limit error: {err}");
}
let event = EventMsg::Error(e.to_error_event(/*message_prefix*/ None));
sess.send_event(&turn_context, event).await;
// let the user continue the conversation