Add a configurable skill catalog token budget (#38978)

## What changed

- Add `[skills].max_context_tokens` to override the token budget used to render the available-skills catalog.
- Require a positive value and cap configured budgets at 10,000 tokens.
- Preserve the existing default of 2% of the model context window when the option is unset.

## Testing

- Cover configuration parsing, override behavior, the 10,000-token cap, and catalog rendering within the configured budget.

GitOrigin-RevId: f29ddcb57fc2d800c0006d03e1c33cbe3ab6272d
This commit is contained in:
jif
2026-08-17 10:01:59 +00:00
committed by copyberry
parent c6058ccaa9
commit 632e35ce8d
19 changed files with 126 additions and 7 deletions
+1
View File
@@ -115,6 +115,7 @@ where
codex_otel::global(),
|config: &Config| codex_skills_extension::SkillsExtensionConfig {
include_instructions: config.include_skill_instructions,
max_context_tokens: config.skill_max_context_tokens,
bundled_skills_enabled: config.bundled_skills_enabled(),
orchestrator_skills_enabled: config.orchestrator_skills_enabled,
shadow_selection_enabled: config
+1
View File
@@ -2127,6 +2127,7 @@ 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,
max_context_tokens: config.skill_max_context_tokens,
bundled_skills_enabled: config.bundled_skills_enabled(),
orchestrator_skills_enabled: config.orchestrator_skills_enabled,
shadow_selection_enabled: config
+6
View File
@@ -1,6 +1,7 @@
//! Skill-related configuration types shared across crates.
use std::collections::HashSet;
use std::num::NonZeroUsize;
use crate::ConfigLayerSource;
use crate::ConfigLayerStack;
@@ -36,6 +37,11 @@ pub struct SkillsConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub include_instructions: Option<bool>,
/// Maximum tokens used by the available-skills catalog. Defaults to 2% of
/// the model context window and is capped at 10,000 tokens when set.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_context_tokens: Option<NonZeroUsize>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub config: Vec<SkillConfig>,
}
+6
View File
@@ -3231,6 +3231,12 @@
"include_instructions": {
"description": "Whether turns receive the automatic skills instructions block.",
"type": "boolean"
},
"max_context_tokens": {
"description": "Maximum tokens used by the available-skills catalog. Defaults to 2% of the model context window and is capped at 10,000 tokens when set.",
"format": "uint",
"minimum": 1.0,
"type": "integer"
}
},
"type": "object"
+4
View File
@@ -358,6 +358,7 @@ fn parses_bundled_skills_config() {
r#"
[skills]
include_instructions = false
max_context_tokens = 1200
[skills.bundled]
enabled = false
@@ -370,9 +371,12 @@ enabled = false
Some(SkillsConfig {
bundled: Some(BundledSkillsConfig { enabled: false }),
include_instructions: Some(false),
max_context_tokens: std::num::NonZeroUsize::new(1_200),
config: Vec::new(),
})
);
assert!(toml::from_str::<ConfigToml>("[skills]\nmax_context_tokens = 0\n").is_err());
}
#[test]
+9
View File
@@ -130,6 +130,7 @@ use std::collections::BTreeMap;
use std::collections::HashMap;
use std::collections::HashSet;
use std::io::ErrorKind;
use std::num::NonZeroUsize;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
@@ -649,6 +650,9 @@ pub struct Config {
/// Whether to inject the `<skills_instructions>` developer block.
pub include_skill_instructions: bool,
/// Optional token budget override for the available-skills catalog.
pub skill_max_context_tokens: Option<NonZeroUsize>,
/// Whether orchestrator-owned skills are exposed to the model.
pub orchestrator_skills_enabled: bool,
@@ -3797,6 +3801,10 @@ impl Config {
.as_ref()
.and_then(|skills| skills.include_instructions)
.unwrap_or(true);
let skill_max_context_tokens = cfg
.skills
.as_ref()
.and_then(|skills| skills.max_context_tokens);
let include_environment_context = cfg.include_environment_context.unwrap_or(true);
let guardian_policy_config =
guardian_policy_config_from_requirements(config_layer_stack.requirements_toml())
@@ -4001,6 +4009,7 @@ impl Config {
include_apps_instructions,
include_collaboration_mode_instructions,
include_skill_instructions,
skill_max_context_tokens,
orchestrator_skills_enabled,
orchestrator_mcp_enabled,
include_environment_context,
@@ -78,6 +78,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,
max_context_tokens: config.skill_max_context_tokens,
bundled_skills_enabled: config.bundled_skills_enabled(),
orchestrator_skills_enabled: config.orchestrator_skills_enabled,
shadow_selection_enabled: config.features.enabled(Feature::SkillSearch),
+1
View File
@@ -75,6 +75,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,
max_context_tokens: config.skill_max_context_tokens,
bundled_skills_enabled: config.bundled_skills_enabled(),
orchestrator_skills_enabled: config.orchestrator_skills_enabled,
shadow_selection_enabled: config.features.enabled(Feature::SkillSearch),
+1
View File
@@ -157,6 +157,7 @@ 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,
max_context_tokens: config.skill_max_context_tokens,
bundled_skills_enabled: false,
orchestrator_skills_enabled: false,
shadow_selection_enabled: false,
@@ -258,6 +258,7 @@ fn catalog_extensions(
install_with_providers(&mut extensions, providers, |config: &Config| {
SkillsExtensionConfig {
include_instructions: config.include_skill_instructions,
max_context_tokens: config.skill_max_context_tokens,
bundled_skills_enabled: false,
orchestrator_skills_enabled: false,
shadow_selection_enabled: false,
@@ -534,6 +535,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,
max_context_tokens: config.skill_max_context_tokens,
bundled_skills_enabled: config.bundled_skills_enabled(),
orchestrator_skills_enabled: config.orchestrator_skills_enabled,
shadow_selection_enabled: config.features.enabled(Feature::SkillSearch),
@@ -960,6 +962,7 @@ async fn explicit_only_orchestrator_skill_is_hidden_but_can_be_invoked() -> Resu
.with_orchestrator_provider(Arc::new(OrchestratorSkillProvider::new())),
|config: &Config| SkillsExtensionConfig {
include_instructions: config.include_skill_instructions,
max_context_tokens: config.skill_max_context_tokens,
bundled_skills_enabled: false,
orchestrator_skills_enabled: true,
shadow_selection_enabled: false,
@@ -1120,6 +1123,7 @@ async fn production_turn_aliases_discovered_singleton_orchestrator_root() -> Res
.with_orchestrator_provider(Arc::new(OrchestratorSkillProvider::new())),
|config: &Config| SkillsExtensionConfig {
include_instructions: config.include_skill_instructions,
max_context_tokens: config.skill_max_context_tokens,
bundled_skills_enabled: false,
orchestrator_skills_enabled: true,
shadow_selection_enabled: false,
@@ -1412,6 +1416,7 @@ async fn production_turn_aliases_combined_skill_catalogs_under_shared_budget() -
.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,
orchestrator_skills_enabled: true,
shadow_selection_enabled: false,
@@ -1512,6 +1517,7 @@ async fn production_turn_scales_extension_catalog_from_resolved_model_window() -
)),
|config: &Config| SkillsExtensionConfig {
include_instructions: config.include_skill_instructions,
max_context_tokens: config.skill_max_context_tokens,
bundled_skills_enabled: false,
orchestrator_skills_enabled: false,
shadow_selection_enabled: false,
@@ -1619,6 +1625,52 @@ async fn production_turn_shares_catalog_budget_across_host_and_executor_sections
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn production_turn_uses_configured_skill_catalog_token_budget() -> Result<()> {
let server = responses::start_mock_server().await;
let response = mount_sse_once(
&server,
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
)
.await;
let codex_home = Arc::new(TempDir::new()?);
std::fs::write(
codex_home.path().join("config.toml"),
"[skills]\nmax_context_tokens = 800\n",
)?;
write_host_skills(codex_home.path(), &HOST_CATALOG)?;
let (extensions, _event_rx) = catalog_extensions(
executor_catalog(&EXECUTOR_CATALOG),
/*include_host_provider*/ true,
);
let mut builder = test_codex()
.with_home(codex_home)
.with_extensions(extensions)
.with_model_info_override("gpt-5.5", |model_info| {
model_info.context_window = Some(SHORTENING_CONTEXT_WINDOW);
model_info.max_context_window = None;
})
.with_config(configure_catalog_test);
let test = builder.build_with_auto_env(&server).await?;
test.submit_turn("Inspect the available skills.").await?;
let request = response.single_request();
let developer_texts = request.message_input_texts("developer");
let host_lines = skill_lines(catalog_text(&developer_texts, "host"), "host");
let executor_lines = skill_lines(catalog_text(&developer_texts, "exec"), "exec");
let combined_lines = host_lines
.iter()
.chain(executor_lines.iter())
.copied()
.collect::<Vec<_>>();
assert_full_descriptions(&host_lines, &HOST_CATALOG);
assert_full_descriptions(&executor_lines, &EXECUTOR_CATALOG);
assert!(metadata_cost(&combined_lines) <= 800);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn production_turn_keeps_full_host_only_catalog_when_it_fits() -> Result<()> {
let (developer_texts, _) =
@@ -1785,6 +1837,7 @@ async fn production_turn_uses_provider_host_catalog_and_core_snapshot_injection(
})),
|config: &Config| SkillsExtensionConfig {
include_instructions: config.include_skill_instructions,
max_context_tokens: config.skill_max_context_tokens,
bundled_skills_enabled: false,
orchestrator_skills_enabled: false,
shadow_selection_enabled: false,
@@ -1885,6 +1938,7 @@ async fn production_turn_suppresses_only_the_superseded_host_skill_prompt() -> R
)),
|config: &Config| SkillsExtensionConfig {
include_instructions: config.include_skill_instructions,
max_context_tokens: config.skill_max_context_tokens,
bundled_skills_enabled: false,
orchestrator_skills_enabled: false,
shadow_selection_enabled: false,
@@ -2133,6 +2187,7 @@ async fn production_turn_keeps_orchestrator_world_state_incremental_across_turns
.with_orchestrator_provider(Arc::new(CatalogSkillProvider { catalog })),
|config: &Config| SkillsExtensionConfig {
include_instructions: config.include_skill_instructions,
max_context_tokens: config.skill_max_context_tokens,
bundled_skills_enabled: false,
orchestrator_skills_enabled: config.orchestrator_skills_enabled,
shadow_selection_enabled: false,
@@ -2398,6 +2453,7 @@ async fn production_turn_fairly_shortens_extension_catalog_descriptions() -> Res
)),
|config: &Config| SkillsExtensionConfig {
include_instructions: config.include_skill_instructions,
max_context_tokens: config.skill_max_context_tokens,
bundled_skills_enabled: false,
orchestrator_skills_enabled: false,
shadow_selection_enabled: false,
@@ -1436,6 +1436,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,
max_context_tokens: config.skill_max_context_tokens,
bundled_skills_enabled: config.bundled_skills_enabled(),
orchestrator_skills_enabled: config.orchestrator_skills_enabled,
shadow_selection_enabled: config.features.enabled(Feature::SkillSearch),
+4
View File
@@ -1,8 +1,12 @@
use std::num::NonZeroUsize;
/// Host-supplied configuration used by the skills extension.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SkillsExtensionConfig {
/// Whether the available-skills catalog is included in model context.
pub include_instructions: bool,
/// Optional token budget override for the available-skills catalog.
pub max_context_tokens: Option<NonZeroUsize>,
/// Whether bundled skills are eligible for discovery.
pub bundled_skills_enabled: bool,
/// Whether orchestrator-owned skills are eligible for discovery.
+3 -2
View File
@@ -228,7 +228,7 @@ where
&catalog,
include_usage,
SkillCatalogRenderPolicy::ExtensionCompatible,
skill_metadata_budget(/*context_window*/ None),
skill_metadata_budget(/*context_window*/ None, config.max_context_tokens),
);
if let Some(message) = rendered.warning_message {
self.emit_warning(thread_store.level_id(), /*turn_id*/ None, message);
@@ -420,7 +420,8 @@ where
let context_window = model_info
.as_deref()
.and_then(ModelInfo::resolved_context_window);
let metadata_budget = skill_metadata_budget(context_window);
let metadata_budget =
skill_metadata_budget(context_window, config.max_context_tokens);
let rendered = render_catalog(
extension_metrics.as_deref(),
CatalogSurface::TurnInput,
+14 -1
View File
@@ -1,4 +1,5 @@
use std::borrow::Cow;
use std::num::NonZeroUsize;
use codex_protocol::protocol::SkillScope;
use codex_utils_string::approx_token_count;
@@ -14,6 +15,7 @@ use crate::fragments::AvailableSkillsInstructions;
use crate::host_aliases::shared_host_alias_roots;
const DEFAULT_SKILL_METADATA_CHAR_BUDGET: usize = 8_000;
const MAX_CONFIGURED_SKILL_METADATA_TOKEN_BUDGET: usize = 10_000;
const MAX_SKILL_PROMPT_BYTES: usize = 8_000;
const SKILL_METADATA_CONTEXT_WINDOW_PERCENT: usize = 2;
const MAX_CATALOG_SKILL_DESCRIPTION_CHARS: usize = 1_024;
@@ -122,7 +124,18 @@ impl SkillRenderReport {
}
}
pub(crate) fn skill_metadata_budget(context_window: Option<i64>) -> SkillMetadataBudget {
pub(crate) fn skill_metadata_budget(
context_window: Option<i64>,
max_context_tokens: Option<NonZeroUsize>,
) -> SkillMetadataBudget {
if let Some(max_context_tokens) = max_context_tokens {
return SkillMetadataBudget::Tokens(
max_context_tokens
.get()
.min(MAX_CONFIGURED_SKILL_METADATA_TOKEN_BUDGET),
);
}
context_window
.and_then(|window| usize::try_from(window).ok())
.filter(|window| *window > 0)
+13 -3
View File
@@ -194,17 +194,27 @@ fn description_selection_follows_render_policy() {
#[test]
fn catalog_budget_uses_context_percentage_or_character_fallback() {
assert_eq!(
skill_metadata_budget(Some(100_000)),
skill_metadata_budget(Some(100_000), /*max_context_tokens*/ None),
SkillMetadataBudget::Tokens(2_000)
);
assert_eq!(
skill_metadata_budget(Some(400_000)),
skill_metadata_budget(Some(400_000), /*max_context_tokens*/ None),
SkillMetadataBudget::Tokens(8_000)
);
assert_eq!(
skill_metadata_budget(/*context_window*/ None),
skill_metadata_budget(
/*context_window*/ None, /*max_context_tokens*/ None
),
SkillMetadataBudget::Characters(8_000)
);
assert_eq!(
skill_metadata_budget(Some(100_000), NonZeroUsize::new(5_000)),
SkillMetadataBudget::Tokens(5_000)
);
assert_eq!(
skill_metadata_budget(/*context_window*/ None, NonZeroUsize::new(50_000)),
SkillMetadataBudget::Tokens(10_000)
);
}
#[test]
@@ -107,6 +107,7 @@ impl<'a> CatalogContext<'a> {
let context_window = model_info
.as_deref()
.and_then(ModelInfo::resolved_context_window);
let metadata_budget = skill_metadata_budget(context_window, config.max_context_tokens);
let emitted_warnings = input
.turn_store
.get_or_init(EmittedCatalogBudgetWarnings::default);
@@ -127,7 +128,7 @@ impl<'a> CatalogContext<'a> {
input,
thread_state,
config,
metadata_budget: skill_metadata_budget(context_window),
metadata_budget,
include_usage,
warning_emitter,
})
@@ -2393,6 +2393,7 @@ fn default_config() -> TestConfig {
fn skills_extension_config(config: &TestConfig) -> SkillsExtensionConfig {
SkillsExtensionConfig {
include_instructions: config.include_instructions,
max_context_tokens: None,
bundled_skills_enabled: config.bundled_skills_enabled,
orchestrator_skills_enabled: config.orchestrator_skills_enabled,
shadow_selection_enabled: config.shadow_selection_enabled,
@@ -88,6 +88,7 @@ impl MessageProcessor {
codex_otel::global(),
|config: &Config| codex_skills_extension::SkillsExtensionConfig {
include_instructions: config.include_skill_instructions,
max_context_tokens: config.skill_max_context_tokens,
bundled_skills_enabled: config.bundled_skills_enabled(),
orchestrator_skills_enabled: config.orchestrator_skills_enabled,
shadow_selection_enabled: config
@@ -215,6 +215,7 @@ fn new_config(model: Option<String>, arg0_paths: Arg0DispatchPaths) -> anyhow::R
include_apps_instructions: false,
include_collaboration_mode_instructions: false,
include_skill_instructions: false,
skill_max_context_tokens: None,
orchestrator_skills_enabled: false,
orchestrator_mcp_enabled: false,
include_environment_context: false,