Allow user-requested goal pauses through update_goal (#44290)

## Why

`update_goal` only accepted `complete` and `blocked`, preventing the agent from pausing a goal in response to an explicit user request.

## What changed

- Accept `paused` and account for final goal progress when pausing, with budget limits taking precedence.
- Update tool instructions and goal prompts to allow pauses only at the user's explicit request, report the returned status, and stop goal work. A later resume revokes the pause request.

## Testing

Extend coverage for pause accounting, budget-limit precedence, rejection of resume and system-limit statuses, and preservation of a tool-paused goal when resuming a thread.

GitOrigin-RevId: c3c15a51f848ce6eb65854d51b1a1bc75bb456a7
This commit is contained in:
Eric Traut authored and copyberry committed 2026-09-09 20:03:38 +00:00
1 parent d117c2eb02
commit fa7af3883d
7 files changed
+187 -102

No files matched your search

@@ -11,6 +11,7 @@ use app_test_support::create_fake_rollout_with_text_elements;
use app_test_support::create_fake_rollout_with_token_usage; use app_test_support::create_fake_rollout_with_token_usage;
use app_test_support::create_final_assistant_message_sse_response; use app_test_support::create_final_assistant_message_sse_response;
use app_test_support::create_mock_responses_server_repeating_assistant; use app_test_support::create_mock_responses_server_repeating_assistant;
use app_test_support::create_mock_responses_server_sequence;
use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_mock_responses_server_sequence_unchecked;
use app_test_support::rollout_path; use app_test_support::rollout_path;
use app_test_support::test_absolute_path; use app_test_support::test_absolute_path;
@@ -2894,8 +2895,25 @@ async fn thread_resume_rejects_archived_session_by_id() -> Result<()> {
} }
#[tokio::test] #[tokio::test]
async fn thread_resume_keeps_paused_goal_paused() -> Result<()> { async fn thread_resume_keeps_tool_paused_goal_paused() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await; let server = create_mock_responses_server_sequence(vec![
responses::sse(vec![
responses::ev_response_created("create-goal"),
responses::ev_function_call(
"create-goal-call",
"create_goal",
r#"{"objective":"keep polishing"}"#,
),
responses::ev_completed("create-goal"),
]),
responses::sse(vec![
responses::ev_response_created("pause-goal"),
responses::ev_function_call("pause-goal-call", "update_goal", r#"{"status":"paused"}"#),
responses::ev_completed("pause-goal"),
]),
create_final_assistant_message_sse_response("The goal is paused.")?,
])
.await;
let codex_home = TempDir::new()?; let codex_home = TempDir::new()?;
mock_responses_config(&server.uri()).write(codex_home.path())?; mock_responses_config(&server.uri()).write(codex_home.path())?;
let config_path = codex_home.path().join("config.toml"); let config_path = codex_home.path().join("config.toml");
@@ -2925,7 +2943,7 @@ async fn thread_resume_keeps_paused_goal_paused() -> Result<()> {
thread_id: thread.id.clone(), thread_id: thread.id.clone(),
client_user_message_id: None, client_user_message_id: None,
input: vec![UserInput::Text { input: vec![UserInput::Text {
text: "materialize this thread".to_string(), text: "Create a goal to keep polishing, then pause it.".to_string(),
text_elements: Vec::new(), text_elements: Vec::new(),
}], }],
..Default::default() ..Default::default()
@@ -2943,22 +2961,14 @@ async fn thread_resume_keeps_paused_goal_paused() -> Result<()> {
.await??; .await??;
let goal_id = mcp let goal_id = mcp
.send_raw_request( .send_raw_request("thread/goal/get", Some(json!({ "threadId": thread.id })))
"thread/goal/set",
Some(json!({
"threadId": thread.id,
"objective": "keep polishing",
"status": "paused",
})),
)
.await?; .await?;
let _goal: ThreadGoalSetResponse = let goal: ThreadGoalGetResponse =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(goal_id)).await??; timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(goal_id)).await??;
timeout( assert_eq!(
DEFAULT_READ_TIMEOUT, goal.goal.expect("goal should exist").status,
mcp.read_stream_until_notification_message("thread/goal/updated"), ThreadGoalStatus::Paused
) );
.await??;
mcp.clear_message_buffer(); mcp.clear_message_buffer();
let resume_id = mcp let resume_id = mcp
+4 -4
View File
@@ -61,9 +61,9 @@ pub fn create_update_goal_tool() -> ToolSpec {
let properties = BTreeMap::from([( let properties = BTreeMap::from([(
"status".to_string(), "status".to_string(),
JsonSchema::string_enum( JsonSchema::string_enum(
vec![json!("complete"), json!("blocked")], vec![json!("complete"), json!("blocked"), json!("paused")],
Some( Some(
"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." "Required. `paused` requires an explicit user request. 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(), .to_string(),
), ),
), ),
@@ -72,14 +72,14 @@ pub fn create_update_goal_tool() -> ToolSpec {
ToolSpec::Function(ResponsesApiTool { ToolSpec::Function(ResponsesApiTool {
name: UPDATE_GOAL_TOOL_NAME.to_string(), name: UPDATE_GOAL_TOOL_NAME.to_string(),
description: r#"Update the existing goal. description: r#"Update the existing goal.
Use this tool only to mark the goal achieved or genuinely blocked. Set status to `paused` only at the user's explicit request to pause this goal, never on your own initiative. Ask if unclear; a later resume revokes that request. Report the returned status and stop goal work. Budget limits take precedence over pausing.
Set status to `complete` only when the objective has actually been achieved and no required work remains. 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. 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. 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`. 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 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. 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, budget-limit, or usage-limit a goal; those status changes are controlled by the user or system. You cannot use this tool to 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."# When marking a budgeted goal achieved with status `complete`, report the final token usage from the tool result to the user."#
.to_string(), .to_string(),
strict: false, strict: false,
+5 -4
View File
@@ -238,10 +238,10 @@ impl GoalToolExecutor {
let args: UpdateGoalArgs = parse_arguments(invocation.function_arguments()?)?; let args: UpdateGoalArgs = parse_arguments(invocation.function_arguments()?)?;
if !matches!( if !matches!(
args.status, args.status,
ThreadGoalStatus::Complete | ThreadGoalStatus::Blocked ThreadGoalStatus::Complete | ThreadGoalStatus::Blocked | ThreadGoalStatus::Paused
) { ) {
return Err(FunctionCallError::RespondToModel( return Err(FunctionCallError::RespondToModel(
"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" "update_goal can only mark the existing goal complete, blocked, or paused at the user's explicit request; resume, budget-limited, and usage-limited status changes are controlled by the user or system"
.to_string(), .to_string(),
)); ));
} }
@@ -249,9 +249,10 @@ impl GoalToolExecutor {
self.account_active_goal_progress( self.account_active_goal_progress(
match args.status { match args.status {
ThreadGoalStatus::Complete => codex_state::GoalAccountingMode::ActiveOrComplete, ThreadGoalStatus::Complete => codex_state::GoalAccountingMode::ActiveOrComplete,
ThreadGoalStatus::Blocked => codex_state::GoalAccountingMode::ActiveOrStopped, ThreadGoalStatus::Blocked | ThreadGoalStatus::Paused => {
codex_state::GoalAccountingMode::ActiveOrStopped
}
ThreadGoalStatus::Active ThreadGoalStatus::Active
| ThreadGoalStatus::Paused
| ThreadGoalStatus::UsageLimited | ThreadGoalStatus::UsageLimited
| ThreadGoalStatus::BudgetLimited => unreachable!("status validated above"), | ThreadGoalStatus::BudgetLimited => unreachable!("status validated above"),
}, },
@@ -13,4 +13,4 @@ Budget:
The system has marked the goal as budget_limited, so do not start new substantive work for this goal. Wrap up this turn soon: summarize useful progress, identify remaining work or blockers, and leave the user with a clear next step. The system has marked the goal as budget_limited, so do not start new substantive work for this goal. Wrap up this turn soon: summarize useful progress, identify remaining work or blockers, and leave the user with a clear next step.
Do not call update_goal unless the goal is actually complete. Do not call update_goal unless the goal is actually complete or the user explicitly requests a pause; budget_limited takes precedence over paused.
@@ -53,4 +53,4 @@ Blocked audit:
- Once the blocked threshold is satisfied, do not keep reporting that you are still blocked while leaving the goal active; call update_goal with status "blocked". - Once the blocked threshold is satisfied, do not keep reporting that you are still blocked while leaving the goal active; call update_goal with status "blocked".
- Never use status "blocked" merely because the work is hard, slow, uncertain, incomplete, or would benefit from clarification. - Never use status "blocked" merely because the work is hard, slow, uncertain, incomplete, or would benefit from clarification.
Do not call update_goal unless the goal is complete or the strict blocked audit above is satisfied. Do not mark a goal complete merely because the budget is nearly exhausted or because you are stopping work. Call update_goal only after the completion or blocked audit passes, or when the user explicitly requests pausing this goal. For a requested pause, use status "paused", report the returned status, and stop goal work; never pause on your own initiative. Do not mark a goal complete merely because the budget is nearly exhausted or because you are stopping work.
@@ -13,4 +13,4 @@ Budget:
Adjust the current turn to pursue the updated objective. Avoid continuing work that only served the previous objective unless it also helps the updated objective. Adjust the current turn to pursue the updated objective. Avoid continuing work that only served the previous objective unless it also helps the updated objective.
Do not call update_goal unless the updated goal is actually complete. Do not call update_goal unless the updated goal is actually complete or the user explicitly requests a pause.
+148 -74
View File
@@ -1050,84 +1050,158 @@ async fn usage_limit_stale_turn_does_not_stop_current_goal() -> anyhow::Result<(
} }
#[tokio::test] #[tokio::test]
async fn update_goal_can_block_and_accounts_final_progress() -> anyhow::Result<()> { async fn update_goal_can_stop_and_accounts_final_progress() -> anyhow::Result<()> {
for (status, token_budget, expected_status, state_status) in [
(
ThreadGoalStatus::Blocked,
100_i64,
ThreadGoalStatus::Blocked,
codex_state::ThreadGoalStatus::Blocked,
),
(
ThreadGoalStatus::Paused,
100,
ThreadGoalStatus::Paused,
codex_state::ThreadGoalStatus::Paused,
),
(
ThreadGoalStatus::Paused,
20,
ThreadGoalStatus::BudgetLimited,
codex_state::ThreadGoalStatus::BudgetLimited,
),
] {
let runtime = test_runtime().await?;
let thread_id = test_thread_id()?;
seed_thread_metadata(runtime.as_ref(), thread_id).await?;
let harness = GoalExtensionHarness::new(runtime.clone(), thread_id).await?;
harness.start_turn("turn-1", &TokenUsage::default()).await;
let tools = harness.tools();
let create_tool = tool_by_name(&tools, "create_goal");
create_tool
.handle(tool_call(
"create_goal",
"call-create-goal",
json!({ "objective": "ship goal extension backend", "token_budget": token_budget }),
))
.await?;
harness.sink.clear();
harness
.record_token_usage(
"turn-1",
&token_usage(
/*input_tokens*/ 20, /*cached_input_tokens*/ 5,
/*output_tokens*/ 8, /*reasoning_output_tokens*/ 2,
/*total_tokens*/ 30,
),
)
.await;
let update_tool = tool_by_name(&tools, "update_goal");
let invocation = tool_call(
"update_goal",
"call-update-goal",
json!({ "status": status }),
);
let output = update_tool.handle(invocation.clone()).await?;
let result = output.code_mode_result(&invocation.payload);
assert_eq!(
result,
json!({
"goal": {
"threadId": thread_id,
"objective": "ship goal extension backend",
"status": expected_status,
"tokenBudget": token_budget,
"tokensUsed": 23,
"timeUsedSeconds": 0,
"createdAt": result["goal"]["createdAt"],
"updatedAt": result["goal"]["updatedAt"],
},
"remainingTokens": (token_budget - 23).max(0),
"completionBudgetReport": serde_json::Value::Null,
})
);
let goal = runtime
.thread_goals()
.get_thread_goal(thread_id)
.await?
.ok_or_else(|| anyhow::anyhow!("goal should exist"))?;
assert_eq!(23, goal.tokens_used);
assert_eq!(state_status, goal.status);
assert_eq!(
vec![
CapturedGoalEvent {
event_id: "call-update-goal".to_string(),
turn_id: Some("turn-1".to_string()),
status: if token_budget > 23 {
ThreadGoalStatus::Active
} else {
ThreadGoalStatus::BudgetLimited
},
tokens_used: 23,
},
CapturedGoalEvent {
event_id: "call-update-goal".to_string(),
turn_id: Some("turn-1".to_string()),
status: expected_status,
tokens_used: 23,
},
],
harness.sink.goal_events()
);
harness
.record_token_usage(
"turn-1",
&token_usage(
/*input_tokens*/ 40, /*cached_input_tokens*/ 5,
/*output_tokens*/ 10, /*reasoning_output_tokens*/ 2,
/*total_tokens*/ 52,
),
)
.await;
harness
.notify_tool_finish("turn-1", "call-shell", "shell")
.await;
harness.stop_turn("turn-1").await;
assert_eq!(
Some(goal),
runtime.thread_goals().get_thread_goal(thread_id).await?
);
}
Ok(())
}
#[tokio::test]
async fn update_goal_rejects_resume_and_system_limit_statuses() -> anyhow::Result<()> {
let runtime = test_runtime().await?; let runtime = test_runtime().await?;
let thread_id = test_thread_id()?; let thread_id = test_thread_id()?;
seed_thread_metadata(runtime.as_ref(), thread_id).await?; seed_thread_metadata(runtime.as_ref(), thread_id).await?;
let harness = GoalExtensionHarness::new(runtime.clone(), thread_id).await?; let tools = installed_tools(runtime, thread_id).await;
harness.start_turn("turn-1", &TokenUsage::default()).await;
let tools = harness.tools();
let create_tool = tool_by_name(&tools, "create_goal");
create_tool
.handle(tool_call(
"create_goal",
"call-create-goal",
json!({ "objective": "ship goal extension backend" }),
))
.await?;
harness.sink.clear();
harness
.record_token_usage(
"turn-1",
&token_usage(
/*input_tokens*/ 20, /*cached_input_tokens*/ 5, /*output_tokens*/ 8,
/*reasoning_output_tokens*/ 2, /*total_tokens*/ 30,
),
)
.await;
let update_tool = tool_by_name(&tools, "update_goal"); let update_tool = tool_by_name(&tools, "update_goal");
let invocation = tool_call( for status in ["active", "budgetLimited", "usageLimited"] {
"update_goal", let result = update_tool
"call-update-goal", .handle(tool_call(
json!({ "status": "blocked" }), "update_goal",
); "call-update-goal",
let output = update_tool.handle(invocation.clone()).await?; json!({ "status": status }),
let result = output.code_mode_result(&invocation.payload); ))
.await;
assert_eq!( let error = match result {
result, Ok(_) => panic!("agent must not set {status}"),
json!({ Err(error) => error,
"goal": { };
"threadId": thread_id, assert_eq!(
"objective": "ship goal extension backend", error,
"status": "blocked", FunctionCallError::RespondToModel(
"tokensUsed": 23, "update_goal can only mark the existing goal complete, blocked, or paused at the user's explicit request; resume, budget-limited, and usage-limited status changes are controlled by the user or system".to_string()
"timeUsedSeconds": 0, )
"createdAt": result["goal"]["createdAt"], );
"updatedAt": result["goal"]["updatedAt"], }
},
"remainingTokens": serde_json::Value::Null,
"completionBudgetReport": serde_json::Value::Null,
})
);
let goal = runtime
.thread_goals()
.get_thread_goal(thread_id)
.await?
.ok_or_else(|| anyhow::anyhow!("goal should exist"))?;
assert_eq!(23, goal.tokens_used);
assert_eq!(codex_state::ThreadGoalStatus::Blocked, goal.status);
assert_eq!(
vec![
CapturedGoalEvent {
event_id: "call-update-goal".to_string(),
turn_id: Some("turn-1".to_string()),
status: ThreadGoalStatus::Active,
tokens_used: 23,
},
CapturedGoalEvent {
event_id: "call-update-goal".to_string(),
turn_id: Some("turn-1".to_string()),
status: ThreadGoalStatus::Blocked,
tokens_used: 23,
},
],
harness.sink.goal_events()
);
Ok(()) Ok(())
} }