diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index de33d4554..90924cb49 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Breaking Changes + +- Changed `prepareNextTurn` and `prepareNextTurnWithContext` to run only after `shouldStopAfterTurn` and queued-message checks determine that the agent loop will start another assistant turn. They no longer run after final or terminating turns; move end-of-run work to `agent_end` handling ([#6879](https://github.com/earendil-works/pi/issues/6879)). + ### Fixed - Fixed Windows `NodeExecutionEnv` aborts crashing when `taskkill.exe` is unavailable on `PATH` ([#6596](https://github.com/earendil-works/pi/issues/6596)). diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index a251fede0..7a9d3516b 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -19,6 +19,7 @@ import type { AgentTool, AgentToolCall, AgentToolResult, + PrepareNextTurnContext, StreamFn, } from "./types.ts"; @@ -162,7 +163,7 @@ async function runLoop( ): Promise { let currentContext = initialContext; let config = initialConfig; - let firstTurn = true; + let lastCompletedTurn: PrepareNextTurnContext | undefined; // Check for steering messages at start (user may have typed while waiting) let pendingMessages: AgentMessage[] = (await config.getSteeringMessages?.()) || []; @@ -172,10 +173,28 @@ async function runLoop( // Inner loop: process tool calls and steering messages while (hasMoreToolCalls || pendingMessages.length > 0) { - if (!firstTurn) { + if (lastCompletedTurn) { + const nextTurnSnapshot = await config.prepareNextTurn?.(lastCompletedTurn); + if (nextTurnSnapshot) { + currentContext = nextTurnSnapshot.context ?? currentContext; + config = { + ...config, + model: nextTurnSnapshot.model ?? config.model, + reasoning: + nextTurnSnapshot.thinkingLevel === undefined + ? config.reasoning + : nextTurnSnapshot.thinkingLevel === "off" + ? undefined + : nextTurnSnapshot.thinkingLevel, + }; + } + // Preparation can be long-running (for example, compaction). Pick up steering + // queued while it ran. Only poll again if the earlier poll returned nothing; + // otherwise one-at-a-time mode would deliver two messages in this turn. + if (pendingMessages.length === 0) { + pendingMessages = (await config.getSteeringMessages?.()) || []; + } await emit({ type: "turn_start" }); - } else { - firstTurn = false; } // Process pending messages (inject before next assistant response) @@ -223,35 +242,14 @@ async function runLoop( await emit({ type: "turn_end", message, toolResults }); - const nextTurnContext = { + lastCompletedTurn = { message, toolResults, context: currentContext, newMessages, }; - const nextTurnSnapshot = await config.prepareNextTurn?.(nextTurnContext); - if (nextTurnSnapshot) { - currentContext = nextTurnSnapshot.context ?? currentContext; - config = { - ...config, - model: nextTurnSnapshot.model ?? config.model, - reasoning: - nextTurnSnapshot.thinkingLevel === undefined - ? config.reasoning - : nextTurnSnapshot.thinkingLevel === "off" - ? undefined - : nextTurnSnapshot.thinkingLevel, - }; - } - if ( - await config.shouldStopAfterTurn?.({ - message, - toolResults, - context: currentContext, - newMessages, - }) - ) { + if (await config.shouldStopAfterTurn?.(lastCompletedTurn)) { await emit({ type: "agent_end", messages: newMessages }); return; } diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index 5b20b21f4..1e16690b9 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -214,6 +214,7 @@ export interface AgentLoopConfig extends SimpleStreamOptions { * * If it returns true, the loop emits `agent_end` and exits before polling steering or follow-up queues, * without starting another LLM call. The current assistant response and any tool executions finish normally. + * This callback sees the completed-turn context and runs before `prepareNextTurn`. * * Use this to request a graceful stop after the current turn, e.g. before context gets too full. * @@ -222,8 +223,8 @@ export interface AgentLoopConfig extends SimpleStreamOptions { shouldStopAfterTurn?: (context: ShouldStopAfterTurnContext) => boolean | Promise; /** - * Called after `turn_end` and before the loop decides whether another provider request should start. - * Return replacement context/model/thinking state to affect the next turn in this run. + * Called after `turn_end` when the loop will continue, immediately before the next turn starts. + * Return replacement context/model/thinking state to affect that turn. * Return undefined to keep using the current context/config. */ prepareNextTurn?: ( diff --git a/packages/agent/test/agent-loop.test.ts b/packages/agent/test/agent-loop.test.ts index e5b2f1db8..d3255dfd0 100644 --- a/packages/agent/test/agent-loop.test.ts +++ b/packages/agent/test/agent-loop.test.ts @@ -1048,11 +1048,13 @@ describe("agentLoop with AgentMessage", () => { tools: [tool], }; let convertedSecondTurnSystemPrompt = ""; + let prepareCalls = 0; let prepared = false; const config: AgentLoopConfig = { model: createModel(), convertToLlm: identityConverter, prepareNextTurn: async ({ context: currentContext }) => { + prepareCalls++; if (prepared) return undefined; prepared = true; return { @@ -1098,6 +1100,7 @@ describe("agentLoop with AgentMessage", () => { } expect(llmCalls).toBe(2); + expect(prepareCalls).toBe(1); expect(convertedSecondTurnSystemPrompt).toBe("second prompt"); }); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 2dc6355ac..cdc10fdaf 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -17,6 +17,7 @@ - Fixed resumed sessions corrupting the next appended entry when their JSONL file lacks a trailing newline ([#8345](https://github.com/earendil-works/pi/issues/8345)). - Fixed extension messages sent with `triggerTurn: false` while the agent is running being inserted between a tool call and its result, which made providers that validate message order reject the replayed history. They are now appended once the turn's tool results are in ([#8537](https://github.com/earendil-works/pi/issues/8537)). - Fixed compaction and branch summaries forcing `toolChoice: "none"` ([#8649](https://github.com/earendil-works/pi/issues/8649), [#8638](https://github.com/earendil-works/pi/issues/8638)). +- Fixed large tool results crossing the auto-compaction threshold being sent to the provider before compaction. Pi now compacts between tool execution and the next assistant response in the same run, and restores interactive progress when that run resumes ([#6879](https://github.com/earendil-works/pi/issues/6879)). - Fixed Google Vertex requests failing with `HttpsProxyAgent is not a constructor` when the bundled Node.js runtime uses an HTTP(S) proxy ([#8610](https://github.com/earendil-works/pi/issues/8610)). ## [0.84.3] - 2026-08-24 diff --git a/packages/coding-agent/docs/compaction.md b/packages/coding-agent/docs/compaction.md index 3a9dff1c6..c3896f4e6 100644 --- a/packages/coding-agent/docs/compaction.md +++ b/packages/coding-agent/docs/compaction.md @@ -34,6 +34,8 @@ contextTokens > contextWindow - reserveTokens By default, `reserveTokens` is 16384 tokens (configurable in `~/.pi/agent/settings.json` or `/.pi/settings.json`). This leaves room for the LLM's response. +During a multi-turn agent run, Pi checks this threshold after tools finish and their results are appended, before starting the next assistant response. If the threshold is crossed, Pi compacts inside the same agent run and resumes with the summary and retained messages. It skips this between-turn check when the completed tool batch terminates the run and no queued message requires another response. Pi also checks the threshold before a new user prompt and after a low-level agent run ends. + You can also trigger manually with `/compact [instructions]`, where optional instructions focus the summary. ### How It Works diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index ea69b84ef..27b5ac13f 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -17,6 +17,7 @@ import { readFileSync } from "node:fs"; import { basename, dirname } from "node:path"; import type { Agent, + AgentContext, AgentEvent, AgentMessage, AgentState, @@ -539,6 +540,25 @@ export class AgentSession { }; } + private async _compactBeforeNextAssistantResponse(context: AgentContext): Promise { + const model = this.model; + const settings = this.settingsManager.getCompactionSettings(); + + if ( + !model || + model.contextWindow <= 0 || + !shouldCompact(estimateContextTokens(context.messages).tokens, model.contextWindow, settings) + ) { + return context; + } + + await this._runAutoCompaction("threshold", false); + return { + ...context, + messages: this.agent.state.messages.slice(), + }; + } + private _installAgentNextTurnRefresh(): void { const previousPrepareNextTurnWithContext = this.agent.prepareNextTurnWithContext ?? @@ -546,13 +566,14 @@ export class AgentSession { ? 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 context = await this._compactBeforeNextAssistantResponse(turn.context); + const previousSnapshot = await previousPrepareNextTurnWithContext?.({ ...turn, context }, signal); + const nextContext = previousSnapshot?.context ?? context; return { ...previousSnapshot, context: { - ...previousContext, + ...nextContext, systemPrompt: this._systemPromptOverride ?? this._baseSystemPrompt, tools: this.agent.state.tools.slice(), }, diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index bce3828e3..aee28d448 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -2159,6 +2159,16 @@ export class InteractiveMode { } } + private showWorkingStatusIndicator(): void { + this.showStatusIndicator( + new WorkingStatusIndicator( + this.ui, + this.workingMessage ?? this.defaultWorkingMessage, + this.workingIndicatorOptions, + ), + ); + } + private setWorkingVisible(visible: boolean): void { this.workingVisible = visible; if (!visible) { @@ -2167,13 +2177,7 @@ export class InteractiveMode { return; } if (this.session.isStreaming && this.activeStatusIndicator?.kind !== "working") { - this.showStatusIndicator( - new WorkingStatusIndicator( - this.ui, - this.workingMessage ?? this.defaultWorkingMessage, - this.workingIndicatorOptions, - ), - ); + this.showWorkingStatusIndicator(); } this.ui.requestRender(); } @@ -3182,23 +3186,22 @@ export class InteractiveMode { switch (event.type) { case "agent_start": this.pendingTools.clear(); - if (this.settingsManager.getShowTerminalProgress()) { - this.ui.terminal.setProgress(true); - } // Restore main escape handler if retry handler is still active // (retry success event fires later, but we need main handler now) if (this.retryEscapeHandler) { this.defaultEditor.onEscape = this.retryEscapeHandler; this.retryEscapeHandler = undefined; } + break; + + case "turn_start": + if (this.settingsManager.getShowTerminalProgress()) { + this.ui.terminal.setProgress(true); + } if (this.workingVisible) { - this.showStatusIndicator( - new WorkingStatusIndicator( - this.ui, - this.workingMessage ?? this.defaultWorkingMessage, - this.workingIndicatorOptions, - ), - ); + if (this.activeStatusIndicator?.kind !== "working") { + this.showWorkingStatusIndicator(); + } } else { this.clearStatusIndicator(); } diff --git a/packages/coding-agent/test/interactive-mode-compaction.test.ts b/packages/coding-agent/test/interactive-mode-compaction.test.ts index ece2d43ca..378be9303 100644 --- a/packages/coding-agent/test/interactive-mode-compaction.test.ts +++ b/packages/coding-agent/test/interactive-mode-compaction.test.ts @@ -197,6 +197,37 @@ describe("InteractiveMode compaction events", () => { expect(fakeThis.flushCompactionQueue).toHaveBeenCalledWith({ willRetry: false }); }); + test("updates the working state when the same agent run resumes after compaction", async () => { + const fakeThis = { + isInitialized: true, + footer: { invalidate: vi.fn() }, + activeStatusIndicator: undefined, + workingVisible: true, + showWorkingStatusIndicator: vi.fn(), + clearStatusIndicator: vi.fn(), + settingsManager: { getShowTerminalProgress: () => true }, + ui: { requestRender: vi.fn(), terminal: { setProgress: vi.fn() } }, + }; + const handleEvent = Reflect.get(InteractiveMode.prototype, "handleEvent") as ( + this: typeof fakeThis, + event: { type: "turn_start" }, + ) => Promise; + + await handleEvent.call(fakeThis, { type: "turn_start" }); + + expect(fakeThis.ui.terminal.setProgress).toHaveBeenCalledWith(true); + expect(fakeThis.showWorkingStatusIndicator).toHaveBeenCalledTimes(1); + expect(fakeThis.clearStatusIndicator).not.toHaveBeenCalled(); + expect(fakeThis.ui.requestRender).toHaveBeenCalledTimes(1); + + fakeThis.workingVisible = false; + await handleEvent.call(fakeThis, { type: "turn_start" }); + + expect(fakeThis.showWorkingStatusIndicator).toHaveBeenCalledTimes(1); + expect(fakeThis.clearStatusIndicator).toHaveBeenCalledTimes(1); + expect(fakeThis.ui.requestRender).toHaveBeenCalledTimes(2); + }); + test("preserves steering behavior when flushing into an active agent run", async () => { const fakeThis = { compactionQueuedMessages: [{ text: "change direction", mode: "steer" as const }], diff --git a/packages/coding-agent/test/suite/agent-session-compaction.test.ts b/packages/coding-agent/test/suite/agent-session-compaction.test.ts index 7d81358c5..64da8b4ab 100644 --- a/packages/coding-agent/test/suite/agent-session-compaction.test.ts +++ b/packages/coding-agent/test/suite/agent-session-compaction.test.ts @@ -1,12 +1,14 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { AgentMessage, AgentTool } from "@earendil-works/pi-agent-core"; import { type AssistantMessage, type Context, createAssistantMessageEventStream, fauxAssistantMessage, + fauxToolCall, type Model, type SimpleStreamOptions, } from "@earendil-works/pi-ai"; +import { Type } from "typebox"; import { afterEach, describe, expect, it, vi } from "vitest"; import { estimateTokens } from "../../src/core/compaction/index.ts"; import { createHarness, getUserTexts, type Harness } from "./harness.ts"; @@ -392,6 +394,175 @@ describe("AgentSession compaction characterization", () => { expect(harness.session.getLastAssistantText()).toBe("completed response"); }); + it("compacts after a tool result before the next assistant request in the same run", async () => { + const toolResult = `large-tool-result:${"x".repeat(6800)}`; + const largeTool: AgentTool = { + name: "large_result", + label: "Large result", + description: "Returns enough content to cross the compaction threshold", + parameters: Type.Object({}), + execute: async () => ({ content: [{ type: "text", text: toolResult }], details: {} }), + }; + const order: string[] = []; + const harness = await createHarness({ + models: [{ id: "faux-1", contextWindow: 2600, maxTokens: 100 }], + settings: { compaction: { enabled: true, reserveTokens: 400, keepRecentTokens: 1750 } }, + tools: [largeTool], + extensionFactories: [ + (pi) => { + pi.on("session_before_compact", (event) => { + order.push("compaction"); + return { + compaction: { + summary: "compacted history", + firstKeptEntryId: event.preparation.firstKeptEntryId, + tokensBefore: event.preparation.tokensBefore, + details: {}, + }, + }; + }); + }, + ], + }); + harnesses.push(harness); + let resumedRequest = ""; + harness.setResponses([ + fauxAssistantMessage(`old-history:${"a".repeat(800)}`), + fauxAssistantMessage(`recent-history:${"b".repeat(800)}`), + fauxAssistantMessage(fauxToolCall("large_result", {}), { stopReason: "toolUse" }), + (context) => { + order.push("provider"); + resumedRequest = JSON.stringify(context.messages); + return fauxAssistantMessage("finished after compaction"); + }, + ]); + + await harness.session.prompt("seed old history"); + await harness.session.prompt("seed recent history"); + const agentStartsBefore = harness.eventsOfType("agent_start").length; + await harness.session.prompt("run the large tool"); + + expect(order).toEqual(["compaction", "provider"]); + expect(harness.eventsOfType("agent_start")).toHaveLength(agentStartsBefore + 1); + expect(harness.eventsOfType("compaction_start").at(-1)).toEqual({ + type: "compaction_start", + reason: "threshold", + }); + expect(resumedRequest).toContain("compacted history"); + expect(resumedRequest).toContain("large-tool-result"); + expect(harness.session.getLastAssistantText()).toBe("finished after compaction"); + }); + + it("includes steering queued during compaction in the resumed assistant request", async () => { + const largeTool: AgentTool = { + name: "large_result", + label: "Large result", + description: "Returns enough content to cross the compaction threshold", + parameters: Type.Object({}), + execute: async () => ({ + content: [{ type: "text", text: `large-tool-result:${"x".repeat(6800)}` }], + details: {}, + }), + }; + let markCompactionStarted = () => {}; + const compactionStarted = new Promise((resolve) => { + markCompactionStarted = resolve; + }); + let releaseCompaction = () => {}; + const compactionReleased = new Promise((resolve) => { + releaseCompaction = resolve; + }); + const harness = await createHarness({ + models: [{ id: "faux-1", contextWindow: 2600, maxTokens: 100 }], + settings: { compaction: { enabled: true, reserveTokens: 400, keepRecentTokens: 1750 } }, + tools: [largeTool], + extensionFactories: [ + (pi) => { + pi.on("session_before_compact", async (event) => { + markCompactionStarted(); + await compactionReleased; + return { + compaction: { + summary: "compacted history", + firstKeptEntryId: event.preparation.firstKeptEntryId, + tokensBefore: event.preparation.tokensBefore, + details: {}, + }, + }; + }); + }, + ], + }); + harnesses.push(harness); + let resumedRequest = ""; + harness.setResponses([ + fauxAssistantMessage(`old-history:${"a".repeat(800)}`), + fauxAssistantMessage(`recent-history:${"b".repeat(800)}`), + fauxAssistantMessage(fauxToolCall("large_result", {}), { stopReason: "toolUse" }), + (context) => { + resumedRequest = JSON.stringify(context.messages); + return fauxAssistantMessage("finished after compaction"); + }, + fauxAssistantMessage("finished after delayed steering"), + ]); + + await harness.session.prompt("seed old history"); + await harness.session.prompt("seed recent history"); + const promptPromise = harness.session.prompt("run the large tool"); + await compactionStarted; + await harness.session.steer("change direction"); + releaseCompaction(); + await promptPromise; + + expect(resumedRequest).toContain("change direction"); + expect(harness.faux.state.callCount).toBe(4); + }); + + it("does not compact after a terminating tool result", async () => { + const terminatingTool: AgentTool = { + name: "terminate_with_large_result", + label: "Terminate with large result", + description: "Returns enough content to cross the compaction threshold, then terminates", + parameters: Type.Object({}), + execute: async () => ({ + content: [{ type: "text", text: `large-tool-result:${"x".repeat(6800)}` }], + details: {}, + terminate: true, + }), + }; + const harness = await createHarness({ + models: [{ id: "faux-1", contextWindow: 2600, maxTokens: 100 }], + settings: { compaction: { enabled: true, reserveTokens: 400, keepRecentTokens: 1750 } }, + tools: [terminatingTool], + extensionFactories: [ + (pi) => { + pi.on("session_before_compact", (event) => ({ + compaction: { + summary: "unexpected compaction", + firstKeptEntryId: event.preparation.firstKeptEntryId, + tokensBefore: event.preparation.tokensBefore, + details: {}, + }, + })); + }, + ], + }); + harnesses.push(harness); + harness.setResponses([ + fauxAssistantMessage(`old-history:${"a".repeat(800)}`), + fauxAssistantMessage(`recent-history:${"b".repeat(800)}`), + fauxAssistantMessage(fauxToolCall("terminate_with_large_result", {}), { stopReason: "toolUse" }), + ]); + + await harness.session.prompt("seed old history"); + await harness.session.prompt("seed recent history"); + await harness.session.prompt("run the terminating tool"); + + expect(harness.eventsOfType("compaction_start")).toEqual([]); + expect(harness.sessionManager.getEntries().filter((entry) => entry.type === "compaction")).toEqual([]); + expect(harness.getPendingResponseCount()).toBe(0); + }); + it("does not compact when a length stop reaches the desired output limit", async () => { const harness = await createHarness({ models: [{ id: "faux-1", contextWindow: 1_000_000, maxTokens: 100 }], diff --git a/packages/coding-agent/test/suite/regressions/7253-manual-compact-during-response.test.ts b/packages/coding-agent/test/suite/regressions/7253-manual-compact-during-response.test.ts index 4c0169ec2..937179bf1 100644 --- a/packages/coding-agent/test/suite/regressions/7253-manual-compact-during-response.test.ts +++ b/packages/coding-agent/test/suite/regressions/7253-manual-compact-during-response.test.ts @@ -23,7 +23,7 @@ describe("issue #7253: manual compaction during an active response", () => { } }); - it("runs only the requested manual compaction when the previous turn crossed the threshold", async () => { + it("persists the aborted response before running the requested manual compaction", async () => { let markSecondResponseStarted = () => {}; const secondResponseStarted = new Promise((resolve) => { markSecondResponseStarted = resolve; @@ -34,8 +34,8 @@ describe("issue #7253: manual compaction during an active response", () => { }); const harness = await createHarness({ - models: [{ id: "faux-1", contextWindow: 1000, maxTokens: 100 }], - settings: { compaction: { enabled: true, reserveTokens: 999, keepRecentTokens: 2 } }, + models: [{ id: "faux-1", contextWindow: 1000, maxTokens: 1000 }], + settings: { compaction: { enabled: true, reserveTokens: 200, keepRecentTokens: 2 } }, tools: [createNoopTool()], extensionFactories: [ (pi) => { @@ -56,7 +56,7 @@ describe("issue #7253: manual compaction during an active response", () => { async () => { markSecondResponseStarted(); await secondResponseReleased; - return fauxAssistantMessage("second response"); + return fauxAssistantMessage(`second response:${"x".repeat(4000)}`); }, ]); @@ -70,6 +70,14 @@ describe("issue #7253: manual compaction during an active response", () => { expect(harness.eventsOfType("compaction_start").map((event) => event.reason)).toEqual(["manual"]); expect(harness.eventsOfType("compaction_end").map((event) => event.reason)).toEqual(["manual"]); - expect(harness.sessionManager.getEntries().filter((entry) => entry.type === "compaction")).toHaveLength(1); + const entries = harness.sessionManager.getEntries(); + const abortedResponseIndex = entries.findIndex( + (entry) => + entry.type === "message" && entry.message.role === "assistant" && entry.message.stopReason === "aborted", + ); + const compactionIndex = entries.findIndex((entry) => entry.type === "compaction"); + expect(abortedResponseIndex).toBeGreaterThan(-1); + expect(compactionIndex).toBeGreaterThan(abortedResponseIndex); + expect(entries.filter((entry) => entry.type === "compaction")).toHaveLength(1); }); });