Fix goal extension lifecycle edges

This commit is contained in:
Eric Traut
2026-05-27 16:03:14 -07:00
parent ceec9b9660
commit 84e2863e9e
24 changed files with 364 additions and 250 deletions
-1
View File
@@ -2970,7 +2970,6 @@ dependencies = [
"anyhow",
"async-trait",
"chrono",
"codex-core",
"codex-extension-api",
"codex-otel",
"codex-protocol",
+27 -1
View File
@@ -13,10 +13,13 @@ use codex_extension_api::ExtensionEventFuture;
use codex_extension_api::ExtensionEventSink;
use codex_extension_api::ExtensionRegistry;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::ResponseItemInjectionFuture;
use codex_extension_api::ResponseItemInjector;
use codex_features::Feature;
use codex_login::AuthManager;
use codex_protocol::ThreadId;
use codex_protocol::error::CodexErr;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_rollout::state_db::StateDbHandle;
@@ -40,7 +43,7 @@ where
&mut builder,
state_db,
codex_otel::global(),
thread_manager,
Arc::new(ThreadManagerResponseItemInjector { thread_manager }),
|config: &Config| config.features.enabled(Feature::Goals),
);
}
@@ -49,6 +52,29 @@ where
Arc::new(builder.build())
}
struct ThreadManagerResponseItemInjector {
thread_manager: Weak<ThreadManager>,
}
impl ResponseItemInjector for ThreadManagerResponseItemInjector {
fn inject_response_items<'a>(
&'a self,
thread_id: ThreadId,
items: Vec<ResponseInputItem>,
) -> ResponseItemInjectionFuture<'a> {
let thread_manager = self.thread_manager.clone();
Box::pin(async move {
let Some(thread_manager) = thread_manager.upgrade() else {
return Err(items);
};
let Ok(thread) = thread_manager.get_thread(thread_id).await else {
return Err(items);
};
thread.inject_response_items_into_active_turn(items).await
})
}
}
pub(crate) fn app_server_extension_event_sink(
outgoing: Arc<OutgoingMessageSender>,
) -> Arc<dyn ExtensionEventSink> {
+5 -7
View File
@@ -176,13 +176,11 @@ impl CodexThread {
.thread_lifecycle_contributors()
{
contributor
.on_thread_resume_with_settings(
codex_extension_api::ThreadResumeWithSettingsInput {
thread_settings: &thread_settings,
session_store: &self.codex.session.services.session_extension_data,
thread_store: &self.codex.session.services.thread_extension_data,
},
)
.on_thread_resume(codex_extension_api::ThreadResumeInput {
thread_settings: &thread_settings,
session_store: &self.codex.session.services.session_extension_data,
thread_store: &self.codex.session.services.thread_extension_data,
})
.await;
}
}
@@ -4,9 +4,9 @@ use codex_protocol::models::ContentItem;
use super::AdditionalContextUserFragment;
use super::EnvironmentContext;
use super::ExtensionContext;
use super::FragmentRegistration;
use super::FragmentRegistrationProxy;
use super::GoalContext;
use super::LegacyApplyPatchExecCommandWarning;
use super::LegacyModelMismatchWarning;
use super::LegacyUnifiedExecProcessLimitWarning;
@@ -22,7 +22,7 @@ static ENVIRONMENT_CONTEXT_REGISTRATION: FragmentRegistrationProxy<EnvironmentCo
FragmentRegistrationProxy::new();
static ADDITIONAL_CONTEXT_REGISTRATION: FragmentRegistrationProxy<AdditionalContextUserFragment> =
FragmentRegistrationProxy::new();
static EXTENSION_CONTEXT_REGISTRATION: FragmentRegistrationProxy<ExtensionContext> =
static GOAL_CONTEXT_REGISTRATION: FragmentRegistrationProxy<GoalContext> =
FragmentRegistrationProxy::new();
static SKILL_INSTRUCTIONS_REGISTRATION: FragmentRegistrationProxy<SkillInstructions> =
FragmentRegistrationProxy::new();
@@ -46,7 +46,7 @@ static CONTEXTUAL_USER_FRAGMENTS: &[&dyn FragmentRegistration] = &[
&USER_INSTRUCTIONS_REGISTRATION,
&ENVIRONMENT_CONTEXT_REGISTRATION,
&ADDITIONAL_CONTEXT_REGISTRATION,
&EXTENSION_CONTEXT_REGISTRATION,
&GOAL_CONTEXT_REGISTRATION,
&SKILL_INSTRUCTIONS_REGISTRATION,
&USER_SHELL_COMMAND_REGISTRATION,
&TURN_ABORTED_REGISTRATION,
@@ -14,9 +14,16 @@ fn detects_environment_context_fragment() {
}
#[test]
fn detects_extension_context_fragment() {
fn detects_legacy_goal_context_fragment() {
assert!(is_contextual_user_fragment(&ContentItem::InputText {
text: "<extension_context>\n<example>data</example>\n</extension_context>".to_string(),
text: "<goal_context>\nlegacy hidden goal prompt\n</goal_context>".to_string(),
}));
}
#[test]
fn does_not_treat_generic_extension_context_as_hidden() {
assert!(!is_contextual_user_fragment(&ContentItem::InputText {
text: "<extension_context>\nuser-authored text\n</extension_context>".to_string(),
}));
}
@@ -1,44 +0,0 @@
//! Hidden user-context wrappers owned by extensions.
//!
//! Extensions can use this host-recognized tag when they need hidden
//! model-visible context to survive history filtering.
use super::ContextualUserFragment;
use super::fragment::matches_marked_text;
const START_MARKER: &str = "<extension_context>";
const END_MARKER: &str = "</extension_context>";
/// Hidden user-context fragment for extension-owned steering prompts.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExtensionContext {
body: String,
}
impl ExtensionContext {
pub fn new(body: impl Into<String>) -> Self {
Self { body: body.into() }
}
}
impl ContextualUserFragment for ExtensionContext {
fn role() -> &'static str {
"user"
}
fn markers(&self) -> (&'static str, &'static str) {
(START_MARKER, END_MARKER)
}
fn type_markers() -> (&'static str, &'static str) {
(START_MARKER, END_MARKER)
}
fn matches_text(text: &str) -> bool {
matches_marked_text(START_MARKER, END_MARKER, text)
}
fn body(&self) -> String {
format!("\n{}\n", self.body)
}
}
+29
View File
@@ -0,0 +1,29 @@
//! Legacy hidden user-context marker for goal steering prompts.
//!
//! The goal implementation now lives in `codex-goal-extension`, but existing
//! rollouts can still contain this marker. Core keeps recognizing it so old
//! hidden goal prompts do not resurface as normal user messages after resume.
use super::ContextualUserFragment;
/// Marker-only registration for hidden goal steering context.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct GoalContext;
impl ContextualUserFragment for GoalContext {
fn role() -> &'static str {
"user"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn body(&self) -> String {
String::new()
}
fn type_markers() -> (&'static str, &'static str) {
("<goal_context>", "</goal_context>")
}
}
+2 -2
View File
@@ -7,9 +7,9 @@ mod available_skills_instructions;
mod collaboration_mode_instructions;
mod contextual_user_message;
mod environment_context;
mod extension_context;
mod fragment;
mod fragments;
mod goal_context;
mod guardian_followup_review_reminder;
mod hook_additional_context;
mod image_generation_instructions;
@@ -38,12 +38,12 @@ pub(crate) use collaboration_mode_instructions::CollaborationModeInstructions;
pub(crate) use contextual_user_message::is_contextual_user_fragment;
pub(crate) use contextual_user_message::parse_visible_hook_prompt_message;
pub(crate) use environment_context::EnvironmentContext;
pub use extension_context::ExtensionContext;
pub use fragment::ContextualUserFragment;
pub(crate) use fragment::FragmentRegistration;
pub(crate) use fragment::FragmentRegistrationProxy;
pub(crate) use fragments::AdditionalContextDeveloperFragment;
pub(crate) use fragments::AdditionalContextUserFragment;
pub(crate) 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;
+37
View File
@@ -3,6 +3,7 @@ use crate::state::MailboxDeliveryPhase;
use crate::state::TurnState;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::protocol::InterAgentCommunication;
use codex_protocol::protocol::TokenUsage;
use codex_protocol::user_input::UserInput;
use std::collections::VecDeque;
use std::sync::Arc;
@@ -181,6 +182,42 @@ impl InputQueue {
turn_state.lock().await.pending_input.items.split_off(0)
}
#[expect(
clippy::await_holding_invalid_type,
reason = "startup input must move only while the claimed active turn is still current"
)]
pub(crate) async fn prepare_starting_turn_input(
&self,
active_turn: &mut ActiveTurn,
token_usage_at_turn_start: TokenUsage,
) {
let queued_response_items = self.take_queued_response_items_for_next_turn().await;
let mut turn_state = active_turn.turn_state.lock().await;
turn_state.token_usage_at_turn_start = token_usage_at_turn_start;
let existing_pending_input = turn_state.pending_input.items.split_off(0);
let accepts_mailbox_delivery = turn_state.accepts_mailbox_delivery_for_current_turn();
turn_state.pending_input.items.extend(
queued_response_items
.into_iter()
.map(TurnInput::ResponseInputItem),
);
turn_state
.pending_input
.items
.extend(existing_pending_input);
drop(turn_state);
if accepts_mailbox_delivery {
let mailbox_items = self.drain_mailbox_input_items().await;
active_turn
.turn_state
.lock()
.await
.pending_input
.items
.extend(mailbox_items.into_iter().map(TurnInput::ResponseInputItem));
}
}
#[expect(
clippy::await_holding_invalid_type,
reason = "active turn checks and turn state updates must remain atomic"
+68 -2
View File
@@ -8271,8 +8271,10 @@ async fn start_task_does_not_overwrite_racing_active_task() {
#[tokio::test]
async fn start_task_does_not_drain_input_while_another_start_is_claiming_turn() {
let (sess, tc, _rx) = make_session_and_context_with_rx().await;
let mut active_turn = ActiveTurn::default();
active_turn.task_starting = true;
let active_turn = ActiveTurn {
task_starting: true,
..Default::default()
};
*sess.active_turn.lock().await = Some(active_turn);
let queued_item = ResponseInputItem::Message {
@@ -8310,6 +8312,70 @@ async fn start_task_does_not_drain_input_while_another_start_is_claiming_turn()
);
}
#[tokio::test]
async fn abort_during_turn_start_lifecycle_preserves_queued_input() {
struct BlockingTurnStart {
entered: Arc<tokio::sync::Notify>,
release: Arc<tokio::sync::Notify>,
}
#[async_trait::async_trait]
impl codex_extension_api::TurnLifecycleContributor for BlockingTurnStart {
async fn on_turn_start(&self, _input: codex_extension_api::TurnStartInput<'_>) {
self.entered.notify_waiters();
self.release.notified().await;
}
}
let (mut session, turn_context) = make_session_and_context().await;
let entered = Arc::new(tokio::sync::Notify::new());
let release = Arc::new(tokio::sync::Notify::new());
let mut builder = codex_extension_api::ExtensionRegistryBuilder::<crate::config::Config>::new();
builder.turn_lifecycle_contributor(Arc::new(BlockingTurnStart {
entered: Arc::clone(&entered),
release: Arc::clone(&release),
}));
session.services.extensions = Arc::new(builder.build());
let sess = Arc::new(session);
let queued_item = ResponseInputItem::Message {
role: "assistant".to_string(),
content: vec![ContentItem::InputText {
text: "queued before lifecycle".to_string(),
}],
phase: None,
};
sess.input_queue
.queue_response_items_for_next_turn(vec![queued_item])
.await;
let start_task = tokio::spawn({
let sess = Arc::clone(&sess);
async move {
sess.start_task(
Arc::new(turn_context),
Vec::new(),
NeverEndingTask {
kind: TaskKind::Regular,
listen_to_cancellation_token: true,
},
)
.await;
}
});
entered.notified().await;
sess.abort_all_tasks(TurnAbortReason::Interrupted).await;
release.notify_waiters();
start_task.await.expect("start task should finish");
assert!(
sess.input_queue
.has_queued_response_items_for_next_turn()
.await
);
assert!(sess.active_turn.lock().await.is_none());
}
#[tokio::test]
async fn idle_interrupt_does_not_wake_queued_next_turn_items() {
let (sess, _tc, _rx) = make_session_and_context_with_rx().await;
+20 -27
View File
@@ -3,12 +3,8 @@
use std::sync::Arc;
use codex_extension_api::ThreadIdleRequest;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::protocol::ThreadSettingsSnapshot;
use crate::context::ContextualUserFragment;
use crate::context::ExtensionContext;
use crate::session::TurnInput;
use crate::session::session::Session;
use crate::state::ActiveTurn;
@@ -89,7 +85,7 @@ pub(crate) async fn maybe_start_turn(session: Arc<Session>) {
return;
}
let input = vec![idle_extension_input(candidate.request.prompt)];
let input = vec![idle_extension_input(candidate.request)];
// Treat extension-provided prompts as the new turn's initial input rather than stashing them
// in turn-state pending input; this keeps rollback logic out of the scheduler.
@@ -108,7 +104,7 @@ async fn notify_thread_idle(session: &Session) {
let thread_settings = thread_settings_snapshot(session).await;
for contributor in session.services.extensions.thread_lifecycle_contributors() {
contributor
.on_thread_idle_with_settings(codex_extension_api::ThreadIdleWithSettingsInput {
.on_thread_idle(codex_extension_api::ThreadIdleInput {
thread_settings: &thread_settings,
session_store: &session.services.session_extension_data,
thread_store: &session.services.thread_extension_data,
@@ -144,13 +140,11 @@ async fn next_idle_turn_candidate(session: &Session) -> Option<IdleTurnCandidate
.enumerate()
{
let Some(request) = contributor
.request_thread_idle_turn_with_settings(
codex_extension_api::ThreadIdleWithSettingsInput {
thread_settings: &thread_settings,
session_store: &session.services.session_extension_data,
thread_store: &session.services.thread_extension_data,
},
)
.request_thread_idle_turn(codex_extension_api::ThreadIdleInput {
thread_settings: &thread_settings,
session_store: &session.services.session_extension_data,
thread_store: &session.services.thread_extension_data,
})
.await
else {
continue;
@@ -209,25 +203,21 @@ async fn clear_reserved_idle_turn(session: &Session) {
}
}
fn idle_extension_input(prompt: String) -> TurnInput {
fn idle_extension_input(request: ThreadIdleRequest) -> TurnInput {
let prompt = codex_utils_string::truncate_middle_with_token_budget(
&prompt,
&request.prompt,
MAX_IDLE_EXTENSION_PROMPT_TOKENS,
)
.0;
if ExtensionContext::matches_text(&prompt) {
return TurnInput::ResponseInputItem(ResponseInputItem::Message {
role: ExtensionContext::role().to_string(),
content: vec![ContentItem::InputText { text: prompt }],
phase: None,
});
}
TurnInput::ResponseInputItem(ExtensionContext::new(prompt).into_response_input_item())
TurnInput::ResponseInputItem(request.context_marker.response_input_item(prompt))
}
#[cfg(test)]
mod tests {
use codex_extension_api::HiddenContextMarker;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseInputItem;
use super::*;
#[test]
@@ -238,9 +228,12 @@ mod tests {
);
let original_len = prompt.len();
let prompt = ExtensionContext::new(prompt).render();
let request = ThreadIdleRequest::new(
HiddenContextMarker::new("<goal_context>", "</goal_context>"),
prompt,
);
let TurnInput::ResponseInputItem(ResponseInputItem::Message { content, .. }) =
idle_extension_input(prompt)
idle_extension_input(request)
else {
panic!("expected message input");
};
@@ -248,7 +241,7 @@ mod tests {
panic!("expected one text content item");
};
assert!(text.starts_with("<extension_context>"));
assert!(text.starts_with("<goal_context>"));
assert!(text.contains("start"));
assert!(text.contains("end"));
assert!(text.len() < original_len);
+46 -54
View File
@@ -311,6 +311,10 @@ impl Session {
self.start_task(turn_context, input, task).await;
}
#[expect(
clippy::await_holding_invalid_type,
reason = "the active-turn guard must cover spawning until RunningTask is installed"
)]
pub(crate) async fn start_task<T: SessionTask>(
self: &Arc<Self>,
turn_context: Arc<TurnContext>,
@@ -348,32 +352,10 @@ impl Session {
.await
.clear_turn(&turn_context.sub_id);
let queued_response_items = self
.input_queue
.take_queued_response_items_for_next_turn()
.await;
let mailbox_items = self.input_queue.get_pending_input(&self.active_turn).await;
turn_state.lock().await.token_usage_at_turn_start = token_usage_at_turn_start.clone();
let mut pending_items = queued_response_items
.into_iter()
.map(TurnInput::ResponseInputItem)
.collect::<Vec<_>>();
pending_items.extend(mailbox_items);
self.input_queue
.extend_pending_input_for_turn_state(turn_state.as_ref(), pending_items)
.await;
self.emit_turn_start_lifecycle(turn_context.as_ref(), &token_usage_at_turn_start)
.await;
let turn_extension_data = Arc::clone(&turn_context.extension_data);
let mut active = self.active_turn.lock().await;
let Some(turn) = active.as_mut() else {
return;
};
if !Arc::ptr_eq(&turn.turn_state, &turn_state) || !turn.task_starting || turn.task.is_some()
{
return;
}
let done_clone = Arc::clone(&done);
let session_ctx = Arc::new(SessionTaskContext::new(
Arc::clone(self),
@@ -400,43 +382,53 @@ impl Session {
codex.turn.token_usage.reasoning_output_tokens = field::Empty,
codex.turn.token_usage.total_tokens = field::Empty,
);
let handle = tokio::spawn(
async move {
let ctx_for_finish = Arc::clone(&ctx);
let last_agent_message = task_for_run
.run(
Arc::clone(&session_ctx),
ctx,
task_input,
task_cancellation_token.child_token(),
)
.await;
let sess = session_ctx.clone_session();
if let Err(err) = sess.flush_rollout().await {
warn!("failed to flush rollout before completing turn: {err}");
sess.send_event(
ctx_for_finish.as_ref(),
EventMsg::Warning(WarningEvent {
message: format!(
"Failed to save the conversation transcript; Codex will continue retrying. Error: {err}"
),
}),
)
.await;
}
if !task_cancellation_token.is_cancelled() {
// Emit completion uniformly from spawn site so all tasks share the same lifecycle.
sess.on_task_finished(Arc::clone(&ctx_for_finish), last_agent_message)
.await;
}
done_clone.notify_waiters();
let task_future = async move {
let ctx_for_finish = Arc::clone(&ctx);
let last_agent_message = task_for_run
.run(
Arc::clone(&session_ctx),
ctx,
task_input,
task_cancellation_token.child_token(),
)
.await;
let sess = session_ctx.clone_session();
if let Err(err) = sess.flush_rollout().await {
warn!("failed to flush rollout before completing turn: {err}");
sess.send_event(
ctx_for_finish.as_ref(),
EventMsg::Warning(WarningEvent {
message: format!(
"Failed to save the conversation transcript; Codex will continue retrying. Error: {err}"
),
}),
)
.await;
}
.instrument(task_span),
);
if !task_cancellation_token.is_cancelled() {
// Emit completion uniformly from spawn site so all tasks share the same lifecycle.
sess.on_task_finished(Arc::clone(&ctx_for_finish), last_agent_message)
.await;
}
done_clone.notify_waiters();
}
.instrument(task_span);
let timer = turn_context
.session_telemetry
.start_timer(TURN_E2E_DURATION_METRIC, &[])
.ok();
let mut active = self.active_turn.lock().await;
let Some(turn) = active.as_mut() else {
return;
};
if !Arc::ptr_eq(&turn.turn_state, &turn_state) || !turn.task_starting || turn.task.is_some()
{
return;
}
self.input_queue
.prepare_starting_turn_input(turn, token_usage_at_turn_start)
.await;
let handle = tokio::spawn(task_future);
let running_task = RunningTask {
done,
handle: AbortOnDropHandle::new(handle),
@@ -1,6 +1,7 @@
use std::future::Future;
use std::pin::Pin;
use codex_protocol::ThreadId;
use codex_protocol::models::ResponseInputItem;
/// Future returned when an extension asks the host to inject model-visible input.
@@ -15,6 +16,7 @@ pub type ResponseItemInjectionFuture<'a> =
pub trait ResponseItemInjector: Send + Sync {
fn inject_response_items<'a>(
&'a self,
thread_id: ThreadId,
items: Vec<ResponseInputItem>,
) -> ResponseItemInjectionFuture<'a>;
}
@@ -26,6 +28,7 @@ pub struct NoopResponseItemInjector;
impl ResponseItemInjector for NoopResponseItemInjector {
fn inject_response_items<'a>(
&'a self,
_thread_id: ThreadId,
items: Vec<ResponseInputItem>,
) -> ResponseItemInjectionFuture<'a> {
Box::pin(std::future::ready(Err(items)))
+37
View File
@@ -0,0 +1,37 @@
//! Helpers for extension-owned hidden context messages.
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseInputItem;
/// Marker pair for extension-owned hidden model-visible context.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct HiddenContextMarker {
start_marker: &'static str,
end_marker: &'static str,
}
impl HiddenContextMarker {
/// Creates a marker pair for one extension-owned hidden context shape.
pub const fn new(start_marker: &'static str, end_marker: &'static str) -> Self {
Self {
start_marker,
end_marker,
}
}
/// Renders this marker pair as a user-role response input item.
pub fn response_input_item(&self, body: impl AsRef<str>) -> ResponseInputItem {
ResponseInputItem::Message {
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: format!(
"{}\n{}\n{}",
self.start_marker,
body.as_ref(),
self.end_marker
),
}],
phase: None,
}
}
}
+4 -23
View File
@@ -22,9 +22,7 @@ pub use prompt::PromptSlot;
pub use thread_lifecycle::ThreadIdleInput;
pub use thread_lifecycle::ThreadIdleRequest;
pub use thread_lifecycle::ThreadIdleTurnStartInput;
pub use thread_lifecycle::ThreadIdleWithSettingsInput;
pub use thread_lifecycle::ThreadResumeInput;
pub use thread_lifecycle::ThreadResumeWithSettingsInput;
pub use thread_lifecycle::ThreadStartInput;
pub use thread_lifecycle::ThreadStopInput;
pub use tool_lifecycle::ToolCallOutcome;
@@ -60,21 +58,13 @@ pub trait ThreadLifecycleContributor<C: Sync>: Send + Sync {
/// Called after the host constructs a runtime from persisted history.
async fn on_thread_resume(&self, _input: ThreadResumeInput<'_>) {}
/// Called after the host constructs a runtime from persisted history, with
/// a snapshot of host-owned thread settings.
async fn on_thread_resume_with_settings(&self, input: ThreadResumeWithSettingsInput<'_>) {
self.on_thread_resume(input.without_settings()).await;
}
/// Called after the host has drained immediately pending thread work.
///
/// Implementations may use host capabilities captured by the extension to
/// submit follow-up input. The host remains responsible for deciding
/// whether that input starts a turn, is queued, or is ignored.
async fn on_thread_idle(&self, _input: ThreadIdleInput<'_>) {}
/// Called after the host has drained immediately pending thread work, with
/// a snapshot of host-owned thread settings.
async fn on_thread_idle_with_settings(&self, input: ThreadIdleWithSettingsInput<'_>) {
self.on_thread_idle(input.without_settings()).await;
}
/// Called before the host drops the thread runtime and thread-scoped store.
async fn on_thread_stop(&self, _input: ThreadStopInput<'_>) {}
}
@@ -101,15 +91,6 @@ pub trait ThreadIdleTurnContributor: Send + Sync {
Box::pin(std::future::ready(None))
}
/// Returns one hidden prompt body to start an idle turn, with a snapshot of
/// host-owned thread settings.
fn request_thread_idle_turn_with_settings<'a>(
&'a self,
input: ThreadIdleWithSettingsInput<'a>,
) -> ThreadIdleTurnRequestFuture<'a> {
self.request_thread_idle_turn(input.without_settings())
}
/// Confirms that the idle-turn request is still current immediately before
/// the host starts the turn.
fn should_start_thread_idle_turn<'a>(
@@ -1,4 +1,5 @@
use crate::ExtensionData;
use crate::HiddenContextMarker;
use codex_protocol::protocol::ThreadSettingsSnapshot;
/// Input supplied when the host starts a runtime for a thread.
@@ -13,14 +14,6 @@ pub struct ThreadStartInput<'a, C> {
/// Input supplied when the host resumes an existing thread.
pub struct ThreadResumeInput<'a> {
/// Store scoped to the host session runtime.
pub session_store: &'a ExtensionData,
/// Store scoped to this thread runtime.
pub thread_store: &'a ExtensionData,
}
/// Settings-aware input supplied when the host resumes an existing thread.
pub struct ThreadResumeWithSettingsInput<'a> {
/// Current host-owned thread settings at resume time.
pub thread_settings: &'a ThreadSettingsSnapshot,
/// Store scoped to the host session runtime.
@@ -29,25 +22,8 @@ pub struct ThreadResumeWithSettingsInput<'a> {
pub thread_store: &'a ExtensionData,
}
impl<'a> ThreadResumeWithSettingsInput<'a> {
pub fn without_settings(&self) -> ThreadResumeInput<'a> {
ThreadResumeInput {
session_store: self.session_store,
thread_store: self.thread_store,
}
}
}
/// Input supplied when the host has no immediately pending thread work.
pub struct ThreadIdleInput<'a> {
/// Store scoped to the host session runtime.
pub session_store: &'a ExtensionData,
/// Store scoped to this thread runtime.
pub thread_store: &'a ExtensionData,
}
/// Settings-aware input supplied when the host has no immediately pending thread work.
pub struct ThreadIdleWithSettingsInput<'a> {
/// Current host-owned thread settings for the idle thread.
pub thread_settings: &'a ThreadSettingsSnapshot,
/// Store scoped to the host session runtime.
@@ -56,27 +32,21 @@ pub struct ThreadIdleWithSettingsInput<'a> {
pub thread_store: &'a ExtensionData,
}
impl<'a> ThreadIdleWithSettingsInput<'a> {
pub fn without_settings(&self) -> ThreadIdleInput<'a> {
ThreadIdleInput {
session_store: self.session_store,
thread_store: self.thread_store,
}
}
}
/// Extension request to start a new idle turn.
pub struct ThreadIdleRequest {
/// Hidden prompt body that the host wraps as extension-owned context.
pub prompt: String,
/// Marker pair used to wrap the prompt as hidden extension-owned context.
pub context_marker: HiddenContextMarker,
/// Opaque extension-owned key used to reject stale requests before start.
pub validation_key: Option<String>,
}
impl ThreadIdleRequest {
pub fn new(prompt: impl Into<String>) -> Self {
pub fn new(context_marker: HiddenContextMarker, prompt: impl Into<String>) -> Self {
Self {
prompt: prompt.into(),
context_marker,
validation_key: None,
}
}
+2 -2
View File
@@ -1,4 +1,5 @@
mod capabilities;
mod context;
mod contributors;
mod registry;
mod state;
@@ -23,6 +24,7 @@ pub use codex_tools::ToolPayload;
pub use codex_tools::ToolSpec;
pub use codex_tools::parse_tool_input_schema;
pub use codex_tools::parse_tool_input_schema_without_compaction;
pub use context::HiddenContextMarker;
pub use contributors::ApprovalReviewContributor;
pub use contributors::ConfigContributor;
pub use contributors::ContextContributor;
@@ -34,10 +36,8 @@ pub use contributors::ThreadIdleTurnContributor;
pub use contributors::ThreadIdleTurnRequestFuture;
pub use contributors::ThreadIdleTurnStartFuture;
pub use contributors::ThreadIdleTurnStartInput;
pub use contributors::ThreadIdleWithSettingsInput;
pub use contributors::ThreadLifecycleContributor;
pub use contributors::ThreadResumeInput;
pub use contributors::ThreadResumeWithSettingsInput;
pub use contributors::ThreadStartInput;
pub use contributors::ThreadStopInput;
pub use contributors::TokenUsageContributor;
-1
View File
@@ -15,7 +15,6 @@ workspace = true
[dependencies]
async-trait = { workspace = true }
codex-core = { workspace = true }
codex-extension-api = { workspace = true }
codex-otel = { workspace = true }
codex-protocol = { workspace = true }
+12 -13
View File
@@ -1,19 +1,18 @@
use std::sync::Arc;
use std::sync::Weak;
use async_trait::async_trait;
use codex_core::ThreadManager;
use codex_extension_api::ConfigContributor;
use codex_extension_api::ExtensionData;
use codex_extension_api::ExtensionEventSink;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::ResponseItemInjector;
use codex_extension_api::ThreadIdleInput;
use codex_extension_api::ThreadIdleTurnContributor;
use codex_extension_api::ThreadIdleTurnRequestFuture;
use codex_extension_api::ThreadIdleTurnStartFuture;
use codex_extension_api::ThreadIdleTurnStartInput;
use codex_extension_api::ThreadIdleWithSettingsInput;
use codex_extension_api::ThreadLifecycleContributor;
use codex_extension_api::ThreadResumeWithSettingsInput;
use codex_extension_api::ThreadResumeInput;
use codex_extension_api::ThreadStartInput;
use codex_extension_api::TokenUsageContributor;
use codex_extension_api::ToolCallOutcome;
@@ -49,7 +48,7 @@ pub struct GoalExtension<C> {
state_dbs: Arc<codex_state::StateRuntime>,
event_emitter: GoalEventEmitter,
metrics: GoalMetrics,
thread_manager: Weak<ThreadManager>,
response_item_injector: Arc<dyn ResponseItemInjector>,
goals_enabled: Arc<dyn Fn(&C) -> bool + Send + Sync>,
}
@@ -64,14 +63,14 @@ impl<C> GoalExtension<C> {
state_dbs: Arc<codex_state::StateRuntime>,
event_sink: Arc<dyn ExtensionEventSink>,
metrics_client: Option<MetricsClient>,
thread_manager: Weak<ThreadManager>,
response_item_injector: Arc<dyn ResponseItemInjector>,
goals_enabled: impl Fn(&C) -> bool + Send + Sync + 'static,
) -> Self {
Self {
state_dbs,
event_emitter: GoalEventEmitter::new(event_sink),
metrics: GoalMetrics::new(metrics_client),
thread_manager,
response_item_injector,
goals_enabled: Arc::new(goals_enabled),
}
}
@@ -96,7 +95,7 @@ where
Arc::clone(&self.state_dbs),
self.event_emitter.clone(),
self.metrics.clone(),
self.thread_manager.clone(),
Arc::clone(&self.response_item_injector),
accounting_state,
enabled,
)
@@ -104,7 +103,7 @@ where
runtime.set_enabled(enabled);
}
async fn on_thread_resume_with_settings(&self, input: ThreadResumeWithSettingsInput<'_>) {
async fn on_thread_resume(&self, input: ThreadResumeInput<'_>) {
let Some(runtime) = goal_runtime_handle(input.thread_store) else {
return;
};
@@ -122,9 +121,9 @@ impl<C> ThreadIdleTurnContributor for GoalExtension<C>
where
C: Send + Sync + 'static,
{
fn request_thread_idle_turn_with_settings<'a>(
fn request_thread_idle_turn<'a>(
&'a self,
input: ThreadIdleWithSettingsInput<'a>,
input: ThreadIdleInput<'a>,
) -> ThreadIdleTurnRequestFuture<'a> {
Box::pin(async move {
let runtime = goal_runtime_handle(input.thread_store)?;
@@ -456,7 +455,7 @@ pub fn install_with_backend<C>(
registry: &mut ExtensionRegistryBuilder<C>,
state_dbs: Arc<codex_state::StateRuntime>,
metrics_client: Option<MetricsClient>,
thread_manager: Weak<ThreadManager>,
response_item_injector: Arc<dyn ResponseItemInjector>,
goals_enabled: impl Fn(&C) -> bool + Send + Sync + 'static,
) where
C: Send + Sync + 'static,
@@ -465,7 +464,7 @@ pub fn install_with_backend<C>(
state_dbs,
registry.event_sink(),
metrics_client,
thread_manager,
response_item_injector,
goals_enabled,
));
registry.thread_lifecycle_contributor(extension.clone());
+8 -15
View File
@@ -1,9 +1,8 @@
use std::sync::Arc;
use std::sync::Weak;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use codex_core::ThreadManager;
use codex_extension_api::ResponseItemInjector;
use codex_protocol::ThreadId;
use codex_protocol::config_types::ModeKind;
use codex_protocol::models::ResponseInputItem;
@@ -28,7 +27,7 @@ struct GoalRuntimeInner {
state_dbs: Arc<codex_state::StateRuntime>,
event_emitter: GoalEventEmitter,
metrics: GoalMetrics,
thread_manager: Weak<ThreadManager>,
response_item_injector: Arc<dyn ResponseItemInjector>,
accounting_state: Arc<GoalAccountingState>,
enabled: AtomicBool,
}
@@ -50,7 +49,7 @@ impl GoalRuntimeHandle {
state_dbs: Arc<codex_state::StateRuntime>,
event_emitter: GoalEventEmitter,
metrics: GoalMetrics,
thread_manager: Weak<ThreadManager>,
response_item_injector: Arc<dyn ResponseItemInjector>,
accounting_state: Arc<GoalAccountingState>,
enabled: bool,
) -> Self {
@@ -60,7 +59,7 @@ impl GoalRuntimeHandle {
state_dbs,
event_emitter,
metrics,
thread_manager,
response_item_injector,
accounting_state,
enabled: AtomicBool::new(enabled),
}),
@@ -325,16 +324,10 @@ impl GoalRuntimeHandle {
}
pub(crate) async fn inject_active_turn_steering(&self, item: ResponseInputItem) {
let Some(thread_manager) = self.inner.thread_manager.upgrade() else {
tracing::debug!("skipping goal steering because thread manager is unavailable");
return;
};
let Ok(thread) = thread_manager.get_thread(self.inner.thread_id).await else {
tracing::debug!("skipping goal steering because live thread is unavailable");
return;
};
if thread
.inject_response_items_into_active_turn(vec![item])
if self
.inner
.response_item_injector
.inject_response_items(self.inner.thread_id, vec![item])
.await
.is_err()
{
+9 -7
View File
@@ -1,11 +1,13 @@
use codex_core::context::ContextualUserFragment;
use codex_core::context::ExtensionContext;
use codex_extension_api::HiddenContextMarker;
use codex_extension_api::ThreadIdleRequest;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::protocol::ThreadGoal;
use codex_utils_template::Template;
use std::sync::LazyLock;
const GOAL_CONTEXT_MARKER: HiddenContextMarker =
HiddenContextMarker::new("<goal_context>", "</goal_context>");
static CONTINUATION_TEMPLATE: LazyLock<Template> = LazyLock::new(|| {
parse_embedded_template(
include_str!("../templates/goals/continuation.md"),
@@ -46,19 +48,19 @@ fn render_embedded_template<const N: usize>(
}
pub(crate) fn budget_limit_steering_item(goal: &ThreadGoal) -> ResponseInputItem {
extension_context_input_item(budget_limit_prompt(goal))
goal_context_input_item(budget_limit_prompt(goal))
}
pub(crate) fn continuation_steering_request(goal: &ThreadGoal) -> ThreadIdleRequest {
ThreadIdleRequest::new(ExtensionContext::new(continuation_prompt(goal)).render())
ThreadIdleRequest::new(GOAL_CONTEXT_MARKER, continuation_prompt(goal))
}
pub(crate) fn objective_updated_steering_item(goal: &ThreadGoal) -> ResponseInputItem {
extension_context_input_item(objective_updated_prompt(goal))
goal_context_input_item(objective_updated_prompt(goal))
}
fn extension_context_input_item(prompt: String) -> ResponseInputItem {
ExtensionContext::new(prompt).into_response_input_item()
fn goal_context_input_item(prompt: String) -> ResponseInputItem {
GOAL_CONTEXT_MARKER.response_input_item(prompt)
}
fn continuation_prompt(goal: &ThreadGoal) -> String {
@@ -42,7 +42,7 @@ Do not rely on intent, partial progress, memory of earlier work, or a plausible
Blocked audit:
- Do not call update_goal with status "blocked" the first time a blocker appears.
- Only use status "blocked" when the same blocking condition has repeated for at least three consecutive goal turns, counting the original/user-triggered turn and any automatic continuations.
- Only use status "blocked" when the same blocking condition has repeated for at least three consecutive goal turns, counting the original/user-triggered turn and any automatic goal continuations.
- 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, call update_goal with status "blocked" again.
- Use status "blocked" only when you are truly at an impasse and cannot make meaningful progress without user input or an external-state change.
- 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".
@@ -1,17 +1,17 @@
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::PoisonError;
use std::sync::Weak;
use std::time::Duration;
use codex_extension_api::ExtensionData;
use codex_extension_api::ExtensionEventSink;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::FunctionCallError;
use codex_extension_api::NoopResponseItemInjector;
use codex_extension_api::ThreadIdleInput;
use codex_extension_api::ThreadIdleRequest;
use codex_extension_api::ThreadIdleTurnStartInput;
use codex_extension_api::ThreadIdleWithSettingsInput;
use codex_extension_api::ThreadResumeWithSettingsInput;
use codex_extension_api::ThreadResumeInput;
use codex_extension_api::ThreadStartInput;
use codex_extension_api::ToolCall;
use codex_extension_api::ToolCallOutcome;
@@ -507,14 +507,12 @@ async fn thread_idle_hook_requests_hidden_goal_continuation() -> anyhow::Result<
.ok_or_else(|| anyhow::anyhow!("active goal should request idle continuation"))?;
assert!(request.validation_key.is_some());
assert!(request.prompt.starts_with("<extension_context>"));
assert!(
request
.prompt
.contains("Continue working toward the active thread goal.")
);
assert!(request.prompt.contains("Completion audit:"));
assert!(request.prompt.ends_with("</extension_context>"));
assert!(
harness
.should_start_idle_turn(ModeKind::Default, &request)
@@ -1114,7 +1112,7 @@ async fn installed_tools(
&mut builder,
runtime,
/*metrics_client*/ None,
Weak::new(),
Arc::new(NoopResponseItemInjector),
|_| true,
);
let registry = builder.build();
@@ -1163,7 +1161,7 @@ impl GoalExtensionHarness {
&mut builder,
runtime,
/*metrics_client*/ None,
Weak::new(),
Arc::new(NoopResponseItemInjector),
|_| true,
);
let registry = builder.build();
@@ -1272,7 +1270,7 @@ impl GoalExtensionHarness {
let thread_settings = thread_settings(mode);
for contributor in self.registry.thread_lifecycle_contributors() {
contributor
.on_thread_resume_with_settings(ThreadResumeWithSettingsInput {
.on_thread_resume(ThreadResumeInput {
thread_settings: &thread_settings,
session_store: &self.session_store,
thread_store: &self.thread_store,
@@ -1319,7 +1317,7 @@ impl GoalExtensionHarness {
let thread_settings = thread_settings(mode);
for contributor in self.registry.thread_idle_turn_contributors() {
if let Some(request) = contributor
.request_thread_idle_turn_with_settings(ThreadIdleWithSettingsInput {
.request_thread_idle_turn(ThreadIdleInput {
thread_settings: &thread_settings,
session_store: &self.session_store,
thread_store: &self.thread_store,
+30 -1
View File
@@ -1,5 +1,6 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::Weak;
use codex_arg0::Arg0DispatchPaths;
use codex_core::StateDbHandle;
@@ -7,11 +8,14 @@ use codex_core::ThreadManager;
use codex_core::config::Config;
use codex_exec_server::EnvironmentManager;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::ResponseItemInjectionFuture;
use codex_extension_api::ResponseItemInjector;
use codex_features::Feature;
use codex_login::AuthManager;
use codex_login::default_client::USER_AGENT_SUFFIX;
use codex_login::default_client::get_codex_user_agent;
use codex_protocol::ThreadId;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::Submission;
use rmcp::model::CallToolRequestParams;
@@ -72,7 +76,9 @@ impl MessageProcessor {
&mut extensions,
state_db,
codex_otel::global(),
thread_manager.clone(),
Arc::new(ThreadManagerResponseItemInjector {
thread_manager: thread_manager.clone(),
}),
|config: &Config| config.features.enabled(Feature::Goals),
);
}
@@ -622,3 +628,26 @@ impl MessageProcessor {
tracing::info!("notifications/initialized");
}
}
struct ThreadManagerResponseItemInjector {
thread_manager: Weak<ThreadManager>,
}
impl ResponseItemInjector for ThreadManagerResponseItemInjector {
fn inject_response_items<'a>(
&'a self,
thread_id: ThreadId,
items: Vec<ResponseInputItem>,
) -> ResponseItemInjectionFuture<'a> {
let thread_manager = self.thread_manager.clone();
Box::pin(async move {
let Some(thread_manager) = thread_manager.upgrade() else {
return Err(items);
};
let Ok(thread) = thread_manager.get_thread(thread_id).await else {
return Err(items);
};
thread.inject_response_items_into_active_turn(items).await
})
}
}