From 63480fc06afef0168bb3dcb1bc845166ba6bc4f5 Mon Sep 17 00:00:00 2001 From: teddy-paris Date: Mon, 13 Jul 2026 14:40:37 -0700 Subject: [PATCH] CD-1293: cap vault_search tool results at the context boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Search results returned the API's full chunk text verbatim — a limit:50 search produced ~46K chars of tool result, the last uncapped context-ingestion vector after the CD-1284 read/bash caps (observed in the Jul 13 production verification; same accumulation mechanic that burned the Lindenberg session). Both vault_search tools now trim result text fields to 500-char snippets, drop trailing results if the serialized payload still exceeds 20K chars (with an explicit omission marker), and carry a steering note: for full passages, vault_read_text + grep -n. The server API is unchanged — SDK consumers keep full chunks; only what enters billed model context is bounded. Co-Authored-By: Claude Fable 5 --- .../src/linc/casedev-vault-tools.ts | 70 ++++++++++++++++++- .../test/linc-vault-matter.test.ts | 53 ++++++++++++++ 2 files changed, 121 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/linc/casedev-vault-tools.ts b/packages/coding-agent/src/linc/casedev-vault-tools.ts index 89e01ff9..b4651501 100644 --- a/packages/coding-agent/src/linc/casedev-vault-tools.ts +++ b/packages/coding-agent/src/linc/casedev-vault-tools.ts @@ -218,6 +218,72 @@ async function downloadVaultPath(ctx: ExtensionContext, vaultId: string, path: s const DOWNLOAD_CONTENT_NOTE = "This is the original file (raw bytes). To read or analyze the document's content, use vault_read_text — it returns the extracted, page-numbered text."; +const SEARCH_SNIPPET_MAX_CHARS = 500; +const SEARCH_RESULT_MAX_CHARS = 20_000; +const SEARCH_RESULT_NOTE = + "Result snippets are truncated for context economy. For full passages, use vault_read_text to write the document text into the workspace, then grep -n around the match."; + +function trimSearchText(value: unknown): unknown { + if (typeof value === "string") { + return value.length > SEARCH_SNIPPET_MAX_CHARS ? `${value.slice(0, SEARCH_SNIPPET_MAX_CHARS)}…` : value; + } + if (Array.isArray(value)) return value.map(trimSearchText); + if (typeof value === "object" && value !== null) { + const out: Record = {}; + for (const [k, v] of Object.entries(value)) { + out[k] = trimSearchText(v); + } + return out; + } + return value; +} + +// Search results come back from the API with full chunk text — 50 results can +// be ~46K chars, and tool results are billed model context on every later +// call in the session. Trim snippets, then drop trailing results until the +// serialized payload fits the cap; the note steers agents to vault_read_text +// + grep for full passages (the file on disk is free; tool results are not). +function capSearchResult(data: unknown): { payload: unknown; omitted: number } { + let payload = trimSearchText(data); + let omitted = 0; + const serialized = () => JSON.stringify(payload, null, 2).length; + if (serialized() <= SEARCH_RESULT_MAX_CHARS) return { payload, omitted }; + if (typeof payload === "object" && payload !== null && !Array.isArray(payload)) { + const record = { ...(payload as Record) }; + for (const key of ["chunks", "results", "matches", "data"]) { + const list = record[key]; + if (Array.isArray(list)) { + const capped = [...list]; + while ( + capped.length > 1 && + JSON.stringify({ ...record, [key]: capped }, null, 2).length > SEARCH_RESULT_MAX_CHARS + ) { + capped.pop(); + omitted += 1; + } + record[key] = capped; + payload = record; + break; + } + } + } + return { payload, omitted }; +} + +function searchToolResult(command: string[], data: unknown) { + const { payload, omitted } = capSearchResult(data); + const body: Record = { note: SEARCH_RESULT_NOTE }; + if (omitted > 0) { + body.omittedResults = `${omitted} result(s) omitted to bound output size — narrow the query or lower limit.`; + } + if (typeof payload === "object" && payload !== null && !Array.isArray(payload)) { + Object.assign(body, payload as Record); + } else { + body.results = payload; + } + return resultContent(command, JSON.stringify(body, null, 2)); +} + async function executeVaultReadText( ctx: ExtensionContext, signal: AbortSignal | undefined, @@ -298,7 +364,7 @@ export function createCaseDevVaultTools(): ToolDefinition[] { async execute(_toolCallId, params: VaultSearchInput, signal, _onUpdate, ctx) { const vaultId = resolveVaultId(ctx, params.vaultId); const result = await searchCaseDevVault({ ...ctx, signal }, vaultId, params); - return jsonResult(["POST", `/vault/${vaultId}/search`], result); + return searchToolResult(["POST", `/vault/${vaultId}/search`], result); }, renderCall: (args, theme) => renderCall("case.dev vault search", args, theme), renderResult, @@ -385,7 +451,7 @@ export function createCaseDevVaultTools(): ToolDefinition[] { async execute(_toolCallId, params: VaultSearchInput, signal, _onUpdate, ctx) { const vaultId = resolveVaultId(ctx, params.vaultId); const result = await searchCaseDevVault({ ...ctx, signal }, vaultId, params); - return jsonResult(["POST", `/vault/${vaultId}/search`], result); + return searchToolResult(["POST", `/vault/${vaultId}/search`], result); }, renderCall: (args, theme) => renderCall("vault_search", args, theme), renderResult, diff --git a/packages/coding-agent/test/linc-vault-matter.test.ts b/packages/coding-agent/test/linc-vault-matter.test.ts index 3ae9aa6b..b7ace5eb 100644 --- a/packages/coding-agent/test/linc-vault-matter.test.ts +++ b/packages/coding-agent/test/linc-vault-matter.test.ts @@ -24,6 +24,7 @@ const mocks = vi.hoisted(() => ({ listCaseDevVaultObjects: vi.fn(), downloadCaseDevVaultObject: vi.fn(), uploadCaseDevVaultFile: vi.fn(), + searchCaseDevVault: vi.fn(), })); vi.mock("../src/linc/casedev-cli.ts", () => ({ @@ -43,6 +44,7 @@ vi.mock("../src/linc/casedev-vault-api.ts", async (importOriginal) => { listCaseDevVaultObjects: mocks.listCaseDevVaultObjects, downloadCaseDevVaultObject: mocks.downloadCaseDevVaultObject, uploadCaseDevVaultFile: mocks.uploadCaseDevVaultFile, + searchCaseDevVault: mocks.searchCaseDevVault, }; }); @@ -296,6 +298,57 @@ describe("Case.dev vault tools", () => { ).rejects.toThrow("not in CASE_ALLOWED_VAULT_IDS"); expect(mocks.runCaseDevCli).not.toHaveBeenCalled(); }); + + it("caps oversized search results and steers toward vault_read_text", async () => { + delete process.env.CASE_ALLOWED_VAULT_IDS; + const ctx = createContext({ cwd: "/tmp/linc-test" }) as unknown as ExtensionContext; + mocks.searchCaseDevVault.mockResolvedValue({ + chunks: Array.from({ length: 50 }, (_, i) => ({ + objectId: `obj-${i}`, + text: "x".repeat(2_000), + metadata: { page: i + 1 }, + })), + }); + + const result = await getTool("vault_search").execute( + "tool-call-1", + { query: "facts", vaultId: "vault-1", limit: 50 }, + undefined, + undefined, + ctx, + ); + const first = result.content[0]; + const output = first?.type === "text" ? first.text : ""; + expect(output.length).toBeLessThanOrEqual(22_000); + const parsed = JSON.parse(output); + expect(parsed.note).toContain("vault_read_text"); + for (const chunk of parsed.chunks) { + expect(chunk.text.length).toBeLessThanOrEqual(501); + } + expect(parsed.omittedResults ?? "").toContain("omitted"); + }); + + it("passes small search results through intact with the steering note", async () => { + delete process.env.CASE_ALLOWED_VAULT_IDS; + const ctx = createContext({ cwd: "/tmp/linc-test" }) as unknown as ExtensionContext; + mocks.searchCaseDevVault.mockResolvedValue({ + chunks: [{ objectId: "obj-1", text: "short match", metadata: { page: 3 } }], + }); + + const result = await getTool("vault_search").execute( + "tool-call-1", + { query: "facts", vaultId: "vault-1" }, + undefined, + undefined, + ctx, + ); + const firstSmall = result.content[0]; + const parsed = JSON.parse(firstSmall?.type === "text" ? firstSmall.text : "{}"); + expect(parsed.chunks).toHaveLength(1); + expect(parsed.chunks[0].text).toBe("short match"); + expect(parsed.note).toContain("vault_read_text"); + expect(parsed.omittedResults).toBeUndefined(); + }); }); describe("MATTER.md source precedence", () => {