Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 68 additions & 2 deletions packages/coding-agent/src/linc/casedev-vault-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {};
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<string, unknown>) };
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<string, unknown> = { 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<string, unknown>);
} else {
body.results = payload;
}
return resultContent(command, JSON.stringify(body, null, 2));
}

async function executeVaultReadText(
ctx: ExtensionContext,
signal: AbortSignal | undefined,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
53 changes: 53 additions & 0 deletions packages/coding-agent/test/linc-vault-matter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand All @@ -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,
};
});

Expand Down Expand Up @@ -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", () => {
Expand Down
Loading