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
+3 -3
View File
@@ -1,8 +1,8 @@
//! Built-in model tool handlers for persisted thread goals.
//!
//! The public tool contract intentionally splits goal creation from completion:
//! `create_goal` starts an active objective, while `update_goal` can only mark
//! the existing goal complete.
//! The public tool contract intentionally splits goal creation from stopped
//! status updates: `create_goal` starts an active objective, while
//! `update_goal` can only mark the existing goal complete or blocked.
use crate::function_tool::FunctionCallError;
use crate::tools::context::FunctionToolOutput;
@@ -51,9 +51,12 @@ impl ToolExecutor<ToolInvocation> for UpdateGoalHandler {
};
let args: UpdateGoalArgs = parse_arguments(&arguments)?;
if args.status != ThreadGoalStatus::Complete {
if !matches!(
args.status,
ThreadGoalStatus::Complete | ThreadGoalStatus::Blocked
) {
return Err(FunctionCallError::RespondToModel(
"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"
.to_string(),
));
}
@@ -68,13 +71,18 @@ impl ToolExecutor<ToolInvocation> for UpdateGoalHandler {
turn.as_ref(),
SetGoalRequest {
objective: None,
status: Some(ThreadGoalStatus::Complete),
status: Some(args.status),
token_budget: None,
},
)
.await
.map_err(|err| FunctionCallError::RespondToModel(format_goal_error(err)))?;
goal_response(Some(goal), CompletionBudgetReport::Include).map(boxed_tool_output)
let completion_budget_report = if args.status == ThreadGoalStatus::Complete {
CompletionBudgetReport::Include
} else {
CompletionBudgetReport::Omit
};
goal_response(Some(goal), completion_budget_report).map(boxed_tool_output)
}
}
+13 -6
View File
@@ -63,9 +63,9 @@ pub fn create_update_goal_tool() -> ToolSpec {
let properties = BTreeMap::from([(
"status".to_string(),
JsonSchema::string_enum(
vec![json!("complete")],
vec![json!("complete"), json!("blocked")],
Some(
"Required. Set to complete only when the objective is achieved and no required work remains."
"Required. Set to `complete` only when the objective is achieved and no required work remains. Set to `blocked` only after the same blocking condition has recurred for at least three consecutive goal turns and the agent is at an impasse. After a previously blocked goal is resumed, the resumed run starts a fresh blocked audit."
.to_string(),
),
),
@@ -74,10 +74,14 @@ pub fn create_update_goal_tool() -> ToolSpec {
ToolSpec::Function(ResponsesApiTool {
name: UPDATE_GOAL_TOOL_NAME.to_string(),
description: r#"Update the existing goal.
Use this tool only to mark the goal achieved.
Use this tool only to mark the goal achieved or genuinely blocked.
Set status to `complete` only when the objective has actually been achieved and no required work remains.
Set status to `blocked` only when the same blocking condition has repeated for at least three consecutive goal turns, counting the original/user-triggered turn and any automatic continuations, and the agent cannot make meaningful progress without user input or an external-state change.
If the user resumes a goal that was previously marked `blocked`, treat the resumed run as a fresh blocked audit. If the same blocking condition then repeats for at least three consecutive resumed goal turns, set status to `blocked` again.
Once the blocked threshold is satisfied, do not keep reporting that you are still blocked while leaving the goal active; set status to `blocked`.
Do not use `blocked` merely because the work is hard, slow, uncertain, incomplete, or would benefit from clarification.
Do not mark a goal complete merely because its budget is nearly exhausted or because you are stopping work.
You cannot use this tool to pause, resume, or budget-limit a goal; those status changes are controlled by the user or system.
You cannot use this tool to pause, resume, budget-limit, or usage-limit a goal; those status changes are controlled by the user or system.
When marking a budgeted goal achieved with status `complete`, report the final token usage from the tool result to the user."#
.to_string(),
strict: false,
@@ -96,7 +100,7 @@ mod tests {
use super::*;
#[test]
fn update_goal_tool_only_exposes_complete_status() {
fn update_goal_tool_exposes_complete_and_blocked_statuses() {
let ToolSpec::Function(tool) = create_update_goal_tool() else {
panic!("update_goal should be a function tool");
};
@@ -107,6 +111,9 @@ mod tests {
.and_then(|properties| properties.get("status"))
.expect("status property should exist");
assert_eq!(status.enum_values, Some(vec![json!("complete")]));
assert_eq!(
status.enum_values,
Some(vec![json!("complete"), json!("blocked")])
);
}
}