feat(plugins): emit agent_settled when a session run fully quiesces (#110174)

This commit is contained in:
Peter Steinberger
2026-07-18 00:53:40 +01:00
committed by GitHub
parent ac6fa704e8
commit f7e386d26d
4 changed files with 64 additions and 9 deletions
@@ -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
@@ -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<string, Array<(...args: unknown[]) => Promise<unknown>>>([
["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<string, Array<(...args: unknown[]) => Promise<unknown>>>([
[
"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<string, Array<(...args: unknown[]) => Promise<unknown>>>([
["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();
});
+25 -7
View File
@@ -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<void> {
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<boolean> {
private async handlePostAgentRun(): Promise<PostAgentRunAction> {
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(
+7
View File
@@ -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<AgentStartEvent>): void;
on(event: "agent_end", handler: ExtensionHandler<AgentEndEvent>): void;
on(event: "agent_settled", handler: ExtensionHandler<AgentSettledEvent>): void;
on(event: "turn_start", handler: ExtensionHandler<TurnStartEvent>): void;
on(event: "turn_end", handler: ExtensionHandler<TurnEndEvent>): void;
on(event: "message_start", handler: ExtensionHandler<MessageStartEvent>): void;