mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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.
This commit is contained in:
parent
02eed88fd8
commit
1ff5b6fddf
5 changed files with 90 additions and 4 deletions
|
|
@ -23,6 +23,7 @@
|
|||
- Built-in extensions and tools are named `builtin:<name>` (for example `builtin:mcp` and `builtin:read`) in errors, diagnostics, RPC source info, and bug reports, instead of `<inline:name>` and `<builtin:name>`. 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:<name>`, 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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<typeof bashSchema>;
|
|||
|
||||
/**
|
||||
* 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 {
|
||||
|
|
|
|||
|
|
@ -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<FullOutput> {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue