From 1ff5b6fddf69c322c6937781a720f97e87c93774 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 29 Sep 2026 18:06:34 +0200 Subject: [PATCH] feat(coding-agent): return up to 1 MiB of bash output to codemode scripts Structured bash results now hold the full output up to 1 MiB instead of the model-facing 2000 lines or 50KB, keeping the first and last 512 KiB of longer output, and add truncated and full_output_path. --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/cli.md | 2 +- packages/coding-agent/src/core/tools/bash.ts | 20 +++++++-- .../src/core/tools/output-accumulator.ts | 41 +++++++++++++++++++ packages/coding-agent/test/tools.test.ts | 30 ++++++++++++++ 5 files changed, 90 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 3558f3536..8e1062327 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -23,6 +23,7 @@ - Built-in extensions and tools are named `builtin:` (for example `builtin:mcp` and `builtin:read`) in errors, diagnostics, RPC source info, and bug reports, instead of `` and ``. Their slash commands no longer carry a `[t]` autocomplete tag. - `--no-extensions` also disables the built-in extensions, including the llama.cpp provider. Load one explicitly with `-e builtin:`, for example `pi -ne -e builtin:mcp`. - Tool calls without a custom call renderer, including direct MCP tool calls, now show their arguments: as `key=value` pairs on the title line when collapsed and one `key: value` line per argument when expanded. MCP calls are titled `server/tool` and their results collapse to 5 lines. +- `bash` and `powershell` structured results, which codemode scripts receive, now hold up to 1 MiB of output instead of the model-facing 2000 lines or 50KB, and add `truncated` and `full_output_path`. Longer output keeps its first and last 512 KiB. Empty output is `""` instead of `(no output)`. ### Fixed diff --git a/packages/coding-agent/docs/cli.md b/packages/coding-agent/docs/cli.md index ad083099b..a9dcd0c79 100644 --- a/packages/coding-agent/docs/cli.md +++ b/packages/coding-agent/docs/cli.md @@ -173,7 +173,7 @@ While `codemode` is active, `codemode.mode` in [settings](settings.md#tools) dec The `codemode` description lists the callable tools with their TypeScript declarations, grouped by namespace (for example one MCP server). Declarations share a budget of 3000 estimated tokens (`codemode.inlineBudget` in [settings](settings.md#tools)); every namespace is still listed with its tool count, and the description says whether the list is complete. Scripts find the rest with `await searchTools(query, { limit, namespace })`, which ranks tools with BM25, and `await describeTool(name)`, or by filtering `ALL_TOOLS`. -Tools with an output schema resolve to structured values: `bash` to `{ output, exit_code, wall_time_seconds }`, also for non-zero exit codes, and MCP tools to their `CallToolResult`. Other tools resolve to their text output. +Tools with an output schema resolve to structured values: `bash` to `{ output, truncated, full_output_path?, exit_code, wall_time_seconds }`, also for non-zero exit codes, and MCP tools to their `CallToolResult`. Other tools resolve to their text output. The `output` of `bash` is not limited to the 2000 lines or 50KB the model sees: it holds up to 1 MiB, and longer output keeps its first and last 512 KiB around an omission marker, with `truncated` set and the full output in `full_output_path`. `store(key, value)` and `load(key)` keep JSON values across `codemode` calls: each successful script that stores values appends a `codemode-store` custom entry to the session, so resumed sessions keep the values and each branch sees only the values written on its path. Scripts can also use `models`: `getModelsOfType`, `getAvailableOfType`, and `getModelOfType` list the model catalog, and `classify(model, context)` runs a classifier model with the session's credentials, at most four at a time per script. diff --git a/packages/coding-agent/src/core/tools/bash.ts b/packages/coding-agent/src/core/tools/bash.ts index b74e4ad58..45bd9df80 100644 --- a/packages/coding-agent/src/core/tools/bash.ts +++ b/packages/coding-agent/src/core/tools/bash.ts @@ -20,6 +20,8 @@ import { wrapToolDefinition } from "./tool-definition-wrapper.ts"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult } from "./truncate.ts"; const MAX_TIMEOUT_MS = 2_147_483_647; +/** Output limit of `structuredContent.output`, which programmatic callers such as codemode scripts receive. */ +const STRUCTURED_OUTPUT_MAX_BYTES = 1024 * 1024; const MAX_TIMEOUT_SECONDS = MAX_TIMEOUT_MS / 1000; function resolveTimeoutMs(timeout: number | undefined): number | undefined { @@ -49,9 +51,15 @@ export type BashToolInput = Static; /** * Result for programmatic callers such as codemode scripts. A non-zero exit code is an error result for the model, but scripts still resolve to this value. + * `output` is not limited like the model-facing output: callers decide how much of it reaches the model. */ const bashOutputSchema = Type.Object({ - output: Type.String({ description: "Combined stdout and stderr, truncated like the model-facing output" }), + output: Type.String({ + description: + "Combined stdout and stderr, up to 1 MiB. Longer output keeps its first and last 512 KiB around an omission marker.", + }), + truncated: Type.Boolean({ description: "Whether `output` omits part of the command output" }), + full_output_path: Type.Optional(Type.String({ description: "Temp file with the full output, when truncated" })), exit_code: Type.Number(), wall_time_seconds: Type.Number(), }); @@ -381,10 +389,16 @@ export function createShellToolDefinition( if (exitCode === null) { throw new Error(appendStatus(outputText, "Command terminated without an exit code")); } + const wallTimeSeconds = Math.round((performance.now() - startedAt) / 100) / 10; + const fullOutput = await output.readFullOutput(STRUCTURED_OUTPUT_MAX_BYTES); const structuredContent: BashToolOutput = { - output: outputText, + output: fullOutput.content, + truncated: fullOutput.truncated, + ...(fullOutput.truncated && snapshot.fullOutputPath + ? { full_output_path: snapshot.fullOutputPath } + : {}), exit_code: exitCode, - wall_time_seconds: Math.round((performance.now() - startedAt) / 100) / 10, + wall_time_seconds: wallTimeSeconds, }; if (exitCode !== 0) { return { diff --git a/packages/coding-agent/src/core/tools/output-accumulator.ts b/packages/coding-agent/src/core/tools/output-accumulator.ts index e13fe458e..da4e5bbfd 100644 --- a/packages/coding-agent/src/core/tools/output-accumulator.ts +++ b/packages/coding-agent/src/core/tools/output-accumulator.ts @@ -1,5 +1,6 @@ import { randomBytes } from "node:crypto"; import { createWriteStream, type WriteStream } from "node:fs"; +import { open } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, type TruncationResult, truncateTail } from "./truncate.ts"; @@ -16,6 +17,12 @@ export interface OutputSnapshot { fullOutputPath?: string; } +export interface FullOutput { + content: string; + /** Whether `content` omits part of the output. */ + truncated: boolean; +} + function defaultTempFilePath(prefix: string): string { const id = randomBytes(8).toString("hex"); return join(tmpdir(), `${prefix}-${id}.log`); @@ -141,6 +148,40 @@ export class OutputAccumulator { }); } + /** + * The complete output, for callers that can take more than the display snapshot. Call after + * `finish()` and `closeTempFile()`. Output longer than `maxBytes` raw bytes keeps its first and + * last `maxBytes / 2` bytes around an omission marker. + */ + async readFullOutput(maxBytes: number): Promise { + if (!this.tempFilePath) { + return { content: new TextDecoder().decode(Buffer.concat(this.rawChunks)), truncated: false }; + } + const file = await open(this.tempFilePath, "r"); + try { + const size = (await file.stat()).size; + if (size <= maxBytes) { + return { content: new TextDecoder().decode(await file.readFile()), truncated: false }; + } + const headBytes = Math.floor(maxBytes / 2); + const tailBytes = maxBytes - headBytes; + const head = Buffer.alloc(headBytes); + const tail = Buffer.alloc(tailBytes); + await file.read(head, 0, headBytes, 0); + await file.read(tail, 0, tailBytes, size - tailBytes); + // Cut at character boundaries: streaming decode holds back an incomplete trailing sequence, + // and the tail skips leading continuation bytes. + const headText = new TextDecoder().decode(head, { stream: true }); + let tailStart = 0; + while (tailStart < tail.length && (tail[tailStart] & 0xc0) === 0x80) tailStart++; + const tailText = new TextDecoder().decode(tail.subarray(tailStart)); + const omitted = size - headBytes - tailBytes; + return { content: `${headText}\n\n[... ${omitted} bytes omitted ...]\n\n${tailText}`, truncated: true }; + } finally { + await file.close(); + } + } + getLastLineBytes(): number { return this.currentLineBytes; } diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index bb24e90ee..eb9e0a8ad 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -496,6 +496,7 @@ describe("Coding Agent Tools", () => { expect(getTextOutput(result)).toBe("out\n\n\nCommand exited with code 3"); expect(result.structuredContent).toEqual({ output: "out\n", + truncated: false, exit_code: 3, wall_time_seconds: expect.any(Number), }); @@ -503,6 +504,35 @@ describe("Coding Agent Tools", () => { const ok = await bashTool.execute("test-call-9b", { command: "echo fine" }); expect(ok.isError).toBeUndefined(); expect(ok.structuredContent).toMatchObject({ output: "fine\n", exit_code: 0 }); + + const empty = await bashTool.execute("test-call-9c", { command: "true" }); + expect(getTextOutput(empty)).toBe("(no output)"); + expect(empty.structuredContent).toMatchObject({ output: "", truncated: false }); + }); + + it("should return up to 1 MiB of output in structured content", async () => { + // 3000 lines exceed the model-facing 2000 line limit but not 1 MiB. + const medium = await bashTool.execute("test-call-9d", { command: "seq 1 3000" }); + expect(getTextOutput(medium)).not.toContain("\n1\n2\n"); + expect(medium.details?.truncation?.truncated).toBe(true); + const mediumOutput = medium.structuredContent as { output: string; truncated: boolean }; + expect(mediumOutput.truncated).toBe(false); + expect(mediumOutput.output).toBe(`${Array.from({ length: 3000 }, (_, i) => i + 1).join("\n")}\n`); + + // About 2 MB: keeps the first and last 512 KiB around an omission marker. + const large = await bashTool.execute("test-call-9e", { command: "seq 1 300000" }); + const largeOutput = large.structuredContent as { + output: string; + truncated: boolean; + full_output_path?: string; + }; + expect(largeOutput.truncated).toBe(true); + expect(largeOutput.output.startsWith("1\n2\n3\n")).toBe(true); + expect(largeOutput.output.endsWith("299999\n300000\n")).toBe(true); + expect(largeOutput.output).toMatch(/\n\n\[\.\.\. \d+ bytes omitted \.\.\.\]\n\n/); + expect(Buffer.byteLength(largeOutput.output)).toBeLessThan(1024 * 1024 + 100); + expect(largeOutput.full_output_path).toBe(large.details?.fullOutputPath); + expect(readFileSync(largeOutput.full_output_path!, "utf-8").endsWith("300000\n")).toBe(true); }); // Regression tests for https://github.com/earendil-works/pi/issues/9577