pi-mono/packages/coding-agent/test/tool-search.test.ts
Armin Ronacher 1c7e7df765 feat(coding-agent): list MCP servers instead of their tools in codemode
MCP codemode exposure no longer lists tools, tool counts, or server
instructions in the codemode description. Servers are listed by name and
an optional mcp.json description; scripts find tools with searchTools()
and read instructions with describeNamespace(). codemode-deferred is an
alias for codemode.

refs #10212
2026-09-30 13:54:02 +02:00

149 lines
6.9 KiB
TypeScript

import type { AgentTool } from "@earendil-works/pi-agent-core";
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import type { ToolNamespace } from "../src/core/extensions/types.ts";
import { createCodemodeDescription } from "../src/extensions/codemode/tool.ts";
import {
Bm25Ranker,
createToolSearchDescription,
createToolSearchDocument,
tokenize,
} from "../src/extensions/tool-search/tool.ts";
function tool(name: string, description: string, properties: Record<string, unknown> = {}): AgentTool {
return {
name,
label: name,
description,
parameters: Type.Unsafe({ type: "object", properties }),
execute: async () => ({ content: [], details: undefined }),
};
}
describe("tokenize", () => {
it("splits camelCase and snake_case, drops stop words, and folds plurals", () => {
expect(tokenize("listIssues for the GitHub_repo")).toEqual(["list", "issue", "git", "hub", "repo"]);
expect(tokenize("searches queries HTTPServer")).toEqual(["search", "query", "http", "server"]);
});
});
describe("Bm25Ranker", () => {
const tools = [
tool("mcp__github__list_issues", "List issues in a repository.", {
state: { type: "string", description: "open or closed" },
}),
tool("mcp__github__create_pull_request", "Open a pull request."),
tool("mcp__linear__search_issues", "Search Linear issues by text."),
tool("mcp__docs__search", "Search the documentation."),
];
const documents = tools.map((entry) => createToolSearchDocument(entry));
it("ranks by term relevance and respects the limit", () => {
const ranker = new Bm25Ranker();
expect(ranker.rank("issue", documents, 8).map((match) => match.name)).toEqual([
"mcp__linear__search_issues",
"mcp__github__list_issues",
]);
expect(ranker.rank("pull requests", documents, 8)[0].name).toBe("mcp__github__create_pull_request");
expect(ranker.rank("search", documents, 1)).toHaveLength(1);
// Property names and descriptions are searchable.
expect(ranker.rank("closed", documents, 8).map((match) => match.name)).toEqual(["mcp__github__list_issues"]);
});
it("returns nothing for unknown or empty queries", () => {
const ranker = new Bm25Ranker();
expect(ranker.rank("kubernetes", documents, 8)).toEqual([]);
expect(ranker.rank("the", documents, 8)).toEqual([]);
// Known v1 limit: no synonyms, so "tickets" does not find "issues".
expect(ranker.rank("tickets", documents, 8)).toEqual([]);
});
it("includes the namespace in the search text", () => {
const document = createToolSearchDocument(tool("mcp__x__run", "Run it."), {
name: "mcp__x",
description: "Kubernetes cluster tools",
});
expect(new Bm25Ranker().rank("kubernetes", [document], 8)).toEqual([
{ name: "mcp__x__run", score: expect.any(Number) },
]);
});
});
describe("tool_search description", () => {
it("lists the sources", () => {
expect(
createToolSearchDescription([{ name: "mcp__docs", description: "Docs server\nmore" }, { name: "mcp__x" }]),
).toBe(
"# Tool discovery\n\nSearches over deferred tool metadata with BM25 and exposes matching tools for the next model call.\n\nYou have access to tools from the following sources:\n- mcp__docs: Docs server\n- mcp__x\n\nSome of the tools may not have been provided to you upfront, and you should use this tool (`tool_search`) to search for the required tools. For MCP tool discovery, always use `tool_search`.",
);
expect(createToolSearchDescription()).toContain("None currently enabled.");
});
});
describe("codemode description catalog", () => {
const plain = tool("read_notes", "Read notes.");
const github = ["a", "b", "c"].map((suffix) => tool(`mcp__github__${suffix}`, `GitHub ${suffix}.`));
const docs = [tool("mcp__docs__search", "Search docs."), tool("mcp__docs__long", "Long ".repeat(200))];
const all = [plain, ...github, ...docs];
const namespaces = new Map<string, ToolNamespace>([
...github.map((entry) => [entry.name, { name: "mcp__github", description: "GitHub server" }] as const),
...docs.map((entry) => [entry.name, { name: "mcp__docs" }] as const),
]);
it("lists everything without a budget", () => {
const description = createCodemodeDescription(all, { namespaces });
expect(description).toContain("Nested tools: COMPLETE list.");
expect(description).toContain("## mcp__github\nGitHub server");
expect(description).toContain("## mcp__docs\n\n### `mcp__docs");
expect(description).not.toContain("Some deferred nested tools");
});
it("fills the budget round-robin, cheapest first, and says what is missing", () => {
// Each small section costs about 42 tokens: one tool per group, then one more.
const description = createCodemodeDescription(all, { namespaces, inlineBudget: 170 });
expect(description).toContain("Nested tools: PARTIAL list.");
expect(description).toContain("### `read_notes`");
expect(description).toContain("## mcp__docs (some tools not listed)");
expect(description).toContain("### `mcp__docs__search`");
expect(description).not.toContain("### `mcp__docs__long`");
expect(description).toContain("## mcp__github (some tools not listed)");
expect(description).toContain("To find one, call `await searchTools(query)`");
// Deterministic: the same input gives the same description.
expect(createCodemodeDescription(all, { namespaces, inlineBudget: 170 })).toBe(description);
});
it("never lists deferred tools or counts them", () => {
const deferred = new Set(github.map((entry) => entry.name));
const description = createCodemodeDescription(all, { namespaces, deferred });
expect(description).toContain("Nested tools: PARTIAL list.");
expect(description).toContain("## mcp__github (tools not listed)\nGitHub server");
expect(description).not.toContain("### `mcp__github__a`");
expect(description).toContain("Some deferred nested tools may be omitted from this description.");
// A server adding a deferred tool does not change the description.
const added = tool("mcp__github__d", "GitHub d.");
const withAdded = new Map([...namespaces, [added.name, { name: "mcp__github", description: "GitHub server" }]]);
expect(
createCodemodeDescription([...all, added], {
namespaces: withAdded,
deferred: new Set([...deferred, added.name]),
}),
).toBe(description);
});
it("leaves namespace instructions out", () => {
const description = createCodemodeDescription(github, {
namespaces: new Map(
github.map((entry) => [entry.name, { name: "mcp__github", instructions: "Long usage guide." }] as const),
),
});
expect(description).toContain("## mcp__github\n\n### `mcp__github__a`");
expect(description).not.toContain("Long usage guide.");
});
it("lists only namespaces with a zero budget", () => {
const description = createCodemodeDescription(all, { namespaces, inlineBudget: 0 });
expect(description).toContain("Nested tools: PARTIAL list.");
expect(description).toContain("## mcp__docs (tools not listed)");
expect(description).not.toContain("codemode tool declaration:");
});
});