diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index e8fe4947cde5..81ee859b5a7d 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -18,7 +18,7 @@ ec22d7a039fb58d0b8343ad149322960d3d8ca58b3f4c70f2fa8a099f8186d0c module/agent-h 5f63bf587bf3547d59d0dc5d0dc2fee54745aa6edaab4aa3ae700dba03443edb module/agent-harness-tool-runtime 5168648cd946abad8a92822889f13ceacc87ed502314a66190d0b1eb8ebe76ea module/agent-media-payload 7e43370bef8d69479f958ee5f8c72d2c227b8d638e04857018439f7b34d4b116 module/agent-runtime -df9ef9807f5b7563885c2a77a621103a817f6c0690d2204ab10a1e42a049c060 module/agent-sessions +d24a44ad9030f4657fa339287ac342724a9c0969f2a390b915c82359345a568c module/agent-sessions dd9282f1eeadf44db2887599b52d80db7f5fb99c6d9eac720dbf1b77065f2145 module/allow-from 55cea5390d68839ca7768b4a0cc570b17b65fa0fa3bc4d76130ef0f16cb79ede module/allowlist-config-edit 7ddd81bd5f55de9adf64bf4d92d012f24b37b6da0a72805a3a220d8feff24ca3 module/approval-auth-runtime diff --git a/src/agents/sessions/agent-session-loop-correctness.test.ts b/src/agents/sessions/agent-session-loop-correctness.test.ts index 996c444a8564..571c458b5b04 100644 --- a/src/agents/sessions/agent-session-loop-correctness.test.ts +++ b/src/agents/sessions/agent-session-loop-correctness.test.ts @@ -201,6 +201,24 @@ afterEach(() => { }); describe("AgentSession loop correctness", () => { + it("emits agent_settled once after a normal run", async () => { + const lifecycleEvents: string[] = []; + const handlers = new Map Promise>>([ + ["agent_end", [async () => lifecycleEvents.push("agent_end")]], + ["agent_settled", [async () => lifecycleEvents.push("agent_settled")]], + ]); + streamMocks.streamSimple.mockImplementation((activeModel: Model) => + createAssistantResultStream( + createAssistant(activeModel, [{ type: "text", text: "complete answer" }]), + ), + ); + const { session } = await createTestSession({ resourceLoader: createResourceLoader(handlers) }); + + await session.prompt("new prompt"); + + expect(lifecycleEvents).toEqual(["agent_end", "agent_settled"]); + }); + it("manually compacts a completed turn smaller than the retained-token budget", async () => { const sessionManager = SessionManager.inMemory(); appendHistory( @@ -383,11 +401,13 @@ describe("AgentSession loop correctness", () => { it("drains a follow-up queued by an agent-end handler", async () => { const sessionRef: { current?: AgentSession } = {}; let queued = false; + const lifecycleEvents: string[] = []; const handlers = new Map Promise>>([ [ "agent_end", [ async () => { + lifecycleEvents.push("agent_end"); if (!queued) { queued = true; await sessionRef.current?.followUp("queued after end"); @@ -396,6 +416,7 @@ describe("AgentSession loop correctness", () => { }, ], ], + ["agent_settled", [async () => lifecycleEvents.push("agent_settled")]], ]); const requests: Context[] = []; streamMocks.streamSimple.mockImplementation((activeModel: Model, context: Context) => { @@ -412,10 +433,15 @@ describe("AgentSession loop correctness", () => { expect(requests).toHaveLength(2); expect(JSON.stringify(requests[1]?.messages)).toContain("queued after end"); expect(session.agent.hasQueuedMessages()).toBe(false); + expect(lifecycleEvents).toEqual(["agent_end", "agent_end", "agent_settled"]); }); it("leaves queued messages dormant after a turn handoff", async () => { const sessionRef: { current?: AgentSession } = {}; + const settled = vi.fn(); + const handlers = new Map Promise>>([ + ["agent_settled", [async () => settled()]], + ]); const yieldTool: ToolDefinition = { name: "yield_turn", label: "Yield turn", @@ -446,13 +472,17 @@ describe("AgentSession loop correctness", () => { ), ), ); - const { session } = await createTestSession({ customTools: [yieldTool] }); + const { session } = await createTestSession({ + customTools: [yieldTool], + resourceLoader: createResourceLoader(handlers), + }); sessionRef.current = session; await session.prompt("yield now"); expect(streamMocks.streamSimple).toHaveBeenCalledOnce(); expect(session.agent.hasQueuedMessages()).toBe(true); + expect(settled).not.toHaveBeenCalled(); session.agent.clearAllQueues(); }); diff --git a/src/agents/sessions/agent-session-prompting.ts b/src/agents/sessions/agent-session-prompting.ts index d34232b2f31d..9db679592119 100644 --- a/src/agents/sessions/agent-session-prompting.ts +++ b/src/agents/sessions/agent-session-prompting.ts @@ -14,35 +14,53 @@ import type { CustomMessage } from "./messages.js"; import { expandPromptTemplate } from "./prompt-templates.js"; import type { ResourceLoader } from "./resource-loader.js"; +type PostAgentRunAction = "continue" | "settled" | "handoff"; + export abstract class AgentSessionPrompting extends AgentSessionBase { // ========================================================================= // Prompting // ========================================================================= private async runAgentPrompt(messages: AgentMessage | AgentMessage[]): Promise { + let endedForTurnHandoff = false; try { await this.agent.prompt(messages); - while (await this.handlePostAgentRun()) { + while (true) { + const action = await this.handlePostAgentRun(); + if (action !== "continue") { + endedForTurnHandoff = action === "handoff"; + break; + } await this.agent.continue(); } } finally { this.systemPromptOverride = undefined; this.flushPendingBashMessages(); + // Consume handoff state before callbacks can start a nested run and set it again. + endedForTurnHandoff ||= this.lastRunEndedForTurnHandoff; + this.lastRunEndedForTurnHandoff = false; + // Failed or aborted runs can still be idle; only handoff leaves external delivery pending. + if (!endedForTurnHandoff) { + await this.currentExtensionRunner.emit({ type: "agent_settled" }); + } } } - private async handlePostAgentRun(): Promise { + private async handlePostAgentRun(): Promise { const msg = this.lastAssistantMessage; this.lastAssistantMessage = undefined; const endedForTurnHandoff = this.lastRunEndedForTurnHandoff; this.lastRunEndedForTurnHandoff = false; - if (!msg || endedForTurnHandoff) { + if (endedForTurnHandoff) { // External delivery owns the next run after a deliberate turn handoff. - return false; + return "handoff"; + } + if (!msg) { + return "settled"; } if (this.isRetryableError(msg) && (await this.prepareRetry(msg))) { - return true; + return "continue"; } if (msg.stopReason === "error" && this.retryCount > 0) { @@ -56,11 +74,11 @@ export abstract class AgentSessionPrompting extends AgentSessionBase { } if (await this.checkCompaction(msg)) { - return true; + return "continue"; } // Messages queued by agent_end handlers arrive after the loop's final queue drain. - return this.agent.hasQueuedMessages(); + return this.agent.hasQueuedMessages() ? "continue" : "settled"; } private createUserContent( diff --git a/src/agents/sessions/extensions/types.ts b/src/agents/sessions/extensions/types.ts index 53f05e87a181..4d7e2e559e84 100644 --- a/src/agents/sessions/extensions/types.ts +++ b/src/agents/sessions/extensions/types.ts @@ -717,6 +717,11 @@ interface AgentEndEvent { messages: AgentMessage[]; } +/** Fired once the session has no automatic retry, compaction, or queued continuation left. */ +interface AgentSettledEvent { + type: "agent_settled"; +} + /** Fired at the start of each turn */ export interface TurnStartEvent { type: "turn_start"; @@ -1047,6 +1052,7 @@ export type ExtensionEvent = | BeforeAgentStartEvent | AgentStartEvent | AgentEndEvent + | AgentSettledEvent | TurnStartEvent | TurnEndEvent | MessageStartEvent @@ -1218,6 +1224,7 @@ export interface ExtensionAPI { ): void; on(event: "agent_start", handler: ExtensionHandler): void; on(event: "agent_end", handler: ExtensionHandler): void; + on(event: "agent_settled", handler: ExtensionHandler): void; on(event: "turn_start", handler: ExtensionHandler): void; on(event: "turn_end", handler: ExtensionHandler): void; on(event: "message_start", handler: ExtensionHandler): void;