From 4e919868f665e13c61dd9f24c6e22b96f49418c9 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 22 Apr 2026 14:58:17 +0200 Subject: [PATCH] fix(coding-agent): chain system prompt in before_agent_start closes #3539 --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/extensions.md | 14 +++++- .../src/core/extensions/runner.ts | 11 ++++- .../test/extensions-runner.test.ts | 44 +++++++++++++++++++ 4 files changed, 66 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index b821b465..4ce1addb 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -13,6 +13,7 @@ ### Fixed +- Fixed `ctx.getSystemPrompt()` inside `before_agent_start` to reflect chained system-prompt changes made by earlier `before_agent_start` handlers, and clarified the extension docs around provider-payload rewrites and what `ctx.getSystemPrompt()` does and does not report ([#3539](https://github.com/badlogic/pi-mono/issues/3539)) - Fixed extension session-replacement flows so `ctx.newSession()`, `ctx.fork()`, `ctx.switchSession()`, and imported-session replacements fully rebind before post-switch work runs, added `withSession` replacement callbacks with fresh `ReplacedSessionContext` helpers, and make stale pre-replacement `pi` / `ctx` session-bound accesses throw instead of silently targeting the wrong session ([#2860](https://github.com/badlogic/pi-mono/issues/2860)) - Fixed `models.json` built-in provider overrides to accept `headers` without requiring `baseUrl`, so request-header-only overrides now load and apply correctly ([#3538](https://github.com/badlogic/pi-mono/issues/3538)) diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 86a0b385..b94555f2 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -466,7 +466,8 @@ Fired after user submits prompt, before agent loop. Can inject a message and/or pi.on("before_agent_start", async (event, ctx) => { // event.prompt - user's prompt text // event.images - attached images (if any) - // event.systemPrompt - current system prompt + // event.systemPrompt - current chained system prompt for this handler + // (includes changes from earlier before_agent_start handlers) // event.systemPromptOptions - structured options used to build the system prompt // .customPrompt - any custom system prompt (from --system-prompt, SYSTEM.md, or custom templates) // .selectedTools - tools currently active in the prompt @@ -492,6 +493,8 @@ pi.on("before_agent_start", async (event, ctx) => { The `systemPromptOptions` field gives extensions access to the same structured data Pi uses to build the system prompt. This lets you inspect what Pi has loaded — custom prompts, guidelines, tool snippets, context files, skills — without re-discovering resources or re-parsing flags. Use it when your extension needs to make deep, informed changes to the system prompt while respecting user-provided configuration. +Inside `before_agent_start`, `event.systemPrompt` and `ctx.getSystemPrompt()` both reflect the chained system prompt as of the current handler. Later `before_agent_start` handlers can still modify it again. + #### agent_start / agent_end Fired once per user prompt. @@ -580,6 +583,8 @@ pi.on("context", async (event, ctx) => { Fired after the provider-specific payload is built, right before the request is sent. Handlers run in extension load order. Returning `undefined` keeps the payload unchanged. Returning any other value replaces the payload for later handlers and for the actual request. +This hook can rewrite provider-level system instructions or remove them entirely. Those payload-level changes are not reflected by `ctx.getSystemPrompt()`, which reports Pi's system prompt string rather than the final serialized provider payload. + ```typescript pi.on("before_provider_request", (event, ctx) => { console.log(JSON.stringify(event.payload, null, 2)); @@ -918,7 +923,12 @@ ctx.compact({ ### ctx.getSystemPrompt() -Returns the current effective system prompt. This includes any modifications made by `before_agent_start` handlers for the current turn. +Returns Pi's current system prompt string. + +- During `before_agent_start`, this reflects chained system-prompt changes made so far for the current turn. +- It does not include later `context` message mutations. +- It does not include `before_provider_request` payload rewrites. +- If later-loaded extensions run after yours, they can still change what is ultimately sent. ```typescript pi.on("before_agent_start", (event, ctx) => { diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index 7ce0a2c4..15c571bf 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -877,9 +877,16 @@ export class ExtensionRunner { systemPrompt: string, systemPromptOptions: BuildSystemPromptOptions, ): Promise { - const ctx = this.createContext(); - const messages: NonNullable[] = []; let currentSystemPrompt = systemPrompt; + const ctx = Object.defineProperties( + {}, + Object.getOwnPropertyDescriptors(this.createContext()), + ) as ExtensionContext; + ctx.getSystemPrompt = () => { + this.assertActive(); + return currentSystemPrompt; + }; + const messages: NonNullable[] = []; let systemPromptModified = false; for (const ext of this.extensions) { diff --git a/packages/coding-agent/test/extensions-runner.test.ts b/packages/coding-agent/test/extensions-runner.test.ts index 7719ed48..8ad5607b 100644 --- a/packages/coding-agent/test/extensions-runner.test.ts +++ b/packages/coding-agent/test/extensions-runner.test.ts @@ -562,6 +562,50 @@ describe("ExtensionRunner", () => { }); }); + describe("before_agent_start", () => { + it("keeps ctx.getSystemPrompt() in sync with chained system prompt updates", async () => { + const extCode1 = ` + export default function(pi) { + pi.on("before_agent_start", async (_event, ctx) => { + return { + systemPrompt: ctx.getSystemPrompt() + "\\nfirst", + }; + }); + } + `; + const extCode2 = ` + export default function(pi) { + pi.on("before_agent_start", async (_event, ctx) => { + return { + systemPrompt: ctx.getSystemPrompt() + "\\nsecond", + }; + }); + } + `; + fs.writeFileSync(path.join(extensionsDir, "before-agent-start-1.ts"), extCode1); + fs.writeFileSync(path.join(extensionsDir, "before-agent-start-2.ts"), extCode2); + + const result = await discoverAndLoadExtensions([], tempDir, tempDir); + expect(result.errors).toEqual([]); + expect(result.extensions).toHaveLength(2); + const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); + const errors: string[] = []; + runner.onError((error) => errors.push(error.error)); + runner.bindCore(extensionActions, extensionContextActions); + + const chained = await runner.emitBeforeAgentStart("hello", undefined, "base", { + cwd: tempDir, + }); + + expect(errors).toEqual([]); + + expect(chained).toEqual({ + messages: undefined, + systemPrompt: "base\nfirst\nsecond", + }); + }); + }); + describe("tool_result chaining", () => { it("chains content modifications across handlers", async () => { const extCode1 = `