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
5757 }
5858
5959 function sourceSignature(workspaceId: string, sources: Array<{ id: string; updatedAt: number; enabled: boolean }>): string {
60− const signatureVersion = "v12";
60+ const signatureVersion = "v13";
6161 const parts = sources
6262 .map((source) => `${source.id}:${source.updatedAt}:${source.enabled ? 1 : 0}`)
6363 .sort();
6464 return `${signatureVersion}|${workspaceId}|${parts.join(",")}`;
6565 }
6666
67+function normalizeToolPathSegment(segment: string): string {
68+ return segment.toLowerCase().replace(/[^a-z0-9]/g, "");
69+}
70+
71+function normalizeToolPath(path: string): string {
72+ return path
73+ .split(".")
74+ .filter(Boolean)
75+ .map((segment) => normalizeToolPathSegment(segment))
76+ .join(".");
77+}
78+
79+function 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+
104+function 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+
129+function 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+
67171 function normalizeExternalToolSource(raw: {
68172 type: ToolSourceRecord["type"];
69173 name: string;
@@ -678,19 +782,33 @@ async function invokeTool(ctx: any, task: TaskRecord, call: ToolCallRequest): Pr · 608 unmodified lines
678782 const typedPolicies = policies as AccessPolicyRecord[];
679783
680784 let workspaceTools: Map<string, ToolDefinition> | undefined;
785+ let resolvedToolPath = toolPath;
681786 let tool = baseTools.get(toolPath);
682787 if (!tool) {
683788 const result = await getWorkspaceTools(ctx, task.workspaceId);
684789 workspaceTools = result.tools;
685790 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+ }
686799 }
687800
688801 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}`);
690808 }
691809
692810 let decision: PolicyDecision;
693− let effectiveToolPath = toolPath;
811+ let effectiveToolPath = resolvedToolPath;
694812 if (tool._graphqlSource) {
695813 if (!workspaceTools) {
696814 const result = await getWorkspaceTools(ctx, task.workspaceId);
executor/lib/mcp_server.ts+7−3
@@ -276,15 +276,19 @@ function waitForTerminalTask( · 275 unmodified lines
276276
277277 function buildRunCodeDescription(tools?: ToolDescriptor[]): string {
278278 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.";
280280 const toolList = tools ?? [];
281281 const topLevelKeys = listTopLevelToolKeys(toolList);
282282 const rootKeysNote = topLevelKeys.length > 0
283283 ? `\n\nTop-level tool keys: ${topLevelKeys.join(", ")}`
284284 : "";
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+ : "";
286290
287− return base + rootKeysNote + discoverNote + generateToolInventory(toolList);
291+ return base + rootKeysNote + discoverNote + graphqlNote + generateToolInventory(toolList);
288292 }
289293
290294 function 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
360360
361361 const result = await runCodeWithAdapter(
362362 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 });
364364 const hello = await tools.gql.query.hello({});
365365 const inc = await tools.gql.graphql({
366366 query: "mutation($value: Int!) { increment(value: $value) }",
367367 variables: { value: 3 },
368368 });
369369 console.log("sum", sum.sum);
370− console.log("hello", hello.hello);
371− console.log("inc", inc.increment);
372− return sum.sum + inc.increment;
370+ console.log("hello", hello.data);
371+ console.log("inc", inc.data.increment);
372+ return sum.sum + inc.data.increment;
373373 `),
374374 createRuntimeAdapter(toolMap, outputEvents),
375375 );
executor/lib/tool-discovery.test.ts+66−0
@@ -0,0 +1,66 @@
1+import { expect, test } from "bun:test";
2+import { createDiscoverTool } from "./tool_discovery";
3+import type { ToolDefinition } from "./types";
4+
5+test("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+
41+test("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
234234 expect(toolPaths).toContain("intercom_like.contacts.list_contacts");
235235 expect(toolPaths).toContain("intercom_like.conversations.create_conversation");
236236 });
237+
238+test("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
22
33 interface DiscoverIndexEntry {
44 path: string;
5+ aliases: string[];
56 description: string;
67 approval: ToolDefinition["approval"];
78 source: string;
89 argsType: string;
910 returnsType: string;
1011 searchText: string;
12+ normalizedPath: string;
13+ normalizedSearchText: string;
1114 }
1215
1316 function normalizeType(type?: string): string {
1417 return type && type.trim().length > 0 ? type : "unknown";
1518 }
1619
20+function normalizeSearchToken(value: string): string {
21+ return value.toLowerCase().replace(/[^a-z0-9]/g, "");
22+}
23+
24+function toCamelSegment(segment: string): string {
25+ return segment.replace(/_+([a-z0-9])/g, (_m, char: string) => char.toUpperCase());
26+}
27+
28+function 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+
44+function 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+
90+function 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+
17110 function buildIndex(tools: ToolDefinition[]): DiscoverIndexEntry[] {
18111 return tools
19112 .filter((tool) => tool.path !== "discover")
20− .map((tool) => ({
21− path: tool.path,
22− description: tool.description,
23− approval: tool.approval,
24− source: tool.source ?? "local",
25− argsType: normalizeType(tool.metadata?.argsType),
26− returnsType: normalizeType(tool.metadata?.returnsType),
27− searchText: `${tool.path} ${tool.description} ${tool.source ?? ""}`.toLowerCase(),
28− }));
113+ .map((tool) => {
114+ const aliases = getPathAliases(tool.path);
115+ const searchText = `${tool.path} ${aliases.join(" ")} ${tool.description} ${tool.source ?? ""}`.toLowerCase();
116+
117+ return {
118+ path: tool.path,
119+ aliases,
120+ description: tool.description,
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+ });
29130 }
30131
31132 function scoreEntry(entry: DiscoverIndexEntry, terms: string[]): number {
@@ -33,12 +134,15 @@ function scoreEntry(entry: DiscoverIndexEntry, terms: string[]): number { · 1 unmodified line
33134 let matched = 0;
34135
35136 for (const term of terms) {
137+ const normalizedTerm = normalizeSearchToken(term);
36138 const inPath = entry.path.toLowerCase().includes(term);
139+ const inNormalizedPath = normalizedTerm.length > 0 && entry.normalizedPath.includes(normalizedTerm);
37140 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;
39143 matched += 1;
40144 score += 1;
41− if (inPath) score += 2;
145+ if (inPath || inNormalizedPath) score += 2;
42146 }
43147
44148 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
66170 source: "system",
67171 approval: "auto",
68172 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.",
70174 metadata: {
71175 argsType: "{ query: string; depth?: number; limit?: number }",
72176 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 }",
74178 },
75179 run: async (input: unknown, context) => {
76180 const payload = input && typeof input === "object" ? (input as Record<string, unknown>) : {};
@@ -87,10 +191,12 @@ export function createDiscoverTool(tools: ToolDefinition[]): ToolDefinition { · 10 unmodified lines
87191 .slice(0, limit)
88192 .map(({ entry }) => ({
89193 path: entry.path,
194+ aliases: entry.aliases,
90195 source: entry.source,
91196 approval: entry.approval,
92197 description: entry.description,
93198 signature: formatSignature(entry, depth),
199+ exampleCall: buildExampleCall(entry),
94200 }));
95201
96202 return {
executor/lib/tool_sources.ts+214−35
@@ -1169,6 +1169,80 @@ interface GqlSchema { · 1168 unmodified lines
11691169 types: GqlType[];
11701170 }
11711171
1172+interface GraphqlExecutionEnvelope {
1173+ data: unknown;
1174+ errors: unknown[];
1175+}
1176+
1177+function 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+
1184+function 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+
1191+function 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+
1209+function 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+
11721246 /** Resolve a GqlTypeRef to the underlying named type (unwrapping NON_NULL/LIST wrappers) */
11731247 function unwrapType(ref: GqlTypeRef): string | null {
11741248 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
12421316 return `{ ${entries.join("; ")} }`;
12431317 }
12441318
1319+function 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+
1327+function 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+
12451416 /** Build a minimal GraphQL document for a single root field with its arguments */
12461417 function buildFieldQuery(
12471418 operationType: "query" | "mutation",
12481419 fieldName: string,
12491420 args: GqlField["args"],
1421+ fieldType?: GqlTypeRef,
1422+ typeMap?: Map<string, GqlType>,
12501423 ): string {
1424+ const selectionSet = fieldType && typeMap
1425+ ? buildFieldSelectionSet(fieldType, typeMap)
1426+ : "";
1427+ const selectionSuffix = selectionSet ? ` ${selectionSet}` : "";
1428+
12511429 if (args.length === 0) {
1252− return `${operationType} { ${fieldName} }`;
1430+ return `${operationType} { ${fieldName}${selectionSuffix} }`;
12531431 }
12541432 const varDefs = args.map((a) => `$${a.name}: ${printGqlType(a.type)}`).join(", ");
12551433 const fieldArgs = args.map((a) => `${a.name}: $${a.name}`).join(", ");
1256− return `${operationType}(${varDefs}) { ${fieldName}(${fieldArgs}) }`;
1434+ return `${operationType}(${varDefs}) { ${fieldName}(${fieldArgs})${selectionSuffix} }`;
12571435 }
12581436
12591437 function printGqlType(ref: GqlTypeRef): string {
@@ -1368,11 +1546,11 @@ async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise<ToolDe · 108 unmodified lines
13681546 tools.push({
13691547 path: mainToolPath,
13701548 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.`,
13721550 approval: "auto", // Actual approval is determined dynamically per-invocation
13731551 metadata: {
13741552 argsType: "{ query: string; variables?: Record<string, unknown> }",
1375− returnsType: "unknown",
1553+ returnsType: "{ data: unknown; errors: unknown[] }",
13761554 },
13771555 credential: credentialSpec,
13781556 // 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
14071585 }
14081586
14091587 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)) {
14111589 throw new Error(`GraphQL errors: ${JSON.stringify(result.errors).slice(0, 1000)}`);
14121590 }
1413− // Return both data and errors if partial
1414− if (result.errors) return result;
1415− return result.data;
1591+ return normalizeGraphqlEnvelope(result);
14161592 },
14171593 } as ToolDefinition & { _graphqlSource: string });
14181594
@@ -1439,18 +1615,21 @@ async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise<ToolDe · 20 unmodified lines
14391615 const approval = config.overrides?.[field.name]?.approval ?? defaultApproval;
14401616
14411617 // 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(", ")} })`;
14431622
14441623 tools.push({
14451624 path: fieldPath,
14461625 source: sourceKey,
14471626 description: field.description
1448− ? `${field.description}\n\nExample: ${sourceName}.graphql({ query: \`${exampleQuery}\`, variables: {...} })`
1449− : `GraphQL ${operationType}: ${field.name}\n\nExample: ${sourceName}.graphql({ query: \`${exampleQuery}\`, variables: {...} })`,
1627+ ? `${field.description}\n\nPreferred: ${directCallExample}\nReturns: { data, errors }\nRaw GraphQL: ${sourceName}.graphql({ query: \`${exampleQuery}\`, variables: {...} })`
1628+ : `GraphQL ${operationType}: ${field.name}\n\nPreferred: ${directCallExample}\nReturns: { data, errors }\nRaw GraphQL: ${sourceName}.graphql({ query: \`${exampleQuery}\`, variables: {...} })`,
14501629 approval,
14511630 metadata: {
14521631 argsType: gqlFieldArgsTypeHint(field.args, typeMap),
1453− returnsType: gqlTypeToHint(field.type, typeMap),
1632+ returnsType: `{ data: ${gqlTypeToHint(field.type, typeMap)}; errors: unknown[] }`,
14541633 },
14551634 _runSpec: {
14561635 kind: "graphql_field" as const,
@@ -1458,6 +1637,7 @@ async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise<ToolDe · 1 unmodified line
14581637 operationName: field.name,
14591638 operationType,
14601639 queryTemplate: exampleQuery,
1640+ argNames: field.args.map((arg) => arg.name),
14611641 authHeaders,
14621642 },
14631643 // 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
14651645 run: async (input: unknown, context) => {
14661646 // If someone calls this directly, delegate to the main graphql tool
14671647 const payload = asRecord(input);
1468− if (!payload.query) {
1648+ const hasExplicitQuery = typeof payload.query === "string" && payload.query.trim().length > 0;
1649+ if (!hasExplicitQuery) {
14691650 // 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+ }
14711658 }
14721659 // Find and invoke the main tool
14731660 const mainTool = tools.find((t) => t.path === mainToolPath);
14741661 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);
14761664 },
14771665 } as ToolDefinition & { _pseudoTool: boolean });
14781666 }
@@ -1546,7 +1734,7 @@ export interface SerializedTool { · 67 unmodified lines
15461734 * OpenAPI: { kind: "openapi", baseUrl, method, pathTemplate, parameters, authHeaders }
15471735 * MCP: { kind: "mcp", url, transport?, queryParams?, toolName }
15481736 * 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 }
15501738 * GraphQL (legacy compat): { kind: "graphql", endpoint, operationName, operationType, authHeaders }
15511739 * Builtin: { kind: "builtin" } — run comes from DEFAULT_TOOLS
15521740 */
@@ -1577,6 +1765,7 @@ export interface SerializedTool { · 24 unmodified lines
15771765 operationName: string;
15781766 operationType: "query" | "mutation";
15791767 queryTemplate: string;
1768+ argNames?: string[];
15801769 authHeaders: Record<string, string>;
15811770 }
15821771 | {
@@ -1637,7 +1826,7 @@ export function rehydrateTools( · 54 unmodified lines
16371826 query: string,
16381827 variables: unknown,
16391828 context: { credential?: { headers: Record<string, string> } },
1640− ): Promise<unknown> {
1829+ ): Promise<GraphqlExecutionEnvelope> {
16411830 const response = await fetch(endpoint, {
16421831 method: "POST",
16431832 headers: {
@@ -1654,11 +1843,10 @@ export function rehydrateTools( · 10 unmodified lines
16541843 }
16551844
16561845 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)) {
16581847 throw new Error(`GraphQL errors: ${JSON.stringify(result.errors).slice(0, 1000)}`);
16591848 }
1660− if (result.errors) return result;
1661− return result.data;
1849+ return normalizeGraphqlEnvelope(result);
16621850 }
16631851
16641852 return serialized.map((st) => {
@@ -1765,7 +1953,7 @@ export function rehydrateTools( · 100 unmodified lines
17651953 }
17661954
17671955 if (st.runSpec.kind === "graphql_field") {
1768− const { endpoint, operationName, queryTemplate, authHeaders } = st.runSpec;
1956+ const { endpoint, operationName, queryTemplate, authHeaders, argNames } = st.runSpec;
17691957 return {
17701958 ...base,
17711959 run: async (input: unknown, context) => {
@@ -1775,17 +1963,11 @@ export function rehydrateTools( · 3 unmodified lines
17751963
17761964 let variables = payload.variables;
17771965 if (variables === undefined && !hasExplicitQuery) {
1778− const variablePayload: Record<string, unknown> = { ...payload };
1779− delete variablePayload.query;
1780− delete variablePayload.variables;
1781− variables = Object.keys(variablePayload).length > 0 ? variablePayload : undefined;
1966+ variables = normalizeGraphqlFieldVariables(argNames ?? [], payload);
17821967 }
17831968
1784− const result = await executeGraphql(endpoint, authHeaders, query, variables, context);
1785− if (result && typeof result === "object") {
1786− return (result as Record<string, unknown>)[operationName];
1787− }
1788− return result;
1969+ const envelope = await executeGraphql(endpoint, authHeaders, query, variables, context);
1970+ return selectGraphqlFieldEnvelope(envelope, operationName);
17891971 },
17901972 };
17911973 }
@@ -1799,11 +1981,8 @@ export function rehydrateTools( · 7 unmodified lines
17991981 run: async (input: unknown, context) => {
18001982 const payload = asRecord(input);
18011983 const variables = payload.variables ?? { input: payload };
1802− const result = await executeGraphql(endpoint, authHeaders, queryTemplate, variables, context);
1803− if (result && typeof result === "object") {
1804− return (result as Record<string, unknown>)[operationName];
1805− }
1806− return result;
1984+ const envelope = await executeGraphql(endpoint, authHeaders, queryTemplate, variables, context);
1985+ return selectGraphqlFieldEnvelope(envelope, operationName);
18071986 },
18081987 };
18091988 }
executor/lib/typechecker.ts+7−1
@@ -218,14 +218,20 @@ export function generateToolInventory(tools: ToolDescriptor[]): string { · 217 unmodified lines
218218 .slice(0, 8)
219219 .map((tool) => ` - tools.${tool.path}(...)`);
220220
221+ const hasGraphqlTools = tools.some((tool) => tool.path.endsWith(".graphql"));
222+
221223 return [
222224 "",
223225 "You have access to these tool namespaces:",
224226 ` ${namespaces.join(", ")}`,
225227 "",
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.",
227230 "Never shadow the global `tools` object (do NOT write `const tools = ...`).",
228231 "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+ : []),
229235 ...(examples.length > 0
230236 ? ["", "Example callable paths:", ...examples]
231237 : []),
executor/lib/workspace-tool-cache.test.ts+2−2
@@ -350,8 +350,8 @@ describe("serializeTools + rehydrateTools round-trip", () => { · 349 unmodified lines
350350 { taskId: "t", workspaceId: "w", isToolAllowed: () => true },
351351 );
352352
353− expect(rawResult).toEqual({ viewer: { id: "user_1" } });
354− expect(teamsResult).toEqual([{ id: "team_1", name: "Core" }]);
353+ expect(rawResult).toEqual({ data: { viewer: { id: "user_1" } }, errors: [] });
354+ expect(teamsResult).toEqual({ data: [{ id: "team_1", name: "Core" }], errors: [] });
355355 expect(calls.length).toBe(2);
356356 } finally {
357357 globalThis.fetch = originalFetch;