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 | 104 | // The core surface is untouched. |
| 105 | 105 | expect(names, "execute still works on an opted-in session").toContain("execute"); |
| 106 | 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 | 110 | const described = optedInTools.find((tool) => tool.name === searchTool); |
| 109 | − expect(described?.description, "the tool description names its namespace").toContain(slug); | |
| 110 | 111 | expect(described?.description, "the tool description points at execute").toContain( |
| 111 | 112 | "execute", |
| 112 | 113 | ); |
| 114 | + expect( | |
| 115 | + (described?.description ?? "").length, | |
| 116 | + "the tool description stays lean", | |
| 117 | + ).toBeLessThan(120); | |
| 113 | 118 | |
| 114 | 119 | // A keyword call returns the matching tool, exactly as |
| 115 | 120 | // `tools.search({ query, namespace })` inside execute would. |
| @@ -118,16 +118,38 @@ describe("MCP host — per-integration search tools", () => { · 117 unmodified lines | ||
| 118 | 118 | // session. |
| 119 | 119 | expect(names.filter((name) => name.startsWith("search_"))).toHaveLength(2); |
| 120 | 120 | |
| 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. | |
| 123 | 123 | const gmail = tools.find((tool) => tool.name === "search_google_gmail"); |
| 124 | − expect(gmail?.description).toContain("google_gmail"); | |
| 125 | 124 | expect(gmail?.description).toContain("execute"); |
| 126 | 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 | 153 | it("registers none when the description carries no inventory", async () => { |
| 132 | 154 | const { engine } = makeRecordingEngine(); |
| 133 | 155 | await withClient( |
| @@ -149,11 +171,9 @@ describe("MCP host — per-integration search tools", () => { · 15 unmodified lines | ||
| 149 | 171 | async (client) => { |
| 150 | 172 | const result = await client.callTool({ |
| 151 | 173 | name: "search_github", |
| 152 | − arguments: { query: "issues", limit: 5 }, | |
| 174 | + arguments: { query: "issues" }, | |
| 153 | 175 | }); |
| 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"})']); | |
| 157 | 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 | 1268 | // `search_<integration>` is `execute` running `tools.search` with the |
| 1269 | 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 | 1271 | // raw model input — and then takes the exact `executeCode` path, so the |
| 1272 | 1272 | // results, formatting, and telemetry match a hand-written |
| 1273 | 1273 | // `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 })})`; | |
| 1284 | 1276 | |
| 1285 | 1277 | /** What the caller could bind an unresolved role to. Best effort: the |
| 1286 | 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 | 1619 | // `tools.search({ namespace })` inside `execute` (see searchNamespaceCode). |
| 1628 | 1620 | // The inventory comes from the same built description the model reads, so |
| 1629 | 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 | 1629 | if (searchToolsEnabled) { |
| 1631 | 1630 | // The MCP tool-name grammar ([A-Za-z0-9_-]). Integration slugs already |
| 1632 | 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 | 1639 | server.registerTool( |
| 1641 | 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.`, | |
| 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() }, | |
| 1652 | 1645 | }, |
| 1653 | − ({ query, limit, offset }, extra) => | |
| 1646 | + ({ query }, extra) => | |
| 1654 | 1647 | runToolEffect( |
| 1655 | − executeCode(searchNamespaceCode(integration, { query, limit, offset }), extra).pipe( | |
| 1648 | + executeCode(searchNamespaceCode(integration, query), extra).pipe( | |
| 1656 | 1649 | Effect.withSpan("mcp.host.tool.namespace_search", { |
| 1657 | 1650 | attributes: { |
| 1658 | 1651 | "mcp.tool.name": `search_${integration}`, |