From a7eb62573f829c5072d1de57b04a18723e0d4430 Mon Sep 17 00:00:00 2001 From: eunwoo song Date: Mon, 7 Sep 2026 05:34:41 +0900 Subject: [PATCH] fix: validate compact budget options --- CHANGELOG.md | 2 ++ README.md | 3 +++ src/index.mjs | 8 ++++++++ test/basic.test.mjs | 18 ++++++++++++++++++ 4 files changed, 31 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51b2cf5..b3ed778 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ - Invalid or negative numeric CLI flags now fail clearly instead of producing `NaN` costs or silently skipping token-budget compaction. +- `compact()` now rejects negative or non-finite token budgets and invalid + protected-turn counts before transforming the payload. ## [0.1.1] - 2026-08-06 diff --git a/README.md b/README.md index dc631c0..7dfdadd 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,9 @@ const { payload, report: cut } = compact(messages, { // cut -> { beforeTokens, afterTokens, savedTokens, savedPct, actions } ``` +Token budgets must be finite, non-negative numbers. `keepLastTurns` must also +be an integer; invalid values throw a `RangeError` before the payload changes. + `payload` is an array of messages, or `{ system, messages }`. Message `content` may be a string or an array of Anthropic-style blocks. ## Accuracy diff --git a/src/index.mjs b/src/index.mjs index f8cc850..31151be 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -57,6 +57,11 @@ export function analyzePayload(payload, { pricePerMTok = 3, counter = estimateTo function clone(x) { return JSON.parse(JSON.stringify(x)); } function tokensToChars(t) { return Math.max(0, Math.round(t * 4)); } +function requireNonNegativeNumber(name, value, { integer = false } = {}) { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || (integer && !Number.isInteger(value))) { + throw new RangeError(`${name} must be a non-negative${integer ? " integer" : " finite number"}`); + } +} // Cut a payload's token cost deterministically. Returns { payload, report }. // Options: @@ -66,6 +71,9 @@ function tokensToChars(t) { return Math.max(0, Math.round(t * 4)); } // keepLastTurns never drop the last N messages when trimming to budget (default 4) export function compact(payload, opts = {}) { const { maxToolResultTokens = 500, dropDuplicates = true, maxTokens = null, keepLastTurns = 4, counter = estimateTokens } = opts; + requireNonNegativeNumber("maxToolResultTokens", maxToolResultTokens); + if (maxTokens != null) requireNonNegativeNumber("maxTokens", maxTokens); + requireNonNegativeNumber("keepLastTurns", keepLastTurns, { integer: true }); const before = analyzePayload(payload, { counter }).totalTokens; const out = clone(payload); const actions = []; diff --git a/test/basic.test.mjs b/test/basic.test.mjs index 98d1bdc..24f213e 100644 --- a/test/basic.test.mjs +++ b/test/basic.test.mjs @@ -74,3 +74,21 @@ test("compact trims a large payload with linear token counting", () => { assert.equal(report.actions.length, 496); assert.ok(counterCalls <= messages.length * 3, `counter called ${counterCalls} times`); }); + +test("compact rejects invalid budgets before transforming the payload", () => { + const payload = { messages: [{ role: "user", content: "keep me" }] }; + const invalidOptions = [ + ["maxToolResultTokens", -1], + ["maxToolResultTokens", Number.POSITIVE_INFINITY], + ["maxTokens", Number.NaN], + ["keepLastTurns", -1], + ["keepLastTurns", 1.5], + ]; + + for (const [name, value] of invalidOptions) { + assert.throws( + () => compact(payload, { [name]: value }), + { name: "RangeError", message: new RegExp(`${name} must be`) }, + ); + } +});