Unified · Split
| @@ -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`. | ||
| @@ -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 as | 119 | // 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. |
| @@ -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 points | 121 | // 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 | ); |
| @@ -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 the | 1268 | // `search_<integration>` is `execute` running `tools.search` with the |
| 1269 | // namespace pinned. The code is built HERE, from the slug the tool was | 1269 | // 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 |
| 1271 | // raw model input — and then takes the exact `executeCode` path, so the | 1271 | // raw model input — and then takes the exact `executeCode` path, so the |
| 1272 | // results, formatting, and telemetry match a hand-written | 1272 | // 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: the | 1277 | /** What the caller could bind an unresolved role to. Best effort: the |
| 1286 | * connections port is optional, and a failure to enumerate must not | 1278 | * 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, so | 1620 | // 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 already | 1630 | // The MCP tool-name grammar ([A-Za-z0-9_-]). Integration slugs already |
| 1632 | // conform (they are `tools.<slug>` property names in sandbox code); one | 1631 | // 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: z | 1644 | 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}`, |