diff --git a/packages/agent-core/src/agents/query/hooks/auto-compact.test.ts b/packages/agent-core/src/agents/query/hooks/auto-compact.test.ts index e96fea0a..e64181e7 100644 --- a/packages/agent-core/src/agents/query/hooks/auto-compact.test.ts +++ b/packages/agent-core/src/agents/query/hooks/auto-compact.test.ts @@ -38,7 +38,6 @@ beforeEach(() => { function summary() { return { - childBlockRefs: [], sections: { "Current Objective": "Continue the current task", "User Constraints": "Preserve explicit user constraints", diff --git a/packages/agent-core/src/agents/query/hooks/hybrid-compression.test.ts b/packages/agent-core/src/agents/query/hooks/hybrid-compression.test.ts index b1f41fd2..c0708678 100644 --- a/packages/agent-core/src/agents/query/hooks/hybrid-compression.test.ts +++ b/packages/agent-core/src/agents/query/hooks/hybrid-compression.test.ts @@ -51,7 +51,6 @@ beforeEach(() => { function summary(childBlockRefs: string[] = []) { return { - childBlockRefs, sections: { "Current Objective": childBlockRefs.length === 0 ? "Continue the current task" : `Continue after (${childBlockRefs[0]})`, "User Constraints": "Preserve explicit user constraints", @@ -176,6 +175,26 @@ describe("hybrid compression hooks", () => { expect(JSON.stringify(strong.messages)).toContain("strong nudge"); }); + test("lists active block refs and explains materialization in compression nudges", async () => { + const store = makeStore(8); + const child = prepareDynamicRangeCompression( + store.getState(), + { startId: "m0002", endId: "m0003", summary: summary() }, + 1000, + ); + expect(child.ok).toBe(true); + if (!child.ok) throw new Error("expected child compression success"); + store.setState({ compression: child.state }); + const hook = createHybridCompressionHook(silentLogger); + const call = callCtx(store, 550); + + await hook.beforeModelCall(call); + + const rendered = JSON.stringify(call.messages); + expect(rendered).toContain("Active compressed blocks: b1"); + expect(rendered).toContain("complete stored summary should be inserted"); + }); + test("runs forced hard compact at exactly 85% when a safe range exists", async () => { const store = makeStore(); const hook = createHybridCompressionHook(silentLogger); diff --git a/packages/agent-core/src/agents/query/hooks/hybrid-compression.ts b/packages/agent-core/src/agents/query/hooks/hybrid-compression.ts index 070c309b..dfd7d2bd 100644 --- a/packages/agent-core/src/agents/query/hooks/hybrid-compression.ts +++ b/packages/agent-core/src/agents/query/hooks/hybrid-compression.ts @@ -75,7 +75,11 @@ export function createHybridCompressionHook( const pressure = getCompressionTokenPressure(ctx.store, ctx.binding.modelInfo.limit.context); if (pressure === null || pressure.ratio < SOFT_NUDGE_RATIO || pressure.ratio >= HARD_COMPACT_RATIO) return; const strength = pressure.ratio >= STRONG_NUDGE_RATIO ? "strong" : "soft"; - ctx.messages.push(compressionNudgeMessage(strength, pressure.ratio)); + ctx.messages.push(compressionNudgeMessage( + strength, + pressure.ratio, + ctx.store.getState().compression.activeBlockRefs, + )); }; return { beforeModelBuild, beforeModelCall, circuitBreaker, scheduleToolOutputRecoveryNotice }; @@ -91,13 +95,20 @@ function toolOutputRecoveryNotice(count: number): ModelCallMessage { }; } -function compressionNudgeMessage(strength: "soft" | "strong", ratio: number): ModelCallMessage { +function compressionNudgeMessage( + strength: "soft" | "strong", + ratio: number, + activeBlockRefs: readonly string[], +): ModelCallMessage { const percent = Math.floor(ratio * 100); const guidance = strength === "strong" ? "Context pressure is high. Dynamic compression is an in-conversation tool action: use the compress tool on a safe older range only if it helps before the hard safety threshold. Do not compress the latest two rounds or protected content." : "Context pressure is rising. Keep responses concise and consider whether an older safe range should be dynamically compressed later."; + const blockGuidance = activeBlockRefs.length === 0 + ? "There are no active compressed blocks." + : `Active compressed blocks: ${activeBlockRefs.join(", ")}. If a selected range contains one, place each required (bN) placeholder exactly once where its complete stored summary should be inserted.`; return { role: "user", - content: [{ type: "text", text: `\nDynamic compression ${strength} nudge at ${percent}% context pressure. ${guidance}\n` }], + content: [{ type: "text", text: `\nDynamic compression ${strength} nudge at ${percent}% context pressure. ${guidance} ${blockGuidance}\n` }], }; } diff --git a/packages/agent-core/src/compression/constants.ts b/packages/agent-core/src/compression/constants.ts index b80f9db5..2a2d0e9d 100644 --- a/packages/agent-core/src/compression/constants.ts +++ b/packages/agent-core/src/compression/constants.ts @@ -33,7 +33,7 @@ export const DCP_PARITY_ITEMS = [ "stable_session_local_block_refs", "range_compression_by_start_end_ref", "model_callable_compress_contract", - "nested_blocks_with_placeholder_validation", + "nested_blocks_with_materialized_child_summaries", "active_inactive_superseded_lifecycle", "protected_content_contracts", "user_messages_preserve_canonical_originals", diff --git a/packages/agent-core/src/compression/dcp-parity.test.ts b/packages/agent-core/src/compression/dcp-parity.test.ts index add6e8c6..2c1d2a79 100644 --- a/packages/agent-core/src/compression/dcp-parity.test.ts +++ b/packages/agent-core/src/compression/dcp-parity.test.ts @@ -48,16 +48,16 @@ const EXECUTABLE_DCP_PARITY_COVERAGE = { anchors: ["startId: \"m0001\"", "endId: \"m0004\"", "summary: summary()"], }, ], - nested_blocks_with_placeholder_validation: [ + nested_blocks_with_materialized_child_summaries: [ { file: "packages/agent-core/src/compression/summary.test.ts", - testName: "summary requires child placeholders exactly once", - anchors: ["validateCompressionSummary(summary, [\"b1\"]).ok", "Resume Instructions"], + testName: "replaces a child placeholder with the complete stored summary", + anchors: ["CHILD_SENTINEL", "not.toContain(\"(b1)\")"], }, { file: "packages/agent-core/src/compression/dynamic-range.test.ts", - testName: "nested parent requires child placeholder exactly once and supersedes the child", - anchors: ["summary([\"b1\"])", "supersededBy"], + testName: "three-level nesting stores a self-contained top summary", + anchors: ["LEVEL_THREE", "LEVEL_ONE_SENTINEL", "activeBlockRefs).toEqual([\"b3\"])"], }, ], active_inactive_superseded_lifecycle: [ diff --git a/packages/agent-core/src/compression/dynamic-range.test.ts b/packages/agent-core/src/compression/dynamic-range.test.ts index 772e206f..df788b40 100644 --- a/packages/agent-core/src/compression/dynamic-range.test.ts +++ b/packages/agent-core/src/compression/dynamic-range.test.ts @@ -1,6 +1,11 @@ import { describe, expect, test } from "bun:test"; -import { createEmptyCompressionState, prepareDynamicRangeCompression, purgeRepeatedOldErrors } from "./index"; -import type { CompressionSummary } from "./types"; +import { + createEmptyCompressionState, + prepareDynamicRangeCompression, + purgeRepeatedOldErrors, + renderCompressionSummary, +} from "./index"; +import type { CompressionSummaryTemplate } from "./types"; import type { SessionStoreState, StoredMessage } from "../store/types"; import { createEmptySessionStats, @@ -8,11 +13,10 @@ import { type UserSessionPart, } from "@archcode/protocol"; -function summary(childBlockRefs: string[] = []): CompressionSummary { +function summary(childBlockRefs: string[] = [], objective?: string): CompressionSummaryTemplate { return { - childBlockRefs: childBlockRefs as CompressionSummary["childBlockRefs"], sections: { - "Current Objective": childBlockRefs.length > 0 ? "Continue after nested child block" : "Continue task", + "Current Objective": objective ?? (childBlockRefs.length > 0 ? "Continue after nested child block" : "Continue task"), "User Constraints": "Preserve user constraints", "Decisions Made": "Dynamic range compression is model-authored", "Open Tasks": "Continue implementation", @@ -199,9 +203,13 @@ describe("dynamic range compression", () => { expect(result.event.type).toBe("compression.block_failed"); }); - test("nested parent requires child placeholder exactly once and supersedes the child", () => { + test("nested parent materializes the child summary and supersedes its lineage block", () => { const state = baseState(fourMessages()); - const child = prepareDynamicRangeCompression(state, { startId: "m0002", endId: "m0003", summary: summary() }, 1000); + const child = prepareDynamicRangeCompression(state, { + startId: "m0002", + endId: "m0003", + summary: summary([], "CHILD_SUMMARY_SENTINEL"), + }, 1000); expect(child.ok).toBe(true); if (!child.ok) throw new Error("expected child success"); @@ -214,6 +222,116 @@ describe("dynamic range compression", () => { expect(parent.state.blocksByRef.b2?.status).toBe("active"); expect(parent.state.blocksByRef.b1?.supersededBy).toBe("b2"); expect(parent.state.activeBlockRefs).toEqual(["b2"]); + expect(parent.block.summary.sections["Child Block Refs"]).toContain("CHILD_SUMMARY_SENTINEL"); + expect(JSON.stringify(parent.block.summary)).not.toContain("(b1)"); + }); + + test("nested parent rejects a summary that omits its runtime-derived child placeholder", () => { + const state = baseState(fourMessages()); + const child = prepareDynamicRangeCompression(state, { + startId: "m0002", + endId: "m0003", + summary: summary([], "OMITTED_CHILD_SENTINEL"), + }, 1000); + expect(child.ok).toBe(true); + if (!child.ok) throw new Error("expected child success"); + + const parent = prepareDynamicRangeCompression( + { ...state, compression: child.state }, + { startId: "m0001", endId: "m0004", summary: summary() }, + 2000, + ); + + expect(parent.ok).toBe(false); + if (parent.ok) throw new Error("expected parent rejection"); + expect(parent.code).toBe("summary_rejected"); + expect(parent.issues[0]?.message).toContain("Required child placeholder (b1) must appear exactly once; found 0"); + }); + + test("three-level nesting stores a self-contained top summary", () => { + const state = baseState(fourMessages()); + const child = prepareDynamicRangeCompression(state, { + startId: "m0002", + endId: "m0003", + summary: summary([], "LEVEL_ONE_SENTINEL"), + }, 1000); + expect(child.ok).toBe(true); + if (!child.ok) throw new Error("expected child success"); + const parent = prepareDynamicRangeCompression( + { ...state, compression: child.state }, + { startId: "m0001", endId: "m0004", summary: summary(["b1"], "LEVEL_TWO") }, + 2000, + ); + expect(parent.ok).toBe(true); + if (!parent.ok) throw new Error("expected parent success"); + const grandparent = prepareDynamicRangeCompression( + { ...state, compression: parent.state }, + { startId: "b2", endId: "b2", summary: summary(["b2"], "LEVEL_THREE") }, + 3000, + ); + + expect(grandparent.ok).toBe(true); + if (!grandparent.ok) throw new Error("expected grandparent success"); + const rendered = JSON.stringify(grandparent.block.summary); + expect(rendered).toContain("LEVEL_THREE"); + expect(rendered).toContain("LEVEL_TWO"); + expect(rendered).toContain("LEVEL_ONE_SENTINEL"); + expect(rendered).not.toMatch(/\(b[12]\)/); + expect(grandparent.state.activeBlockRefs).toEqual(["b3"]); + expect(grandparent.state.blocksByRef.b2?.supersededBy).toBe("b3"); + }); + + test("nested token estimate counts sibling child summaries once plus only uncovered messages", () => { + const messages = fourMessages(); + messages.push( + message("msg-7", "user", [text("t7", "seven")]), + message("msg-8", "assistant", [output("t8", "eight")]), + ); + messages[1] = message("msg-2", "assistant", [output("t2-large", "x".repeat(8_000))]); + messages[2] = message("msg-3", "user", [text("t3-large", "y".repeat(8_000))]); + messages[3] = message("msg-4", "assistant", [output("t4-large", "z".repeat(8_000))]); + messages[4] = message("msg-5", "user", [text("t5-large", "w".repeat(8_000))]); + const state = baseState(messages); + const firstChild = prepareDynamicRangeCompression(state, { + startId: "m0002", + endId: "m0003", + summary: summary([], "FIRST_CONDENSED_CHILD"), + }, 1000); + expect(firstChild.ok).toBe(true); + if (!firstChild.ok) throw new Error("expected first child success"); + const secondChild = prepareDynamicRangeCompression( + { ...state, compression: firstChild.state }, + { + startId: "m0004", + endId: "m0005", + summary: summary([], "SECOND_CONDENSED_CHILD"), + }, + 1500, + ); + expect(secondChild.ok).toBe(true); + if (!secondChild.ok) throw new Error("expected second child success"); + const parent = prepareDynamicRangeCompression( + { ...state, compression: secondChild.state }, + { startId: "m0001", endId: "m0006", summary: summary(["b1", "b2"]) }, + 2000, + ); + expect(parent.ok).toBe(true); + if (!parent.ok) throw new Error("expected parent success"); + + const expectedOriginalChars = renderCompressionSummary(firstChild.block.summary).length + + renderCompressionSummary(secondChild.block.summary).length + + JSON.stringify(messages[0]!.parts).length + + JSON.stringify(messages[5]!.parts).length; + expect(parent.block.tokenEstimate).toEqual({ + originalTokens: Math.ceil(expectedOriginalChars / 4), + summaryTokens: Math.ceil(renderCompressionSummary(parent.block.summary).length / 4), + savedTokens: Math.max( + 0, + Math.ceil(expectedOriginalChars / 4) + - Math.ceil(renderCompressionSummary(parent.block.summary).length / 4), + ), + estimatedAt: 2000, + }); }); test("rejects partial active overlap", () => { diff --git a/packages/agent-core/src/compression/dynamic-range.ts b/packages/agent-core/src/compression/dynamic-range.ts index 94faad77..5a40381a 100644 --- a/packages/agent-core/src/compression/dynamic-range.ts +++ b/packages/agent-core/src/compression/dynamic-range.ts @@ -7,7 +7,7 @@ import type { ProtectedRef, } from "./types"; import { commitCompressionBlock, createEmptyCompressionState, recordCompressionFailure, CompressionStateError } from "./state"; -import { renderCompressionSummary } from "./summary"; +import { materializeCompressionSummaryTemplate, renderCompressionSummary } from "./summary"; import { collectProtectedRefsForRange } from "./protection"; import { deduplicateCompletedToolOutputs, type DeduplicatedToolOutputGroup } from "./deduplication"; import { purgeRepeatedOldErrors, type PurgedRepeatedErrorGroup } from "./purge-errors"; @@ -75,16 +75,34 @@ export function prepareDynamicRangeCompression( ); } + let materializedSummary: CompressionSummary; + try { + materializedSummary = materializeCompressionSummaryTemplate( + summary.summary, + resolved.value.requiredChildRefs, + stateWithRefs.blocksByRef, + ); + } catch (error) { + const issue = stateIssue(error); + return reject(stateWithRefs, issue.code, issue.message, [issue], [], input, now); + } + const draft: CompressionBlockDraft = { id: crypto.randomUUID(), canonicalBlockId: crypto.randomUUID(), strategy: "dynamic-range", trigger: "model_tool_call", range: resolved.value.range, - summary: summary.summary, + summary: materializedSummary, protectedRefs: [], - childBlockRefs: summary.summary.childBlockRefs, - tokenEstimate: estimateCompressionTokens(storeState, resolved.value.range, summary.summary, now), + childBlockRefs: resolved.value.requiredChildRefs, + tokenEstimate: estimateCompressionTokens( + storeState, + resolved.value.range, + resolved.value.requiredChildRefs, + materializedSummary, + now, + ), createdAt: now, }; @@ -135,7 +153,7 @@ export function compressionBlockSnapshot(block: CompressionBlock): CompressionBl trigger: block.trigger, range: block.range, summary: { sections: { ...block.summary.sections } }, - childBlockRefs: block.childBlockRefs, + childBlockRefs: [...block.childBlockRefs], protectedRefs: block.protectedRefs.map((ref) => ref.ref), ...(block.tokenEstimate === undefined ? {} : { tokenEstimate: block.tokenEstimate }), createdAt: block.createdAt, @@ -192,13 +210,28 @@ function compressionFailureSnapshot(failure: CompressionFailure): CompressionFai function estimateCompressionTokens( storeState: SessionStoreState, range: CompressionBlockDraft["range"], + childBlockRefs: readonly CompressionBlock["ref"][], summary: CompressionSummary, now: number, ): CompressionBlockDraft["tokenEstimate"] { - const originalChars = storeState.messages - .slice(range.startIndex, range.endIndex + 1) - .map((message) => JSON.stringify(message.parts)) - .join("\n").length; + const childBlocks = childBlockRefs + .map((ref) => storeState.compression?.blocksByRef[ref]) + .filter((block): block is CompressionBlock => block !== undefined); + const coveredIndexes = new Set(); + for (const child of childBlocks) { + for (let index = child.range.startIndex; index <= child.range.endIndex; index += 1) { + coveredIndexes.add(index); + } + } + + let originalChars = childBlocks + .map((child) => renderCompressionSummary(child.summary).length) + .reduce((total, length) => total + length, 0); + for (let index = range.startIndex; index <= range.endIndex; index += 1) { + if (coveredIndexes.has(index)) continue; + const message = storeState.messages[index]; + if (message !== undefined) originalChars += JSON.stringify(message.parts).length; + } const summaryChars = renderCompressionSummary(summary).length; const originalTokens = Math.ceil(originalChars / 4); const summaryTokens = Math.ceil(summaryChars / 4); diff --git a/packages/agent-core/src/compression/original-range.test.ts b/packages/agent-core/src/compression/original-range.test.ts index a8a77482..393ef2f6 100644 --- a/packages/agent-core/src/compression/original-range.test.ts +++ b/packages/agent-core/src/compression/original-range.test.ts @@ -6,7 +6,7 @@ import { } from "@archcode/protocol"; import { createEmptyCompressionState, prepareDynamicRangeCompression } from "./index"; import { resolveCompressionOriginalRange } from "./original-range"; -import type { CompressionSummary } from "./types"; +import type { BlockRef, CompressionSummaryTemplate } from "./types"; import type { SessionFile } from "../store/helpers"; import type { SessionStoreState, StoredMessage } from "../store/types"; @@ -29,9 +29,8 @@ function finalizedResult( }; } -function summary(childBlockRefs: CompressionSummary["childBlockRefs"] = []): CompressionSummary { +function summary(childBlockRefs: BlockRef[] = []): CompressionSummaryTemplate { return { - childBlockRefs, sections: { "Current Objective": childBlockRefs.length > 0 ? "Continue task after child blocks" : "Continue task", "User Constraints": "Preserve constraints", diff --git a/packages/agent-core/src/compression/original-range.ts b/packages/agent-core/src/compression/original-range.ts index 7e671ef3..6d45c802 100644 --- a/packages/agent-core/src/compression/original-range.ts +++ b/packages/agent-core/src/compression/original-range.ts @@ -70,7 +70,7 @@ export function resolveCompressionOriginalRange( status: block.status, strategy: block.strategy, trigger: block.trigger, - childBlockRefs: block.childBlockRefs, + childBlockRefs: [...block.childBlockRefs], range: block.range, coveredRefs: coveredEntries.map((entry) => entry.ref), coveredMessageIds: coveredEntries.map((entry) => entry.message.id), diff --git a/packages/agent-core/src/compression/state.test.ts b/packages/agent-core/src/compression/state.test.ts index 5411b69c..573ff4e8 100644 --- a/packages/agent-core/src/compression/state.test.ts +++ b/packages/agent-core/src/compression/state.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { commitCompressionBlock, createEmptyCompressionState, CompressionStateError } from "./state"; -import type { BlockRef, CompressionBlockDraft, CompressionRange, CompressionSummary } from "./types"; +import { materializeCompressionSummaryTemplate } from "./summary"; +import type { BlockRef, CompressionBlockDraft, CompressionRange, CompressionState, CompressionSummary } from "./types"; function range(startIndex: number, endIndex: number): CompressionRange { return { @@ -15,9 +16,8 @@ function range(startIndex: number, endIndex: number): CompressionRange { function summary(childBlockRefs: BlockRef[] = []): CompressionSummary { return { - childBlockRefs, sections: { - "Current Objective": childBlockRefs.length > 0 ? `Continue after (${childBlockRefs[0]})` : "Continue task", + "Current Objective": childBlockRefs.length > 0 ? `Continue after materialized ${childBlockRefs[0]}` : "Continue task", "User Constraints": "Preserve constraints", "Decisions Made": "Use contracts first", "Open Tasks": "Implement later runtime tasks", @@ -44,10 +44,28 @@ function draft(canonicalBlockId: string, blockRange: CompressionRange, childBloc }; } +function nestedDraft( + state: CompressionState, + canonicalBlockId: string, + blockRange: CompressionRange, + childBlockRefs: BlockRef[], +): CompressionBlockDraft { + const template = { + sections: { + ...summary().sections, + "Child Block Refs": childBlockRefs.map((ref) => `(${ref})`).join(" "), + }, + }; + return { + ...draft(canonicalBlockId, blockRange, childBlockRefs), + summary: materializeCompressionSummaryTemplate(template, childBlockRefs, state.blocksByRef), + }; +} + describe("compression nested block DAG", () => { test("nested parent allows whole-child nesting and preserves superseded child resolvability", () => { const childState = commitCompressionBlock(createEmptyCompressionState(), draft("child", range(1, 2))); - const parentState = commitCompressionBlock(childState, draft("parent", range(0, 4), ["b1"])); + const parentState = commitCompressionBlock(childState, nestedDraft(childState, "parent", range(0, 4), ["b1"])); expect(parentState.blocksByRef.b1).toBeDefined(); expect(parentState.blocksByRef.b1?.status).toBe("superseded"); @@ -68,4 +86,30 @@ describe("compression nested block DAG", () => { expect(() => commitCompressionBlock(childState, draft("parent", range(0, 4)))).toThrow(CompressionStateError); }); + + test("rejects duplicate child lineage before committing state", () => { + const childState = commitCompressionBlock(createEmptyCompressionState(), draft("child", range(1, 2))); + + expect(() => commitCompressionBlock(childState, draft("parent", range(0, 4), ["b1", "b1"]))) + .toThrow(new CompressionStateError("duplicate_child_block", "Compression child block refs must be unique")); + }); + + test("committed lineage is isolated from the caller-owned draft array", () => { + const childState = commitCompressionBlock(createEmptyCompressionState(), draft("child", range(1, 2))); + const childBlockRefs: BlockRef[] = ["b1"]; + const parentDraft = nestedDraft(childState, "parent", range(0, 4), childBlockRefs); + + const parentState = commitCompressionBlock(childState, parentDraft); + childBlockRefs.push("b1"); + + expect(parentState.blocksByRef.b2?.childBlockRefs).toEqual(["b1"]); + expect(Object.isFrozen(parentState.blocksByRef.b2?.childBlockRefs)).toBe(true); + }); + + test("nested parent rejects lineage without the materialized child payload", () => { + const childState = commitCompressionBlock(createEmptyCompressionState(), draft("child", range(1, 2))); + + expect(() => commitCompressionBlock(childState, draft("parent", range(0, 4), ["b1"]))) + .toThrow("Materialized child block b1 must appear exactly once; found 0"); + }); }); diff --git a/packages/agent-core/src/compression/state.ts b/packages/agent-core/src/compression/state.ts index 88a3a549..8b3775b3 100644 --- a/packages/agent-core/src/compression/state.ts +++ b/packages/agent-core/src/compression/state.ts @@ -1,11 +1,11 @@ import { rangeContains, rangesPartiallyOverlap } from "./coverage"; import { createEmptyCompressionRefMap, ensureBlockRef } from "./refs"; -import { assertValidCompressionSummary } from "./summary"; +import { assertValidCompressionSummaryLineage } from "./summary"; import type { BlockRef, CompressionBlock, CompressionBlockDraft, CompressionState } from "./types"; export class CompressionStateError extends Error { constructor( - public readonly code: "partial_active_overlap" | "nested_child_missing" | "unknown_child_block" | "child_not_active", + public readonly code: "partial_active_overlap" | "nested_child_missing" | "unknown_child_block" | "child_not_active" | "duplicate_child_block", message: string, ) { super(message); @@ -30,8 +30,10 @@ export function commitCompressionBlock(state: CompressionState, draft: Compressi const blockRefResult = ensureBlockRef(state.refMap, draft.canonicalBlockId); const ref = blockRefResult.ref; - const childBlockRefs = draft.childBlockRefs ?? []; - assertValidCompressionSummary(draft.summary, childBlockRefs); + const childBlockRefs = Object.freeze([...draft.childBlockRefs]) as BlockRef[]; + const summary: CompressionBlock["summary"] = Object.freeze({ + sections: Object.freeze({ ...draft.summary.sections }), + }); const timestamp = draft.createdAt; const childRefsToSupersede = new Set(childBlockRefs); @@ -58,8 +60,8 @@ export function commitCompressionBlock(state: CompressionState, draft: Compressi strategy: draft.strategy, trigger: draft.trigger, range: draft.range, - summary: draft.summary, - protectedRefs: draft.protectedRefs ?? [], + summary, + protectedRefs: [...(draft.protectedRefs ?? [])], childBlockRefs, ...(draft.tokenEstimate ? { tokenEstimate: draft.tokenEstimate } : {}), createdAt: timestamp, @@ -84,7 +86,10 @@ export function recordCompressionFailure( } export function validateCompressionBlockDraft(state: CompressionState, draft: CompressionBlockDraft): void { - const childRefs = new Set(draft.childBlockRefs ?? []); + const childRefs = new Set(draft.childBlockRefs); + if (childRefs.size !== draft.childBlockRefs.length) { + throw new CompressionStateError("duplicate_child_block", "Compression child block refs must be unique"); + } for (const childRef of childRefs) { const child = state.blocksByRef[childRef]; if (!child) { @@ -125,6 +130,8 @@ export function validateCompressionBlockDraft(state: CompressionState, draft: Co `Draft range is inside active block ${activeRef}; the current contract only allows consuming whole child blocks`, ); } + + assertValidCompressionSummaryLineage(draft.summary, draft.childBlockRefs, state.blocksByRef); } function normalizeCompressionState(state: CompressionState): CompressionState { diff --git a/packages/agent-core/src/compression/summary.test.ts b/packages/agent-core/src/compression/summary.test.ts index 7815962d..60f4556f 100644 --- a/packages/agent-core/src/compression/summary.test.ts +++ b/packages/agent-core/src/compression/summary.test.ts @@ -1,99 +1,128 @@ import { describe, expect, test } from "bun:test"; -import { validateCompressionSummary } from "./summary"; -import type { CompressionSummary } from "./types"; +import { + materializeCompressionSummaryTemplate, + renderCompressionSummary, + CompressionSummaryValidationError, + validateCompressionSummary, + validateCompressionSummaryTemplate, +} from "./summary"; +import type { + BlockRef, + CompressionBlock, + CompressionSummary, + CompressionSummaryTemplate, +} from "./types"; + +function sections(objective = "Ship contract layer") { + return { + "Current Objective": objective, + "User Constraints": "No runtime wiring", + "Decisions Made": "Use strict schemas", + "Open Tasks": "Later tasks wire projection", + "Important Files": "packages/agent-core/src/compression/summary.ts", + "Tool Results": "Tests only", + "Errors/Unknown Results": "None", + "Protected Refs": "None", + "Child Block Refs": "None", + "Resume Instructions": "Continue with Task 2", + }; +} + +function template(overrides: Partial = {}): CompressionSummaryTemplate { + return { sections: sections(), ...overrides }; +} + +function storedSummary(objective: string): CompressionSummary { + return { sections: sections(objective) }; +} -function validSummary(overrides: Partial = {}): CompressionSummary { +function block(ref: BlockRef, summary: CompressionSummary): CompressionBlock { + const index = Number(ref.slice(1)); return { - childBlockRefs: [], - sections: { - "Current Objective": "Ship contract layer", - "User Constraints": "No runtime wiring", - "Decisions Made": "Use strict schemas", - "Open Tasks": "Later tasks wire projection", - "Important Files": "packages/agent-core/src/compression/summary.ts", - "Tool Results": "Tests only", - "Errors/Unknown Results": "None", - "Protected Refs": "None", - "Child Block Refs": "None", - "Resume Instructions": "Continue with Task 2", + id: `block-${ref}`, + ref, + status: "active", + strategy: "dynamic-range", + trigger: "model_tool_call", + range: { + startMessageId: `msg-${index}`, + endMessageId: `msg-${index + 1}`, + startRef: `m${String(index).padStart(4, "0")}`, + endRef: `m${String(index + 1).padStart(4, "0")}`, + startIndex: index - 1, + endIndex: index, }, - ...overrides, + summary, + protectedRefs: [], + childBlockRefs: [], + createdAt: index, + updatedAt: index, }; } describe("compression summary schema", () => { - test("summary rejects missing required sections", () => { - const summary = validSummary(); - const { "Current Objective": _removed, ...sections } = summary.sections; - - const result = validateCompressionSummary({ ...summary, sections }); - - expect(result.ok).toBe(false); - }); + test("rejects missing sections and fields outside summary content", () => { + const { "Current Objective": _removed, ...incomplete } = sections(); - test("summary rejects unknown fields", () => { - expect(validateCompressionSummary({ ...validSummary(), unexpectedField: true }).ok).toBe(false); + expect(validateCompressionSummaryTemplate({ sections: incomplete }).ok).toBe(false); + expect(validateCompressionSummaryTemplate({ ...template(), unexpectedField: true }).errors) + .toContain("Unknown summary field unexpectedField"); }); - test("summary requires child placeholders exactly once", () => { - const summary = validSummary({ - childBlockRefs: ["b1"], + test("requires every runtime-derived child placeholder exactly once", () => { + const missing = template(); + const duplicate = template({ sections: { - ...validSummary().sections, - "Current Objective": "Continue after (b1)", - "Child Block Refs": "b1", + ...sections(), + "Current Objective": "First (b1)", + "Resume Instructions": "Second (b1)", }, }); - - expect(validateCompressionSummary(summary, ["b1"]).ok).toBe(true); - expect(validateCompressionSummary(validSummary({ childBlockRefs: ["b1"] }), ["b1"]).ok).toBe(false); - expect(validateCompressionSummary({ - ...summary, - sections: { ...summary.sections, "Resume Instructions": "Use (b1) too" }, - }, ["b1"]).ok).toBe(false); - }); - - test("summary with no required children accepts no declared child refs", () => { - expect(validateCompressionSummary(validSummary()).ok).toBe(true); + const unknown = template({ sections: { ...sections(), "Current Objective": "Unknown (b9)" } }); + + expect(validateCompressionSummaryTemplate(missing, ["b1"]).errors) + .toContain("Required child placeholder (b1) must appear exactly once; found 0"); + expect(validateCompressionSummaryTemplate(duplicate, ["b1"]).errors) + .toContain("Required child placeholder (b1) must appear exactly once; found 2"); + expect(validateCompressionSummaryTemplate(unknown, ["b1"]).errors) + .toContain("Placeholder (b9) is not a required child block ref"); }); - test("summary rejects declared child refs when no children are required", () => { - const result = validateCompressionSummary(validSummary({ childBlockRefs: ["b1"] })); - - expect(result.ok).toBe(false); - expect(result.errors).toContain("Child Block Refs must not include unknown ref b1"); + test("stored summaries reject unresolved block placeholders", () => { + expect(validateCompressionSummary(storedSummary("Expanded child text")).ok).toBe(true); + expect(validateCompressionSummary(storedSummary("Still points at (b1)")).ok).toBe(false); + expect(validateCompressionSummary(storedSummary('')).ok).toBe(false); }); +}); - test("summary rejects extra declared child refs outside required children", () => { - const summary = validSummary({ - childBlockRefs: ["b1", "b2"], - sections: { - ...validSummary().sections, - "Current Objective": "Continue after (b1)", - "Child Block Refs": "b1, b2", - }, +describe("compression summary materialization", () => { + test("replaces a child placeholder with the complete stored summary", () => { + const child = block("b1", storedSummary("CHILD_SENTINEL")); + const parentTemplate = template({ + sections: { ...sections("Before (b1) after"), "Child Block Refs": "b1 contributes prior work" }, }); - const result = validateCompressionSummary(summary, ["b1"]); + const materialized = materializeCompressionSummaryTemplate(parentTemplate, ["b1"], { b1: child }); + const rendered = renderCompressionSummary(materialized); - expect(result.ok).toBe(false); - expect(result.errors).toContain("Child Block Refs must not include unknown ref b2"); + expect(rendered).toContain('Before \n## Current Objective\nCHILD_SENTINEL'); + expect(rendered).toContain(" after"); + expect(rendered).toContain("after"); + expect(rendered).not.toContain("(b1)"); + expect(Object.keys(materialized)).toEqual(["sections"]); }); - test("summary rejects undeclared or non-required block placeholders in rendered text", () => { - const summary = validSummary({ - childBlockRefs: ["b1"], - sections: { - ...validSummary().sections, - "Current Objective": "Continue after (b1)", - "Resume Instructions": "Do not follow unknown placeholder (b2)", - "Child Block Refs": "b1", - }, + test("rejects invalid templates and missing required child blocks", () => { + const unexpectedPlaceholder = template({ + sections: { ...sections(), "Current Objective": "Unexpected (b9)" }, + }); + const missingChild = template({ + sections: { ...sections(), "Current Objective": "Missing (b1)" }, }); - const result = validateCompressionSummary(summary, ["b1"]); - - expect(result.ok).toBe(false); - expect(result.errors).toContain("Placeholder (b2) is not a required declared child block ref"); + expect(() => materializeCompressionSummaryTemplate(unexpectedPlaceholder, [], {})) + .toThrow(CompressionSummaryValidationError); + expect(() => materializeCompressionSummaryTemplate(missingChild, ["b1"], {})) + .toThrow("Required child block b1 does not exist"); }); }); diff --git a/packages/agent-core/src/compression/summary.ts b/packages/agent-core/src/compression/summary.ts index bbd1f09a..f6b1a8b5 100644 --- a/packages/agent-core/src/compression/summary.ts +++ b/packages/agent-core/src/compression/summary.ts @@ -1,40 +1,151 @@ import { COMPRESSION_SUMMARY_SECTION_NAMES } from "./constants"; -import type { BlockRef, CompressionSummary, CompressionSummarySectionName } from "./types"; - -export interface CompressionSummarySchemaContract { - readonly requiredSections: readonly CompressionSummarySectionName[]; - readonly strict: true; -} - -export const CompressionSummarySchema: CompressionSummarySchemaContract = { - requiredSections: COMPRESSION_SUMMARY_SECTION_NAMES, - strict: true, -}; +import { + isMaterializedCompressionSummarySnapshot, + renderCompressionSummarySnapshot, +} from "@archcode/protocol"; +import type { + BlockRef, + CompressionState, + CompressionSummary, + CompressionSummarySections, + CompressionSummaryTemplate, +} from "./types"; export interface SummaryValidationResult { readonly ok: boolean; readonly errors: string[]; } -export function validateCompressionSummary( +interface MaterializedChildSummary { + readonly ref: BlockRef; + readonly rendered: string; +} + +const CHILD_SUMMARY_OPEN = '"; +const CHILD_SUMMARY_TAG_PATTERN = /|<\/compression-child>/g; +const CHILD_SUMMARY_RESERVED_OPEN_PATTERN = / placeholder === ref).length; + if (occurrences !== 1) { + errors.push(`Required child placeholder (${ref}) must appear exactly once; found ${occurrences}`); + } + } + return { ok: errors.length === 0, errors }; } export function assertValidCompressionSummary( summary: unknown, - requiredChildRefs: readonly BlockRef[] = [], ): asserts summary is CompressionSummary { - const result = validateCompressionSummary(summary, requiredChildRefs); - if (!result.ok) { - throw new CompressionSummaryValidationError(result.errors); + const result = validateCompressionSummary(summary); + if (!result.ok) throw new CompressionSummaryValidationError(result.errors); +} + +export function validateCompressionSummaryLineage( + summary: unknown, + childBlockRefs: readonly BlockRef[], + blocksByRef: CompressionState["blocksByRef"], +): SummaryValidationResult { + const summaryValidation = validateCompressionSummary(summary); + if (!summaryValidation.ok) return summaryValidation; + + const parsed = parseCompressionSummary(summary); + if (!parsed.ok) return parsed; + const materialized = parseMaterializedChildSummaries(renderCompressionSummary(parsed.summary)); + if (materialized.errors.length > 0) return { ok: false, errors: materialized.errors }; + + const errors: string[] = []; + const expectedRefs = new Set(childBlockRefs); + if (expectedRefs.size !== childBlockRefs.length) { + errors.push("Compression child block refs must be unique"); + } + + const materializedByRef = new Map(); + for (const child of materialized.children) { + const entries = materializedByRef.get(child.ref) ?? []; + entries.push(child); + materializedByRef.set(child.ref, entries); + if (!expectedRefs.has(child.ref)) { + errors.push(`Materialized summary contains undeclared child block ${child.ref}`); + } } + + for (const ref of childBlockRefs) { + const child = blocksByRef[ref]; + if (child === undefined) { + errors.push(`Required child block ${ref} does not exist`); + continue; + } + + const entries = materializedByRef.get(ref) ?? []; + if (entries.length !== 1) { + errors.push(`Materialized child block ${ref} must appear exactly once; found ${entries.length}`); + continue; + } + + const expected = renderMaterializedChildSummary(ref, child.summary); + if (entries[0]?.rendered !== expected) { + errors.push(`Materialized child block ${ref} does not match its stored summary`); + } + } + + return { ok: errors.length === 0, errors }; +} + +export function assertValidCompressionSummaryLineage( + summary: unknown, + childBlockRefs: readonly BlockRef[], + blocksByRef: CompressionState["blocksByRef"], +): asserts summary is CompressionSummary { + const result = validateCompressionSummaryLineage(summary, childBlockRefs, blocksByRef); + if (!result.ok) throw new CompressionSummaryValidationError(result.errors); } export class CompressionSummaryValidationError extends Error { @@ -45,101 +156,125 @@ export class CompressionSummaryValidationError extends Error { } export function renderCompressionSummary(summary: CompressionSummary): string { - return COMPRESSION_SUMMARY_SECTION_NAMES - .map((section) => `## ${section}\n${summary.sections[section]}`) - .join("\n\n"); + return renderCompressionSummarySnapshot(summary); } -function validateChildPlaceholders( - summary: CompressionSummary, +export function materializeCompressionSummaryTemplate( + template: CompressionSummaryTemplate, requiredChildRefs: readonly BlockRef[], -): string[] { - const errors: string[] = []; - const uniqueRequiredRefs = [...new Set(requiredChildRefs)]; - const requiredRefs = new Set(uniqueRequiredRefs); - const declaredRefs = new Set(summary.childBlockRefs); - if (declaredRefs.size !== summary.childBlockRefs.length) { - errors.push("Child Block Refs must not contain duplicates"); - } - - const rendered = renderCompressionSummary(summary); - for (const ref of declaredRefs) { - if (!requiredRefs.has(ref)) { - errors.push(`Child Block Refs must not include unknown ref ${ref}`); - } - } + blocksByRef: CompressionState["blocksByRef"], +): CompressionSummary { + const validation = validateCompressionSummaryTemplate(template, requiredChildRefs); + if (!validation.ok) throw new CompressionSummaryValidationError(validation.errors); - for (const ref of extractBlockPlaceholders(rendered)) { - if (!declaredRefs.has(ref) || !requiredRefs.has(ref)) { - errors.push(`Placeholder (${ref}) is not a required declared child block ref`); + const childSummaries = new Map(requiredChildRefs.map((ref) => { + const child = blocksByRef[ref]; + if (child === undefined) { + throw new CompressionSummaryValidationError([`Required child block ${ref} does not exist`]); } - } + return [ref, renderMaterializedChildSummary(ref, child.summary)] as const; + })); - for (const ref of uniqueRequiredRefs) { - if (!declaredRefs.has(ref)) { - errors.push(`Child Block Refs must include ${ref}`); - } - const count = countPlaceholder(rendered, ref); - if (count !== 1) { - errors.push(`Placeholder (${ref}) must appear exactly once; found ${count}`); - } - } + const sections = Object.fromEntries(COMPRESSION_SUMMARY_SECTION_NAMES.map((section) => [ + section, + template.sections[section].replace(/\((b\d+)\)/g, (_placeholder, rawRef: string) => ( + childSummaries.get(rawRef as BlockRef)! + )), + ])) as CompressionSummarySections; + const summary = { sections }; + assertValidCompressionSummary(summary); + assertValidCompressionSummaryLineage(summary, requiredChildRefs, blocksByRef); + return summary; +} - return errors; +export function renderMaterializedChildSummary( + ref: BlockRef, + summary: CompressionSummary, +): string { + return `${CHILD_SUMMARY_OPEN}${ref}">\n${renderCompressionSummary(summary)}\n${CHILD_SUMMARY_CLOSE}`; } function parseCompressionSummary( value: unknown, ): { ok: true; summary: CompressionSummary } | { ok: false; errors: string[] } { - const errors: string[] = []; - if (!isRecord(value)) { - return { ok: false, errors: ["Summary must be an object"] }; - } + if (!isRecord(value)) return { ok: false, errors: ["Summary must be an object"] }; - const allowedTopLevel = new Set(["sections", "childBlockRefs"]); + const errors: string[] = []; for (const key of Object.keys(value)) { - if (!allowedTopLevel.has(key)) errors.push(`Unknown summary field ${key}`); + if (key !== "sections") errors.push(`Unknown summary field ${key}`); } + errors.push(...validateSections(value.sections)); + if (errors.length > 0) return { ok: false, errors }; + return { ok: true, summary: value as unknown as CompressionSummary }; +} - if (!Array.isArray(value.childBlockRefs)) { - errors.push("childBlockRefs must be an array"); - } else { - for (const ref of value.childBlockRefs) { - if (typeof ref !== "string" || !/^b\d+$/.test(ref)) { - errors.push(`Invalid child block ref ${String(ref)}`); - } +function validateSections(value: unknown): string[] { + if (!isRecord(value)) return ["sections must be an object"]; + const errors: string[] = []; + const required = new Set(COMPRESSION_SUMMARY_SECTION_NAMES); + for (const section of COMPRESSION_SUMMARY_SECTION_NAMES) { + const content = value[section]; + if (typeof content !== "string" || content.length === 0) { + errors.push(`Missing required summary section ${section}`); } } - - if (!isRecord(value.sections)) { - errors.push("sections must be an object"); - } else { - const required = new Set(COMPRESSION_SUMMARY_SECTION_NAMES); - for (const section of COMPRESSION_SUMMARY_SECTION_NAMES) { - const content = value.sections[section]; - if (typeof content !== "string" || content.length === 0) { - errors.push(`Missing required summary section ${section}`); - } - } - for (const key of Object.keys(value.sections)) { - if (!required.has(key)) errors.push(`Unknown summary section ${key}`); - } + for (const key of Object.keys(value)) { + if (!required.has(key)) errors.push(`Unknown summary section ${key}`); } - - if (errors.length > 0) return { ok: false, errors }; - - return { ok: true, summary: value as unknown as CompressionSummary }; + return errors; } function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } -function countPlaceholder(text: string, ref: BlockRef): number { - const escaped = ref.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - return text.match(new RegExp(`\\(${escaped}\\)`, "g"))?.length ?? 0; -} - function extractBlockPlaceholders(text: string): BlockRef[] { return [...text.matchAll(/\((b\d+)\)/g)].map((match) => match[1] as BlockRef); } + +function parseMaterializedChildSummaries( + text: string, +): { readonly children: MaterializedChildSummary[]; readonly errors: string[] } { + const children: MaterializedChildSummary[] = []; + const errors: string[] = []; + const stack: Array<{ readonly ref: BlockRef; readonly startIndex: number }> = []; + let recognizedOpenTags = 0; + let recognizedCloseTags = 0; + + for (const match of text.matchAll(CHILD_SUMMARY_TAG_PATTERN)) { + const index = match.index; + if (index === undefined) continue; + const ref = match[1] as BlockRef | undefined; + if (ref !== undefined) { + recognizedOpenTags += 1; + stack.push({ ref, startIndex: index }); + continue; + } + + recognizedCloseTags += 1; + const opening = stack.pop(); + if (opening === undefined) { + errors.push("Materialized summary contains an unmatched child closing boundary"); + continue; + } + if (stack.length === 0) { + children.push({ + ref: opening.ref, + rendered: text.slice(opening.startIndex, index + match[0].length), + }); + } + } + + if (stack.length > 0) { + errors.push("Materialized summary contains an unclosed child boundary"); + } + const reservedOpenTags = [...text.matchAll(CHILD_SUMMARY_RESERVED_OPEN_PATTERN)].length; + const reservedCloseTags = [...text.matchAll(CHILD_SUMMARY_RESERVED_CLOSE_PATTERN)].length; + if (reservedOpenTags !== recognizedOpenTags) { + errors.push("Materialized summary contains a malformed child opening boundary"); + } + if (reservedCloseTags !== recognizedCloseTags) { + errors.push("Materialized summary contains a malformed child closing boundary"); + } + return { children, errors }; +} diff --git a/packages/agent-core/src/compression/types.ts b/packages/agent-core/src/compression/types.ts index 23268d59..821011e1 100644 --- a/packages/agent-core/src/compression/types.ts +++ b/packages/agent-core/src/compression/types.ts @@ -50,11 +50,14 @@ export interface ProtectedRef { readonly partId?: string; } -export type CompressionSummarySections = Record; +export type CompressionSummarySections = Readonly>; + +export interface CompressionSummaryTemplate { + readonly sections: CompressionSummarySections; +} export interface CompressionSummary { readonly sections: CompressionSummarySections; - readonly childBlockRefs: BlockRef[]; } export interface CompressionBlock { @@ -108,7 +111,7 @@ export interface CompressionBlockDraft { readonly range: CompressionRange; readonly summary: CompressionSummary; readonly protectedRefs?: ProtectedRef[]; - readonly childBlockRefs?: BlockRef[]; + readonly childBlockRefs: BlockRef[]; readonly tokenEstimate?: CompressionTokenEstimate; readonly createdAt: number; } diff --git a/packages/agent-core/src/compression/validation.ts b/packages/agent-core/src/compression/validation.ts index 597459f4..9befa6d4 100644 --- a/packages/agent-core/src/compression/validation.ts +++ b/packages/agent-core/src/compression/validation.ts @@ -1,11 +1,10 @@ -import { COMPRESSION_SUMMARY_SECTION_NAMES } from "./constants"; import { buildMessageRefMap } from "./refs"; -import { validateCompressionSummary } from "./summary"; +import { validateCompressionSummaryTemplate } from "./summary"; import type { BlockRef, CompressionRange, CompressionState, - CompressionSummary, + CompressionSummaryTemplate, MessageRef, } from "./types"; import type { StoredMessage } from "../store/types"; @@ -90,17 +89,13 @@ export function resolveCompressionRange( export function validateDynamicCompressionSummary( summary: unknown, requiredChildRefs: readonly BlockRef[], -): { ok: true; summary: CompressionSummary } | { ok: false; issues: CompressionValidationIssue[] } { - const result = validateCompressionSummary(summary, requiredChildRefs); +): { ok: true; summary: CompressionSummaryTemplate } | { ok: false; issues: CompressionValidationIssue[] } { + const result = validateCompressionSummaryTemplate(summary, requiredChildRefs); if (!result.ok) { return { ok: false, issues: result.errors.map((message) => ({ code: "invalid_summary", message })) }; } - return { ok: true, summary: summary as CompressionSummary }; -} - -export function compressionSummaryZodShape(): Record { - return Object.fromEntries(COMPRESSION_SUMMARY_SECTION_NAMES.map((section) => [section, section])); + return { ok: true, summary: summary as CompressionSummaryTemplate }; } interface BoundaryResolution { diff --git a/packages/agent-core/src/store/helpers.test.ts b/packages/agent-core/src/store/helpers.test.ts index 5c0cbc3c..01c3f0a8 100644 --- a/packages/agent-core/src/store/helpers.test.ts +++ b/packages/agent-core/src/store/helpers.test.ts @@ -19,7 +19,11 @@ import type { SystemNoticePart, TextPart, } from "./types"; -import { createEmptyCompressionState, type CompressionState } from "../compression"; +import { + createEmptyCompressionState, + materializeCompressionSummaryTemplate, + type CompressionState, +} from "../compression"; import type { AssistantOutputPart, DelegationRequest } from "@archcode/protocol"; const TMP_DIR = join(import.meta.dir, "__test_tmp__", "helpers", crypto.randomUUID()); @@ -452,7 +456,6 @@ function appendCanonicalUserMessage(store: { getState(): SessionStoreState }, co function compressionSummary(childBlockRefs: CompressionState["activeBlockRefs"] = []) { return { - childBlockRefs, sections: { "Current Objective": "Keep the implementation moving", "User Constraints": "Stay inside store scope", @@ -1846,6 +1849,53 @@ describe("compaction and meta transcript round-trip", () => { expect(loadedCompression.protectedRefs[0]?.kind).toBe("latest_tail"); }); + test("session files reject non-materialized summaries and duplicate child lineage", () => { + const compression = richCompressionState(); + const active = compression.blocksByRef.b1!; + const state = persistedState(uniqueSessionId("invalid-compression-state")); + const file = sessionFileInternals.toSessionFile({ ...state, compression }); + const withActiveBlock = (block: typeof active) => ({ + ...file, + compression: { + ...file.compression, + blocksByRef: { ...file.compression.blocksByRef, b1: block }, + }, + }); + + expect(SessionFileSchema.safeParse(withActiveBlock({ + ...active, + summary: { + sections: { ...active.summary.sections, "Current Objective": "Unresolved (b2)" }, + }, + })).success).toBe(false); + expect(SessionFileSchema.safeParse(withActiveBlock({ + ...active, + childBlockRefs: ["b2", "b2"], + })).success).toBe(false); + + expect(SessionFileSchema.safeParse(withActiveBlock({ + ...active, + childBlockRefs: ["b99"], + })).success).toBe(false); + + expect(SessionFileSchema.safeParse(withActiveBlock({ + ...active, + childBlockRefs: ["b2"], + })).success).toBe(false); + + const materializedParent = materializeCompressionSummaryTemplate({ + sections: { + ...active.summary.sections, + "Child Block Refs": "(b2)", + }, + }, ["b2"], compression.blocksByRef); + expect(SessionFileSchema.safeParse(withActiveBlock({ + ...active, + summary: materializedParent, + childBlockRefs: ["b2"], + })).success).toBe(true); + }); + test("session files without compression are rejected", async () => { const sessionId = uniqueSessionId("missing-compression"); const state = persistedState(sessionId); diff --git a/packages/agent-core/src/store/helpers.ts b/packages/agent-core/src/store/helpers.ts index b481c3b0..e0e01410 100644 --- a/packages/agent-core/src/store/helpers.ts +++ b/packages/agent-core/src/store/helpers.ts @@ -23,6 +23,8 @@ import { COMPRESSION_TRIGGERS, PROTECTED_CONTENT_KINDS, createEmptyCompressionState, + validateCompressionSummaryLineage, + validateCompressionSummary, } from "../compression"; import { AGENT_NAMES, type AgentName } from "../agents/names"; import { resolveSessionProfile } from "../agents/session-profile"; @@ -835,9 +837,16 @@ const CompressionSummarySchema = z.strictObject({ sections: z.strictObject(Object.fromEntries( COMPRESSION_SUMMARY_SECTION_NAMES.map((section) => [section, z.string()]), ) as Record<(typeof COMPRESSION_SUMMARY_SECTION_NAMES)[number], z.ZodString>), - childBlockRefs: z.array(BlockRefSchema), +}).superRefine((summary, ctx) => { + const validation = validateCompressionSummary(summary); + for (const message of validation.errors) ctx.addIssue({ code: "custom", message }); }); +const CompressionChildBlockRefsSchema = z.array(BlockRefSchema).refine( + (refs) => new Set(refs).size === refs.length, + "Compression child block refs must be unique", +); + const CompressionBlockSchema = z.strictObject({ id: z.string(), ref: BlockRefSchema, @@ -847,7 +856,7 @@ const CompressionBlockSchema = z.strictObject({ range: CompressionRangeSchema, summary: CompressionSummarySchema, protectedRefs: z.array(ProtectedRefSchema), - childBlockRefs: z.array(BlockRefSchema), + childBlockRefs: CompressionChildBlockRefsSchema, tokenEstimate: CompressionTokenEstimateSchema.optional(), createdAt: z.number(), updatedAt: z.number(), @@ -873,6 +882,21 @@ const CompressionStateSchema = z.strictObject({ protectedRefs: z.array(ProtectedRefSchema), failures: z.array(CompressionFailureSchema), updatedAt: z.number().optional(), +}).superRefine((state, ctx) => { + for (const [ref, block] of Object.entries(state.blocksByRef)) { + const validation = validateCompressionSummaryLineage( + block.summary, + block.childBlockRefs, + state.blocksByRef, + ); + for (const message of validation.errors) { + ctx.addIssue({ + code: "custom", + path: ["blocksByRef", ref, "summary"], + message, + }); + } + } }); const PromptTraceSnapshotSchema = z.strictObject({ diff --git a/packages/agent-core/src/store/projection.test.ts b/packages/agent-core/src/store/projection.test.ts index 236e986c..3cfaca09 100644 --- a/packages/agent-core/src/store/projection.test.ts +++ b/packages/agent-core/src/store/projection.test.ts @@ -270,7 +270,6 @@ function storedMessage( function compressionSummary(childBlockRefs: CompressionState["activeBlockRefs"] = []) { return { - childBlockRefs, sections: { "Current Objective": "Compressed old implementation discussion", "User Constraints": "Do not mutate canonical message text", @@ -280,7 +279,7 @@ function compressionSummary(childBlockRefs: CompressionState["activeBlockRefs"] "Tool Results": "No tool result required", "Errors/Unknown Results": "None", "Protected Refs": "m0003 remains visible", - "Child Block Refs": childBlockRefs.length === 0 ? "None" : childBlockRefs.map((ref) => `(${ref})`).join(" "), + "Child Block Refs": childBlockRefs.length === 0 ? "None" : childBlockRefs.map((ref) => `Materialized ${ref}`).join(" "), "Resume Instructions": "Resume after the tail question", }, }; @@ -1222,17 +1221,25 @@ describe("toModelMessagesFromStoredMessages compression projection", () => { expect(serialized).not.toContain("m0001"); }); - test("child block refs are present exactly once through the validated summary", () => { + test("model projection carries materialized child content without unresolved placeholders", () => { const messages: StoredMessage[] = [ { ...storedMessage("user", [textPart("old")]), id: "msg-old-user" }, { ...storedMessage("assistant", [outputPart("older")]), id: "msg-old-assistant" }, ]; - const compression = compressionStateForProjection({ childBlockRefs: ["b2"], summary: compressionSummary(["b2"]) }); + const materialized = compressionSummary(["b2"]); + const compression = compressionStateForProjection({ + childBlockRefs: ["b2"], + summary: { + ...materialized, + sections: { ...materialized.sections, "Child Block Refs": "MATERIALIZED_CHILD_SENTINEL" }, + }, + }); const projected = toModelMessagesFromStoredMessages(messages, { compression }); const serialized = JSON.stringify(projected); - expect(serialized.match(/\(b2\)/g)).toHaveLength(1); + expect(serialized).toContain("MATERIALIZED_CHILD_SENTINEL"); + expect(serialized).not.toContain("(b2)"); }); test("fresh compression state injects projection refs for uncompressed messages", () => { diff --git a/packages/agent-core/src/store/reduce.ts b/packages/agent-core/src/store/reduce.ts index 42495a7c..7bb02ca6 100644 --- a/packages/agent-core/src/store/reduce.ts +++ b/packages/agent-core/src/store/reduce.ts @@ -194,7 +194,7 @@ function compressionBlockFromSnapshot(block: CompressionBlockSnapshot): Compress startIndex: block.range.startIndex, endIndex: block.range.endIndex, }, - summary: summaryFromSnapshot(block.summary, block.childBlockRefs as BlockRef[]), + summary: summaryFromSnapshot(block.summary), protectedRefs, childBlockRefs: block.childBlockRefs as BlockRef[], ...(block.tokenEstimate === undefined ? {} : { tokenEstimate: block.tokenEstimate }), @@ -207,13 +207,9 @@ function compressionBlockFromSnapshot(block: CompressionBlockSnapshot): Compress function summaryFromSnapshot( summary: CompressionSummarySnapshot, - childBlockRefs: BlockRef[], ): CompressionSummary { - const result = { - sections: { ...summary.sections }, - childBlockRefs, - }; - assertValidCompressionSummary(result, childBlockRefs); + const result = { sections: { ...summary.sections } }; + assertValidCompressionSummary(result); return result; } diff --git a/packages/agent-core/src/store/session-read-projection.test.ts b/packages/agent-core/src/store/session-read-projection.test.ts index c9d154a8..f6c5ba39 100644 --- a/packages/agent-core/src/store/session-read-projection.test.ts +++ b/packages/agent-core/src/store/session-read-projection.test.ts @@ -39,7 +39,6 @@ function block(input: { "Child Block Refs": "None", "Resume Instructions": "Continue", }, - childBlockRefs: [], }, protectedRefs: [], childBlockRefs: [], diff --git a/packages/agent-core/src/tools/builtins/compress.test.ts b/packages/agent-core/src/tools/builtins/compress.test.ts index 498b4d22..49d66fc4 100644 --- a/packages/agent-core/src/tools/builtins/compress.test.ts +++ b/packages/agent-core/src/tools/builtins/compress.test.ts @@ -101,7 +101,6 @@ function message( function summary(childBlockRefs: string[] = []) { return { - childBlockRefs, sections: { "Current Objective": childBlockRefs.length > 0 ? `Continue after (${childBlockRefs[0]})` : "Continue task", "User Constraints": "Preserve constraints", diff --git a/packages/agent-core/src/tools/builtins/compress.ts b/packages/agent-core/src/tools/builtins/compress.ts index befd040a..9bfa92ff 100644 --- a/packages/agent-core/src/tools/builtins/compress.ts +++ b/packages/agent-core/src/tools/builtins/compress.ts @@ -37,10 +37,8 @@ export const CompressInputSchema = z.strictObject({ endId: z.string().describe("Projection end ref, e.g. m0004 or a known block ref like b1."), summary: z.strictObject({ sections: CompressionSummarySectionsSchema - .describe("All ten required semantic sections that preserve the compressed range's continuation context."), - childBlockRefs: z.array(z.string().regex(/^b\d+$/)) - .describe("Required nested refs in the range: list each once, no unknown refs, and mention each exactly once as (bN) across the sections."), - }).describe("Strict structured compression summary with all required sections."), + .describe("All ten required semantic sections that preserve the compressed range's continuation context. If the range contains active compression blocks, place each required (bN) placeholder exactly once where that block's complete stored summary should be inserted. The runtime derives the required child refs."), + }).describe("Strict structured compression summary template. A (bN) placeholder represents the complete previously compressed conversation segment and surrounding text must remain coherent after expansion."), }); export type CompressInput = z.infer; @@ -48,7 +46,7 @@ export type CompressInput = z.infer; export const compressTool = defineTool({ name: TOOL_COMPRESS, description: - "Compresses a visible transcript range by projection refs. Validates the model-authored structured summary and commits compression metadata without changing canonical transcript text.", + "Compresses a visible transcript range by projection refs. Previously compressed blocks inside the range are materialized into the new summary before commit, while canonical transcript text remains unchanged.", inputSchema: CompressInputSchema, traits: COMPRESS_TOOL_TRAITS, outputPolicy: { kind: "inline", previewDirection: "head" }, diff --git a/packages/protocol/src/compression.ts b/packages/protocol/src/compression.ts index 96d577dc..8617b18e 100644 --- a/packages/protocol/src/compression.ts +++ b/packages/protocol/src/compression.ts @@ -25,3 +25,12 @@ export function renderCompressionSummarySnapshot( .map((section) => `## ${section}\n${summary.sections[section]}`) .join("\n\n"); } + +export function isMaterializedCompressionSummarySnapshot( + summary: CompressionSummarySnapshot, +): boolean { + return COMPRESSION_SUMMARY_SECTION_NAMES.every((section) => { + const content = summary.sections[section]; + return content.length > 0 && !/\(b\d+\)/.test(content); + }); +} diff --git a/packages/protocol/src/guards.test.ts b/packages/protocol/src/guards.test.ts index 9305e58d..f72e91ec 100644 --- a/packages/protocol/src/guards.test.ts +++ b/packages/protocol/src/guards.test.ts @@ -547,6 +547,18 @@ describe("protocol event guards", () => { link: { ...validPayloads[23]!.link, durationMs: 1 }, })).toBe(false); expect(isSessionEventPayload({ type: "compression.block_committed", block: { ...compressionBlock, range: { ...compressionBlock.range, endIndex: "0" } } })).toBe(false); + expect(isSessionEventPayload({ + type: "compression.block_committed", + block: { ...compressionBlock, summary: compressionSummary("Unresolved (b1)") }, + })).toBe(false); + expect(isSessionEventPayload({ + type: "compression.block_committed", + block: { ...compressionBlock, summary: compressionSummary("") }, + })).toBe(false); + expect(isSessionEventPayload({ + type: "compression.block_committed", + block: { ...compressionBlock, childBlockRefs: ["b1", "b1"] }, + })).toBe(false); expect(isSessionEventPayload({ type: "compression.block_committed", block: { diff --git a/packages/protocol/src/guards.ts b/packages/protocol/src/guards.ts index 97ec9d7b..8c7e56a1 100644 --- a/packages/protocol/src/guards.ts +++ b/packages/protocol/src/guards.ts @@ -8,7 +8,11 @@ import type { ToolChildSessionLinkStatus, } from "./types"; import type { GlobalSSEUpdateChangedEvent, UpdateStatus } from "./update"; -import { COMPRESSION_SUMMARY_SECTION_NAMES } from "./compression"; +import { + COMPRESSION_SUMMARY_SECTION_NAMES, + isMaterializedCompressionSummarySnapshot, + type CompressionSummarySnapshot, +} from "./compression"; import { SESSION_GOAL_BLOCKED_REASON_MAX_LENGTH, SESSION_GOAL_OBJECTIVE_MAX_LENGTH, @@ -1031,7 +1035,8 @@ function isCompressionSummary(value: unknown): boolean { const sections = record(summary.sections); return sections !== undefined && exact(sections, COMPRESSION_SUMMARY_SECTION_NAMES) - && COMPRESSION_SUMMARY_SECTION_NAMES.every((section) => isString(sections[section])); + && COMPRESSION_SUMMARY_SECTION_NAMES.every((section) => isString(sections[section])) + && isMaterializedCompressionSummarySnapshot(summary as unknown as CompressionSummarySnapshot); })(); } @@ -1049,6 +1054,7 @@ function isCompressionBlock(value: unknown): boolean { && oneOf(block.trigger, ["model_tool_call", "soft_nudge_response", "strong_nudge_response"]) && isCompressionRange(block.range) && isCompressionSummary(block.summary) && arrayOf(block.childBlockRefs, isBlockRef) + && new Set(block.childBlockRefs as string[]).size === (block.childBlockRefs as string[]).length && arrayOf(block.protectedRefs, (item) => isMessageRef(item) || isBlockRef(item)) && (block.tokenEstimate === undefined || isCompressionTokenEstimate(block.tokenEstimate)) && isFiniteNumber(block.createdAt) && isFiniteNumber(block.updatedAt)