fix(coding-agent): preserve run prompt during tool refresh
Some checks are pending
CI / build-check-test (push) Waiting to run

closes #6162
This commit is contained in:
Mario Zechner 2026-06-30 14:08:48 +02:00
parent e547bb9f41
commit fd6659dd5d
7 changed files with 217 additions and 77 deletions

View file

@ -2,6 +2,14 @@
## [Unreleased] ## [Unreleased]
### Added
- Added `prepareNextTurnWithContext` for `Agent` users that need the next-turn loop context.
### Fixed
- Fixed `Agent.prepareNextTurn` to keep receiving the run abort signal instead of the next-turn context.
## [0.80.2] - 2026-06-23 ## [0.80.2] - 2026-06-23
### Changed ### Changed

View file

@ -105,6 +105,9 @@ export interface AgentOptions {
beforeToolCall?: (context: BeforeToolCallContext, signal?: AbortSignal) => Promise<BeforeToolCallResult | undefined>; beforeToolCall?: (context: BeforeToolCallContext, signal?: AbortSignal) => Promise<BeforeToolCallResult | undefined>;
afterToolCall?: (context: AfterToolCallContext, signal?: AbortSignal) => Promise<AfterToolCallResult | undefined>; afterToolCall?: (context: AfterToolCallContext, signal?: AbortSignal) => Promise<AfterToolCallResult | undefined>;
prepareNextTurn?: ( prepareNextTurn?: (
signal?: AbortSignal,
) => Promise<AgentLoopTurnUpdate | undefined> | AgentLoopTurnUpdate | undefined;
prepareNextTurnWithContext?: (
context: PrepareNextTurnContext, context: PrepareNextTurnContext,
signal?: AbortSignal, signal?: AbortSignal,
) => Promise<AgentLoopTurnUpdate | undefined> | AgentLoopTurnUpdate | undefined; ) => Promise<AgentLoopTurnUpdate | undefined> | AgentLoopTurnUpdate | undefined;
@ -186,6 +189,9 @@ export class Agent {
signal?: AbortSignal, signal?: AbortSignal,
) => Promise<AfterToolCallResult | undefined>; ) => Promise<AfterToolCallResult | undefined>;
public prepareNextTurn?: ( public prepareNextTurn?: (
signal?: AbortSignal,
) => Promise<AgentLoopTurnUpdate | undefined> | AgentLoopTurnUpdate | undefined;
public prepareNextTurnWithContext?: (
context: PrepareNextTurnContext, context: PrepareNextTurnContext,
signal?: AbortSignal, signal?: AbortSignal,
) => Promise<AgentLoopTurnUpdate | undefined> | AgentLoopTurnUpdate | undefined; ) => Promise<AgentLoopTurnUpdate | undefined> | AgentLoopTurnUpdate | undefined;
@ -212,6 +218,7 @@ export class Agent {
this.beforeToolCall = options.beforeToolCall; this.beforeToolCall = options.beforeToolCall;
this.afterToolCall = options.afterToolCall; this.afterToolCall = options.afterToolCall;
this.prepareNextTurn = options.prepareNextTurn; this.prepareNextTurn = options.prepareNextTurn;
this.prepareNextTurnWithContext = options.prepareNextTurnWithContext;
this.steeringQueue = new PendingMessageQueue(options.steeringMode ?? "one-at-a-time"); this.steeringQueue = new PendingMessageQueue(options.steeringMode ?? "one-at-a-time");
this.followUpQueue = new PendingMessageQueue(options.followUpMode ?? "one-at-a-time"); this.followUpQueue = new PendingMessageQueue(options.followUpMode ?? "one-at-a-time");
this.sessionId = options.sessionId; this.sessionId = options.sessionId;
@ -436,9 +443,15 @@ export class Agent {
toolExecution: this.toolExecution, toolExecution: this.toolExecution,
beforeToolCall: this.beforeToolCall, beforeToolCall: this.beforeToolCall,
afterToolCall: this.afterToolCall, afterToolCall: this.afterToolCall,
prepareNextTurn: this.prepareNextTurn prepareNextTurn:
? async (context) => await this.prepareNextTurn?.(context, this.signal) this.prepareNextTurnWithContext || this.prepareNextTurn
: undefined, ? async (context) => {
if (this.prepareNextTurnWithContext) {
return await this.prepareNextTurnWithContext(context, this.signal);
}
return await this.prepareNextTurn?.(this.signal);
}
: undefined,
convertToLlm: this.convertToLlm, convertToLlm: this.convertToLlm,
transformContext: this.transformContext, transformContext: this.transformContext,
getApiKey: this.getApiKey, getApiKey: this.getApiKey,

View file

@ -630,6 +630,47 @@ describe("Agent", () => {
expect(responseCount).toBe(2); expect(responseCount).toBe(2);
}); });
it("keeps legacy prepareNextTurn signal callback behavior", async () => {
const schema = Type.Object({});
const tool: AgentTool<typeof schema> = {
name: "noop",
label: "Noop",
description: "Noop tool",
parameters: schema,
execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }),
};
let requestCount = 0;
let sawAbortSignal = false;
const agent = new Agent({
initialState: { tools: [tool] },
prepareNextTurn: async (signal) => {
sawAbortSignal = signal instanceof AbortSignal;
return undefined;
},
streamFn: () => {
requestCount++;
const stream = new MockAssistantStream();
queueMicrotask(() => {
if (requestCount === 1) {
const message = createAssistantToolUseMessage([
{ type: "toolCall", id: "tool-1", name: "noop", arguments: {} },
]);
stream.push({ type: "done", reason: "toolUse", message });
return;
}
const message = createAssistantMessage("done");
stream.push({ type: "done", reason: "stop", message });
});
return stream;
},
});
await agent.prompt("start");
expect(requestCount).toBe(2);
expect(sawAbortSignal).toBe(true);
});
it("forwards sessionId to streamFn options", async () => { it("forwards sessionId to streamFn options", async () => {
let receivedSessionId: string | undefined; let receivedSessionId: string | undefined;
const agent = new Agent({ const agent = new Agent({

View file

@ -9,6 +9,7 @@
### Fixed ### Fixed
- Fixed extension tool changes to apply before the next provider request in the same agent run without dropping `before_agent_start` system-prompt overrides ([#6162](https://github.com/earendil-works/pi/issues/6162)).
- Fixed a crash when undici emits an internal client error while terminating a mid-stream HTTP response ([#6133](https://github.com/earendil-works/pi/issues/6133)). - Fixed a crash when undici emits an internal client error while terminating a mid-stream HTTP response ([#6133](https://github.com/earendil-works/pi/issues/6133)).
- Fixed the compaction event regression test to cover status indicator cleanup and keep CI passing. - Fixed the compaction event regression test to cover status indicator cleanup and keep CI passing.
- Fixed interactive status indicators so ending work, retry, compaction, or branch-summary indicators no longer shrink the TUI when clear-on-shrink is enabled ([#6026](https://github.com/earendil-works/pi/pull/6026)). - Fixed interactive status indicators so ending work, retry, compaction, or branch-summary indicators no longer shrink the TUI when clear-on-shrink is enabled ([#6026](https://github.com/earendil-works/pi/pull/6026)).

View file

@ -21,6 +21,7 @@ import type {
AgentMessage, AgentMessage,
AgentState, AgentState,
AgentTool, AgentTool,
PrepareNextTurnContext,
ThinkingLevel, ThinkingLevel,
} from "@earendil-works/pi-agent-core"; } from "@earendil-works/pi-agent-core";
import type { AssistantMessage, ImageContent, Message, Model, TextContent } from "@earendil-works/pi-ai/compat"; import type { AssistantMessage, ImageContent, Message, Model, TextContent } from "@earendil-works/pi-ai/compat";
@ -331,6 +332,7 @@ export class AgentSession {
// Base system prompt (without extension appends) - used to apply fresh appends each turn // Base system prompt (without extension appends) - used to apply fresh appends each turn
private _baseSystemPrompt = ""; private _baseSystemPrompt = "";
private _baseSystemPromptOptions!: BuildSystemPromptOptions; private _baseSystemPromptOptions!: BuildSystemPromptOptions;
private _systemPromptOverride?: string;
constructor(config: AgentSessionConfig) { constructor(config: AgentSessionConfig) {
this.agent = config.agent; this.agent = config.agent;
@ -464,16 +466,20 @@ export class AgentSession {
} }
private _installAgentNextTurnRefresh(): void { private _installAgentNextTurnRefresh(): void {
const previousPrepareNextTurn = this.agent.prepareNextTurn; const previousPrepareNextTurnWithContext =
this.agent.prepareNextTurn = async (turn, signal) => { this.agent.prepareNextTurnWithContext ??
const previousSnapshot = await previousPrepareNextTurn?.(turn, signal); (this.agent.prepareNextTurn
? async (_turn: PrepareNextTurnContext, signal?: AbortSignal) => await this.agent.prepareNextTurn?.(signal)
: undefined);
this.agent.prepareNextTurnWithContext = async (turn, signal) => {
const previousSnapshot = await previousPrepareNextTurnWithContext?.(turn, signal);
const previousContext = previousSnapshot?.context ?? turn.context; const previousContext = previousSnapshot?.context ?? turn.context;
return { return {
...previousSnapshot, ...previousSnapshot,
context: { context: {
...previousContext, ...previousContext,
systemPrompt: this.agent.state.systemPrompt, systemPrompt: this._systemPromptOverride ?? this._baseSystemPrompt,
tools: this.agent.state.tools.slice(), tools: this.agent.state.tools.slice(),
}, },
model: this.agent.state.model, model: this.agent.state.model,
@ -844,7 +850,7 @@ export class AgentSession {
// Rebuild base system prompt with new tool set // Rebuild base system prompt with new tool set
this._baseSystemPrompt = this._rebuildSystemPrompt(validToolNames); this._baseSystemPrompt = this._rebuildSystemPrompt(validToolNames);
this.agent.state.systemPrompt = this._baseSystemPrompt; this.agent.state.systemPrompt = this._systemPromptOverride ?? this._baseSystemPrompt;
} }
/** Whether compaction or branch summarization is currently running */ /** Whether compaction or branch summarization is currently running */
@ -972,6 +978,7 @@ export class AgentSession {
await this.agent.continue(); await this.agent.continue();
} }
} finally { } finally {
this._systemPromptOverride = undefined;
this._flushPendingBashMessages(); this._flushPendingBashMessages();
} }
} }
@ -1143,10 +1150,12 @@ export class AgentSession {
} }
} }
// Apply extension-modified system prompt, or reset to base // Apply extension-modified system prompt, or reset to base
if (result?.systemPrompt) { if (result?.systemPrompt !== undefined) {
this._systemPromptOverride = result.systemPrompt;
this.agent.state.systemPrompt = result.systemPrompt; this.agent.state.systemPrompt = result.systemPrompt;
} else { } else {
// Ensure we're using the base prompt (in case previous turn had modifications) // Ensure we're using the base prompt (in case previous turn had modifications)
this._systemPromptOverride = undefined;
this.agent.state.systemPrompt = this._baseSystemPrompt; this.agent.state.systemPrompt = this._baseSystemPrompt;
} }
} catch (error) { } catch (error) {

View file

@ -0,0 +1,136 @@
import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai";
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import type { ExtensionFactory } from "../../../src/index.ts";
import { createHarness } from "../harness.ts";
describe("extension active tools next-turn refresh", () => {
it("applies pi.setActiveTools before the next provider request in the same run", async () => {
const extensionFactories: ExtensionFactory[] = [
(pi) => {
pi.registerTool({
name: "switch_tools",
label: "Switch Tools",
description: "Switch the active extension tool set",
promptSnippet: "Switch to the next extension tool",
parameters: Type.Object({}),
execute: async () => {
pi.setActiveTools(["after_switch"]);
return {
content: [{ type: "text", text: "switched" }],
details: {},
};
},
});
pi.registerTool({
name: "after_switch",
label: "After Switch",
description: "Tool that should be available after switching",
promptSnippet: "Run after the active tool set changes",
parameters: Type.Object({}),
execute: async () => ({
content: [{ type: "text", text: "after" }],
details: {},
}),
});
},
];
const harness = await createHarness({
extensionFactories,
});
try {
harness.session.setActiveToolsByName(["switch_tools"]);
const providerToolNames: string[][] = [];
harness.setResponses([
(context) => {
providerToolNames.push((context.tools ?? []).map((tool) => tool.name).sort());
return fauxAssistantMessage(fauxToolCall("switch_tools", {}), { stopReason: "toolUse" });
},
(context) => {
providerToolNames.push((context.tools ?? []).map((tool) => tool.name).sort());
return fauxAssistantMessage("done");
},
]);
expect(harness.session.getActiveToolNames()).toEqual(["switch_tools"]);
await harness.session.prompt("start");
expect(harness.session.getActiveToolNames()).toEqual(["after_switch"]);
expect(providerToolNames).toEqual([["switch_tools"], ["after_switch"]]);
} finally {
harness.cleanup();
}
});
it("preserves before_agent_start system prompt overrides when tools change mid-run", async () => {
const extensionFactories: ExtensionFactory[] = [
(pi) => {
pi.on("before_agent_start", async (event) => ({
systemPrompt: `${event.systemPrompt}\n\nkeep this run override`,
}));
pi.registerTool({
name: "switch_tools",
label: "Switch Tools",
description: "Switch the active extension tool set",
promptSnippet: "Switch to the next extension tool",
parameters: Type.Object({}),
execute: async () => {
pi.setActiveTools(["after_switch"]);
return {
content: [{ type: "text", text: "switched" }],
details: {},
};
},
});
pi.registerTool({
name: "after_switch",
label: "After Switch",
description: "Tool that should be available after switching",
promptSnippet: "Run after the active tool set changes",
parameters: Type.Object({}),
execute: async () => ({
content: [{ type: "text", text: "after" }],
details: {},
}),
});
},
];
const harness = await createHarness({
extensionFactories,
});
try {
harness.session.setActiveToolsByName(["switch_tools"]);
const providerSystemPrompts: string[] = [];
const providerToolNames: string[][] = [];
harness.setResponses([
(context) => {
providerSystemPrompts.push(context.systemPrompt ?? "");
providerToolNames.push((context.tools ?? []).map((tool) => tool.name).sort());
return fauxAssistantMessage(fauxToolCall("switch_tools", {}), { stopReason: "toolUse" });
},
(context) => {
providerSystemPrompts.push(context.systemPrompt ?? "");
providerToolNames.push((context.tools ?? []).map((tool) => tool.name).sort());
return fauxAssistantMessage("done");
},
]);
await harness.session.prompt("start");
expect(providerToolNames).toEqual([["switch_tools"], ["after_switch"]]);
expect(providerSystemPrompts).toHaveLength(2);
expect(providerSystemPrompts[0]).toContain("keep this run override");
expect(providerSystemPrompts[1]).toContain("keep this run override");
} finally {
harness.cleanup();
}
});
});

View file

@ -1,68 +0,0 @@
import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai";
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import type { ExtensionFactory } from "../../../src/index.ts";
import { createHarness } from "../harness.ts";
describe("extension active tools next-turn refresh", () => {
it("applies pi.setActiveTools before the next provider request in the same run", async () => {
const extensionFactories: ExtensionFactory[] = [
(pi) => {
pi.registerTool({
name: "switch_tools",
label: "Switch Tools",
description: "Switch the active extension tool set",
promptSnippet: "Switch to the next extension tool",
parameters: Type.Object({}),
execute: async () => {
pi.setActiveTools(["after_switch"]);
return {
content: [{ type: "text", text: "switched" }],
details: {},
};
},
});
pi.registerTool({
name: "after_switch",
label: "After Switch",
description: "Tool that should be available after switching",
promptSnippet: "Run after the active tool set changes",
parameters: Type.Object({}),
execute: async () => ({
content: [{ type: "text", text: "after" }],
details: {},
}),
});
},
];
const harness = await createHarness({
extensionFactories,
});
try {
harness.session.setActiveToolsByName(["switch_tools"]);
const providerToolNames: string[][] = [];
harness.setResponses([
(context) => {
providerToolNames.push((context.tools ?? []).map((tool) => tool.name).sort());
return fauxAssistantMessage(fauxToolCall("switch_tools", {}), { stopReason: "toolUse" });
},
(context) => {
providerToolNames.push((context.tools ?? []).map((tool) => tool.name).sort());
return fauxAssistantMessage("done");
},
]);
expect(harness.session.getActiveToolNames()).toEqual(["switch_tools"]);
await harness.session.prompt("start");
expect(harness.session.getActiveToolNames()).toEqual(["after_switch"]);
expect(providerToolNames).toEqual([["switch_tools"], ["after_switch"]]);
} finally {
harness.cleanup();
}
});
});