[codex] Steer budget-limited goal extension turns (#23718)

## What
- Add a small extension capability for injecting model-visible response
items into the active turn
- Have the goal extension inject hidden goal-context steering when
tool-finish accounting reaches `BudgetLimited`
- Cover the extension backend path with an assertion on the injected
steering item

## Why
PR #23696 persists and emits the budget-limited goal update from
tool-finish accounting, but it leaves the model unaware of that
transition. The existing core runtime steers the model to wrap up in
this case; the extension path should do the same through an explicit
host capability.

## Testing
- `just fmt`
- `cargo test -p codex-goal-extension`
- `cargo test -p codex-extension-api`
This commit is contained in:
jif-oai
2026-05-21 12:54:00 +02:00
committed by GitHub
parent 20fedafff8
commit 791b69dd53
14 changed files with 298 additions and 34 deletions
@@ -31,10 +31,7 @@ fn detects_subagent_notification_fragment_case_insensitively() {
#[test]
fn detects_goal_context_fragment() {
let text = GoalContext {
prompt: "Continue working toward the active thread goal.".to_string(),
}
.render();
let text = GoalContext::new("Continue working toward the active thread goal.").render();
assert!(is_contextual_user_fragment(&ContentItem::InputText {
text
@@ -43,9 +40,9 @@ fn detects_goal_context_fragment() {
#[test]
fn contextual_user_fragment_is_dyn_compatible() {
let fragment: Box<dyn ContextualUserFragment> = Box::new(GoalContext {
prompt: "Continue working toward the active thread goal.".to_string(),
});
let fragment: Box<dyn ContextualUserFragment> = Box::new(GoalContext::new(
"Continue working toward the active thread goal.",
));
assert_eq!(
fragment.render(),
+25 -2
View File
@@ -1,10 +1,33 @@
//! Hidden user-context fragment for runtime-owned goal steering prompts.
use super::ContextualUserFragment;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseInputItem;
/// Hidden runtime-owned goal steering context injected into model input.
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct GoalContext {
pub(crate) prompt: String,
pub struct GoalContext {
prompt: String,
}
impl GoalContext {
/// Creates goal context around an already-rendered steering prompt.
pub fn new(prompt: impl Into<String>) -> Self {
Self {
prompt: prompt.into(),
}
}
/// Converts the registered fragment into an active-turn injectable item.
pub fn into_response_input_item(self) -> ResponseInputItem {
ResponseInputItem::Message {
role: <Self as ContextualUserFragment>::role().to_string(),
content: vec![ContentItem::InputText {
text: self.render(),
}],
phase: None,
}
}
}
impl ContextualUserFragment for GoalContext {
+1 -1
View File
@@ -40,7 +40,7 @@ pub(crate) use environment_context::EnvironmentContext;
pub use fragment::ContextualUserFragment;
pub(crate) use fragment::FragmentRegistration;
pub(crate) use fragment::FragmentRegistrationProxy;
pub(crate) use goal_context::GoalContext;
pub use goal_context::GoalContext;
pub(crate) use guardian_followup_review_reminder::GuardianFollowupReviewReminder;
pub(crate) use hook_additional_context::HookAdditionalContext;
pub(crate) use image_generation_instructions::ImageGenerationInstructions;
+1 -4
View File
@@ -322,10 +322,7 @@ fn goal_context_does_not_parse_as_visible_turn_item() {
id: Some("msg-1".to_string()),
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: GoalContext {
prompt: "Continue working toward the active thread goal.".to_string(),
}
.render(),
text: GoalContext::new("Continue working toward the active thread goal.").render(),
}],
phase: None,
};
+1 -10
View File
@@ -5,7 +5,6 @@
//! events, and owns helper hooks used by goal lifecycle behavior.
use crate::StateDbHandle;
use crate::context::ContextualUserFragment;
use crate::context::GoalContext;
use crate::session::TurnInput;
use crate::session::session::Session;
@@ -26,7 +25,6 @@ use codex_otel::GOAL_TOKEN_COUNT_METRIC;
use codex_otel::GOAL_USAGE_LIMITED_METRIC;
use codex_protocol::ThreadId;
use codex_protocol::config_types::ModeKind;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ThreadGoal;
@@ -1632,14 +1630,7 @@ fn budget_limit_steering_item(goal: &ThreadGoal) -> ResponseInputItem {
}
fn goal_context_input_item(prompt: String) -> ResponseInputItem {
let context = GoalContext { prompt };
ResponseInputItem::Message {
role: GoalContext::role().to_string(),
content: vec![ContentItem::InputText {
text: context.render(),
}],
phase: None,
}
GoalContext::new(prompt).into_response_input_item()
}
pub(crate) fn protocol_goal_from_state(goal: codex_state::ThreadGoal) -> ThreadGoal {