Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 29 additions & 6 deletions cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -58,7 +58,7 @@ const REGISTRY = loadRegistry(REGISTRY_PATH);
// taking parser turns `dispatch <id> --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) {
Expand Down Expand Up @@ -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 <agent-id> [--pro] [--json] [--stream] [--transport generate_new|edit_exists|read_only] [--effort <level>] [--cwd <dir>] [--file path[:lines]] \"<prompt>\"", 2);
if (!agentId) die("usage: cli.js dispatch <agent-id> [--pro] [--json] [--stream] [--transport generate_new|edit_exists|read_only] [--effort <level>] [--cwd <dir>] [--file path[:lines]] [--allow-oversized-prompt] \"<prompt>\"", 2);
if (!prompt) die("dispatch: missing prompt", 2);

// --file path[:lines] — repeatable. "src/foo.ts:10-50" → {path, lines}.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <agent-id> [--pro] [--json] [--transport generate_new|edit_exists|read_only] [--effort <level>] [--cwd <dir>] [--require-base <ref>] [--file path[:lines]] "<prompt>"
dispatch <agent-id> [--pro] [--json] [--transport generate_new|edit_exists|read_only] [--effort <level>] [--cwd <dir>] [--require-base <ref>] [--file path[:lines]] [--allow-oversized-prompt] "<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.)
Expand Down
57 changes: 57 additions & 0 deletions lib/dispatch.js
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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 <agent-id>` —
// 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") {
Expand Down
52 changes: 52 additions & 0 deletions lib/dispatch.prompt-size.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
32 changes: 32 additions & 0 deletions lib/pick.inherited-limits.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
13 changes: 13 additions & 0 deletions lib/pick.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 15 additions & 4 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -162,14 +162,19 @@ 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: {
agent_id: { type: "string" },
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",
Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -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
Expand Down
Loading