Skip to content

Commit cbba84f

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 94e6423 commit cbba84f

8 files changed

Lines changed: 453 additions & 70 deletions

src/context-compactor.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -652,7 +652,10 @@ describe("createPruningCompactor — consolidated handoff (CL-7521)", () => {
652652
expect(compactedTurns(output2)).toHaveLength(1);
653653
expect(
654654
output2.some(
655-
(t) => t.role === "user" && t.content.some((b) => b.type === "text" && b.text === goal),
655+
(t) =>
656+
t.role === "user" &&
657+
!firstText(t).startsWith(COMPACTED_PREFIX) &&
658+
t.content.some((b) => b.type === "text" && b.text === goal),
656659
),
657660
).toBe(true);
658661
expect(hasConsecutiveSameRole(output2)).toBe(false);

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

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { toolOutputAbsolutePath } from "./tool-result-materialize.js";
1111
import { CREDENTIAL_REDACTION } from "./tool-result-secret-scrub.js";
1212
import { toolResultSecretScrubPlugin } from "./tool-result-secret-scrub-plugin.js";
1313
import type { ToolPlugin } from "@intx/tools-posix";
14+
import type { CompactionArchive } from "../session/compaction-archive.js";
1415

1516
/** In-memory stand-in for ContextStore's writeBlob/readBlob pair, for tests. */
1617
function fakeBlobStore() {
@@ -335,6 +336,92 @@ describe("resultTruncationPlugin", () => {
335336
expect(result.content).toEqual(record);
336337
expect(store.blobs.size).toBe(0);
337338
});
339+
340+
test("archives non-truncatable tool results without truncating them", async () => {
341+
const payloads: unknown[] = [];
342+
const blobs: unknown[] = [];
343+
const archive = {
344+
recordAuthorizedPayload: async (input: unknown) => {
345+
payloads.push(input);
346+
return {
347+
occurrenceId: "occ-1",
348+
sessionId: "s",
349+
kind: "tool_result",
350+
contentHash: "h",
351+
blobKey: "b",
352+
recordedAt: 1,
353+
};
354+
},
355+
recordExistingBlobReference: async (input: unknown) => {
356+
blobs.push(input);
357+
return {
358+
occurrenceId: "occ-blob",
359+
sessionId: "s",
360+
kind: "overflow_blob",
361+
contentHash: "h",
362+
blobKey: "b",
363+
recordedAt: 1,
364+
};
365+
},
366+
} as unknown as CompactionArchive;
367+
const oversized = "x".repeat(MAX_RESULT_CHARS + 50);
368+
const plugin = resultTruncationPlugin({ getEvidenceArchive: () => archive });
369+
if (plugin.middleware === undefined) throw new Error("expected middleware");
370+
const middleware = plugin.middleware(async (call) => ({
371+
callId: call.id,
372+
content: oversized,
373+
}));
374+
const result = await middleware(
375+
{ id: "call-ld", name: "list_dir", arguments: { path: "." } },
376+
new AbortController().signal,
377+
);
378+
expect(result.content).toBe(oversized);
379+
expect(payloads).toEqual([
380+
{
381+
kind: "tool_result",
382+
payload: oversized,
383+
callId: "call-ld",
384+
provenance: "posix:post-policy-pre-truncation",
385+
},
386+
]);
387+
expect(blobs).toEqual([]);
388+
});
389+
390+
test("archives error results from non-truncatable tools", async () => {
391+
const payloads: unknown[] = [];
392+
const archive = {
393+
recordAuthorizedPayload: async (input: unknown) => {
394+
payloads.push(input);
395+
return {
396+
occurrenceId: "occ-err",
397+
sessionId: "s",
398+
kind: "tool_result",
399+
contentHash: "h",
400+
blobKey: "b",
401+
recordedAt: 1,
402+
};
403+
},
404+
} as unknown as CompactionArchive;
405+
const plugin = resultTruncationPlugin({ getEvidenceArchive: () => archive });
406+
if (plugin.middleware === undefined) throw new Error("expected middleware");
407+
const middleware = plugin.middleware(async (call) => ({
408+
callId: call.id,
409+
content: { error: "conflict" },
410+
isError: true,
411+
}));
412+
await middleware(
413+
{ id: "call-wf", name: "write_file", arguments: { path: "a.ts" } },
414+
new AbortController().signal,
415+
);
416+
expect(payloads).toEqual([
417+
{
418+
kind: "tool_result",
419+
payload: { error: "conflict" },
420+
callId: "call-wf",
421+
provenance: "posix:error",
422+
},
423+
]);
424+
});
338425
});
339426

340427
describe("scrub-before-spill", () => {

src/plugins/result-truncation-plugin.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -233,12 +233,10 @@ export function resultTruncationPlugin(options: ResultTruncationPluginOptions =
233233
const result = await next(call, signal);
234234
const archive = getEvidenceArchive?.();
235235

236-
if (TRUNCATABLE_TOOLS.has(call.name)) {
237-
if (typeof result.content === "string") {
238-
await archiveAuthorizedResult(archive, call.id, result.content, result.isError);
239-
} else if (result.content !== null && typeof result.content === "object") {
240-
await archiveAuthorizedResult(archive, call.id, result.content, result.isError);
241-
}
236+
if (typeof result.content === "string") {
237+
await archiveAuthorizedResult(archive, call.id, result.content, result.isError);
238+
} else if (result.content !== null && typeof result.content === "object") {
239+
await archiveAuthorizedResult(archive, call.id, result.content, result.isError);
242240
}
243241

244242
if (!TRUNCATABLE_TOOLS.has(call.name) || result.isError) return result;

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)