pi-mono/packages/coding-agent/test/tool-search.test.ts
Armin Ronacher 8562bcf66a feat(coding-agent): codemode and MCP
This adds codemode and MCP support to pi. Codemode (packages/codemode) runs
model-written JavaScript in a QuickJS wasm VM inside a worker. From there the
script calls pi's tools as async functions, uses a per-session store, and can
read the model catalog and run classifiers. The codemode tool in coding-agent
is compatible with Codex's exec tool, and codemode.mode works like Codex's
tool modes (on or only). Large results are written to a JSON temp file. MCP
(packages/mcp) is a standalone client for stdio and streamable HTTP servers,
with OAuth.

The core only gets general mechanisms; codemode, tool_search and MCP are
built-in extensions that use them. Other extensions can replace these
built-ins. Tools declare an exposure: direct, model-only, codemode, deferred
or hidden. prepareLoadout() lets an orchestrating tool change which tool
declarations the model sees. ctx.executeTool() runs nested calls through
validation and the tool_call/tool_result hooks. Those calls emit events with
parentToolCallId and are recorded as bounded nestedCalls on the parent
result, which compaction and HTML export use. Tools can also return
structuredContent with an outputSchema, and can report errors with isError
instead of throwing. MCP servers come from mcp.json (global, or per project
after the project is trusted) or from pi.registerMcpServer(). They are
managed with /mcp and pi mcp list|login|logout, and their runtimes load on
first use.

Closes #10040
2026-09-29 09:12:50 +02:00

132 lines
6.1 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 (6 tools).");
expect(description).toContain("## mcp__github (3 tools)\nGitHub server");
expect(description).toContain("## mcp__docs (2 tools)\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 - 4 of 6 shown.");
expect(description).toContain("### `read_notes`");
expect(description).toContain("## mcp__docs (2 tools, 1 shown)");
expect(description).toContain("### `mcp__docs__search`");
expect(description).not.toContain("### `mcp__docs__long`");
expect(description).toContain("## mcp__github (3 tools, 2 shown)");
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 and still counts them", () => {
const description = createCodemodeDescription(all, {
namespaces,
deferred: new Set(github.map((entry) => entry.name)),
});
expect(description).toContain("Nested tools: PARTIAL - 3 of 6 shown.");
expect(description).toContain("## mcp__github (3 tools, none shown)\nGitHub server");
expect(description).not.toContain("### `mcp__github__a`");
expect(description).toContain("Some deferred nested tools may be omitted from this description.");
});
it("lists only namespaces with a zero budget", () => {
const description = createCodemodeDescription(all, { namespaces, inlineBudget: 0 });
expect(description).toContain("Nested tools: PARTIAL - 0 of 6 shown.");
expect(description).toContain("## mcp__docs (2 tools, none shown)");
expect(description).not.toContain("codemode tool declaration:");
});
});