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:
zw-xysk
2026-07-06 17:26:23 +08:00
committed by GitHub
parent c9abc75884
commit 5000f324cb
2 changed files with 58 additions and 7 deletions
@@ -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 () => {
+48 -6
View File
@@ -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