History

Enhance code presentation and tool discovery in executor

- Introduced a new FormattedCodeBlock component for improved display of code snippets in approvals and tasks views. - Updated styles for formatted code blocks to enhance readability and compactness in detail panels. - Enhanced tool discovery to support compact signatures by default, allowing for clearer and more concise tool descriptions. - Added functionality to handle large output previews in task summaries, ensuring better user experience with runtime outputs. - Improved type hints and documentation for tool discovery functions to reflect recent changes in input handling.

Rhys Sullivan committed 9 months ago

Commit 669b5b11 parent e2294f417 files · +674 −37Browse files

Unified · Split

17 files
  1. executor/apps/web/src/app/globals.css +36 −0
  2. executor/apps/web/src/components/app-shell.tsx +2 −0
  3. executor/apps/web/src/components/approval-notifier.tsx +156 −0
  4. executor/apps/web/src/components/approvals-view.tsx +69 −7
  5. executor/apps/web/src/components/formatted-code-block.tsx +45 −0
  6. executor/apps/web/src/components/tasks-view.tsx +24 −12
  7. executor/convex/database.ts +8 −0
  8. executor/convex/executorNode.ts +1 −1
  9. executor/convex/schema.ts +15 −0
  10. executor/lib/mcp-server.test.ts +35 −1
  11. executor/lib/mcp_server.ts +23 −4
  12. executor/lib/tool-discovery.test.ts +71 −0
  13. executor/lib/tool-sources.test.ts +122 −0
  14. executor/lib/tool_discovery.ts +56 −6
  15. executor/lib/tool_sources.ts +6 −2
  16. executor/lib/typechecker.test.ts +3 −3
  17. executor/lib/typechecker.ts +2 −1
executor/apps/web/src/app/globals.css+36−0
@@ -173,6 +173,42 @@ · 172 unmodified lines
173173 border-radius: 0.375rem;
174174 }
175175
176+/* Slim code block presentation for compact detail panels */
177+.formatted-code-block [data-streamdown="code-block"] {
178+ margin: 0;
179+ border-radius: 0.375rem;
180+ border: 1px solid var(--border);
181+}
182+
183+.formatted-code-block [data-streamdown="code-block-header"] {
184+ display: none;
185+}
186+
187+.formatted-code-block [data-streamdown="code-block-body"] {
188+ border: 0 !important;
189+ border-top: 0 !important;
190+ border-radius: 0.375rem;
191+ padding: 0.75rem !important;
192+ font-size: 0.75rem;
193+ line-height: 1.55;
194+}
195+
196+.formatted-code-block [data-streamdown="code-block-body"] code > span::before {
197+ display: none;
198+}
199+
200+.formatted-code-block--green [data-streamdown="code-block-body"] {
201+ color: color-mix(in oklch, var(--terminal-green) 80%, var(--foreground)) !important;
202+}
203+
204+.formatted-code-block--amber [data-streamdown="code-block-body"] {
205+ color: color-mix(in oklch, var(--terminal-amber) 80%, var(--foreground)) !important;
206+}
207+
208+.formatted-code-block--red [data-streamdown="code-block-body"] {
209+ color: color-mix(in oklch, var(--terminal-red) 80%, var(--foreground)) !important;
210+}
211+
176212 /* Scrollbar styling */
177213 ::-webkit-scrollbar {
178214 width: 6px;
executor/apps/web/src/components/app-shell.tsx+2−0
@@ -40,6 +40,7 @@ import { useSession } from "@/lib/session-context"; · 39 unmodified lines
4040 import { convexApi } from "@/lib/convex-api";
4141 import { workosEnabled } from "@/lib/auth-capabilities";
4242 import { ThemeSwitcher } from "@/components/theme-switcher";
43+import { ApprovalNotifier } from "@/components/approval-notifier";
4344 import { cn } from "@/lib/utils";
4445
4546 const NAV_ITEMS = [
@@ -710,6 +711,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { · 664 unmodified lines
710711
711712 return (
712713 <div className="flex min-h-screen">
714+ <ApprovalNotifier />
713715 <Sidebar />
714716 <div className="flex-1 flex flex-col min-w-0">
715717 <MobileHeader />
executor/apps/web/src/components/approval-notifier.tsx+156−0
@@ -0,0 +1,156 @@
1+"use client";
2+
3+import { useEffect, useRef } from "react";
4+import { useRouter } from "next/navigation";
5+import { useQuery } from "convex/react";
6+import { toast } from "sonner";
7+import { useSession } from "@/lib/session-context";
8+import { convexApi } from "@/lib/convex-api";
9+import type { PendingApprovalRecord } from "@/lib/types";
10+
11+const NOTIFICATION_PROMPT_KEY = "executor_approval_desktop_notifications_prompted_v1";
12+
13+function supportsDesktopNotifications(): boolean {
14+ return typeof window !== "undefined" && "Notification" in window;
15+}
16+
17+function notifyDesktop(approval: PendingApprovalRecord) {
18+ if (!supportsDesktopNotifications()) {
19+ return;
20+ }
21+
22+ if (Notification.permission !== "granted") {
23+ return;
24+ }
25+
26+ if (document.visibilityState === "visible") {
27+ return;
28+ }
29+
30+ const notification = new Notification("Approval required", {
31+ body: `${approval.toolPath} is waiting for review`,
32+ tag: `approval-${approval.id}`,
33+ });
34+
35+ notification.onclick = () => {
36+ window.focus();
37+ window.location.assign("/approvals");
38+ notification.close();
39+ };
40+
41+ window.setTimeout(() => {
42+ notification.close();
43+ }, 12_000);
44+}
45+
46+export function ApprovalNotifier() {
47+ const router = useRouter();
48+ const { context } = useSession();
49+ const seenApprovalIdsRef = useRef<Set<string>>(new Set());
50+ const activeWorkspaceRef = useRef<string | null>(null);
51+
52+ const approvals = useQuery(
53+ convexApi.workspace.listPendingApprovals,
54+ context ? { workspaceId: context.workspaceId, sessionId: context.sessionId } : "skip",
55+ );
56+
57+ useEffect(() => {
58+ if (!context || approvals === undefined) {
59+ return;
60+ }
61+
62+ const approvalRecords = approvals as PendingApprovalRecord[];
63+
64+ const currentWorkspaceId = context.workspaceId;
65+ const currentIds = new Set(approvalRecords.map((approval) => approval.id));
66+
67+ if (activeWorkspaceRef.current !== currentWorkspaceId) {
68+ activeWorkspaceRef.current = currentWorkspaceId;
69+ seenApprovalIdsRef.current = currentIds;
70+ return;
71+ }
72+
73+ const newApprovals = approvalRecords.filter(
74+ (approval) => !seenApprovalIdsRef.current.has(approval.id),
75+ );
76+
77+ seenApprovalIdsRef.current = currentIds;
78+
79+ if (newApprovals.length === 0) {
80+ return;
81+ }
82+
83+ const newest = newApprovals[newApprovals.length - 1];
84+
85+ toast.info(
86+ newApprovals.length === 1
87+ ? `Approval required: ${newest.toolPath}`
88+ : `${newApprovals.length} new approvals pending`,
89+ {
90+ description:
91+ newApprovals.length === 1
92+ ? `Task ${newest.taskId} is waiting for review.`
93+ : "Open approvals to review pending tool calls.",
94+ action: {
95+ label: "Review",
96+ onClick: () => router.push("/approvals"),
97+ },
98+ },
99+ );
100+
101+ for (const approval of newApprovals) {
102+ notifyDesktop(approval);
103+ }
104+ }, [approvals, context, router]);
105+
106+ useEffect(() => {
107+ if (!context) {
108+ return;
109+ }
110+
111+ if (!supportsDesktopNotifications()) {
112+ return;
113+ }
114+
115+ if (Notification.permission !== "default") {
116+ return;
117+ }
118+
119+ if (localStorage.getItem(NOTIFICATION_PROMPT_KEY)) {
120+ return;
121+ }
122+
123+ localStorage.setItem(NOTIFICATION_PROMPT_KEY, "1");
124+
125+ toast.message("Enable desktop approval alerts?", {
126+ description: "Get browser notifications when new approvals arrive.",
127+ duration: 14_000,
128+ action: {
129+ label: "Enable",
130+ onClick: async () => {
131+ if (!supportsDesktopNotifications()) {
132+ return;
133+ }
134+
135+ try {
136+ const permission = await Notification.requestPermission();
137+
138+ if (permission === "granted") {
139+ toast.success("Desktop notifications enabled");
140+ } else if (permission === "denied") {
141+ toast.error("Desktop notifications blocked in this browser");
142+ }
143+ } catch {
144+ toast.error("Failed to request notification permission");
145+ }
146+ },
147+ },
148+ cancel: {
149+ label: "Later",
150+ onClick: () => {},
151+ },
152+ });
153+ }, [context]);
154+
155+ return null;
156+}
executor/apps/web/src/components/approvals-view.tsx+69−7
@@ -1,6 +1,6 @@
11 "use client";
22
3−import { useState } from "react";
3+import { useMemo, useState } from "react";
44 import {
55 ShieldCheck,
66 ShieldX,
@@ -8,7 +8,7 @@ import { · 1 unmodified line
88 Clock,
99 } from "lucide-react";
1010 import { Button } from "@/components/ui/button";
11−import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
11+import { Card, CardContent } from "@/components/ui/card";
1212 import { Skeleton } from "@/components/ui/skeleton";
1313 import { Separator } from "@/components/ui/separator";
1414 import { PageHeader } from "@/components/page-header";
@@ -19,6 +19,63 @@ import { useMutation, useQuery } from "convex/react"; · 4 unmodified lines
1919 import type { PendingApprovalRecord } from "@/lib/types";
2020 import { toast } from "sonner";
2121 import { formatTimeAgo } from "@/lib/format";
22+import { FormattedCodeBlock } from "@/components/formatted-code-block";
23+
24+function formatApprovalInput(
25+ input: unknown,
26+): {
27+ content: string;
28+ language: "json" | "text";
29+} | null {
30+ if (input === null || input === undefined) {
31+ return null;
32+ }
33+
34+ if (typeof input === "string") {
35+ const trimmed = input.trim();
36+ if (!trimmed) {
37+ return null;
38+ }
39+
40+ try {
41+ const parsed = JSON.parse(trimmed);
42+ const serialized = JSON.stringify(parsed, null, 2);
43+ if (serialized === "null" || serialized === "{}") {
44+ return null;
45+ }
46+ return {
47+ content: serialized,
48+ language: "json",
49+ };
50+ } catch {
51+ return {
52+ content: trimmed,
53+ language: "text",
54+ };
55+ }
56+ }
57+
58+ try {
59+ const serialized = JSON.stringify(input, null, 2);
60+ if (serialized === "null" || serialized === "{}") {
61+ return null;
62+ }
63+
64+ return {
65+ content: serialized,
66+ language: "json",
67+ };
68+ } catch {
69+ const fallback = String(input).trim();
70+ if (!fallback) {
71+ return null;
72+ }
73+ return {
74+ content: fallback,
75+ language: "text",
76+ };
77+ }
78+}
2279
2380 function ApprovalCard({
2481 approval,
@@ -53,7 +110,10 @@ function ApprovalCard({ · 28 unmodified lines
53110 }
54111 };
55112
56− const inputStr = JSON.stringify(approval.input, null, 2);
113+ const inputDisplay = useMemo(
114+ () => formatApprovalInput(approval.input),
115+ [approval.input],
116+ );
57117
58118 return (
59119 <Card className="bg-card border-border border-l-2 border-l-terminal-amber glow-amber">
@@ -83,14 +143,16 @@ function ApprovalCard({ · 23 unmodified lines
83143 </div>
84144
85145 {/* Input */}
86− {inputStr !== "null" && inputStr !== "{}" && (
146+ {inputDisplay && (
87147 <div>
88148 <span className="text-[10px] uppercase tracking-widest text-muted-foreground block mb-1.5">
89149 Input
90150 </span>
91− <pre className="terminal-block max-h-36 overflow-y-auto text-xs">
92− {inputStr}
93− </pre>
151+ <FormattedCodeBlock
152+ content={inputDisplay.content}
153+ language={inputDisplay.language}
154+ className="max-h-52 overflow-y-auto"
155+ />
94156 </div>
95157 )}
96158
executor/apps/web/src/components/formatted-code-block.tsx+45−0
@@ -0,0 +1,45 @@
1+"use client";
2+
3+import { useMemo } from "react";
4+import { Streamdown } from "streamdown";
5+import { createCodePlugin } from "@streamdown/code";
6+import { cn } from "@/lib/utils";
7+
8+const codePlugin = createCodePlugin({
9+ themes: ["github-light", "github-dark"],
10+});
11+
12+type CodeTone = "default" | "green" | "amber" | "red";
13+
14+function toCodeFence(content: string, language: string) {
15+ return `~~~~${language}\n${content}\n~~~~`;
16+}
17+
18+function toneClass(tone: CodeTone): string {
19+ if (tone === "green") return "formatted-code-block--green";
20+ if (tone === "amber") return "formatted-code-block--amber";
21+ if (tone === "red") return "formatted-code-block--red";
22+ return "";
23+}
24+
25+export function FormattedCodeBlock({
26+ content,
27+ language = "text",
28+ tone = "default",
29+ className,
30+}: {
31+ content: string;
32+ language?: string;
33+ tone?: CodeTone;
34+ className?: string;
35+}) {
36+ const markdown = useMemo(() => toCodeFence(content, language), [content, language]);
37+
38+ return (
39+ <div className={cn("formatted-code-block", toneClass(tone), className)}>
40+ <Streamdown plugins={{ code: codePlugin }} controls={false}>
41+ {markdown}
42+ </Streamdown>
43+ </div>
44+ );
45+}
executor/apps/web/src/components/tasks-view.tsx+24−12
@@ -24,6 +24,7 @@ import { Skeleton } from "@/components/ui/skeleton"; · 23 unmodified lines
2424 import { Separator } from "@/components/ui/separator";
2525 import { PageHeader } from "@/components/page-header";
2626 import { TaskStatusBadge } from "@/components/status-badge";
27+import { FormattedCodeBlock } from "@/components/formatted-code-block";
2728 import { useSession } from "@/lib/session-context";
2829 import { useWorkspaceTools } from "@/hooks/use-workspace-tools";
2930 import { useMutation, useQuery } from "convex/react";
@@ -298,9 +299,11 @@ function TaskDetail({ · 268 unmodified lines
298299 <span className="text-[10px] uppercase tracking-widest text-muted-foreground block mb-2">
299300 Code
300301 </span>
301− <pre className="terminal-block max-h-48 overflow-y-auto">
302− {liveTask.code}
303− </pre>
302+ <FormattedCodeBlock
303+ content={liveTask.code}
304+ language="typescript"
305+ className="max-h-48 overflow-y-auto"
306+ />
304307 </div>
305308
306309 {/* Stdout */}
@@ -309,9 +312,12 @@ function TaskDetail({ · 2 unmodified lines
309312 <span className="text-[10px] uppercase tracking-widest text-terminal-green block mb-2">
310313 Stdout
311314 </span>
312− <pre className="terminal-block max-h-48 overflow-y-auto text-terminal-green/80">
313− {liveStdout}
314− </pre>
315+ <FormattedCodeBlock
316+ content={liveStdout}
317+ language="text"
318+ tone="green"
319+ className="max-h-48 overflow-y-auto"
320+ />
315321 </div>
316322 )}
317323
@@ -321,9 +327,12 @@ function TaskDetail({ · 3 unmodified lines
321327 <span className="text-[10px] uppercase tracking-widest text-terminal-amber block mb-2">
322328 Stderr
323329 </span>
324− <pre className="terminal-block max-h-48 overflow-y-auto text-terminal-amber/80">
325− {liveStderr}
326− </pre>
330+ <FormattedCodeBlock
331+ content={liveStderr}
332+ language="text"
333+ tone="amber"
334+ className="max-h-48 overflow-y-auto"
335+ />
327336 </div>
328337 )}
329338
@@ -333,9 +342,12 @@ function TaskDetail({ · 3 unmodified lines
333342 <span className="text-[10px] uppercase tracking-widest text-terminal-red block mb-2">
334343 Error
335344 </span>
336− <pre className="terminal-block max-h-48 overflow-y-auto text-terminal-red/80">
337− {liveTask.error}
338− </pre>
345+ <FormattedCodeBlock
346+ content={liveTask.error}
347+ language="text"
348+ tone="red"
349+ className="max-h-48 overflow-y-auto"
350+ />
339351 </div>
340352 )}
341353 </CardContent>
executor/convex/database.ts+8−0
@@ -1,5 +1,6 @@
11 import { v } from "convex/values";
22 import type { Doc } from "./_generated/dataModel";
3+import { internal } from "./_generated/api";
34 import { mutation,
45 query,
56 type MutationCtx, type QueryCtx } from "./_generated/server";
@@ -503,6 +504,13 @@ export const createApproval = internalMutation({ · 497 unmodified lines
503504 createdAt: now,
504505 });
505506
507+ await ctx.scheduler.runAfter(0, internal.pushNotifications.sendApprovalRequested, {
508+ workspaceId: task.workspaceId,
509+ approvalId: args.id,
510+ taskId: args.taskId,
511+ toolPath: args.toolPath,
512+ });
513+
506514 const created = await getApprovalDoc(ctx, args.id);
507515 if (!created) {
508516 throw new Error(`Failed to fetch approval ${args.id}`);
executor/convex/executorNode.ts+1−1
@@ -57,7 +57,7 @@ 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 = "v13";
60+ const signatureVersion = "v14";
6161 const parts = sources
6262 .map((source) => `${source.id}:${source.updatedAt}:${source.enabled ? 1 : 0}`)
6363 .sort();
executor/convex/schema.ts+15−0
@@ -217,6 +217,21 @@ export default defineSchema({ · 216 unmodified lines
217217 .index("by_workspace_created", ["workspaceId", "createdAt"])
218218 .index("by_workspace_status_created", ["workspaceId", "status", "createdAt"]),
219219
220+ pushSubscriptions: defineTable({
221+ workspaceId: v.string(),
222+ accountId: v.string(),
223+ endpoint: v.string(),
224+ p256dh: v.string(),
225+ auth: v.string(),
226+ expirationTime: v.optional(v.number()),
227+ userAgent: v.optional(v.string()),
228+ createdAt: v.number(),
229+ updatedAt: v.number(),
230+ })
231+ .index("by_workspace_created", ["workspaceId", "createdAt"])
232+ .index("by_workspace_account", ["workspaceId", "accountId"])
233+ .index("by_endpoint", ["endpoint"]),
234+
220235 taskEvents: defineTable({
221236 sequence: v.number(),
222237 taskId: v.string(),
executor/lib/mcp-server.test.ts+35−1
@@ -43,7 +43,9 @@ class FakeMcpService { · 42 unmodified lines
4343 completedAt: current.createdAt + 2,
4444 updatedAt: current.createdAt + 2,
4545 exitCode: 0,
46− stdout: `ran:${input.code.slice(0, 20)}`,
46+ stdout: input.metadata?.largeStdout === true
47+ ? `header\n${"x".repeat(35_000)}`
48+ : `ran:${input.code.slice(0, 20)}`,
4749 stderr: "",
4850 });
4951 // Notify subscribers
@@ -164,6 +166,38 @@ test("run_code MCP tool returns terminal task result", async () => { · 114 unmodified lines
164166 });
165167 });
166168
169+test("run_code MCP tool warns and previews large output", async () => {
170+ const service = new FakeMcpService();
171+
172+ await withMcpClient(service, async (client) => {
173+ const result = (await client.callTool({
174+ name: "run_code",
175+ arguments: {
176+ code: "console.log('large')",
177+ metadata: { largeStdout: true },
178+ },
179+ })) as {
180+ isError?: boolean;
181+ content: Array<{ type: string; text?: string }>;
182+ structuredContent?: Record<string, unknown>;
183+ };
184+
185+ expect(result.isError).toBeUndefined();
186+ const text = result.content.find((part) => part.type === "text");
187+ expect(text?.type).toBe("text");
188+ if (text?.type === "text") {
189+ expect(text.text).toContain("outputWarning: Large runtime output detected");
190+ expect(text.text).toContain("outputHint: Return compact summaries");
191+ expect(text.text).toContain("[stdout preview truncated");
192+ }
193+
194+ const fullStdout = typeof result.structuredContent?.stdout === "string"
195+ ? result.structuredContent.stdout
196+ : "";
197+ expect(fullStdout.length).toBeGreaterThan(30_000);
198+ });
199+});
200+
167201 test("run_code MCP tool bootstraps anonymous context when workspace is omitted", async () => {
168202 const service = new FakeMcpService();
169203
executor/lib/mcp_server.ts+23−4
@@ -128,6 +128,7 @@ async function loadSourceDtsByUrl(dtsUrls: Record<string, string>): Promise<Reco · 127 unmodified lines
128128 }
129129
130130 function summarizeTask(task: TaskRecord): string {
131+ const maxStreamPreviewChars = 30_000;
131132 const lines = [
132133 `taskId: ${task.id}`,
133134 `status: ${task.status}`,
@@ -142,12 +143,29 @@ function summarizeTask(task: TaskRecord): string { · 8 unmodified lines
142143 lines.push(`error: ${task.error}`);
143144 }
144145
146+ const stdoutLength = task.stdout?.length ?? 0;
147+ const stderrLength = task.stderr?.length ?? 0;
148+ if (stdoutLength > maxStreamPreviewChars || stderrLength > maxStreamPreviewChars) {
149+ lines.push(
150+ `outputWarning: Large runtime output detected (stdout=${stdoutLength} chars, stderr=${stderrLength} chars).`,
151+ );
152+ lines.push(
153+ "outputHint: Return compact summaries (counts, IDs, top-N samples) to avoid truncation in tool responses.",
154+ );
155+ }
156+
145157 let text = lines.join("\n");
146158 if (task.stdout && task.stdout.trim()) {
147− text += asCodeBlock("text", task.stdout);
159+ const stdoutPreview = task.stdout.length > maxStreamPreviewChars
160+ ? `${task.stdout.slice(0, maxStreamPreviewChars)}\n... [stdout preview truncated ${task.stdout.length - maxStreamPreviewChars} chars]`
161+ : task.stdout;
162+ text += asCodeBlock("text", stdoutPreview);
148163 }
149164 if (task.stderr && task.stderr.trim()) {
150− text += asCodeBlock("text", task.stderr);
165+ const stderrPreview = task.stderr.length > maxStreamPreviewChars
166+ ? `${task.stderr.slice(0, maxStreamPreviewChars)}\n... [stderr preview truncated ${task.stderr.length - maxStreamPreviewChars} chars]`
167+ : task.stderr;
168+ text += asCodeBlock("text", stderrPreview);
151169 }
152170 return text;
153171 }
@@ -283,12 +301,13 @@ function buildRunCodeDescription(tools?: ToolDescriptor[]): string { · 129 unmodified lines
283301 ? `\n\nTop-level tool keys: ${topLevelKeys.join(", ")}`
284302 : "";
285303 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 = ...`).";
304+ const discoverNote = "\n\nTip: use `tools.discover({ query, depth?, limit?, compact? })` first. It returns `{ results, total }`; call the exact `results[i].path` (or copy `results[i].exampleCall`). Compact mode is on by default (set `compact: false` for full signatures). Do not assign to `const tools = ...`; use a different variable name (e.g. `const discovered = ...`).";
305+ const executionNote = "\n\nExecution tip: for migration/ETL-style tasks, discover once, then run in small batches and `return` compact summaries (counts, IDs, and top-N samples) instead of full objects.";
287306 const graphqlNote = hasGraphqlTools
288307 ? "\n\nGraphQL tip: prefer `source.query.*` / `source.mutation.*` helper paths when available; GraphQL tools return `{ data, errors }`."
289308 : "";
290309
291− return base + rootKeysNote + discoverNote + graphqlNote + generateToolInventory(toolList);
310+ return base + rootKeysNote + discoverNote + executionNote + graphqlNote + generateToolInventory(toolList);
292311 }
293312
294313 function formatApprovalInput(input: unknown, maxLength = 2000): string {
executor/lib/tool-discovery.test.ts+71−0
@@ -21,6 +21,7 @@ test("discover returns aliases and example calls", async () => { · 20 unmodified lines
2121 { query: "addnumbers", depth: 2 },
2222 { taskId: "t", workspaceId: "w", isToolAllowed: () => true },
2323 ) as {
24+ bestPath: string | null;
2425 results: Array<{
2526 path: string;
2627 aliases: string[];
@@ -30,6 +31,7 @@ test("discover returns aliases and example calls", async () => { · 3 unmodified lines
3031 total: number;
3132 };
3233
34+ expect(result.bestPath).toBe("calc.math.add_numbers");
3335 expect(result.total).toBe(1);
3436 expect(result.results[0]?.path).toBe("calc.math.add_numbers");
3537 expect(result.results[0]?.aliases).toContain("calc.math.addNumbers");
@@ -57,10 +59,79 @@ test("discover example call handles input-shaped args", async () => { · 21 unmodified lines
5759 { query: "issuecreate", depth: 2 },
5860 { taskId: "t", workspaceId: "w", isToolAllowed: () => true },
5961 ) as {
62+ bestPath: string | null;
6063 results: Array<{ exampleCall: string }>;
6164 };
6265
66+ expect(result.bestPath).toBe("linear.mutation.issuecreate");
6367 expect(result.results[0]?.exampleCall).toBe(
6468 "await tools.linear.mutation.issuecreate({ input: { /* ... */ } });",
6569 );
6670 });
71+
72+test("discover uses compact signatures by default and allows full mode", async () => {
73+ const tool = createDiscoverTool([
74+ {
75+ path: "linear.query.teams",
76+ description: "All teams whose issues can be accessed by the user. Compact output should trim this long explanation before it reaches the trailing marker text to keep discover results concise for models. TRAILING_MARKER_TEXT",
77+ approval: "auto",
78+ source: "graphql:linear",
79+ metadata: {
80+ argsType: "{ filter?: TeamFilter; before?: string; after?: string; first?: number; last?: number; includeArchived?: boolean; orderBy?: PaginationOrderBy }",
81+ returnsType: "{ data: TeamConnection; errors: unknown[] }",
82+ },
83+ run: async () => ({ data: {}, errors: [] }),
84+ } satisfies ToolDefinition,
85+ ]);
86+
87+ const compactResult = await tool.run(
88+ { query: "linear teams", depth: 2 },
89+ { taskId: "t", workspaceId: "w", isToolAllowed: () => true },
90+ ) as {
91+ bestPath: string | null;
92+ results: Array<{ description: string; signature: string }>;
93+ };
94+
95+ const fullResult = await tool.run(
96+ { query: "linear teams", depth: 2, compact: false },
97+ { taskId: "t", workspaceId: "w", isToolAllowed: () => true },
98+ ) as {
99+ bestPath: string | null;
100+ results: Array<{ description: string; signature: string }>;
101+ };
102+
103+ expect(compactResult.bestPath).toBe("linear.query.teams");
104+ expect(fullResult.bestPath).toBe("linear.query.teams");
105+ expect(compactResult.results[0]?.signature).toContain("filter: ...");
106+ expect(compactResult.results[0]?.signature).toContain("Promise<{ data: ...; errors: unknown[] }>");
107+ expect(compactResult.results[0]?.description).not.toContain("TRAILING_MARKER_TEXT");
108+
109+ expect(fullResult.results[0]?.signature).toContain("TeamFilter");
110+ expect(fullResult.results[0]?.signature).toContain("TeamConnection");
111+ expect(fullResult.results[0]?.description).toContain("TRAILING_MARKER_TEXT");
112+});
113+
114+test("discover returns null bestPath when there are no matches", async () => {
115+ const tool = createDiscoverTool([
116+ {
117+ path: "calc.math.add_numbers",
118+ description: "Add numbers",
119+ approval: "auto",
120+ source: "openapi:calc",
121+ metadata: {
122+ argsType: "{ a: number; b: number }",
123+ returnsType: "{ sum: number }",
124+ },
125+ run: async () => ({ sum: 0 }),
126+ } satisfies ToolDefinition,
127+ ]);
128+
129+ const result = await tool.run(
130+ { query: "totally_unrelated_keyword" },
131+ { taskId: "t", workspaceId: "w", isToolAllowed: () => true },
132+ ) as { bestPath: string | null; results: Array<unknown>; total: number };
133+
134+ expect(result.bestPath).toBeNull();
135+ expect(result.total).toBe(0);
136+ expect(result.results).toHaveLength(0);
137+});
executor/lib/tool-sources.test.ts+122−0
@@ -511,3 +511,125 @@ test("graphql helper tools generate valid selection sets and envelope responses" · 510 unmodified lines
511511 server.stop(true);
512512 }
513513 });
514+
515+test("openapi fallback type hints include index signature when truncated", async () => {
516+ const manyProps = Object.fromEntries(
517+ Array.from({ length: 20 }, (_, i) => [`field_${i}`, { type: "string" }]),
518+ );
519+
520+ const { tools, warnings } = await loadExternalTools([
521+ {
522+ type: "openapi",
523+ name: "wide",
524+ baseUrl: "https://example.com",
525+ spec: {
526+ openapi: "3.0.3",
527+ info: { title: "Wide", version: "1.0.0" },
528+ paths: {
529+ "/items": {
530+ get: {
531+ operationId: "getItems",
532+ tags: ["items"],
533+ responses: {
534+ "200": {
535+ description: "ok",
536+ content: {
537+ "application/json": {
538+ schema: {
539+ type: "object",
540+ properties: manyProps,
541+ },
542+ },
543+ },
544+ },
545+ },
546+ },
547+ },
548+ },
549+ },
550+ },
551+ ]);
552+
553+ expect(warnings).toHaveLength(0);
554+ const getItems = tools.find((tool) => tool.path === "wide.items.get_items");
555+ expect(getItems).toBeDefined();
556+ expect(getItems?.metadata?.returnsType).toContain("[key: string]: any");
557+});
558+
559+test("graphql helper tools inherit credential spec from source auth", async () => {
560+ const server = Bun.serve({
561+ port: 0,
562+ fetch: async (req) => {
563+ const body = (await req.json()) as { query?: string };
564+ const query = String(body.query ?? "");
565+
566+ if (query.includes("__schema")) {
567+ return Response.json({
568+ data: {
569+ __schema: {
570+ queryType: { name: "Query" },
571+ mutationType: null,
572+ types: [
573+ {
574+ kind: "OBJECT",
575+ name: "Query",
576+ fields: [
577+ {
578+ name: "teams",
579+ description: null,
580+ args: [],
581+ type: { kind: "SCALAR", name: "String", ofType: null },
582+ },
583+ ],
584+ inputFields: null,
585+ enumValues: null,
586+ },
587+ {
588+ kind: "SCALAR",
589+ name: "String",
590+ fields: null,
591+ inputFields: null,
592+ enumValues: null,
593+ },
594+ ],
595+ },
596+ },
597+ });
598+ }
599+
600+ return Response.json({ data: { teams: "ok" } });
601+ },
602+ });
603+
604+ try {
605+ const { tools, warnings } = await loadExternalTools([
606+ {
607+ type: "graphql",
608+ name: "linear",
609+ endpoint: `http://127.0.0.1:${server.port}/graphql`,
610+ auth: {
611+ type: "bearer",
612+ mode: "actor",
613+ },
614+ },
615+ ]);
616+
617+ expect(warnings).toHaveLength(0);
618+
619+ const rawTool = tools.find((tool) => tool.path === "linear.graphql");
620+ const helperTool = tools.find((tool) => tool.path === "linear.query.teams");
621+
622+ expect(rawTool?.credential).toEqual({
623+ sourceKey: "graphql:linear",
624+ mode: "actor",
625+ authType: "bearer",
626+ });
627+ expect(helperTool?.credential).toEqual({
628+ sourceKey: "graphql:linear",
629+ mode: "actor",
630+ authType: "bearer",
631+ });
632+ } finally {
633+ server.stop(true);
634+ }
635+});
executor/lib/tool_discovery.ts+56−6
@@ -107,6 +107,40 @@ function buildExampleCall(entry: DiscoverIndexEntry): string { · 106 unmodified lines
107107 return `await tools.${entry.path}({ /* ... */ });`;
108108 }
109109
110+function truncateInline(value: string, maxLength: number): string {
111+ const normalized = value.replace(/\s+/g, " ").trim();
112+ if (normalized.length <= maxLength) return normalized;
113+ return `${normalized.slice(0, Math.max(16, maxLength - 3)).trim()}...`;
114+}
115+
116+function compactArgTypeHint(argsType: string): string {
117+ if (argsType === "{}") return "{}";
118+ const keys = extractTopLevelArgKeys(argsType);
119+ if (keys.length > 0) {
120+ const maxKeys = 4;
121+ const shown = keys.slice(0, maxKeys).map((key) => `${key}: ...`);
122+ const suffix = keys.length > maxKeys ? "; ..." : "";
123+ return `{ ${shown.join("; ")}${suffix} }`;
124+ }
125+ return truncateInline(argsType, 120);
126+}
127+
128+function compactReturnTypeHint(returnsType: string): string {
129+ const normalized = returnsType.replace(/\s+/g, " ").trim();
130+ if (normalized.startsWith("{ data:") && normalized.includes("errors:")) {
131+ return "{ data: ...; errors: unknown[] }";
132+ }
133+ if (normalized.endsWith("[]") && normalized.length > 90) {
134+ return "Array<...>";
135+ }
136+ return truncateInline(normalized, 130);
137+}
138+
139+function compactDescription(description: string): string {
140+ const firstLine = description.split("\n")[0] ?? description;
141+ return truncateInline(firstLine, 180);
142+}
143+
110144 function buildIndex(tools: ToolDefinition[]): DiscoverIndexEntry[] {
111145 return tools
112146 .filter((tool) => tool.path !== "discover")
@@ -152,7 +186,21 @@ function scoreEntry(entry: DiscoverIndexEntry, terms: string[]): number { · 39 unmodified lines
152186 return score + matched * 2;
153187 }
154188
155−function formatSignature(entry: DiscoverIndexEntry, depth: number): string {
189+function formatSignature(entry: DiscoverIndexEntry, depth: number, compact: boolean): string {
190+ if (compact) {
191+ if (depth <= 0) {
192+ return "(input: ...): Promise<...>";
193+ }
194+
195+ const args = compactArgTypeHint(entry.argsType);
196+ const returns = compactReturnTypeHint(entry.returnsType);
197+
198+ if (depth === 1) {
199+ return `(input: ${args}): Promise<${returns}>`;
200+ }
201+ return `(input: ${args}): Promise<${returns}> [source=${entry.source}]`;
202+ }
203+
156204 if (depth <= 0) {
157205 return `(input: ${entry.argsType}): Promise<...>`;
158206 }
@@ -170,17 +218,18 @@ export function createDiscoverTool(tools: ToolDefinition[]): ToolDefinition { · 11 unmodified lines
170218 source: "system",
171219 approval: "auto",
172220 description:
173− "Search available tools by keyword. Returns canonical path, aliases, signature hints, and ready-to-copy call examples.",
221+ "Search available tools by keyword. Returns canonical path, aliases, signature hints, and ready-to-copy call examples. Compact mode is enabled by default.",
174222 metadata: {
175− argsType: "{ query: string; depth?: number; limit?: number }",
223+ argsType: "{ query: string; depth?: number; limit?: number; compact?: boolean }",
176224 returnsType:
177− "{ results: Array<{ path: string; aliases: string[]; source: string; approval: 'auto' | 'required'; description: string; signature: string; exampleCall: string }>; total: number }",
225+ "{ bestPath: string | null; results: Array<{ path: string; aliases: string[]; source: string; approval: 'auto' | 'required'; description: string; signature: string; exampleCall: string }>; total: number }",
178226 },
179227 run: async (input: unknown, context) => {
180228 const payload = input && typeof input === "object" ? (input as Record<string, unknown>) : {};
181229 const query = String(payload.query ?? "").trim().toLowerCase();
182230 const depth = Math.max(0, Math.min(2, Number(payload.depth ?? 1)));
183231 const limit = Math.max(1, Math.min(50, Number(payload.limit ?? 8)));
232+ const compact = payload.compact === false ? false : true;
184233 const terms = query.length > 0 ? query.split(/\s+/).filter(Boolean) : [];
185234
186235 const ranked = index
@@ -194,12 +243,13 @@ export function createDiscoverTool(tools: ToolDefinition[]): ToolDefinition { · 7 unmodified lines
194243 aliases: entry.aliases,
195244 source: entry.source,
196245 approval: entry.approval,
197− description: entry.description,
198− signature: formatSignature(entry, depth),
246+ description: compact ? compactDescription(entry.description) : entry.description,
247+ signature: formatSignature(entry, depth, compact),
199248 exampleCall: buildExampleCall(entry),
200249 }));
201250
202251 return {
252+ bestPath: ranked[0]?.path ?? null,
203253 results: ranked,
204254 total: ranked.length,
205255 };
executor/lib/tool_sources.ts+6−2
@@ -484,11 +484,14 @@ function jsonSchemaTypeHintFallback( · 483 unmodified lines
484484 }
485485 return "Record<string, unknown>";
486486 }
487+ const maxInlineProps = 12;
488+ const isTruncated = propEntries.length > maxInlineProps;
487489 const inner = propEntries
488− .slice(0, 12)
490+ .slice(0, maxInlineProps)
489491 .map(([key, value]) => `${formatTsPropertyKey(key)}${required.has(key) ? "" : "?"}: ${jsonSchemaTypeHintFallback(value, depth + 1, componentSchemas, seenRefs)}`)
490492 .join("; ");
491− return `{ ${inner} }`;
493+ const indexSignature = isTruncated ? `${inner ? "; " : ""}[key: string]: any` : "";
494+ return `{ ${inner}${indexSignature} }`;
492495 }
493496
494497 return "unknown";
@@ -1627,6 +1630,7 @@ async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise<ToolDe · 1132 unmodified lines
16271630 ? `${field.description}\n\nPreferred: ${directCallExample}\nReturns: { data, errors }\nRaw GraphQL: ${sourceName}.graphql({ query: \`${exampleQuery}\`, variables: {...} })`
16281631 : `GraphQL ${operationType}: ${field.name}\n\nPreferred: ${directCallExample}\nReturns: { data, errors }\nRaw GraphQL: ${sourceName}.graphql({ query: \`${exampleQuery}\`, variables: {...} })`,
16291632 approval,
1633+ credential: credentialSpec,
16301634 metadata: {
16311635 argsType: gqlFieldArgsTypeHint(field.args, typeMap),
16321636 returnsType: `{ data: ${gqlTypeToHint(field.type, typeMap)}; errors: unknown[] }`,
executor/lib/typechecker.test.ts+3−3
@@ -137,7 +137,7 @@ describe("generateToolInventory", () => { · 136 unmodified lines
137137 expect(result).toContain("You have access to these tool namespaces:");
138138 expect(result).toContain("admin (1)");
139139 expect(result).toContain("math (1)");
140− expect(result).toContain("Use `tools.discover({ query, depth?, limit? })` first");
140+ expect(result).toContain("Use `tools.discover({ query, depth?, limit?, compact? })` first");
141141 expect(result).toContain("Never shadow the global `tools` object");
142142 });
143143
@@ -378,7 +378,7 @@ export interface operations { · 234 unmodified lines
378378 description: "Discover tools",
379379 approval: "auto",
380380 source: "system",
381− argsType: "{ query: string; depth?: number; limit?: number }",
381+ argsType: "{ query: string; depth?: number; limit?: number; compact?: boolean }",
382382 returnsType: "unknown",
383383 });
384384
@@ -406,7 +406,7 @@ export interface operations { · 21 unmodified lines
406406 path: "discover",
407407 description: "Discover tools",
408408 approval: "auto",
409− argsType: "{ query: string; depth?: number; limit?: number }",
409+ argsType: "{ query: string; depth?: number; limit?: number; compact?: boolean }",
410410 returnsType: "unknown",
411411 },
412412 ];
executor/lib/typechecker.ts+2−1
@@ -225,8 +225,9 @@ export function generateToolInventory(tools: ToolDescriptor[]): string { · 224 unmodified lines
225225 "You have access to these tool namespaces:",
226226 ` ${namespaces.join(", ")}`,
227227 "",
228− "Use `tools.discover({ query, depth?, limit? })` first. It returns `{ results, total }` (not an array).",
228+ "Use `tools.discover({ query, depth?, limit?, compact? })` first. It returns `{ results, total }` (not an array).",
229229 "Use the exact `path` returned by discover, and prefer its `exampleCall` snippet for invocation shape.",
230+ "For migration/ETL tasks: discover once, then execute in small batches and return compact summaries (counts, IDs, top-N samples).",
230231 "Never shadow the global `tools` object (do NOT write `const tools = ...`).",
231232 "Then call tools directly using the returned path.",
232233 ...(hasGraphqlTools