[2 of 2] Finish moving goal runtime to extension (#26548)

## Stack

1. [#26547](https://github.com/openai/codex/pull/26547) - [1 of 2] Align
goal extension with core behavior
2. [#26548](https://github.com/openai/codex/pull/26548) - [2 of 2] Move
goal runtime to extension

## Why

This PR completes the switch of the goal behavior to the
extension-backed runtime and removes the old core goal implementation.

## What Changed

- Installs the goal extension for app-server `ThreadManager` sessions.
- Routes app-server thread goal `get`, `set`, and `clear` through
`GoalService`.
- Uses thread-idle lifecycle emission after goal resume and snapshot
ordering so the extension can decide whether to continue the goal.
- Forwards extension goal updates through a FIFO async app-server
notification path so backpressure does not drop them or reorder updates.
- Keeps review turns from enabling goal runtime behavior.
- Plans extension tools before dynamic tools so built-in goal tool names
keep their old precedence when goals are enabled.
- Removes the old core goal runtime, core goal tool handlers, and core
goal tool specs.
- Updates tests that were coupled to the core-owned goal runtime while
leaving the legacy `<goal_context>` compatibility path in core for old
threads.
- Removes the stale cargo-shear ignore now that `codex-goal-extension`
is used by the workspace.
- Keeps realtime event matching exhaustive after removing the old
goal-specific realtime text path.


## Validation

- Ran manual `/goal` runs in TUI. Validated time accounting matched
wall-clock time and goal lifecycle state transitions.
This commit is contained in:
Eric Traut
2026-06-05 14:17:30 -07:00
committed by GitHub
Unverified
parent 679cc08445
commit 479a14cf59
34 changed files with 280 additions and 3908 deletions
-158
View File
@@ -1,158 +0,0 @@
//! Built-in model tool handlers for persisted thread goals.
//!
//! 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;
use codex_protocol::protocol::ThreadGoal;
use codex_protocol::protocol::ThreadGoalStatus;
use serde::Deserialize;
use serde::Serialize;
use std::fmt::Write as _;
mod create_goal;
mod get_goal;
mod update_goal;
pub use create_goal::CreateGoalHandler;
pub use get_goal::GetGoalHandler;
pub use update_goal::UpdateGoalHandler;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
struct CreateGoalArgs {
objective: String,
token_budget: Option<i64>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
struct UpdateGoalArgs {
status: ThreadGoalStatus,
}
#[derive(Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct GoalToolResponse {
goal: Option<ThreadGoal>,
remaining_tokens: Option<i64>,
completion_budget_report: Option<String>,
}
#[derive(Clone, Copy)]
enum CompletionBudgetReport {
Include,
Omit,
}
impl GoalToolResponse {
fn new(goal: Option<ThreadGoal>, report_mode: CompletionBudgetReport) -> Self {
let remaining_tokens = goal.as_ref().and_then(|goal| {
goal.token_budget
.map(|budget| (budget - goal.tokens_used).max(0))
});
let completion_budget_report = match report_mode {
CompletionBudgetReport::Include => goal
.as_ref()
.filter(|goal| goal.status == ThreadGoalStatus::Complete)
.and_then(completion_budget_report),
CompletionBudgetReport::Omit => None,
};
Self {
goal,
remaining_tokens,
completion_budget_report,
}
}
}
fn format_goal_error(err: anyhow::Error) -> String {
let mut message = err.to_string();
for cause in err.chain().skip(1) {
let _ = write!(message, ": {cause}");
}
message
}
fn goal_response(
goal: Option<ThreadGoal>,
completion_budget_report: CompletionBudgetReport,
) -> Result<FunctionToolOutput, FunctionCallError> {
let response =
serde_json::to_string_pretty(&GoalToolResponse::new(goal, completion_budget_report))
.map_err(|err| FunctionCallError::Fatal(err.to_string()))?;
Ok(FunctionToolOutput::from_text(response, Some(true)))
}
fn completion_budget_report(goal: &ThreadGoal) -> Option<String> {
if goal.token_budget.is_none() && goal.time_used_seconds <= 0 {
None
} else {
Some(
"Goal achieved. Report final usage from this tool result's structured goal fields. If `goal.tokenBudget` is present, include token usage from `goal.tokensUsed` and `goal.tokenBudget`. If `goal.timeUsedSeconds` is greater than 0, summarize elapsed time in a concise, human-friendly form appropriate to the response language."
.to_string(),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use codex_protocol::ThreadId;
use pretty_assertions::assert_eq;
#[test]
fn completed_budgeted_goal_response_reports_final_usage() {
let goal = ThreadGoal {
thread_id: ThreadId::new(),
objective: "Keep optimizing".to_string(),
status: ThreadGoalStatus::Complete,
token_budget: Some(10_000),
tokens_used: 3_250,
time_used_seconds: 75,
created_at: 1,
updated_at: 2,
};
let response = GoalToolResponse::new(Some(goal.clone()), CompletionBudgetReport::Include);
assert_eq!(
response,
GoalToolResponse {
goal: Some(goal),
remaining_tokens: Some(6_750),
completion_budget_report: Some(
"Goal achieved. Report final usage from this tool result's structured goal fields. If `goal.tokenBudget` is present, include token usage from `goal.tokensUsed` and `goal.tokenBudget`. If `goal.timeUsedSeconds` is greater than 0, summarize elapsed time in a concise, human-friendly form appropriate to the response language."
.to_string()
),
}
);
}
#[test]
fn completed_unbudgeted_goal_response_omits_budget_report() {
let goal = ThreadGoal {
thread_id: ThreadId::new(),
objective: "Write a poem".to_string(),
status: ThreadGoalStatus::Complete,
token_budget: None,
tokens_used: 120,
time_used_seconds: 0,
created_at: 1,
updated_at: 2,
};
let response = GoalToolResponse::new(Some(goal.clone()), CompletionBudgetReport::Include);
assert_eq!(
response,
GoalToolResponse {
goal: Some(goal),
remaining_tokens: None,
completion_budget_report: None,
}
);
}
}
@@ -1,78 +0,0 @@
use crate::function_tool::FunctionCallError;
use crate::goals::CreateGoalRequest;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::context::boxed_tool_output;
use crate::tools::handlers::goal_spec::CREATE_GOAL_TOOL_NAME;
use crate::tools::handlers::goal_spec::create_create_goal_tool;
use crate::tools::handlers::parse_arguments;
use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::ToolExecutor;
use codex_tools::ToolName;
use codex_tools::ToolSpec;
use super::CompletionBudgetReport;
use super::CreateGoalArgs;
use super::format_goal_error;
use super::goal_response;
pub struct CreateGoalHandler;
#[async_trait::async_trait]
impl ToolExecutor<ToolInvocation> for CreateGoalHandler {
fn tool_name(&self) -> ToolName {
ToolName::plain(CREATE_GOAL_TOOL_NAME)
}
fn spec(&self) -> ToolSpec {
create_create_goal_tool()
}
async fn handle(
&self,
invocation: ToolInvocation,
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
let ToolInvocation {
session,
turn,
payload,
..
} = invocation;
let arguments = match payload {
ToolPayload::Function { arguments } => arguments,
_ => {
return Err(FunctionCallError::RespondToModel(
"goal handler received unsupported payload".to_string(),
));
}
};
let args: CreateGoalArgs = parse_arguments(&arguments)?;
let goal = session
.create_thread_goal(
turn.as_ref(),
CreateGoalRequest {
objective: args.objective,
token_budget: args.token_budget,
},
)
.await
.map_err(|err| {
if err
.chain()
.any(|cause| cause.to_string().contains("already has a goal"))
{
FunctionCallError::RespondToModel(
"cannot create a new goal because this thread already has a goal; use update_goal only when the existing goal is complete"
.to_string(),
)
} else {
FunctionCallError::RespondToModel(format_goal_error(err))
}
})?;
goal_response(Some(goal), CompletionBudgetReport::Omit).map(boxed_tool_output)
}
}
impl CoreToolRuntime for CreateGoalHandler {}
@@ -1,51 +0,0 @@
use crate::function_tool::FunctionCallError;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::context::boxed_tool_output;
use crate::tools::handlers::goal_spec::GET_GOAL_TOOL_NAME;
use crate::tools::handlers::goal_spec::create_get_goal_tool;
use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::ToolExecutor;
use codex_tools::ToolName;
use codex_tools::ToolSpec;
use super::CompletionBudgetReport;
use super::format_goal_error;
use super::goal_response;
pub struct GetGoalHandler;
#[async_trait::async_trait]
impl ToolExecutor<ToolInvocation> for GetGoalHandler {
fn tool_name(&self) -> ToolName {
ToolName::plain(GET_GOAL_TOOL_NAME)
}
fn spec(&self) -> ToolSpec {
create_get_goal_tool()
}
async fn handle(
&self,
invocation: ToolInvocation,
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
let ToolInvocation {
session, payload, ..
} = invocation;
match payload {
ToolPayload::Function { .. } => {
let goal = session
.get_thread_goal()
.await
.map_err(|err| FunctionCallError::RespondToModel(format_goal_error(err)))?;
goal_response(goal, CompletionBudgetReport::Omit).map(boxed_tool_output)
}
_ => Err(FunctionCallError::RespondToModel(
"get_goal handler received unsupported payload".to_string(),
)),
}
}
}
impl CoreToolRuntime for GetGoalHandler {}
@@ -1,89 +0,0 @@
use crate::function_tool::FunctionCallError;
use crate::goals::GoalRuntimeEvent;
use crate::goals::SetGoalRequest;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::context::boxed_tool_output;
use crate::tools::handlers::goal_spec::UPDATE_GOAL_TOOL_NAME;
use crate::tools::handlers::goal_spec::create_update_goal_tool;
use crate::tools::handlers::parse_arguments;
use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::ToolExecutor;
use codex_protocol::protocol::ThreadGoalStatus;
use codex_tools::ToolName;
use codex_tools::ToolSpec;
use super::CompletionBudgetReport;
use super::UpdateGoalArgs;
use super::format_goal_error;
use super::goal_response;
pub struct UpdateGoalHandler;
#[async_trait::async_trait]
impl ToolExecutor<ToolInvocation> for UpdateGoalHandler {
fn tool_name(&self) -> ToolName {
ToolName::plain(UPDATE_GOAL_TOOL_NAME)
}
fn spec(&self) -> ToolSpec {
create_update_goal_tool()
}
async fn handle(
&self,
invocation: ToolInvocation,
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
let ToolInvocation {
session,
turn,
payload,
..
} = invocation;
let arguments = match payload {
ToolPayload::Function { arguments } => arguments,
_ => {
return Err(FunctionCallError::RespondToModel(
"update_goal handler received unsupported payload".to_string(),
));
}
};
let args: UpdateGoalArgs = parse_arguments(&arguments)?;
if !matches!(
args.status,
ThreadGoalStatus::Complete | ThreadGoalStatus::Blocked
) {
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"
.to_string(),
));
}
session
.goal_runtime_apply(GoalRuntimeEvent::ToolCompletedGoal {
turn_context: turn.as_ref(),
})
.await
.map_err(|err| FunctionCallError::RespondToModel(format_goal_error(err)))?;
let goal = session
.set_thread_goal(
turn.as_ref(),
SetGoalRequest {
objective: None,
status: Some(args.status),
token_budget: None,
},
)
.await
.map_err(|err| FunctionCallError::RespondToModel(format_goal_error(err)))?;
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)
}
}
impl CoreToolRuntime for UpdateGoalHandler {}
@@ -1,120 +0,0 @@
//! Responses API tool definitions for persisted thread goals.
//!
//! These specs expose goal read/update primitives to the model while keeping
//! usage accounting system-managed.
use codex_tools::JsonSchema;
use codex_tools::ResponsesApiTool;
use codex_tools::ToolSpec;
use serde_json::json;
use std::collections::BTreeMap;
pub const GET_GOAL_TOOL_NAME: &str = "get_goal";
pub const CREATE_GOAL_TOOL_NAME: &str = "create_goal";
pub const UPDATE_GOAL_TOOL_NAME: &str = "update_goal";
pub fn create_get_goal_tool() -> ToolSpec {
ToolSpec::Function(ResponsesApiTool {
name: GET_GOAL_TOOL_NAME.to_string(),
description: "Get the current goal for this thread, including status, budgets, token and elapsed-time usage, and remaining token budget."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::object(BTreeMap::new(), Some(Vec::new()), Some(false.into())),
output_schema: None,
})
}
pub fn create_create_goal_tool() -> ToolSpec {
let properties = BTreeMap::from([
(
"objective".to_string(),
JsonSchema::string(Some(
"Required. The concrete objective to start pursuing. This starts a new active goal only when no goal is currently defined; if a goal already exists, this tool fails."
.to_string(),
)),
),
(
"token_budget".to_string(),
JsonSchema::integer(Some(
"Positive token budget for the new goal. Omit unless explicitly requested."
.to_string(),
)),
),
]);
ToolSpec::Function(ResponsesApiTool {
name: CREATE_GOAL_TOOL_NAME.to_string(),
description: format!(
r#"Create a goal only when explicitly requested by the user or system/developer instructions; do not infer goals from ordinary tasks.
Set token_budget only when an explicit token budget is requested. Fails if a goal exists; use {UPDATE_GOAL_TOOL_NAME} only for status."#
),
strict: false,
defer_loading: None,
parameters: JsonSchema::object(
properties,
/*required*/ Some(vec!["objective".to_string()]),
Some(false.into()),
),
output_schema: None,
})
}
pub fn create_update_goal_tool() -> ToolSpec {
let properties = BTreeMap::from([(
"status".to_string(),
JsonSchema::string_enum(
vec![json!("complete"), json!("blocked")],
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."
.to_string(),
),
),
)]);
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 `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.
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,
defer_loading: None,
parameters: JsonSchema::object(
properties,
/*required*/ Some(vec!["status".to_string()]),
Some(false.into()),
),
output_schema: None,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
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");
};
let status = tool
.parameters
.properties
.as_ref()
.and_then(|properties| properties.get("status"))
.expect("status property should exist");
assert_eq!(
status.enum_values,
Some(vec![json!("complete"), json!("blocked")])
);
}
}
-5
View File
@@ -4,8 +4,6 @@ pub(crate) mod apply_patch;
pub(crate) mod apply_patch_spec;
mod dynamic;
pub(crate) mod extension_tools;
mod goal;
pub(crate) mod goal_spec;
mod list_available_plugins_to_install;
pub(crate) mod list_available_plugins_to_install_spec;
mod mcp;
@@ -53,9 +51,6 @@ pub use apply_patch::ApplyPatchHandler;
use codex_protocol::models::AdditionalPermissionProfile;
use codex_protocol::protocol::AskForApproval;
pub use dynamic::DynamicToolHandler;
pub use goal::CreateGoalHandler;
pub use goal::GetGoalHandler;
pub use goal::UpdateGoalHandler;
pub use list_available_plugins_to_install::ListAvailablePluginsToInstallHandler;
pub use mcp::McpHandler;
pub use mcp_resource::ListMcpResourceTemplatesHandler;
+1 -15
View File
@@ -5,7 +5,6 @@ use std::sync::atomic::Ordering;
use std::time::Duration;
use crate::function_tool::FunctionCallError;
use crate::goals::GoalRuntimeEvent;
use crate::hook_runtime::PreToolUseHookResult;
use crate::hook_runtime::record_additional_contexts;
use crate::hook_runtime::run_post_tool_use_hooks;
@@ -34,7 +33,6 @@ use codex_tools::ToolSearchInfo;
use codex_tools::ToolSpec;
use futures::future::BoxFuture;
use serde_json::Value;
use tracing::warn;
pub(crate) type ToolTelemetryTags = Vec<(&'static str, String)>;
@@ -649,25 +647,13 @@ impl ToolRegistry {
handler_executed: true,
},
};
let finished = notify_tool_finish_if_unclaimed(
notify_tool_finish_if_unclaimed(
&invocation,
terminal_outcome_reached.as_deref(),
lifecycle_outcome,
)
.await;
if finished
&& let Err(err) = invocation
.session
.goal_runtime_apply(GoalRuntimeEvent::ToolCompleted {
turn_context: invocation.turn.as_ref(),
tool_name: tool_name.name.as_str(),
})
.await
{
warn!("failed to account thread goal progress after tool call: {err}");
}
match result {
Ok(_) => {
let mut guard = response_cell.lock().await;
+1 -17
View File
@@ -6,11 +6,9 @@ use crate::tools::context::ToolInvocation;
use crate::tools::handlers::ApplyPatchHandler;
use crate::tools::handlers::CodeModeExecuteHandler;
use crate::tools::handlers::CodeModeWaitHandler;
use crate::tools::handlers::CreateGoalHandler;
use crate::tools::handlers::DynamicToolHandler;
use crate::tools::handlers::ExecCommandHandler;
use crate::tools::handlers::ExecCommandHandlerOptions;
use crate::tools::handlers::GetGoalHandler;
use crate::tools::handlers::ListAvailablePluginsToInstallHandler;
use crate::tools::handlers::ListMcpResourceTemplatesHandler;
use crate::tools::handlers::ListMcpResourcesHandler;
@@ -24,7 +22,6 @@ use crate::tools::handlers::ShellCommandHandler;
use crate::tools::handlers::ShellCommandHandlerOptions;
use crate::tools::handlers::TestSyncHandler;
use crate::tools::handlers::ToolSearchHandler;
use crate::tools::handlers::UpdateGoalHandler;
use crate::tools::handlers::ViewImageHandler;
use crate::tools::handlers::WriteStdinHandler;
use crate::tools::handlers::agent_jobs::ReportAgentJobResultHandler;
@@ -305,14 +302,6 @@ fn collab_tools_enabled(turn_context: &TurnContext) -> bool {
}
}
fn goal_tools_enabled(turn_context: &TurnContext) -> bool {
turn_context.goal_tools_enabled()
&& !matches!(
turn_context.session_source,
SessionSource::SubAgent(SubAgentSource::Review)
)
}
fn agent_jobs_tools_enabled(turn_context: &TurnContext) -> bool {
turn_context.features.get().enabled(Feature::SpawnCsv) && collab_tools_enabled(turn_context)
}
@@ -558,8 +547,8 @@ fn add_tool_sources(context: &CoreToolPlanContext<'_>, planned_tools: &mut Plann
add_core_utility_tools(context, planned_tools);
add_collaboration_tools(context, planned_tools);
add_mcp_runtime_tools(context, planned_tools);
add_dynamic_tools(context, planned_tools);
add_extension_tools(context, planned_tools);
add_dynamic_tools(context, planned_tools);
for spec in hosted_model_tool_specs(context) {
planned_tools.add_hosted_spec(spec);
}
@@ -639,11 +628,6 @@ fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut
let environment_mode = turn_context.tool_environment_mode();
planned_tools.add(PlanHandler);
if goal_tools_enabled(turn_context) {
planned_tools.add(GetGoalHandler);
planned_tools.add(CreateGoalHandler);
planned_tools.add(UpdateGoalHandler);
}
if turn_context.config.experimental_request_user_input_enabled {
planned_tools.add(RequestUserInputHandler {
+1 -30
View File
@@ -619,36 +619,7 @@ async fn environment_count_controls_environment_backed_tools() {
}
#[tokio::test]
async fn host_context_gates_goal_and_agent_job_tools() {
let feature_disabled = probe(|turn| {
set_feature(turn, Feature::Goals, /*enabled*/ false);
turn.goal_tools_supported = true;
})
.await;
feature_disabled.assert_visible_lacks(&["get_goal", "create_goal", "update_goal"]);
let host_disabled = probe(|turn| {
set_feature(turn, Feature::Goals, /*enabled*/ true);
turn.goal_tools_supported = false;
})
.await;
host_disabled.assert_visible_lacks(&["get_goal", "create_goal", "update_goal"]);
let enabled = probe(|turn| {
set_feature(turn, Feature::Goals, /*enabled*/ true);
turn.goal_tools_supported = true;
})
.await;
enabled.assert_visible_contains(&["get_goal", "create_goal", "update_goal"]);
let review_thread = probe(|turn| {
set_feature(turn, Feature::Goals, /*enabled*/ true);
turn.goal_tools_supported = true;
turn.session_source = SessionSource::SubAgent(SubAgentSource::Review);
})
.await;
review_thread.assert_visible_lacks(&["get_goal", "create_goal", "update_goal"]);
async fn host_context_gates_agent_job_tools() {
let normal_agent_job = probe(|turn| {
set_feature(turn, Feature::SpawnCsv, /*enabled*/ true);
})