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
6Slim 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
104 // The core surface is untouched.104 // The core surface is untouched.
105 expect(names, "execute still works on an opted-in session").toContain("execute");105 expect(names, "execute still works on an opted-in session").toContain("execute");
106 expect(names, "skills still works on an opted-in session").toContain("skills");106 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.
108 const described = optedInTools.find((tool) => tool.name === searchTool);110 const described = optedInTools.find((tool) => tool.name === searchTool);
109 expect(described?.description, "the tool description names its namespace").toContain(slug);
110 expect(described?.description, "the tool description points at execute").toContain(111 expect(described?.description, "the tool description points at execute").toContain(
111 "execute",112 "execute",
112 );113 );
114 expect(
115 (described?.description ?? "").length,
116 "the tool description stays lean",
117 ).toBeLessThan(120);
113 118
114 // A keyword call returns the matching tool, exactly as119 // A keyword call returns the matching tool, exactly as
115 // `tools.search({ query, namespace })` inside execute would.120 // `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
118 // session.118 // session.
119 expect(names.filter((name) => name.startsWith("search_"))).toHaveLength(2);119 expect(names.filter((name) => name.startsWith("search_"))).toHaveLength(2);
120 120
121 // Minimal descriptions: one line that names the namespace and points121 // Minimal descriptions: the NAME carries the namespace; the shared
122 // back at the execute flow.122 // one-line description only points back at the execute flow.
123 const gmail = tools.find((tool) => tool.name === "search_google_gmail");123 const gmail = tools.find((tool) => tool.name === "search_google_gmail");
124 expect(gmail?.description).toContain("google_gmail");
125 expect(gmail?.description).toContain("execute");124 expect(gmail?.description).toContain("execute");
126 expect(gmail?.description?.includes("\n")).toBe(false);125 expect(gmail?.description?.includes("\n")).toBe(false);
127 },126 },
128 );127 );
129 });128 });
130 129
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
131 it("registers none when the description carries no inventory", async () => {153 it("registers none when the description carries no inventory", async () => {
132 const { engine } = makeRecordingEngine();154 const { engine } = makeRecordingEngine();
133 await withClient(155 await withClient(
@@ -149,11 +171,9 @@ describe("MCP host — per-integration search tools", () => { · 15 unmodified lines
149 async (client) => {171 async (client) => {
150 const result = await client.callTool({172 const result = await client.callTool({
151 name: "search_github",173 name: "search_github",
152 arguments: { query: "issues", limit: 5 },174 arguments: { query: "issues" },
153 });175 });
154 expect(executed).toEqual([176 expect(executed).toEqual(['return tools.search({"query":"issues","namespace":"github"})']);
155 'return tools.search({"query":"issues","namespace":"github","limit":5})',
156 ]);
157 expect(result.isError ?? false).toBe(false);177 expect(result.isError ?? false).toBe(false);
158 },178 },
159 );179 );
packages/hosts/mcp/src/tool-server.ts+15−22
@@ -1267,20 +1267,12 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 1266 unmodified lines
1267 1267
1268 // `search_<integration>` is `execute` running `tools.search` with the1268 // `search_<integration>` is `execute` running `tools.search` with the
1269 // namespace pinned. The code is built HERE, from the slug the tool was1269 // namespace pinned. The code is built HERE, from the slug the tool was
1270 // registered under and JSON-encoded arguments — never concatenated from1270 // registered under and a JSON-encoded query — never concatenated from
1271 // raw model input — and then takes the exact `executeCode` path, so the1271 // raw model input — and then takes the exact `executeCode` path, so the
1272 // results, formatting, and telemetry match a hand-written1272 // results, formatting, and telemetry match a hand-written
1273 // `tools.search({ namespace })` call.1273 // `tools.search({ namespace })` call.
1274 const searchNamespaceCode = (1274 const searchNamespaceCode = (integration: string, query: string | undefined): string =>
1275 integration: string,1275 `return tools.search(${JSON.stringify({ query: query ?? "", namespace: integration })})`;
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 })})`;
1284 1276
1285 /** What the caller could bind an unresolved role to. Best effort: the1277 /** What the caller could bind an unresolved role to. Best effort: the
1286 * connections port is optional, and a failure to enumerate must not1278 * 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
1627 // `tools.search({ namespace })` inside `execute` (see searchNamespaceCode).1619 // `tools.search({ namespace })` inside `execute` (see searchNamespaceCode).
1628 // The inventory comes from the same built description the model reads, so1620 // The inventory comes from the same built description the model reads, so
1629 // the two surfaces cannot list different integrations.1621 // 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.
1630 if (searchToolsEnabled) {1629 if (searchToolsEnabled) {
1631 // The MCP tool-name grammar ([A-Za-z0-9_-]). Integration slugs already1630 // The MCP tool-name grammar ([A-Za-z0-9_-]). Integration slugs already
1632 // conform (they are `tools.<slug>` property names in sandbox code); one1631 // 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
1640 server.registerTool(1639 server.registerTool(
1641 `search_${integration}`,1640 `search_${integration}`,
1642 {1641 {
1643 description: `Find \`${integration}\` tools. Same results as \`tools.search({ query, namespace: "${integration}" })\` inside execute; run what you find with execute.`,1642 description:
1644 inputSchema: {1643 "Search this integration's tools; empty query lists all. Run results with execute.",
1645 query: z1644 inputSchema: { query: z.string().optional() },
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 },
1652 },1645 },
1653 ({ query, limit, offset }, extra) =>1646 ({ query }, extra) =>
1654 runToolEffect(1647 runToolEffect(
1655 executeCode(searchNamespaceCode(integration, { query, limit, offset }), extra).pipe(1648 executeCode(searchNamespaceCode(integration, query), extra).pipe(
1656 Effect.withSpan("mcp.host.tool.namespace_search", {1649 Effect.withSpan("mcp.host.tool.namespace_search", {
1657 attributes: {1650 attributes: {
1658 "mcp.tool.name": `search_${integration}`,1651 "mcp.tool.name": `search_${integration}`,