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