Skip to content

Commit c60e032

Browse files
committed
Align evidence capture with the completeness gate
Compact fail-closed because the certificate hashed live history while admission stored a different representation. Record the same bytes the gate looks up; do not weaken the certificate.
1 parent 9d5a46f commit c60e032

7 files changed

Lines changed: 451 additions & 63 deletions

File tree

‎src/context-compactor.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -866,6 +866,7 @@ describe("createPruningCompactor — consolidated handoff (CL-7521)", () => {
866866
output2.some(
867867
(t) =>
868868
t.role === "user" &&
869+
!firstText(t).startsWith(COMPACTED_PREFIX) &&
869870
t.content.some((b) => b.type === "text" && b.text === goal),
870871
),
871872
).toBe(true);

‎src/plugins/result-truncation-plugin.test.ts‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { toolOutputAbsolutePath } from "./tool-result-materialize.js";
1919
import { CREDENTIAL_REDACTION } from "./tool-result-secret-scrub.js";
2020
import { toolResultSecretScrubPlugin } from "./tool-result-secret-scrub-plugin.js";
2121
import type { ToolPlugin } from "@intx/tools-posix";
22+
import type { CompactionArchive } from "../session/compaction-archive.js";
2223

2324
/** In-memory stand-in for ContextStore's writeBlob/readBlob pair, for tests. */
2425
function fakeBlobStore() {
@@ -611,6 +612,97 @@ describe("wrapAgentToolsWithResultTruncation", () => {
611612
});
612613
});
613614

615+
describe("archive then truncate", () => {
616+
test("archives oversized results before truncating them", async () => {
617+
const payloads: unknown[] = [];
618+
const blobs: unknown[] = [];
619+
const archive = {
620+
recordAuthorizedPayload: async (input: unknown) => {
621+
payloads.push(input);
622+
return {
623+
occurrenceId: "occ-1",
624+
sessionId: "s",
625+
kind: "tool_result",
626+
contentHash: "h",
627+
blobKey: "b",
628+
recordedAt: 1,
629+
};
630+
},
631+
recordExistingBlobReference: async (input: unknown) => {
632+
blobs.push(input);
633+
return {
634+
occurrenceId: "occ-blob",
635+
sessionId: "s",
636+
kind: "overflow_blob",
637+
contentHash: "h",
638+
blobKey: "b",
639+
recordedAt: 1,
640+
};
641+
},
642+
} as unknown as CompactionArchive;
643+
const oversized = "x".repeat(MAX_RESULT_CHARS + 50);
644+
const plugin = resultTruncationPlugin({ getEvidenceArchive: () => archive });
645+
if (plugin.middleware === undefined) throw new Error("expected middleware");
646+
const middleware = plugin.middleware(async (call) => ({
647+
callId: call.id,
648+
content: oversized,
649+
}));
650+
const result = await middleware(
651+
{ id: "call-ld", name: "list_dir", arguments: { path: "." } },
652+
new AbortController().signal,
653+
);
654+
expect(String(result.content)).not.toBe(oversized);
655+
expect(String(result.content).length).toBeLessThanOrEqual(MAX_RESULT_CHARS);
656+
expect(payloads).toEqual([
657+
{
658+
kind: "tool_result",
659+
payload: oversized,
660+
callId: "call-ld",
661+
provenance: "posix:post-policy-pre-truncation",
662+
},
663+
]);
664+
expect(blobs).toHaveLength(1);
665+
});
666+
667+
test("archives error results without truncating them", async () => {
668+
const payloads: unknown[] = [];
669+
const archive = {
670+
recordAuthorizedPayload: async (input: unknown) => {
671+
payloads.push(input);
672+
return {
673+
occurrenceId: "occ-err",
674+
sessionId: "s",
675+
kind: "tool_result",
676+
contentHash: "h",
677+
blobKey: "b",
678+
recordedAt: 1,
679+
};
680+
},
681+
} as unknown as CompactionArchive;
682+
const plugin = resultTruncationPlugin({ getEvidenceArchive: () => archive });
683+
if (plugin.middleware === undefined) throw new Error("expected middleware");
684+
const middleware = plugin.middleware(async (call) => ({
685+
callId: call.id,
686+
content: { error: "conflict" },
687+
isError: true,
688+
}));
689+
const result = await middleware(
690+
{ id: "call-wf", name: "write_file", arguments: { path: "a.ts" } },
691+
new AbortController().signal,
692+
);
693+
expect(result.content).toEqual({ error: "conflict" });
694+
expect(result.isError).toBe(true);
695+
expect(payloads).toEqual([
696+
{
697+
kind: "tool_result",
698+
payload: { error: "conflict" },
699+
callId: "call-wf",
700+
provenance: "posix:error",
701+
},
702+
]);
703+
});
704+
});
705+
614706
describe("scrub-before-spill", () => {
615707
test("secret scrub runs on the full content before truncation spills", async () => {
616708
const store = fakeBlobStore();

‎src/session/compaction-archive.test.ts‎

Lines changed: 140 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import fs from "node:fs";
44
import os from "node:os";
55
import path from "node:path";
66
import type { InboundMessage } from "@intx/types/runtime";
7+
import { base64Encode } from "@intx/types";
8+
import { createInboundTurn } from "@intx/inference";
79
import { CREDENTIAL_REDACTION } from "../plugins/tool-result-secret-scrub.js";
810
import {
911
admitPrimaryInboundMessage,
@@ -180,7 +182,11 @@ describe("primary message admission", () => {
180182
expect(occurrences).toHaveLength(1);
181183
expect(occurrences[0]!.kind).toBe("user_message");
182184
const archived = await archive.readAuthorizedPayload(occurrences[0]!.occurrenceId);
183-
expect(archived).toBe(admitted);
185+
const history = createInboundTurn(delivered[0]!);
186+
const historyText = history?.content.find((block) => block.type === "text");
187+
expect(historyText?.type === "text" ? historyText.text : undefined).toBe(archived);
188+
expect(archived.startsWith("[From: user@local]\n\n")).toBe(true);
189+
expect(archived.endsWith(admitted)).toBe(true);
184190
});
185191

186192
test("send(string) admits and archives like InboundMessage", async () => {
@@ -217,7 +223,9 @@ describe("primary message admission", () => {
217223
expect(sent[0]).not.toContain(secret);
218224
const occurrences = await archive.listOccurrences();
219225
expect(occurrences).toHaveLength(1);
220-
expect(await archive.readAuthorizedPayload(occurrences[0]!.occurrenceId)).toBe(sent[0]!);
226+
expect(await archive.readAuthorizedPayload(occurrences[0]!.occurrenceId)).toBe(
227+
`[From: user@local]\n\n${sent[0]!}`,
228+
);
221229
});
222230
});
223231

@@ -712,7 +720,8 @@ describe("wrapCompactorWithCompletenessGate", () => {
712720
const { wrapCompactorWithCompletenessGate } = await import("./compaction-archive.js");
713721
const { archive } = memoryArchive();
714722
const wrapped = wrapCompactorWithCompletenessGate(truncating("pruning-compactor"), archive);
715-
const png = "iVBORw0KGgo=";
723+
const pngBytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10, 1, 2, 3]);
724+
const png = base64Encode(pngBytes);
716725
const turns: import("@intx/types/runtime").ConversationTurn[] = [
717726
{
718727
role: "user",
@@ -735,16 +744,140 @@ describe("wrapCompactorWithCompletenessGate", () => {
735744
expect(blocked.output).toBe(turns);
736745
expect(blocked.record.reason).toBe("incomplete-evidence-archive");
737746

747+
const agent = {
748+
deliver(_message: InboundMessage) {
749+
/* admission archives; history is the turns above */
750+
},
751+
async send(content: string | InboundMessage) {
752+
return { ok: true as const, content };
753+
},
754+
};
755+
const admitted = createPrimaryDeliveryAdmission(agent, archive);
756+
await admitted.send(
757+
inbound({
758+
attachments: [{ name: "shot.png", contentType: "image/png", data: pngBytes }],
759+
}),
760+
);
761+
const allowed = await wrapped.apply(turns, ctx);
762+
expect(allowed.output).toHaveLength(1);
763+
expect(allowed.record.reason).toBe("compact");
764+
});
765+
766+
test("dropped list_dir and write_file results fail-close until archived", async () => {
767+
const { wrapCompactorWithCompletenessGate } = await import("./compaction-archive.js");
768+
const { archive } = memoryArchive();
769+
const wrapped = wrapCompactorWithCompletenessGate(truncating("pruning-compactor"), archive);
770+
const turns: import("@intx/types/runtime").ConversationTurn[] = [
771+
{
772+
role: "user",
773+
content: [{ type: "text", text: "do work" }],
774+
timestamp: 1,
775+
},
776+
{
777+
role: "assistant",
778+
content: [{ type: "tool_call", id: "ld", name: "list_dir", arguments: { path: "." } }],
779+
timestamp: 2,
780+
},
781+
{
782+
role: "user",
783+
content: [
784+
{ type: "tool_result", callId: "ld", content: [{ type: "text", text: "src/\n" }] },
785+
],
786+
timestamp: 3,
787+
},
788+
{
789+
role: "assistant",
790+
content: [{ type: "tool_call", id: "wf", name: "write_file", arguments: { path: "a.ts" } }],
791+
timestamp: 4,
792+
},
793+
{
794+
role: "user",
795+
content: [
796+
{ type: "tool_result", callId: "wf", content: [{ type: "text", text: "wrote" }] },
797+
],
798+
timestamp: 5,
799+
},
800+
{
801+
role: "user",
802+
content: [{ type: "text", text: "keep" }],
803+
timestamp: 6,
804+
},
805+
];
806+
807+
const blocked = await wrapped.apply(turns, ctx);
808+
expect(blocked.output).toBe(turns);
809+
expect(blocked.record.reason).toBe("incomplete-evidence-archive");
810+
738811
await archive.recordAuthorizedPayload({
739-
kind: "attachment",
740-
payload: png,
812+
kind: "user_message",
813+
payload: "do work",
741814
});
742815
await archive.recordAuthorizedPayload({
743-
kind: "user_message",
744-
payload: "keep",
816+
kind: "tool_args",
817+
payload: { name: "list_dir", arguments: { path: "." } },
818+
callId: "ld",
819+
});
820+
await archive.recordAuthorizedPayload({
821+
kind: "tool_result",
822+
payload: "src/\n",
823+
callId: "ld",
824+
});
825+
await archive.recordAuthorizedPayload({
826+
kind: "tool_args",
827+
payload: { name: "write_file", arguments: { path: "a.ts" } },
828+
callId: "wf",
829+
});
830+
await archive.recordAuthorizedPayload({
831+
kind: "tool_result",
832+
payload: "wrote",
833+
callId: "wf",
745834
});
835+
746836
const allowed = await wrapped.apply(turns, ctx);
747837
expect(allowed.output).toHaveLength(1);
748838
expect(allowed.record.reason).toBe("compact");
749839
});
840+
841+
test("cloned keep-window turns are not treated as dropped", async () => {
842+
const { wrapCompactorWithCompletenessGate } = await import("./compaction-archive.js");
843+
const { archive } = memoryArchive();
844+
await archive.recordAuthorizedPayload({
845+
kind: "user_message",
846+
payload: "dropped-prefix",
847+
});
848+
const inner: import("@intx/types/runtime").Compactor = {
849+
name: "pruning-compactor",
850+
version: "1",
851+
async apply(turns) {
852+
const kept = turns.slice(-1).map((turn) => ({ ...turn, content: [...turn.content] }));
853+
return {
854+
output: kept,
855+
record: {
856+
strategy: "pruning-compactor",
857+
version: "1",
858+
parameters: {},
859+
reason: "compact",
860+
decisions: { dropped: turns.length - 1 },
861+
},
862+
};
863+
},
864+
};
865+
const wrapped = wrapCompactorWithCompletenessGate(inner, archive);
866+
const turns: import("@intx/types/runtime").ConversationTurn[] = [
867+
{
868+
role: "user",
869+
content: [{ type: "text", text: "dropped-prefix" }],
870+
timestamp: 1,
871+
},
872+
{
873+
role: "user",
874+
content: [{ type: "text", text: "keep-window" }],
875+
timestamp: 2,
876+
},
877+
];
878+
const result = await wrapped.apply(turns, ctx);
879+
expect(result.record.reason).toBe("compact");
880+
expect(result.output).toHaveLength(1);
881+
expect(result.output[0]).not.toBe(turns[1]);
882+
});
750883
});

0 commit comments

Comments
 (0)