fix(skills): a skill body stops at the 25k-token read cap

`read_file` exempted SKILL.md from every limit, and both injection paths
(`/skill` expansion and the `skill` tool) pasted the whole body. A 220 KB
skill put 55k tokens into the context on each call.

`cap_skill_body` cuts an over-cap body at the last line boundary that
fits (a char boundary when the body is one long line) and appends a note
that points at offset and limit. It runs before `apply_substitutions`, so
the arguments land after the cut. Both injection paths call it and log
the cut.

`read_file` returns a SKILL.md whole only while it fits the cap. Over the
cap it reads like any other file: offset and limit apply, and a window
that is still too large answers FileTooLarge. Without that the note would
send the model to a read that ignores its range.

`skill_file_skips_token_limit` pinned the exemption; it gives way to
`skill_file_over_cap_returns_file_too_large` and
`skill_file_over_cap_honors_offset_and_limit`, as upstream did.

Ported from upstream (grok-build tools skills/skill.rs cap_skill_body and
read_file skill_full, 1.0.33). Upstream's `max_output_bytes` read budget
in the same hunk is a feature and stays out.
This commit is contained in:
ZacharyZhang-NY committed 2026-09-19 02:31:24 -04:00
1 parent 443de79e2f
commit 206711bed5
6 files changed
+163 -28

No files matched your search

@@ -1020,6 +1020,7 @@ pub(super) async fn build_skill_information_for_refs(
slash_skills: &[SkillInfo],
session_id: &str,
) -> Option<String> {
use kigi_tools::implementations::skills::body_cap::cap_skill_body;
use kigi_tools::implementations::skills::skill::{
SkillRef, SubstitutionContext, apply_substitutions, build_skill_block,
build_skill_information, load_skill_content,
@@ -1034,6 +1035,13 @@ pub(super) async fn build_skill_information_for_refs(
};
match load_skill_content(info).await {
Ok(mut content) => {
if cap_skill_body(&mut content) {
tracing::info!(
skill = %sk.name,
path = %info.path,
"skill body truncated at read cap"
);
}
let skill_dir = std::path::Path::new(&info.path)
.parent()
.and_then(|p| p.to_str());
@@ -1465,6 +1473,31 @@ mod tests {
);
}
/// A 220 KB skill body stops at the read cap; the tail stays on disk.
#[tokio::test]
async fn build_skill_information_for_refs_caps_an_oversized_body() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("SKILL.md");
let body = (1..=1100)
.map(|n| format!("{n:05} {}", "x".repeat(194)))
.collect::<Vec<_>>()
.join("\n");
std::fs::write(&path, &body).unwrap();
let mut skill = make_skill("big", true);
skill.path = path.to_string_lossy().to_string();
let skills = vec![skill];
let parsed =
parse_skill_references("/big", &skills, all_gated()).expect("known skill must parse");
let info = build_skill_information_for_refs(&parsed, &skills, "sid-1")
.await
.expect("skill body must load");
assert!(info.contains("00001 "), "head must survive");
assert!(!info.contains("01100 "), "tail must be cut");
assert!(info.contains("Skill body truncated at the 25000-token cap"));
}
#[test]
fn resolve_loop_annotates_block_with_compact_display_text() {
let outcome = resolve(