fix(tools): keep truncation metadata when spilling read output
The artifact spill wrapper dropped the read truncation metadata, so a spilled oversized read lost its next-offset pagination hint, and an artifact:// read of an already-spilled result was spilled a second time. Preserve the existing truncation metadata and skip re-spilling artifact reads.
This commit is contained in:
@@ -680,6 +680,17 @@ async function spillLargeResultToArtifact(
|
||||
const existingMeta: OutputMeta | undefined = result.details?.meta;
|
||||
if (existingMeta?.truncation?.artifactId) return result;
|
||||
|
||||
// Reading an artifact already addresses recoverable full output. Spilling that
|
||||
// read would only create a redundant artifact containing another artifact's
|
||||
// page (and can repeat indefinitely on subsequent reads).
|
||||
if (
|
||||
toolName === "read" &&
|
||||
existingMeta?.source?.type === "internal" &&
|
||||
existingMeta.source.value.startsWith("artifact://")
|
||||
) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Measure total text content
|
||||
const textParts: string[] = [];
|
||||
for (const block of result.content) {
|
||||
@@ -759,6 +770,7 @@ async function spillLargeResultToArtifact(
|
||||
elidedLines,
|
||||
elidedBytes,
|
||||
artifactId,
|
||||
nextOffset: existingMeta?.truncation?.nextOffset,
|
||||
};
|
||||
} else {
|
||||
const shownStart = truncated.totalLines - outputLines + 1;
|
||||
@@ -772,6 +784,7 @@ async function spillLargeResultToArtifact(
|
||||
maxBytes: tailBytes,
|
||||
shownRange: { start: shownStart, end: truncated.totalLines },
|
||||
artifactId,
|
||||
nextOffset: existingMeta?.truncation?.nextOffset,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -237,7 +237,7 @@ function createTestToolSession(
|
||||
getArtifactsDir: () => sessionDir,
|
||||
allocateOutputArtifact: async (toolType: string) => {
|
||||
fs.mkdirSync(sessionDir, { recursive: true });
|
||||
const id = `artifact-${++artifactCounter}`;
|
||||
const id = String(++artifactCounter);
|
||||
return { id, path: path.join(sessionDir, `${id}.${toolType}.log`) };
|
||||
},
|
||||
settings,
|
||||
@@ -640,30 +640,60 @@ describe("Coding Agent Tools", () => {
|
||||
it("should spill oversized read output to an artifact", async () => {
|
||||
const testFile = path.join(testDir, "oversized-read.txt");
|
||||
const line = "0123456789".repeat(20);
|
||||
fs.writeFileSync(testFile, `${Array.from({ length: 600 }, () => line).join("\n")}\n`);
|
||||
fs.writeFileSync(testFile, `${Array.from({ length: 3500 }, () => line).join("\n")}\n`);
|
||||
const spillSettings = Settings.isolated({
|
||||
"tools.artifactSpillThreshold": 20,
|
||||
"tools.artifactTailBytes": 1,
|
||||
"tools.artifactTailLines": 10,
|
||||
"tools.artifactHeadBytes": 0,
|
||||
});
|
||||
const spillSession = createTestToolSession(testDir, spillSettings);
|
||||
const defaultLimit = spillSettings.get("read.defaultLimit");
|
||||
const spillManager = SessionManager.create(testDir, path.join(testDir, "spill-sessions"));
|
||||
await spillManager.ensureOnDisk();
|
||||
const spillSession = createTestToolSession(testDir, spillSettings, {
|
||||
getSessionFile: () => spillManager.getSessionFile() ?? null,
|
||||
getArtifactsDir: () => spillManager.getArtifactsDir(),
|
||||
localProtocolOptions: {
|
||||
getArtifactsDir: () => spillManager.getArtifactsDir(),
|
||||
getSessionId: () => spillManager.getSessionId(),
|
||||
},
|
||||
});
|
||||
const spillReadTool = wrapToolWithMetaNotice(new ReadTool(spillSession));
|
||||
const context = { ...createTestToolContext(["read"]), settings: spillSettings };
|
||||
const context = {
|
||||
...createTestToolContext(["read"]),
|
||||
settings: spillSettings,
|
||||
sessionManager: spillManager,
|
||||
};
|
||||
|
||||
const result = await spillReadTool.execute(
|
||||
"test-call-read-spill",
|
||||
{ path: testFile },
|
||||
undefined,
|
||||
undefined,
|
||||
context,
|
||||
);
|
||||
const truncation = result.details?.meta?.truncation;
|
||||
const output = getTextOutput(result);
|
||||
try {
|
||||
const result = await spillReadTool.execute(
|
||||
"test-call-read-spill",
|
||||
{ path: testFile },
|
||||
undefined,
|
||||
undefined,
|
||||
context,
|
||||
);
|
||||
const truncation = result.details?.meta?.truncation;
|
||||
const output = getTextOutput(result);
|
||||
|
||||
expect(truncation?.artifactId).toBeDefined();
|
||||
expect(Buffer.byteLength(output, "utf-8")).toBeLessThan(20 * 1024);
|
||||
expect(output).toContain("artifact://");
|
||||
expect(truncation?.artifactId).toBeDefined();
|
||||
expect(Buffer.byteLength(output, "utf-8")).toBeLessThan(20 * 1024);
|
||||
expect(output).toContain("artifact://");
|
||||
expect(truncation?.nextOffset).toBe(defaultLimit + 1);
|
||||
|
||||
const saveArtifact = vi.spyOn(spillManager, "saveArtifact");
|
||||
const artifactResult = await spillReadTool.execute(
|
||||
"test-call-read-spilled-artifact",
|
||||
{ path: `artifact://${truncation?.artifactId}` },
|
||||
undefined,
|
||||
undefined,
|
||||
context,
|
||||
);
|
||||
expect(getTextOutput(artifactResult)).toContain(line);
|
||||
expect(saveArtifact).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await spillManager.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("should render directories as a two-level tree without capping root entries", async () => {
|
||||
|
||||
Reference in New Issue
Block a user