diff --git a/docs/providers.md b/docs/providers.md index 537acd5ed..a87fbfc41 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -24,7 +24,7 @@ Paseo tools are not implemented as MCP tools internally. They live in a shared t Pi is a process-backed provider. Paseo requires the user to have the `pi` binary installed and talks to it through `pi --mode rpc`; the server package does not embed Pi's SDK/runtime packages. -Paseo's per-agent and daemon-wide system prompts are passed to Pi with `--append-system-prompt`, so Pi keeps its default coding prompt while receiving Paseo's additional instructions. +Paseo's per-agent and daemon-wide system prompts are appended by its generated Pi integration extension. Paseo deliberately does not pass `--append-system-prompt`, because that flag replaces Pi's automatic `APPEND_SYSTEM.md` discovery instead of composing with it. Pi model records expose input capabilities through `model.input`. Only send raw RPC `images` when the current model explicitly includes `"image"` in that list. Text-only Pi/OMP models reject image content and persist the rejected image in JSONL history, so image prompts for those models must be materialized to a local file and passed as a text path hint instead. diff --git a/packages/server/src/server/agent/providers/pi/agent.test.ts b/packages/server/src/server/agent/providers/pi/agent.test.ts index 889a45204..88a3f6cb5 100644 --- a/packages/server/src/server/agent/providers/pi/agent.test.ts +++ b/packages/server/src/server/agent/providers/pi/agent.test.ts @@ -13,6 +13,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import pino from "pino"; import { setImmediate as waitForImmediate } from "node:timers/promises"; +import { pathToFileURL } from "node:url"; import { describe, expect, onTestFinished, test } from "vitest"; import type { AgentSession, AgentSessionConfig, AgentStreamEvent } from "../../agent-sdk-types.js"; @@ -55,6 +56,26 @@ function readUtf8File(pathname: string): string { closeSync(fd); } } + +async function applyPaseoExtensionSystemPrompt( + extensionPath: string, + systemPrompt: string, +): Promise { + const listeners = new Map unknown>(); + const extension = (await import(pathToFileURL(extensionPath).href)) as { + default: (piApi: { + on: (event: string, listener: (event: { systemPrompt: string }) => unknown) => void; + registerCommand: () => void; + }) => void; + }; + extension.default({ + on: (event, listener) => listeners.set(event, listener), + registerCommand: () => undefined, + }); + const result = await listeners.get("before_agent_start")?.({ systemPrompt }); + return (result as { systemPrompt?: string } | undefined)?.systemPrompt; +} + async function flushTurnScheduling(): Promise { await waitForImmediate(); } @@ -713,11 +734,11 @@ describe("PiRpcAgentSession", () => { ]); }); - test("creates Pi sessions with agent and daemon system prompts appended", async () => { + test("appends agent and daemon prompts after Pi's discovered system prompt", async () => { const pi = new FakePi(); const client = createClient(pi); - await client.createSession( + const session = await client.createSession( createConfig({ systemPrompt: "Agent prompt", daemonAppendSystemPrompt: "Daemon prompt", @@ -727,7 +748,6 @@ describe("PiRpcAgentSession", () => { const actualLaunch = pi.recordedLaunches[0]!; expect(actualLaunch).toMatchObject({ cwd: "/tmp/paseo-pi-rpc-test", - systemPrompt: "Agent prompt\n\nDaemon prompt", }); expect(actualLaunch.extensionPaths).toHaveLength(1); expect(actualLaunch.argv).toEqual([ @@ -736,11 +756,15 @@ describe("PiRpcAgentSession", () => { "rpc", "--thinking", "medium", - "--append-system-prompt", - "Agent prompt\n\nDaemon prompt", "--extension", actualLaunch.extensionPaths[0], ]); + + await expect( + applyPaseoExtensionSystemPrompt(actualLaunch.extensionPaths[0]!, "Pi project prompt"), + ).resolves.toBe("Pi project prompt\n\nAgent prompt\n\nDaemon prompt"); + + await session.close(); }); test("resumes Pi sessions with daemon system prompts appended", async () => { @@ -769,7 +793,6 @@ describe("PiRpcAgentSession", () => { expect(actualLaunch).toMatchObject({ cwd: "/workspace/project", session: "/tmp/native-pi-session", - systemPrompt: "Agent prompt\n\nDaemon prompt", }); expect(actualLaunch.extensionPaths).toHaveLength(1); expect(actualLaunch.argv).toEqual([ @@ -782,11 +805,12 @@ describe("PiRpcAgentSession", () => { "high", "--session", "/tmp/native-pi-session", - "--append-system-prompt", - "Agent prompt\n\nDaemon prompt", "--extension", actualLaunch.extensionPaths[0], ]); + await expect( + applyPaseoExtensionSystemPrompt(actualLaunch.extensionPaths[0]!, "Pi project prompt"), + ).resolves.toBe("Pi project prompt\n\nAgent prompt\n\nDaemon prompt"); }); test("updates model and thinking through Pi runtime commands", async () => { diff --git a/packages/server/src/server/agent/providers/pi/agent.ts b/packages/server/src/server/agent/providers/pi/agent.ts index d5fcec39d..f1f55d600 100644 --- a/packages/server/src/server/agent/providers/pi/agent.ts +++ b/packages/server/src/server/agent/providers/pi/agent.ts @@ -535,10 +535,6 @@ function buildResumeStartInput(input: { session: input.sessionFile, model: input.resumeConfig.model, thinkingOptionId: normalizePiThinkingOption(input.resumeConfig.thinkingOptionId) ?? undefined, - systemPrompt: composeSystemPromptParts( - input.resumeConfig.config.systemPrompt, - input.resumeConfig.config.daemonAppendSystemPrompt, - ), mcpConfigPath: input.mcpConfig?.path, extensionPaths: input.paseoExtension ? [input.paseoExtension.path] : undefined, }; @@ -630,7 +626,7 @@ function createPiMcpConfigFile( }; } -function createPiPaseoExtensionFile(): PiTempFile { +function createPiPaseoExtensionFile(systemPrompt?: string): PiTempFile { const dir = mkdtempSync(join(tmpdir(), "paseo-pi-extension-")); const filePath = join(dir, "paseo-integration.mjs"); writeFileSync( @@ -680,6 +676,14 @@ function createPiPaseoExtensionFile(): PiTempFile { } export default function paseoIntegration(pi) { + ${ + systemPrompt + ? `pi.on("before_agent_start", async (event) => ({ + systemPrompt: event.systemPrompt + "\\n\\n" + ${JSON.stringify(systemPrompt)}, + }));` + : "" + } + pi.on("session_start", async (_event, ctx) => { emitEntryCapture(ctx, "session_start"); }); @@ -2307,7 +2311,9 @@ export class PiRpcAgentClient implements AgentClient { ...launchContext?.env, }; const mcpConfig = await this.prepareMcpConfig(config.cwd, config.mcpServers, mcpEnv); - const paseoExtension = createPiPaseoExtensionFile(); + const paseoExtension = createPiPaseoExtensionFile( + composeSystemPromptParts(config.systemPrompt, config.daemonAppendSystemPrompt), + ); let runtimeSession: PiRuntimeSession; try { runtimeSession = await this.runtime.startSession({ @@ -2316,10 +2322,6 @@ export class PiRpcAgentClient implements AgentClient { thinkingOptionId: normalizePiThinkingOption(config.thinkingOptionId) ?? DEFAULT_PI_THINKING_LEVEL, noSession: config.internal === true, - systemPrompt: composeSystemPromptParts( - config.systemPrompt, - config.daemonAppendSystemPrompt, - ), env: launchContext?.env, mcpConfigPath: mcpConfig?.path, extensionPaths: paseoExtension ? [paseoExtension.path] : undefined, @@ -2368,7 +2370,12 @@ export class PiRpcAgentClient implements AgentClient { resumeConfig.config.mcpServers, mcpEnv, ); - const paseoExtension = createPiPaseoExtensionFile(); + const paseoExtension = createPiPaseoExtensionFile( + composeSystemPromptParts( + resumeConfig.config.systemPrompt, + resumeConfig.config.daemonAppendSystemPrompt, + ), + ); let runtimeSession: PiRuntimeSession; try { runtimeSession = await this.runtime.startSession( diff --git a/packages/server/src/server/agent/providers/pi/cli-runtime.test.ts b/packages/server/src/server/agent/providers/pi/cli-runtime.test.ts index f3b2af84a..b62627105 100644 --- a/packages/server/src/server/agent/providers/pi/cli-runtime.test.ts +++ b/packages/server/src/server/agent/providers/pi/cli-runtime.test.ts @@ -229,26 +229,6 @@ describe("PiCliRuntime", () => { ]); }); - test("passes an appended system prompt to Pi", async () => { - const child = createPiChild(); - replyToCommands(child, () => ({})); - const launches: PiRuntimeLaunch[] = []; - const runtime = createRuntime(child, launches); - - await runtime.startSession({ - cwd: "/workspace/project", - systemPrompt: " Use the daemon prompt. ", - }); - - expect(launches).toEqual([ - expect.objectContaining({ - cwd: "/workspace/project", - systemPrompt: "Use the daemon prompt.", - argv: ["pi", "--mode", "rpc", "--append-system-prompt", "Use the daemon prompt."], - }), - ]); - }); - test("delivers events separately from command responses", async () => { const child = createPiChild(); replyToCommands(child, () => ({ models: [] })); diff --git a/packages/server/src/server/agent/providers/pi/runtime.ts b/packages/server/src/server/agent/providers/pi/runtime.ts index 6d50693ed..2d4800c14 100644 --- a/packages/server/src/server/agent/providers/pi/runtime.ts +++ b/packages/server/src/server/agent/providers/pi/runtime.ts @@ -19,7 +19,6 @@ export interface PiRuntimeLaunch { modeId?: string; session?: string; noSession?: boolean; - systemPrompt?: string; mcpConfigPath?: string; extensionPaths?: string[]; extraArgs?: string[]; @@ -34,7 +33,6 @@ export interface PiStartSessionInput { modeId?: string; session?: string; noSession?: boolean; - systemPrompt?: string; mcpConfigPath?: string; extensionPaths?: string[]; extraArgs?: string[]; @@ -85,8 +83,7 @@ export function buildPiLaunch(input: { const argv = [...command]; const protocolMode = input.session.protocolMode ?? "rpc"; - const systemPrompt = input.session.systemPrompt?.trim(); - appendPiLaunchArgs(argv, input.session, protocolMode, systemPrompt); + appendPiLaunchArgs(argv, input.session, protocolMode); return { cwd: input.session.cwd, @@ -104,7 +101,6 @@ export function buildPiLaunch(input: { modeId: input.session.modeId, session: input.session.session, noSession: input.session.noSession, - systemPrompt, mcpConfigPath: input.session.mcpConfigPath, extensionPaths: input.session.extensionPaths, extraArgs: input.session.extraArgs, @@ -115,7 +111,6 @@ function appendPiLaunchArgs( argv: string[], session: PiStartSessionInput, protocolMode: "rpc" | "rpc-ui", - systemPrompt: string | undefined, ): void { if (!hasModeFlag(argv)) { argv.push("--mode", protocolMode); @@ -134,9 +129,6 @@ function appendPiLaunchArgs( } else if (session.session) { argv.push("--session", session.session); } - if (systemPrompt) { - argv.push("--append-system-prompt", systemPrompt); - } if (session.mcpConfigPath) { argv.push("--mcp-config", session.mcpConfigPath); } diff --git a/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts index de289195b..d992638ba 100644 --- a/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts @@ -139,6 +139,51 @@ beforeEach((context) => { } }); +test( + "real Pi daemon composes project and Paseo system prompts", + async () => { + const cwd = tmpCwd("pi-system-prompts-"); + + try { + mkdirSync(path.join(cwd, ".pi"), { recursive: true }); + writeFileSync( + path.join(cwd, ".pi", "APPEND_SYSTEM.md"), + [ + "When the user says PASEO_SYSTEM_PROMPT_PROBE, reply with exactly two tokens:", + "PROJECT_PROMPT followed by the value of PASEO_PROMPT_TOKEN from later system instructions.", + "If no PASEO_PROMPT_TOKEN exists, use MISSING as the second token.", + ].join("\n"), + ); + + await withConnectedPiDaemon(async ({ client }) => { + const agent = await client.createAgent({ + cwd, + title: "pi-system-prompts", + provider: "pi", + model: PI_REAL_TEST_MODEL, + systemPrompt: + "PASEO_PROMPT_TOKEN is PASEO_PROMPT. Follow the project instruction for PASEO_SYSTEM_PROMPT_PROBE.", + }); + + await client.sendMessage(agent.id, "PASEO_SYSTEM_PROMPT_PROBE"); + const finish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS); + expect(finish.status).toBe("idle"); + + const items = await fetchCanonicalTimeline(client, agent.id); + const response = items + .filter((item) => item.type === "assistant_message") + .map((item) => item.text) + .join("") + .trim(); + expect(response).toBe("PROJECT_PROMPT PASEO_PROMPT"); + }); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, + PI_TEST_TIMEOUT_MS, +); + test( "real Pi daemon lists Paseo-handled compact slash commands", async () => {