From c6dcfa4137b02a5decc85a07418255f1ead94e43 Mon Sep 17 00:00:00 2001 From: can1357 Date: Fri, 24 Jul 2026 15:19:23 +0200 Subject: [PATCH] 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. --- docs/tools/debug.md | 2 +- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/debug/raw-sse-buffer.ts | 187 +++++++++++++++--- .../test/debug/raw-sse-buffer.test.ts | 47 +++++ 4 files changed, 206 insertions(+), 31 deletions(-) diff --git a/docs/tools/debug.md b/docs/tools/debug.md index e0670b49f..27ed46055 100644 --- a/docs/tools/debug.md +++ b/docs/tools/debug.md @@ -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` diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index efc763565..defbed870 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/debug/raw-sse-buffer.ts b/packages/coding-agent/src/debug/raw-sse-buffer.ts index b62614666..3490b4b4b 100644 --- a/packages/coding-agent/src/debug/raw-sse-buffer.ts +++ b/packages/coding-agent/src/debug/raw-sse-buffer.ts @@ -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; + 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; + 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 { diff --git a/packages/coding-agent/test/debug/raw-sse-buffer.test.ts b/packages/coding-agent/test/debug/raw-sse-buffer.test.ts index f393e2feb..efc49f2ca 100644 --- a/packages/coding-agent/test/debug/raw-sse-buffer.test.ts +++ b/packages/coding-agent/test/debug/raw-sse-buffer.test.ts @@ -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); + }); });