History

Slim per-integration search tool definitions

Rhys Sullivan committed 2 months ago

Commit b1dbe5f1 parent 1bbd47e4 files · +55 −31Browse files

Unified · Split

4 files
  1. .changeset/slim-search-tools.md +6 −0
  2. e2e/scenarios/namespace-search-tools.test.ts +7 −2
  3. packages/hosts/mcp/src/namespace-search-tools.test.ts +27 −7
  4. packages/hosts/mcp/src/tool-server.ts +15 −22
.changeset/slim-search-tools.md+6−0
@@ -0,0 +1,6 @@
1+---
2+"@executor-js/execution": patch
3+"executor": patch
4+---
5+
6+Slim the per-integration `search_<integration>` tool definitions to under half their size: one shared one-line description (the tool name already carries the namespace) and a single bare `query` parameter, dropping the `limit`/`offset` knobs. A session pays for these definitions once per connected integration, so the surface now costs ~2k tokens instead of ~5k at 30 integrations; paging through a namespace belongs in `execute`.
e2e/scenarios/namespace-search-tools.test.ts+7−2
@@ -104,12 +104,17 @@ scenario( · 103 unmodified lines
104104 // The core surface is untouched.
105105 expect(names, "execute still works on an opted-in session").toContain("execute");
106106 expect(names, "skills still works on an opted-in session").toContain("skills");
107− // The description is minimal and points back at the execute flow.
107+ // The NAME carries the namespace; the description is one shared line
108+ // that points back at the execute flow, kept tiny because a session
109+ // pays for it once per connected integration.
108110 const described = optedInTools.find((tool) => tool.name === searchTool);
109− expect(described?.description, "the tool description names its namespace").toContain(slug);
110111 expect(described?.description, "the tool description points at execute").toContain(
111112 "execute",
112113 );
114+ expect(
115+ (described?.description ?? "").length,
116+ "the tool description stays lean",
117+ ).toBeLessThan(120);
113118
114119 // A keyword call returns the matching tool, exactly as
115120 // `tools.search({ query, namespace })` inside execute would.
packages/hosts/mcp/src/namespace-search-tools.test.ts+27−7
@@ -118,16 +118,38 @@ describe("MCP host — per-integration search tools", () => { · 117 unmodified lines
118118 // session.
119119 expect(names.filter((name) => name.startsWith("search_"))).toHaveLength(2);
120120
121− // Minimal descriptions: one line that names the namespace and points
122− // back at the execute flow.
121+ // Minimal descriptions: the NAME carries the namespace; the shared
122+ // one-line description only points back at the execute flow.
123123 const gmail = tools.find((tool) => tool.name === "search_google_gmail");
124− expect(gmail?.description).toContain("google_gmail");
125124 expect(gmail?.description).toContain("execute");
126125 expect(gmail?.description?.includes("\n")).toBe(false);
127126 },
128127 );
129128 });
130129
130+ it("keeps each serialized definition small — the whole point is cheap context", async () => {
131+ // A session serves one of these per connected integration (up to 50), so
132+ // definition bytes multiply. 300 serialized chars/tool keeps the full
133+ // surface around ~2k tokens; the original shipped shape was 551 chars/tool
134+ // (~5k tokens for 30 integrations), which defeated the feature's purpose.
135+ const { engine } = makeRecordingEngine();
136+ await withClient(
137+ { engine, description: DESCRIPTION_WITH_INVENTORY, searchToolsEnabled: true },
138+ async (client) => {
139+ const tools = (await client.listTools()).tools.filter((tool) =>
140+ tool.name.startsWith("search_"),
141+ );
142+ expect(tools.length).toBeGreaterThan(0);
143+ for (const tool of tools) {
144+ expect(
145+ JSON.stringify(tool).length,
146+ `${tool.name} definition must stay lean`,
147+ ).toBeLessThan(300);
148+ }
149+ },
150+ );
151+ });
152+
131153 it("registers none when the description carries no inventory", async () => {
132154 const { engine } = makeRecordingEngine();
133155 await withClient(
@@ -149,11 +171,9 @@ describe("MCP host — per-integration search tools", () => { · 15 unmodified lines
149171 async (client) => {
150172 const result = await client.callTool({
151173 name: "search_github",
152− arguments: { query: "issues", limit: 5 },
174+ arguments: { query: "issues" },
153175 });
154− expect(executed).toEqual([
155− 'return tools.search({"query":"issues","namespace":"github","limit":5})',
156− ]);
176+ expect(executed).toEqual(['return tools.search({"query":"issues","namespace":"github"})']);
157177 expect(result.isError ?? false).toBe(false);
158178 },
159179 );
packages/hosts/mcp/src/tool-server.ts+15−22
@@ -1267,20 +1267,12 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 1266 unmodified lines
12671267
12681268 // `search_<integration>` is `execute` running `tools.search` with the
12691269 // namespace pinned. The code is built HERE, from the slug the tool was
1270− // registered under and JSON-encoded arguments — never concatenated from
1270+ // registered under and a JSON-encoded query — never concatenated from
12711271 // raw model input — and then takes the exact `executeCode` path, so the
12721272 // results, formatting, and telemetry match a hand-written
12731273 // `tools.search({ namespace })` call.
1274− const searchNamespaceCode = (
1275− integration: string,
1276− args: { readonly query?: string; readonly limit?: number; readonly offset?: number },
1277− ): string =>
1278− `return tools.search(${JSON.stringify({
1279− query: args.query ?? "",
1280− namespace: integration,
1281− ...(args.limit === undefined ? {} : { limit: args.limit }),
1282− ...(args.offset === undefined ? {} : { offset: args.offset }),
1283− })})`;
1274+ const searchNamespaceCode = (integration: string, query: string | undefined): string =>
1275+ `return tools.search(${JSON.stringify({ query: query ?? "", namespace: integration })})`;
12841276
12851277 /** What the caller could bind an unresolved role to. Best effort: the
12861278 * connections port is optional, and a failure to enumerate must not
@@ -1627,6 +1619,13 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 340 unmodified lines
16271619 // `tools.search({ namespace })` inside `execute` (see searchNamespaceCode).
16281620 // The inventory comes from the same built description the model reads, so
16291621 // the two surfaces cannot list different integrations.
1622+ //
1623+ // A session serves up to 50 of these, so every definition byte is paid ~50
1624+ // times in the client's context. The NAME is the payload; everything else
1625+ // stays as small as it can: one shared description sentence (the slug
1626+ // would only repeat the name) and a single bare `query` parameter — no
1627+ // paging knobs, because anything past the first page belongs in `execute`.
1628+ // `namespace-search-tools.test.ts` pins the serialized size.
16301629 if (searchToolsEnabled) {
16311630 // The MCP tool-name grammar ([A-Za-z0-9_-]). Integration slugs already
16321631 // conform (they are `tools.<slug>` property names in sandbox code); one
@@ -1640,19 +1639,13 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 7 unmodified lines
16401639 server.registerTool(
16411640 `search_${integration}`,
16421641 {
1643− description: `Find \`${integration}\` tools. Same results as \`tools.search({ query, namespace: "${integration}" })\` inside execute; run what you find with execute.`,
1644− inputSchema: {
1645− query: z
1646− .string()
1647− .optional()
1648− .describe("Keywords to match. Omit to list the whole namespace."),
1649− limit: z.number().optional().describe("Max results per page."),
1650− offset: z.number().optional().describe("Pagination offset."),
1651− },
1642+ description:
1643+ "Search this integration's tools; empty query lists all. Run results with execute.",
1644+ inputSchema: { query: z.string().optional() },
16521645 },
1653− ({ query, limit, offset }, extra) =>
1646+ ({ query }, extra) =>
16541647 runToolEffect(
1655− executeCode(searchNamespaceCode(integration, { query, limit, offset }), extra).pipe(
1648+ executeCode(searchNamespaceCode(integration, query), extra).pipe(
16561649 Effect.withSpan("mcp.host.tool.namespace_search", {
16571650 attributes: {
16581651 "mcp.tool.name": `search_${integration}`,