Skip to content

Commit 6acc82e

Browse files
committed
fix(pi): preserve discovered project prompts
Append Paseo instructions from the integration extension because Pi's append-system-prompt flag suppresses automatic APPEND_SYSTEM.md discovery.
1 parent d0456b1 commit 6acc82e

6 files changed

Lines changed: 97 additions & 49 deletions

File tree

docs/providers.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ Paseo tools are not implemented as MCP tools internally. They live in a shared t
2424

2525
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.
2626

27-
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.
27+
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.
2828

2929
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.
3030

packages/server/src/server/agent/providers/pi/agent.test.ts

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { tmpdir } from "node:os";
1313
import path from "node:path";
1414
import pino from "pino";
1515
import { setImmediate as waitForImmediate } from "node:timers/promises";
16+
import { pathToFileURL } from "node:url";
1617
import { describe, expect, onTestFinished, test } from "vitest";
1718

1819
import type { AgentSession, AgentSessionConfig, AgentStreamEvent } from "../../agent-sdk-types.js";
@@ -55,6 +56,26 @@ function readUtf8File(pathname: string): string {
5556
closeSync(fd);
5657
}
5758
}
59+
60+
async function applyPaseoExtensionSystemPrompt(
61+
extensionPath: string,
62+
systemPrompt: string,
63+
): Promise<string | undefined> {
64+
const listeners = new Map<string, (event: { systemPrompt: string }) => unknown>();
65+
const extension = (await import(pathToFileURL(extensionPath).href)) as {
66+
default: (piApi: {
67+
on: (event: string, listener: (event: { systemPrompt: string }) => unknown) => void;
68+
registerCommand: () => void;
69+
}) => void;
70+
};
71+
extension.default({
72+
on: (event, listener) => listeners.set(event, listener),
73+
registerCommand: () => undefined,
74+
});
75+
const result = await listeners.get("before_agent_start")?.({ systemPrompt });
76+
return (result as { systemPrompt?: string } | undefined)?.systemPrompt;
77+
}
78+
5879
async function flushTurnScheduling(): Promise<void> {
5980
await waitForImmediate();
6081
}
@@ -713,11 +734,11 @@ describe("PiRpcAgentSession", () => {
713734
]);
714735
});
715736

716-
test("creates Pi sessions with agent and daemon system prompts appended", async () => {
737+
test("appends agent and daemon prompts after Pi's discovered system prompt", async () => {
717738
const pi = new FakePi();
718739
const client = createClient(pi);
719740

720-
await client.createSession(
741+
const session = await client.createSession(
721742
createConfig({
722743
systemPrompt: "Agent prompt",
723744
daemonAppendSystemPrompt: "Daemon prompt",
@@ -727,7 +748,6 @@ describe("PiRpcAgentSession", () => {
727748
const actualLaunch = pi.recordedLaunches[0]!;
728749
expect(actualLaunch).toMatchObject({
729750
cwd: "/tmp/paseo-pi-rpc-test",
730-
systemPrompt: "Agent prompt\n\nDaemon prompt",
731751
});
732752
expect(actualLaunch.extensionPaths).toHaveLength(1);
733753
expect(actualLaunch.argv).toEqual([
@@ -736,11 +756,15 @@ describe("PiRpcAgentSession", () => {
736756
"rpc",
737757
"--thinking",
738758
"medium",
739-
"--append-system-prompt",
740-
"Agent prompt\n\nDaemon prompt",
741759
"--extension",
742760
actualLaunch.extensionPaths[0],
743761
]);
762+
763+
await expect(
764+
applyPaseoExtensionSystemPrompt(actualLaunch.extensionPaths[0]!, "Pi project prompt"),
765+
).resolves.toBe("Pi project prompt\n\nAgent prompt\n\nDaemon prompt");
766+
767+
await session.close();
744768
});
745769

746770
test("resumes Pi sessions with daemon system prompts appended", async () => {
@@ -769,7 +793,6 @@ describe("PiRpcAgentSession", () => {
769793
expect(actualLaunch).toMatchObject({
770794
cwd: "/workspace/project",
771795
session: "/tmp/native-pi-session",
772-
systemPrompt: "Agent prompt\n\nDaemon prompt",
773796
});
774797
expect(actualLaunch.extensionPaths).toHaveLength(1);
775798
expect(actualLaunch.argv).toEqual([
@@ -782,11 +805,12 @@ describe("PiRpcAgentSession", () => {
782805
"high",
783806
"--session",
784807
"/tmp/native-pi-session",
785-
"--append-system-prompt",
786-
"Agent prompt\n\nDaemon prompt",
787808
"--extension",
788809
actualLaunch.extensionPaths[0],
789810
]);
811+
await expect(
812+
applyPaseoExtensionSystemPrompt(actualLaunch.extensionPaths[0]!, "Pi project prompt"),
813+
).resolves.toBe("Pi project prompt\n\nAgent prompt\n\nDaemon prompt");
790814
});
791815

792816
test("updates model and thinking through Pi runtime commands", async () => {

packages/server/src/server/agent/providers/pi/agent.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -535,10 +535,6 @@ function buildResumeStartInput(input: {
535535
session: input.sessionFile,
536536
model: input.resumeConfig.model,
537537
thinkingOptionId: normalizePiThinkingOption(input.resumeConfig.thinkingOptionId) ?? undefined,
538-
systemPrompt: composeSystemPromptParts(
539-
input.resumeConfig.config.systemPrompt,
540-
input.resumeConfig.config.daemonAppendSystemPrompt,
541-
),
542538
mcpConfigPath: input.mcpConfig?.path,
543539
extensionPaths: input.paseoExtension ? [input.paseoExtension.path] : undefined,
544540
};
@@ -630,7 +626,7 @@ function createPiMcpConfigFile(
630626
};
631627
}
632628

633-
function createPiPaseoExtensionFile(): PiTempFile {
629+
function createPiPaseoExtensionFile(systemPrompt?: string): PiTempFile {
634630
const dir = mkdtempSync(join(tmpdir(), "paseo-pi-extension-"));
635631
const filePath = join(dir, "paseo-integration.mjs");
636632
writeFileSync(
@@ -680,6 +676,14 @@ function createPiPaseoExtensionFile(): PiTempFile {
680676
}
681677
682678
export default function paseoIntegration(pi) {
679+
${
680+
systemPrompt
681+
? `pi.on("before_agent_start", async (event) => ({
682+
systemPrompt: event.systemPrompt + "\\n\\n" + ${JSON.stringify(systemPrompt)},
683+
}));`
684+
: ""
685+
}
686+
683687
pi.on("session_start", async (_event, ctx) => {
684688
emitEntryCapture(ctx, "session_start");
685689
});
@@ -2307,7 +2311,9 @@ export class PiRpcAgentClient implements AgentClient {
23072311
...launchContext?.env,
23082312
};
23092313
const mcpConfig = await this.prepareMcpConfig(config.cwd, config.mcpServers, mcpEnv);
2310-
const paseoExtension = createPiPaseoExtensionFile();
2314+
const paseoExtension = createPiPaseoExtensionFile(
2315+
composeSystemPromptParts(config.systemPrompt, config.daemonAppendSystemPrompt),
2316+
);
23112317
let runtimeSession: PiRuntimeSession;
23122318
try {
23132319
runtimeSession = await this.runtime.startSession({
@@ -2316,10 +2322,6 @@ export class PiRpcAgentClient implements AgentClient {
23162322
thinkingOptionId:
23172323
normalizePiThinkingOption(config.thinkingOptionId) ?? DEFAULT_PI_THINKING_LEVEL,
23182324
noSession: config.internal === true,
2319-
systemPrompt: composeSystemPromptParts(
2320-
config.systemPrompt,
2321-
config.daemonAppendSystemPrompt,
2322-
),
23232325
env: launchContext?.env,
23242326
mcpConfigPath: mcpConfig?.path,
23252327
extensionPaths: paseoExtension ? [paseoExtension.path] : undefined,
@@ -2368,7 +2370,12 @@ export class PiRpcAgentClient implements AgentClient {
23682370
resumeConfig.config.mcpServers,
23692371
mcpEnv,
23702372
);
2371-
const paseoExtension = createPiPaseoExtensionFile();
2373+
const paseoExtension = createPiPaseoExtensionFile(
2374+
composeSystemPromptParts(
2375+
resumeConfig.config.systemPrompt,
2376+
resumeConfig.config.daemonAppendSystemPrompt,
2377+
),
2378+
);
23722379
let runtimeSession: PiRuntimeSession;
23732380
try {
23742381
runtimeSession = await this.runtime.startSession(

packages/server/src/server/agent/providers/pi/cli-runtime.test.ts

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -229,26 +229,6 @@ describe("PiCliRuntime", () => {
229229
]);
230230
});
231231

232-
test("passes an appended system prompt to Pi", async () => {
233-
const child = createPiChild();
234-
replyToCommands(child, () => ({}));
235-
const launches: PiRuntimeLaunch[] = [];
236-
const runtime = createRuntime(child, launches);
237-
238-
await runtime.startSession({
239-
cwd: "/workspace/project",
240-
systemPrompt: " Use the daemon prompt. ",
241-
});
242-
243-
expect(launches).toEqual([
244-
expect.objectContaining({
245-
cwd: "/workspace/project",
246-
systemPrompt: "Use the daemon prompt.",
247-
argv: ["pi", "--mode", "rpc", "--append-system-prompt", "Use the daemon prompt."],
248-
}),
249-
]);
250-
});
251-
252232
test("delivers events separately from command responses", async () => {
253233
const child = createPiChild();
254234
replyToCommands(child, () => ({ models: [] }));

packages/server/src/server/agent/providers/pi/runtime.ts

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ export interface PiRuntimeLaunch {
1919
modeId?: string;
2020
session?: string;
2121
noSession?: boolean;
22-
systemPrompt?: string;
2322
mcpConfigPath?: string;
2423
extensionPaths?: string[];
2524
extraArgs?: string[];
@@ -34,7 +33,6 @@ export interface PiStartSessionInput {
3433
modeId?: string;
3534
session?: string;
3635
noSession?: boolean;
37-
systemPrompt?: string;
3836
mcpConfigPath?: string;
3937
extensionPaths?: string[];
4038
extraArgs?: string[];
@@ -85,8 +83,7 @@ export function buildPiLaunch(input: {
8583
const argv = [...command];
8684

8785
const protocolMode = input.session.protocolMode ?? "rpc";
88-
const systemPrompt = input.session.systemPrompt?.trim();
89-
appendPiLaunchArgs(argv, input.session, protocolMode, systemPrompt);
86+
appendPiLaunchArgs(argv, input.session, protocolMode);
9087

9188
return {
9289
cwd: input.session.cwd,
@@ -104,7 +101,6 @@ export function buildPiLaunch(input: {
104101
modeId: input.session.modeId,
105102
session: input.session.session,
106103
noSession: input.session.noSession,
107-
systemPrompt,
108104
mcpConfigPath: input.session.mcpConfigPath,
109105
extensionPaths: input.session.extensionPaths,
110106
extraArgs: input.session.extraArgs,
@@ -115,7 +111,6 @@ function appendPiLaunchArgs(
115111
argv: string[],
116112
session: PiStartSessionInput,
117113
protocolMode: "rpc" | "rpc-ui",
118-
systemPrompt: string | undefined,
119114
): void {
120115
if (!hasModeFlag(argv)) {
121116
argv.push("--mode", protocolMode);
@@ -134,9 +129,6 @@ function appendPiLaunchArgs(
134129
} else if (session.session) {
135130
argv.push("--session", session.session);
136131
}
137-
if (systemPrompt) {
138-
argv.push("--append-system-prompt", systemPrompt);
139-
}
140132
if (session.mcpConfigPath) {
141133
argv.push("--mcp-config", session.mcpConfigPath);
142134
}

packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,51 @@ beforeEach((context) => {
139139
}
140140
});
141141

142+
test(
143+
"real Pi daemon composes project and Paseo system prompts",
144+
async () => {
145+
const cwd = tmpCwd("pi-system-prompts-");
146+
147+
try {
148+
mkdirSync(path.join(cwd, ".pi"), { recursive: true });
149+
writeFileSync(
150+
path.join(cwd, ".pi", "APPEND_SYSTEM.md"),
151+
[
152+
"When the user says PASEO_SYSTEM_PROMPT_PROBE, reply with exactly two tokens:",
153+
"PROJECT_PROMPT followed by the value of PASEO_PROMPT_TOKEN from later system instructions.",
154+
"If no PASEO_PROMPT_TOKEN exists, use MISSING as the second token.",
155+
].join("\n"),
156+
);
157+
158+
await withConnectedPiDaemon(async ({ client }) => {
159+
const agent = await client.createAgent({
160+
cwd,
161+
title: "pi-system-prompts",
162+
provider: "pi",
163+
model: PI_REAL_TEST_MODEL,
164+
systemPrompt:
165+
"PASEO_PROMPT_TOKEN is PASEO_PROMPT. Follow the project instruction for PASEO_SYSTEM_PROMPT_PROBE.",
166+
});
167+
168+
await client.sendMessage(agent.id, "PASEO_SYSTEM_PROMPT_PROBE");
169+
const finish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS);
170+
expect(finish.status).toBe("idle");
171+
172+
const items = await fetchCanonicalTimeline(client, agent.id);
173+
const response = items
174+
.filter((item) => item.type === "assistant_message")
175+
.map((item) => item.text)
176+
.join("")
177+
.trim();
178+
expect(response).toBe("PROJECT_PROMPT PASEO_PROMPT");
179+
});
180+
} finally {
181+
rmSync(cwd, { recursive: true, force: true });
182+
}
183+
},
184+
PI_TEST_TIMEOUT_MS,
185+
);
186+
142187
test(
143188
"real Pi daemon lists Paseo-handled compact slash commands",
144189
async () => {

0 commit comments

Comments
 (0)