mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
committed by
GitHub
Unverified
parent
d32cb2c6ac
commit
0d344aca9b
+104
-5
@@ -15,12 +15,14 @@ use crate::tasks::RegularTask;
|
||||
use crate::tools::handlers::goal_spec::UPDATE_GOAL_TOOL_NAME;
|
||||
use anyhow::Context;
|
||||
use codex_features::Feature;
|
||||
use codex_otel::GOAL_BLOCKED_METRIC;
|
||||
use codex_otel::GOAL_BUDGET_LIMITED_METRIC;
|
||||
use codex_otel::GOAL_COMPLETED_METRIC;
|
||||
use codex_otel::GOAL_CREATED_METRIC;
|
||||
use codex_otel::GOAL_DURATION_SECONDS_METRIC;
|
||||
use codex_otel::GOAL_RESUMED_METRIC;
|
||||
use codex_otel::GOAL_TOKEN_COUNT_METRIC;
|
||||
use codex_otel::GOAL_USAGE_LIMITED_METRIC;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
@@ -156,6 +158,9 @@ pub(crate) enum GoalRuntimeEvent<'a> {
|
||||
turn_context: Option<&'a TurnContext>,
|
||||
reason: TurnAbortReason,
|
||||
},
|
||||
UsageLimitReached {
|
||||
turn_context: &'a TurnContext,
|
||||
},
|
||||
ExternalMutationStarting,
|
||||
ExternalSet {
|
||||
external_set: ExternalGoalSet,
|
||||
@@ -393,6 +398,11 @@ impl Session {
|
||||
.await;
|
||||
Ok(())
|
||||
}),
|
||||
GoalRuntimeEvent::UsageLimitReached { turn_context } => Box::pin(async move {
|
||||
self.usage_limit_active_thread_goal_for_turn(turn_context)
|
||||
.await?;
|
||||
Ok(())
|
||||
}),
|
||||
GoalRuntimeEvent::ExternalMutationStarting => Box::pin(async move {
|
||||
if let Err(err) = self.account_thread_goal_before_external_mutation().await {
|
||||
tracing::warn!(
|
||||
@@ -537,6 +547,7 @@ impl Session {
|
||||
if replacing_goal {
|
||||
self.emit_goal_created_metric();
|
||||
}
|
||||
self.emit_goal_resumed_metric_if_status_changed(previous_status_for_goal, goal_status);
|
||||
self.emit_goal_terminal_metrics_if_status_changed(previous_status_for_goal, &goal);
|
||||
let goal = protocol_goal_from_state(goal);
|
||||
*self.goal_runtime.budget_limit_reported_goal_id.lock().await = None;
|
||||
@@ -653,6 +664,7 @@ impl Session {
|
||||
let previous_status = previous_goal
|
||||
.as_ref()
|
||||
.and_then(|previous_goal| (!replaced_existing_goal).then_some(previous_goal.status));
|
||||
self.emit_goal_resumed_metric_if_status_changed(previous_status, goal.status);
|
||||
self.emit_goal_terminal_metrics_if_status_changed(previous_status, &goal);
|
||||
let goal_for_steering = objective_changed.then(|| protocol_goal_from_state(goal.clone()));
|
||||
let goal_id = goal.goal_id;
|
||||
@@ -681,7 +693,10 @@ impl Session {
|
||||
self.clear_stopped_thread_goal_runtime_state().await;
|
||||
}
|
||||
}
|
||||
codex_state::ThreadGoalStatus::Paused | codex_state::ThreadGoalStatus::Complete => {
|
||||
codex_state::ThreadGoalStatus::Paused
|
||||
| codex_state::ThreadGoalStatus::Blocked
|
||||
| codex_state::ThreadGoalStatus::UsageLimited
|
||||
| codex_state::ThreadGoalStatus::Complete => {
|
||||
self.clear_stopped_thread_goal_runtime_state().await;
|
||||
}
|
||||
}
|
||||
@@ -741,6 +756,25 @@ impl Session {
|
||||
.counter(GOAL_RESUMED_METRIC, /*inc*/ 1, &[]);
|
||||
}
|
||||
|
||||
fn emit_goal_resumed_metric_if_status_changed(
|
||||
&self,
|
||||
previous_status: Option<codex_state::ThreadGoalStatus>,
|
||||
goal_status: codex_state::ThreadGoalStatus,
|
||||
) {
|
||||
if goal_status == codex_state::ThreadGoalStatus::Active
|
||||
&& matches!(
|
||||
previous_status,
|
||||
Some(
|
||||
codex_state::ThreadGoalStatus::Paused
|
||||
| codex_state::ThreadGoalStatus::Blocked
|
||||
| codex_state::ThreadGoalStatus::UsageLimited
|
||||
)
|
||||
)
|
||||
{
|
||||
self.emit_goal_resumed_metric();
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_goal_terminal_metrics_if_status_changed(
|
||||
&self,
|
||||
previous_status: Option<codex_state::ThreadGoalStatus>,
|
||||
@@ -751,6 +785,8 @@ impl Session {
|
||||
}
|
||||
|
||||
let counter = match goal.status {
|
||||
codex_state::ThreadGoalStatus::Blocked => GOAL_BLOCKED_METRIC,
|
||||
codex_state::ThreadGoalStatus::UsageLimited => GOAL_USAGE_LIMITED_METRIC,
|
||||
codex_state::ThreadGoalStatus::BudgetLimited => GOAL_BUDGET_LIMITED_METRIC,
|
||||
codex_state::ThreadGoalStatus::Complete => GOAL_COMPLETED_METRIC,
|
||||
codex_state::ThreadGoalStatus::Active | codex_state::ThreadGoalStatus::Paused => {
|
||||
@@ -1011,6 +1047,8 @@ impl Session {
|
||||
matches!(budget_limit_steering, BudgetLimitSteering::Suppressed)
|
||||
}
|
||||
codex_state::ThreadGoalStatus::Paused
|
||||
| codex_state::ThreadGoalStatus::Blocked
|
||||
| codex_state::ThreadGoalStatus::UsageLimited
|
||||
| codex_state::ThreadGoalStatus::Complete => true,
|
||||
};
|
||||
{
|
||||
@@ -1200,6 +1238,59 @@ impl Session {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn usage_limit_active_thread_goal_for_turn(
|
||||
&self,
|
||||
turn_context: &TurnContext,
|
||||
) -> anyhow::Result<()> {
|
||||
if should_ignore_goal_for_mode(turn_context.collaboration_mode.mode) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !self.enabled(Feature::Goals) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let _continuation_guard = self
|
||||
.goal_runtime
|
||||
.continuation_lock
|
||||
.acquire()
|
||||
.await
|
||||
.context("goal continuation semaphore closed")?;
|
||||
let Some(state_db) = self.state_db_for_thread_goals().await? else {
|
||||
return Ok(());
|
||||
};
|
||||
self.account_thread_goal_progress(
|
||||
turn_context,
|
||||
BudgetLimitSteering::Suppressed,
|
||||
TerminalMetricEmission::Emit,
|
||||
)
|
||||
.await?;
|
||||
let previous_status = self
|
||||
.current_goal_status_for_metrics(&state_db, /*expected_goal_id*/ None)
|
||||
.await?;
|
||||
let Some(goal) = state_db
|
||||
.thread_goals()
|
||||
.usage_limit_active_thread_goal(self.conversation_id)
|
||||
.await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
self.emit_goal_terminal_metrics_if_status_changed(previous_status, &goal);
|
||||
let goal = protocol_goal_from_state(goal);
|
||||
*self.goal_runtime.budget_limit_reported_goal_id.lock().await = None;
|
||||
self.clear_active_goal_accounting(turn_context).await;
|
||||
self.send_event(
|
||||
turn_context,
|
||||
EventMsg::ThreadGoalUpdated(ThreadGoalUpdatedEvent {
|
||||
thread_id: self.conversation_id,
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
goal,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn restore_thread_goal_runtime_after_resume(&self) -> anyhow::Result<()> {
|
||||
if !self.enabled(Feature::Goals) {
|
||||
return Ok(());
|
||||
@@ -1239,6 +1330,8 @@ impl Session {
|
||||
self.emit_goal_resumed_metric();
|
||||
}
|
||||
codex_state::ThreadGoalStatus::Paused
|
||||
| codex_state::ThreadGoalStatus::Blocked
|
||||
| codex_state::ThreadGoalStatus::UsageLimited
|
||||
| codex_state::ThreadGoalStatus::BudgetLimited
|
||||
| codex_state::ThreadGoalStatus::Complete => {
|
||||
self.clear_stopped_thread_goal_runtime_state().await;
|
||||
@@ -1594,6 +1687,8 @@ pub(crate) fn protocol_goal_status_from_state(
|
||||
match status {
|
||||
codex_state::ThreadGoalStatus::Active => ThreadGoalStatus::Active,
|
||||
codex_state::ThreadGoalStatus::Paused => ThreadGoalStatus::Paused,
|
||||
codex_state::ThreadGoalStatus::Blocked => ThreadGoalStatus::Blocked,
|
||||
codex_state::ThreadGoalStatus::UsageLimited => ThreadGoalStatus::UsageLimited,
|
||||
codex_state::ThreadGoalStatus::BudgetLimited => ThreadGoalStatus::BudgetLimited,
|
||||
codex_state::ThreadGoalStatus::Complete => ThreadGoalStatus::Complete,
|
||||
}
|
||||
@@ -1605,6 +1700,8 @@ pub(crate) fn state_goal_status_from_protocol(
|
||||
match status {
|
||||
ThreadGoalStatus::Active => codex_state::ThreadGoalStatus::Active,
|
||||
ThreadGoalStatus::Paused => codex_state::ThreadGoalStatus::Paused,
|
||||
ThreadGoalStatus::Blocked => codex_state::ThreadGoalStatus::Blocked,
|
||||
ThreadGoalStatus::UsageLimited => codex_state::ThreadGoalStatus::UsageLimited,
|
||||
ThreadGoalStatus::BudgetLimited => codex_state::ThreadGoalStatus::BudgetLimited,
|
||||
ThreadGoalStatus::Complete => codex_state::ThreadGoalStatus::Complete,
|
||||
}
|
||||
@@ -1683,7 +1780,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continuation_prompt_only_tells_model_to_update_goal_when_complete() {
|
||||
fn continuation_prompt_allows_complete_and_strict_blocked_updates() {
|
||||
let prompt = continuation_prompt(&ThreadGoal {
|
||||
thread_id: ThreadId::new(),
|
||||
objective: "finish the stack".to_string(),
|
||||
@@ -1700,9 +1797,11 @@ mod tests {
|
||||
assert!(prompt.contains("<objective>\nfinish the stack\n</objective>"));
|
||||
assert!(prompt.contains("Token budget: 10000"));
|
||||
assert!(prompt.contains("call update_goal with status \"complete\""));
|
||||
assert!(!prompt.contains(
|
||||
"explain the blocker or next required input to the user and wait for new input"
|
||||
));
|
||||
assert!(prompt.contains("status \"blocked\""));
|
||||
assert!(prompt.contains("at least three consecutive goal turns"));
|
||||
assert!(prompt.contains("same blocking condition"));
|
||||
assert!(prompt.contains("original/user-triggered turn"));
|
||||
assert!(prompt.contains("truly at an impasse"));
|
||||
assert!(!prompt.contains("budgetLimited"));
|
||||
assert!(!prompt.contains("status \"paused\""));
|
||||
}
|
||||
|
||||
@@ -8629,6 +8629,57 @@ async fn budget_limited_accounting_steers_active_turn_without_aborting() -> anyh
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn usage_limit_runtime_stops_active_goal_and_prevents_idle_continuation() -> anyhow::Result<()>
|
||||
{
|
||||
let (sess, tc, _rx, _codex_home) = make_goal_session_and_context_with_rx().await;
|
||||
sess.set_thread_goal(
|
||||
tc.as_ref(),
|
||||
SetGoalRequest {
|
||||
objective: Some("Keep improving the benchmark".to_string()),
|
||||
status: None,
|
||||
token_budget: Some(Some(50)),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
sess.goal_runtime_apply(GoalRuntimeEvent::TurnStarted {
|
||||
turn_context: tc.as_ref(),
|
||||
token_usage: TokenUsage::default(),
|
||||
})
|
||||
.await?;
|
||||
sess.spawn_task(
|
||||
Arc::clone(&tc),
|
||||
Vec::new(),
|
||||
NeverEndingTask {
|
||||
kind: TaskKind::Regular,
|
||||
listen_to_cancellation_token: false,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
set_total_token_usage(&sess, post_goal_token_usage()).await;
|
||||
|
||||
sess.goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached {
|
||||
turn_context: tc.as_ref(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let state_db = goal_test_state_db(sess.as_ref()).await?;
|
||||
let goal = state_db
|
||||
.thread_goals()
|
||||
.get_thread_goal(sess.conversation_id)
|
||||
.await?
|
||||
.expect("goal should remain persisted after usage limiting");
|
||||
assert_eq!(codex_state::ThreadGoalStatus::UsageLimited, goal.status);
|
||||
assert_eq!(70, goal.tokens_used);
|
||||
|
||||
sess.abort_all_tasks(TurnAbortReason::Replaced).await;
|
||||
sess.goal_runtime_apply(GoalRuntimeEvent::MaybeContinueIfIdle)
|
||||
.await?;
|
||||
assert!(sess.active_turn.lock().await.is_none());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn external_goal_mutation_accounts_active_turn_before_status_change() -> anyhow::Result<()> {
|
||||
let (sess, tc, _rx, _codex_home) = make_goal_session_and_context_with_rx().await;
|
||||
@@ -9550,7 +9601,121 @@ async fn update_goal_tool_rejects_pausing_goal() {
|
||||
};
|
||||
assert_eq!(
|
||||
output,
|
||||
"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"
|
||||
);
|
||||
|
||||
let goal = session
|
||||
.get_thread_goal()
|
||||
.await
|
||||
.expect("read thread goal")
|
||||
.expect("goal should still exist");
|
||||
assert_eq!(goal.status, ThreadGoalStatus::Active);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_goal_tool_marks_goal_blocked() {
|
||||
let (session, turn_context, _rx, _codex_home) = make_goal_session_and_context_with_rx().await;
|
||||
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
|
||||
let create_handler = CreateGoalHandler;
|
||||
let update_handler = UpdateGoalHandler;
|
||||
|
||||
create_handler
|
||||
.handle(ToolInvocation {
|
||||
session: Arc::clone(&session),
|
||||
turn: Arc::clone(&turn_context),
|
||||
cancellation_token: CancellationToken::new(),
|
||||
tracker: Arc::clone(&tracker),
|
||||
call_id: "create-goal".to_string(),
|
||||
tool_name: codex_tools::ToolName::plain("create_goal"),
|
||||
source: ToolCallSource::Direct,
|
||||
payload: ToolPayload::Function {
|
||||
arguments: serde_json::json!({
|
||||
"objective": "Keep the watcher alive",
|
||||
"token_budget": 123,
|
||||
})
|
||||
.to_string(),
|
||||
},
|
||||
})
|
||||
.await
|
||||
.expect("initial create_goal should succeed");
|
||||
|
||||
update_handler
|
||||
.handle(ToolInvocation {
|
||||
session: Arc::clone(&session),
|
||||
turn: Arc::clone(&turn_context),
|
||||
cancellation_token: CancellationToken::new(),
|
||||
tracker,
|
||||
call_id: "block-goal".to_string(),
|
||||
tool_name: codex_tools::ToolName::plain("update_goal"),
|
||||
source: ToolCallSource::Direct,
|
||||
payload: ToolPayload::Function {
|
||||
arguments: serde_json::json!({
|
||||
"status": "blocked",
|
||||
})
|
||||
.to_string(),
|
||||
},
|
||||
})
|
||||
.await
|
||||
.expect("update_goal should mark the goal blocked");
|
||||
|
||||
let goal = session
|
||||
.get_thread_goal()
|
||||
.await
|
||||
.expect("read thread goal")
|
||||
.expect("goal should still exist");
|
||||
assert_eq!(goal.status, ThreadGoalStatus::Blocked);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_goal_tool_rejects_usage_limited_goal() {
|
||||
let (session, turn_context, _rx, _codex_home) = make_goal_session_and_context_with_rx().await;
|
||||
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
|
||||
let create_handler = CreateGoalHandler;
|
||||
let update_handler = UpdateGoalHandler;
|
||||
|
||||
create_handler
|
||||
.handle(ToolInvocation {
|
||||
session: Arc::clone(&session),
|
||||
turn: Arc::clone(&turn_context),
|
||||
cancellation_token: CancellationToken::new(),
|
||||
tracker: Arc::clone(&tracker),
|
||||
call_id: "create-goal".to_string(),
|
||||
tool_name: codex_tools::ToolName::plain("create_goal"),
|
||||
source: ToolCallSource::Direct,
|
||||
payload: ToolPayload::Function {
|
||||
arguments: serde_json::json!({
|
||||
"objective": "Keep the watcher alive",
|
||||
})
|
||||
.to_string(),
|
||||
},
|
||||
})
|
||||
.await
|
||||
.expect("initial create_goal should succeed");
|
||||
|
||||
let response = update_handler
|
||||
.handle(ToolInvocation {
|
||||
session: Arc::clone(&session),
|
||||
turn: Arc::clone(&turn_context),
|
||||
cancellation_token: CancellationToken::new(),
|
||||
tracker,
|
||||
call_id: "usage-limit-goal".to_string(),
|
||||
tool_name: codex_tools::ToolName::plain("update_goal"),
|
||||
source: ToolCallSource::Direct,
|
||||
payload: ToolPayload::Function {
|
||||
arguments: serde_json::json!({
|
||||
"status": "usageLimited",
|
||||
})
|
||||
.to_string(),
|
||||
},
|
||||
})
|
||||
.await;
|
||||
|
||||
let Err(FunctionCallError::RespondToModel(output)) = response else {
|
||||
panic!("expected update_goal to reject usage-limiting a goal");
|
||||
};
|
||||
assert_eq!(
|
||||
output,
|
||||
"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"
|
||||
);
|
||||
|
||||
let goal = session
|
||||
|
||||
@@ -20,6 +20,7 @@ use crate::compact_remote_v2::run_inline_remote_auto_compact_task as run_inline_
|
||||
use crate::connectors;
|
||||
use crate::context::ContextualUserFragment;
|
||||
use crate::feedback_tags;
|
||||
use crate::goals::GoalRuntimeEvent;
|
||||
use crate::hook_runtime::PendingInputHookDisposition;
|
||||
use crate::hook_runtime::emit_hook_completed_events;
|
||||
use crate::hook_runtime::inspect_pending_input;
|
||||
@@ -162,7 +163,16 @@ pub(crate) async fn run_turn(
|
||||
let pre_sampling_compact =
|
||||
match run_pre_sampling_compact(&sess, &turn_context, &mut client_session).await {
|
||||
Ok(pre_sampling_compact) => pre_sampling_compact,
|
||||
Err(_) => {
|
||||
Err(err) => {
|
||||
if err.to_codex_protocol_error() == CodexErrorInfo::UsageLimitExceeded
|
||||
&& let Err(err) = sess
|
||||
.goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached {
|
||||
turn_context: turn_context.as_ref(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
warn!("failed to usage-limit active goal after usage-limit error: {err}");
|
||||
}
|
||||
error!("Failed to run pre-sampling compact");
|
||||
return None;
|
||||
}
|
||||
@@ -517,7 +527,20 @@ pub(crate) async fn run_turn(
|
||||
.await
|
||||
{
|
||||
Ok(reset_client_session) => reset_client_session,
|
||||
Err(_) => return None,
|
||||
Err(err) => {
|
||||
if err.to_codex_protocol_error() == CodexErrorInfo::UsageLimitExceeded
|
||||
&& let Err(err) = sess
|
||||
.goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached {
|
||||
turn_context: turn_context.as_ref(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
"failed to usage-limit active goal after usage-limit error: {err}"
|
||||
);
|
||||
}
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if reset_client_session {
|
||||
client_session.reset_websocket_session();
|
||||
@@ -673,6 +696,15 @@ pub(crate) async fn run_turn(
|
||||
}
|
||||
Err(e) => {
|
||||
info!("Turn error: {e:#}");
|
||||
if e.to_codex_protocol_error() == CodexErrorInfo::UsageLimitExceeded
|
||||
&& let Err(err) = sess
|
||||
.goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached {
|
||||
turn_context: turn_context.as_ref(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
warn!("failed to usage-limit active goal after usage-limit error: {err}");
|
||||
}
|
||||
let event = EventMsg::Error(e.to_error_event(/*message_prefix*/ None));
|
||||
sess.send_event(&turn_context, event).await;
|
||||
// let the user continue the conversation
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user