From a358c7abfa2b94933cb9446e12e5c11b5271bf2b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 14 Aug 2026 22:59:59 +0000 Subject: [PATCH] fix(skills): include metadata in skill size limit --- docs/tools/self-learning.md | 2 +- docs/tools/skill-workshop.md | 6 +-- docs/tools/skills-config.md | 6 +-- .../tools/skill-workshop-tool-schema.ts | 2 +- src/config/types.skills.ts | 2 +- src/skills/workshop/proposal-draft.test.ts | 38 ++++++++++++++++++- src/skills/workshop/proposal-draft.ts | 7 +++- 7 files changed, 51 insertions(+), 12 deletions(-) diff --git a/docs/tools/self-learning.md b/docs/tools/self-learning.md index 29c20dd679ea..ab81fdd0a202 100644 --- a/docs/tools/self-learning.md +++ b/docs/tools/self-learning.md @@ -277,7 +277,7 @@ result pending regardless of autonomous mode. | `skills.workshop.autonomous.mode` | `"auto"` | Chooses capture behavior; `auto` also enables daily collection review. | | `skills.workshop.approvalPolicy` | `"auto"` | Controls prompts for normal agent-initiated lifecycle calls. It never expands the isolated reviewer tool surface. | | `skills.workshop.maxPending` | `50` | Caps pending and quarantined proposals per workspace. | -| `skills.workshop.maxSkillBytes` | `40000` | Caps proposal body size in bytes. | +| `skills.workshop.maxSkillBytes` | `40000` | Caps persisted `SKILL.md` size in bytes, including retained frontmatter. | | `skills.workshop.allowSymlinkTargetWrites` | `false` | Allows apply through explicitly trusted workspace skill symlinks. Capture itself does not widen the trusted target list. | See [Skills config](/tools/skills-config#workshop-skills-workshop) for ranges and diff --git a/docs/tools/skill-workshop.md b/docs/tools/skill-workshop.md index be6311a14e69..68f28c22e4b0 100644 --- a/docs/tools/skill-workshop.md +++ b/docs/tools/skill-workshop.md @@ -378,7 +378,7 @@ the proposal threshold, and troubleshooting. | `allowSymlinkTargetWrites` | `false` | Lets apply write through workspace skill symlinks whose real target is listed in `skills.load.allowSymlinkTargets`. | | `approvalPolicy` | `"auto"` | `"auto"` skips an additional prompt for agent-initiated `apply`, `reject`, or `quarantine` (the agent still has to call the action). `"pending"` requires approval. | | `maxPending` | `50` | Caps pending and quarantined proposals per workspace (1-200). | -| `maxSkillBytes` | `40000` | Caps proposal body size in bytes (1024-200000). | +| `maxSkillBytes` | `40000` | Caps persisted `SKILL.md` size in bytes, including retained frontmatter (1024-200000). | In `propose` and `auto` modes, an isolated run of the selected model decides whether the completed trajectory clears the evidence-gated proposal bar. The foreground model is not prompted @@ -474,7 +474,7 @@ proposals remain listed with a previous-workspace marker instead of disappearing | Limit | Value | | ------------------------------- | ---------------------------------------------------------------------------- | | Description | 160 bytes | -| Proposal body | `skills.workshop.maxSkillBytes` (default 40,000; hard ceiling 200,000 bytes) | +| Persisted `SKILL.md` | `skills.workshop.maxSkillBytes` (default 40,000; hard ceiling 200,000 bytes) | | Support files | 64 per proposal | | Support file size | 256 KiB each, 2 MiB total | | Pending + quarantined proposals | `skills.workshop.maxPending` per workspace (default 50) | @@ -484,7 +484,7 @@ proposals remain listed with a previous-workspace marker instead of disappearing | Problem | Resolution | | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Skill proposal description is too large` | Shorten `description` to 160 bytes or less. | -| `Skill proposal content is too large` | Shorten the proposal body or raise `skills.workshop.maxSkillBytes`. | +| `Skill proposal content is too large` | Shorten the resulting skill or raise `skills.workshop.maxSkillBytes`. | | `Target skill changed after proposal creation` | Revise the proposal against the current target, or create a new proposal. | | `Proposal scan failed` | Inspect scanner findings, then revise or quarantine the proposal. | | `untrusted symlink target` | Configure `skills.load.allowSymlinkTargets` and enable `skills.workshop.allowSymlinkTargetWrites` only for intentional shared skill roots. | diff --git a/docs/tools/skills-config.md b/docs/tools/skills-config.md index e22509900601..1cd1ed3f3588 100644 --- a/docs/tools/skills-config.md +++ b/docs/tools/skills-config.md @@ -389,9 +389,9 @@ proposal-only permissions, and troubleshooting. - Maximum proposal body size in bytes (allowed range: 1024-200000). Proposal - descriptions are hard-capped at 160 bytes separately, because they appear - in discovery and listing output. + Maximum persisted `SKILL.md` size in bytes, including retained frontmatter + (allowed range: 1024-200000). Proposal descriptions are hard-capped at 160 + bytes separately, because they appear in discovery and listing output. See [Skill Workshop](/tools/skill-workshop) for the proposal lifecycle, CLI diff --git a/src/agents/tools/skill-workshop-tool-schema.ts b/src/agents/tools/skill-workshop-tool-schema.ts index 876f854da99a..c50f92df8816 100644 --- a/src/agents/tools/skill-workshop-tool-schema.ts +++ b/src/agents/tools/skill-workshop-tool-schema.ts @@ -130,7 +130,7 @@ export function buildSkillWorkshopToolSchema( proposal_content: Type.Optional( Type.String({ description: - "Complete final skill body for action=create or action=update, or when action=revise changes the body. Must be the full skill content ready to become the active SKILL.md — not a plan, diff, change description, or implementation notes. On revise, omit this field to preserve the current body. On update/revise, preserve all existing content except changes the user explicitly requested. Proposal frontmatter is added automatically. Keep under configured skills.workshop.maxSkillBytes; default max is 40000 bytes.", + "Complete final skill body for action=create or action=update, or when action=revise changes the body. Must be the full skill content ready to become the active SKILL.md — not a plan, diff, change description, or implementation notes. On revise, omit this field to preserve the current body. On update/revise, preserve all existing content except changes the user explicitly requested. Proposal frontmatter is added automatically. The persisted SKILL.md, including retained frontmatter, must fit configured skills.workshop.maxSkillBytes; default max is 40000 bytes.", }), ), support_files: Type.Optional( diff --git a/src/config/types.skills.ts b/src/config/types.skills.ts index 6c23b2d334d0..33a14aff873d 100644 --- a/src/config/types.skills.ts +++ b/src/config/types.skills.ts @@ -69,7 +69,7 @@ export type SkillsWorkshopConfig = { approvalPolicy?: "pending" | "auto"; /** Maximum pending/quarantined proposals retained per workspace. */ maxPending?: number; - /** Maximum generated skill proposal size in bytes. */ + /** Maximum persisted SKILL.md size in bytes, including retained frontmatter. */ maxSkillBytes?: number; }; diff --git a/src/skills/workshop/proposal-draft.test.ts b/src/skills/workshop/proposal-draft.test.ts index b4180cba5269..bc7b94a21d3c 100644 --- a/src/skills/workshop/proposal-draft.test.ts +++ b/src/skills/workshop/proposal-draft.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { stripProposalFrontmatterForSkill } from "./frontmatter.js"; import { nextProposalVersion, prepareSkillProposalDraft, @@ -52,7 +53,9 @@ describe("Skill Workshop proposal draft preparation", () => { expect(oversized).toMatchObject({ ok: false, error: { - message: "Skill proposal content is too large (5 bytes, max 4).", + message: expect.stringMatching( + /^Skill proposal content is too large \(\d+ bytes, max 4\)\.$/, + ), }, }); @@ -77,6 +80,39 @@ describe("Skill Workshop proposal draft preparation", () => { }); }); + it("measures the canonical persisted skill instead of the submitted body", () => { + const maxSkillBytes = 1024; + const input = { + name: "boundary-skill", + description: "Boundary skill", + content: "x".repeat(maxSkillBytes), + date: "2026-08-14T00:00:00.000Z", + }; + const prepared = prepareSkillProposalDraft({ + ...input, + maxSkillBytes, + }); + + expect(prepared).toMatchObject({ + ok: false, + error: { + message: expect.stringMatching( + /^Skill proposal content is too large \(1\d{3} bytes, max 1024\)\.$/, + ), + }, + }); + + const roomy = prepareSkillProposalDraft({ ...input, maxSkillBytes: 2048 }); + if (!roomy.ok) { + throw roomy.error.cause; + } + const persistedBytes = Buffer.byteLength( + stripProposalFrontmatterForSkill(roomy.value.content), + "utf8", + ); + expect(prepareSkillProposalDraft({ ...input, maxSkillBytes: persistedBytes }).ok).toBe(true); + }); + it("preserves version and UTF-8 description behavior", () => { expect(nextProposalVersion("v1")).toBe("v2"); expect(nextProposalVersion("invalid")).toBe("v2"); diff --git a/src/skills/workshop/proposal-draft.ts b/src/skills/workshop/proposal-draft.ts index 56dfecffb6d1..559214e9edac 100644 --- a/src/skills/workshop/proposal-draft.ts +++ b/src/skills/workshop/proposal-draft.ts @@ -7,7 +7,7 @@ import { MAX_WORKSPACE_SKILL_SUPPORT_FILE_BYTES, normalizeWorkspaceSkillSupportPath, } from "../lifecycle/workspace-skill-write.js"; -import { renderProposalMarkdown } from "./frontmatter.js"; +import { renderProposalMarkdown, stripProposalFrontmatterForSkill } from "./frontmatter.js"; import { assertProposalContainsNoLiteralSecrets, scanProposalBundle } from "./proposal-scan.js"; import { hashSkillProposalContent, @@ -54,7 +54,6 @@ export function prepareSkillProposalDraft(input: { }): Result { try { assertProposalDescriptionWithinLimit(input.description); - assertProposalContentWithinLimit(input.content, input.maxSkillBytes); const supportFiles = prepareSkillProposalSupportFiles(input.supportFiles); const content = renderProposalMarkdown({ name: input.name, @@ -64,6 +63,10 @@ export function prepareSkillProposalDraft(input: { version: input.version, date: input.date, }); + assertProposalContentWithinLimit( + stripProposalFrontmatterForSkill(content), + input.maxSkillBytes, + ); const goal = normalizeOptionalString(input.goal); const evidence = normalizeOptionalString(input.evidence); const scan = scanProposalBundle(content, supportFiles, [