diff --git a/codex-rs/app-server/src/extensions.rs b/codex-rs/app-server/src/extensions.rs index e4f43ca88d..72eb267cbb 100644 --- a/codex-rs/app-server/src/extensions.rs +++ b/codex-rs/app-server/src/extensions.rs @@ -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, diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 8b39fa155c..c22ebc3efd 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -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, diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index cc12557fcb..795b6590bd 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -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" }, diff --git a/codex-rs/core/tests/suite/executor_discovery_v2.rs b/codex-rs/core/tests/suite/executor_discovery_v2.rs index 8ad711e5a0..ab62dfd944 100644 --- a/codex-rs/core/tests/suite/executor_discovery_v2.rs +++ b/codex-rs/core/tests/suite/executor_discovery_v2.rs @@ -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, diff --git a/codex-rs/core/tests/suite/guardian_environments_tests.rs b/codex-rs/core/tests/suite/guardian_environments_tests.rs index f11155ad77..39b05bc54d 100644 --- a/codex-rs/core/tests/suite/guardian_environments_tests.rs +++ b/codex-rs/core/tests/suite/guardian_environments_tests.rs @@ -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, diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index f220babc05..1619257cdc 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -88,6 +88,7 @@ fn skills_extensions() -> Arc> { let mut extensions = ExtensionRegistryBuilder::::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, diff --git a/codex-rs/core/tests/suite/plugins.rs b/codex-rs/core/tests/suite/plugins.rs index 6336db0414..38c36200f0 100644 --- a/codex-rs/core/tests/suite/plugins.rs +++ b/codex-rs/core/tests/suite/plugins.rs @@ -93,6 +93,7 @@ fn skills_extensions() -> Arc> { let mut extensions = ExtensionRegistryBuilder::::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, diff --git a/codex-rs/core/tests/suite/remote_env_required_skills_tests.rs b/codex-rs/core/tests/suite/remote_env_required_skills_tests.rs index bb520fd513..82708a7eda 100644 --- a/codex-rs/core/tests/suite/remote_env_required_skills_tests.rs +++ b/codex-rs/core/tests/suite/remote_env_required_skills_tests.rs @@ -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, diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index adb7852665..c87bcedf5f 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -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, diff --git a/codex-rs/core/tests/suite/scenarios.rs b/codex-rs/core/tests/suite/scenarios.rs index a3a7a47e3e..9ec56ebfe6 100644 --- a/codex-rs/core/tests/suite/scenarios.rs +++ b/codex-rs/core/tests/suite/scenarios.rs @@ -200,6 +200,7 @@ fn skills_extensions() -> Arc> { let mut extensions = ExtensionRegistryBuilder::::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, diff --git a/codex-rs/core/tests/suite/scenarios_frozen_cloud_skills.rs b/codex-rs/core/tests/suite/scenarios_frozen_cloud_skills.rs index f863af7aac..31b8432fc0 100644 --- a/codex-rs/core/tests/suite/scenarios_frozen_cloud_skills.rs +++ b/codex-rs/core/tests/suite/scenarios_frozen_cloud_skills.rs @@ -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; diff --git a/codex-rs/core/tests/suite/scenarios_skill_catalog_dedup.rs b/codex-rs/core/tests/suite/scenarios_skill_catalog_dedup.rs index ebcf54e1fc..3ba2753a74 100644 --- a/codex-rs/core/tests/suite/scenarios_skill_catalog_dedup.rs +++ b/codex-rs/core/tests/suite/scenarios_skill_catalog_dedup.rs @@ -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("")) + { + 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::>(); + 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::>(); + 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(()) +} diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 35e407b3ba..7e2a5c6caa 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -325,6 +325,9 @@ async fn user_turn_selects_symlinked_skill_by_advertised_discovery_path() -> Res let mut extensions = ExtensionRegistryBuilder::::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, diff --git a/codex-rs/core/tests/suite/skills_extension.rs b/codex-rs/core/tests/suite/skills_extension.rs index 0aef50aec2..0777b2e5a9 100644 --- a/codex-rs/core/tests/suite/skills_extension.rs +++ b/codex-rs/core/tests/suite/skills_extension.rs @@ -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::::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) -> 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, diff --git a/codex-rs/core/tests/suite/skills_extension/cloud_lifecycle_tests.rs b/codex-rs/core/tests/suite/skills_extension/cloud_lifecycle_tests.rs index 96e7375281..fd062218a5 100644 --- a/codex-rs/core/tests/suite/skills_extension/cloud_lifecycle_tests.rs +++ b/codex-rs/core/tests/suite/skills_extension/cloud_lifecycle_tests.rs @@ -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, diff --git a/codex-rs/core/tests/suite/skills_extension/cloud_skill_tests.rs b/codex-rs/core/tests/suite/skills_extension/cloud_skill_tests.rs index 1b0e1c6c93..74a14c0aff 100644 --- a/codex-rs/core/tests/suite/skills_extension/cloud_skill_tests.rs +++ b/codex-rs/core/tests/suite/skills_extension/cloud_skill_tests.rs @@ -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, diff --git a/codex-rs/core/tests/suite/skills_extension/yielded_skill_tests.rs b/codex-rs/core/tests/suite/skills_extension/yielded_skill_tests.rs index e5753764af..8966dd5143 100644 --- a/codex-rs/core/tests/suite/skills_extension/yielded_skill_tests.rs +++ b/codex-rs/core/tests/suite/skills_extension/yielded_skill_tests.rs @@ -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, diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__skill_catalog_dedup__skill_catalog_deltas.snap b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__skill_catalog_dedup__skill_catalog_deltas.snap new file mode 100644 index 0000000000..c951770857 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__skill_catalog_dedup__skill_catalog_deltas.snap @@ -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: "" + 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] + + + + - 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] + ## 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` = `` + - `r1` = `/.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) + + [02] + 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. + + [03] # 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 `...<...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. + +03:message/developer: + 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. + + - 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"`. [hash=d5928243dc643bcc] +04:message/developer: + Any earlier instruction enabling proactive multi-agent delegation no longer applies. D...elegation, or parallel agent work. [hash=2b6e037067b4390c] +05:message/user: + + + + + + <...e="unrestricted" /> [hash=d40b2f45cb028888] + +06:message/user: + Inspect the available skills. +07:configuration_update:reasoning={"effort":"low"} +-- request 2 (turn) -- +08:message/developer: + + ## Host skills update + ### Added or updated skills: + - release-notes: Draft release notes grouped by feature and bug fix. (file: r0/release-notes/SKILL.md) + +09:message/user: + Check the release-notes skill. +-- request 3 (turn) -- +10:message/developer: + + ## Host skills update + ### Added or updated skills: + - incident-summary: Summarize incidents and follow-up actions. (file: r0/incident-summary/SKILL.md) + +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: + + + + + + [hash=46e2a5c52f0891de] + +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] + + + + - 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] + ## 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` = `` + - `r1` = `/.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) + + [02] + 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. + + [03] # 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 `...<...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. + +08:message/developer: + 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. + + - 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"`. [hash=d5928243dc643bcc] +09:message/developer: + Any earlier instruction enabling proactive multi-agent delegation no longer applies. D...elegation, or parallel agent work. [hash=2b6e037067b4390c] +10:message/user: + + + + + + [hash=46e2a5c52f0891de] + +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: + + + + + + [hash=84b040355e543755] + +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] + + + + - 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] + ## 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` = `` + - `r1` = `/.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) + + [02] + 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. + + [03] # 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 `...<...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. + +11:message/developer: + 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. + + - 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"`. [hash=d5928243dc643bcc] +12:message/developer: + Any earlier instruction enabling proactive multi-agent delegation no longer applies. D...elegation, or parallel agent work. [hash=2b6e037067b4390c] +13:message/user: + + + + + + [hash=84b040355e543755] + +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] + + + + - 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] + ## 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` = `` + - `r1` = `/.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) + + [02] + 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. + + [03] # 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 `...<...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. + +17:message/developer: + 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. + + - 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"`. [hash=d5928243dc643bcc] +18:message/developer: + Any earlier instruction enabling proactive multi-agent delegation no longer applies. D...elegation, or parallel agent work. [hash=2b6e037067b4390c] +19:message/user: + + + + + + [hash=84b040355e543755] + +20:message/user: + Check the documentation skill. +21:compaction:encrypted=true; chars=24; hash=68820eb5ac69d541 +22:message/user: + Continue with the rebuilt skills catalog. diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index 301242a627..47629f5cb8 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -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, diff --git a/codex-rs/core/tests/suite/token_budget.rs b/codex-rs/core/tests/suite/token_budget.rs index 7d48c1baa6..65e92e0865 100644 --- a/codex-rs/core/tests/suite/token_budget.rs +++ b/codex-rs/core/tests/suite/token_budget.rs @@ -1694,6 +1694,7 @@ async fn new_context_tool_skips_auto_compact_fallback() -> Result<()> { let mut extensions = ExtensionRegistryBuilder::::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, diff --git a/codex-rs/ext/skills/src/aliases.rs b/codex-rs/ext/skills/src/aliases.rs index 2b4849e497..0b402a1bf2 100644 --- a/codex-rs/ext/skills/src/aliases.rs +++ b/codex-rs/ext/skills/src/aliases.rs @@ -148,9 +148,15 @@ struct AliasBudget { } #[derive(Clone, Debug)] -struct AliasRoot { - name: String, - value: Arc, +pub(crate) struct AliasRoot { + pub(crate) name: String, + pub(crate) value: Arc, +} + +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 { + 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 { + 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 { @@ -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 { - self.roots - .iter() - .map(|root| format!("- `{}` = `{}`", root.name, root.value)) - .collect() + self.roots.iter().map(AliasRoot::line).collect() } } diff --git a/codex-rs/ext/skills/src/catalog.rs b/codex-rs/ext/skills/src/catalog.rs index 1c2900aa61..7e0dbb0838 100644 --- a/codex-rs/ext/skills/src/catalog.rs +++ b/codex-rs/ext/skills/src/catalog.rs @@ -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() diff --git a/codex-rs/ext/skills/src/catalog_delta.rs b/codex-rs/ext/skills/src/catalog_delta.rs new file mode 100644 index 0000000000..1ed5b7cb47 --- /dev/null +++ b/codex-rs/ext/skills/src/catalog_delta.rs @@ -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, +} + +/// 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, + roots: BTreeMap, + usage: CatalogUsage, + instructions_sent: bool, + allocation: Option, +} + +pub(crate) struct CatalogDelta { + pub(crate) rendered: Option, + pub(crate) include_usage: bool, + pub(crate) allocation: Option, +} + +struct CatalogResult { + snapshot: CatalogSnapshot, + body: Option, + 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, + root_lines: Vec, +} + +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 { + 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; diff --git a/codex-rs/ext/skills/src/catalog_delta_tests.rs b/codex-rs/ext/skills/src/catalog_delta_tests.rs new file mode 100644 index 0000000000..950b681441 --- /dev/null +++ b/codex-rs/ext/skills/src/catalog_delta_tests.rs @@ -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(¬es, &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)); +} diff --git a/codex-rs/ext/skills/src/cloud_cache_tests.rs b/codex-rs/ext/skills/src/cloud_cache_tests.rs index 0c64158d8e..d7d407124c 100644 --- a/codex-rs/ext/skills/src/cloud_cache_tests.rs +++ b/codex-rs/ext/skills/src/cloud_cache_tests.rs @@ -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, diff --git a/codex-rs/ext/skills/src/config.rs b/codex-rs/ext/skills/src/config.rs index 563bcb55dd..98ce1f5f42 100644 --- a/codex-rs/ext/skills/src/config.rs +++ b/codex-rs/ext/skills/src/config.rs @@ -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, /// Whether bundled skills are eligible for discovery. diff --git a/codex-rs/ext/skills/src/extension.rs b/codex-rs/ext/skills/src/extension.rs index effe7624b8..faf7d927af 100644 --- a/codex-rs/ext/skills/src/extension.rs +++ b/codex-rs/ext/skills/src/extension.rs @@ -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, diff --git a/codex-rs/ext/skills/src/invocation_tests.rs b/codex-rs/ext/skills/src/invocation_tests.rs index c8d666617e..c38e404821 100644 --- a/codex-rs/ext/skills/src/invocation_tests.rs +++ b/codex-rs/ext/skills/src/invocation_tests.rs @@ -45,6 +45,7 @@ fn detect_executor_skill(command: &str, environments: &[&str]) -> Option Option { - 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 { + 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, + lines: Vec, + /// Only needed when some rows were omitted. + included_ids: Option>, 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, skill_lines: Vec, + included_ids: Option>, report: SkillRenderReport, } pub(crate) struct AvailableSkillsRender { - prompt_kind: SkillPromptKind, - skill_root_lines: Vec, + pub(crate) prompt_kind: SkillPromptKind, + pub(crate) skill_root_lines: Vec, skill_lines: Vec, + included_ids: Option>, + /// 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, 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 { - 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], diff --git a/codex-rs/ext/skills/src/render_dedup.rs b/codex-rs/ext/skills/src/render_dedup.rs index fc86de3a56..f5c95cda76 100644 --- a/codex-rs/ext/skills/src/render_dedup.rs +++ b/codex-rs/ext/skills/src/render_dedup.rs @@ -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 { + 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::>()); + 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, }) diff --git a/codex-rs/ext/skills/src/render_tests.rs b/codex-rs/ext/skills/src/render_tests.rs index 2c1b855c4c..754be97c6b 100644 --- a/codex-rs/ext/skills/src/render_tests.rs +++ b/codex-rs/ext/skills/src/render_tests.rs @@ -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"); diff --git a/codex-rs/ext/skills/src/shadow_selection_experiment/experiment_tests.rs b/codex-rs/ext/skills/src/shadow_selection_experiment/experiment_tests.rs index 2dd6996972..c40c73e6a8 100644 --- a/codex-rs/ext/skills/src/shadow_selection_experiment/experiment_tests.rs +++ b/codex-rs/ext/skills/src/shadow_selection_experiment/experiment_tests.rs @@ -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, diff --git a/codex-rs/ext/skills/src/world_state_catalogs.rs b/codex-rs/ext/skills/src/world_state_catalogs.rs index c5ff99cfb1..06f6d67fd7 100644 --- a/codex-rs/ext/skills/src/world_state_catalogs.rs +++ b/codex-rs/ext/skills/src/world_state_catalogs.rs @@ -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, 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::(); + 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() } diff --git a/codex-rs/ext/skills/tests/skills_extension.rs b/codex-rs/ext/skills/tests/skills_extension.rs index b0fbf38466..6f13258acd 100644 --- a/codex-rs/ext/skills/tests/skills_extension.rs +++ b/codex-rs/ext/skills/tests/skills_extension.rs @@ -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, diff --git a/codex-rs/ext/skills/tests/skills_extension/catalog_delta_tests.rs b/codex-rs/ext/skills/tests/skills_extension/catalog_delta_tests.rs new file mode 100644 index 0000000000..fbc7919f0b --- /dev/null +++ b/codex-rs/ext/skills/tests/skills_extension/catalog_delta_tests.rs @@ -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::(); + 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, +) -> Result> { + 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(®istry, &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( + ®istry, + &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) +} diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 1fdbb4bf5a..c6bfe00c75 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -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",