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
173 border-radius: 0.375rem;173 border-radius: 0.375rem;
174}174}
175 175
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
176/* Scrollbar styling */212/* Scrollbar styling */
177::-webkit-scrollbar {213::-webkit-scrollbar {
178 width: 6px;214 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
40import { convexApi } from "@/lib/convex-api";40import { convexApi } from "@/lib/convex-api";
41import { workosEnabled } from "@/lib/auth-capabilities";41import { workosEnabled } from "@/lib/auth-capabilities";
42import { ThemeSwitcher } from "@/components/theme-switcher";42import { ThemeSwitcher } from "@/components/theme-switcher";
43import { ApprovalNotifier } from "@/components/approval-notifier";
43import { cn } from "@/lib/utils";44import { cn } from "@/lib/utils";
44 45
45const NAV_ITEMS = [46const NAV_ITEMS = [
@@ -710,6 +711,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { · 664 unmodified lines
710 711
711 return (712 return (
712 <div className="flex min-h-screen">713 <div className="flex min-h-screen">
714 <ApprovalNotifier />
713 <Sidebar />715 <Sidebar />
714 <div className="flex-1 flex flex-col min-w-0">716 <div className="flex-1 flex flex-col min-w-0">
715 <MobileHeader />717 <MobileHeader />
executor/apps/web/src/components/approval-notifier.tsx+156−0
@@ -0,0 +1,156 @@
1"use client";
2
3import { useEffect, useRef } from "react";
4import { useRouter } from "next/navigation";
5import { useQuery } from "convex/react";
6import { toast } from "sonner";
7import { useSession } from "@/lib/session-context";
8import { convexApi } from "@/lib/convex-api";
9import type { PendingApprovalRecord } from "@/lib/types";
10
11const NOTIFICATION_PROMPT_KEY = "executor_approval_desktop_notifications_prompted_v1";
12
13function supportsDesktopNotifications(): boolean {
14 return typeof window !== "undefined" && "Notification" in window;
15}
16
17function 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
46export 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 @@
1"use client";1"use client";
2 2
3import { useState } from "react";3import { useMemo, useState } from "react";
4import {4import {
5 ShieldCheck,5 ShieldCheck,
6 ShieldX,6 ShieldX,
@@ -8,7 +8,7 @@ import { · 1 unmodified line
8 Clock,8 Clock,
9} from "lucide-react";9} from "lucide-react";
10import { Button } from "@/components/ui/button";10import { Button } from "@/components/ui/button";
11import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";11import { Card, CardContent } from "@/components/ui/card";
12import { Skeleton } from "@/components/ui/skeleton";12import { Skeleton } from "@/components/ui/skeleton";
13import { Separator } from "@/components/ui/separator";13import { Separator } from "@/components/ui/separator";
14import { PageHeader } from "@/components/page-header";14import { PageHeader } from "@/components/page-header";
@@ -19,6 +19,63 @@ import { useMutation, useQuery } from "convex/react"; · 4 unmodified lines
19import type { PendingApprovalRecord } from "@/lib/types";19import type { PendingApprovalRecord } from "@/lib/types";
20import { toast } from "sonner";20import { toast } from "sonner";
21import { formatTimeAgo } from "@/lib/format";21import { formatTimeAgo } from "@/lib/format";
22import { FormattedCodeBlock } from "@/components/formatted-code-block";
23
24function 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}
22 79
23function ApprovalCard({80function ApprovalCard({
24 approval,81 approval,
@@ -53,7 +110,10 @@ function ApprovalCard({ · 28 unmodified lines
53 }110 }
54 };111 };
55 112
56 const inputStr = JSON.stringify(approval.input, null, 2);113 const inputDisplay = useMemo(
114 () => formatApprovalInput(approval.input),
115 [approval.input],
116 );
57 117
58 return (118 return (
59 <Card className="bg-card border-border border-l-2 border-l-terminal-amber glow-amber">119 <Card className="bg-card border-border border-l-2 border-l-terminal-amber glow-amber">
@@ -83,14 +143,16 @@ function ApprovalCard({ · 23 unmodified lines
83 </div>143 </div>
84 144
85 {/* Input */}145 {/* Input */}
86 {inputStr !== "null" && inputStr !== "{}" && (146 {inputDisplay && (
87 <div>147 <div>
88 <span className="text-[10px] uppercase tracking-widest text-muted-foreground block mb-1.5">148 <span className="text-[10px] uppercase tracking-widest text-muted-foreground block mb-1.5">
89 Input149 Input
90 </span>150 </span>
91 <pre className="terminal-block max-h-36 overflow-y-auto text-xs">151 <FormattedCodeBlock
92 {inputStr}152 content={inputDisplay.content}
93 </pre>153 language={inputDisplay.language}
154 className="max-h-52 overflow-y-auto"
155 />
94 </div>156 </div>
95 )}157 )}
96 158
executor/apps/web/src/components/formatted-code-block.tsx+45−0
@@ -0,0 +1,45 @@
1"use client";
2
3import { useMemo } from "react";
4import { Streamdown } from "streamdown";
5import { createCodePlugin } from "@streamdown/code";
6import { cn } from "@/lib/utils";
7
8const codePlugin = createCodePlugin({
9 themes: ["github-light", "github-dark"],
10});
11
12type CodeTone = "default" | "green" | "amber" | "red";
13
14function toCodeFence(content: string, language: string) {
15 return `~~~~${language}\n${content}\n~~~~`;
16}
17
18function 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
25export 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
24import { Separator } from "@/components/ui/separator";24import { Separator } from "@/components/ui/separator";
25import { PageHeader } from "@/components/page-header";25import { PageHeader } from "@/components/page-header";
26import { TaskStatusBadge } from "@/components/status-badge";26import { TaskStatusBadge } from "@/components/status-badge";
27import { FormattedCodeBlock } from "@/components/formatted-code-block";
27import { useSession } from "@/lib/session-context";28import { useSession } from "@/lib/session-context";
28import { useWorkspaceTools } from "@/hooks/use-workspace-tools";29import { useWorkspaceTools } from "@/hooks/use-workspace-tools";
29import { useMutation, useQuery } from "convex/react";30import { useMutation, useQuery } from "convex/react";
@@ -298,9 +299,11 @@ function TaskDetail({ · 268 unmodified lines
298 <span className="text-[10px] uppercase tracking-widest text-muted-foreground block mb-2">299 <span className="text-[10px] uppercase tracking-widest text-muted-foreground block mb-2">
299 Code300 Code
300 </span>301 </span>
301 <pre className="terminal-block max-h-48 overflow-y-auto">302 <FormattedCodeBlock
302 {liveTask.code}303 content={liveTask.code}
303 </pre>304 language="typescript"
305 className="max-h-48 overflow-y-auto"
306 />
304 </div>307 </div>
305 308
306 {/* Stdout */}309 {/* Stdout */}
@@ -309,9 +312,12 @@ function TaskDetail({ · 2 unmodified lines
309 <span className="text-[10px] uppercase tracking-widest text-terminal-green block mb-2">312 <span className="text-[10px] uppercase tracking-widest text-terminal-green block mb-2">
310 Stdout313 Stdout
311 </span>314 </span>
312 <pre className="terminal-block max-h-48 overflow-y-auto text-terminal-green/80">315 <FormattedCodeBlock
313 {liveStdout}316 content={liveStdout}
314 </pre>317 language="text"
318 tone="green"
319 className="max-h-48 overflow-y-auto"
320 />
315 </div>321 </div>
316 )}322 )}
317 323
@@ -321,9 +327,12 @@ function TaskDetail({ · 3 unmodified lines
321 <span className="text-[10px] uppercase tracking-widest text-terminal-amber block mb-2">327 <span className="text-[10px] uppercase tracking-widest text-terminal-amber block mb-2">
322 Stderr328 Stderr
323 </span>329 </span>
324 <pre className="terminal-block max-h-48 overflow-y-auto text-terminal-amber/80">330 <FormattedCodeBlock
325 {liveStderr}331 content={liveStderr}
326 </pre>332 language="text"
333 tone="amber"
334 className="max-h-48 overflow-y-auto"
335 />
327 </div>336 </div>
328 )}337 )}
329 338
@@ -333,9 +342,12 @@ function TaskDetail({ · 3 unmodified lines
333 <span className="text-[10px] uppercase tracking-widest text-terminal-red block mb-2">342 <span className="text-[10px] uppercase tracking-widest text-terminal-red block mb-2">
334 Error343 Error
335 </span>344 </span>
336 <pre className="terminal-block max-h-48 overflow-y-auto text-terminal-red/80">345 <FormattedCodeBlock
337 {liveTask.error}346 content={liveTask.error}
338 </pre>347 language="text"
348 tone="red"
349 className="max-h-48 overflow-y-auto"
350 />
339 </div>351 </div>
340 )}352 )}
341 </CardContent>353 </CardContent>
executor/convex/database.ts+8−0
@@ -1,5 +1,6 @@
1import { v } from "convex/values";1import { v } from "convex/values";
2import type { Doc } from "./_generated/dataModel";2import type { Doc } from "./_generated/dataModel";
3import { internal } from "./_generated/api";
3import { mutation,4import { mutation,
4query,5query,
5type MutationCtx, type QueryCtx } from "./_generated/server";6type MutationCtx, type QueryCtx } from "./_generated/server";
@@ -503,6 +504,13 @@ export const createApproval = internalMutation({ · 497 unmodified lines
503 createdAt: now,504 createdAt: now,
504 });505 });
505 506
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
506 const created = await getApprovalDoc(ctx, args.id);514 const created = await getApprovalDoc(ctx, args.id);
507 if (!created) {515 if (!created) {
508 throw new Error(`Failed to fetch approval ${args.id}`);516 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
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 = "v13";60 const signatureVersion = "v14";
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();
executor/convex/schema.ts+15−0
@@ -217,6 +217,21 @@ export default defineSchema({ · 216 unmodified lines
217 .index("by_workspace_created", ["workspaceId", "createdAt"])217 .index("by_workspace_created", ["workspaceId", "createdAt"])
218 .index("by_workspace_status_created", ["workspaceId", "status", "createdAt"]),218 .index("by_workspace_status_created", ["workspaceId", "status", "createdAt"]),
219 219
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
220 taskEvents: defineTable({235 taskEvents: defineTable({
221 sequence: v.number(),236 sequence: v.number(),
222 taskId: v.string(),237 taskId: v.string(),
executor/lib/mcp-server.test.ts+35−1
@@ -43,7 +43,9 @@ class FakeMcpService { · 42 unmodified lines
43 completedAt: current.createdAt + 2,43 completedAt: current.createdAt + 2,
44 updatedAt: current.createdAt + 2,44 updatedAt: current.createdAt + 2,
45 exitCode: 0,45 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)}`,
47 stderr: "",49 stderr: "",
48 });50 });
49 // Notify subscribers51 // Notify subscribers
@@ -164,6 +166,38 @@ test("run_code MCP tool returns terminal task result", async () => { · 114 unmodified lines
164 });166 });
165});167});
166 168
169test("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
167test("run_code MCP tool bootstraps anonymous context when workspace is omitted", async () => {201test("run_code MCP tool bootstraps anonymous context when workspace is omitted", async () => {
168 const service = new FakeMcpService();202 const service = new FakeMcpService();
169 203
executor/lib/mcp_server.ts+23−4
@@ -128,6 +128,7 @@ async function loadSourceDtsByUrl(dtsUrls: Record<string, string>): Promise<Reco · 127 unmodified lines
128}128}
129 129
130function summarizeTask(task: TaskRecord): string {130function summarizeTask(task: TaskRecord): string {
131 const maxStreamPreviewChars = 30_000;
131 const lines = [132 const lines = [
132 `taskId: ${task.id}`,133 `taskId: ${task.id}`,
133 `status: ${task.status}`,134 `status: ${task.status}`,
@@ -142,12 +143,29 @@ function summarizeTask(task: TaskRecord): string { · 8 unmodified lines
142 lines.push(`error: ${task.error}`);143 lines.push(`error: ${task.error}`);
143 }144 }
144 145
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
145 let text = lines.join("\n");157 let text = lines.join("\n");
146 if (task.stdout && task.stdout.trim()) {158 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);
148 }163 }
149 if (task.stderr && task.stderr.trim()) {164 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);
151 }169 }
152 return text;170 return text;
153}171}
@@ -283,12 +301,13 @@ function buildRunCodeDescription(tools?: ToolDescriptor[]): string { · 129 unmodified lines
283 ? `\n\nTop-level tool keys: ${topLevelKeys.join(", ")}`301 ? `\n\nTop-level tool keys: ${topLevelKeys.join(", ")}`
284 : "";302 : "";
285 const hasGraphqlTools = toolList.some((tool) => tool.path.endsWith(".graphql"));303 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.";
287 const graphqlNote = hasGraphqlTools306 const graphqlNote = hasGraphqlTools
288 ? "\n\nGraphQL tip: prefer `source.query.*` / `source.mutation.*` helper paths when available; GraphQL tools return `{ data, errors }`."307 ? "\n\nGraphQL tip: prefer `source.query.*` / `source.mutation.*` helper paths when available; GraphQL tools return `{ data, errors }`."
289 : "";308 : "";
290 309
291 return base + rootKeysNote + discoverNote + graphqlNote + generateToolInventory(toolList);310 return base + rootKeysNote + discoverNote + executionNote + graphqlNote + generateToolInventory(toolList);
292}311}
293 312
294function formatApprovalInput(input: unknown, maxLength = 2000): string {313function 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
21 { query: "addnumbers", depth: 2 },21 { query: "addnumbers", depth: 2 },
22 { taskId: "t", workspaceId: "w", isToolAllowed: () => true },22 { taskId: "t", workspaceId: "w", isToolAllowed: () => true },
23 ) as {23 ) as {
24 bestPath: string | null;
24 results: Array<{25 results: Array<{
25 path: string;26 path: string;
26 aliases: string[];27 aliases: string[];
@@ -30,6 +31,7 @@ test("discover returns aliases and example calls", async () => { · 3 unmodified lines
30 total: number;31 total: number;
31 };32 };
32 33
34 expect(result.bestPath).toBe("calc.math.add_numbers");
33 expect(result.total).toBe(1);35 expect(result.total).toBe(1);
34 expect(result.results[0]?.path).toBe("calc.math.add_numbers");36 expect(result.results[0]?.path).toBe("calc.math.add_numbers");
35 expect(result.results[0]?.aliases).toContain("calc.math.addNumbers");37 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
57 { query: "issuecreate", depth: 2 },59 { query: "issuecreate", depth: 2 },
58 { taskId: "t", workspaceId: "w", isToolAllowed: () => true },60 { taskId: "t", workspaceId: "w", isToolAllowed: () => true },
59 ) as {61 ) as {
62 bestPath: string | null;
60 results: Array<{ exampleCall: string }>;63 results: Array<{ exampleCall: string }>;
61 };64 };
62 65
66 expect(result.bestPath).toBe("linear.mutation.issuecreate");
63 expect(result.results[0]?.exampleCall).toBe(67 expect(result.results[0]?.exampleCall).toBe(
64 "await tools.linear.mutation.issuecreate({ input: { /* ... */ } });",68 "await tools.linear.mutation.issuecreate({ input: { /* ... */ } });",
65 );69 );
66});70});
71
72test("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
114test("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
511 server.stop(true);511 server.stop(true);
512 }512 }
513});513});
514
515test("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
559test("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
107 return `await tools.${entry.path}({ /* ... */ });`;107 return `await tools.${entry.path}({ /* ... */ });`;
108}108}
109 109
110function 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
116function 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
128function 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
139function compactDescription(description: string): string {
140 const firstLine = description.split("\n")[0] ?? description;
141 return truncateInline(firstLine, 180);
142}
143
110function buildIndex(tools: ToolDefinition[]): DiscoverIndexEntry[] {144function buildIndex(tools: ToolDefinition[]): DiscoverIndexEntry[] {
111 return tools145 return tools
112 .filter((tool) => tool.path !== "discover")146 .filter((tool) => tool.path !== "discover")
@@ -152,7 +186,21 @@ function scoreEntry(entry: DiscoverIndexEntry, terms: string[]): number { · 39 unmodified lines
152 return score + matched * 2;186 return score + matched * 2;
153}187}
154 188
155function formatSignature(entry: DiscoverIndexEntry, depth: number): string {189function 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
156 if (depth <= 0) {204 if (depth <= 0) {
157 return `(input: ${entry.argsType}): Promise<...>`;205 return `(input: ${entry.argsType}): Promise<...>`;
158 }206 }
@@ -170,17 +218,18 @@ export function createDiscoverTool(tools: ToolDefinition[]): ToolDefinition { · 11 unmodified lines
170 source: "system",218 source: "system",
171 approval: "auto",219 approval: "auto",
172 description:220 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.",
174 metadata: {222 metadata: {
175 argsType: "{ query: string; depth?: number; limit?: number }",223 argsType: "{ query: string; depth?: number; limit?: number; compact?: boolean }",
176 returnsType:224 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 }",
178 },226 },
179 run: async (input: unknown, context) => {227 run: async (input: unknown, context) => {
180 const payload = input && typeof input === "object" ? (input as Record<string, unknown>) : {};228 const payload = input && typeof input === "object" ? (input as Record<string, unknown>) : {};
181 const query = String(payload.query ?? "").trim().toLowerCase();229 const query = String(payload.query ?? "").trim().toLowerCase();
182 const depth = Math.max(0, Math.min(2, Number(payload.depth ?? 1)));230 const depth = Math.max(0, Math.min(2, Number(payload.depth ?? 1)));
183 const limit = Math.max(1, Math.min(50, Number(payload.limit ?? 8)));231 const limit = Math.max(1, Math.min(50, Number(payload.limit ?? 8)));
232 const compact = payload.compact === false ? false : true;
184 const terms = query.length > 0 ? query.split(/\s+/).filter(Boolean) : [];233 const terms = query.length > 0 ? query.split(/\s+/).filter(Boolean) : [];
185 234
186 const ranked = index235 const ranked = index
@@ -194,12 +243,13 @@ export function createDiscoverTool(tools: ToolDefinition[]): ToolDefinition { · 7 unmodified lines
194 aliases: entry.aliases,243 aliases: entry.aliases,
195 source: entry.source,244 source: entry.source,
196 approval: entry.approval,245 approval: entry.approval,
197 description: entry.description,246 description: compact ? compactDescription(entry.description) : entry.description,
198 signature: formatSignature(entry, depth),247 signature: formatSignature(entry, depth, compact),
199 exampleCall: buildExampleCall(entry),248 exampleCall: buildExampleCall(entry),
200 }));249 }));
201 250
202 return {251 return {
252 bestPath: ranked[0]?.path ?? null,
203 results: ranked,253 results: ranked,
204 total: ranked.length,254 total: ranked.length,
205 };255 };
executor/lib/tool_sources.ts+6−2
@@ -484,11 +484,14 @@ function jsonSchemaTypeHintFallback( · 483 unmodified lines
484 }484 }
485 return "Record<string, unknown>";485 return "Record<string, unknown>";
486 }486 }
487 const maxInlineProps = 12;
488 const isTruncated = propEntries.length > maxInlineProps;
487 const inner = propEntries489 const inner = propEntries
488 .slice(0, 12)490 .slice(0, maxInlineProps)
489 .map(([key, value]) => `${formatTsPropertyKey(key)}${required.has(key) ? "" : "?"}: ${jsonSchemaTypeHintFallback(value, depth + 1, componentSchemas, seenRefs)}`)491 .map(([key, value]) => `${formatTsPropertyKey(key)}${required.has(key) ? "" : "?"}: ${jsonSchemaTypeHintFallback(value, depth + 1, componentSchemas, seenRefs)}`)
490 .join("; ");492 .join("; ");
491 return `{ ${inner} }`;493 const indexSignature = isTruncated ? `${inner ? "; " : ""}[key: string]: any` : "";
494 return `{ ${inner}${indexSignature} }`;
492 }495 }
493 496
494 return "unknown";497 return "unknown";
@@ -1627,6 +1630,7 @@ async function loadGraphqlTools(config: GraphqlToolSourceConfig): Promise<ToolDe · 1132 unmodified lines
1627 ? `${field.description}\n\nPreferred: ${directCallExample}\nReturns: { data, errors }\nRaw GraphQL: ${sourceName}.graphql({ query: \`${exampleQuery}\`, variables: {...} })`1630 ? `${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: {...} })`,1631 : `GraphQL ${operationType}: ${field.name}\n\nPreferred: ${directCallExample}\nReturns: { data, errors }\nRaw GraphQL: ${sourceName}.graphql({ query: \`${exampleQuery}\`, variables: {...} })`,
1629 approval,1632 approval,
1633 credential: credentialSpec,
1630 metadata: {1634 metadata: {
1631 argsType: gqlFieldArgsTypeHint(field.args, typeMap),1635 argsType: gqlFieldArgsTypeHint(field.args, typeMap),
1632 returnsType: `{ data: ${gqlTypeToHint(field.type, typeMap)}; errors: unknown[] }`,1636 returnsType: `{ data: ${gqlTypeToHint(field.type, typeMap)}; errors: unknown[] }`,
executor/lib/typechecker.test.ts+3−3
@@ -137,7 +137,7 @@ describe("generateToolInventory", () => { · 136 unmodified lines
137 expect(result).toContain("You have access to these tool namespaces:");137 expect(result).toContain("You have access to these tool namespaces:");
138 expect(result).toContain("admin (1)");138 expect(result).toContain("admin (1)");
139 expect(result).toContain("math (1)");139 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");
141 expect(result).toContain("Never shadow the global `tools` object");141 expect(result).toContain("Never shadow the global `tools` object");
142 });142 });
143 143
@@ -378,7 +378,7 @@ export interface operations { · 234 unmodified lines
378 description: "Discover tools",378 description: "Discover tools",
379 approval: "auto",379 approval: "auto",
380 source: "system",380 source: "system",
381 argsType: "{ query: string; depth?: number; limit?: number }",381 argsType: "{ query: string; depth?: number; limit?: number; compact?: boolean }",
382 returnsType: "unknown",382 returnsType: "unknown",
383 });383 });
384 384
@@ -406,7 +406,7 @@ export interface operations { · 21 unmodified lines
406 path: "discover",406 path: "discover",
407 description: "Discover tools",407 description: "Discover tools",
408 approval: "auto",408 approval: "auto",
409 argsType: "{ query: string; depth?: number; limit?: number }",409 argsType: "{ query: string; depth?: number; limit?: number; compact?: boolean }",
410 returnsType: "unknown",410 returnsType: "unknown",
411 },411 },
412 ];412 ];
executor/lib/typechecker.ts+2−1
@@ -225,8 +225,9 @@ export function generateToolInventory(tools: ToolDescriptor[]): string { · 224 unmodified lines
225 "You have access to these tool namespaces:",225 "You have access to these tool namespaces:",
226 ` ${namespaces.join(", ")}`,226 ` ${namespaces.join(", ")}`,
227 "",227 "",
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).",
229 "Use the exact `path` returned by discover, and prefer its `exampleCall` snippet for invocation shape.",229 "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).",
230 "Never shadow the global `tools` object (do NOT write `const tools = ...`).",231 "Never shadow the global `tools` object (do NOT write `const tools = ...`).",
231 "Then call tools directly using the returned path.",232 "Then call tools directly using the returned path.",
232 ...(hasGraphqlTools233 ...(hasGraphqlTools