Skip to content
90 changes: 90 additions & 0 deletions codex-rs/ext/goal/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -767,6 +767,96 @@ impl GoalService {
})
}

pub(crate) async fn clear_thread_goal_plan_max_tokens(
&self,
state_db: &codex_state::StateRuntime,
event_id: &str,
params: codex_state::ThreadGoalPlanMaxTokensClearParams,
) -> Result<codex_state::ThreadGoalPlanMaxTokensClearOutcome, String> {
let plan_snapshot = state_db
.thread_goals()
.get_thread_goal_plan_for_thread(params.thread_id, params.plan_id.as_str())
.await
.map_err(|err| err.to_string())?;
let Some(plan_snapshot) = plan_snapshot else {
return state_db
.thread_goals()
.clear_thread_goal_plan_max_tokens(params)
.await
.map_err(|err| err.to_string());
};
if plan_snapshot.plan.thread_id != params.thread_id {
return state_db
.thread_goals()
.clear_thread_goal_plan_max_tokens(params)
.await
.map_err(|err| err.to_string());
}
let mut participant_thread_ids = plan_snapshot
.nodes
.into_iter()
.map(|node| node.assigned_thread_id)
.collect::<Vec<_>>();
participant_thread_ids.sort_by_key(std::string::ToString::to_string);
participant_thread_ids.dedup();
let runtimes = participant_thread_ids
.into_iter()
.filter_map(|thread_id| self.runtime_for_thread(thread_id))
.collect::<Vec<_>>();
let mut permits = Vec::with_capacity(runtimes.len());
for runtime in &runtimes {
permits.push(runtime.goal_state_permit().await?);
}
for runtime in &runtimes {
runtime.prepare_external_goal_mutation().await?;
}

let outcome = state_db
.thread_goals()
.clear_thread_goal_plan_max_tokens(params)
.await
.map_err(|err| err.to_string())?;
for goal in &outcome.revived_goals {
crate::pending_interaction::clear_goal_status_waits(
state_db,
goal.thread_id,
goal.goal_id.as_str(),
"goal plan token budget cleared",
)
.await?;
crate::pending_interaction::clear_goal_plan_termination_waits(
state_db,
goal.thread_id,
outcome.snapshot.plan.plan_id.as_str(),
"goal plan token budget cleared",
)
.await?;
}
let mut attached_goals = Vec::with_capacity(outcome.revived_goals.len());
for (index, goal) in outcome.revived_goals.iter().enumerate() {
let Some(runtime) = self.runtime_for_thread(goal.thread_id) else {
continue;
};
let turn_id = runtime.reattach_revived_goal_while_locked(goal).await;
attached_goals.push((index, runtime, turn_id, goal.clone()));
}
drop(permits);
for (index, runtime, turn_id, goal) in attached_goals {
if let Err(err) = runtime.continue_external_goal_after_unlock().await {
tracing::warn!(
"failed to continue cleared goal plan budget runtime for thread {}: {err}",
goal.thread_id
);
}
runtime.emit_external_goal_updated(
format!("{event_id}:revived-goal:{index}"),
turn_id,
goal,
);
}
Ok(outcome)
}

pub(crate) fn register_runtime(&self, runtime: &Arc<GoalRuntimeHandle>) {
self.runtimes()
.insert(runtime.thread_id().to_string(), Arc::downgrade(runtime));
Expand Down
11 changes: 11 additions & 0 deletions codex-rs/ext/goal/src/extension.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ use crate::spec::PAUSE_GOAL_TOOL_NAME;
use crate::spec::RESUME_GOAL_TOOL_NAME;
use crate::spec::SET_GOAL_PLAN_NODE_STATUS_TOOL_NAME;
use crate::spec::UPDATE_GOAL_PLAN_NODE_TOOL_NAME;
use crate::spec::UPDATE_GOAL_PLAN_TOOL_NAME;
use crate::spec::UPDATE_GOAL_TOOL_NAME;
use crate::steering::budget_limit_steering_item;
use crate::steering::plan_completion_guard_steering_item;
Expand Down Expand Up @@ -601,6 +602,7 @@ where
| RESUME_GOAL_TOOL_NAME
| GET_GOAL_PLAN_TOOL_NAME
| CREATE_GOAL_PLAN_TOOL_NAME
| UPDATE_GOAL_PLAN_TOOL_NAME
| ACTIVATE_GOAL_PLAN_NODE_TOOL_NAME
| UPDATE_GOAL_PLAN_NODE_TOOL_NAME
| INSERT_GOAL_PLAN_NODE_TOOL_NAME
Expand Down Expand Up @@ -692,6 +694,15 @@ where
self.metrics.clone(),
runtime.plan_config_handle(),
)),
Arc::new(GoalToolExecutor::update_plan(
runtime.thread_id(),
Arc::clone(&self.state_dbs),
runtime.accounting_state(),
self.event_emitter.clone(),
self.metrics.clone(),
runtime.plan_config_handle(),
Arc::clone(&self.goal_service),
)),
Arc::new(GoalToolExecutor::activate_plan_node(
runtime.thread_id(),
Arc::clone(&self.state_dbs),
Expand Down
1 change: 1 addition & 0 deletions codex-rs/ext/goal/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,6 @@ pub use spec::GET_GOAL_TOOL_NAME;
pub use spec::INSERT_GOAL_PLAN_NODE_TOOL_NAME;
pub use spec::SET_GOAL_PLAN_NODE_STATUS_TOOL_NAME;
pub use spec::UPDATE_GOAL_PLAN_NODE_TOOL_NAME;
pub use spec::UPDATE_GOAL_PLAN_TOOL_NAME;
pub use spec::UPDATE_GOAL_TOOL_NAME;
pub use tool::CreateGoalRequest;
1 change: 1 addition & 0 deletions codex-rs/ext/goal/src/metrics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ impl GoalMetrics {
| codex_state::ThreadGoalStatus::Blocked
| codex_state::ThreadGoalStatus::Deferred
| codex_state::ThreadGoalStatus::UsageLimited
| codex_state::ThreadGoalStatus::BudgetLimited
)
)
{
Expand Down
27 changes: 27 additions & 0 deletions codex-rs/ext/goal/src/pending_interaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,33 @@ pub(crate) async fn clear_goal_status_waits(
.map_err(|err| err.to_string())
}

pub(crate) async fn clear_goal_plan_termination_waits(
state_db: &codex_state::StateRuntime,
thread_id: ThreadId,
plan_id: &str,
reason: &str,
) -> Result<(), String> {
state_db
.respond_thread_pending_interactions_for_source(
codex_state::PendingInteractionRespondForSourceParams {
thread_id,
source_kind: codex_state::PendingInteractionSourceKind::Thread,
source_id: plan_id.to_string(),
kinds: vec![codex_state::PendingInteractionKind::Blocked],
response_payload_json: json!({
"type": "terminal",
"reason": reason,
}),
response_payload_preview: reason.to_string(),
response_redactions_json: json!([]),
terminal_status: codex_state::PendingInteractionStatus::NoLongerWaiting,
},
)
.await
.map(|_| ())
.map_err(|err| err.to_string())
}

fn pending_interaction_kind_for_goal_status(
status: codex_state::ThreadGoalStatus,
) -> Option<codex_state::PendingInteractionKind> {
Expand Down
56 changes: 52 additions & 4 deletions codex-rs/ext/goal/src/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -257,9 +257,9 @@ impl GoalRuntimeHandle {
&self,
goal: codex_state::ThreadGoal,
previous_goal: Option<PreviousGoalSnapshot>,
) -> Result<(), String> {
) -> Result<Option<String>, String> {
if !self.is_enabled() {
return Ok(());
return Ok(None);
}

let replaced_existing_goal = previous_goal
Expand Down Expand Up @@ -320,6 +320,7 @@ impl GoalRuntimeHandle {
let objective_changed = previous_goal.as_ref().is_some_and(|previous_goal| {
!replaced_existing_goal && previous_goal.objective != goal.objective
});
let mut active_turn_id = None;
match goal.status {
codex_state::ThreadGoalStatus::Active => {
if matches!(
Expand All @@ -338,7 +339,7 @@ impl GoalRuntimeHandle {
.await?;
}
if self.inner.accounting_state.current_turn_id().is_some() {
let _ = self
active_turn_id = self
.inner
.accounting_state
.mark_current_turn_goal_active(goal.goal_id.clone());
Expand Down Expand Up @@ -439,7 +440,54 @@ impl GoalRuntimeHandle {
{
tracing::info!("{report}");
}
Ok(())
Ok(active_turn_id)
}

pub(crate) async fn reattach_revived_goal_while_locked(
&self,
goal: &codex_state::ThreadGoal,
) -> Option<String> {
if !self.is_enabled() {
return None;
}
self.inner.metrics.record_resumed_if_status_changed(
Some(codex_state::ThreadGoalStatus::BudgetLimited),
goal.status,
);
if self.inner.accounting_state.current_turn_id().is_some() {
let turn_id = self
.inner
.accounting_state
.mark_current_turn_goal_active(goal.goal_id.clone());
crate::line_changes::establish_current_turn_baseline(
self.inner.accounting_state.as_ref(),
goal,
)
.await;
turn_id
} else {
self.inner
.accounting_state
.mark_idle_goal_active(goal.goal_id.clone());
None
}
}

pub(crate) async fn continue_external_goal_after_unlock(&self) -> Result<(), String> {
self.continue_if_idle().await
}

pub(crate) fn emit_external_goal_updated(
&self,
event_id: String,
turn_id: Option<String>,
goal: codex_state::ThreadGoal,
) {
self.inner.event_emitter.thread_goal_updated(
event_id,
turn_id,
protocol_goal_from_state(goal),
);
}

pub async fn apply_external_goal_clear(&self) -> Result<(), String> {
Expand Down
35 changes: 35 additions & 0 deletions codex-rs/ext/goal/src/spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ pub const GET_GOAL_TOOL_NAME: &str = "get_goal";
pub const CREATE_GOAL_TOOL_NAME: &str = "create_goal";
pub const GET_GOAL_PLAN_TOOL_NAME: &str = "get_goal_plan";
pub const CREATE_GOAL_PLAN_TOOL_NAME: &str = "create_goal_plan";
pub const UPDATE_GOAL_PLAN_TOOL_NAME: &str = "update_goal_plan";
pub const ACTIVATE_GOAL_PLAN_NODE_TOOL_NAME: &str = "activate_goal_plan_node";
pub const UPDATE_GOAL_PLAN_NODE_TOOL_NAME: &str = "update_goal_plan_node";
pub const INSERT_GOAL_PLAN_NODE_TOOL_NAME: &str = "insert_goal_plan_node";
Expand Down Expand Up @@ -277,6 +278,40 @@ Set post_goal_context or post_goal_plan_context only when the user or system/dev
})
}

pub fn create_update_goal_plan_tool() -> ToolSpec {
let mut properties = BTreeMap::new();
properties.insert(
"plan_id".to_string(),
JsonSchema::string(Some(
"Required stable goal-plan id from get_goal_plan. The plan must belong to the current thread."
.to_string(),
)),
);
properties.insert(
"clear_max_tokens".to_string(),
JsonSchema::boolean(Some(
"Must be true. Clears only the selected plan's max_tokens. False is rejected."
.to_string(),
)),
);

ToolSpec::Function(ResponsesApiTool {
name: UPDATE_GOAL_PLAN_TOOL_NAME.to_string(),
description: r#"Clear the token budget from one existing goal plan without replacing it.
Use this only after the user explicitly asks to remove the plan-level token budget. Pass the exact plan_id and clear_max_tokens=true. This is not a generic plan setter: it preserves plan, node, goal, and thread lineage and cannot change any other plan field.
Completed and cancelled plans cannot be updated. A configured runtime plan-token ceiling cannot be cleared. When a budget-limited projected goal is safely revivable, the tool preserves its accounting and emits both goal and plan updates.
Adversarial verification is required before completing any goal: use at least one adversarial agent to verify and validate the work even if the user did not ask for one, reconcile the result before calling update_goal with status complete, and if no adversarial agent can be spawned, explicitly perform and report an adversarial self-review with the same standards."#.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::object(
properties,
Some(vec!["plan_id".to_string(), "clear_max_tokens".to_string()]),
Some(false.into()),
),
output_schema: None,
})
}

pub fn create_activate_goal_plan_node_tool() -> ToolSpec {
let properties = BTreeMap::from([(
"node_id".to_string(),
Expand Down
Loading
Loading