mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
fix(coding-agent): emit before_agent_start for runs started by custom messages (#10739)
Runs started by pi.sendMessage(..., { triggerTurn: true }) skipped before_agent_start. After their first tool call, the next-turn refresh fell back to the base prompt options and patched away the sections handlers had added, changing the system prompt mid-run and breaking the provider prompt cache.
These runs now go through the same setup as prompt(): compaction check, before_agent_start, tool loadout, nextTurn messages, handler messages, and the system patch. BeforeAgentStartEvent is a union on messageType ("user" or "custom"); custom events carry the starting message.
The session counts as busy from the start of setup, so messages sent while handlers run go into the starting run instead of starting a second one, and an abort requested meanwhile aborts the run once it starts.
closes #10267, closes #5581, closes #10639, closes #10289
partly fixes #10606
This commit is contained in:
parent
c661bc574f
commit
a447ba11f3
11 changed files with 312 additions and 92 deletions
|
|
@ -5,6 +5,8 @@
|
|||
### Breaking Changes
|
||||
|
||||
- Published generated configuration schemas for models, settings, keybindings, and themes at `https://pi.dev/schemas/*.schema.json`. The theme schema moved to `https://pi.dev/schemas/theme.schema.json`; themes loaded during startup now receive the same schema validation as later reloads. Update theme `$schema` references, define reusable colors under `vars`, and remove unsupported metadata.
|
||||
- Changed `before_agent_start` to also fire for runs started by `pi.sendMessage(..., { triggerTurn: true })` while idle. `BeforeAgentStartEvent` is now a union on `messageType`: `"user"` events carry `prompt` and `images`, and `"custom"` events carry the starting `message` and no `prompt`. Messages returned by handlers are added to both kinds of run, and pending `deliverAs: "nextTurn"` messages are delivered with the custom-message run. Handlers that should act only on user prompts must check `event.messageType === "user"` ([#10267](https://github.com/earendil-works/pi/issues/10267), [#5581](https://github.com/earendil-works/pi/issues/5581))
|
||||
- Changed the session to count as busy while a run starts, including during `before_agent_start` handlers: `ctx.isIdle()` is false, `pi.sendMessage()` queues into the starting run instead of starting a second run that failed with "Agent is already processing", `pi.sendUserMessage()` requires `deliverAs`, and an abort requested meanwhile aborts the run once it starts
|
||||
|
||||
### Added
|
||||
|
||||
|
|
@ -23,6 +25,7 @@
|
|||
- Fixed Mistral streams being aborted mid-generation (e.g. during long thinking) after the request timeout; the timeout now applies only to waiting for response headers ([#10609](https://github.com/earendil-works/pi/issues/10609))
|
||||
- Fixed a symlinked `AGENTS.md` in a git worktree nested inside its main repo causing no project context file to load ([#10681](https://github.com/earendil-works/pi/issues/10681))
|
||||
- Fixed the code mode tool description claiming every failed tool call rejects; a tool that declares an output schema resolves with its value on failure too (e.g. `bash` with a nonzero exit)
|
||||
- Fixed runs started by `pi.sendMessage(..., { triggerTurn: true })` removing system prompt sections added in `before_agent_start` after their first tool call, which changed the prompt mid-run and broke the provider prompt cache twice per run ([#10267](https://github.com/earendil-works/pi/issues/10267))
|
||||
|
||||
## [1.1.0] - 2026-10-07
|
||||
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ Start long-lived resources from `session_start` or from the command or tool that
|
|||
Close session-scoped resources from an idempotent `session_shutdown` handler.
|
||||
|
||||
A run proceeds from input and `before_agent_start`, through model, message, and tool events, to `agent_end`.
|
||||
Pi counts as busy while a run starts, including the compaction check and `before_agent_start`, so messages sent meanwhile go into the starting run.
|
||||
Automatic retries, recovery, compaction, or queued work can continue afterward.
|
||||
<a id="agent_start--agent_end--agent_before_settle--agent_settled"></a>
|
||||
|
||||
|
|
@ -100,7 +101,7 @@ Use each event’s declared result type rather than assuming every return value
|
|||
|
||||
Events cover resource discovery, sessions, agent and message lifecycle, providers, tools, and raw input.
|
||||
|
||||
`before_agent_start` exposes both the current prompt and its structured `systemPromptOptions`. Prefer changing prompt sections, selected tools, or guidelines so Pi can append a transcript delta. Returning `systemPrompt`, or setting `forceSystemPrompt`, replaces the whole prompt for that run while the transcript continues recording the structured sections. Providers receive the forced text as their leading system prompt.
|
||||
`before_agent_start` exposes both the current prompt and its structured `systemPromptOptions`. `event.messageType` says what started the run: `"user"` for a prompt or `pi.sendUserMessage()`, with `prompt` and `images`; `"custom"` for `pi.sendMessage(..., { triggerTurn: true })`, with the starting `message` and no `prompt`. A returned `message` is added to either kind of run; check `messageType` to skip runs started by custom messages. Prefer changing prompt sections, selected tools, or guidelines so Pi can append a transcript delta. Returning `systemPrompt`, or setting `forceSystemPrompt`, replaces the whole prompt for that run while the transcript continues recording the structured sections. Providers receive the forced text as their leading system prompt.
|
||||
|
||||
`message_end` can replace a finalized message while preserving its role. `tool_call` can mutate input or block execution. `tool_result` handlers compose, with each handler seeing prior changes.
|
||||
|
||||
|
|
|
|||
|
|
@ -197,8 +197,12 @@ export default function planModeExtension(pi: ExtensionAPI): void {
|
|||
};
|
||||
});
|
||||
|
||||
// Inject plan/execution context before agent starts
|
||||
pi.on("before_agent_start", async () => {
|
||||
// Inject plan/execution context before a user prompt. Runs started by custom messages, such as
|
||||
// background task notifications, skip it: the context from the last prompt is still in the
|
||||
// conversation, and adding it again on every notification would only repeat it.
|
||||
pi.on("before_agent_start", async (event) => {
|
||||
if (event.messageType !== "user") return;
|
||||
|
||||
if (planModeEnabled) {
|
||||
return {
|
||||
message: {
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ import {
|
|||
type TurnStartEvent,
|
||||
wrapRegisteredTools,
|
||||
} from "./extensions/index.ts";
|
||||
import { emitSessionShutdownEvent } from "./extensions/runner.ts";
|
||||
import { type BeforeAgentStartTrigger, emitSessionShutdownEvent } from "./extensions/runner.ts";
|
||||
import { createToolNameMatcher, isMcpToolName } from "./mcp-servers.ts";
|
||||
import { type BashExecutionMessage, type CustomMessage, convertToLlm } from "./messages.ts";
|
||||
import { ModelRegistry } from "./model-registry.ts";
|
||||
|
|
@ -1101,6 +1101,9 @@ export class AgentSession {
|
|||
|
||||
/** Internal handler for agent events - shared by subscribe and reconnect */
|
||||
private _handleAgentEvent = async (event: AgentEvent): Promise<void> => {
|
||||
// An abort requested while the run was starting, e.g. during before_agent_start, stops it
|
||||
// here. agent_start is awaited before the first request.
|
||||
if (event.type === "agent_start" && this._agentRunAbortRequested) this.agent.abort();
|
||||
// Record the calls a tool made through ctx.executeTool() and their usage on its result message.
|
||||
if (this._nestedToolCalls) {
|
||||
if (event.type === "message_start" && event.message.role === "toolResult") {
|
||||
|
|
@ -1820,15 +1823,37 @@ export class AgentSession {
|
|||
// Prompting
|
||||
// =========================================================================
|
||||
|
||||
private async _runAgentPrompt(messages: AgentMessage | AgentMessage[]): Promise<void> {
|
||||
/**
|
||||
* Start a run from a user or custom message. The session counts as busy from here on, so
|
||||
* messages sent while before_agent_start handlers run are queued into this run instead of
|
||||
* starting another, and an abort requested meanwhile aborts the run as soon as it exists.
|
||||
* @param onStarted Called once the run's messages are prepared, before the first request.
|
||||
*/
|
||||
private async _startRun(trigger: BeforeAgentStartTrigger, onStarted?: () => void): Promise<void> {
|
||||
this._isAgentRunActive = true;
|
||||
this._agentRunAbortRequested = false;
|
||||
let messages: AgentMessage[];
|
||||
try {
|
||||
messages = await this._prepareRun(trigger);
|
||||
} catch (error) {
|
||||
this._isAgentRunActive = false;
|
||||
this._flushPendingBashMessages();
|
||||
this._flushPendingCustomMessages();
|
||||
this._resolveIdleWaitIfIdle();
|
||||
throw error;
|
||||
}
|
||||
// Messages held while the run was starting go before its starting messages, as they would
|
||||
// have while idle. No run exists yet, so they cannot split a tool call from its result.
|
||||
this._flushPendingBashMessages();
|
||||
this._flushPendingCustomMessages();
|
||||
onStarted?.();
|
||||
|
||||
// Compaction before the prompt may have scheduled a retry; the new prompt replaces it.
|
||||
this._failedResponse = undefined;
|
||||
this._recordSelection();
|
||||
// The run records the loadout in the transcript; restored tools that did not register by now
|
||||
// are dropped, so a tool that never registers does not stay pending.
|
||||
this._pendingToolNames.clear();
|
||||
this._isAgentRunActive = true;
|
||||
try {
|
||||
await this.agent.prompt(messages);
|
||||
while (!this._agentRunAbortRequested) {
|
||||
|
|
@ -1851,6 +1876,65 @@ export class AgentSession {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare a run and return its starting messages: compact first when needed, emit
|
||||
* before_agent_start, apply the run's prompt options and tool loadout, and build the starting
|
||||
* message followed by pending nextTurn messages (consumed here) and handler messages, with a
|
||||
* system patch first when the prompt changed.
|
||||
*/
|
||||
private async _prepareRun(trigger: BeforeAgentStartTrigger): Promise<AgentMessage[]> {
|
||||
// Check if we need to compact before sending (catches aborted responses).
|
||||
// The starting message is sent below, so do not call agent.continue() here.
|
||||
const lastAssistant = this._findLastAssistantMessage();
|
||||
if (lastAssistant) {
|
||||
await this._checkCompaction(lastAssistant, false);
|
||||
}
|
||||
|
||||
const selectedToolsBefore = this._baseSystemPromptOptions.selectedTools;
|
||||
const result = await this._extensionRunner.emitBeforeAgentStart(trigger, this._baseSystemPromptOptions);
|
||||
// Handlers may edit event.systemPromptOptions.selectedTools or call setActiveTools(),
|
||||
// which updates the live loadout instead. An explicit edit wins; otherwise the live
|
||||
// loadout is authoritative, so a setActiveTools() call is not undone here.
|
||||
const handlerEditedTools =
|
||||
result.systemPromptOptions.selectedTools.length !== selectedToolsBefore.length ||
|
||||
result.systemPromptOptions.selectedTools.some((name, index) => name !== selectedToolsBefore[index]);
|
||||
if (!handlerEditedTools) result.systemPromptOptions.selectedTools = this.getActiveToolNames();
|
||||
|
||||
const messages: AgentMessage[] = [];
|
||||
if (trigger.messageType === "custom") {
|
||||
messages.push(trigger.message);
|
||||
} else {
|
||||
// Normalize images after before_agent_start so extension-driven model selection
|
||||
// determines the resize profile used for the request and history.
|
||||
const normalized = await this._normalizePromptImages(trigger.images);
|
||||
const text =
|
||||
normalized.hints.length > 0 ? `${trigger.prompt}\n\n${normalized.hints.join("\n")}` : trigger.prompt;
|
||||
messages.push({
|
||||
role: "user",
|
||||
content: [{ type: "text", text }, ...normalized.images],
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
// Inject any pending "nextTurn" messages as context alongside the starting message
|
||||
messages.push(...this._pendingNextTurnMessages);
|
||||
this._pendingNextTurnMessages = [];
|
||||
for (const msg of result.messages) {
|
||||
messages.push({
|
||||
role: "custom",
|
||||
customType: msg.customType,
|
||||
// Untyped extensions can pass null/missing content; normalize at ingestion.
|
||||
content: msg.content ?? [],
|
||||
display: msg.display,
|
||||
details: msg.details,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
const updateMessage = this._preparePromptAndToolLoadout(result.systemPromptOptions);
|
||||
this._runSystemPromptOptions = result.systemPromptOptions;
|
||||
if (updateMessage) messages.unshift(updateMessage);
|
||||
return messages;
|
||||
}
|
||||
|
||||
private async _handlePostAgentRun(): Promise<boolean> {
|
||||
const message = this._lastAssistantMessage;
|
||||
const toolResults = this._lastAssistantToolResults;
|
||||
|
|
@ -2026,10 +2110,6 @@ export class AgentSession {
|
|||
return;
|
||||
}
|
||||
|
||||
// Flush any pending bash and custom messages before the new prompt
|
||||
this._flushPendingBashMessages();
|
||||
this._flushPendingCustomMessages();
|
||||
|
||||
// Validate model
|
||||
if (!this.model) {
|
||||
throw new Error(formatNoModelSelectedMessage());
|
||||
|
|
@ -2050,65 +2130,9 @@ export class AgentSession {
|
|||
throw new Error(formatNoApiKeyFoundMessage(this.model.provider));
|
||||
}
|
||||
|
||||
// Check if we need to compact before sending (catches aborted responses).
|
||||
// The user's new prompt is sent below, so do not call agent.continue() here.
|
||||
const lastAssistant = this._findLastAssistantMessage();
|
||||
if (lastAssistant) {
|
||||
await this._checkCompaction(lastAssistant, false);
|
||||
}
|
||||
|
||||
// Emit before_agent_start before normalizing images so extension-driven model
|
||||
// selection determines the resize profile used for the request and history.
|
||||
const selectedToolsBefore = this._baseSystemPromptOptions.selectedTools;
|
||||
const result = await this._extensionRunner.emitBeforeAgentStart(
|
||||
expandedText,
|
||||
currentImages,
|
||||
this._baseSystemPromptOptions,
|
||||
await this._startRun({ messageType: "user", prompt: expandedText, images: currentImages }, () =>
|
||||
preflightResult?.("started"),
|
||||
);
|
||||
// Handlers may edit event.systemPromptOptions.selectedTools or call setActiveTools(),
|
||||
// which updates the live loadout instead. An explicit edit wins; otherwise the live
|
||||
// loadout is authoritative, so a setActiveTools() call is not undone here.
|
||||
const handlerEditedTools =
|
||||
result.systemPromptOptions.selectedTools.length !== selectedToolsBefore.length ||
|
||||
result.systemPromptOptions.selectedTools.some((name, index) => name !== selectedToolsBefore[index]);
|
||||
if (!handlerEditedTools) result.systemPromptOptions.selectedTools = this.getActiveToolNames();
|
||||
|
||||
const normalized = await this._normalizePromptImages(currentImages);
|
||||
const userText = normalized.hints.length > 0 ? `${expandedText}\n\n${normalized.hints.join("\n")}` : expandedText;
|
||||
|
||||
// Build messages only after hooks and image normalization have completed.
|
||||
const messages: AgentMessage[] = [];
|
||||
const userContent: (TextContent | ImageContent)[] = [{ type: "text", text: userText }];
|
||||
userContent.push(...normalized.images);
|
||||
messages.push({
|
||||
role: "user",
|
||||
content: userContent,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// Inject any pending "nextTurn" messages as context alongside the user message
|
||||
for (const msg of this._pendingNextTurnMessages) {
|
||||
messages.push(msg);
|
||||
}
|
||||
this._pendingNextTurnMessages = [];
|
||||
|
||||
for (const msg of result.messages) {
|
||||
messages.push({
|
||||
role: "custom",
|
||||
customType: msg.customType,
|
||||
// Untyped extensions can pass null/missing content; normalize at ingestion.
|
||||
content: msg.content ?? [],
|
||||
display: msg.display,
|
||||
details: msg.details,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
const updateMessage = this._preparePromptAndToolLoadout(result.systemPromptOptions);
|
||||
this._runSystemPromptOptions = result.systemPromptOptions;
|
||||
if (updateMessage) messages.unshift(updateMessage);
|
||||
|
||||
preflightResult?.("started");
|
||||
await this._runAgentPrompt(messages);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -2314,10 +2338,10 @@ export class AgentSession {
|
|||
}
|
||||
} else if (options?.triggerTurn) {
|
||||
if (this._isEmittingAgentSettled) {
|
||||
this._deferredSettledActions.push(async () => await this._runAgentPrompt(appMessage));
|
||||
this._deferredSettledActions.push(async () => await this.sendCustomMessage(message, options));
|
||||
return;
|
||||
}
|
||||
await this._runAgentPrompt(appMessage);
|
||||
await this._startRun({ messageType: "custom", message: appMessage });
|
||||
} else if (this.isStreaming) {
|
||||
// Appending now would put the message between an assistant tool call and its
|
||||
// result, which providers that validate message order reject on replay. Defer
|
||||
|
|
|
|||
|
|
@ -37,8 +37,10 @@ export type {
|
|||
// Events - Tool (ToolCallEvent types)
|
||||
BashToolCallEvent,
|
||||
BashToolResultEvent,
|
||||
BeforeAgentStartCustomEvent,
|
||||
BeforeAgentStartEvent,
|
||||
BeforeAgentStartEventResult,
|
||||
BeforeAgentStartUserEvent,
|
||||
BeforeProviderHeadersEvent,
|
||||
BeforeProviderRequestEvent,
|
||||
BeforeProviderRequestEventResult,
|
||||
|
|
|
|||
|
|
@ -27,8 +27,10 @@ import {
|
|||
import type { VirtualModelDefinition } from "../virtual-models.ts";
|
||||
import type {
|
||||
AgentBeforeSettleEvent,
|
||||
BeforeAgentStartCustomEvent,
|
||||
BeforeAgentStartEvent,
|
||||
BeforeAgentStartEventResult,
|
||||
BeforeAgentStartUserEvent,
|
||||
BeforeProviderHeadersEvent,
|
||||
BeforeProviderRequestEvent,
|
||||
BoundaryContextPreview,
|
||||
|
|
@ -163,6 +165,11 @@ function isUserBashEventResult(value: unknown): value is UserBashEventResult {
|
|||
);
|
||||
}
|
||||
|
||||
/** The message that starts a run: the before_agent_start event without its prompt fields. */
|
||||
export type BeforeAgentStartTrigger =
|
||||
| Pick<BeforeAgentStartUserEvent, "messageType" | "prompt" | "images">
|
||||
| Pick<BeforeAgentStartCustomEvent, "messageType" | "message">;
|
||||
|
||||
/** Combined result from all before_agent_start handlers. */
|
||||
interface BeforeAgentStartCombinedResult {
|
||||
messages: NonNullable<BeforeAgentStartEventResult["message"]>[];
|
||||
|
|
@ -1418,8 +1425,7 @@ export class ExtensionRunner {
|
|||
}
|
||||
|
||||
async emitBeforeAgentStart(
|
||||
prompt: string,
|
||||
images: ImageContent[] | undefined,
|
||||
trigger: BeforeAgentStartTrigger,
|
||||
systemPromptOptions: BuildSystemPromptOptions,
|
||||
): Promise<BeforeAgentStartCombinedResult> {
|
||||
const currentOptions = normalizeBuildSystemPromptOptions(systemPromptOptions);
|
||||
|
|
@ -1438,9 +1444,8 @@ export class ExtensionRunner {
|
|||
for (const handler of handlers) {
|
||||
try {
|
||||
const event: BeforeAgentStartEvent = {
|
||||
...trigger,
|
||||
type: "before_agent_start",
|
||||
prompt,
|
||||
images,
|
||||
get systemPrompt() {
|
||||
return renderCurrentSystemPrompt();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -929,19 +929,33 @@ export interface ProviderStreamEvent {
|
|||
data: unknown;
|
||||
}
|
||||
|
||||
/** Fired after user submits prompt but before agent loop. */
|
||||
export interface BeforeAgentStartEvent {
|
||||
interface BeforeAgentStartEventBase {
|
||||
type: "before_agent_start";
|
||||
/** The raw user prompt text (after expansion). */
|
||||
prompt: string;
|
||||
/** Images attached to the user prompt, if any. */
|
||||
images?: ImageContent[];
|
||||
/** The current system prompt, rendered from systemPromptOptions and earlier handler changes. */
|
||||
readonly systemPrompt: string;
|
||||
/** Mutable prompt sections. Later handlers observe mutations made by earlier handlers. */
|
||||
systemPromptOptions: NormalizedBuildSystemPromptOptions;
|
||||
}
|
||||
|
||||
/** A run started by a submitted prompt, RPC prompt, or `pi.sendUserMessage()`. */
|
||||
export interface BeforeAgentStartUserEvent extends BeforeAgentStartEventBase {
|
||||
messageType: "user";
|
||||
/** The raw user prompt text (after expansion). */
|
||||
prompt: string;
|
||||
/** Images attached to the user prompt, if any. */
|
||||
images?: ImageContent[];
|
||||
}
|
||||
|
||||
/** A run started by `pi.sendMessage(..., { triggerTurn: true })` while idle. */
|
||||
export interface BeforeAgentStartCustomEvent extends BeforeAgentStartEventBase {
|
||||
messageType: "custom";
|
||||
/** The custom message that starts the run. */
|
||||
message: CustomMessage;
|
||||
}
|
||||
|
||||
/** Fired before a run starts from a user message or an idle custom message with `triggerTurn`. */
|
||||
export type BeforeAgentStartEvent = BeforeAgentStartUserEvent | BeforeAgentStartCustomEvent;
|
||||
|
||||
/** Fired when an agent loop starts */
|
||||
export interface AgentStartEvent {
|
||||
type: "agent_start";
|
||||
|
|
|
|||
|
|
@ -64,8 +64,10 @@ export type {
|
|||
AppKeybinding,
|
||||
AutocompleteProviderFactory,
|
||||
BashToolCallEvent,
|
||||
BeforeAgentStartCustomEvent,
|
||||
BeforeAgentStartEvent,
|
||||
BeforeAgentStartEventResult,
|
||||
BeforeAgentStartUserEvent,
|
||||
BeforeProviderHeadersEvent,
|
||||
BeforeProviderRequestEvent,
|
||||
BeforeProviderRequestEventResult,
|
||||
|
|
|
|||
|
|
@ -444,8 +444,7 @@ describe("AgentSession concurrent prompt guard", () => {
|
|||
streamingBehavior?: "steer" | "followUp",
|
||||
) => Promise<{ action: "continue" }>;
|
||||
emitBeforeAgentStart: (
|
||||
prompt: string,
|
||||
images: unknown,
|
||||
trigger: unknown,
|
||||
systemPromptOptions: BuildSystemPromptOptions,
|
||||
) => Promise<{ messages: []; systemPromptOptions: NormalizedBuildSystemPromptOptions }>;
|
||||
invalidate: (message?: string) => void;
|
||||
|
|
@ -465,7 +464,7 @@ describe("AgentSession concurrent prompt guard", () => {
|
|||
return undefined;
|
||||
},
|
||||
emitInput: async () => ({ action: "continue" }),
|
||||
emitBeforeAgentStart: async (_prompt, _images, systemPromptOptions) => ({
|
||||
emitBeforeAgentStart: async (_trigger, systemPromptOptions) => ({
|
||||
messages: [],
|
||||
systemPromptOptions: normalizeBuildSystemPromptOptions(systemPromptOptions),
|
||||
}),
|
||||
|
|
@ -591,8 +590,7 @@ describe("AgentSession concurrent prompt guard", () => {
|
|||
streamingBehavior?: "steer" | "followUp",
|
||||
) => Promise<{ action: "continue" }>;
|
||||
emitBeforeAgentStart: (
|
||||
prompt: string,
|
||||
images: unknown,
|
||||
trigger: unknown,
|
||||
systemPromptOptions: BuildSystemPromptOptions,
|
||||
) => Promise<{ messages: []; systemPromptOptions: NormalizedBuildSystemPromptOptions }>;
|
||||
invalidate: (message?: string) => void;
|
||||
|
|
@ -608,7 +606,7 @@ describe("AgentSession concurrent prompt guard", () => {
|
|||
return undefined;
|
||||
},
|
||||
emitInput: async () => ({ action: "continue" }),
|
||||
emitBeforeAgentStart: async (_prompt, _images, systemPromptOptions) => ({
|
||||
emitBeforeAgentStart: async (_trigger, systemPromptOptions) => ({
|
||||
messages: [],
|
||||
systemPromptOptions: normalizeBuildSystemPromptOptions(systemPromptOptions),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -877,10 +877,13 @@ describe("ExtensionRunner", () => {
|
|||
runner.onError((error) => errors.push(error.error));
|
||||
runner.bindCore(extensionActions, extensionContextActions);
|
||||
|
||||
const chained = await runner.emitBeforeAgentStart("hello", undefined, {
|
||||
cwd: tempDir,
|
||||
customPrompt: "base",
|
||||
});
|
||||
const chained = await runner.emitBeforeAgentStart(
|
||||
{ messageType: "user", prompt: "hello" },
|
||||
{
|
||||
cwd: tempDir,
|
||||
customPrompt: "base",
|
||||
},
|
||||
);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(chained.messages).toEqual([]);
|
||||
|
|
|
|||
|
|
@ -2,10 +2,16 @@ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||
import { fauxAssistantMessage, fauxToolCall, type Model } from "@earendil-works/pi-ai";
|
||||
import {
|
||||
fauxAssistantMessage,
|
||||
fauxToolCall,
|
||||
getCurrentSystemPrompt,
|
||||
type Model,
|
||||
type TranscriptContext,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import { Type } from "typebox";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ExtensionAPI, InputEvent } from "../../src/core/extensions/index.ts";
|
||||
import type { BeforeAgentStartEvent, ExtensionAPI, InputEvent } from "../../src/core/extensions/index.ts";
|
||||
import type { PromptTemplate } from "../../src/core/prompt-templates.ts";
|
||||
import { createSyntheticSourceInfo } from "../../src/core/source-info.ts";
|
||||
import { createTestResourceLoader } from "../utilities.ts";
|
||||
|
|
@ -556,4 +562,162 @@ describe("AgentSession prompt characterization", () => {
|
|||
`No API key found for ${harness.getModel().provider}.`,
|
||||
);
|
||||
});
|
||||
|
||||
// Issue #10267, #5581: a run started by sendMessage(..., { triggerTurn: true }) skipped
|
||||
// before_agent_start, so its second request patched away the sections handlers had added.
|
||||
describe("runs started by a custom message", () => {
|
||||
it("keeps the same system prompt for every request of the run", async () => {
|
||||
const events: BeforeAgentStartEvent[] = [];
|
||||
const noopTool: AgentTool = {
|
||||
name: "noop",
|
||||
label: "Noop",
|
||||
description: "Does nothing",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }),
|
||||
};
|
||||
const harness = await createHarness({
|
||||
tools: [noopTool],
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("before_agent_start", (event) => {
|
||||
events.push(event);
|
||||
event.systemPromptOptions.sections.extension_rules = "Answer in one word.";
|
||||
});
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
const prompts: string[] = [];
|
||||
const record = (response: ReturnType<typeof fauxAssistantMessage>) => (context: TranscriptContext) => {
|
||||
prompts.push(getCurrentSystemPrompt(context.messages));
|
||||
return response;
|
||||
};
|
||||
harness.setResponses([
|
||||
record(fauxAssistantMessage("hi")),
|
||||
record(fauxAssistantMessage([fauxToolCall("noop", {})], { stopReason: "toolUse" })),
|
||||
record(fauxAssistantMessage("done")),
|
||||
]);
|
||||
|
||||
await harness.session.prompt("hello");
|
||||
await harness.session.sendCustomMessage(
|
||||
{ customType: "task-done", content: "background task finished", display: true },
|
||||
{ triggerTurn: true },
|
||||
);
|
||||
|
||||
expect(prompts).toHaveLength(3);
|
||||
expect(prompts[0]).toContain("Answer in one word.");
|
||||
expect(new Set(prompts).size).toBe(1);
|
||||
expect(events.map((event) => event.messageType)).toEqual(["user", "custom"]);
|
||||
const customEvent = events[1];
|
||||
expect(customEvent.messageType === "custom" && customEvent.message.customType).toBe("task-done");
|
||||
});
|
||||
|
||||
it("queues a custom message sent while before_agent_start runs into the starting run", async () => {
|
||||
let releaseHandler: () => void = () => {};
|
||||
const handlerStarted = Promise.withResolvers<void>();
|
||||
const harness = await createHarness({
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("before_agent_start", async (event) => {
|
||||
if (event.messageType !== "custom" || event.message.customType !== "first") return;
|
||||
handlerStarted.resolve();
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseHandler = resolve;
|
||||
});
|
||||
});
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
const requests: TranscriptContext[] = [];
|
||||
harness.setResponses([
|
||||
(context) => {
|
||||
requests.push(context);
|
||||
return fauxAssistantMessage("handled both");
|
||||
},
|
||||
]);
|
||||
|
||||
const first = harness.session.sendCustomMessage(
|
||||
{ customType: "first", content: "task one finished", display: true },
|
||||
{ triggerTurn: true },
|
||||
);
|
||||
await handlerStarted.promise;
|
||||
expect(harness.session.isIdle).toBe(false);
|
||||
const second = harness.session.sendCustomMessage(
|
||||
{ customType: "second", content: "task two finished", display: true },
|
||||
{ triggerTurn: true },
|
||||
);
|
||||
releaseHandler();
|
||||
await Promise.all([first, second]);
|
||||
|
||||
expect(requests).toHaveLength(1);
|
||||
const texts = requests[0].messages.flatMap((message) =>
|
||||
message.role === "user" && typeof message.content !== "string"
|
||||
? message.content.flatMap((part) => (part.type === "text" ? [part.text] : []))
|
||||
: [],
|
||||
);
|
||||
expect(texts).toEqual(["task one finished", "task two finished"]);
|
||||
});
|
||||
|
||||
it("includes a custom message sent without triggerTurn while the run starts in its first request", async () => {
|
||||
const harness = await createHarness({
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("before_agent_start", () => {
|
||||
pi.sendMessage(
|
||||
{ customType: "note", content: "context from handler", display: true },
|
||||
{ triggerTurn: false },
|
||||
);
|
||||
});
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
const requests: TranscriptContext[] = [];
|
||||
harness.setResponses([
|
||||
(context) => {
|
||||
requests.push(context);
|
||||
return fauxAssistantMessage("done");
|
||||
},
|
||||
]);
|
||||
|
||||
await harness.session.prompt("hello");
|
||||
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(JSON.stringify(requests[0].messages)).toContain("context from handler");
|
||||
});
|
||||
|
||||
it("aborts a run when abort is requested while before_agent_start runs", async () => {
|
||||
let releaseHandler: () => void = () => {};
|
||||
const handlerStarted = Promise.withResolvers<void>();
|
||||
const harness = await createHarness({
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("before_agent_start", async () => {
|
||||
handlerStarted.resolve();
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseHandler = resolve;
|
||||
});
|
||||
});
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
harness.setResponses([fauxAssistantMessage("should not complete")]);
|
||||
|
||||
const run = harness.session.sendCustomMessage(
|
||||
{ customType: "task-done", content: "background task finished", display: true },
|
||||
{ triggerTurn: true },
|
||||
);
|
||||
await handlerStarted.promise;
|
||||
const aborted = harness.session.abort();
|
||||
releaseHandler();
|
||||
await Promise.all([run, aborted]);
|
||||
|
||||
const last = harness.session.messages.at(-1);
|
||||
expect(last?.role).toBe("assistant");
|
||||
expect(last?.role === "assistant" && last.stopReason).toBe("aborted");
|
||||
expect(harness.session.isIdle).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue