From 72abc9bcc229c96a22bb14bf2a009344aac468a0 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Fri, 24 Jul 2026 17:47:31 -0600 Subject: [PATCH] fix(profile): require a written reason to weaken the prompt-budget cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit composeAgentProfile enforces a 40,000-byte system-prompt ceiling, but the cap could be raised — or muted with warnOnly — silently. That escape hatch is how reference material concatenated into a prompt kept shipping: one product ran a 103,602-byte prompt behind a 120,000 ceiling, and its turns failed outright. Weakening the cap now needs overBudgetReason, and the check runs on every compose rather than only once a prompt has outgrown the raised number, so the silent raise is the step that fails. The error names the fix: move reference material to resources.files via corpusSkills/userSkillMounts or composeSkills({ mode: 'mounted' }) and keep inline only what the agent must obey without a tool call. Lowering the cap still needs no justification. --- src/profile/index.test.ts | 32 +++++++++++++++++++++++++++++--- src/profile/index.ts | 32 +++++++++++++++++++++++++++++++- 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/src/profile/index.test.ts b/src/profile/index.test.ts index 221a7fc..03c5fb8 100644 --- a/src/profile/index.test.ts +++ b/src/profile/index.test.ts @@ -314,20 +314,46 @@ describe('composeAgentProfile — system-prompt byte budget', () => { expect(() => composeAgentProfile(BASE, {}, { systemPrompt: 'x'.repeat(200) }, { maxSystemPromptBytes: 100 }), ).toThrow(/over the 100-byte budget/) - // And a raised budget lets a known-big prompt through. + // A raised budget lets a known-big prompt through once its reason is stated. const out = composeAgentProfile( BASE, {}, { systemPrompt: OVER_BUDGET_PROMPT }, - { maxSystemPromptBytes: 100_000 }, + { maxSystemPromptBytes: 100_000, overBudgetReason: 'irreducible per-turn doctrine' }, ) expect(out.prompt?.systemPrompt).toBe(OVER_BUDGET_PROMPT) }) + it('REDS when the cap is raised above the default with no overBudgetReason', () => { + // The failure that keeps recurring is reference material concatenated into + // the prompt, then the ceiling lifted to hide it. Raising the cap silently + // is the step this blocks, so it must fire even when the prompt still fits + // under the raised number. + expect(() => + composeAgentProfile(BASE, {}, { systemPrompt: 'short' }, { maxSystemPromptBytes: 120_000 }), + ).toThrow(/exceeds the 40000-byte default without an overBudgetReason[\s\S]*resources\.files/) + }) + + it('REDS when warnOnly mutes the gate with no overBudgetReason', () => { + expect(() => + composeAgentProfile(BASE, {}, { systemPrompt: OVER_BUDGET_PROMPT }, { warnOnly: true }), + ).toThrow(/warnOnly downgrades the over-budget throw[\s\S]*overBudgetReason/) + }) + + it('allows lowering the cap without a reason — only weakening it needs justifying', () => { + const out = composeAgentProfile(BASE, {}, { systemPrompt: 'short' }, { maxSystemPromptBytes: 1_000 }) + expect(out.prompt?.systemPrompt).toBe('short') + }) + it('warnOnly downgrades the throw to a console.warn that still yells', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) try { - const out = composeAgentProfile(BASE, {}, { systemPrompt: OVER_BUDGET_PROMPT }, { warnOnly: true }) + const out = composeAgentProfile( + BASE, + {}, + { systemPrompt: OVER_BUDGET_PROMPT }, + { warnOnly: true, overBudgetReason: 'shipping while the corpus moves to mounts' }, + ) expect(out.prompt?.systemPrompt).toBe(OVER_BUDGET_PROMPT) expect(warn).toHaveBeenCalledTimes(1) expect(String(warn.mock.calls[0]?.[0])).toMatch(/over the 40000-byte budget/) diff --git a/src/profile/index.ts b/src/profile/index.ts index bee8ec9..36242c7 100644 --- a/src/profile/index.ts +++ b/src/profile/index.ts @@ -135,6 +135,34 @@ export interface ComposeProfileBudget { * for a product with a known-big prompt that must still ship (it yells on * every compose instead of blocking). */ warnOnly?: boolean + /** Required to raise {@link maxSystemPromptBytes} above + * {@link DEFAULT_MAX_SYSTEM_PROMPT_BYTES} or to set {@link warnOnly}: a + * written reason naming what stays inline and why it cannot be mounted. + * Weakening the cap is a product decision that outlives the person making + * it, and the usual cause is reference material concatenated into the prompt + * that belongs in `resources.files`; demanding the sentence here keeps that + * from happening by accident. */ + overBudgetReason?: string +} + +/** Reject a budget that weakens the cap without stating why. Runs before the + * size check so it fires on every compose, not only once a prompt has already + * grown past the raised ceiling. */ +function assertBudgetPolicy(budget: ComposeProfileBudget): void { + const raisedCap = + budget.maxSystemPromptBytes !== undefined && + budget.maxSystemPromptBytes > DEFAULT_MAX_SYSTEM_PROMPT_BYTES + if (!raisedCap && !budget.warnOnly) return + if ((budget.overBudgetReason ?? '').trim() !== '') return + const weakened = raisedCap + ? `maxSystemPromptBytes ${budget.maxSystemPromptBytes} exceeds the ${DEFAULT_MAX_SYSTEM_PROMPT_BYTES}-byte default` + : 'warnOnly downgrades the over-budget throw to a warning' + throw new Error( + `${weakened} without an overBudgetReason. Oversized system prompts degrade toward empty answers, so the cap is not a formality. ` + + 'Before raising it: rank the prompt with largestPromptSections() — reference material (playbooks, checklists, corpora) belongs in resources.files ' + + "via corpusSkills()/userSkillMounts() or composeSkills({ mode: 'mounted' }), which puts the bodies on disk in the sandbox and leaves a short index in the prompt. " + + 'Only content the agent must obey without a tool call should stay inline. If the prompt is genuinely irreducible, set overBudgetReason to the sentence that says so.', + ) } /** Largest markdown-heading-delimited sections of a prompt, by UTF-8 bytes. @@ -171,6 +199,7 @@ export function assertSystemPromptWithinBudget( systemPrompt: string, budget: ComposeProfileBudget = {}, ): void { + assertBudgetPolicy(budget) const max = budget.maxSystemPromptBytes ?? DEFAULT_MAX_SYSTEM_PROMPT_BYTES const bytes = new TextEncoder().encode(systemPrompt).byteLength if (bytes <= max) return @@ -181,7 +210,8 @@ export function assertSystemPromptWithinBudget( `composed systemPrompt is ${bytes} bytes — over the ${max}-byte budget ` + `(oversized prompts degrade to empty answers). ` + (sections ? `Largest sections: ${sections}. ` : '') + - `Trim the prompt, move content to skills/knowledge mounts, or raise maxSystemPromptBytes deliberately.` + `Move reference material to resources.files (corpusSkills/userSkillMounts, or composeSkills({ mode: 'mounted' })) so the bodies land on disk in the sandbox ` + + `and the prompt keeps only an index; keep inline only what the agent must obey without a tool call. Raising maxSystemPromptBytes requires an overBudgetReason.` if (budget.warnOnly) { console.warn(`[profile] ${message}`) return