Compare commits

...
1 Commits
Author SHA1 Message Date
Hunter Bown 9272789447 fix: skills: the ambient index caps descriptions at 400 chars with no config knob and no signal in the prompt or to the model (#7073)
Produced by CodeWhale running in a Boat VM. Untested.
2026-10-24 22:29:54 -07:00
4 changed files with 135 additions and 18 deletions

No files matched your search

@@ -485,13 +485,8 @@ fn inspect_skills(group: &mut dyn CommandSkillGroupContext) -> CommandResult {
}
// The model index caps each description; say so here instead of
// cutting an imported skill mid-sentence with nobody told.
let description_chars = skill
.description
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.chars()
.count();
let description_chars =
crate::skills::prompt_description_char_count(&skill.description);
if description_chars > crate::skills::MAX_SKILL_DESCRIPTION_CHARS {
let _ = writeln!(
output,
+7 -2
View File
@@ -613,7 +613,7 @@ fn base_source_entries(
),
};
if let Some(block) = skills_block {
builder.push(SourceEntry::text(
let mut entry = SourceEntry::text(
SourceKind::SkillsBlock,
"Available skills",
skills_dir.map(|path| path.display().to_string()),
@@ -621,7 +621,12 @@ fn base_source_entries(
&block,
CountingConfidence::High,
Some(5),
));
);
if block.contains("descriptions shortened for") {
entry.truncation_reason =
Some("some skill descriptions were shortened for the ambient index".to_string());
}
builder.push(entry);
} else {
builder.push(SourceEntry::omitted(
SourceKind::SkillsBlock,
+69 -9
View File
@@ -838,7 +838,7 @@ impl SkillRegistry {
// extracting the first `# Heading` as the skill name so that plain
// Markdown files (no `---` fence) are accepted instead of rejected.
let parsed = parse_frontmatter(content)?;
let warnings = validate_skill_frontmatter(
let mut warnings = validate_skill_frontmatter(
parsed.as_ref().map(|(metadata, _)| metadata),
Some(path),
SkillValidationMode::Lenient,
@@ -862,6 +862,16 @@ impl SkillRegistry {
description.push_str(trigger.trim());
}
if prompt_description_char_count(&description) > MAX_SKILL_DESCRIPTION_CHARS
&& split_trigger(&description).1.is_none()
{
warnings.push(format!(
"description is {} chars and has no `Use when:` trigger; the model index shows at most {} chars, so it will be cut mid-sentence — trim it or end it with `Use when: <trigger>`",
prompt_description_char_count(&description),
MAX_SKILL_DESCRIPTION_CHARS
));
}
let explicit =
SkillInvocation::from_frontmatter(metadata.get("invocation").map(String::as_str))
== SkillInvocation::ExplicitOnly;
@@ -2018,12 +2028,22 @@ Skills are optional instruction packs. This index exposes routing metadata; bodi
// Reserve using the model-selectable total: an actual omitted count can
// never exceed it. This remains safe for catalogues above 9,999 entries.
let skill_omission_reserve = omitted_skills_line(rows.len()).chars().count();
let shortened_reserve = if rows.is_empty() {
0
} else {
shortened_skills_line(rows.len(), rows.len())
.chars()
.count()
};
let warning_omission_reserve = if warnings.is_empty() {
0
} else {
WARNING_HEADING.chars().count() + omitted_warnings_line(warnings.len()).chars().count()
};
let fixed = HEADER.chars().count() + USAGE.chars().count() + warning_omission_reserve;
let fixed = HEADER.chars().count()
+ USAGE.chars().count()
+ warning_omission_reserve
+ shortened_reserve;
// Warnings are rendered after the index and share the budget; give them a
// bounded slice so a noisy install cannot erase the index, and vice versa.
let warning_slice = if warnings.is_empty() {
@@ -2035,6 +2055,7 @@ Skills are optional instruction packs. This index exposes routing metadata; bodi
let mut out = String::from(HEADER);
let mut omitted = 0usize;
let mut shortened = 0usize;
// Tier 1: full descriptions.
let full_lines: Vec<String> = rows
@@ -2049,6 +2070,13 @@ Skills are optional instruction packs. This index exposes routing metadata; bodi
for line in &full_lines {
out.push_str(line);
}
shortened = rows
.iter()
.filter(|row| {
let (s, t) = description_split(row, MAX_SKILL_DESCRIPTION_CHARS);
s < row.summary_len() || t < row.trigger_len()
})
.count();
} else {
// Tier 2: shorten descriptions proportionally. Fixed cost per row is
// the name-plus-source scaffolding; whatever remains is shared among
@@ -2065,7 +2093,7 @@ Skills are optional instruction packs. This index exposes routing metadata; bodi
})
.sum();
let desc_avail = index_budget.saturating_sub(scaffold);
let shortened: Option<Vec<String>> = (desc_full > 0
let shortened_lines: Option<Vec<(String, bool)>> = (desc_full > 0
&& desc_avail >= rows.len() * MIN_SHORTENED_DESCRIPTION_CHARS)
.then(|| {
rows.iter()
@@ -2078,16 +2106,26 @@ Skills are optional instruction packs. This index exposes routing metadata; bodi
.max(MIN_SHORTENED_DESCRIPTION_CHARS)
.min(natural);
let (s, t) = description_split(row, cap);
row.render(s, t)
(
row.render(s, t),
s < row.summary_len() || t < row.trigger_len(),
)
})
.collect()
})
.filter(|lines: &Vec<String>| {
lines.iter().map(|l| l.chars().count()).sum::<usize>() <= index_budget
.filter(|lines: &Vec<(String, bool)>| {
lines
.iter()
.map(|(line, _)| line.chars().count())
.sum::<usize>()
<= index_budget
});
if let Some(lines) = shortened {
for line in &lines {
out.push_str(line);
if let Some(lines) = shortened_lines {
for (line, was_shortened) in lines {
if was_shortened {
shortened += 1;
}
out.push_str(&line);
}
} else {
// Tier 3: names only. Omission is the last resort and only when
@@ -2102,6 +2140,9 @@ Skills are optional instruction packs. This index exposes routing metadata; bodi
} else {
used += len;
out.push_str(&line);
if row.summary_len() + row.trigger_len() > 0 {
shortened += 1;
}
}
}
}
@@ -2111,6 +2152,10 @@ Skills are optional instruction packs. This index exposes routing metadata; bodi
out.push_str(&omitted_skills_line(omitted));
}
if shortened > 0 {
out.push_str(&shortened_skills_line(shortened, rows.len()));
}
if !warnings.is_empty() {
out.push_str(WARNING_HEADING);
let warnings_budget = budget_chars.saturating_sub(
@@ -2176,6 +2221,21 @@ fn omitted_warnings_line(count: usize) -> String {
format!("- ... {count} additional warnings omitted; run `/skills` to inspect them.\n")
}
fn shortened_skills_line(shortened: usize, total: usize) -> String {
format!(
"- ... descriptions shortened for {shortened} of {total} skills; `load_skill` with `name=\"list\"` returns full text.\n"
)
}
pub(crate) fn prompt_description_char_count(value: &str) -> usize {
value
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.chars()
.count()
}
fn truncate_for_prompt(value: &str, max_chars: usize) -> String {
let single_line = value.split_whitespace().collect::<Vec<_>>().join(" ");
if single_line.chars().count() <= max_chars {
+57
View File
@@ -474,6 +474,63 @@ fn render_available_skills_context_truncates_long_descriptions() {
);
}
#[test]
fn render_skills_block_reports_shortened_descriptions_and_warns() {
let tmpdir = TempDir::new().unwrap();
let long_desc = "x".repeat(2_000);
let body = format!("---\nname: bigdesc\ndescription: {long_desc}\n---\nbody");
create_skill_dir(&tmpdir, "bigdesc", &body);
let rendered = crate::skills::render_available_skills_context(&tmpdir.path().join("skills"))
.expect("skill context");
assert!(
rendered.contains("descriptions shortened for 1 of 1 skills"),
"aggregate footer missing:\n{rendered}"
);
assert!(
rendered.contains("### Skill load warnings"),
"discovery warning heading missing:\n{rendered}"
);
assert!(
rendered.contains("no `Use when:` trigger"),
"discovery warning missing:\n{rendered}"
);
}
#[test]
fn render_skills_block_reports_proportional_shortening_without_warning() {
let tmpdir = TempDir::new().unwrap();
let mut registry = super::SkillRegistry::default();
let desc = "y".repeat(300);
for i in 0..100 {
registry.skills.push(super::Skill {
legacy_activation_name: None,
name: format!("skill-{i:03}"),
description: desc.clone(),
localized_descriptions: std::collections::HashMap::new(),
invocation: super::SkillInvocation::ModelAndUser,
aliases: Vec::new(),
argument_hint: None,
body: "body".to_string(),
path: tmpdir.path().join(format!("skill-{i:03}/SKILL.md")),
source: super::SkillSource::Native,
});
}
let rendered =
super::render_skills_block(&registry, "en", tmpdir.path()).expect("skill context");
assert!(
rendered.contains("descriptions shortened for 100 of 100 skills"),
"aggregate footer missing for tier-2 shortening:\n{rendered}"
);
assert!(
!rendered.contains("no `Use when:` trigger"),
"sub-cap descriptions must not produce per-skill warnings:\n{rendered}"
);
}
#[test]
fn render_available_skills_context_collapses_internal_whitespace() {
let tmpdir = TempDir::new().unwrap();