Train 1D: merge via --admin on .113 validation

Squash merge from local merge-train (Hard Rule owner-approved). Tip 029cdf4215cf465f0e1716ac9f84a84692b1e881 validated on 192.168.0.113: 26631/26653 pass.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-27 11:30:44 -03:00
committed by GitHub
parent 899d19881f
commit f9899c57ca
2 changed files with 105 additions and 5 deletions

View File

@@ -305,7 +305,11 @@ export function compressContext(
const targetTokens = Math.max(0, maxTokens - reserveTokens);
let messages = [...body.messages];
let currentTokens = estimateTokens(JSON.stringify(messages));
// #8594: pass the structured messages array directly — estimateTokens walks it for
// inline base64 image blocks (#8368) and substitutes a bounded per-image estimate.
// JSON.stringify()-ing first forces the char/4 text path and mis-measures a ~500KB
// image as ~125k tokens, triggering needless compression / context loss.
let currentTokens = estimateTokens(messages);
const stats = { original: currentTokens, layers: [] as { name: string; tokens: number }[] };
// Already fits
@@ -315,7 +319,7 @@ export function compressContext(
// Layer 1: Trim tool_result/tool messages
messages = trimToolMessages(messages, 2000); // Max 2000 chars per tool result
currentTokens = estimateTokens(JSON.stringify(messages));
currentTokens = estimateTokens(messages); // #8594: object-path keeps the #8368 image estimate
stats.layers.push({ name: "trim_tools", tokens: currentTokens });
if (currentTokens <= targetTokens) {
@@ -328,7 +332,7 @@ export function compressContext(
// Layer 2: Compress structured thinking blocks (remove from non-last assistant messages)
messages = compressThinking(messages);
currentTokens = estimateTokens(JSON.stringify(messages));
currentTokens = estimateTokens(messages); // #8594: object-path keeps the #8368 image estimate
stats.layers.push({ name: "compress_thinking", tokens: currentTokens });
if (currentTokens <= targetTokens) {
@@ -341,7 +345,7 @@ export function compressContext(
// Layer 3: Aggressive purification — drop oldest messages keeping system + last N pairs
messages = purifyHistory(messages, targetTokens);
currentTokens = estimateTokens(JSON.stringify(messages));
currentTokens = estimateTokens(messages); // #8594: object-path keeps the #8368 image estimate
stats.layers.push({ name: "purify_history", tokens: currentTokens });
return {
@@ -427,7 +431,9 @@ function purifyHistory(messages: Record<string, unknown>[], targetTokens: number
// orphan tool_results that Claude rejects ("tool_result without preceding tool_use").
candidate = fixToolPairs(candidate);
candidate = stripTrailingAssistantOrphanToolUse(candidate);
const tokens = estimateTokens(JSON.stringify(candidate));
// #8594: measure the candidate structure directly so image-bearing turns are not
// over-counted and pruned during the binary search.
const tokens = estimateTokens(candidate);
if (tokens <= targetTokens) break;
keep = Math.max(2, Math.floor(keep * 0.7)); // Drop 30% each iteration
}

View File

@@ -0,0 +1,94 @@
import test from "node:test";
import assert from "node:assert/strict";
import { compressContext } from "../../open-sse/services/contextManager.ts";
// #8594 — six production call sites (compressContext layers + purifyHistory binary
// search + the combo.ts fallback threshold) pre-JSON.stringify the messages before
// handing them to estimateTokens(). Stringifying forces the char/4 text path and
// undoes the #8368 inline-base64-image bounded estimate, so an image-bearing request
// that fits comfortably is mis-measured as ~100× larger and compressed needlessly.
function makeFakePngBase64(approxBytes: number): string {
return Buffer.alloc(approxBytes, 65).toString("base64");
}
test("#8594: compressContext does NOT compress a within-limit inline-image request", () => {
// ~500KB image => ~666KB base64. As raw text that is ~166k tokens (over the limit);
// as a bounded image estimate it is ~1.2k tokens (well under). maxTokens=20000 sits
// between the two, so the bug (stringify) trips compression and the fix does not.
const base64 = makeFakePngBase64(500_000);
const body = {
model: "gpt-image-vision",
messages: [
{ role: "system", content: "You are a helpful vision assistant." },
{
role: "user",
content: [
{ type: "text", text: "What is in this image?" },
{ type: "image_url", image_url: { url: `data:image/png;base64,${base64}` } },
],
},
],
};
const result = compressContext(body, { maxTokens: 20000, reserveTokens: 0 });
assert.equal(
result.compressed,
false,
`BUG #8594 reproduced: within-limit image request was compressed. ` +
`stats.original=${(result.stats as { original?: number }).original} tokens ` +
`(should be a bounded ~1.2k image estimate, not the ~166k base64-as-text value)`
);
assert.ok(
(result.stats as { original: number }).original < 5000,
`expected bounded image-token estimate (<5000), got ${(result.stats as { original: number }).original}`
);
});
test("#8594: purifyHistory keeps all image-bearing turns when they fit within the limit", () => {
// Multiple image messages that, measured correctly, fit under the limit. The bug
// makes the Layer-3 binary search over-estimate and prune turns; the fix preserves them.
const base64 = makeFakePngBase64(200_000);
const imageTurn = (n: number) => ({
role: "user",
content: [
{ type: "text", text: `Image number ${n}` },
{ type: "image_url", image_url: { url: `data:image/png;base64,${base64}` } },
],
});
const body = {
model: "gpt-image-vision",
messages: [
{ role: "system", content: "vision" },
imageTurn(1),
{ role: "assistant", content: "ok 1" },
imageTurn(2),
{ role: "assistant", content: "ok 2" },
imageTurn(3),
{ role: "assistant", content: "ok 3" },
],
};
const originalCount = body.messages.length;
const result = compressContext(body, { maxTokens: 20000, reserveTokens: 0 });
assert.equal(result.compressed, false, "within-limit image history must not be compressed");
assert.equal(
(result.body as { messages: unknown[] }).messages.length,
originalCount,
"all image-bearing turns must be retained when they fit"
);
});
test("#8594: control — an oversized text request is still compressed (no regression)", () => {
const body = {
model: "gpt-text",
messages: [
{ role: "system", content: "s" },
{ role: "user", content: "a".repeat(200_000) }, // ~50k tokens, over a 20k limit
],
};
const result = compressContext(body, { maxTokens: 20000, reserveTokens: 0 });
assert.equal(result.compressed, true, "oversized text must still trigger compression");
});