fix(coding-agent): bounded edit-tool oldText/newText snapshots in tool-result details

Edit-tool results carried the full pre/post file content in
`details.oldText` / `details.newText`. For large files this bloated each
per-turn JSONL line by hundreds of KB even though the snapshots are
never sent to the LLM (provider serializers send only `content`) and
only consumed by the ACP event mapper for diff visualization.

Add `pruneOversizedEditSnapshots` and apply it at every site that
constructs an `EditToolDetails` / `EditToolPerFileResult`:
`executePatchSingle`, `executeReplaceSingle`, hashline `renderSection`
(delete + update branches), and both aggregators in `edit/index.ts`.
When combined `oldText` + `newText` exceeds 32 KB the helper returns a
shallow copy with both fields omitted; smaller edits pass through
unchanged. The diff, path, firstChangedLine, op, move, and diagnostics
fields are preserved, and ACP returns no diff content for over-budget
files (the text content still flows — graceful degradation).

Fixes #3786
This commit is contained in:
roboomp
2026-06-29 05:12:15 +00:00
parent ca9f2847e6
commit 9e4e0f6691
7 changed files with 220 additions and 15 deletions
@@ -27,6 +27,7 @@ import { ToolError } from "../../tools/tool-errors";
import { generateDiffString } from "../diff";
import { getFileSnapshotStore } from "../file-snapshot-store";
import type { EditToolDetails, EditToolPerFileResult, LspBatchRequest } from "../renderer";
import { pruneOversizedEditSnapshots } from "../snapshot-details";
import { nativeBlockResolver } from "./block-resolver";
import { HashlineFilesystem } from "./filesystem";
import { hashPatchInput, NOOP_HARD_LIMIT, recordNoopEdit, resetNoopEdit } from "./noop-loop-guard";
@@ -114,17 +115,22 @@ function renderSection(
if (result.op === "delete") {
const toolResult: AgentToolResult<EditToolDetails, typeof hashlineEditParamsSchema> = {
content: [{ type: "text", text: `Deleted ${result.path}` }],
details: {
details: pruneOversizedEditSnapshots({
diff: "",
op: "delete",
path: result.path,
oldText: result.before,
meta: outputMeta().get(),
},
}),
};
return {
toolResult,
perFileResult: { path: result.path, diff: "", op: "delete", oldText: result.before },
perFileResult: pruneOversizedEditSnapshots({
path: result.path,
diff: "",
op: "delete",
oldText: result.before,
}),
};
}
@@ -161,7 +167,7 @@ function renderSection(
text: `${result.header}${blockBlock}${moveBlock}${previewBlock}${warningsBlock}`,
},
],
details: {
details: pruneOversizedEditSnapshots({
diff: diff.diff,
firstChangedLine,
diagnostics,
@@ -172,9 +178,9 @@ function renderSection(
oldText: result.before,
newText: result.after,
meta,
},
}),
},
perFileResult: {
perFileResult: pruneOversizedEditSnapshots({
path: result.moveDest ?? result.path,
diff: diff.diff,
firstChangedLine,
@@ -184,7 +190,7 @@ function renderSection(
sourcePath: result.moveDest ? sourcePath : undefined,
oldText: result.before,
newText: result.after,
},
}),
};
}
+6 -4
View File
@@ -25,6 +25,7 @@ import applyPatchGrammar from "./modes/apply-patch.lark" with { type: "text" };
import { executePatchSingle, type PatchEditEntry, type PatchParams, patchEditSchema } from "./modes/patch";
import { executeReplaceSingle, type ReplaceEditEntry, type ReplaceParams, replaceEditSchema } from "./modes/replace";
import { type EditToolDetails, type EditToolPerFileResult, getLspBatchRequest, type LspBatchRequest } from "./renderer";
import { pruneOversizedEditSnapshots } from "./snapshot-details";
import { EDIT_MODE_STRATEGIES } from "./streaming";
export * from "@oh-my-pi/hashline";
@@ -38,6 +39,7 @@ export * from "./modes/patch";
export * from "./modes/replace";
export * from "./normalize";
export * from "./renderer";
export * from "./snapshot-details";
export * from "./streaming";
type TInput =
@@ -186,14 +188,14 @@ async function executeApplyPatchPerFile(
return {
content: [{ type: "text", text: contentTexts.join("\n") }],
details: {
details: pruneOversizedEditSnapshots({
diff: perFileResults
.map(r => r.diff)
.filter(Boolean)
.join("\n"),
firstChangedLine: perFileResults.find(r => r.firstChangedLine)?.firstChangedLine,
perFileResults,
},
}),
};
}
@@ -276,13 +278,13 @@ async function executeSinglePathEntries(
return {
content: [{ type: "text", text: contentTexts.join("\n") }],
details: {
details: pruneOversizedEditSnapshots({
diff: diffTexts.join("\n"),
firstChangedLine,
path: metadataPath ?? path,
...(hasFirstOldText ? { oldText: firstOldText } : {}),
...(hasLastNewText ? { newText: lastNewText } : {}),
},
}),
// Any per-entry failure marks the aggregate result as an error so the
// renderer takes the error branch instead of falling through to the
// streaming-edit preview (which displays the *proposed* diff and looks
@@ -48,6 +48,7 @@ import {
} from "../normalize";
import { readEditFileText, serializeEditFileText } from "../read-file";
import type { EditToolDetails, LspBatchRequest } from "../renderer";
import { pruneOversizedEditSnapshots } from "../snapshot-details";
import {
type ContextLineResult,
DEFAULT_FUZZY_THRESHOLD,
@@ -1894,7 +1895,7 @@ export async function executePatchSingle(
return {
content: [{ type: "text", text: resultText }],
details: {
details: pruneOversizedEditSnapshots({
diff: diffResult.diff,
// When the patch moves the file, anchor the diff to the destination
// path. ACP `ToolCallContent.diff.path` comes from this field, and
@@ -1909,6 +1910,6 @@ export async function executePatchSingle(
meta,
oldText,
newText,
},
}),
};
}
@@ -24,6 +24,7 @@ import {
} from "../normalize";
import { readEditFileText, serializeEditFileText } from "../read-file";
import type { EditToolDetails, LspBatchRequest } from "../renderer";
import { pruneOversizedEditSnapshots } from "../snapshot-details";
export interface FuzzyMatch {
actualText: string;
@@ -1123,7 +1124,7 @@ export async function executeReplaceSingle(
return {
content: [{ type: "text", text: resultText }],
details: {
details: pruneOversizedEditSnapshots({
diff: diffResult.diff,
path: absolutePath,
firstChangedLine: diffResult.firstChangedLine,
@@ -1131,6 +1132,6 @@ export async function executeReplaceSingle(
meta,
oldText: rawContent,
newText: finalContent,
},
}),
};
}
@@ -0,0 +1,51 @@
/**
* Bound the size of the `oldText` / `newText` snapshots that edit-tool results
* carry in `details`. These fields hold the full pre/post file content; for
* large files they balloon the per-turn JSONL line and the session file
* (300 KB+ each on the cases reported in #3786) without paying for any LLM
* context (provider serializers send only `content`, never `details`).
*
* Only consumer of the raw snapshots is the ACP event mapper, which builds a
* `diff` ToolCallContent for ACP clients (Zed). When the snapshots are pruned
* the mapper returns `undefined` for that file and the text content still
* flows — diff visualization degrades gracefully for over-threshold edits.
*/
import type { EditToolDetails, EditToolPerFileResult } from "./renderer";
/**
* Combined `oldText` + `newText` character budget per edit-tool result.
*
* Picked so typical code-file edits keep ACP diff visualization while
* pathological cases (large generated files, full-file rewrites) drop the raw
* snapshots before they hit the session JSONL.
*/
export const MAX_EDIT_SNAPSHOT_TEXT_CHARS = 32_768;
type WithSnapshot = { oldText?: string; newText?: string };
function pruneSnapshot<T extends WithSnapshot>(details: T): T {
if ((details.oldText?.length ?? 0) + (details.newText?.length ?? 0) <= MAX_EDIT_SNAPSHOT_TEXT_CHARS) {
return details;
}
const { oldText: _old, newText: _new, ...rest } = details;
return rest as T;
}
/**
* Prune oversized `oldText` / `newText` from an edit-tool details payload,
* recursing into `perFileResults` when present. Per-file overload comes first
* so the more specific shape (required `path`) wins overload resolution at
* the per-file call sites.
*/
export function pruneOversizedEditSnapshots(details: EditToolPerFileResult): EditToolPerFileResult;
export function pruneOversizedEditSnapshots(details: EditToolDetails): EditToolDetails;
export function pruneOversizedEditSnapshots(
details: EditToolDetails | EditToolPerFileResult,
): EditToolDetails | EditToolPerFileResult {
const pruned = pruneSnapshot(details);
if ("perFileResults" in pruned && pruned.perFileResults) {
return { ...pruned, perFileResults: pruned.perFileResults.map(pruneSnapshot) };
}
return pruned;
}