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