Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/explanation/execution-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,14 @@ remaining (incomplete) steps in order. This preserves accumulated context across
step boundaries, avoiding the re-hydration cost of spawning a fresh worker per
step.

Runtime V2 passes composed worker, reviewer, and merger system prompts to pi
through `--system-prompt <file>`. This preserves prompt replacement semantics
while keeping prompt text off the operating system's command line, including
Windows' command-line length limit. Each spawn gets a private, unique file in
its agent runtime directory (or the OS temporary directory without registry
integration). The file remains available until the child exits, is removed
after successful completion, and is retained after failure for diagnostics.

Each iteration:

1. Identify all incomplete steps
Expand Down
36 changes: 35 additions & 1 deletion extensions/taskplane/agent-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,16 @@ import {
writeFileSync,
appendFileSync,
mkdirSync,
mkdtempSync,
existsSync,
readdirSync,
renameSync,
rmSync,
} from "fs";
import { join, dirname, basename, resolve } from "path";
import { tmpdir } from "os";
import { StringDecoder } from "string_decoder";
import { runtimeAgentDir } from "./types.ts";

import type {
RuntimeAgentId,
Expand Down Expand Up @@ -452,7 +456,28 @@ export function spawnAgent(
const piArgs: string[] = [cliPath, "--mode", "rpc", "--no-session"];
if (opts.model) piArgs.push("--model", opts.model);
if (opts.tools) piArgs.push("--tools", opts.tools);
if (opts.systemPrompt) piArgs.push("--system-prompt", opts.systemPrompt);
let systemPromptDir: string | undefined;
if (opts.systemPrompt) {
// Pi's --system-prompt accepts a file path and still replaces its default
// prompt. Keep the composed text off argv to avoid OS command-line limits.
const promptRoot = opts.stateRoot
? runtimeAgentDir(opts.stateRoot, opts.batchId, opts.agentId)
: tmpdir();
mkdirSync(promptRoot, { recursive: true });
systemPromptDir = mkdtempSync(join(resolve(promptRoot), "system-prompt-"));
const promptPath = join(systemPromptDir, "prompt.md");
try {
writeFileSync(promptPath, opts.systemPrompt, { encoding: "utf-8", mode: 0o600 });
} catch (err) {
try {
rmSync(systemPromptDir, { recursive: true, force: true });
} catch {
/* best effort — preserve the original write error */
}
throw err;
}
piArgs.push("--system-prompt", promptPath);
}
// Always pass --no-extensions to prevent auto-discovery from cwd.
// Explicit -e entries are still honored by pi even with --no-extensions.
// This matches the fix from TP-095 that eliminated duplicate extension loading.
Expand Down Expand Up @@ -724,6 +749,15 @@ export function spawnAgent(
if (finished) return;
finished = true;
if (timeoutHandle) clearTimeout(timeoutHandle);
// Keep the file available for the entire child lifetime (including
// reloads and intercepted exits). Retain failed runs for diagnostics.
if (systemPromptDir && exitCode === 0 && agentEnded && !killed) {
try {
rmSync(systemPromptDir, { recursive: true, force: true });
} catch {
/* best effort */
}
}

const result: AgentHostResult = {
exitCode,
Expand Down
159 changes: 159 additions & 0 deletions extensions/tests/agent-host-system-prompt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, isAbsolute, join } from "node:path";
import { afterEach, beforeEach, describe, it, mock } from "node:test";

import type { AgentHostOptions } from "../taskplane/agent-host.ts";
import { runtimeAgentDir } from "../taskplane/types.ts";

let cliPath: string;
const resolver = await import("../taskplane/path-resolver.ts");
mock.module("../taskplane/path-resolver.ts", {
namedExports: { ...resolver, resolvePiCliPath: () => cliPath },
});
const { spawnAgent } = await import("../taskplane/agent-host.ts");

// A real Node child uses Pi's text-or-file argument contract, then completes
// one RPC turn. No child_process mock or model credentials are involved.
const CHILD = `
const fs = require("node:fs");
const crypto = require("node:crypto");
const readline = require("node:readline");
const args = process.argv.slice(2);
const index = args.indexOf("--system-prompt");
const argument = index >= 0 ? args[index + 1] : null;
const fromFile = argument !== null && fs.existsSync(argument);
const content = fromFile ? fs.readFileSync(argument, "utf8") : argument;
fs.writeFileSync(process.env.PROMPT_CAPTURE, JSON.stringify({
argument, fromFile, args,
digest: content === null ? null : crypto.createHash("sha256").update(content).digest("hex"),
mode: fromFile ? fs.statSync(argument).mode & 0o777 : null,
}));
const input = readline.createInterface({ input: process.stdin });
input.on("line", (line) => {
if (JSON.parse(line).type === "prompt") {
process.stdout.write(JSON.stringify({ type: "agent_end" }) + "\\n");
}
});
input.on("close", () => { process.exitCode = Number(process.env.PROMPT_EXIT_CODE || 0); });
`;

interface Capture {
argument: string | null;
fromFile: boolean;
args: string[];
digest: string | null;
mode: number | null;
}

describe("agent-host system prompt transport (#612)", () => {
let root: string;
let originalTmpdir: string | undefined;
let opts: AgentHostOptions;

beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "tp system-prompt-"));
originalTmpdir = process.env.TMPDIR;
process.env.TMPDIR = root;
cliPath = join(root, "fake-pi.cjs");
writeFileSync(cliPath, CHILD);
const cwd = join(root, "worktree");
mkdirSync(cwd);
opts = {
agentId: "orch-test-lane-1-worker",
role: "worker",
batchId: "prompt-file-test",
laneNumber: 1,
taskId: "TP-612",
repoId: "default",
cwd,
prompt: "Run one fixture turn",
stateRoot: root,
closeDelayMs: 0,
timeoutMs: 5000,
env: { PROMPT_CAPTURE: join(root, "capture.json") },
};
});

afterEach(() => {
if (originalTmpdir === undefined) delete process.env.TMPDIR;
else process.env.TMPDIR = originalTmpdir;
rmSync(root, { recursive: true, force: true });
});

function capture(path = opts.env!.PROMPT_CAPTURE): Capture {
return JSON.parse(readFileSync(path, "utf-8"));
}

function assertPrompt(actual: Capture, prompt: string): void {
assert.equal(actual.fromFile, true, "the child must receive a prompt file, not inline text");
assert.ok(actual.argument);
assert.ok(isAbsolute(actual.argument), "the child's worktree differs from the state root");
assert.equal(actual.digest, createHash("sha256").update(prompt).digest("hex"));
assert.ok(actual.args.join(" ").length < 32767);
assert.equal(actual.args.includes("--append-system-prompt"), false);
if (process.platform !== "win32") assert.equal(actual.mode, 0o600);
}

for (const role of ["worker", "reviewer", "merger"] as const) {
it(`passes a 60K ${role} prompt through a private file and removes it after success`, async () => {
opts.role = role;
opts.agentId = `orch-test-${role}`;
opts.systemPrompt = 'Project rules: "quotes", 中文, backslash \\\n'.repeat(1800);
const result = await spawnAgent(opts).promise;
assert.equal(result.exitCode, 0, result.error ?? result.stderrTail);
assert.equal(result.agentEnded, true);
const actual = capture();
assertPrompt(actual, opts.systemPrompt);
assert.ok(actual.argument!.startsWith(runtimeAgentDir(root, opts.batchId, opts.agentId)));
assert.equal(existsSync(dirname(actual.argument!)), false);
});
}

it("spawns with a 256 KiB prompt without registry integration", async () => {
opts.stateRoot = null;
opts.systemPrompt = "x".repeat(256 * 1024);
const result = await spawnAgent(opts).promise;
assert.equal(result.exitCode, 0, result.error ?? result.stderrTail);
const actual = capture();
assertPrompt(actual, opts.systemPrompt);
assert.equal(existsSync(dirname(actual.argument!)), false);
});

for (const systemPrompt of [undefined, ""]) {
it(`preserves Pi's default prompt for ${systemPrompt === undefined ? "omitted" : "empty"} overrides`, async () => {
opts.systemPrompt = systemPrompt;
const result = await spawnAgent(opts).promise;
assert.equal(result.exitCode, 0, result.error ?? result.stderrTail);
assert.equal(capture().argument, null);
});
}

it("isolates overlapping spawns that reuse an agent identity", async () => {
const second = {
...opts,
systemPrompt: "second prompt",
env: { PROMPT_CAPTURE: join(root, "second.json") },
};
opts.systemPrompt = "first prompt";
const results = await Promise.all([spawnAgent(opts).promise, spawnAgent(second).promise]);
for (const result of results) assert.equal(result.exitCode, 0, result.error ?? result.stderrTail);
const firstCapture = capture();
const secondCapture = capture(second.env.PROMPT_CAPTURE);
assertPrompt(firstCapture, opts.systemPrompt);
assertPrompt(secondCapture, second.systemPrompt);
assert.notEqual(firstCapture.argument, secondCapture.argument);
});

it("retains the prompt file when the child exits with an error", async () => {
opts.systemPrompt = "retain this diagnostic prompt";
opts.env!.PROMPT_EXIT_CODE = "7";
const result = await spawnAgent(opts).promise;
assert.equal(result.exitCode, 7);
const actual = capture();
assertPrompt(actual, opts.systemPrompt);
assert.equal(readFileSync(actual.argument!, "utf-8"), opts.systemPrompt);
});
});