Add incremental skill catalogs behind skill_catalog_deltas (#57262)

## What changed

- Add the disabled-by-default `skill_catalog_deltas` feature flag to append new and updated skills without repeating unchanged catalog entries.
- Share a cumulative metadata budget across executor, cloud, and host catalogs, preserving usage across turns and resume. Removals emit no update and do not refund budget.
- Rebuild the current catalog and reset its budget after compaction, including any required skill root mappings in updates.

## Testing

Add unit and integration coverage for update costs, shared source budgets, feature rollback, and catalog updates across resume and compaction, with a request-history snapshot.

GitOrigin-RevId: e88b4265d7361498113d43fdc2e0260a9f930bbc
This commit is contained in:
Alex Daley
2026-10-26 21:20:21 +00:00
committed by copyberry
parent bd72161881
commit 3cacafa0b7
37 changed files with 1780 additions and 84 deletions
+3
View File
@@ -124,6 +124,9 @@ pub(crate) fn thread_extensions(
codex_otel::global(),
|config: &Config| codex_skills_extension::SkillsExtensionConfig {
include_instructions: config.include_skill_instructions,
catalog_deltas_enabled: config
.features
.enabled(codex_features::Feature::SkillCatalogDeltas),
max_context_tokens: config.skill_max_context_tokens,
bundled_skills_enabled: config.bundled_skills_enabled(),
cloud_skill_enabled: config.cloud_skill_enabled,
+3
View File
@@ -2167,6 +2167,9 @@ async fn run_debug_prompt_input_command(
codex_skills_extension::install(&mut extensions, |config: &Config| {
codex_skills_extension::SkillsExtensionConfig {
include_instructions: config.include_skill_instructions,
catalog_deltas_enabled: config
.features
.enabled(codex_features::Feature::SkillCatalogDeltas),
max_context_tokens: config.skill_max_context_tokens,
bundled_skills_enabled: config.bundled_skills_enabled(),
cloud_skill_enabled: config.cloud_skill_enabled,
+6
View File
@@ -1302,6 +1302,9 @@
"shell_zsh_fork": {
"type": "boolean"
},
"skill_catalog_deltas": {
"type": "boolean"
},
"skill_env_var_dependency_prompt": {
"type": "boolean"
},
@@ -7926,6 +7929,9 @@
"shell_zsh_fork": {
"type": "boolean"
},
"skill_catalog_deltas": {
"type": "boolean"
},
"skill_env_var_dependency_prompt": {
"type": "boolean"
},
@@ -585,6 +585,7 @@ async fn check_acquisition(enabled: bool, version: &'static str) -> Result<()> {
)),
|config: &Config| codex_skills_extension::SkillsExtensionConfig {
include_instructions: true,
catalog_deltas_enabled: config.features.enabled(Feature::SkillCatalogDeltas),
max_context_tokens: config.skill_max_context_tokens,
bundled_skills_enabled: false,
cloud_skill_enabled: false,
@@ -1100,6 +1101,7 @@ async fn auth_rotation_refreshes_plugin_acceptance_before_merged_skills() -> Res
)),
|_config: &Config| SkillsExtensionConfig {
include_instructions: true,
catalog_deltas_enabled: false,
max_context_tokens: None,
bundled_skills_enabled: false,
cloud_skill_enabled: true,
@@ -347,6 +347,9 @@ async fn guardian_reviews_with_offline_primary_executor() -> Result<()> {
codex_skills_extension::install(&mut extensions, |config: &Config| {
codex_skills_extension::SkillsExtensionConfig {
include_instructions: config.include_skill_instructions,
catalog_deltas_enabled: config
.features
.enabled(codex_features::Feature::SkillCatalogDeltas),
max_context_tokens: config.skill_max_context_tokens,
bundled_skills_enabled: false,
cloud_skill_enabled: false,
@@ -88,6 +88,7 @@ fn skills_extensions() -> Arc<ExtensionRegistry<Config>> {
let mut extensions = ExtensionRegistryBuilder::<Config>::new();
install(&mut extensions, |config: &Config| SkillsExtensionConfig {
include_instructions: config.include_skill_instructions,
catalog_deltas_enabled: config.features.enabled(Feature::SkillCatalogDeltas),
max_context_tokens: config.skill_max_context_tokens,
bundled_skills_enabled: config.bundled_skills_enabled(),
cloud_skill_enabled: config.cloud_skill_enabled,
+1
View File
@@ -93,6 +93,7 @@ fn skills_extensions() -> Arc<ExtensionRegistry<Config>> {
let mut extensions = ExtensionRegistryBuilder::<Config>::new();
install(&mut extensions, |config: &Config| SkillsExtensionConfig {
include_instructions: config.include_skill_instructions,
catalog_deltas_enabled: config.features.enabled(Feature::SkillCatalogDeltas),
max_context_tokens: config.skill_max_context_tokens,
bundled_skills_enabled: config.bundled_skills_enabled(),
cloud_skill_enabled: config.cloud_skill_enabled,
@@ -75,6 +75,7 @@ async fn required_environment_skill_gates_inference(availability: Availability)
SkillProviders::new().with_executor_provider(Arc::new(CatalogSkillProvider { catalog })),
|_: &Config| SkillsExtensionConfig {
include_instructions: false,
catalog_deltas_enabled: false,
max_context_tokens: None,
bundled_skills_enabled: false,
cloud_skill_enabled: false,
+1
View File
@@ -2248,6 +2248,7 @@ async fn local_stdio_server_uses_runtime_fallback_cwd_when_config_omits_cwd() ->
codex_skills_extension::install(&mut extensions, |config: &Config| {
codex_skills_extension::SkillsExtensionConfig {
include_instructions: config.include_skill_instructions,
catalog_deltas_enabled: config.features.enabled(Feature::SkillCatalogDeltas),
max_context_tokens: config.skill_max_context_tokens,
bundled_skills_enabled: config.bundled_skills_enabled(),
cloud_skill_enabled: config.cloud_skill_enabled,
+3
View File
@@ -200,6 +200,7 @@ fn skills_extensions() -> Arc<ExtensionRegistry<Config>> {
let mut extensions = ExtensionRegistryBuilder::<Config>::new();
install(&mut extensions, |config: &Config| SkillsExtensionConfig {
include_instructions: config.include_skill_instructions,
catalog_deltas_enabled: config.features.enabled(Feature::SkillCatalogDeltas),
max_context_tokens: config.skill_max_context_tokens,
bundled_skills_enabled: config.bundled_skills_enabled(),
cloud_skill_enabled: config.cloud_skill_enabled,
@@ -989,6 +990,7 @@ async fn astra_omits_disabled_executor_and_plugin_skills_from_model_context(
SkillProviders::new().with_executor_provider(Arc::new(provider)),
|config: &Config| SkillsExtensionConfig {
include_instructions: config.include_skill_instructions,
catalog_deltas_enabled: config.features.enabled(Feature::SkillCatalogDeltas),
max_context_tokens: config.skill_max_context_tokens,
bundled_skills_enabled: false,
cloud_skill_enabled: false,
@@ -1476,6 +1478,7 @@ async fn skill_read_recovers_omitted_installation_and_reads_selected_environment
)),
|config: &Config| SkillsExtensionConfig {
include_instructions: config.include_skill_instructions,
catalog_deltas_enabled: config.features.enabled(Feature::SkillCatalogDeltas),
max_context_tokens: config.skill_max_context_tokens,
bundled_skills_enabled: false,
cloud_skill_enabled: true,
@@ -39,6 +39,7 @@ async fn frozen_cloud_skills_with_named_executor_request_history() -> Result<()>
bundled_skills_enabled: false,
cloud_skill_enabled: true,
shadow_selection_enabled: false,
catalog_deltas_enabled: config.features.enabled(Feature::SkillCatalogDeltas),
},
);
let server = start_mock_server().await;
@@ -1,11 +1,17 @@
//! Snapshots cloud-preferred skill deduplication and stable executor aliases in model requests.
//! Snapshots skill deduplication and incremental catalogs across turns and context resets.
use std::collections::HashSet;
use std::num::NonZeroUsize;
use std::sync::Arc;
use anyhow::Result;
use codex_core::config::Config;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_features::Feature;
use codex_login::CodexAuth;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use codex_skills_extension::HostSkillProvider;
use codex_skills_extension::SkillProviders;
use codex_skills_extension::SkillsExtensionConfig;
use codex_skills_extension::catalog::SkillAuthority;
@@ -20,7 +26,9 @@ use core_test_support::context_snapshot::ContextSnapshotOptions;
use core_test_support::responses;
use core_test_support::skip_if_no_network;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use pretty_assertions::assert_eq;
use serde_json::json;
use super::super::skills_extension::CatalogSkillProvider;
@@ -82,6 +90,7 @@ async fn cloud_preference_preserves_executor_aliases_and_description_budget() ->
bundled_skills_enabled: false,
cloud_skill_enabled: true,
shadow_selection_enabled: false,
catalog_deltas_enabled: config.features.enabled(Feature::SkillCatalogDeltas),
},
);
let mock = responses::mount_sse_sequence(
@@ -153,3 +162,294 @@ async fn cloud_preference_preserves_executor_aliases_and_description_budget() ->
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn skill_catalog_deltas_survive_resume_and_rebuild_after_compaction() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
// Both skill roots include the home path, so its length affects the metadata budget.
let temp_root = dunce::canonicalize(std::env::temp_dir())?;
let home = Arc::new(
tempfile::Builder::new()
.rand_bytes(186_usize.saturating_sub(temp_root.to_string_lossy().len()))
.tempdir_in(temp_root)?,
);
for (name, description) in [
("code-review", "Review code changes for correctness."),
("release-notes", "Draft release notes from recent commits."),
("browser-testing", "Run browser tests for web applications."),
] {
let directory = home.path().join("skills").join(name);
std::fs::create_dir_all(&directory)?;
std::fs::write(
directory.join("SKILL.md"),
format!("---\nname: {name}\ndescription: {description}\n---\n\n# Instructions\n"),
)?;
}
let mut extensions = ExtensionRegistryBuilder::new();
install_with_providers(
&mut extensions,
SkillProviders::new().with_host_provider(Arc::new(HostSkillProvider::new())),
|config: &Config| SkillsExtensionConfig {
include_instructions: config.include_skill_instructions,
max_context_tokens: config.skill_max_context_tokens,
bundled_skills_enabled: false,
cloud_skill_enabled: false,
shadow_selection_enabled: false,
catalog_deltas_enabled: config.features.enabled(Feature::SkillCatalogDeltas),
},
);
let extensions = Arc::new(extensions.build());
let builder = || {
test_codex()
.with_home(Arc::clone(&home))
.with_extensions(Arc::clone(&extensions))
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_model_info_override("gpt-6-astra", |model| {
model.use_responses_lite = true;
model.include_skills_usage_instructions = false;
})
.with_config(|config| {
super::configure_scenario_catalog(config);
config.workspace_roots = vec![config.cwd.clone()];
config
.features
.enable(Feature::SkillCatalogDeltas)
.expect("enable skill catalog deltas");
})
};
let mock = responses::mount_sse_sequence(
&server,
(0..19)
.map(|index| {
let mut events = Vec::new();
if index == 6 || index == 10 || index == 17 {
events.push(json!({
"type": "response.output_item.done",
"item": {
"type": "compaction",
"encrypted_content": "SKILL_CATALOG_CHECKPOINT",
},
}));
}
events.push(responses::ev_completed(&format!("response-{index}")));
responses::sse(events)
})
.collect(),
)
.await;
let test = builder().build_with_auto_env(&server).await?;
test.submit_turn("Inspect the available skills.").await?;
std::fs::write(
home.path().join("skills/release-notes/SKILL.md"),
"---\nname: release-notes\ndescription: Draft release notes grouped by feature and bug fix.\n---\n\n# Instructions\n",
)?;
test.thread_manager.skills_service().clear_cache();
test.submit_turn("Check the release-notes skill.").await?;
let incident_summary = home.path().join("skills/incident-summary");
std::fs::create_dir_all(&incident_summary)?;
std::fs::write(
incident_summary.join("SKILL.md"),
"---\nname: incident-summary\ndescription: Summarize incidents and follow-up actions.\n---\n\n# Instructions\n",
)?;
test.thread_manager.skills_service().clear_cache();
test.submit_turn("Check the incident-summary skill.")
.await?;
std::fs::remove_dir_all(home.path().join("skills/browser-testing"))?;
test.thread_manager.skills_service().clear_cache();
test.submit_turn("List the available skills after removing browser-testing.")
.await?;
test.submit_turn("Continue with the same skills.").await?;
let resumed = builder().restart_with_auto_env(&server, &test).await?;
resumed.submit_turn("Resume with the same skills.").await?;
resumed.codex.submit(Op::Compact).await?;
wait_for_event(&resumed.codex, |event| {
assert!(!matches!(event, EventMsg::Error(_)), "{event:?}");
matches!(event, EventMsg::TurnComplete(_))
})
.await;
resumed.submit_turn("Continue after compaction.").await?;
let limited = builder()
.with_config(|config| {
super::configure_scenario_catalog(config);
config.workspace_roots = vec![config.cwd.clone()];
config
.features
.enable(Feature::SkillCatalogDeltas)
.expect("enable skill catalog deltas");
config.skill_max_context_tokens = NonZeroUsize::new(172);
})
.restart_with_auto_env(&server, &resumed)
.await?;
limited
.submit_turn("Continue with a smaller skills list budget.")
.await?;
let translation = home.path().join("skills/translation");
std::fs::create_dir_all(&translation)?;
std::fs::write(
translation.join("SKILL.md"),
"---\nname: translation\ndescription: Translate text while preserving its tone.\n---\n\n# Instructions\n",
)?;
limited.thread_manager.skills_service().clear_cache();
limited.submit_turn("Check the translation skill.").await?;
limited.codex.submit(Op::Compact).await?;
wait_for_event(&limited.codex, |event| {
assert!(!matches!(event, EventMsg::Error(_)), "{event:?}");
matches!(event, EventMsg::TurnComplete(_))
})
.await;
limited
.submit_turn("Continue after compacting the smaller skills list.")
.await?;
// Compaction omitted translation from the new catalog. Editing and then
// removing it must not advertise a skill that is no longer shown.
std::fs::write(
translation.join("SKILL.md"),
"---\nname: translation\ndescription: Translate and localize text.\n---\n\n# Instructions\n",
)?;
limited.thread_manager.skills_service().clear_cache();
limited
.submit_turn("Continue after editing translation.")
.await?;
std::fs::remove_dir_all(&translation)?;
limited.thread_manager.skills_service().clear_cache();
limited
.submit_turn("Continue after removing translation.")
.await?;
// Editing a shown skill also needs remaining budget. Removing skills does
// not refund the space their earlier descriptions still occupy in history.
std::fs::write(
home.path().join("skills/code-review/SKILL.md"),
format!(
"---\nname: code-review\ndescription: {}\n---\n\n# Instructions\n",
"Review the entire project. ".repeat(40)
),
)?;
limited.thread_manager.skills_service().clear_cache();
limited
.submit_turn("Check the updated code-review skill.")
.await?;
for name in ["code-review", "incident-summary", "release-notes"] {
std::fs::remove_dir_all(home.path().join("skills").join(name))?;
}
limited.thread_manager.skills_service().clear_cache();
limited
.submit_turn("Continue after removing more skills.")
.await?;
let documentation = home.path().join("skills/documentation");
std::fs::create_dir_all(&documentation)?;
std::fs::write(
documentation.join("SKILL.md"),
"---\nname: documentation\ndescription: Write project documentation.\n---\n\n# Instructions\n",
)?;
limited.thread_manager.skills_service().clear_cache();
limited
.submit_turn("Check the documentation skill.")
.await?;
limited.codex.submit(Op::Compact).await?;
wait_for_event(&limited.codex, |event| {
assert!(!matches!(event, EventMsg::Error(_)), "{event:?}");
matches!(event, EventMsg::TurnComplete(_))
})
.await;
limited
.submit_turn("Continue with the rebuilt skills catalog.")
.await?;
let requests = mock.requests();
assert_eq!(requests.len(), 19);
// Count newly injected catalog parts by message ID and content index, so retained
// history is not counted again on later requests or after resume.
let mut seen_catalog_parts = HashSet::new();
let injections = requests
.iter()
.map(|request| {
let mut injected = 0;
for item in request.input() {
let Some(content) = item["content"].as_array() else {
continue;
};
for (index, part) in content.iter().enumerate() {
if !part["text"]
.as_str()
.is_some_and(|text| text.starts_with("<skills_instructions>"))
{
continue;
}
let metadata = &item["internal_chat_message_metadata_passthrough"];
assert_eq!(
(
&metadata["content_item_kinds"][index],
&metadata["content_item_metadata"][index],
),
(
&json!("host_skills.instructions"),
&json!({"provenance": {"type": "skills"}}),
),
);
let id = item["id"].as_str().expect("catalog message ID");
if seen_catalog_parts.insert((id.to_owned(), index)) {
injected += 1;
}
}
}
injected
})
.collect::<Vec<_>>();
assert_eq!(
injections,
vec![1, 1, 1, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1]
);
let exhausted = requests[16].message_input_texts("developer").join("\n");
assert!(!exhausted.contains("- translation:"), "{exhausted}");
assert!(!exhausted.contains("- documentation:"), "{exhausted}");
let rebuilt = requests[18].message_input_texts("developer").join("\n");
assert!(rebuilt.contains("- documentation:"), "{rebuilt}");
assert!(!rebuilt.contains("- release-notes:"), "{rebuilt}");
assert!(!rebuilt.contains("- code-review:"), "{rebuilt}");
let skill_versions = requests
.iter()
.take(8)
.map(|request| {
let developer = request.message_input_texts("developer").join("\n");
[
"- code-review: Review code changes for correctness.",
"- release-notes: Draft release notes from recent commits.",
"- release-notes: Draft release notes grouped by feature and bug fix.",
"- incident-summary: Summarize incidents and follow-up actions.",
"- browser-testing: Run browser tests for web applications.",
]
.map(|entry| developer.matches(entry).count())
})
.collect::<Vec<_>>();
assert_eq!(
skill_versions,
vec![
[1, 1, 0, 0, 1],
[1, 1, 1, 0, 1],
[1, 1, 1, 1, 1],
[1, 1, 1, 1, 1],
[1, 1, 1, 1, 1],
[1, 1, 1, 1, 1],
[1, 1, 1, 1, 1],
[1, 0, 1, 1, 0],
]
);
insta::assert_snapshot!(
"skill_catalog_deltas",
context_snapshot::format_request_history_snapshot(
"Skill additions and edits share the remaining metadata budget across turns and resume. Removals stay silent and do not refund budget. Compaction rebuilds the current catalog and resets the allowance.",
&requests,
&ContextSnapshotOptions::default().include_request_settings(),
)
);
Ok(())
}
+3
View File
@@ -325,6 +325,9 @@ async fn user_turn_selects_symlinked_skill_by_advertised_discovery_path() -> Res
let mut extensions = ExtensionRegistryBuilder::<Config>::new();
install(&mut extensions, |config: &Config| SkillsExtensionConfig {
include_instructions: config.include_skill_instructions,
catalog_deltas_enabled: config
.features
.enabled(codex_features::Feature::SkillCatalogDeltas),
max_context_tokens: config.skill_max_context_tokens,
bundled_skills_enabled: false,
cloud_skill_enabled: false,
@@ -366,6 +366,7 @@ fn catalog_extensions(
install_with_providers(&mut extensions, providers, |config: &Config| {
SkillsExtensionConfig {
include_instructions: config.include_skill_instructions,
catalog_deltas_enabled: config.features.enabled(Feature::SkillCatalogDeltas),
max_context_tokens: config.skill_max_context_tokens,
bundled_skills_enabled: false,
cloud_skill_enabled: false,
@@ -643,6 +644,7 @@ async fn capability_sections_render_in_order_with_host_repo_and_plugin_skills()
let mut extensions = ExtensionRegistryBuilder::<Config>::new();
install(&mut extensions, |config: &Config| SkillsExtensionConfig {
include_instructions: config.include_skill_instructions,
catalog_deltas_enabled: config.features.enabled(Feature::SkillCatalogDeltas),
max_context_tokens: config.skill_max_context_tokens,
bundled_skills_enabled: config.bundled_skills_enabled(),
cloud_skill_enabled: config.cloud_skill_enabled,
@@ -1053,6 +1055,7 @@ async fn opted_in_executor_provider_skips_host_discovery_but_injects_discovered_
)),
|config: &Config| SkillsExtensionConfig {
include_instructions: config.include_skill_instructions,
catalog_deltas_enabled: config.features.enabled(Feature::SkillCatalogDeltas),
max_context_tokens: config.skill_max_context_tokens,
bundled_skills_enabled: false,
cloud_skill_enabled: false,
@@ -1282,6 +1285,7 @@ async fn executor_only_provider_preserves_structured_repo_skill_without_discover
)),
|config: &Config| SkillsExtensionConfig {
include_instructions: config.include_skill_instructions,
catalog_deltas_enabled: config.features.enabled(Feature::SkillCatalogDeltas),
max_context_tokens: config.skill_max_context_tokens,
bundled_skills_enabled: false,
cloud_skill_enabled: false,
@@ -1431,6 +1435,7 @@ async fn executor_skill_tool_reads_references_under_current_permissions(
)),
|config: &Config| SkillsExtensionConfig {
include_instructions: config.include_skill_instructions,
catalog_deltas_enabled: config.features.enabled(Feature::SkillCatalogDeltas),
max_context_tokens: config.skill_max_context_tokens,
bundled_skills_enabled: false,
cloud_skill_enabled: false,
@@ -1872,6 +1877,7 @@ async fn explicit_executor_skill_prompt_rejects_oversized_resource() -> Result<(
)),
|config: &Config| SkillsExtensionConfig {
include_instructions: config.include_skill_instructions,
catalog_deltas_enabled: config.features.enabled(Feature::SkillCatalogDeltas),
max_context_tokens: config.skill_max_context_tokens,
bundled_skills_enabled: false,
cloud_skill_enabled: false,
@@ -2104,6 +2110,7 @@ async fn production_turn_aliases_catalogs_with_separate_cloud_budget() -> Result
.with_host_provider(Arc::new(HostSkillProvider::new())),
|config: &Config| SkillsExtensionConfig {
include_instructions: config.include_skill_instructions,
catalog_deltas_enabled: config.features.enabled(Feature::SkillCatalogDeltas),
max_context_tokens: config.skill_max_context_tokens,
bundled_skills_enabled: false,
cloud_skill_enabled: true,
@@ -2243,6 +2250,7 @@ async fn assert_catalog_model_switch(max_context_tokens: Option<usize>) -> Resul
SkillProviders::new().with_host_provider(provider.clone()),
|config: &Config| SkillsExtensionConfig {
include_instructions: config.include_skill_instructions,
catalog_deltas_enabled: config.features.enabled(Feature::SkillCatalogDeltas),
max_context_tokens: config.skill_max_context_tokens,
bundled_skills_enabled: false,
cloud_skill_enabled: false,
@@ -2682,6 +2690,7 @@ async fn production_turn_uses_provider_host_catalog_and_core_snapshot_injection(
})),
|config: &Config| SkillsExtensionConfig {
include_instructions: config.include_skill_instructions,
catalog_deltas_enabled: config.features.enabled(Feature::SkillCatalogDeltas),
max_context_tokens: config.skill_max_context_tokens,
bundled_skills_enabled: false,
cloud_skill_enabled: false,
@@ -2803,6 +2812,7 @@ async fn production_turn_suppresses_only_the_superseded_host_skill_prompt() -> R
)),
|config: &Config| SkillsExtensionConfig {
include_instructions: config.include_skill_instructions,
catalog_deltas_enabled: config.features.enabled(Feature::SkillCatalogDeltas),
max_context_tokens: config.skill_max_context_tokens,
bundled_skills_enabled: false,
cloud_skill_enabled: false,
@@ -3057,6 +3067,7 @@ async fn production_turn_keeps_rebalanced_catalogs_stable_after_compaction_and_r
})),
|config: &Config| SkillsExtensionConfig {
include_instructions: config.include_skill_instructions,
catalog_deltas_enabled: config.features.enabled(Feature::SkillCatalogDeltas),
max_context_tokens: config.skill_max_context_tokens,
bundled_skills_enabled: false,
cloud_skill_enabled: config.cloud_skill_enabled,
@@ -3568,6 +3579,7 @@ async fn production_turn_fairly_shortens_extension_catalog_descriptions() -> Res
)),
|config: &Config| SkillsExtensionConfig {
include_instructions: config.include_skill_instructions,
catalog_deltas_enabled: config.features.enabled(Feature::SkillCatalogDeltas),
max_context_tokens: config.skill_max_context_tokens,
bundled_skills_enabled: false,
cloud_skill_enabled: false,
@@ -112,6 +112,7 @@ async fn cloud_skills_reuse_cache_and_invalidate_on_connection_or_auth_change()
SkillProviders::new().with_cloud_provider(provider.clone()),
|_config: &Config| SkillsExtensionConfig {
include_instructions: true,
catalog_deltas_enabled: false,
max_context_tokens: None,
bundled_skills_enabled: false,
cloud_skill_enabled: true,
@@ -163,6 +163,7 @@ text({ names: result.skills.map(skill => skill.name), warnings: result.warnings,
})),
|config: &Config| SkillsExtensionConfig {
include_instructions: config.include_skill_instructions,
catalog_deltas_enabled: config.features.enabled(Feature::SkillCatalogDeltas),
max_context_tokens: config.skill_max_context_tokens,
bundled_skills_enabled: false,
cloud_skill_enabled: true,
@@ -506,6 +507,7 @@ async fn production_turn_aliases_discovered_singleton_cloud_locator() -> Result<
})),
|config: &Config| SkillsExtensionConfig {
include_instructions: config.include_skill_instructions,
catalog_deltas_enabled: config.features.enabled(Feature::SkillCatalogDeltas),
max_context_tokens: config.skill_max_context_tokens,
bundled_skills_enabled: false,
cloud_skill_enabled: true,
@@ -595,6 +597,7 @@ async fn cloud_skill_reads_run_in_parallel() -> Result<()> {
})),
|config: &Config| SkillsExtensionConfig {
include_instructions: config.include_skill_instructions,
catalog_deltas_enabled: config.features.enabled(Feature::SkillCatalogDeltas),
max_context_tokens: config.skill_max_context_tokens,
bundled_skills_enabled: false,
cloud_skill_enabled: true,
@@ -718,6 +721,7 @@ async fn cloud_skill_can_read_referenced_resource_without_an_executor() -> Resul
SkillProviders::new().with_cloud_provider(provider.clone()),
|config: &Config| SkillsExtensionConfig {
include_instructions: config.include_skill_instructions,
catalog_deltas_enabled: config.features.enabled(Feature::SkillCatalogDeltas),
max_context_tokens: config.skill_max_context_tokens,
bundled_skills_enabled: false,
cloud_skill_enabled: true,
@@ -36,6 +36,7 @@ async fn yielded_skill_read_keeps_originating_turn_metadata() -> Result<()> {
})),
|config: &Config| SkillsExtensionConfig {
include_instructions: config.include_skill_instructions,
catalog_deltas_enabled: config.features.enabled(Feature::SkillCatalogDeltas),
max_context_tokens: config.skill_max_context_tokens,
bundled_skills_enabled: false,
cloud_skill_enabled: true,
@@ -0,0 +1,568 @@
---
source: core/tests/suite/scenarios_skill_catalog_dedup.rs
expression: "context_snapshot::format_request_history_snapshot(\"Skill additions and edits share the remaining metadata budget across turns and resume. Removals stay silent and do not refund budget. Compaction rebuilds the current catalog and resets the allowance.\",\n&requests, &ContextSnapshotOptions::default().include_request_settings(),)"
---
Scenario: Skill additions and edits share the remaining metadata budget across turns and resume. Removals stay silent and do not refund budget. Compaction rebuilds the current catalog and resets the allowance.
## Window 1
Settings:
include: ["reasoning.encrypted_content"]
model: "gpt-6-astra"
parallel_tool_calls: false
prompt_cache_key: "<PROMPT_CACHE_KEY 1>"
reasoning: {"context":"all_turns","effort":"low"}
store: false
stream: true
text: {"verbosity":"low"}
tool_choice: "auto"
-- request 1 (turn) --
00:additional_tools/developer (3; hash=bf2e30605ff5dc17):
- namespace/functions; hash=fada4a476a3754dd
- custom/exec
- function/wait
- function/request_user_input
- function/request_user_input_async
- namespace/clock: Tools for reading and waiting on time.; hash=f402e8c5e9b5e317
- function/sleep
- namespace/collaboration: Tools for spawning and managing sub-agents.; hash=2c8d6295978dbe63
- function/followup_task
- function/interrupt_agent
- function/list_agents
- function/send_message
- function/spawn_agent
- function/wait_agent
01:message/developer:
You are Codex, an agent based on GPT-6. You and the user share one workspace, and your job is to collabo...them until their intended goal is completely handled. [hash=efa50f074efa5803]
# When to ask the user for permission
Use your best judgement given task context for when you really need user permission, like a competent co...ork without ending the turn to clarify with the user. [hash=ca7367e658b51388]
User authorization and preferences persist across turns. Do not request permission again when the user h... any guidelines provided in skills or external files. [hash=911f91c92c72882c]
<OMITTED 155 LINES; ~4935 TOKENS; hash=5573869716192298>
- Skill naming: If a plugin contributes skills, those skill entries are prefixed with plugin_name: in the Skills list.
- MCP naming: Plugin-provided MCP tools keep standard MCP identifiers such as mcp__server__tool; use tool provenance to tell which plugin they come from.
- Trigger rules: If the user explicitly names a plugin, prefer capabilities associated with that plugin for that turn.
- Relationship to capabilities: Plugins are not invoked directly. Use their underlying skills, MCP tools, and app tools to help solve the task.
- Relevance: Determine what a plugin can help with from explicit user mention or from the plugin-associa..., MCP tools, and apps exposed elsewhere in this turn. [hash=620459a4e7b590f0]
- Missing/blocked: If the user requests a plugin that does not have relevant callable capabilities for t..., say so briefly and continue with the best fallback. [hash=746b0469a8f6cb3e]
02:message/developer[3]:
[01] <skills_instructions>
## Host skills
## Skills
A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list ...ed into an absolute path using the skill roots table. [hash=ca27c3957f42502b]
### Skill roots
- `r0` = `<SKILLS_ROOT>`
- `r1` = `<SKILLS_ROOT>/.system`
### Available skills
- imagegen: Generate or edit raster images when the task benefits from AI-created bitmap visuals such as...ctly in HTML/CSS/canvas. (file: r1/imagegen/SKILL.md) [hash=dcccaed407aafb03]
- openai-docs: Use for Codex models/pricing, scheduled tasks, skills, settings, setup, troubleshooting, ...merely mention Codex. (file: r1/openai-docs/SKILL.md) [hash=1c1693bbc4dfd0f7]
- skill-creator: Create or update a Codex skill with appropriately scoped instructions and any needed supporting resources. (file: r1/skill-creator/SKILL.md)
- skill-installer: Install Codex skills into $CODEX_HOME/skills from a curated list or a GitHub repo pat...g private repos). (file: r1/skill-installer/SKILL.md) [hash=80e87da510d34d88]
- browser-testing: Run browser tests for web applications. (file: r0/browser-testing/SKILL.md)
- code-review: Review code changes for correctness. (file: r0/code-review/SKILL.md)
- release-notes: Draft release notes from recent commits. (file: r0/release-notes/SKILL.md)
</skills_instructions>
[02] <permissions instructions>
Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `danger-full-access`...ll commands are permitted. Network access is enabled. [hash=c134061cebb94a1b]
Approval policy is currently never. Do not provide the `sandbox_permissions` for any reason, commands will be rejected.
</permissions instructions>
[03] <collaboration_mode># Collaboration Mode: Default
You are now in Default mode. Any previous instructions for other modes (e.g. Plan mode) are no longer active.
Your active mode changes only when new developer instructions with a different `<collaboration_mode>...<...by themselves. Known mode names are Default and Plan. [hash=e75f6b12fc52d812]
## request_user_input availability
Use the `request_user_input` tool only when it is listed in the available tools for this turn.
Use the `request_user_input` tool only for optional questions where the answer would materially improve the quality of the work.
If `request_user_input` returns no answers, continue with best judgment instead of asking again or treating the turn as blocked.
Never use the `request_user_input` tool for permission requests or permission-related escalations.
</collaboration_mode>
03:message/developer:
<multi_agent_role>You are `/root`, the primary agent in a team of agents collaborating to fulfill the user's goals.
At the start of your turn, you are the active agent.
You can spawn sub-agents to handle subtasks, and those sub-agents can spawn their own sub-agents.
All agents in the team, including the agents that you can assign tasks to, are equally intelligent and capable, and have access to the same set of tools.
You can use `spawn_agent` to create a new agent, `followup_task` to give an existing agent a new task an...message to a running agent without triggering a turn. [hash=7d40ff8b58977202]
`send_message` calls may be read by a human, so ensure they are legible. Always put proper spaces between words and/or numbers.
<OMITTED 17 LINES; ~246 TOKENS; hash=780031e2df907944>
- All agents use the same current working directory.
- As a result, edits made by one agent are immediately visible to all other agents.
When calling `wait_agent`, prefer longer waits (minutes) to avoid busy polling.
There are 4 available concurrency slots, meaning that up to 4 agents can be active at once, including you.
Full-history forks (`fork_turns` omitted or `"all"`) inherit the parent model and reasoning effort and d... so, set `fork_turns` to `"none"`.</multi_agent_role> [hash=d5928243dc643bcc]
04:message/developer:
<multi_agent_mode>Any earlier instruction enabling proactive multi-agent delegation no longer applies. D...elegation, or parallel agent work.</multi_agent_mode> [hash=2b6e037067b4390c]
05:message/user:
<environment_context>
<cwd><CWD></cwd>
<shell><HOST_SHELL></shell>
<current_date><CURRENT_DATE></current_date>
<timezone><HOST_TIMEZONE></timezone>
<filesystem><workspace_roots><root><CWD></root></workspace_roots><permission_profile type="disabled"><...e="unrestricted" /></permission_profile></filesystem> [hash=d40b2f45cb028888]
</environment_context>
06:message/user:
Inspect the available skills.
07:configuration_update:reasoning={"effort":"low"}
-- request 2 (turn) --
08:message/developer:
<skills_instructions>
## Host skills update
### Added or updated skills:
- release-notes: Draft release notes grouped by feature and bug fix. (file: r0/release-notes/SKILL.md)
</skills_instructions>
09:message/user:
Check the release-notes skill.
-- request 3 (turn) --
10:message/developer:
<skills_instructions>
## Host skills update
### Added or updated skills:
- incident-summary: Summarize incidents and follow-up actions. (file: r0/incident-summary/SKILL.md)
</skills_instructions>
11:message/user:
Check the incident-summary skill.
-- request 4 (turn) --
12:message/user:
List the available skills after removing browser-testing.
-- request 5 (turn) --
13:message/user:
Continue with the same skills.
-- request 6 (turn) --
14:message/user:
<environment_context>
<cwd><CWD 2></cwd>
<shell><HOST_SHELL></shell>
<current_date><CURRENT_DATE></current_date>
<timezone><HOST_TIMEZONE></timezone>
<filesystem><workspace_roots><root><CWD 2></root></workspace_roots><permission_profile type="disabled"...e="unrestricted" /></permission_profile></filesystem> [hash=46e2a5c52f0891de]
</environment_context>
15:message/user:
Resume with the same skills.
16:configuration_update:reasoning={"effort":"low"}
-- request 7 (compaction) --
17:compaction_trigger
## Window 2 (after request 7: input diverged at item 02)
Settings: same as window 1
-- request 8 (turn) --
00:additional_tools/developer (3; hash=bf2e30605ff5dc17):
- namespace/functions; hash=fada4a476a3754dd
- custom/exec
- function/wait
- function/request_user_input
- function/request_user_input_async
- namespace/clock: Tools for reading and waiting on time.; hash=f402e8c5e9b5e317
- function/sleep
- namespace/collaboration: Tools for spawning and managing sub-agents.; hash=2c8d6295978dbe63
- function/followup_task
- function/interrupt_agent
- function/list_agents
- function/send_message
- function/spawn_agent
- function/wait_agent
01:message/developer:
You are Codex, an agent based on GPT-6. You and the user share one workspace, and your job is to collabo...them until their intended goal is completely handled. [hash=efa50f074efa5803]
# When to ask the user for permission
Use your best judgement given task context for when you really need user permission, like a competent co...ork without ending the turn to clarify with the user. [hash=ca7367e658b51388]
User authorization and preferences persist across turns. Do not request permission again when the user h... any guidelines provided in skills or external files. [hash=911f91c92c72882c]
<OMITTED 155 LINES; ~4935 TOKENS; hash=5573869716192298>
- Skill naming: If a plugin contributes skills, those skill entries are prefixed with plugin_name: in the Skills list.
- MCP naming: Plugin-provided MCP tools keep standard MCP identifiers such as mcp__server__tool; use tool provenance to tell which plugin they come from.
- Trigger rules: If the user explicitly names a plugin, prefer capabilities associated with that plugin for that turn.
- Relationship to capabilities: Plugins are not invoked directly. Use their underlying skills, MCP tools, and app tools to help solve the task.
- Relevance: Determine what a plugin can help with from explicit user mention or from the plugin-associa..., MCP tools, and apps exposed elsewhere in this turn. [hash=620459a4e7b590f0]
- Missing/blocked: If the user requests a plugin that does not have relevant callable capabilities for t..., say so briefly and continue with the best fallback. [hash=746b0469a8f6cb3e]
02:message/user:
Inspect the available skills.
03:message/user:
Check the release-notes skill.
04:message/user:
Check the incident-summary skill.
05:message/user:
List the available skills after removing browser-testing.
06:message/user:
Continue with the same skills.
07:message/developer[3]:
[01] <skills_instructions>
## Host skills
## Skills
A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list ...ed into an absolute path using the skill roots table. [hash=ca27c3957f42502b]
### Skill roots
- `r0` = `<SKILLS_ROOT>`
- `r1` = `<SKILLS_ROOT>/.system`
### Available skills
- imagegen: Generate or edit raster images when the task benefits from AI-created bitmap visuals such as...ctly in HTML/CSS/canvas. (file: r1/imagegen/SKILL.md) [hash=dcccaed407aafb03]
- openai-docs: Use for Codex models/pricing, scheduled tasks, skills, settings, setup, troubleshooting, ...merely mention Codex. (file: r1/openai-docs/SKILL.md) [hash=1c1693bbc4dfd0f7]
- skill-creator: Create or update a Codex skill with appropriately scoped instructions and any needed supporting resources. (file: r1/skill-creator/SKILL.md)
- skill-installer: Install Codex skills into $CODEX_HOME/skills from a curated list or a GitHub repo pat...g private repos). (file: r1/skill-installer/SKILL.md) [hash=80e87da510d34d88]
- code-review: Review code changes for correctness. (file: r0/code-review/SKILL.md)
- incident-summary: Summarize incidents and follow-up actions. (file: r0/incident-summary/SKILL.md)
- release-notes: Draft release notes grouped by feature and bug fix. (file: r0/release-notes/SKILL.md)
</skills_instructions>
[02] <permissions instructions>
Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `danger-full-access`...ll commands are permitted. Network access is enabled. [hash=c134061cebb94a1b]
Approval policy is currently never. Do not provide the `sandbox_permissions` for any reason, commands will be rejected.
</permissions instructions>
[03] <collaboration_mode># Collaboration Mode: Default
You are now in Default mode. Any previous instructions for other modes (e.g. Plan mode) are no longer active.
Your active mode changes only when new developer instructions with a different `<collaboration_mode>...<...by themselves. Known mode names are Default and Plan. [hash=e75f6b12fc52d812]
## request_user_input availability
Use the `request_user_input` tool only when it is listed in the available tools for this turn.
Use the `request_user_input` tool only for optional questions where the answer would materially improve the quality of the work.
If `request_user_input` returns no answers, continue with best judgment instead of asking again or treating the turn as blocked.
Never use the `request_user_input` tool for permission requests or permission-related escalations.
</collaboration_mode>
08:message/developer:
<multi_agent_role>You are `/root`, the primary agent in a team of agents collaborating to fulfill the user's goals.
At the start of your turn, you are the active agent.
You can spawn sub-agents to handle subtasks, and those sub-agents can spawn their own sub-agents.
All agents in the team, including the agents that you can assign tasks to, are equally intelligent and capable, and have access to the same set of tools.
You can use `spawn_agent` to create a new agent, `followup_task` to give an existing agent a new task an...message to a running agent without triggering a turn. [hash=7d40ff8b58977202]
`send_message` calls may be read by a human, so ensure they are legible. Always put proper spaces between words and/or numbers.
<OMITTED 17 LINES; ~246 TOKENS; hash=780031e2df907944>
- All agents use the same current working directory.
- As a result, edits made by one agent are immediately visible to all other agents.
When calling `wait_agent`, prefer longer waits (minutes) to avoid busy polling.
There are 4 available concurrency slots, meaning that up to 4 agents can be active at once, including you.
Full-history forks (`fork_turns` omitted or `"all"`) inherit the parent model and reasoning effort and d... so, set `fork_turns` to `"none"`.</multi_agent_role> [hash=d5928243dc643bcc]
09:message/developer:
<multi_agent_mode>Any earlier instruction enabling proactive multi-agent delegation no longer applies. D...elegation, or parallel agent work.</multi_agent_mode> [hash=2b6e037067b4390c]
10:message/user:
<environment_context>
<cwd><CWD 2></cwd>
<shell><HOST_SHELL></shell>
<current_date><CURRENT_DATE></current_date>
<timezone><HOST_TIMEZONE></timezone>
<filesystem><workspace_roots><root><CWD 2></root></workspace_roots><permission_profile type="disabled"...e="unrestricted" /></permission_profile></filesystem> [hash=46e2a5c52f0891de]
</environment_context>
11:message/user:
Resume with the same skills.
12:compaction:encrypted=true; chars=24; hash=68820eb5ac69d541
13:message/user:
Continue after compaction.
-- request 9 (turn) --
14:message/user:
<environment_context>
<cwd><CWD 3></cwd>
<shell><HOST_SHELL></shell>
<current_date><CURRENT_DATE></current_date>
<timezone><HOST_TIMEZONE></timezone>
<filesystem><workspace_roots><root><CWD 3></root></workspace_roots><permission_profile type="disabled"...e="unrestricted" /></permission_profile></filesystem> [hash=84b040355e543755]
</environment_context>
15:message/user:
Continue with a smaller skills list budget.
16:configuration_update:reasoning={"effort":"low"}
-- request 10 (turn) --
17:message/user:
Check the translation skill.
-- request 11 (compaction) --
18:compaction_trigger
## Window 3 (after request 11: input diverged at item 07)
Settings: same as window 1
-- request 12 (turn) --
00:additional_tools/developer (3; hash=bf2e30605ff5dc17):
- namespace/functions; hash=fada4a476a3754dd
- custom/exec
- function/wait
- function/request_user_input
- function/request_user_input_async
- namespace/clock: Tools for reading and waiting on time.; hash=f402e8c5e9b5e317
- function/sleep
- namespace/collaboration: Tools for spawning and managing sub-agents.; hash=2c8d6295978dbe63
- function/followup_task
- function/interrupt_agent
- function/list_agents
- function/send_message
- function/spawn_agent
- function/wait_agent
01:message/developer:
You are Codex, an agent based on GPT-6. You and the user share one workspace, and your job is to collabo...them until their intended goal is completely handled. [hash=efa50f074efa5803]
# When to ask the user for permission
Use your best judgement given task context for when you really need user permission, like a competent co...ork without ending the turn to clarify with the user. [hash=ca7367e658b51388]
User authorization and preferences persist across turns. Do not request permission again when the user h... any guidelines provided in skills or external files. [hash=911f91c92c72882c]
<OMITTED 155 LINES; ~4935 TOKENS; hash=5573869716192298>
- Skill naming: If a plugin contributes skills, those skill entries are prefixed with plugin_name: in the Skills list.
- MCP naming: Plugin-provided MCP tools keep standard MCP identifiers such as mcp__server__tool; use tool provenance to tell which plugin they come from.
- Trigger rules: If the user explicitly names a plugin, prefer capabilities associated with that plugin for that turn.
- Relationship to capabilities: Plugins are not invoked directly. Use their underlying skills, MCP tools, and app tools to help solve the task.
- Relevance: Determine what a plugin can help with from explicit user mention or from the plugin-associa..., MCP tools, and apps exposed elsewhere in this turn. [hash=620459a4e7b590f0]
- Missing/blocked: If the user requests a plugin that does not have relevant callable capabilities for t..., say so briefly and continue with the best fallback. [hash=746b0469a8f6cb3e]
02:message/user:
Inspect the available skills.
03:message/user:
Check the release-notes skill.
04:message/user:
Check the incident-summary skill.
05:message/user:
List the available skills after removing browser-testing.
06:message/user:
Continue with the same skills.
07:message/user:
Resume with the same skills.
08:message/user:
Continue after compaction.
09:message/user:
Continue with a smaller skills list budget.
10:message/developer[3]:
[01] <skills_instructions>
## Host skills
## Skills
A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list ...ed into an absolute path using the skill roots table. [hash=ca27c3957f42502b]
### Skill roots
- `r0` = `<SKILLS_ROOT>`
- `r1` = `<SKILLS_ROOT>/.system`
### Available skills
- imagegen: (file: r1/imagegen/SKILL.md)
- openai-docs: (file: r1/openai-docs/SKILL.md)
- skill-creator: (file: r1/skill-creator/SKILL.md)
- skill-installer: (file: r1/skill-installer/SKILL.md)
- code-review: (file: r0/code-review/SKILL.md)
- incident-summary: (file: r0/incident-summary/SKILL.md)
- release-notes: (file: r0/release-notes/SKILL.md)
</skills_instructions>
[02] <permissions instructions>
Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `danger-full-access`...ll commands are permitted. Network access is enabled. [hash=c134061cebb94a1b]
Approval policy is currently never. Do not provide the `sandbox_permissions` for any reason, commands will be rejected.
</permissions instructions>
[03] <collaboration_mode># Collaboration Mode: Default
You are now in Default mode. Any previous instructions for other modes (e.g. Plan mode) are no longer active.
Your active mode changes only when new developer instructions with a different `<collaboration_mode>...<...by themselves. Known mode names are Default and Plan. [hash=e75f6b12fc52d812]
## request_user_input availability
Use the `request_user_input` tool only when it is listed in the available tools for this turn.
Use the `request_user_input` tool only for optional questions where the answer would materially improve the quality of the work.
If `request_user_input` returns no answers, continue with best judgment instead of asking again or treating the turn as blocked.
Never use the `request_user_input` tool for permission requests or permission-related escalations.
</collaboration_mode>
11:message/developer:
<multi_agent_role>You are `/root`, the primary agent in a team of agents collaborating to fulfill the user's goals.
At the start of your turn, you are the active agent.
You can spawn sub-agents to handle subtasks, and those sub-agents can spawn their own sub-agents.
All agents in the team, including the agents that you can assign tasks to, are equally intelligent and capable, and have access to the same set of tools.
You can use `spawn_agent` to create a new agent, `followup_task` to give an existing agent a new task an...message to a running agent without triggering a turn. [hash=7d40ff8b58977202]
`send_message` calls may be read by a human, so ensure they are legible. Always put proper spaces between words and/or numbers.
<OMITTED 17 LINES; ~246 TOKENS; hash=780031e2df907944>
- All agents use the same current working directory.
- As a result, edits made by one agent are immediately visible to all other agents.
When calling `wait_agent`, prefer longer waits (minutes) to avoid busy polling.
There are 4 available concurrency slots, meaning that up to 4 agents can be active at once, including you.
Full-history forks (`fork_turns` omitted or `"all"`) inherit the parent model and reasoning effort and d... so, set `fork_turns` to `"none"`.</multi_agent_role> [hash=d5928243dc643bcc]
12:message/developer:
<multi_agent_mode>Any earlier instruction enabling proactive multi-agent delegation no longer applies. D...elegation, or parallel agent work.</multi_agent_mode> [hash=2b6e037067b4390c]
13:message/user:
<environment_context>
<cwd><CWD 3></cwd>
<shell><HOST_SHELL></shell>
<current_date><CURRENT_DATE></current_date>
<timezone><HOST_TIMEZONE></timezone>
<filesystem><workspace_roots><root><CWD 3></root></workspace_roots><permission_profile type="disabled"...e="unrestricted" /></permission_profile></filesystem> [hash=84b040355e543755]
</environment_context>
14:message/user:
Check the translation skill.
15:compaction:encrypted=true; chars=24; hash=68820eb5ac69d541
16:message/user:
Continue after compacting the smaller skills list.
-- request 13 (turn) --
17:message/user:
Continue after editing translation.
-- request 14 (turn) --
18:message/user:
Continue after removing translation.
-- request 15 (turn) --
19:message/user:
Check the updated code-review skill.
-- request 16 (turn) --
20:message/user:
Continue after removing more skills.
-- request 17 (turn) --
21:message/user:
Check the documentation skill.
-- request 18 (compaction) --
22:compaction_trigger
## Window 4 (after request 18: input diverged at item 10)
Settings: same as window 1
-- request 19 (turn) --
00:additional_tools/developer (3; hash=bf2e30605ff5dc17):
- namespace/functions; hash=fada4a476a3754dd
- custom/exec
- function/wait
- function/request_user_input
- function/request_user_input_async
- namespace/clock: Tools for reading and waiting on time.; hash=f402e8c5e9b5e317
- function/sleep
- namespace/collaboration: Tools for spawning and managing sub-agents.; hash=2c8d6295978dbe63
- function/followup_task
- function/interrupt_agent
- function/list_agents
- function/send_message
- function/spawn_agent
- function/wait_agent
01:message/developer:
You are Codex, an agent based on GPT-6. You and the user share one workspace, and your job is to collabo...them until their intended goal is completely handled. [hash=efa50f074efa5803]
# When to ask the user for permission
Use your best judgement given task context for when you really need user permission, like a competent co...ork without ending the turn to clarify with the user. [hash=ca7367e658b51388]
User authorization and preferences persist across turns. Do not request permission again when the user h... any guidelines provided in skills or external files. [hash=911f91c92c72882c]
<OMITTED 155 LINES; ~4935 TOKENS; hash=5573869716192298>
- Skill naming: If a plugin contributes skills, those skill entries are prefixed with plugin_name: in the Skills list.
- MCP naming: Plugin-provided MCP tools keep standard MCP identifiers such as mcp__server__tool; use tool provenance to tell which plugin they come from.
- Trigger rules: If the user explicitly names a plugin, prefer capabilities associated with that plugin for that turn.
- Relationship to capabilities: Plugins are not invoked directly. Use their underlying skills, MCP tools, and app tools to help solve the task.
- Relevance: Determine what a plugin can help with from explicit user mention or from the plugin-associa..., MCP tools, and apps exposed elsewhere in this turn. [hash=620459a4e7b590f0]
- Missing/blocked: If the user requests a plugin that does not have relevant callable capabilities for t..., say so briefly and continue with the best fallback. [hash=746b0469a8f6cb3e]
02:message/user:
Inspect the available skills.
03:message/user:
Check the release-notes skill.
04:message/user:
Check the incident-summary skill.
05:message/user:
List the available skills after removing browser-testing.
06:message/user:
Continue with the same skills.
07:message/user:
Resume with the same skills.
08:message/user:
Continue after compaction.
09:message/user:
Continue with a smaller skills list budget.
10:message/user:
Check the translation skill.
11:message/user:
Continue after compacting the smaller skills list.
12:message/user:
Continue after editing translation.
13:message/user:
Continue after removing translation.
14:message/user:
Check the updated code-review skill.
15:message/user:
Continue after removing more skills.
16:message/developer[3]:
[01] <skills_instructions>
## Host skills
## Skills
A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list ...ed into an absolute path using the skill roots table. [hash=ca27c3957f42502b]
### Skill roots
- `r0` = `<SKILLS_ROOT>`
- `r1` = `<SKILLS_ROOT>/.system`
### Available skills
- imagegen: Generate or edit raster images (file: r1/imagegen/SKILL.md)
- openai-docs: Use for Codex models/pricing, sc (file: r1/openai-docs/SKILL.md)
- skill-creator: Create or update a Codex skill w (file: r1/skill-creator/SKILL.md)
- skill-installer: Install Codex skills into $C (file: r1/skill-installer/SKILL.md)
- documentation: Write project documentation. (file: r0/documentation/SKILL.md)
</skills_instructions>
[02] <permissions instructions>
Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `danger-full-access`...ll commands are permitted. Network access is enabled. [hash=c134061cebb94a1b]
Approval policy is currently never. Do not provide the `sandbox_permissions` for any reason, commands will be rejected.
</permissions instructions>
[03] <collaboration_mode># Collaboration Mode: Default
You are now in Default mode. Any previous instructions for other modes (e.g. Plan mode) are no longer active.
Your active mode changes only when new developer instructions with a different `<collaboration_mode>...<...by themselves. Known mode names are Default and Plan. [hash=e75f6b12fc52d812]
## request_user_input availability
Use the `request_user_input` tool only when it is listed in the available tools for this turn.
Use the `request_user_input` tool only for optional questions where the answer would materially improve the quality of the work.
If `request_user_input` returns no answers, continue with best judgment instead of asking again or treating the turn as blocked.
Never use the `request_user_input` tool for permission requests or permission-related escalations.
</collaboration_mode>
17:message/developer:
<multi_agent_role>You are `/root`, the primary agent in a team of agents collaborating to fulfill the user's goals.
At the start of your turn, you are the active agent.
You can spawn sub-agents to handle subtasks, and those sub-agents can spawn their own sub-agents.
All agents in the team, including the agents that you can assign tasks to, are equally intelligent and capable, and have access to the same set of tools.
You can use `spawn_agent` to create a new agent, `followup_task` to give an existing agent a new task an...message to a running agent without triggering a turn. [hash=7d40ff8b58977202]
`send_message` calls may be read by a human, so ensure they are legible. Always put proper spaces between words and/or numbers.
<OMITTED 17 LINES; ~246 TOKENS; hash=780031e2df907944>
- All agents use the same current working directory.
- As a result, edits made by one agent are immediately visible to all other agents.
When calling `wait_agent`, prefer longer waits (minutes) to avoid busy polling.
There are 4 available concurrency slots, meaning that up to 4 agents can be active at once, including you.
Full-history forks (`fork_turns` omitted or `"all"`) inherit the parent model and reasoning effort and d... so, set `fork_turns` to `"none"`.</multi_agent_role> [hash=d5928243dc643bcc]
18:message/developer:
<multi_agent_mode>Any earlier instruction enabling proactive multi-agent delegation no longer applies. D...elegation, or parallel agent work.</multi_agent_mode> [hash=2b6e037067b4390c]
19:message/user:
<environment_context>
<cwd><CWD 3></cwd>
<shell><HOST_SHELL></shell>
<current_date><CURRENT_DATE></current_date>
<timezone><HOST_TIMEZONE></timezone>
<filesystem><workspace_roots><root><CWD 3></root></workspace_roots><permission_profile type="disabled"...e="unrestricted" /></permission_profile></filesystem> [hash=84b040355e543755]
</environment_context>
20:message/user:
Check the documentation skill.
21:compaction:encrypted=true; chars=24; hash=68820eb5ac69d541
22:message/user:
Continue with the rebuilt skills catalog.
@@ -3326,6 +3326,7 @@ async fn skill_instructions_in_parent_and_spawned_child(include_instructions: bo
let mut extensions = ExtensionRegistryBuilder::new();
codex_skills_extension::install(&mut extensions, |config: &Config| SkillsExtensionConfig {
include_instructions: config.include_skill_instructions,
catalog_deltas_enabled: config.features.enabled(Feature::SkillCatalogDeltas),
max_context_tokens: config.skill_max_context_tokens,
bundled_skills_enabled: false,
cloud_skill_enabled: false,
@@ -1694,6 +1694,7 @@ async fn new_context_tool_skips_auto_compact_fallback() -> Result<()> {
let mut extensions = ExtensionRegistryBuilder::<Config>::new();
install(&mut extensions, |config: &Config| SkillsExtensionConfig {
include_instructions: config.include_skill_instructions,
catalog_deltas_enabled: config.features.enabled(Feature::SkillCatalogDeltas),
max_context_tokens: config.skill_max_context_tokens,
bundled_skills_enabled: config.bundled_skills_enabled(),
cloud_skill_enabled: config.cloud_skill_enabled,
+23 -16
View File
@@ -148,9 +148,15 @@ struct AliasBudget {
}
#[derive(Clone, Debug)]
struct AliasRoot {
name: String,
value: Arc<str>,
pub(crate) struct AliasRoot {
pub(crate) name: String,
pub(crate) value: Arc<str>,
}
impl AliasRoot {
pub(crate) fn line(&self) -> String {
format!("- `{}` = `{}`", self.name, self.value)
}
}
#[derive(Clone, Debug, Default)]
@@ -210,17 +216,21 @@ impl AliasPlan {
}
}
pub(crate) fn shorten(&self, locator: &str) -> Option<String> {
pub(crate) fn root_for_locator(&self, locator: &str) -> Option<&AliasRoot> {
self.roots
.iter()
.filter_map(|root| {
let suffix = locator
.strip_prefix(root.value.trim_end_matches('/'))?
.strip_prefix('/')?;
Some((root, suffix))
.filter(|root| {
locator
.strip_prefix(root.value.trim_end_matches('/'))
.is_some_and(|suffix| suffix.starts_with('/'))
})
.max_by_key(|(root, _)| root.value.len())
.map(|(root, suffix)| format!("{}/{suffix}", root.name))
.max_by_key(|root| root.value.len())
}
pub(crate) fn shorten(&self, locator: &str) -> Option<String> {
let root = self.root_for_locator(locator)?;
let suffix = &locator[root.value.trim_end_matches('/').len()..];
Some(format!("{}{suffix}", root.name))
}
fn expand(&self, alias: &str) -> Option<String> {
@@ -233,15 +243,12 @@ impl AliasPlan {
self.roots
.iter()
.filter(|root| used.contains(&root.name))
.map(|root| format!("- `{}` = `{}`", root.name, root.value))
.map(AliasRoot::line)
.collect()
}
pub(crate) fn root_lines(&self) -> Vec<String> {
self.roots
.iter()
.map(|root| format!("- `{}` = `{}`", root.name, root.value))
.collect()
self.roots.iter().map(AliasRoot::line).collect()
}
}
+7
View File
@@ -304,6 +304,13 @@ impl SkillCatalogEntry {
std::iter::once(self.primary_source()).chain(self.sources.iter().cloned())
}
pub(crate) fn unaliased_locator(&self) -> &str {
match &self.authority.kind {
SkillSourceKind::Executor | SkillSourceKind::Cloud => self.id.0.as_str(),
SkillSourceKind::Host | SkillSourceKind::Custom(_) => self.rendered_path(),
}
}
pub(crate) fn rendered_path(&self) -> &str {
self.display_path
.as_deref()
+346
View File
@@ -0,0 +1,346 @@
//! Publishes skill additions and edits within one metadata budget per context window.
//! Each snapshot remembers the full inventory and which skills the model has seen.
//! Compaction starts a fresh listing and budget.
use std::collections::BTreeMap;
use std::sync::LazyLock;
use codex_extension_api::ContextualUserFragment;
use codex_extension_api::PreviousWorldStateSection;
use codex_extension_api::RenderedWorldStateFragment;
use codex_extension_api::WorldStateSectionContribution;
use codex_protocol::models::ContentItemMetadata;
use codex_protocol::models::input_text;
use codex_protocol::protocol::SKILLS_INSTRUCTIONS_CLOSE_TAG;
use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG;
use serde::Deserialize;
use serde::Serialize;
use serde_json::json;
use crate::aliases::AliasRoot;
use crate::catalog_prompt::SkillPromptKind;
use crate::fragments::AvailableSkillsInstructions;
use crate::render::AvailableSkillsRender;
use crate::render::SkillMetadataBudget;
use crate::render::SkillRenderReport;
use crate::render::aliased_metadata_overhead_cost;
use crate::render::metadata_line_cost;
use crate::world_state_catalogs::CatalogBudgetAllocation;
const SNAPSHOT_VERSION: u8 = 1;
const ROOTS_HEADING: &str =
"### Skill roots\nThese mappings replace earlier mappings for the same aliases.\n";
const SKILLS_HEADING: &str = "### Added or updated skills:\n";
#[derive(Deserialize, Serialize)]
pub(crate) struct SkillEntrySnapshot {
pub(crate) fingerprint: String,
pub(crate) shown: bool,
}
pub(crate) struct CatalogSkill {
pub(crate) snapshot: SkillEntrySnapshot,
pub(crate) line: String,
pub(crate) root: Option<AliasRoot>,
}
/// Retain both units so switching budget units cannot refund earlier emissions.
#[derive(Clone, Copy, Default, Deserialize, Serialize)]
pub(crate) struct CatalogUsage {
tokens: usize,
characters: usize,
}
impl CatalogUsage {
fn measure(cost: impl Fn(SkillMetadataBudget) -> usize) -> Self {
Self {
tokens: cost(SkillMetadataBudget::Tokens(0)),
characters: cost(SkillMetadataBudget::Characters(0)),
}
}
pub(crate) fn cost(self, budget: SkillMetadataBudget) -> usize {
match budget {
SkillMetadataBudget::Tokens(_) => self.tokens,
SkillMetadataBudget::Characters(_) => self.characters,
}
}
}
#[derive(Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct CatalogSnapshot {
version: u8,
entries: BTreeMap<String, SkillEntrySnapshot>,
roots: BTreeMap<String, String>,
usage: CatalogUsage,
instructions_sent: bool,
allocation: Option<CatalogBudgetAllocation>,
}
pub(crate) struct CatalogDelta {
pub(crate) rendered: Option<AvailableSkillsRender>,
pub(crate) include_usage: bool,
pub(crate) allocation: Option<CatalogBudgetAllocation>,
}
struct CatalogResult {
snapshot: CatalogSnapshot,
body: Option<String>,
report: SkillRenderReport,
}
pub(crate) fn skills_delta_section(
id: &'static str,
source: &'static str,
catalog: CatalogDelta,
previous: Option<&serde_json::Value>,
remaining: &mut SkillMetadataBudget,
on_render: impl Fn(&SkillRenderReport) + Send + Sync + 'static,
) -> WorldStateSectionContribution {
// Updates share their remaining budget in source order. A full render, including
// the one requested by compaction, always starts from the bounded initial listing.
let update = previous
.and_then(|value| CatalogSnapshot::deserialize(value).ok())
.filter(|snapshot| snapshot.version == SNAPSHOT_VERSION)
.map(|previous| build_catalog_update(source, &catalog, previous, remaining));
let initial = LazyLock::new(move || build_initial_catalog(source, catalog));
WorldStateSectionContribution::new(id, move |previous| {
let result = match previous {
PreviousWorldStateSection::Known(_) => update.as_ref().unwrap_or_else(|| &initial),
PreviousWorldStateSection::Absent | PreviousWorldStateSection::Unknown => &initial,
};
if result.body.is_some() || result.report.omitted_count > 0 {
on_render(&result.report);
}
let fragment = result.body.as_ref().map(|body| {
RenderedWorldStateFragment::new(
"developer",
(SKILLS_INSTRUCTIONS_OPEN_TAG, SKILLS_INSTRUCTIONS_CLOSE_TAG),
input_text(body.clone()),
)
.with_metadata(ContentItemMetadata::skills())
});
(Some(json!(result.snapshot)), fragment)
})
.with_legacy_matcher(|role, text| {
role == "developer"
&& text.trim_start().starts_with(SKILLS_INSTRUCTIONS_OPEN_TAG)
&& text.trim_end().ends_with(SKILLS_INSTRUCTIONS_CLOSE_TAG)
})
}
fn build_initial_catalog(source: &str, catalog: CatalogDelta) -> CatalogResult {
let mut snapshot = CatalogSnapshot {
version: SNAPSHOT_VERSION,
allocation: catalog.allocation,
..CatalogSnapshot::default()
};
let mut report = SkillRenderReport::default();
let body = catalog.rendered.and_then(|mut rendered| {
report = std::mem::take(&mut rendered.report);
let usage =
CatalogUsage::measure(|budget| rendered.metadata_cost(budget, catalog.include_usage));
let entries = std::mem::take(&mut rendered.entries);
let fragment = rendered.into_fragment(catalog.include_usage);
for (id, skill) in entries {
if fragment.is_some()
&& let Some(root) = skill.root
{
snapshot.roots.insert(root.name, root.value.to_string());
}
snapshot.entries.insert(id, skill.snapshot);
}
let fragment = fragment?;
snapshot.instructions_sent = true;
snapshot.usage = usage;
Some(listing_body(source, fragment))
});
CatalogResult {
snapshot,
report,
body,
}
}
fn build_catalog_update(
source: &str,
catalog: &CatalogDelta,
mut previous: CatalogSnapshot,
remaining: &mut SkillMetadataBudget,
) -> CatalogResult {
let message = if previous.instructions_sent {
CatalogMessage::Update {
heading: format!("\n## {source} skills update\n"),
}
} else {
CatalogMessage::FirstListing {
prompt_kind: catalog
.rendered
.as_ref()
.map_or(SkillPromptKind::Unaliased, |r| r.prompt_kind),
include_usage: catalog.include_usage,
}
};
let mut draft = UpdateDraft {
message,
skill_lines: Vec::new(),
root_lines: Vec::new(),
};
let mut report = SkillRenderReport::default();
let mut entries = BTreeMap::new();
for (id, skill) in catalog.rendered.iter().flat_map(|r| &r.entries) {
let old = previous.entries.get(id);
let mut shown = old.is_some_and(|old| old.shown);
if old.is_none_or(|old| old.shown && old.fingerprint != skill.snapshot.fingerprint) {
report.total_count += 1;
if draft.try_append_skill(skill, &mut previous, remaining) {
shown = true;
report.included_count += 1;
} else {
report.omitted_count += 1;
}
}
entries.insert(
id.clone(),
SkillEntrySnapshot {
fingerprint: skill.snapshot.fingerprint.clone(),
shown,
},
);
}
let body = draft.into_body(source);
previous.entries = entries;
previous.instructions_sent |= body.is_some();
previous.allocation = catalog.allocation;
CatalogResult {
snapshot: previous,
report,
body,
}
}
fn listing_body(source: &str, instructions: AvailableSkillsInstructions) -> String {
format!(
"\n## {source} skills\n{}",
instructions.body().into_text().unwrap_or_default()
)
}
enum CatalogMessage {
FirstListing {
prompt_kind: SkillPromptKind,
include_usage: bool,
},
Update {
heading: String,
},
}
struct UpdateDraft {
message: CatalogMessage,
skill_lines: Vec<String>,
root_lines: Vec<String>,
}
impl UpdateDraft {
fn try_append_skill(
&mut self,
skill: &CatalogSkill,
snapshot: &mut CatalogSnapshot,
remaining: &mut SkillMetadataBudget,
) -> bool {
let new_mapping = skill
.root
.as_ref()
.filter(|root| {
snapshot.roots.get(&root.name).map(String::as_str) != Some(root.value.as_ref())
})
.map(|root| (root, root.line()));
let cost = CatalogUsage::measure(|budget| {
let mut cost = metadata_line_cost(budget, &skill.line);
if let CatalogMessage::Update { heading } = &self.message
&& self.skill_lines.is_empty()
{
cost += budget.cost(heading) + budget.cost(SKILLS_HEADING);
}
if let Some((_, line)) = &new_mapping {
cost += match &self.message {
CatalogMessage::FirstListing {
prompt_kind,
include_usage,
} if self.root_lines.is_empty() => aliased_metadata_overhead_cost(
budget,
*prompt_kind,
std::slice::from_ref(line),
*include_usage,
),
CatalogMessage::Update { .. } if self.root_lines.is_empty() => {
budget.cost(ROOTS_HEADING) + metadata_line_cost(budget, line)
}
CatalogMessage::FirstListing { .. } | CatalogMessage::Update { .. } => {
metadata_line_cost(budget, line)
}
};
}
cost
});
let charge = cost.cost(*remaining);
if charge > remaining.limit() {
return false;
}
*remaining = remaining.with_limit(remaining.limit() - charge);
snapshot.usage.tokens += cost.tokens;
snapshot.usage.characters += cost.characters;
if let Some((root, line)) = new_mapping {
snapshot
.roots
.insert(root.name.clone(), root.value.to_string());
self.root_lines.push(line);
}
self.skill_lines.push(skill.line.clone());
true
}
fn into_body(self, source: &str) -> Option<String> {
if self.skill_lines.is_empty() {
return None;
}
Some(match self.message {
CatalogMessage::FirstListing {
prompt_kind,
include_usage,
} => {
let kind = if self.root_lines.is_empty() {
SkillPromptKind::Unaliased
} else {
prompt_kind
};
listing_body(
source,
AvailableSkillsInstructions::from_skill_lines(
kind,
self.root_lines,
self.skill_lines,
include_usage,
),
)
}
CatalogMessage::Update { mut heading } => {
if !self.root_lines.is_empty() {
heading.push_str(ROOTS_HEADING);
heading.push_str(&self.root_lines.join("\n"));
heading.push('\n');
}
heading.push_str(SKILLS_HEADING);
heading.push_str(&self.skill_lines.join("\n"));
heading.push('\n');
heading
}
})
}
}
#[cfg(test)]
#[path = "catalog_delta_tests.rs"]
mod tests;
@@ -0,0 +1,46 @@
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn update_spends_budget_only_on_accepted_rows_and_their_new_roots() {
let mut draft = UpdateDraft {
message: CatalogMessage::Update {
heading: "\n## Cloud skills update\n".to_string(),
},
skill_lines: Vec::new(),
root_lines: Vec::new(),
};
let skill = |line: &str, alias: &str, root: &str| CatalogSkill {
snapshot: SkillEntrySnapshot {
fingerprint: String::new(),
shown: false,
},
line: line.to_string(),
root: Some(AliasRoot {
name: alias.to_string(),
value: root.into(),
}),
};
let notes = skill(
"- release-notes: (cloud package: c0/notes)",
"c0",
"skill://writing",
);
let docs = skill("- docs: (cloud package: c0/docs)", "c0", "skill://writing");
let expected = "\n## Cloud skills update\n### Skill roots\nThese mappings replace earlier mappings for the same aliases.\n- `c0` = `skill://writing`\n### Added or updated skills:\n- release-notes: (cloud package: c0/notes)\n- docs: (cloud package: c0/docs)\n";
let mut remaining = SkillMetadataBudget::Characters(expected.chars().count());
let mut snapshot = CatalogSnapshot::default();
let large = skill(&"a".repeat(remaining.limit()), "c1", "skill://design");
assert!(!draft.try_append_skill(&large, &mut snapshot, &mut remaining));
assert!(draft.try_append_skill(&notes, &mut snapshot, &mut remaining));
assert!(draft.try_append_skill(&docs, &mut snapshot, &mut remaining));
assert_eq!(draft.into_body("Cloud").as_deref(), Some(expected));
assert_eq!(
snapshot.roots,
BTreeMap::from([("c0".into(), "skill://writing".into())])
);
assert_eq!(snapshot.usage.characters, expected.chars().count());
assert_eq!(remaining, SkillMetadataBudget::Characters(0));
}
@@ -94,6 +94,7 @@ fn state() -> SkillsThreadState {
SkillsThreadState::new(
SkillsExtensionConfig {
include_instructions: true,
catalog_deltas_enabled: false,
max_context_tokens: None,
bundled_skills_enabled: false,
cloud_skill_enabled: true,
+2
View File
@@ -5,6 +5,8 @@ use std::num::NonZeroUsize;
pub struct SkillsExtensionConfig {
/// Whether the available-skills catalog is included in model context.
pub include_instructions: bool,
/// Whether skill additions and edits share a cumulative metadata budget until compaction.
pub catalog_deltas_enabled: bool,
/// Optional token budget override for the available-skills catalog.
pub max_context_tokens: Option<NonZeroUsize>,
/// Whether bundled skills are eligible for discovery.
+3 -1
View File
@@ -126,7 +126,9 @@ fn render_prepared_catalog(
return RenderedCatalog::default();
};
record_catalog_render(extension_metrics, catalog_surface, budget, &rendered.report);
let warning_message = rendered.report.warning_message();
let warning_message = rendered
.report
.warning_message(/*catalog_deltas_enabled*/ false);
let fragment = rendered.into_fragment(include_skills_usage_instructions);
RenderedCatalog {
fragment,
@@ -45,6 +45,7 @@ fn detect_executor_skill(command: &str, environments: &[&str]) -> Option<SkillIn
cloud_status: crate::step_state::CatalogStatus::Unavailable,
config: crate::SkillsExtensionConfig {
include_instructions: true,
catalog_deltas_enabled: false,
max_context_tokens: None,
bundled_skills_enabled: false,
cloud_skill_enabled: false,
+1
View File
@@ -1,5 +1,6 @@
mod aliases;
pub mod catalog;
mod catalog_delta;
mod catalog_observability;
mod catalog_prompt;
mod catalog_state;
+66 -48
View File
@@ -1,4 +1,5 @@
use std::borrow::Cow;
use std::collections::BTreeMap;
use std::collections::HashSet;
use std::num::NonZeroUsize;
@@ -12,6 +13,7 @@ use crate::aliases::AliasPlan;
use crate::catalog::SkillCatalog;
use crate::catalog::SkillCatalogEntry;
use crate::catalog::SkillSourceKind;
use crate::catalog_delta::CatalogSkill;
use crate::catalog_prompt::SkillPromptKind;
use crate::catalog_prompt::render_available_skills_body;
use crate::fragments::AvailableSkillsInstructions;
@@ -93,25 +95,22 @@ pub(crate) struct SkillRenderReport {
}
impl SkillRenderReport {
pub(crate) fn warning_message(&self) -> Option<String> {
if self.omitted_count > 0 {
let skill_word = if self.omitted_count == 1 {
"skill"
} else {
"skills"
};
let verb = if self.omitted_count == 1 {
"was"
} else {
"were"
};
return Some(format!(
"{} {} additional {} {} not included in the model-visible skills list.",
SKILL_DESCRIPTIONS_REMOVED_WARNING_PREFIX, self.omitted_count, skill_word, verb
));
pub(crate) fn warning_message(&self, catalog_deltas_enabled: bool) -> Option<String> {
let omitted_count = self.omitted_count;
if omitted_count == 0 {
return None;
}
None
if catalog_deltas_enabled {
return Some("Exceeded skills context budget. Some skills were not included in the model-visible skills list.".to_string());
}
let skills_were = if omitted_count == 1 {
"skill was"
} else {
"skills were"
};
Some(format!(
"{SKILL_DESCRIPTIONS_REMOVED_WARNING_PREFIX} {omitted_count} additional {skills_were} not included in the model-visible skills list."
))
}
pub(crate) fn average_truncated_description_chars(&self) -> usize {
@@ -153,7 +152,7 @@ pub(crate) fn skill_metadata_budget(
))
}
fn metadata_line_cost(budget: SkillMetadataBudget, line: &str) -> usize {
pub(crate) fn metadata_line_cost(budget: SkillMetadataBudget, line: &str) -> usize {
let line = format!("{line}\n");
match budget {
SkillMetadataBudget::Tokens(_) => approx_token_count(&line),
@@ -185,7 +184,7 @@ impl SkillMetadataBudget {
}
}
fn cost(self, text: &str) -> usize {
pub(crate) fn cost(self, text: &str) -> usize {
match self {
Self::Tokens(_) => approx_token_count(text),
Self::Characters(_) => text.chars().count(),
@@ -194,6 +193,7 @@ impl SkillMetadataBudget {
}
struct SkillLine<'a> {
entry: &'a SkillCatalogEntry,
name: &'a str,
description: Cow<'a, str>,
locator: String,
@@ -202,10 +202,7 @@ struct SkillLine<'a> {
impl<'a> SkillLine<'a> {
fn new(entry: &'a SkillCatalogEntry, policy: SkillCatalogRenderPolicy) -> Self {
let locator = match &entry.authority.kind {
SkillSourceKind::Executor | SkillSourceKind::Cloud => entry.id.0.as_str(),
SkillSourceKind::Host | SkillSourceKind::Custom(_) => entry.rendered_path(),
};
let locator = entry.unaliased_locator();
Self::with_locator(entry, policy, locator.to_string())
}
@@ -216,6 +213,7 @@ impl<'a> SkillLine<'a> {
) -> Self {
let description = policy.description(entry);
Self {
entry,
name: entry.name.as_str(),
description: truncate_catalog_skill_description(description),
locator,
@@ -228,6 +226,15 @@ impl<'a> SkillLine<'a> {
}
}
fn catalog_id(&self) -> String {
serde_json::json!([
self.entry.authority.kind.to_string(),
self.entry.authority.id,
self.entry.id.0
])
.to_string()
}
fn full_cost(&self, budget: SkillMetadataBudget) -> usize {
metadata_line_cost(budget, &self.render_full())
}
@@ -269,12 +276,10 @@ impl<'a> SkillLine<'a> {
}
}
struct RenderedSkillLine {
line: String,
}
struct RenderedSkillLines {
lines: Vec<RenderedSkillLine>,
lines: Vec<String>,
/// Only needed when some rows were omitted.
included_ids: Option<Vec<String>>,
omitted_count: usize,
truncated_description_chars: usize,
truncated_description_count: usize,
@@ -378,6 +383,10 @@ fn render_allocated_skill_lines(
allocations: &[SkillLineAllocation],
) -> RenderedSkillLines {
let mut lines = Vec::new();
let mut included_ids = allocations
.iter()
.any(|a| matches!(a, SkillLineAllocation::Omitted))
.then(Vec::new);
let mut omitted_count = 0usize;
let mut truncated_description_chars = 0usize;
let mut truncated_description_count = 0usize;
@@ -399,14 +408,16 @@ fn render_allocated_skill_lines(
truncated_description_chars.saturating_add(truncated_chars);
truncated_description_count = truncated_description_count.saturating_add(1);
}
lines.push(RenderedSkillLine {
line: line.render_with_description_chars(*description_chars),
});
lines.push(line.render_with_description_chars(*description_chars));
if let Some(ids) = &mut included_ids {
ids.push(line.catalog_id());
}
}
}
}
RenderedSkillLines {
lines,
included_ids,
omitted_count,
truncated_description_chars,
truncated_description_count,
@@ -458,13 +469,18 @@ struct RenderedCatalog {
prompt_kind: SkillPromptKind,
skill_root_lines: Vec<String>,
skill_lines: Vec<String>,
included_ids: Option<Vec<String>>,
report: SkillRenderReport,
}
pub(crate) struct AvailableSkillsRender {
prompt_kind: SkillPromptKind,
skill_root_lines: Vec<String>,
pub(crate) prompt_kind: SkillPromptKind,
pub(crate) skill_root_lines: Vec<String>,
skill_lines: Vec<String>,
included_ids: Option<Vec<String>>,
/// Populated for delta-enabled world state after selecting the final rendering.
/// Includes all eligible entries, even skills omitted from `skill_lines`.
pub(crate) entries: BTreeMap<String, CatalogSkill>,
preserve_empty_fragment: bool,
pub(crate) report: SkillRenderReport,
}
@@ -740,17 +756,20 @@ fn render_combined_group(
) -> AvailableSkillsRender {
let RenderedSkillLines {
mut lines,
included_ids,
omitted_count,
truncated_description_chars,
truncated_description_count,
} = render_allocated_skill_lines(skill_lines, allocations);
if let Some(marker) = omission_marker {
lines.push(RenderedSkillLine { line: marker });
lines.push(marker);
}
AvailableSkillsRender {
prompt_kind,
skill_root_lines,
skill_lines: lines.into_iter().map(|rendered| rendered.line).collect(),
entries: BTreeMap::new(),
skill_lines: lines,
included_ids,
preserve_empty_fragment: false,
report: SkillRenderReport {
total_count: skill_lines.len(),
@@ -914,25 +933,29 @@ fn render_catalog(
let total_count = skill_lines.len();
let RenderedSkillLines {
lines: mut rendered_lines,
mut included_ids,
omitted_count: mut omitted,
truncated_description_chars,
truncated_description_count,
} = render_skill_lines(skill_lines, budget);
let mut total_cost = rendered_lines.iter().fold(0usize, |used, rendered| {
used.saturating_add(metadata_line_cost(budget, &rendered.line))
used.saturating_add(metadata_line_cost(budget, rendered))
});
if omitted > 0 && policy.includes_omission_notice() {
loop {
let marker = omission_marker(omitted);
if total_cost.saturating_add(metadata_line_cost(budget, &marker)) <= budget.limit() {
rendered_lines.push(RenderedSkillLine { line: marker });
rendered_lines.push(marker);
break;
}
let Some(rendered) = rendered_lines.pop() else {
break;
};
total_cost = total_cost.saturating_sub(metadata_line_cost(budget, &rendered.line));
if let Some(ids) = &mut included_ids {
ids.pop();
}
total_cost = total_cost.saturating_sub(metadata_line_cost(budget, &rendered));
omitted = omitted.saturating_add(1);
}
}
@@ -940,10 +963,8 @@ fn render_catalog(
RenderedCatalog {
prompt_kind,
skill_root_lines,
skill_lines: rendered_lines
.into_iter()
.map(|rendered| rendered.line)
.collect(),
skill_lines: rendered_lines,
included_ids,
report: SkillRenderReport {
total_count,
included_count: total_count.saturating_sub(omitted),
@@ -1030,10 +1051,7 @@ fn render_skill_locator_with_aliases(
plan: &AliasPlan,
used_roots: &mut HashSet<String>,
) -> String {
let locator = match &entry.authority.kind {
SkillSourceKind::Executor | SkillSourceKind::Cloud => entry.id.0.as_str(),
SkillSourceKind::Host | SkillSourceKind::Custom(_) => entry.rendered_path(),
};
let locator = entry.unaliased_locator();
if entry.alias_root().is_none() {
return locator.to_string();
}
@@ -1046,7 +1064,7 @@ fn render_skill_locator_with_aliases(
alias
}
fn aliased_metadata_overhead_cost(
pub(crate) fn aliased_metadata_overhead_cost(
budget: SkillMetadataBudget,
prompt_kind: SkillPromptKind,
skill_root_lines: &[String],
+59
View File
@@ -1,6 +1,8 @@
//! Prefers cloud skills before budgeting while retaining aliases from the full visible inventory.
//! Collects comparison metadata after the final rendering has been selected.
use std::borrow::Cow;
use std::collections::BTreeMap;
use std::collections::HashSet;
use super::AvailableSkillsRender;
@@ -14,6 +16,8 @@ use super::render_catalog;
use crate::aliases::AliasPlan;
use crate::catalog::SkillCatalog;
use crate::catalog::SkillCatalogEntry;
use crate::catalog_delta::CatalogSkill;
use crate::catalog_delta::SkillEntrySnapshot;
use crate::catalog_prompt::SkillPromptKind;
pub(crate) struct PreparedSkillCatalog<'a> {
@@ -72,6 +76,59 @@ impl<'a> PreparedSkillCatalog<'a> {
CatalogLines::aliased(&self.entries, self.policy, self.alias_plan.as_ref())
}
/// Collect comparison metadata once the final layout and budget have been selected.
/// Include every eligible entry, even when its rendered row was omitted.
pub(crate) fn entries_for_render(
&self,
rendered: &AvailableSkillsRender,
) -> BTreeMap<String, CatalogSkill> {
let lines = if rendered.skill_root_lines.is_empty() {
self.unaliased()
} else {
self.aliased()
};
let shown = rendered
.included_ids
.as_ref()
.map(|ids| ids.iter().collect::<HashSet<_>>());
lines
.skills
.into_iter()
.map(|line| {
let id = line.catalog_id();
let entry = line.entry;
let fingerprint = blake3::hash(
serde_json::json!([
entry.name,
line.description,
entry.main_prompt.as_str(),
line.locator,
])
.to_string()
.as_bytes(),
)
.to_hex()
.to_string();
let locator = entry.unaliased_locator();
let root = self
.alias_plan
.as_ref()
.filter(|_| line.locator != locator)
.and_then(|plan| plan.root_for_locator(locator))
.cloned();
let shown = shown.as_ref().is_none_or(|ids| ids.contains(&id));
(
id,
CatalogSkill {
snapshot: SkillEntrySnapshot { fingerprint, shown },
line: line.render_full(),
root,
},
)
})
.collect()
}
pub(crate) fn render(
&self,
budget: SkillMetadataBudget,
@@ -107,6 +164,8 @@ impl<'a> PreparedSkillCatalog<'a> {
prompt_kind: selected.prompt_kind,
skill_root_lines: selected.skill_root_lines,
skill_lines: selected.skill_lines,
included_ids: selected.included_ids,
entries: BTreeMap::new(),
preserve_empty_fragment: self.policy == SkillCatalogRenderPolicy::CoreCompatible,
report: selected.report,
})
+49 -7
View File
@@ -40,6 +40,37 @@ fn render_combined_available_skills(
)
}
#[test]
fn edits_beyond_the_catalog_description_cap_keep_the_same_fingerprint() {
let prefix = "a".repeat(MAX_CATALOG_SKILL_DESCRIPTION_CHARS);
let fingerprint = |suffix| {
let catalog = SkillCatalog {
entries: vec![entry(
"release-notes",
&format!("{prefix}{suffix}"),
/*short_description*/ None,
)],
..SkillCatalog::default()
};
let prepared =
PreparedSkillCatalog::new(&catalog, SkillCatalogRenderPolicy::CoreCompatible);
let rendered = prepared
.render(
SkillMetadataBudget::Characters(8_000),
/*include_skills_usage_instructions*/ false,
)
.unwrap();
prepared
.entries_for_render(&rendered)
.into_values()
.next()
.unwrap()
.snapshot
.fingerprint
};
assert_eq!(fingerprint("draft"), fingerprint("published"));
}
#[test]
fn skill_prompt_contents_are_bounded_at_utf8_boundaries() {
let contents = format!("{}é", "a".repeat(MAX_SKILL_PROMPT_BYTES - 1));
@@ -1124,13 +1155,6 @@ fn catalog_emits_omission_marker_when_every_minimum_skill_line_exceeds_budget()
truncated_description_chars: MAX_CATALOG_SKILL_DESCRIPTION_CHARS,
truncated_description_count: 1,
};
assert_eq!(
expected_report.warning_message(),
Some(
"Exceeded skills context budget. All skill descriptions were removed and 1 additional skill was not included in the model-visible skills list."
.to_string()
)
);
let core_render = render_available_skills(
&catalog,
SkillCatalogRenderPolicy::CoreCompatible,
@@ -1140,6 +1164,24 @@ fn catalog_emits_omission_marker_when_every_minimum_skill_line_exceeds_budget()
)
.expect("core-compatible report should render");
assert_eq!(core_render.report, expected_report);
assert_eq!(
core_render
.report
.warning_message(/*catalog_deltas_enabled*/ false)
.as_deref(),
Some(
"Exceeded skills context budget. All skill descriptions were removed and 1 additional skill was not included in the model-visible skills list."
)
);
assert_eq!(
core_render
.report
.warning_message(/*catalog_deltas_enabled*/ true)
.as_deref(),
Some(
"Exceeded skills context budget. Some skills were not included in the model-visible skills list."
)
);
let core_fragment = core_render
.into_fragment(/*include_skills_usage_instructions*/ false)
.expect("core-compatible rendering should preserve an empty skills fragment");
@@ -78,6 +78,7 @@ async fn turn_input_and_history_advance_while_ranking_is_blocked()
let registry = builder.build();
let config = SkillsExtensionConfig {
include_instructions: false,
catalog_deltas_enabled: false,
max_context_tokens: None,
bundled_skills_enabled: false,
cloud_skill_enabled: false,
+98 -11
View File
@@ -11,6 +11,9 @@ use serde::Deserialize;
use serde::Serialize;
use crate::catalog::SkillCatalog;
use crate::catalog_delta::CatalogDelta;
use crate::catalog_delta::CatalogUsage;
use crate::catalog_delta::skills_delta_section;
use crate::render::AvailableSkillsRender;
use crate::render::PreparedSkillCatalog;
use crate::render::RenderedSkillCatalogs;
@@ -234,6 +237,32 @@ impl<'a> CatalogRenderingContext<'a> {
self.render_with_cloud_limit(&catalogs, &executor, allocation.cloud_limit);
}
if self.step_state.config.catalog_deltas_enabled {
let cloud = PreparedSkillCatalog::with_alias_plan(
&catalogs.cloud.catalog,
self.render_policy,
Some(
self.step_state
.catalog
.aliases
.for_authority(SkillToolAuthoritySelector::Cloud),
),
);
let host = PreparedSkillCatalog::new(
&catalogs.host.catalog,
SkillCatalogRenderPolicy::CoreCompatible,
);
for (catalog, rendered) in [
(&executor, &mut rendered.executor),
(&cloud, &mut rendered.cloud),
(&host, &mut rendered.host),
] {
if let Some(rendered) = rendered {
rendered.entries = catalog.entries_for_render(rendered);
}
}
}
[
(CatalogKind::Executor, catalogs.executor, rendered.executor),
(CatalogKind::Cloud, catalogs.cloud, rendered.cloud),
@@ -355,6 +384,7 @@ impl<'a> CatalogRenderingContext<'a> {
fn build_world_state_section(
&self,
catalog: RenderedCatalogContribution,
remaining: &mut SkillMetadataBudget,
) -> WorldStateSectionContribution {
let RenderedCatalogContribution {
kind,
@@ -367,14 +397,11 @@ impl<'a> CatalogRenderingContext<'a> {
.as_ref()
.map(|rendered| rendered.report.clone())
.unwrap_or_default();
let body = rendered
.and_then(|rendered| rendered.into_fragment(self.include_usage))
.map(|fragment| fragment.body().into_text().unwrap_or_default());
let include_instructions = self.step_state.config.include_instructions;
let catalog_deltas_enabled = self.step_state.config.catalog_deltas_enabled;
let metrics = self.input.extension_metrics.clone();
let warning_emitter = Arc::clone(&self.warning_emitter);
let render_report = report.clone();
let on_render: CatalogRenderCallback = Box::new(move || {
let on_render = move |render_report: &crate::render::SkillRenderReport| {
if !include_instructions || status != CatalogStatus::Enabled {
return;
}
@@ -383,13 +410,48 @@ impl<'a> CatalogRenderingContext<'a> {
metrics.as_deref(),
kind.metrics_surface(),
budget,
&render_report,
render_report,
);
if let Some(message) = render_report.warning_message() {
if let Some(message) = render_report.warning_message(catalog_deltas_enabled) {
warning_emitter(message);
}
});
};
if catalog_deltas_enabled {
let (id, source) = match kind {
CatalogKind::Executor => (
crate::world_state::SKILLS_WORLD_STATE_ID,
"Selected-environment",
),
CatalogKind::Cloud => (crate::world_state::CLOUD_SKILLS_WORLD_STATE_ID, "Cloud"),
CatalogKind::Host => {
if status != CatalogStatus::Unavailable {
self.input.turn_store.insert(HostSkillsCatalogInWorldState);
}
(crate::world_state::HOST_SKILLS_WORLD_STATE_ID, "Host")
}
};
return skills_delta_section(
id,
source,
CatalogDelta {
rendered,
include_usage: self.include_usage,
allocation: (kind == CatalogKind::Cloud).then_some(allocation),
},
self.input
.previous_world_state
.and_then(|state| state.get(id)),
remaining,
on_render,
);
}
let body = rendered
.and_then(|rendered| rendered.into_fragment(self.include_usage))
.map(|fragment| fragment.body().into_text().unwrap_or_default());
let render_report = report.clone();
let on_render: CatalogRenderCallback = Box::new(move || on_render(&render_report));
match kind {
CatalogKind::Executor => executor_skills_world_state_section(
body,
@@ -414,7 +476,7 @@ impl<'a> CatalogRenderingContext<'a> {
}
}
/// Returns complete sections, omitting catalogs unavailable in this step.
/// Renders catalog sections, retaining empty delta snapshots for shared budget accounting.
pub(crate) fn render_world_state_sections(
event_sink: Arc<dyn ExtensionEventSink>,
input: WorldStateContributionInput<'_>,
@@ -422,10 +484,35 @@ pub(crate) fn render_world_state_sections(
let Some(context) = CatalogRenderingContext::new(event_sink, input) else {
return Vec::new();
};
let used = context
.input
.previous_world_state
.into_iter()
.flat_map(|state| {
[
crate::world_state::SKILLS_WORLD_STATE_ID,
crate::world_state::CLOUD_SKILLS_WORLD_STATE_ID,
crate::world_state::HOST_SKILLS_WORLD_STATE_ID,
]
.into_iter()
.filter_map(|id| state.get(id))
})
.filter_map(|snapshot| snapshot.get("usage"))
.filter_map(|usage| CatalogUsage::deserialize(usage).ok())
.map(|usage| usage.cost(context.metadata_budget))
.sum::<usize>();
let mut remaining = context
.metadata_budget
.with_limit(context.metadata_budget.limit().saturating_sub(used));
context
.render_world_state_catalogs()
.into_iter()
.filter(|catalog| catalog.status != CatalogStatus::Unavailable)
.map(|catalog| context.build_world_state_section(catalog))
// Keep empty snapshots too: a source that becomes available later must use
// the same remaining budget instead of publishing another initial catalog.
.filter(|catalog| {
context.step_state.config.catalog_deltas_enabled
|| catalog.status != CatalogStatus::Unavailable
})
.map(|catalog| context.build_world_state_section(catalog, &mut remaining))
.collect()
}
@@ -103,6 +103,9 @@ mod shadow_task_context_tests;
#[path = "skills_extension/presentation_dedup_tests.rs"]
mod presentation_dedup_tests;
#[path = "skills_extension/catalog_delta_tests.rs"]
mod catalog_delta_tests;
static NEXT_CODEX_HOME_ID: AtomicUsize = AtomicUsize::new(0);
const SKILLS_INTRO_WITH_ABSOLUTE_PATHS: &str = "A skill is a set of instructions provided through a `SKILL.md` source. Below is the list of skills that can be used. Each entry includes a name, description, and source locator. `file` locators are on the host filesystem, `executor package` locators are owned by their execution environment, `cloud package` locators are opaque package identifiers, and `custom resource` locators use their provider's access mechanism.";
const DEMO_SKILL_CONTENTS: &str =
@@ -3610,6 +3613,7 @@ fn test_entry(
#[derive(Clone, Debug, Eq, PartialEq)]
struct TestConfig {
include_instructions: bool,
catalog_deltas_enabled: bool,
bundled_skills_enabled: bool,
cloud_skill_enabled: bool,
shadow_selection_enabled: bool,
@@ -3618,6 +3622,7 @@ struct TestConfig {
fn default_config() -> TestConfig {
TestConfig {
include_instructions: true,
catalog_deltas_enabled: false,
bundled_skills_enabled: true,
cloud_skill_enabled: true,
shadow_selection_enabled: false,
@@ -3627,6 +3632,7 @@ fn default_config() -> TestConfig {
fn skills_extension_config(config: &TestConfig) -> SkillsExtensionConfig {
SkillsExtensionConfig {
include_instructions: config.include_instructions,
catalog_deltas_enabled: config.catalog_deltas_enabled,
max_context_tokens: None,
bundled_skills_enabled: config.bundled_skills_enabled,
cloud_skill_enabled: config.cloud_skill_enabled,
@@ -0,0 +1,146 @@
//! Multi-source budgeting and feature rollback through the skills extension.
use super::*;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn skill_sources_spend_the_same_remaining_budget() -> TestResult {
let mut previous = serde_json::Map::new();
let enabled = TestConfig {
catalog_deltas_enabled: true,
..default_config()
};
render_catalogs(&[], &enabled, &mut previous).await?;
// One release-notes row fits the 100-token budget. The larger entry sorts first.
let large = "Detailed architectural guidance. ".repeat(40);
let small = "Draft release notes. ".repeat(9);
let entries = [
("architecture-review", large.as_str()),
("release-notes", small.as_str()),
];
let emitted = render_catalogs(&entries, &enabled, &mut previous).await?;
assert_eq!(
emitted
.lines()
.filter(|line| line.starts_with("- ") && line.contains("release-notes"))
.count(),
1
);
assert!(emitted.contains("- executor-release-notes:"), "{emitted}");
assert!(!emitted.contains("architecture-review"), "{emitted}");
let used = previous
.values()
.filter_map(|s| s["usage"]["tokens"].as_u64())
.sum::<u64>();
assert!((50..=100).contains(&used), "{used}");
let edited = [
("architecture-review", "Review architecture."),
("release-notes", small.as_str()),
];
assert_eq!(render_catalogs(&edited, &enabled, &mut previous).await?, "");
let disabled = default_config();
let empty = render_catalogs(&[], &disabled, &mut previous).await?;
assert!(empty.contains("skills are currently available"), "{empty}");
assert_eq!(render_catalogs(&[], &disabled, &mut previous).await?, "");
Ok(())
}
async fn render_catalogs(
skills: &[(&str, &str)],
config: &TestConfig,
previous: &mut serde_json::Map<String, serde_json::Value>,
) -> Result<String, Box<dyn std::error::Error>> {
let provider = |kind: SkillSourceKind| {
let entries = skills
.iter()
.map(|(name, description)| {
let name = format!("{kind}-{name}");
let path = format!("/skills/{name}/SKILL.md");
let mut entry =
test_entry(kind.clone(), "skills", &format!("{kind}/{name}"), &path)
.with_display_path(path);
entry.description = description.to_string();
entry
})
.collect();
Arc::new(StaticSkillProvider {
catalog: SkillCatalog {
entries,
..Default::default()
},
read_requests: Arc::new(Mutex::new(Vec::new())),
list_calls: None,
fail_first_list: false,
})
};
let providers = SkillProviders::new()
.with_executor_provider(provider(SkillSourceKind::Executor))
.with_cloud_provider(provider(SkillSourceKind::Cloud))
.with_host_provider(provider(SkillSourceKind::Host));
let mut builder = ExtensionRegistryBuilder::new();
install_with_providers(&mut builder, providers, skills_extension_config);
let registry = builder.build();
let session_store = ExtensionData::new("session");
let thread_store = ExtensionData::new("thread");
registry.thread_lifecycle_contributors()[0]
.on_thread_start(ThreadStartInput {
config,
session_source: &SessionSource::Cli,
persistent_thread_state_available: true,
environments: &[],
mcp_resource_client: None,
extension_metrics: None,
session_store: &session_store,
thread_store: &thread_store,
})
.await;
start_registered_turn(&registry, &session_store, &thread_store, "turn").await;
let roots = [SelectedCapabilityRoot {
id: "skills".to_string(),
location: CapabilityRootLocation::Environment {
environment_id: "env-1".to_string(),
path: PathUri::parse("file:///skills")?,
},
}];
let turn_store = ExtensionData::new("turn");
turn_store.insert(HostSkillsSnapshot::new(Arc::new(
SkillLoadOutcome::default(),
)));
let sections = prepared_world_state(
&registry,
&roots,
WorldStateContributionInput {
previous_world_state: Some(previous),
step_store: &ExtensionData::new("step"),
model_info: &ModelInfo {
context_window: Some(5_000),
..catalog_model_info()
},
thread_id: codex_protocol::ThreadId::new(),
turn_id: "turn",
environments: &[],
extension_metrics: None,
session_store: &session_store,
thread_store: &thread_store,
turn_store: &turn_store,
},
)
.await;
let mut emitted = String::new();
for section in sections {
let prior = previous.get(section.id()).map_or(
PreviousWorldStateSection::Absent,
PreviousWorldStateSection::Known,
);
let (snapshot, fragment) = section.render_diff(prior);
if let Some(snapshot) = snapshot {
previous.insert(section.id().to_string(), snapshot);
}
if let Some(fragment) = fragment {
emitted.push_str(&fragment.body().clone().expect_text());
}
}
Ok(emitted)
}
+8
View File
@@ -378,6 +378,8 @@ pub enum Feature {
SkillMcpDependencyInstall,
/// Run cheap skill-search methods in shadow mode and emit experiment metrics.
SkillSearch,
/// Append skill additions and edits within a cumulative context budget.
SkillCatalogDeltas,
/// Removed compatibility flag for deleted skill env var dependency prompting.
SkillEnvVarDependencyPrompt,
/// Enable the unified mention popup used by default in the TUI.
@@ -1897,6 +1899,12 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Stable,
default_enabled: true,
},
FeatureSpec {
id: Feature::SkillCatalogDeltas,
key: "skill_catalog_deltas",
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::SkillEnvVarDependencyPrompt,
key: "skill_env_var_dependency_prompt",