Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
import type { AgentMessage, StreamFn } from "@earendil-works/pi-agent-core";
import { contentText } from "@earendil-works/pi-ai";
import type { Model, SimpleStreamOptions } from "@earendil-works/pi-ai/compat";
import { completeSimple } from "@earendil-works/pi-ai/compat";
import {
convertToLlm,
createBranchSummaryMessage,
Expand All @@ -17,6 +16,7 @@ import {
} from "../messages.ts";
import type { ReadonlySessionManager, SessionEntry } from "../session-manager.ts";
import { estimateTokens } from "./compaction.ts";
import { completeSummaryRequest } from "./summary-request.ts";
import {
computeFileLists,
createFileOps,
Expand Down Expand Up @@ -340,9 +340,7 @@ export async function generateBranchSummary(
// without running through agent state/events.
const context = { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages };
const requestOptions: SimpleStreamOptions = { apiKey, headers, env, signal, maxTokens: 2048 };
const response = streamFn
? await (await streamFn(model, context, requestOptions)).result()
: await completeSimple(model, context, requestOptions);
const response = await completeSummaryRequest(model, context, requestOptions, streamFn);

// Check if aborted or errored
if (response.stopReason === "aborted") {
Expand Down
21 changes: 4 additions & 17 deletions packages/coding-agent/src/core/compaction/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,15 @@

import type { AgentMessage, StreamFn, ThinkingLevel } from "@earendil-works/pi-agent-core";
import { contentText } from "@earendil-works/pi-ai";
import type { AssistantMessage, Context, Model, SimpleStreamOptions, Usage } from "@earendil-works/pi-ai/compat";
import { completeSimple } from "@earendil-works/pi-ai/compat";
import type { AssistantMessage, Model, SimpleStreamOptions, Usage } from "@earendil-works/pi-ai/compat";
import { convertToLlm } from "../messages.ts";
import {
buildSessionContext,
type CompactionEntry,
type SessionEntry,
sessionEntryToContextMessages,
} from "../session-manager.ts";
import { completeSummaryRequest } from "./summary-request.ts";
import {
computeFileLists,
createFileOps,
Expand Down Expand Up @@ -527,19 +527,6 @@ function createSummarizationOptions(
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.
* If previousSummary is provided, uses the update prompt to merge.
Expand Down Expand Up @@ -590,7 +577,7 @@ export async function generateSummary(

const completionOptions = createSummarizationOptions(model, maxTokens, apiKey, headers, env, signal, thinkingLevel);

const response = await completeSummarization(
const response = await completeSummaryRequest(
model,
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
completionOptions,
Expand Down Expand Up @@ -852,7 +839,7 @@ async function generateTurnPrefixSummary(
},
];

const response = await completeSummarization(
const response = await completeSummaryRequest(
model,
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
createSummarizationOptions(model, maxTokens, apiKey, headers, env, signal, thinkingLevel),
Expand Down
32 changes: 32 additions & 0 deletions packages/coding-agent/src/core/compaction/summary-request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { StreamFn } from "@earendil-works/pi-agent-core";
import type { Api, AssistantMessage, Context, Model, SimpleStreamOptions } from "@earendil-works/pi-ai/compat";
import { completeSimple, isRetryableAssistantError } from "@earendil-works/pi-ai/compat";

const MAX_SUMMARY_RETRIES = 1;

/**
* Complete a summary request, retrying one retryable stream failure.
* Summary requests have no side effects until their final response is used, so
* a fresh request is safe after a transient transport or provider failure.
*/
export async function completeSummaryRequest(
model: Model<Api>,
context: Context,
options: SimpleStreamOptions,
streamFn?: StreamFn,
): Promise<AssistantMessage> {
let response = streamFn
? await (await streamFn(model, context, options)).result()
: await completeSimple(model, context, options);

for (let attempt = 0; attempt < MAX_SUMMARY_RETRIES; attempt++) {
if (options.signal?.aborted || !isRetryableAssistantError(response)) {
break;
}
response = streamFn
? await (await streamFn(model, context, options)).result()
: await completeSimple(model, context, options);
}

return response;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { fauxAssistantMessage } from "@earendil-works/pi-ai";
import { afterEach, describe, expect, it } from "vitest";
import { createHarness, type Harness } from "../harness.ts";

describe("issue #6647 compaction summary retry", () => {
const harnesses: Harness[] = [];

afterEach(() => {
while (harnesses.length > 0) {
harnesses.pop()?.cleanup();
}
});

it("retries a transient split-turn summary failure", async () => {
const harness = await createHarness({ settings: { compaction: { keepRecentTokens: 1 } } });
harnesses.push(harness);

const now = Date.now();
harness.sessionManager.appendMessage({
role: "user",
content: [{ type: "text", text: "A request large enough to split during compaction." }],
timestamp: now - 1000,
});
harness.sessionManager.appendMessage({
...fauxAssistantMessage("An assistant response that should be retained."),
timestamp: now,
});
harness.session.agent.state.messages = harness.sessionManager.buildSessionContext().messages;

harness.setResponses([
fauxAssistantMessage("", {
stopReason: "error",
errorMessage: "OpenAI Responses stream ended before a terminal response event",
}),
fauxAssistantMessage("## Original Request\nRecovered split-turn summary"),
]);

await expect(harness.session.compact()).resolves.toMatchObject({
summary: expect.stringContaining("Recovered split-turn summary"),
});
expect(harness.faux.state.callCount).toBe(2);
});

it("retries a transient branch summary failure", async () => {
const harness = await createHarness();
harnesses.push(harness);

const targetId = harness.sessionManager.appendMessage({
role: "user",
content: [{ type: "text", text: "Start a branch." }],
timestamp: Date.now() - 1000,
});
harness.sessionManager.appendMessage(fauxAssistantMessage("Initial branch reply."));
harness.sessionManager.appendMessage({
role: "user",
content: [{ type: "text", text: "Abandoned branch work." }],
timestamp: Date.now(),
});
harness.sessionManager.appendMessage(fauxAssistantMessage("Abandoned branch reply."));

harness.setResponses([
fauxAssistantMessage("", {
stopReason: "error",
errorMessage: "OpenAI Responses stream ended before a terminal response event",
}),
fauxAssistantMessage("## Goal\nRecovered branch summary"),
]);

const result = await harness.session.navigateTree(targetId, { summarize: true });

expect(result.cancelled).toBe(false);
expect(result.summaryEntry?.summary).toContain("Recovered branch summary");
expect(harness.faux.state.callCount).toBe(2);
});
});
Loading