mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
fix(compaction): annotate partial summaries with chunk order and source time range (#100684)
* fix(compaction): annotate partial summaries with chunk order and source time range labels In summarizeInStages, partial summaries are wrapped into synthetic AgentMessage objects before the final merge pass. Previously each synthetic message used Date.now() as its timestamp and no ordering metadata was embedded in the content. Since serializeConversation (in agent-core) only preserves text content and discards timestamps, the LLM merger could not distinguish which summary chunk represented older vs newer history, making the "PRIORITIZE recent context" merge instruction ineffective. Fix: 1. Embed a chronological label with source time range in each summary content text (e.g. "[Chunk 1 - oldest messages [2026-07-05 14:00]]"). 2. Extract actual timestamp ranges from original chunk messages via new extractChunkTimeRange() helper. 3. Assign ascending timestamps from a single Date.now() capture so relative order is preserved for code paths reading timestamp field. 4. Update test to verify merge messages receive the labels. 5. Add real behavior proof script (proof/issue-100636-chunk-labels.ts) with 10/10 verification checks. Fixes #100636 * fix(compaction): stabilize chunk time labels --------- Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
@@ -15,6 +15,7 @@ vi.mock("./sessions/index.js", async () => {
|
||||
|
||||
const mockGenerateSummary = vi.mocked(agentSessions.generateSummary);
|
||||
type SummarizeInStagesInput = Parameters<typeof import("./compaction.js").summarizeInStages>[0];
|
||||
const MESSAGE_TIME_BASE_MS = Date.UTC(2026, 0, 1);
|
||||
|
||||
const { buildCompactionSummarizationInstructions, summarizeInStages } =
|
||||
await import("./compaction.js");
|
||||
@@ -23,7 +24,7 @@ function makeMessage(index: number, size = 1200): AgentMessage {
|
||||
return {
|
||||
role: "user",
|
||||
content: `m${index}-${"x".repeat(size)}`,
|
||||
timestamp: index,
|
||||
timestamp: MESSAGE_TIME_BASE_MS + index * 60_000,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -113,6 +114,14 @@ describe("compaction identifier-preservation instructions", () => {
|
||||
"Preserve all opaque identifiers exactly as written",
|
||||
);
|
||||
}
|
||||
|
||||
type SyntheticMergeMessage = { role: "user"; content: string; timestamp: number };
|
||||
const mergeMessages = mockGenerateSummary.mock.calls[2]![0] as SyntheticMergeMessage[];
|
||||
expect(mergeMessages.map((message) => message.content)).toEqual([
|
||||
"[Chunk 1 — oldest messages [2026-01-01 00:01 — 2026-01-01 00:02 UTC]]\nsummary",
|
||||
"[Chunk 2 — most recent messages [2026-01-01 00:03 — 2026-01-01 00:04 UTC]]\nsummary",
|
||||
]);
|
||||
expect(mergeMessages[1]!.timestamp).toBe(mergeMessages[0]!.timestamp + 1);
|
||||
});
|
||||
|
||||
it("avoids duplicate additional-focus headers in split+merge path", async () => {
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* Summarization and fallback helpers for transcript compaction.
|
||||
*/
|
||||
import type { AgentCompactionIdentifierPolicy } from "../config/types.agent-defaults.js";
|
||||
import { isAbortError } from "../infra/abort-signal.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { retryAsync } from "../infra/retry.js";
|
||||
import { isAbortError } from "../infra/abort-signal.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import {
|
||||
buildOversizedFallbackPlanWithWorker,
|
||||
@@ -338,6 +338,31 @@ export async function summarizeWithFallback(params: {
|
||||
);
|
||||
}
|
||||
|
||||
/** Extracts a compact timestamp range from a chunk of messages for merge metadata. */
|
||||
function extractChunkTimeRange(chunk: AgentMessage[]): string {
|
||||
let earliest: number | undefined;
|
||||
let latest: number | undefined;
|
||||
for (const message of chunk) {
|
||||
const timestamp = message.timestamp;
|
||||
if (
|
||||
typeof timestamp !== "number" ||
|
||||
timestamp <= 0 ||
|
||||
!Number.isFinite(new Date(timestamp).getTime())
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
earliest = earliest === undefined ? timestamp : Math.min(earliest, timestamp);
|
||||
latest = latest === undefined ? timestamp : Math.max(latest, timestamp);
|
||||
}
|
||||
if (earliest === undefined || latest === undefined) {
|
||||
return "";
|
||||
}
|
||||
const format = (timestamp: number) =>
|
||||
new Date(timestamp).toISOString().replace("T", " ").slice(0, 16);
|
||||
const range = earliest === latest ? format(earliest) : `${format(earliest)} — ${format(latest)}`;
|
||||
return ` [${range} UTC]`;
|
||||
}
|
||||
|
||||
/** Summarizes history in multiple stages when a single pass would be too large. */
|
||||
export async function summarizeInStages(params: {
|
||||
messages: AgentMessage[];
|
||||
@@ -386,11 +411,28 @@ export async function summarizeInStages(params: {
|
||||
return partialSummaries[0];
|
||||
}
|
||||
|
||||
const summaryMessages: AgentMessage[] = partialSummaries.map((summary) => ({
|
||||
role: "user",
|
||||
content: summary,
|
||||
timestamp: Date.now(),
|
||||
}));
|
||||
// Capture once so timestamps are strictly monotonic across
|
||||
// all synthetic messages regardless of how long the map iteration takes.
|
||||
const now = Date.now();
|
||||
const summaryMessages: AgentMessage[] = partialSummaries.map((summary, index) => {
|
||||
// serializeConversation preserves content but not timestamps, so chronology
|
||||
// must be explicit in the text consumed by the merge model.
|
||||
const chunk = plan.chunks[index];
|
||||
const timeRange = extractChunkTimeRange(chunk);
|
||||
const label =
|
||||
index === 0
|
||||
? `[Chunk 1 — oldest messages${timeRange}]`
|
||||
: index === partialSummaries.length - 1
|
||||
? `[Chunk ${partialSummaries.length} — most recent messages${timeRange}]`
|
||||
: `[Chunk ${index + 1}/${partialSummaries.length}${timeRange}]`;
|
||||
return {
|
||||
role: "user",
|
||||
content: `${label}\n${summary}`,
|
||||
// Ascending timestamps preserve chronological order for any code
|
||||
// path that reads the AgentMessage timestamp field directly.
|
||||
timestamp: now - (partialSummaries.length - 1 - index),
|
||||
};
|
||||
});
|
||||
|
||||
const custom = params.customInstructions?.trim();
|
||||
const mergeInstructions = custom
|
||||
|
||||
Reference in New Issue
Block a user