fix(skills): include metadata in skill size limit

This commit is contained in:
Peter Steinberger
2026-08-14 22:59:59 +00:00
committed by Josh Lehman
parent 887b9dd5e8
commit a358c7abfa
7 changed files with 51 additions and 12 deletions
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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. |
+3 -3
View File
@@ -389,9 +389,9 @@ proposal-only permissions, and troubleshooting.
</ParamField>
<ParamField path="skills.workshop.maxSkillBytes" type="number" default="40000">
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.
</ParamField>
See [Skill Workshop](/tools/skill-workshop) for the proposal lifecycle, CLI
@@ -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(
+1 -1
View File
@@ -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;
};
+37 -1
View File
@@ -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");
+5 -2
View File
@@ -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<PreparedSkillProposalDraft, SkillProposalDraftValidationError> {
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, [