Skip to content

Commit 776431f

Browse files
committed
Add primary archive search and read tools
1 parent bfeda6a commit 776431f

13 files changed

Lines changed: 549 additions & 0 deletions

src/agent/archive-tools.test.ts

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import { mkdtempSync } from "node:fs";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import { describe, expect, test } from "bun:test";
5+
6+
import type { AgentTool } from "@intx/agent";
7+
import { CATALOG_TOOL_NAMES, CORE_TOOL_NAMES } from "./tool-search.js";
8+
import {
9+
createReadArchiveTool,
10+
createSearchArchiveTool,
11+
readArchiveDefinition,
12+
searchArchiveDefinition,
13+
} from "./archive-tools.js";
14+
import { formatArchiveRef } from "../session/archive-uri.js";
15+
import { createCompactionArchive, type CompactionArchive } from "../session/compaction-archive.js";
16+
17+
function call(tool: AgentTool, args: Record<string, unknown>): Promise<string> {
18+
if (tool.kind !== "string") throw new Error("expected string tool");
19+
return tool.handler(args, new AbortController().signal);
20+
}
21+
22+
function memoryArchive(sessionId: string): CompactionArchive {
23+
const dir = mkdtempSync(join(tmpdir(), "archive-tools-"));
24+
const blobs = new Map<string, Uint8Array>();
25+
return createCompactionArchive({
26+
sessionId,
27+
contextDir: dir,
28+
writeBlob: async (key, bytes) => {
29+
blobs.set(key, bytes);
30+
},
31+
readBlob: async (key) => {
32+
const bytes = blobs.get(key);
33+
if (bytes === undefined) throw new Error(`missing blob ${key}`);
34+
return bytes;
35+
},
36+
});
37+
}
38+
39+
function wrapReads(archive: CompactionArchive): string[] {
40+
const ids: string[] = [];
41+
const orig = archive.readAuthorizedPayload.bind(archive);
42+
archive.readAuthorizedPayload = async (occurrenceId) => {
43+
ids.push(occurrenceId);
44+
return orig(occurrenceId);
45+
};
46+
return ids;
47+
}
48+
49+
describe("archive tool definitions", () => {
50+
test("catalog advertises search_archive and read_archive; they are not CORE", () => {
51+
expect(CORE_TOOL_NAMES).not.toContain("search_archive");
52+
expect(CORE_TOOL_NAMES).not.toContain("read_archive");
53+
expect(CATALOG_TOOL_NAMES).toContain("search_archive");
54+
expect(CATALOG_TOOL_NAMES).toContain("read_archive");
55+
expect(searchArchiveDefinition.description).toContain("archive:///");
56+
expect(readArchiveDefinition.description).toContain("archive:///");
57+
});
58+
});
59+
60+
describe("search_archive and read_archive", () => {
61+
test("search returns archive:/// refs and read returns the payload", async () => {
62+
const archive = memoryArchive("sess-primary");
63+
const occ = await archive.recordAuthorizedPayload({
64+
kind: "user_message",
65+
payload: "unique-payload-alpha",
66+
provenance: "primary-admission",
67+
});
68+
const search = createSearchArchiveTool(() => archive);
69+
const read = createReadArchiveTool(() => archive);
70+
71+
const hits = await call(search, { query: "unique-payload-alpha" });
72+
expect(hits).toContain(formatArchiveRef(occ.occurrenceId));
73+
expect(hits).toContain("user_message");
74+
expect(hits).not.toContain(occ.sessionId);
75+
expect(hits).not.toContain(occ.blobKey);
76+
77+
const body = await call(read, { ref: formatArchiveRef(occ.occurrenceId) });
78+
expect(body).toContain("unique-payload-alpha");
79+
expect(body).not.toContain(occ.blobKey);
80+
});
81+
82+
test("gap rows match metadata only and never load payload", async () => {
83+
const archive = memoryArchive("sess-gap");
84+
const reads = wrapReads(archive);
85+
const gap = await archive.recordAuthorizedPayload({
86+
kind: "attachment",
87+
payload: { secret: "gap-payload-must-not-search" },
88+
provenance: "primary-admission:attachment-missing",
89+
gap: true,
90+
});
91+
const search = createSearchArchiveTool(() => archive);
92+
const read = createReadArchiveTool(() => archive);
93+
94+
const payloadHits = await call(search, { query: "gap-payload-must-not-search" });
95+
expect(payloadHits).toContain("No evidence-archive occurrences matched");
96+
expect(reads).toEqual([]);
97+
98+
const metaHits = await call(search, { query: "attachment-missing" });
99+
expect(metaHits).toContain(formatArchiveRef(gap.occurrenceId));
100+
expect(metaHits).toContain("gap");
101+
expect(reads).toEqual([]);
102+
103+
const body = await call(read, { ref: formatArchiveRef(gap.occurrenceId) });
104+
expect(body).toContain("explicit gap");
105+
expect(reads).toEqual([gap.occurrenceId]);
106+
});
107+
108+
test("forged and other-session refs are not found", async () => {
109+
const primary = memoryArchive("sess-a");
110+
const other = memoryArchive("sess-b");
111+
const foreign = await other.recordAuthorizedPayload({
112+
kind: "assistant_text",
113+
payload: "other-session-only",
114+
});
115+
const read = createReadArchiveTool(() => primary);
116+
const search = createSearchArchiveTool(() => primary);
117+
118+
const forged = await call(read, { ref: "archive:///occ-forged-not-in-index" });
119+
expect(forged).toContain("unknown occurrence");
120+
121+
const cross = await call(read, { ref: formatArchiveRef(foreign.occurrenceId) });
122+
expect(cross).toContain("unknown occurrence");
123+
124+
const hits = await call(search, { query: "other-session-only" });
125+
expect(hits).toContain("No evidence-archive occurrences matched");
126+
});
127+
128+
test("rejects sessionId, path, and blobKey locators", async () => {
129+
const archive = memoryArchive("sess-locators");
130+
const occ = await archive.recordAuthorizedPayload({
131+
kind: "user_message",
132+
payload: "locator-payload",
133+
});
134+
const search = createSearchArchiveTool(() => archive);
135+
const read = createReadArchiveTool(() => archive);
136+
const ref = formatArchiveRef(occ.occurrenceId);
137+
138+
for (const args of [
139+
{ query: "locator-payload", sessionId: "sess-locators" },
140+
{ query: "locator-payload", path: "evidence-archive/index.jsonl" },
141+
{ query: "locator-payload", blobKey: occ.blobKey },
142+
]) {
143+
const out = await call(search, args);
144+
expect(out).toContain("does not accept sessionId, path, or blobKey");
145+
}
146+
147+
for (const args of [
148+
{ ref, sessionId: "sess-locators" },
149+
{ ref, path: "evidence-archive/index.jsonl" },
150+
{ ref, blobKey: occ.blobKey },
151+
]) {
152+
const out = await call(read, args);
153+
expect(out).toContain("does not accept sessionId, path, or blobKey");
154+
}
155+
156+
const badRef = await call(read, { ref: occ.occurrenceId });
157+
expect(badRef).toContain("archive:///{occurrenceId}");
158+
});
159+
160+
test("read_archive pages with offset and limit via readBytesBounded", async () => {
161+
const archive = memoryArchive("sess-page");
162+
const lines = Array.from({ length: 8 }, (_, i) => `archive-line-${i}`).join("\n");
163+
const occ = await archive.recordAuthorizedPayload({
164+
kind: "tool_result",
165+
payload: lines,
166+
});
167+
const read = createReadArchiveTool(() => archive);
168+
const body = await call(read, {
169+
ref: formatArchiveRef(occ.occurrenceId),
170+
offset: 2,
171+
limit: 2,
172+
});
173+
expect(body).toContain("archive-line-2");
174+
expect(body).toContain("archive-line-3");
175+
expect(body).not.toContain("archive-line-0");
176+
expect(body).not.toContain("archive-line-4");
177+
expect(body).toContain("Use offset=");
178+
});
179+
});
180+
181+
describe("createAgentToolset archive mount", () => {
182+
test("mounts search_archive and read_archive only when getEvidenceArchive is set", async () => {
183+
const cwd = mkdtempSync(join(tmpdir(), "corbits-archive-mount-"));
184+
const { createAgentToolset } = await import("./tools.js");
185+
const permissionGate = {
186+
check: async () => ({ allowed: true }),
187+
getSkipPermissions: () => false,
188+
} as never;
189+
190+
const worker = await createAgentToolset({
191+
cwd,
192+
permissionGate,
193+
onOperatorGate: async () => ({ kind: "option", index: 0 }),
194+
});
195+
const workerNames = worker.dynamicRunner.currentDefinitions().map((d) => d.name);
196+
expect(workerNames).not.toContain("search_archive");
197+
expect(workerNames).not.toContain("read_archive");
198+
await worker.dispose();
199+
200+
const primary = await createAgentToolset({
201+
cwd,
202+
permissionGate,
203+
onOperatorGate: async () => ({ kind: "option", index: 0 }),
204+
getEvidenceArchive: () => undefined,
205+
});
206+
const primaryNames = primary.dynamicRunner.currentDefinitions().map((d) => d.name);
207+
expect(primaryNames).toContain("search_archive");
208+
expect(primaryNames).toContain("read_archive");
209+
await primary.dispose();
210+
});
211+
});

src/agent/archive-tools.ts

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import { stringTool } from "@intx/agent";
2+
import type { AgentTool } from "@intx/agent";
3+
import type { ToolDefinition } from "@intx/types/runtime";
4+
import { type } from "arktype";
5+
6+
import {
7+
READ_FILE_DEFAULT_MAX_LINES,
8+
readBytesBounded,
9+
} from "../plugins/read-file-guard-plugin.js";
10+
import type { CompactionArchive } from "../session/compaction-archive.js";
11+
import type { ArchiveOccurrence } from "../session/compaction-archive-schema.js";
12+
import { formatArchiveRef, parseArchiveRef } from "../session/archive-uri.js";
13+
14+
const SEARCH_DEFAULT_LIMIT = 20;
15+
const SEARCH_MAX_LIMIT = 50;
16+
17+
export const searchArchiveDefinition: ToolDefinition = {
18+
name: "search_archive",
19+
description:
20+
"Search this session's compaction evidence archive. Returns archive:///{occurrenceId} refs only — follow a hit with read_archive. Do not pass sessionId, path, or blobKey; do not read_file evidence-archive or tool-output/archive-* paths.",
21+
inputSchema: {
22+
type: "object",
23+
properties: {
24+
query: {
25+
type: "string",
26+
description: "Keywords to match against kind, call id, provenance, or payload text.",
27+
},
28+
limit: {
29+
type: "number",
30+
description: "Max hits to return (default 20, cap 50).",
31+
},
32+
},
33+
required: ["query"],
34+
},
35+
};
36+
37+
export const readArchiveDefinition: ToolDefinition = {
38+
name: "read_archive",
39+
description:
40+
"Read one evidence-archive occurrence by archive:///{occurrenceId} from search_archive. Bounded like read_file (offset/limit). Do not pass sessionId, path, or blobKey.",
41+
inputSchema: {
42+
type: "object",
43+
properties: {
44+
ref: {
45+
type: "string",
46+
description: "An archive:///{occurrenceId} handle from search_archive.",
47+
},
48+
offset: {
49+
type: "number",
50+
description: "Zero-based line skip, same as read_file.",
51+
},
52+
limit: {
53+
type: "number",
54+
description: "Max lines to return.",
55+
},
56+
},
57+
required: ["ref"],
58+
},
59+
};
60+
61+
const SearchArchiveArgs = type({
62+
query: "string",
63+
"limit?": "number",
64+
});
65+
66+
const ReadArchiveArgs = type({
67+
ref: "string",
68+
"offset?": "number",
69+
"limit?": "number",
70+
});
71+
72+
function forbiddenLocatorMessage(tool: string): string {
73+
return `Error: ${tool} does not accept sessionId, path, or blobKey. Use archive:///{occurrenceId} refs only.`;
74+
}
75+
76+
function hasForbiddenLocatorArgs(rawArgs: Record<string, unknown>): boolean {
77+
return "sessionId" in rawArgs || "path" in rawArgs || "blobKey" in rawArgs;
78+
}
79+
80+
function formatHit(occ: ArchiveOccurrence): string {
81+
const parts = [formatArchiveRef(occ.occurrenceId), occ.kind];
82+
if (occ.callId !== undefined) parts.push(`callId=${occ.callId}`);
83+
if (occ.lifecycle !== undefined) parts.push(`lifecycle=${occ.lifecycle}`);
84+
if (occ.provenance !== undefined) parts.push(`provenance=${occ.provenance}`);
85+
if (occ.gap === true) parts.push("gap");
86+
return parts.join(" ");
87+
}
88+
89+
function metadataBlob(occ: ArchiveOccurrence): string {
90+
return [occ.occurrenceId, occ.kind, occ.callId ?? "", occ.lifecycle ?? "", occ.provenance ?? ""]
91+
.join(" ")
92+
.toLowerCase();
93+
}
94+
95+
export function createSearchArchiveTool(
96+
getArchive: () => CompactionArchive | undefined,
97+
): AgentTool {
98+
return stringTool({
99+
definition: searchArchiveDefinition,
100+
handler: async (rawArgs: Record<string, unknown>): Promise<string> => {
101+
if (hasForbiddenLocatorArgs(rawArgs)) return forbiddenLocatorMessage("search_archive");
102+
const parsed = SearchArchiveArgs(rawArgs);
103+
if (parsed instanceof type.errors) {
104+
return "Error: search_archive requires query (string).";
105+
}
106+
const archive = getArchive();
107+
if (archive === undefined) {
108+
return "Error: evidence archive is not available in this session.";
109+
}
110+
const query = parsed.query.trim().toLowerCase();
111+
const limitRaw = parsed.limit;
112+
const limit =
113+
typeof limitRaw === "number" && Number.isFinite(limitRaw) && limitRaw > 0
114+
? Math.min(Math.floor(limitRaw), SEARCH_MAX_LIMIT)
115+
: SEARCH_DEFAULT_LIMIT;
116+
const occurrences = await archive.listOccurrences();
117+
const hits: string[] = [];
118+
for (const occ of occurrences) {
119+
if (hits.length >= limit) break;
120+
if (query.length === 0 || metadataBlob(occ).includes(query)) {
121+
hits.push(formatHit(occ));
122+
continue;
123+
}
124+
if (occ.gap === true) continue;
125+
try {
126+
const payload = await archive.readAuthorizedPayload(occ.occurrenceId);
127+
if (payload.toLowerCase().includes(query)) hits.push(formatHit(occ));
128+
} catch {
129+
continue;
130+
}
131+
}
132+
if (hits.length === 0) {
133+
return query.length === 0
134+
? "No evidence-archive occurrences are recorded yet."
135+
: `No evidence-archive occurrences matched "${parsed.query.trim()}".`;
136+
}
137+
return [
138+
"Matching evidence-archive occurrences (pass ref to read_archive):",
139+
"",
140+
...hits,
141+
].join("\n");
142+
},
143+
});
144+
}
145+
146+
export function createReadArchiveTool(getArchive: () => CompactionArchive | undefined): AgentTool {
147+
return stringTool({
148+
definition: readArchiveDefinition,
149+
handler: async (rawArgs: Record<string, unknown>, signal: AbortSignal): Promise<string> => {
150+
if (hasForbiddenLocatorArgs(rawArgs)) return forbiddenLocatorMessage("read_archive");
151+
const parsed = ReadArchiveArgs(rawArgs);
152+
if (parsed instanceof type.errors) {
153+
return "Error: read_archive requires ref (archive:///{occurrenceId}).";
154+
}
155+
const occurrenceId = parseArchiveRef(parsed.ref.trim());
156+
if (occurrenceId === undefined) {
157+
return "Error: read_archive ref must be archive:///{occurrenceId}.";
158+
}
159+
const archive = getArchive();
160+
if (archive === undefined) {
161+
return "Error: evidence archive is not available in this session.";
162+
}
163+
const offset =
164+
typeof parsed.offset === "number" && parsed.offset > 0 ? Math.floor(parsed.offset) : 0;
165+
const limit =
166+
typeof parsed.limit === "number" && parsed.limit > 0
167+
? Math.floor(parsed.limit)
168+
: READ_FILE_DEFAULT_MAX_LINES;
169+
try {
170+
const text = await archive.readAuthorizedPayload(occurrenceId);
171+
const bytes = new TextEncoder().encode(text);
172+
const display = formatArchiveRef(occurrenceId);
173+
const result = await readBytesBounded(bytes, offset, limit, signal, display);
174+
return result.content;
175+
} catch (err) {
176+
return `Error: ${err instanceof Error ? err.message : String(err)}`;
177+
}
178+
},
179+
});
180+
}

0 commit comments

Comments
 (0)