feat(coding-agent): compacted oversized sse payloads in debug buffer

- Added tool schema and description compaction for oversized data payloads in `packages/coding-agent/src/debug/raw-sse-buffer.ts`.
- Implemented head-tail trimming to preserve leading and trailing event fields when events exceed character budgets.
- Added test coverage verifying SSE debug event truncation, elision markers, and JSON-safe tool compaction behavior.
This commit is contained in:
can1357
2026-07-24 15:19:23 +02:00
parent 27e019981a
commit c6dcfa4137
4 changed files with 206 additions and 31 deletions
+1 -1
View File
@@ -276,7 +276,7 @@ Example `.omp/dap.json`:
- Raw SSE buffer caps in `packages/coding-agent/src/debug/raw-sse-buffer.ts`:
- `MAX_RAW_SSE_EVENTS = 1_000`
- `MAX_RAW_SSE_CHARS = 512_000`
- `MAX_RAW_SSE_EVENT_CHARS = 64_000` per event, with `: omp-debug-truncated ...` marker appended on trim
- `MAX_RAW_SSE_EVENT_CHARS = 64_000` per event; over-budget events first get `tools` schemas compacted (name kept, schema/description elided), then a head+tail trim that keeps the first and last portions with a `: omp-debug-elided chars=...` comment in the middle and a final `: omp-debug-truncated originalChars=...` marker
- Log viewer window in `packages/coding-agent/src/debug/log-viewer.ts`:
- `INITIAL_LOG_CHUNK = 50`
- `LOAD_OLDER_CHUNK = 50`
+1
View File
@@ -10,6 +10,7 @@
### Changed
- Large pastes saved via the large-paste menu now insert `local://paste-N.md` references (previously `local://attachment-N`), so the saved paste carries a markdown extension and a clearer name.
- Raw SSE debug capture now trims over-budget events smartly instead of chopping off the tail: tool definitions inside `data:` payloads are compacted first (name kept, schema/description elided — often enough to keep the whole payload as valid JSON), and anything still over the 64k cap keeps its head and tail with a `: omp-debug-elided chars=N` comment marking the removed middle, so trailing fields like `usage` stay visible.
### Removed
+157 -30
View File
@@ -3,6 +3,13 @@ import type { Model, ProviderResponseMetadata, RawSseEvent } from "@oh-my-pi/pi-
const MAX_RAW_SSE_EVENTS = 1_000;
const MAX_RAW_SSE_CHARS = 512_000;
const MAX_RAW_SSE_EVENT_CHARS = 64_000;
// Reserve room for the `: omp-debug-truncated` / `: omp-debug-elided` marker
// lines so a trimmed event stays within MAX_RAW_SSE_EVENT_CHARS overall.
const TRIM_MARKER_RESERVE = 200;
// Caps applied to individual tool entries when compacting a `tools` array
// inside an oversized `data:` payload.
const MAX_TOOL_SCHEMA_CHARS = 200;
const MAX_TOOL_DESCRIPTION_CHARS = 200;
export type RawSseDebugRecord =
| {
@@ -45,42 +52,162 @@ export interface RawSseDebugSnapshot {
// path. The parallel array keeps records as plain monomorphic objects.
type TrimResult = { raw: string[]; truncated: boolean; originalChars: number; chars: number };
// Single-pass trim. Returns the final `chars` count using the historical
// formula `reduce(line.length + 1, init = 1)` so the new accounting matches
// the previous `countRecordChars` byte-for-byte (the trailing +1 covers the
// record-level newline that `rawRecordText` appends in `toRawText`).
//
// When the event fits within budget the input `raw` array is returned
// **by reference** — see the ownership contract documented at
// `RawSseDebugBuffer.recordEvent` below.
function trimRawLines(raw: string[]): TrimResult {
let originalChars = 0;
for (let i = 0; i < raw.length; i++) originalChars += raw[i].length + 1;
// `chars` uses the historical formula `reduce(line.length + 1, init = 1)` so
// the accounting matches the previous `countRecordChars` byte-for-byte (the
// trailing +1 covers the record-level newline that `rawRecordText` appends in
// `toRawText`).
function countLines(lines: readonly string[]): number {
let chars = 0;
for (let i = 0; i < lines.length; i++) chars += lines[i].length + 1;
return chars;
}
function elideText(text: string, max: number): string {
if (text.length <= max) return text;
return `${text.slice(0, max)}… (+${text.length - max} chars)`;
}
// Shrinks one tool definition in place: schemas (`parameters` for OpenAI
// shapes, `input_schema` for Anthropic) become elided JSON strings and long
// descriptions are cut, while `name`/`type` survive untouched. Chat-completions
// nests the payload under `function`.
function compactToolEntry(tool: unknown): boolean {
if (typeof tool !== "object" || tool === null) return false;
const obj = tool as Record<string, unknown>;
let changed = false;
if (typeof obj.function === "object" && obj.function !== null) {
changed = compactToolEntry(obj.function);
}
for (const key of ["parameters", "input_schema"]) {
const schema = obj[key];
if (schema === undefined || schema === null) continue;
const text = typeof schema === "string" ? schema : JSON.stringify(schema);
if (text.length <= MAX_TOOL_SCHEMA_CHARS) continue;
obj[key] = elideText(text, MAX_TOOL_SCHEMA_CHARS);
changed = true;
}
if (typeof obj.description === "string" && obj.description.length > MAX_TOOL_DESCRIPTION_CHARS) {
obj.description = elideText(obj.description, MAX_TOOL_DESCRIPTION_CHARS);
changed = true;
}
return changed;
}
// Walks a parsed SSE payload and compacts every `tools` array it finds
// (e.g. `response.tools` echoed back by the Responses API). Mutates `node`.
function compactToolsDeep(node: unknown): boolean {
if (Array.isArray(node)) {
let changed = false;
for (const item of node) changed = compactToolsDeep(item) || changed;
return changed;
}
if (typeof node !== "object" || node === null) return false;
let changed = false;
const obj = node as Record<string, unknown>;
for (const key in obj) {
const value = obj[key];
if (key === "tools" && Array.isArray(value)) {
for (const tool of value) changed = compactToolEntry(tool) || changed;
} else {
changed = compactToolsDeep(value) || changed;
}
}
return changed;
}
// Rewrites oversized `data:` lines with tool schemas compacted. Returns null
// when nothing changed (unparseable payloads or no tools to shrink). Only
// invoked on events that already blew the budget, so the JSON round-trip is
// off the streaming hot path.
function compactToolLines(raw: readonly string[]): string[] | null {
let changed = false;
const out = raw.map(line => {
if (!line.startsWith("data:") || line.length <= MAX_TOOL_SCHEMA_CHARS) return line;
const start = line.charCodeAt(5) === 32 ? 6 : 5;
try {
const parsed = JSON.parse(line.slice(start));
if (!compactToolsDeep(parsed)) return line;
changed = true;
return `data: ${JSON.stringify(parsed)}`;
} catch {
return line;
}
});
return changed ? out : null;
}
// Keeps the first and last portions of an over-budget event and drops the
// middle, so leading fields (id/model/status) AND trailing fields
// (usage/finish_reason) both stay visible. A `: omp-debug-elided` comment
// marks the cut; split lines carry `…` at the cut edge.
function headTailTrim(lines: string[], budget: number, elidedTotal: number): string[] {
const headBudget = budget >> 1;
const tailBudget = budget - headBudget;
let i = 0;
let headRemaining = headBudget;
const out: string[] = [];
while (i < lines.length && lines[i].length + 1 <= headRemaining) {
headRemaining -= lines[i].length + 1;
out.push(lines[i]);
i++;
}
let j = lines.length - 1;
let tailRemaining = tailBudget;
const tail: string[] = [];
while (j >= i && lines[j].length + 1 <= tailRemaining) {
tailRemaining -= lines[j].length + 1;
tail.push(lines[j]);
j--;
}
tail.reverse();
let elided = elidedTotal - countLines(out) - countLines(tail);
if (i <= j) {
// lines[i..j] straddle the cut: keep a head slice of the first and a
// tail slice of the last (the same line when i === j).
const headSlice = lines[i].slice(0, Math.max(0, headRemaining - 2));
const tailStart =
i === j
? Math.max(headSlice.length, lines[j].length - tailRemaining + 2)
: Math.max(0, lines[j].length - tailRemaining + 2);
const tailSlice = lines[j].slice(tailStart);
elided -= headSlice.length + tailSlice.length;
if (headSlice.length > 0) out.push(`${headSlice}…`);
out.push(`: omp-debug-elided chars=${Math.max(0, elided)}`);
if (tailSlice.length > 0) out.push(`…${tailSlice}`);
} else if (elided > 0) {
out.push(`: omp-debug-elided chars=${elided}`);
}
out.push(...tail);
return out;
}
// Trim pipeline for one SSE event:
// 1. fits → return `raw` **by reference** (ownership contract at
// `RawSseDebugBuffer.recordEvent` below).
// 2. over budget → compact tool schemas inside `data:` JSON payloads;
// if that alone fits, the payload stays parseable JSON.
// 3. still over → head+tail trim (middle elided).
// Any trimmed result ends with the `: omp-debug-truncated` marker carrying
// the original size.
function trimRawLines(raw: string[]): TrimResult {
const originalChars = countLines(raw);
if (originalChars <= MAX_RAW_SSE_EVENT_CHARS) {
return { raw, truncated: false, originalChars, chars: originalChars + 1 };
}
const trimmed: string[] = [];
let remaining = MAX_RAW_SSE_EVENT_CHARS;
let chars = 1; // matches reduce(.., init = 1)
for (const line of raw) {
if (remaining <= 0) break;
if (line.length + 1 <= remaining) {
trimmed.push(line);
chars += line.length + 1;
remaining -= line.length + 1;
continue;
}
const slice = line.slice(0, Math.max(0, remaining));
trimmed.push(slice);
chars += slice.length + 1;
remaining = 0;
const budget = MAX_RAW_SSE_EVENT_CHARS - TRIM_MARKER_RESERVE;
let lines = compactToolLines(raw) ?? raw;
const compactedChars = lines === raw ? originalChars : countLines(lines);
if (compactedChars > budget) {
lines = headTailTrim(lines, budget, compactedChars);
} else if (lines === raw) {
lines = raw.slice();
}
const tail = `: omp-debug-truncated originalChars=${originalChars}`;
trimmed.push(tail);
chars += tail.length + 1;
return { raw: trimmed, truncated: true, originalChars, chars };
lines.push(`: omp-debug-truncated originalChars=${originalChars}`);
return { raw: lines, truncated: true, originalChars, chars: countLines(lines) + 1 };
}
export function formatRawSseIsoTime(timestamp: number): string {
@@ -170,4 +170,51 @@ describe("RawSseDebugBuffer", () => {
expect(dataLines.at(-1)).toBe(1_005);
expect(dataLines.every((n, idx) => n === 6 + idx)).toBe(true);
});
it("keeps head and tail of an over-budget event with the middle elided", () => {
const buffer = new RawSseDebugBuffer();
const line = `data: {"type":"response.completed","id":"gen-head-marker","filler":"${"x".repeat(130_000)}","usage":{"output_tokens":42}}`;
buffer.recordEvent(
{ event: "response.completed", data: line.slice("data: ".length), raw: ["event: response.completed", line] },
model,
);
const record = buffer.snapshot().records[0];
if (record.kind !== "event") throw new Error("expected event record");
expect(record.truncated).toBe(true);
expect(record.originalChars).toBe("event: response.completed".length + 1 + line.length + 1);
expect(record.raw[0]).toBe("event: response.completed");
const text = record.raw.join("\n");
expect(text).toContain("gen-head-marker");
expect(text).toContain('"usage":{"output_tokens":42}}');
expect(text).toContain(": omp-debug-elided chars=");
expect(record.raw.at(-1)).toBe(`: omp-debug-truncated originalChars=${record.originalChars}`);
expect(text.length).toBeLessThanOrEqual(64_000);
});
it("compacts tool schemas in oversized data payloads so the JSON stays parseable", () => {
const buffer = new RawSseDebugBuffer();
const tools = Array.from({ length: 20 }, (_, i) => ({
type: "function",
name: `tool_${i}`,
description: "d".repeat(1_000),
parameters: { type: "object", properties: { arg: { type: "string", description: "p".repeat(4_000) } } },
}));
const data = JSON.stringify({
type: "response.created",
response: { id: "resp_1", tools, usage: { input_tokens: 7 } },
});
buffer.recordEvent({ event: "response.created", data, raw: [`data: ${data}`] }, model);
const record = buffer.snapshot().records[0];
if (record.kind !== "event") throw new Error("expected event record");
expect(record.truncated).toBe(true);
expect(record.raw.at(-1)).toBe(`: omp-debug-truncated originalChars=${data.length + "data: ".length + 1}`);
const parsed = JSON.parse(record.raw[0].slice("data: ".length));
expect(parsed.response.usage.input_tokens).toBe(7);
expect(parsed.response.tools).toHaveLength(20);
expect(parsed.response.tools[0].name).toBe("tool_0");
expect(typeof parsed.response.tools[0].parameters).toBe("string");
expect(parsed.response.tools[0].parameters).toContain("… (+");
expect(parsed.response.tools[0].description).toHaveLength(200 + "… (+800 chars)".length);
});
});