From 5b4f3a2c68c97d7a59d8265c5f57e8d836c49868 Mon Sep 17 00:00:00 2001 From: Vlad Feninets Date: Wed, 9 Sep 2026 17:45:51 +0300 Subject: [PATCH] fix(dispatch): refuse a prompt too large for the seat's known TPM ceiling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pick has refused to SEAT an oversized prompt since token_limits landed (#51), but dispatch — reachable directly by any caller naming an agent id, skipping pick entirely — never checked the same ceiling before sending. Two live 413s on groq-gpt-oss-120b (2026-09-08/09) requested 17124 and 22583 tokens against an observed ceiling of 8000 that had not moved in days: the ceiling was known, nothing had consulted it. runAny now runs the same effectiveTokenCeiling check pick already uses (declared token_limits, inherited from a same-voice sibling when the entry declares none, or the fresher observed ceiling) right after assembling the full prompt, and refuses before dispatching — same precheck/recordFailure/throw shape as the existing transport refusal. --allow-oversized-prompt (CLI) / allow_oversized_prompt (MCP) opts out deliberately, mirroring --require-base's opt-in-only shape. --- cli.js | 35 +++++++++++++++---- lib/dispatch.js | 57 +++++++++++++++++++++++++++++++ lib/dispatch.prompt-size.test.js | 52 ++++++++++++++++++++++++++++ lib/pick.inherited-limits.test.js | 32 +++++++++++++++++ lib/pick.js | 13 +++++++ server.js | 19 ++++++++--- 6 files changed, 198 insertions(+), 10 deletions(-) create mode 100644 lib/dispatch.prompt-size.test.js create mode 100644 lib/pick.inherited-limits.test.js diff --git a/cli.js b/cli.js index 59584b0..4eee6a7 100755 --- a/cli.js +++ b/cli.js @@ -22,7 +22,7 @@ import { loadRegistry, LOCAL_PATH, withLocalOverlayLock } from "./lib/registry.j import yaml from "js-yaml"; import { readState, writeState, probeInstalled, resetCooldownsForEnvVar, enableAgentsAwaitingCredential, mergeAuditState, auditCooldown, deriveDisplayState } from "./lib/state.js"; import { runAny, resolveEscalation, classifyDispatchFailure, getStats, verifyCredential, auditCliEntry, getTransportConfig, selectTransport, probeReadOnlyNonWriting, classifyVerifyResult, shouldPersistOutcome, repoProvenance, sweepDispatchTemp } from "./lib/dispatch.js"; -import { pickAgents, providerFamily, isAgentEnabled, explainEmptyPick, formatEmptyPick } from "./lib/pick.js"; +import { pickAgents, providerFamily, isAgentEnabled, explainEmptyPick, formatEmptyPick, inheritedTokenLimits } from "./lib/pick.js"; import { nextStateAfterOutcome, sharedQuotaBucketIds, withObservations, floorExhaustionReset } from "./lib/outcome.js"; import { resolveExhaustionResetAt } from "./lib/quota-reset.js"; import { persistCredential, bootEnv, displayPath, KEYS_FILE } from "./lib/credentials.js"; @@ -58,7 +58,7 @@ const REGISTRY = loadRegistry(REGISTRY_PATH); // taking parser turns `dispatch --json "prompt"` into {json:"prompt"} and // eats the positional — the prompt vanishes ("missing prompt"). Same latent // trap for --pro. Everything else stays a value flag (--n 3, --tier strong, …). -const BOOLEAN_FLAGS = new Set(["json", "pro", "no-open", "force", "stream", "enabled", "disabled", "include-disabled"]); +const BOOLEAN_FLAGS = new Set(["json", "pro", "no-open", "force", "stream", "enabled", "disabled", "include-disabled", "allow-oversized-prompt"]); const ARRAY_FLAGS = new Set(["file"]); const VALID_EFFORT_LEVELS = new Set(["none", "minimal", "default", "low", "medium", "high", "xhigh", "max"]); function parseArgs(argv) { @@ -186,7 +186,7 @@ function cmdPick(flags) { async function cmdDispatch(args, flags) { const [agentId, ...promptParts] = args; const prompt = promptParts.join(" "); - if (!agentId) die("usage: cli.js dispatch [--pro] [--json] [--stream] [--transport generate_new|edit_exists|read_only] [--effort ] [--cwd ] [--file path[:lines]] \"\"", 2); + if (!agentId) die("usage: cli.js dispatch [--pro] [--json] [--stream] [--transport generate_new|edit_exists|read_only] [--effort ] [--cwd ] [--file path[:lines]] [--allow-oversized-prompt] \"\"", 2); if (!prompt) die("dispatch: missing prompt", 2); // --file path[:lines] — repeatable. "src/foo.ts:10-50" → {path, lines}. @@ -300,7 +300,26 @@ async function cmdDispatch(args, flags) { process.stderr.write(message.endsWith("\n") ? message : `${message}\n`); } : undefined; - const result = await runAny(entry, prompt, { transport, cwd: flags.cwd, files, effort, progress }); + let result; + try { + result = await runAny(entry, prompt, { + transport, + cwd: flags.cwd, + files, + effort, + progress, + inheritedTokenLimits: inheritedTokenLimits(REGISTRY, entry), + allowOversizedPrompt: Boolean(flags["allow-oversized-prompt"]), + }); + } catch (e) { + if (!e.preDispatchRefusal) throw e; + // Already recorded inside runAny (the same choke point selectTransport's + // refusal above uses) — this catch only turns it into the same clean + // exit(4)/JSON shape as every other pre-dispatch refusal here, instead of + // an uncaught stack trace. + console.error(JSON.stringify({ outcome: "oversized_prompt_refused", requested: entry.id, reason: e.message })); + process.exit(4); + } const now = Math.floor(Date.now() / 1000); // Centralized outcome→state via nextStateAfterOutcome (lib/outcome.js): tracks @@ -1257,9 +1276,13 @@ switch (helpRequested ? "--help" : subcmd) { (--exclude/--exclude-providers cascade to API-key clones: excluding one id drops every entry serving the same model; providers match by family, so \`google\` covers google3..8) (--tier = strict single tier; --tier-prefer = prefer that tier, backfill the other to fill N slots, provider-diverse) - dispatch [--pro] [--json] [--transport generate_new|edit_exists|read_only] [--effort ] [--cwd ] [--require-base ] [--file path[:lines]] "" + dispatch [--pro] [--json] [--transport generate_new|edit_exists|read_only] [--effort ] [--cwd ] [--require-base ] [--file path[:lines]] [--allow-oversized-prompt] "" (exit 4 = refused before anything was spawned: unknown/disabled agent, no escalation candidate, - --require-base mismatch, or a transport the entry does not declare) + --require-base mismatch, a transport the entry does not declare, or a prompt bigger than the + agent's known token-per-minute ceiling) + (--allow-oversized-prompt = send it anyway. Without this, dispatch refuses a prompt pick would + have refused to SEAT — the same declared/observed ceiling \`pick --prompt-bytes\` checks, now also + checked here because a direct dispatch-by-id skips pick entirely.) (--json = one structured {text,outcome,tokens,…} object on stdout; default = text on stdout + trailer on stderr) (--effort = reasoning depth. Use \`high\` for planning, design and review; omit it for mechanical edits and lookups — the provider's own default applies.) diff --git a/lib/dispatch.js b/lib/dispatch.js index 7fd1106..98c1ed9 100644 --- a/lib/dispatch.js +++ b/lib/dispatch.js @@ -8,6 +8,34 @@ import { isAgentEnabled } from "./pick.js"; import { recordFailure, readFailureLogConfig } from "./failure-log.js"; import { appendDispatchRow, readDispatchRows } from "./dispatch-log.js"; import { parseCliUsage } from "./cli-usage.js"; +import { effectiveTokenCeiling } from "./budget.js"; +import { readState } from "./state.js"; + +// Bytes→tokens at 4:1 — the same deliberately crude estimate `pick --prompt-bytes` +// uses (lib/pick.js), so a prompt judged "too big" here is judged the same way +// there. +const PROMPT_BYTES_PER_TOKEN = 4; + +/** + * Would this prompt overflow `agentEntry`'s known token-per-minute ceiling? + * Returns a human-readable refusal reason, or null if it fits (including when + * no ceiling is known at all — silence is not a refusal, see effectiveTokenCeiling). + * + * `pick` has done this check since the registry gained `token_limits` (#51): + * seat only an agent whose declared/observed ceiling can hold the prompt. But + * `dispatch` — reachable directly, by any caller that names an agent id and + * skips pick — never checked it, so a caller that forgets `--prompt-bytes` (or + * never went through pick at all) still gets a live HTTP 413. Two on + * groq-gpt-oss-120b on 2026-09-08/09 asked for 17124 and 22583 tokens against + * an observed ceiling of 8000 that had not moved in days — the ceiling was + * known; nothing before the request had consulted it. + */ +export function oversizedPromptReason(agentEntry, fullPrompt, stateRecord, inheritedLimits, now = Math.floor(Date.now() / 1000)) { + const estimatedTokens = Math.ceil(Buffer.byteLength(fullPrompt || "", "utf-8") / PROMPT_BYTES_PER_TOKEN); + const { tpm } = effectiveTokenCeiling(agentEntry, stateRecord, inheritedLimits, now); + if (tpm == null || tpm >= estimatedTokens) return null; + return `prompt too large for ${agentEntry.id}: ~${estimatedTokens} estimated tokens exceeds its ${tpm}-token ceiling (pick a different agent, shrink the prompt, or pass --allow-oversized-prompt to send it anyway)`; +} export function getTransportConfig(agentEntry, transport) { const value = agentEntry?.transports?.[transport]; @@ -2306,6 +2334,35 @@ export async function runAny(agentEntry, prompt, options = {}) { const fullPrompt = provenanceHeader + fileContext + prompt; emitPromptSizeWarning(fullPrompt, options.progress); + // A second PRE-DISPATCH refusal, same shape as the transport one above: + // `pick --prompt-bytes` has refused to SEAT an oversized prompt since the + // registry gained token_limits (#51), but a direct `dispatch ` — + // by definition skipping pick — was never checked against the same ceiling. + // `options.allowOversizedPrompt` is the deliberate override, mirroring + // --require-base's opt-in-only shape: silence here is "unknown", not "fits". + if (!options.allowOversizedPrompt) { + const reason = oversizedPromptReason( + agentEntry, + fullPrompt, + readState()[agentEntry.id], + options.inheritedTokenLimits, + ); + if (reason) { + const e = new Error(reason); + e.preDispatchRefusal = true; + recordFailure({ + stage: "precheck", + outcome: "refused", + reason, + agent_id: agentEntry?.id, + provider: agentEntry?.provider, + model: agentEntry?.model, + prompt_bytes: Buffer.byteLength(fullPrompt, "utf-8"), + }, { env: redactionEnv(agentEntry, transport) }); + throw e; + } + } + if (transport === "generate_new") { result = await runGenerate(agentEntry, fullPrompt, options); } else if (transport === "read_only") { diff --git a/lib/dispatch.prompt-size.test.js b/lib/dispatch.prompt-size.test.js new file mode 100644 index 0000000..7ae2a83 --- /dev/null +++ b/lib/dispatch.prompt-size.test.js @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { oversizedPromptReason } from "./dispatch.js"; + +// Regression guard for the class of 413 seen on groq-gpt-oss-120b: `pick` +// already refuses to SEAT an oversized prompt (lib/pick.js), but `dispatch` +// (and therefore any caller that names an agent directly, skipping pick) never +// checked the same ceiling before sending. Two live 413s on 2026-09-08/09 +// requested 17124 and 22583 tokens against a seat whose observed tpm ceiling +// was a known, unchanged 8000 the whole time. + +const GROQ_ENTRY = { id: "groq-gpt-oss-120b", provider: "groq", model: "openai/gpt-oss-120b" }; + +test("oversizedPromptReason refuses a prompt bigger than the seat's observed ceiling", () => { + const now = 1_000_000; + const rec = { observed_limits: { tpm: 8000, axis_seen_at: { tpm: now - 10 }, source: "headers" } }; + const bigPrompt = "x".repeat(40_000); // ~10000 estimated tokens at the pool's 4 bytes/token estimate + const reason = oversizedPromptReason(GROQ_ENTRY, bigPrompt, rec, null, now); + assert.match(reason, /groq-gpt-oss-120b/); + assert.match(reason, /8000/); +}); + +test("oversizedPromptReason allows a prompt within the ceiling", () => { + const now = 1_000_000; + const rec = { observed_limits: { tpm: 8000, axis_seen_at: { tpm: now - 10 }, source: "headers" } }; + const smallPrompt = "x".repeat(1000); + assert.equal(oversizedPromptReason(GROQ_ENTRY, smallPrompt, rec, null, now), null); +}); + +test("oversizedPromptReason allows anything when no ceiling is known (declared or observed)", () => { + const hugePrompt = "x".repeat(500_000); + assert.equal(oversizedPromptReason({ id: "mystery" }, hugePrompt, undefined, null, 1_000_000), null); +}); + +test("oversizedPromptReason falls back to inherited (sibling-voice) declared limits", () => { + const entryWithNoOwnLimits = { id: "groq-gpt-oss-120b-3", provider: "groq3", model: "openai/gpt-oss-120b" }; + const inherited = { tpm: 8000 }; + const bigPrompt = "x".repeat(40_000); + const reason = oversizedPromptReason(entryWithNoOwnLimits, bigPrompt, undefined, inherited, 1_000_000); + assert.match(reason, /groq-gpt-oss-120b-3/); +}); + +test("oversizedPromptReason ignores a stale observed ceiling past its TTL, falling back to declared", () => { + // 30 days (OBSERVED_LIMIT_TTL_S) plus a bit — the observation must not be + // trusted forever; a declared ceiling (if any) takes over instead of the + // (absent here) observation silently disappearing into "no ceiling at all". + const now = 1_000_000; + const staleRec = { observed_limits: { tpm: 8000, axis_seen_at: { tpm: now - 31 * 86400 }, source: "headers" } }; + const bigPrompt = "x".repeat(40_000); + assert.equal(oversizedPromptReason(GROQ_ENTRY, bigPrompt, staleRec, null, now), null); +}); diff --git a/lib/pick.inherited-limits.test.js b/lib/pick.inherited-limits.test.js new file mode 100644 index 0000000..c125063 --- /dev/null +++ b/lib/pick.inherited-limits.test.js @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { inheritedTokenLimits } from "./pick.js"; + +// Extracted from pickAgents' own `limitsByVoice` build (lib/pick.js) so +// dispatch.js's oversized-prompt precheck can look up the same +// sibling-inherited token_limits pick already uses, without duplicating the +// voice-collapsing logic pick was fixed to use in #51/#54. + +const registry = { + agents: [ + { id: "groq-gpt-oss-120b", provider: "groq", model: "openai/gpt-oss-120b", token_limits: { tpm: 8000 } }, + { id: "groq-gpt-oss-120b-2", provider: "groq2", model: "openai/gpt-oss-120b" }, // no own limits + { id: "unrelated", provider: "openrouter", model: "some/other-model" }, + ], +}; + +test("inheritedTokenLimits returns the entry's own declared limits when it has them", () => { + const entry = registry.agents[0]; + assert.deepEqual(inheritedTokenLimits(registry, entry), { tpm: 8000 }); +}); + +test("inheritedTokenLimits finds a same-voice sibling's declared limits", () => { + const entry = registry.agents[1]; + assert.deepEqual(inheritedTokenLimits(registry, entry), { tpm: 8000 }); +}); + +test("inheritedTokenLimits returns null when no entry sharing the voice declares limits", () => { + const entry = registry.agents[2]; + assert.equal(inheritedTokenLimits(registry, entry), null); +}); diff --git a/lib/pick.js b/lib/pick.js index f74a112..4d078d2 100644 --- a/lib/pick.js +++ b/lib/pick.js @@ -21,6 +21,19 @@ export const providerFamily = (provider) => String(provider || "").replace(/\d+$ // Qualify it with the family, which is what actually distinguishes them. export const agentVoice = (entry) => `${providerFamily(entry.provider)}::${entry.model || entry.id}`; +// The registry's declared token_limits for `entry`'s voice: its own, or (a +// numbered key declares none of its own) the first same-voice sibling's. Same +// rule pickAgents applies inline for every candidate at once — pulled out so a +// single dispatch can ask the same question about one entry without pick. +export function inheritedTokenLimits(registry, entry) { + if (entry.token_limits) return entry.token_limits; + const voice = agentVoice(entry); + for (const other of registry.agents) { + if (other.token_limits && agentVoice(other) === voice) return other.token_limits; + } + return null; +} + // The operator kill switch, two layers: // 1. Registry `enabled: false` — model disabled by default (e.g. paid-only) // 2. State `enabled === false` — operator toggle from the UI/CLI diff --git a/server.js b/server.js index c25c7b1..f56ab8d 100755 --- a/server.js +++ b/server.js @@ -15,7 +15,7 @@ import { nextStateAfterOutcome, sharedQuotaBucketIds, withObservations, floorExh import { resolveExhaustionResetAt } from "./lib/quota-reset.js"; import { runAny, classifyDispatchFailure, resolveEscalation, getStats } from "./lib/dispatch.js"; import { recordFailure } from "./lib/failure-log.js"; -import { pickAgents, isAgentEnabled } from "./lib/pick.js"; +import { pickAgents, isAgentEnabled, inheritedTokenLimits } from "./lib/pick.js"; // Resolve agents.yaml relative to THIS module, never the process cwd. As an // MCP server, external-agents-mcp is spawned by the client (Codex/Claude) with @@ -162,7 +162,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { "you need to constrain or highlight context. Paths resolve relative to cwd. " + "IMPORTANT: when using files, ALWAYS pass cwd (the repo root) — it serves as the " + "containment basedir for path resolution and security. Without cwd, paths resolve against " + - "the MCP server process cwd, which is likely wrong.", + "the MCP server process cwd, which is likely wrong." + + "\n\nBy default this refuses a prompt larger than agent_id's known token-per-minute ceiling " + + "(the same declared/observed limit pick_agents checks before seating one) instead of sending " + + "it and getting a live HTTP 413. Pick a different (or larger-ceiling) agent_id, or set " + + "allow_oversized_prompt=true to send it anyway.", inputSchema: { type: "object", properties: { @@ -170,6 +174,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { prompt: { type: "string" }, transport: { type: "string", enum: ["generate_new", "edit_exists", "read_only"] }, escalate_to_pro: { type: "boolean" }, + allow_oversized_prompt: { type: "boolean" }, cwd: { type: "string" }, files: { type: "array", @@ -329,7 +334,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { } if (name === "dispatch") { - const { agent_id, prompt, transport, escalate_to_pro, cwd, files } = request.params.arguments; + const { agent_id, prompt, transport, escalate_to_pro, cwd, files, allow_oversized_prompt } = request.params.arguments; if (!agent_id || !prompt) { throw new Error("dispatch: missing agent_id or prompt"); } @@ -377,7 +382,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { [entry.id]: { ...(state[entry.id] || {}), last_used_at: Math.floor(Date.now() / 1000) }, }); - const result = await runAny(entry, prompt, { transport, cwd, files }); + const result = await runAny(entry, prompt, { + transport, + cwd, + files, + inheritedTokenLimits: inheritedTokenLimits(REGISTRY, entry), + allowOversizedPrompt: Boolean(allow_oversized_prompt), + }); const now = Math.floor(Date.now() / 1000); // Shared outcome→state (lib/outcome.js) — escalating cooldown on repeated