diff --git a/frontend/src/lib/components/copilot/chat/global/apiCatalogTools.test.ts b/frontend/src/lib/components/copilot/chat/global/apiCatalogTools.test.ts new file mode 100644 index 0000000000..470b396b53 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/global/apiCatalogTools.test.ts @@ -0,0 +1,229 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { listMcpToolsMock } = vi.hoisted(() => ({ + listMcpToolsMock: vi.fn() +})) + +vi.mock('../shared', () => ({ + createToolDef: (_schema: unknown, name: string, description: string) => ({ + type: 'function', + function: { name, description, parameters: {} } + }) +})) + +vi.mock('$lib/gen', () => ({ + McpService: { + listMcpTools: listMcpToolsMock + } +})) + +import { apiCatalogTools, clearApiCatalogCache } from './apiCatalogTools' + +const CATALOG = [ + { + name: 'listWorkers', + description: 'List workers', + instructions: 'List all workers with their ping status', + path: '/workers/list', + method: 'GET', + query_params_schema: { + type: 'object', + properties: { page: { type: 'integer' }, per_page: { type: 'integer' } } + } + }, + { + name: 'getJob', + description: 'Get job details', + instructions: '', + path: '/w/{workspace}/jobs_u/get/{id}', + method: 'GET', + path_params_schema: { + type: 'object', + properties: { workspace: { type: 'string' }, id: { type: 'string' } }, + required: ['workspace', 'id'] + } + }, + { + name: 'deleteSchedule', + description: 'Delete a schedule', + instructions: '', + path: '/w/{workspace}/schedules/delete/{path}', + method: 'DELETE', + path_params_schema: { + type: 'object', + properties: { workspace: { type: 'string' }, path: { type: 'string' } }, + required: ['workspace', 'path'] + } + }, + { + name: 'createFlow', + description: 'Create a flow', + instructions: '', + path: '/w/{workspace}/flows/create', + method: 'POST' + }, + { + name: 'getVariable', + description: 'Get variable', + instructions: '', + path: '/w/{workspace}/variables/get/{path}', + method: 'GET' + }, + { + name: 'deleteScriptByHash', + description: 'Delete a script by hash', + instructions: '', + path: '/w/{workspace}/scripts/delete/h/{hash}', + method: 'POST', + path_params_schema: { + type: 'object', + properties: { workspace: { type: 'string' }, hash: { type: 'string' } }, + required: ['workspace', 'hash'] + } + }, + { + name: 'runFlowByPath', + description: 'Run flow by path', + instructions: 'Trigger a run of a deployed flow', + path: '/w/{workspace}/jobs/run/f/{path}', + method: 'POST', + path_params_schema: { + type: 'object', + properties: { workspace: { type: 'string' }, path: { type: 'string' } }, + required: ['workspace', 'path'] + }, + body_schema: { + type: 'object', + properties: { args: { type: 'object' } } + } + } +] + +function createToolCallbacks() { + return { + setToolStatus: vi.fn(), + removeToolStatus: vi.fn() + } as any +} + +function getTool(name: string) { + const tool = apiCatalogTools.find((entry) => entry.def.function.name === name) + if (!tool) throw new Error(`${name} tool not found`) + return tool +} + +async function run(name: string, args: Record) { + const raw = await getTool(name).fn({ + args, + workspace: 'test-ws', + helpers: {}, + toolCallbacks: createToolCallbacks(), + toolId: 'tool-1' + }) + return JSON.parse(raw) +} + +beforeEach(() => { + vi.clearAllMocks() + clearApiCatalogCache() + listMcpToolsMock.mockResolvedValue(CATALOG) + vi.unstubAllGlobals() +}) + +describe('search_api_endpoints', () => { + it('matches on name/path tokens, plural-insensitively, and excludes covered endpoints', async () => { + const result = await run('search_api_endpoints', { query: 'worker' }) + expect(result.matches.map((m: any) => m.name)).toEqual(['listWorkers']) + expect(result.matches[0].endpoint).toBe('GET /workers/list') + expect(result.matches[0].params).toEqual(['page', 'per_page']) + expect(result.matches[0].instructions).toContain('ping status') + + const flows = await run('search_api_endpoints', { query: 'create flow' }) + expect(flows.matches.map((m: any) => m.name)).not.toContain('createFlow') + expect(flows.covered_by_dedicated_tools).toContain('createFlow → use write_flow') + }) + + it('returns endpoint categories when nothing matches', async () => { + const result = await run('search_api_endpoints', { query: 'kubernetes' }) + expect(result.matches).toEqual([]) + expect(result.hint).toContain('workers') + expect(result.hint).toContain('jobs') + }) +}) + +describe('call_api_get', () => { + it('rejects covered, unknown, and non-GET endpoints with a pointer', async () => { + const covered = await run('call_api_get', { name: 'createFlow' }) + expect(covered.error).toContain('write_flow') + + const unknown = await run('call_api_get', { name: 'nope' }) + expect(unknown.error).toContain('search_api_endpoints') + + const mutating = await run('call_api_get', { name: 'runFlowByPath' }) + expect(mutating.error).toContain('call_api_endpoint') + + const deleting = await run('call_api_endpoint', { name: 'deleteSchedule' }) + expect(deleting.error).toContain('delete_workspace_item') + + const byHash = await run('call_api_endpoint', { name: 'deleteScriptByHash' }) + expect(byHash.error).toContain('delete_workspace_item') + const search = await run('search_api_endpoints', { query: 'delete script' }) + expect(search.matches.map((m: any) => m.name)).not.toContain('deleteScriptByHash') + }) + + it('refuses variable reads so variable values never reach the model', async () => { + const result = await run('call_api_get', { name: 'getVariable' }) + expect(result.success).toBe(false) + expect(result.error).toContain('never readable') + + const search = await run('search_api_endpoints', { query: 'variable' }) + expect(search.matches.map((m: any) => m.name)).not.toContain('getVariable') + }) + + it('returns the endpoint schema when a required path param is missing', async () => { + const result = await run('call_api_get', { name: 'getJob' }) + expect(result.success).toBe(false) + expect(result.error).toContain('id') + expect(result.schema.path_params_schema.required).toContain('id') + }) + + it('substitutes path params and sends the rest as query params', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => [{ worker: 'w1' }] + }) + vi.stubGlobal('fetch', fetchMock) + const result = await run('call_api_get', { name: 'listWorkers', params: { page: 2 } }) + expect(fetchMock).toHaveBeenCalledWith('/api/workers/list?page=2', { method: 'GET' }) + expect(result).toEqual({ success: true, data: [{ worker: 'w1' }] }) + }) +}) + +describe('call_api_endpoint', () => { + it('requires confirmation and executes mutating endpoints with a body', async () => { + expect(getTool('call_api_endpoint').requiresConfirmation).toBe(true) + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ 'content-type': 'text/plain' }), + text: async () => 'job-id-1' + }) + vi.stubGlobal('fetch', fetchMock) + const result = await run('call_api_endpoint', { + name: 'runFlowByPath', + params: { path: 'u/me/myflow' }, + body: { args: { n: 1 } } + }) + expect(fetchMock).toHaveBeenCalledWith('/api/w/test-ws/jobs/run/f/u%2Fme%2Fmyflow', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ args: { n: 1 } }) + }) + expect(result).toEqual({ success: true, data: 'job-id-1' }) + }) + + it('redirects GET endpoints to call_api_get', async () => { + const result = await run('call_api_endpoint', { name: 'listWorkers' }) + expect(result.error).toContain('call_api_get') + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/global/apiCatalogTools.ts b/frontend/src/lib/components/copilot/chat/global/apiCatalogTools.ts new file mode 100644 index 0000000000..be4ca892d8 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/global/apiCatalogTools.ts @@ -0,0 +1,369 @@ +import { z } from 'zod' +import { McpService, type EndpointTool } from '$lib/gen' +import { createToolDef, type Tool } from '../shared' + +/** + * Generic access to the backend's MCP endpoint catalog (the endpoints marked + * `x-mcp-tool` in openapi.yaml) as three small static tools — search, GET + * call, mutating call — instead of one registered tool per endpoint. Search + * results carry parameter names and usage instructions; full parameter schemas + * enter the model's context only when a call fails validation. This keeps the + * per-iteration tool-schema cost constant. + */ + +// Endpoints whose job a dedicated global tool already does, plus the variable +// read endpoints. Hidden from search and refused at call time: the authoring +// and delete ones would bypass the draft lifecycle (conflict detection, +// explicit deploy, draft cleanup on delete), the variable reads would expose +// variable values to the model (getVariable even decrypts secrets by default), +// and the rest are exact duplicates that would fragment behavior across two +// code paths. getResource stays available: resource values are readable in +// this chat by design (read_workspace_item returns them too) — secrets belong +// in variables referenced as "$var:path", which a plain get leaves unresolved. +const COVERED_ENDPOINTS: Record = { + getVariable: 'read_workspace_item (variable values are never readable in chat)', + listVariable: 'list_workspace_items (variable values are never readable in chat)', + deleteScriptByPath: 'delete_workspace_item', + deleteScriptByHash: 'delete_workspace_item', + deleteFlowByPath: 'delete_workspace_item', + deleteSchedule: 'delete_workspace_item', + deleteVariable: 'delete_workspace_item', + deleteResource: 'delete_workspace_item', + createScript: 'write_script', + createFlow: 'write_flow', + updateFlow: 'patch_flow_json or write_flow', + createApp: 'init_app and the app draft tools', + updateApp: 'write_app_file / write_app_runnable', + createVariable: 'write_variable', + updateVariable: 'write_variable', + createResource: 'write_resource', + updateResource: 'write_resource', + createSchedule: 'write_schedule', + updateSchedule: 'write_schedule', + searchDocs: 'search_docs', + readDocsPage: 'read_docs_page', + listJobs: 'list_runs', + getJobLogs: 'get_job_logs', + runScriptPreviewAndWaitResult: 'test_run_script' +} + +const MAX_SEARCH_RESULTS = 10 +const MAX_DESCRIPTION_CHARS = 200 +const MAX_INSTRUCTIONS_CHARS = 400 +const MAX_RESULT_CHARS = 20_000 + +let catalogCache: { workspace: string; endpoints: EndpointTool[] } | undefined + +async function loadCatalog(workspace: string): Promise { + if (catalogCache?.workspace !== workspace) { + const endpoints = await McpService.listMcpTools({ workspace }) + catalogCache = { workspace, endpoints } + } + return catalogCache.endpoints +} + +export function clearApiCatalogCache() { + catalogCache = undefined +} + +function tokenize(text: string): string[] { + return text + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter((t) => t.length > 1) +} + +// Cheap plural-insensitive comparison so "workers" matches "worker" and vice versa. +function tokenMatches(token: string, queryToken: string): boolean { + const strip = (t: string) => (t.length > 3 && t.endsWith('s') ? t.slice(0, -1) : t) + return strip(token) === strip(queryToken) +} + +function pathSegments(path: string): string[] { + return path.split('/').filter((seg) => seg && seg !== 'w' && !seg.startsWith('{')) +} + +// Name and path tokens identify the operation; description tokens only support it. +function scoreEndpoint(endpoint: EndpointTool, queryTokens: string[]): number { + const nameTokens = [...tokenize(endpoint.name), ...pathSegments(endpoint.path).flatMap(tokenize)] + const descTokens = tokenize(`${endpoint.description} ${endpoint.instructions}`) + let score = 0 + for (const qt of queryTokens) { + if (nameTokens.some((t) => tokenMatches(t, qt))) score += 3 + else if (descTokens.some((t) => tokenMatches(t, qt))) score += 1 + } + return score +} + +function schemaPropertyNames(schema: unknown): string[] { + const properties = (schema as { properties?: Record } | null | undefined) + ?.properties + return properties ? Object.keys(properties).filter((k) => k !== 'workspace') : [] +} + +function truncate(text: string, max: number): string { + return text.length > max ? text.slice(0, max) + '…' : text +} + +function summarizeEndpoint(endpoint: EndpointTool) { + const params = [ + ...schemaPropertyNames(endpoint.path_params_schema), + ...schemaPropertyNames(endpoint.query_params_schema) + ] + const bodyParams = schemaPropertyNames(endpoint.body_schema) + const instructions = endpoint.instructions.trim() + return { + name: endpoint.name, + endpoint: `${endpoint.method.toUpperCase()} ${endpoint.path}`, + description: truncate(endpoint.description, MAX_DESCRIPTION_CHARS), + ...(instructions ? { instructions: truncate(instructions, MAX_INSTRUCTIONS_CHARS) } : {}), + ...(params.length > 0 ? { params } : {}), + ...(bodyParams.length > 0 ? { body_params: bodyParams } : {}) + } +} + +function endpointSchemaHelp(endpoint: EndpointTool) { + return { + name: endpoint.name, + endpoint: `${endpoint.method.toUpperCase()} ${endpoint.path}`, + path_params_schema: endpoint.path_params_schema ?? undefined, + query_params_schema: endpoint.query_params_schema ?? undefined, + body_schema: endpoint.body_schema ?? undefined + } +} + +async function resolveEndpoint( + workspace: string, + name: string +): Promise<{ endpoint: EndpointTool } | { error: string }> { + const covered = COVERED_ENDPOINTS[name] + if (covered) { + return { error: `"${name}" is covered by the dedicated ${covered} tool — use it instead.` } + } + const catalog = await loadCatalog(workspace) + const endpoint = catalog.find((e) => e.name === name) + if (!endpoint) { + return { + error: `Unknown endpoint "${name}". Use search_api_endpoints to find the endpoint name.` + } + } + return { endpoint } +} + +async function executeEndpoint( + endpoint: EndpointTool, + workspace: string, + params: Record, + body?: unknown +): Promise { + let url = `/api${endpoint.path.replace('{workspace}', encodeURIComponent(workspace))}` + const queryParams = new URLSearchParams() + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null) continue + if (url.includes(`{${key}}`)) { + url = url.replace(`{${key}}`, encodeURIComponent(String(value))) + } else { + queryParams.append(key, String(value)) + } + } + const unresolved = [...url.matchAll(/\{([^}]+)\}/g)].map((m) => m[1]) + if (unresolved.length > 0) { + return JSON.stringify({ + success: false, + error: `Missing required path parameter(s): ${unresolved.join(', ')}`, + schema: endpointSchemaHelp(endpoint) + }) + } + const search = queryParams.toString() + if (search) url += `?${search}` + + const method = endpoint.method.toUpperCase() + const fetchOptions: RequestInit = { method } + if (body !== undefined && method !== 'GET') { + fetchOptions.headers = { 'Content-Type': 'application/json' } + fetchOptions.body = JSON.stringify(body) + } + + const response = await fetch(url, fetchOptions) + const raw = response.headers.get('content-type')?.includes('application/json') + ? await response.json() + : await response.text() + if (!response.ok) { + return JSON.stringify({ + success: false, + status: response.status, + error: typeof raw === 'string' ? raw : JSON.stringify(raw), + // 4xx usually means wrong arguments — echo the schema so the model can + // self-correct in the next call without a separate schema-fetch tool. + ...(response.status >= 400 && response.status < 500 + ? { schema: endpointSchemaHelp(endpoint) } + : {}) + }) + } + const result = JSON.stringify({ success: true, data: raw }) + if (result.length <= MAX_RESULT_CHARS) return result + return JSON.stringify({ + success: true, + truncated: true, + data: (typeof raw === 'string' ? raw : JSON.stringify(raw)).slice(0, MAX_RESULT_CHARS), + note: `Result truncated to ${MAX_RESULT_CHARS} characters. Use filter or pagination parameters to narrow it.` + }) +} + +const searchApiEndpointsSchema = z.object({ + query: z + .string() + .describe( + 'Keywords matched against endpoint names, paths, and descriptions (e.g. "workers", "queue", "run flow"). Jobs are called "runs" in the UI.' + ) +}) + +const callApiGetSchema = z.object({ + name: z.string().describe('Endpoint name as returned by search_api_endpoints'), + params: z + .record(z.string(), z.any()) + .optional() + .describe( + 'Path and query parameter values, keyed by parameter name. The workspace parameter is filled automatically.' + ) +}) + +const callApiEndpointSchema = z.object({ + name: z.string().describe('Endpoint name as returned by search_api_endpoints'), + params: z + .record(z.string(), z.any()) + .optional() + .describe( + 'Path and query parameter values, keyed by parameter name. The workspace parameter is filled automatically.' + ), + body: z + .record(z.string(), z.any()) + .optional() + .describe('JSON request body, when the endpoint takes one') +}) + +export const apiCatalogTools: Tool<{}>[] = [ + { + def: createToolDef( + searchApiEndpointsSchema, + 'search_api_endpoints', + 'Search the Windmill REST API endpoint catalog for operations no dedicated tool covers (workers, queue state, job details, running deployed items, deletions, ...). Returns endpoint names to pass to call_api_get or call_api_endpoint.' + ), + fn: async ({ args, workspace, toolId, toolCallbacks }) => { + const parsed = searchApiEndpointsSchema.parse(args) + toolCallbacks.setToolStatus(toolId, { content: 'Searching API endpoints...' }) + const catalog = await loadCatalog(workspace) + const queryTokens = tokenize(parsed.query) + const available = catalog.filter((e) => !COVERED_ENDPOINTS[e.name]) + const scored = available + .map((endpoint) => ({ endpoint, score: scoreEndpoint(endpoint, queryTokens) })) + .filter((s) => s.score > 0) + .sort((a, b) => b.score - a.score || a.endpoint.name.localeCompare(b.endpoint.name)) + const coveredHits = catalog + .filter((e) => COVERED_ENDPOINTS[e.name] && scoreEndpoint(e, queryTokens) > 0) + .map((e) => `${e.name} → use ${COVERED_ENDPOINTS[e.name]}`) + + if (scored.length === 0) { + const categories = [...new Set(available.map((e) => pathSegments(e.path)[0]))].sort() + const result = JSON.stringify( + { + matches: [], + hint: `No endpoint matched. Available endpoint categories: ${categories.join(', ')}. Retry with different keywords, or use a dedicated tool if one covers the need.`, + ...(coveredHits.length > 0 ? { covered_by_dedicated_tools: coveredHits } : {}) + }, + null, + 2 + ) + toolCallbacks.setToolStatus(toolId, { content: 'No matching API endpoint', result }) + return result + } + const top = scored.slice(0, MAX_SEARCH_RESULTS) + const result = JSON.stringify( + { + matches: top.map((s) => summarizeEndpoint(s.endpoint)), + ...(scored.length > top.length + ? { + note: `${scored.length - top.length} more match(es) — refine the query to see them.` + } + : {}), + ...(coveredHits.length > 0 ? { covered_by_dedicated_tools: coveredHits } : {}) + }, + null, + 2 + ) + toolCallbacks.setToolStatus(toolId, { + content: `Found ${top.length} API endpoint(s) for "${parsed.query}"`, + result + }) + return result + } + }, + { + def: createToolDef( + callApiGetSchema, + 'call_api_get', + 'Call a read-only GET endpoint from the API catalog by name. Use search_api_endpoints first to find the endpoint name; a failed call returns the parameter schema.' + ), + showDetails: true, + fn: async ({ args, workspace, toolId, toolCallbacks }) => { + const parsed = callApiGetSchema.parse(args) + const resolved = await resolveEndpoint(workspace, parsed.name) + if ('error' in resolved) { + toolCallbacks.setToolStatus(toolId, { content: resolved.error, error: resolved.error }) + return JSON.stringify({ success: false, error: resolved.error }) + } + if (resolved.endpoint.method.toUpperCase() !== 'GET') { + const error = `"${parsed.name}" is a ${resolved.endpoint.method.toUpperCase()} endpoint — use call_api_endpoint for mutating calls.` + toolCallbacks.setToolStatus(toolId, { content: error, error }) + return JSON.stringify({ success: false, error }) + } + toolCallbacks.setToolStatus(toolId, { content: `Calling ${parsed.name}...` }) + const result = await executeEndpoint(resolved.endpoint, workspace, parsed.params ?? {}) + const ok = JSON.parse(result).success === true + toolCallbacks.setToolStatus(toolId, { + content: ok ? `Called ${parsed.name}` : `Call to ${parsed.name} failed`, + result, + ...(ok ? {} : { error: `Call to ${parsed.name} failed` }) + }) + return result + } + }, + { + def: createToolDef( + callApiEndpointSchema, + 'call_api_endpoint', + 'Call a mutating (POST/PUT/PATCH/DELETE) endpoint from the API catalog by name; the user is asked to confirm. Use search_api_endpoints first to find the endpoint name; a failed call returns the parameter schema.' + ), + requiresConfirmation: true, + confirmationMessage: (args) => `Call API endpoint ${args?.name ?? ''}`, + showDetails: true, + fn: async ({ args, workspace, toolId, toolCallbacks }) => { + const parsed = callApiEndpointSchema.parse(args) + const resolved = await resolveEndpoint(workspace, parsed.name) + if ('error' in resolved) { + toolCallbacks.setToolStatus(toolId, { content: resolved.error, error: resolved.error }) + return JSON.stringify({ success: false, error: resolved.error }) + } + if (resolved.endpoint.method.toUpperCase() === 'GET') { + const error = `"${parsed.name}" is a GET endpoint — use call_api_get (no confirmation needed).` + toolCallbacks.setToolStatus(toolId, { content: error, error }) + return JSON.stringify({ success: false, error }) + } + toolCallbacks.setToolStatus(toolId, { content: `Calling ${parsed.name}...` }) + const result = await executeEndpoint( + resolved.endpoint, + workspace, + parsed.params ?? {}, + parsed.body + ) + const ok = JSON.parse(result).success === true + toolCallbacks.setToolStatus(toolId, { + content: ok ? `Called ${parsed.name}` : `Call to ${parsed.name} failed`, + result, + ...(ok ? {} : { error: `Call to ${parsed.name} failed` }) + }) + return result + } + } +] diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 495d0637d2..28c594a1e6 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -152,6 +152,7 @@ import { setEphemeralSecretVariableDraftValue, type DraftPersistResult } from './userDraftAdapter' +import { apiCatalogTools } from './apiCatalogTools' import { isSessionPipelinesEnabled, SESSION_PIPELINES_GATED_MESSAGE } from './pipelineGate' const ITEM_TYPES = [ @@ -936,6 +937,7 @@ ${pipelineBullet} - After creating or editing a script or flow draft, run test_run_script, test_run_flow, or test_run_step with representative args before reporting that it works. These tools prefer drafts, so testing does not require deployment. - Use list_runs to find recent runs (optionally filtered by path, creator, label, or status), then get_job_logs with a returned id to inspect a specific run's logs — without starting a new test run. - Use open_page to show a workspace page with filters applied — Runs, Schedules, Variables, Resources, Assets, Audit logs, or Workspace settings on a specific tab (e.g. "open the failed runs of f/foo/bar", "open the schedule for X", "open the git sync settings"). Only the pages listed for this user in the tool are available; don't offer pages that aren't listed. Don't use it as a substitute for list_runs when you just need the data yourself. +- For a Windmill operation no other tool covers (workers, queue state, a run's result or args, running deployed items, ...), use search_api_endpoints to find a REST endpoint, then call_api_get for reads or call_api_endpoint for mutations (the user is asked to confirm those). Always prefer a dedicated tool when one exists; endpoints for authoring or deleting scripts, flows, apps, schedules, resources, or variables are not available through the API catalog tools — use the draft tools and delete_workspace_item instead. - When a required decision is ambiguous, use askUserQuestion with two to ten clear proposed answer strings instead of guessing. The user can also type a custom answer when none of the proposed answers fit. Set multiSelect: true only when the answers can genuinely co-apply and the user may pick several (not mutually exclusive). - When the user asks you to remember a lasting preference, always/never do something, or change/stop a behavior going forward, call update_user_instructions to persist it. It edits only the USER INSTRUCTIONS block (not WORKSPACE INSTRUCTIONS). Keep each instruction concise; do not use it for one-off requests scoped to the current task. - Keep context targeted.${ @@ -2994,7 +2996,10 @@ export const globalTools: Tool<{}>[] = [ // Workspace-scoped datatable tools (unrestricted: no whitelist, no creation policy) ...getDatatableTools(), // Read-only tools over files the user attached to the conversation - ...fileTools + ...fileTools, + // Search + call access to the backend API endpoint catalog, for operations + // no dedicated tool covers + ...apiCatalogTools ] // Tools that only make sense inside an AI session (they drive the session's