feat(core): recover truncated tool output via the Session scratchpad (#145)
Co-authored-by: Yaowei Zheng <hiyouga@buaa.edu.cn> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createAgent } from "../src/index.js";
|
||||
import { formatSessionId } from "../src/internal/session-support.js";
|
||||
import { projectDir } from "../src/state/paths.js";
|
||||
import { modelVisiblePath } from "../src/internal/model-visible-path.js";
|
||||
import { stubProviderKeys } from "./provider-keys.js";
|
||||
|
||||
let tmpRoot: string;
|
||||
@@ -99,7 +100,11 @@ describe("Agent.createSession session id + no .penguin symlink", () => {
|
||||
const session = await agent.createSession({ workspaceDir: ws });
|
||||
const prompt = (session.metaMessage.payload as { system_prompt: string }).system_prompt;
|
||||
expect(prompt).toContain(`Agent ID: ${agent.state.agentId}`);
|
||||
expect(prompt).toContain(`App Data Dir: ${projectDir(tmpRoot, agent.state.projectId)}`);
|
||||
// The prompt shows the model-visible spelling (forward slashes on Windows), not path.join's.
|
||||
expect(prompt).toContain(
|
||||
`App Data Dir: ${modelVisiblePath(projectDir(tmpRoot, agent.state.projectId))}`,
|
||||
);
|
||||
expect(prompt).toContain(`CWD: ${modelVisiblePath(ws)}`);
|
||||
expect(prompt).not.toContain(".penguin");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -152,6 +152,11 @@ async function readFileEventually(
|
||||
}
|
||||
}
|
||||
|
||||
/** Extracts the plain archive path from the note (the path is always last before `]`). */
|
||||
function recoveryPath(output: string): string | undefined {
|
||||
return output.match(/\[output archived[^:]*: ([^\]]+)\]/)?.[1];
|
||||
}
|
||||
|
||||
describe("ContextEngine ReAct loop (mock LLM, approve callback)", () => {
|
||||
let workspace: string;
|
||||
let traces: string;
|
||||
@@ -210,6 +215,112 @@ describe("ContextEngine ReAct loop (mock LLM, approve callback)", () => {
|
||||
expect(recordedTypes.some((t) => t?.startsWith("partial_"))).toBe(false);
|
||||
});
|
||||
|
||||
it("lets the next Agent turn recover truncated text, keeps UI == Agent output, and preserves it after Task end", async () => {
|
||||
const NAME = "__recoverable_text_tool__";
|
||||
const source = `BEGIN\n${"detail\n".repeat(100)}FINAL ANSWER\n`;
|
||||
BUILTIN_TOOL_FACTORIES[NAME] = (definition) => ({
|
||||
name: NAME,
|
||||
definition,
|
||||
async *execute(_args, ctx) {
|
||||
yield partialToolCallOutput({
|
||||
eventType: "delta",
|
||||
output: source.slice(0, 200),
|
||||
toolCallId: ctx.toolCallId,
|
||||
});
|
||||
yield partialToolCallOutput({
|
||||
eventType: "delta",
|
||||
output: source.slice(200),
|
||||
toolCallId: ctx.toolCallId,
|
||||
});
|
||||
},
|
||||
});
|
||||
try {
|
||||
let calls = 0;
|
||||
let agentVisibleOutput = "";
|
||||
let recoveredPath = "";
|
||||
let recoveredDuringTask = "";
|
||||
const llm: LLMInterface = {
|
||||
async *streamGenerate(params): AsyncGenerator<OmniMessage, LLMOutcome> {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
yield toolCall({
|
||||
name: NAME,
|
||||
arguments: "{}",
|
||||
toolCallId: "recover-call",
|
||||
stopReason: "completed",
|
||||
});
|
||||
yield tokenUsage(emptyTokenCounts(), {
|
||||
cache_read: 0,
|
||||
cache_write: 0,
|
||||
output: 1,
|
||||
total: 1,
|
||||
});
|
||||
return { status: "completed" };
|
||||
}
|
||||
const toolResult = params.newMessages.find(
|
||||
(m) => (m.payload as { type?: string }).type === "tool_call_output",
|
||||
);
|
||||
agentVisibleOutput = (toolResult?.payload as { output?: string }).output ?? "";
|
||||
recoveredPath = recoveryPath(agentVisibleOutput) ?? "";
|
||||
recoveredDuringTask = await readFile(recoveredPath, "utf8");
|
||||
yield assistantText("Recovered the final answer.");
|
||||
yield tokenUsage(emptyTokenCounts(), {
|
||||
cache_read: 0,
|
||||
cache_write: 0,
|
||||
output: 1,
|
||||
total: 2,
|
||||
});
|
||||
return { status: "completed" };
|
||||
},
|
||||
};
|
||||
const environment = new Environment({
|
||||
workspaceDir: workspace,
|
||||
toolConfig: {
|
||||
customTools: [
|
||||
{ name: NAME, description: "recover", permission: "r", maxOutputLength: 40 },
|
||||
],
|
||||
mcpServers: [],
|
||||
},
|
||||
sessionScratchpadDir: join(workspace, "session-scratchpad"),
|
||||
});
|
||||
const trace = new Writer({ tracesDir: traces, sessionId: "sess_truncated_output" });
|
||||
const engine = new ContextEngine({ llm, environment, trace });
|
||||
const all = await collectRun(engine, [userText("recover it")], allowAll);
|
||||
|
||||
expect(recoveredDuringTask).toBe(source);
|
||||
const frontendComplete = all.find(
|
||||
(m) =>
|
||||
(m.payload as { type?: string }).type === "tool_call_output" &&
|
||||
(m.payload as { tool_call_id?: string }).tool_call_id === "recover-call",
|
||||
);
|
||||
expect((frontendComplete!.payload as { output: string }).output).toBe(agentVisibleOutput);
|
||||
const frontendStream = all
|
||||
.filter(
|
||||
(m) =>
|
||||
(m.payload as { type?: string }).type === "partial_tool_call_output" &&
|
||||
(m.payload as { tool_call_id?: string }).tool_call_id === "recover-call" &&
|
||||
(m.payload as { event_type?: string }).event_type === "delta",
|
||||
)
|
||||
.map((m) => (m.payload as { output?: string }).output ?? "")
|
||||
.join("");
|
||||
expect(frontendStream).toBe(agentVisibleOutput);
|
||||
|
||||
const recorded = await readTrace(trace.currentPath());
|
||||
const tracedOutput = recorded.find(
|
||||
(m) =>
|
||||
(m.payload as { type?: string }).type === "tool_call_output" &&
|
||||
(m.payload as { tool_call_id?: string }).tool_call_id === "recover-call",
|
||||
);
|
||||
expect((tracedOutput!.payload as { output: string }).output).toBe(agentVisibleOutput);
|
||||
|
||||
expect(await readFile(recoveredPath, "utf8")).toBe(source);
|
||||
environment.dispose();
|
||||
expect(await readFile(recoveredPath, "utf8")).toBe(source);
|
||||
} finally {
|
||||
delete BUILTIN_TOOL_FACTORIES[NAME];
|
||||
}
|
||||
});
|
||||
|
||||
it("a slow tool delays the run only by its own latency: the loop adds no waits, timers, or dropped wakes", async () => {
|
||||
// Regression pin for the ci-windows timeout of the test above (goal-mode PR #66's
|
||||
// merge-ref run): on one cold Windows runner the suite-start burst of first Git-Bash
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { access, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { Environment } from "../src/environment/index.js";
|
||||
import { TruncatedToolOutputCapture } from "../src/environment/truncated-tool-output-archive.js";
|
||||
import {
|
||||
partialToolCallOutput,
|
||||
toolCall,
|
||||
@@ -71,6 +72,11 @@ function payloadTypes(messages: OmniMessage[]): string[] {
|
||||
return messages.map((m) => (m.payload as { type?: string }).type ?? "");
|
||||
}
|
||||
|
||||
/** Extracts the plain archive path from the note (the path is always last before `]`). */
|
||||
function recoveryPath(output: string): string | undefined {
|
||||
return output.match(/\[output archived[^:]*: ([^\]]+)\]/)?.[1];
|
||||
}
|
||||
|
||||
let tmp: string;
|
||||
let originalHome: string | undefined;
|
||||
|
||||
@@ -251,11 +257,31 @@ describe("Environment.executeTool — edit file", () => {
|
||||
});
|
||||
|
||||
describe("Environment.executeTool — maxOutputLength truncation", () => {
|
||||
it("truncates front-to-back at the limit with a trailing marker; stream == complete", async () => {
|
||||
it("keeps standalone Environment's legacy truncation behavior without archive config", async () => {
|
||||
const env = new Environment({
|
||||
workspaceDir: tmp,
|
||||
toolConfig: makeToolConfig(execTool({ maxOutputLength: 5 })),
|
||||
});
|
||||
const messages = await collect(
|
||||
env.executeTool({
|
||||
toolCall: toolCall({
|
||||
name: "exec_command",
|
||||
arguments: JSON.stringify({ cmd: "printf 'abcdefghijklmnopqrstuvwxyz'" }),
|
||||
toolCallId: "call_standalone_truncation",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const output = (messages[messages.length - 1]!.payload as { output: string }).output;
|
||||
expect(output).toContain("[output truncated: exceeded 5 chars]");
|
||||
expect(output).not.toContain("[output archived:");
|
||||
});
|
||||
|
||||
it("truncates front-to-back, archives the received output, and keeps stream == complete", async () => {
|
||||
const maxOutputLength = 50;
|
||||
const env = new Environment({
|
||||
workspaceDir: tmp,
|
||||
toolConfig: makeToolConfig(execTool({ maxOutputLength })),
|
||||
sessionScratchpadDir: path.join(tmp, "scratch"),
|
||||
});
|
||||
|
||||
const messages = await collect(
|
||||
@@ -276,7 +302,10 @@ describe("Environment.executeTool — maxOutputLength truncation", () => {
|
||||
expect(output.startsWith("1\n2\n3\n")).toBe(true);
|
||||
const marker = `[output truncated: exceeded ${maxOutputLength} chars]`;
|
||||
expect(output).toContain(marker);
|
||||
expect(output.length).toBeLessThanOrEqual(maxOutputLength + marker.length + 1);
|
||||
expect(output.indexOf(marker)).toBe(maxOutputLength + 1);
|
||||
const savedPath = recoveryPath(output);
|
||||
expect(savedPath).toBeDefined();
|
||||
expect(await readFile(savedPath!, "utf8")).toMatch(/100000\r?\n$/);
|
||||
// Even when truncated, concatenating the streamed deltas == the complete content (the
|
||||
// excess part is never forwarded).
|
||||
const streamed = messages
|
||||
@@ -291,9 +320,12 @@ describe("Environment.executeTool — maxOutputLength truncation", () => {
|
||||
});
|
||||
|
||||
it("maxOutputLength <= 0 disables truncation", async () => {
|
||||
const sessionScratchpadDir = path.join(tmp, "scratch");
|
||||
const truncatedToolOutputRoot = path.join(sessionScratchpadDir, "truncated-tool-output");
|
||||
const env = new Environment({
|
||||
workspaceDir: tmp,
|
||||
toolConfig: makeToolConfig(execTool({ maxOutputLength: 0 })),
|
||||
sessionScratchpadDir,
|
||||
});
|
||||
const messages = await collect(
|
||||
env.executeTool({
|
||||
@@ -307,6 +339,330 @@ describe("Environment.executeTool — maxOutputLength truncation", () => {
|
||||
const output = (messages[messages.length - 1]!.payload as { output: string }).output;
|
||||
expect(output).toContain("100");
|
||||
expect(output).not.toContain("[output truncated");
|
||||
await expect(access(truncatedToolOutputRoot)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("saves exact overflow for the Session without changing stream == complete", async () => {
|
||||
const maxOutputLength = 50;
|
||||
const workspaceDir = path.join(tmp, "workspace");
|
||||
await mkdir(workspaceDir);
|
||||
const sessionScratchpadDir = path.join(tmp, "session-scratchpad");
|
||||
const truncatedToolOutputRoot = path.join(sessionScratchpadDir, "truncated-tool-output");
|
||||
const toolConfig: ToolConfig = {
|
||||
customTools: [
|
||||
execTool({ maxOutputLength }),
|
||||
{
|
||||
name: "read_file",
|
||||
description: "Read a text file.",
|
||||
permission: "r",
|
||||
maxOutputLength: 64_000,
|
||||
},
|
||||
],
|
||||
mcpServers: [],
|
||||
};
|
||||
const env = new Environment({
|
||||
workspaceDir,
|
||||
toolConfig,
|
||||
sessionScratchpadDir,
|
||||
});
|
||||
const expected = `BEGIN\n${"x".repeat(200)}\nEND\n`;
|
||||
const messages = await collect(
|
||||
env.executeTool({
|
||||
toolCall: toolCall({
|
||||
name: "exec_command",
|
||||
arguments: JSON.stringify({
|
||||
cmd: `node -e ${JSON.stringify(`process.stdout.write(${JSON.stringify(expected)})`)}`,
|
||||
}),
|
||||
toolCallId: "call_recoverable",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const complete = messages[messages.length - 1]!.payload as {
|
||||
output: string;
|
||||
stop_reason?: string;
|
||||
};
|
||||
expect(complete.stop_reason).toBe("completed");
|
||||
expect(complete.output.startsWith(expected.slice(0, maxOutputLength))).toBe(true);
|
||||
const savedPath = recoveryPath(complete.output);
|
||||
expect(savedPath).toBeDefined();
|
||||
if (process.platform === "win32") {
|
||||
expect(savedPath).not.toContain("\\");
|
||||
}
|
||||
expect(await readFile(savedPath!, "utf8")).toBe(expected);
|
||||
if (process.platform !== "win32") {
|
||||
expect((await stat(savedPath!)).mode & 0o777).toBe(0o600);
|
||||
}
|
||||
|
||||
const streamed = messages
|
||||
.filter(
|
||||
(m) =>
|
||||
(m.payload as { type?: string }).type === "partial_tool_call_output" &&
|
||||
(m.payload as { event_type?: string }).event_type === "delta",
|
||||
)
|
||||
.map((m) => (m.payload as { output?: string }).output ?? "")
|
||||
.join("");
|
||||
// Core product invariant: Web/CLI consume this stream, while the Agent receives the
|
||||
// complete result. The archive path is present identically on both sides.
|
||||
expect(streamed).toBe(complete.output);
|
||||
|
||||
// The production recovery directory is outside the Workspace. Pin that the existing
|
||||
// read_file tool accepts the absolute path, so no dedicated recovery tool is needed.
|
||||
const readMessages = await collect(
|
||||
env.executeTool({
|
||||
toolCall: toolCall({
|
||||
name: "read_file",
|
||||
arguments: JSON.stringify({ file_path: savedPath }),
|
||||
toolCallId: "read_recovery",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const readOutput = (readMessages[readMessages.length - 1]!.payload as { output: string })
|
||||
.output;
|
||||
expect(readOutput).toContain("BEGIN");
|
||||
expect(readOutput).toContain("END");
|
||||
|
||||
env.dispose();
|
||||
expect(await readFile(savedPath!, "utf8")).toBe(expected);
|
||||
await expect(access(truncatedToolOutputRoot)).resolves.toBeUndefined();
|
||||
|
||||
// Resuming the Session creates a fresh Environment over the same scratchpad. The path
|
||||
// recorded in Trace must still work with the ordinary read_file tool.
|
||||
const resumedEnv = new Environment({
|
||||
workspaceDir,
|
||||
toolConfig,
|
||||
sessionScratchpadDir,
|
||||
});
|
||||
const resumedRead = await collect(
|
||||
resumedEnv.executeTool({
|
||||
toolCall: toolCall({
|
||||
name: "read_file",
|
||||
arguments: JSON.stringify({ file_path: savedPath }),
|
||||
toolCallId: "read_after_resume",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect((resumedRead[resumedRead.length - 1]!.payload as { output: string }).output).toContain(
|
||||
"END",
|
||||
);
|
||||
resumedEnv.dispose();
|
||||
});
|
||||
|
||||
it("does not create a Session output directory for a result that fits the visible limit", async () => {
|
||||
const sessionScratchpadDir = path.join(tmp, "scratch");
|
||||
const truncatedToolOutputRoot = path.join(sessionScratchpadDir, "truncated-tool-output");
|
||||
const env = new Environment({
|
||||
workspaceDir: tmp,
|
||||
toolConfig: makeToolConfig(execTool({ maxOutputLength: 100 })),
|
||||
sessionScratchpadDir,
|
||||
});
|
||||
const messages = await collect(
|
||||
env.executeTool({
|
||||
toolCall: toolCall({
|
||||
name: "exec_command",
|
||||
arguments: JSON.stringify({ cmd: "printf short" }),
|
||||
toolCallId: "call_short",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const output = (messages[messages.length - 1]!.payload as { output: string }).output;
|
||||
expect(output).not.toContain("[output archived:");
|
||||
await expect(access(truncatedToolOutputRoot)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("keeps the original tool outcome when the recovery file cannot be written", async () => {
|
||||
const sessionScratchpadDir = path.join(tmp, "scratchpad-is-a-file");
|
||||
await writeFile(sessionScratchpadDir, "occupied", "utf8");
|
||||
const env = new Environment({
|
||||
workspaceDir: tmp,
|
||||
toolConfig: makeToolConfig(execTool({ maxOutputLength: 20 })),
|
||||
sessionScratchpadDir,
|
||||
});
|
||||
const stderr: string[] = [];
|
||||
const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation((chunk) => {
|
||||
stderr.push(String(chunk));
|
||||
return true;
|
||||
});
|
||||
let messages: OmniMessage[];
|
||||
try {
|
||||
messages = await collect(
|
||||
env.executeTool({
|
||||
toolCall: toolCall({
|
||||
name: "exec_command",
|
||||
arguments: JSON.stringify({ cmd: "printf 'abcdefghijklmnopqrstuvwxyz'" }),
|
||||
toolCallId: "call_archive_failure",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
stderrSpy.mockRestore();
|
||||
}
|
||||
const complete = messages[messages.length - 1]!.payload as {
|
||||
output: string;
|
||||
stop_reason?: string;
|
||||
};
|
||||
expect(complete.output).toMatch(/\[output archive failed: [^\]]+\]/);
|
||||
expect(complete.stop_reason).toBe("completed");
|
||||
expect(stderr.join("")).toMatch(
|
||||
/\[penguin\] tool "exec_command" truncated output archive write failed \([^)]+\)\./,
|
||||
);
|
||||
const streamed = messages
|
||||
.filter(
|
||||
(m) =>
|
||||
(m.payload as { type?: string }).type === "partial_tool_call_output" &&
|
||||
(m.payload as { event_type?: string }).event_type === "delta",
|
||||
)
|
||||
.map((m) => (m.payload as { output?: string }).output ?? "")
|
||||
.join("");
|
||||
expect(streamed).toBe(complete.output);
|
||||
});
|
||||
|
||||
it("freezes the completed outcome before auxiliary archive I/O", async () => {
|
||||
const controller = new AbortController();
|
||||
const originalSave = TruncatedToolOutputCapture.prototype.save;
|
||||
const saveSpy = vi
|
||||
.spyOn(TruncatedToolOutputCapture.prototype, "save")
|
||||
.mockImplementation(function (this: TruncatedToolOutputCapture, toolName, toolCallId) {
|
||||
// The tool has already reached its terminal state when save() starts. This late abort
|
||||
// must not retroactively turn a completed tool into an aborted one.
|
||||
controller.abort();
|
||||
return originalSave.call(this, toolName, toolCallId);
|
||||
});
|
||||
const env = new Environment({
|
||||
workspaceDir: tmp,
|
||||
toolConfig: makeToolConfig(execTool({ maxOutputLength: 5 })),
|
||||
sessionScratchpadDir: path.join(tmp, "scratch"),
|
||||
});
|
||||
try {
|
||||
const messages = await collect(
|
||||
env.executeTool({
|
||||
toolCall: toolCall({
|
||||
name: "exec_command",
|
||||
arguments: JSON.stringify({ cmd: "printf 'abcdefghijklmnopqrstuvwxyz'" }),
|
||||
toolCallId: "call_late_abort",
|
||||
}),
|
||||
signal: controller.signal,
|
||||
}),
|
||||
);
|
||||
const complete = messages[messages.length - 1]!.payload as {
|
||||
output: string;
|
||||
stop_reason?: string;
|
||||
};
|
||||
expect(saveSpy).toHaveBeenCalledOnce();
|
||||
expect(controller.signal.aborted).toBe(true);
|
||||
expect(complete.stop_reason).toBe("completed");
|
||||
expect(complete.output).toContain("[output archived:");
|
||||
expect(complete.output).not.toContain("[interrupted: tool aborted by user]");
|
||||
} finally {
|
||||
saveSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("bounds a very large archive and preserves its UTF-8 head and tail", async () => {
|
||||
const NAME = "__large_text_tool__";
|
||||
BUILTIN_TOOL_FACTORIES[NAME] = (definition) => ({
|
||||
name: NAME,
|
||||
definition,
|
||||
async *execute(_args, ctx) {
|
||||
yield partialToolCallOutput({
|
||||
eventType: "delta",
|
||||
output: "BEGIN-企鹅\n",
|
||||
toolCallId: ctx.toolCallId,
|
||||
});
|
||||
yield partialToolCallOutput({
|
||||
eventType: "delta",
|
||||
output: "x".repeat(8 * 1024 * 1024 + 100_000),
|
||||
toolCallId: ctx.toolCallId,
|
||||
});
|
||||
yield partialToolCallOutput({
|
||||
eventType: "delta",
|
||||
output: "\n-END-🐧",
|
||||
toolCallId: ctx.toolCallId,
|
||||
});
|
||||
},
|
||||
});
|
||||
try {
|
||||
const env = new Environment({
|
||||
workspaceDir: tmp,
|
||||
toolConfig: {
|
||||
customTools: [{ name: NAME, description: "large", permission: "r", maxOutputLength: 50 }],
|
||||
mcpServers: [],
|
||||
},
|
||||
sessionScratchpadDir: path.join(tmp, "scratch"),
|
||||
});
|
||||
const messages = await collect(
|
||||
env.executeTool({
|
||||
toolCall: toolCall({ name: NAME, arguments: "{}", toolCallId: "call_huge" }),
|
||||
}),
|
||||
);
|
||||
const complete = messages[messages.length - 1]!.payload as { output: string };
|
||||
expect(complete.output).toContain("head and tail kept");
|
||||
const savedPath = recoveryPath(complete.output);
|
||||
expect(savedPath).toBeDefined();
|
||||
const archived = await readFile(savedPath!, "utf8");
|
||||
expect(Buffer.byteLength(archived, "utf8")).toBeLessThanOrEqual(8 * 1024 * 1024);
|
||||
expect(archived).toContain("BEGIN-企鹅");
|
||||
expect(archived).toContain("-END-🐧");
|
||||
expect(archived).toContain("[archive middle truncated]");
|
||||
expect(archived).not.toContain("\uFFFD");
|
||||
} finally {
|
||||
delete BUILTIN_TOOL_FACTORIES[NAME];
|
||||
}
|
||||
});
|
||||
|
||||
it("recovers a compatibility tool that returns only a complete message", async () => {
|
||||
const NAME = "__complete_message_tool__";
|
||||
const source = `COMPLETE-ONLY-${"z".repeat(100)}-END`;
|
||||
BUILTIN_TOOL_FACTORIES[NAME] = (definition) => ({
|
||||
name: NAME,
|
||||
definition,
|
||||
async *execute(_args, ctx) {
|
||||
yield toolCallOutput({
|
||||
output: source,
|
||||
toolCallId: ctx.toolCallId,
|
||||
stopReason: "completed",
|
||||
});
|
||||
},
|
||||
});
|
||||
try {
|
||||
const env = new Environment({
|
||||
workspaceDir: tmp,
|
||||
toolConfig: {
|
||||
customTools: [
|
||||
{ name: NAME, description: "complete", permission: "r", maxOutputLength: 20 },
|
||||
],
|
||||
mcpServers: [],
|
||||
},
|
||||
sessionScratchpadDir: path.join(tmp, "scratch"),
|
||||
});
|
||||
const messages = await collect(
|
||||
env.executeTool({
|
||||
toolCall: toolCall({ name: NAME, arguments: "{}", toolCallId: "complete-only" }),
|
||||
}),
|
||||
);
|
||||
const complete = messages[messages.length - 1]!.payload as {
|
||||
output: string;
|
||||
stop_reason?: string;
|
||||
};
|
||||
const savedPath = recoveryPath(complete.output);
|
||||
expect(savedPath).toBeDefined();
|
||||
expect(await readFile(savedPath!, "utf8")).toBe(source);
|
||||
expect(complete.output.startsWith(source.slice(0, 20))).toBe(true);
|
||||
expect(complete.stop_reason).toBe("completed");
|
||||
const streamed = messages
|
||||
.filter(
|
||||
(m) =>
|
||||
(m.payload as { type?: string }).type === "partial_tool_call_output" &&
|
||||
(m.payload as { event_type?: string }).event_type === "delta",
|
||||
)
|
||||
.map((m) => (m.payload as { output?: string }).output ?? "")
|
||||
.join("");
|
||||
expect(streamed).toBe(complete.output);
|
||||
env.dispose();
|
||||
expect(await readFile(savedPath!, "utf8")).toBe(source);
|
||||
} finally {
|
||||
delete BUILTIN_TOOL_FACTORIES[NAME];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -461,13 +817,14 @@ describe("Environment.executeTool — relaxed tool contract", () => {
|
||||
it("passes origin-tagged nested messages through verbatim, excluded from the tool output", async () => {
|
||||
const NAME = "__forwarding_tool__";
|
||||
const hop = "sess_child";
|
||||
const childOutput = "child result ".repeat(100);
|
||||
BUILTIN_TOOL_FACTORIES[NAME] = (definition) => ({
|
||||
name: NAME,
|
||||
definition,
|
||||
async *execute(_args, ctx) {
|
||||
// Nested forwarding: origin-tagged messages pass through verbatim (a child session's
|
||||
// complete tool_call_output is not folded into the finish either).
|
||||
yield withOrigin(toolCallOutput({ output: "child result", toolCallId: "child_call" }), hop);
|
||||
yield withOrigin(toolCallOutput({ output: childOutput, toolCallId: "child_call" }), hop);
|
||||
yield partialToolCallOutput({
|
||||
eventType: "delta",
|
||||
output: "own output",
|
||||
@@ -476,12 +833,15 @@ describe("Environment.executeTool — relaxed tool contract", () => {
|
||||
},
|
||||
});
|
||||
try {
|
||||
const sessionScratchpadDir = path.join(tmp, "scratch");
|
||||
const truncatedToolOutputRoot = path.join(sessionScratchpadDir, "truncated-tool-output");
|
||||
const env = new Environment({
|
||||
workspaceDir: tmp,
|
||||
toolConfig: {
|
||||
customTools: [{ name: NAME, description: "fwd", permission: "rw" }],
|
||||
customTools: [{ name: NAME, description: "fwd", permission: "rw", maxOutputLength: 10 }],
|
||||
mcpServers: [],
|
||||
},
|
||||
sessionScratchpadDir,
|
||||
});
|
||||
const out = await collect(
|
||||
env.executeTool({
|
||||
@@ -491,7 +851,7 @@ describe("Environment.executeTool — relaxed tool contract", () => {
|
||||
// The forwarded nested message keeps its origin and original payload.
|
||||
const forwarded = out.find((m) => m.origin?.length);
|
||||
expect(forwarded).toBeDefined();
|
||||
expect((forwarded!.payload as { output?: string }).output).toBe("child result");
|
||||
expect((forwarded!.payload as { output?: string }).output).toBe(childOutput);
|
||||
// This tool's own complete output contains only its own deltas, not mixed with the
|
||||
// child session's content.
|
||||
const completes = out.filter(
|
||||
@@ -500,6 +860,9 @@ describe("Environment.executeTool — relaxed tool contract", () => {
|
||||
expect(completes).toHaveLength(1);
|
||||
expect((completes[0]!.payload as { output?: string }).output).toBe("own output");
|
||||
expect((completes[0]!.payload as { stop_reason?: string }).stop_reason).toBe("completed");
|
||||
// The oversized child result belongs to the child's Session. The parent's own
|
||||
// output fits exactly, so the parent must not create a duplicate recovery archive.
|
||||
await expect(access(truncatedToolOutputRoot)).rejects.toThrow();
|
||||
} finally {
|
||||
delete BUILTIN_TOOL_FACTORIES[NAME];
|
||||
}
|
||||
|
||||
@@ -451,6 +451,43 @@ describe("read_file — bounded scan and output budget", () => {
|
||||
expect(text).toContain("sed -n");
|
||||
});
|
||||
|
||||
it.skipIf(process.platform !== "win32")(
|
||||
"accepts the same absolute path in Windows and POSIX spellings",
|
||||
async () => {
|
||||
await writeFile(path.join(tmp, "spellings.txt"), "both spellings work\n");
|
||||
const windowsSpelling = path.join(tmp, "spellings.txt");
|
||||
const posixSpelling = windowsSpelling.replaceAll("\\", "/");
|
||||
const tool = () => createReadFileTool(def(READ_FILE_NAME, "r"));
|
||||
for (const spelling of [windowsSpelling, posixSpelling]) {
|
||||
const { text } = await run(tool(), { file_path: spelling }, tmp);
|
||||
expect(text).toContain("both spellings work");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform !== "win32")(
|
||||
"names the workspace with forward slashes when a file is missing",
|
||||
async () => {
|
||||
const tool = () => createReadFileTool(def(READ_FILE_NAME, "r"));
|
||||
const { text } = await run(tool(), { file_path: "no-such-file.txt" }, tmp);
|
||||
expect(text).toContain("File not found");
|
||||
expect(text).not.toContain("\\");
|
||||
},
|
||||
);
|
||||
|
||||
it("confirms EOF for an unterminated line one byte below the scan cap", async () => {
|
||||
await writeFile(
|
||||
path.join(tmp, "scan-cap-minus-one.txt"),
|
||||
"x".repeat(READ_FILE_SCAN_CAP_BYTES - 1),
|
||||
);
|
||||
const tool = () => createReadFileTool(def(READ_FILE_NAME, "r"));
|
||||
const { result, text } = await run(tool(), { file_path: "scan-cap-minus-one.txt" }, tmp);
|
||||
expect(result?.stopReason).toBeUndefined();
|
||||
expect(text).toContain("[line truncated]");
|
||||
expect(text).not.toContain("file has more than");
|
||||
expect(text).not.toContain("Stopped after scanning");
|
||||
});
|
||||
|
||||
it("reports a lower-bound total when the file outruns the scan cap after the window", async () => {
|
||||
// > 8MB of two-byte lines: the window (1-2000) completes early, counting stops at the cap.
|
||||
await writeFile(path.join(tmp, "long.txt"), "x\n".repeat(READ_FILE_SCAN_CAP_BYTES / 2 + 4096));
|
||||
|
||||
@@ -15,7 +15,9 @@ import {
|
||||
goalFinishedOf,
|
||||
imageUrlMessage,
|
||||
isGoalRoundInput,
|
||||
modelVisiblePath,
|
||||
parseGoalMessage,
|
||||
sessionScratchpadDir,
|
||||
stripConversationMarkers,
|
||||
tokenUsage,
|
||||
userText,
|
||||
@@ -211,8 +213,14 @@ describe("[goal] marker parsing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("goal paths", () => {
|
||||
it("derives the goal file path from the scratchpad session directory", () => {
|
||||
describe("session scratchpad paths", () => {
|
||||
it("derives one Session's scratchpad directory", () => {
|
||||
expect(sessionScratchpadDir("/root", "p", "a", "s1")).toBe(
|
||||
path.join("/root", "p", "agents", "a", "scratchpad", "s1"),
|
||||
);
|
||||
});
|
||||
|
||||
it("derives the goal file path from the same Session scratchpad", () => {
|
||||
expect(goalFilePath("/root", "p", "a", "s1")).toBe(
|
||||
path.join("/root", "p", "agents", "a", "scratchpad", "s1", "GOAL.yaml"),
|
||||
);
|
||||
@@ -474,7 +482,7 @@ describe("Session.runGoal input", () => {
|
||||
// The picture is on disk, and both rounds point at that same file.
|
||||
const saved = await fs.readdir(path.join(dir, "scratchpad", "session-1"));
|
||||
expect(saved).toHaveLength(1);
|
||||
const line = `[attached image: ${path.join(dir, "scratchpad", "session-1", saved[0]!)}]`;
|
||||
const line = `[attached image: ${modelVisiblePath(path.join(dir, "scratchpad", "session-1", saved[0]!))}]`;
|
||||
for (const text of rounds) expect(text).toContain(line);
|
||||
// Round 2 re-injects the objective alone, which is where the line matters most: it
|
||||
// survives because stripLeadingMarkerBlocks only removes leading blocks, and the fold
|
||||
|
||||
@@ -11,6 +11,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { modelVisiblePath } from "../src/internal/model-visible-path.js";
|
||||
import { appendAttachmentLines, imagesToScratchpadPaths } from "../src/internal/session-support.js";
|
||||
import { Session } from "../src/index.js";
|
||||
import type {
|
||||
@@ -69,7 +70,7 @@ describe("imagesToScratchpadPaths", () => {
|
||||
const files = await readdir(dir);
|
||||
expect(files).toHaveLength(2);
|
||||
for (const f of paths) {
|
||||
expect(path.dirname(f)).toBe(dir);
|
||||
expect(path.dirname(f)).toBe(modelVisiblePath(dir));
|
||||
expect(path.basename(f)).toMatch(/^upload-[0-9a-f]{8}\.png$/);
|
||||
expect(await readFile(f)).toEqual(PNG_1X1);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { modelVisiblePath } from "../src/internal/model-visible-path.js";
|
||||
|
||||
describe("modelVisiblePath", () => {
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"passes POSIX paths through, including backslash filename characters",
|
||||
() => {
|
||||
expect(modelVisiblePath("/tmp/a path/report.log")).toBe("/tmp/a path/report.log");
|
||||
expect(modelVisiblePath("/tmp/we\\ird.log")).toBe("/tmp/we\\ird.log");
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform !== "win32")(
|
||||
"spells ordinary Windows and UNC paths with forward slashes",
|
||||
() => {
|
||||
expect(modelVisiblePath("C:\\Users\\x\\a path\\report.log")).toBe(
|
||||
"C:/Users/x/a path/report.log",
|
||||
);
|
||||
expect(modelVisiblePath("\\\\server\\share\\report.log")).toBe("//server/share/report.log");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
TRUNCATED_TOOL_OUTPUT_FILE_LIMIT_BYTES,
|
||||
TruncatedToolOutputArchive,
|
||||
} from "../src/environment/truncated-tool-output-archive.js";
|
||||
|
||||
let tmp: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmp = await mkdtemp(path.join(tmpdir(), "penguin-truncated-tool-output-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmp, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 });
|
||||
});
|
||||
|
||||
describe("TruncatedToolOutputArchive", () => {
|
||||
it("pins the production per-call budget", () => {
|
||||
expect(TRUNCATED_TOOL_OUTPUT_FILE_LIMIT_BYTES).toBe(8 * 1024 * 1024 - 1);
|
||||
});
|
||||
|
||||
it("writes an exact UTF-8 log with private permissions and leaves it Session-owned", async () => {
|
||||
const ordinaryDir = path.join(tmp, "ordinary-session-dir");
|
||||
const sessionDir = path.join(tmp, "archive-session-dir");
|
||||
await mkdir(ordinaryDir);
|
||||
const archive = new TruncatedToolOutputArchive({
|
||||
rootDir: path.join(sessionDir, "output"),
|
||||
fileLimitBytes: 128,
|
||||
});
|
||||
const capture = archive.startCapture();
|
||||
capture.append("hello ");
|
||||
capture.append("企鹅");
|
||||
// Split one surrogate pair across deltas: the recovery file must reconstruct the same text
|
||||
// instead of serializing two replacement characters.
|
||||
capture.append("\ud83d");
|
||||
capture.append("\udc27");
|
||||
// If another high surrogate arrives first, only the old one is known to be lone; the new one
|
||||
// must remain pending so it can still pair with the following low surrogate.
|
||||
capture.append("|");
|
||||
capture.append("\ud83d");
|
||||
capture.append("\ud83d");
|
||||
capture.append("\udc27");
|
||||
|
||||
const retained = capture as unknown as { exactChunks: Buffer[] };
|
||||
expect(retained.exactChunks.length).toBeGreaterThan(0);
|
||||
expect(retained.exactChunks.every((chunk) => Buffer.isBuffer(chunk))).toBe(true);
|
||||
|
||||
const saved = await capture.save("exec_command", "call/private");
|
||||
expect(saved.status).toBe("saved");
|
||||
if (saved.status !== "saved") throw new Error("expected saved output");
|
||||
expect(saved.archiveTruncated).toBe(false);
|
||||
expect(await readFile(saved.path, "utf8")).toBe("hello 企鹅🐧|�🐧");
|
||||
if (process.platform !== "win32") {
|
||||
expect((await stat(saved.path)).mode & 0o777).toBe(0o600);
|
||||
expect((await stat(path.dirname(saved.path))).mode & 0o777).toBe(0o700);
|
||||
expect((await stat(sessionDir)).mode & 0o777).toBe((await stat(ordinaryDir)).mode & 0o777);
|
||||
}
|
||||
|
||||
// The archive owns no Task/runtime cleanup. The host removes this whole directory through
|
||||
// the existing Session scratchpad deletion path.
|
||||
expect(await readFile(saved.path, "utf8")).toContain("hello");
|
||||
});
|
||||
|
||||
it("keeps UTF-8-safe head and tail windows when one archive exceeds its file budget", async () => {
|
||||
const fileLimitBytes = 96;
|
||||
const archive = new TruncatedToolOutputArchive({
|
||||
rootDir: path.join(tmp, "output"),
|
||||
fileLimitBytes,
|
||||
});
|
||||
const capture = archive.startCapture();
|
||||
const source = `HEAD-${"企鹅🐧".repeat(80)}-TAIL`;
|
||||
// Exercise incremental rolling-tail updates rather than one monolithic append.
|
||||
capture.append(source.slice(0, 70));
|
||||
capture.append(source.slice(70, 210));
|
||||
capture.append(source.slice(210));
|
||||
|
||||
const saved = await capture.save("describe/image", "unicode-call");
|
||||
expect(saved.status).toBe("saved");
|
||||
if (saved.status !== "saved") throw new Error("expected saved output");
|
||||
const archived = await readFile(saved.path, "utf8");
|
||||
expect(saved.archiveTruncated).toBe(true);
|
||||
expect(Buffer.byteLength(archived, "utf8")).toBeLessThanOrEqual(fileLimitBytes);
|
||||
expect(archived).toContain("HEAD-");
|
||||
expect(archived).toContain("-TAIL");
|
||||
expect(archived).toContain("[archive middle truncated]");
|
||||
expect(archived).not.toContain("\uFFFD");
|
||||
});
|
||||
|
||||
it("does not retain a huge delta's backing buffer after reducing it to bounded windows", async () => {
|
||||
const fileLimitBytes = 96;
|
||||
const archive = new TruncatedToolOutputArchive({
|
||||
rootDir: path.join(tmp, "output"),
|
||||
fileLimitBytes,
|
||||
});
|
||||
const capture = archive.startCapture();
|
||||
capture.append(`HEAD-${"x".repeat(16 * 1024 * 1024)}-TAIL`);
|
||||
|
||||
const retained = capture as unknown as {
|
||||
exactChunks: Buffer[];
|
||||
head: Buffer;
|
||||
tail: Buffer;
|
||||
tailLength: number;
|
||||
};
|
||||
expect(retained.exactChunks).toEqual([]);
|
||||
expect(retained.head.length + retained.tailLength).toBeLessThanOrEqual(fileLimitBytes);
|
||||
// A short subarray of the original 16 MiB Buffer would pass the length assertion while
|
||||
// still pinning the entire ArrayBuffer. Node may use its fixed small-buffer pool for this
|
||||
// tiny test override, but neither result may retain the huge source.
|
||||
const largestBoundedBackingStore = Math.max(fileLimitBytes, Buffer.poolSize);
|
||||
expect(retained.head.buffer.byteLength).toBeLessThanOrEqual(largestBoundedBackingStore);
|
||||
expect(retained.tail.buffer.byteLength).toBeLessThanOrEqual(largestBoundedBackingStore);
|
||||
|
||||
const saved = await capture.save("tool", "huge-single-delta");
|
||||
expect(saved.status).toBe("saved");
|
||||
if (saved.status !== "saved") throw new Error("expected saved output");
|
||||
const archived = await readFile(saved.path, "utf8");
|
||||
expect(Buffer.byteLength(archived, "utf8")).toBeLessThanOrEqual(fileLimitBytes);
|
||||
expect(archived).toContain("HEAD-");
|
||||
expect(archived).toContain("-TAIL");
|
||||
});
|
||||
|
||||
it("reuses fixed tail storage across many small post-promotion deltas", async () => {
|
||||
const archive = new TruncatedToolOutputArchive({
|
||||
rootDir: path.join(tmp, "output"),
|
||||
fileLimitBytes: 128,
|
||||
});
|
||||
const capture = archive.startCapture();
|
||||
capture.append("H".repeat(256));
|
||||
const retained = capture as unknown as {
|
||||
tail: Buffer;
|
||||
tailLength: number;
|
||||
};
|
||||
const tailStorage = retained.tail;
|
||||
// Vary encoded widths and split emoji pairs across deltas so ring wrap-around must preserve
|
||||
// the same UTF-8 and surrogate semantics as the exact phase.
|
||||
const pattern = ["企", "鹅", "\ud83d", "\udc27", "|"];
|
||||
const chunks = Array.from({ length: 4096 }, (_, index) => pattern[index % pattern.length]!);
|
||||
for (const chunk of chunks) capture.append(chunk);
|
||||
|
||||
expect(retained.tail).toBe(tailStorage);
|
||||
expect(retained.tailLength).toBeLessThanOrEqual(tailStorage.length);
|
||||
|
||||
const saved = await capture.save("tool", "many-small-deltas");
|
||||
expect(saved.status).toBe("saved");
|
||||
if (saved.status !== "saved") throw new Error("expected saved output");
|
||||
const archived = await readFile(saved.path, "utf8");
|
||||
const archivedTail = archived.split("[archive middle truncated]\n")[1] ?? "";
|
||||
expect(chunks.join("").endsWith(archivedTail)).toBe(true);
|
||||
expect(archived).not.toContain("\uFFFD");
|
||||
});
|
||||
|
||||
it("allows independent captures without imposing an aggregate Session budget", async () => {
|
||||
const archive = new TruncatedToolOutputArchive({
|
||||
rootDir: path.join(tmp, "output"),
|
||||
fileLimitBytes: 64,
|
||||
});
|
||||
const captures = [archive.startCapture(), archive.startCapture(), archive.startCapture()];
|
||||
captures.forEach((capture, index) => capture.append(`output-${index}`));
|
||||
|
||||
const saved = await Promise.all(
|
||||
captures.map((capture, index) => capture.save("tool", `call-${index}`)),
|
||||
);
|
||||
expect(saved.every((result) => result.status === "saved")).toBe(true);
|
||||
});
|
||||
|
||||
it("reports a write failure without throwing", async () => {
|
||||
const rootFile = path.join(tmp, "not-a-directory");
|
||||
await writeFile(rootFile, "occupied", "utf8");
|
||||
const archive = new TruncatedToolOutputArchive({
|
||||
rootDir: rootFile,
|
||||
fileLimitBytes: 64,
|
||||
});
|
||||
const capture = archive.startCapture();
|
||||
capture.append("output");
|
||||
const stderr: string[] = [];
|
||||
const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation((chunk) => {
|
||||
stderr.push(String(chunk));
|
||||
return true;
|
||||
});
|
||||
try {
|
||||
const result = await capture.save("tool", "call");
|
||||
expect(result.status).toBe("failed");
|
||||
if (result.status !== "failed") throw new Error("expected failed output");
|
||||
expect(typeof result.code).toBe("string");
|
||||
expect(stderr.join("")).toContain(
|
||||
`[penguin] tool "tool" truncated output archive write failed (${result.code}).`,
|
||||
);
|
||||
} finally {
|
||||
stderrSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("distinguishes a repeated save from an archive write failure", async () => {
|
||||
const archive = new TruncatedToolOutputArchive({
|
||||
rootDir: path.join(tmp, "output"),
|
||||
fileLimitBytes: 64,
|
||||
});
|
||||
const capture = archive.startCapture();
|
||||
capture.append("output");
|
||||
expect((await capture.save("tool", "call")).status).toBe("saved");
|
||||
await expect(capture.save("tool", "call")).resolves.toEqual({
|
||||
status: "failed",
|
||||
code: "ALREADY_SAVED",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user