History

Enhance tool resolution and GraphQL support in executor

- Updated sourceSignature function to version 13 for improved signature handling. - Introduced new functions for normalizing tool paths and resolving aliased tool paths. - Implemented suggestion logic for tool paths to provide better user guidance on unknown tools. - Enhanced GraphQL tool handling by ensuring proper selection sets and response envelopes. - Improved tool discovery with alias generation and example call snippets for better usability. - Updated documentation and type hints to reflect changes in tool invocation and GraphQL integration.

Rhys Sullivan committed 9 months ago

Commit e2294f41 parent f8deaef9 files · +817 −61Browse files

Unified · Split

9 files
  1. executor/convex/executorNode.ts +121 −3
  2. executor/lib/mcp_server.ts +7 −3
  3. executor/lib/runtimes/runtime-core.test.ts +4 −4
  4. executor/lib/tool-discovery.test.ts +66 −0
  5. executor/lib/tool-sources.test.ts +277 −0
  6. executor/lib/tool_discovery.ts +119 −13
  7. executor/lib/tool_sources.ts +214 −35
  8. executor/lib/typechecker.ts +7 −1
  9. executor/lib/workspace-tool-cache.test.ts +2 −2
executor/convex/executorNode.ts+121−3
@@ -57,13 +57,117 @@ function policySpecificity(policy: AccessPolicyRecord, actorId?: string, clientI · 56 unmodified lines
57}57}
58 58
59function sourceSignature(workspaceId: string, sources: Array<{ id: string; updatedAt: number; enabled: boolean }>): string {59function sourceSignature(workspaceId: string, sources: Array<{ id: string; updatedAt: number; enabled: boolean }>): string {
60 const signatureVersion = "v12";60 const signatureVersion = "v13";
61 const parts = sources61 const parts = sources
62 .map((source) => `${source.id}:${source.updatedAt}:${source.enabled ? 1 : 0}`)62 .map((source) => `${source.id}:${source.updatedAt}:${source.enabled ? 1 : 0}`)
63 .sort();63 .sort();
64 return `${signatureVersion}|${workspaceId}|${parts.join(",")}`;64 return `${signatureVersion}|${workspaceId}|${parts.join(",")}`;
65}65}
66 66
67function normalizeToolPathSegment(segment: string): string {
68 return segment.toLowerCase().replace(/[^a-z0-9]/g, "");
69}
70
71function normalizeToolPath(path: string): string {
72 return path
73 .split(".")
74 .filter(Boolean)
75 .map((segment) => normalizeToolPathSegment(segment))
76 .join(".");
77}
78
79function levenshteinDistance(a: string, b: string): number {
80 if (a === b) return 0;
81 if (a.length === 0) return b.length;
82 if (b.length === 0) return a.length;
83
84 const prev = new Array<number>(b.length + 1);
85 const curr = new Array<number>(b.length + 1);
86 for (let j = 0; j <= b.length; j++) prev[j] = j;
87
88 for (let i = 1; i <= a.length; i++) {
89 curr[0] = i;
90 for (let j = 1; j <= b.length; j++) {
91 const cost = a[i - 1] === b[j - 1] ? 0 : 1;
92 curr[j] = Math.min(
93 curr[j - 1] + 1,
94 prev[j] + 1,
95 prev[j - 1] + cost,
96 );
97 }
98 for (let j = 0; j <= b.length; j++) prev[j] = curr[j];
99 }
100
101 return prev[b.length] ?? Math.max(a.length, b.length);
102}
103
104function resolveAliasedToolPath(
105 requestedPath: string,
106 toolMap: Map<string, ToolDefinition>,
107): string | null {
108 if (toolMap.has(requestedPath)) return requestedPath;
109
110 const normalizedRequested = normalizeToolPath(requestedPath);
111 if (!normalizedRequested) return null;
112
113 const matches: string[] = [];
114 for (const path of toolMap.keys()) {
115 if (normalizeToolPath(path) === normalizedRequested) {
116 matches.push(path);
117 }
118 }
119
120 if (matches.length === 0) return null;
121 if (matches.length === 1) return matches[0]!;
122
123 const requestedSegments = requestedPath.split(".").length;
124 const sameSegmentCount = matches.filter((path) => path.split(".").length === requestedSegments);
125 const pool = sameSegmentCount.length > 0 ? sameSegmentCount : matches;
126 return [...pool].sort((a, b) => a.length - b.length || a.localeCompare(b))[0] ?? null;
127}
128
129function suggestToolPaths(
130 requestedPath: string,
131 toolMap: Map<string, ToolDefinition>,
132 limit = 3,
133): string[] {
134 const normalizedRequested = normalizeToolPath(requestedPath);
135 const requestedSegments = normalizedRequested.split(".").filter(Boolean);
136 const requestedNamespace = requestedSegments[0] ?? "";
137
138 const scored = [...toolMap.keys()]
139 .map((path) => {
140 const normalizedCandidate = normalizeToolPath(path);
141 const candidateSegments = normalizedCandidate.split(".").filter(Boolean);
142 const candidateNamespace = candidateSegments[0] ?? "";
143
144 let score = -levenshteinDistance(normalizedRequested, normalizedCandidate);
145
146 if (requestedNamespace && requestedNamespace === candidateNamespace) {
147 score += 6;
148 }
149
150 if (normalizedCandidate.includes(normalizedRequested) || normalizedRequested.includes(normalizedCandidate)) {
151 score += 3;
152 }
153
154 const sharedPrefix = Math.min(requestedSegments.length, candidateSegments.length);
155 let prefixMatches = 0;
156 for (let i = 0; i < sharedPrefix; i++) {
157 if (requestedSegments[i] !== candidateSegments[i]) break;
158 prefixMatches += 1;
159 }
160 score += prefixMatches * 2;
161
162 return { path, score };
163 })
164 .sort((a, b) => b.score - a.score)
165 .slice(0, limit)
166 .map((item) => item.path);
167
168 return scored;
169}
170
67function normalizeExternalToolSource(raw: {171function normalizeExternalToolSource(raw: {
68 type: ToolSourceRecord["type"];172 type: ToolSourceRecord["type"];
69 name: string;173 name: string;
@@ -678,19 +782,33 @@ async function invokeTool(ctx: any, task: TaskRecord, call: ToolCallRequest): Pr · 608 unmodified lines
678 const typedPolicies = policies as AccessPolicyRecord[];782 const typedPolicies = policies as AccessPolicyRecord[];
679 783
680 let workspaceTools: Map<string, ToolDefinition> | undefined;784 let workspaceTools: Map<string, ToolDefinition> | undefined;
785 let resolvedToolPath = toolPath;
681 let tool = baseTools.get(toolPath);786 let tool = baseTools.get(toolPath);
682 if (!tool) {787 if (!tool) {
683 const result = await getWorkspaceTools(ctx, task.workspaceId);788 const result = await getWorkspaceTools(ctx, task.workspaceId);
684 workspaceTools = result.tools;789 workspaceTools = result.tools;
685 tool = workspaceTools.get(toolPath);790 tool = workspaceTools.get(toolPath);
791
792 if (!tool) {
793 const aliasedPath = resolveAliasedToolPath(toolPath, workspaceTools);
794 if (aliasedPath) {
795 resolvedToolPath = aliasedPath;
796 tool = workspaceTools.get(aliasedPath);
797 }
798 }
686 }799 }
687 800
688 if (!tool) {801 if (!tool) {
689 throw new Error(`Unknown tool: ${toolPath}`);802 const availableTools = workspaceTools ?? baseTools;
803 const suggestions = suggestToolPaths(toolPath, availableTools);
804 const suggestionText = suggestions.length > 0
805 ? `\nDid you mean: ${suggestions.map((path) => `tools.${path}`).join(", ")}`
806 : "";
807 throw new Error(`Unknown tool: ${toolPath}${suggestionText}`);
690 }808 }
691 809
692 let decision: PolicyDecision;810 let decision: PolicyDecision;
693 let effectiveToolPath = toolPath;811 let effectiveToolPath = resolvedToolPath;
694 if (tool._graphqlSource) {812 if (tool._graphqlSource) {
695 if (!workspaceTools) {813 if (!workspaceTools) {
696 const result = await getWorkspaceTools(ctx, task.workspaceId);814 const result = await getWorkspaceTools(ctx, task.workspaceId);
executor/lib/mcp_server.ts+7−3
@@ -276,15 +276,19 @@ function waitForTerminalTask( · 275 unmodified lines
276 276
277function buildRunCodeDescription(tools?: ToolDescriptor[]): string {277function buildRunCodeDescription(tools?: ToolDescriptor[]): string {
278 const base =278 const base =
279 "Execute TypeScript code in a sandboxed runtime. The code has access to a `tools` object with typed methods for calling external services. Use `return` to return a value. Waits for completion and returns stdout/stderr. Code is typechecked before execution — type errors are returned without running.";279 "Execute TypeScript code in a sandboxed runtime. The code has access to a `tools` object with typed methods for calling external services. Use `return` to return a value. Waits for completion and returns stdout/stderr. Code is typechecked before execution — type errors are returned without running. Runtime has no filesystem/process/import access; use `tools.*` for external calls.";
280 const toolList = tools ?? [];280 const toolList = tools ?? [];
281 const topLevelKeys = listTopLevelToolKeys(toolList);281 const topLevelKeys = listTopLevelToolKeys(toolList);
282 const rootKeysNote = topLevelKeys.length > 0282 const rootKeysNote = topLevelKeys.length > 0
283 ? `\n\nTop-level tool keys: ${topLevelKeys.join(", ")}`283 ? `\n\nTop-level tool keys: ${topLevelKeys.join(", ")}`
284 : "";284 : "";
285 const discoverNote = "\n\nTip: use `tools.discover({ query, depth?, limit? })` to find exact callable tool paths before invoking them. Do not assign to `const tools = ...`; use a different variable name (e.g. `const discovered = ...`).";285 const hasGraphqlTools = toolList.some((tool) => tool.path.endsWith(".graphql"));
286 const discoverNote = "\n\nTip: use `tools.discover({ query, depth?, limit? })` first. It returns `{ results, total }`; call the exact `results[i].path` (or copy `results[i].exampleCall`). Do not assign to `const tools = ...`; use a different variable name (e.g. `const discovered = ...`).";
287 const graphqlNote = hasGraphqlTools
288 ? "\n\nGraphQL tip: prefer `source.query.*` / `source.mutation.*` helper paths when available; GraphQL tools return `{ data, errors }`."
289 : "";
286 290
287 return base + rootKeysNote + discoverNote + generateToolInventory(toolList);291 return base + rootKeysNote + discoverNote + graphqlNote + generateToolInventory(toolList);
288}292}
289 293
290function formatApprovalInput(input: unknown, maxLength = 2000): string {294function formatApprovalInput(input: unknown, maxLength = 2000): string {
executor/lib/runtimes/runtime-core.test.ts+4−4
@@ -360,16 +360,16 @@ test("runs openapi and graphql sourced tools through the runtime", async () => { · 359 unmodified lines
360 360
361 const result = await runCodeWithAdapter(361 const result = await runCodeWithAdapter(
362 request(`362 request(`
363 const sum = await tools.calc.math.addnumbers({ a: 2, b: 5 });363 const sum = await tools.calc.math.add_numbers({ a: 2, b: 5 });
364 const hello = await tools.gql.query.hello({});364 const hello = await tools.gql.query.hello({});
365 const inc = await tools.gql.graphql({365 const inc = await tools.gql.graphql({
366 query: "mutation($value: Int!) { increment(value: $value) }",366 query: "mutation($value: Int!) { increment(value: $value) }",
367 variables: { value: 3 },367 variables: { value: 3 },
368 });368 });
369 console.log("sum", sum.sum);369 console.log("sum", sum.sum);
370 console.log("hello", hello.hello);370 console.log("hello", hello.data);
371 console.log("inc", inc.increment);371 console.log("inc", inc.data.increment);
372 return sum.sum + inc.increment;372 return sum.sum + inc.data.increment;
373 `),373 `),
374 createRuntimeAdapter(toolMap, outputEvents),374 createRuntimeAdapter(toolMap, outputEvents),
375 );375 );
executor/lib/tool-discovery.test.ts+66−0
@@ -0,0 +1,66 @@
1import { expect, test } from "bun:test";
2import { createDiscoverTool } from "./tool_discovery";
3import type { ToolDefinition } from "./types";
4
5test("discover returns aliases and example calls", async () => {
6 const tool = createDiscoverTool([
7 {
8 path: "calc.math.add_numbers",
9 description: "Add numbers",
10 approval: "auto",
11 source: "openapi:calc",
12 metadata: {
13 argsType: "{ a: number; b: number }",
14 returnsType: "{ sum: number }",
15 },
16 run: async () => ({ sum: 0 }),
17 } satisfies ToolDefinition,
18 ]);
19
20 const result = await tool.run(
21 { query: "addnumbers", depth: 2 },
22 { taskId: "t", workspaceId: "w", isToolAllowed: () => true },
23 ) as {
24 results: Array<{
25 path: string;
26 aliases: string[];
27 exampleCall: string;
28 signature: string;
29 }>;
30 total: number;
31 };
32
33 expect(result.total).toBe(1);
34 expect(result.results[0]?.path).toBe("calc.math.add_numbers");
35 expect(result.results[0]?.aliases).toContain("calc.math.addNumbers");
36 expect(result.results[0]?.aliases).toContain("calc.math.addnumbers");
37 expect(result.results[0]?.exampleCall).toBe("await tools.calc.math.add_numbers({ a: ..., b: ... });");
38 expect(result.results[0]?.signature).toContain("Promise<{ sum: number }>");
39});
40
41test("discover example call handles input-shaped args", async () => {
42 const tool = createDiscoverTool([
43 {
44 path: "linear.mutation.issuecreate",
45 description: "Create issue",
46 approval: "required",
47 source: "graphql:linear",
48 metadata: {
49 argsType: "{ input: { teamId: string; title: string } }",
50 returnsType: "{ data: { id: string }; errors: unknown[] }",
51 },
52 run: async () => ({ data: { id: "x" }, errors: [] }),
53 } satisfies ToolDefinition,
54 ]);
55
56 const result = await tool.run(
57 { query: "issuecreate", depth: 2 },
58 { taskId: "t", workspaceId: "w", isToolAllowed: () => true },
59 ) as {
60 results: Array<{ exampleCall: string }>;
61 };
62
63 expect(result.results[0]?.exampleCall).toBe(
64 "await tools.linear.mutation.issuecreate({ input: { /* ... */ } });",
65 );
66});
executor/lib/tool-sources.test.ts+277−0
@@ -234,3 +234,280 @@ test("loadExternalTools tolerates OpenAPI specs with broken internal refs", asyn · 233 unmodified lines
234 expect(toolPaths).toContain("intercom_like.contacts.list_contacts");234 expect(toolPaths).toContain("intercom_like.contacts.list_contacts");
235 expect(toolPaths).toContain("intercom_like.conversations.create_conversation");235 expect(toolPaths).toContain("intercom_like.conversations.create_conversation");
236});236});
237
238test("graphql helper tools generate valid selection sets and envelope responses", async () => {
239 const capturedQueries: string[] = [];
240 const capturedVariables: Array<Record<string, unknown> | undefined> = [];
241
242 const server = Bun.serve({
243 port: 0,
244 fetch: async (req) => {
245 const body = (await req.json()) as { query?: string; variables?: Record<string, unknown> };
246 const query = String(body.query ?? "");
247 capturedQueries.push(query);
248 capturedVariables.push(body.variables);
249
250 if (query.includes("__schema")) {
251 return Response.json({
252 data: {
253 __schema: {
254 queryType: { name: "Query" },
255 mutationType: { name: "Mutation" },
256 types: [
257 {
258 kind: "OBJECT",
259 name: "Query",
260 fields: [
261 {
262 name: "teams",
263 description: null,
264 args: [],
265 type: { kind: "OBJECT", name: "TeamConnection", ofType: null },
266 },
267 ],
268 inputFields: null,
269 enumValues: null,
270 },
271 {
272 kind: "OBJECT",
273 name: "Mutation",
274 fields: [
275 {
276 name: "issueBatchCreate",
277 description: null,
278 args: [
279 {
280 name: "input",
281 description: null,
282 defaultValue: null,
283 type: {
284 kind: "NON_NULL",
285 name: null,
286 ofType: { kind: "INPUT_OBJECT", name: "IssueBatchCreateInput", ofType: null },
287 },
288 },
289 ],
290 type: { kind: "OBJECT", name: "IssueBatchCreatePayload", ofType: null },
291 },
292 ],
293 inputFields: null,
294 enumValues: null,
295 },
296 {
297 kind: "OBJECT",
298 name: "TeamConnection",
299 fields: [
300 {
301 name: "totalCount",
302 description: null,
303 args: [],
304 type: { kind: "SCALAR", name: "Int", ofType: null },
305 },
306 {
307 name: "nodes",
308 description: null,
309 args: [],
310 type: {
311 kind: "LIST",
312 name: null,
313 ofType: { kind: "OBJECT", name: "Team", ofType: null },
314 },
315 },
316 ],
317 inputFields: null,
318 enumValues: null,
319 },
320 {
321 kind: "OBJECT",
322 name: "Team",
323 fields: [
324 {
325 name: "id",
326 description: null,
327 args: [],
328 type: { kind: "SCALAR", name: "ID", ofType: null },
329 },
330 {
331 name: "name",
332 description: null,
333 args: [],
334 type: { kind: "SCALAR", name: "String", ofType: null },
335 },
336 ],
337 inputFields: null,
338 enumValues: null,
339 },
340 {
341 kind: "OBJECT",
342 name: "IssueBatchCreatePayload",
343 fields: [
344 {
345 name: "success",
346 description: null,
347 args: [],
348 type: { kind: "SCALAR", name: "Boolean", ofType: null },
349 },
350 {
351 name: "issues",
352 description: null,
353 args: [],
354 type: {
355 kind: "LIST",
356 name: null,
357 ofType: { kind: "OBJECT", name: "Issue", ofType: null },
358 },
359 },
360 ],
361 inputFields: null,
362 enumValues: null,
363 },
364 {
365 kind: "OBJECT",
366 name: "Issue",
367 fields: [
368 {
369 name: "id",
370 description: null,
371 args: [],
372 type: { kind: "SCALAR", name: "ID", ofType: null },
373 },
374 {
375 name: "identifier",
376 description: null,
377 args: [],
378 type: { kind: "SCALAR", name: "String", ofType: null },
379 },
380 ],
381 inputFields: null,
382 enumValues: null,
383 },
384 {
385 kind: "INPUT_OBJECT",
386 name: "IssueBatchCreateInput",
387 fields: null,
388 inputFields: [
389 {
390 name: "issues",
391 description: null,
392 defaultValue: null,
393 type: {
394 kind: "LIST",
395 name: null,
396 ofType: { kind: "SCALAR", name: "String", ofType: null },
397 },
398 },
399 ],
400 enumValues: null,
401 },
402 { kind: "SCALAR", name: "String", fields: null, inputFields: null, enumValues: null },
403 { kind: "SCALAR", name: "ID", fields: null, inputFields: null, enumValues: null },
404 { kind: "SCALAR", name: "Int", fields: null, inputFields: null, enumValues: null },
405 { kind: "SCALAR", name: "Boolean", fields: null, inputFields: null, enumValues: null },
406 ],
407 },
408 },
409 });
410 }
411
412 if (query.includes("issueBatchCreate")) {
413 if (!/issueBatchCreate(?:\([^)]*\))?\s*\{/.test(query)) {
414 return Response.json({ errors: [{ message: "issueBatchCreate requires a selection set" }] });
415 }
416 return Response.json({
417 data: {
418 issueBatchCreate: {
419 success: true,
420 issues: [{ id: "issue_1", identifier: "RHY-1" }],
421 },
422 },
423 });
424 }
425
426 if (query.includes("teams")) {
427 if (!/teams(?:\([^)]*\))?\s*\{/.test(query)) {
428 return Response.json({ errors: [{ message: "teams requires a selection set" }] });
429 }
430 return Response.json({
431 data: {
432 teams: {
433 totalCount: 1,
434 nodes: [{ id: "team_1", name: "Core" }],
435 },
436 },
437 });
438 }
439
440 return Response.json({ errors: [{ message: "Unknown operation" }] });
441 },
442 });
443
444 try {
445 const { tools, warnings } = await loadExternalTools([
446 {
447 type: "graphql",
448 name: "linear",
449 endpoint: `http://127.0.0.1:${server.port}/graphql`,
450 },
451 ]);
452
453 expect(warnings).toHaveLength(0);
454 const toolMap = new Map(tools.map((tool) => [tool.path, tool]));
455
456 const teamsTool = toolMap.get("linear.query.teams");
457 const batchTool = toolMap.get("linear.mutation.issuebatchcreate");
458 const rawTool = toolMap.get("linear.graphql");
459
460 expect(teamsTool).toBeDefined();
461 expect(batchTool).toBeDefined();
462 expect(rawTool).toBeDefined();
463
464 const context = { taskId: "t", workspaceId: "w", isToolAllowed: () => true };
465
466 const teamsResult = await teamsTool!.run({}, context);
467 const batchResult = await batchTool!.run({ input: { issues: ["one"] } }, context);
468 const batchResultDoubleWrapped = await batchTool!.run({ input: { input: { issues: ["two"] } } }, context);
469 const rawResult = await rawTool!.run({ query: "query { teams { totalCount } }" }, context);
470
471 expect(teamsResult).toEqual({
472 data: {
473 totalCount: 1,
474 nodes: [{ id: "team_1", name: "Core" }],
475 },
476 errors: [],
477 });
478 expect(batchResult).toEqual({
479 data: {
480 success: true,
481 issues: [{ id: "issue_1", identifier: "RHY-1" }],
482 },
483 errors: [],
484 });
485 expect(batchResultDoubleWrapped).toEqual({
486 data: {
487 success: true,
488 issues: [{ id: "issue_1", identifier: "RHY-1" }],
489 },
490 errors: [],
491 });
492 expect(rawResult).toEqual({
493 data: {
494 teams: {
495 totalCount: 1,
496 nodes: [{ id: "team_1", name: "Core" }],
497 },
498 },
499 errors: [],
500 });
501
502 const helperQueries = capturedQueries.filter((query) => !query.includes("__schema"));
503 const helperVariables = capturedVariables.slice(1);
504 expect(helperQueries.some((query) => /teams(?:\([^)]*\))?\s*\{/.test(query))).toBe(true);
505 expect(helperQueries.some((query) => /issueBatchCreate(?:\([^)]*\))?\s*\{/.test(query))).toBe(true);
506 expect(helperQueries.some((query) => /teams(?:\([^)]*\))?\s*\{[^}]*nodes\s*\{/.test(query))).toBe(true);
507 expect(helperQueries.some((query) => /issueBatchCreate(?:\([^)]*\))?\s*\{[^}]*issues\s*\{/.test(query))).toBe(true);
508 expect(helperVariables.some((variables) => JSON.stringify(variables) === JSON.stringify({ input: { issues: ["one"] } }))).toBe(true);
509 expect(helperVariables.some((variables) => JSON.stringify(variables) === JSON.stringify({ input: { issues: ["two"] } }))).toBe(true);
510 } finally {
511 server.stop(true);
512 }
513});
executor/lib/tool_discovery.ts+119−13
@@ -2,30 +2,131 @@ import type { ToolDefinition } from "./types"; · 1 unmodified line
2 2
3interface DiscoverIndexEntry {3interface DiscoverIndexEntry {
4 path: string;4 path: string;
5 aliases: string[];
5 description: string;6 description: string;
6 approval: ToolDefinition["approval"];7 approval: ToolDefinition["approval"];
7 source: string;8 source: string;
8 argsType: string;9 argsType: string;
9 returnsType: string;10 returnsType: string;
10 searchText: string;11 searchText: string;
12 normalizedPath: string;
13 normalizedSearchText: string;
11}14}
12 15
13function normalizeType(type?: string): string {16function normalizeType(type?: string): string {
14 return type && type.trim().length > 0 ? type : "unknown";17 return type && type.trim().length > 0 ? type : "unknown";
15}18}
16 19
20function normalizeSearchToken(value: string): string {
21 return value.toLowerCase().replace(/[^a-z0-9]/g, "");
22}
23
24function toCamelSegment(segment: string): string {
25 return segment.replace(/_+([a-z0-9])/g, (_m, char: string) => char.toUpperCase());
26}
27
28function getPathAliases(path: string): string[] {
29 const segments = path.split(".").filter(Boolean);
30 if (segments.length === 0) return [];
31
32 const aliases = new Set<string>();
33 const camelPath = segments.map(toCamelSegment).join(".");
34 const compactPath = segments.map((segment) => segment.replace(/[_-]/g, "")).join(".");
35 const lowerPath = path.toLowerCase();
36
37 if (camelPath !== path) aliases.add(camelPath);
38 if (compactPath !== path) aliases.add(compactPath);
39 if (lowerPath !== path) aliases.add(lowerPath);
40
41 return [...aliases].slice(0, 4);
42}
43
44function extractTopLevelArgKeys(argsType: string): string[] {
45 const text = argsType.trim();
46 if (!text.startsWith("{") || !text.endsWith("}")) return [];
47
48 const inner = text.slice(1, -1);
49 const keys: string[] = [];
50 let segment = "";
51 let depthCurly = 0;
52 let depthSquare = 0;
53 let depthParen = 0;
54 let depthAngle = 0;
55
56 const flushSegment = () => {
57 const part = segment.trim();
58 segment = "";
59 if (!part) return;
60 const colon = part.indexOf(":");
61 if (colon <= 0) return;
62 const rawKey = part.slice(0, colon).trim();
63 const cleanedKey = rawKey.replace(/[?"']/g, "").trim();
64 if (!cleanedKey || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(cleanedKey)) return;
65 if (!keys.includes(cleanedKey)) keys.push(cleanedKey);
66 };
67
68 for (const char of inner) {
69 if (char === "{" ) depthCurly += 1;
70 else if (char === "}" ) depthCurly = Math.max(0, depthCurly - 1);
71 else if (char === "[") depthSquare += 1;
72 else if (char === "]") depthSquare = Math.max(0, depthSquare - 1);
73 else if (char === "(") depthParen += 1;
74 else if (char === ")") depthParen = Math.max(0, depthParen - 1);
75 else if (char === "<") depthAngle += 1;
76 else if (char === ">") depthAngle = Math.max(0, depthAngle - 1);
77
78 if (char === ";" && depthCurly === 0 && depthSquare === 0 && depthParen === 0 && depthAngle === 0) {
79 flushSegment();
80 continue;
81 }
82
83 segment += char;
84 }
85
86 flushSegment();
87 return keys;
88}
89
90function buildExampleCall(entry: DiscoverIndexEntry): string {
91 if (entry.path.endsWith(".graphql")) {
92 return `await tools.${entry.path}({ query: "query { __typename }", variables: {} });`;
93 }
94
95 if (entry.argsType === "{}") {
96 return `await tools.${entry.path}({});`;
97 }
98
99 const keys = extractTopLevelArgKeys(entry.argsType);
100 if (keys.length > 0) {
101 const argsSnippet = keys.slice(0, 3)
102 .map((key) => `${key}: ${key.toLowerCase().includes("input") ? "{ /* ... */ }" : "..."}`)
103 .join(", ");
104 return `await tools.${entry.path}({ ${argsSnippet} });`;
105 }
106
107 return `await tools.${entry.path}({ /* ... */ });`;
108}
109
17function buildIndex(tools: ToolDefinition[]): DiscoverIndexEntry[] {110function buildIndex(tools: ToolDefinition[]): DiscoverIndexEntry[] {
18 return tools111 return tools
19 .filter((tool) => tool.path !== "discover")112 .filter((tool) => tool.path !== "discover")
20 .map((tool) => ({113 .map((tool) => {
21 path: tool.path,114 const aliases = getPathAliases(tool.path);
22 description: tool.description,115 const searchText = `${tool.path} ${aliases.join(" ")} ${tool.description} ${tool.source ?? ""}`.toLowerCase();
23 approval: tool.approval,116
24 source: tool.source ?? "local",117 return {
25 argsType: normalizeType(tool.metadata?.argsType),118 path: tool.path,
26 returnsType: normalizeType(tool.metadata?.returnsType),119 aliases,
27 searchText: `${tool.path} ${tool.description} ${tool.source ?? ""}`.toLowerCase(),120 description: tool.description,
28 }));121 approval: tool.approval,
122 source: tool.source ?? "local",
123 argsType: normalizeType(tool.metadata?.argsType),
124 returnsType: normalizeType(tool.metadata?.returnsType),
125 searchText,
126 normalizedPath: normalizeSearchToken(tool.path),
127 normalizedSearchText: normalizeSearchToken(searchText),
128 };
129 });
29}130}
30 131
31function scoreEntry(entry: DiscoverIndexEntry, terms: string[]): number {132function scoreEntry(entry: DiscoverIndexEntry, terms: string[]): number {
@@ -33,12 +134,15 @@ function scoreEntry(entry: DiscoverIndexEntry, terms: string[]): number { · 1 unmodified line
33 let matched = 0;134 let matched = 0;
34 135
35 for (const term of terms) {136 for (const term of terms) {
137 const normalizedTerm = normalizeSearchToken(term);
36 const inPath = entry.path.toLowerCase().includes(term);138 const inPath = entry.path.toLowerCase().includes(term);
139 const inNormalizedPath = normalizedTerm.length > 0 && entry.normalizedPath.includes(normalizedTerm);
37 const inText = entry.searchText.includes(term);140 const inText = entry.searchText.includes(term);
38 if (!inPath && !inText) continue;141 const inNormalizedText = normalizedTerm.length > 0 && entry.normalizedSearchText.includes(normalizedTerm);
142 if (!inPath && !inText && !inNormalizedPath && !inNormalizedText) continue;
39 matched += 1;143 matched += 1;
40 score += 1;144 score += 1;
41 if (inPath) score += 2;145 if (inPath || inNormalizedPath) score += 2;
42 }146 }
43 147
44 if (terms.length > 0 && matched < Math.max(1, Math.ceil(terms.length / 2))) {148 if (terms.length > 0 && matched < Math.max(1, Math.ceil(terms.length / 2))) {
@@ -66,11 +170,11 @@ export function createDiscoverTool(tools: ToolDefinition[]): ToolDefinition { · 21 unmodified lines
66 source: "system",170 source: "system",
67 approval: "auto",171 approval: "auto",
68 description:172 description:
69 "Search available tools by keyword. Returns tool path, source, approval mode, and signature hints so code can call tools accurately.",173 "Search available tools by keyword. Returns canonical path, aliases, signature hints, and ready-to-copy call examples.",
70 metadata: {174 metadata: {
71 argsType: "{ query: string; depth?: number; limit?: number }",175 argsType: "{ query: string; depth?: number; limit?: number }",
72 returnsType:176 returnsType:
73 "{ results: Array<{ path: string; source: string; approval: 'auto' | 'required'; description: string; signature: string }>; total: number }",177 "{ results: Array<{ path: string; aliases: string[]; source: string; approval: 'auto' | 'required'; description: string; signature: string; exampleCall: string }>; total: number }",
74 },178 },
75 run: async (input: unknown, context) => {179 run: async (input: unknown, context) => {
76 const payload = input && typeof input === "object" ? (input as Record<string, unknown>) : {};180 const payload = input && typeof input === "object" ? (input as Record<string, unknown>) : {};
@@ -87,10 +191,12 @@ export function createDiscoverTool(tools: ToolDefinition[]): ToolDefinition { · 10 unmodified lines
87 .slice(0, limit)191 .slice(0, limit)
88 .map(({ entry }) => ({192 .map(({ entry }) => ({
89 path: entry.path,193 path: entry.path,
194 aliases: entry.aliases,
90 source: entry.source,195 source: entry.source,
91 approval: entry.approval,196 approval: entry.approval,
92 description: entry.description,197 description: entry.description,
93 signature: formatSignature(entry, depth),198 signature: formatSignature(entry, depth),
199 exampleCall: buildExampleCall(entry),
94 }));200 }));
95 201
96 return {202 return {
executor/lib/tool_sources.ts+214−35
@@ -1169,6 +1169,80 @@ interface GqlSchema { · 1168 unmodified lines
1169 types: GqlType[];1169 types: GqlType[];
1170}1170}
1171 1171
1172interface GraphqlExecutionEnvelope {
1173 data: unknown;
1174 errors: unknown[];
1175}
1176
1177function hasGraphqlData(data: unknown): boolean {
1178 if (data === null || data === undefined) return false;
1179 if (Array.isArray(data)) return data.length > 0;
1180 if (typeof data === "object") return Object.keys(data as Record<string, unknown>).length > 0;
1181 return true;
1182}
1183
1184function normalizeGraphqlEnvelope(result: { data?: unknown; errors?: unknown[] }): GraphqlExecutionEnvelope {
1185 return {
1186 data: result.data ?? null,
1187 errors: Array.isArray(result.errors) ? result.errors : [],
1188 };
1189}
1190
1191function selectGraphqlFieldEnvelope(
1192 envelope: GraphqlExecutionEnvelope,
1193 operationName: string,
1194): GraphqlExecutionEnvelope {
1195 const data = envelope.data;
1196 if (data && typeof data === "object" && !Array.isArray(data)) {
1197 const record = data as Record<string, unknown>;
1198 if (Object.prototype.hasOwnProperty.call(record, operationName)) {
1199 return {
1200 data: record[operationName],
1201 errors: envelope.errors,
1202 };
1203 }
1204 }
1205
1206 return envelope;
1207}
1208
1209function normalizeGraphqlFieldVariables(
1210 argNames: string[],
1211 payload: Record<string, unknown>,
1212): Record<string, unknown> | undefined {
1213 const variablePayload: Record<string, unknown> = { ...payload };
1214 delete variablePayload.query;
1215 delete variablePayload.variables;
1216
1217 if (Object.keys(variablePayload).length === 0) {
1218 return undefined;
1219 }
1220
1221 if (argNames.length === 1) {
1222 const argName = argNames[0]!;
1223 if (Object.prototype.hasOwnProperty.call(variablePayload, argName)) {
1224 const value = variablePayload[argName];
1225 if (
1226 Object.keys(variablePayload).length === 1
1227 && value
1228 && typeof value === "object"
1229 && !Array.isArray(value)
1230 ) {
1231 const nested = value as Record<string, unknown>;
1232 if (Object.keys(nested).length === 1 && Object.prototype.hasOwnProperty.call(nested, argName)) {
1233 return { [argName]: nested[argName] };
1234 }
1235 }
1236 return variablePayload;
1237 }
1238
1239 // Common LLM shape: pass fields directly for single `input` argument.
1240 return { [argName]: variablePayload };
1241 }
1242
1243 return variablePayload;
1244}
1245
1172/** Resolve a GqlTypeRef to the underlying named type (unwrapping NON_NULL/LIST wrappers) */1246/** Resolve a GqlTypeRef to the underlying named type (unwrapping NON_NULL/LIST wrappers) */
1173function unwrapType(ref: GqlTypeRef): string | null {1247function unwrapType(ref: GqlTypeRef): string | null {
1174 if (ref.kind === "NON_NULL" && ref.ofType) return unwrapType(ref.ofType);1248 if (ref.kind === "NON_NULL" && ref.ofType) return unwrapType(ref.ofType);
@@ -1242,18 +1316,122 @@ function gqlFieldArgsTypeHint(args: GqlField["args"], typeMap?: Map<string, GqlT · 67 unmodified lines
1242 return `{ ${entries.join("; ")} }`;1316 return `{ ${entries.join("; ")} }`;
1243}1317}
1244 1318
1319function isGraphqlLeafType(ref: GqlTypeRef, typeMap: Map<string, GqlType>): boolean {
1320 const name = unwrapType(ref);
1321 if (!name) return true;
1322 const resolved = typeMap.get(name);
1323 if (!resolved) return true;
1324 return resolved.kind === "SCALAR" || resolved.kind === "ENUM";
1325}
1326
1327function buildFieldSelectionSet(
1328 typeRef: GqlTypeRef,
1329 typeMap: Map<string, GqlType>,
1330 depth = 0,
1331 seenTypes = new Set<string>(),
1332): string {
1333 const namedType = unwrapType(typeRef);
1334 if (!namedType) return "";
1335
1336 const resolved = typeMap.get(namedType);
1337 if (!resolved) return "";
1338
1339 if (resolved.kind === "SCALAR" || resolved.kind === "ENUM") {
1340 return "";
1341 }
1342
1343 if (resolved.kind === "UNION") {
1344 return "{ __typename }";
1345 }
1346
1347 if (depth >= 2 || seenTypes.has(namedType)) {
1348 return "{ __typename }";
1349 }
1350
1351 const nextSeen = new Set(seenTypes);
1352 nextSeen.add(namedType);
1353
1354 if ((resolved.kind === "OBJECT" || resolved.kind === "INTERFACE") && resolved.fields) {
1355 const preferredLeafNames = ["id", "identifier", "key", "name", "title", "number", "url", "success"];
1356 const preferredNestedNames = ["nodes", "edges", "node", "items", "issue", "issues", "team", "teams", "viewer", "user"];
1357
1358 const fields = resolved.fields.filter((field) => !field.name.startsWith("__"));
1359 const leafFields = resolved.fields
1360 .filter((field) => !field.name.startsWith("__"))
1361 .filter((field) => isGraphqlLeafType(field.type, typeMap));
1362 const nestedFields = fields.filter((field) => !isGraphqlLeafType(field.type, typeMap));
1363
1364 const selectedParts: string[] = [];
1365 const selectedNames = new Set<string>();
1366
1367 for (const preferred of preferredLeafNames) {
1368 const match = leafFields.find((field) => field.name === preferred);
1369 if (!match || selectedNames.has(match.name)) continue;
1370 selectedNames.add(match.name);
1371 selectedParts.push(match.name);
1372 if (selectedParts.length >= 2) break;
1373 }
1374
1375 if (selectedParts.length < 2) {
1376 for (const field of leafFields) {
1377 if (selectedNames.has(field.name)) continue;
1378 selectedNames.add(field.name);
1379 selectedParts.push(field.name);
1380 if (selectedParts.length >= 2) break;
1381 }
1382 }
1383
1384 const nestedCandidates = [
1385 ...preferredNestedNames
1386 .map((name) => nestedFields.find((field) => field.name === name))
1387 .filter((field): field is GqlField => Boolean(field)),
1388 ...nestedFields,
1389 ];
1390
1391 if (selectedParts.length < 3) {
1392 for (const field of nestedCandidates) {
1393 if (selectedNames.has(field.name)) continue;
1394 const nestedSelection = buildFieldSelectionSet(field.type, typeMap, depth + 1, nextSeen);
1395 if (!nestedSelection) continue;
1396 selectedNames.add(field.name);
1397 selectedParts.push(`${field.name} ${nestedSelection}`);
1398 break;
1399 }
1400 }
1401
1402 if (selectedParts.length === 0) {
1403 return "{ __typename }";
1404 }
1405
1406 if (!selectedParts.includes("__typename")) {
1407 selectedParts.push("__typename");
1408 }
1409
1410 return `{ ${selectedParts.join(" ")} }`;
1411 }
1412
1413 return "{ __typename }";
1414}
1415
1245/** Build a minimal GraphQL document for a single root field with its arguments */1416/** Build a minimal GraphQL document for a single root field with its arguments */
1246function buildFieldQuery(1417function buildFieldQuery(
1247 operationType: "query" | "mutation",1418 operationType: "query" | "mutation",
1248 fieldName: string,1419 fieldName: string,
1249 args: GqlField["args"],1420 args: GqlField["args"],
1421 fieldType?: GqlTypeRef,
1422 typeMap?: Map<string, GqlType>,
1250): string {1423): string {
1424 const selectionSet = fieldType && typeMap
1425 ? buildFieldSelectionSet(fieldType, typeMap)
1426 : "";
1427 const selectionSuffix = selectionSet ? ` ${selectionSet}` : "";
1428
1251 if (args.length === 0) {1429 if (args.length === 0) {
1252 return `${operationType} { ${fieldName} }`;1430 return `${operationType} { ${fieldName}${selectionSuffix} }`;
1253 }1431 }
1254 const varDefs = args.map((a) => `$${a.name}: ${printGqlType(a.type)}`).join(", ");1432 const varDefs = args.map((a) => `$${a.name}: ${printGqlType(a.type)}`).join(", ");
1255 const fieldArgs = args.map((a) => `${a.name}: $${a.name}`).join(", ");1433 const fieldArgs = args.map((a) => `${a.name}: $${a.name}`).join(", ");
1256 return `${operationType}(${varDefs}) { ${fieldName}(${fieldArgs}) }`;1434 return `${operationType}(${varDefs}) { ${fieldName}(${fieldArgs})${selectionSuffix} }`;
1257}1435}
1258 1436
1259function printGqlType(ref: GqlTypeRef): string {1437function printGqlType(ref: GqlTypeRef): string {
@@ -1368,11 +1546,11 @@ async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise<ToolDe · 108 unmodified lines
1368 tools.push({1546 tools.push({
1369 path: mainToolPath,1547 path: mainToolPath,
1370 source: sourceKey,1548 source: sourceKey,
1371 description: `Execute a GraphQL query or mutation against ${config.name}. Use the ${sourceName}.query.* and ${sourceName}.mutation.* tool descriptions to see available operations.`,1549 description: `Execute a GraphQL query or mutation against ${config.name}. Returns { data, errors }. Use ${sourceName}.query.* and ${sourceName}.mutation.* helpers when available.`,
1372 approval: "auto", // Actual approval is determined dynamically per-invocation1550 approval: "auto", // Actual approval is determined dynamically per-invocation
1373 metadata: {1551 metadata: {
1374 argsType: "{ query: string; variables?: Record<string, unknown> }",1552 argsType: "{ query: string; variables?: Record<string, unknown> }",
1375 returnsType: "unknown",1553 returnsType: "{ data: unknown; errors: unknown[] }",
1376 },1554 },
1377 credential: credentialSpec,1555 credential: credentialSpec,
1378 // Tag as graphql source so invokeTool knows to do dynamic path extraction1556 // Tag as graphql source so invokeTool knows to do dynamic path extraction
@@ -1407,12 +1585,10 @@ async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise<ToolDe · 28 unmodified lines
1407 }1585 }
1408 1586
1409 const result = await response.json() as { data?: unknown; errors?: unknown[] };1587 const result = await response.json() as { data?: unknown; errors?: unknown[] };
1410 if (result.errors && (!result.data || Object.keys(result.data as object).length === 0)) {1588 if (result.errors && !hasGraphqlData(result.data)) {
1411 throw new Error(`GraphQL errors: ${JSON.stringify(result.errors).slice(0, 1000)}`);1589 throw new Error(`GraphQL errors: ${JSON.stringify(result.errors).slice(0, 1000)}`);
1412 }1590 }
1413 // Return both data and errors if partial1591 return normalizeGraphqlEnvelope(result);
1414 if (result.errors) return result;
1415 return result.data;
1416 },1592 },
1417 } as ToolDefinition & { _graphqlSource: string });1593 } as ToolDefinition & { _graphqlSource: string });
1418 1594
@@ -1439,18 +1615,21 @@ async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise<ToolDe · 20 unmodified lines
1439 const approval = config.overrides?.[field.name]?.approval ?? defaultApproval;1615 const approval = config.overrides?.[field.name]?.approval ?? defaultApproval;
1440 1616
1441 // Build the example query for the description1617 // Build the example query for the description
1442 const exampleQuery = buildFieldQuery(operationType, field.name, field.args);1618 const exampleQuery = buildFieldQuery(operationType, field.name, field.args, field.type, typeMap);
1619 const directCallExample = field.args.length === 0
1620 ? `tools.${fieldPath}({})`
1621 : `tools.${fieldPath}({ ${field.args.map((arg) => `${arg.name}: ...`).join(", ")} })`;
1443 1622
1444 tools.push({1623 tools.push({
1445 path: fieldPath,1624 path: fieldPath,
1446 source: sourceKey,1625 source: sourceKey,
1447 description: field.description1626 description: field.description
1448 ? `${field.description}\n\nExample: ${sourceName}.graphql({ query: \`${exampleQuery}\`, variables: {...} })`1627 ? `${field.description}\n\nPreferred: ${directCallExample}\nReturns: { data, errors }\nRaw GraphQL: ${sourceName}.graphql({ query: \`${exampleQuery}\`, variables: {...} })`
1449 : `GraphQL ${operationType}: ${field.name}\n\nExample: ${sourceName}.graphql({ query: \`${exampleQuery}\`, variables: {...} })`,1628 : `GraphQL ${operationType}: ${field.name}\n\nPreferred: ${directCallExample}\nReturns: { data, errors }\nRaw GraphQL: ${sourceName}.graphql({ query: \`${exampleQuery}\`, variables: {...} })`,
1450 approval,1629 approval,
1451 metadata: {1630 metadata: {
1452 argsType: gqlFieldArgsTypeHint(field.args, typeMap),1631 argsType: gqlFieldArgsTypeHint(field.args, typeMap),
1453 returnsType: gqlTypeToHint(field.type, typeMap),1632 returnsType: `{ data: ${gqlTypeToHint(field.type, typeMap)}; errors: unknown[] }`,
1454 },1633 },
1455 _runSpec: {1634 _runSpec: {
1456 kind: "graphql_field" as const,1635 kind: "graphql_field" as const,
@@ -1458,6 +1637,7 @@ async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise<ToolDe · 1 unmodified line
1458 operationName: field.name,1637 operationName: field.name,
1459 operationType,1638 operationType,
1460 queryTemplate: exampleQuery,1639 queryTemplate: exampleQuery,
1640 argNames: field.args.map((arg) => arg.name),
1461 authHeaders,1641 authHeaders,
1462 },1642 },
1463 // Pseudo-tools don't have a run — they exist for discovery and policy matching only1643 // Pseudo-tools don't have a run — they exist for discovery and policy matching only
@@ -1465,14 +1645,22 @@ async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise<ToolDe · 1 unmodified line
1465 run: async (input: unknown, context) => {1645 run: async (input: unknown, context) => {
1466 // If someone calls this directly, delegate to the main graphql tool1646 // If someone calls this directly, delegate to the main graphql tool
1467 const payload = asRecord(input);1647 const payload = asRecord(input);
1468 if (!payload.query) {1648 const hasExplicitQuery = typeof payload.query === "string" && payload.query.trim().length > 0;
1649 if (!hasExplicitQuery) {
1469 // Auto-build the query from the variables1650 // Auto-build the query from the variables
1470 payload.query = buildFieldQuery(operationType, field.name, field.args);1651 payload.query = buildFieldQuery(operationType, field.name, field.args, field.type, typeMap);
1652 if (payload.variables === undefined) {
1653 payload.variables = normalizeGraphqlFieldVariables(
1654 field.args.map((arg) => arg.name),
1655 payload,
1656 );
1657 }
1471 }1658 }
1472 // Find and invoke the main tool1659 // Find and invoke the main tool
1473 const mainTool = tools.find((t) => t.path === mainToolPath);1660 const mainTool = tools.find((t) => t.path === mainToolPath);
1474 if (!mainTool) throw new Error(`Main GraphQL tool not found`);1661 if (!mainTool) throw new Error(`Main GraphQL tool not found`);
1475 return mainTool.run(payload, context);1662 const envelope = await mainTool.run(payload, context) as GraphqlExecutionEnvelope;
1663 return selectGraphqlFieldEnvelope(envelope, field.name);
1476 },1664 },
1477 } as ToolDefinition & { _pseudoTool: boolean });1665 } as ToolDefinition & { _pseudoTool: boolean });
1478 }1666 }
@@ -1546,7 +1734,7 @@ export interface SerializedTool { · 67 unmodified lines
1546 * OpenAPI: { kind: "openapi", baseUrl, method, pathTemplate, parameters, authHeaders }1734 * OpenAPI: { kind: "openapi", baseUrl, method, pathTemplate, parameters, authHeaders }
1547 * MCP: { kind: "mcp", url, transport?, queryParams?, toolName }1735 * MCP: { kind: "mcp", url, transport?, queryParams?, toolName }
1548 * GraphQL raw: { kind: "graphql_raw", endpoint, authHeaders }1736 * GraphQL raw: { kind: "graphql_raw", endpoint, authHeaders }
1549 * GraphQL field: { kind: "graphql_field", endpoint, operationName, operationType, queryTemplate, authHeaders }1737 * GraphQL field: { kind: "graphql_field", endpoint, operationName, operationType, queryTemplate, argNames?, authHeaders }
1550 * GraphQL (legacy compat): { kind: "graphql", endpoint, operationName, operationType, authHeaders }1738 * GraphQL (legacy compat): { kind: "graphql", endpoint, operationName, operationType, authHeaders }
1551 * Builtin: { kind: "builtin" } — run comes from DEFAULT_TOOLS1739 * Builtin: { kind: "builtin" } — run comes from DEFAULT_TOOLS
1552 */1740 */
@@ -1577,6 +1765,7 @@ export interface SerializedTool { · 24 unmodified lines
1577 operationName: string;1765 operationName: string;
1578 operationType: "query" | "mutation";1766 operationType: "query" | "mutation";
1579 queryTemplate: string;1767 queryTemplate: string;
1768 argNames?: string[];
1580 authHeaders: Record<string, string>;1769 authHeaders: Record<string, string>;
1581 }1770 }
1582 | {1771 | {
@@ -1637,7 +1826,7 @@ export function rehydrateTools( · 54 unmodified lines
1637 query: string,1826 query: string,
1638 variables: unknown,1827 variables: unknown,
1639 context: { credential?: { headers: Record<string, string> } },1828 context: { credential?: { headers: Record<string, string> } },
1640 ): Promise<unknown> {1829 ): Promise<GraphqlExecutionEnvelope> {
1641 const response = await fetch(endpoint, {1830 const response = await fetch(endpoint, {
1642 method: "POST",1831 method: "POST",
1643 headers: {1832 headers: {
@@ -1654,11 +1843,10 @@ export function rehydrateTools( · 10 unmodified lines
1654 }1843 }
1655 1844
1656 const result = await response.json() as { data?: unknown; errors?: unknown[] };1845 const result = await response.json() as { data?: unknown; errors?: unknown[] };
1657 if (result.errors && (!result.data || Object.keys(result.data as object).length === 0)) {1846 if (result.errors && !hasGraphqlData(result.data)) {
1658 throw new Error(`GraphQL errors: ${JSON.stringify(result.errors).slice(0, 1000)}`);1847 throw new Error(`GraphQL errors: ${JSON.stringify(result.errors).slice(0, 1000)}`);
1659 }1848 }
1660 if (result.errors) return result;1849 return normalizeGraphqlEnvelope(result);
1661 return result.data;
1662 }1850 }
1663 1851
1664 return serialized.map((st) => {1852 return serialized.map((st) => {
@@ -1765,7 +1953,7 @@ export function rehydrateTools( · 100 unmodified lines
1765 }1953 }
1766 1954
1767 if (st.runSpec.kind === "graphql_field") {1955 if (st.runSpec.kind === "graphql_field") {
1768 const { endpoint, operationName, queryTemplate, authHeaders } = st.runSpec;1956 const { endpoint, operationName, queryTemplate, authHeaders, argNames } = st.runSpec;
1769 return {1957 return {
1770 ...base,1958 ...base,
1771 run: async (input: unknown, context) => {1959 run: async (input: unknown, context) => {
@@ -1775,17 +1963,11 @@ export function rehydrateTools( · 3 unmodified lines
1775 1963
1776 let variables = payload.variables;1964 let variables = payload.variables;
1777 if (variables === undefined && !hasExplicitQuery) {1965 if (variables === undefined && !hasExplicitQuery) {
1778 const variablePayload: Record<string, unknown> = { ...payload };1966 variables = normalizeGraphqlFieldVariables(argNames ?? [], payload);
1779 delete variablePayload.query;
1780 delete variablePayload.variables;
1781 variables = Object.keys(variablePayload).length > 0 ? variablePayload : undefined;
1782 }1967 }
1783 1968
1784 const result = await executeGraphql(endpoint, authHeaders, query, variables, context);1969 const envelope = await executeGraphql(endpoint, authHeaders, query, variables, context);
1785 if (result && typeof result === "object") {1970 return selectGraphqlFieldEnvelope(envelope, operationName);
1786 return (result as Record<string, unknown>)[operationName];
1787 }
1788 return result;
1789 },1971 },
1790 };1972 };
1791 }1973 }
@@ -1799,11 +1981,8 @@ export function rehydrateTools( · 7 unmodified lines
1799 run: async (input: unknown, context) => {1981 run: async (input: unknown, context) => {
1800 const payload = asRecord(input);1982 const payload = asRecord(input);
1801 const variables = payload.variables ?? { input: payload };1983 const variables = payload.variables ?? { input: payload };
1802 const result = await executeGraphql(endpoint, authHeaders, queryTemplate, variables, context);1984 const envelope = await executeGraphql(endpoint, authHeaders, queryTemplate, variables, context);
1803 if (result && typeof result === "object") {1985 return selectGraphqlFieldEnvelope(envelope, operationName);
1804 return (result as Record<string, unknown>)[operationName];
1805 }
1806 return result;
1807 },1986 },
1808 };1987 };
1809 }1988 }
executor/lib/typechecker.ts+7−1
@@ -218,14 +218,20 @@ export function generateToolInventory(tools: ToolDescriptor[]): string { · 217 unmodified lines
218 .slice(0, 8)218 .slice(0, 8)
219 .map((tool) => ` - tools.${tool.path}(...)`);219 .map((tool) => ` - tools.${tool.path}(...)`);
220 220
221 const hasGraphqlTools = tools.some((tool) => tool.path.endsWith(".graphql"));
222
221 return [223 return [
222 "",224 "",
223 "You have access to these tool namespaces:",225 "You have access to these tool namespaces:",
224 ` ${namespaces.join(", ")}`,226 ` ${namespaces.join(", ")}`,
225 "",227 "",
226 "Use `tools.discover({ query, depth?, limit? })` first to find the exact callable tool paths relevant to the user request.",228 "Use `tools.discover({ query, depth?, limit? })` first. It returns `{ results, total }` (not an array).",
229 "Use the exact `path` returned by discover, and prefer its `exampleCall` snippet for invocation shape.",
227 "Never shadow the global `tools` object (do NOT write `const tools = ...`).",230 "Never shadow the global `tools` object (do NOT write `const tools = ...`).",
228 "Then call tools directly using the returned path.",231 "Then call tools directly using the returned path.",
232 ...(hasGraphqlTools
233 ? ["GraphQL tools return `{ data, errors }`; prefer `source.query.*` / `source.mutation.*` helpers over raw `source.graphql` when available."]
234 : []),
229 ...(examples.length > 0235 ...(examples.length > 0
230 ? ["", "Example callable paths:", ...examples]236 ? ["", "Example callable paths:", ...examples]
231 : []),237 : []),
executor/lib/workspace-tool-cache.test.ts+2−2
@@ -350,8 +350,8 @@ describe("serializeTools + rehydrateTools round-trip", () => { · 349 unmodified lines
350 { taskId: "t", workspaceId: "w", isToolAllowed: () => true },350 { taskId: "t", workspaceId: "w", isToolAllowed: () => true },
351 );351 );
352 352
353 expect(rawResult).toEqual({ viewer: { id: "user_1" } });353 expect(rawResult).toEqual({ data: { viewer: { id: "user_1" } }, errors: [] });
354 expect(teamsResult).toEqual([{ id: "team_1", name: "Core" }]);354 expect(teamsResult).toEqual({ data: [{ id: "team_1", name: "Core" }], errors: [] });
355 expect(calls.length).toBe(2);355 expect(calls.length).toBe(2);
356 } finally {356 } finally {
357 globalThis.fetch = originalFetch;357 globalThis.fetch = originalFetch;