Merge pull request #4600 from earendil-works/stream-fn-in-compaction

fix(coding-agent): route compaction through streamFn
This commit is contained in:
Mario Zechner
2026-05-17 21:00:01 +02:00
committed by GitHub
4 changed files with 149 additions and 27 deletions

View File

@@ -8,6 +8,7 @@
### Fixed ### Fixed
- Fixed compaction summary calls to use custom agent stream functions, preserving proxy-backed LLM routing ([#4484](https://github.com/earendil-works/pi/issues/4484)).
- Fixed user-scoped npm pi packages to install under `~/.pi/agent/npm/` instead of npm's global package root, avoiding permission errors with system-managed Node installs ([#4587](https://github.com/earendil-works/pi/issues/4587)). - Fixed user-scoped npm pi packages to install under `~/.pi/agent/npm/` instead of npm's global package root, avoiding permission errors with system-managed Node installs ([#4587](https://github.com/earendil-works/pi/issues/4587)).
- Fixed Mistral requests failing after the global fetch proxy/timeout workaround by removing the custom fetch override and using undici 8 dispatcher support instead ([#4619](https://github.com/earendil-works/pi/issues/4619)). - Fixed Mistral requests failing after the global fetch proxy/timeout workaround by removing the custom fetch override and using undici 8 dispatcher support instead ([#4619](https://github.com/earendil-works/pi/issues/4619)).
- Fixed default output token requests for models whose advertised output limit is effectively their full context window, avoiding impossible provider requests inherited from `@earendil-works/pi-ai` ([#4614](https://github.com/earendil-works/pi/issues/4614)). - Fixed default output token requests for models whose advertised output limit is effectively their full context window, avoiding impossible provider requests inherited from `@earendil-works/pi-ai` ([#4614](https://github.com/earendil-works/pi/issues/4614)).

View File

@@ -31,6 +31,7 @@ import {
isContextOverflow, isContextOverflow,
modelsAreEqual, modelsAreEqual,
resetApiProviders, resetApiProviders,
streamSimple,
} from "@earendil-works/pi-ai"; } from "@earendil-works/pi-ai";
import { theme } from "../modes/interactive/theme/theme.js"; import { theme } from "../modes/interactive/theme/theme.js";
import { stripFrontmatter } from "../utils/frontmatter.js"; import { stripFrontmatter } from "../utils/frontmatter.js";
@@ -367,6 +368,18 @@ export class AgentSession {
throw new Error(formatNoApiKeyFoundMessage(model.provider)); throw new Error(formatNoApiKeyFoundMessage(model.provider));
} }
private async _getCompactionRequestAuth(model: Model<any>): Promise<{
apiKey?: string;
headers?: Record<string, string>;
}> {
if (this.agent.streamFn === streamSimple) {
return this._getRequiredRequestAuth(model);
}
const result = await this._modelRegistry.getApiKeyAndHeaders(model);
return result.ok ? { apiKey: result.apiKey, headers: result.headers } : {};
}
/** /**
* Install tool hooks once on the Agent instance. * Install tool hooks once on the Agent instance.
* *
@@ -1618,7 +1631,7 @@ export class AgentSession {
throw new Error(formatNoModelSelectedMessage()); throw new Error(formatNoModelSelectedMessage());
} }
const { apiKey, headers } = await this._getRequiredRequestAuth(this.model); const { apiKey, headers } = await this._getCompactionRequestAuth(this.model);
const pathEntries = this.sessionManager.getBranch(); const pathEntries = this.sessionManager.getBranch();
const settings = this.settingsManager.getCompactionSettings(); const settings = this.settingsManager.getCompactionSettings();
@@ -1676,6 +1689,7 @@ export class AgentSession {
customInstructions, customInstructions,
this._compactionAbortController.signal, this._compactionAbortController.signal,
this.thinkingLevel, this.thinkingLevel,
this.agent.streamFn,
); );
summary = result.summary; summary = result.summary;
firstKeptEntryId = result.firstKeptEntryId; firstKeptEntryId = result.firstKeptEntryId;
@@ -1864,18 +1878,25 @@ export class AgentSession {
return; return;
} }
const authResult = await this._modelRegistry.getApiKeyAndHeaders(this.model); let apiKey: string | undefined;
if (!authResult.ok || !authResult.apiKey) { let headers: Record<string, string> | undefined;
this._emit({ if (this.agent.streamFn === streamSimple) {
type: "compaction_end", const authResult = await this._modelRegistry.getApiKeyAndHeaders(this.model);
reason, if (!authResult.ok || !authResult.apiKey) {
result: undefined, this._emit({
aborted: false, type: "compaction_end",
willRetry: false, reason,
}); result: undefined,
return; aborted: false,
willRetry: false,
});
return;
}
apiKey = authResult.apiKey;
headers = authResult.headers;
} else {
({ apiKey, headers } = await this._getCompactionRequestAuth(this.model));
} }
const { apiKey, headers } = authResult;
const pathEntries = this.sessionManager.getBranch(); const pathEntries = this.sessionManager.getBranch();
@@ -1941,6 +1962,7 @@ export class AgentSession {
undefined, undefined,
this._autoCompactionAbortController.signal, this._autoCompactionAbortController.signal,
this.thinkingLevel, this.thinkingLevel,
this.agent.streamFn,
); );
summary = compactResult.summary; summary = compactResult.summary;
firstKeptEntryId = compactResult.firstKeptEntryId; firstKeptEntryId = compactResult.firstKeptEntryId;

View File

@@ -5,8 +5,8 @@
* and after compaction the session is reloaded. * and after compaction the session is reloaded.
*/ */
import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { AgentMessage, StreamFn, ThinkingLevel } from "@earendil-works/pi-agent-core";
import type { AssistantMessage, Model, Usage } from "@earendil-works/pi-ai"; import type { AssistantMessage, Context, Model, SimpleStreamOptions, Usage } from "@earendil-works/pi-ai";
import { completeSimple } from "@earendil-works/pi-ai"; import { completeSimple } from "@earendil-works/pi-ai";
import { import {
convertToLlm, convertToLlm,
@@ -523,6 +523,34 @@ Use this EXACT format:
Keep each section concise. Preserve exact file paths, function names, and error messages.`; Keep each section concise. Preserve exact file paths, function names, and error messages.`;
function createSummarizationOptions(
model: Model<any>,
maxTokens: number,
apiKey: string | undefined,
headers: Record<string, string> | undefined,
signal: AbortSignal | undefined,
thinkingLevel: ThinkingLevel | undefined,
): SimpleStreamOptions {
const options: SimpleStreamOptions = { maxTokens, signal, apiKey, headers };
if (model.reasoning && thinkingLevel && thinkingLevel !== "off") {
options.reasoning = thinkingLevel;
}
return options;
}
async function completeSummarization(
model: Model<any>,
context: Context,
options: SimpleStreamOptions,
streamFn?: StreamFn,
): Promise<AssistantMessage> {
if (!streamFn) {
return completeSimple(model, context, options);
}
const stream = await streamFn(model, context, options);
return stream.result();
}
/** /**
* Generate a summary of the conversation using the LLM. * Generate a summary of the conversation using the LLM.
* If previousSummary is provided, uses the update prompt to merge. * If previousSummary is provided, uses the update prompt to merge.
@@ -531,12 +559,13 @@ export async function generateSummary(
currentMessages: AgentMessage[], currentMessages: AgentMessage[],
model: Model<any>, model: Model<any>,
reserveTokens: number, reserveTokens: number,
apiKey: string, apiKey: string | undefined,
headers?: Record<string, string>, headers?: Record<string, string>,
signal?: AbortSignal, signal?: AbortSignal,
customInstructions?: string, customInstructions?: string,
previousSummary?: string, previousSummary?: string,
thinkingLevel?: ThinkingLevel, thinkingLevel?: ThinkingLevel,
streamFn?: StreamFn,
): Promise<string> { ): Promise<string> {
const maxTokens = Math.min( const maxTokens = Math.min(
Math.floor(0.8 * reserveTokens), Math.floor(0.8 * reserveTokens),
@@ -569,15 +598,13 @@ export async function generateSummary(
}, },
]; ];
const completionOptions = const completionOptions = createSummarizationOptions(model, maxTokens, apiKey, headers, signal, thinkingLevel);
model.reasoning && thinkingLevel && thinkingLevel !== "off"
? { maxTokens, signal, apiKey, headers, reasoning: thinkingLevel }
: { maxTokens, signal, apiKey, headers };
const response = await completeSimple( const response = await completeSummarization(
model, model,
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }, { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
completionOptions, completionOptions,
streamFn,
); );
if (response.stopReason === "error") { if (response.stopReason === "error") {
@@ -720,11 +747,12 @@ Be concise. Focus on what's needed to understand the kept suffix.`;
export async function compact( export async function compact(
preparation: CompactionPreparation, preparation: CompactionPreparation,
model: Model<any>, model: Model<any>,
apiKey: string, apiKey: string | undefined,
headers?: Record<string, string>, headers?: Record<string, string>,
customInstructions?: string, customInstructions?: string,
signal?: AbortSignal, signal?: AbortSignal,
thinkingLevel?: ThinkingLevel, thinkingLevel?: ThinkingLevel,
streamFn?: StreamFn,
): Promise<CompactionResult> { ): Promise<CompactionResult> {
const { const {
firstKeptEntryId, firstKeptEntryId,
@@ -754,6 +782,7 @@ export async function compact(
customInstructions, customInstructions,
previousSummary, previousSummary,
thinkingLevel, thinkingLevel,
streamFn,
) )
: Promise.resolve("No prior history."), : Promise.resolve("No prior history."),
generateTurnPrefixSummary( generateTurnPrefixSummary(
@@ -764,6 +793,7 @@ export async function compact(
headers, headers,
signal, signal,
thinkingLevel, thinkingLevel,
streamFn,
), ),
]); ]);
// Merge into single summary // Merge into single summary
@@ -780,6 +810,7 @@ export async function compact(
customInstructions, customInstructions,
previousSummary, previousSummary,
thinkingLevel, thinkingLevel,
streamFn,
); );
} }
@@ -806,10 +837,11 @@ async function generateTurnPrefixSummary(
messages: AgentMessage[], messages: AgentMessage[],
model: Model<any>, model: Model<any>,
reserveTokens: number, reserveTokens: number,
apiKey: string, apiKey: string | undefined,
headers?: Record<string, string>, headers?: Record<string, string>,
signal?: AbortSignal, signal?: AbortSignal,
thinkingLevel?: ThinkingLevel, thinkingLevel?: ThinkingLevel,
streamFn?: StreamFn,
): Promise<string> { ): Promise<string> {
const maxTokens = Math.min( const maxTokens = Math.min(
Math.floor(0.5 * reserveTokens), Math.floor(0.5 * reserveTokens),
@@ -826,12 +858,11 @@ async function generateTurnPrefixSummary(
}, },
]; ];
const response = await completeSimple( const response = await completeSummarization(
model, model,
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }, { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
model.reasoning && thinkingLevel && thinkingLevel !== "off" createSummarizationOptions(model, maxTokens, apiKey, headers, signal, thinkingLevel),
? { maxTokens, signal, apiKey, headers, reasoning: thinkingLevel } streamFn,
: { maxTokens, signal, apiKey, headers },
); );
if (response.stopReason === "error") { if (response.stopReason === "error") {

View File

@@ -1,4 +1,9 @@
import { type AssistantMessage, fauxAssistantMessage, type Model } from "@earendil-works/pi-ai"; import {
type AssistantMessage,
createAssistantMessageEventStream,
fauxAssistantMessage,
type Model,
} from "@earendil-works/pi-ai";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { createHarness, type Harness } from "./harness.js"; import { createHarness, type Harness } from "./harness.js";
@@ -41,6 +46,43 @@ function createAssistant(
}; };
} }
function useSummaryStreamFn(harness: Harness, summary: string): () => number {
let callCount = 0;
harness.session.agent.streamFn = (model) => {
callCount++;
const stream = createAssistantMessageEventStream();
queueMicrotask(() => {
const message: AssistantMessage = {
...fauxAssistantMessage(summary),
api: model.api,
provider: model.provider,
model: model.id,
usage: createUsage(10),
};
stream.push({ type: "done", reason: "stop", message });
});
return stream;
};
return () => callCount;
}
function seedCompactableSession(harness: Harness): void {
const now = Date.now();
harness.sessionManager.appendMessage({
role: "user",
content: [{ type: "text", text: "message to compact" }],
timestamp: now - 1000,
});
harness.sessionManager.appendMessage(
createAssistant(harness, {
stopReason: "stop",
totalTokens: 100,
timestamp: now - 500,
}),
);
harness.session.agent.state.messages = harness.sessionManager.buildSessionContext().messages;
}
describe("AgentSession compaction characterization", () => { describe("AgentSession compaction characterization", () => {
const harnesses: Harness[] = []; const harnesses: Harness[] = [];
@@ -95,6 +137,32 @@ describe("AgentSession compaction characterization", () => {
await expect(harness.session.compact()).rejects.toThrow(`No API key found for ${harness.getModel().provider}.`); await expect(harness.session.compact()).rejects.toThrow(`No API key found for ${harness.getModel().provider}.`);
}); });
it("manually compacts with a custom streamFn when registry auth is absent", async () => {
const harness = await createHarness({ withConfiguredAuth: false });
harnesses.push(harness);
seedCompactableSession(harness);
const getStreamCallCount = useSummaryStreamFn(harness, "summary from custom stream");
const result = await harness.session.compact();
expect(result.summary).toBe("summary from custom stream");
expect(getStreamCallCount()).toBe(1);
});
it("auto-compacts with a custom streamFn when registry auth is absent", async () => {
const harness = await createHarness({ withConfiguredAuth: false });
harnesses.push(harness);
seedCompactableSession(harness);
const getStreamCallCount = useSummaryStreamFn(harness, "auto summary from custom stream");
const sessionInternals = harness.session as unknown as SessionWithCompactionInternals;
await sessionInternals._runAutoCompaction("threshold", false);
const compactionEntries = harness.sessionManager.getEntries().filter((entry) => entry.type === "compaction");
expect(compactionEntries).toHaveLength(1);
expect(getStreamCallCount()).toBe(1);
});
it("cancels in-progress manual compaction when abortCompaction is called", async () => { it("cancels in-progress manual compaction when abortCompaction is called", async () => {
const harness = await createHarness({ const harness = await createHarness({
extensionFactories: [ extensionFactories: [