fix(coding-agent): chain system prompt in before_agent_start closes #3539

This commit is contained in:
Mario Zechner
2026-04-22 14:58:17 +02:00
parent 0e5b6e54f0
commit 4e919868f6
4 changed files with 66 additions and 4 deletions

View File

@@ -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))

View File

@@ -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) => {

View File

@@ -877,9 +877,16 @@ export class ExtensionRunner {
systemPrompt: string,
systemPromptOptions: BuildSystemPromptOptions,
): Promise<BeforeAgentStartCombinedResult | undefined> {
const ctx = this.createContext();
const messages: NonNullable<BeforeAgentStartEventResult["message"]>[] = [];
let currentSystemPrompt = systemPrompt;
const ctx = Object.defineProperties(
{},
Object.getOwnPropertyDescriptors(this.createContext()),
) as ExtensionContext;
ctx.getSystemPrompt = () => {
this.assertActive();
return currentSystemPrompt;
};
const messages: NonNullable<BeforeAgentStartEventResult["message"]>[] = [];
let systemPromptModified = false;
for (const ext of this.extensions) {

View File

@@ -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 = `