From 290de6ce16db683d0c7a898a890c942e23a6a55f Mon Sep 17 00:00:00 2001 From: Trevin Chow Date: Mon, 17 Aug 2026 14:01:51 -0700 Subject: [PATCH] fix(manifest): drop Agent Plugins $schema so Codex stops truncating skills at 8KB Codex >= 0.147 classifies a plugin whose root plugin.json carries an agent-plugins.org $schema as an Agent Plugin and injects only the first 8000 bytes of each SKILL.md. 26 bundled skills exceed that, so gates and handoffs were silently dropped. Falling back to the legacy .codex-plugin/plugin.json manifest restores full injection. Adds a shrink-only size guard so no new skill crosses the bound and the $schema cannot return until every skill fits. Fixes #1412 Claude-Session: https://claude.ai/code/session_0139gs5yWrMWY9Crx17Ci2Zv --- docs/specs/agent-plugins.md | 6 +- plugin.json | 1 - tests/codex-skill-prompt-budget.test.ts | 107 ++++++++++++++++++++++++ tests/release-metadata.test.ts | 6 +- 4 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 tests/codex-skill-prompt-budget.test.ts diff --git a/docs/specs/agent-plugins.md b/docs/specs/agent-plugins.md index 051a82ed9..b2543bc16 100644 --- a/docs/specs/agent-plugins.md +++ b/docs/specs/agent-plugins.md @@ -4,12 +4,14 @@ Last verified: 2026-08-07 against [Agent Plugins v1.0.0](https://agent-plugins.o ## What this repo does -Root `plugin.json` targets the Agent Plugins 1.0.0 manifest schema: +Root `plugin.json` follows the Agent Plugins 1.0.0 manifest authoring rules (field set and shapes) but **currently omits the `$schema` field**: ```text https://agent-plugins.org/schemas/1.0.0/plugin.schema.json ``` +**Why `$schema` is withheld (#1412):** Codex >= 0.147 ([openai/codex#37027](https://github.com/openai/codex/pull/37027)) treats a root `plugin.json` whose `$schema` starts with `https://agent-plugins.org/schemas/` as an Agent Plugin, and for Agent Plugin skills injects only the first `MAX_SKILL_PROMPT_BYTES` (8000) of each `SKILL.md` into the model-visible prompt, silently dropping the rest. Legacy manifests (`.codex-plugin/plugin.json`) are exempt. Most bundled skills exceed 8000 bytes, so shipping the `$schema` truncates them on Codex. `tests/codex-skill-prompt-budget.test.ts` pins this: it forbids the `$schema` while any skill is over budget, and holds a shrink-only allowlist of over-budget skills (CRLF-adjusted, since Windows checkouts inflate the byte count). Restore the `$schema` only once that allowlist is empty. + Layout already matches the portable package shape: root manifest + `skills//SKILL.md`. No `mcp.json` (valid — MCP is optional). CI pins authoring rules in `tests/release-metadata.test.ts` (schema const, name pattern, closed field set, field shapes). Rules are pinned locally; tests never fetch the schema at runtime. @@ -44,6 +46,8 @@ Agent Plugins discovers skills via the [Agent Skills](https://agentskills.io/spe ## Re-verify when +- Every `SKILL.md` fits Codex's 8000-byte prompt bound (then restore `$schema`) +- Codex changes `MAX_SKILL_PROMPT_BYTES` or applies it to legacy/host skills ([openai/codex#37463](https://github.com/openai/codex/issues/37463)) - Agent Plugins leaves Working Draft / publishes a new schema version - Adding top-level fields to root `plugin.json` - A concrete Agent Plugins client is observed to skip or reject skills with Claude-only frontmatter diff --git a/plugin.json b/plugin.json index 5cde85db7..7f5f7a443 100644 --- a/plugin.json +++ b/plugin.json @@ -1,5 +1,4 @@ { - "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "compound-engineering", "version": "3.22.2", "description": "Brainstorm, plan, debug, review, and compound learnings with AI agents", diff --git a/tests/codex-skill-prompt-budget.test.ts b/tests/codex-skill-prompt-budget.test.ts new file mode 100644 index 000000000..526423147 --- /dev/null +++ b/tests/codex-skill-prompt-budget.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, test } from "bun:test" +import { readdirSync, readFileSync, statSync } from "node:fs" +import path from "node:path" + +/** + * Codex >= 0.147 (openai/codex#37027) classifies a plugin as an Agent Plugin when the + * root `plugin.json` carries an `https://agent-plugins.org/schemas/...` `$schema`, and + * then injects only the first MAX_SKILL_PROMPT_BYTES (8000) of each SKILL.md into the + * model-visible prompt (#1412). Legacy manifests (`.codex-plugin/plugin.json`) are exempt. + * + * Until every skill entrypoint fits, the root manifest must not carry that `$schema`, + * and no skill may newly cross the bound. Shrink OVER_BUDGET as skills are restructured; + * when it is empty, the `$schema` may return. + */ +const CODEX_MAX_SKILL_PROMPT_BYTES = 8_000 +const AGENT_PLUGINS_SCHEMA_PREFIX = "https://agent-plugins.org/schemas/" + +/** + * Skills known to exceed the bound. Membership is a set on purpose: an over-budget skill is + * already truncated on Codex, so its exact size is not pinned and ordinary edits do not churn + * this list. Remove a name once its SKILL.md fits; never add one for a new skill. + */ +const OVER_BUDGET = new Set([ + "ce-babysit-pr", + "ce-brainstorm", + "ce-code-review", + "ce-commit-push-pr", + "ce-compound", + "ce-compound-refresh", + "ce-debug", + "ce-doc-review", + "ce-dogfood", + "ce-explain", + "ce-handoff", + "ce-ideate", + "ce-optimize", + "ce-plan", + "ce-pov", + "ce-product-pulse", + "ce-proof", + "ce-prototype", + "ce-resolve-pr-feedback", + "ce-retune", + "ce-setup", + "ce-strategy", + "ce-sweep", + "ce-test-browser", + "ce-work", + "lfg", +]) + +const repoRoot = path.join(import.meta.dir, "..") +const skillsDir = path.join(repoRoot, "skills") + +/** Byte size as a Windows checkout with CRLF line endings would inject it. */ +function crlfByteSize(contents: string): number { + const lf = contents.replace(/\r\n/g, "\n") + return Buffer.byteLength(lf, "utf8") + (lf.match(/\n/g)?.length ?? 0) +} + +function skillSizes(): Map { + const sizes = new Map() + for (const name of readdirSync(skillsDir)) { + const file = path.join(skillsDir, name, "SKILL.md") + if (!statSync(path.join(skillsDir, name)).isDirectory()) continue + try { + sizes.set(name, crlfByteSize(readFileSync(file, "utf8"))) + } catch { + // no SKILL.md; other tests own that invariant + } + } + return sizes +} + +describe("Codex skill prompt budget (#1412)", () => { + const sizes = skillSizes() + + test("no skill newly exceeds Codex's 8000-byte prompt bound (CRLF-adjusted)", () => { + const violations: string[] = [] + for (const [name, size] of sizes) { + if (size > CODEX_MAX_SKILL_PROMPT_BYTES && !OVER_BUDGET.has(name)) { + violations.push(`${name}: ${size} bytes > ${CODEX_MAX_SKILL_PROMPT_BYTES}`) + } + } + expect(violations).toEqual([]) + }) + + test("OVER_BUDGET only lists skills that still exceed the bound (ratchet down)", () => { + const stale = [...OVER_BUDGET].filter( + (name) => (sizes.get(name) ?? 0) <= CODEX_MAX_SKILL_PROMPT_BYTES, + ) + expect(stale).toEqual([]) + }) + + test("root plugin.json omits the Agent Plugins $schema while any skill is over budget", () => { + const manifest = JSON.parse( + readFileSync(path.join(repoRoot, "plugin.json"), "utf8"), + ) as Record + const schema = typeof manifest.$schema === "string" ? manifest.$schema : "" + const anyOverBudget = [...sizes.values()].some( + (size) => size > CODEX_MAX_SKILL_PROMPT_BYTES, + ) + if (anyOverBudget) { + expect(schema.startsWith(AGENT_PLUGINS_SCHEMA_PREFIX)).toBe(false) + } + }) +}) diff --git a/tests/release-metadata.test.ts b/tests/release-metadata.test.ts index 9a5530b13..391baacf0 100644 --- a/tests/release-metadata.test.ts +++ b/tests/release-metadata.test.ts @@ -937,8 +937,10 @@ const AGENT_PLUGINS_STRING_FIELDS = [ function agentPluginsManifestErrors(manifest: Record): string[] { const errors: string[] = [] - if (manifest.$schema !== AGENT_PLUGINS_SCHEMA) { - errors.push(`$schema must be ${AGENT_PLUGINS_SCHEMA}`) + // $schema is deliberately absent while any SKILL.md exceeds Codex's 8000-byte + // Agent Plugin prompt bound (see tests/codex-skill-prompt-budget.test.ts, #1412). + if (manifest.$schema !== undefined && manifest.$schema !== AGENT_PLUGINS_SCHEMA) { + errors.push(`$schema must be ${AGENT_PLUGINS_SCHEMA} when present`) } if (typeof manifest.name !== "string") {