fix(coding-agent): count only rendered skills in /context accounting
computeNonMessageBreakdown estimated Skills tokens from the unfiltered session.skills registry and subtracted that from the first system-prompt block, which only ever contained the rendered (filtered) skills. Hidden explicit-only skills (hide/disable-model-invocation) and all skills when the read tool was absent inflated Skills and clamped System prompt to 0. Add a renderedSkills helper mirroring buildSystemPrompt's filter and use it for the Skills estimate so the category split matches the provider prompt. Fixes #6498
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed `/context` counting hidden, explicit-only skills (`hide: true` / `disable-model-invocation`) in the Skills category and subtracting that inflated estimate from the first system-prompt block, which reported `System prompt: 0 tokens` and inflated Skills usage. Accounting now counts only the skills actually rendered into the system prompt — mirroring `buildSystemPrompt`'s filter, so hidden skills and all skills when the `read` tool is unavailable contribute zero ([#6498](https://github.com/can1357/oh-my-pi/issues/6498)).
|
||||
|
||||
## [17.1.1] - 2026-07-24
|
||||
|
||||
### Added
|
||||
|
||||
@@ -56,6 +56,22 @@ const EMPTY_STRING_PARTS: string[] = [];
|
||||
const EMPTY_TOOLS: ReadonlyArray<Pick<Tool, "name" | "description" | "parameters">> = [];
|
||||
const EMPTY_SKILLS: readonly Skill[] = [];
|
||||
|
||||
/**
|
||||
* Skills actually rendered into the system prompt, mirroring the filter in
|
||||
* `buildSystemPrompt` (`system-prompt.ts`): the `read` tool must be present so
|
||||
* the model can fetch skill content, and skills with frontmatter `hide: true`
|
||||
* (or `disable-model-invocation`, normalized onto `hide`) are excluded.
|
||||
* Accounting must count only these so the Skills category and the System-prompt
|
||||
* subtraction stay aligned with the provider-facing prompt.
|
||||
*/
|
||||
function renderedSkills(
|
||||
skills: readonly Skill[],
|
||||
tools: ReadonlyArray<Pick<Tool, "name" | "description" | "parameters">>,
|
||||
): readonly Skill[] {
|
||||
if (!tools.some(tool => tool.name === "read")) return EMPTY_SKILLS;
|
||||
return skills.filter(skill => skill.hide !== true);
|
||||
}
|
||||
|
||||
export function estimateSkillsTokens(skills: readonly Skill[]): number {
|
||||
const fragments: string[] = [];
|
||||
for (const skill of skills) {
|
||||
@@ -171,8 +187,9 @@ export function computeNonMessageBreakdown(session: NonMessageTokenSource): {
|
||||
} {
|
||||
const entry = nonMessageTokenCacheEntry(session);
|
||||
if (entry.breakdown) return entry.breakdown;
|
||||
const skillsTokens = estimateSkillsTokens(session.skills ?? EMPTY_SKILLS);
|
||||
const toolsTokens = estimateToolSchemaTokens(session.agent?.state?.tools ?? EMPTY_TOOLS);
|
||||
const tools = session.agent?.state?.tools ?? EMPTY_TOOLS;
|
||||
const skillsTokens = estimateSkillsTokens(renderedSkills(session.skills ?? EMPTY_SKILLS, tools));
|
||||
const toolsTokens = estimateToolSchemaTokens(tools);
|
||||
const systemPromptParts = session.systemPrompt ?? EMPTY_STRING_PARTS;
|
||||
const systemContextTokens = countTokens(systemPromptParts.slice(1));
|
||||
const systemPromptTokens = Math.max(0, countTokens(systemPromptParts[0] ?? "") - skillsTokens);
|
||||
|
||||
@@ -137,3 +137,36 @@ describe("computeNonMessageTokens / computeNonMessageBreakdown memoization", ()
|
||||
expect(computeNonMessageBreakdown(session as never).systemPromptTokens).not.toBe(breakdown.systemPromptTokens);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Contract: the Skills category counts only skills actually rendered into the
|
||||
* system prompt (mirroring `buildSystemPrompt`'s filter) — hidden/explicit-only
|
||||
* skills, and every skill when the `read` tool is absent, contribute zero. The
|
||||
* System-prompt subtraction must not be inflated by unrendered skill metadata
|
||||
* and clamped to 0 (issue #6498).
|
||||
*/
|
||||
describe("computeNonMessageBreakdown skills filtering", () => {
|
||||
const readTool = { name: "read", description: "read files", parameters: {} };
|
||||
const hidden = { name: "hidden-skill", description: "X".repeat(4000), filePath: "/s/h.md", hide: true };
|
||||
const visible = { name: "vis", description: "small visible skill", filePath: "/s/v.md" };
|
||||
// First prompt block as rendered: only the visible skill appears.
|
||||
const renderedPrompt = "You are an agent.\nSkills:\n- vis: small visible skill\n";
|
||||
|
||||
function session(tools: unknown[], skills: unknown[]) {
|
||||
return { systemPrompt: [renderedPrompt], agent: { state: { tools } }, skills } as never;
|
||||
}
|
||||
|
||||
it("excludes hidden skills and does not clamp System prompt to 0", () => {
|
||||
const b = computeNonMessageBreakdown(session([readTool], [hidden, visible]));
|
||||
// Only the visible skill is counted, not the large hidden one.
|
||||
expect(b.skillsTokens).toBe(computeNonMessageBreakdown(session([readTool], [visible])).skillsTokens);
|
||||
expect(b.skillsTokens).toBeLessThan(100);
|
||||
expect(b.systemPromptTokens).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("counts zero Skills tokens when the read tool is unavailable", () => {
|
||||
const b = computeNonMessageBreakdown(session([], [hidden, visible]));
|
||||
expect(b.skillsTokens).toBe(0);
|
||||
expect(b.systemPromptTokens).toBe(computeNonMessageBreakdown(session([], [])).systemPromptTokens);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user