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:
+1
-1
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user