UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

304 lines • 14.6 kB
"use strict"; /** * ContextWindowExceededError — "the request did not fit" as a typed error * that names the fixes (9.6.0). * * Every vendor refuses an over-long request in its own words, and until now * every one of them reached the caller as an opaque provider error: * * [openai] 400 Input tokens exceed the configured limit of 272000 tokens. * Your messages resulted in 879073 tokens. * * That is the message a production field deployment spent a day on. It is a * true sentence and a useless one: it does not say which of the framework's * dials moves that number, and it looks exactly like a transient 400 — so the * natural next move is to retry, which re-sends the same oversized history and * fails identically, forever. * * So the adapters translate it, ONCE, into an error whose message contains the * three fixes in the order they are worth trying, and which carries the two * numbers (limit, actual) when the vendor stated them. * * **Detection is deliberately conservative.** Only unmistakable phrases and * vendor codes translate; everything else passes through untouched as the * provider error it always was. In particular a RATE limit ("rate limit * reached … tokens per min") is a different failure with a different fix and * must never land here — which is why no pattern matches on "too many tokens". * * @see ../core/runCheckpoint.ts where this class makes `resumeOnError` * stop advertising a resume that cannot work */ Object.defineProperty(exports, "__esModule", { value: true }); exports.asContextWindowExceeded = exports.looksLikeContextWindowExceeded = exports.isContextWindowExceeded = exports.ContextWindowExceededError = exports.ERR_CONTEXT_WINDOW_EXCEEDED = void 0; /** Discriminator carried on the error — stable across releases. */ exports.ERR_CONTEXT_WINDOW_EXCEEDED = 'ERR_CONTEXT_WINDOW_EXCEEDED'; /** * Thrown by an LLM adapter when the provider refused the request because the * prompt did not fit the model's context window. * * A refusal, not a failure of the model: nothing was generated and nothing was * charged for output. Retrying the identical request is deterministic — it * fails the same way — so this is one of the classes * {@link canResume} reports as not resumable. * * @example * ```ts * import { ContextWindowExceededError } from 'agentfootprint'; * * try { * await agent.run({ message: 'summarise the fabric inventory' }); * } catch (err) { * if (err instanceof ContextWindowExceededError) { * console.error(`sent ${err.actualTokens} against ${err.limitTokens}`); * // …cap the tool result, or add .window(slidingWindow({ keepRecentTurns: 2 })) * } * } * ``` */ class ContextWindowExceededError extends Error { code = exports.ERR_CONTEXT_WINDOW_EXCEEDED; /** Which adapter refused — `'openai'`, `'anthropic'`, `'bedrock'`, … */ provider; /** The model's (or gateway's) ceiling in tokens, when the vendor said it. */ limitTokens; /** What the request actually came to in tokens, when the vendor said it. */ actualTokens; /** HTTP status, when there was one. Kept so retry policies still see a 4xx. */ status; /** The provider's own error, unchanged. */ cause; constructor(args) { super(buildMessage(args)); this.name = 'ContextWindowExceededError'; this.provider = args.provider; if (args.limitTokens !== undefined) this.limitTokens = args.limitTokens; if (args.actualTokens !== undefined) this.actualTokens = args.actualTokens; if (args.status !== undefined) this.status = args.status; if (args.cause !== undefined) this.cause = args.cause; } } exports.ContextWindowExceededError = ContextWindowExceededError; /** `true` for the typed error, including across a `structuredClone`-free * boundary where `instanceof` still holds. Kept as a function so callers do * not have to import the class to ask the question. */ function isContextWindowExceeded(err) { return (err instanceof ContextWindowExceededError || (err instanceof Error && err.code === exports.ERR_CONTEXT_WINDOW_EXCEEDED)); } exports.isContextWindowExceeded = isContextWindowExceeded; // ─── Detection ─────────────────────────────────────────────────────── /** * The phrases that mean "your request was too big for the context window", * as the four shipped wire vendors actually word them. Every one of these is * unambiguous on its own — which is the bar for being on this list, because a * pattern that also matches a rate limit or a `max_tokens` validation error * would send callers to the wrong fix. * * openai This model's maximum context length is 8192 tokens. However, * your messages resulted in 9000 tokens. * openai (code) context_length_exceeded * gateways Input tokens exceed the configured limit of 272000 tokens. * Your messages resulted in 879073 tokens. * anthropic prompt is too long: 250000 tokens > 200000 maximum * anthropic input length and `max_tokens` exceed context limit: * 195000 + 8192 > 200000 * bedrock Input is too long for requested model. * bedrock (ValidationException carrying either anthropic phrase) * gemini The input token count (1200293) exceeds the maximum number * of tokens allowed (1048576). */ const CONTEXT_PHRASES = [ /context_length_exceeded/i, /maximum context length/i, /prompt is too long/i, /exceed(?:s)? context limit/i, /input (?:is )?too long for (?:the )?requested model/i, /input tokens exceed the configured limit/i, // "input tokens" spelled out on purpose. A gateway saying "exceeds the // maximum allowed number of tokens" with no side named could equally be an // OUTPUT `max_tokens` cap, which has a different fix — and a pattern that // cannot tell those apart sends callers to the wrong one. /exceeds? the maximum (?:allowed )?(?:number of )?input tokens/i, // gemini (9.13.0) — the same claim with the words in the other order, which // is why the pattern above does not catch it. "INPUT token count" is what // keeps it off a `max_tokens` validation error, and Google spells a rate // limit "quota exceeded", so there is no overlap with throttling either. /input token count[^.]{0,80}exceeds the maximum number of tokens allowed/i, ]; /** `"272,000"` → `272000`; anything unparseable → undefined. */ function num(raw) { if (raw === undefined) return undefined; const value = Number(raw.replace(/[,_\s]/g, '')); return Number.isFinite(value) && value > 0 ? value : undefined; } /** Pull `{ limit, actual }` out of the vendor's sentence when it stated them. */ function readNumbers(text) { // anthropic: "prompt is too long: 250000 tokens > 200000 maximum" const tooLong = /prompt is too long:\s*([\d,_]+)\s*tokens?\s*>\s*([\d,_]+)/i.exec(text); if (tooLong) { return { actualTokens: num(tooLong[1]), limitTokens: num(tooLong[2]) }; } // anthropic / bedrock: "input length and `max_tokens` exceed context limit: // 195000 + 8192 > 200000" const sum = /exceed(?:s)? context limit:\s*([\d,_]+)\s*\+\s*([\d,_]+)\s*>\s*([\d,_]+)/i.exec(text); if (sum) { const input = num(sum[1]); const output = num(sum[2]); return { ...(input !== undefined && output !== undefined && { actualTokens: input + output }), ...(num(sum[3]) !== undefined && { limitTokens: num(sum[3]) }), }; } // gemini: "The input token count (1200293) exceeds the maximum number of // tokens allowed (1048576)." Both parentheses are matched loosely because // Google sometimes ships the first one EMPTY — `num('')` is undefined, so a // half-stated sentence yields the half it stated rather than nothing. const geminiPair = /input token count\s*\(([\d,_]*)\)\s*exceeds the maximum number of tokens allowed\s*\(([\d,_]*)\)/i.exec(text); if (geminiPair) { const actual = num(geminiPair[1]); const limit = num(geminiPair[2]); return { ...(actual !== undefined && { actualTokens: actual }), ...(limit !== undefined && { limitTokens: limit }), }; } const limit = num(/maximum context length is\s*([\d,_]+)/i.exec(text)?.[1]) ?? num(/configured limit of\s*([\d,_]+)/i.exec(text)?.[1]) ?? num(/limit of\s*([\d,_]+)\s*tokens/i.exec(text)?.[1]); const actual = num(/resulted in\s*([\d,_]+)\s*tokens/i.exec(text)?.[1]) ?? num(/requested\s*([\d,_]+)\s*tokens/i.exec(text)?.[1]) ?? num(/you (?:sent|submitted|provided)\s*([\d,_]+)\s*tokens/i.exec(text)?.[1]); return { ...(limit !== undefined && { limitTokens: limit }), ...(actual !== undefined && { actualTokens: actual }), }; } /** Every place a vendor hides its sentence, flattened into one string. */ function textOf(err, extraText) { const parts = []; if (typeof extraText === 'string') parts.push(extraText); const seen = new Set(); let node = err; for (let depth = 0; depth < 4 && node !== undefined && node !== null; depth++) { if (seen.has(node)) break; seen.add(node); const record = node; for (const value of [record.message, record.code, record.type, record.name]) { if (typeof value === 'string') parts.push(value); } node = record.error ?? record.cause; } return parts.join(' • '); } /** HTTP status from wherever the SDK put it. */ function statusOf(err) { const record = err; for (const value of [ record?.status, record?.statusCode, record?.$metadata?.httpStatusCode, record?.response?.status, ]) { if (typeof value === 'number' && Number.isFinite(value)) return value; } return undefined; } /** * Does this error SAY it is a context overflow, whoever produced it? * * The same conservative phrase test the adapters translate on, asked without * building anything — for code that has to classify an error it did not * catch at an adapter boundary. A custom `LLMProvider`, a gateway wrapper or * a proxy in front of a vendor produces the vendor's sentence without ever * passing through `wrapError`, and a caller reading that error deserves the * same answer an adapter would have given. * * @see ../core/runCheckpoint.ts — the caller this exists for */ function looksLikeContextWindowExceeded(err, bodyText) { if (isContextWindowExceeded(err)) return true; const text = textOf(err, bodyText); return text.length > 0 && CONTEXT_PHRASES.some((pattern) => pattern.test(text)); } exports.looksLikeContextWindowExceeded = looksLikeContextWindowExceeded; /** * Translate a provider error into {@link ContextWindowExceededError} — or * return `undefined`, which means "not this failure, leave it alone". * * Already-translated errors are returned as they are, so an adapter that * wraps another adapter cannot double-wrap. */ function asContextWindowExceeded(err, options) { if (isContextWindowExceeded(err)) return err; if (!looksLikeContextWindowExceeded(err, options.bodyText)) return undefined; const text = textOf(err, options.bodyText); const numbers = readNumbers(text); // The status the SDK attached, else the one the adapter read off the // response, else a leading `400` the vendor put in the message itself // (several gateways do). Kept because retry policies classify on it, and a // context overflow must never look retryable. const status = statusOf(err) ?? options.status ?? num(/^\s*(4\d\d)\b/.exec(messageOf(err))?.[1]); return new ContextWindowExceededError({ provider: options.provider, providerMessage: messageOf(err) || options.bodyText || text, ...numbers, ...(status !== undefined && { status }), ...(err instanceof Error && { cause: err }), }); } exports.asContextWindowExceeded = asContextWindowExceeded; function messageOf(err) { if (err instanceof Error) return err.message; if (typeof err === 'string') return err; return ''; } // ─── The message ───────────────────────────────────────────────────── function tokens(value) { return value === undefined ? undefined : `${value.toLocaleString('en-US')} tokens`; } /** * The teaching half. Three fixes, cheapest first, and the one thing about * `.compaction()` that is not obvious until it fails: folding a span means * SENDING that span to the summarizer, so compaction cannot rescue a single * round that is already bigger than the window. */ function buildMessage(args) { const sent = tokens(args.actualTokens); const limit = tokens(args.limitTokens); const size = sent && limit ? ` — ${sent} sent against a limit of ${limit}` : limit ? ` — the limit is ${limit}` : sent ? ` — the request came to ${sent}` : ''; return (`[${args.provider}] the request did not fit the context window${size}. ` + `The provider refused it before the model saw it, so nothing was generated ` + `and re-sending the same conversation fails the same way.\n` + ` Fix 1 — cap oversized TOOL RESULTS where they are produced. One raw ` + `database dump returned to the model is the usual whole overrun: return a ` + `summary plus an id the model can ask about, not every row.\n` + ` Fix 2 — .window(slidingWindow({ keepRecentTurns: 2 })) drops older ` + `rounds before the request is assembled, so the conversation stops growing ` + `without bound.\n` + ` Fix 3 — .compaction() keeps a LONG conversation small, but it cannot ` + `fold a span that is already bigger than the window: the summarizer call ` + `sends that span, so it fails exactly as this call did. Fix the oversized ` + `round first, then compact.\n` + ` Provider said: ${args.providerMessage}`); } //# sourceMappingURL=contextWindow.js.map