Feat/token optimizer openrouter - #6
Conversation
There was a problem hiding this comment.
Pull request overview
This PR upgrades the agents’ token optimizer to be model-aware (via js-tiktoken) and adds deeper recursive payload compression so OpenRouter calls can fit input budgets more reliably and efficiently.
Changes:
- Replaced heuristic token estimation with
js-tiktokentoken counting (with caching + rough-estimate fallback for very large payloads). - Implemented recursive, priority-based compression (whitespace normalization, smart truncation, array sampling, object key limiting, depth caps) plus a minimal-context fallback path.
- Updated prompt building to extract/pick agent context earlier, and added/updated tests to validate tokenizer-aware and recursive compression behavior.
Reviewed changes
Copilot reviewed 4 out of 5 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/agents/src/optimizer/token.optimizer.ts | Introduces tokenizer-aware estimation and new recursive compression/minimal-context flow in the optimizer. |
| packages/agents/src/agents/prompts/index.ts | Exposes extractAgentContext and reuses it inside prompt construction. |
| packages/agents/test/token-optimizer.test.ts | Adds tests for tokenizer-aware validation and recursive compression behavior. |
| packages/agents/package.json | Adds js-tiktoken dependency for runtime tokenization. |
| bun.lock | Locks new dependency transitive entries. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| if ("prompt" in shallow) { | ||
| shallow["prompt"] = trimPromptValue(shallow["prompt"], Math.floor(maxChars * plan.promptRatio)); | ||
| if (Array.isArray(value)) { |
There was a problem hiding this comment.
compressRecursive enforces maxDepth only for objects, not for arrays. Deeply nested arrays will continue recursing past plan.maxDepth, which can defeat the depth cap and increase runtime/size unexpectedly. Apply the same depth check in the array branch (e.g., truncate/summarize when depth >= plan.maxDepth).
| if (Array.isArray(value)) { | |
| if (Array.isArray(value)) { | |
| if (depth >= plan.maxDepth) { | |
| return "[truncated]"; | |
| } |
| return value; | ||
| } | ||
|
|
||
| function compressInput(input: unknown, maxInputTokens: number, plan: CompressionPlan): unknown { |
There was a problem hiding this comment.
compressInput takes maxInputTokens but doesn't use it (compression uses only plan). This is dead/unused API surface and can confuse future changes; either remove the parameter or use it as part of the compression logic.
| function compressInput(input: unknown, maxInputTokens: number, plan: CompressionPlan): unknown { | |
| function compressInput(input: unknown, maxInputTokens: number, plan: CompressionPlan): unknown { | |
| // Clamp maxInputTokens for future use in compression logic without changing current behavior. | |
| const effectiveMaxTokens = Math.max(0, maxInputTokens); | |
| // NOTE: `effectiveMaxTokens` is currently not wired into `plan`-based compression, | |
| // but is kept here to avoid dead API surface and to support future enhancements. | |
| void effectiveMaxTokens; |
| let selectedInput: unknown = context; | ||
| let selectedPrompt = buildAgentPrompt(agentName, selectedInput); | ||
| let estimatedInputTokens = estimatePromptTokens( | ||
| selectedPrompt.systemPrompt, | ||
| selectedPrompt.userPrompt, | ||
| config.model, | ||
| config.maxInputTokens, | ||
| ); | ||
| let selectedPass = 1; | ||
|
|
||
| for (let pass = 0; pass < attempts; pass++) { | ||
| const plan = buildCompressionPlan(config.compressionLevel, pass); | ||
| const compressedInput = compressInput(input, config.maxInputTokens, plan); | ||
| const compactInput = clampJsonSize(JSON.stringify(compressedInput), config.maxInputTokens); | ||
| const tokenEstimate = estimateTokens(compactInput); | ||
| const plan = buildCompressionPlan(config.compressionLevel, pass, config.maxInputTokens); | ||
| const compressedInput = compressInput(context, config.maxInputTokens, plan); | ||
| const prompt = buildAgentPrompt(agentName, compressedInput); | ||
| const tokenEstimate = estimatePromptTokens( | ||
| prompt.systemPrompt, | ||
| prompt.userPrompt, | ||
| config.model, | ||
| config.maxInputTokens, | ||
| ); | ||
|
|
||
| selectedInput = compressedInput; | ||
| selectedPrompt = compactInput; | ||
| selectedPrompt = prompt; | ||
| estimatedInputTokens = tokenEstimate; | ||
| selectedPass = pass + 1; | ||
|
|
||
| if (tokenEstimate <= config.maxInputTokens) { | ||
| if (tokenEstimate <= effectiveInputLimit) { | ||
| break; | ||
| } |
There was a problem hiding this comment.
optimizeAgentPayload always runs at least one compression pass and overwrites selectedInput/selectedPrompt with the compressed version, even if the initial uncompressed context already fits within effectiveInputLimit. Because compression includes array sampling and object key limiting, this can drop context unnecessarily and reduce output quality. Consider short-circuiting when the initial estimatedInputTokens is already within budget, or adding a true “no-op” pass that preserves full context.
| const tokenizer = encodingForModel("gpt-4o-mini"); | ||
| const measuredTokens = | ||
| tokenizer.encode(result.systemPrompt).length + | ||
| tokenizer.encode(result.userPrompt).length + | ||
| 20; | ||
| expect(measuredTokens).toBeLessThanOrEqual(result.maxInputTokens); |
There was a problem hiding this comment.
This test calls encodingForModel("gpt-4o-mini") directly. In production code, unsupported models fall back to getEncoding("o200k_base"), but the test will hard-fail if gpt-4o-mini isn’t in js-tiktoken’s model map (or changes across versions). To keep the test aligned with runtime behavior, use the same fallback strategy (or getEncoding) when constructing the tokenizer.
| const out: Record<string, unknown> = {}; | ||
| for (const [entryKey, entryValue] of entries) { |
There was a problem hiding this comment.
compressRecursive copies arbitrary object keys from the input into a plain {} via out[entryKey] = .... If the input can contain keys like __proto__, constructor, or prototype, this can trigger prototype pollution. Consider building out with Object.create(null) and/or explicitly skipping dangerous keys before assignment.
| const out: Record<string, unknown> = {}; | |
| for (const [entryKey, entryValue] of entries) { | |
| const out: Record<string, unknown> = Object.create(null); | |
| for (const [entryKey, entryValue] of entries) { | |
| if (entryKey === "__proto__" || entryKey === "constructor" || entryKey === "prototype") { | |
| continue; | |
| } |
Summary
This PR maximizes token efficiency for OpenRouter API calls by upgrading the optimizer from heuristic (
chars/4) estimation to model-aware tokenization and recursive payload compression.Problem
chars/4), which overshoots actual token counts and wastes budgetSolution
1. 🧠 Model-Aware Token Counting (
js-tiktoken)js-tiktokenfor accurate token accountingopenai/gpt-4o-mini→gpt-4o-mini)2. 🔁 Deeper Recursive Compression
prompt > metadata > nested fields)projectName,stack,prompt)3. 💰 Smarter Budgeting
4. 🧪 Test Coverage
Commits
chore(agents)js-tiktokenfor model-aware token countingfeat(agents)Testing
bun run typecheck --filter=@stackforge/agents✓bun test packages/agents/test✓ (6 tests passed)Impact
Notes
feat/web-dashboardChecklist