diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index 8e64dcc935a8..9abded45df04 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -1667,7 +1667,7 @@ src/agents/agent-bundle-mcp-runtime-shared.ts 2 src/agents/agent-bundle-mcp-runtime.ts 9 src/agents/agent-command-restart-recovery.ts 3 src/agents/agent-command.ts 1 -src/agents/agent-hooks/compaction-safeguard.ts 19 +src/agents/agent-hooks/compaction-safeguard.ts 14 src/agents/agent-model-discovery.ts 5 src/agents/agent-project-settings-snapshot.ts 5 src/agents/agent-run-terminal-delivery.ts 1 diff --git a/packages/ai/src/providers/openai-chatgpt-responses-streaming.test.ts b/packages/ai/src/providers/openai-chatgpt-responses-streaming.test.ts index 79deb31e0728..86a177bb2aec 100644 --- a/packages/ai/src/providers/openai-chatgpt-responses-streaming.test.ts +++ b/packages/ai/src/providers/openai-chatgpt-responses-streaming.test.ts @@ -262,6 +262,80 @@ describe("OpenAI ChatGPT Responses inference streaming", () => { expect(fetchMock).not.toHaveBeenCalled(); }); + it("logs the caller abort reason with bounded ChatGPT transport metadata", async () => { + const logWarn = vi.fn(); + configureAiTransportHost({ logWarn }); + const controller = new AbortController(); + class AbortedWebSocket extends EventTarget { + constructor() { + super(); + queueMicrotask(() => this.dispatchEvent(new Event("open"))); + } + + send(): void { + controller.abort(new Error("Compaction timed out")); + } + + close(): void {} + } + vi.stubGlobal("WebSocket", AbortedWebSocket); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + const result = await streamOpenAICodexResponses(model, context, { + apiKey: createJwt({ + "https://api.openai.com/auth": { chatgpt_account_id: "acct-1" }, + }), + signal: controller.signal, + }).result(); + + expect(result).toMatchObject({ stopReason: "aborted", errorMessage: "Request was aborted" }); + expect(logWarn).toHaveBeenCalledWith( + "openai-transport", + "ChatGPT Responses stream terminated", + { + api: "openai-chatgpt-responses", + elapsedMs: expect.any(Number), + failureKind: "caller-abort", + model: "gpt-5.6-luna", + provider: "openai", + stopReason: "aborted", + transport: "auto", + }, + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("classifies a direct HTTP rejection as a provider failure without logging its text", async () => { + const logWarn = vi.fn(); + configureAiTransportHost({ logWarn }); + const fetchMock = vi.fn().mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: { message: "hostile prompt echo in a 400 body", code: "invalid_request" }, + }), + { status: 400, statusText: "Bad Request" }, + ), + ); + vi.stubGlobal("fetch", fetchMock); + + const result = await streamOpenAICodexResponses(model, context, { + apiKey: createJwt({ + "https://api.openai.com/auth": { chatgpt_account_id: "acct-1" }, + }), + transport: "sse", + }).result(); + + expect(result.stopReason).toBe("error"); + expect(logWarn).toHaveBeenCalledTimes(1); + const logged = logWarn.mock.calls[0]?.[2]; + expect(logged).toMatchObject({ stopReason: "error", failureKind: "provider-failure" }); + const serialized = JSON.stringify(logged); + for (const hostile of ["hostile", "invalid_request", "400 body"]) { + expect(serialized).not.toContain(hostile); + } + }); + it.each(["sse", "websocket"] as const)( "preserves failed response identity and provider error details over %s", async (transport) => { @@ -270,9 +344,15 @@ describe("OpenAI ChatGPT Responses inference streaming", () => { response: { id: "resp_failed", status: "failed", - error: { code: "invalid_prompt", message: "rejected" }, + error: { + code: "invalid_prompt", + type: "hostile type: user prompt echoed here", + message: "rejected", + }, }, }; + const logWarn = vi.fn(); + configureAiTransportHost({ logWarn }); const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); @@ -323,6 +403,23 @@ describe("OpenAI ChatGPT Responses inference streaming", () => { errorMessage: "invalid_prompt: rejected", }, }); + // Provider message text reaches the stream consumer only; the transport + // log keeps timing and classification and never the message body. + expect(logWarn).toHaveBeenCalledTimes(1); + const logged = logWarn.mock.calls[0]?.[2]; + expect(logged).toEqual({ + api: "openai-chatgpt-responses", + elapsedMs: expect.any(Number), + failureKind: "provider-failure", + model: "gpt-5.6-luna", + provider: "openai", + stopReason: "error", + transport, + }); + const serialized = JSON.stringify(logged); + for (const hostile of ["rejected", "invalid_prompt", "hostile type", "echoed"]) { + expect(serialized).not.toContain(hostile); + } if (transport === "websocket") { expect(fetchMock).not.toHaveBeenCalled(); } diff --git a/packages/ai/src/providers/openai-chatgpt-responses.ts b/packages/ai/src/providers/openai-chatgpt-responses.ts index 593efca5569c..65b005c3d067 100644 --- a/packages/ai/src/providers/openai-chatgpt-responses.ts +++ b/packages/ai/src/providers/openai-chatgpt-responses.ts @@ -243,6 +243,26 @@ function isRequestTimeoutError( ); } +type StreamFailureKind = "timeout" | "caller-abort" | "provider-failure" | "transport"; + +/** Local-only failure category for transport diagnostics; never provider text. */ +function classifyStreamFailure( + error: unknown, + signal: AbortSignal | undefined, + requestTimedOut: boolean, +): StreamFailureKind { + if (requestTimedOut) { + return "timeout"; + } + if (signal?.aborted) { + return "caller-abort"; + } + // CodexApiError carries a non-OK HTTP reply; both are provider decisions, not transport faults. + return error instanceof ResponsesStreamFailure || error instanceof CodexApiError + ? "provider-failure" + : "transport"; +} + function formatRequestTimeoutError(timeoutMs: number, cause: unknown): Error { return new Error(`Request timed out after ${timeoutMs}ms`, { cause: cause instanceof Error ? cause : undefined, @@ -288,6 +308,7 @@ export const streamOpenAICodexResponses: StreamFunction< const stream = new AssistantMessageEventStream(); void (async () => { + const startedAt = Date.now(); let requestTimeoutMs: number | undefined; let requestTimeoutSignal: AbortSignal | undefined; let activeSignal: AbortSignal | undefined; @@ -621,9 +642,11 @@ export const streamOpenAICodexResponses: StreamFunction< }); stream.end(); } catch (error) { - const normalizedError = + const requestTimedOut = isRequestTimeoutError(error, options?.signal, requestTimeoutSignal, requestTimeoutMs) && - requestTimeoutMs !== undefined + requestTimeoutMs !== undefined; + const normalizedError = + requestTimedOut && requestTimeoutMs !== undefined ? formatRequestTimeoutError(requestTimeoutMs, error) : error; for (const block of output.content) { @@ -631,6 +654,19 @@ export const streamOpenAICodexResponses: StreamFunction< delete (block as { partialJson?: string }).partialJson; } const terminal = projectProviderError(normalizedError, options?.signal); + // Log only locally-derived facts: timing and a fixed failure category. No + // projected provider field (message, body, code, type, name) is logged — + // all of them are provider-controlled text that can carry prompt- or + // response-derived content. + getAiTransportHost().logWarn("openai-transport", "ChatGPT Responses stream terminated", { + provider: model.provider, + api: model.api, + model: model.id, + transport: options?.transport || "auto", + elapsedMs: Math.max(0, Date.now() - startedAt), + stopReason: terminal.stopReason, + failureKind: classifyStreamFailure(error, options?.signal, requestTimedOut), + }); Object.assign(output, terminal); stream.push({ type: "error", reason: terminal.stopReason, error: output }); stream.end(); diff --git a/src/agents/agent-hooks/compaction-safeguard-quality.ts b/src/agents/agent-hooks/compaction-safeguard-quality.ts index 68f3ea991ac2..e1a97af1ab3e 100644 --- a/src/agents/agent-hooks/compaction-safeguard-quality.ts +++ b/src/agents/agent-hooks/compaction-safeguard-quality.ts @@ -20,6 +20,9 @@ const REQUIRED_SUMMARY_SECTIONS = [ "## Exact identifiers", ] as const; const QUALITY_PROTECTED_SECTION_START = 3; +const PENDING_ASK_SECTION_INDEX = 3; +const EXACT_IDENTIFIERS_SECTION_INDEX = 4; +const MAX_PROTECTED_SECTION_CONTENT_SHARE = 0.25; const STRICT_EXACT_IDENTIFIERS_INSTRUCTION = "For ## Exact identifiers, preserve literal values exactly as seen (IDs, URLs, file paths, ports, hashes, dates, times)."; const POLICY_OFF_EXACT_IDENTIFIERS_INSTRUCTION = @@ -97,7 +100,13 @@ function hasRequiredSummarySections(summary: string): boolean { type SummaryQualityRetentionPlan = { minimumChars: number; - render: (maxChars: number) => string | null; + /** + * True when render() must rebuild even a body that fits: a strict source + * identifier is missing, or an audit-bearing section exceeds its share cap. + */ + needsRebuild: (maxChars: number) => boolean; + /** Null when even the protected facts cannot fit `maxChars`. */ + render: (maxChars: number) => { text: string; trimmed: boolean } | null; }; function parseRequiredSummarySectionContents(summary: string): string[] | null { @@ -120,7 +129,14 @@ function parseRequiredSummarySectionContents(summary: string): string[] | null { return contents.map((lines) => lines.join("\n").trim()); } -/** Plan truncation that keeps audit-required headings, pending asks, and exact identifiers. */ +/** + * Plan truncation that keeps the audit facts and lets everything else shrink. + * Only the headings, the bounded latest-ask context, and the audited source + * identifiers are untrimmable. Model-written section text — including the + * "## Exact identifiers" list — is optional content; protecting it verbatim let + * a re-distilled identifier dump grow past the whole artifact budget while the + * real sections were starved to empty headings. + */ export function createSummaryQualityRetentionPlan( summary: string, truncatedMarker: string, @@ -138,81 +154,123 @@ export function createSummaryQualityRetentionPlan( } const enforceIdentifiers = (params.identifierPolicy ?? "strict") === "strict"; const auditSummary = params.auditSummary ?? summary; - if ( - enforceIdentifiers && - params.identifiers.some((identifier) => !summaryIncludesIdentifier(auditSummary, identifier)) - ) { - return null; - } if (!hasAskOverlap(auditSummary, params.latestAsk)) { return null; } - const pendingAsk = contents[QUALITY_PROTECTED_SECTION_START] ?? ""; const requiredAskContext = params.requiredAskContext?.trim() ?? ""; - const exactIdentifiers = contents[QUALITY_PROTECTED_SECTION_START + 1] ?? ""; - const missingIdentifiers = enforceIdentifiers - ? params.identifiers.filter( - (identifier) => !summaryIncludesIdentifier(exactIdentifiers, identifier), - ) - : []; - const protectedContents = [ - [ - pendingAsk, - requiredAskContext && !pendingAsk.includes(requiredAskContext) ? requiredAskContext : "", - ] - .filter(Boolean) - .join("\n"), - [exactIdentifiers, ...missingIdentifiers].filter(Boolean).join("\n"), - ]; + const auditedIdentifiers = enforceIdentifiers ? params.identifiers : []; const marker = truncatedMarker.trim(); - const protectedBlocks = REQUIRED_SUMMARY_SECTIONS.slice(QUALITY_PROTECTED_SECTION_START).map( - (heading, index) => { - const content = protectedContents[index]; + // Protected tails render after each section's optional content so the audit + // facts survive regardless of how much model text the budget keeps. + const protectedTails = REQUIRED_SUMMARY_SECTIONS.map((_, index) => + index === PENDING_ASK_SECTION_INDEX + ? requiredAskContext + : index === EXACT_IDENTIFIERS_SECTION_INDEX + ? auditedIdentifiers.join("\n") + : "", + ); + const bodyHasIdentifiers = auditedIdentifiers.every((identifier) => + summaryIncludesIdentifier(summary, identifier), + ); + const renderSections = (sectionContents: string[]) => + REQUIRED_SUMMARY_SECTIONS.map((heading, index) => { + const content = sectionContents[index]; return content ? `${heading}\n${content}` : heading; - }, + }); + const joinSectionContent = (index: number, optional: string) => { + const tail = protectedTails[index] ?? ""; + if (!tail) { + return optional; + } + if (index === PENDING_ASK_SECTION_INDEX && optional.includes(tail)) { + return optional; + } + if (index === EXACT_IDENTIFIERS_SECTION_INDEX) { + const missing = auditedIdentifiers.filter( + (identifier) => !summaryIncludesIdentifier(optional, identifier), + ); + return [optional, ...missing].filter(Boolean).join("\n"); + } + return [optional, tail].filter(Boolean).join("\n"); + }; + // Reserve every heading/content/tail separator up front so trimmed optional + // text can never push the rendered artifact past `maxChars`. + const minimumBlocks = REQUIRED_SUMMARY_SECTIONS.map( + (heading, index) => `${heading}\n\n${protectedTails[index] ?? ""}`, ); - const optionalHeadings = REQUIRED_SUMMARY_SECTIONS.slice(0, QUALITY_PROTECTED_SECTION_START); - const optionalContents = contents.slice(0, QUALITY_PROTECTED_SECTION_START); - const optionalScaffolds = optionalHeadings.map((heading, index) => - optionalContents[index] ? `${heading}\n` : heading, - ); - const minimumSummary = [...optionalScaffolds, marker, ...protectedBlocks].join("\n\n"); + const minimumSummary = [ + ...minimumBlocks.slice(0, QUALITY_PROTECTED_SECTION_START), + marker, + ...minimumBlocks.slice(QUALITY_PROTECTED_SECTION_START), + ].join("\n\n"); + // Audit-bearing sections (pending asks, exact identifiers) are funded first so + // a runaway earlier section cannot starve them, but each is hard-capped: an + // uncapped identifier list re-distills into the whole budget — even while the + // artifact still fits — and leaves every other section as a bare heading. + const protectedCapFor = (maxChars: number) => + Math.floor(Math.max(0, maxChars - minimumSummary.length) * MAX_PROTECTED_SECTION_CONTENT_SHARE); + const protectedWithinCap = (maxChars: number) => + contents + .slice(QUALITY_PROTECTED_SECTION_START) + .every((content) => content.length <= protectedCapFor(maxChars)); return { minimumChars: minimumSummary.length, + needsRebuild: (maxChars) => !bodyHasIdentifiers || !protectedWithinCap(maxChars), render(maxChars) { const bodyHasRequiredAskContext = !requiredAskContext || summary.includes(requiredAskContext); - const bodyHasIdentifiers = - !enforceIdentifiers || - params.identifiers.every((identifier) => summaryIncludesIdentifier(summary, identifier)); - if (summary.length <= maxChars && bodyHasRequiredAskContext && bodyHasIdentifiers) { - return summary; + if ( + summary.length <= maxChars && + bodyHasRequiredAskContext && + bodyHasIdentifiers && + protectedWithinCap(maxChars) + ) { + return { text: summary, trimmed: false }; } if (maxChars < minimumSummary.length) { return null; } const contentBudget = maxChars - minimumSummary.length; - const totalContentChars = optionalContents.reduce( - (total, content) => total + content.length, + const protectedCap = protectedCapFor(maxChars); + const allocations = contents.map((content, index) => + index >= QUALITY_PROTECTED_SECTION_START ? Math.min(content.length, protectedCap) : 0, + ); + const optionalBudget = Math.max( 0, + contentBudget - allocations.reduce((total, chars) => total + chars, 0), ); - const allocations = optionalContents.map((content) => - totalContentChars > 0 - ? Math.floor((contentBudget * content.length) / totalContentChars) - : 0, - ); - let remainder = contentBudget - allocations.reduce((total, chars) => total + chars, 0); + const optionalContents = contents.slice(0, QUALITY_PROTECTED_SECTION_START); + const optionalTotal = optionalContents.reduce((total, content) => total + content.length, 0); + for (const [index, content] of optionalContents.entries()) { + allocations[index] = + optionalTotal > 0 ? Math.floor((optionalBudget * content.length) / optionalTotal) : 0; + } + // Surplus returns to the optional sections only; the protected caps stay + // hard so short decisions cannot hand the budget back to the identifier dump. + let remainder = + optionalBudget - + allocations + .slice(0, QUALITY_PROTECTED_SECTION_START) + .reduce((total, chars) => total + chars, 0); for (const [index, content] of optionalContents.entries()) { const allocation = allocations[index] ?? 0; const extra = Math.min(remainder, Math.max(0, content.length - allocation)); allocations[index] = allocation + extra; remainder -= extra; } - const optionalBlocks = optionalHeadings.map((heading, index) => { - const content = truncateUtf16Safe(optionalContents[index] ?? "", allocations[index] ?? 0); - return content ? `${heading}\n${content}` : heading; - }); - return [...optionalBlocks, marker, ...protectedBlocks].join("\n\n"); + const trimmed = contents.some((content, index) => content.length > (allocations[index] ?? 0)); + const sectionContents = contents.map((content, index) => + joinSectionContent(index, truncateUtf16Safe(content, allocations[index] ?? 0)), + ); + const blocks = renderSections(sectionContents); + return { + text: [ + ...blocks.slice(0, QUALITY_PROTECTED_SECTION_START), + ...(trimmed ? [marker] : []), + ...blocks.slice(QUALITY_PROTECTED_SECTION_START), + ].join("\n\n"), + trimmed, + }; }, }; } diff --git a/src/agents/agent-hooks/compaction-safeguard.test.ts b/src/agents/agent-hooks/compaction-safeguard.test.ts index 2a0ccd892720..90c0ec2f720d 100644 --- a/src/agents/agent-hooks/compaction-safeguard.test.ts +++ b/src/agents/agent-hooks/compaction-safeguard.test.ts @@ -2180,6 +2180,234 @@ describe("compaction-safeguard recent-turn preservation", () => { expect(consumeCompactionSafeguardCancelReason(sessionManager)).toBeNull(); }); + it("restores source identifiers omitted by an oversized generated summary", async () => { + mockSummarizeInStages.mockReset(); + const latestAsk = "preserve the pending deployment status"; + const identifier = "/tmp/source-only-compaction-id.log"; + const generatedSummary = [ + "## Decisions", + "x".repeat(MAX_COMPACTION_SUMMARY_CHARS), + "## Open TODOs", + "None.", + "## Constraints/Rules", + "Preserve exact context.", + "## Pending user asks", + latestAsk, + "## Exact identifiers", + "None.", + ].join("\n"); + mockSummarizeInStages.mockResolvedValue(summaryResult(generatedSummary)); + + const sessionManager = stubSessionManager(); + setCompactionSafeguardRuntime(sessionManager, { + model: createAnthropicModelFixture(), + recentTurnsPreserve: 0, + qualityGuardEnabled: true, + qualityGuardMaxRetries: 1, + }); + const event = createCompactionEvent({ + messageText: `${latestAsk} ${identifier}`, + tokensBefore: 1_500, + }); + ( + event.preparation as { settings?: { reserveTokens: number }; isSplitTurn?: boolean } + ).settings = { reserveTokens: 4_000 }; + (event.preparation as { isSplitTurn?: boolean }).isSplitTurn = false; + + const { result } = await runCompactionScenario({ sessionManager, event, apiKey: "test-key" }); + + const summary = expectCompactionResult(result).summary; + expect(summary.length).toBeLessThanOrEqual(MAX_COMPACTION_SUMMARY_CHARS); + expect(summary).toContain(`## Exact identifiers\nNone.\n${identifier}`); + expect(mockSummarizeInStages).toHaveBeenCalledTimes(1); + expect(consumeCompactionSafeguardCancelReason(sessionManager)).toBeNull(); + }); + + it("keeps real sections when a re-distilled identifier list outgrows the budget", async () => { + mockSummarizeInStages.mockReset(); + const latestAsk = "preserve the pending deployment status"; + const identifier = "/tmp/source-only-compaction-id.log"; + // A model that hoards every identifier it has ever seen: the list alone is + // larger than the whole artifact budget while the real sections stay small. + const hoardedIdentifiers = Array.from( + { length: 400 }, + (_, index) => `- /home/vac/clawd/tmp/session-artifacts/run-${index}/output.log`, + ).join("\n"); + const generatedSummary = [ + "## Decisions", + "Deployment stays paused until the backup is verified.", + "## Open TODOs", + "Verify the backup.", + "## Constraints/Rules", + "Preserve exact context.", + "## Pending user asks", + latestAsk, + "## Exact identifiers", + hoardedIdentifiers, + ].join("\n"); + expect(generatedSummary.length).toBeGreaterThan(MAX_COMPACTION_SUMMARY_CHARS); + mockSummarizeInStages.mockResolvedValue(summaryResult(generatedSummary)); + + const sessionManager = stubSessionManager(); + setCompactionSafeguardRuntime(sessionManager, { + model: createAnthropicModelFixture(), + recentTurnsPreserve: 0, + qualityGuardEnabled: true, + qualityGuardMaxRetries: 1, + }); + const event = createCompactionEvent({ + messageText: `${latestAsk} ${identifier}`, + tokensBefore: 1_500, + }); + (event.preparation as { settings?: { reserveTokens: number } }).settings = { + reserveTokens: 4_000, + }; + + const { result } = await runCompactionScenario({ sessionManager, event, apiKey: "test-key" }); + + const summary = expectCompactionResult(result).summary; + expect(summary.length).toBeLessThanOrEqual(MAX_COMPACTION_SUMMARY_CHARS); + expect(summary).toContain( + "## Decisions\nDeployment stays paused until the backup is verified.", + ); + expect(summary).toContain("## Open TODOs\nVerify the backup."); + expect(summary).toContain(`## Pending user asks\n${latestAsk}`); + expect(summary).toContain(identifier); + expect(summary).toContain("run-0/output.log"); + expect(summary).not.toContain("run-399/output.log"); + const identifiersSection = summary.slice(summary.indexOf("## Exact identifiers")); + expect(identifiersSection.length).toBeLessThanOrEqual( + MAX_COMPACTION_SUMMARY_CHARS * 0.25 + 200, + ); + expect(consumeCompactionSafeguardCancelReason(sessionManager)).toBeNull(); + }); + + it("keeps surplus budget out of the protected sections when trimming", () => { + const identifier = "/tmp/surplus-compaction-id.log"; + const latestAsk = "preserve the pending deployment status"; + // The re-distilled shape seen in production: every optional section is an + // empty heading, so all surplus would otherwise flow to the identifier list. + const body = [ + "## Decisions", + "## Open TODOs", + "## Constraints/Rules", + "## Pending user asks", + latestAsk, + "## Exact identifiers", + [identifier, ...Array.from({ length: 300 }, (_, i) => `- /tmp/run-${i}/out.log`)].join("\n"), + ].join("\n"); + const maxChars = 4_000; + expect(body.length).toBeGreaterThan(maxChars); + + const finalized = budgetCompactionSummary(body, "", maxChars, { + identifiers: [identifier], + latestAsk, + identifierPolicy: "strict", + }); + + const structural = (finalized as { structuralSummary: string }).structuralSummary; + expect(structural.length).toBeLessThanOrEqual(maxChars); + expect(structural).toContain("## Decisions"); + expect(structural).toContain(identifier); + const identifiersSection = structural.slice(structural.indexOf("## Exact identifiers")); + expect(identifiersSection.length).toBeLessThanOrEqual(maxChars * 0.25 + 100); + }); + + it("caps an identifier list that outgrew its share while the summary still fits", async () => { + mockSummarizeInStages.mockReset(); + const latestAsk = "preserve the pending deployment status"; + const identifier = "/tmp/source-only-compaction-id.log"; + const hoardedIdentifiers = Array.from( + { length: 200 }, + (_, index) => `- /home/vac/clawd/tmp/session-artifacts/run-${index}/output.log`, + ).join("\n"); + const generatedSummary = [ + "## Decisions", + "Deployment stays paused until the backup is verified.", + "## Open TODOs", + "Verify the backup.", + "## Constraints/Rules", + "Preserve exact context.", + "## Pending user asks", + latestAsk, + "## Exact identifiers", + `${identifier}\n${hoardedIdentifiers}`, + ].join("\n"); + expect(generatedSummary.length).toBeLessThan(MAX_COMPACTION_SUMMARY_CHARS); + mockSummarizeInStages.mockResolvedValue(summaryResult(generatedSummary)); + + const sessionManager = stubSessionManager(); + setCompactionSafeguardRuntime(sessionManager, { + model: createAnthropicModelFixture(), + recentTurnsPreserve: 0, + qualityGuardEnabled: true, + qualityGuardMaxRetries: 1, + }); + const event = createCompactionEvent({ + messageText: `${latestAsk} ${identifier}`, + tokensBefore: 1_500, + }); + (event.preparation as { settings?: { reserveTokens: number } }).settings = { + reserveTokens: 4_000, + }; + + const { result } = await runCompactionScenario({ sessionManager, event, apiKey: "test-key" }); + + const summary = expectCompactionResult(result).summary; + expect(summary).toContain( + "## Decisions\nDeployment stays paused until the backup is verified.", + ); + expect(summary).toContain(identifier); + expect(summary).not.toContain("run-199/output.log"); + const identifiersSection = summary.slice(summary.indexOf("## Exact identifiers")); + expect(identifiersSection.length).toBeLessThanOrEqual( + MAX_COMPACTION_SUMMARY_CHARS * 0.25 + 200, + ); + expect(consumeCompactionSafeguardCancelReason(sessionManager)).toBeNull(); + }); + + it("restores source identifiers omitted by a generated summary that fits the budget", async () => { + mockSummarizeInStages.mockReset(); + const latestAsk = "preserve the pending deployment status"; + const identifier = "/tmp/source-only-compaction-id.log"; + const generatedSummary = [ + "## Decisions", + "Deployment stays paused.", + "## Open TODOs", + "None.", + "## Constraints/Rules", + "Preserve exact context.", + "## Pending user asks", + latestAsk, + "## Exact identifiers", + "None.", + ].join("\n"); + mockSummarizeInStages.mockResolvedValue(summaryResult(generatedSummary)); + + const sessionManager = stubSessionManager(); + setCompactionSafeguardRuntime(sessionManager, { + model: createAnthropicModelFixture(), + recentTurnsPreserve: 0, + qualityGuardEnabled: true, + qualityGuardMaxRetries: 1, + }); + const event = createCompactionEvent({ + messageText: `${latestAsk} ${identifier}`, + tokensBefore: 1_500, + }); + (event.preparation as { settings?: { reserveTokens: number } }).settings = { + reserveTokens: 4_000, + }; + + const { result } = await runCompactionScenario({ sessionManager, event, apiKey: "test-key" }); + + const summary = expectCompactionResult(result).summary; + expect(summary).toContain(`## Exact identifiers\nNone.\n${identifier}`); + expect(summary).not.toContain(SUMMARY_TRUNCATED_MARKER.trim()); + expect(mockSummarizeInStages).toHaveBeenCalledTimes(1); + expect(consumeCompactionSafeguardCancelReason(sessionManager)).toBeNull(); + }); + it("fails closed when audit-required tail sections cannot fit the artifact cap", async () => { mockSummarizeInStages.mockReset(); const latestAsk = "preserve the pending deployment status"; @@ -3586,61 +3814,80 @@ describe("compaction-safeguard double-compaction guard", () => { expect(getApiKeyAndHeadersMock).toHaveBeenCalledWith(model); }); - it("falls back to visible custom session branch entries before writing an empty boundary", async () => { + it("summarizes only the prepared tool-only window when the context anchors it", async () => { mockSummarizeInStages.mockReset(); - mockSummarizeInStages.mockResolvedValue(summaryResult("branch summary")); + mockSummarizeInStages.mockResolvedValue(summaryResult("tool window summary")); const now = Date.now(); + // History behind a reset and a compaction boundary: the old fallback re-read + // all of it through getBranch() and summarized the whole session again. const sessionManager = { ...stubSessionManager(), getBranch: () => [ { - type: "custom_message", - id: "custom-1", + type: "message", + id: "old-user", parentId: null, timestamp: new Date(now).toISOString(), - customType: "cron-request", - content: "prepare the daily report", - display: true, + message: { role: "user", content: "old request behind reset", timestamp: now }, }, { type: "message", - id: "assistant-1", - parentId: "custom-1", + id: "old-assistant", + parentId: "old-user", timestamp: new Date(now + 1).toISOString(), message: { role: "assistant", - content: [{ type: "toolCall", id: "call-1", name: "read", arguments: {} }], + content: [{ type: "text", text: "old reply behind reset" }], timestamp: now + 1, }, }, { - type: "message", - id: "tool-1", - parentId: "assistant-1", + type: "reset", + id: "reset-1", + parentId: "old-assistant", timestamp: new Date(now + 2).toISOString(), - message: { - role: "toolResult", - toolCallId: "call-1", - toolName: "read", - content: [{ type: "text", text: "report source data" }], - timestamp: now + 2, - }, + reason: "new", + firstKeptEntryId: "old-assistant", + }, + { + type: "compaction", + id: "compaction-1", + parentId: "reset-1", + timestamp: new Date(now + 3).toISOString(), + summary: "## Decisions\nUser asked for the deploy status.", + firstKeptEntryId: "compaction-1", + tokensBefore: 90_000, + fromHook: true, }, ], } as ExtensionContext["sessionManager"]; const model = createAnthropicModelFixture(); setCompactionSafeguardRuntime(sessionManager, { model, recentTurnsPreserve: 0 }); + const toolOnlyWindow = [ + { + role: "assistant", + content: [{ type: "toolCall", id: "call-1", name: "exec", arguments: {} }], + timestamp: now + 4, + }, + { + role: "toolResult", + toolCallId: "call-1", + toolName: "exec", + content: [{ type: "text", text: "deploy green" }], + timestamp: now + 5, + }, + ] as AgentMessage[]; const mockEvent = { preparation: { - messagesToSummarize: [] as AgentMessage[], + messagesToSummarize: toolOnlyWindow, turnPrefixMessages: [] as AgentMessage[], - firstKeptEntryId: "entry-5", + firstKeptEntryId: "entry-6", tokensBefore: 38085, fileOps: { read: [], edited: [], written: [] }, settings: { reserveTokens: 4000 }, - isSplitTurn: true, + isSplitTurn: false, }, customInstructions: "", signal: new AbortController().signal, @@ -3652,182 +3899,74 @@ describe("compaction-safeguard double-compaction guard", () => { }); const compaction = expectCompactionResult(result); - expect(compaction.summary).toContain("branch summary"); - expect(compaction.summary).not.toContain("No prior history."); + expect(compaction.summary).toContain("tool window summary"); expect(mockSummarizeInStages).toHaveBeenCalledTimes(1); const summarizeCall = requireRecord(mockCallArg(mockSummarizeInStages)); const messages = requireArray(summarizeCall.messages); - expect( - messages.some((message) => { - const record = requireRecord(message); - return ( - record.role === "custom" && - record.customType === "cron-request" && - record.content === "prepare the daily report" - ); - }), - ).toBe(true); - expect( - messages.some((message) => { - const record = requireRecord(message); - return record.role === "toolResult" && record.toolName === "read"; - }), - ).toBe(true); + expect(messages.map((message) => requireRecord(message).role)).toEqual([ + "assistant", + "toolResult", + ]); + expect(JSON.stringify(messages)).not.toContain("behind reset"); }); - it.each([ - { assistantText: "bee reply", completed: true }, - { assistantText: " \t\n ", completed: false }, - ])( - "drops delegated branch turns only after meaningful terminal output ($completed)", - async ({ assistantText, completed }) => { - mockSummarizeInStages.mockReset(); - mockSummarizeInStages.mockResolvedValue(summaryResult("branch summary")); - - const now = Date.now(); - const sessionManager = { - ...stubSessionManager(), - getBranch: () => [ - { - type: "message", - id: "user-1", - parentId: null, - timestamp: new Date(now).toISOString(), - message: { - role: "user", - content: "say bee", - provenance: { - kind: "inter_session", - sourceSessionKey: "agent:pm", - sourceTool: "sessions_send", - }, - timestamp: now, - }, - }, - { - type: "message", - id: "assistant-1", - parentId: "user-1", - timestamp: new Date(now + 1).toISOString(), - message: { - role: "assistant", - content: [{ type: "text", text: assistantText }], - timestamp: now + 1, - }, - }, - ], - } as ExtensionContext["sessionManager"]; - const model = createAnthropicModelFixture(); - setCompactionSafeguardRuntime(sessionManager, { model, recentTurnsPreserve: 0 }); - - const mockEvent = { - preparation: { - messagesToSummarize: [] as AgentMessage[], - turnPrefixMessages: [] as AgentMessage[], - firstKeptEntryId: "entry-7", - tokensBefore: 38085, - fileOps: { read: [], edited: [], written: [] }, - settings: { reserveTokens: 4000 }, - isSplitTurn: true, - }, - customInstructions: "", - signal: new AbortController().signal, - }; - const { result, getApiKeyAndHeadersMock } = await runCompactionScenario({ - sessionManager, - event: mockEvent, - apiKey: "dummy", - }); - - const compaction = expectCompactionResult(result); - if (completed) { - expect(compaction.summary).toContain("No prior history."); - expect(mockSummarizeInStages).not.toHaveBeenCalled(); - expect(getApiKeyAndHeadersMock).not.toHaveBeenCalled(); - return; - } - expect(compaction.summary).toContain("branch summary"); - expect(mockSummarizeInStages).toHaveBeenCalledTimes(1); - expect(getApiKeyAndHeadersMock).toHaveBeenCalledTimes(1); - const summarizeCall = requireRecord(mockCallArg(mockSummarizeInStages)); - expect( - requireArray(summarizeCall.messages).map((message) => requireRecord(message).role), - ).toEqual(["user", "assistant"]); - }, - ); - - it.each([ - { toolName: "read", expectedRoles: ["user", "assistant", "toolResult"] }, - { toolName: "functions.sessions_send", expectedRoles: ["user", "assistant"] }, - ])("preserves unfinished inter-session work after a $toolName result", async (scenario) => { + it("recovers real conversation the preparation omitted from its boundary-scoped range", async () => { mockSummarizeInStages.mockReset(); - mockSummarizeInStages.mockResolvedValue(summaryResult("unfinished branch summary")); + mockSummarizeInStages.mockResolvedValue(summaryResult("range summary")); const now = Date.now(); + const entry = (id: string, parentId: string | null, offset: number, message: AgentMessage) => ({ + type: "message", + id, + parentId, + timestamp: new Date(now + offset).toISOString(), + message: { ...message, timestamp: now + offset }, + }); + const omittedUser = { role: "user", content: "verify the deploy status now" } as AgentMessage; + const toolCallAssistant = { + role: "assistant", + content: [{ type: "toolCall", id: "call-1", name: "exec", arguments: {} }], + } as AgentMessage; + const toolResult = { + role: "toolResult", + toolCallId: "call-1", + toolName: "exec", + content: [{ type: "text", text: "deploy green" }], + } as AgentMessage; const sessionManager = { ...stubSessionManager(), getBranch: () => [ + entry("old-user", null, 0, { + role: "user", + content: "old request behind reset", + } as AgentMessage), { - type: "message", - id: "user-1", - parentId: null, - timestamp: new Date(now).toISOString(), - message: { - role: "user", - content: "continue the delegated task", - provenance: { - kind: "inter_session", - sourceSessionKey: "agent:pm", - sourceTool: "sessions_send", - }, - timestamp: now, - }, - }, - { - type: "message", - id: "assistant-1", - parentId: "user-1", + type: "reset", + id: "reset-1", + parentId: "old-user", timestamp: new Date(now + 1).toISOString(), - message: { - role: "assistant", - content: [ - { - type: "toolCall", - id: "call-tool", - name: scenario.toolName, - arguments: {}, - }, - ], - timestamp: now + 1, - }, - }, - { - type: "message", - id: "tool-1", - parentId: "assistant-1", - timestamp: new Date(now + 2).toISOString(), - message: { - role: "toolResult", - toolCallId: "call-tool", - toolName: scenario.toolName, - content: [{ type: "text", text: "partial evidence" }], - timestamp: now + 2, - }, + reason: "new", + firstKeptEntryId: "reset-1", }, + entry("omitted-user", "reset-1", 2, omittedUser), + entry("assistant-1", "omitted-user", 3, toolCallAssistant), + entry("tool-1", "assistant-1", 4, toolResult), + entry("kept-user", "tool-1", 5, { role: "user", content: "and then?" } as AgentMessage), ], } as ExtensionContext["sessionManager"]; const model = createAnthropicModelFixture(); setCompactionSafeguardRuntime(sessionManager, { model, recentTurnsPreserve: 0 }); + // The preparation covers everything before "kept-user" but lost the user turn. const mockEvent = { preparation: { - messagesToSummarize: [] as AgentMessage[], + messagesToSummarize: [toolCallAssistant, toolResult], turnPrefixMessages: [] as AgentMessage[], - firstKeptEntryId: "entry-8", + firstKeptEntryId: "kept-user", tokensBefore: 38085, fileOps: { read: [], edited: [], written: [] }, settings: { reserveTokens: 4000 }, - isSplitTurn: true, + isSplitTurn: false, }, customInstructions: "", signal: new AbortController().signal, @@ -3835,259 +3974,29 @@ describe("compaction-safeguard double-compaction guard", () => { const { result } = await runCompactionScenario({ sessionManager, event: mockEvent, - apiKey: "dummy", + apiKey: "test-key", }); - const compaction = expectCompactionResult(result); - expect(compaction.summary).toContain("unfinished branch summary"); - expect(mockSummarizeInStages).toHaveBeenCalledTimes(1); - const summarizeCall = requireRecord(mockCallArg(mockSummarizeInStages)); - const messages = requireArray(summarizeCall.messages); - expect(messages.map((message) => requireRecord(message).role)).toEqual(scenario.expectedRoles); - }); - - it("keeps source-session sends as inert status history", async () => { - mockSummarizeInStages.mockReset(); - mockSummarizeInStages.mockResolvedValue(summaryResult("completed send summary")); - - const now = Date.now(); - const sessionManager = { - ...stubSessionManager(), - getBranch: () => [ - { - type: "message", - id: "user-1", - parentId: null, - timestamp: new Date(now).toISOString(), - message: { - role: "user", - content: "ask the bee session to respond", - timestamp: now, - }, - }, - { - type: "message", - id: "assistant-1", - parentId: "user-1", - timestamp: new Date(now + 1).toISOString(), - message: { - role: "assistant", - content: [ - { - type: "toolCall", - id: "call-1", - name: "functions.sessions_send", - arguments: { sessionKey: "agent:bee", message: "say bee" }, - }, - { - type: "toolCall", - id: "call-2", - name: "tools/sessions_send", - arguments: { sessionKey: "agent:wasp", message: "say wasp" }, - }, - { - type: "toolCall", - id: "call-3", - name: "sessions_send", - arguments: { sessionKey: "agent:ant", message: "say ant" }, - }, - ], - timestamp: now + 1, - }, - }, - { - type: "message", - id: "tool-1", - parentId: "assistant-1", - timestamp: new Date(now + 2).toISOString(), - message: { - role: "toolResult", - toolCallId: "call-1", - toolName: "functions.sessions_send", - content: [{ type: "text", text: '{"status":"ok","reply":"bee replied"}' }], - timestamp: now + 2, - }, - }, - { - type: "message", - id: "tool-3", - parentId: "tool-1", - timestamp: new Date(now + 3).toISOString(), - message: { - role: "toolResult", - toolCallId: "call-3", - toolName: "sessions_send", - content: [{ type: "text", text: '{"status":"error","error":"ant unavailable"}' }], - timestamp: now + 3, - }, - }, - ], - } as ExtensionContext["sessionManager"]; - const model = createAnthropicModelFixture(); - setCompactionSafeguardRuntime(sessionManager, { model, recentTurnsPreserve: 0 }); - - const mockEvent = { - preparation: { - messagesToSummarize: [] as AgentMessage[], - turnPrefixMessages: [] as AgentMessage[], - firstKeptEntryId: "entry-8", - tokensBefore: 38085, - fileOps: { read: [], edited: [], written: [] }, - settings: { reserveTokens: 4000 }, - isSplitTurn: true, - }, - customInstructions: "", - signal: new AbortController().signal, - }; - const { result } = await runCompactionScenario({ - sessionManager, - event: mockEvent, - apiKey: "dummy", - }); - - const compaction = expectCompactionResult(result); - expect(compaction.summary).toContain("completed send summary"); - expect(mockSummarizeInStages).toHaveBeenCalledTimes(1); - const summarizeCall = requireRecord(mockCallArg(mockSummarizeInStages)); - const messages = requireArray(summarizeCall.messages); - expect(messages.map((message) => requireRecord(message).role)).toEqual(["user", "assistant"]); - expect(JSON.stringify(messages)).toContain("sessions_send result received"); - expect(JSON.stringify(messages)).toContain("sessions_send result missing"); - expect(JSON.stringify(messages)).toContain("bee replied"); - expect(JSON.stringify(messages)).toContain("ant unavailable"); - expect(JSON.stringify(messages)).toContain("agent:wasp"); - expect(JSON.stringify(messages)).toContain("say wasp"); - expect(JSON.stringify(messages)).not.toContain("sessions_send completed"); - expect(JSON.stringify(messages)).not.toContain("functions.sessions_send"); - expect(JSON.stringify(messages)).not.toContain("tools/sessions_send"); - }); - - it("preserves completed historical inter-session turns outside the active tail", async () => { - mockSummarizeInStages.mockReset(); - mockSummarizeInStages.mockResolvedValue(summaryResult("historical branch summary")); - - const now = Date.now(); - const sessionManager = { - ...stubSessionManager(), - getBranch: () => [ - { - type: "message", - id: "user-1", - parentId: null, - timestamp: new Date(now).toISOString(), - message: { - role: "user", - content: "historical inter-session request", - provenance: { - kind: "inter_session", - sourceSessionKey: "agent:pm", - sourceTool: "sessions_send", - }, - timestamp: now, - }, - }, - { - type: "message", - id: "assistant-1", - parentId: "user-1", - timestamp: new Date(now + 1).toISOString(), - message: { - role: "assistant", - content: [{ type: "text", text: "historical reply" }], - timestamp: now + 1, - }, - }, - { - type: "message", - id: "user-2", - parentId: "assistant-1", - timestamp: new Date(now + 2).toISOString(), - message: { role: "user", content: "later user request", timestamp: now + 2 }, - }, - { - type: "message", - id: "assistant-2", - parentId: "user-2", - timestamp: new Date(now + 3).toISOString(), - message: { - role: "assistant", - content: [{ type: "text", text: "later reply" }], - timestamp: now + 3, - }, - }, - ], - } as ExtensionContext["sessionManager"]; - const model = createAnthropicModelFixture(); - setCompactionSafeguardRuntime(sessionManager, { model, recentTurnsPreserve: 0 }); - - const mockEvent = { - preparation: { - messagesToSummarize: [] as AgentMessage[], - turnPrefixMessages: [] as AgentMessage[], - firstKeptEntryId: "entry-8", - tokensBefore: 38085, - fileOps: { read: [], edited: [], written: [] }, - settings: { reserveTokens: 4000 }, - isSplitTurn: true, - }, - customInstructions: "", - signal: new AbortController().signal, - }; - const { result } = await runCompactionScenario({ - sessionManager, - event: mockEvent, - apiKey: "dummy", - }); - - const compaction = expectCompactionResult(result); - expect(compaction.summary).toContain("historical branch summary"); + expect(expectCompactionResult(result).summary).toContain("range summary"); expect(mockSummarizeInStages).toHaveBeenCalledTimes(1); const summarizeCall = requireRecord(mockCallArg(mockSummarizeInStages)); const messages = requireArray(summarizeCall.messages); expect(messages.map((message) => requireRecord(message).role)).toEqual([ "user", "assistant", - "user", - "assistant", + "toolResult", ]); - expect(JSON.stringify(messages)).toContain("historical inter-session request"); - expect(JSON.stringify(messages)).toContain("historical reply"); + const serialized = JSON.stringify(messages); + expect(serialized).toContain("verify the deploy status now"); + expect(serialized).not.toContain("behind reset"); + expect(serialized).not.toContain("and then?"); }); - it("recovers user and assistant branch turns when compaction preparation has only tool output", async () => { + it("writes the anti-loop boundary for a tool-only window when nothing anchors it", async () => { mockSummarizeInStages.mockReset(); - mockSummarizeInStages.mockResolvedValue(summaryResult("branch summary with visible turns")); - - const now = Date.now(); - const sessionManager = { - ...stubSessionManager(), - getBranch: () => [ - { - type: "message", - id: "user-1", - parentId: null, - timestamp: new Date(now).toISOString(), - message: { - role: "user", - content: "what is the deployment status?", - timestamp: now, - }, - }, - { - type: "message", - id: "assistant-1", - parentId: "user-1", - timestamp: new Date(now + 1).toISOString(), - message: { - role: "assistant", - content: [{ type: "text", text: "I will check the deploy." }], - timestamp: now + 1, - }, - }, - ], - } as ExtensionContext["sessionManager"]; + const sessionManager = stubSessionManager(); const model = createAnthropicModelFixture(); - setCompactionSafeguardRuntime(sessionManager, { model, recentTurnsPreserve: 0 }); + setCompactionSafeguardRuntime(sessionManager, { model }); const mockEvent = { preparation: { @@ -4095,32 +4004,29 @@ describe("compaction-safeguard double-compaction guard", () => { { role: "toolResult", toolCallId: "call-1", - toolName: "status", - content: [{ type: "text", text: "deploy green" }], - timestamp: now + 2, + toolName: "exec", + content: [{ type: "text", text: "heartbeat probe ok" }], + timestamp: Date.now(), }, ] as AgentMessage[], turnPrefixMessages: [] as AgentMessage[], - firstKeptEntryId: "entry-6", - tokensBefore: 38085, + firstKeptEntryId: "entry-1", + tokensBefore: 1500, fileOps: { read: [], edited: [], written: [] }, - settings: { reserveTokens: 4000 }, - isSplitTurn: true, }, customInstructions: "", signal: new AbortController().signal, }; - const { result } = await runCompactionScenario({ + const { result, getApiKeyAndHeadersMock } = await runCompactionScenario({ sessionManager, event: mockEvent, apiKey: "test-key", }); const compaction = expectCompactionResult(result); - expect(compaction.summary).toContain("branch summary with visible turns"); - const summarizeCall = requireRecord(mockCallArg(mockSummarizeInStages)); - const messages = requireArray(summarizeCall.messages); - expect(messages.map((message) => requireRecord(message).role)).toEqual(["user", "assistant"]); + expect(compaction.summary).toContain("No prior history."); + expect(mockSummarizeInStages).not.toHaveBeenCalled(); + expect(getApiKeyAndHeadersMock).not.toHaveBeenCalled(); }); it("continues when messages include real conversation content", async () => { diff --git a/src/agents/agent-hooks/compaction-safeguard.ts b/src/agents/agent-hooks/compaction-safeguard.ts index 2b41e32c1c95..f509a4a8375f 100644 --- a/src/agents/agent-hooks/compaction-safeguard.ts +++ b/src/agents/agent-hooks/compaction-safeguard.ts @@ -3,8 +3,6 @@ import fs from "node:fs"; import path from "node:path"; /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ -import { parseDateFirstTimestampMs } from "@openclaw/normalization-core/number-coercion"; -import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { sliceUtf16Safe, truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { capCompactionSummary, @@ -27,7 +25,6 @@ import { getCompactionProvider, type CompactionProvider, } from "../../plugins/compaction-provider.js"; -import { normalizeInputProvenance } from "../../sessions/input-provenance.js"; import { normalizeAcceptedSessionSpawnResult } from "../accepted-session-spawn.js"; import { computeAdaptiveChunkRatioWithWorker } from "../compaction-planning-worker.js"; import { buildHistoryPrunePlan } from "../compaction-planning.js"; @@ -48,7 +45,11 @@ import { collectTextContentBlocks } from "../content-blocks.js"; import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "../copilot-dynamic-headers.js"; import { isTimeoutError } from "../failover-error.js"; import { stripRuntimeContextCustomMessages } from "../internal-runtime-context.js"; -import type { AgentMessage } from "../runtime/index.js"; +import { + buildSessionContext as buildCoreSessionContext, + type AgentMessage, + type SessionTreeEntry as CoreSessionTreeEntry, +} from "../runtime/index.js"; import { repairToolUseResultPairing } from "../session-transcript-repair.js"; import type { ExtensionAPI, ExtensionContext } from "../sessions/index.js"; import { extractToolCallsFromAssistant, extractToolResultId } from "../tool-call-id.js"; @@ -93,7 +94,6 @@ const MAX_QUALITY_GUARD_MAX_RETRIES = 3; const MAX_RECENT_TURN_TEXT_CHARS = 600; const MAX_REQUIRED_ASK_CONTEXT_CHARS = 2_000; const REQUIRED_ASK_CONTEXT_TRUNCATED_MARKER = "\n[... split-turn ask context truncated ...]\n"; -const TOOL_CALL_BLOCK_TYPES = new Set(["toolCall", "toolUse", "functionCall"]); const PREVIOUS_SUMMARY_REDISTILL_PREFIX = "Previous compaction summary to re-distill with the current conversation. " + "Prune stale, duplicate, or superseded details instead of preserving it verbatim."; @@ -130,172 +130,50 @@ function prependPreviousSummaryForRedistill(params: { ]; } -function coerceTimestamp(value: unknown): number { - return parseDateFirstTimestampMs(value) ?? 0; +/** + * Messages the model currently sees: the last reset/compaction boundary's kept + * tail plus everything after it. Never the raw branch — that re-reads history + * behind every boundary and turns one compaction into dozens of model calls. + */ +function collectSessionContextMessages(sessionManager: unknown): AgentMessage[] { + return projectBranchEntries(readSessionBranch(sessionManager)); } -function sessionBranchEntryToMessage(entry: Record): unknown { - if (entry.type === "message" && entry.message && typeof entry.message === "object") { - return entry.message; +/** + * The boundary-scoped range a preparation was meant to cover: everything the + * current context holds before its kept tail, minus the prior summary message + * (that is re-distilled separately). Bounded by construction — it can never + * reach behind the last reset/compaction boundary. + */ +function collectPreparationRangeMessages( + sessionManager: unknown, + firstKeptEntryId: string, +): AgentMessage[] { + const entries = readSessionBranch(sessionManager); + const firstKeptIndex = entries.findIndex((entry) => entry.id === firstKeptEntryId); + if (firstKeptIndex < 0) { + return []; } - if (entry.type === "custom_message") { - return { - role: "custom", - customType: typeof entry.customType === "string" ? entry.customType : "custom", - content: entry.content, - display: entry.display !== false, - details: entry.details, - timestamp: coerceTimestamp(entry.timestamp), - }; - } - if (entry.type === "branch_summary") { - return { - role: "branchSummary", - summary: typeof entry.summary === "string" ? entry.summary : "", - fromId: typeof entry.fromId === "string" ? entry.fromId : "root", - timestamp: coerceTimestamp(entry.timestamp), - }; - } - return undefined; + return projectBranchEntries(entries.slice(0, firstKeptIndex)).filter( + (message) => message.role !== "compactionSummary", + ); } -function collectSessionBranchMessages(sessionManager: unknown): AgentMessage[] { +function readSessionBranch(sessionManager: unknown): CoreSessionTreeEntry[] { try { const entries: unknown = (sessionManager as { getBranch?: () => unknown })?.getBranch?.(); - return Array.isArray(entries) - ? entries.flatMap((entry) => { - const message = - entry && typeof entry === "object" - ? sessionBranchEntryToMessage(entry as Record) - : undefined; - return message ? [message as AgentMessage] : []; - }) - : []; + return Array.isArray(entries) ? (entries as CoreSessionTreeEntry[]) : []; } catch { return []; } } -function isSessionsSendToolName(value: unknown): boolean { - return ( - normalizeOptionalString(value) - ?.toLowerCase() - .replace(/^(?:functions?|tools?)[./_-]/, "") === "sessions_send" - ); -} - -function sanitizeSourceSessionSends(messages: AgentMessage[]): AgentMessage[] { - const sendCallIds = new Set( - messages.flatMap((message) => - message.role === "assistant" - ? extractToolCallsFromAssistant(message) - .filter((call) => isSessionsSendToolName(call.name)) - .map((call) => call.id.trim()) - .filter(Boolean) - : [], - ), - ); - const resultTextByCallId = new Map(); - - for (const message of messages) { - if (message.role !== "toolResult") { - continue; - } - const callId = extractToolResultId(message); - if (!callId || !sendCallIds.has(callId)) { - continue; - } - resultTextByCallId.set( - callId, - extractMessageText(message) || formatNonTextPlaceholder(message.content) || "", - ); +function projectBranchEntries(entries: CoreSessionTreeEntry[]): AgentMessage[] { + try { + return buildCoreSessionContext(entries).messages as AgentMessage[]; + } catch { + return []; } - - return messages.flatMap((message) => { - if (message.role === "assistant" && Array.isArray(message.content)) { - let replaced = false; - const content = message.content.map((block) => { - if (!block || typeof block !== "object") { - return block; - } - const record = block as { - type?: unknown; - id?: unknown; - name?: unknown; - arguments?: unknown; - }; - if ( - typeof record.type !== "string" || - !TOOL_CALL_BLOCK_TYPES.has(record.type) || - !isSessionsSendToolName(record.name) - ) { - return block; - } - replaced = true; - const callId = typeof record.id === "string" ? record.id.trim() : ""; - const resultText = callId ? resultTextByCallId.get(callId) : undefined; - const resolved = Boolean(callId && resultTextByCallId.has(callId)); - const requestText = JSON.stringify({ callId: callId || undefined, args: record.arguments }); - const resultSuffix = resolved ? `\nResult: ${resultText || "[empty]"}` : ""; - return { - type: "text", - text: `sessions_send result ${resolved ? "received" : "missing"}; delivery call omitted from replay.\nRequest: ${requestText}${resultSuffix}`, - }; - }); - return replaced ? [{ ...message, content } as AgentMessage] : [message]; - } - if (message.role === "toolResult") { - const callId = extractToolResultId(message); - if ((callId && sendCallIds.has(callId)) || isSessionsSendToolName(message.toolName)) { - return []; - } - } - return [message]; - }); -} - -function filterReplayUnsafeSessionBranchMessages(messages: AgentMessage[]): AgentMessage[] { - const sanitizedMessages = sanitizeSourceSessionSends(messages); - let turnStart = sanitizedMessages.length; - while (turnStart > 0) { - const role = (sanitizedMessages[turnStart - 1] as { role?: unknown }).role; - if (role !== "assistant" && role !== "toolResult") { - break; - } - turnStart -= 1; - } - - const tailMessage = messages.at(-1); - const endsWithTerminalAssistantText = - tailMessage !== undefined && - tailMessage.role === "assistant" && - Boolean(extractMessageText(tailMessage).trim()) && - (!Array.isArray(tailMessage.content) || - !tailMessage.content.some((block) => { - if (!block || typeof block !== "object") { - return false; - } - const type = (block as { type?: unknown }).type; - return typeof type === "string" && TOOL_CALL_BLOCK_TYPES.has(type); - })); - const activeInput = sanitizedMessages[turnStart - 1]; - const activeInputProvenance = - activeInput?.role === "user" - ? normalizeInputProvenance((activeInput as { provenance?: unknown }).provenance) - : undefined; - - // A completed sessions_send target run is already delivered to its caller. - // Require terminal text so compaction after tool output can still recover unfinished work. - if ( - endsWithTerminalAssistantText && - turnStart < sanitizedMessages.length && - turnStart > 0 && - activeInputProvenance?.kind === "inter_session" && - activeInputProvenance.sourceTool === "sessions_send" - ) { - return sanitizedMessages.slice(0, turnStart - 1); - } - return sanitizedMessages; } function containsRealConversation(messages: AgentMessage[]): boolean { @@ -621,7 +499,13 @@ function budgetCompactionSummary( ) { const suffix = normalizeCompactionSuffix(suffixInput); const joined = `${summaryBody}${suffix.text}`; - if (maxChars <= 0 || joined.length <= maxChars) { + // A body that fits still goes through the retention plan when it omits an + // audited identifier or lets an audit section outgrow its cap; both would + // re-distill into the next summary otherwise. + const retentionPlan = qualityRetention + ? createSummaryQualityRetentionPlan(summaryBody, SUMMARY_TRUNCATED_MARKER, qualityRetention) + : null; + if (maxChars <= 0 || (joined.length <= maxChars && !retentionPlan?.needsRebuild(maxChars))) { return { summary: joined, structuralSummary: summaryBody, @@ -632,9 +516,6 @@ function budgetCompactionSummary( }; } - const retentionPlan = qualityRetention - ? createSummaryQualityRetentionPlan(summaryBody, SUMMARY_TRUNCATED_MARKER, qualityRetention) - : null; const bodyCapacity = retentionPlan ? maxChars : summaryBody.length; const bodyFloor = Math.min( bodyCapacity, @@ -643,14 +524,15 @@ function budgetCompactionSummary( ); const suffixReservation = Math.min(suffix.text.length, maxChars); const bodySlot = Math.min(bodyCapacity, Math.max(bodyFloor, maxChars - suffixReservation)); - const cappedBody = retentionPlan?.render(bodySlot) ?? capCompactionSummary(summaryBody, bodySlot); + const rendered = retentionPlan?.render(bodySlot); + const cappedBody = rendered?.text ?? capCompactionSummary(summaryBody, bodySlot); const suffixBudget = Math.max(0, maxChars - cappedBody.length); const cappedSuffix = capCompactionSuffix(suffix, suffixBudget); return { summary: `${cappedBody}${cappedSuffix}`, structuralSummary: cappedBody, bodyBudget: bodySlot, - bodyTrimmed: cappedBody.length < summaryBody.length, + bodyTrimmed: rendered ? rendered.trimmed : cappedBody.length < summaryBody.length, suffixTrimmed: cappedSuffix.length < suffix.text.length, qualityRetentionInfeasible: retentionPlan !== null && retentionPlan.minimumChars > maxChars, }; @@ -1044,29 +926,37 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { thinkingLevel, streamFn, } = event; - const rawTurnPrefixMessages = preparation.turnPrefixMessages ?? []; let baseMessagesToSummarize = stripRuntimeContextCustomMessages( preparation.messagesToSummarize, ); - let baseTurnPrefixMessages = stripRuntimeContextCustomMessages(rawTurnPrefixMessages); - let hasRealSummarizable = containsRealConversation(baseMessagesToSummarize); - let hasRealTurnPrefix = containsRealConversation(baseTurnPrefixMessages); - if (!hasRealSummarizable && !hasRealTurnPrefix) { - const branchMessages = filterReplayUnsafeSessionBranchMessages( - stripRuntimeContextCustomMessages(collectSessionBranchMessages(ctx.sessionManager)), + let baseTurnPrefixMessages = stripRuntimeContextCustomMessages( + preparation.turnPrefixMessages ?? [], + ); + if (!containsRealConversation([...baseMessagesToSummarize, ...baseTurnPrefixMessages])) { + // Safety net for a preparation that dropped real conversation from the + // range it covers: summarize that boundary-scoped range instead. It is + // never the raw branch, which re-read every reset and prior compaction. + const rangeMessages = stripRuntimeContextCustomMessages( + collectPreparationRangeMessages(ctx.sessionManager, preparation.firstKeptEntryId), ); - if (containsRealConversation(branchMessages)) { + if (containsRealConversation(rangeMessages)) { log.info( - "Compaction safeguard: using session branch messages after compaction preparation omitted real conversation content.", + "Compaction safeguard: summarizing the boundary-scoped preparation range after compaction preparation omitted real conversation content.", ); - baseMessagesToSummarize = branchMessages; + baseMessagesToSummarize = rangeMessages; baseTurnPrefixMessages = []; - hasRealSummarizable = true; - hasRealTurnPrefix = false; } } + // A prepared window of pure tool traffic is still real work when the current + // context anchors it (a kept user turn or a prior compaction summary); only a + // context with no real conversation at all gets the anti-loop boundary below. + const hasRealConversation = + containsRealConversation([...baseMessagesToSummarize, ...baseTurnPrefixMessages]) || + containsRealConversation( + stripRuntimeContextCustomMessages(collectSessionContextMessages(ctx.sessionManager)), + ); setCompactionSafeguardCancelReason(ctx.sessionManager, undefined); - if (!hasRealSummarizable && !hasRealTurnPrefix) { + if (!hasRealConversation) { // When there are no summarizable messages AND no real turn-prefix content, // cancelling compaction leaves context unchanged but the SDK re-triggers // _checkCompaction after every assistant response — creating a cancel loop