|
| 1 | +# Franklin 3.15.98 — image-bearing context-token counters across the codebase |
| 2 | + |
| 3 | +*May 12, 2026 · 1 patch release · PR #54 + 3 sibling sites caught in review* |
| 4 | + |
| 5 | +`KillerQueen-Z` filed PR #54 with three context-window display fixes |
| 6 | +and a clean empirical reproduction. While reviewing, I grepped the |
| 7 | +codebase for the same `JSON.stringify(part.content)` pattern and |
| 8 | +found three more sites with the same image-token-inflation bug. |
| 9 | +Landing all six together. |
| 10 | + |
| 11 | +## The bug class, one final time |
| 12 | + |
| 13 | +Any function that handles `tool_result.content` arrays and falls back |
| 14 | +to `JSON.stringify(content)` will tokenize image base64 as text. A |
| 15 | +typical normalized image is ~140KB base64 → ~70K phantom |
| 16 | +chars / ~35K phantom tokens. Anthropic actually bills `(w*h)/750` |
| 17 | +≈ 1100-1500 tokens per image. |
| 18 | + |
| 19 | +We've been fixing this site by site: |
| 20 | + |
| 21 | +| Release | Site | Damage | |
| 22 | +|---|---|---| |
| 23 | +| 3.15.89 | `optimize.ts:budgetToolResults` | trimmed → **destroyed image** | |
| 24 | +| 3.15.90 | `reduce.ts:ageToolResults` (PR #53) | aged → **destroyed image** | |
| 25 | +| 3.15.90 | `reduce.ts:deduplicateToolResultLines` | deduped → **destroyed image** | |
| 26 | +| 3.15.90 | `reduce.ts:collapseRepetitiveTools` | collapsed → **destroyed image** | |
| 27 | +| **3.15.98** | `tokens.ts:estimateContentPartTokens` | inflated /context by 40× | |
| 28 | +| **3.15.98** | `reduce.ts:estimateChars` | inflated → wrong collapse decisions | |
| 29 | +| **3.15.98** | `compact.ts:tool_result preview` | base64 in summary prompt | |
| 30 | +| **3.15.98** | `commands.ts:/context tool char display` | inflated UI char count | |
| 31 | + |
| 32 | +Eight sites total. After this release, every place in the codebase |
| 33 | +that touches `tool_result.content` arrays handles them correctly. |
| 34 | + |
| 35 | +## PR #54 (landed verbatim) |
| 36 | + |
| 37 | +### `tokens.ts:estimateContentPartTokens` — main fix |
| 38 | + |
| 39 | +Empirically verified by the contributor: same 4-message session with |
| 40 | +one ~100KB image showed: |
| 41 | + |
| 42 | +- Before: `/context` = **75K / 200K (37.8%)** |
| 43 | +- After: `/context` = **1.9K / 200K (1.0%)** |
| 44 | + |
| 45 | +That's a 40× over-count. It also triggered premature `/compact` |
| 46 | +calls — agent saw 37% "context fullness" on a session that was 1% |
| 47 | +full, fired bloat compactions that weren't needed, burned tokens |
| 48 | +unnecessarily. |
| 49 | + |
| 50 | +The fix walks the content array block-by-block. Text blocks count as |
| 51 | +text. Image blocks count as 1500 tokens (flat). Unknown block types |
| 52 | +still stringify, but with `source.data` redacted to `<bytes>` so |
| 53 | +future block kinds (audio? video?) don't regress. |
| 54 | + |
| 55 | +### `getAnchoredTokenCount` — `contextUsagePct: 0` always |
| 56 | + |
| 57 | +Both return paths of this function hardcoded the field. The agent |
| 58 | +loop emits this via `kind: 'usage'` events to the renderer, so the |
| 59 | +desktop/extension's context ring was stuck at 0% regardless of how |
| 60 | +full the context actually was. |
| 61 | + |
| 62 | +Fix: compute `(estimated / contextWindow) * 100` using the current |
| 63 | +model's window from `getContextWindow(_currentModel)`. |
| 64 | + |
| 65 | +### `loop.ts` — integer rounding froze the ring |
| 66 | + |
| 67 | +```ts |
| 68 | +contextPct: Math.round(contextUsagePct), |
| 69 | +``` |
| 70 | + |
| 71 | +A 200-message session at 0.4% rounded to 0 and froze the renderer. |
| 72 | +Now `Math.round(contextUsagePct * 10) / 10` keeps one decimal. |
| 73 | + |
| 74 | +## Sibling sites (caught during PR #54 review) |
| 75 | + |
| 76 | +### `reduce.ts:estimateChars` |
| 77 | + |
| 78 | +This function gates `reduceTokens`'s passes (dedupe, collapse, |
| 79 | +normalize). When an image inflates the char count by ~140K, the |
| 80 | +reduce decisions trigger aggressive collapsing — including the |
| 81 | +image-bearing tool_result, which (because of the 3.15.90 array-aware |
| 82 | +fix) survives the collapse but only after needlessly burning the |
| 83 | +reduce pass. |
| 84 | + |
| 85 | +Fix walks blocks: text blocks count text length; image blocks count |
| 86 | +~6000 chars (the char-equivalent of 1500 tokens at the 4-chars/token |
| 87 | +rule). |
| 88 | + |
| 89 | +### `compact.ts:tool_result preview` |
| 90 | + |
| 91 | +When the agent's summarizer needs to compress old turns, it builds a |
| 92 | +preview of each tool_result for the summary prompt: |
| 93 | + |
| 94 | +```ts |
| 95 | +const content = typeof part.content === 'string' |
| 96 | + ? part.content |
| 97 | + : JSON.stringify(part.content); |
| 98 | +const truncated = content.length > 500 ? content.slice(0, 500) + '...' : content; |
| 99 | +textParts.push(`[Tool result: ${truncated}]`); |
| 100 | +``` |
| 101 | + |
| 102 | +For an image-bearing result, `JSON.stringify` produces a string that |
| 103 | +starts with `[{"type":"text","text":"..."},{"type":"image","source":{"type":"base64","data":"AAA...`. |
| 104 | +Slicing to 500 chars gives the summarizer a useless preview of base64 |
| 105 | +garbage. |
| 106 | + |
| 107 | +Fix builds the preview from text blocks only, then appends `[N image |
| 108 | +block(s)]` to mark their presence: |
| 109 | + |
| 110 | +```ts |
| 111 | +const pieces: string[] = []; |
| 112 | +let imageCount = 0; |
| 113 | +for (const block of part.content) { |
| 114 | + if (block.type === 'text') pieces.push(block.text); |
| 115 | + else if (block.type === 'image') imageCount++; |
| 116 | +} |
| 117 | +if (imageCount > 0) pieces.push(`[${imageCount} image block(s)]`); |
| 118 | +``` |
| 119 | + |
| 120 | +The summarizer now sees `[Tool result: Image file: /tmp/scene.png [1 image block]]` |
| 121 | +instead of 500 chars of base64. |
| 122 | + |
| 123 | +### `commands.ts:/context tool char count` |
| 124 | + |
| 125 | +`/context` displays "Total tool result chars: X" alongside the token |
| 126 | +estimate. Pre-fix, X included the base64 bytes — so a user with the |
| 127 | +fixed token count seeing `/context = 1.9K/200K (1.0%)` would also see |
| 128 | +"Total tool result chars: 142,847" and be confused. Now the char |
| 129 | +count walks blocks the same way. |
| 130 | + |
| 131 | +## Tests |
| 132 | + |
| 133 | +Three new in `test/local.mjs`: |
| 134 | + |
| 135 | +1. **`estimateContentPartTokens: image block counts as ~1500 tokens, not |
| 136 | + base64 char length`** — pin the main PR #54 fix against a 140KB |
| 137 | + synthetic image. Asserts result is `< 3000` tokens and `> 1000` |
| 138 | + (not silently zero either). |
| 139 | +2. **`estimateContentPartTokens: text-only string content path |
| 140 | + unchanged`** — 4000-char string body → ~2000 tokens (within ±25%). |
| 141 | + Guards against regression in the simple path. |
| 142 | +3. **`estimateChars (reduce.ts): image blocks count as ~6K chars, not |
| 143 | + base64 length`** — build a 12-message history with one image |
| 144 | + carrying 140KB base64, run `reduceTokens`, assert the image base64 |
| 145 | + survives. Pre-fix, the inflated char count triggered aggressive |
| 146 | + collapse that would have destroyed the image. |
| 147 | + |
| 148 | +387/387 tests pass. |
| 149 | + |
| 150 | +## What didn't change |
| 151 | + |
| 152 | +- **Wallet billing**: unchanged — the gateway has its own (working) |
| 153 | + image accounting and uses its own input estimate. PR #54 explicitly |
| 154 | + notes this. |
| 155 | +- **3.15.95's `cacheCreationInputTokens` / `cacheReadInputTokens` |
| 156 | + capture** — independent, complementary fix for wallet-truth |
| 157 | + accounting (different layer). |
| 158 | +- **The `Read` tool's `sharp` normalization** (3.15.90) is unchanged. |
| 159 | + It caps long-edge at 1280px which is why "image ≈ 1500 tokens flat" |
| 160 | + is a good estimate. |
| 161 | + |
| 162 | +## Credits |
| 163 | + |
| 164 | +`KillerQueen-Z` (PR #54) — empirical reproduction (40× discrepancy |
| 165 | +on a real session) and the clean three-part fix. Same contributor as |
| 166 | +PR #53 (vision token explosion). Two sharp diagnostics in a row. |
| 167 | + |
| 168 | +## Behavioral implications |
| 169 | + |
| 170 | +After this release: |
| 171 | + |
| 172 | +- `/context` shows the actual context fullness on image-bearing |
| 173 | + sessions. Pre-fix, it could read 37% on a 1% session. |
| 174 | +- The desktop/extension context ring updates correctly. Pre-fix, it |
| 175 | + was stuck at 0% regardless of fullness. |
| 176 | +- Compaction triggers fire on real fullness, not on image-token |
| 177 | + inflation. Fewer spurious `/compact` events on vision workflows. |
| 178 | +- The summarizer (when compaction does fire) sees text previews |
| 179 | + marked with image counts instead of base64 garbage. Marginally |
| 180 | + better summaries, marginally lower summary-call costs. |
| 181 | + |
| 182 | +If you've been seeing `/context` numbers that don't match your gut |
| 183 | +sense of session length — they should match now. |
0 commit comments