fix(coding-agent): compact before post-tool model requests (#8782)

Run next-turn preparation only when the agent loop continues, allowing threshold compaction before the next provider request. Restore interactive progress when the same run resumes.

Closes #6879

Important to notice:
this changes when prepareNextTurn & prepareNextTurnWithContext are invoked:
the now run only when a next turn actually happens, and after shouldStopAfterTurn.
This commit is contained in:
David Brailovsky 2026-08-28 13:40:59 +02:00 • committed by GitHub
parent 56f3f33a9a
commit 56700d42ed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 298 additions and 55 deletions

View file

@ -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)).

View file

@ -19,6 +19,7 @@ import type {
AgentTool,
AgentToolCall,
AgentToolResult,
PrepareNextTurnContext,
StreamFn,
} from "./types.ts";
@ -162,7 +163,7 @@ async function runLoop(
): Promise<void> {
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;
}

View file

@ -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<boolean>;
/**
* 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?: (

View file

@ -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");
});

View file

@ -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

View file

@ -34,6 +34,8 @@ contextTokens > contextWindow - reserveTokens
By default, `reserveTokens` is 16384 tokens (configurable in `~/.pi/agent/settings.json` or `<project-dir>/.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

View file

@ -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<AgentContext> {
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(),
},

View file

@ -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();
}

View file

@ -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<void>;
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 }],

View file

@ -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<void>((resolve) => {
markCompactionStarted = resolve;
});
let releaseCompaction = () => {};
const compactionReleased = new Promise<void>((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 }],

View file

@ -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<void>((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);
});
});