diff --git a/apps/desktop/electron/main/runtime/session-launch.ts b/apps/desktop/electron/main/runtime/session-launch.ts index 827c38431..e8a5a986b 100644 --- a/apps/desktop/electron/main/runtime/session-launch.ts +++ b/apps/desktop/electron/main/runtime/session-launch.ts @@ -19,6 +19,7 @@ import { capabilitiesFromModelConfig, clampThinkingLevel, genericModelConfig, + loadCustomSystemPrompt, loadInstructionChain, loadSubagentDefinitions, modelConfigWithBinding, @@ -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 { @@ -611,6 +615,7 @@ export function createSessionLaunchRuntime({ scratchDir: join(dataDir, "scratch", sessionId), attachmentsDir: join(dataDir, "attachments"), projectPath, + customSystemPrompt, projectInstructions, projectMemory, provider: { diff --git a/apps/desktop/test/custom-system-prompt-launch.test.mjs b/apps/desktop/test/custom-system-prompt-launch.test.mjs new file mode 100644 index 000000000..edc4311da --- /dev/null +++ b/apps/desktop/test/custom-system-prompt-launch.test.mjs @@ -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 +// /.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); +}); diff --git a/docs/spec/03-runtime/02-agent-runtime.md b/docs/spec/03-runtime/02-agent-runtime.md index c39871745..c42f11ccc 100644 --- a/docs/spec/03-runtime/02-agent-runtime.md +++ b/docs/spec/03-runtime/02-agent-runtime.md @@ -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 +`/.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. diff --git a/packages/agent-runtime/src/custom-system-prompt.test.ts b/packages/agent-runtime/src/custom-system-prompt.test.ts new file mode 100644 index 000000000..94a4db1fc --- /dev/null +++ b/packages/agent-runtime/src/custom-system-prompt.test.ts @@ -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) { + 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 /.pi", () => { + expect(customSystemPromptDirs("/w").project).toBe(join("/w", ".pi")); + }); +}); diff --git a/packages/agent-runtime/src/custom-system-prompt.ts b/packages/agent-runtime/src/custom-system-prompt.ts new file mode 100644 index 000000000..388d8693d --- /dev/null +++ b/packages/agent-runtime/src/custom-system-prompt.ts @@ -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: + * + * - `/.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 { + 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 { + 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; +} diff --git a/packages/agent-runtime/src/index.ts b/packages/agent-runtime/src/index.ts index 146f97f73..5546ac24b 100644 --- a/packages/agent-runtime/src/index.ts +++ b/packages/agent-runtime/src/index.ts @@ -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"; diff --git a/packages/agent-runtime/src/runtime.test.ts b/packages/agent-runtime/src/runtime.test.ts index 45daff8ae..1ea049971 100644 --- a/packages/agent-runtime/src/runtime.test.ts +++ b/packages/agent-runtime/src/runtime.test.ts @@ -146,6 +146,7 @@ function createRuntime( compactionStrategy: CompactionStrategy; projectPath: string; scratchDir: string; + customSystemPrompt: import("./custom-system-prompt.js").CustomSystemPrompt; projectInstructions: import("./project-instructions.js").ProjectInstructions; projectMemory: string; pluginTools: PluginToolDef[]; @@ -173,6 +174,7 @@ function createRuntime( compactionStrategy: overrides.compactionStrategy, projectPath: overrides.projectPath, scratchDir: overrides.scratchDir, + customSystemPrompt: overrides.customSystemPrompt, pluginTools: overrides.pluginTools, subagents: overrides.subagents, subagentProviders: overrides.subagentProviders, @@ -219,6 +221,7 @@ function runtimeMatches( pluginTools: (runtime as any).pluginTools, pluginSkills: (runtime as any).pluginSkills, projectInstructions: (runtime as any).baseProjectInstructions, + customSystemPrompt: (runtime as any).customSystemPrompt, projectMemory: (runtime as any).projectMemory, projectPath: (runtime as any).projectPath, commandShell: (runtime as any).commandShell, @@ -229,6 +232,100 @@ function runtimeMatches( }); } +describe("custom system prompt files (issue #542)", () => { + const persona = "You are Custom, a specialized assistant."; + const appendix = "MARKER-XYZ-123 Always end with the marker."; + + function promptOf(runtime: DesktopAgentRuntime): string { + return (runtime as any).agent.state.systemPrompt as string; + } + + it("replaces only the persona, keeping operational rules", async () => { + const runtime = createRuntime({ + customSystemPrompt: { replace: persona }, + }); + const prompt = promptOf(runtime); + + expect(prompt).toContain(persona); + expect(prompt).not.toContain("You are PI-Desktop"); + // Operational rules from the default prompt must survive the replacement. + expect(prompt).toContain("Collaboration: answer in the same language"); + expect(prompt).toContain("Searching and reading: prefer the Read"); + expect(prompt).toContain("multi_tool_use.parallel"); + expect(prompt).toContain("Editing workflow: use the built-in Edit or Write tool"); + expect(prompt).toContain("You are operating in Agent mode."); + + await runtime.dispose(); + }); + + it("appends APPEND_SYSTEM.md after the base prompt and before project instructions", async () => { + const runtime = createRuntime({ + customSystemPrompt: { append: appendix }, + projectInstructions: { + entries: [{ source: "AGENTS.md", content: "Run unit tests." }], + }, + }); + const prompt = promptOf(runtime); + + expect(prompt).toContain(appendix); + expect(prompt).toContain("You are PI-Desktop"); + expect(prompt).toContain("Run unit tests."); + expect(prompt.indexOf(appendix)).toBeGreaterThan( + prompt.indexOf("You are PI-Desktop"), + ); + expect(prompt.indexOf("Run unit tests.")).toBeGreaterThan( + prompt.indexOf(appendix), + ); + + await runtime.dispose(); + }); + + it("applies replace and append together", async () => { + const runtime = createRuntime({ + customSystemPrompt: { replace: persona, append: appendix }, + }); + const prompt = promptOf(runtime); + + expect(prompt).toContain(persona); + expect(prompt).toContain(appendix); + expect(prompt).not.toContain("You are PI-Desktop"); + + await runtime.dispose(); + }); + + it("keeps the default prompt without custom files", async () => { + const runtime = createRuntime(); + const prompt = promptOf(runtime); + + expect(prompt).toContain("You are PI-Desktop"); + expect(prompt).not.toContain("MARKER-XYZ-123"); + + await runtime.dispose(); + }); + + it("retires the runtime when custom prompt content changes", async () => { + const runtime = createRuntime({ + customSystemPrompt: { append: appendix }, + }); + expect( + runtimeMatches(runtime, { customSystemPrompt: { append: appendix } }), + ).toBe(true); + expect( + runtimeMatches(runtime, { + customSystemPrompt: { append: "Different appendix." }, + }), + ).toBe(false); + expect( + runtimeMatches(runtime, { customSystemPrompt: undefined }), + ).toBe(false); + expect( + runtimeMatches(runtime, { customSystemPrompt: { replace: persona } }), + ).toBe(false); + + await runtime.dispose(); + }); +}); + describe("DesktopAgentRuntime configuration matching", () => { it("retires an idle runtime when project memory changes", async () => { const runtime = createRuntime({ projectMemory: "Use the staging database." }); diff --git a/packages/agent-runtime/src/runtime.ts b/packages/agent-runtime/src/runtime.ts index 572831bf8..d9cc52b7a 100644 --- a/packages/agent-runtime/src/runtime.ts +++ b/packages/agent-runtime/src/runtime.ts @@ -165,6 +165,7 @@ import { import { genericModelConfig, visionFromModelConfig } from "./model-capabilities.js"; import type { ProjectInstructions } from "./project-instructions.js"; import { projectInstructionsPrompt } from "./project-instructions-prompt.js"; +import type { CustomSystemPrompt } from "./custom-system-prompt.js"; import { projectMemoryPrompt } from "./project-memory-prompt.js"; import { pluginSkillsPrompt, @@ -840,6 +841,8 @@ export type AgentRuntimeOptions = { provider: RuntimeProviderConfig; thinkingLevel: SessionThinkingLevel; systemPrompt?: string; + /** pi-compatible SYSTEM.md / APPEND_SYSTEM.md resolved for the session (issue #542). */ + customSystemPrompt?: CustomSystemPrompt; /** Session-bound workspace root used for path-scoped instruction requests. */ projectPath?: string; /** Instructions resolved from the session's workspace. */ @@ -894,6 +897,7 @@ export type RuntimeMatchConfig = { pluginTools?: PluginToolDef[]; pluginSkills?: PluginSkillDef[]; trustedExtensions?: TrustedExtensionSpec[]; + customSystemPrompt?: CustomSystemPrompt; projectInstructions?: ProjectInstructions; projectMemory?: string; projectPath?: string; @@ -1478,6 +1482,7 @@ export class DesktopAgentRuntime { private onEvent: (envelope: AgentEventEnvelope) => void; private streamSink: StreamCoalescer; private baseSystemPrompt: string; + private customSystemPrompt?: CustomSystemPrompt; private planningState: PlanningState; private pendingPlanId?: string; private currentAssistant?: UiMessage; @@ -1697,6 +1702,7 @@ export class DesktopAgentRuntime { this.commandShell = opts.commandShell; this.scratchDir = opts.scratchDir; this.projectPath = opts.projectPath?.trim() || undefined; + this.customSystemPrompt = opts.customSystemPrompt; this.baseProjectInstructions = opts.projectInstructions; this.projectInstructions = opts.projectInstructions; this.projectMemory = opts.projectMemory?.trim() || undefined; @@ -1718,7 +1724,9 @@ export class DesktopAgentRuntime { rebuildChainsFromTranscript(this.transcriptHistory), ); const skillsPrompt = pluginSkillsPrompt(this.pluginSkills); - const defaultSystemPrompt = [ + // Parts: [0] is the product persona; [1:] are operational rules a custom + // SYSTEM.md must not remove (tool guidance, delegation, scratch, skills). + const defaultSystemPromptParts = [ DEFAULT_RUNTIME_SYSTEM_PROMPT, // Collaboration rules. Measured sessions ran hours with 380 assistant // messages and exactly one non-empty text body: a reasoning model reads @@ -1778,8 +1786,18 @@ Delegation rules: // path-scoped instruction reload never drops it, and it stays ahead of // the instruction chain so the user's own AGENTS.md keeps the last word. ...(skillsPrompt ? [skillsPrompt] : []), + ]; + // A custom SYSTEM.md replaces only the product persona line, never the + // operational rules in the default parts: tool guidance, delegation + // steering and scratch mechanics keep the desktop working (issue #542). + this.baseSystemPrompt = [ + ( + opts.customSystemPrompt?.replace ?? + opts.systemPrompt ?? + DEFAULT_RUNTIME_SYSTEM_PROMPT + ).trim(), + ...defaultSystemPromptParts.slice(1), ].join("\n\n"); - this.baseSystemPrompt = opts.systemPrompt ?? defaultSystemPrompt; this.agent = new Agent({ streamFn: (m, context, options) => { this.setAgentActivity({ phase: "waiting-model", since: Date.now() }); @@ -1958,6 +1976,7 @@ Delegation rules: this.mode, [ this.baseSystemPrompt, + ...(this.customSystemPrompt?.append ? [this.customSystemPrompt.append] : []), ...(optionalToolsPrompt ? [optionalToolsPrompt] : []), ...(projectPrompt ? [projectPrompt] : []), ...(memoryPrompt ? [memoryPrompt] : []), @@ -2210,6 +2229,10 @@ Delegation rules: safeJson(this.commandShell) === safeJson(config.commandShell) && safeJson(this.baseProjectInstructions ?? null) === safeJson(config.projectInstructions ?? null) && + // Editing SYSTEM.md / APPEND_SYSTEM.md retires the runtime so the next + // prompt recomposes from the fresh content. + safeJson(this.customSystemPrompt ?? null) === + safeJson(config.customSystemPrompt ?? null) && (this.projectMemory ?? "") === (config.projectMemory?.trim() ?? "") && (this.projectPath ?? "") === (config.projectPath?.trim() ?? "") && // Enabling a plugin, revoking agent.prompt.inject or renaming a skill diff --git a/packages/agent-runtime/src/sidecar.ts b/packages/agent-runtime/src/sidecar.ts index 5a4e227f2..885c3c7a2 100644 --- a/packages/agent-runtime/src/sidecar.ts +++ b/packages/agent-runtime/src/sidecar.ts @@ -21,6 +21,7 @@ import { import type { PluginSkillDef } from "./plugin-skills-prompt.js"; import type { SessionMessageOrigin, TrustedExtensionSpec } from "@pi-desktop/shared"; import type { ProjectInstructions } from "./project-instructions.js"; +import type { CustomSystemPrompt } from "./custom-system-prompt.js"; import { normalizeSupportedThinkingLevels, normalizeThinkingLevel, @@ -110,6 +111,7 @@ type RuntimeParams = { scratchDir?: string; /** Session-bound workspace root supplied by Electron main. */ projectPath?: string; + customSystemPrompt?: CustomSystemPrompt; projectInstructions?: ProjectInstructions; projectMemory?: string; compactionSettings?: ContextCompactionSettings; @@ -334,6 +336,7 @@ async function runtimeFor( subagentProviders, subagentModelKeys, projectInstructions: params.projectInstructions, + customSystemPrompt: params.customSystemPrompt, projectMemory: params.projectMemory, projectPath: params.projectPath, commandShell: params.commandShell, @@ -392,6 +395,7 @@ async function runtimeFor( subagentProviders, subagentModelKeys, projectPath: params.projectPath, + customSystemPrompt: params.customSystemPrompt, projectInstructions: params.projectInstructions, projectMemory: params.projectMemory, scratchDir: