Unified · Split
| @@ -29,7 +29,8 @@ import type { TaskEvent } from "@openassistant/core/events"; · 28 unmodified lines | |||
| 29 | interface PendingApproval { | 29 | interface PendingApproval { |
| 30 | readonly id: string; | 30 | readonly id: string; |
| 31 | readonly toolPath: string; | 31 | readonly toolPath: string; |
| 32 | readonly preview: { title: string; details?: string }; | 32 | readonly input: unknown; |
| 33 | readonly preview: { title: string; details?: string; link?: string }; | ||
| 33 | } | 34 | } |
| 34 | 35 | ||
| 35 | interface TaskState { | 36 | interface TaskState { |
| @@ -65,7 +66,7 @@ function reduceEvent(state: TaskState, event: TaskEvent): TaskState { · 29 unmodified lines | |||
| 65 | ...state, | 66 | ...state, |
| 66 | pendingApprovals: [ | 67 | pendingApprovals: [ |
| 67 | ...state.pendingApprovals, | 68 | ...state.pendingApprovals, |
| 68 | { id: event.id, toolPath: event.toolPath, preview: event.preview }, | 69 | { id: event.id, toolPath: event.toolPath, input: event.input, preview: event.preview }, |
| 69 | ], | 70 | ], |
| 70 | statusMessage: "Waiting for approval...", | 71 | statusMessage: "Waiting for approval...", |
| 71 | }; | 72 | }; |
| @@ -214,6 +215,7 @@ export function TaskMessage({ taskId, prompt, api }: TaskMessageProps) { · 142 unmodified lines | |||
| 214 | 215 | ||
| 215 | function ApprovalButtons({ approval, api }: { approval: PendingApproval; api: ApiClient }) { | 216 | function ApprovalButtons({ approval, api }: { approval: PendingApproval; api: ApiClient }) { |
| 216 | const [resolved, setResolved] = useState(false); | 217 | const [resolved, setResolved] = useState(false); |
| 218 | const argsPreview = formatApprovalInput(approval.input); | ||
| 217 | 219 | ||
| 218 | const handle = async (decision: "approved" | "denied") => { | 220 | const handle = async (decision: "approved" | "denied") => { |
| 219 | setResolved(true); | 221 | setResolved(true); |
| @@ -228,8 +230,9 @@ function ApprovalButtons({ approval, api }: { approval: PendingApproval; api: Ap · 8 unmodified lines | |||
| 228 | <> | 230 | <> |
| 229 | <Separator /> | 231 | <Separator /> |
| 230 | <TextDisplay> | 232 | <TextDisplay> |
| 231 | {`\u{1f6e1}\ufe0f **Approval required:** ${approval.preview.title}${approval.preview.details ? `\n${approval.preview.details}` : ""}`} | 233 | {`\u{1f6e1}\ufe0f **Approval required:** ${approval.preview.title}${approval.preview.details ? `\n${approval.preview.details}` : ""}${approval.preview.link ? `\n${approval.preview.link}` : ""}`} |
| 232 | </TextDisplay> | 234 | </TextDisplay> |
| 235 | <TextDisplay>{`\`\`\`json\n${argsPreview}\n\`\`\``}</TextDisplay> | ||
| 233 | {!resolved && ( | 236 | {!resolved && ( |
| 234 | <ActionRow> | 237 | <ActionRow> |
| 235 | <Button label="Approve" style="success" onClick={() => handle("approved")} /> | 238 | <Button label="Approve" style="success" onClick={() => handle("approved")} /> |
| @@ -252,3 +255,15 @@ function statusEmoji(status: TaskState["status"]): string { · 16 unmodified lines | |||
| 252 | case "cancelled": return "\u26d4"; | 255 | case "cancelled": return "\u26d4"; |
| 253 | } | 256 | } |
| 254 | } | 257 | } |
| 258 | |||
| 259 | function formatApprovalInput(input: unknown): string { | ||
| 260 | if (input === undefined) return "undefined"; | ||
| 261 | if (input === null) return "null"; | ||
| 262 | try { | ||
| 263 | const serialized = JSON.stringify(input, null, 2); | ||
| 264 | if (!serialized) return String(input); | ||
| 265 | return serialized.length > 1000 ? `${serialized.slice(0, 1000)}...` : serialized; | ||
| 266 | } catch { | ||
| 267 | return String(input); | ||
| 268 | } | ||
| 269 | } | ||
| @@ -186,19 +186,31 @@ function buildSystemPrompt(guidance: string, discoveryMode: boolean): string { · 185 unmodified lines | |||
| 186 | ? ` | 186 | ? ` |
| 187 | ## Tool Discovery | 187 | ## Tool Discovery |
| 188 | 188 | ||
| 189 | Some tool namespaces are too large to list here. Use \`tools.discover({ query: "..." })\` to search for tools by keyword. | 189 | Some tool namespaces are too large to list here. Use \`tools.discover({ query, depth? })\` to search for tools by keyword. |
| 190 | It returns matching tool paths, descriptions, and TypeScript signatures. | ||
| 191 | 190 | ||
| 192 | **Workflow:** First call discover to learn what tools are available. Then write a SINGLE self-contained script that does the entire task — fetching data, looping, transforming, and writing back. Don't split the actual work across multiple run_code calls. | 191 | The \`depth\` parameter controls how much type detail you get: |
| 192 | - **depth 0** (default): tool paths, descriptions, and input arg types only. Fast, use for browsing. | ||
| 193 | - **depth 1**: adds return types (comments stripped). Use when you need to know response shapes before writing code. | ||
| 194 | - **depth 2**: full signatures with JSDoc comments and examples. Use when you need exact details. | ||
| 195 | |||
| 196 | **Workflow:** | ||
| 197 | 1. Discover tools at depth 0 to find what's available | ||
| 198 | 2. If you need to know response shapes, re-discover at depth 1 for the specific tools you'll use | ||
| 199 | 3. Write a SINGLE self-contained script that does the entire task | ||
| 193 | 200 | ||
| 194 | Example — "close all open issues on acme/myapp": | 201 | Example — "close all open issues on acme/myapp": |
| 195 | 202 | ||
| 196 | Call 1 (discovery): | 203 | Call 1 (find tools): |
| 197 | \`\`\`ts | 204 | \`\`\`ts |
| 198 | return await tools.discover({ query: "issues list update repo" }); | 205 | return await tools.discover({ query: "issues list update repo" }); |
| 199 | \`\`\` | 206 | \`\`\` |
| 200 | 207 | ||
| 201 | Call 2 (do the work — everything in one script): | 208 | Call 2 (get return types for the tools you'll use): |
| 209 | \`\`\`ts | ||
| 210 | return await tools.discover({ query: "issues list_for_repo update", depth: 1 }); | ||
| 211 | \`\`\` | ||
| 212 | |||
| 213 | Call 3 (do the work — everything in one script): | ||
| 202 | \`\`\`ts | 214 | \`\`\`ts |
| 203 | const issues = await tools.github.issues.issues_list_for_repo({ | 215 | const issues = await tools.github.issues.issues_list_for_repo({ |
| 204 | owner: "acme", repo: "myapp", state: "open", per_page: 100 | 216 | owner: "acme", repo: "myapp", state: "open", per_page: 100 |
| @@ -1,10 +1,12 @@ | |||
| 1 | /** | 1 | /** |
| 2 | * Tool discovery — searchable index of tools for large APIs. | 2 | * Tool discovery — searchable index of tools for large APIs. |
| 3 | * | 3 | * |
| 4 | * When there are too many tools to fit in the LLM context window, | 4 | * The LLM uses `tools.discover({ query, depth? })` to find tools and |
| 5 | * the agent uses `tools.discover({ query })` to search for relevant | 5 | * control how much type detail to retrieve: |
| 6 | * tools by name, path, and description. Returns type signatures so | 6 | * |
| 7 | * the LLM knows how to call what it finds. | 7 | * - depth 0 (default): paths, descriptions, input args only. Cheap in tokens. |
| 8 | * - depth 1: adds return types with top-level properties. | ||
| 9 | * - depth 2: full return types with nested objects expanded. | ||
| 8 | * | 10 | * |
| 9 | * The discovered tools are already wired in the sandbox — the LLM | 11 | * The discovered tools are already wired in the sandbox — the LLM |
| 10 | * just doesn't get told about all of them in the system prompt. | 12 | * just doesn't get told about all of them in the system prompt. |
| @@ -26,8 +28,10 @@ export interface ToolIndexEntry { · 15 unmodified lines | |||
| 26 | readonly description: string; | 28 | readonly description: string; |
| 27 | /** Approval mode */ | 29 | /** Approval mode */ |
| 28 | readonly approval: "auto" | "required"; | 30 | readonly approval: "auto" | "required"; |
| 29 | /** TypeScript signature: "(input: { ... }): Promise<{ ... }>" */ | 31 | /** Full args type string (always available) */ |
| 30 | readonly signature: string; | 32 | readonly argsType: string; |
| 33 | /** Full returns type string (may be large for OpenAPI tools) */ | ||
| 34 | readonly returnsType: string; | ||
| 31 | /** Searchable text (lowercase): path + description */ | 35 | /** Searchable text (lowercase): path + description */ |
| 32 | readonly searchText: string; | 36 | readonly searchText: string; |
| 33 | } | 37 | } |
| @@ -44,13 +48,12 @@ export function buildToolIndex(tree: ToolTree): ToolIndexEntry[] { · 10 unmodified lines | |||
| 44 | const path = prefix ? `${prefix}.${key}` : key; | 48 | const path = prefix ? `${prefix}.${key}` : key; |
| 45 | if (isToolDefinition(value)) { | 49 | if (isToolDefinition(value)) { |
| 46 | const tool = value as ToolDefinition; | 50 | const tool = value as ToolDefinition; |
| 47 | const argsType = getArgsTypeString(tool); | ||
| 48 | const returnsType = getReturnsTypeString(tool); | ||
| 49 | entries.push({ | 51 | entries.push({ |
| 50 | path, | 52 | path, |
| 51 | description: tool.description, | 53 | description: tool.description, |
| 52 | approval: tool.approval, | 54 | approval: tool.approval, |
| 53 | signature: `(input: ${argsType}): Promise<${returnsType}>`, | 55 | argsType: getArgsTypeString(tool), |
| 56 | returnsType: getReturnsTypeString(tool), | ||
| 54 | searchText: `${path} ${tool.description}`.toLowerCase(), | 57 | searchText: `${path} ${tool.description}`.toLowerCase(), |
| 55 | }); | 58 | }); |
| 56 | } else { | 59 | } else { |
| @@ -63,6 +66,38 @@ export function buildToolIndex(tree: ToolTree): ToolIndexEntry[] { · 6 unmodified lines | |||
| 63 | return entries; | 66 | return entries; |
| 64 | } | 67 | } |
| 65 | 68 | ||
| 69 | // --------------------------------------------------------------------------- | ||
| 70 | // Strip JSDoc comments from type strings | ||
| 71 | // --------------------------------------------------------------------------- | ||
| 72 | |||
| 73 | function stripJSDoc(typeStr: string): string { | ||
| 74 | return typeStr | ||
| 75 | .replace(/\/\*\*[^]*?\*\//g, "") // block comments | ||
| 76 | .replace(/\n\s*\n/g, "\n") // collapse blank lines | ||
| 77 | .trim(); | ||
| 78 | } | ||
| 79 | |||
| 80 | // --------------------------------------------------------------------------- | ||
| 81 | // Format signature at different depths | ||
| 82 | // --------------------------------------------------------------------------- | ||
| 83 | |||
| 84 | /** | ||
| 85 | * Build a signature string for a tool at a given depth: | ||
| 86 | * - depth 0: input args only, no return type | ||
| 87 | * - depth 1: input args + return type (JSDoc stripped for conciseness) | ||
| 88 | * - depth 2: input args + full return type (with JSDoc) | ||
| 89 | */ | ||
| 90 | function formatSignature(entry: ToolIndexEntry, depth: number): string { | ||
| 91 | const args = depth >= 2 ? entry.argsType : stripJSDoc(entry.argsType); | ||
| 92 | |||
| 93 | if (depth === 0) { | ||
| 94 | return `(input: ${args}): Promise<...>`; | ||
| 95 | } | ||
| 96 | |||
| 97 | const returns = depth >= 2 ? entry.returnsType : stripJSDoc(entry.returnsType); | ||
| 98 | return `(input: ${args}): Promise<${returns}>`; | ||
| 99 | } | ||
| 100 | |||
| 66 | // --------------------------------------------------------------------------- | 101 | // --------------------------------------------------------------------------- |
| 67 | // Search | 102 | // Search |
| 68 | // --------------------------------------------------------------------------- | 103 | // --------------------------------------------------------------------------- |
| @@ -128,9 +163,17 @@ export interface DiscoverToolOptions { · 59 unmodified lines | |||
| 128 | * Create a `discover` tool that searches the tool index. | 163 | * Create a `discover` tool that searches the tool index. |
| 129 | * | 164 | * |
| 130 | * Usage in LLM-generated code: | 165 | * Usage in LLM-generated code: |
| 166 | * // Step 1: find tools (cheap — no return types) | ||
| 131 | * const results = await tools.discover({ query: "feature flags" }); | 167 | * const results = await tools.discover({ query: "feature flags" }); |
| 132 | * // results is an array of { path, description, approval, signature } | 168 | * |
| 133 | * // Then call: await tools.posthog.feature_flags.list({ ... }) | 169 | * // Step 2: get full types for the tools you'll use |
| 170 | * const details = await tools.discover({ | ||
| 171 | * query: "feature flags list", | ||
| 172 | * depth: 1 | ||
| 173 | * }); | ||
| 174 | * | ||
| 175 | * // Step 3: call the tool | ||
| 176 | * await tools.posthog.feature_flags.list({ ... }) | ||
| 134 | */ | 177 | */ |
| 135 | export function createDiscoverTool( | 178 | export function createDiscoverTool( |
| 136 | tree: ToolTree, | 179 | tree: ToolTree, |
| @@ -140,10 +183,16 @@ export function createDiscoverTool( · 3 unmodified lines | |||
| 140 | const maxResults = options?.maxResults ?? 20; | 183 | const maxResults = options?.maxResults ?? 20; |
| 141 | 184 | ||
| 142 | return defineTool({ | 185 | return defineTool({ |
| 143 | description: `Search for available tools by keyword. Returns matching tool paths, descriptions, and TypeScript signatures. Use this when you need to find tools for a specific task. There are ${index.length} tools available across all connected services.`, | 186 | description: `Search for available tools by keyword. Returns matching tool paths, descriptions, and type signatures. There are ${index.length} tools available. |
| 187 | |||
| 188 | depth controls how much type detail to return: | ||
| 189 | - depth 0 (default): input args only, return type hidden. Fast, cheap in tokens. Use for browsing. | ||
| 190 | - depth 1: input args + return types (comments stripped). Use when you need to know response shapes. | ||
| 191 | - depth 2: full signatures with JSDoc comments and examples. Use when you need exact details.`, | ||
| 144 | approval: "auto" as const, | 192 | approval: "auto" as const, |
| 145 | args: z.object({ | 193 | args: z.object({ |
| 146 | query: z.string().describe("Search keywords (e.g. 'feature flags', 'create user', 'analytics events')"), | 194 | query: z.string().describe("Search keywords (e.g. 'feature flags', 'create user', 'list issues')"), |
| 195 | depth: z.number().optional().describe("Type detail level: 0 (default) = args only, 1 = + return types, 2 = + JSDoc comments"), | ||
| 147 | }), | 196 | }), |
| 148 | returns: z.object({ | 197 | returns: z.object({ |
| 149 | results: z.array(z.object({ | 198 | results: z.array(z.object({ |
| @@ -155,13 +204,14 @@ export function createDiscoverTool( · 5 unmodified lines | |||
| 155 | total: z.number(), | 204 | total: z.number(), |
| 156 | }), | 205 | }), |
| 157 | run: async (input) => { | 206 | run: async (input) => { |
| 207 | const depth = input.depth ?? 0; | ||
| 158 | const results = searchIndex(index, input.query, maxResults); | 208 | const results = searchIndex(index, input.query, maxResults); |
| 159 | return { | 209 | return { |
| 160 | results: results.map((e) => ({ | 210 | results: results.map((e) => ({ |
| 161 | path: e.path, | 211 | path: e.path, |
| 162 | description: e.description, | 212 | description: e.description, |
| 163 | approval: e.approval, | 213 | approval: e.approval, |
| 164 | signature: e.signature, | 214 | signature: formatSignature(e, depth), |
| 165 | })), | 215 | })), |
| 166 | total: results.length, | 216 | total: results.length, |
| 167 | }; | 217 | }; |
| @@ -329,6 +329,49 @@ describe("runner — approval flow", () => { · 328 unmodified lines | |||
| 329 | const req = capturedRequest as { preview: { title: string } }; | 329 | const req = capturedRequest as { preview: { title: string } }; |
| 330 | expect(req.preview.title).toBe("Delete user_42 from users"); | 330 | expect(req.preview.title).toBe("Delete user_42 from users"); |
| 331 | }); | 331 | }); |
| 332 | |||
| 333 | test("approval request falls back to default preview when formatApproval is missing", async () => { | ||
| 334 | let capturedRequest: unknown; | ||
| 335 | const tools: ToolTree = { | ||
| 336 | vercel: { | ||
| 337 | projects: { | ||
| 338 | deleteProject: defineTool({ | ||
| 339 | description: "Delete a project", | ||
| 340 | approval: "required", | ||
| 341 | args: z.object({ idOrName: z.string() }), | ||
| 342 | returns: z.object({ ok: z.boolean() }), | ||
| 343 | run: async () => ({ ok: true }), | ||
| 344 | }), | ||
| 345 | }, | ||
| 346 | }, | ||
| 347 | }; | ||
| 348 | |||
| 349 | const runner = createRunner({ | ||
| 350 | tools, | ||
| 351 | requestApproval: async (req) => { | ||
| 352 | capturedRequest = req; | ||
| 353 | return "denied"; | ||
| 354 | }, | ||
| 355 | newCallId: testCallId, | ||
| 356 | }); | ||
| 357 | |||
| 358 | await runner.run('await tools.vercel.projects.deleteProject({ idOrName: "prj_123" })'); | ||
| 359 | |||
| 360 | const req = capturedRequest as { | ||
| 361 | preview: { | ||
| 362 | title: string; | ||
| 363 | details?: string; | ||
| 364 | action?: string; | ||
| 365 | resourceIds?: string[]; | ||
| 366 | isDestructive?: boolean; | ||
| 367 | }; | ||
| 368 | }; | ||
| 369 | expect(req.preview.title).toBe("Delete via vercel.projects.deleteProject"); | ||
| 370 | expect(req.preview.details).toContain("Target: prj_123"); | ||
| 371 | expect(req.preview.action).toBe("delete"); | ||
| 372 | expect(req.preview.resourceIds).toEqual(["prj_123"]); | ||
| 373 | expect(req.preview.isDestructive).toBe(true); | ||
| 374 | }); | ||
| 332 | }); | 375 | }); |
| 333 | 376 | ||
| 334 | describe("runner — error handling", () => { | 377 | describe("runner — error handling", () => { |
| @@ -53,6 +53,7 @@ export interface Runner { · 52 unmodified lines | |||
| 53 | // --------------------------------------------------------------------------- | 53 | // --------------------------------------------------------------------------- |
| 54 | 54 | ||
| 55 | const MAX_PREVIEW = 180; | 55 | const MAX_PREVIEW = 180; |
| 56 | const MAX_APPROVAL_DETAILS = 500; | ||
| 56 | 57 | ||
| 57 | function preview(value: unknown): string { | 58 | function preview(value: unknown): string { |
| 58 | if (value === undefined) return "undefined"; | 59 | if (value === undefined) return "undefined"; |
| @@ -65,6 +66,96 @@ function preview(value: unknown): string { · 6 unmodified lines | |||
| 65 | } | 66 | } |
| 66 | } | 67 | } |
| 67 | 68 | ||
| 69 | function isRecord(value: unknown): value is Record<string, unknown> { | ||
| 70 | return typeof value === "object" && value !== null && !Array.isArray(value); | ||
| 71 | } | ||
| 72 | |||
| 73 | function inferApprovalAction(toolPath: string): { | ||
| 74 | action: NonNullable<ApprovalPresentation["action"]>; | ||
| 75 | isDestructive: boolean; | ||
| 76 | } { | ||
| 77 | const lower = toolPath.toLowerCase(); | ||
| 78 | if (/(delete|remove|destroy|purge)/.test(lower)) { | ||
| 79 | return { action: "delete", isDestructive: true }; | ||
| 80 | } | ||
| 81 | if (/(create|add|insert|provision)/.test(lower)) { | ||
| 82 | return { action: "create", isDestructive: false }; | ||
| 83 | } | ||
| 84 | if (/(update|set|patch|edit|rename)/.test(lower)) { | ||
| 85 | return { action: "update", isDestructive: false }; | ||
| 86 | } | ||
| 87 | if (/(get|list|search|find|read)/.test(lower)) { | ||
| 88 | return { action: "read", isDestructive: false }; | ||
| 89 | } | ||
| 90 | return { action: "execute", isDestructive: false }; | ||
| 91 | } | ||
| 92 | |||
| 93 | function inferResourceType(toolPath: string): string | undefined { | ||
| 94 | const parts = toolPath.split(".").filter(Boolean); | ||
| 95 | const candidate = parts.at(-2) ?? parts.at(-1); | ||
| 96 | if (!candidate) return undefined; | ||
| 97 | return candidate | ||
| 98 | .replace(/_/g, " ") | ||
| 99 | .replace(/\b\w/g, (m) => m.toUpperCase()); | ||
| 100 | } | ||
| 101 | |||
| 102 | function extractResourceIds(input: unknown): string[] { | ||
| 103 | if (!isRecord(input)) return []; | ||
| 104 | const ids: string[] = []; | ||
| 105 | const candidateKeys = ["id", "ids", "name", "slug", "key", "idOrName"]; | ||
| 106 | for (const key of candidateKeys) { | ||
| 107 | const value = input[key]; | ||
| 108 | if (typeof value === "string" || typeof value === "number") { | ||
| 109 | ids.push(String(value)); | ||
| 110 | continue; | ||
| 111 | } | ||
| 112 | if (Array.isArray(value)) { | ||
| 113 | for (const item of value) { | ||
| 114 | if (typeof item === "string" || typeof item === "number") { | ||
| 115 | ids.push(String(item)); | ||
| 116 | } | ||
| 117 | } | ||
| 118 | } | ||
| 119 | } | ||
| 120 | return ids.slice(0, 5); | ||
| 121 | } | ||
| 122 | |||
| 123 | function extractCount(input: unknown): number | undefined { | ||
| 124 | if (!isRecord(input)) return undefined; | ||
| 125 | const raw = input["count"]; | ||
| 126 | return typeof raw === "number" && Number.isFinite(raw) ? raw : undefined; | ||
| 127 | } | ||
| 128 | |||
| 129 | function buildDefaultApprovalPresentation( | ||
| 130 | toolPath: string, | ||
| 131 | input: unknown, | ||
| 132 | ): ApprovalPresentation { | ||
| 133 | const { action, isDestructive } = inferApprovalAction(toolPath); | ||
| 134 | const actionVerb = action === "execute" | ||
| 135 | ? "Run" | ||
| 136 | : action.charAt(0).toUpperCase() + action.slice(1); | ||
| 137 | const resourceType = inferResourceType(toolPath); | ||
| 138 | const resourceIds = extractResourceIds(input); | ||
| 139 | const count = extractCount(input); | ||
| 140 | const rawPreview = preview(input); | ||
| 141 | |||
| 142 | const idDetail = resourceIds.length > 0 ? `Target: ${resourceIds.join(", ")}` : undefined; | ||
| 143 | const argsDetail = `Arguments: ${rawPreview}`; | ||
| 144 | const details = [idDetail, argsDetail].filter(Boolean).join("\n"); | ||
| 145 | |||
| 146 | return { | ||
| 147 | title: `${actionVerb} via ${toolPath}`, | ||
| 148 | details: details.length > MAX_APPROVAL_DETAILS | ||
| 149 | ? `${details.slice(0, MAX_APPROVAL_DETAILS)}...` | ||
| 150 | : details, | ||
| 151 | action, | ||
| 152 | resourceType, | ||
| 153 | resourceIds: resourceIds.length > 0 ? resourceIds : undefined, | ||
| 154 | count, | ||
| 155 | isDestructive, | ||
| 156 | }; | ||
| 157 | } | ||
| 158 | |||
| 68 | function describeError(error: unknown): string { | 159 | function describeError(error: unknown): string { |
| 69 | if (error instanceof Error) { | 160 | if (error instanceof Error) { |
| 70 | // Include cause if present (e.g. nested API errors) | 161 | // Include cause if present (e.g. nested API errors) |
| @@ -177,7 +268,7 @@ function createToolInvoker( · 106 unmodified lines | |||
| 177 | if (tool.approval === "required") { | 268 | if (tool.approval === "required") { |
| 178 | const presentation: ApprovalPresentation = tool.formatApproval | 269 | const presentation: ApprovalPresentation = tool.formatApproval |
| 179 | ? tool.formatApproval(validatedInput) | 270 | ? tool.formatApproval(validatedInput) |
| 180 | : { title: `Approve ${toolPath}` }; | 271 | : buildDefaultApprovalPresentation(toolPath, validatedInput); |
| 181 | 272 | ||
| 182 | const decision = await ctx.requestApproval({ | 273 | const decision = await ctx.requestApproval({ |
| 183 | callId, | 274 | callId, |
| @@ -27,6 +27,16 @@ export interface ApprovalPresentation { · 26 unmodified lines | |||
| 27 | title: string; | 27 | title: string; |
| 28 | details?: string; | 28 | details?: string; |
| 29 | link?: string; | 29 | link?: string; |
| 30 | /** | ||
| 31 | * Optional structured metadata for policy and richer UIs. | ||
| 32 | * These fields are additive and backward-compatible. | ||
| 33 | */ | ||
| 34 | action?: "create" | "update" | "delete" | "read" | "execute" | "other"; | ||
| 35 | resourceType?: string; | ||
| 36 | resourceIds?: string[]; | ||
| 37 | count?: number; | ||
| 38 | isDestructive?: boolean; | ||
| 39 | selector?: string; | ||
| 30 | } | 40 | } |
| 31 | 41 | ||
| 32 | /** | 42 | /** |
| @@ -30,6 +30,7 @@ describe("generateMcpTools — AnswerOverflow MCP", () => { · 29 unmodified lines | |||
| 30 | expect(tool.description).toBeDefined(); | 30 | expect(tool.description).toBeDefined(); |
| 31 | expect(tool.description.length).toBeGreaterThan(0); | 31 | expect(tool.description.length).toBeGreaterThan(0); |
| 32 | expect(tool.approval).toBe("auto"); // default | 32 | expect(tool.approval).toBe("auto"); // default |
| 33 | expect(typeof tool.formatApproval).toBe("function"); | ||
| 33 | }); | 34 | }); |
| 34 | 35 | ||
| 35 | // TypeScript declarations should be generated | 36 | // TypeScript declarations should be generated |
| @@ -91,4 +92,30 @@ describe("generateMcpTools — AnswerOverflow MCP", () => { · 55 unmodified lines | |||
| 91 | await result.close(); | 92 | await result.close(); |
| 92 | } | 93 | } |
| 93 | }, { timeout: 30_000 }); | 94 | }, { timeout: 30_000 }); |
| 95 | |||
| 96 | test("generated tools include approval preview formatter", async () => { | ||
| 97 | const result = await generateMcpTools({ | ||
| 98 | name: "answeroverflow", | ||
| 99 | url: "https://www.answeroverflow.com/mcp", | ||
| 100 | overrides: { | ||
| 101 | search_servers: { approval: "required" }, | ||
| 102 | }, | ||
| 103 | }); | ||
| 104 | |||
| 105 | try { | ||
| 106 | let foundPreview = false; | ||
| 107 | walkToolTree(result.tools, (path, tool) => { | ||
| 108 | if (path === "answeroverflow.search_servers") { | ||
| 109 | const preview = tool.formatApproval?.({ query: "bun" }); | ||
| 110 | expect(preview).toBeDefined(); | ||
| 111 | expect(preview?.title).toBe("Run search_servers"); | ||
| 112 | expect(preview?.details).toContain("query"); | ||
| 113 | foundPreview = true; | ||
| 114 | } | ||
| 115 | }); | ||
| 116 | expect(foundPreview).toBe(true); | ||
| 117 | } finally { | ||
| 118 | await result.close(); | ||
| 119 | } | ||
| 120 | }, { timeout: 30_000 }); | ||
| 94 | }); | 121 | }); |
| @@ -53,6 +53,82 @@ export interface McpGenerateResult { · 52 unmodified lines | |||
| 53 | // Connection | 53 | // Connection |
| 54 | // --------------------------------------------------------------------------- | 54 | // --------------------------------------------------------------------------- |
| 55 | 55 | ||
| 56 | const MAX_APPROVAL_PREVIEW = 240; | ||
| 57 | |||
| 58 | function isRecord(value: unknown): value is Record<string, unknown> { | ||
| 59 | return typeof value === "object" && value !== null && !Array.isArray(value); | ||
| 60 | } | ||
| 61 | |||
| 62 | function toPreview(value: unknown): string { | ||
| 63 | if (value === undefined) return "undefined"; | ||
| 64 | if (value === null) return "null"; | ||
| 65 | try { | ||
| 66 | const text = typeof value === "string" ? value : JSON.stringify(value); | ||
| 67 | return text.length > MAX_APPROVAL_PREVIEW | ||
| 68 | ? `${text.slice(0, MAX_APPROVAL_PREVIEW)}...` | ||
| 69 | : text; | ||
| 70 | } catch { | ||
| 71 | return String(value); | ||
| 72 | } | ||
| 73 | } | ||
| 74 | |||
| 75 | function inferMcpAction(toolName: string): { | ||
| 76 | action: "create" | "update" | "delete" | "read" | "execute" | "other"; | ||
| 77 | isDestructive: boolean; | ||
| 78 | } { | ||
| 79 | const lower = toolName.toLowerCase(); | ||
| 80 | if (/(delete|remove|destroy|purge)/.test(lower)) { | ||
| 81 | return { action: "delete", isDestructive: true }; | ||
| 82 | } | ||
| 83 | if (/(create|add|insert)/.test(lower)) { | ||
| 84 | return { action: "create", isDestructive: false }; | ||
| 85 | } | ||
| 86 | if (/(update|set|patch|edit)/.test(lower)) { | ||
| 87 | return { action: "update", isDestructive: false }; | ||
| 88 | } | ||
| 89 | if (/(get|list|search|find|read)/.test(lower)) { | ||
| 90 | return { action: "read", isDestructive: false }; | ||
| 91 | } | ||
| 92 | return { action: "execute", isDestructive: false }; | ||
| 93 | } | ||
| 94 | |||
| 95 | function extractIds(input: unknown): string[] { | ||
| 96 | if (!isRecord(input)) return []; | ||
| 97 | const ids: string[] = []; | ||
| 98 | for (const key of ["id", "ids", "idOrName", "name", "slug", "key"]) { | ||
| 99 | const value = input[key]; | ||
| 100 | if (typeof value === "string" || typeof value === "number") { | ||
| 101 | ids.push(String(value)); | ||
| 102 | continue; | ||
| 103 | } | ||
| 104 | if (Array.isArray(value)) { | ||
| 105 | for (const item of value) { | ||
| 106 | if (typeof item === "string" || typeof item === "number") { | ||
| 107 | ids.push(String(item)); | ||
| 108 | } | ||
| 109 | } | ||
| 110 | } | ||
| 111 | } | ||
| 112 | return ids.slice(0, 5); | ||
| 113 | } | ||
| 114 | |||
| 115 | function buildMcpApprovalPreview(toolName: string, input: unknown) { | ||
| 116 | const { action, isDestructive } = inferMcpAction(toolName); | ||
| 117 | const ids = extractIds(input); | ||
| 118 | const details = [ | ||
| 119 | ids.length > 0 ? `Target: ${ids.join(", ")}` : undefined, | ||
| 120 | `Arguments: ${toPreview(input)}`, | ||
| 121 | ].filter(Boolean).join("\n"); | ||
| 122 | return { | ||
| 123 | title: `Run ${toolName}`, | ||
| 124 | details, | ||
| 125 | action, | ||
| 126 | resourceType: toolName.replace(/[_-]/g, " "), | ||
| 127 | resourceIds: ids.length > 0 ? ids : undefined, | ||
| 128 | isDestructive, | ||
| 129 | } as const; | ||
| 130 | } | ||
| 131 | |||
| 56 | async function connectMcp( | 132 | async function connectMcp( |
| 57 | url: string, | 133 | url: string, |
| 58 | transport?: "sse" | "streamable-http", | 134 | transport?: "sse" | "streamable-http", |
| @@ -146,6 +222,7 @@ export async function generateMcpTools( · 87 unmodified lines | |||
| 146 | } | 222 | } |
| 147 | return result.content; | 223 | return result.content; |
| 148 | }, | 224 | }, |
| 225 | formatApproval: (input) => buildMcpApprovalPreview(toolName, input), | ||
| 149 | }); | 226 | }); |
| 150 | 227 | ||
| 151 | // Build TypeScript declaration | 228 | // Build TypeScript declaration |
| @@ -3,6 +3,56 @@ import { walkToolTree, isToolDefinition } from "@openassistant/core"; · 2 unmodified lines | |||
| 3 | import { generateOpenApiTools } from "./openapi.js"; | 3 | import { generateOpenApiTools } from "./openapi.js"; |
| 4 | 4 | ||
| 5 | describe("generateOpenApiTools — FastSpring spec", () => { | 5 | describe("generateOpenApiTools — FastSpring spec", () => { |
| 6 | test("generated write tools include approval preview formatter", async () => { | ||
| 7 | const result = await generateOpenApiTools({ | ||
| 8 | name: "vercel", | ||
| 9 | spec: { | ||
| 10 | openapi: "3.0.0", | ||
| 11 | info: { title: "Test", version: "1.0.0" }, | ||
| 12 | servers: [{ url: "https://api.example.com" }], | ||
| 13 | paths: { | ||
| 14 | "/v1/projects/{idOrName}": { | ||
| 15 | delete: { | ||
| 16 | tags: ["projects"], | ||
| 17 | operationId: "deleteProject", | ||
| 18 | summary: "Delete project", | ||
| 19 | parameters: [ | ||
| 20 | { | ||
| 21 | name: "idOrName", | ||
| 22 | in: "path", | ||
| 23 | required: true, | ||
| 24 | schema: { type: "string" }, | ||
| 25 | }, | ||
| 26 | ], | ||
| 27 | responses: { | ||
| 28 | "200": { | ||
| 29 | description: "ok", | ||
| 30 | content: { | ||
| 31 | "application/json": { | ||
| 32 | schema: { type: "object", properties: { ok: { type: "boolean" } } }, | ||
| 33 | }, | ||
| 34 | }, | ||
| 35 | }, | ||
| 36 | }, | ||
| 37 | }, | ||
| 38 | }, | ||
| 39 | }, | ||
| 40 | }, | ||
| 41 | }); | ||
| 42 | |||
| 43 | const ns = result.tools["vercel"] as Record<string, unknown>; | ||
| 44 | const projects = ns["projects"] as Record<string, unknown>; | ||
| 45 | const deleteTool = projects["deleteProject"]; | ||
| 46 | |||
| 47 | expect(isToolDefinition(deleteTool)).toBe(true); | ||
| 48 | const preview = deleteTool.formatApproval?.({ idOrName: "prj_123" }); | ||
| 49 | expect(preview).toBeDefined(); | ||
| 50 | expect(preview?.title).toBe("DELETE /v1/projects/{idOrName}"); | ||
| 51 | expect(preview?.details).toContain("Target: prj_123"); | ||
| 52 | expect(preview?.action).toBe("delete"); | ||
| 53 | expect(preview?.isDestructive).toBe(true); | ||
| 54 | }); | ||
| 55 | |||
| 6 | test("parses the FastSpring OpenAPI spec and generates tools", async () => { | 56 | test("parses the FastSpring OpenAPI spec and generates tools", async () => { |
| 7 | const result = await generateOpenApiTools({ | 57 | const result = await generateOpenApiTools({ |
| 8 | name: "fastspring", | 58 | name: "fastspring", |
| @@ -81,6 +81,7 @@ interface ParsedParameter { · 80 unmodified lines | |||
| 81 | // --------------------------------------------------------------------------- | 81 | // --------------------------------------------------------------------------- |
| 82 | 82 | ||
| 83 | const READ_METHODS = new Set(["get", "head", "options"]); | 83 | const READ_METHODS = new Set(["get", "head", "options"]); |
| 84 | const MAX_APPROVAL_PREVIEW = 240; | ||
| 84 | 85 | ||
| 85 | 86 | ||
| 86 | function buildAuthHeaders(auth: OpenApiAuth | undefined): Record<string, string> { | 87 | function buildAuthHeaders(auth: OpenApiAuth | undefined): Record<string, string> { |
| @@ -141,6 +142,109 @@ function sanitizeTag(tag: string): string { · 54 unmodified lines | |||
| 141 | .replace(/^_|_$/g, "") || "default"; | 142 | .replace(/^_|_$/g, "") || "default"; |
| 142 | } | 143 | } |
| 143 | 144 | ||
| 145 | function isRecord(value: unknown): value is Record<string, unknown> { | ||
| 146 | return typeof value === "object" && value !== null && !Array.isArray(value); | ||
| 147 | } | ||
| 148 | |||
| 149 | function toPreview(value: unknown): string { | ||
| 150 | if (value === undefined) return "undefined"; | ||
| 151 | if (value === null) return "null"; | ||
| 152 | try { | ||
| 153 | const text = typeof value === "string" ? value : JSON.stringify(value); | ||
| 154 | return text.length > MAX_APPROVAL_PREVIEW | ||
| 155 | ? `${text.slice(0, MAX_APPROVAL_PREVIEW)}...` | ||
| 156 | : text; | ||
| 157 | } catch { | ||
| 158 | return String(value); | ||
| 159 | } | ||
| 160 | } | ||
| 161 | |||
| 162 | function actionFromMethod(method: string): "create" | "update" | "delete" | "read" | "execute" { | ||
| 163 | switch (method.toLowerCase()) { | ||
| 164 | case "get": | ||
| 165 | case "head": | ||
| 166 | case "options": | ||
| 167 | return "read"; | ||
| 168 | case "post": | ||
| 169 | return "create"; | ||
| 170 | case "put": | ||
| 171 | case "patch": | ||
| 172 | return "update"; | ||
| 173 | case "delete": | ||
| 174 | return "delete"; | ||
| 175 | default: | ||
| 176 | return "execute"; | ||
| 177 | } | ||
| 178 | } | ||
| 179 | |||
| 180 | function resourceTypeFromPath(path: string): string | undefined { | ||
| 181 | const segments = path.split("/").filter(Boolean).filter((segment) => !segment.startsWith("{")); | ||
| 182 | const raw = segments.at(-1); | ||
| 183 | if (!raw) return undefined; | ||
| 184 | const normalized = raw.endsWith("s") ? raw.slice(0, -1) : raw; | ||
| 185 | return normalized.replace(/[_-]/g, " "); | ||
| 186 | } | ||
| 187 | |||
| 188 | function extractIds(input: unknown): string[] { | ||
| 189 | if (!isRecord(input)) return []; | ||
| 190 | const ids: string[] = []; | ||
| 191 | for (const key of ["id", "ids", "idOrName", "name", "slug", "projectId"]) { | ||
| 192 | const value = input[key]; | ||
| 193 | if (typeof value === "string" || typeof value === "number") { | ||
| 194 | ids.push(String(value)); | ||
| 195 | continue; | ||
| 196 | } | ||
| 197 | if (Array.isArray(value)) { | ||
| 198 | for (const item of value) { | ||
| 199 | if (typeof item === "string" || typeof item === "number") { | ||
| 200 | ids.push(String(item)); | ||
| 201 | } | ||
| 202 | } | ||
| 203 | } | ||
| 204 | } | ||
| 205 | return ids.slice(0, 5); | ||
| 206 | } | ||
| 207 | |||
| 208 | function buildSelector(input: unknown): string | undefined { | ||
| 209 | if (!isRecord(input)) return undefined; | ||
| 210 | const selectors: string[] = []; | ||
| 211 | for (const [key, value] of Object.entries(input)) { | ||
| 212 | if (key === "id" || key === "ids" || key === "idOrName") continue; | ||
| 213 | if (value === undefined || value === null) continue; | ||
| 214 | if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { | ||
| 215 | selectors.push(`${key}=${String(value)}`); | ||
| 216 | if (selectors.length >= 3) break; | ||
| 217 | } | ||
| 218 | } | ||
| 219 | return selectors.length > 0 ? selectors.join(", ") : undefined; | ||
| 220 | } | ||
| 221 | |||
| 222 | function buildOpenApiApprovalPreview( | ||
| 223 | op: Pick<ParsedOperation, "method" | "path" | "operationId">, | ||
| 224 | input: unknown, | ||
| 225 | ) { | ||
| 226 | const action = actionFromMethod(op.method); | ||
| 227 | const ids = extractIds(input); | ||
| 228 | const selector = buildSelector(input); | ||
| 229 | const resourceType = resourceTypeFromPath(op.path); | ||
| 230 | const title = `${op.method.toUpperCase()} ${op.path}`; | ||
| 231 | const detailsParts = [ | ||
| 232 | `Operation: ${op.operationId}`, | ||
| 233 | ids.length > 0 ? `Target: ${ids.join(", ")}` : undefined, | ||
| 234 | selector ? `Selector: ${selector}` : undefined, | ||
| 235 | `Arguments: ${toPreview(input)}`, | ||
| 236 | ].filter(Boolean); | ||
| 237 | return { | ||
| 238 | title, | ||
| 239 | details: detailsParts.join("\n"), | ||
| 240 | action, | ||
| 241 | resourceType, | ||
| 242 | resourceIds: ids.length > 0 ? ids : undefined, | ||
| 243 | isDestructive: action === "delete", | ||
| 244 | selector, | ||
| 245 | } as const; | ||
| 246 | } | ||
| 247 | |||
| 144 | // --------------------------------------------------------------------------- | 248 | // --------------------------------------------------------------------------- |
| 145 | // Spec parsing | 249 | // Spec parsing |
| 146 | // --------------------------------------------------------------------------- | 250 | // --------------------------------------------------------------------------- |
| @@ -366,6 +470,7 @@ export async function generateOpenApiTools( · 219 unmodified lines | |||
| 366 | } | 470 | } |
| 367 | return response.text(); | 471 | return response.text(); |
| 368 | }, | 472 | }, |
| 473 | formatApproval: (input) => buildOpenApiApprovalPreview(op, input), | ||
| 369 | }); | 474 | }); |
| 370 | 475 | ||
| 371 | // Build TypeScript declaration | 476 | // Build TypeScript declaration |