diff --git a/CHANGELOG.md b/CHANGELOG.md index cbbb28b8a4c..f1441754c4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -256,6 +256,7 @@ Docs: https://docs.openclaw.ai - Exec approvals: treat bare allowlist `*` as a true wildcard for parsed executables, including unresolved PATH lookups, so global opt-in allowlists work as configured. (#25250) Thanks @widingmarcus-cyber. - Gateway/Auth: allow trusted-proxy authenticated Control UI websocket sessions to skip device pairing when device identity is absent, preventing false `pairing required` failures behind trusted reverse proxies. (#25428) Thanks @SidQin-cyber. - Agents/Tool dispatch: await block-reply flush before tool execution starts so buffered block replies preserve message ordering around tool calls. (#25427) Thanks @SidQin-cyber. +- Agents/Compaction: harden summarization prompts to preserve opaque identifiers verbatim (UUIDs, IDs, tokens, host/IP/port, URLs), reducing post-compaction identifier drift and hallucinated identifier reconstruction. - iOS/Signing: improve `scripts/ios-team-id.sh` for Xcode 16+ by falling back to Xcode-managed provisioning profiles, add actionable guidance when an Apple account exists but no Team ID can be resolved, and ignore Xcode `xcodebuild` output directories (`apps/ios/build`, `apps/shared/OpenClawKit/build`, `Swabble/build`). (#22773) Thanks @brianleach. - macOS/Menu bar: stop reusing the injector delegate for the "Usage cost (30 days)" submenu to prevent recursive submenu injection loops when opening cost history. (#25341) Thanks @yingchunbai. - Control UI/Chat images: route image-click opens through a shared safe-open helper (allowing only safe URL schemes) and open new tabs with opener isolation to block tabnabbing. (#18685, #25444, #25847) Thanks @Mariana-Codebase and @shakkernerd. diff --git a/src/agents/compaction.identifier-preservation.test.ts b/src/agents/compaction.identifier-preservation.test.ts new file mode 100644 index 00000000000..f9667e3c800 --- /dev/null +++ b/src/agents/compaction.identifier-preservation.test.ts @@ -0,0 +1,107 @@ +import type { AgentMessage } from "@mariozechner/pi-agent-core"; +import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; +import * as piCodingAgent from "@mariozechner/pi-coding-agent"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { buildCompactionSummarizationInstructions, summarizeInStages } from "./compaction.js"; + +vi.mock("@mariozechner/pi-coding-agent", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + generateSummary: vi.fn(), + }; +}); + +const mockGenerateSummary = vi.mocked(piCodingAgent.generateSummary); + +function makeMessage(index: number, size = 1200): AgentMessage { + return { + role: "user", + content: `m${index}-${"x".repeat(size)}`, + timestamp: index, + }; +} + +describe("compaction identifier-preservation instructions", () => { + const testModel = { + provider: "anthropic", + model: "claude-3-opus", + contextWindow: 200_000, + } as unknown as NonNullable; + + beforeEach(() => { + mockGenerateSummary.mockReset(); + mockGenerateSummary.mockResolvedValue("summary"); + }); + + it("injects identifier-preservation guidance even without custom instructions", async () => { + await summarizeInStages({ + messages: [makeMessage(1), makeMessage(2)], + model: testModel, + apiKey: "test-key", + signal: new AbortController().signal, + reserveTokens: 4000, + maxChunkTokens: 8000, + contextWindow: 200_000, + }); + + expect(mockGenerateSummary).toHaveBeenCalled(); + const firstCall = mockGenerateSummary.mock.calls[0]; + expect(firstCall?.[5]).toContain("Preserve all opaque identifiers exactly as written"); + expect(firstCall?.[5]).toContain("UUIDs"); + expect(firstCall?.[5]).toContain("IPs"); + expect(firstCall?.[5]).toContain("ports"); + }); + + it("keeps identifier-preservation guidance when custom instructions are provided", async () => { + await summarizeInStages({ + messages: [makeMessage(1), makeMessage(2)], + model: testModel, + apiKey: "test-key", + signal: new AbortController().signal, + reserveTokens: 4000, + maxChunkTokens: 8000, + contextWindow: 200_000, + customInstructions: "Focus on release-impacting bugs.", + }); + + const firstCall = mockGenerateSummary.mock.calls[0]; + expect(firstCall?.[5]).toContain("Preserve all opaque identifiers exactly as written"); + expect(firstCall?.[5]).toContain("Additional focus:"); + expect(firstCall?.[5]).toContain("Focus on release-impacting bugs."); + }); + + it("applies identifier-preservation guidance on staged split + merge summarization", async () => { + await summarizeInStages({ + messages: [makeMessage(1), makeMessage(2), makeMessage(3), makeMessage(4)], + model: testModel, + apiKey: "test-key", + signal: new AbortController().signal, + reserveTokens: 4000, + maxChunkTokens: 1000, + contextWindow: 200_000, + parts: 2, + minMessagesForSplit: 4, + }); + + expect(mockGenerateSummary.mock.calls.length).toBeGreaterThan(1); + for (const call of mockGenerateSummary.mock.calls) { + expect(call[5]).toContain("Preserve all opaque identifiers exactly as written"); + } + }); +}); + +describe("buildCompactionSummarizationInstructions", () => { + it("returns base instructions when no custom text is provided", () => { + const result = buildCompactionSummarizationInstructions(); + expect(result).toContain("Preserve all opaque identifiers exactly as written"); + expect(result).not.toContain("Additional focus:"); + }); + + it("appends custom instructions in a stable format", () => { + const result = buildCompactionSummarizationInstructions("Keep deployment details."); + expect(result).toContain("Preserve all opaque identifiers exactly as written"); + expect(result).toContain("Additional focus:"); + expect(result).toContain("Keep deployment details."); + }); +}); diff --git a/src/agents/compaction.ts b/src/agents/compaction.ts index 25163471839..f2c56286b6d 100644 --- a/src/agents/compaction.ts +++ b/src/agents/compaction.ts @@ -16,6 +16,16 @@ const DEFAULT_PARTS = 2; const MERGE_SUMMARIES_INSTRUCTIONS = "Merge these partial summaries into a single cohesive summary. Preserve decisions," + " TODOs, open questions, and any constraints."; +const IDENTIFIER_PRESERVATION_INSTRUCTIONS = + "Preserve all opaque identifiers exactly as written (no shortening or reconstruction), " + + "including UUIDs, hashes, IDs, tokens, API keys, hostnames, IPs, ports, URLs, and file names."; + +export function buildCompactionSummarizationInstructions(customInstructions?: string): string { + if (!customInstructions || customInstructions.trim().length === 0) { + return IDENTIFIER_PRESERVATION_INSTRUCTIONS; + } + return `${IDENTIFIER_PRESERVATION_INSTRUCTIONS}\n\nAdditional focus:\n${customInstructions}`; +} export function estimateMessagesTokens(messages: AgentMessage[]): number { // SECURITY: toolResult.details can contain untrusted/verbose payloads; never include in LLM-facing compaction. @@ -174,7 +184,7 @@ async function summarizeChunks(params: { const safeMessages = stripToolResultDetails(params.messages); const chunks = chunkMessagesByMaxTokens(safeMessages, params.maxChunkTokens); let summary = params.previousSummary; - + const effectiveInstructions = buildCompactionSummarizationInstructions(params.customInstructions); for (const chunk of chunks) { summary = await retryAsync( () => @@ -184,7 +194,7 @@ async function summarizeChunks(params: { params.reserveTokens, params.apiKey, params.signal, - params.customInstructions, + effectiveInstructions, summary, ), {