skills: resolve per-turn catalogs from turn input context (#26106)

## Why

The skills extension needs the resolved turn environments to build a
real per-turn `SkillListQuery`. The previous `TurnLifecycleContributor`
hook only had a turn id, so it could only seed a placeholder query and
never carry the executor authorities that executor-scoped skill routing
will need.

Moving catalog resolution onto `TurnInputContributor` puts the skills
extension on the same turn-preparation path that already has the
environment ids and working directories for the submitted turn, while
keeping the actual prompt injection work for follow-up changes.

## What changed

- switch `ext/skills` from `TurnLifecycleContributor` to
`TurnInputContributor`
- build `executor_authorities` from `TurnInputContext.environments` and
pass them through `SkillListQuery`
- keep storing the resolved catalog in `SkillsTurnState`, but drop the
placeholder query helper that no longer matches the real data flow
- update the extension TODOs to reflect that per-turn catalog resolution
now happens in the turn-input contributor, and that prompt/context
injection still needs to move later

## Testing

- Not run locally.
This commit is contained in:
jif authored and GitHub committed 2026-06-03 13:32:55 +02:00
1 parent 51493157cd
commit 3389fa554e
35 files changed
+92 -74

No files matched your search

+1
View File
@@ -2863,6 +2863,7 @@ name = "codex-extension-api"
version = "0.0.0" version = "0.0.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"codex-context-fragments",
"codex-protocol", "codex-protocol",
"codex-tools", "codex-tools",
] ]
@@ -19,7 +19,7 @@ impl AdditionalContextUserFragment {
} }
impl ContextualUserFragment for AdditionalContextUserFragment { impl ContextualUserFragment for AdditionalContextUserFragment {
fn role() -> &'static str { fn role(&self) -> &'static str {
"user" "user"
} }
@@ -65,7 +65,7 @@ impl AdditionalContextDeveloperFragment {
} }
impl ContextualUserFragment for AdditionalContextDeveloperFragment { impl ContextualUserFragment for AdditionalContextDeveloperFragment {
fn role() -> &'static str { fn role(&self) -> &'static str {
"developer" "developer"
} }
+14 -5
View File
@@ -44,9 +44,7 @@ impl<T: ContextualUserFragment> FragmentRegistration for FragmentRegistrationPro
/// in which case the default helpers render only the body and never match /// in which case the default helpers render only the body and never match
/// arbitrary text. /// arbitrary text.
pub trait ContextualUserFragment { pub trait ContextualUserFragment {
fn role() -> &'static str fn role(&self) -> &'static str;
where
Self: Sized;
fn markers(&self) -> (&'static str, &'static str); fn markers(&self) -> (&'static str, &'static str);
@@ -80,7 +78,18 @@ pub trait ContextualUserFragment {
{ {
ResponseItem::Message { ResponseItem::Message {
id: None, id: None,
role: Self::role().to_string(), role: self.role().to_string(),
content: vec![ContentItem::InputText {
text: self.render(),
}],
phase: None,
}
}
fn into_boxed_response_item(self: Box<Self>) -> ResponseItem {
ResponseItem::Message {
id: None,
role: self.role().to_string(),
content: vec![ContentItem::InputText { content: vec![ContentItem::InputText {
text: self.render(), text: self.render(),
}], }],
@@ -93,7 +102,7 @@ pub trait ContextualUserFragment {
Self: Sized, Self: Sized,
{ {
ResponseInputItem::Message { ResponseInputItem::Message {
role: Self::role().to_string(), role: self.role().to_string(),
content: vec![ContentItem::InputText { content: vec![ContentItem::InputText {
text: self.render(), text: self.render(),
}], }],
@@ -20,7 +20,7 @@ impl From<&SkillInjection> for SkillInstructions {
} }
impl ContextualUserFragment for SkillInstructions { impl ContextualUserFragment for SkillInstructions {
fn role() -> &'static str { fn role(&self) -> &'static str {
"user" "user"
} }
@@ -14,7 +14,7 @@ impl ApprovedCommandPrefixSaved {
} }
impl ContextualUserFragment for ApprovedCommandPrefixSaved { impl ContextualUserFragment for ApprovedCommandPrefixSaved {
fn role() -> &'static str { fn role(&self) -> &'static str {
"developer" "developer"
} }
@@ -18,7 +18,7 @@ impl AppsInstructions {
} }
impl ContextualUserFragment for AppsInstructions { impl ContextualUserFragment for AppsInstructions {
fn role() -> &'static str { fn role(&self) -> &'static str {
"developer" "developer"
} }
@@ -22,7 +22,7 @@ impl AvailablePluginsInstructions {
} }
impl ContextualUserFragment for AvailablePluginsInstructions { impl ContextualUserFragment for AvailablePluginsInstructions {
fn role() -> &'static str { fn role(&self) -> &'static str {
"developer" "developer"
} }
@@ -21,7 +21,7 @@ impl From<AvailableSkills> for AvailableSkillsInstructions {
} }
impl ContextualUserFragment for AvailableSkillsInstructions { impl ContextualUserFragment for AvailableSkillsInstructions {
fn role() -> &'static str { fn role(&self) -> &'static str {
"developer" "developer"
} }
@@ -22,7 +22,7 @@ impl CollaborationModeInstructions {
} }
impl ContextualUserFragment for CollaborationModeInstructions { impl ContextualUserFragment for CollaborationModeInstructions {
fn role() -> &'static str { fn role(&self) -> &'static str {
"developer" "developer"
} }
@@ -523,7 +523,7 @@ fn workspace_roots_from_turn_context_item(
} }
impl ContextualUserFragment for EnvironmentContext { impl ContextualUserFragment for EnvironmentContext {
fn role() -> &'static str { fn role(&self) -> &'static str {
"user" "user"
} }
@@ -4,7 +4,7 @@ use super::ContextualUserFragment;
pub(crate) struct GuardianFollowupReviewReminder; pub(crate) struct GuardianFollowupReviewReminder;
impl ContextualUserFragment for GuardianFollowupReviewReminder { impl ContextualUserFragment for GuardianFollowupReviewReminder {
fn role() -> &'static str { fn role(&self) -> &'static str {
"developer" "developer"
} }
@@ -12,7 +12,7 @@ impl HookAdditionalContext {
} }
impl ContextualUserFragment for HookAdditionalContext { impl ContextualUserFragment for HookAdditionalContext {
fn role() -> &'static str { fn role(&self) -> &'static str {
"developer" "developer"
} }
@@ -17,7 +17,7 @@ impl ImageGenerationInstructions {
} }
impl ContextualUserFragment for ImageGenerationInstructions { impl ContextualUserFragment for ImageGenerationInstructions {
fn role() -> &'static str { fn role(&self) -> &'static str {
"developer" "developer"
} }
@@ -76,7 +76,7 @@ impl InternalModelContextFragment {
} }
impl ContextualUserFragment for InternalModelContextFragment { impl ContextualUserFragment for InternalModelContextFragment {
fn role() -> &'static str { fn role(&self) -> &'static str {
"user" "user"
} }
@@ -5,7 +5,7 @@ use super::ContextualUserFragment;
pub(crate) struct LegacyApplyPatchExecCommandWarning; pub(crate) struct LegacyApplyPatchExecCommandWarning;
impl ContextualUserFragment for LegacyApplyPatchExecCommandWarning { impl ContextualUserFragment for LegacyApplyPatchExecCommandWarning {
fn role() -> &'static str { fn role(&self) -> &'static str {
"user" "user"
} }
@@ -5,7 +5,7 @@ use super::ContextualUserFragment;
pub(crate) struct LegacyModelMismatchWarning; pub(crate) struct LegacyModelMismatchWarning;
impl ContextualUserFragment for LegacyModelMismatchWarning { impl ContextualUserFragment for LegacyModelMismatchWarning {
fn role() -> &'static str { fn role(&self) -> &'static str {
"user" "user"
} }
@@ -5,7 +5,7 @@ use super::ContextualUserFragment;
pub(crate) struct LegacyUnifiedExecProcessLimitWarning; pub(crate) struct LegacyUnifiedExecProcessLimitWarning;
impl ContextualUserFragment for LegacyUnifiedExecProcessLimitWarning { impl ContextualUserFragment for LegacyUnifiedExecProcessLimitWarning {
fn role() -> &'static str { fn role(&self) -> &'static str {
"user" "user"
} }
@@ -14,7 +14,7 @@ impl ModelSwitchInstructions {
} }
impl ContextualUserFragment for ModelSwitchInstructions { impl ContextualUserFragment for ModelSwitchInstructions {
fn role() -> &'static str { fn role(&self) -> &'static str {
"developer" "developer"
} }
@@ -18,7 +18,7 @@ impl NetworkRuleSaved {
} }
impl ContextualUserFragment for NetworkRuleSaved { impl ContextualUserFragment for NetworkRuleSaved {
fn role() -> &'static str { fn role(&self) -> &'static str {
"developer" "developer"
} }
@@ -12,7 +12,7 @@ impl PersonalitySpecInstructions {
} }
impl ContextualUserFragment for PersonalitySpecInstructions { impl ContextualUserFragment for PersonalitySpecInstructions {
fn role() -> &'static str { fn role(&self) -> &'static str {
"developer" "developer"
} }
@@ -12,7 +12,7 @@ impl PluginInstructions {
} }
impl ContextualUserFragment for PluginInstructions { impl ContextualUserFragment for PluginInstructions {
fn role() -> &'static str { fn role(&self) -> &'static str {
"developer" "developer"
} }
@@ -17,7 +17,7 @@ impl RealtimeEndInstructions {
} }
impl ContextualUserFragment for RealtimeEndInstructions { impl ContextualUserFragment for RealtimeEndInstructions {
fn role() -> &'static str { fn role(&self) -> &'static str {
"developer" "developer"
} }
@@ -7,7 +7,7 @@ use codex_protocol::protocol::REALTIME_CONVERSATION_OPEN_TAG;
pub(crate) struct RealtimeStartInstructions; pub(crate) struct RealtimeStartInstructions;
impl ContextualUserFragment for RealtimeStartInstructions { impl ContextualUserFragment for RealtimeStartInstructions {
fn role() -> &'static str { fn role(&self) -> &'static str {
"developer" "developer"
} }
@@ -16,7 +16,7 @@ impl RealtimeStartWithInstructions {
} }
impl ContextualUserFragment for RealtimeStartWithInstructions { impl ContextualUserFragment for RealtimeStartWithInstructions {
fn role() -> &'static str { fn role(&self) -> &'static str {
"developer" "developer"
} }
@@ -18,7 +18,7 @@ impl SubagentNotification {
} }
impl ContextualUserFragment for SubagentNotification { impl ContextualUserFragment for SubagentNotification {
fn role() -> &'static str { fn role(&self) -> &'static str {
"user" "user"
} }
+1 -1
View File
@@ -17,7 +17,7 @@ impl TurnAborted {
} }
impl ContextualUserFragment for TurnAborted { impl ContextualUserFragment for TurnAborted {
fn role() -> &'static str { fn role(&self) -> &'static str {
"user" "user"
} }
@@ -7,7 +7,7 @@ pub(crate) struct UserInstructions {
} }
impl ContextualUserFragment for UserInstructions { impl ContextualUserFragment for UserInstructions {
fn role() -> &'static str { fn role(&self) -> &'static str {
"user" "user"
} }
@@ -27,7 +27,7 @@ impl UserShellCommand {
} }
impl ContextualUserFragment for UserShellCommand { impl ContextualUserFragment for UserShellCommand {
fn role() -> &'static str { fn role(&self) -> &'static str {
"user" "user"
} }
+6 -2
View File
@@ -628,7 +628,7 @@ async fn build_extension_turn_input_items(
let mut items = Vec::new(); let mut items = Vec::new();
for contributor in contributors { for contributor in contributors {
let contributed_items = contributor let contributed_fragments = contributor
.contribute( .contribute(
input.clone(), input.clone(),
&sess.services.session_extension_data, &sess.services.session_extension_data,
@@ -638,7 +638,11 @@ async fn build_extension_turn_input_items(
.or_cancel(cancellation_token) .or_cancel(cancellation_token)
.await .await
.ok()?; .ok()?;
items.extend(contributed_items); items.extend(
contributed_fragments
.into_iter()
.map(ContextualUserFragment::into_boxed_response_item),
);
} }
Some(items) Some(items)
+1
View File
@@ -15,5 +15,6 @@ workspace = true
[dependencies] [dependencies]
async-trait = { workspace = true } async-trait = { workspace = true }
codex-context-fragments = { workspace = true }
codex-protocol = { workspace = true } codex-protocol = { workspace = true }
codex-tools = { workspace = true } codex-tools = { workspace = true }
@@ -1,8 +1,8 @@
use std::future::Future; use std::future::Future;
use std::sync::Arc; use std::sync::Arc;
use codex_context_fragments::ContextualUserFragment;
use codex_protocol::items::TurnItem; use codex_protocol::items::TurnItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::TokenUsageInfo; use codex_protocol::protocol::TokenUsageInfo;
use codex_tools::ToolCall; use codex_tools::ToolCall;
@@ -98,14 +98,14 @@ pub trait TurnLifecycleContributor: Send + Sync {
/// host, not in this input. /// host, not in this input.
#[async_trait::async_trait] #[async_trait::async_trait]
pub trait TurnInputContributor: Send + Sync { pub trait TurnInputContributor: Send + Sync {
/// Returns additional model input items for one submitted turn. /// Returns additional contextual fragments for one submitted turn.
async fn contribute( async fn contribute(
&self, &self,
input: TurnInputContext, input: TurnInputContext,
session_store: &ExtensionData, session_store: &ExtensionData,
thread_store: &ExtensionData, thread_store: &ExtensionData,
turn_store: &ExtensionData, turn_store: &ExtensionData,
) -> Vec<ResponseItem>; ) -> Vec<Box<dyn ContextualUserFragment + Send>>;
} }
/// Contributor for host-owned configuration changes. /// Contributor for host-owned configuration changes.
+1
View File
@@ -10,6 +10,7 @@ pub use capabilities::NoopExtensionEventSink;
pub use capabilities::NoopResponseItemInjector; pub use capabilities::NoopResponseItemInjector;
pub use capabilities::ResponseItemInjectionFuture; pub use capabilities::ResponseItemInjectionFuture;
pub use capabilities::ResponseItemInjector; pub use capabilities::ResponseItemInjector;
pub use codex_context_fragments::ContextualUserFragment;
pub use codex_protocol::models::ResponseItem; pub use codex_protocol::models::ResponseItem;
pub use codex_tools::ConversationHistory; pub use codex_tools::ConversationHistory;
pub use codex_tools::ExtensionTurnItem; pub use codex_tools::ExtensionTurnItem;
+38 -25
View File
@@ -5,14 +5,17 @@ use std::sync::Arc;
use codex_core::config::Config; use codex_core::config::Config;
use codex_extension_api::ConfigContributor; use codex_extension_api::ConfigContributor;
use codex_extension_api::ContextContributor; use codex_extension_api::ContextContributor;
use codex_extension_api::ContextualUserFragment;
use codex_extension_api::ExtensionData; use codex_extension_api::ExtensionData;
use codex_extension_api::ExtensionRegistryBuilder; use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::PromptFragment; use codex_extension_api::PromptFragment;
use codex_extension_api::ThreadLifecycleContributor; use codex_extension_api::ThreadLifecycleContributor;
use codex_extension_api::ThreadStartInput; use codex_extension_api::ThreadStartInput;
use codex_extension_api::TurnLifecycleContributor; use codex_extension_api::TurnInputContext;
use codex_extension_api::TurnStartInput; use codex_extension_api::TurnInputContributor;
use crate::catalog::SkillAuthority;
use crate::catalog::SkillSourceKind;
use crate::provider::SkillListQuery; use crate::provider::SkillListQuery;
use crate::providers::SkillProviders; use crate::providers::SkillProviders;
use crate::state::SkillsExtensionConfig; use crate::state::SkillsExtensionConfig;
@@ -29,8 +32,8 @@ impl ThreadLifecycleContributor<Config> for SkillsExtension {
// TODO(skills-extension): this is only the thread-level config snapshot. // TODO(skills-extension): this is only the thread-level config snapshot.
// Skills are loaded per turn today because cwd, plugin roots, config // Skills are loaded per turn today because cwd, plugin roots, config
// layers, and the primary environment filesystem can change between // layers, and the primary environment filesystem can change between
// turns. The real migration needs a turn-preparation hook before model // turns. The TurnInputContributor below owns per-turn catalog
// input construction, not just thread startup. // resolution.
input input
.thread_store .thread_store
.insert(SkillsExtensionConfig::from_config(input.config)); .insert(SkillsExtensionConfig::from_config(input.config));
@@ -66,42 +69,50 @@ impl ContextContributor for SkillsExtension {
} }
// TODO(skills-extension): render the available-skills developer // TODO(skills-extension): render the available-skills developer
// block from the merged per-turn SkillCatalog. This should // block only if the final model needs thread-level guidance that
// preserve the existing bounded metadata budget, root aliasing, // does not depend on the per-turn SkillCatalog.
// warning behavior, and telemetry side effects.
// //
// TODO(skills-extension): avoid using raw PromptFragment strings // TODO(skills-extension): avoid using raw PromptFragment strings
// for final skills context if the extension API grows typed // for final skills context if the extension API grows typed
// contextual fragments. Existing skill blocks are typed so resume // contextual fragments. Existing skill blocks are typed so resume
// and history filtering can recognize them reliably. // and history filtering can recognize them reliably.
//
// TODO(skills-extension): ContextContributor currently cannot see
// the turn_store, so it cannot read the per-turn catalog seeded by
// the turn provider path below. This is the main extension-api gap
// to close before skills can move out of codex-core.
Vec::new() Vec::new()
}) })
} }
} }
#[async_trait::async_trait] #[async_trait::async_trait]
impl TurnLifecycleContributor for SkillsExtension { impl TurnInputContributor for SkillsExtension {
async fn on_turn_start(&self, input: TurnStartInput<'_>) { async fn contribute(
// TODO(skills-extension): replace this lifecycle callback with a real &self,
// turn-input contributor in codex-extension-api. This placeholder only input: TurnInputContext,
// demonstrates where provider aggregation belongs; it cannot resolve _session_store: &ExtensionData,
// real skills because this hook does not receive cwd, executor _thread_store: &ExtensionData,
// selections, effective plugins/materialized plugin skill roots, turn_store: &ExtensionData,
// connector slug counts, user input, cancellation, analytics, or a ) -> Vec<Box<dyn ContextualUserFragment + Send>> {
// response-item output channel. let executor_authorities = input
let query = SkillListQuery::placeholder_for_turn(input.turn_id); .environments
.iter()
.map(|environment| {
SkillAuthority::new(
SkillSourceKind::Executor,
environment.environment_id.clone(),
)
})
.collect();
let query = SkillListQuery {
turn_id: input.turn_id,
executor_authorities,
include_host_skills: true,
include_remote_skills: true,
};
let catalog = self let catalog = self
.providers .providers
.list_for_turn(query) .list_for_turn(query)
.await .await
.unwrap_or_default(); .unwrap_or_default();
input.turn_store.insert(SkillsTurnState { turn_store.insert(SkillsTurnState {
catalog, catalog,
entrypoints_injected: false, entrypoints_injected: false,
}); });
@@ -115,7 +126,9 @@ impl TurnLifecycleContributor for SkillsExtension {
// //
// TODO(skills-extension): move explicit $skill mention resolution, // TODO(skills-extension): move explicit $skill mention resolution,
// SKILL.md reads, skill body injection, and MCP dependency prompting // SKILL.md reads, skill body injection, and MCP dependency prompting
// out of codex-core's turn assembly once that hook exists. // out of codex-core's turn assembly once provider implementations
// are ready to preserve behavior.
Vec::new()
} }
} }
@@ -136,5 +149,5 @@ pub fn install(registry: &mut ExtensionRegistryBuilder<Config>) {
registry.thread_lifecycle_contributor(extension.clone()); registry.thread_lifecycle_contributor(extension.clone());
registry.config_contributor(extension.clone()); registry.config_contributor(extension.clone());
registry.prompt_contributor(extension.clone()); registry.prompt_contributor(extension.clone());
registry.turn_lifecycle_contributor(extension); registry.turn_input_contributor(extension);
} }
-11
View File
@@ -16,17 +16,6 @@ pub struct SkillListQuery {
pub include_remote_skills: bool, pub include_remote_skills: bool,
} }
impl SkillListQuery {
pub(crate) fn placeholder_for_turn(turn_id: &str) -> Self {
Self {
turn_id: turn_id.to_string(),
executor_authorities: Vec::new(),
include_host_skills: true,
include_remote_skills: true,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct SkillReadRequest { pub struct SkillReadRequest {
pub authority: SkillAuthority, pub authority: SkillAuthority,
@@ -143,7 +143,7 @@ impl PermissionsInstructions {
} }
impl ContextualUserFragment for PermissionsInstructions { impl ContextualUserFragment for PermissionsInstructions {
fn role() -> &'static str { fn role(&self) -> &'static str {
"developer" "developer"
} }