Merge pull request #4600 from earendil-works/stream-fn-in-compaction
fix(coding-agent): route compaction through streamFn
This commit is contained in:
@@ -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)).
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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") {
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
Reference in New Issue
Block a user