Skip to content

fix(context): cap tokenizer input to prevent quadratic hang - #358

Open
harsh20048 wants to merge 1 commit into
Conway-Research:mainfrom
harsh20048:fix/tokenizer-quadratic-hang
Open

fix(context): cap tokenizer input to prevent quadratic hang#358
harsh20048 wants to merge 1 commit into
Conway-Research:mainfrom
harsh20048:fix/tokenizer-quadratic-hang

Conversation

@harsh20048

Copy link
Copy Markdown

Problem

estimateTokens() in src/agent/context.ts passes unbounded text to the js-tiktoken BPE encoder. BPE cost is superlinear in the length of an unbroken run of a single character, so pathological input stalls the event loop indefinitely.

Same length, different content (cl100k_base):

chars repeated "x" prose
5,000 1,218 ms 1 ms
20,000 18,385 ms 3 ms
50,000 113,194 ms 6 ms

Normal text is completely unaffected — this only bites on long unbroken runs.

It hangs the test suite

src/__tests__/context-hardening.test.ts:166 builds a 500,000-char string, which extrapolates to hours. vitest's 30s testTimeout cannot fire, because the spin is synchronous and blocks the event loop. vitest run therefore never terminates — one worker sits at ~100% CPU indefinitely.

It is reachable in production

estimateTurnTokens() tokenises tc.result before truncateToolResult() is applied, and the exec tool returns stdout uncapped (src/agent/tools.ts:143). A base64 blob, minified asset, or long separator bar in tool output can freeze the agent loop.

Fix

Sample a 2,000-char prefix and scale linearly, keeping the existing Math.max() floor against the character heuristic.

  • Worst case: unbounded → ~187 ms
  • Measured error on realistic text: 0.2% (12,003 actual vs 12,030 estimated over 60k chars)

Verification

  • src/__tests__/context-hardening.test.ts: hangs indefinitely → 29 passed in 1.14s
  • Full suite: 64 files / 1,643 tests pass, and now actually completes (~174s)
  • No new tests needed — the existing suite already covered this; it just could never finish

Reproduction:

const { getEncoding } = require("js-tiktoken");
const enc = getEncoding("cl100k_base");
console.time("50k"); enc.encode("x".repeat(50_000)); console.timeEnd("50k");

🤖 Generated with Claude Code

estimateTokens() passes unbounded text to the js-tiktoken BPE encoder.
BPE cost is superlinear in the length of an unbroken run of a single
character, so pathological input stalls the event loop indefinitely.

Measured on Node 22 (cl100k_base), same length, different content:

  chars   repeated "x"   prose
  5,000        1,218ms     1ms
  20,000      18,385ms     3ms
  50,000     113,194ms     6ms

Real text is unaffected; a long unbroken run is not. This is reachable
in production: estimateTurnTokens() tokenises tc.result before
truncateToolResult() is applied, and the exec tool returns stdout
uncapped, so a base64 blob, minified asset or separator bar in tool
output can freeze the agent loop.

It also hangs the test suite. context-hardening.test.ts builds a
500,000-char string, which extrapolates to hours; vitest's 30s
testTimeout cannot fire because the spin is synchronous and blocks the
event loop. `vitest run` therefore never terminates.

Fix: sample a 2,000-char prefix and scale linearly, keeping the existing
Math.max() floor against the character heuristic. Worst case drops from
unbounded to ~187ms; measured error on realistic text is 0.2%.

src/__tests__/context-hardening.test.ts: hangs indefinitely -> 29 passed
in 1.14s. Full suite: 64 files / 1643 tests pass, and now completes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant