Skip to content
Merged
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
5 changes: 5 additions & 0 deletions apps/desktop/electron/main/runtime/session-launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
capabilitiesFromModelConfig,
clampThinkingLevel,
genericModelConfig,
loadCustomSystemPrompt,
loadInstructionChain,
loadSubagentDefinitions,
modelConfigWithBinding,
Expand Down Expand Up @@ -366,6 +367,9 @@ export function createSessionLaunchRuntime({
? session.projectPath.trim()
: undefined;
let projectInstructions = await loadInstructionChain(projectPath);
// pi-compatible SYSTEM.md / APPEND_SYSTEM.md (issue #542): resolved once
// per launch; a change retires the runtime through the reuse match.
const customSystemPrompt = await loadCustomSystemPrompt(projectPath);
let projectMemory: string | undefined;
if (projectPath) {
try {
Expand Down Expand Up @@ -611,6 +615,7 @@ export function createSessionLaunchRuntime({
scratchDir: join(dataDir, "scratch", sessionId),
attachmentsDir: join(dataDir, "attachments"),
projectPath,
customSystemPrompt,
projectInstructions,
projectMemory,
provider: {
Expand Down
89 changes: 89 additions & 0 deletions apps/desktop/test/custom-system-prompt-launch.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import assert from "node:assert/strict";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { register } from "node:module";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
register(new URL("./helpers/ts-import-hooks.mjs", import.meta.url));
const { createSessionLaunchRuntime } = await import("../electron/main/runtime/session-launch.ts");

// Issue #542: pi CLI's SYSTEM.md / APPEND_SYSTEM.md must be discovered at
// launch with pi's precedence (project .pi/ over ~/.pi/agent/) and reach the
// sidecar params that compose the system prompt. The global directory is the
// developer's real ~/.pi/agent — the assertions are therefore relative to a
// recorded baseline, never to an empty home, so leftover files on a dev
// machine do not fail the suite.
const workspace = mkdtempSync(join(tmpdir(), "pi-csp-ws-"));

test.after(() => {
rmSync(workspace, { recursive: true, force: true });
});

const shell = { id: "bash", label: "Bash", dialect: "posix", available: true, isDefault: true };
const provider = {
id: "fixture-provider", vendorKey: "fixture", name: "Fixture", enabled: true,
authKind: "none", baseUrl: "http://127.0.0.1:1/v1", apiStyle: "openai-chat",
models: [{ id: "parent", thinkingLevels: ["off"] }],
};

function launchRuntime() {
return createSessionLaunchRuntime({
runtimeState: { host: {
isAvailable: () => true,
call: async (method) => {
if (method === "commandShells.list") return { configuredId: "bash", effective: shell, fallback: false, choices: [shell] };
if (method === "providers.list") return { providers: [provider] };
if (method === "providers.getSecret") return {};
if (method === "agents.active") return { subagents: [] };
if (method === "skills.active") return { skills: [] };
if (method === "mcp.active") return { servers: [] };
if (method === "project.memory.get") return {};
throw new Error(`Unexpected host call ${method}`);
},
} },
logger: { app() {} }, userMcp: { setRecords() {}, toolsForProject: async () => [] },
plugins: { listLoaded: () => [], getSkills: () => [], getTools: () => [], getAgentExtensions: () => [] },
sessionProjects: new Map(), dataDir: workspace, vendorOAuth: {},
modelsDevCatalog: { ensureLoaded: async () => {}, findModel: () => undefined },
getWorkspacePath: () => workspace, pluginActiveInProject: () => true,
bindingForModel: (row, id) => row.models.find((m) => m.id === id),
modelsDevModelFor: () => undefined,
effectiveSubagentModelConfig: () => ({}),
normalizeThinkingLevel: () => "off",
});
}

async function launchParams(runtime) {
const launch = await runtime.resolveAgentRuntimeLaunch("session", {
providerId: provider.id, modelId: "parent", projectPath: workspace,
}, {});
return launch.sidecarParams;
}

// The global (~/.pi/agent) precedence and per-kind independence are covered
// against injectable directories in packages/agent-runtime/src/custom-system-prompt.test.ts;
// this suite covers the real user path through the launch: files on disk in
// <workspace>/.pi reach sidecarParams and win per kind, and removing them
// reverts to whatever the global layer provides.
test("launch discovers project custom system prompt files (issue #542)", async () => {
const runtime = launchRuntime();

// Baseline: no project files yet; may be undefined or the developer's real
// global files — both are valid starting points for the assertions below.
const baseline = (await launchParams(runtime)).customSystemPrompt;

// Project .pi/SYSTEM.md wins the replace kind over any global file.
mkdirSync(join(workspace, ".pi"), { recursive: true });
writeFileSync(join(workspace, ".pi", "SYSTEM.md"), "MARKER-PROJECT-PERSONA");
assert.equal((await launchParams(runtime)).customSystemPrompt?.replace, "MARKER-PROJECT-PERSONA");

// Project .pi/APPEND_SYSTEM.md wins the append kind independently.
writeFileSync(join(workspace, ".pi", "APPEND_SYSTEM.md"), "MARKER-PROJECT-APPEND");
const both = (await launchParams(runtime)).customSystemPrompt;
assert.equal(both?.replace, "MARKER-PROJECT-PERSONA");
assert.equal(both?.append, "MARKER-PROJECT-APPEND");

// Deleting the project files reverts the launch to the global-only state.
rmSync(join(workspace, ".pi"), { recursive: true, force: true });
assert.deepEqual((await launchParams(runtime)).customSystemPrompt, baseline);
});
25 changes: 25 additions & 0 deletions docs/spec/03-runtime/02-agent-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -1065,6 +1065,31 @@ same gateway backend as the conversation it summarizes.
+ [optional user custom instructions]
```

### 7.0.1 User custom system prompt files (issue #542)

The `[optional user custom instructions]` layer is the pi-compatible file pair
`SYSTEM.md` / `APPEND_SYSTEM.md`, discovered per session launch from
`<workspace>/.pi/` (project) and `~/.pi/agent/` (global), each kind picking a
single winner with project over global, exactly like pi CLI. A change to the
resolved content retires the runtime through the reuse match, so the next
prompt recomposes; the files are not re-read per tool call like the project
instruction chain. Native-pi sessions keep resolving them through the upstream
`DefaultResourceLoader` as before.

Two deliberate deviations from pi CLI's semantics:

- `SYSTEM.md` replaces only the base product persona line, not the whole
prompt: the operational rules below (collaboration, search, edit contract,
scratch, delegation, skills) are desktop mechanics a persona file must not
remove.
- `APPEND_SYSTEM.md` is appended after the composed base prompt and before
the project instruction chain, matching pi's ordering, so the user's own
`AGENTS.md` keeps the last word.

Both files are capped at 64 KiB, and a whitespace-only file counts as absent.
Native `SYSTEM.md` / `APPEND_SYSTEM.md` resolution in a native-pi session is
unaffected: it stays with the upstream loader.

The base prompt states collaboration rules explicitly, because omitting them
is what produced silent sessions: "prefer concise, actionable answers" was the
only relevant line, and a reasoning model executed it as saying nothing at all.
Expand Down
115 changes: 115 additions & 0 deletions packages/agent-runtime/src/custom-system-prompt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
customSystemPromptDirs,
loadCustomSystemPrompt,
} from "./custom-system-prompt.js";

let root: string | undefined;
let globalDir: string | undefined;

afterEach(async () => {
for (const dir of [root, globalDir]) {
if (dir) await rm(dir, { recursive: true, force: true });
}
root = undefined;
globalDir = undefined;
});

async function fixture(files: Record<string, string>) {
root = await mkdtemp(join(tmpdir(), "pi-desktop-csp-"));
globalDir = await mkdtemp(join(tmpdir(), "pi-desktop-csp-global-"));
for (const [name, content] of Object.entries(files)) {
const dir = name.startsWith("global/")
? globalDir!
: root!;
const relative = name.startsWith("global/") ? name.slice("global/".length) : name;
const target = join(dir, relative);
await mkdir(join(target, ".."), { recursive: true });
await writeFile(target, content);
}
}

describe("loadCustomSystemPrompt", () => {
it("returns undefined without any files", async () => {
await fixture({});
await expect(
loadCustomSystemPrompt(root, { project: join(root!, ".pi"), global: globalDir! }),
).resolves.toBeUndefined();
});

it("reads the global SYSTEM.md and APPEND_SYSTEM.md", async () => {
await fixture({ "global/SYSTEM.md": " Custom persona.\n" });
await expect(
loadCustomSystemPrompt(root, { project: join(root!, ".pi"), global: globalDir! }),
).resolves.toEqual({ replace: "Custom persona." });
});

it("reads the global APPEND_SYSTEM.md independently", async () => {
await fixture({ "global/APPEND_SYSTEM.md": "Always cite sources." });
await expect(
loadCustomSystemPrompt(root, { project: join(root!, ".pi"), global: globalDir! }),
).resolves.toEqual({ append: "Always cite sources." });
});

it("reads both files when both exist", async () => {
await fixture({
"global/SYSTEM.md": "Custom persona.",
"global/APPEND_SYSTEM.md": "Also cite sources.",
});
await expect(
loadCustomSystemPrompt(root, { project: join(root!, ".pi"), global: globalDir! }),
).resolves.toEqual({ replace: "Custom persona.", append: "Also cite sources." });
});

it("prefers the project file over the global one per kind", async () => {
await fixture({
".pi/SYSTEM.md": "Project persona.",
"global/SYSTEM.md": "Global persona.",
"global/APPEND_SYSTEM.md": "Global appendix.",
});
await expect(
loadCustomSystemPrompt(root, { project: join(root!, ".pi"), global: globalDir! }),
).resolves.toEqual({ replace: "Project persona.", append: "Global appendix." });
});

it("treats a whitespace-only file as absent and falls back", async () => {
await fixture({
".pi/SYSTEM.md": " \n\t\n",
"global/SYSTEM.md": "Global persona.",
});
await expect(
loadCustomSystemPrompt(root, { project: join(root!, ".pi"), global: globalDir! }),
).resolves.toEqual({ replace: "Global persona." });
});

it("caps content at 64 KiB without splitting UTF-8 characters", async () => {
await fixture({ "global/APPEND_SYSTEM.md": "ü".repeat(70_000) });
const loaded = await loadCustomSystemPrompt(root, {
project: join(root!, ".pi"),
global: globalDir!,
});
expect(Buffer.byteLength(loaded!.append!, "utf8")).toBeLessThanOrEqual(64 * 1024);
expect(loaded!.append!.endsWith("ü")).toBe(true);
});

it("works without a workspace root (global only)", async () => {
await fixture({ "global/SYSTEM.md": "Global persona." });
await expect(
loadCustomSystemPrompt(null, { global: globalDir! }),
).resolves.toEqual({ replace: "Global persona." });
});
});

describe("customSystemPromptDirs", () => {
it("omits the project dir without a workspace root", () => {
expect(customSystemPromptDirs(null).project).toBeUndefined();
expect(customSystemPromptDirs(" ").project).toBeUndefined();
});

it("points the project dir at <workspace>/.pi", () => {
expect(customSystemPromptDirs("/w").project).toBe(join("/w", ".pi"));
});
});
87 changes: 87 additions & 0 deletions packages/agent-runtime/src/custom-system-prompt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/**
* pi-compatible custom system prompt files (issue #542).
*
* pi CLI honors `SYSTEM.md` (replace the default persona) and
* `APPEND_SYSTEM.md` (append to it) in two locations, discovered
* independently and each picked as a single winner — project before global:
*
* - `<workspace>/.pi/SYSTEM.md` / `.pi/APPEND_SYSTEM.md` (project)
* - `~/.pi/agent/SYSTEM.md` / `~/.pi/agent/APPEND_SYSTEM.md` (global)
*
* PI-Desktop follows the same discovery and precedence. One deliberate
* deviation, recorded in spec 03-runtime/02-agent-runtime.md §7: replacing
* the default prompt here means replacing only the product persona block;
* the runtime's operational rules (tool guidance, collaboration, scratch
* and delegation mechanics) always stay in the composed prompt, so desktop
* features keep working under a custom persona. `APPEND_SYSTEM.md` is
* appended after the composed base prompt and before project instructions,
* so the user's own AGENTS.md chain keeps the last word, matching pi's
* ordering.
*/

import { readFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";

const MAX_PROMPT_BYTES = 64 * 1024;

export type CustomSystemPrompt = {
/** Resolved `SYSTEM.md` content, when a file was found. */
replace?: string;
/** Resolved `APPEND_SYSTEM.md` content, when a file was found. */
append?: string;
};

export type CustomSystemPromptDirs = {
project?: string;
global: string;
};

export function customSystemPromptDirs(
workspaceRoot: string | null | undefined,
): CustomSystemPromptDirs {
return {
...(workspaceRoot?.trim() ? { project: join(workspaceRoot.trim(), ".pi") } : {}),
global: join(homedir(), ".pi", "agent"),
};
}

function limitUtf8(content: string, maxBytes: number): string {
if (Buffer.byteLength(content, "utf8") <= maxBytes) return content;
let bytes = 0;
let end = 0;
for (const char of content) {
const charBytes = Buffer.byteLength(char, "utf8");
if (bytes + charBytes > maxBytes) break;
bytes += charBytes;
end += char.length;
}
return content.slice(0, end);
}

/** Project wins over global; a whitespace-only file counts as absent. */
async function readFirst(
dirs: CustomSystemPromptDirs,
fileName: string,
): Promise<string | undefined> {
for (const dir of [dirs.project, dirs.global]) {
if (!dir) continue;
try {
const content = (await readFile(join(dir, fileName), "utf8")).trim();
if (content) return limitUtf8(content, MAX_PROMPT_BYTES);
} catch {
// Missing or unreadable files are an expected state; fall through.
}
}
return undefined;
}

export async function loadCustomSystemPrompt(
workspaceRoot: string | null | undefined,
dirs?: CustomSystemPromptDirs,
): Promise<CustomSystemPrompt | undefined> {
const resolved = dirs ?? customSystemPromptDirs(workspaceRoot);
const replace = await readFirst(resolved, "SYSTEM.md");
const append = await readFirst(resolved, "APPEND_SYSTEM.md");
return replace || append ? { ...(replace ? { replace } : {}), ...(append ? { append } : {}) } : undefined;
}
1 change: 1 addition & 0 deletions packages/agent-runtime/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from "./host-client.js";
export * from "./custom-system-prompt.js";
export * from "./model-capabilities.js";
export * from "./mode-prompts.js";
export * from "./runtime.js";
Expand Down
Loading
Loading