Unify extension response item injection

This commit is contained in:
Eric Traut
2026-05-27 19:09:43 -07:00
parent 8c9cd2da4a
commit 48118fa6a0
12 changed files with 145 additions and 56 deletions
+10 -2
View File
@@ -45,14 +45,22 @@ struct SessionResponseItemInjector {
impl codex_extension_api::ResponseItemInjector for SessionResponseItemInjector {
fn inject_response_items<'a>(
&'a self,
items: Vec<ResponseInputItem>,
items: Vec<codex_extension_api::ResponseInjectionItem>,
) -> codex_extension_api::ResponseItemInjectionFuture<'a> {
let session = self.session.clone();
Box::pin(async move {
let Some(session) = session.upgrade() else {
return Err(items);
};
session.inject_response_items(items).await
let response_items = items
.clone()
.into_iter()
.map(codex_extension_api::ResponseInjectionItem::into_response_input_item)
.collect();
session
.inject_response_items(response_items)
.await
.map_err(|_| items)
})
}
}
+26 -9
View File
@@ -2,6 +2,7 @@
use std::sync::Arc;
use codex_extension_api::ResponseInjectionItem;
use codex_extension_api::ThreadIdleRequest;
use codex_protocol::protocol::ThreadSettingsSnapshot;
@@ -149,7 +150,7 @@ async fn next_idle_turn_candidate(session: &Session) -> Option<IdleTurnCandidate
else {
continue;
};
if !request.prompt.trim().is_empty() {
if is_non_empty_idle_input(&request.item) {
return Some(IdleTurnCandidate {
contributor_index,
request,
@@ -160,6 +161,13 @@ async fn next_idle_turn_candidate(session: &Session) -> Option<IdleTurnCandidate
None
}
fn is_non_empty_idle_input(item: &ResponseInjectionItem) -> bool {
match item {
ResponseInjectionItem::HiddenContext(context) => !context.body().trim().is_empty(),
ResponseInjectionItem::Raw(_) => true,
}
}
async fn should_start_idle_turn(session: &Session, candidate: &IdleTurnCandidate) -> bool {
let Some(contributor) = session
.services
@@ -204,16 +212,25 @@ async fn clear_reserved_idle_turn(session: &Session) {
}
fn idle_extension_input(request: ThreadIdleRequest) -> TurnInput {
let prompt = codex_utils_string::truncate_middle_with_token_budget(
&request.prompt,
MAX_IDLE_EXTENSION_PROMPT_TOKENS,
)
.0;
TurnInput::ResponseInputItem(request.context_marker.response_input_item(prompt))
match request.item {
ResponseInjectionItem::HiddenContext(context) => {
let (marker, body) = context.into_parts();
let prompt = codex_utils_string::truncate_middle_with_token_budget(
&body,
MAX_IDLE_EXTENSION_PROMPT_TOKENS,
)
.0;
TurnInput::ResponseInputItem(
codex_extension_api::HiddenContext::new(marker, prompt).into_response_input_item(),
)
}
ResponseInjectionItem::Raw(item) => TurnInput::ResponseInputItem(item),
}
}
#[cfg(test)]
mod tests {
use codex_extension_api::HiddenContext;
use codex_extension_api::HiddenContextMarker;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseInputItem;
@@ -228,10 +245,10 @@ mod tests {
);
let original_len = prompt.len();
let request = ThreadIdleRequest::new(
let request = ThreadIdleRequest::new(HiddenContext::new(
HiddenContextMarker::new("<goal_context>", "</goal_context>"),
prompt,
);
));
let TurnInput::ResponseInputItem(ResponseInputItem::Message { content, .. }) =
idle_extension_input(request)
else {
@@ -8,5 +8,6 @@ pub use events::ExtensionEventFuture;
pub use events::ExtensionEventSink;
pub use events::NoopExtensionEventSink;
pub use response_items::NoopResponseItemInjector;
pub use response_items::ResponseInjectionItem;
pub use response_items::ResponseItemInjectionFuture;
pub use response_items::ResponseItemInjector;
@@ -1,21 +1,54 @@
//! Host capability for injecting extension-owned model input into an active turn.
use std::future::Future;
use std::pin::Pin;
use crate::HiddenContext;
use codex_protocol::models::ResponseInputItem;
/// Future returned when an extension asks the host to inject model-visible input.
/// Extension-owned input to inject into an active model turn.
#[derive(Debug, Clone)]
pub enum ResponseInjectionItem {
/// Hidden context whose marker wrapping is owned by the extension API.
HiddenContext(HiddenContext),
/// Raw response item for extensions that intentionally need unwrapped input.
Raw(ResponseInputItem),
}
impl ResponseInjectionItem {
pub fn into_response_input_item(self) -> ResponseInputItem {
match self {
Self::HiddenContext(context) => context.into_response_input_item(),
Self::Raw(item) => item,
}
}
}
impl From<HiddenContext> for ResponseInjectionItem {
fn from(context: HiddenContext) -> Self {
Self::HiddenContext(context)
}
}
impl From<ResponseInputItem> for ResponseInjectionItem {
fn from(item: ResponseInputItem) -> Self {
Self::Raw(item)
}
}
/// Future returned when an extension asks the host to inject model input.
pub type ResponseItemInjectionFuture<'a> =
Pin<Box<dyn Future<Output = Result<(), Vec<ResponseInputItem>>> + Send + 'a>>;
Pin<Box<dyn Future<Output = Result<(), Vec<ResponseInjectionItem>>> + Send + 'a>>;
/// Thread-scoped host helper for extensions that need to steer the active model turn.
///
/// Implementations should inject the supplied response items into the active turn
/// for the current thread when it can accept same-turn model input. If injection
/// is unavailable, they return the unchanged items to the caller.
/// Implementations should inject the supplied items into the active turn for
/// the current thread when it can accept same-turn model input. If injection is
/// unavailable, they return the unchanged items to the caller.
pub trait ResponseItemInjector: Send + Sync {
fn inject_response_items<'a>(
&'a self,
items: Vec<ResponseInputItem>,
items: Vec<ResponseInjectionItem>,
) -> ResponseItemInjectionFuture<'a>;
}
@@ -26,7 +59,7 @@ pub struct NoopResponseItemInjector;
impl ResponseItemInjector for NoopResponseItemInjector {
fn inject_response_items<'a>(
&'a self,
items: Vec<ResponseInputItem>,
items: Vec<ResponseInjectionItem>,
) -> ResponseItemInjectionFuture<'a> {
Box::pin(std::future::ready(Err(items)))
}
+30 -5
View File
@@ -18,17 +18,42 @@ impl HiddenContextMarker {
end_marker,
}
}
}
/// Renders this marker pair as a user-role response input item.
pub fn response_input_item(&self, body: impl AsRef<str>) -> ResponseInputItem {
/// Extension-owned hidden context body with the marker pair used to wrap it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HiddenContext {
marker: HiddenContextMarker,
body: String,
}
impl HiddenContext {
/// Creates one hidden context body with its wrapping marker pair.
pub fn new(marker: HiddenContextMarker, body: impl Into<String>) -> Self {
Self {
marker,
body: body.into(),
}
}
/// Returns the unwrapped hidden context body.
pub fn body(&self) -> &str {
&self.body
}
/// Splits this context into its marker pair and unwrapped body.
pub fn into_parts(self) -> (HiddenContextMarker, String) {
(self.marker, self.body)
}
/// Renders this hidden context as a user-role response input item.
pub fn into_response_input_item(self) -> ResponseInputItem {
ResponseInputItem::Message {
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: format!(
"{}\n{}\n{}",
self.start_marker,
body.as_ref(),
self.end_marker
self.marker.start_marker, self.body, self.marker.end_marker
),
}],
phase: None,
@@ -78,12 +78,13 @@ pub type ThreadIdleTurnStartFuture<'a> = std::pin::Pin<Box<dyn Future<Output = b
/// Contributor that can request a host-owned turn while a thread is idle.
///
/// Implementations should return a concise hidden prompt body. The host owns
/// the response-item shape and may ask the contributor to confirm the request
/// again immediately before the turn starts.
/// Implementations should normally return hidden context. Raw items are
/// available for extensions that intentionally need unwrapped input. The host
/// owns hidden-context wrapping and may ask the contributor to confirm the
/// request again immediately before the turn starts.
pub trait ThreadIdleTurnContributor: Send + Sync {
/// Returns one hidden prompt body to start an idle turn, if the extension
/// still has work that should run without user input.
/// Returns input to start an idle turn, if the extension still has work
/// that should run without user input.
fn request_thread_idle_turn<'a>(
&'a self,
_input: ThreadIdleInput<'a>,
@@ -1,7 +1,7 @@
use std::sync::Arc;
use crate::ExtensionData;
use crate::HiddenContextMarker;
use crate::ResponseInjectionItem;
use crate::ResponseItemInjector;
use codex_protocol::protocol::ThreadSettingsSnapshot;
@@ -40,19 +40,16 @@ pub struct ThreadIdleInput<'a> {
/// 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,
/// Extension-owned input that the host injects as model-visible input.
pub item: ResponseInjectionItem,
/// Opaque extension-owned key used to reject stale requests before start.
pub validation_key: Option<String>,
}
impl ThreadIdleRequest {
pub fn new(context_marker: HiddenContextMarker, prompt: impl Into<String>) -> Self {
pub fn new(item: impl Into<ResponseInjectionItem>) -> Self {
Self {
prompt: prompt.into(),
context_marker,
item: item.into(),
validation_key: None,
}
}
+2
View File
@@ -10,6 +10,7 @@ pub use capabilities::ExtensionEventFuture;
pub use capabilities::ExtensionEventSink;
pub use capabilities::NoopExtensionEventSink;
pub use capabilities::NoopResponseItemInjector;
pub use capabilities::ResponseInjectionItem;
pub use capabilities::ResponseItemInjectionFuture;
pub use capabilities::ResponseItemInjector;
pub use codex_tools::ConversationHistory;
@@ -24,6 +25,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::HiddenContext;
pub use context::HiddenContextMarker;
pub use contributors::ApprovalReviewContributor;
pub use contributors::ConfigContributor;
+3 -3
View File
@@ -39,7 +39,7 @@ use crate::events::GoalEventEmitter;
use crate::metrics::GoalMetrics;
use crate::runtime::GoalRuntimeHandle;
use crate::spec::UPDATE_GOAL_TOOL_NAME;
use crate::steering::budget_limit_steering_item;
use crate::steering::budget_limit_steering_context;
use crate::tool::GoalToolExecutor;
#[derive(Clone)]
@@ -372,8 +372,8 @@ where
{
return;
}
let item = budget_limit_steering_item(&goal);
runtime.inject_active_turn_steering(item).await;
let context = budget_limit_steering_context(&goal);
runtime.inject_active_turn_steering(context).await;
})
}
}
+7 -6
View File
@@ -2,10 +2,10 @@ use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use codex_extension_api::HiddenContext;
use codex_extension_api::ResponseItemInjector;
use codex_protocol::ThreadId;
use codex_protocol::config_types::ModeKind;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::protocol::ThreadGoal;
use codex_protocol::protocol::ThreadSettingsSnapshot;
@@ -14,7 +14,7 @@ use crate::accounting::GoalAccountingState;
use crate::events::GoalEventEmitter;
use crate::metrics::GoalMetrics;
use crate::steering::continuation_steering_request;
use crate::steering::objective_updated_steering_item;
use crate::steering::objective_updated_steering_context;
use crate::tool::protocol_goal_from_state;
#[derive(Clone)]
@@ -147,8 +147,9 @@ impl GoalRuntimeHandle {
.mark_idle_goal_active(goal.goal_id.clone());
}
if should_steer_active_turn {
let item = objective_updated_steering_item(&protocol_goal_from_state(goal));
self.inject_active_turn_steering(item).await;
let context =
objective_updated_steering_context(&protocol_goal_from_state(goal));
self.inject_active_turn_steering(context).await;
}
}
codex_state::ThreadGoalStatus::BudgetLimited => {
@@ -323,11 +324,11 @@ impl GoalRuntimeHandle {
Ok(goal.goal_id == expected_goal_id)
}
pub(crate) async fn inject_active_turn_steering(&self, item: ResponseInputItem) {
pub(crate) async fn inject_active_turn_steering(&self, context: HiddenContext) {
if self
.inner
.response_item_injector
.inject_response_items(vec![item])
.inject_response_items(vec![context.into()])
.await
.is_err()
{
+8 -8
View File
@@ -1,6 +1,6 @@
use codex_extension_api::HiddenContext;
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;
@@ -47,20 +47,20 @@ fn render_embedded_template<const N: usize>(
}
}
pub(crate) fn budget_limit_steering_item(goal: &ThreadGoal) -> ResponseInputItem {
goal_context_input_item(budget_limit_prompt(goal))
pub(crate) fn budget_limit_steering_context(goal: &ThreadGoal) -> HiddenContext {
goal_context(budget_limit_prompt(goal))
}
pub(crate) fn continuation_steering_request(goal: &ThreadGoal) -> ThreadIdleRequest {
ThreadIdleRequest::new(GOAL_CONTEXT_MARKER, continuation_prompt(goal))
ThreadIdleRequest::new(goal_context(continuation_prompt(goal)))
}
pub(crate) fn objective_updated_steering_item(goal: &ThreadGoal) -> ResponseInputItem {
goal_context_input_item(objective_updated_prompt(goal))
pub(crate) fn objective_updated_steering_context(goal: &ThreadGoal) -> HiddenContext {
goal_context(objective_updated_prompt(goal))
}
fn goal_context_input_item(prompt: String) -> ResponseInputItem {
GOAL_CONTEXT_MARKER.response_input_item(prompt)
fn goal_context(prompt: String) -> HiddenContext {
HiddenContext::new(GOAL_CONTEXT_MARKER, prompt)
}
fn continuation_prompt(goal: &ThreadGoal) -> String {
@@ -8,6 +8,7 @@ use codex_extension_api::ExtensionEventSink;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::FunctionCallError;
use codex_extension_api::NoopResponseItemInjector;
use codex_extension_api::ResponseInjectionItem;
use codex_extension_api::ThreadIdleInput;
use codex_extension_api::ThreadIdleRequest;
use codex_extension_api::ThreadIdleTurnStartInput;
@@ -507,12 +508,15 @@ 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());
let ResponseInjectionItem::HiddenContext(context) = &request.item else {
panic!("expected hidden context item");
};
assert!(
request
.prompt
context
.body()
.contains("Continue working toward the active thread goal.")
);
assert!(request.prompt.contains("Completion audit:"));
assert!(context.body().contains("Completion audit:"));
assert!(
harness
.should_start_idle_turn(ModeKind::Default, &request)