Skip to content

Commit 64bdeed

Browse files
[codex] Preserve skill descriptions outside model context (openai#29006)
## Why Skill descriptions are used in model-visible lists: the default available-skills catalog that supports implicit selection, and the on-demand `skills.list` tool response used to discover orchestrator skills. A single overlong description should not consume a disproportionate share of either list. Enforcing the 1024-character limit while loading or migrating skills is the wrong boundary: it rejects otherwise-valid skills and discards metadata that non-model consumers and full skill reads may need. Skill metadata and `SKILL.md` content should remain intact; the cap belongs at model-visible list rendering boundaries. ## What changed - Preserve full `description` and `metadata.short-description` values when loading skills. - Preserve full external-agent command descriptions during `source-command-*` migration instead of skipping commands solely because their descriptions exceed 1024 characters. - Preserve full normalized orchestrator descriptions in the underlying skills catalog. - Cap each description at 1024 Unicode characters when rendering the default available-skills context in `codex-core-skills` and `codex-skills-extension`. - Apply the same cap when serializing descriptions in the model-visible `skills.list` response. - Render truncated descriptions as 1021 original characters plus `...`. - Leave explicit `$skill` injection, `skills.read`, underlying metadata, and on-disk `SKILL.md` files unchanged and full-fidelity. ## Implicit skill selection Codex injects a bounded catalog containing each implicitly allowed skill's name, description, and source locator, together with instructions to use a skill when the task clearly matches its description. The model makes that semantic choice; after selecting a skill, it reads the full `SKILL.md` from its filesystem or provider resource. Explicit `$skill` mentions remain a separate path that injects the full skill instructions. For orchestrator skills, `skills.list` provides bounded discovery metadata before `skills.read` returns the full selected resource. ## Test plan - `just test -p codex-core-skills` - `just test -p codex-skills-extension` - `just test -p codex-external-agent-migration` The focused regressions verify that overlong metadata is preserved at load and migration boundaries while default available-skills rendering and `skills.list` output produce the 1021-character prefix plus `...`.
1 parent abd9017 commit 64bdeed

8 files changed

Lines changed: 263 additions & 34 deletions

File tree

codex-rs/core-skills/src/loader.rs

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -684,13 +684,8 @@ async fn parse_skill_file(
684684

685685
validate_len(&base_name, MAX_NAME_LEN, "name")?;
686686
validate_len(&name, MAX_QUALIFIED_NAME_LEN, "qualified name")?;
687-
validate_len(&description, MAX_DESCRIPTION_LEN, "description")?;
688-
if let Some(short_description) = short_description.as_deref() {
689-
validate_len(
690-
short_description,
691-
MAX_SHORT_DESCRIPTION_LEN,
692-
"metadata.short-description",
693-
)?;
687+
if description.is_empty() {
688+
return Err(SkillParseError::MissingField("description"));
694689
}
695690

696691
let resolved_path = canonicalize_for_skill_identity(fs, path).await;

codex-rs/core-skills/src/loader_tests.rs

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1573,7 +1573,7 @@ async fn preserves_block_scalar_body_while_repairing_other_fields() {
15731573
}
15741574

15751575
#[tokio::test]
1576-
async fn enforces_short_description_length_limits() {
1576+
async fn preserves_overlong_short_descriptions() {
15771577
let codex_home = tempfile::tempdir().expect("tempdir");
15781578
let skill_dir = codex_home.path().join("skills/demo");
15791579
fs::create_dir_all(&skill_dir).unwrap();
@@ -1585,15 +1585,13 @@ async fn enforces_short_description_length_limits() {
15851585

15861586
let cfg = make_config(&codex_home).await;
15871587
let outcome = load_skills_for_test(&cfg).await;
1588-
assert_eq!(outcome.skills.len(), 0);
1589-
assert_eq!(outcome.errors.len(), 1);
15901588
assert!(
1591-
outcome.errors[0]
1592-
.message
1593-
.contains("invalid metadata.short-description"),
1594-
"expected length error, got: {:?}",
1589+
outcome.errors.is_empty(),
1590+
"unexpected errors: {:?}",
15951591
outcome.errors
15961592
);
1593+
assert_eq!(outcome.skills.len(), 1);
1594+
assert_eq!(outcome.skills[0].short_description, Some(too_long));
15971595
}
15981596

15991597
#[tokio::test]
@@ -1625,7 +1623,7 @@ async fn skips_hidden_and_invalid() {
16251623
}
16261624

16271625
#[tokio::test]
1628-
async fn enforces_length_limits() {
1626+
async fn preserves_overlong_descriptions() {
16291627
let codex_home = tempfile::tempdir().expect("tempdir");
16301628
let max_desc = "\u{1F4A1}".repeat(MAX_DESCRIPTION_LEN);
16311629
write_skill(&codex_home, "max-len", "max-len", &max_desc);
@@ -1642,12 +1640,18 @@ async fn enforces_length_limits() {
16421640
let too_long_desc = "\u{1F4A1}".repeat(MAX_DESCRIPTION_LEN + 1);
16431641
write_skill(&codex_home, "too-long", "too-long", &too_long_desc);
16441642
let outcome = load_skills_for_test(&cfg).await;
1645-
assert_eq!(outcome.skills.len(), 1);
1646-
assert_eq!(outcome.errors.len(), 1);
16471643
assert!(
1648-
outcome.errors[0].message.contains("invalid description"),
1649-
"expected length error"
1644+
outcome.errors.is_empty(),
1645+
"unexpected errors: {:?}",
1646+
outcome.errors
16501647
);
1648+
assert_eq!(outcome.skills.len(), 2);
1649+
let too_long_skill = outcome
1650+
.skills
1651+
.iter()
1652+
.find(|skill| skill.name == "too-long")
1653+
.expect("too-long skill");
1654+
assert_eq!(too_long_skill.description, too_long_desc);
16511655
}
16521656

16531657
#[tokio::test]

codex-rs/core-skills/src/render.rs

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use std::borrow::Cow;
12
use std::collections::HashMap;
23
use std::collections::HashSet;
34
use std::path::Component;
@@ -16,6 +17,8 @@ use codex_utils_output_truncation::approx_token_count;
1617

1718
const DEFAULT_SKILL_METADATA_CHAR_BUDGET: usize = 8_000;
1819
const SKILL_METADATA_CONTEXT_WINDOW_PERCENT: usize = 2;
20+
const MAX_DEFAULT_CONTEXT_SKILL_DESCRIPTION_CHARS: usize = 1_024;
21+
const TRUNCATED_SKILL_DESCRIPTION_SUFFIX: &str = "...";
1922
const SKILL_DESCRIPTION_TRUNCATION_WARNING_THRESHOLD_CHARS: usize = 100;
2023
const APPROX_BYTES_PER_TOKEN: usize = 4;
2124
pub const SKILL_DESCRIPTION_TRUNCATED_WARNING: &str = "Skill descriptions were shortened to fit the skills context budget. Codex can still see every skill, but some descriptions are shorter. Disable unused skills or plugins to leave more room for the rest.";
@@ -446,7 +449,7 @@ impl SkillRenderReport {
446449

447450
struct SkillLine<'a> {
448451
name: &'a str,
449-
description: &'a str,
452+
description: Cow<'a, str>,
450453
path: String,
451454
}
452455

@@ -485,9 +488,10 @@ impl<'a> SkillLine<'a> {
485488
}
486489

487490
fn with_path(skill: &'a SkillMetadata, path: String) -> Self {
491+
let description = truncate_default_context_skill_description(skill.description.as_str());
488492
Self {
489493
name: skill.name.as_str(),
490-
description: skill.description.as_str(),
494+
description,
491495
path,
492496
}
493497
}
@@ -505,7 +509,7 @@ impl<'a> SkillLine<'a> {
505509
}
506510

507511
fn render_full(&self) -> String {
508-
self.render_with_description(self.description)
512+
self.render_with_description(self.description.as_ref())
509513
}
510514

511515
fn render_minimum(&self) -> String {
@@ -524,7 +528,7 @@ impl<'a> SkillLine<'a> {
524528
format!("- {}: (file: {})", self.name, self.path)
525529
} else {
526530
let end = self.rendered_description_prefix_len(description_chars);
527-
let description = &self.description[..end];
531+
let description = &self.description.as_ref()[..end];
528532
format!("- {}: {} (file: {})", self.name, description, self.path)
529533
}
530534
}
@@ -538,6 +542,26 @@ impl<'a> SkillLine<'a> {
538542
}
539543
}
540544

545+
fn truncate_default_context_skill_description(description: &str) -> Cow<'_, str> {
546+
if description
547+
.char_indices()
548+
.nth(MAX_DEFAULT_CONTEXT_SKILL_DESCRIPTION_CHARS)
549+
.is_none()
550+
{
551+
return Cow::Borrowed(description);
552+
}
553+
554+
let prefix_chars = MAX_DEFAULT_CONTEXT_SKILL_DESCRIPTION_CHARS
555+
.saturating_sub(TRUNCATED_SKILL_DESCRIPTION_SUFFIX.chars().count());
556+
let prefix_end = description
557+
.char_indices()
558+
.nth(prefix_chars)
559+
.map_or(description.len(), |(index, _)| index);
560+
let mut truncated = description[..prefix_end].to_string();
561+
truncated.push_str(TRUNCATED_SKILL_DESCRIPTION_SUFFIX);
562+
Cow::Owned(truncated)
563+
}
564+
541565
impl<'a> DescriptionBudgetLine<'a> {
542566
fn new(line: &'a SkillLine<'a>, budget: SkillMetadataBudget) -> Self {
543567
let minimum_line = line.render_minimum();
@@ -1030,6 +1054,28 @@ mod tests {
10301054
);
10311055
}
10321056

1057+
#[test]
1058+
fn default_context_caps_descriptions_without_mutating_metadata() {
1059+
let description = "\u{1F4A1}".repeat(MAX_DEFAULT_CONTEXT_SKILL_DESCRIPTION_CHARS + 1);
1060+
let skill = make_skill_with_description("long-skill", SkillScope::Repo, &description);
1061+
let expected_description = "\u{1F4A1}".repeat(
1062+
MAX_DEFAULT_CONTEXT_SKILL_DESCRIPTION_CHARS
1063+
- TRUNCATED_SKILL_DESCRIPTION_SUFFIX.chars().count(),
1064+
) + TRUNCATED_SKILL_DESCRIPTION_SUFFIX;
1065+
1066+
let rendered = build_available_skills_from_metadata(
1067+
std::slice::from_ref(&skill),
1068+
SkillMetadataBudget::Characters(usize::MAX),
1069+
)
1070+
.expect("skill should render");
1071+
1072+
assert_eq!(skill.description, description);
1073+
assert_eq!(
1074+
rendered.skill_lines,
1075+
vec![expected_skill_line(&skill, &expected_description)]
1076+
);
1077+
}
1078+
10331079
#[test]
10341080
fn budgeted_rendering_truncates_descriptions_equally_before_omitting_skills() {
10351081
let alpha = make_skill_with_description("alpha-skill", SkillScope::Repo, "abcdef");

codex-rs/ext/skills/src/provider/orchestrator.rs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ const MAX_RESOURCE_PAGES: usize = 10;
2828
const MAX_ORCHESTRATOR_SKILLS: usize = 100;
2929
const MAX_SKILL_NAME_CHARS: usize = 64;
3030
const MAX_QUALIFIED_SKILL_NAME_CHARS: usize = 128;
31-
const MAX_SKILL_DESCRIPTION_CHARS: usize = 1_024;
3231
const MAX_SKILL_PACKAGE_URI_CHARS: usize = 1_024;
3332
const MAX_SKILL_RESOURCE_URI_CHARS: usize = 2_048;
3433
const MAX_SKILL_RESOURCE_CONTENT_BYTES: usize = 1024 * 1024;
@@ -308,12 +307,17 @@ fn normalized_label(value: &str, max_chars: usize) -> Option<String> {
308307
}
309308

310309
fn normalized_description(value: &str) -> Option<String> {
311-
normalized_single_line(value, MAX_SKILL_DESCRIPTION_CHARS).map(|value| {
310+
let value = value.split_whitespace().collect::<Vec<_>>().join(" ");
311+
if value.chars().any(char::is_control) {
312+
return None;
313+
}
314+
315+
Some(
312316
value
313317
.replace('&', "&amp;")
314318
.replace('<', "&lt;")
315-
.replace('>', "&gt;")
316-
})
319+
.replace('>', "&gt;"),
320+
)
317321
}
318322

319323
fn normalized_single_line(value: &str, max_chars: usize) -> Option<String> {

codex-rs/ext/skills/src/render.rs

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
use std::borrow::Cow;
2+
13
use codex_utils_string::take_bytes_at_char_boundary;
24

35
use crate::catalog::SkillCatalog;
@@ -7,6 +9,8 @@ use crate::fragments::AvailableSkillsInstructions;
79

810
const MAX_AVAILABLE_SKILLS_BYTES: usize = 8_000;
911
const MAX_MAIN_PROMPT_BYTES: usize = 8_000;
12+
const MAX_CATALOG_SKILL_DESCRIPTION_CHARS: usize = 1_024;
13+
const TRUNCATED_SKILL_DESCRIPTION_SUFFIX: &str = "...";
1014
pub(crate) const MAX_SKILL_NAME_BYTES: usize = 256;
1115
pub(crate) const MAX_SKILL_PATH_BYTES: usize = 1_024;
1216

@@ -31,7 +35,8 @@ pub(crate) fn available_skills_fragment(
3135
.short_description
3236
.as_deref()
3337
.unwrap_or(entry.description.as_str());
34-
let line = render_skill_line(entry, description);
38+
let description = truncate_catalog_skill_description(description);
39+
let line = render_skill_line(entry, description.as_ref());
3540
let next_bytes = total_bytes.saturating_add(line.len());
3641
if next_bytes > MAX_AVAILABLE_SKILLS_BYTES {
3742
omitted = omitted.saturating_add(1);
@@ -54,6 +59,26 @@ pub(crate) fn available_skills_fragment(
5459
Some(AvailableSkillsInstructions::from_skill_lines(skill_lines))
5560
}
5661

62+
pub(crate) fn truncate_catalog_skill_description(description: &str) -> Cow<'_, str> {
63+
if description
64+
.char_indices()
65+
.nth(MAX_CATALOG_SKILL_DESCRIPTION_CHARS)
66+
.is_none()
67+
{
68+
return Cow::Borrowed(description);
69+
}
70+
71+
let prefix_chars = MAX_CATALOG_SKILL_DESCRIPTION_CHARS
72+
.saturating_sub(TRUNCATED_SKILL_DESCRIPTION_SUFFIX.chars().count());
73+
let prefix_end = description
74+
.char_indices()
75+
.nth(prefix_chars)
76+
.map_or(description.len(), |(index, _)| index);
77+
let mut truncated = description[..prefix_end].to_string();
78+
truncated.push_str(TRUNCATED_SKILL_DESCRIPTION_SUFFIX);
79+
Cow::Owned(truncated)
80+
}
81+
5782
fn render_skill_line(entry: &SkillCatalogEntry, description: &str) -> String {
5883
let locator_kind = match &entry.authority.kind {
5984
SkillSourceKind::Host => "file",

codex-rs/ext/skills/src/tools/list.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use serde::Deserialize;
88
use serde::Serialize;
99

1010
use crate::catalog::SkillCatalogEntry;
11+
use crate::render::truncate_catalog_skill_description;
1112
use crate::render::truncate_utf8_to_bytes;
1213

1314
use super::MAX_HANDLE_BYTES;
@@ -95,7 +96,7 @@ fn listed_skill(entry: SkillCatalogEntry) -> Option<ListedSkill> {
9596
authority,
9697
package: entry.id.0,
9798
name: entry.name,
98-
description: entry.description,
99+
description: truncate_catalog_skill_description(&entry.description).into_owned(),
99100
main_resource: entry.main_prompt.as_str().to_string(),
100101
})
101102
}

0 commit comments

Comments
 (0)