From fa7af3883df4d14861f825f9a6aadbe1ffebe63d Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 9 Sep 2026 19:49:28 +0000 Subject: [PATCH] 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 --- .../tests/suite/v2/thread_resume.rs | 44 ++-- codex-rs/ext/goal/src/spec.rs | 8 +- codex-rs/ext/goal/src/tool.rs | 9 +- .../ext/goal/templates/goals/budget_limit.md | 2 +- .../ext/goal/templates/goals/continuation.md | 2 +- .../goal/templates/goals/objective_updated.md | 2 +- .../ext/goal/tests/goal_extension_backend.rs | 222 ++++++++++++------ 7 files changed, 187 insertions(+), 102 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 01a6d34051..e977da7003 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -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_final_assistant_message_sse_response; 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::rollout_path; use app_test_support::test_absolute_path; @@ -2894,8 +2895,25 @@ async fn thread_resume_rejects_archived_session_by_id() -> Result<()> { } #[tokio::test] -async fn thread_resume_keeps_paused_goal_paused() -> Result<()> { - let server = create_mock_responses_server_repeating_assistant("Done").await; +async fn thread_resume_keeps_tool_paused_goal_paused() -> Result<()> { + 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()?; mock_responses_config(&server.uri()).write(codex_home.path())?; 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(), client_user_message_id: None, 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(), }], ..Default::default() @@ -2943,22 +2961,14 @@ async fn thread_resume_keeps_paused_goal_paused() -> Result<()> { .await??; let goal_id = mcp - .send_raw_request( - "thread/goal/set", - Some(json!({ - "threadId": thread.id, - "objective": "keep polishing", - "status": "paused", - })), - ) + .send_raw_request("thread/goal/get", Some(json!({ "threadId": thread.id }))) .await?; - let _goal: ThreadGoalSetResponse = + let goal: ThreadGoalGetResponse = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(goal_id)).await??; - timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("thread/goal/updated"), - ) - .await??; + assert_eq!( + goal.goal.expect("goal should exist").status, + ThreadGoalStatus::Paused + ); mcp.clear_message_buffer(); let resume_id = mcp diff --git a/codex-rs/ext/goal/src/spec.rs b/codex-rs/ext/goal/src/spec.rs index 6b97a24e9c..6d1d7a9230 100644 --- a/codex-rs/ext/goal/src/spec.rs +++ b/codex-rs/ext/goal/src/spec.rs @@ -61,9 +61,9 @@ pub fn create_update_goal_tool() -> ToolSpec { let properties = BTreeMap::from([( "status".to_string(), JsonSchema::string_enum( - vec![json!("complete"), json!("blocked")], + vec![json!("complete"), json!("blocked"), json!("paused")], 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(), ), ), @@ -72,14 +72,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 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 `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, 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."# .to_string(), strict: false, diff --git a/codex-rs/ext/goal/src/tool.rs b/codex-rs/ext/goal/src/tool.rs index 582edf39e9..41bb312282 100644 --- a/codex-rs/ext/goal/src/tool.rs +++ b/codex-rs/ext/goal/src/tool.rs @@ -238,10 +238,10 @@ impl GoalToolExecutor { let args: UpdateGoalArgs = parse_arguments(invocation.function_arguments()?)?; if !matches!( args.status, - ThreadGoalStatus::Complete | ThreadGoalStatus::Blocked + ThreadGoalStatus::Complete | ThreadGoalStatus::Blocked | ThreadGoalStatus::Paused ) { 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(), )); } @@ -249,9 +249,10 @@ impl GoalToolExecutor { self.account_active_goal_progress( match args.status { ThreadGoalStatus::Complete => codex_state::GoalAccountingMode::ActiveOrComplete, - ThreadGoalStatus::Blocked => codex_state::GoalAccountingMode::ActiveOrStopped, + ThreadGoalStatus::Blocked | ThreadGoalStatus::Paused => { + codex_state::GoalAccountingMode::ActiveOrStopped + } ThreadGoalStatus::Active - | ThreadGoalStatus::Paused | ThreadGoalStatus::UsageLimited | ThreadGoalStatus::BudgetLimited => unreachable!("status validated above"), }, diff --git a/codex-rs/ext/goal/templates/goals/budget_limit.md b/codex-rs/ext/goal/templates/goals/budget_limit.md index 60aa594df3..6d26dbd4a1 100644 --- a/codex-rs/ext/goal/templates/goals/budget_limit.md +++ b/codex-rs/ext/goal/templates/goals/budget_limit.md @@ -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. -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. diff --git a/codex-rs/ext/goal/templates/goals/continuation.md b/codex-rs/ext/goal/templates/goals/continuation.md index 62391c523c..0e0a273389 100644 --- a/codex-rs/ext/goal/templates/goals/continuation.md +++ b/codex-rs/ext/goal/templates/goals/continuation.md @@ -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". - 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. diff --git a/codex-rs/ext/goal/templates/goals/objective_updated.md b/codex-rs/ext/goal/templates/goals/objective_updated.md index 47525e2f94..a6baed6de5 100644 --- a/codex-rs/ext/goal/templates/goals/objective_updated.md +++ b/codex-rs/ext/goal/templates/goals/objective_updated.md @@ -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. -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. diff --git a/codex-rs/ext/goal/tests/goal_extension_backend.rs b/codex-rs/ext/goal/tests/goal_extension_backend.rs index 2612414262..7c1dbee345 100644 --- a/codex-rs/ext/goal/tests/goal_extension_backend.rs +++ b/codex-rs/ext/goal/tests/goal_extension_backend.rs @@ -1050,84 +1050,158 @@ async fn usage_limit_stale_turn_does_not_stop_current_goal() -> anyhow::Result<( } #[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 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" }), - )) - .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 tools = installed_tools(runtime, thread_id).await; let update_tool = tool_by_name(&tools, "update_goal"); - let invocation = tool_call( - "update_goal", - "call-update-goal", - json!({ "status": "blocked" }), - ); - 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": "blocked", - "tokensUsed": 23, - "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() - ); + for status in ["active", "budgetLimited", "usageLimited"] { + let result = update_tool + .handle(tool_call( + "update_goal", + "call-update-goal", + json!({ "status": status }), + )) + .await; + let error = match result { + Ok(_) => panic!("agent must not set {status}"), + Err(error) => error, + }; + assert_eq!( + error, + FunctionCallError::RespondToModel( + "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() + ) + ); + } Ok(()) }