diff --git a/docs/explanation/execution-model.md b/docs/explanation/execution-model.md index 71486976..07cc4fdd 100644 --- a/docs/explanation/execution-model.md +++ b/docs/explanation/execution-model.md @@ -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 `. 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 diff --git a/extensions/taskplane/agent-host.ts b/extensions/taskplane/agent-host.ts index 7816e514..36e1b9db 100644 --- a/extensions/taskplane/agent-host.ts +++ b/extensions/taskplane/agent-host.ts @@ -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, @@ -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. @@ -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, diff --git a/extensions/tests/agent-host-system-prompt.test.ts b/extensions/tests/agent-host-system-prompt.test.ts new file mode 100644 index 00000000..1eb76a0c --- /dev/null +++ b/extensions/tests/agent-host-system-prompt.test.ts @@ -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); + }); +});