From deb39d225cdf514d32035bb77df12a02568531c9 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Thu, 23 Jul 2026 19:03:22 -0700 Subject: [PATCH 01/24] feat(copilot): replace search_documentation with path-scoped search_docs; serve openapi.json publicly - search_docs server tool: same vector search over docs_embeddings plus an optional docs/documentation/... VFS path prefix mapped onto a source_document scope (covers both .mdx and /... layouts); unscoped searches exclude academy/ and api-reference/ rows so the scope is exactly the Documentation tab - @docs chat context repointed to the new tool; display label updated - apps/docs now serves /openapi.json so the mothership can build its docs/api-reference/.json VFS views from the deployed spec - generated tool catalog/schemas regenerated from the mothership contract Companion: simstudioai/mothership feat/enhance-search-agent Co-Authored-By: Claude Fable 5 --- apps/docs/app/openapi.json/route.ts | 23 ++++ apps/sim/lib/copilot/chat/process-contents.ts | 6 +- .../tools/server/docs/search-docs.test.ts | 42 +++++++ .../copilot/tools/server/docs/search-docs.ts | 110 ++++++++++++++++++ .../tools/server/docs/search-documentation.ts | 61 ---------- apps/sim/lib/copilot/tools/server/router.ts | 4 +- apps/sim/lib/copilot/tools/tool-display.ts | 2 +- 7 files changed, 181 insertions(+), 67 deletions(-) create mode 100644 apps/docs/app/openapi.json/route.ts create mode 100644 apps/sim/lib/copilot/tools/server/docs/search-docs.test.ts create mode 100644 apps/sim/lib/copilot/tools/server/docs/search-docs.ts delete mode 100644 apps/sim/lib/copilot/tools/server/docs/search-documentation.ts diff --git a/apps/docs/app/openapi.json/route.ts b/apps/docs/app/openapi.json/route.ts new file mode 100644 index 00000000000..a7d07ae3fa8 --- /dev/null +++ b/apps/docs/app/openapi.json/route.ts @@ -0,0 +1,23 @@ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' + +export const revalidate = false + +/** + * Serves the raw OpenAPI spec (apps/docs/openapi.json) publicly so external + * consumers — notably the Mothership search agent's docs/api-reference/ VFS — + * can build per-tag views from the same spec that renders the API Reference. + */ +export async function GET() { + try { + const spec = await readFile(join(process.cwd(), 'openapi.json'), 'utf-8') + return new Response(spec, { + headers: { + 'Content-Type': 'application/json; charset=utf-8', + }, + }) + } catch (error) { + console.error('Error serving openapi.json:', error) + return new Response('OpenAPI spec unavailable', { status: 500 }) + } +} diff --git a/apps/sim/lib/copilot/chat/process-contents.ts b/apps/sim/lib/copilot/chat/process-contents.ts index 28f6543841d..27b5354e4f1 100644 --- a/apps/sim/lib/copilot/chat/process-contents.ts +++ b/apps/sim/lib/copilot/chat/process-contents.ts @@ -289,12 +289,12 @@ export async function processContextsServer( } if (ctx.kind === 'docs') { try { - const { searchDocumentationServerTool } = await import( - '@/lib/copilot/tools/server/docs/search-documentation' + const { searchDocsServerTool } = await import( + '@/lib/copilot/tools/server/docs/search-docs' ) const rawQuery = (userMessage || '').trim() || ctx.label || 'Sim documentation' const query = sanitizeMessageForDocs(rawQuery, contexts) - const res = await searchDocumentationServerTool.execute({ query, topK: 10 }) + const res = await searchDocsServerTool.execute({ query, topK: 10 }) const content = JSON.stringify(res?.results || []) return { type: 'docs', tag: ctx.label ? `@${ctx.label}` : '@', content } } catch (e) { diff --git a/apps/sim/lib/copilot/tools/server/docs/search-docs.test.ts b/apps/sim/lib/copilot/tools/server/docs/search-docs.test.ts new file mode 100644 index 00000000000..4d0077f5540 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/docs/search-docs.test.ts @@ -0,0 +1,42 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/knowledge/embeddings', () => ({ + generateSearchEmbedding: vi.fn(), +})) + +import { docsScopeTail } from '@/lib/copilot/tools/server/docs/search-docs' + +describe('docsScopeTail', () => { + it('returns undefined for an unscoped search', () => { + expect(docsScopeTail(undefined)).toBeUndefined() + expect(docsScopeTail('')).toBeUndefined() + expect(docsScopeTail(' ')).toBeUndefined() + }) + + it('treats the bare docs/documentation prefix as unscoped', () => { + expect(docsScopeTail('docs/documentation')).toBeUndefined() + expect(docsScopeTail('docs/documentation/')).toBeUndefined() + expect(docsScopeTail('/docs/documentation/')).toBeUndefined() + }) + + it('maps directory scopes to their source_document tail', () => { + expect(docsScopeTail('docs/documentation/workflows')).toBe('workflows') + expect(docsScopeTail('/docs/documentation/workflows/')).toBe('workflows') + expect(docsScopeTail('docs/documentation/integrations/gmail')).toBe('integrations/gmail') + }) + + it('maps file scopes by stripping the mdx extension', () => { + expect(docsScopeTail('docs/documentation/agents/choosing.mdx')).toBe('agents/choosing') + expect(docsScopeTail('docs/documentation/workflows/index.mdx')).toBe('workflows') + }) + + it('rejects paths outside docs/documentation/', () => { + expect(() => docsScopeTail('docs/academy/agents')).toThrow(/must start with/) + expect(() => docsScopeTail('docs/api-reference/workflows.json')).toThrow(/must start with/) + expect(() => docsScopeTail('workflows')).toThrow(/must start with/) + expect(() => docsScopeTail('docs/documentation-extra/foo')).toThrow(/must start with/) + }) +}) diff --git a/apps/sim/lib/copilot/tools/server/docs/search-docs.ts b/apps/sim/lib/copilot/tools/server/docs/search-docs.ts new file mode 100644 index 00000000000..dcc3b6d6b67 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/docs/search-docs.ts @@ -0,0 +1,110 @@ +import { db } from '@sim/db' +import { docsEmbeddings } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq, like, notLike, or, sql } from 'drizzle-orm' +import { SearchDocs } from '@/lib/copilot/generated/tool-catalog-v1' +import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' +import { generateSearchEmbedding } from '@/lib/knowledge/embeddings' + +interface SearchDocsParams { + query: string + topK?: number + path?: string +} + +const DEFAULT_DOCS_SIMILARITY_THRESHOLD = 0.3 +const DEFAULT_TOP_K = 10 +const MAX_TOP_K = 25 +const DOCS_DOCUMENTATION_PREFIX = 'docs/documentation' + +/** + * Maps a docs/documentation/... VFS path onto a docs_embeddings source_document + * scope tail. VFS paths mirror docs.sim.ai URLs while source_document stores + * the en-relative mdx path, so a scope tail must cover both layouts a page can + * have on disk: `.mdx` and `/...` (including `/index.mdx`). + * Returns undefined for an unscoped search; throws when the path does not + * address docs/documentation/. + */ +export function docsScopeTail(path?: string): string | undefined { + if (!path || path.trim() === '') return undefined + const normalized = path.trim().replace(/^\.?\//, '') + if ( + normalized !== DOCS_DOCUMENTATION_PREFIX && + !normalized.startsWith(`${DOCS_DOCUMENTATION_PREFIX}/`) + ) { + throw new Error(`path must start with ${DOCS_DOCUMENTATION_PREFIX}/ (got "${path}")`) + } + const tail = normalized + .slice(DOCS_DOCUMENTATION_PREFIX.length) + .replace(/^\/+|\/+$/g, '') + .replace(/\/index\.mdx$/, '') + .replace(/\.mdx$/, '') + return tail === '' ? undefined : tail +} + +function escapeLikePattern(value: string): string { + return value.replace(/[\\%_]/g, (char) => `\\${char}`) +} + +/** + * Unscoped searches cover exactly the Documentation tab (everything under the + * docs/documentation/ VFS tree), so Academy and API-reference rows are + * excluded; a scope tail narrows to one page or directory subtree. + */ +function scopeCondition(tail?: string) { + if (!tail) { + return and( + notLike(docsEmbeddings.sourceDocument, 'academy/%'), + notLike(docsEmbeddings.sourceDocument, 'api-reference/%') + ) + } + return or( + eq(docsEmbeddings.sourceDocument, `${tail}.mdx`), + like(docsEmbeddings.sourceDocument, `${escapeLikePattern(tail)}/%`) + ) +} + +export const searchDocsServerTool: BaseServerTool = { + name: SearchDocs.id, + async execute(params: SearchDocsParams): Promise { + const logger = createLogger('SearchDocsServerTool') + const { query, path } = params + if (!query || typeof query !== 'string') throw new Error('query is required') + const topK = Math.min(Math.max(Math.trunc(params.topK ?? DEFAULT_TOP_K), 1), MAX_TOP_K) + const scopeTail = docsScopeTail(path) + + logger.info('Executing docs search', { query, topK, path: path ?? null }) + + const { embedding: queryEmbedding } = await generateSearchEmbedding(query) + if (!queryEmbedding || queryEmbedding.length === 0) { + return { results: [], query, totalResults: 0 } + } + + const results = await db + .select({ + chunkId: docsEmbeddings.chunkId, + chunkText: docsEmbeddings.chunkText, + sourceDocument: docsEmbeddings.sourceDocument, + sourceLink: docsEmbeddings.sourceLink, + headerText: docsEmbeddings.headerText, + headerLevel: docsEmbeddings.headerLevel, + similarity: sql`1 - (${docsEmbeddings.embedding} <=> ${JSON.stringify(queryEmbedding)}::vector)`, + }) + .from(docsEmbeddings) + .where(scopeCondition(scopeTail)) + .orderBy(sql`${docsEmbeddings.embedding} <=> ${JSON.stringify(queryEmbedding)}::vector`) + .limit(topK) + + const filteredResults = results.filter((r) => r.similarity >= DEFAULT_DOCS_SIMILARITY_THRESHOLD) + const documentationResults = filteredResults.map((r, idx) => ({ + id: idx + 1, + title: String(r.headerText || 'Untitled Section'), + url: String(r.sourceLink || '#'), + content: String(r.chunkText || ''), + similarity: r.similarity, + })) + + logger.info('Docs search complete', { count: documentationResults.length }) + return { results: documentationResults, query, totalResults: documentationResults.length } + }, +} diff --git a/apps/sim/lib/copilot/tools/server/docs/search-documentation.ts b/apps/sim/lib/copilot/tools/server/docs/search-documentation.ts deleted file mode 100644 index 9226e27b369..00000000000 --- a/apps/sim/lib/copilot/tools/server/docs/search-documentation.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { db } from '@sim/db' -import { docsEmbeddings } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { sql } from 'drizzle-orm' -import { SearchDocumentation } from '@/lib/copilot/generated/tool-catalog-v1' -import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' -import { projectServerToolModelInput } from '@/lib/copilot/tools/server/model-input' -import { generateSearchEmbedding } from '@/lib/knowledge/embeddings' - -interface DocsSearchParams { - query: string - topK?: number - threshold?: number -} - -const DEFAULT_DOCS_SIMILARITY_THRESHOLD = 0.3 - -export const searchDocumentationServerTool: BaseServerTool = { - name: SearchDocumentation.id, - async execute(params: DocsSearchParams, context?: ServerToolContext): Promise { - const logger = createLogger('SearchDocumentationServerTool') - const { query, topK = 10, threshold } = params - if (!query || typeof query !== 'string') throw new Error('query is required') - - logger.info('Executing docs search', { queryLength: query.length, topK }) - - const similarityThreshold = threshold ?? DEFAULT_DOCS_SIMILARITY_THRESHOLD - - const { query: modelQuery } = projectServerToolModelInput({ query }, context) - const { embedding: queryEmbedding } = await generateSearchEmbedding(modelQuery) - if (!queryEmbedding || queryEmbedding.length === 0) { - return { results: [], query, totalResults: 0 } - } - - const results = await db - .select({ - chunkId: docsEmbeddings.chunkId, - chunkText: docsEmbeddings.chunkText, - sourceDocument: docsEmbeddings.sourceDocument, - sourceLink: docsEmbeddings.sourceLink, - headerText: docsEmbeddings.headerText, - headerLevel: docsEmbeddings.headerLevel, - similarity: sql`1 - (${docsEmbeddings.embedding} <=> ${JSON.stringify(queryEmbedding)}::vector)`, - }) - .from(docsEmbeddings) - .orderBy(sql`${docsEmbeddings.embedding} <=> ${JSON.stringify(queryEmbedding)}::vector`) - .limit(topK) - - const filteredResults = results.filter((r) => r.similarity >= similarityThreshold) - const documentationResults = filteredResults.map((r, idx) => ({ - id: idx + 1, - title: String(r.headerText || 'Untitled Section'), - url: String(r.sourceLink || '#'), - content: String(r.chunkText || ''), - similarity: r.similarity, - })) - - logger.info('Docs search complete', { count: documentationResults.length }) - return { results: documentationResults, query, totalResults: documentationResults.length } - }, -} diff --git a/apps/sim/lib/copilot/tools/server/router.ts b/apps/sim/lib/copilot/tools/server/router.ts index 323738741b6..6645647d94d 100644 --- a/apps/sim/lib/copilot/tools/server/router.ts +++ b/apps/sim/lib/copilot/tools/server/router.ts @@ -24,7 +24,7 @@ import { } from '@/lib/copilot/tools/server/base-tool' import { getBlocksMetadataServerTool } from '@/lib/copilot/tools/server/blocks/get-blocks-metadata-tool' import { getTriggerBlocksServerTool } from '@/lib/copilot/tools/server/blocks/get-trigger-blocks' -import { searchDocumentationServerTool } from '@/lib/copilot/tools/server/docs/search-documentation' +import { searchDocsServerTool } from '@/lib/copilot/tools/server/docs/search-docs' import { enrichmentRunServerTool } from '@/lib/copilot/tools/server/enrichment/enrichment-run' import { createFileServerTool } from '@/lib/copilot/tools/server/files/create-file' import { downloadToWorkspaceFileServerTool } from '@/lib/copilot/tools/server/files/download-to-workspace-file' @@ -155,7 +155,7 @@ const baseServerToolRegistry: Record = { [getTriggerBlocksServerTool.name]: getTriggerBlocksServerTool, [editWorkflowServerTool.name]: editWorkflowServerTool, [queryLogsServerTool.name]: queryLogsServerTool, - [searchDocumentationServerTool.name]: searchDocumentationServerTool, + [searchDocsServerTool.name]: searchDocsServerTool, [searchOnlineServerTool.name]: searchOnlineServerTool, [setEnvironmentVariablesServerTool.name]: setEnvironmentVariablesServerTool, [getCredentialsServerTool.name]: getCredentialsServerTool, diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index e81ab9a54e2..ebadb3fd4b1 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -498,7 +498,7 @@ const TOOL_TITLES: Record = { restore_resource: 'Restoring resource', run_block: 'Running block', scheduled_task: 'Managing scheduled task', - search_documentation: 'Searching documentation', + search_docs: 'Searching docs', search_patterns: 'Searching patterns', set_block_enabled: 'Toggling block', set_environment_variables: 'Setting environment variables', From b3097684be9fec99af9c72d627743125d8a178d7 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:28:41 -0700 Subject: [PATCH 02/24] improvement(copilot): label docs corpus reads as Section/filename in tool chips read("docs/documentation/workflows/index.mdx") now renders "Read Workflows/index" instead of the leaf-only fallback ("Read Index"). Co-Authored-By: Claude Fable 5 --- .../copilot/tools/client/store-utils.test.ts | 26 ++++++++++++++++++ .../lib/copilot/tools/client/store-utils.ts | 27 +++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/apps/sim/lib/copilot/tools/client/store-utils.test.ts b/apps/sim/lib/copilot/tools/client/store-utils.test.ts index 7a849821895..6f973c23f8e 100644 --- a/apps/sim/lib/copilot/tools/client/store-utils.test.ts +++ b/apps/sim/lib/copilot/tools/client/store-utils.test.ts @@ -49,6 +49,32 @@ describe('resolveToolDisplay', () => { ).toBe('Read RET XYZ') }) + it('formats docs corpus reads as Section/filename', () => { + expect( + resolveToolDisplay(ReadTool.id, ClientToolCallState.success, { + path: 'docs/documentation/workflows/index.mdx', + })?.text + ).toBe('Read Workflows/index') + + expect( + resolveToolDisplay(ReadTool.id, ClientToolCallState.executing, { + path: 'docs/academy/agents/block.mdx', + })?.text + ).toBe('Reading Agents/block') + + expect( + resolveToolDisplay(ReadTool.id, ClientToolCallState.success, { + path: 'docs/api-reference/workflows.json', + })?.text + ).toBe('Read Workflows') + + expect( + resolveToolDisplay(ReadTool.id, ClientToolCallState.error, { + path: 'docs/documentation/getting-started.mdx', + })?.text + ).toBe('Attempted to read Getting-started') + }) + it('decodes percent-encoded VFS path segments for display', () => { expect( resolveToolDisplay(ReadTool.id, ClientToolCallState.executing, { diff --git a/apps/sim/lib/copilot/tools/client/store-utils.ts b/apps/sim/lib/copilot/tools/client/store-utils.ts index 343c9e2712d..5ca68ff5597 100644 --- a/apps/sim/lib/copilot/tools/client/store-utils.ts +++ b/apps/sim/lib/copilot/tools/client/store-utils.ts @@ -97,6 +97,10 @@ function describeReadTarget(path: string | undefined): string | undefined { if (segments.length === 0) return undefined + if (segments[0] === 'docs') { + return describeDocsReadTarget(segments) + } + const resourceType = VFS_DIR_TO_RESOURCE[segments[0]] if (!resourceType) { return humanizeDisplayIdentifier(stripExtension(segments[segments.length - 1]), 'sentence') @@ -140,6 +144,29 @@ function describeFileReadTarget(segments: string[]): string { return lastSegment } +const DOCS_TAB_SEGMENTS = new Set(['documentation', 'academy', 'api-reference']) + +/** + * Labels a docs/ corpus read as `
/` (e.g. `Workflows/index` + * for docs/documentation/workflows/index.mdx). The tab segment is dropped and + * single-level pages show just their capitalized name (e.g. `Getting-started`, + * or `Workflows` for the api-reference tag file workflows.json). + */ +function describeDocsReadTarget(segments: string[]): string { + let rest = segments.slice(1) + if (rest.length > 0 && DOCS_TAB_SEGMENTS.has(rest[0])) { + rest = rest.slice(1) + } + if (rest.length === 0) return 'docs' + const leaf = stripExtension(rest[rest.length - 1]) + if (rest.length === 1) return capitalizeFirst(leaf) + return `${capitalizeFirst(rest[0])}/${leaf}` +} + +function capitalizeFirst(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1) +} + function getLeafResourceSegment(segments: string[]): string { const lastSegment = segments[segments.length - 1] || '' if (hasFileExtension(lastSegment) && segments.length > 1) { From 7d1e7bfdb67fa1100f8d9f25d8c377722ef552fd Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:51:00 -0700 Subject: [PATCH 03/24] improvement(copilot): show the query in search_docs tool chips "Searched docs" becomes 'Searched docs for ""' (toolTitle/title preferred, query fallback, truncated at 60 chars). Also adds the missing browser_list_sessions display title the catalog regen surfaced. Co-Authored-By: Claude Fable 5 --- apps/sim/lib/copilot/tools/tool-display.test.ts | 13 +++++++++++++ apps/sim/lib/copilot/tools/tool-display.ts | 6 +++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/copilot/tools/tool-display.test.ts b/apps/sim/lib/copilot/tools/tool-display.test.ts index 9fd352aff15..68a537d16ef 100644 --- a/apps/sim/lib/copilot/tools/tool-display.test.ts +++ b/apps/sim/lib/copilot/tools/tool-display.test.ts @@ -78,6 +78,19 @@ describe('getToolDisplayTitle natural-language coverage', () => { expect(getToolDisplayTitle('diff_workflows')).toBe('Comparing workflows') }) + it('includes the query in search_docs titles', () => { + expect(getToolDisplayTitle('search_docs')).toBe('Searching docs') + expect(getToolDisplayTitle('search_docs', { query: 'loop blocks iteration' })).toBe( + 'Searching docs for "loop blocks iteration"' + ) + expect( + getToolDisplayTitle('search_docs', { + query: + 'reference block outputs connection tags blockname.field pass data between blocks in a workflow', + })?.length + ).toBeLessThanOrEqual('Searching docs for ""'.length + 60 + '...'.length) + }) + it('falls back to running code for function_execute without a title', () => { expect(getToolDisplayTitle('function_execute')).toBe('Running code') expect(getToolDisplayTitle('function_execute', { title: 'Crunching numbers' })).toBe( diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index ebadb3fd4b1..c8803a3d42e 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -1,4 +1,4 @@ -import { stripVersionSuffix } from '@sim/utils/string' +import { stripVersionSuffix, truncate } from '@sim/utils/string' /** * Single source of truth for copilot tool-call display titles. @@ -793,6 +793,10 @@ export function getToolDisplayTitle(name: string, args?: Record const target = firstStringArg(args, 'toolTitle', 'title') return target ? `Searching online for ${target}` : 'Searching online' } + case 'search_docs': { + const target = firstStringArg(args, 'toolTitle', 'title', 'query') + return target ? `Searching docs for "${truncate(target, 60)}"` : 'Searching docs' + } case 'grep': { const target = firstStringArg(args, 'toolTitle', 'title') return target ? `Searching for ${target}` : 'Searching' From 2a0ba3edc21dda642e85a5d201667680f9d6006c Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:07:42 -0700 Subject: [PATCH 04/24] feat(copilot): build the docs vfs from a generated manifest, rescope search_docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the mothership's runtime docs corpus (llms.txt + llms-full.txt + openapi.json behind a 15m TTL cache) with a static manifest generated from the docs source, plus live per-page fetches. ~1,000 fewer lines of hand- written code and one repo instead of two. - scripts/sync-docs-manifest.ts walks apps/docs/content/docs/en and emits lib/copilot/generated/docs-manifest.ts. Each entry is simultaneously the docs/ VFS path and the docs.sim.ai URL path, so a read is a plain fetch. Section index pages fold onto their parent (fumadocs serves /workflows, not /workflows/index); academy/ and api-reference/ are excluded — they stay unmounted and unsearchable, reachable only via scrape_page. - docs-manifest:generate / :check, with a CI step so a page added, renamed, or deleted without regenerating fails the build. Content edits don't. - lib/copilot/docs/docs-corpus.ts + tools/handlers/vfs.ts: glob matches the manifest with no network, read fetches the page live, grep takes exactly ONE page (each is a fetch, so there is no corpus-wide grep). Opt-in like uploads/ — only an explicit docs/ prefix ever matches. - search_docs now scopes to the docs/ tree instead of docs/documentation/, validates its path against the manifest (a bad path errors instead of silently returning nothing), and returns the docs/ path with every chunk so search chains into read. Unscoped searches drop rows the agent could not then read: unmounted sections, and pages gone since the last index rebuild. - @docs tagging disabled: its query was the raw user message, a poor embedding query, and the mention UI it fed was already dead code. - Reverts the apps/docs /openapi.json route, added only for the old api-reference VFS views. Companion: simstudioai/mothership feat/enhance-search-agent Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/test-build.yml | 3 + apps/docs/app/openapi.json/route.ts | 23 - apps/sim/lib/copilot/chat/process-contents.ts | 69 +- apps/sim/lib/copilot/docs/docs-corpus.test.ts | 134 ++ apps/sim/lib/copilot/docs/docs-corpus.ts | 174 ++ apps/sim/lib/copilot/docs/docs-search.test.ts | 171 ++ apps/sim/lib/copilot/docs/docs-search.ts | 143 ++ .../lib/copilot/generated/docs-manifest.ts | 365 ++++ .../lib/copilot/generated/tool-catalog-v1.ts | 1714 ++++------------- .../lib/copilot/generated/tool-schemas-v1.ts | 1611 +++------------- .../copilot/tools/client/store-utils.test.ts | 18 +- .../lib/copilot/tools/client/store-utils.ts | 14 +- apps/sim/lib/copilot/tools/handlers/vfs.ts | 53 +- .../tools/server/docs/search-docs.test.ts | 42 - .../copilot/tools/server/docs/search-docs.ts | 106 +- .../lib/copilot/tools/tool-display.test.ts | 13 - apps/sim/lib/copilot/tools/tool-display.ts | 6 +- package.json | 2 + scripts/sync-docs-manifest.ts | 108 ++ 19 files changed, 1795 insertions(+), 2974 deletions(-) delete mode 100644 apps/docs/app/openapi.json/route.ts create mode 100644 apps/sim/lib/copilot/docs/docs-corpus.test.ts create mode 100644 apps/sim/lib/copilot/docs/docs-corpus.ts create mode 100644 apps/sim/lib/copilot/docs/docs-search.test.ts create mode 100644 apps/sim/lib/copilot/docs/docs-search.ts create mode 100644 apps/sim/lib/copilot/generated/docs-manifest.ts delete mode 100644 apps/sim/lib/copilot/tools/server/docs/search-docs.test.ts create mode 100644 scripts/sync-docs-manifest.ts diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 09cdf7dbb48..d5aa22c1ac2 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -123,6 +123,9 @@ jobs: - name: Repo audits run: bun run check:audits + - name: Verify docs manifest is in sync + run: bun run docs-manifest:check + - name: Migration safety (zero-downtime) audit run: | if [ "${{ github.event_name }}" = "pull_request" ]; then diff --git a/apps/docs/app/openapi.json/route.ts b/apps/docs/app/openapi.json/route.ts deleted file mode 100644 index a7d07ae3fa8..00000000000 --- a/apps/docs/app/openapi.json/route.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { readFile } from 'node:fs/promises' -import { join } from 'node:path' - -export const revalidate = false - -/** - * Serves the raw OpenAPI spec (apps/docs/openapi.json) publicly so external - * consumers — notably the Mothership search agent's docs/api-reference/ VFS — - * can build per-tag views from the same spec that renders the API Reference. - */ -export async function GET() { - try { - const spec = await readFile(join(process.cwd(), 'openapi.json'), 'utf-8') - return new Response(spec, { - headers: { - 'Content-Type': 'application/json; charset=utf-8', - }, - }) - } catch (error) { - console.error('Error serving openapi.json:', error) - return new Response('OpenAPI spec unavailable', { status: 500 }) - } -} diff --git a/apps/sim/lib/copilot/chat/process-contents.ts b/apps/sim/lib/copilot/chat/process-contents.ts index 27b5354e4f1..581d57c88d4 100644 --- a/apps/sim/lib/copilot/chat/process-contents.ts +++ b/apps/sim/lib/copilot/chat/process-contents.ts @@ -42,7 +42,6 @@ import { getSkillById } from '@/lib/workflows/skills/operations' import { listFolders } from '@/lib/workflows/utils' import { checkKnowledgeBaseAccess } from '@/app/api/knowledge/utils' import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' -import { escapeRegExp } from '@/executor/constants' import type { BrowserTextSelection, ChatContext, TerminalTextSelection } from '@/stores/panel' type AgentContextType = @@ -116,7 +115,8 @@ function formatTerminalSelection(selection: TerminalTextSelection): string { export async function processContextsServer( contexts: ChatContext[] | undefined, userId: string, - userMessage?: string, + /** Retained for call-site compatibility; unused while @docs tagging is disabled. */ + _userMessage: string | undefined, currentWorkspaceId?: string, chatId?: string ): Promise { @@ -287,21 +287,9 @@ export async function processContextsServer( path: result.path, } } - if (ctx.kind === 'docs') { - try { - const { searchDocsServerTool } = await import( - '@/lib/copilot/tools/server/docs/search-docs' - ) - const rawQuery = (userMessage || '').trim() || ctx.label || 'Sim documentation' - const query = sanitizeMessageForDocs(rawQuery, contexts) - const res = await searchDocsServerTool.execute({ query, topK: 10 }) - const content = JSON.stringify(res?.results || []) - return { type: 'docs', tag: ctx.label ? `@${ctx.label}` : '@', content } - } catch (e) { - logger.error('Failed to process docs context', e) - return null - } - } + // `docs` contexts are intentionally inert: @docs tagging is disabled while + // the docs corpus moves to the `docs/` VFS tree. A tagged context resolves + // to nothing and is filtered out below. return null } catch (error) { logger.error('Failed processing context (server)', { ctx, error }) @@ -323,53 +311,6 @@ export async function processContextsServer( return filtered } -function sanitizeMessageForDocs(rawMessage: string, contexts: ChatContext[] | undefined): string { - if (!rawMessage) return '' - if (!Array.isArray(contexts) || contexts.length === 0) { - // No context mapping; conservatively strip all @mentions-like tokens - const stripped = rawMessage - .replace(/(^|\s)@([^\s]+)/g, ' ') - .replace(/\s{2,}/g, ' ') - .trim() - return stripped - } - - // Gather labels by kind - const blockLabels = new Set( - contexts - .filter((c) => c.kind === 'blocks') - .map((c) => c.label) - .filter((l): l is string => typeof l === 'string' && l.length > 0) - ) - const nonBlockLabels = new Set( - contexts - .filter((c) => c.kind !== 'blocks') - .map((c) => c.label) - .filter((l): l is string => typeof l === 'string' && l.length > 0) - ) - - let result = rawMessage - - // 1) Remove all non-block mentions entirely - for (const label of nonBlockLabels) { - const pattern = new RegExp(`(^|\\s)@${escapeRegExp(label)}(?!\\S)`, 'g') - result = result.replace(pattern, ' ') - } - - // 2) For block mentions, strip the '@' but keep the block name - for (const label of blockLabels) { - const pattern = new RegExp(`@${escapeRegExp(label)}(?!\\S)`, 'g') - result = result.replace(pattern, label) - } - - // 3) Remove any remaining @mentions (unknown or not in contexts) - result = result.replace(/(^|\s)@([^\s]+)/g, ' ') - - // Normalize whitespace - result = result.replace(/\s{2,}/g, ' ').trim() - return result -} - async function processSkillFromDb( skillId: string, workspaceId: string, diff --git a/apps/sim/lib/copilot/docs/docs-corpus.test.ts b/apps/sim/lib/copilot/docs/docs-corpus.test.ts new file mode 100644 index 00000000000..0142ee4f169 --- /dev/null +++ b/apps/sim/lib/copilot/docs/docs-corpus.test.ts @@ -0,0 +1,134 @@ +/** + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + couldMatchDocsScope, + DocsCorpusError, + globDocs, + grepDocsPage, + isDocsPath, + readDocsPage, +} from '@/lib/copilot/docs/docs-corpus' +import { DOCS_MANIFEST } from '@/lib/copilot/generated/docs-manifest' + +const SAMPLE_PAGE = DOCS_MANIFEST.find((path) => path === 'workflows/blocks/agent.mdx') + +describe('docs corpus scoping', () => { + it('recognizes docs paths', () => { + expect(isDocsPath('docs/workflows.mdx')).toBe(true) + expect(isDocsPath('docs')).toBe(true) + expect(isDocsPath('/docs/workflows.mdx')).toBe(true) + expect(isDocsPath('workflows.mdx')).toBe(false) + expect(isDocsPath('files/report.pdf')).toBe(false) + expect(isDocsPath('docsomething/x')).toBe(false) + expect(isDocsPath(undefined)).toBe(false) + }) + + it('is opt-in: only an explicit docs/ pattern can match', () => { + expect(couldMatchDocsScope('docs/**')).toBe(true) + expect(couldMatchDocsScope('docs/workflows/**')).toBe(true) + expect(couldMatchDocsScope('**')).toBe(false) + expect(couldMatchDocsScope('**/*.mdx')).toBe(false) + expect(couldMatchDocsScope('*')).toBe(false) + expect(couldMatchDocsScope(undefined)).toBe(false) + }) +}) + +describe('globDocs', () => { + it('lists the whole corpus under docs/**', () => { + const files = globDocs('docs/**') + expect(files.length).toBeGreaterThan(DOCS_MANIFEST.length) + expect(files).toContain('docs/workflows/blocks/agent.mdx') + expect(files).toContain('docs/workflows/blocks') + }) + + it('scopes to a section', () => { + const files = globDocs('docs/integrations/*.mdx') + expect(files).toContain('docs/integrations/gmail.mdx') + expect(files.every((path) => path.startsWith('docs/integrations/'))).toBe(true) + }) + + it('excludes academy and api-reference', () => { + expect(globDocs('docs/academy/**')).toEqual([]) + expect(globDocs('docs/api-reference/**')).toEqual([]) + }) + + it('maps section index pages onto their parent URL path', () => { + expect(globDocs('docs/workflows.mdx')).toEqual(['docs/workflows.mdx']) + expect(globDocs('docs/workflows/index.mdx')).toEqual([]) + }) +}) + +describe('readDocsPage', () => { + const fetchMock = vi.fn() + + beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('fetches the manifest path verbatim from the docs site', async () => { + expect(SAMPLE_PAGE).toBeDefined() + fetchMock.mockResolvedValue({ ok: true, status: 200, text: async () => '# Agent\n\nbody' }) + + const page = await readDocsPage(`docs/${SAMPLE_PAGE}`) + + expect(fetchMock).toHaveBeenCalledOnce() + expect(fetchMock.mock.calls[0][0]).toBe(`https://docs.sim.ai/${SAMPLE_PAGE}`) + expect(page).toEqual({ content: '# Agent\n\nbody', totalLines: 3 }) + }) + + it('rejects an unknown page without fetching', async () => { + await expect(readDocsPage('docs/not-a-real-page.mdx')).rejects.toThrow(DocsCorpusError) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('points a directory read at glob', async () => { + await expect(readDocsPage('docs/workflows/blocks')).rejects.toThrow(/is a directory/) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('surfaces a docs-site failure as a retryable error', async () => { + fetchMock.mockResolvedValue({ ok: false, status: 502, text: async () => '' }) + await expect(readDocsPage(`docs/${SAMPLE_PAGE}`)).rejects.toThrow(/temporarily unavailable/) + }) +}) + +describe('grepDocsPage', () => { + const fetchMock = vi.fn() + + beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('greps exactly one page', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + text: async () => 'intro line\nsystemPrompt matters\ntail', + }) + + const matches = await grepDocsPage(`docs/${SAMPLE_PAGE}`, 'systemPrompt') + + expect(fetchMock).toHaveBeenCalledOnce() + expect(matches).toEqual([ + { path: `docs/${SAMPLE_PAGE}`, line: 2, content: 'systemPrompt matters' }, + ]) + }) + + it('refuses a multi-page scope so one grep is never hundreds of fetches', async () => { + await expect(grepDocsPage('docs/', 'cron')).rejects.toThrow(/single page/) + await expect(grepDocsPage('docs/workflows', 'cron')).rejects.toThrow(/single page/) + expect(fetchMock).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/copilot/docs/docs-corpus.ts b/apps/sim/lib/copilot/docs/docs-corpus.ts new file mode 100644 index 00000000000..5a5c3f1d7ee --- /dev/null +++ b/apps/sim/lib/copilot/docs/docs-corpus.ts @@ -0,0 +1,174 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { DOCS_MANIFEST } from '@/lib/copilot/generated/docs-manifest' +import type { GrepCountEntry, GrepMatch, GrepOptions } from '@/lib/copilot/vfs/operations' +import { glob as globPaths, grep as grepFiles } from '@/lib/copilot/vfs/operations' + +const logger = createLogger('DocsCorpus') + +/** The public docs site the `docs/` tree is a lazy view of. */ +const DOCS_BASE_URL = 'https://docs.sim.ai' + +/** VFS prefix the docs corpus is mounted at. */ +const DOCS_PREFIX = 'docs/' + +const FETCH_TIMEOUT_MS = 10_000 + +/** + * Thrown for expected, user-facing docs-corpus conditions (unknown page, + * directory path, site unreachable). The VFS handlers return the message as the + * tool error instead of logging an internal failure. + */ +export class DocsCorpusError extends Error { + readonly code = 'DOCS_CORPUS' as const + constructor(message: string) { + super(message) + this.name = 'DocsCorpusError' + } +} + +/** + * Keys-only view of the corpus for glob: every manifest path under `docs/`, + * mapped to empty content. `ops.glob` matches keys and derives the virtual + * directories from them, so this never touches the network. + */ +const docsKeyView: Map = new Map( + DOCS_MANIFEST.map((path) => [`${DOCS_PREFIX}${path}`, '']) +) + +function normalize(path: string): string { + return path.trim().replace(/^\/+/, '') +} + +/** + * True when a read/grep `path` addresses the docs corpus. Deliberately not a + * `path is string` type predicate: the callers chain it ahead of the other + * namespace checks, and a predicate would narrow `path` to `never` in every + * later branch. + */ +export function isDocsPath(path: string | undefined): boolean { + if (!path) return false + const normalized = normalize(path) + return normalized === 'docs' || normalized.startsWith(DOCS_PREFIX) +} + +/** + * True when a glob `pattern` could match the docs corpus. Like `uploads/` and + * `recently-deleted/`, the corpus is opt-in: only a pattern that explicitly + * starts with `docs/` (or is exactly `docs`) sees it, so a broad `**` glob never + * drags 300+ doc pages into the result. + */ +export function couldMatchDocsScope(pattern: string | undefined): boolean { + if (!pattern) return false + const normalized = normalize(pattern) + return normalized === 'docs' || normalized.startsWith(DOCS_PREFIX) +} + +/** Manifest paths (and their virtual directories) matching an explicit `docs/` pattern. */ +export function globDocs(pattern: string): string[] { + return globPaths(docsKeyView, normalize(pattern)) +} + +/** True when `path` is a page in the docs tree. */ +export function isDocsPage(path: string): boolean { + return docsKeyView.has(normalize(path)) +} + +/** + * Map a `docs_embeddings.source_document` (the en-relative mdx file path) back to + * its `docs/` VFS path, applying the same index-page fold as the manifest + * generator. Returns null when the source has no live VFS path — an unmounted + * section (academy, api-reference) or a page deleted since the index was built. + */ +export function docsPathForSourceDocument(sourceDocument: string | null): string | null { + if (!sourceDocument) return null + const path = `${DOCS_PREFIX}${sourceDocument.replace(/^\/+/, '').replace(/\/index\.mdx$/, '.mdx')}` + return docsKeyView.has(path) ? path : null +} + +/** True when `path` is a directory in the docs tree rather than a page. */ +export function isDocsDir(path: string): boolean { + const dir = `${normalize(path).replace(/\/+$/, '')}/` + if (dir === DOCS_PREFIX) return true + for (const key of docsKeyView.keys()) { + if (key.startsWith(dir)) return true + } + return false +} + +export interface DocsPage { + content: string + totalLines: number +} + +/** + * Fetch one docs page's raw markdown from the live site. The manifest path IS + * the URL path (`docs/workflows/blocks/agent.mdx` → + * `https://docs.sim.ai/workflows/blocks/agent.mdx`, which the docs app rewrites + * to its raw-markdown route), so no mapping table is needed. Returns null when + * the page is not in the manifest or the site does not serve it. + */ +async function fetchDocsPage(path: string): Promise { + const key = normalize(path) + if (!docsKeyView.has(key)) return null + const url = `${DOCS_BASE_URL}/${key.slice(DOCS_PREFIX.length)}` + try { + const response = await fetch(url, { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + headers: { Accept: 'text/markdown, text/plain' }, + }) + if (!response.ok) { + logger.warn('Docs page fetch returned a non-OK status', { url, status: response.status }) + return null + } + return await response.text() + } catch (err) { + logger.warn('Docs page fetch failed', { url, error: toError(err).message }) + return null + } +} + +/** + * Read one docs page. Throws {@link DocsCorpusError} for the expected user-facing + * conditions (directory path, unknown page, site unreachable) so the handler can + * surface the message verbatim. + */ +export async function readDocsPage(path: string): Promise { + const key = normalize(path) + if (!docsKeyView.has(key)) { + if (isDocsDir(key)) { + const dir = key.replace(/\/+$/, '') + throw new DocsCorpusError(`${dir} is a directory — glob "${dir}/**" to list its pages.`) + } + throw new DocsCorpusError( + `Docs page not found: ${path}. Use glob("docs/**") to list the docs corpus.` + ) + } + const content = await fetchDocsPage(key) + if (content === null) { + throw new DocsCorpusError( + `Could not load ${key} from ${DOCS_BASE_URL} — the docs site is temporarily unavailable. Retry shortly.` + ) + } + return { content, totalLines: content.split('\n').length } +} + +/** + * Grep ONE docs page, mirroring how grep over `files/` works: each page is a + * separate fetch from the docs site, so a multi-page grep would mean hundreds of + * requests. A path that is not a single page throws. + */ +export async function grepDocsPage( + path: string, + pattern: string, + options?: GrepOptions +): Promise { + const key = normalize(path) + if (!docsKeyView.has(key)) { + throw new DocsCorpusError( + `Grep over the docs corpus must target a single page (e.g. path: "docs/workflows/blocks/agent.mdx"). "${path}" is not a docs page. Use glob("docs/**") to find the exact path, then grep that one page.` + ) + } + const page = await readDocsPage(key) + return grepFiles(new Map([[key, page.content]]), pattern, undefined, options) +} diff --git a/apps/sim/lib/copilot/docs/docs-search.test.ts b/apps/sim/lib/copilot/docs/docs-search.test.ts new file mode 100644 index 00000000000..8407f2e336f --- /dev/null +++ b/apps/sim/lib/copilot/docs/docs-search.test.ts @@ -0,0 +1,171 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGenerateSearchEmbedding, capturedWhere, mockRows } = vi.hoisted(() => ({ + mockGenerateSearchEmbedding: vi.fn(), + capturedWhere: { value: undefined as unknown }, + mockRows: { value: [] as unknown[] }, +})) + +vi.mock('@/lib/knowledge/embeddings', () => ({ + generateSearchEmbedding: mockGenerateSearchEmbedding, +})) + +/** + * Override the global drizzle mock with operators that record their arguments, + * so a test can assert on the `source_document` filter the scope produced. + */ +vi.mock('drizzle-orm', () => { + const op = + (name: string) => + (...args: unknown[]) => ({ op: name, args }) + return { + and: op('and'), + or: op('or'), + eq: op('eq'), + like: op('like'), + notLike: op('notLike'), + sql: (strings: TemplateStringsArray) => ({ op: 'sql', text: strings.join('?') }), + } +}) + +vi.mock('@sim/db', () => ({ + db: { + select: () => ({ + from: () => ({ + where: (condition: unknown) => { + capturedWhere.value = condition + return { + orderBy: () => ({ limit: async () => mockRows.value }), + } + }, + }), + }), + }, +})) + +import { DocsSearchScopeError, searchDocs } from '@/lib/copilot/docs/docs-search' + +/** Render a drizzle condition to comparable SQL-ish text for assertions. */ +function whereText(): string { + return JSON.stringify(capturedWhere.value) +} + +describe('searchDocs path scoping', () => { + beforeEach(() => { + capturedWhere.value = undefined + mockRows.value = [] + mockGenerateSearchEmbedding.mockResolvedValue({ embedding: [0.1, 0.2] }) + }) + + it('excludes unmounted sections when unscoped', async () => { + await searchDocs('cron') + expect(whereText()).toContain('academy/%') + expect(whereText()).toContain('api-reference/%') + }) + + it('treats a bare docs prefix as unscoped', async () => { + await searchDocs('cron', { path: 'docs/' }) + expect(whereText()).toContain('academy/%') + }) + + it('scopes a page to both on-disk layouts', async () => { + await searchDocs('cron', { path: 'docs/workflows/blocks/agent.mdx' }) + const text = whereText() + expect(text).toContain('workflows/blocks/agent.mdx') + expect(text).toContain('workflows/blocks/agent/index.mdx') + }) + + it('maps a section overview page onto its index file', async () => { + await searchDocs('cron', { path: 'docs/workflows.mdx' }) + const text = whereText() + expect(text).toContain('workflows/index.mdx') + }) + + it('scopes a directory to its subtree', async () => { + await searchDocs('cron', { path: 'docs/workflows' }) + expect(whereText()).toContain('workflows/%') + }) + + it('rejects a path outside the docs corpus', async () => { + await expect(searchDocs('cron', { path: 'files/report.pdf' })).rejects.toThrow( + DocsSearchScopeError + ) + }) + + it('rejects a docs path that is neither a page nor a section', async () => { + await expect(searchDocs('cron', { path: 'docs/not-a-real-section' })).rejects.toThrow( + /not a page or section/ + ) + }) + + it('rejects unmounted sections that exist on the site but not in the VFS', async () => { + await expect(searchDocs('cron', { path: 'docs/academy' })).rejects.toThrow( + /not a page or section/ + ) + }) +}) + +describe('searchDocs results', () => { + beforeEach(() => { + capturedWhere.value = undefined + mockGenerateSearchEmbedding.mockResolvedValue({ embedding: [0.1, 0.2] }) + }) + + it('returns the docs/ path to read next, folding index pages', async () => { + mockRows.value = [ + { + chunkText: 'body', + sourceDocument: 'workflows/index.mdx', + sourceLink: 'https://docs.sim.ai/workflows', + headerText: 'Overview', + similarity: 0.8, + }, + ] + const results = await searchDocs('cron') + expect(results).toEqual([ + { + path: 'docs/workflows.mdx', + url: 'https://docs.sim.ai/workflows', + title: 'Overview', + content: 'body', + similarity: 0.8, + }, + ]) + }) + + it('drops chunks whose source has no live docs/ path', async () => { + mockRows.value = [ + { + chunkText: 'a', + sourceDocument: 'academy/lesson-1.mdx', + sourceLink: 'x', + headerText: 'h', + similarity: 0.9, + }, + { + chunkText: 'b', + sourceDocument: 'deleted-page.mdx', + sourceLink: 'y', + headerText: 'h', + similarity: 0.9, + }, + ] + expect(await searchDocs('cron')).toEqual([]) + }) + + it('drops chunks below the similarity threshold', async () => { + mockRows.value = [ + { + chunkText: 'a', + sourceDocument: 'agents.mdx', + sourceLink: 'x', + headerText: 'h', + similarity: 0.1, + }, + ] + expect(await searchDocs('cron')).toEqual([]) + }) +}) diff --git a/apps/sim/lib/copilot/docs/docs-search.ts b/apps/sim/lib/copilot/docs/docs-search.ts new file mode 100644 index 00000000000..0f5553a4858 --- /dev/null +++ b/apps/sim/lib/copilot/docs/docs-search.ts @@ -0,0 +1,143 @@ +import { db } from '@sim/db' +import { docsEmbeddings } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq, like, notLike, or, sql } from 'drizzle-orm' +import { docsPathForSourceDocument, isDocsDir, isDocsPage } from '@/lib/copilot/docs/docs-corpus' +import { generateSearchEmbedding } from '@/lib/knowledge/embeddings' + +const logger = createLogger('DocsSearch') + +const SIMILARITY_THRESHOLD = 0.3 +const DEFAULT_TOP_K = 10 +const MAX_TOP_K = 25 + +export interface DocsSearchResult { + /** The `docs/` VFS path this chunk came from — pass it to `read` for the full page. */ + path: string + /** Public docs.sim.ai URL for the section, for citation. */ + url: string + title: string + content: string + similarity: number +} + +/** + * Thrown when the caller scopes a search to a `path` that is not a real page or + * section in the docs corpus. Surfaced verbatim so the model can correct itself + * rather than reading an empty result as "the docs say nothing about this". + */ +export class DocsSearchScopeError extends Error { + readonly code = 'DOCS_SEARCH_SCOPE' as const + constructor(message: string) { + super(message) + this.name = 'DocsSearchScopeError' + } +} + +/** + * Translate an optional `docs/` VFS path into a `source_document` filter. + * + * `source_document` stores the en-relative mdx file path, while VFS paths mirror + * the public URL — so a section overview is `docs/workflows.mdx` in the VFS but + * `workflows/index.mdx` (or `workflows.mdx`) on disk. A directory scope covers + * the whole subtree, including that overview page. + * + * Returns undefined for an unscoped search, which excludes `academy/` and + * `api-reference/`: both are indexed but neither is mounted in the VFS, so a hit + * there would be a chunk the agent cannot then read. + */ +function scopeCondition(path?: string) { + const normalized = (path ?? '').trim().replace(/^\/+/, '').replace(/\/+$/, '') + if (normalized === '' || normalized === 'docs') { + return and( + notLike(docsEmbeddings.sourceDocument, 'academy/%'), + notLike(docsEmbeddings.sourceDocument, 'api-reference/%') + ) + } + + if (!normalized.startsWith('docs/')) { + throw new DocsSearchScopeError( + `path must be a docs/ VFS path (got "${path}"). Use glob("docs/**") to find one, or omit path to search everything.` + ) + } + + const tail = normalized.slice('docs/'.length) + + if (isDocsPage(normalized)) { + // One page: on disk it is either `.mdx` or `/index.mdx`. + const stem = tail.replace(/\.mdx$/, '') + return or( + eq(docsEmbeddings.sourceDocument, `${stem}.mdx`), + eq(docsEmbeddings.sourceDocument, `${stem}/index.mdx`) + ) + } + + if (isDocsDir(normalized)) { + return like(docsEmbeddings.sourceDocument, `${escapeLikePattern(tail)}/%`) + } + + throw new DocsSearchScopeError( + `"${path}" is not a page or section in the docs corpus. Use glob("docs/**") to find a valid path, or omit path to search everything.` + ) +} + +function escapeLikePattern(value: string): string { + return value.replace(/[\\%_]/g, (char) => `\\${char}`) +} + +/** + * Semantic search over the indexed docs corpus (`docs_embeddings`, rebuilt by + * `scripts/process-docs.ts` on release). Every result carries the `docs/` path + * it came from so the caller can `read` the full page next. + * + * The index lags the VFS: a page added since the last index rebuild is readable + * but not searchable, and a deleted one can still return chunks. Results whose + * source no longer maps to a live `docs/` path are dropped. + */ +export async function searchDocs( + query: string, + options?: { path?: string; topK?: number } +): Promise { + if (!query || typeof query !== 'string') throw new Error('query is required') + + const topK = Math.min(Math.max(Math.trunc(options?.topK ?? DEFAULT_TOP_K), 1), MAX_TOP_K) + const where = scopeCondition(options?.path) + + logger.info('Executing docs search', { query, topK, path: options?.path ?? null }) + + const { embedding: queryEmbedding } = await generateSearchEmbedding(query) + if (!queryEmbedding || queryEmbedding.length === 0) return [] + + const rows = await db + .select({ + chunkText: docsEmbeddings.chunkText, + sourceDocument: docsEmbeddings.sourceDocument, + sourceLink: docsEmbeddings.sourceLink, + headerText: docsEmbeddings.headerText, + similarity: sql`1 - (${docsEmbeddings.embedding} <=> ${JSON.stringify(queryEmbedding)}::vector)`, + }) + .from(docsEmbeddings) + .where(where) + .orderBy(sql`${docsEmbeddings.embedding} <=> ${JSON.stringify(queryEmbedding)}::vector`) + .limit(topK) + + const results: DocsSearchResult[] = [] + for (const row of rows) { + if (row.similarity < SIMILARITY_THRESHOLD) continue + const path = docsPathForSourceDocument(row.sourceDocument) + if (!path) continue + results.push({ + path, + url: String(row.sourceLink || '#'), + title: String(row.headerText || 'Untitled Section'), + content: String(row.chunkText || ''), + similarity: row.similarity, + }) + } + + logger.info('Docs search complete', { + count: results.length, + dropped: rows.length - results.length, + }) + return results +} diff --git a/apps/sim/lib/copilot/generated/docs-manifest.ts b/apps/sim/lib/copilot/generated/docs-manifest.ts new file mode 100644 index 00000000000..720d5371947 --- /dev/null +++ b/apps/sim/lib/copilot/generated/docs-manifest.ts @@ -0,0 +1,365 @@ +// AUTO-GENERATED FILE. DO NOT EDIT. +// Generated from apps/docs/content/docs/en by scripts/sync-docs-manifest.ts +// Run: bun run docs-manifest:generate +// + +/** + * Every page in the copilot's read-only `docs/` VFS tree, as a path that is + * simultaneously the `docs/`-relative VFS path and the docs.sim.ai URL path + * (so `docs/workflows/blocks/agent.mdx` reads + * `https://docs.sim.ai/workflows/blocks/agent.mdx`). Sorted. + */ +export const DOCS_MANIFEST: readonly string[] = [ + 'agents.mdx', + 'agents/choosing.mdx', + 'agents/custom-tools.mdx', + 'agents/mcp.mdx', + 'agents/skills.mdx', + 'files.mdx', + 'files/editor.mdx', + 'files/generating.mdx', + 'files/passing-files.mdx', + 'files/using-in-workflows.mdx', + 'getting-started.mdx', + 'integrations.mdx', + 'integrations/a2a.mdx', + 'integrations/agentmail.mdx', + 'integrations/agentphone.mdx', + 'integrations/agiloft.mdx', + 'integrations/ahrefs.mdx', + 'integrations/airtable-service-account.mdx', + 'integrations/airtable.mdx', + 'integrations/airweave.mdx', + 'integrations/algolia.mdx', + 'integrations/amplitude.mdx', + 'integrations/apify.mdx', + 'integrations/apollo.mdx', + 'integrations/appconfig.mdx', + 'integrations/arxiv.mdx', + 'integrations/asana-service-account.mdx', + 'integrations/asana.mdx', + 'integrations/ashby.mdx', + 'integrations/athena.mdx', + 'integrations/atlassian-service-account.mdx', + 'integrations/attio-service-account.mdx', + 'integrations/attio.mdx', + 'integrations/azure_devops.mdx', + 'integrations/box-service-account.mdx', + 'integrations/box.mdx', + 'integrations/brandfetch.mdx', + 'integrations/brex.mdx', + 'integrations/brightdata.mdx', + 'integrations/browser_use.mdx', + 'integrations/buffer.mdx', + 'integrations/calcom-service-account.mdx', + 'integrations/calcom.mdx', + 'integrations/calendly.mdx', + 'integrations/circleback.mdx', + 'integrations/clay.mdx', + 'integrations/clerk.mdx', + 'integrations/clickhouse.mdx', + 'integrations/clickup-service-account.mdx', + 'integrations/clickup.mdx', + 'integrations/cloudflare.mdx', + 'integrations/cloudformation.mdx', + 'integrations/cloudwatch.mdx', + 'integrations/codepipeline.mdx', + 'integrations/confluence.mdx', + 'integrations/context_dev.mdx', + 'integrations/convex.mdx', + 'integrations/crowdstrike.mdx', + 'integrations/cursor.mdx', + 'integrations/dagster.mdx', + 'integrations/databricks.mdx', + 'integrations/datadog.mdx', + 'integrations/datagma.mdx', + 'integrations/daytona.mdx', + 'integrations/deployments.mdx', + 'integrations/devin.mdx', + 'integrations/discord.mdx', + 'integrations/docusign.mdx', + 'integrations/downdetector.mdx', + 'integrations/dropbox.mdx', + 'integrations/dropcontact.mdx', + 'integrations/dspy.mdx', + 'integrations/dub.mdx', + 'integrations/duckduckgo.mdx', + 'integrations/dynamodb.mdx', + 'integrations/elasticsearch.mdx', + 'integrations/elevenlabs.mdx', + 'integrations/emailbison.mdx', + 'integrations/enrich.mdx', + 'integrations/enrichment.mdx', + 'integrations/enrow.mdx', + 'integrations/evernote.mdx', + 'integrations/exa.mdx', + 'integrations/extend.mdx', + 'integrations/fathom.mdx', + 'integrations/file.mdx', + 'integrations/findymail.mdx', + 'integrations/firecrawl.mdx', + 'integrations/fireflies.mdx', + 'integrations/flint.mdx', + 'integrations/gamma.mdx', + 'integrations/github.mdx', + 'integrations/gitlab.mdx', + 'integrations/gmail.mdx', + 'integrations/gong.mdx', + 'integrations/google-service-account.mdx', + 'integrations/google_ads.mdx', + 'integrations/google_appsheet.mdx', + 'integrations/google_bigquery.mdx', + 'integrations/google_books.mdx', + 'integrations/google_calendar.mdx', + 'integrations/google_contacts.mdx', + 'integrations/google_docs.mdx', + 'integrations/google_drive.mdx', + 'integrations/google_forms.mdx', + 'integrations/google_groups.mdx', + 'integrations/google_maps.mdx', + 'integrations/google_meet.mdx', + 'integrations/google_pagespeed.mdx', + 'integrations/google_search.mdx', + 'integrations/google_sheets.mdx', + 'integrations/google_slides.mdx', + 'integrations/google_tasks.mdx', + 'integrations/google_translate.mdx', + 'integrations/google_vault.mdx', + 'integrations/grafana.mdx', + 'integrations/grain.mdx', + 'integrations/granola.mdx', + 'integrations/greenhouse.mdx', + 'integrations/greptile.mdx', + 'integrations/hex.mdx', + 'integrations/hubspot-service-account.mdx', + 'integrations/hubspot-setup.mdx', + 'integrations/hubspot.mdx', + 'integrations/huggingface.mdx', + 'integrations/hunter.mdx', + 'integrations/iam.mdx', + 'integrations/icypeas.mdx', + 'integrations/identity_center.mdx', + 'integrations/imap.mdx', + 'integrations/incidentio.mdx', + 'integrations/infisical.mdx', + 'integrations/instantly.mdx', + 'integrations/intercom.mdx', + 'integrations/jina.mdx', + 'integrations/jira.mdx', + 'integrations/jira_service_management.mdx', + 'integrations/jupyter.mdx', + 'integrations/kalshi.mdx', + 'integrations/ketch.mdx', + 'integrations/knowledge.mdx', + 'integrations/langsmith.mdx', + 'integrations/latex.mdx', + 'integrations/launchdarkly.mdx', + 'integrations/leadmagic.mdx', + 'integrations/lemlist.mdx', + 'integrations/linear-service-account.mdx', + 'integrations/linear.mdx', + 'integrations/linkedin.mdx', + 'integrations/linkup.mdx', + 'integrations/linq.mdx', + 'integrations/logs.mdx', + 'integrations/loops.mdx', + 'integrations/luma.mdx', + 'integrations/mailchimp.mdx', + 'integrations/mailgun.mdx', + 'integrations/mem0.mdx', + 'integrations/memory.mdx', + 'integrations/microsoft_ad.mdx', + 'integrations/microsoft_dataverse.mdx', + 'integrations/microsoft_excel.mdx', + 'integrations/microsoft_planner.mdx', + 'integrations/microsoft_teams.mdx', + 'integrations/millionverifier.mdx', + 'integrations/mistral_parse.mdx', + 'integrations/monday-service-account.mdx', + 'integrations/monday.mdx', + 'integrations/mongodb.mdx', + 'integrations/mysql.mdx', + 'integrations/neo4j.mdx', + 'integrations/neverbounce.mdx', + 'integrations/new_relic.mdx', + 'integrations/notion-service-account.mdx', + 'integrations/notion.mdx', + 'integrations/obsidian.mdx', + 'integrations/okta.mdx', + 'integrations/onedrive.mdx', + 'integrations/onepassword.mdx', + 'integrations/openai.mdx', + 'integrations/outlook.mdx', + 'integrations/pagerduty.mdx', + 'integrations/parallel_ai.mdx', + 'integrations/peopledatalabs.mdx', + 'integrations/perplexity.mdx', + 'integrations/persona.mdx', + 'integrations/pinecone.mdx', + 'integrations/pipedrive-service-account.mdx', + 'integrations/pipedrive.mdx', + 'integrations/polymarket.mdx', + 'integrations/postgresql.mdx', + 'integrations/posthog.mdx', + 'integrations/profound.mdx', + 'integrations/prospeo.mdx', + 'integrations/pulse.mdx', + 'integrations/qdrant.mdx', + 'integrations/quartr.mdx', + 'integrations/quiver.mdx', + 'integrations/railway.mdx', + 'integrations/rb2b.mdx', + 'integrations/rds.mdx', + 'integrations/reddit.mdx', + 'integrations/redis.mdx', + 'integrations/reducto.mdx', + 'integrations/resend.mdx', + 'integrations/revenuecat.mdx', + 'integrations/rippling.mdx', + 'integrations/rocketlane.mdx', + 'integrations/rootly.mdx', + 'integrations/s3.mdx', + 'integrations/salesforce-service-account.mdx', + 'integrations/salesforce.mdx', + 'integrations/sap_concur.mdx', + 'integrations/sap_s4hana.mdx', + 'integrations/secrets_manager.mdx', + 'integrations/sendblue.mdx', + 'integrations/sendgrid.mdx', + 'integrations/sentry.mdx', + 'integrations/serper.mdx', + 'integrations/servicenow.mdx', + 'integrations/ses.mdx', + 'integrations/sftp.mdx', + 'integrations/sharepoint.mdx', + 'integrations/shopify-service-account.mdx', + 'integrations/shopify.mdx', + 'integrations/similarweb.mdx', + 'integrations/sixtyfour.mdx', + 'integrations/slack.mdx', + 'integrations/smtp.mdx', + 'integrations/sportmonks.mdx', + 'integrations/sqs.mdx', + 'integrations/square.mdx', + 'integrations/ssh.mdx', + 'integrations/stagehand.mdx', + 'integrations/stripe.mdx', + 'integrations/sts.mdx', + 'integrations/supabase.mdx', + 'integrations/table.mdx', + 'integrations/tailscale.mdx', + 'integrations/tavily.mdx', + 'integrations/telegram.mdx', + 'integrations/temporal.mdx', + 'integrations/textract.mdx', + 'integrations/thrive.mdx', + 'integrations/tinybird.mdx', + 'integrations/trello-service-account.mdx', + 'integrations/trello.mdx', + 'integrations/trigger_dev.mdx', + 'integrations/twilio.mdx', + 'integrations/twilio_sms.mdx', + 'integrations/twilio_voice.mdx', + 'integrations/typeform.mdx', + 'integrations/upstash.mdx', + 'integrations/uptimerobot.mdx', + 'integrations/vanta.mdx', + 'integrations/vercel.mdx', + 'integrations/wealthbox-service-account.mdx', + 'integrations/wealthbox.mdx', + 'integrations/webflow-service-account.mdx', + 'integrations/webflow.mdx', + 'integrations/whatsapp.mdx', + 'integrations/wikipedia.mdx', + 'integrations/wiza.mdx', + 'integrations/wordpress.mdx', + 'integrations/workday.mdx', + 'integrations/x.mdx', + 'integrations/youtube.mdx', + 'integrations/zendesk.mdx', + 'integrations/zep.mdx', + 'integrations/zerobounce.mdx', + 'integrations/zoom-service-account.mdx', + 'integrations/zoom.mdx', + 'integrations/zoominfo.mdx', + 'introduction.mdx', + 'keyboard-shortcuts.mdx', + 'knowledgebase.mdx', + 'knowledgebase/chunking-strategies.mdx', + 'knowledgebase/connectors.mdx', + 'knowledgebase/debugging-retrieval.mdx', + 'knowledgebase/tags.mdx', + 'knowledgebase/using-in-workflows.mdx', + 'logs-debugging.mdx', + 'logs-debugging/alerts.mdx', + 'logs-debugging/logging.mdx', + 'mothership.mdx', + 'mothership/files.mdx', + 'mothership/knowledge.mdx', + 'mothership/mailer.mdx', + 'mothership/research.mdx', + 'mothership/tables.mdx', + 'mothership/tasks.mdx', + 'mothership/workflows.mdx', + 'platform/costs.mdx', + 'platform/credentials.mdx', + 'platform/enterprise.mdx', + 'platform/enterprise/access-control.mdx', + 'platform/enterprise/audit-logs.mdx', + 'platform/enterprise/custom-blocks.mdx', + 'platform/enterprise/data-drains.mdx', + 'platform/enterprise/data-retention.mdx', + 'platform/enterprise/forks.mdx', + 'platform/enterprise/session-policies.mdx', + 'platform/enterprise/sso.mdx', + 'platform/enterprise/verified-domains.mdx', + 'platform/enterprise/whitelabeling.mdx', + 'platform/organization.mdx', + 'platform/permissions.mdx', + 'platform/self-hosting.mdx', + 'platform/self-hosting/docker.mdx', + 'platform/self-hosting/environment-variables.mdx', + 'platform/self-hosting/kubernetes.mdx', + 'platform/self-hosting/object-storage.mdx', + 'platform/self-hosting/platforms.mdx', + 'platform/self-hosting/troubleshooting.mdx', + 'platform/workspaces.mdx', + 'quick-reference.mdx', + 'tables.mdx', + 'tables/using-in-workflows.mdx', + 'tables/workflow-columns.mdx', + 'workflows.mdx', + 'workflows/blocks/agent.mdx', + 'workflows/blocks/api.mdx', + 'workflows/blocks/condition.mdx', + 'workflows/blocks/credential.mdx', + 'workflows/blocks/evaluator.mdx', + 'workflows/blocks/function.mdx', + 'workflows/blocks/guardrails.mdx', + 'workflows/blocks/human-in-the-loop.mdx', + 'workflows/blocks/logs.mdx', + 'workflows/blocks/loop.mdx', + 'workflows/blocks/parallel.mdx', + 'workflows/blocks/pi.mdx', + 'workflows/blocks/response.mdx', + 'workflows/blocks/router.mdx', + 'workflows/blocks/variables.mdx', + 'workflows/blocks/wait.mdx', + 'workflows/blocks/webhook.mdx', + 'workflows/blocks/workflow.mdx', + 'workflows/connections.mdx', + 'workflows/data-flow.mdx', + 'workflows/deployment.mdx', + 'workflows/deployment/agent-events.mdx', + 'workflows/deployment/api.mdx', + 'workflows/deployment/chat.mdx', + 'workflows/deployment/mcp.mdx', + 'workflows/how-it-runs.mdx', + 'workflows/triggers/rss.mdx', + 'workflows/triggers/schedule.mdx', + 'workflows/triggers/sim.mdx', + 'workflows/triggers/start.mdx', + 'workflows/triggers/table.mdx', + 'workflows/triggers/webhook.mdx', + 'workflows/variables.mdx', +] diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 9c996be921a..76e0c906090 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -9,35 +9,17 @@ export interface ToolCatalogEntry { id: | 'agent' | 'auth' - | 'browser' - | 'browser_click' - | 'browser_close_tab' - | 'browser_extract' - | 'browser_go_back' - | 'browser_go_forward' - | 'browser_hover' - | 'browser_list_sessions' - | 'browser_list_tabs' - | 'browser_navigate' - | 'browser_open_tab' - | 'browser_open_url' - | 'browser_press_key' - | 'browser_read_text' - | 'browser_request_takeover' - | 'browser_screenshot' - | 'browser_scroll' - | 'browser_select_option' - | 'browser_snapshot' - | 'browser_switch_tab' - | 'browser_type' - | 'browser_wait_for' | 'call_integration_tool' | 'check_deployment_status' + | 'complete_scheduled_task' | 'cp' | 'crawl_website' | 'create_file' | 'create_workflow' | 'create_workspace_mcp_server' + | 'delete_file' + | 'delete_file_folder' + | 'delete_workflow' | 'delete_workspace_mcp_server' | 'deploy' | 'deploy_api' @@ -62,6 +44,7 @@ export interface ToolCatalogEntry { | 'get_deployment_log' | 'get_page_contents' | 'get_platform_actions' + | 'get_scheduled_task_logs' | 'get_workflow_data' | 'get_workflow_run_options' | 'glob' @@ -73,11 +56,11 @@ export interface ToolCatalogEntry { | 'list_workspace_mcp_servers' | 'load_deployment' | 'load_integration_tool' - | 'load_skill' | 'manage_credential' | 'manage_custom_tool' + | 'manage_folder' | 'manage_mcp_tool' - | 'manage_sandbox' + | 'manage_scheduled_task' | 'manage_skill' | 'materialize_file' | 'media' @@ -93,16 +76,16 @@ export interface ToolCatalogEntry { | 'redeploy' | 'respond' | 'restore_resource' - | 'rm' | 'run' | 'run_block' | 'run_code' | 'run_from_block' | 'run_workflow' | 'run_workflow_until_block' + | 'scheduled_task' | 'scrape_page' | 'search' - | 'search_documentation' + | 'search_docs' | 'search_integration_tools' | 'search_knowledge_base' | 'search_library_docs' @@ -113,11 +96,10 @@ export interface ToolCatalogEntry { | 'set_global_workflow_variables' | 'share_file' | 'table' - | 'terminal' | 'update_deployment_version' + | 'update_scheduled_task_history' | 'update_workspace_mcp_server' | 'user_table' - | 'wait' | 'workflow' | 'workspace_file' internal?: boolean @@ -125,35 +107,17 @@ export interface ToolCatalogEntry { name: | 'agent' | 'auth' - | 'browser' - | 'browser_click' - | 'browser_close_tab' - | 'browser_extract' - | 'browser_go_back' - | 'browser_go_forward' - | 'browser_hover' - | 'browser_list_sessions' - | 'browser_list_tabs' - | 'browser_navigate' - | 'browser_open_tab' - | 'browser_open_url' - | 'browser_press_key' - | 'browser_read_text' - | 'browser_request_takeover' - | 'browser_screenshot' - | 'browser_scroll' - | 'browser_select_option' - | 'browser_snapshot' - | 'browser_switch_tab' - | 'browser_type' - | 'browser_wait_for' | 'call_integration_tool' | 'check_deployment_status' + | 'complete_scheduled_task' | 'cp' | 'crawl_website' | 'create_file' | 'create_workflow' | 'create_workspace_mcp_server' + | 'delete_file' + | 'delete_file_folder' + | 'delete_workflow' | 'delete_workspace_mcp_server' | 'deploy' | 'deploy_api' @@ -178,6 +142,7 @@ export interface ToolCatalogEntry { | 'get_deployment_log' | 'get_page_contents' | 'get_platform_actions' + | 'get_scheduled_task_logs' | 'get_workflow_data' | 'get_workflow_run_options' | 'glob' @@ -189,11 +154,11 @@ export interface ToolCatalogEntry { | 'list_workspace_mcp_servers' | 'load_deployment' | 'load_integration_tool' - | 'load_skill' | 'manage_credential' | 'manage_custom_tool' + | 'manage_folder' | 'manage_mcp_tool' - | 'manage_sandbox' + | 'manage_scheduled_task' | 'manage_skill' | 'materialize_file' | 'media' @@ -209,16 +174,16 @@ export interface ToolCatalogEntry { | 'redeploy' | 'respond' | 'restore_resource' - | 'rm' | 'run' | 'run_block' | 'run_code' | 'run_from_block' | 'run_workflow' | 'run_workflow_until_block' + | 'scheduled_task' | 'scrape_page' | 'search' - | 'search_documentation' + | 'search_docs' | 'search_integration_tools' | 'search_knowledge_base' | 'search_library_docs' @@ -229,27 +194,25 @@ export interface ToolCatalogEntry { | 'set_global_workflow_variables' | 'share_file' | 'table' - | 'terminal' | 'update_deployment_version' + | 'update_scheduled_task_history' | 'update_workspace_mcp_server' | 'user_table' - | 'wait' | 'workflow' | 'workspace_file' parameters: unknown requiredPermission?: 'admin' | 'write' - requiresApproval?: boolean resultSchema?: unknown route: 'client' | 'go' | 'sim' | 'subagent' subagentId?: | 'agent' | 'auth' - | 'browser' | 'deploy' | 'file' | 'knowledge' | 'media' | 'run' + | 'scheduled_task' | 'search' | 'table' | 'workflow' @@ -288,910 +251,6 @@ export const Auth: ToolCatalogEntry = { internal: true, } -export const Browser: ToolCatalogEntry = { - id: 'browser', - name: 'browser', - route: 'subagent', - mode: 'async', - parameters: { - properties: { - task: { - description: - 'The web task to complete, in plain language (include the target site/URL if known).', - type: 'string', - }, - }, - required: ['task'], - type: 'object', - }, - subagentId: 'browser', - internal: true, -} - -export const BrowserClick: ToolCatalogEntry = { - id: 'browser_click', - name: 'browser_click', - route: 'client', - mode: 'async', - parameters: { - type: 'object', - properties: { - elementId: { - type: 'number', - description: - "The element id to act on (from the current tab's most recent browser_snapshot). Treat refs as invalid across tab switches or later snapshots.", - }, - }, - required: ['elementId'], - }, - resultSchema: { - type: 'object', - properties: { - activation: { - type: 'string', - description: 'native-pointer, native-keyboard, or synthetic-pointer.', - }, - activeTab: { - type: 'object', - description: 'New active tab after a tab-changing click.', - properties: { - tabId: { type: 'string', description: 'Stable browser tab id.' }, - url: { type: 'string', description: 'New active tab URL.' }, - }, - }, - dialogs: { - type: 'array', - description: 'Visible DOM dialogs remaining after the click.', - items: { type: 'string' }, - }, - dispatched: { type: 'boolean', description: 'Whether input dispatch completed.' }, - effect: { - type: 'object', - description: - 'Detailed postcondition signals; generic title/DOM/scroll churn is weak evidence unless the tool documents otherwise.', - properties: { - dialogChanged: { type: 'boolean', description: 'The visible DOM dialog set changed.' }, - domChanged: { - type: 'boolean', - description: 'The DOM mutation revision changed; weak evidence on its own.', - }, - fieldChanged: { - type: 'boolean', - description: 'The safely inspectable focused-field state changed.', - }, - focusChanged: { type: 'boolean', description: 'The focused element changed.' }, - popupChanged: { type: 'boolean', description: 'The visible popup/menu set changed.' }, - scrollChanged: { - type: 'boolean', - description: 'A tracked scroll offset changed; weak evidence except for scroll keys.', - }, - tabChanged: { type: 'boolean', description: 'The active browser tab changed.' }, - targetChanged: { - type: 'boolean', - description: "The requested target's checked/selected/expanded/open state changed.", - }, - titleChanged: { - type: 'boolean', - description: 'The document title changed; weak evidence on its own.', - }, - urlChanged: { type: 'boolean', description: 'The observed URL changed.' }, - }, - }, - effectObserved: { - type: 'boolean', - description: - 'Whether a URL/tab/dialog/popup/target-state change, or editable-target focus change, was observed.', - }, - element: { type: 'string', description: 'Resolved target element kind.' }, - note: { type: 'string', description: 'Postcondition or recovery guidance.' }, - notices: { - type: 'array', - description: - 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', - items: { type: 'string' }, - }, - obstructedAfterNavigation: { - type: 'boolean', - description: 'Navigation/tab change occurred while a visible DOM dialog remained.', - }, - possibleEffectObserved: { - type: 'boolean', - description: 'Includes weak title/DOM/scroll churn; never treat this alone as success.', - }, - refRecovered: { - type: 'boolean', - description: - 'Whether a stale detached ref was safely rebound to one unique semantic match.', - }, - trusted: { - type: 'boolean', - description: 'Whether Chromium trusted pointer/keyboard input was used.', - }, - }, - required: ['dispatched'], - }, - clientExecutable: true, -} - -export const BrowserCloseTab: ToolCatalogEntry = { - id: 'browser_close_tab', - name: 'browser_close_tab', - route: 'client', - mode: 'async', - parameters: { - type: 'object', - properties: { - tabId: { - type: 'string', - description: 'The id of the tab to close (from browser_list_tabs).', - }, - }, - required: ['tabId'], - }, - clientExecutable: true, -} - -export const BrowserExtract: ToolCatalogEntry = { - id: 'browser_extract', - name: 'browser_extract', - route: 'client', - mode: 'async', - parameters: { - type: 'object', - properties: { - instruction: { - type: 'string', - description: - 'What you intend to extract, in plain language. Echoed back unchanged; it does not filter or shape the returned text.', - }, - }, - required: ['instruction'], - }, - resultSchema: { - type: 'object', - properties: { - instruction: { type: 'string', description: 'The extraction instruction echoed unchanged.' }, - notices: { - type: 'array', - description: - 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', - items: { type: 'string' }, - }, - page: { - type: 'object', - description: 'Bounded visible page/frame text result.', - properties: { - framesRead: { - type: 'number', - description: 'Visible child frames whose text was appended.', - }, - hiddenFrames: { - type: 'number', - description: - 'Eligible child frames skipped because their embedding surface was not visible.', - }, - text: { - type: 'string', - description: - 'Visible text, capped across the top page and eligible visible child frames.', - }, - title: { type: 'string', description: 'Top-page title when available.' }, - truncated: { - type: 'boolean', - description: 'Whether a page, frame, or combined character cap omitted text.', - }, - unreadableFrames: { - type: 'number', - description: 'Eligible child frames whose text could not be read.', - }, - url: { type: 'string', description: 'Top-page URL.' }, - }, - }, - }, - }, - clientExecutable: true, -} - -export const BrowserGoBack: ToolCatalogEntry = { - id: 'browser_go_back', - name: 'browser_go_back', - route: 'client', - mode: 'async', - parameters: { type: 'object', properties: {} }, - clientExecutable: true, -} - -export const BrowserGoForward: ToolCatalogEntry = { - id: 'browser_go_forward', - name: 'browser_go_forward', - route: 'client', - mode: 'async', - parameters: { type: 'object', properties: {} }, - clientExecutable: true, -} - -export const BrowserHover: ToolCatalogEntry = { - id: 'browser_hover', - name: 'browser_hover', - route: 'client', - mode: 'async', - parameters: { - type: 'object', - properties: { - elementId: { - type: 'number', - description: - "The element id to act on (from the current tab's most recent browser_snapshot). Treat refs as invalid across tab switches or later snapshots.", - }, - }, - required: ['elementId'], - }, - resultSchema: { - type: 'object', - properties: { - effect: { - type: 'object', - description: - 'Detailed postcondition signals; generic title/DOM/scroll churn is weak evidence unless the tool documents otherwise.', - properties: { - dialogChanged: { type: 'boolean', description: 'The visible DOM dialog set changed.' }, - domChanged: { - type: 'boolean', - description: 'The DOM mutation revision changed; weak evidence on its own.', - }, - fieldChanged: { - type: 'boolean', - description: 'The safely inspectable focused-field state changed.', - }, - focusChanged: { type: 'boolean', description: 'The focused element changed.' }, - popupChanged: { type: 'boolean', description: 'The visible popup/menu set changed.' }, - scrollChanged: { - type: 'boolean', - description: 'A tracked scroll offset changed; weak evidence except for scroll keys.', - }, - tabChanged: { type: 'boolean', description: 'The active browser tab changed.' }, - targetChanged: { - type: 'boolean', - description: "The requested target's checked/selected/expanded/open state changed.", - }, - titleChanged: { - type: 'boolean', - description: 'The document title changed; weak evidence on its own.', - }, - urlChanged: { type: 'boolean', description: 'The observed URL changed.' }, - }, - }, - effectObserved: { - type: 'boolean', - description: 'Whether a URL/dialog/popup/target-state change was observed.', - }, - element: { type: 'string', description: 'Resolved target element kind when available.' }, - hovered: { type: 'boolean', description: 'Whether hover input was dispatched.' }, - note: { type: 'string', description: 'Guidance when no tooltip/menu was confirmed.' }, - notices: { - type: 'array', - description: - 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', - items: { type: 'string' }, - }, - possibleEffectObserved: { - type: 'boolean', - description: 'Includes weak title/DOM/scroll churn; not proof of success.', - }, - refRecovered: { - type: 'boolean', - description: - 'Whether a stale detached ref was safely rebound to one unique semantic match.', - }, - trusted: { - type: 'boolean', - description: 'Whether Chromium trusted pointer movement was used.', - }, - }, - required: ['hovered'], - }, - clientExecutable: true, -} - -export const BrowserListSessions: ToolCatalogEntry = { - id: 'browser_list_sessions', - name: 'browser_list_sessions', - route: 'client', - mode: 'async', - parameters: { type: 'object', properties: {} }, - clientExecutable: true, -} - -export const BrowserListTabs: ToolCatalogEntry = { - id: 'browser_list_tabs', - name: 'browser_list_tabs', - route: 'client', - mode: 'async', - parameters: { type: 'object', properties: {} }, - clientExecutable: true, -} - -export const BrowserNavigate: ToolCatalogEntry = { - id: 'browser_navigate', - name: 'browser_navigate', - route: 'client', - mode: 'async', - parameters: { - type: 'object', - properties: { - url: { - type: 'string', - description: - 'The absolute URL to navigate to, including scheme (https:// or http://). Must resolve to a public address — localhost and private/internal hosts are rejected.', - }, - }, - required: ['url'], - }, - clientExecutable: true, -} - -export const BrowserOpenTab: ToolCatalogEntry = { - id: 'browser_open_tab', - name: 'browser_open_tab', - route: 'client', - mode: 'async', - parameters: { - type: 'object', - properties: { url: { type: 'string', description: 'Optional URL to open the new tab at.' } }, - }, - clientExecutable: true, -} - -export const BrowserOpenUrl: ToolCatalogEntry = { - id: 'browser_open_url', - name: 'browser_open_url', - route: 'client', - mode: 'async', - parameters: { - type: 'object', - properties: { - url: { - type: 'string', - description: - 'The absolute URL to open, including scheme (https:// or http:// — localhost/local dev URLs are supported).', - }, - }, - required: ['url'], - }, - clientExecutable: true, -} - -export const BrowserPressKey: ToolCatalogEntry = { - id: 'browser_press_key', - name: 'browser_press_key', - route: 'client', - mode: 'async', - parameters: { - type: 'object', - properties: { - key: { - type: 'string', - description: - "Key or combination. Named keys (case-insensitive): Enter, Escape (Esc), Tab, Backspace, Delete, Space, ArrowUp/ArrowDown/ArrowLeft/ArrowRight (or Up/Down/Left/Right), Home, End, PageUp, PageDown. Any single character also works ('a', '5', '/', ','). Anything else — 'F5', 'Return', 'Insert' — is rejected. Join modifiers with '+'. Use Mod (aliases Primary, ControlOrMeta, CommandOrControl) for the platform primary modifier, e.g. Mod+K or Mod+,. Raw Control/Ctrl and Cmd/Command/Meta remain available; Control is not generally Cmd on macOS. Check effectObserved and primaryModifier in the result.", - }, - }, - required: ['key'], - }, - resultSchema: { - type: 'object', - properties: { - activeElement: { type: 'string', description: 'Focused element kind after the action.' }, - dialogs: { - type: 'array', - description: 'Visible DOM dialogs after the key.', - items: { type: 'string' }, - }, - effect: { - type: 'object', - description: - 'Detailed postcondition signals; generic title/DOM/scroll churn is weak evidence unless the tool documents otherwise.', - properties: { - dialogChanged: { type: 'boolean', description: 'The visible DOM dialog set changed.' }, - domChanged: { - type: 'boolean', - description: 'The DOM mutation revision changed; weak evidence on its own.', - }, - fieldChanged: { - type: 'boolean', - description: 'The safely inspectable focused-field state changed.', - }, - focusChanged: { type: 'boolean', description: 'The focused element changed.' }, - popupChanged: { type: 'boolean', description: 'The visible popup/menu set changed.' }, - scrollChanged: { - type: 'boolean', - description: 'A tracked scroll offset changed; weak evidence except for scroll keys.', - }, - tabChanged: { type: 'boolean', description: 'The active browser tab changed.' }, - targetChanged: { - type: 'boolean', - description: "The requested target's checked/selected/expanded/open state changed.", - }, - titleChanged: { - type: 'boolean', - description: 'The document title changed; weak evidence on its own.', - }, - urlChanged: { type: 'boolean', description: 'The observed URL changed.' }, - }, - }, - effectObserved: { type: 'boolean', description: 'A strong targeted effect was observed.' }, - note: { type: 'string', description: 'No-op/fallback guidance.' }, - notices: { - type: 'array', - description: - 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', - items: { type: 'string' }, - }, - possibleEffectObserved: { - type: 'boolean', - description: 'Includes weak title/DOM/scroll churn; not proof of success.', - }, - pressed: { type: 'string', description: 'Requested key/combo whose dispatch completed.' }, - primaryModifier: { type: 'string', description: 'Cmd on macOS, Control elsewhere.' }, - redacted: { - type: 'boolean', - description: 'Whether sensitive focused-field details were withheld.', - }, - selectedChars: { - type: 'number', - description: 'Number of selected characters when safely inspectable.', - }, - target: { - type: 'string', - description: 'Synthetic fallback target element kind, when applicable.', - }, - trusted: { type: 'boolean', description: 'Whether Chromium trusted key input was used.' }, - valueLength: { - type: 'number', - description: 'Focused non-secret field length when safely inspectable.', - }, - valuePreview: { - type: 'string', - description: 'Bounded focused non-secret field preview when safely inspectable.', - }, - }, - required: ['pressed'], - }, - clientExecutable: true, -} - -export const BrowserReadText: ToolCatalogEntry = { - id: 'browser_read_text', - name: 'browser_read_text', - route: 'client', - mode: 'async', - parameters: { - type: 'object', - properties: { - elementId: { - type: 'number', - description: - "Optional element id from the current tab's most recent browser_snapshot. Treat refs as invalid across tab switches or later snapshots. Omit to read the whole page.", - }, - }, - }, - resultSchema: { - type: 'object', - properties: { - framesRead: { type: 'number', description: 'Visible child frames whose text was appended.' }, - hiddenFrames: { - type: 'number', - description: - 'Eligible child frames skipped because their embedding surface was not visible.', - }, - notices: { - type: 'array', - description: - 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', - items: { type: 'string' }, - }, - text: { - type: 'string', - description: 'Visible text, capped across the top page and eligible visible child frames.', - }, - title: { type: 'string', description: 'Top-page title when available.' }, - truncated: { - type: 'boolean', - description: 'Whether a page, frame, or combined character cap omitted text.', - }, - unreadableFrames: { - type: 'number', - description: 'Eligible child frames whose text could not be read.', - }, - url: { type: 'string', description: 'Top-page URL.' }, - }, - }, - clientExecutable: true, -} - -export const BrowserRequestTakeover: ToolCatalogEntry = { - id: 'browser_request_takeover', - name: 'browser_request_takeover', - route: 'client', - mode: 'async', - parameters: { - type: 'object', - properties: { - purpose: { - type: 'string', - description: - 'Why takeover is needed. Set sign_in for a login/password flow so the desktop can remember a privacy-preserving session hint after the user finishes.', - enum: ['sign_in', 'captcha', 'payment', 'sensitive_confirmation', 'other'], - }, - reason: { - type: 'string', - description: - "Short explanation shown to the user of what they need to do (e.g. 'Sign in to Notion').", - }, - }, - required: ['reason'], - }, - clientExecutable: true, -} - -export const BrowserScreenshot: ToolCatalogEntry = { - id: 'browser_screenshot', - name: 'browser_screenshot', - route: 'client', - mode: 'async', - parameters: { type: 'object', properties: {} }, - clientExecutable: true, -} - -export const BrowserScroll: ToolCatalogEntry = { - id: 'browser_scroll', - name: 'browser_scroll', - route: 'client', - mode: 'async', - parameters: { - type: 'object', - properties: { - amount: { - type: 'number', - description: - 'Optional distance to scroll in pixels (default: 85% of the viewport height, so a little context carries over).', - }, - direction: { type: 'string', description: 'Scroll direction.', enum: ['up', 'down'] }, - elementId: { - type: 'number', - description: - "The element id to act on (from the current tab's most recent browser_snapshot). Treat refs as invalid across tab switches or later snapshots.", - }, - }, - required: ['direction'], - }, - resultSchema: { - type: 'object', - properties: { - atBottom: { - type: 'boolean', - description: 'Whether the selected region is at its bottom boundary.', - }, - atTop: { - type: 'boolean', - description: 'Whether the selected region is at its top boundary.', - }, - clientHeight: { type: 'number', description: 'Region viewport height.' }, - movedBy: { - type: 'number', - description: 'Actual signed movement; zero means the target did not move.', - }, - notices: { - type: 'array', - description: - 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', - items: { type: 'string' }, - }, - scrollHeight: { type: 'number', description: 'Region content height.' }, - scrollTop: { type: 'number', description: 'Resulting region scroll offset.' }, - target: { type: 'string', description: 'Chosen scroll region label.' }, - targetSource: { - type: 'string', - description: - 'element, element-boundary, focus, focus-boundary, viewport-center, viewport-center-boundary, largest-visible, or page.', - }, - windowScrollY: { - type: 'number', - description: 'Top-page window scroll offset after the region scroll.', - }, - }, - required: ['atTop', 'atBottom'], - }, - clientExecutable: true, -} - -export const BrowserSelectOption: ToolCatalogEntry = { - id: 'browser_select_option', - name: 'browser_select_option', - route: 'client', - mode: 'async', - parameters: { - type: 'object', - properties: { - elementId: { - type: 'number', - description: - "The element id to act on (from the current tab's most recent browser_snapshot). Treat refs as invalid across tab switches or later snapshots.", - }, - value: { type: 'string', description: "The option's visible label or its value." }, - }, - required: ['elementId', 'value'], - }, - resultSchema: { - type: 'object', - properties: { - effectObserved: { - type: 'boolean', - description: 'Whether the settled readback retained the requested selection.', - }, - note: { type: 'string', description: 'Guidance when the page reverted the selection.' }, - notices: { - type: 'array', - description: - 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', - items: { type: 'string' }, - }, - readback: { - type: 'object', - description: 'Settled selected label and value.', - properties: { - selected: { type: 'string', description: 'Settled visible option label.' }, - value: { type: 'string', description: 'Settled option value.' }, - }, - }, - refRecovered: { - type: 'boolean', - description: - 'Whether a stale detached ref was safely rebound to one unique semantic match.', - }, - selected: { type: 'string', description: 'Canonical visible label of the matched option.' }, - value: { type: 'string', description: 'Canonical value of the matched option.' }, - }, - required: ['selected'], - }, - clientExecutable: true, -} - -export const BrowserSnapshot: ToolCatalogEntry = { - id: 'browser_snapshot', - name: 'browser_snapshot', - route: 'client', - mode: 'async', - parameters: { type: 'object', properties: {} }, - resultSchema: { - type: 'object', - properties: { - capturedCrossOriginFrames: { - type: 'number', - description: 'Number of non-empty eligible cross-origin frames appended.', - }, - hiddenCrossOriginFrames: { - type: 'number', - description: - 'Eligible cross-origin frames skipped because their embedding surface was hidden, offscreen, or covered.', - }, - notices: { - type: 'array', - description: - 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', - items: { type: 'string' }, - }, - outline: { - type: 'string', - description: 'Mounted DOM/frame outline containing model-visible [ref=N] ids.', - }, - pageHeight: { type: 'number', description: 'Top-page document height.' }, - scrollY: { type: 'number', description: 'Top-page window scroll offset.' }, - title: { type: 'string', description: 'Captured top-page title.' }, - truncated: { - type: 'boolean', - description: 'True when page/ref/frame/combined output caps omitted content.', - }, - unreadableCrossOriginFrames: { - type: 'number', - description: 'Eligible cross-origin frames that could not be captured.', - }, - url: { type: 'string', description: 'Captured top-page URL.' }, - viewportHeight: { type: 'number', description: 'Top-page viewport height.' }, - viewportWidth: { type: 'number', description: 'Top-page viewport width.' }, - }, - required: ['outline', 'truncated'], - }, - clientExecutable: true, -} - -export const BrowserSwitchTab: ToolCatalogEntry = { - id: 'browser_switch_tab', - name: 'browser_switch_tab', - route: 'client', - mode: 'async', - parameters: { - type: 'object', - properties: { - tabId: { - type: 'string', - description: 'The id of the tab to activate (from browser_list_tabs).', - }, - }, - required: ['tabId'], - }, - clientExecutable: true, -} - -export const BrowserType: ToolCatalogEntry = { - id: 'browser_type', - name: 'browser_type', - route: 'client', - mode: 'async', - parameters: { - type: 'object', - properties: { - elementId: { - type: 'number', - description: - "The element id to act on (from the current tab's most recent browser_snapshot). Treat refs as invalid across tab switches or later snapshots.", - }, - submit: { type: 'boolean', description: 'Press Enter after typing. Default false.' }, - text: { - type: 'string', - description: - "The text to type. Replaces the element's current content. Must be non-empty — an empty string is rejected as a missing parameter; to clear a field, press Mod+A then Backspace with browser_press_key.", - }, - }, - required: ['elementId', 'text'], - }, - resultSchema: { - type: 'object', - properties: { - activeElement: { type: 'string', description: 'Focused element kind after the action.' }, - dispatched: { type: 'boolean', description: 'Whether text dispatch completed.' }, - effect: { - type: 'object', - description: - 'Detailed postcondition signals; generic title/DOM/scroll churn is weak evidence unless the tool documents otherwise.', - properties: { - dialogChanged: { type: 'boolean', description: 'The visible DOM dialog set changed.' }, - domChanged: { - type: 'boolean', - description: 'The DOM mutation revision changed; weak evidence on its own.', - }, - fieldChanged: { - type: 'boolean', - description: 'The safely inspectable focused-field state changed.', - }, - focusChanged: { type: 'boolean', description: 'The focused element changed.' }, - popupChanged: { type: 'boolean', description: 'The visible popup/menu set changed.' }, - scrollChanged: { - type: 'boolean', - description: 'A tracked scroll offset changed; weak evidence except for scroll keys.', - }, - tabChanged: { type: 'boolean', description: 'The active browser tab changed.' }, - targetChanged: { - type: 'boolean', - description: "The requested target's checked/selected/expanded/open state changed.", - }, - titleChanged: { - type: 'boolean', - description: 'The document title changed; weak evidence on its own.', - }, - urlChanged: { type: 'boolean', description: 'The observed URL changed.' }, - }, - }, - effectObserved: { type: 'boolean', description: 'A strong field/page effect was observed.' }, - note: { - type: 'string', - description: 'Postcondition guidance when readback did not prove a change.', - }, - notices: { - type: 'array', - description: - 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', - items: { type: 'string' }, - }, - possibleEffectObserved: { - type: 'boolean', - description: 'Includes weak title/DOM/scroll churn; not proof of success.', - }, - redacted: { - type: 'boolean', - description: 'Whether sensitive focused-field details were withheld.', - }, - refRecovered: { - type: 'boolean', - description: - 'Whether a stale detached ref was safely rebound to one unique semantic match.', - }, - replacedExisting: { - type: 'boolean', - description: "Whether the operation replaced the field's existing content.", - }, - selectedChars: { - type: 'number', - description: 'Number of selected characters when safely inspectable.', - }, - submissionEffectObserved: { - type: 'boolean', - description: - 'Whether a strong effect was observed after Enter, separately from the text write.', - }, - submitDispatched: { - type: 'boolean', - description: - 'Whether Enter dispatch acknowledged completion; this alone is not proof of submission.', - }, - submitRequested: { type: 'boolean', description: 'Whether submit=true was requested.' }, - submitUncertain: { - type: 'boolean', - description: - 'Whether Enter key-down may have landed but dispatch did not acknowledge completion.', - }, - submitted: { - type: 'boolean', - description: - 'Whether Enter dispatch completed and a strong submission effect was observed.', - }, - trusted: { type: 'boolean', description: 'Whether native Chromium input was used.' }, - valueLength: { - type: 'number', - description: 'Focused non-secret field length when safely inspectable.', - }, - valuePreview: { - type: 'string', - description: 'Bounded focused non-secret field preview when safely inspectable.', - }, - }, - required: ['dispatched'], - }, - clientExecutable: true, -} - -export const BrowserWaitFor: ToolCatalogEntry = { - id: 'browser_wait_for', - name: 'browser_wait_for', - route: 'client', - mode: 'async', - parameters: { - type: 'object', - properties: { - text: { type: 'string', description: 'Optional visible text to wait for.' }, - timeoutMs: { - type: 'number', - description: 'Maximum time to wait, in milliseconds (default 10000, capped at 120000).', - }, - }, - }, - resultSchema: { - type: 'object', - properties: { - elapsedMs: { type: 'number', description: 'Elapsed wait duration.' }, - found: { - type: 'boolean', - description: 'Whether the requested text appeared before timeout.', - }, - foundInFrame: { - type: 'boolean', - description: 'Whether the match was found in an eligible visible child frame.', - }, - note: { type: 'string', description: 'Timeout/recovery guidance.' }, - notices: { - type: 'array', - description: - 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', - items: { type: 'string' }, - }, - waitedMs: { - type: 'number', - description: 'Completed sleep duration when no text was requested.', - }, - }, - }, - clientExecutable: true, -} - export const CallIntegrationTool: ToolCatalogEntry = { id: 'call_integration_tool', name: 'call_integration_tool', @@ -1219,7 +278,6 @@ export const CallIntegrationTool: ToolCatalogEntry = { required: ['toolId', 'description', 'arguments'], type: 'object', }, - requiresApproval: true, } export const CheckDeploymentStatus: ToolCatalogEntry = { @@ -1238,6 +296,20 @@ export const CheckDeploymentStatus: ToolCatalogEntry = { }, } +export const CompleteScheduledTask: ToolCatalogEntry = { + id: 'complete_scheduled_task', + name: 'complete_scheduled_task', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + jobId: { type: 'string', description: 'The ID of the scheduled task to mark as completed.' }, + }, + required: ['jobId'], + }, +} + export const Cp: ToolCatalogEntry = { id: 'cp', name: 'cp', @@ -1412,6 +484,72 @@ export const CreateWorkspaceMcpServer: ToolCatalogEntry = { requiredPermission: 'admin', } +export const DeleteFile: ToolCatalogEntry = { + id: 'delete_file', + name: 'delete_file', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + paths: { + type: 'array', + description: + 'Canonical workspace file VFS paths to delete, e.g. ["files/Reports/draft.md"].', + items: { type: 'string' }, + }, + }, + required: ['paths'], + }, + resultSchema: { + type: 'object', + properties: { + message: { type: 'string', description: 'Human-readable outcome.' }, + success: { type: 'boolean', description: 'Whether the delete succeeded.' }, + }, + required: ['success', 'message'], + }, + requiredPermission: 'write', +} + +export const DeleteFileFolder: ToolCatalogEntry = { + id: 'delete_file_folder', + name: 'delete_file_folder', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + paths: { + type: 'array', + description: 'Canonical folder VFS paths to delete, e.g. ["files/Archive"].', + items: { type: 'string' }, + }, + }, + required: ['paths'], + }, + requiredPermission: 'write', +} + +export const DeleteWorkflow: ToolCatalogEntry = { + id: 'delete_workflow', + name: 'delete_workflow', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + workflowIds: { + type: 'array', + description: 'The workflow IDs to delete.', + items: { type: 'string' }, + }, + }, + required: ['workflowIds'], + }, + requiredPermission: 'write', +} + export const DeleteWorkspaceMcpServer: ToolCatalogEntry = { id: 'delete_workspace_mcp_server', name: 'delete_workspace_mcp_server', @@ -1425,7 +563,6 @@ export const DeleteWorkspaceMcpServer: ToolCatalogEntry = { required: ['serverId'], }, requiredPermission: 'admin', - requiresApproval: true, } export const Deploy: ToolCatalogEntry = { @@ -1526,7 +663,6 @@ export const DeployApi: ToolCatalogEntry = { ], }, requiredPermission: 'admin', - requiresApproval: true, } export const DeployChat: ToolCatalogEntry = { @@ -1672,7 +808,6 @@ export const DeployChat: ToolCatalogEntry = { ], }, requiredPermission: 'admin', - requiresApproval: true, } export const DeployCustomBlock: ToolCatalogEntry = { @@ -1735,10 +870,11 @@ export const DeployCustomBlock: ToolCatalogEntry = { name: { type: 'string', description: - 'Display name for the block, max 60 characters. REQUIRED the first time a workflow is published. When republishing an existing block, omit it to keep the current name or pass a new one to rename. Ignored for undeploy.', + 'Display name for the block, max 60 characters. When republishing an existing block, pass the current name to keep it or a new name to rename.', }, workflowId: { type: 'string', description: 'Workflow ID (defaults to active workflow)' }, }, + required: ['name'], }, resultSchema: { type: 'object', @@ -1795,12 +931,6 @@ export const DeployMcp: ToolCatalogEntry = { parameters: { type: 'object', properties: { - action: { - type: 'string', - description: - '"deploy" (default) adds/updates the workflow as an MCP tool on the server; "undeploy" removes the workflow\'s tool from the server.', - enum: ['deploy', 'undeploy'], - }, parameterDescriptions: { type: 'array', description: 'Array of parameter descriptions for the tool', @@ -1883,7 +1013,6 @@ export const DeployMcp: ToolCatalogEntry = { required: ['deploymentType', 'deploymentStatus'], }, requiredPermission: 'admin', - requiresApproval: true, } export const DiffWorkflows: ToolCatalogEntry = { @@ -2080,7 +1209,7 @@ export const EnrichmentRun: ToolCatalogEntry = { description: 'True when a provider returned a non-empty result.', }, provider: { - type: ['string', 'null'], + type: 'string', description: 'Internal label of the provider that produced the result (billing/diagnostics only — do NOT surface it to the user), or null on no match.', }, @@ -2291,7 +1420,7 @@ export const FunctionExecute: ToolCatalogEntry = { code: { type: 'string', description: - 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with pre-installed CLI tools. Use each needed secret as {{VAR_NAME}}; the reference resolves to the value exactly as stored.', + 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with access to pre-installed CLI tools and workspace env vars as $VAR_NAME.', }, inputs: { type: 'object', @@ -2407,15 +1536,10 @@ export const FunctionExecute: ToolCatalogEntry = { }, }, }, - sandboxId: { - type: 'string', - description: - 'Optional Sim sandbox id from agent/sandboxes/{name}.json. DEFAULT-FIRST: omit this whenever the documented default function_execute environment can do the job. Select a ready existing Sim sandbox only when a required third-party dependency, Debian system package, or managed CLI is known to be absent, or a default attempt failed specifically because it was missing. Never guess an id.', - }, timeout: { type: 'number', description: - 'Maximum execution time in SECONDS (Sim converts to milliseconds). The sandbox stops execution and returns a timeout error after this duration. Defaults to 10 seconds and is capped at 300 seconds regardless of plan.', + 'Maximum execution time in seconds. The sandbox stops execution and returns a timeout error after this duration. Defaults to 10 seconds; the platform execution limit still applies.', default: 10, }, title: { @@ -2427,7 +1551,6 @@ export const FunctionExecute: ToolCatalogEntry = { required: ['code'], }, requiredPermission: 'write', - requiresApproval: true, capabilities: ['file_input', 'directory_input', 'file_output', 'table_input', 'table_output'], } @@ -3012,6 +2135,26 @@ export const GetPlatformActions: ToolCatalogEntry = { parameters: { type: 'object', properties: {} }, } +export const GetScheduledTaskLogs: ToolCatalogEntry = { + id: 'get_scheduled_task_logs', + name: 'get_scheduled_task_logs', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + executionId: { type: 'string', description: 'Optional execution ID for a specific run.' }, + includeDetails: { + type: 'boolean', + description: 'Include tool calls, outputs, and cost details.', + }, + jobId: { type: 'string', description: 'The scheduled task (schedule) ID to get logs for.' }, + limit: { type: 'number', description: 'Max number of entries (default: 3, max: 5)' }, + }, + required: ['jobId'], + }, +} + export const GetWorkflowData: ToolCatalogEntry = { id: 'get_workflow_data', name: 'get_workflow_data', @@ -3066,7 +2209,7 @@ export const Glob: ToolCatalogEntry = { toolTitle: { type: 'string', description: - 'Required target-only UI phrase for the search row. The UI verb is supplied for you, so pass text like "workflow configs" or "knowledge bases", not a full sentence like "Finding workflow configs".', + 'Optional target-only UI phrase for the search row. The UI verb is supplied for you, so pass text like "workflow configs" or "knowledge bases", not a full sentence like "Finding workflow configs".', }, }, required: ['pattern', 'toolTitle'], @@ -3084,7 +2227,7 @@ export const Grep: ToolCatalogEntry = { context: { type: 'number', description: - "Number of lines to show before and after each match (default 0). Only applies to output_mode 'content'.", + "Number of lines to show before and after each match. Only applies to output_mode 'content'.", }, ignoreCase: { type: 'boolean', description: 'Case insensitive search (default false).' }, lineNumbers: { @@ -3110,12 +2253,12 @@ export const Grep: ToolCatalogEntry = { pattern: { type: 'string', description: - "Regex pattern to search for. Searches VFS map entries (workflow JSON, metadata, memories) by default; searches a single file's extracted text when path is one files/ or uploads/ file leaf.", + "Regex pattern to search for. Searches VFS map entries (workflow JSON, metadata, plans, memories) by default; searches a single file's extracted text when path is one files/ or uploads/ file leaf.", }, toolTitle: { type: 'string', description: - 'Required target-only UI phrase for the search row. The UI verb is supplied for you, so pass text like "Slack integrations" or "deployed workflows", not a full sentence like "Searching for Slack integrations".', + 'Optional target-only UI phrase for the search row. The UI verb is supplied for you, so pass text like "Slack integrations" or "deployed workflows", not a full sentence like "Searching for Slack integrations".', }, }, required: ['pattern', 'toolTitle'], @@ -3287,6 +2430,7 @@ export const KnowledgeBase: ToolCatalogEntry = { 'query', 'add_file', 'update', + 'delete', 'delete_document', 'update_document', 'list_tags', @@ -3306,11 +2450,7 @@ export const KnowledgeBase: ToolCatalogEntry = { resultSchema: { type: 'object', properties: { - data: { - type: ['object', 'array'], - description: - 'Operation-specific result payload. An object for most operations; list_tags and get_tag_usage return an array of tag definitions.', - }, + data: { type: 'object', description: 'Operation-specific result payload.' }, message: { type: 'string', description: 'Human-readable outcome summary.' }, success: { type: 'boolean', description: 'Whether the operation succeeded.' }, }, @@ -3321,8 +2461,8 @@ export const KnowledgeBase: ToolCatalogEntry = { export const ListIntegrationTools: ToolCatalogEntry = { id: 'list_integration_tools', name: 'list_integration_tools', - route: 'go', - mode: 'sync', + route: 'sim', + mode: 'async', parameters: { properties: { integration: { @@ -3387,8 +2527,8 @@ export const LoadDeployment: ToolCatalogEntry = { export const LoadIntegrationTool: ToolCatalogEntry = { id: 'load_integration_tool', name: 'load_integration_tool', - route: 'go', - mode: 'sync', + route: 'sim', + mode: 'async', parameters: { properties: { tool_ids: { @@ -3399,25 +2539,7 @@ export const LoadIntegrationTool: ToolCatalogEntry = { }, }, required: ['tool_ids'], - type: 'object', - }, -} - -export const LoadSkill: ToolCatalogEntry = { - id: 'load_skill', - name: 'load_skill', - route: 'go', - mode: 'sync', - parameters: { - type: 'object', - properties: { - name: { - type: 'string', - description: - "Skill name exactly as it appears in the Loadable Skills index (e.g. 'pptx-writing').", - }, - }, - required: ['name'], + type: 'object', }, } @@ -3463,7 +2585,7 @@ export const ManageCustomTool: ToolCatalogEntry = { operation: { type: 'string', description: - "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — other manage_* tools may use create/update instead of add/edit.", + "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — manage_scheduled_task uses create/update instead of add/edit.", enum: ['add', 'edit', 'delete', 'list'], }, schema: { @@ -3516,6 +2638,31 @@ export const ManageCustomTool: ToolCatalogEntry = { requiredPermission: 'write', } +export const ManageFolder: ToolCatalogEntry = { + id: 'manage_folder', + name: 'manage_folder', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + folderId: { + type: 'string', + description: + 'Target folder ID, used as a fallback when path is not given. Readable from a contained workflow\'s meta.json "folderId".', + }, + operation: { type: 'string', description: 'The operation to perform.', enum: ['delete'] }, + path: { + type: 'string', + description: + 'Target folder\'s VFS path (e.g. "workflows/Marketing/Q3 Campaigns"), per-segment percent-encoded like every VFS path.', + }, + }, + required: ['operation'], + }, + requiredPermission: 'write', +} + export const ManageMcpTool: ToolCatalogEntry = { id: 'manage_mcp_tool', name: 'manage_mcp_tool', @@ -3553,7 +2700,7 @@ export const ManageMcpTool: ToolCatalogEntry = { operation: { type: 'string', description: - "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — other manage_* tools may use create/update instead of add/edit.", + "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — manage_scheduled_task uses create/update instead of add/edit.", enum: ['add', 'edit', 'delete', 'list'], }, serverId: { @@ -3567,57 +2714,78 @@ export const ManageMcpTool: ToolCatalogEntry = { requiredPermission: 'write', } -export const ManageSandbox: ToolCatalogEntry = { - id: 'manage_sandbox', - name: 'manage_sandbox', +export const ManageScheduledTask: ToolCatalogEntry = { + id: 'manage_scheduled_task', + name: 'manage_scheduled_task', route: 'sim', mode: 'async', parameters: { type: 'object', properties: { - cliTools: { - type: 'array', - description: - 'Complete managed CLI id list (maximum 10). Use exact pinned ids returned by list. On edit, passing this replaces the whole list; pass [] to clear it.', - items: { type: 'string' }, - }, - dependencies: { - type: 'array', - description: - 'Complete npm or PyPI dependency list (maximum 50). On edit, passing this replaces the whole list; pass [] to clear it.', - items: { type: 'string' }, - }, - language: { - type: 'string', - description: - 'Dependency language. javascript installs from npm; python installs from PyPI. Required for add; optional for edit.', - enum: ['javascript', 'python'], - }, - name: { - type: 'string', + args: { + type: 'object', description: - 'Workspace-unique Sim sandbox name (1-64 characters). Required for add; optional for edit.', + 'Operation-specific arguments. For create: {title, prompt, cron?, time?, timezone?, lifecycle?, successCondition?, maxRuns?}. For get/delete: {jobId}. For update: {jobId, title?, prompt?, cron?, timezone?, status?, lifecycle?, successCondition?, maxRuns?}. For list: no args needed.', + properties: { + cron: { + type: 'string', + description: + "Cron expression for a recurring scheduled task (e.g. '0 9 * * *'). Set exactly one of cron or time: recurring -> cron; one-time -> time.", + }, + jobId: { type: 'string', description: 'Scheduled task ID (required for get, update)' }, + jobIds: { + type: 'array', + description: 'Array of scheduled task IDs (for batch delete)', + items: { type: 'string' }, + }, + lifecycle: { + type: 'string', + description: + "'persistent' (default) or 'until_complete'. Until_complete scheduled tasks stop when complete_scheduled_task is called.", + enum: ['persistent', 'until_complete'], + }, + maxRuns: { + type: 'integer', + description: 'Max executions before auto-completing. Safety limit.', + }, + prompt: { + type: 'string', + description: 'The prompt to execute when the scheduled task fires', + }, + status: { + type: 'string', + description: 'Scheduled task status: active, paused', + enum: ['active', 'paused'], + }, + successCondition: { + type: 'string', + description: + 'What must happen for the scheduled task to be considered complete (until_complete lifecycle).', + }, + time: { + type: 'string', + description: + "ISO 8601 datetime. One-time scheduled task -> set time and omit cron. May also anchor a recurring cron task's first-fire time.", + }, + timezone: { + type: 'string', + description: 'IANA timezone (e.g. America/New_York). Defaults to UTC.', + }, + title: { + type: 'string', + description: "Short descriptive title for the scheduled task (e.g. 'Email Poller')", + }, + }, }, operation: { type: 'string', - description: "The operation to perform: 'add', 'edit', 'list', or 'delete'.", - enum: ['add', 'edit', 'delete', 'list'], - }, - sandboxId: { - type: 'string', - description: - 'The Sim sandbox id. Get it from list or the inner id field in agent/sandboxes/{name}.json; never guess it. Required for edit and delete.', - }, - systemPackages: { - type: 'array', description: - 'Complete Debian package-coordinate list in package[:architecture][=version] form (maximum 50). On edit, passing this replaces the whole list; pass [] to clear it.', - items: { type: 'string' }, + 'The operation to perform: create, list, get, update, delete. These verbs are tool-specific — the custom-tool/MCP/skill managers use add/edit instead of create/update.', + enum: ['create', 'list', 'get', 'update', 'delete'], }, }, required: ['operation'], }, - requiredPermission: 'admin', } export const ManageSkill: ToolCatalogEntry = { @@ -3644,7 +2812,7 @@ export const ManageSkill: ToolCatalogEntry = { operation: { type: 'string', description: - "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — other manage_* tools may use create/update instead of add/edit.", + "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — manage_scheduled_task uses create/update instead of add/edit.", enum: ['add', 'edit', 'delete', 'list'], }, skillId: { @@ -3824,7 +2992,7 @@ export const OpenResource: ToolCatalogEntry = { type: { type: 'string', description: 'The resource type.', - enum: ['workflow', 'table', 'knowledgebase', 'file', 'log'], + enum: ['workflow', 'table', 'knowledgebase', 'file', 'log', 'scheduledtask'], }, }, required: ['type'], @@ -3856,7 +3024,6 @@ export const PromoteToLive: ToolCatalogEntry = { required: ['version'], }, requiredPermission: 'admin', - requiresApproval: true, } export const QueryLogs: ToolCatalogEntry = { @@ -3976,27 +3143,21 @@ export const QueryUserTable: ToolCatalogEntry = { type: 'object', description: 'Arguments for the operation', properties: { - cursor: { - type: 'string', - description: - 'Opaque pagination cursor for query_rows (optional). Omit for the first page; to fetch the next page, pass back the nextCursor from the previous result\'s "more available" message verbatim. Cannot be combined with a fresh order — the cursor already encodes the paging position.', - }, - filter: { - type: 'object', - description: - 'Predicate filter object for query_rows. A predicate is a tree: {"all":[...]} (AND) or {"any":[...]} (OR); members are leaves {field, op, value} or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (use * as the wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array value. Examples: {"all":[{"field":"status","op":"eq","value":"active"}]}; {"any":[{"field":"status","op":"eq","value":"active"},{"field":"status","op":"eq","value":"pending"}]}; {"all":[{"field":"name","op":"ilike","value":"*jo*"}]}.', - }, + filter: { type: 'object', description: 'MongoDB-style filter for query_rows' }, limit: { type: 'number', - description: - 'Maximum rows per page for query_rows (optional). Omit to fetch the ENTIRE matching result in one response — the call fails if the result exceeds the 5MB budget (narrow with a filter or set a limit). With a limit, a page may end early at the byte budget with more remaining; a non-null nextCursor in the result means more rows exist (continue with cursor).', + description: 'Maximum rows to return (optional, default 100, max 1000 per call)', }, - order: { - type: 'array', - description: - 'Sort spec for query_rows (optional). Ordered list of {field, direction} where direction is asc or desc, e.g. [{"field":"wins","direction":"desc"},{"field":"name","direction":"asc"}].', + offset: { + type: 'number', + description: 'Number of rows to skip (optional for query_rows, default 0)', }, rowId: { type: 'string', description: 'Row ID (required for get_row)' }, + sort: { + type: 'object', + description: + "Sort specification as { field: 'asc' | 'desc' } (optional for query_rows)", + }, tableId: { type: 'string', description: 'Table ID (required for all operations)' }, }, }, @@ -4117,7 +3278,6 @@ export const Redeploy: ToolCatalogEntry = { ], }, requiredPermission: 'admin', - requiresApproval: true, } export const Respond: ToolCatalogEntry = { @@ -4169,31 +3329,6 @@ export const RestoreResource: ToolCatalogEntry = { requiredPermission: 'admin', } -export const Rm: ToolCatalogEntry = { - id: 'rm', - name: 'rm', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - paths: { - type: 'array', - description: - 'Canonical VFS paths to delete, e.g. ["files/Reports/draft.md"]. Copy paths verbatim from glob/grep/read output. Paths from different categories may be mixed in one call.', - items: { type: 'string' }, - }, - toolTitle: { - type: 'string', - description: - 'Target-only UI phrase for the action row, e.g. "draft.md" or "3 files", not a full sentence like "Deleting draft.md".', - }, - }, - required: ['paths', 'toolTitle'], - }, - requiredPermission: 'write', -} - export const Run: ToolCatalogEntry = { id: 'run', name: 'run', @@ -4259,7 +3394,7 @@ export const RunCode: ToolCatalogEntry = { code: { type: 'string', description: - 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with pre-installed CLI tools. Use each needed secret as {{VAR_NAME}}; the reference resolves to the value exactly as stored.', + 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with access to pre-installed CLI tools and workspace env vars as $VAR_NAME.', }, inputs: { type: 'object', @@ -4338,7 +3473,6 @@ export const RunCode: ToolCatalogEntry = { required: ['code'], }, requiredPermission: 'write', - requiresApproval: true, capabilities: ['file_input', 'directory_input', 'table_input'], } @@ -4384,11 +3518,6 @@ export const RunWorkflow: ToolCatalogEntry = { parameters: { type: 'object', properties: { - async: { - type: 'boolean', - description: - 'Queue the deployed workflow and return its execution ID immediately. Default: false. Set true only when explicitly asked for a background run, or when the three most recent completed runs each exceeded 30 minutes. Fails if the current workflow differs from its deployed version. Missing history, complexity, or one slow run never justify async; check completion later with query_logs.', - }, inputFromExecutionId: { type: 'string', description: @@ -4422,7 +3551,6 @@ export const RunWorkflow: ToolCatalogEntry = { }, }, clientExecutable: true, - requiresApproval: true, } export const RunWorkflowUntilBlock: ToolCatalogEntry = { @@ -4471,7 +3599,22 @@ export const RunWorkflowUntilBlock: ToolCatalogEntry = { required: ['stopAfterBlockId'], }, clientExecutable: true, - requiresApproval: true, +} + +export const ScheduledTask: ToolCatalogEntry = { + id: 'scheduled_task', + name: 'scheduled_task', + route: 'subagent', + mode: 'async', + parameters: { + properties: { + request: { description: 'What scheduled task action is needed.', type: 'string' }, + }, + required: ['request'], + type: 'object', + }, + subagentId: 'scheduled_task', + internal: true, } export const ScrapePage: ToolCatalogEntry = { @@ -4516,21 +3659,21 @@ export const Search: ToolCatalogEntry = { internal: true, } -export const SearchDocumentation: ToolCatalogEntry = { - id: 'search_documentation', - name: 'search_documentation', +export const SearchDocs: ToolCatalogEntry = { + id: 'search_docs', + name: 'search_docs', route: 'sim', mode: 'async', parameters: { type: 'object', properties: { - query: { type: 'string', description: 'The search query' }, - topK: { - type: 'number', + path: { + type: 'string', description: - 'Number of results to return (default 10). Not clamped — keep it small, since each result is a full doc chunk.', - default: 10, + 'Optional docs/ VFS path (a page such as docs/workflows/blocks/agent.mdx, or a section such as docs/workflows) that limits the search scope', }, + query: { type: 'string', description: 'The search query' }, + topK: { type: 'number', description: 'Number of results (default 10, max 25)' }, }, required: ['query'], }, @@ -4599,11 +3742,7 @@ export const SearchKnowledgeBase: ToolCatalogEntry = { resultSchema: { type: 'object', properties: { - data: { - type: ['object', 'array'], - description: - 'Operation-specific result payload. An object for search results; list_tags returns an array of tag definitions.', - }, + data: { type: 'object', description: 'Operation-specific result payload.' }, message: { type: 'string', description: 'Human-readable outcome summary.' }, success: { type: 'boolean', description: 'Whether the operation succeeded.' }, }, @@ -4627,11 +3766,7 @@ export const SearchLibraryDocs: ToolCatalogEntry = { type: 'string', description: 'The question or topic to find documentation for - be specific', }, - version: { - type: 'string', - description: - "Specific version, numeric only and WITHOUT a leading 'v' (e.g. '14', '2', '2.1') — the 'v' is added for you, so 'v2' resolves to nothing.", - }, + version: { type: 'string', description: "Specific version (optional, e.g., '14', 'v2')" }, }, required: ['library_name', 'query'], }, @@ -4682,7 +3817,7 @@ export const SearchPatterns: ToolCatalogEntry = { properties: { limit: { type: 'integer', - description: 'Maximum number of pattern examples to return per query (defaults to 3).', + description: 'Maximum number of unique pattern examples to return (defaults to 3).', }, queries: { type: 'array', @@ -4776,14 +3911,12 @@ export const SetGlobalWorkflowVariables: ToolCatalogEntry = { operation: { type: 'string', enum: ['add', 'delete', 'edit'] }, type: { type: 'string', - description: - 'Variable type for add/edit. Defaults to the variable\'s existing type, or "plain" for a new one. Ignored for delete.', + description: 'Variable type. Required for add/edit; ignored for delete.', enum: ['plain', 'number', 'boolean', 'array', 'object'], }, value: { type: 'string', - description: - 'Variable value for add/edit, coerced to the declared type. Omitting it leaves the variable with no value. Ignored for delete.', + description: 'Variable value. Required for add/edit; ignored for delete.', }, }, required: ['operation', 'name'], @@ -4867,126 +4000,6 @@ export const Table: ToolCatalogEntry = { internal: true, } -export const Terminal: ToolCatalogEntry = { - id: 'terminal', - name: 'terminal', - route: 'client', - mode: 'async', - parameters: { - type: 'object', - properties: { - args: { - type: 'object', - description: 'Inputs for the operation. Pass only the fields that operation uses.', - properties: { - command: { - type: 'string', - description: - 'For run: the command line, exactly as it would be typed at the prompt. Shell syntax (pipes, &&, quoting, redirection) works because a real shell interprets it.', - }, - cwd: { - type: 'string', - description: - "For new: absolute path to open in. Defaults to the active terminal's directory.", - }, - key: { - type: 'string', - description: - 'For input: a single key to press instead of text. Use "enter" to submit something already typed.', - enum: [ - 'ctrl-c', - 'ctrl-d', - 'ctrl-z', - 'enter', - 'up', - 'down', - 'left', - 'right', - 'escape', - 'tab', - ], - }, - keys: { - type: 'array', - description: - 'For input: several keys pressed in order, e.g. ["down","down","enter"] to walk down a menu and choose. Each is a real keypress with a pause between, so the program redraws as it would under a person\'s hands. Only batch when you already know where the highlight is — read the screen first, and press one key at a time when you do not. Max 20.', - items: { - type: 'string', - enum: [ - 'ctrl-c', - 'ctrl-d', - 'ctrl-z', - 'enter', - 'up', - 'down', - 'left', - 'right', - 'escape', - 'tab', - ], - }, - }, - lines: { - type: 'number', - description: 'For read: how many trailing lines to return. Defaults to 200.', - }, - pane: { - type: 'string', - description: - "Which tmux pane to act on, as a target from the panes operation (session:window.pane). Defaults to that session's active pane. Ignored when the terminal is a plain shell.", - }, - reason: { - type: 'string', - description: - 'For handoff: what the user needs to do, shown on the button they click (e.g. "Enter your sudo password"). Say what is being asked, not that you are waiting.', - }, - signal: { - type: 'string', - description: - 'For kill: which signal. Defaults to SIGINT, the equivalent of the user pressing Ctrl-C.', - enum: ['SIGINT', 'SIGTERM', 'SIGKILL'], - }, - terminalId: { - type: 'string', - description: - 'Which terminal to act on, from the list operation. Defaults to the active one, which is what the user is looking at. Required by switch and close.', - }, - text: { - type: 'string', - description: - 'For input: literal text to type. A trailing newline submits it. Check the returned screen to confirm it submitted rather than sitting unsent in an input box.', - }, - waitSeconds: { - type: 'number', - description: - 'For run: how long to wait before handing back a still-running command. Defaults to 30, capped at 120. Raising it does not make a command finish sooner, it only delays your first look at it.', - }, - }, - }, - operation: { - type: 'string', - description: 'What to do.', - enum: [ - 'run', - 'read', - 'input', - 'kill', - 'cwd', - 'list', - 'new', - 'switch', - 'close', - 'panes', - 'handoff', - ], - }, - }, - required: ['operation'], - }, - clientExecutable: true, - requiresApproval: true, -} - export const UpdateDeploymentVersion: ToolCatalogEntry = { id: 'update_deployment_version', name: 'update_deployment_version', @@ -5018,6 +4031,25 @@ export const UpdateDeploymentVersion: ToolCatalogEntry = { requiredPermission: 'write', } +export const UpdateScheduledTaskHistory: ToolCatalogEntry = { + id: 'update_scheduled_task_history', + name: 'update_scheduled_task_history', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + jobId: { type: 'string', description: 'The scheduled task ID.' }, + summary: { + type: 'string', + description: + "A concise summary of what was done this run (e.g., 'Sent follow-up emails to 3 leads: Alice, Bob, Carol').", + }, + }, + required: ['jobId', 'summary'], + }, +} + export const UpdateWorkspaceMcpServer: ToolCatalogEntry = { id: 'update_workspace_mcp_server', name: 'update_workspace_mcp_server', @@ -5059,8 +4091,7 @@ export const UserTable: ToolCatalogEntry = { }, column: { type: 'object', - description: - 'Column definition for add_column: { name, type, unique?, position? }. For a select (enum) column also pass { options: ["Open", "Closed"], multiple?: true } — options is a list of display names and is required for select.', + description: 'Column definition for add_column: { name, type, unique?, position? }', }, columnName: { type: 'string', @@ -5072,11 +4103,6 @@ export const UserTable: ToolCatalogEntry = { description: 'Array of column names to delete at once (for delete_column). Preferred over columnName when deleting multiple columns.', }, - cursor: { - type: 'string', - description: - 'Opaque pagination cursor for query_rows (optional). Omit for the first page; to fetch the next page, pass back the nextCursor from the previous result\'s "more available" message verbatim. Cannot be combined with a fresh order — the cursor already encodes the paging position.', - }, data: { type: 'object', description: 'Row data as key-value pairs (required for insert_row, update_row)', @@ -5094,12 +4120,6 @@ export const UserTable: ToolCatalogEntry = { }, }, }, - deploymentMode: { - type: 'string', - description: - "Which version of the backing workflow this group's per-row runs execute, for add_workflow_group and update_workflow_group. 'live' (default) runs the editable draft, so later edits take effect immediately. 'deployed' runs the workflow's latest active deployment, pinning rows to a published version — if that workflow has never been deployed the cell fails rather than falling back to the draft. Only meaningful for workflow groups; enrichment groups have no backing workflow.", - enum: ['live', 'deployed'], - }, description: { type: 'string', description: "Table description (optional for 'create')" }, enrichmentId: { type: 'string', @@ -5114,7 +4134,7 @@ export const UserTable: ToolCatalogEntry = { filter: { type: 'object', description: - 'Predicate filter object for query_rows, update_rows_by_filter, delete_rows_by_filter. A predicate is a tree: {"all":[...]} (AND) or {"any":[...]} (OR); members are leaves {field, op, value} or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (use * as the wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array value. Examples: {"all":[{"field":"status","op":"eq","value":"active"}]}; {"all":[{"field":"wins","op":"gte","value":18},{"field":"status","op":"eq","value":"pending"}]}; {"any":[{"field":"status","op":"eq","value":"active"},{"field":"status","op":"eq","value":"pending"}]}; {"all":[{"field":"name","op":"ilike","value":"*jo*"}]}; {"all":[{"field":"slack_user_id","op":"in","value":["U1","U2"]}]}.', + 'MongoDB-style filter for query_rows, update_rows_by_filter, delete_rows_by_filter', }, groupId: { type: 'string', @@ -5149,7 +4169,7 @@ export const UserTable: ToolCatalogEntry = { limit: { type: 'number', description: - 'Maximum rows per page for query_rows (optional). Omit to fetch the ENTIRE matching result in one response — the call fails if the result exceeds the 5MB budget (narrow with a filter or set a limit). With a limit, a page may end early at the byte budget with more remaining; a non-null nextCursor in the result means more rows exist (continue with cursor). On update_rows_by_filter / delete_rows_by_filter, caps affected rows; omit to act on every match.', + 'Maximum rows to return or affect (optional, default 100). Omit on update_rows_by_filter / delete_rows_by_filter to act on every match.', }, mapping: { type: 'object', @@ -5185,11 +4205,6 @@ export const UserTable: ToolCatalogEntry = { "Import mode for import_file. 'append' (default) adds rows; 'replace' truncates existing rows in a transaction before inserting the new rows.", enum: ['append', 'replace'], }, - multiple: { - type: 'boolean', - description: - 'Whether a select (enum) cell may hold several options (default false). Switching an existing column from true to false fails if any row has more than one option selected.', - }, name: { type: 'string', description: @@ -5203,18 +4218,11 @@ export const UserTable: ToolCatalogEntry = { newType: { type: 'string', description: - 'New column type (optional for update_column). Types: string, number, boolean, date, json, select. Converting a column to select also requires options; the conversion fails if any existing cell value doesn\'t match one of them. Converting to a multiple: true select also accepts a comma-separated cell ("Open, Urgent"), which is the form a multi column converts to text as — so multiselect → text → multiselect round-trips.', - }, - options: { - type: 'array', - description: - 'Choices for a select (enum) column, as a list of display names, e.g. ["Open", "Closed"]. Required when creating or converting to a select column. On update_column this REPLACES the option list and is matched against the current one BY NAME: a name still present keeps its cells, a name no longer present is removed and cleared from every cell that held it. Send the full list including the options you are keeping — omitting one deletes it. There is no in-place rename, so re-sending an option under a new name clears the cells that held the old one. Max 100.', - items: { type: 'string' }, + 'New column type (optional for update_column). Types: string, number, boolean, date, json', }, - order: { - type: 'array', - description: - 'Sort spec for query_rows (optional). Ordered list of {field, direction} where direction is asc or desc, e.g. [{"field":"wins","direction":"desc"},{"field":"name","direction":"asc"}].', + offset: { + type: 'number', + description: 'Number of rows to skip (optional for query_rows, default 0)', }, outputColumnNames: { type: 'object', @@ -5228,13 +4236,13 @@ export const UserTable: ToolCatalogEntry = { outputFormat: { type: 'string', description: - 'Explicit format override for outputPath. Only "csv" changes the file\'s CONTENT (rows serialized as a CSV table); "json", "txt", "md" and "html" all write the same pretty-printed JSON and change only the stored MIME type. Usually unnecessary — the extension already selects the format.', + 'Explicit format override for outputPath. Usually unnecessary — the file extension determines the format automatically. Only use this to force a different format than what the extension implies.', enum: ['json', 'csv', 'txt', 'md', 'html'], }, outputPath: { type: 'string', description: - 'Write this call\'s result to a NEW workspace file instead of returning it. Applies to EVERY user_table operation, not just query_rows: on success the tool result is REPLACED by a file receipt (fileId, vfsPath, size), so the operation\'s own payload is no longer visible to you — set it only when the file IS the goal. Only ".csv" changes serialization (query_rows rows become a CSV table); ".json", ".txt", ".md" and ".html" all write pretty-printed JSON of the full { success, message, data } envelope and differ only in stored MIME type. Nested paths like "files/Reports/export.csv" work — missing parent folders are created automatically, and an existing path fails.', + 'Pipe query_rows results directly to a NEW workspace file. The format is auto-inferred from the file extension: .csv → CSV, .json → JSON, .md → Markdown, etc. Use a root output path like "files/export.csv" — nested output paths are not supported.', }, outputs: { type: 'array', @@ -5268,6 +4276,12 @@ export const UserTable: ToolCatalogEntry = { description: 'Zero-based index at which to insert the row (optional, insert_row only). Rows at and below that index shift down. Omit to append at the end.', }, + positions: { + type: 'array', + description: + 'Per-row insertion indices for batch_insert_rows (optional). Must be the same length as rows and contain no duplicates. Values are final positions in the resulting table — lower-index shifts are applied automatically. Omit to append all rows at the end.', + items: { type: 'integer' }, + }, rowId: { type: 'string', description: @@ -5292,7 +4306,7 @@ export const UserTable: ToolCatalogEntry = { schema: { type: 'object', description: - 'Table schema with columns array (required for \'create\'). Each column: { name, type, unique? }. A select (enum) column also takes { options: ["Open", "Closed"], multiple?: true } — options is a list of display names and is required for select.', + "Table schema with columns array (required for 'create'). Each column: { name, type, unique? }", }, scope: { type: 'string', @@ -5300,6 +4314,11 @@ export const UserTable: ToolCatalogEntry = { "Cancellation scope for cancel_table_runs. 'all' cancels in-flight runs across the whole table; 'row' cancels only the row identified by rowId.", enum: ['all', 'row'], }, + sort: { + type: 'object', + description: + "Sort specification as { field: 'asc' | 'desc' } (optional for query_rows)", + }, tableId: { type: 'string', description: @@ -5340,6 +4359,7 @@ export const UserTable: ToolCatalogEntry = { 'import_file', 'get', 'get_schema', + 'delete', 'rename', 'insert_row', 'batch_insert_rows', @@ -5381,25 +4401,6 @@ export const UserTable: ToolCatalogEntry = { }, } -export const Wait: ToolCatalogEntry = { - id: 'wait', - name: 'wait', - route: 'go', - mode: 'sync', - parameters: { - type: 'object', - properties: { - reason: { - type: 'string', - description: - 'What you are waiting for, in a few words (e.g. "the test suite to finish"). Shown to the user so the pause is not unexplained.', - }, - seconds: { type: 'number', description: 'How long to pause, in seconds. Capped at 120.' }, - }, - required: ['seconds'], - }, -} - export const Workflow: ToolCatalogEntry = { id: 'workflow', name: 'workflow', @@ -5593,6 +4594,7 @@ export const KnowledgeBaseOperation = { query: 'query', addFile: 'add_file', update: 'update', + delete: 'delete', deleteDocument: 'delete_document', updateDocument: 'update_document', listTags: 'list_tags', @@ -5615,6 +4617,7 @@ export const KnowledgeBaseOperationValues = [ KnowledgeBaseOperation.query, KnowledgeBaseOperation.addFile, KnowledgeBaseOperation.update, + KnowledgeBaseOperation.delete, KnowledgeBaseOperation.deleteDocument, KnowledgeBaseOperation.updateDocument, KnowledgeBaseOperation.listTags, @@ -5658,6 +4661,15 @@ export const ManageCustomToolOperationValues = [ ManageCustomToolOperation.list, ] as const +export const ManageFolderOperation = { + delete: 'delete', +} as const + +export type ManageFolderOperation = + (typeof ManageFolderOperation)[keyof typeof ManageFolderOperation] + +export const ManageFolderOperationValues = [ManageFolderOperation.delete] as const + export const ManageMcpToolOperation = { add: 'add', edit: 'edit', @@ -5675,21 +4687,23 @@ export const ManageMcpToolOperationValues = [ ManageMcpToolOperation.list, ] as const -export const ManageSandboxOperation = { - add: 'add', - edit: 'edit', - delete: 'delete', +export const ManageScheduledTaskOperation = { + create: 'create', list: 'list', + get: 'get', + update: 'update', + delete: 'delete', } as const -export type ManageSandboxOperation = - (typeof ManageSandboxOperation)[keyof typeof ManageSandboxOperation] +export type ManageScheduledTaskOperation = + (typeof ManageScheduledTaskOperation)[keyof typeof ManageScheduledTaskOperation] -export const ManageSandboxOperationValues = [ - ManageSandboxOperation.add, - ManageSandboxOperation.edit, - ManageSandboxOperation.delete, - ManageSandboxOperation.list, +export const ManageScheduledTaskOperationValues = [ + ManageScheduledTaskOperation.create, + ManageScheduledTaskOperation.list, + ManageScheduledTaskOperation.get, + ManageScheduledTaskOperation.update, + ManageScheduledTaskOperation.delete, ] as const export const ManageSkillOperation = { @@ -5755,42 +4769,13 @@ export const SearchKnowledgeBaseOperationValues = [ SearchKnowledgeBaseOperation.listTags, ] as const -export const TerminalOperation = { - run: 'run', - read: 'read', - input: 'input', - kill: 'kill', - cwd: 'cwd', - list: 'list', - new: 'new', - switch: 'switch', - close: 'close', - panes: 'panes', - handoff: 'handoff', -} as const - -export type TerminalOperation = (typeof TerminalOperation)[keyof typeof TerminalOperation] - -export const TerminalOperationValues = [ - TerminalOperation.run, - TerminalOperation.read, - TerminalOperation.input, - TerminalOperation.kill, - TerminalOperation.cwd, - TerminalOperation.list, - TerminalOperation.new, - TerminalOperation.switch, - TerminalOperation.close, - TerminalOperation.panes, - TerminalOperation.handoff, -] as const - export const UserTableOperation = { create: 'create', createFromFile: 'create_from_file', importFile: 'import_file', get: 'get', getSchema: 'get_schema', + delete: 'delete', rename: 'rename', insertRow: 'insert_row', batchInsertRows: 'batch_insert_rows', @@ -5826,6 +4811,7 @@ export const UserTableOperationValues = [ UserTableOperation.importFile, UserTableOperation.get, UserTableOperation.getSchema, + UserTableOperation.delete, UserTableOperation.rename, UserTableOperation.insertRow, UserTableOperation.batchInsertRows, @@ -5871,35 +4857,17 @@ export const WorkspaceFileOperationValues = [ export const TOOL_CATALOG: Record = { [Agent.id]: Agent, [Auth.id]: Auth, - [Browser.id]: Browser, - [BrowserClick.id]: BrowserClick, - [BrowserCloseTab.id]: BrowserCloseTab, - [BrowserExtract.id]: BrowserExtract, - [BrowserGoBack.id]: BrowserGoBack, - [BrowserGoForward.id]: BrowserGoForward, - [BrowserHover.id]: BrowserHover, - [BrowserListSessions.id]: BrowserListSessions, - [BrowserListTabs.id]: BrowserListTabs, - [BrowserNavigate.id]: BrowserNavigate, - [BrowserOpenTab.id]: BrowserOpenTab, - [BrowserOpenUrl.id]: BrowserOpenUrl, - [BrowserPressKey.id]: BrowserPressKey, - [BrowserReadText.id]: BrowserReadText, - [BrowserRequestTakeover.id]: BrowserRequestTakeover, - [BrowserScreenshot.id]: BrowserScreenshot, - [BrowserScroll.id]: BrowserScroll, - [BrowserSelectOption.id]: BrowserSelectOption, - [BrowserSnapshot.id]: BrowserSnapshot, - [BrowserSwitchTab.id]: BrowserSwitchTab, - [BrowserType.id]: BrowserType, - [BrowserWaitFor.id]: BrowserWaitFor, [CallIntegrationTool.id]: CallIntegrationTool, [CheckDeploymentStatus.id]: CheckDeploymentStatus, + [CompleteScheduledTask.id]: CompleteScheduledTask, [Cp.id]: Cp, [CrawlWebsite.id]: CrawlWebsite, [CreateFile.id]: CreateFile, [CreateWorkflow.id]: CreateWorkflow, [CreateWorkspaceMcpServer.id]: CreateWorkspaceMcpServer, + [DeleteFile.id]: DeleteFile, + [DeleteFileFolder.id]: DeleteFileFolder, + [DeleteWorkflow.id]: DeleteWorkflow, [DeleteWorkspaceMcpServer.id]: DeleteWorkspaceMcpServer, [Deploy.id]: Deploy, [DeployApi.id]: DeployApi, @@ -5924,6 +4892,7 @@ export const TOOL_CATALOG: Record = { [GetDeploymentLog.id]: GetDeploymentLog, [GetPageContents.id]: GetPageContents, [GetPlatformActions.id]: GetPlatformActions, + [GetScheduledTaskLogs.id]: GetScheduledTaskLogs, [GetWorkflowData.id]: GetWorkflowData, [GetWorkflowRunOptions.id]: GetWorkflowRunOptions, [Glob.id]: Glob, @@ -5935,11 +4904,11 @@ export const TOOL_CATALOG: Record = { [ListWorkspaceMcpServers.id]: ListWorkspaceMcpServers, [LoadDeployment.id]: LoadDeployment, [LoadIntegrationTool.id]: LoadIntegrationTool, - [LoadSkill.id]: LoadSkill, [ManageCredential.id]: ManageCredential, [ManageCustomTool.id]: ManageCustomTool, + [ManageFolder.id]: ManageFolder, [ManageMcpTool.id]: ManageMcpTool, - [ManageSandbox.id]: ManageSandbox, + [ManageScheduledTask.id]: ManageScheduledTask, [ManageSkill.id]: ManageSkill, [MaterializeFile.id]: MaterializeFile, [Media.id]: Media, @@ -5955,16 +4924,16 @@ export const TOOL_CATALOG: Record = { [Redeploy.id]: Redeploy, [Respond.id]: Respond, [RestoreResource.id]: RestoreResource, - [Rm.id]: Rm, [Run.id]: Run, [RunBlock.id]: RunBlock, [RunCode.id]: RunCode, [RunFromBlock.id]: RunFromBlock, [RunWorkflow.id]: RunWorkflow, [RunWorkflowUntilBlock.id]: RunWorkflowUntilBlock, + [ScheduledTask.id]: ScheduledTask, [ScrapePage.id]: ScrapePage, [Search.id]: Search, - [SearchDocumentation.id]: SearchDocumentation, + [SearchDocs.id]: SearchDocs, [SearchIntegrationTools.id]: SearchIntegrationTools, [SearchKnowledgeBase.id]: SearchKnowledgeBase, [SearchLibraryDocs.id]: SearchLibraryDocs, @@ -5975,11 +4944,10 @@ export const TOOL_CATALOG: Record = { [SetGlobalWorkflowVariables.id]: SetGlobalWorkflowVariables, [ShareFile.id]: ShareFile, [Table.id]: Table, - [Terminal.id]: Terminal, [UpdateDeploymentVersion.id]: UpdateDeploymentVersion, + [UpdateScheduledTaskHistory.id]: UpdateScheduledTaskHistory, [UpdateWorkspaceMcpServer.id]: UpdateWorkspaceMcpServer, [UserTable.id]: UserTable, - [Wait.id]: Wait, [Workflow.id]: Workflow, [WorkspaceFile.id]: WorkspaceFile, } diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index e7745482158..387e1954367 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -36,1036 +36,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - browser: { - parameters: { - properties: { - task: { - description: - 'The web task to complete, in plain language (include the target site/URL if known).', - type: 'string', - }, - }, - required: ['task'], - type: 'object', - }, - resultSchema: undefined, - }, - browser_click: { - parameters: { - type: 'object', - properties: { - elementId: { - type: 'number', - description: - "The element id to act on (from the current tab's most recent browser_snapshot). Treat refs as invalid across tab switches or later snapshots.", - }, - }, - required: ['elementId'], - }, - resultSchema: { - type: 'object', - properties: { - activation: { - type: 'string', - description: 'native-pointer, native-keyboard, or synthetic-pointer.', - }, - activeTab: { - type: 'object', - description: 'New active tab after a tab-changing click.', - properties: { - tabId: { - type: 'string', - description: 'Stable browser tab id.', - }, - url: { - type: 'string', - description: 'New active tab URL.', - }, - }, - }, - dialogs: { - type: 'array', - description: 'Visible DOM dialogs remaining after the click.', - items: { - type: 'string', - }, - }, - dispatched: { - type: 'boolean', - description: 'Whether input dispatch completed.', - }, - effect: { - type: 'object', - description: - 'Detailed postcondition signals; generic title/DOM/scroll churn is weak evidence unless the tool documents otherwise.', - properties: { - dialogChanged: { - type: 'boolean', - description: 'The visible DOM dialog set changed.', - }, - domChanged: { - type: 'boolean', - description: 'The DOM mutation revision changed; weak evidence on its own.', - }, - fieldChanged: { - type: 'boolean', - description: 'The safely inspectable focused-field state changed.', - }, - focusChanged: { - type: 'boolean', - description: 'The focused element changed.', - }, - popupChanged: { - type: 'boolean', - description: 'The visible popup/menu set changed.', - }, - scrollChanged: { - type: 'boolean', - description: 'A tracked scroll offset changed; weak evidence except for scroll keys.', - }, - tabChanged: { - type: 'boolean', - description: 'The active browser tab changed.', - }, - targetChanged: { - type: 'boolean', - description: "The requested target's checked/selected/expanded/open state changed.", - }, - titleChanged: { - type: 'boolean', - description: 'The document title changed; weak evidence on its own.', - }, - urlChanged: { - type: 'boolean', - description: 'The observed URL changed.', - }, - }, - }, - effectObserved: { - type: 'boolean', - description: - 'Whether a URL/tab/dialog/popup/target-state change, or editable-target focus change, was observed.', - }, - element: { - type: 'string', - description: 'Resolved target element kind.', - }, - note: { - type: 'string', - description: 'Postcondition or recovery guidance.', - }, - notices: { - type: 'array', - description: - 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', - items: { - type: 'string', - }, - }, - obstructedAfterNavigation: { - type: 'boolean', - description: 'Navigation/tab change occurred while a visible DOM dialog remained.', - }, - possibleEffectObserved: { - type: 'boolean', - description: 'Includes weak title/DOM/scroll churn; never treat this alone as success.', - }, - refRecovered: { - type: 'boolean', - description: - 'Whether a stale detached ref was safely rebound to one unique semantic match.', - }, - trusted: { - type: 'boolean', - description: 'Whether Chromium trusted pointer/keyboard input was used.', - }, - }, - required: ['dispatched'], - }, - }, - browser_close_tab: { - parameters: { - type: 'object', - properties: { - tabId: { - type: 'string', - description: 'The id of the tab to close (from browser_list_tabs).', - }, - }, - required: ['tabId'], - }, - resultSchema: undefined, - }, - browser_extract: { - parameters: { - type: 'object', - properties: { - instruction: { - type: 'string', - description: - 'What you intend to extract, in plain language. Echoed back unchanged; it does not filter or shape the returned text.', - }, - }, - required: ['instruction'], - }, - resultSchema: { - type: 'object', - properties: { - instruction: { - type: 'string', - description: 'The extraction instruction echoed unchanged.', - }, - notices: { - type: 'array', - description: - 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', - items: { - type: 'string', - }, - }, - page: { - type: 'object', - description: 'Bounded visible page/frame text result.', - properties: { - framesRead: { - type: 'number', - description: 'Visible child frames whose text was appended.', - }, - hiddenFrames: { - type: 'number', - description: - 'Eligible child frames skipped because their embedding surface was not visible.', - }, - text: { - type: 'string', - description: - 'Visible text, capped across the top page and eligible visible child frames.', - }, - title: { - type: 'string', - description: 'Top-page title when available.', - }, - truncated: { - type: 'boolean', - description: 'Whether a page, frame, or combined character cap omitted text.', - }, - unreadableFrames: { - type: 'number', - description: 'Eligible child frames whose text could not be read.', - }, - url: { - type: 'string', - description: 'Top-page URL.', - }, - }, - }, - }, - }, - }, - browser_go_back: { - parameters: { - type: 'object', - properties: {}, - }, - resultSchema: undefined, - }, - browser_go_forward: { - parameters: { - type: 'object', - properties: {}, - }, - resultSchema: undefined, - }, - browser_hover: { - parameters: { - type: 'object', - properties: { - elementId: { - type: 'number', - description: - "The element id to act on (from the current tab's most recent browser_snapshot). Treat refs as invalid across tab switches or later snapshots.", - }, - }, - required: ['elementId'], - }, - resultSchema: { - type: 'object', - properties: { - effect: { - type: 'object', - description: - 'Detailed postcondition signals; generic title/DOM/scroll churn is weak evidence unless the tool documents otherwise.', - properties: { - dialogChanged: { - type: 'boolean', - description: 'The visible DOM dialog set changed.', - }, - domChanged: { - type: 'boolean', - description: 'The DOM mutation revision changed; weak evidence on its own.', - }, - fieldChanged: { - type: 'boolean', - description: 'The safely inspectable focused-field state changed.', - }, - focusChanged: { - type: 'boolean', - description: 'The focused element changed.', - }, - popupChanged: { - type: 'boolean', - description: 'The visible popup/menu set changed.', - }, - scrollChanged: { - type: 'boolean', - description: 'A tracked scroll offset changed; weak evidence except for scroll keys.', - }, - tabChanged: { - type: 'boolean', - description: 'The active browser tab changed.', - }, - targetChanged: { - type: 'boolean', - description: "The requested target's checked/selected/expanded/open state changed.", - }, - titleChanged: { - type: 'boolean', - description: 'The document title changed; weak evidence on its own.', - }, - urlChanged: { - type: 'boolean', - description: 'The observed URL changed.', - }, - }, - }, - effectObserved: { - type: 'boolean', - description: 'Whether a URL/dialog/popup/target-state change was observed.', - }, - element: { - type: 'string', - description: 'Resolved target element kind when available.', - }, - hovered: { - type: 'boolean', - description: 'Whether hover input was dispatched.', - }, - note: { - type: 'string', - description: 'Guidance when no tooltip/menu was confirmed.', - }, - notices: { - type: 'array', - description: - 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', - items: { - type: 'string', - }, - }, - possibleEffectObserved: { - type: 'boolean', - description: 'Includes weak title/DOM/scroll churn; not proof of success.', - }, - refRecovered: { - type: 'boolean', - description: - 'Whether a stale detached ref was safely rebound to one unique semantic match.', - }, - trusted: { - type: 'boolean', - description: 'Whether Chromium trusted pointer movement was used.', - }, - }, - required: ['hovered'], - }, - }, - browser_list_sessions: { - parameters: { - type: 'object', - properties: {}, - }, - resultSchema: undefined, - }, - browser_list_tabs: { - parameters: { - type: 'object', - properties: {}, - }, - resultSchema: undefined, - }, - browser_navigate: { - parameters: { - type: 'object', - properties: { - url: { - type: 'string', - description: - 'The absolute URL to navigate to, including scheme (https:// or http://). Must resolve to a public address — localhost and private/internal hosts are rejected.', - }, - }, - required: ['url'], - }, - resultSchema: undefined, - }, - browser_open_tab: { - parameters: { - type: 'object', - properties: { - url: { - type: 'string', - description: 'Optional URL to open the new tab at.', - }, - }, - }, - resultSchema: undefined, - }, - browser_open_url: { - parameters: { - type: 'object', - properties: { - url: { - type: 'string', - description: - 'The absolute URL to open, including scheme (https:// or http:// — localhost/local dev URLs are supported).', - }, - }, - required: ['url'], - }, - resultSchema: undefined, - }, - browser_press_key: { - parameters: { - type: 'object', - properties: { - key: { - type: 'string', - description: - "Key or combination. Named keys (case-insensitive): Enter, Escape (Esc), Tab, Backspace, Delete, Space, ArrowUp/ArrowDown/ArrowLeft/ArrowRight (or Up/Down/Left/Right), Home, End, PageUp, PageDown. Any single character also works ('a', '5', '/', ','). Anything else — 'F5', 'Return', 'Insert' — is rejected. Join modifiers with '+'. Use Mod (aliases Primary, ControlOrMeta, CommandOrControl) for the platform primary modifier, e.g. Mod+K or Mod+,. Raw Control/Ctrl and Cmd/Command/Meta remain available; Control is not generally Cmd on macOS. Check effectObserved and primaryModifier in the result.", - }, - }, - required: ['key'], - }, - resultSchema: { - type: 'object', - properties: { - activeElement: { - type: 'string', - description: 'Focused element kind after the action.', - }, - dialogs: { - type: 'array', - description: 'Visible DOM dialogs after the key.', - items: { - type: 'string', - }, - }, - effect: { - type: 'object', - description: - 'Detailed postcondition signals; generic title/DOM/scroll churn is weak evidence unless the tool documents otherwise.', - properties: { - dialogChanged: { - type: 'boolean', - description: 'The visible DOM dialog set changed.', - }, - domChanged: { - type: 'boolean', - description: 'The DOM mutation revision changed; weak evidence on its own.', - }, - fieldChanged: { - type: 'boolean', - description: 'The safely inspectable focused-field state changed.', - }, - focusChanged: { - type: 'boolean', - description: 'The focused element changed.', - }, - popupChanged: { - type: 'boolean', - description: 'The visible popup/menu set changed.', - }, - scrollChanged: { - type: 'boolean', - description: 'A tracked scroll offset changed; weak evidence except for scroll keys.', - }, - tabChanged: { - type: 'boolean', - description: 'The active browser tab changed.', - }, - targetChanged: { - type: 'boolean', - description: "The requested target's checked/selected/expanded/open state changed.", - }, - titleChanged: { - type: 'boolean', - description: 'The document title changed; weak evidence on its own.', - }, - urlChanged: { - type: 'boolean', - description: 'The observed URL changed.', - }, - }, - }, - effectObserved: { - type: 'boolean', - description: 'A strong targeted effect was observed.', - }, - note: { - type: 'string', - description: 'No-op/fallback guidance.', - }, - notices: { - type: 'array', - description: - 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', - items: { - type: 'string', - }, - }, - possibleEffectObserved: { - type: 'boolean', - description: 'Includes weak title/DOM/scroll churn; not proof of success.', - }, - pressed: { - type: 'string', - description: 'Requested key/combo whose dispatch completed.', - }, - primaryModifier: { - type: 'string', - description: 'Cmd on macOS, Control elsewhere.', - }, - redacted: { - type: 'boolean', - description: 'Whether sensitive focused-field details were withheld.', - }, - selectedChars: { - type: 'number', - description: 'Number of selected characters when safely inspectable.', - }, - target: { - type: 'string', - description: 'Synthetic fallback target element kind, when applicable.', - }, - trusted: { - type: 'boolean', - description: 'Whether Chromium trusted key input was used.', - }, - valueLength: { - type: 'number', - description: 'Focused non-secret field length when safely inspectable.', - }, - valuePreview: { - type: 'string', - description: 'Bounded focused non-secret field preview when safely inspectable.', - }, - }, - required: ['pressed'], - }, - }, - browser_read_text: { - parameters: { - type: 'object', - properties: { - elementId: { - type: 'number', - description: - "Optional element id from the current tab's most recent browser_snapshot. Treat refs as invalid across tab switches or later snapshots. Omit to read the whole page.", - }, - }, - }, - resultSchema: { - type: 'object', - properties: { - framesRead: { - type: 'number', - description: 'Visible child frames whose text was appended.', - }, - hiddenFrames: { - type: 'number', - description: - 'Eligible child frames skipped because their embedding surface was not visible.', - }, - notices: { - type: 'array', - description: - 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', - items: { - type: 'string', - }, - }, - text: { - type: 'string', - description: - 'Visible text, capped across the top page and eligible visible child frames.', - }, - title: { - type: 'string', - description: 'Top-page title when available.', - }, - truncated: { - type: 'boolean', - description: 'Whether a page, frame, or combined character cap omitted text.', - }, - unreadableFrames: { - type: 'number', - description: 'Eligible child frames whose text could not be read.', - }, - url: { - type: 'string', - description: 'Top-page URL.', - }, - }, - }, - }, - browser_request_takeover: { - parameters: { - type: 'object', - properties: { - purpose: { - type: 'string', - description: - 'Why takeover is needed. Set sign_in for a login/password flow so the desktop can remember a privacy-preserving session hint after the user finishes.', - enum: ['sign_in', 'captcha', 'payment', 'sensitive_confirmation', 'other'], - }, - reason: { - type: 'string', - description: - "Short explanation shown to the user of what they need to do (e.g. 'Sign in to Notion').", - }, - }, - required: ['reason'], - }, - resultSchema: undefined, - }, - browser_screenshot: { - parameters: { - type: 'object', - properties: {}, - }, - resultSchema: undefined, - }, - browser_scroll: { - parameters: { - type: 'object', - properties: { - amount: { - type: 'number', - description: - 'Optional distance to scroll in pixels (default: 85% of the viewport height, so a little context carries over).', - }, - direction: { - type: 'string', - description: 'Scroll direction.', - enum: ['up', 'down'], - }, - elementId: { - type: 'number', - description: - "The element id to act on (from the current tab's most recent browser_snapshot). Treat refs as invalid across tab switches or later snapshots.", - }, - }, - required: ['direction'], - }, - resultSchema: { - type: 'object', - properties: { - atBottom: { - type: 'boolean', - description: 'Whether the selected region is at its bottom boundary.', - }, - atTop: { - type: 'boolean', - description: 'Whether the selected region is at its top boundary.', - }, - clientHeight: { - type: 'number', - description: 'Region viewport height.', - }, - movedBy: { - type: 'number', - description: 'Actual signed movement; zero means the target did not move.', - }, - notices: { - type: 'array', - description: - 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', - items: { - type: 'string', - }, - }, - scrollHeight: { - type: 'number', - description: 'Region content height.', - }, - scrollTop: { - type: 'number', - description: 'Resulting region scroll offset.', - }, - target: { - type: 'string', - description: 'Chosen scroll region label.', - }, - targetSource: { - type: 'string', - description: - 'element, element-boundary, focus, focus-boundary, viewport-center, viewport-center-boundary, largest-visible, or page.', - }, - windowScrollY: { - type: 'number', - description: 'Top-page window scroll offset after the region scroll.', - }, - }, - required: ['atTop', 'atBottom'], - }, - }, - browser_select_option: { - parameters: { - type: 'object', - properties: { - elementId: { - type: 'number', - description: - "The element id to act on (from the current tab's most recent browser_snapshot). Treat refs as invalid across tab switches or later snapshots.", - }, - value: { - type: 'string', - description: "The option's visible label or its value.", - }, - }, - required: ['elementId', 'value'], - }, - resultSchema: { - type: 'object', - properties: { - effectObserved: { - type: 'boolean', - description: 'Whether the settled readback retained the requested selection.', - }, - note: { - type: 'string', - description: 'Guidance when the page reverted the selection.', - }, - notices: { - type: 'array', - description: - 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', - items: { - type: 'string', - }, - }, - readback: { - type: 'object', - description: 'Settled selected label and value.', - properties: { - selected: { - type: 'string', - description: 'Settled visible option label.', - }, - value: { - type: 'string', - description: 'Settled option value.', - }, - }, - }, - refRecovered: { - type: 'boolean', - description: - 'Whether a stale detached ref was safely rebound to one unique semantic match.', - }, - selected: { - type: 'string', - description: 'Canonical visible label of the matched option.', - }, - value: { - type: 'string', - description: 'Canonical value of the matched option.', - }, - }, - required: ['selected'], - }, - }, - browser_snapshot: { - parameters: { - type: 'object', - properties: {}, - }, - resultSchema: { - type: 'object', - properties: { - capturedCrossOriginFrames: { - type: 'number', - description: 'Number of non-empty eligible cross-origin frames appended.', - }, - hiddenCrossOriginFrames: { - type: 'number', - description: - 'Eligible cross-origin frames skipped because their embedding surface was hidden, offscreen, or covered.', - }, - notices: { - type: 'array', - description: - 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', - items: { - type: 'string', - }, - }, - outline: { - type: 'string', - description: 'Mounted DOM/frame outline containing model-visible [ref=N] ids.', - }, - pageHeight: { - type: 'number', - description: 'Top-page document height.', - }, - scrollY: { - type: 'number', - description: 'Top-page window scroll offset.', - }, - title: { - type: 'string', - description: 'Captured top-page title.', - }, - truncated: { - type: 'boolean', - description: 'True when page/ref/frame/combined output caps omitted content.', - }, - unreadableCrossOriginFrames: { - type: 'number', - description: 'Eligible cross-origin frames that could not be captured.', - }, - url: { - type: 'string', - description: 'Captured top-page URL.', - }, - viewportHeight: { - type: 'number', - description: 'Top-page viewport height.', - }, - viewportWidth: { - type: 'number', - description: 'Top-page viewport width.', - }, - }, - required: ['outline', 'truncated'], - }, - }, - browser_switch_tab: { - parameters: { - type: 'object', - properties: { - tabId: { - type: 'string', - description: 'The id of the tab to activate (from browser_list_tabs).', - }, - }, - required: ['tabId'], - }, - resultSchema: undefined, - }, - browser_type: { - parameters: { - type: 'object', - properties: { - elementId: { - type: 'number', - description: - "The element id to act on (from the current tab's most recent browser_snapshot). Treat refs as invalid across tab switches or later snapshots.", - }, - submit: { - type: 'boolean', - description: 'Press Enter after typing. Default false.', - }, - text: { - type: 'string', - description: - "The text to type. Replaces the element's current content. Must be non-empty — an empty string is rejected as a missing parameter; to clear a field, press Mod+A then Backspace with browser_press_key.", - }, - }, - required: ['elementId', 'text'], - }, - resultSchema: { - type: 'object', - properties: { - activeElement: { - type: 'string', - description: 'Focused element kind after the action.', - }, - dispatched: { - type: 'boolean', - description: 'Whether text dispatch completed.', - }, - effect: { - type: 'object', - description: - 'Detailed postcondition signals; generic title/DOM/scroll churn is weak evidence unless the tool documents otherwise.', - properties: { - dialogChanged: { - type: 'boolean', - description: 'The visible DOM dialog set changed.', - }, - domChanged: { - type: 'boolean', - description: 'The DOM mutation revision changed; weak evidence on its own.', - }, - fieldChanged: { - type: 'boolean', - description: 'The safely inspectable focused-field state changed.', - }, - focusChanged: { - type: 'boolean', - description: 'The focused element changed.', - }, - popupChanged: { - type: 'boolean', - description: 'The visible popup/menu set changed.', - }, - scrollChanged: { - type: 'boolean', - description: 'A tracked scroll offset changed; weak evidence except for scroll keys.', - }, - tabChanged: { - type: 'boolean', - description: 'The active browser tab changed.', - }, - targetChanged: { - type: 'boolean', - description: "The requested target's checked/selected/expanded/open state changed.", - }, - titleChanged: { - type: 'boolean', - description: 'The document title changed; weak evidence on its own.', - }, - urlChanged: { - type: 'boolean', - description: 'The observed URL changed.', - }, - }, - }, - effectObserved: { - type: 'boolean', - description: 'A strong field/page effect was observed.', - }, - note: { - type: 'string', - description: 'Postcondition guidance when readback did not prove a change.', - }, - notices: { - type: 'array', - description: - 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', - items: { - type: 'string', - }, - }, - possibleEffectObserved: { - type: 'boolean', - description: 'Includes weak title/DOM/scroll churn; not proof of success.', - }, - redacted: { - type: 'boolean', - description: 'Whether sensitive focused-field details were withheld.', - }, - refRecovered: { - type: 'boolean', - description: - 'Whether a stale detached ref was safely rebound to one unique semantic match.', - }, - replacedExisting: { - type: 'boolean', - description: "Whether the operation replaced the field's existing content.", - }, - selectedChars: { - type: 'number', - description: 'Number of selected characters when safely inspectable.', - }, - submissionEffectObserved: { - type: 'boolean', - description: - 'Whether a strong effect was observed after Enter, separately from the text write.', - }, - submitDispatched: { - type: 'boolean', - description: - 'Whether Enter dispatch acknowledged completion; this alone is not proof of submission.', - }, - submitRequested: { - type: 'boolean', - description: 'Whether submit=true was requested.', - }, - submitUncertain: { - type: 'boolean', - description: - 'Whether Enter key-down may have landed but dispatch did not acknowledge completion.', - }, - submitted: { - type: 'boolean', - description: - 'Whether Enter dispatch completed and a strong submission effect was observed.', - }, - trusted: { - type: 'boolean', - description: 'Whether native Chromium input was used.', - }, - valueLength: { - type: 'number', - description: 'Focused non-secret field length when safely inspectable.', - }, - valuePreview: { - type: 'string', - description: 'Bounded focused non-secret field preview when safely inspectable.', - }, - }, - required: ['dispatched'], - }, - }, - browser_wait_for: { - parameters: { - type: 'object', - properties: { - text: { - type: 'string', - description: 'Optional visible text to wait for.', - }, - timeoutMs: { - type: 'number', - description: 'Maximum time to wait, in milliseconds (default 10000, capped at 120000).', - }, - }, - }, - resultSchema: { - type: 'object', - properties: { - elapsedMs: { - type: 'number', - description: 'Elapsed wait duration.', - }, - found: { - type: 'boolean', - description: 'Whether the requested text appeared before timeout.', - }, - foundInFrame: { - type: 'boolean', - description: 'Whether the match was found in an eligible visible child frame.', - }, - note: { - type: 'string', - description: 'Timeout/recovery guidance.', - }, - notices: { - type: 'array', - description: - 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', - items: { - type: 'string', - }, - }, - waitedMs: { - type: 'number', - description: 'Completed sleep duration when no text was requested.', - }, - }, - }, - }, call_integration_tool: { parameters: { properties: { @@ -1106,6 +76,19 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + complete_scheduled_task: { + parameters: { + type: 'object', + properties: { + jobId: { + type: 'string', + description: 'The ID of the scheduled task to mark as completed.', + }, + }, + required: ['jobId'], + }, + resultSchema: undefined, + }, cp: { parameters: { type: 'object', @@ -1289,6 +272,68 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + delete_file: { + parameters: { + type: 'object', + properties: { + paths: { + type: 'array', + description: + 'Canonical workspace file VFS paths to delete, e.g. ["files/Reports/draft.md"].', + items: { + type: 'string', + }, + }, + }, + required: ['paths'], + }, + resultSchema: { + type: 'object', + properties: { + message: { + type: 'string', + description: 'Human-readable outcome.', + }, + success: { + type: 'boolean', + description: 'Whether the delete succeeded.', + }, + }, + required: ['success', 'message'], + }, + }, + delete_file_folder: { + parameters: { + type: 'object', + properties: { + paths: { + type: 'array', + description: 'Canonical folder VFS paths to delete, e.g. ["files/Archive"].', + items: { + type: 'string', + }, + }, + }, + required: ['paths'], + }, + resultSchema: undefined, + }, + delete_workflow: { + parameters: { + type: 'object', + properties: { + workflowIds: { + type: 'array', + description: 'The workflow IDs to delete.', + items: { + type: 'string', + }, + }, + }, + required: ['workflowIds'], + }, + resultSchema: undefined, + }, delete_workspace_mcp_server: { parameters: { type: 'object', @@ -1624,13 +669,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { name: { type: 'string', description: - 'Display name for the block, max 60 characters. REQUIRED the first time a workflow is published. When republishing an existing block, omit it to keep the current name or pass a new one to rename. Ignored for undeploy.', + 'Display name for the block, max 60 characters. When republishing an existing block, pass the current name to keep it or a new name to rename.', }, workflowId: { type: 'string', description: 'Workflow ID (defaults to active workflow)', }, }, + required: ['name'], }, resultSchema: { type: 'object', @@ -1690,12 +736,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { parameters: { type: 'object', properties: { - action: { - type: 'string', - description: - '"deploy" (default) adds/updates the workflow as an MCP tool on the server; "undeploy" removes the workflow\'s tool from the server.', - enum: ['deploy', 'undeploy'], - }, parameterDescriptions: { type: 'array', description: 'Array of parameter descriptions for the tool', @@ -1984,7 +1024,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { description: 'True when a provider returned a non-empty result.', }, provider: { - type: ['string', 'null'], + type: 'string', description: 'Internal label of the provider that produced the result (billing/diagnostics only — do NOT surface it to the user), or null on no match.', }, @@ -2198,7 +1238,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { code: { type: 'string', description: - 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with pre-installed CLI tools. Use each needed secret as {{VAR_NAME}}; the reference resolves to the value exactly as stored.', + 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with access to pre-installed CLI tools and workspace env vars as $VAR_NAME.', }, inputs: { type: 'object', @@ -2320,15 +1360,10 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, }, }, - sandboxId: { - type: 'string', - description: - 'Optional Sim sandbox id from agent/sandboxes/{name}.json. DEFAULT-FIRST: omit this whenever the documented default function_execute environment can do the job. Select a ready existing Sim sandbox only when a required third-party dependency, Debian system package, or managed CLI is known to be absent, or a default attempt failed specifically because it was missing. Never guess an id.', - }, timeout: { type: 'number', description: - 'Maximum execution time in SECONDS (Sim converts to milliseconds). The sandbox stops execution and returns a timeout error after this duration. Defaults to 10 seconds and is capped at 300 seconds regardless of plan.', + 'Maximum execution time in seconds. The sandbox stops execution and returns a timeout error after this duration. Defaults to 10 seconds; the platform execution limit still applies.', default: 10, }, title: { @@ -2913,6 +1948,31 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + get_scheduled_task_logs: { + parameters: { + type: 'object', + properties: { + executionId: { + type: 'string', + description: 'Optional execution ID for a specific run.', + }, + includeDetails: { + type: 'boolean', + description: 'Include tool calls, outputs, and cost details.', + }, + jobId: { + type: 'string', + description: 'The scheduled task (schedule) ID to get logs for.', + }, + limit: { + type: 'number', + description: 'Max number of entries (default: 3, max: 5)', + }, + }, + required: ['jobId'], + }, + resultSchema: undefined, + }, get_workflow_data: { parameters: { type: 'object', @@ -2957,7 +2017,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { toolTitle: { type: 'string', description: - 'Required target-only UI phrase for the search row. The UI verb is supplied for you, so pass text like "workflow configs" or "knowledge bases", not a full sentence like "Finding workflow configs".', + 'Optional target-only UI phrase for the search row. The UI verb is supplied for you, so pass text like "workflow configs" or "knowledge bases", not a full sentence like "Finding workflow configs".', }, }, required: ['pattern', 'toolTitle'], @@ -2971,7 +2031,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { context: { type: 'number', description: - "Number of lines to show before and after each match (default 0). Only applies to output_mode 'content'.", + "Number of lines to show before and after each match. Only applies to output_mode 'content'.", }, ignoreCase: { type: 'boolean', @@ -3000,12 +2060,12 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { pattern: { type: 'string', description: - "Regex pattern to search for. Searches VFS map entries (workflow JSON, metadata, memories) by default; searches a single file's extracted text when path is one files/ or uploads/ file leaf.", + "Regex pattern to search for. Searches VFS map entries (workflow JSON, metadata, plans, memories) by default; searches a single file's extracted text when path is one files/ or uploads/ file leaf.", }, toolTitle: { type: 'string', description: - 'Required target-only UI phrase for the search row. The UI verb is supplied for you, so pass text like "Slack integrations" or "deployed workflows", not a full sentence like "Searching for Slack integrations".', + 'Optional target-only UI phrase for the search row. The UI verb is supplied for you, so pass text like "Slack integrations" or "deployed workflows", not a full sentence like "Searching for Slack integrations".', }, }, required: ['pattern', 'toolTitle'], @@ -3182,6 +2242,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { 'query', 'add_file', 'update', + 'delete', 'delete_document', 'update_document', 'list_tags', @@ -3202,9 +2263,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'object', properties: { data: { - type: ['object', 'array'], - description: - 'Operation-specific result payload. An object for most operations; list_tags and get_tag_usage return an array of tag definitions.', + type: 'object', + description: 'Operation-specific result payload.', }, message: { type: 'string', @@ -3288,20 +2348,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - load_skill: { - parameters: { - type: 'object', - properties: { - name: { - type: 'string', - description: - "Skill name exactly as it appears in the Loadable Skills index (e.g. 'pptx-writing').", - }, - }, - required: ['name'], - }, - resultSchema: undefined, - }, manage_credential: { parameters: { type: 'object', @@ -3343,7 +2389,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { operation: { type: 'string', description: - "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — other manage_* tools may use create/update instead of add/edit.", + "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — manage_scheduled_task uses create/update instead of add/edit.", enum: ['add', 'edit', 'delete', 'list'], }, schema: { @@ -3411,6 +2457,30 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + manage_folder: { + parameters: { + type: 'object', + properties: { + folderId: { + type: 'string', + description: + 'Target folder ID, used as a fallback when path is not given. Readable from a contained workflow\'s meta.json "folderId".', + }, + operation: { + type: 'string', + description: 'The operation to perform.', + enum: ['delete'], + }, + path: { + type: 'string', + description: + 'Target folder\'s VFS path (e.g. "workflows/Marketing/Q3 Campaigns"), per-segment percent-encoded like every VFS path.', + }, + }, + required: ['operation'], + }, + resultSchema: undefined, + }, manage_mcp_tool: { parameters: { type: 'object', @@ -3450,7 +2520,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { operation: { type: 'string', description: - "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — other manage_* tools may use create/update instead of add/edit.", + "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — manage_scheduled_task uses create/update instead of add/edit.", enum: ['add', 'edit', 'delete', 'list'], }, serverId: { @@ -3463,54 +2533,75 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - manage_sandbox: { + manage_scheduled_task: { parameters: { type: 'object', properties: { - cliTools: { - type: 'array', - description: - 'Complete managed CLI id list (maximum 10). Use exact pinned ids returned by list. On edit, passing this replaces the whole list; pass [] to clear it.', - items: { - type: 'string', - }, - }, - dependencies: { - type: 'array', + args: { + type: 'object', description: - 'Complete npm or PyPI dependency list (maximum 50). On edit, passing this replaces the whole list; pass [] to clear it.', - items: { - type: 'string', + 'Operation-specific arguments. For create: {title, prompt, cron?, time?, timezone?, lifecycle?, successCondition?, maxRuns?}. For get/delete: {jobId}. For update: {jobId, title?, prompt?, cron?, timezone?, status?, lifecycle?, successCondition?, maxRuns?}. For list: no args needed.', + properties: { + cron: { + type: 'string', + description: + "Cron expression for a recurring scheduled task (e.g. '0 9 * * *'). Set exactly one of cron or time: recurring -> cron; one-time -> time.", + }, + jobId: { + type: 'string', + description: 'Scheduled task ID (required for get, update)', + }, + jobIds: { + type: 'array', + description: 'Array of scheduled task IDs (for batch delete)', + items: { + type: 'string', + }, + }, + lifecycle: { + type: 'string', + description: + "'persistent' (default) or 'until_complete'. Until_complete scheduled tasks stop when complete_scheduled_task is called.", + enum: ['persistent', 'until_complete'], + }, + maxRuns: { + type: 'integer', + description: 'Max executions before auto-completing. Safety limit.', + }, + prompt: { + type: 'string', + description: 'The prompt to execute when the scheduled task fires', + }, + status: { + type: 'string', + description: 'Scheduled task status: active, paused', + enum: ['active', 'paused'], + }, + successCondition: { + type: 'string', + description: + 'What must happen for the scheduled task to be considered complete (until_complete lifecycle).', + }, + time: { + type: 'string', + description: + "ISO 8601 datetime. One-time scheduled task -> set time and omit cron. May also anchor a recurring cron task's first-fire time.", + }, + timezone: { + type: 'string', + description: 'IANA timezone (e.g. America/New_York). Defaults to UTC.', + }, + title: { + type: 'string', + description: "Short descriptive title for the scheduled task (e.g. 'Email Poller')", + }, }, }, - language: { - type: 'string', - description: - 'Dependency language. javascript installs from npm; python installs from PyPI. Required for add; optional for edit.', - enum: ['javascript', 'python'], - }, - name: { - type: 'string', - description: - 'Workspace-unique Sim sandbox name (1-64 characters). Required for add; optional for edit.', - }, operation: { type: 'string', - description: "The operation to perform: 'add', 'edit', 'list', or 'delete'.", - enum: ['add', 'edit', 'delete', 'list'], - }, - sandboxId: { - type: 'string', - description: - 'The Sim sandbox id. Get it from list or the inner id field in agent/sandboxes/{name}.json; never guess it. Required for edit and delete.', - }, - systemPackages: { - type: 'array', description: - 'Complete Debian package-coordinate list in package[:architecture][=version] form (maximum 50). On edit, passing this replaces the whole list; pass [] to clear it.', - items: { - type: 'string', - }, + 'The operation to perform: create, list, get, update, delete. These verbs are tool-specific — the custom-tool/MCP/skill managers use add/edit instead of create/update.', + enum: ['create', 'list', 'get', 'update', 'delete'], }, }, required: ['operation'], @@ -3537,7 +2628,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { operation: { type: 'string', description: - "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — other manage_* tools may use create/update instead of add/edit.", + "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — manage_scheduled_task uses create/update instead of add/edit.", enum: ['add', 'edit', 'delete', 'list'], }, skillId: { @@ -3692,7 +2783,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: { type: 'string', description: 'The resource type.', - enum: ['workflow', 'table', 'knowledgebase', 'file', 'log'], + enum: ['workflow', 'table', 'knowledgebase', 'file', 'log', 'scheduledtask'], }, }, required: ['type'], @@ -3841,30 +2932,27 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'object', description: 'Arguments for the operation', properties: { - cursor: { - type: 'string', - description: - 'Opaque pagination cursor for query_rows (optional). Omit for the first page; to fetch the next page, pass back the nextCursor from the previous result\'s "more available" message verbatim. Cannot be combined with a fresh order — the cursor already encodes the paging position.', - }, filter: { type: 'object', - description: - 'Predicate filter object for query_rows. A predicate is a tree: {"all":[...]} (AND) or {"any":[...]} (OR); members are leaves {field, op, value} or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (use * as the wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array value. Examples: {"all":[{"field":"status","op":"eq","value":"active"}]}; {"any":[{"field":"status","op":"eq","value":"active"},{"field":"status","op":"eq","value":"pending"}]}; {"all":[{"field":"name","op":"ilike","value":"*jo*"}]}.', + description: 'MongoDB-style filter for query_rows', }, limit: { type: 'number', - description: - 'Maximum rows per page for query_rows (optional). Omit to fetch the ENTIRE matching result in one response — the call fails if the result exceeds the 5MB budget (narrow with a filter or set a limit). With a limit, a page may end early at the byte budget with more remaining; a non-null nextCursor in the result means more rows exist (continue with cursor).', + description: 'Maximum rows to return (optional, default 100, max 1000 per call)', }, - order: { - type: 'array', - description: - 'Sort spec for query_rows (optional). Ordered list of {field, direction} where direction is asc or desc, e.g. [{"field":"wins","direction":"desc"},{"field":"name","direction":"asc"}].', + offset: { + type: 'number', + description: 'Number of rows to skip (optional for query_rows, default 0)', }, rowId: { type: 'string', description: 'Row ID (required for get_row)', }, + sort: { + type: 'object', + description: + "Sort specification as { field: 'asc' | 'desc' } (optional for query_rows)", + }, tableId: { type: 'string', description: 'Table ID (required for all operations)', @@ -4053,28 +3141,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - rm: { - parameters: { - type: 'object', - properties: { - paths: { - type: 'array', - description: - 'Canonical VFS paths to delete, e.g. ["files/Reports/draft.md"]. Copy paths verbatim from glob/grep/read output. Paths from different categories may be mixed in one call.', - items: { - type: 'string', - }, - }, - toolTitle: { - type: 'string', - description: - 'Target-only UI phrase for the action row, e.g. "draft.md" or "3 files", not a full sentence like "Deleting draft.md".', - }, - }, - required: ['paths', 'toolTitle'], - }, - resultSchema: undefined, - }, run: { parameters: { properties: { @@ -4131,7 +3197,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { code: { type: 'string', description: - 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with pre-installed CLI tools. Use each needed secret as {{VAR_NAME}}; the reference resolves to the value exactly as stored.', + 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with access to pre-installed CLI tools and workspace env vars as $VAR_NAME.', }, inputs: { type: 'object', @@ -4253,11 +3319,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { parameters: { type: 'object', properties: { - async: { - type: 'boolean', - description: - 'Queue the deployed workflow and return its execution ID immediately. Default: false. Set true only when explicitly asked for a background run, or when the three most recent completed runs each exceeded 30 minutes. Fails if the current workflow differs from its deployed version. Missing history, complexity, or one slow run never justify async; check completion later with query_logs.', - }, inputFromExecutionId: { type: 'string', description: @@ -4335,6 +3396,19 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + scheduled_task: { + parameters: { + properties: { + request: { + description: 'What scheduled task action is needed.', + type: 'string', + }, + }, + required: ['request'], + type: 'object', + }, + resultSchema: undefined, + }, scrape_page: { parameters: { type: 'object', @@ -4370,19 +3444,22 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - search_documentation: { + search_docs: { parameters: { type: 'object', properties: { + path: { + type: 'string', + description: + 'Optional docs/ VFS path (a page such as docs/workflows/blocks/agent.mdx, or a section such as docs/workflows) that limits the search scope', + }, query: { type: 'string', description: 'The search query', }, topK: { type: 'number', - description: - 'Number of results to return (default 10). Not clamped — keep it small, since each result is a full doc chunk.', - default: 10, + description: 'Number of results (default 10, max 25)', }, }, required: ['query'], @@ -4448,9 +3525,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'object', properties: { data: { - type: ['object', 'array'], - description: - 'Operation-specific result payload. An object for search results; list_tags returns an array of tag definitions.', + type: 'object', + description: 'Operation-specific result payload.', }, message: { type: 'string', @@ -4478,8 +3554,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, version: { type: 'string', - description: - "Specific version, numeric only and WITHOUT a leading 'v' (e.g. '14', '2', '2.1') — the 'v' is added for you, so 'v2' resolves to nothing.", + description: "Specific version (optional, e.g., '14', 'v2')", }, }, required: ['library_name', 'query'], @@ -4532,7 +3607,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { properties: { limit: { type: 'integer', - description: 'Maximum number of pattern examples to return per query (defaults to 3).', + description: 'Maximum number of unique pattern examples to return (defaults to 3).', }, queries: { type: 'array', @@ -4624,14 +3699,12 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, type: { type: 'string', - description: - 'Variable type for add/edit. Defaults to the variable\'s existing type, or "plain" for a new one. Ignored for delete.', + description: 'Variable type. Required for add/edit; ignored for delete.', enum: ['plain', 'number', 'boolean', 'array', 'object'], }, value: { type: 'string', - description: - 'Variable value for add/edit, coerced to the declared type. Omitting it leaves the variable with no value. Ignored for delete.', + description: 'Variable value. Required for add/edit; ignored for delete.', }, }, required: ['operation', 'name'], @@ -4716,120 +3789,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - terminal: { - parameters: { - type: 'object', - properties: { - args: { - type: 'object', - description: 'Inputs for the operation. Pass only the fields that operation uses.', - properties: { - command: { - type: 'string', - description: - 'For run: the command line, exactly as it would be typed at the prompt. Shell syntax (pipes, &&, quoting, redirection) works because a real shell interprets it.', - }, - cwd: { - type: 'string', - description: - "For new: absolute path to open in. Defaults to the active terminal's directory.", - }, - key: { - type: 'string', - description: - 'For input: a single key to press instead of text. Use "enter" to submit something already typed.', - enum: [ - 'ctrl-c', - 'ctrl-d', - 'ctrl-z', - 'enter', - 'up', - 'down', - 'left', - 'right', - 'escape', - 'tab', - ], - }, - keys: { - type: 'array', - description: - 'For input: several keys pressed in order, e.g. ["down","down","enter"] to walk down a menu and choose. Each is a real keypress with a pause between, so the program redraws as it would under a person\'s hands. Only batch when you already know where the highlight is — read the screen first, and press one key at a time when you do not. Max 20.', - items: { - type: 'string', - enum: [ - 'ctrl-c', - 'ctrl-d', - 'ctrl-z', - 'enter', - 'up', - 'down', - 'left', - 'right', - 'escape', - 'tab', - ], - }, - }, - lines: { - type: 'number', - description: 'For read: how many trailing lines to return. Defaults to 200.', - }, - pane: { - type: 'string', - description: - "Which tmux pane to act on, as a target from the panes operation (session:window.pane). Defaults to that session's active pane. Ignored when the terminal is a plain shell.", - }, - reason: { - type: 'string', - description: - 'For handoff: what the user needs to do, shown on the button they click (e.g. "Enter your sudo password"). Say what is being asked, not that you are waiting.', - }, - signal: { - type: 'string', - description: - 'For kill: which signal. Defaults to SIGINT, the equivalent of the user pressing Ctrl-C.', - enum: ['SIGINT', 'SIGTERM', 'SIGKILL'], - }, - terminalId: { - type: 'string', - description: - 'Which terminal to act on, from the list operation. Defaults to the active one, which is what the user is looking at. Required by switch and close.', - }, - text: { - type: 'string', - description: - 'For input: literal text to type. A trailing newline submits it. Check the returned screen to confirm it submitted rather than sitting unsent in an input box.', - }, - waitSeconds: { - type: 'number', - description: - 'For run: how long to wait before handing back a still-running command. Defaults to 30, capped at 120. Raising it does not make a command finish sooner, it only delays your first look at it.', - }, - }, - }, - operation: { - type: 'string', - description: 'What to do.', - enum: [ - 'run', - 'read', - 'input', - 'kill', - 'cwd', - 'list', - 'new', - 'switch', - 'close', - 'panes', - 'handoff', - ], - }, - }, - required: ['operation'], - }, - resultSchema: undefined, - }, update_deployment_version: { parameters: { type: 'object', @@ -4859,6 +3818,24 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + update_scheduled_task_history: { + parameters: { + type: 'object', + properties: { + jobId: { + type: 'string', + description: 'The scheduled task ID.', + }, + summary: { + type: 'string', + description: + "A concise summary of what was done this run (e.g., 'Sent follow-up emails to 3 leads: Alice, Bob, Carol').", + }, + }, + required: ['jobId', 'summary'], + }, + resultSchema: undefined, + }, update_workspace_mcp_server: { parameters: { type: 'object', @@ -4904,8 +3881,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, column: { type: 'object', - description: - 'Column definition for add_column: { name, type, unique?, position? }. For a select (enum) column also pass { options: ["Open", "Closed"], multiple?: true } — options is a list of display names and is required for select.', + description: 'Column definition for add_column: { name, type, unique?, position? }', }, columnName: { type: 'string', @@ -4917,11 +3893,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { description: 'Array of column names to delete at once (for delete_column). Preferred over columnName when deleting multiple columns.', }, - cursor: { - type: 'string', - description: - 'Opaque pagination cursor for query_rows (optional). Omit for the first page; to fetch the next page, pass back the nextCursor from the previous result\'s "more available" message verbatim. Cannot be combined with a fresh order — the cursor already encodes the paging position.', - }, data: { type: 'object', description: 'Row data as key-value pairs (required for insert_row, update_row)', @@ -4941,12 +3912,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, }, }, - deploymentMode: { - type: 'string', - description: - "Which version of the backing workflow this group's per-row runs execute, for add_workflow_group and update_workflow_group. 'live' (default) runs the editable draft, so later edits take effect immediately. 'deployed' runs the workflow's latest active deployment, pinning rows to a published version — if that workflow has never been deployed the cell fails rather than falling back to the draft. Only meaningful for workflow groups; enrichment groups have no backing workflow.", - enum: ['live', 'deployed'], - }, description: { type: 'string', description: "Table description (optional for 'create')", @@ -4964,7 +3929,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { filter: { type: 'object', description: - 'Predicate filter object for query_rows, update_rows_by_filter, delete_rows_by_filter. A predicate is a tree: {"all":[...]} (AND) or {"any":[...]} (OR); members are leaves {field, op, value} or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (use * as the wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array value. Examples: {"all":[{"field":"status","op":"eq","value":"active"}]}; {"all":[{"field":"wins","op":"gte","value":18},{"field":"status","op":"eq","value":"pending"}]}; {"any":[{"field":"status","op":"eq","value":"active"},{"field":"status","op":"eq","value":"pending"}]}; {"all":[{"field":"name","op":"ilike","value":"*jo*"}]}; {"all":[{"field":"slack_user_id","op":"in","value":["U1","U2"]}]}.', + 'MongoDB-style filter for query_rows, update_rows_by_filter, delete_rows_by_filter', }, groupId: { type: 'string', @@ -5001,7 +3966,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { limit: { type: 'number', description: - 'Maximum rows per page for query_rows (optional). Omit to fetch the ENTIRE matching result in one response — the call fails if the result exceeds the 5MB budget (narrow with a filter or set a limit). With a limit, a page may end early at the byte budget with more remaining; a non-null nextCursor in the result means more rows exist (continue with cursor). On update_rows_by_filter / delete_rows_by_filter, caps affected rows; omit to act on every match.', + 'Maximum rows to return or affect (optional, default 100). Omit on update_rows_by_filter / delete_rows_by_filter to act on every match.', }, mapping: { type: 'object', @@ -5043,11 +4008,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { "Import mode for import_file. 'append' (default) adds rows; 'replace' truncates existing rows in a transaction before inserting the new rows.", enum: ['append', 'replace'], }, - multiple: { - type: 'boolean', - description: - 'Whether a select (enum) cell may hold several options (default false). Switching an existing column from true to false fails if any row has more than one option selected.', - }, name: { type: 'string', description: @@ -5061,20 +4021,11 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { newType: { type: 'string', description: - 'New column type (optional for update_column). Types: string, number, boolean, date, json, select. Converting a column to select also requires options; the conversion fails if any existing cell value doesn\'t match one of them. Converting to a multiple: true select also accepts a comma-separated cell ("Open, Urgent"), which is the form a multi column converts to text as — so multiselect → text → multiselect round-trips.', - }, - options: { - type: 'array', - description: - 'Choices for a select (enum) column, as a list of display names, e.g. ["Open", "Closed"]. Required when creating or converting to a select column. On update_column this REPLACES the option list and is matched against the current one BY NAME: a name still present keeps its cells, a name no longer present is removed and cleared from every cell that held it. Send the full list including the options you are keeping — omitting one deletes it. There is no in-place rename, so re-sending an option under a new name clears the cells that held the old one. Max 100.', - items: { - type: 'string', - }, + 'New column type (optional for update_column). Types: string, number, boolean, date, json', }, - order: { - type: 'array', - description: - 'Sort spec for query_rows (optional). Ordered list of {field, direction} where direction is asc or desc, e.g. [{"field":"wins","direction":"desc"},{"field":"name","direction":"asc"}].', + offset: { + type: 'number', + description: 'Number of rows to skip (optional for query_rows, default 0)', }, outputColumnNames: { type: 'object', @@ -5088,13 +4039,13 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { outputFormat: { type: 'string', description: - 'Explicit format override for outputPath. Only "csv" changes the file\'s CONTENT (rows serialized as a CSV table); "json", "txt", "md" and "html" all write the same pretty-printed JSON and change only the stored MIME type. Usually unnecessary — the extension already selects the format.', + 'Explicit format override for outputPath. Usually unnecessary — the file extension determines the format automatically. Only use this to force a different format than what the extension implies.', enum: ['json', 'csv', 'txt', 'md', 'html'], }, outputPath: { type: 'string', description: - 'Write this call\'s result to a NEW workspace file instead of returning it. Applies to EVERY user_table operation, not just query_rows: on success the tool result is REPLACED by a file receipt (fileId, vfsPath, size), so the operation\'s own payload is no longer visible to you — set it only when the file IS the goal. Only ".csv" changes serialization (query_rows rows become a CSV table); ".json", ".txt", ".md" and ".html" all write pretty-printed JSON of the full { success, message, data } envelope and differ only in stored MIME type. Nested paths like "files/Reports/export.csv" work — missing parent folders are created automatically, and an existing path fails.', + 'Pipe query_rows results directly to a NEW workspace file. The format is auto-inferred from the file extension: .csv → CSV, .json → JSON, .md → Markdown, etc. Use a root output path like "files/export.csv" — nested output paths are not supported.', }, outputs: { type: 'array', @@ -5134,6 +4085,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { description: 'Zero-based index at which to insert the row (optional, insert_row only). Rows at and below that index shift down. Omit to append at the end.', }, + positions: { + type: 'array', + description: + 'Per-row insertion indices for batch_insert_rows (optional). Must be the same length as rows and contain no duplicates. Values are final positions in the resulting table — lower-index shifts are applied automatically. Omit to append all rows at the end.', + items: { + type: 'integer', + }, + }, rowId: { type: 'string', description: @@ -5160,7 +4119,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { schema: { type: 'object', description: - 'Table schema with columns array (required for \'create\'). Each column: { name, type, unique? }. A select (enum) column also takes { options: ["Open", "Closed"], multiple?: true } — options is a list of display names and is required for select.', + "Table schema with columns array (required for 'create'). Each column: { name, type, unique? }", }, scope: { type: 'string', @@ -5168,6 +4127,11 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { "Cancellation scope for cancel_table_runs. 'all' cancels in-flight runs across the whole table; 'row' cancels only the row identified by rowId.", enum: ['all', 'row'], }, + sort: { + type: 'object', + description: + "Sort specification as { field: 'asc' | 'desc' } (optional for query_rows)", + }, tableId: { type: 'string', description: @@ -5210,6 +4174,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { 'import_file', 'get', 'get_schema', + 'delete', 'rename', 'insert_row', 'batch_insert_rows', @@ -5259,24 +4224,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, - wait: { - parameters: { - type: 'object', - properties: { - reason: { - type: 'string', - description: - 'What you are waiting for, in a few words (e.g. "the test suite to finish"). Shown to the user so the pause is not unexplained.', - }, - seconds: { - type: 'number', - description: 'How long to pause, in seconds. Capped at 120.', - }, - }, - required: ['seconds'], - }, - resultSchema: undefined, - }, workflow: { parameters: { properties: { diff --git a/apps/sim/lib/copilot/tools/client/store-utils.test.ts b/apps/sim/lib/copilot/tools/client/store-utils.test.ts index 6f973c23f8e..fa6de4c0b14 100644 --- a/apps/sim/lib/copilot/tools/client/store-utils.test.ts +++ b/apps/sim/lib/copilot/tools/client/store-utils.test.ts @@ -49,28 +49,22 @@ describe('resolveToolDisplay', () => { ).toBe('Read RET XYZ') }) - it('formats docs corpus reads as Section/filename', () => { + it('formats docs corpus reads as Section/page', () => { expect( resolveToolDisplay(ReadTool.id, ClientToolCallState.success, { - path: 'docs/documentation/workflows/index.mdx', + path: 'docs/workflows/blocks/agent.mdx', })?.text - ).toBe('Read Workflows/index') + ).toBe('Read Workflows/agent') expect( resolveToolDisplay(ReadTool.id, ClientToolCallState.executing, { - path: 'docs/academy/agents/block.mdx', + path: 'docs/integrations/gmail.mdx', })?.text - ).toBe('Reading Agents/block') - - expect( - resolveToolDisplay(ReadTool.id, ClientToolCallState.success, { - path: 'docs/api-reference/workflows.json', - })?.text - ).toBe('Read Workflows') + ).toBe('Reading Integrations/gmail') expect( resolveToolDisplay(ReadTool.id, ClientToolCallState.error, { - path: 'docs/documentation/getting-started.mdx', + path: 'docs/getting-started.mdx', })?.text ).toBe('Attempted to read Getting-started') }) diff --git a/apps/sim/lib/copilot/tools/client/store-utils.ts b/apps/sim/lib/copilot/tools/client/store-utils.ts index 5ca68ff5597..1a448d75e5d 100644 --- a/apps/sim/lib/copilot/tools/client/store-utils.ts +++ b/apps/sim/lib/copilot/tools/client/store-utils.ts @@ -144,19 +144,13 @@ function describeFileReadTarget(segments: string[]): string { return lastSegment } -const DOCS_TAB_SEGMENTS = new Set(['documentation', 'academy', 'api-reference']) - /** - * Labels a docs/ corpus read as `
/` (e.g. `Workflows/index` - * for docs/documentation/workflows/index.mdx). The tab segment is dropped and - * single-level pages show just their capitalized name (e.g. `Getting-started`, - * or `Workflows` for the api-reference tag file workflows.json). + * Labels a docs/ corpus read as `
/` (e.g. `Workflows/agent` for + * docs/workflows/blocks/agent.mdx). Top-level pages show just their capitalized + * name (e.g. `Getting-started` for docs/getting-started.mdx). */ function describeDocsReadTarget(segments: string[]): string { - let rest = segments.slice(1) - if (rest.length > 0 && DOCS_TAB_SEGMENTS.has(rest[0])) { - rest = rest.slice(1) - } + const rest = segments.slice(1) if (rest.length === 0) return 'docs' const leaf = stripExtension(rest[rest.length - 1]) if (rest.length === 1) return capitalizeFirst(leaf) diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.ts b/apps/sim/lib/copilot/tools/handlers/vfs.ts index f5ba4c4aa15..57f5ec09ae7 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.ts @@ -2,6 +2,14 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import { getBlockVisibilityForCopilot } from '@/lib/copilot/block-visibility' import { TOOL_RESULT_MAX_INLINE_CHARS } from '@/lib/copilot/constants' +import { + couldMatchDocsScope, + DocsCorpusError, + globDocs, + grepDocsPage, + isDocsPath, + readDocsPage, +} from '@/lib/copilot/docs/docs-corpus' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import type { SecretMountPolicy } from '@/lib/copilot/secret-mount-policy' import { getOrMaterializeVFS } from '@/lib/copilot/vfs' @@ -137,14 +145,17 @@ export async function executeVfsGrep( // Routing mirrors read/glob: // - uploads/ -> grep one chat upload's content (chat-scoped) + // - docs/ -> grep one docs.sim.ai page (one page only — each is a fetch) // - files/ -> grep one workspace file's content (one file only) // - everything else -> grep the in-memory VFS map (workflow JSON, metadata) - // Chat uploads are opt-in like recently-deleted/: they are never in the VFS - // map, so an unscoped grep can't touch them — only an explicit uploads/ - // path does, and only one upload at a time. + // Chat uploads and the docs corpus are opt-in like recently-deleted/: they are + // never in the VFS map, so an unscoped grep can't touch them — only an explicit + // uploads/ or docs/ path does, and only one at a time. let result: GrepMatch[] | string[] | GrepCountEntry[] let provenanceFile: WorkspaceFileSecretProvenanceIdentity | undefined - if (isChatUploadGrepPath(rawPath)) { + if (rawPath !== undefined && isDocsPath(rawPath)) { + result = await grepDocsPage(rawPath, pattern, grepOptions) + } else if (isChatUploadGrepPath(rawPath)) { if (!context.chatId) { return { success: false, error: 'No chat context available for uploads/' } } @@ -206,8 +217,8 @@ export async function executeVfsGrep( } catch (err) { // Expected single-file scoping / no-text / too-large conditions: surface the // message verbatim instead of logging an internal failure. - if (err instanceof WorkspaceFileGrepError) { - logger.debug('vfs_grep workspace file rejected', { + if (err instanceof WorkspaceFileGrepError || err instanceof DocsCorpusError) { + logger.debug('vfs_grep single-file scope rejected', { pattern, path: rawPath, error: err.message, @@ -238,6 +249,15 @@ export async function executeVfsGlob( } try { + // The docs corpus is a lazy view of docs.sim.ai built from the generated + // manifest, not part of the workspace VFS — an explicit docs/ pattern is the + // only way to see it. + if (couldMatchDocsScope(pattern)) { + const files = globDocs(pattern) + logger.debug('vfs_glob docs result', { pattern, fileCount: files.length }) + return { success: true, output: { files } } + } + const vfs = await getGatedVFS(workspaceId, context.userId, context.secretMountPolicy) let files = vfs.glob(pattern) @@ -306,6 +326,21 @@ export async function executeVfsRead( } } + // Docs pages are fetched from the live docs site on demand — the manifest + // path is the URL path, so there is nothing workspace-scoped to resolve. + if (isDocsPath(path)) { + const page = await readDocsPage(path) + const windowed = applyWindow(page) + if (serializedResultSize(windowed) > TOOL_RESULT_MAX_INLINE_CHARS) { + return { + success: false, + error: `${path} is too large to return inline. Grep that one page for the relevant section, then retry read with offset/limit.`, + } + } + logger.debug('vfs_read resolved docs page', { path, totalLines: page.totalLines }) + return { success: true, output: windowed } + } + // Handle chat-scoped uploads via the uploads/ virtual prefix. // Uploads are flat and have no metadata/content split like files/ — the upload // IS the first path segment after uploads/. Any trailing segment (e.g. a @@ -443,6 +478,12 @@ export async function executeVfsRead( output: result, } } catch (err) { + // Expected docs-corpus conditions (unknown page, directory path, site + // unreachable): surface the message verbatim. + if (err instanceof DocsCorpusError) { + logger.debug('vfs_read docs page rejected', { path, error: err.message }) + return { success: false, error: err.message } + } logger.error('vfs_read failed', { path, error: toError(err).message, diff --git a/apps/sim/lib/copilot/tools/server/docs/search-docs.test.ts b/apps/sim/lib/copilot/tools/server/docs/search-docs.test.ts deleted file mode 100644 index 4d0077f5540..00000000000 --- a/apps/sim/lib/copilot/tools/server/docs/search-docs.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * @vitest-environment node - */ -import { describe, expect, it, vi } from 'vitest' - -vi.mock('@/lib/knowledge/embeddings', () => ({ - generateSearchEmbedding: vi.fn(), -})) - -import { docsScopeTail } from '@/lib/copilot/tools/server/docs/search-docs' - -describe('docsScopeTail', () => { - it('returns undefined for an unscoped search', () => { - expect(docsScopeTail(undefined)).toBeUndefined() - expect(docsScopeTail('')).toBeUndefined() - expect(docsScopeTail(' ')).toBeUndefined() - }) - - it('treats the bare docs/documentation prefix as unscoped', () => { - expect(docsScopeTail('docs/documentation')).toBeUndefined() - expect(docsScopeTail('docs/documentation/')).toBeUndefined() - expect(docsScopeTail('/docs/documentation/')).toBeUndefined() - }) - - it('maps directory scopes to their source_document tail', () => { - expect(docsScopeTail('docs/documentation/workflows')).toBe('workflows') - expect(docsScopeTail('/docs/documentation/workflows/')).toBe('workflows') - expect(docsScopeTail('docs/documentation/integrations/gmail')).toBe('integrations/gmail') - }) - - it('maps file scopes by stripping the mdx extension', () => { - expect(docsScopeTail('docs/documentation/agents/choosing.mdx')).toBe('agents/choosing') - expect(docsScopeTail('docs/documentation/workflows/index.mdx')).toBe('workflows') - }) - - it('rejects paths outside docs/documentation/', () => { - expect(() => docsScopeTail('docs/academy/agents')).toThrow(/must start with/) - expect(() => docsScopeTail('docs/api-reference/workflows.json')).toThrow(/must start with/) - expect(() => docsScopeTail('workflows')).toThrow(/must start with/) - expect(() => docsScopeTail('docs/documentation-extra/foo')).toThrow(/must start with/) - }) -}) diff --git a/apps/sim/lib/copilot/tools/server/docs/search-docs.ts b/apps/sim/lib/copilot/tools/server/docs/search-docs.ts index dcc3b6d6b67..cf88c0bfa1b 100644 --- a/apps/sim/lib/copilot/tools/server/docs/search-docs.ts +++ b/apps/sim/lib/copilot/tools/server/docs/search-docs.ts @@ -1,10 +1,6 @@ -import { db } from '@sim/db' -import { docsEmbeddings } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { and, eq, like, notLike, or, sql } from 'drizzle-orm' +import { searchDocs } from '@/lib/copilot/docs/docs-search' import { SearchDocs } from '@/lib/copilot/generated/tool-catalog-v1' import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' -import { generateSearchEmbedding } from '@/lib/knowledge/embeddings' interface SearchDocsParams { query: string @@ -12,99 +8,21 @@ interface SearchDocsParams { path?: string } -const DEFAULT_DOCS_SIMILARITY_THRESHOLD = 0.3 -const DEFAULT_TOP_K = 10 -const MAX_TOP_K = 25 -const DOCS_DOCUMENTATION_PREFIX = 'docs/documentation' - -/** - * Maps a docs/documentation/... VFS path onto a docs_embeddings source_document - * scope tail. VFS paths mirror docs.sim.ai URLs while source_document stores - * the en-relative mdx path, so a scope tail must cover both layouts a page can - * have on disk: `.mdx` and `/...` (including `/index.mdx`). - * Returns undefined for an unscoped search; throws when the path does not - * address docs/documentation/. - */ -export function docsScopeTail(path?: string): string | undefined { - if (!path || path.trim() === '') return undefined - const normalized = path.trim().replace(/^\.?\//, '') - if ( - normalized !== DOCS_DOCUMENTATION_PREFIX && - !normalized.startsWith(`${DOCS_DOCUMENTATION_PREFIX}/`) - ) { - throw new Error(`path must start with ${DOCS_DOCUMENTATION_PREFIX}/ (got "${path}")`) - } - const tail = normalized - .slice(DOCS_DOCUMENTATION_PREFIX.length) - .replace(/^\/+|\/+$/g, '') - .replace(/\/index\.mdx$/, '') - .replace(/\.mdx$/, '') - return tail === '' ? undefined : tail -} - -function escapeLikePattern(value: string): string { - return value.replace(/[\\%_]/g, (char) => `\\${char}`) +interface SearchDocsOutput { + results: Awaited> + query: string + totalResults: number } /** - * Unscoped searches cover exactly the Documentation tab (everything under the - * docs/documentation/ VFS tree), so Academy and API-reference rows are - * excluded; a scope tail narrows to one page or directory subtree. + * Vector search over Sim's product documentation, scoped to the same pages the + * agent can `read` from the `docs/` VFS tree. Search-agent only; the corpus + * logic lives in `@/lib/copilot/docs/docs-search`. */ -function scopeCondition(tail?: string) { - if (!tail) { - return and( - notLike(docsEmbeddings.sourceDocument, 'academy/%'), - notLike(docsEmbeddings.sourceDocument, 'api-reference/%') - ) - } - return or( - eq(docsEmbeddings.sourceDocument, `${tail}.mdx`), - like(docsEmbeddings.sourceDocument, `${escapeLikePattern(tail)}/%`) - ) -} - -export const searchDocsServerTool: BaseServerTool = { +export const searchDocsServerTool: BaseServerTool = { name: SearchDocs.id, - async execute(params: SearchDocsParams): Promise { - const logger = createLogger('SearchDocsServerTool') - const { query, path } = params - if (!query || typeof query !== 'string') throw new Error('query is required') - const topK = Math.min(Math.max(Math.trunc(params.topK ?? DEFAULT_TOP_K), 1), MAX_TOP_K) - const scopeTail = docsScopeTail(path) - - logger.info('Executing docs search', { query, topK, path: path ?? null }) - - const { embedding: queryEmbedding } = await generateSearchEmbedding(query) - if (!queryEmbedding || queryEmbedding.length === 0) { - return { results: [], query, totalResults: 0 } - } - - const results = await db - .select({ - chunkId: docsEmbeddings.chunkId, - chunkText: docsEmbeddings.chunkText, - sourceDocument: docsEmbeddings.sourceDocument, - sourceLink: docsEmbeddings.sourceLink, - headerText: docsEmbeddings.headerText, - headerLevel: docsEmbeddings.headerLevel, - similarity: sql`1 - (${docsEmbeddings.embedding} <=> ${JSON.stringify(queryEmbedding)}::vector)`, - }) - .from(docsEmbeddings) - .where(scopeCondition(scopeTail)) - .orderBy(sql`${docsEmbeddings.embedding} <=> ${JSON.stringify(queryEmbedding)}::vector`) - .limit(topK) - - const filteredResults = results.filter((r) => r.similarity >= DEFAULT_DOCS_SIMILARITY_THRESHOLD) - const documentationResults = filteredResults.map((r, idx) => ({ - id: idx + 1, - title: String(r.headerText || 'Untitled Section'), - url: String(r.sourceLink || '#'), - content: String(r.chunkText || ''), - similarity: r.similarity, - })) - - logger.info('Docs search complete', { count: documentationResults.length }) - return { results: documentationResults, query, totalResults: documentationResults.length } + async execute(params: SearchDocsParams): Promise { + const results = await searchDocs(params.query, { path: params.path, topK: params.topK }) + return { results, query: params.query, totalResults: results.length } }, } diff --git a/apps/sim/lib/copilot/tools/tool-display.test.ts b/apps/sim/lib/copilot/tools/tool-display.test.ts index 68a537d16ef..9fd352aff15 100644 --- a/apps/sim/lib/copilot/tools/tool-display.test.ts +++ b/apps/sim/lib/copilot/tools/tool-display.test.ts @@ -78,19 +78,6 @@ describe('getToolDisplayTitle natural-language coverage', () => { expect(getToolDisplayTitle('diff_workflows')).toBe('Comparing workflows') }) - it('includes the query in search_docs titles', () => { - expect(getToolDisplayTitle('search_docs')).toBe('Searching docs') - expect(getToolDisplayTitle('search_docs', { query: 'loop blocks iteration' })).toBe( - 'Searching docs for "loop blocks iteration"' - ) - expect( - getToolDisplayTitle('search_docs', { - query: - 'reference block outputs connection tags blockname.field pass data between blocks in a workflow', - })?.length - ).toBeLessThanOrEqual('Searching docs for ""'.length + 60 + '...'.length) - }) - it('falls back to running code for function_execute without a title', () => { expect(getToolDisplayTitle('function_execute')).toBe('Running code') expect(getToolDisplayTitle('function_execute', { title: 'Crunching numbers' })).toBe( diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index c8803a3d42e..ebadb3fd4b1 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -1,4 +1,4 @@ -import { stripVersionSuffix, truncate } from '@sim/utils/string' +import { stripVersionSuffix } from '@sim/utils/string' /** * Single source of truth for copilot tool-call display titles. @@ -793,10 +793,6 @@ export function getToolDisplayTitle(name: string, args?: Record const target = firstStringArg(args, 'toolTitle', 'title') return target ? `Searching online for ${target}` : 'Searching online' } - case 'search_docs': { - const target = firstStringArg(args, 'toolTitle', 'title', 'query') - return target ? `Searching docs for "${truncate(target, 60)}"` : 'Searching docs' - } case 'grep': { const target = firstStringArg(args, 'toolTitle', 'title') return target ? `Searching for ${target}` : 'Searching' diff --git a/package.json b/package.json index f0b2d645a5e..caecba56446 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,8 @@ "metrics-contract:check": "bun run scripts/sync-metrics-contract.ts --check", "vfs-snapshot-contract:generate": "bun run scripts/sync-vfs-snapshot-contract.ts", "vfs-snapshot-contract:check": "bun run scripts/sync-vfs-snapshot-contract.ts --check", + "docs-manifest:generate": "bun run scripts/sync-docs-manifest.ts", + "docs-manifest:check": "bun run scripts/sync-docs-manifest.ts --check", "mship:generate": "bun run scripts/generate-mship-contracts.ts", "mship:check": "bun run scripts/generate-mship-contracts.ts --check", "library:covers": "bun run scripts/generate-library-covers.tsx", diff --git a/scripts/sync-docs-manifest.ts b/scripts/sync-docs-manifest.ts new file mode 100644 index 00000000000..2d81f277331 --- /dev/null +++ b/scripts/sync-docs-manifest.ts @@ -0,0 +1,108 @@ +/** + * Generate the static docs manifest the copilot's `docs/` VFS tree is built from. + * + * Source of truth: `apps/docs/content/docs/en/**\/*.mdx` — the English docs + * corpus, whose folder structure mirrors the public docs.sim.ai URL structure. + * The copilot never reads those files from disk (they are not deployed with + * `apps/sim`); it globs this manifest for structure and fetches page content + * from the live site on demand. That makes the manifest the one thing that can + * drift, hence `--check` in CI. + * + * Path derivation (each entry is BOTH the `docs/`-relative VFS path and the + * docs.sim.ai URL path, so a read is a plain fetch of `https://docs.sim.ai/`): + * - `workflows/blocks/agent.mdx` → `workflows/blocks/agent.mdx` + * - `workflows/index.mdx` → `workflows.mdx` (fumadocs folds index pages + * into their parent URL; `/workflows/index.mdx` + * is a 404 on the site) + * + * Excluded, and intentionally absent from the VFS: `academy/` and + * `api-reference/` (fetch those with the scrape tool if ever needed), the root + * `index.mdx` (its URL is `/`, which redirects), and every non-`en` locale. + * + * Usage: + * bun run docs-manifest:generate # write the manifest + * bun run docs-manifest:check # fail (exit 1) if the manifest is stale + */ +import { readdir, readFile, writeFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { formatGeneratedSource } from './format-generated-source' + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) +const ROOT = resolve(SCRIPT_DIR, '..') +const DOCS_CONTENT_DIR = resolve(ROOT, 'apps/docs/content/docs/en') +const OUTPUT_PATH = resolve(ROOT, 'apps/sim/lib/copilot/generated/docs-manifest.ts') + +/** Top-level docs sections deliberately left out of the copilot's `docs/` tree. */ +const EXCLUDED_SECTIONS = new Set(['academy', 'api-reference']) + +/** Collect every `.mdx` file under `dir`, as paths relative to {@link DOCS_CONTENT_DIR}. */ +async function collectMdxPaths(dir: string, prefix = ''): Promise { + const entries = await readdir(dir, { withFileTypes: true }) + const paths: string[] = [] + for (const entry of entries) { + const relative = prefix ? `${prefix}/${entry.name}` : entry.name + if (entry.isDirectory()) { + if (prefix === '' && EXCLUDED_SECTIONS.has(entry.name)) continue + paths.push(...(await collectMdxPaths(resolve(dir, entry.name), relative))) + continue + } + if (entry.isFile() && entry.name.endsWith('.mdx')) paths.push(relative) + } + return paths +} + +/** Map an `en`-relative mdx file path to its docs.sim.ai URL path, or null to drop it. */ +function toDocsPath(mdxPath: string): string | null { + if (mdxPath === 'index.mdx') return null + return mdxPath.replace(/\/index\.mdx$/, '.mdx') +} + +function render(paths: string[]): string { + const entries = paths.map((path) => ` '${path}',`).join('\n') + return `// AUTO-GENERATED FILE. DO NOT EDIT. +// Generated from apps/docs/content/docs/en by scripts/sync-docs-manifest.ts +// Run: bun run docs-manifest:generate +// + +/** + * Every page in the copilot's read-only \`docs/\` VFS tree, as a path that is + * simultaneously the \`docs/\`-relative VFS path and the docs.sim.ai URL path + * (so \`docs/workflows/blocks/agent.mdx\` reads + * \`https://docs.sim.ai/workflows/blocks/agent.mdx\`). Sorted. + */ +export const DOCS_MANIFEST: readonly string[] = [ +${entries} +] +` +} + +async function main() { + const checkOnly = process.argv.includes('--check') + + const mdxPaths = await collectMdxPaths(DOCS_CONTENT_DIR) + const docsPaths = mdxPaths + .map(toDocsPath) + .filter((path): path is string => path !== null) + .sort() + + if (docsPaths.length === 0) { + throw new Error(`No docs pages found under ${DOCS_CONTENT_DIR}`) + } + + const rendered = formatGeneratedSource(render(docsPaths), OUTPUT_PATH, ROOT) + + if (checkOnly) { + const existing = await readFile(OUTPUT_PATH, 'utf8').catch(() => null) + if (existing !== rendered) { + throw new Error( + 'Generated docs manifest is stale — the docs tree changed (page added, removed, or renamed). Run: bun run docs-manifest:generate' + ) + } + return + } + + await writeFile(OUTPUT_PATH, rendered, 'utf8') +} + +await main() From bb0be4c843d966e682e516fc9bf24b0914885331 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:13:29 -0700 Subject: [PATCH 05/24] fix(review): act on docs-vfs review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-agent review of the docs/ VFS change. Applied the behavior-preserving fixes plus two agent-facing bugs that made real pages unreadable. - docs read no longer hard-fails on oversized pages. Six+ live integration references exceed the inline cap (github.mdx is 354KB, sportmonks 513KB), so a plain read of them ALWAYS failed and cost a second fetch to recover. Truncate to the largest whole-line prefix that fits, keep the true totalLines, and tell the model how to page. An explicit offset/limit that still overflows is still an error — that one is a caller mistake. - classify docs fetch failures. Everything collapsed to null, so a permanent 404 was reported to the agent as "temporarily unavailable, retry shortly", inviting a retry loop on a page that will never exist. 4xx (except 429) is now permanent and says so; 5xx/429/network/timeout keep the retry wording. - register search_documentation as a transitional alias for search_docs. sim and mothership deploy independently and the rename deleted the old id on both sides, so BOTH deploy orders broke docs lookup for the window between them. Old params are a subset of the new. Remove once both ship. - extract the index-page fold (X/index.mdx <-> X.mdx) into docs-path.ts. It was re-derived in three places — the manifest generator, the source_document reverse mapping, and the search scope filter — which is the hand-synced-duplicate shape that has drifted in this repo before. - grepDocsPage now goes through grepReadResult, the primitive files/ and uploads/ grep already use, instead of calling grep directly. - couldMatchDocsScope delegates to isDocsPath; the bodies were identical. - drop the dead 'docs' member from AgentContextType. Tests: 404-vs-5xx-vs-429 classification, network failure, and a docs-path round-trip asserting one source candidate reproduces every manifest entry. Verified: tsc clean, 932 copilot tests, biome clean, docs-manifest:check, check:utils and check:api-validation:strict both pass. Co-Authored-By: Claude Opus 5 (1M context) --- apps/sim/lib/copilot/chat/process-contents.ts | 1 - apps/sim/lib/copilot/docs/docs-corpus.test.ts | 21 ++++++++- apps/sim/lib/copilot/docs/docs-corpus.ts | 43 +++++++++++------ apps/sim/lib/copilot/docs/docs-path.test.ts | 35 ++++++++++++++ apps/sim/lib/copilot/docs/docs-path.ts | 36 ++++++++++++++ apps/sim/lib/copilot/docs/docs-search.ts | 7 +-- apps/sim/lib/copilot/tools/handlers/vfs.ts | 47 +++++++++++++++++-- apps/sim/lib/copilot/tools/server/router.ts | 5 ++ scripts/sync-docs-manifest.ts | 3 +- 9 files changed, 174 insertions(+), 24 deletions(-) create mode 100644 apps/sim/lib/copilot/docs/docs-path.test.ts create mode 100644 apps/sim/lib/copilot/docs/docs-path.ts diff --git a/apps/sim/lib/copilot/chat/process-contents.ts b/apps/sim/lib/copilot/chat/process-contents.ts index 581d57c88d4..c472bf1fc9c 100644 --- a/apps/sim/lib/copilot/chat/process-contents.ts +++ b/apps/sim/lib/copilot/chat/process-contents.ts @@ -56,7 +56,6 @@ type AgentContextType = | 'file' | 'file_selection' | 'workflow_block' - | 'docs' | 'folder' | 'filefolder' | 'active_resource' diff --git a/apps/sim/lib/copilot/docs/docs-corpus.test.ts b/apps/sim/lib/copilot/docs/docs-corpus.test.ts index 0142ee4f169..31117855a08 100644 --- a/apps/sim/lib/copilot/docs/docs-corpus.test.ts +++ b/apps/sim/lib/copilot/docs/docs-corpus.test.ts @@ -93,10 +93,29 @@ describe('readDocsPage', () => { expect(fetchMock).not.toHaveBeenCalled() }) - it('surfaces a docs-site failure as a retryable error', async () => { + it('surfaces a docs-site outage as a retryable error', async () => { fetchMock.mockResolvedValue({ ok: false, status: 502, text: async () => '' }) await expect(readDocsPage(`docs/${SAMPLE_PAGE}`)).rejects.toThrow(/temporarily unavailable/) }) + + it('treats a network failure as retryable', async () => { + fetchMock.mockRejectedValue(new Error('socket hang up')) + await expect(readDocsPage(`docs/${SAMPLE_PAGE}`)).rejects.toThrow(/temporarily unavailable/) + }) + + it('reports a page the site no longer serves as permanent, not retryable', async () => { + fetchMock.mockResolvedValue({ ok: false, status: 404, text: async () => '' }) + const error = await readDocsPage(`docs/${SAMPLE_PAGE}`).catch((e) => e) + expect(error).toBeInstanceOf(DocsCorpusError) + expect(error.message).toMatch(/does not serve it/) + expect(error.message).toMatch(/retrying will not help/) + expect(error.message).not.toMatch(/temporarily unavailable/) + }) + + it('still treats 429 as retryable rather than permanent', async () => { + fetchMock.mockResolvedValue({ ok: false, status: 429, text: async () => '' }) + await expect(readDocsPage(`docs/${SAMPLE_PAGE}`)).rejects.toThrow(/temporarily unavailable/) + }) }) describe('grepDocsPage', () => { diff --git a/apps/sim/lib/copilot/docs/docs-corpus.ts b/apps/sim/lib/copilot/docs/docs-corpus.ts index 5a5c3f1d7ee..16a202a4f84 100644 --- a/apps/sim/lib/copilot/docs/docs-corpus.ts +++ b/apps/sim/lib/copilot/docs/docs-corpus.ts @@ -1,8 +1,9 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { foldDocsIndexPath } from '@/lib/copilot/docs/docs-path' import { DOCS_MANIFEST } from '@/lib/copilot/generated/docs-manifest' import type { GrepCountEntry, GrepMatch, GrepOptions } from '@/lib/copilot/vfs/operations' -import { glob as globPaths, grep as grepFiles } from '@/lib/copilot/vfs/operations' +import { glob as globPaths, grepReadResult } from '@/lib/copilot/vfs/operations' const logger = createLogger('DocsCorpus') @@ -56,12 +57,11 @@ export function isDocsPath(path: string | undefined): boolean { * True when a glob `pattern` could match the docs corpus. Like `uploads/` and * `recently-deleted/`, the corpus is opt-in: only a pattern that explicitly * starts with `docs/` (or is exactly `docs`) sees it, so a broad `**` glob never - * drags 300+ doc pages into the result. + * drags 300+ doc pages into the result. Same rule as {@link isDocsPath}; the + * separate name reads correctly at the glob call site. */ export function couldMatchDocsScope(pattern: string | undefined): boolean { - if (!pattern) return false - const normalized = normalize(pattern) - return normalized === 'docs' || normalized.startsWith(DOCS_PREFIX) + return isDocsPath(pattern) } /** Manifest paths (and their virtual directories) matching an explicit `docs/` pattern. */ @@ -82,7 +82,7 @@ export function isDocsPage(path: string): boolean { */ export function docsPathForSourceDocument(sourceDocument: string | null): string | null { if (!sourceDocument) return null - const path = `${DOCS_PREFIX}${sourceDocument.replace(/^\/+/, '').replace(/\/index\.mdx$/, '.mdx')}` + const path = `${DOCS_PREFIX}${foldDocsIndexPath(sourceDocument.replace(/^\/+/, ''))}` return docsKeyView.has(path) ? path : null } @@ -108,9 +108,16 @@ export interface DocsPage { * to its raw-markdown route), so no mapping table is needed. Returns null when * the page is not in the manifest or the site does not serve it. */ -async function fetchDocsPage(path: string): Promise { +type DocsFetchResult = + | { outcome: 'ok'; content: string } + /** The site will not serve this path however many times we ask. */ + | { outcome: 'missing' } + /** Transient: 5xx, 429, network error, or timeout. */ + | { outcome: 'unavailable' } + +async function fetchDocsPage(path: string): Promise { const key = normalize(path) - if (!docsKeyView.has(key)) return null + if (!docsKeyView.has(key)) return { outcome: 'missing' } const url = `${DOCS_BASE_URL}/${key.slice(DOCS_PREFIX.length)}` try { const response = await fetch(url, { @@ -119,12 +126,13 @@ async function fetchDocsPage(path: string): Promise { }) if (!response.ok) { logger.warn('Docs page fetch returned a non-OK status', { url, status: response.status }) - return null + const permanent = response.status >= 400 && response.status < 500 && response.status !== 429 + return { outcome: permanent ? 'missing' : 'unavailable' } } - return await response.text() + return { outcome: 'ok', content: await response.text() } } catch (err) { logger.warn('Docs page fetch failed', { url, error: toError(err).message }) - return null + return { outcome: 'unavailable' } } } @@ -144,13 +152,18 @@ export async function readDocsPage(path: string): Promise { `Docs page not found: ${path}. Use glob("docs/**") to list the docs corpus.` ) } - const content = await fetchDocsPage(key) - if (content === null) { + const result = await fetchDocsPage(key) + if (result.outcome === 'missing') { + throw new DocsCorpusError( + `${key} is in the docs index but ${DOCS_BASE_URL} does not serve it — the page was likely moved or removed. Use glob("docs/**") to find the current path; retrying will not help.` + ) + } + if (result.outcome === 'unavailable') { throw new DocsCorpusError( `Could not load ${key} from ${DOCS_BASE_URL} — the docs site is temporarily unavailable. Retry shortly.` ) } - return { content, totalLines: content.split('\n').length } + return { content: result.content, totalLines: result.content.split('\n').length } } /** @@ -170,5 +183,5 @@ export async function grepDocsPage( ) } const page = await readDocsPage(key) - return grepFiles(new Map([[key, page.content]]), pattern, undefined, options) + return grepReadResult(key, page, pattern, key, options) } diff --git a/apps/sim/lib/copilot/docs/docs-path.test.ts b/apps/sim/lib/copilot/docs/docs-path.test.ts new file mode 100644 index 00000000000..c40ba0a7abb --- /dev/null +++ b/apps/sim/lib/copilot/docs/docs-path.test.ts @@ -0,0 +1,35 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { docsSourceCandidates, foldDocsIndexPath } from '@/lib/copilot/docs/docs-path' +import { DOCS_MANIFEST } from '@/lib/copilot/generated/docs-manifest' + +describe('foldDocsIndexPath', () => { + it('folds a section overview onto the section path', () => { + expect(foldDocsIndexPath('workflows/index.mdx')).toBe('workflows.mdx') + expect(foldDocsIndexPath('platform/enterprise/index.mdx')).toBe('platform/enterprise.mdx') + }) + + it('leaves a plain page untouched', () => { + expect(foldDocsIndexPath('workflows/blocks/agent.mdx')).toBe('workflows/blocks/agent.mdx') + expect(foldDocsIndexPath('agents.mdx')).toBe('agents.mdx') + }) + + it('does not fold a page merely named index', () => { + expect(foldDocsIndexPath('index.mdx')).toBe('index.mdx') + }) +}) + +describe('docsSourceCandidates', () => { + it('is the inverse of the fold — one candidate always reproduces the input', () => { + for (const publicPath of DOCS_MANIFEST) { + const candidates = docsSourceCandidates(publicPath) + expect(candidates.map(foldDocsIndexPath)).toContain(publicPath) + } + }) + + it('offers both on-disk layouts for a section path', () => { + expect(docsSourceCandidates('workflows.mdx')).toEqual(['workflows.mdx', 'workflows/index.mdx']) + }) +}) diff --git a/apps/sim/lib/copilot/docs/docs-path.ts b/apps/sim/lib/copilot/docs/docs-path.ts new file mode 100644 index 00000000000..650fd2977ac --- /dev/null +++ b/apps/sim/lib/copilot/docs/docs-path.ts @@ -0,0 +1,36 @@ +/** + * The single definition of how a docs source file maps onto its public path. + * + * Fumadocs folds a section's `index.mdx` into the section URL itself, so + * `workflows/index.mdx` on disk is `/workflows` on the site (and + * `/workflows/index.mdx` is a 404). Three places need that rule — the manifest + * generator, the `source_document` -> VFS reverse mapping, and the vector + * search's scope filter — and hand-syncing it has bitten this repo before, so + * it lives here. + * + * Deliberately dependency-free: `scripts/sync-docs-manifest.ts` imports this by + * relative path, and it must not pull in the manifest it generates. + */ + +/** Suffix that marks a section overview page on disk. */ +export const DOCS_INDEX_SUFFIX = '/index.mdx' + +/** + * Fold an `en`-relative mdx file path onto its public path — the value used as + * both the `docs/`-relative VFS path and the docs.sim.ai URL path. + */ +export function foldDocsIndexPath(mdxPath: string): string { + return mdxPath.endsWith(DOCS_INDEX_SUFFIX) + ? `${mdxPath.slice(0, -DOCS_INDEX_SUFFIX.length)}.mdx` + : mdxPath +} + +/** + * The inverse of {@link foldDocsIndexPath}: the on-disk file names a public + * path could have come from. A page is stored either as `.mdx` or, when + * it is a section overview, as `/index.mdx`. + */ +export function docsSourceCandidates(publicPath: string): [string, string] { + const stem = publicPath.replace(/\.mdx$/, '') + return [`${stem}.mdx`, `${stem}${DOCS_INDEX_SUFFIX}`] +} diff --git a/apps/sim/lib/copilot/docs/docs-search.ts b/apps/sim/lib/copilot/docs/docs-search.ts index 0f5553a4858..37679cc867b 100644 --- a/apps/sim/lib/copilot/docs/docs-search.ts +++ b/apps/sim/lib/copilot/docs/docs-search.ts @@ -3,6 +3,7 @@ import { docsEmbeddings } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, like, notLike, or, sql } from 'drizzle-orm' import { docsPathForSourceDocument, isDocsDir, isDocsPage } from '@/lib/copilot/docs/docs-corpus' +import { docsSourceCandidates } from '@/lib/copilot/docs/docs-path' import { generateSearchEmbedding } from '@/lib/knowledge/embeddings' const logger = createLogger('DocsSearch') @@ -65,10 +66,10 @@ function scopeCondition(path?: string) { if (isDocsPage(normalized)) { // One page: on disk it is either `.mdx` or `/index.mdx`. - const stem = tail.replace(/\.mdx$/, '') + const [pageFile, indexFile] = docsSourceCandidates(tail) return or( - eq(docsEmbeddings.sourceDocument, `${stem}.mdx`), - eq(docsEmbeddings.sourceDocument, `${stem}/index.mdx`) + eq(docsEmbeddings.sourceDocument, pageFile), + eq(docsEmbeddings.sourceDocument, indexFile) ) } diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.ts b/apps/sim/lib/copilot/tools/handlers/vfs.ts index 57f5ec09ae7..b74702dbe64 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.ts @@ -117,6 +117,33 @@ async function canReturnWorkspaceFileValue( }) } +/** + * Trim an oversized docs page to the largest whole-line prefix that fits the + * inline budget, preserving the true `totalLines` so the model can page through + * the rest with offset/limit. + */ +function truncateDocsPageToInlineCap(page: { content: string; totalLines: number }): { + output: { content: string; totalLines: number } + returnedLines: number +} { + const lines = page.content.split('\n') + const notice = (shown: number) => + `\n\n[Page truncated: showing lines 1-${shown} of ${page.totalLines}. Grep this path for the section you need, then read with offset/limit.]` + + let kept = lines.length + let content = page.content + while (kept > 0) { + content = `${lines.slice(0, kept).join('\n')}${notice(kept)}` + if ( + serializedResultSize({ content, totalLines: page.totalLines }) <= TOOL_RESULT_MAX_INLINE_CHARS + ) { + break + } + kept = Math.floor(kept / 2) + } + return { output: { content, totalLines: page.totalLines }, returnedLines: kept } +} + export async function executeVfsGrep( params: Record, context: ExecutionContext @@ -332,10 +359,24 @@ export async function executeVfsRead( const page = await readDocsPage(path) const windowed = applyWindow(page) if (serializedResultSize(windowed) > TOOL_RESULT_MAX_INLINE_CHARS) { - return { - success: false, - error: `${path} is too large to return inline. Grep that one page for the relevant section, then retry read with offset/limit.`, + // Several real docs pages (the largest integration references) exceed the + // inline cap, so failing here would make a plain read of them always fail + // and cost a second fetch to recover. Truncate to what fits instead and + // tell the model how to page — but only when it did not ask for a window, + // since an explicit offset/limit that still overflows is a caller error. + if (offset !== undefined || limit !== undefined) { + return { + success: false, + error: `${path} is still too large over the requested window. Narrow offset/limit, or grep this page for the section you need.`, + } } + const truncated = truncateDocsPageToInlineCap(page) + logger.debug('vfs_read truncated oversized docs page', { + path, + totalLines: page.totalLines, + returnedLines: truncated.returnedLines, + }) + return { success: true, output: truncated.output } } logger.debug('vfs_read resolved docs page', { path, totalLines: page.totalLines }) return { success: true, output: windowed } diff --git a/apps/sim/lib/copilot/tools/server/router.ts b/apps/sim/lib/copilot/tools/server/router.ts index 6645647d94d..b652bda2770 100644 --- a/apps/sim/lib/copilot/tools/server/router.ts +++ b/apps/sim/lib/copilot/tools/server/router.ts @@ -156,6 +156,11 @@ const baseServerToolRegistry: Record = { [editWorkflowServerTool.name]: editWorkflowServerTool, [queryLogsServerTool.name]: queryLogsServerTool, [searchDocsServerTool.name]: searchDocsServerTool, + // Transitional alias: sim and mothership deploy independently, so during the + // rollout of the search_documentation -> search_docs rename one side is still + // emitting the old id. The old params are a subset of the new, so routing them + // here is safe. Remove once both repos have shipped the rename. + search_documentation: searchDocsServerTool, [searchOnlineServerTool.name]: searchOnlineServerTool, [setEnvironmentVariablesServerTool.name]: setEnvironmentVariablesServerTool, [getCredentialsServerTool.name]: getCredentialsServerTool, diff --git a/scripts/sync-docs-manifest.ts b/scripts/sync-docs-manifest.ts index 2d81f277331..fa1f0ddcd81 100644 --- a/scripts/sync-docs-manifest.ts +++ b/scripts/sync-docs-manifest.ts @@ -26,6 +26,7 @@ import { readdir, readFile, writeFile } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' +import { foldDocsIndexPath } from '../apps/sim/lib/copilot/docs/docs-path' import { formatGeneratedSource } from './format-generated-source' const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) @@ -55,7 +56,7 @@ async function collectMdxPaths(dir: string, prefix = ''): Promise { /** Map an `en`-relative mdx file path to its docs.sim.ai URL path, or null to drop it. */ function toDocsPath(mdxPath: string): string | null { if (mdxPath === 'index.mdx') return null - return mdxPath.replace(/\/index\.mdx$/, '.mdx') + return foldDocsIndexPath(mdxPath) } function render(paths: string[]): string { From 852e24dfba2d7d74414bcf0918138a71b8eadc99 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:13:40 -0700 Subject: [PATCH 06/24] fix(copilot): explain a short or empty search_docs result set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SQL LIMIT is applied before the similarity-threshold and liveness filters, so search_docs can return fewer hits than topK — or none, when every candidate was filtered. An empty array is indistinguishable from "the documentation does not cover this", which sends the agent off to guess instead of rephrasing or falling back to glob. searchDocs now returns the drop counts alongside the results, and the tool attaches a note when anything was dropped: how many candidates the index returned, why they went, and what to try next. Silent on the common path. This does not change which rows are returned or how many — the ordering issue behind the shortfall is a pre-existing bug the deleted search-documentation.ts had too, and pushing the threshold into SQL is its own change. Co-Authored-By: Claude Opus 5 (1M context) --- apps/sim/lib/copilot/docs/docs-search.test.ts | 55 ++++++++++++++++++- apps/sim/lib/copilot/docs/docs-search.ts | 48 ++++++++++++++-- .../copilot/tools/server/docs/search-docs.ts | 43 ++++++++++++++- 3 files changed, 134 insertions(+), 12 deletions(-) diff --git a/apps/sim/lib/copilot/docs/docs-search.test.ts b/apps/sim/lib/copilot/docs/docs-search.test.ts index 8407f2e336f..c0981a22a82 100644 --- a/apps/sim/lib/copilot/docs/docs-search.test.ts +++ b/apps/sim/lib/copilot/docs/docs-search.test.ts @@ -124,7 +124,7 @@ describe('searchDocs results', () => { similarity: 0.8, }, ] - const results = await searchDocs('cron') + const { results } = await searchDocs('cron') expect(results).toEqual([ { path: 'docs/workflows.mdx', @@ -153,7 +153,7 @@ describe('searchDocs results', () => { similarity: 0.9, }, ] - expect(await searchDocs('cron')).toEqual([]) + expect((await searchDocs('cron')).results).toEqual([]) }) it('drops chunks below the similarity threshold', async () => { @@ -166,6 +166,55 @@ describe('searchDocs results', () => { similarity: 0.1, }, ] - expect(await searchDocs('cron')).toEqual([]) + expect((await searchDocs('cron')).results).toEqual([]) + }) +}) + +describe('searchDocs shortfall reporting', () => { + beforeEach(() => { + capturedWhere.value = undefined + mockGenerateSearchEmbedding.mockResolvedValue({ embedding: [0.1, 0.2] }) + }) + + it('counts why candidates were dropped so an empty set is explainable', async () => { + mockRows.value = [ + { + chunkText: 'a', + sourceDocument: 'agents.mdx', + sourceLink: 'x', + headerText: 'h', + similarity: 0.1, + }, + { + chunkText: 'b', + sourceDocument: 'deleted-page.mdx', + sourceLink: 'y', + headerText: 'h', + similarity: 0.9, + }, + ] + const outcome = await searchDocs('cron') + expect(outcome).toEqual({ + results: [], + candidatesConsidered: 2, + droppedBelowThreshold: 1, + droppedStale: 1, + }) + }) + + it('reports no drops when every candidate survives', async () => { + mockRows.value = [ + { + chunkText: 'a', + sourceDocument: 'agents.mdx', + sourceLink: 'x', + headerText: 'h', + similarity: 0.9, + }, + ] + const outcome = await searchDocs('cron') + expect(outcome.droppedBelowThreshold).toBe(0) + expect(outcome.droppedStale).toBe(0) + expect(outcome.results).toHaveLength(1) }) }) diff --git a/apps/sim/lib/copilot/docs/docs-search.ts b/apps/sim/lib/copilot/docs/docs-search.ts index 37679cc867b..e30145d3a3e 100644 --- a/apps/sim/lib/copilot/docs/docs-search.ts +++ b/apps/sim/lib/copilot/docs/docs-search.ts @@ -27,6 +27,21 @@ export interface DocsSearchResult { * section in the docs corpus. Surfaced verbatim so the model can correct itself * rather than reading an empty result as "the docs say nothing about this". */ +/** + * A search result set plus why it may be shorter than `topK`. The SQL LIMIT is + * applied before the threshold and liveness filters, so these counts are what + * distinguishes "nothing matched" from "matches were filtered out". + */ +export interface DocsSearchOutcome { + results: DocsSearchResult[] + /** Rows the vector search returned before filtering. */ + candidatesConsidered: number + /** Candidates dropped for scoring below the similarity threshold. */ + droppedBelowThreshold: number + /** Candidates dropped because their page is no longer in the docs manifest. */ + droppedStale: number +} + export class DocsSearchScopeError extends Error { readonly code = 'DOCS_SEARCH_SCOPE' as const constructor(message: string) { @@ -94,11 +109,16 @@ function escapeLikePattern(value: string): string { * The index lags the VFS: a page added since the last index rebuild is readable * but not searchable, and a deleted one can still return chunks. Results whose * source no longer maps to a live `docs/` path are dropped. + * + * Because those drops happen after the SQL LIMIT, a caller can get fewer hits + * than it asked for — or none at all when every candidate was filtered. The + * returned {@link DocsSearchOutcome} reports that explicitly so an empty result + * is never mistaken for "the documentation does not cover this". */ export async function searchDocs( query: string, options?: { path?: string; topK?: number } -): Promise { +): Promise { if (!query || typeof query !== 'string') throw new Error('query is required') const topK = Math.min(Math.max(Math.trunc(options?.topK ?? DEFAULT_TOP_K), 1), MAX_TOP_K) @@ -107,7 +127,9 @@ export async function searchDocs( logger.info('Executing docs search', { query, topK, path: options?.path ?? null }) const { embedding: queryEmbedding } = await generateSearchEmbedding(query) - if (!queryEmbedding || queryEmbedding.length === 0) return [] + if (!queryEmbedding || queryEmbedding.length === 0) { + return { results: [], candidatesConsidered: 0, droppedBelowThreshold: 0, droppedStale: 0 } + } const rows = await db .select({ @@ -123,10 +145,18 @@ export async function searchDocs( .limit(topK) const results: DocsSearchResult[] = [] + let droppedBelowThreshold = 0 + let droppedStale = 0 for (const row of rows) { - if (row.similarity < SIMILARITY_THRESHOLD) continue + if (row.similarity < SIMILARITY_THRESHOLD) { + droppedBelowThreshold++ + continue + } const path = docsPathForSourceDocument(row.sourceDocument) - if (!path) continue + if (!path) { + droppedStale++ + continue + } results.push({ path, url: String(row.sourceLink || '#'), @@ -138,7 +168,13 @@ export async function searchDocs( logger.info('Docs search complete', { count: results.length, - dropped: rows.length - results.length, + droppedBelowThreshold, + droppedStale, }) - return results + return { + results, + candidatesConsidered: rows.length, + droppedBelowThreshold, + droppedStale, + } } diff --git a/apps/sim/lib/copilot/tools/server/docs/search-docs.ts b/apps/sim/lib/copilot/tools/server/docs/search-docs.ts index cf88c0bfa1b..96bdc922e2c 100644 --- a/apps/sim/lib/copilot/tools/server/docs/search-docs.ts +++ b/apps/sim/lib/copilot/tools/server/docs/search-docs.ts @@ -1,3 +1,4 @@ +import type { DocsSearchResult } from '@/lib/copilot/docs/docs-search' import { searchDocs } from '@/lib/copilot/docs/docs-search' import { SearchDocs } from '@/lib/copilot/generated/tool-catalog-v1' import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' @@ -9,9 +10,39 @@ interface SearchDocsParams { } interface SearchDocsOutput { - results: Awaited> + results: DocsSearchResult[] query: string totalResults: number + /** + * Present only when the vector search matched chunks that were then filtered + * out. Without it an empty result set reads as "the docs do not cover this", + * which sends the caller off to guess instead of rephrasing or falling back + * to glob. + */ + note?: string +} + +/** + * Explain a short or empty result set in terms the caller can act on. Returns + * undefined when nothing was dropped — the common case needs no commentary. + */ +function shortfallNote(outcome: Awaited>): string | undefined { + const { results, candidatesConsidered, droppedBelowThreshold, droppedStale } = outcome + if (droppedBelowThreshold === 0 && droppedStale === 0) return undefined + + const reasons: string[] = [] + if (droppedBelowThreshold > 0) + reasons.push(`${droppedBelowThreshold} scored too low to be relevant`) + if (droppedStale > 0) { + reasons.push( + `${droppedStale} point at pages no longer in the docs (the search index lags the site)` + ) + } + const dropped = reasons.join(' and ') + + return results.length === 0 + ? `No relevant matches. The search index returned ${candidatesConsidered} candidate(s), but ${dropped} — this does NOT mean the docs lack this topic. Rephrase the query, widen it by dropping the path scope, or browse with glob("docs/**").` + : `Returned ${results.length} of ${candidatesConsidered} candidate(s); ${dropped}. Rephrase or widen the query if these look off-topic.` } /** @@ -22,7 +53,13 @@ interface SearchDocsOutput { export const searchDocsServerTool: BaseServerTool = { name: SearchDocs.id, async execute(params: SearchDocsParams): Promise { - const results = await searchDocs(params.query, { path: params.path, topK: params.topK }) - return { results, query: params.query, totalResults: results.length } + const outcome = await searchDocs(params.query, { path: params.path, topK: params.topK }) + const note = shortfallNote(outcome) + return { + results: outcome.results, + query: params.query, + totalResults: outcome.results.length, + ...(note ? { note } : {}), + } }, } From 7261d4e664c0efec2d81aa25f89be88795536db2 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:02:29 -0700 Subject: [PATCH 07/24] fix(copilot): include a section overview in either layout when scoping search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A directory scope matched only `
/%`, which covers an overview stored as `
/index.mdx` but not one stored as a sibling `
.mdx`. Fumadocs accepts both layouts and page scope already handles both via docsSourceCandidates, so a scoped section search could silently omit the overview chunks — and the doc comment claimed it did not. Every section in the tree currently uses the index.mdx layout, so nothing is broken today; this closes the gap before someone adds a sibling overview and gets quietly incomplete results. --- apps/sim/lib/copilot/docs/docs-search.test.ts | 9 +++++++++ apps/sim/lib/copilot/docs/docs-search.ts | 11 +++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/copilot/docs/docs-search.test.ts b/apps/sim/lib/copilot/docs/docs-search.test.ts index c0981a22a82..5c1a288487e 100644 --- a/apps/sim/lib/copilot/docs/docs-search.test.ts +++ b/apps/sim/lib/copilot/docs/docs-search.test.ts @@ -89,6 +89,15 @@ describe('searchDocs path scoping', () => { expect(whereText()).toContain('workflows/%') }) + it('includes a section overview stored in either on-disk layout', async () => { + await searchDocs('cron', { path: 'docs/workflows' }) + const text = whereText() + // `workflows/index.mdx` is inside the subtree; a sibling `workflows.mdx` is not, + // and fumadocs accepts either, so the scope must name it explicitly. + expect(text).toContain('workflows/%') + expect(text).toContain('workflows.mdx') + }) + it('rejects a path outside the docs corpus', async () => { await expect(searchDocs('cron', { path: 'files/report.pdf' })).rejects.toThrow( DocsSearchScopeError diff --git a/apps/sim/lib/copilot/docs/docs-search.ts b/apps/sim/lib/copilot/docs/docs-search.ts index e30145d3a3e..9c545b40d60 100644 --- a/apps/sim/lib/copilot/docs/docs-search.ts +++ b/apps/sim/lib/copilot/docs/docs-search.ts @@ -56,7 +56,7 @@ export class DocsSearchScopeError extends Error { * `source_document` stores the en-relative mdx file path, while VFS paths mirror * the public URL — so a section overview is `docs/workflows.mdx` in the VFS but * `workflows/index.mdx` (or `workflows.mdx`) on disk. A directory scope covers - * the whole subtree, including that overview page. + * the whole subtree plus the overview in either layout. * * Returns undefined for an unscoped search, which excludes `academy/` and * `api-reference/`: both are indexed but neither is mounted in the VFS, so a hit @@ -89,7 +89,14 @@ function scopeCondition(path?: string) { } if (isDocsDir(normalized)) { - return like(docsEmbeddings.sourceDocument, `${escapeLikePattern(tail)}/%`) + // Everything under the directory, PLUS a sibling `.mdx`. Fumadocs + // accepts either layout for a section overview and only `/index.mdx` + // is inside the subtree, so matching the prefix alone would silently omit + // the overview for the sibling layout — page scope already covers both. + return or( + like(docsEmbeddings.sourceDocument, `${escapeLikePattern(tail)}/%`), + eq(docsEmbeddings.sourceDocument, `${tail}.mdx`) + ) } throw new DocsSearchScopeError( From d42d8b4d0e398f04274169f337247692f6872cf0 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:27:39 -0700 Subject: [PATCH 08/24] fix(copilot): make the search_docs topK clamp type-safe and test it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The clamp guarded magnitude but not type: Math.min/Math.max propagate NaN, so a non-numeric topK reached the query as `.limit(NaN)`. The `?? DEFAULT` only caught undefined. Nothing enforced this but the generated Ajv schema, and searchDocs is also called directly, so it should not depend on that. Extract clampTopK, which falls back to the default for anything non-finite (NaN, Infinity, a string that slipped through) and clamps the rest to [1, 25]. The clamp was completely untested because the db mock's .limit() stub discarded its argument — the mock now records it. Covers default, cap, floor, truncation, and the non-finite fallback. Worth pinning: staging's search_documentation documented "max 10" and enforced nothing, so this bound is new behavior, not just a bigger number. --- apps/sim/lib/copilot/docs/docs-search.test.ts | 50 ++++++++++++++++++- apps/sim/lib/copilot/docs/docs-search.ts | 15 +++++- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/apps/sim/lib/copilot/docs/docs-search.test.ts b/apps/sim/lib/copilot/docs/docs-search.test.ts index 5c1a288487e..a78672d52d6 100644 --- a/apps/sim/lib/copilot/docs/docs-search.test.ts +++ b/apps/sim/lib/copilot/docs/docs-search.test.ts @@ -3,9 +3,10 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGenerateSearchEmbedding, capturedWhere, mockRows } = vi.hoisted(() => ({ +const { mockGenerateSearchEmbedding, capturedWhere, capturedLimit, mockRows } = vi.hoisted(() => ({ mockGenerateSearchEmbedding: vi.fn(), capturedWhere: { value: undefined as unknown }, + capturedLimit: { value: undefined as number | undefined }, mockRows: { value: [] as unknown[] }, })) @@ -38,7 +39,12 @@ vi.mock('@sim/db', () => ({ where: (condition: unknown) => { capturedWhere.value = condition return { - orderBy: () => ({ limit: async () => mockRows.value }), + orderBy: () => ({ + limit: async (n: number) => { + capturedLimit.value = n + return mockRows.value + }, + }), } }, }), @@ -227,3 +233,43 @@ describe('searchDocs shortfall reporting', () => { expect(outcome.results).toHaveLength(1) }) }) + +describe('searchDocs topK clamping', () => { + beforeEach(() => { + capturedLimit.value = undefined + mockRows.value = [] + mockGenerateSearchEmbedding.mockResolvedValue({ embedding: [0.1, 0.2] }) + }) + + it('defaults to 10 when unspecified', async () => { + await searchDocs('cron') + expect(capturedLimit.value).toBe(10) + }) + + it('caps at 25 — the documented max, which the old tool never enforced', async () => { + await searchDocs('cron', { topK: 500 }) + expect(capturedLimit.value).toBe(25) + }) + + it('floors at 1', async () => { + await searchDocs('cron', { topK: 0 }) + expect(capturedLimit.value).toBe(1) + await searchDocs('cron', { topK: -8 }) + expect(capturedLimit.value).toBe(1) + }) + + it('truncates a fractional count', async () => { + await searchDocs('cron', { topK: 7.9 }) + expect(capturedLimit.value).toBe(7) + }) + + it('falls back to the default rather than passing NaN to the query', async () => { + // Math.min/Math.max propagate NaN, so a bare clamp would reach `.limit(NaN)`. + await searchDocs('cron', { topK: Number.NaN }) + expect(capturedLimit.value).toBe(10) + await searchDocs('cron', { topK: 'twelve' as unknown as number }) + expect(capturedLimit.value).toBe(10) + await searchDocs('cron', { topK: Number.POSITIVE_INFINITY }) + expect(capturedLimit.value).toBe(10) + }) +}) diff --git a/apps/sim/lib/copilot/docs/docs-search.ts b/apps/sim/lib/copilot/docs/docs-search.ts index 9c545b40d60..2b77e0f0f67 100644 --- a/apps/sim/lib/copilot/docs/docs-search.ts +++ b/apps/sim/lib/copilot/docs/docs-search.ts @@ -108,6 +108,19 @@ function escapeLikePattern(value: string): string { return value.replace(/[\\%_]/g, (char) => `\\${char}`) } +/** + * Clamp a caller-supplied result count into [1, {@link MAX_TOP_K}]. + * + * Guards magnitude AND type: `Math.min`/`Math.max` propagate NaN, so a + * non-numeric value would otherwise reach the query as `.limit(NaN)`. The + * generated tool schema rejects a non-number upstream today, but this function + * is also called directly, so it does not rely on that. + */ +function clampTopK(requested: number | undefined): number { + if (requested === undefined || !Number.isFinite(requested)) return DEFAULT_TOP_K + return Math.min(Math.max(Math.trunc(requested), 1), MAX_TOP_K) +} + /** * Semantic search over the indexed docs corpus (`docs_embeddings`, rebuilt by * `scripts/process-docs.ts` on release). Every result carries the `docs/` path @@ -128,7 +141,7 @@ export async function searchDocs( ): Promise { if (!query || typeof query !== 'string') throw new Error('query is required') - const topK = Math.min(Math.max(Math.trunc(options?.topK ?? DEFAULT_TOP_K), 1), MAX_TOP_K) + const topK = clampTopK(options?.topK) const where = scopeCondition(options?.path) logger.info('Executing docs search', { query, topK, path: options?.path ?? null }) From 30c6cc7769f45bd75cc2cbf3e0bc8177718d8de8 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Fri, 24 Jul 2026 19:08:16 -0700 Subject: [PATCH 09/24] fix(copilot): restore the query in search_docs tool chips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chips read "Searched docs" with no indication of what was searched. The query-aware title existed earlier on this branch and this commit's own predecessor dropped it: removing search_docs from the catalog deleted the display case and its test, and putting the tool back only restored the static map entry. The generic "every visible catalog tool has a title" assertion still passed, because it checks that a title exists, not that it is the useful one. Chips now read: Searching docs for "how to read workflow logs and view executions" -> Searched docs for "...". The gerund flip already preserves the suffix, so the completed state needs no extra handling — the test now pins that too, since it was the part most likely to regress silently. --- .../lib/copilot/tools/tool-display.test.ts | 20 +++++++++++++++++++ apps/sim/lib/copilot/tools/tool-display.ts | 6 +++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/copilot/tools/tool-display.test.ts b/apps/sim/lib/copilot/tools/tool-display.test.ts index 9fd352aff15..b5c7a86b738 100644 --- a/apps/sim/lib/copilot/tools/tool-display.test.ts +++ b/apps/sim/lib/copilot/tools/tool-display.test.ts @@ -78,6 +78,26 @@ describe('getToolDisplayTitle natural-language coverage', () => { expect(getToolDisplayTitle('diff_workflows')).toBe('Comparing workflows') }) + it('includes the query in search_docs titles', () => { + expect(getToolDisplayTitle('search_docs')).toBe('Searching docs') + expect(getToolDisplayTitle('search_docs', { query: 'loop blocks iteration' })).toBe( + 'Searching docs for "loop blocks iteration"' + ) + // The completed-state flip must keep the suffix, not drop back to the bare label. + expect( + getToolCompletedTitle( + getToolDisplayTitle('search_docs', { query: 'how to read workflow logs' }) + ) + ).toBe('Searched docs for "how to read workflow logs"') + // A long agent-written query is truncated rather than blowing out the chip. + expect( + getToolDisplayTitle('search_docs', { + query: + 'reference block outputs connection tags blockname.field pass data between blocks in a workflow', + })?.length + ).toBeLessThanOrEqual('Searching docs for ""'.length + 60 + '...'.length) + }) + it('falls back to running code for function_execute without a title', () => { expect(getToolDisplayTitle('function_execute')).toBe('Running code') expect(getToolDisplayTitle('function_execute', { title: 'Crunching numbers' })).toBe( diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index ebadb3fd4b1..c8803a3d42e 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -1,4 +1,4 @@ -import { stripVersionSuffix } from '@sim/utils/string' +import { stripVersionSuffix, truncate } from '@sim/utils/string' /** * Single source of truth for copilot tool-call display titles. @@ -793,6 +793,10 @@ export function getToolDisplayTitle(name: string, args?: Record const target = firstStringArg(args, 'toolTitle', 'title') return target ? `Searching online for ${target}` : 'Searching online' } + case 'search_docs': { + const target = firstStringArg(args, 'toolTitle', 'title', 'query') + return target ? `Searching docs for "${truncate(target, 60)}"` : 'Searching docs' + } case 'grep': { const target = firstStringArg(args, 'toolTitle', 'title') return target ? `Searching for ${target}` : 'Searching' From cbd5c56d75e163359e0016dc34fbc0a6bdcd1003 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:33:39 -0700 Subject: [PATCH 10/24] improvement(copilot): share the unmounted-docs list, shrink the search default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two places decide what the docs/ corpus is: the manifest generator (what is readable) and the vector search's unscoped filter (what is findable). They each carried their own copy of the excluded-section list. If they drift, a hit in a section that is indexed but not mounted comes back as a chunk the agent cannot then read — dropped as stale, silently shrinking the result set. UNMOUNTED_DOCS_SECTIONS is now the one list both import. search_docs returns 5 chunks by default instead of 10; raise topK when a pass genuinely comes back thin. A truncated docs page now routes to one more fetch instead of two. grep and read cost the same single uncached fetch of the page, so grep is an alternative to a read here, never a step after one. Co-Authored-By: Claude Opus 5 (1M context) --- apps/sim/lib/copilot/docs/docs-path.ts | 22 +++++++++++++++++++ apps/sim/lib/copilot/docs/docs-search.test.ts | 10 ++++----- apps/sim/lib/copilot/docs/docs-search.ts | 15 +++++++------ apps/sim/lib/copilot/tools/handlers/vfs.ts | 6 ++++- scripts/sync-docs-manifest.ts | 17 +++++++++----- 5 files changed, 51 insertions(+), 19 deletions(-) diff --git a/apps/sim/lib/copilot/docs/docs-path.ts b/apps/sim/lib/copilot/docs/docs-path.ts index 650fd2977ac..b4e5ff66bc1 100644 --- a/apps/sim/lib/copilot/docs/docs-path.ts +++ b/apps/sim/lib/copilot/docs/docs-path.ts @@ -15,6 +15,28 @@ /** Suffix that marks a section overview page on disk. */ export const DOCS_INDEX_SUFFIX = '/index.mdx' +/** + * Top-level docs sections deliberately left out of the copilot's `docs/` tree. + * + * Two places must agree on this list or the corpus goes subtly wrong: the + * manifest generator (which decides what is readable) and the vector search's + * unscoped filter (which decides what is findable). If search still matched an + * unmounted section, every hit there would be a chunk the agent cannot then + * `read` — dropped as stale, silently shrinking the result set. + * + * Mounting a section later is not uniform work, so plan per section: + * - `academy` is plain mdx under `apps/docs/content/docs/en/academy` and is + * already indexed in `docs_embeddings` — removing it here and regenerating + * the manifest is the whole change. + * - `api-reference` is mostly generated from `apps/docs/openapi.json` at build + * time, so its pages have no source mdx for the generator to walk (only the + * four handwritten ones: authentication, getting-started, python, typescript). + * Mounting it properly needs the spec served publicly again — the + * `apps/docs/app/openapi.json` route existed for exactly this and was + * reverted — plus a generator branch that walks the spec's tags. + */ +export const UNMOUNTED_DOCS_SECTIONS = ['academy', 'api-reference'] as const + /** * Fold an `en`-relative mdx file path onto its public path — the value used as * both the `docs/`-relative VFS path and the docs.sim.ai URL path. diff --git a/apps/sim/lib/copilot/docs/docs-search.test.ts b/apps/sim/lib/copilot/docs/docs-search.test.ts index a78672d52d6..5b2bf75f23d 100644 --- a/apps/sim/lib/copilot/docs/docs-search.test.ts +++ b/apps/sim/lib/copilot/docs/docs-search.test.ts @@ -241,9 +241,9 @@ describe('searchDocs topK clamping', () => { mockGenerateSearchEmbedding.mockResolvedValue({ embedding: [0.1, 0.2] }) }) - it('defaults to 10 when unspecified', async () => { + it('defaults to 5 when unspecified', async () => { await searchDocs('cron') - expect(capturedLimit.value).toBe(10) + expect(capturedLimit.value).toBe(5) }) it('caps at 25 — the documented max, which the old tool never enforced', async () => { @@ -266,10 +266,10 @@ describe('searchDocs topK clamping', () => { it('falls back to the default rather than passing NaN to the query', async () => { // Math.min/Math.max propagate NaN, so a bare clamp would reach `.limit(NaN)`. await searchDocs('cron', { topK: Number.NaN }) - expect(capturedLimit.value).toBe(10) + expect(capturedLimit.value).toBe(5) await searchDocs('cron', { topK: 'twelve' as unknown as number }) - expect(capturedLimit.value).toBe(10) + expect(capturedLimit.value).toBe(5) await searchDocs('cron', { topK: Number.POSITIVE_INFINITY }) - expect(capturedLimit.value).toBe(10) + expect(capturedLimit.value).toBe(5) }) }) diff --git a/apps/sim/lib/copilot/docs/docs-search.ts b/apps/sim/lib/copilot/docs/docs-search.ts index 2b77e0f0f67..93e5b2f208f 100644 --- a/apps/sim/lib/copilot/docs/docs-search.ts +++ b/apps/sim/lib/copilot/docs/docs-search.ts @@ -3,13 +3,13 @@ import { docsEmbeddings } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, like, notLike, or, sql } from 'drizzle-orm' import { docsPathForSourceDocument, isDocsDir, isDocsPage } from '@/lib/copilot/docs/docs-corpus' -import { docsSourceCandidates } from '@/lib/copilot/docs/docs-path' +import { docsSourceCandidates, UNMOUNTED_DOCS_SECTIONS } from '@/lib/copilot/docs/docs-path' import { generateSearchEmbedding } from '@/lib/knowledge/embeddings' const logger = createLogger('DocsSearch') const SIMILARITY_THRESHOLD = 0.3 -const DEFAULT_TOP_K = 10 +const DEFAULT_TOP_K = 5 const MAX_TOP_K = 25 export interface DocsSearchResult { @@ -58,16 +58,17 @@ export class DocsSearchScopeError extends Error { * `workflows/index.mdx` (or `workflows.mdx`) on disk. A directory scope covers * the whole subtree plus the overview in either layout. * - * Returns undefined for an unscoped search, which excludes `academy/` and - * `api-reference/`: both are indexed but neither is mounted in the VFS, so a hit - * there would be a chunk the agent cannot then read. + * An unscoped search excludes every {@link UNMOUNTED_DOCS_SECTIONS} section: + * they are indexed but not mounted in the VFS, so a hit there would be a chunk + * the agent cannot then read. */ function scopeCondition(path?: string) { const normalized = (path ?? '').trim().replace(/^\/+/, '').replace(/\/+$/, '') if (normalized === '' || normalized === 'docs') { return and( - notLike(docsEmbeddings.sourceDocument, 'academy/%'), - notLike(docsEmbeddings.sourceDocument, 'api-reference/%') + ...UNMOUNTED_DOCS_SECTIONS.map((section) => + notLike(docsEmbeddings.sourceDocument, `${section}/%`) + ) ) } diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.ts b/apps/sim/lib/copilot/tools/handlers/vfs.ts index b74702dbe64..22c8747ec8b 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.ts @@ -127,8 +127,12 @@ function truncateDocsPageToInlineCap(page: { content: string; totalLines: number returnedLines: number } { const lines = page.content.split('\n') + // Route to ONE more fetch, not two. Telling the model to grep and then read + // costs two more uncached fetches of a page it already partly has; grep and + // read cost the same single fetch, so grep is an alternative to a read here, + // never a step before one. const notice = (shown: number) => - `\n\n[Page truncated: showing lines 1-${shown} of ${page.totalLines}. Grep this path for the section you need, then read with offset/limit.]` + `\n\n[Page truncated: returned lines 1-${shown} of ${page.totalLines}. To continue, read this path with offset: ${shown}. To jump straight to a section, grep this path INSTEAD of reading it — grep is the same single fetch and returns only matching lines with their numbers.]` let kept = lines.length let content = page.content diff --git a/scripts/sync-docs-manifest.ts b/scripts/sync-docs-manifest.ts index fa1f0ddcd81..7373a72e26c 100644 --- a/scripts/sync-docs-manifest.ts +++ b/scripts/sync-docs-manifest.ts @@ -15,9 +15,10 @@ * into their parent URL; `/workflows/index.mdx` * is a 404 on the site) * - * Excluded, and intentionally absent from the VFS: `academy/` and - * `api-reference/` (fetch those with the scrape tool if ever needed), the root - * `index.mdx` (its URL is `/`, which redirects), and every non-`en` locale. + * Excluded, and intentionally absent from the VFS: every section in + * `UNMOUNTED_DOCS_SECTIONS` (fetch those with the scrape tool if ever needed), + * the root `index.mdx` (its URL is `/`, which redirects), and every non-`en` + * locale. * * Usage: * bun run docs-manifest:generate # write the manifest @@ -26,7 +27,7 @@ import { readdir, readFile, writeFile } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -import { foldDocsIndexPath } from '../apps/sim/lib/copilot/docs/docs-path' +import { foldDocsIndexPath, UNMOUNTED_DOCS_SECTIONS } from '../apps/sim/lib/copilot/docs/docs-path' import { formatGeneratedSource } from './format-generated-source' const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) @@ -34,8 +35,12 @@ const ROOT = resolve(SCRIPT_DIR, '..') const DOCS_CONTENT_DIR = resolve(ROOT, 'apps/docs/content/docs/en') const OUTPUT_PATH = resolve(ROOT, 'apps/sim/lib/copilot/generated/docs-manifest.ts') -/** Top-level docs sections deliberately left out of the copilot's `docs/` tree. */ -const EXCLUDED_SECTIONS = new Set(['academy', 'api-reference']) +/** + * Top-level docs sections deliberately left out of the copilot's `docs/` tree. + * Shared with the vector search's unscoped filter so readability and + * findability cannot drift apart — see `UNMOUNTED_DOCS_SECTIONS`. + */ +const EXCLUDED_SECTIONS = new Set(UNMOUNTED_DOCS_SECTIONS) /** Collect every `.mdx` file under `dir`, as paths relative to {@link DOCS_CONTENT_DIR}. */ async function collectMdxPaths(dir: string, prefix = ''): Promise { From 87a94145527e12b390791d9a4819745a06691775 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:53:43 -0700 Subject: [PATCH 11/24] chore(copilot): regenerate the tool catalog for the retired quick-reference tool Picks up get_platform_actions' hidden/retired description from mothership. The id stays in the catalog so isKnownTool keeps routing calls from an older build during a mixed deploy; the handler is unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- apps/sim/lib/copilot/generated/tool-catalog-v1.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 76e0c906090..5e962e9ed34 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -2133,6 +2133,7 @@ export const GetPlatformActions: ToolCatalogEntry = { route: 'sim', mode: 'async', parameters: { type: 'object', properties: {} }, + hidden: true, } export const GetScheduledTaskLogs: ToolCatalogEntry = { From 7debbcb4ee2ae06b414664e1240e243a0d239d30 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:50:53 -0700 Subject: [PATCH 12/24] fix(review): attach the scope-error TSDoc to the class it documents Two TSDoc blocks sat back to back above DocsSearchOutcome; the first describes DocsSearchScopeError, which had no doc comment of its own. Moved it to the class. Co-Authored-By: Claude Opus 5 (1M context) --- apps/sim/lib/copilot/docs/docs-search.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/sim/lib/copilot/docs/docs-search.ts b/apps/sim/lib/copilot/docs/docs-search.ts index 93e5b2f208f..db45bb66990 100644 --- a/apps/sim/lib/copilot/docs/docs-search.ts +++ b/apps/sim/lib/copilot/docs/docs-search.ts @@ -22,11 +22,6 @@ export interface DocsSearchResult { similarity: number } -/** - * Thrown when the caller scopes a search to a `path` that is not a real page or - * section in the docs corpus. Surfaced verbatim so the model can correct itself - * rather than reading an empty result as "the docs say nothing about this". - */ /** * A search result set plus why it may be shorter than `topK`. The SQL LIMIT is * applied before the threshold and liveness filters, so these counts are what @@ -42,6 +37,11 @@ export interface DocsSearchOutcome { droppedStale: number } +/** + * Thrown when the caller scopes a search to a `path` that is not a real page or + * section in the docs corpus. Surfaced verbatim so the model can correct itself + * rather than reading an empty result as "the docs say nothing about this". + */ export class DocsSearchScopeError extends Error { readonly code = 'DOCS_SEARCH_SCOPE' as const constructor(message: string) { From f1158e81bef73914076f9be04e809e0069851379 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:53:23 -0700 Subject: [PATCH 13/24] =?UTF-8?q?fix(review):=20harden=20docs=20corpus=20e?= =?UTF-8?q?dges=20=E2=80=94=20trailing-slash=20glob,=20root-index=20drops,?= =?UTF-8?q?=20oversized-line=20reads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings applied from the multi-agent pass on this branch: - glob("docs/") matched no key and silently returned empty; normalize now strips trailing slashes so it resolves like "docs" - unscoped search_docs no longer returns root-homepage chunks that would only be counted against topK and then dropped as stale (the manifest deliberately omits index.mdx) - a docs page whose single line exceeds the inline cap now fails with grep guidance instead of returning an over-cap payload as success - test coverage for the vfs docs routing (glob/read/grep dispatch, DocsCorpusError surfacing, truncation paths), the search_docs server tool's shortfall notes, the empty-embedding outcome, and the inert @docs context Co-Authored-By: Claude Fable 5 --- .../lib/copilot/chat/process-contents.test.ts | 17 +++ apps/sim/lib/copilot/docs/docs-corpus.test.ts | 5 + apps/sim/lib/copilot/docs/docs-corpus.ts | 5 +- apps/sim/lib/copilot/docs/docs-search.test.ts | 18 +++ apps/sim/lib/copilot/docs/docs-search.ts | 8 +- .../lib/copilot/tools/handlers/vfs.test.ts | 108 +++++++++++++++++- apps/sim/lib/copilot/tools/handlers/vfs.ts | 19 ++- .../tools/server/docs/search-docs.test.ts | 102 +++++++++++++++++ 8 files changed, 272 insertions(+), 10 deletions(-) create mode 100644 apps/sim/lib/copilot/tools/server/docs/search-docs.test.ts diff --git a/apps/sim/lib/copilot/chat/process-contents.test.ts b/apps/sim/lib/copilot/chat/process-contents.test.ts index 0e9b85a26f3..62a9f75e092 100644 --- a/apps/sim/lib/copilot/chat/process-contents.test.ts +++ b/apps/sim/lib/copilot/chat/process-contents.test.ts @@ -146,6 +146,23 @@ describe('processContextsServer - skill contexts', () => { }) }) +describe('processContextsServer - docs contexts', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('resolves a tagged docs context to nothing while @docs tagging is disabled', async () => { + const result = await processContextsServer( + [{ kind: 'docs', label: 'Docs' } as ChatContext], + 'user-1', + 'how do loops work @Docs', + 'ws-1' + ) + + expect(result).toEqual([]) + }) +}) + describe('processContextsServer - MCP contexts', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/lib/copilot/docs/docs-corpus.test.ts b/apps/sim/lib/copilot/docs/docs-corpus.test.ts index 31117855a08..f3dd5a8f4ad 100644 --- a/apps/sim/lib/copilot/docs/docs-corpus.test.ts +++ b/apps/sim/lib/copilot/docs/docs-corpus.test.ts @@ -58,6 +58,11 @@ describe('globDocs', () => { expect(globDocs('docs/workflows.mdx')).toEqual(['docs/workflows.mdx']) expect(globDocs('docs/workflows/index.mdx')).toEqual([]) }) + + it('treats a trailing-slash pattern like the bare directory instead of matching nothing', () => { + expect(globDocs('docs/')).toEqual(['docs']) + expect(globDocs('docs/integrations/')).toEqual(['docs/integrations']) + }) }) describe('readDocsPage', () => { diff --git a/apps/sim/lib/copilot/docs/docs-corpus.ts b/apps/sim/lib/copilot/docs/docs-corpus.ts index 16a202a4f84..5b8bcbc3213 100644 --- a/apps/sim/lib/copilot/docs/docs-corpus.ts +++ b/apps/sim/lib/copilot/docs/docs-corpus.ts @@ -38,7 +38,10 @@ const docsKeyView: Map = new Map( ) function normalize(path: string): string { - return path.trim().replace(/^\/+/, '') + // Trailing slashes are stripped so `docs/` addresses the corpus the same way + // `docs` does — otherwise a trailing-slash glob pattern matches no key and + // silently returns an empty result instead of the corpus listing. + return path.trim().replace(/^\/+/, '').replace(/\/+$/, '') } /** diff --git a/apps/sim/lib/copilot/docs/docs-search.test.ts b/apps/sim/lib/copilot/docs/docs-search.test.ts index 5b2bf75f23d..5920c0d9fef 100644 --- a/apps/sim/lib/copilot/docs/docs-search.test.ts +++ b/apps/sim/lib/copilot/docs/docs-search.test.ts @@ -26,6 +26,7 @@ vi.mock('drizzle-orm', () => { and: op('and'), or: op('or'), eq: op('eq'), + ne: op('ne'), like: op('like'), notLike: op('notLike'), sql: (strings: TemplateStringsArray) => ({ op: 'sql', text: strings.join('?') }), @@ -77,6 +78,12 @@ describe('searchDocs path scoping', () => { expect(whereText()).toContain('academy/%') }) + it('excludes the root homepage when unscoped — its chunks have no live docs/ path', async () => { + await searchDocs('cron') + expect(whereText()).toContain('"op":"ne"') + expect(whereText()).toContain('index.mdx') + }) + it('scopes a page to both on-disk layouts', async () => { await searchDocs('cron', { path: 'docs/workflows/blocks/agent.mdx' }) const text = whereText() @@ -171,6 +178,17 @@ describe('searchDocs results', () => { expect((await searchDocs('cron')).results).toEqual([]) }) + it('returns the zero-candidate outcome without querying when the embedding is empty', async () => { + mockGenerateSearchEmbedding.mockResolvedValue({ embedding: [] }) + const outcome = await searchDocs('cron') + expect(outcome).toEqual({ + results: [], + candidatesConsidered: 0, + droppedBelowThreshold: 0, + droppedStale: 0, + }) + }) + it('drops chunks below the similarity threshold', async () => { mockRows.value = [ { diff --git a/apps/sim/lib/copilot/docs/docs-search.ts b/apps/sim/lib/copilot/docs/docs-search.ts index db45bb66990..0b70d840608 100644 --- a/apps/sim/lib/copilot/docs/docs-search.ts +++ b/apps/sim/lib/copilot/docs/docs-search.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { docsEmbeddings } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, like, notLike, or, sql } from 'drizzle-orm' +import { and, eq, like, ne, notLike, or, sql } from 'drizzle-orm' import { docsPathForSourceDocument, isDocsDir, isDocsPage } from '@/lib/copilot/docs/docs-corpus' import { docsSourceCandidates, UNMOUNTED_DOCS_SECTIONS } from '@/lib/copilot/docs/docs-path' import { generateSearchEmbedding } from '@/lib/knowledge/embeddings' @@ -60,12 +60,16 @@ export class DocsSearchScopeError extends Error { * * An unscoped search excludes every {@link UNMOUNTED_DOCS_SECTIONS} section: * they are indexed but not mounted in the VFS, so a hit there would be a chunk - * the agent cannot then read. + * the agent cannot then read. The root homepage (`index.mdx`) is excluded for + * the same reason — the manifest generator drops it (its URL is `/`, which + * redirects), so its chunks would only ever be counted against topK and then + * discarded as stale. */ function scopeCondition(path?: string) { const normalized = (path ?? '').trim().replace(/^\/+/, '').replace(/\/+$/, '') if (normalized === '' || normalized === 'docs') { return and( + ne(docsEmbeddings.sourceDocument, 'index.mdx'), ...UNMOUNTED_DOCS_SECTIONS.map((section) => notLike(docsEmbeddings.sourceDocument, `${section}/%`) ) diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.test.ts b/apps/sim/lib/copilot/tools/handlers/vfs.test.ts index d314b0bfde0..70e3c2bad7c 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TOOL_RESULT_MAX_INLINE_CHARS } from '@/lib/copilot/constants' const { getOrMaterializeVFS } = vi.hoisted(() => ({ @@ -567,3 +567,109 @@ describe('vfs uploads are opt-in (like recently-deleted/)', () => { expect(grepChatUpload).toHaveBeenCalledWith('report.json', 'chat-1', 'x', expect.any(Object)) }) }) + +describe('vfs handlers docs corpus routing', () => { + const fetchMock = vi.fn() + const DOCS_PAGE = 'docs/workflows/blocks/agent.mdx' + + beforeEach(() => { + vi.clearAllMocks() + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('globs the docs corpus without materializing the workspace VFS', async () => { + const result = await executeVfsGlob({ pattern: 'docs/**' }, GREP_CTX) + + expect(result.success).toBe(true) + expect((result.output as { files: string[] }).files).toContain(DOCS_PAGE) + expect(getOrMaterializeVFS).not.toHaveBeenCalled() + }) + + it('reads a docs page via the live-site fetch, not the workspace VFS', async () => { + fetchMock.mockResolvedValue({ ok: true, status: 200, text: async () => 'line one\nline two' }) + + const result = await executeVfsRead({ path: DOCS_PAGE }, GREP_CTX) + + expect(result.success).toBe(true) + expect(result.output).toEqual({ content: 'line one\nline two', totalLines: 2 }) + expect(getOrMaterializeVFS).not.toHaveBeenCalled() + }) + + it('surfaces DocsCorpusError messages verbatim from read, without fetching', async () => { + const unknown = await executeVfsRead({ path: 'docs/not-a-real-page.mdx' }, GREP_CTX) + expect(unknown.success).toBe(false) + expect(unknown.error).toContain('Docs page not found') + + const dir = await executeVfsRead({ path: 'docs/workflows/blocks' }, GREP_CTX) + expect(dir.success).toBe(false) + expect(dir.error).toContain('is a directory') + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('greps exactly one docs page and rejects multi-page scopes verbatim', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + text: async () => 'alpha\ncron beta\ngamma', + }) + + const single = await executeVfsGrep({ pattern: 'cron', path: DOCS_PAGE }, GREP_CTX) + expect(single.success).toBe(true) + + const multi = await executeVfsGrep({ pattern: 'cron', path: 'docs/workflows' }, GREP_CTX) + expect(multi.success).toBe(false) + expect(multi.error).toContain('single page') + expect(getOrMaterializeVFS).not.toHaveBeenCalled() + }) + + it('truncates an oversized multi-line docs page to fit the inline cap', async () => { + const line = 'y'.repeat(200) + const totalLines = Math.ceil((TOOL_RESULT_MAX_INLINE_CHARS * 2) / (line.length + 1)) + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + text: async () => Array.from({ length: totalLines }, () => line).join('\n'), + }) + + const result = await executeVfsRead({ path: DOCS_PAGE }, GREP_CTX) + + expect(result.success).toBe(true) + const output = result.output as { content: string; totalLines: number } + expect(output.totalLines).toBe(totalLines) + expect(output.content).toContain('[Page truncated: returned lines 1-') + expect(JSON.stringify(output).length).toBeLessThanOrEqual(TOOL_RESULT_MAX_INLINE_CHARS) + }) + + it('fails a docs page whose single line cannot fit inline instead of returning it oversized', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + text: async () => 'z'.repeat(TOOL_RESULT_MAX_INLINE_CHARS + 1000), + }) + + const result = await executeVfsRead({ path: DOCS_PAGE }, GREP_CTX) + + expect(result.success).toBe(false) + expect(result.error).toContain('Grep this page') + }) + + it('rejects an explicit window that still overflows instead of truncating it', async () => { + const line = 'y'.repeat(200) + const totalLines = Math.ceil((TOOL_RESULT_MAX_INLINE_CHARS * 2) / (line.length + 1)) + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + text: async () => Array.from({ length: totalLines }, () => line).join('\n'), + }) + + const result = await executeVfsRead({ path: DOCS_PAGE, offset: 0, limit: totalLines }, GREP_CTX) + + expect(result.success).toBe(false) + expect(result.error).toContain('still too large over the requested window') + }) +}) diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.ts b/apps/sim/lib/copilot/tools/handlers/vfs.ts index 22c8747ec8b..28ca0e35082 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.ts @@ -120,12 +120,14 @@ async function canReturnWorkspaceFileValue( /** * Trim an oversized docs page to the largest whole-line prefix that fits the * inline budget, preserving the true `totalLines` so the model can page through - * the rest with offset/limit. + * the rest with offset/limit. Returns null when not even one line fits — a + * single line longer than the cap — so the caller can fail instead of returning + * an over-cap payload as success. */ function truncateDocsPageToInlineCap(page: { content: string; totalLines: number }): { output: { content: string; totalLines: number } returnedLines: number -} { +} | null { const lines = page.content.split('\n') // Route to ONE more fetch, not two. Telling the model to grep and then read // costs two more uncached fetches of a page it already partly has; grep and @@ -135,17 +137,16 @@ function truncateDocsPageToInlineCap(page: { content: string; totalLines: number `\n\n[Page truncated: returned lines 1-${shown} of ${page.totalLines}. To continue, read this path with offset: ${shown}. To jump straight to a section, grep this path INSTEAD of reading it — grep is the same single fetch and returns only matching lines with their numbers.]` let kept = lines.length - let content = page.content while (kept > 0) { - content = `${lines.slice(0, kept).join('\n')}${notice(kept)}` + const content = `${lines.slice(0, kept).join('\n')}${notice(kept)}` if ( serializedResultSize({ content, totalLines: page.totalLines }) <= TOOL_RESULT_MAX_INLINE_CHARS ) { - break + return { output: { content, totalLines: page.totalLines }, returnedLines: kept } } kept = Math.floor(kept / 2) } - return { output: { content, totalLines: page.totalLines }, returnedLines: kept } + return null } export async function executeVfsGrep( @@ -375,6 +376,12 @@ export async function executeVfsRead( } } const truncated = truncateDocsPageToInlineCap(page) + if (!truncated) { + return { + success: false, + error: `${path} is too large to return inline even truncated. Grep this page for the section you need.`, + } + } logger.debug('vfs_read truncated oversized docs page', { path, totalLines: page.totalLines, diff --git a/apps/sim/lib/copilot/tools/server/docs/search-docs.test.ts b/apps/sim/lib/copilot/tools/server/docs/search-docs.test.ts new file mode 100644 index 00000000000..37318f5fc53 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/docs/search-docs.test.ts @@ -0,0 +1,102 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { DocsSearchOutcome } from '@/lib/copilot/docs/docs-search' + +const { mockSearchDocs } = vi.hoisted(() => ({ + mockSearchDocs: vi.fn(), +})) + +vi.mock('@/lib/copilot/docs/docs-search', () => ({ + searchDocs: mockSearchDocs, +})) + +import { searchDocsServerTool } from '@/lib/copilot/tools/server/docs/search-docs' + +function outcome(overrides: Partial): DocsSearchOutcome { + return { + results: [], + candidatesConsidered: 0, + droppedBelowThreshold: 0, + droppedStale: 0, + ...overrides, + } +} + +const RESULT = { + path: 'docs/agents.mdx', + url: 'https://docs.sim.ai/agents', + title: 'Agents', + content: 'body', + similarity: 0.9, +} + +describe('searchDocsServerTool', () => { + beforeEach(() => { + mockSearchDocs.mockReset() + }) + + it('forwards query, path, and topK to the search layer', async () => { + mockSearchDocs.mockResolvedValue(outcome({ results: [RESULT], candidatesConsidered: 1 })) + + const output = await searchDocsServerTool.execute({ + query: 'how do agents work', + path: 'docs/agents.mdx', + topK: 7, + }) + + expect(mockSearchDocs).toHaveBeenCalledWith('how do agents work', { + path: 'docs/agents.mdx', + topK: 7, + }) + expect(output).toEqual({ + results: [RESULT], + query: 'how do agents work', + totalResults: 1, + }) + }) + + it('omits the note when nothing was dropped', async () => { + mockSearchDocs.mockResolvedValue(outcome({ results: [RESULT], candidatesConsidered: 1 })) + + const output = await searchDocsServerTool.execute({ query: 'q' }) + + expect(output.note).toBeUndefined() + }) + + it('explains an empty result set caused by filtering, so it does not read as missing docs', async () => { + mockSearchDocs.mockResolvedValue( + outcome({ candidatesConsidered: 2, droppedBelowThreshold: 1, droppedStale: 1 }) + ) + + const output = await searchDocsServerTool.execute({ query: 'q' }) + + expect(output.note).toContain('does NOT mean the docs lack this topic') + expect(output.note).toContain('1 scored too low') + expect(output.note).toContain('1 point at pages no longer in the docs') + }) + + it('notes threshold-only drops on a partial result set', async () => { + mockSearchDocs.mockResolvedValue( + outcome({ results: [RESULT], candidatesConsidered: 3, droppedBelowThreshold: 2 }) + ) + + const output = await searchDocsServerTool.execute({ query: 'q' }) + + expect(output.note).toContain('Returned 1 of 3 candidate(s)') + expect(output.note).toContain('2 scored too low') + expect(output.note).not.toContain('no longer in the docs') + }) + + it('notes stale-only drops on a partial result set', async () => { + mockSearchDocs.mockResolvedValue( + outcome({ results: [RESULT], candidatesConsidered: 2, droppedStale: 1 }) + ) + + const output = await searchDocsServerTool.execute({ query: 'q' }) + + expect(output.note).toContain('1 point at pages no longer in the docs') + expect(output.note).not.toContain('scored too low') + }) +}) From 194acc0dee751af0d52cac3b3b558aa121cbd73b Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:39:04 -0700 Subject: [PATCH 14/24] chore(copilot): regenerate the tool catalog for the lean search agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The search subagent's task description now tells callers to pass a fully self-contained task — it no longer inherits the conversation (see the companion mothership change). Co-Authored-By: Claude Fable 5 --- apps/sim/lib/copilot/generated/tool-catalog-v1.ts | 2 +- apps/sim/lib/copilot/generated/tool-schemas-v1.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 5e962e9ed34..945b7e59caa 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -3649,7 +3649,7 @@ export const Search: ToolCatalogEntry = { properties: { task: { description: - "One short scoping sentence — the search agent has full conversation context. Example: 'find current Stripe metered-billing API limits' or 'count how many rows in the leads table have invalid emails'.", + "A fully self-contained task — the search agent sees none of this conversation, so include the question plus every name, id, constraint, and prior finding it needs. Example: 'find current Stripe metered-billing API limits' or 'count how many rows in the leads table have invalid emails'.", type: 'string', }, }, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 387e1954367..3d66c302fa3 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -3435,7 +3435,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { properties: { task: { description: - "One short scoping sentence — the search agent has full conversation context. Example: 'find current Stripe metered-billing API limits' or 'count how many rows in the leads table have invalid emails'.", + "A fully self-contained task — the search agent sees none of this conversation, so include the question plus every name, id, constraint, and prior finding it needs. Example: 'find current Stripe metered-billing API limits' or 'count how many rows in the leads table have invalid emails'.", type: 'string', }, }, From d8b361983053a27c87d7cc44e1da12bbfd0f2a9a Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:25:09 -0700 Subject: [PATCH 15/24] improvement(copilot): retire search_documentation and get_platform_actions outright, no shims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The transitional apparatus is gone: no search_documentation registry alias, no get_platform_actions handler, and the ids are out of the regenerated catalog/schemas. During the deploy window an old Mothership build calling either id gets the recoverable tool-not-found result. The two ids stay in HIDDEN_TOOL_NAMES forever — like load_agent_skill, historical persisted chats contain their tool calls and must replay without rendering chips for retired tools. The alias test is replaced by a dispatch test pinning search_docs's own catalog -> route -> handler chain and the retired ids' gone-but-chip-hidden state. Co-Authored-By: Claude Fable 5 --- .../lib/copilot/generated/tool-catalog-v1.ts | 12 - .../lib/copilot/generated/tool-schemas-v1.ts | 630 ++++++++++++++---- .../tool-executor/register-handlers.ts | 3 - .../tools/handlers/platform-actions.ts | 118 ---- .../lib/copilot/tools/handlers/platform.ts | 9 - .../server/docs/search-docs-dispatch.test.ts | 45 ++ apps/sim/lib/copilot/tools/server/router.ts | 5 - 7 files changed, 549 insertions(+), 273 deletions(-) delete mode 100644 apps/sim/lib/copilot/tools/handlers/platform-actions.ts delete mode 100644 apps/sim/lib/copilot/tools/handlers/platform.ts create mode 100644 apps/sim/lib/copilot/tools/server/docs/search-docs-dispatch.test.ts diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 945b7e59caa..9992ea34d61 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -43,7 +43,6 @@ export interface ToolCatalogEntry { | 'get_deployed_workflow_state' | 'get_deployment_log' | 'get_page_contents' - | 'get_platform_actions' | 'get_scheduled_task_logs' | 'get_workflow_data' | 'get_workflow_run_options' @@ -141,7 +140,6 @@ export interface ToolCatalogEntry { | 'get_deployed_workflow_state' | 'get_deployment_log' | 'get_page_contents' - | 'get_platform_actions' | 'get_scheduled_task_logs' | 'get_workflow_data' | 'get_workflow_run_options' @@ -2127,15 +2125,6 @@ export const GetPageContents: ToolCatalogEntry = { }, } -export const GetPlatformActions: ToolCatalogEntry = { - id: 'get_platform_actions', - name: 'get_platform_actions', - route: 'sim', - mode: 'async', - parameters: { type: 'object', properties: {} }, - hidden: true, -} - export const GetScheduledTaskLogs: ToolCatalogEntry = { id: 'get_scheduled_task_logs', name: 'get_scheduled_task_logs', @@ -4892,7 +4881,6 @@ export const TOOL_CATALOG: Record = { [GetDeployedWorkflowState.id]: GetDeployedWorkflowState, [GetDeploymentLog.id]: GetDeploymentLog, [GetPageContents.id]: GetPageContents, - [GetPlatformActions.id]: GetPlatformActions, [GetScheduledTaskLogs.id]: GetScheduledTaskLogs, [GetWorkflowData.id]: GetWorkflowData, [GetWorkflowRunOptions.id]: GetWorkflowRunOptions, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 3d66c302fa3..c28b6528b18 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -36,6 +36,289 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + browser: { + parameters: { + properties: { + task: { + description: + 'The web task to complete, in plain language (include the target site/URL if known).', + type: 'string', + }, + }, + required: ['task'], + type: 'object', + }, + resultSchema: undefined, + }, + browser_click: { + parameters: { + type: 'object', + properties: { + elementId: { + type: 'number', + description: 'The element id to act on (from the most recent browser_snapshot).', + }, + }, + required: ['elementId'], + }, + resultSchema: undefined, + }, + browser_close_tab: { + parameters: { + type: 'object', + properties: { + tabId: { + type: 'string', + description: 'The id of the tab to close (from browser_list_tabs).', + }, + }, + required: ['tabId'], + }, + resultSchema: undefined, + }, + browser_extract: { + parameters: { + type: 'object', + properties: { + instruction: { + type: 'string', + description: + 'What you intend to extract, in plain language. Echoed back unchanged; it does not filter or shape the returned text.', + }, + }, + required: ['instruction'], + }, + resultSchema: undefined, + }, + browser_go_back: { + parameters: { + type: 'object', + properties: {}, + }, + resultSchema: undefined, + }, + browser_go_forward: { + parameters: { + type: 'object', + properties: {}, + }, + resultSchema: undefined, + }, + browser_hover: { + parameters: { + type: 'object', + properties: { + elementId: { + type: 'number', + description: 'The element id to act on (from the most recent browser_snapshot).', + }, + }, + required: ['elementId'], + }, + resultSchema: undefined, + }, + browser_list_sessions: { + parameters: { + type: 'object', + properties: {}, + }, + resultSchema: undefined, + }, + browser_list_tabs: { + parameters: { + type: 'object', + properties: {}, + }, + resultSchema: undefined, + }, + browser_navigate: { + parameters: { + type: 'object', + properties: { + url: { + type: 'string', + description: + 'The absolute URL to navigate to, including scheme (https:// or http://). Must resolve to a public address — localhost and private/internal hosts are rejected.', + }, + }, + required: ['url'], + }, + resultSchema: undefined, + }, + browser_open_tab: { + parameters: { + type: 'object', + properties: { + url: { + type: 'string', + description: 'Optional URL to open the new tab at.', + }, + }, + }, + resultSchema: undefined, + }, + browser_open_url: { + parameters: { + type: 'object', + properties: { + url: { + type: 'string', + description: + 'The absolute URL to open, including scheme (https:// or http:// — localhost/local dev URLs are supported).', + }, + }, + required: ['url'], + }, + resultSchema: undefined, + }, + browser_press_key: { + parameters: { + type: 'object', + properties: { + key: { + type: 'string', + description: + "Key or combination. Named keys (case-insensitive): Enter, Escape (Esc), Tab, Backspace, Delete, Space, ArrowUp/ArrowDown/ArrowLeft/ArrowRight (or Up/Down/Left/Right), Home, End, PageUp, PageDown. Any single character also works ('a', '5', '/'). Anything else — 'F5', 'Return', 'Insert' — is rejected. Join modifiers with '+': Control (Ctrl), Cmd (Command, Meta), Shift, Alt (Option), e.g. 'Cmd+A' or 'Control+Shift+K'. On macOS, Control maps to Cmd for the editing shortcuts A, C, X, V, and Z only, so 'Control+A' selects all on every platform.", + }, + }, + required: ['key'], + }, + resultSchema: undefined, + }, + browser_read_text: { + parameters: { + type: 'object', + properties: { + elementId: { + type: 'number', + description: + 'Optional element id (from browser_snapshot) to read text from. Omit to read the whole page.', + }, + }, + }, + resultSchema: undefined, + }, + browser_request_takeover: { + parameters: { + type: 'object', + properties: { + purpose: { + type: 'string', + description: + 'Why takeover is needed. Set sign_in for a login/password flow so the desktop can remember a privacy-preserving session hint after the user finishes.', + enum: ['sign_in', 'captcha', 'payment', 'sensitive_confirmation', 'other'], + }, + reason: { + type: 'string', + description: + "Short explanation shown to the user of what they need to do (e.g. 'Sign in to Notion').", + }, + }, + required: ['reason'], + }, + resultSchema: undefined, + }, + browser_screenshot: { + parameters: { + type: 'object', + properties: {}, + }, + resultSchema: undefined, + }, + browser_scroll: { + parameters: { + type: 'object', + properties: { + amount: { + type: 'number', + description: + 'Optional distance to scroll in pixels (default: 85% of the viewport height, so a little context carries over).', + }, + direction: { + type: 'string', + description: 'Scroll direction.', + enum: ['up', 'down'], + }, + }, + required: ['direction'], + }, + resultSchema: undefined, + }, + browser_select_option: { + parameters: { + type: 'object', + properties: { + elementId: { + type: 'number', + description: 'The element id to act on (from the most recent browser_snapshot).', + }, + value: { + type: 'string', + description: "The option's visible label or its value.", + }, + }, + required: ['elementId', 'value'], + }, + resultSchema: undefined, + }, + browser_snapshot: { + parameters: { + type: 'object', + properties: {}, + }, + resultSchema: undefined, + }, + browser_switch_tab: { + parameters: { + type: 'object', + properties: { + tabId: { + type: 'string', + description: 'The id of the tab to activate (from browser_list_tabs).', + }, + }, + required: ['tabId'], + }, + resultSchema: undefined, + }, + browser_type: { + parameters: { + type: 'object', + properties: { + elementId: { + type: 'number', + description: 'The element id to act on (from the most recent browser_snapshot).', + }, + submit: { + type: 'boolean', + description: 'Press Enter after typing. Default false.', + }, + text: { + type: 'string', + description: + "The text to type. Replaces the element's current content. Must be non-empty — an empty string is rejected as a missing parameter; to clear a field, press Cmd+A then Backspace with browser_press_key.", + }, + }, + required: ['elementId', 'text'], + }, + resultSchema: undefined, + }, + browser_wait_for: { + parameters: { + type: 'object', + properties: { + text: { + type: 'string', + description: 'Optional visible text to wait for.', + }, + timeoutMs: { + type: 'number', + description: 'Maximum time to wait, in milliseconds (default 10000, capped at 120000).', + }, + }, + }, + resultSchema: undefined, + }, call_integration_tool: { parameters: { properties: { @@ -272,68 +555,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - delete_file: { - parameters: { - type: 'object', - properties: { - paths: { - type: 'array', - description: - 'Canonical workspace file VFS paths to delete, e.g. ["files/Reports/draft.md"].', - items: { - type: 'string', - }, - }, - }, - required: ['paths'], - }, - resultSchema: { - type: 'object', - properties: { - message: { - type: 'string', - description: 'Human-readable outcome.', - }, - success: { - type: 'boolean', - description: 'Whether the delete succeeded.', - }, - }, - required: ['success', 'message'], - }, - }, - delete_file_folder: { - parameters: { - type: 'object', - properties: { - paths: { - type: 'array', - description: 'Canonical folder VFS paths to delete, e.g. ["files/Archive"].', - items: { - type: 'string', - }, - }, - }, - required: ['paths'], - }, - resultSchema: undefined, - }, - delete_workflow: { - parameters: { - type: 'object', - properties: { - workflowIds: { - type: 'array', - description: 'The workflow IDs to delete.', - items: { - type: 'string', - }, - }, - }, - required: ['workflowIds'], - }, - resultSchema: undefined, - }, delete_workspace_mcp_server: { parameters: { type: 'object', @@ -669,14 +890,13 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { name: { type: 'string', description: - 'Display name for the block, max 60 characters. When republishing an existing block, pass the current name to keep it or a new name to rename.', + 'Display name for the block, max 60 characters. REQUIRED the first time a workflow is published. When republishing an existing block, omit it to keep the current name or pass a new one to rename. Ignored for undeploy.', }, workflowId: { type: 'string', description: 'Workflow ID (defaults to active workflow)', }, }, - required: ['name'], }, resultSchema: { type: 'object', @@ -736,6 +956,12 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { parameters: { type: 'object', properties: { + action: { + type: 'string', + description: + '"deploy" (default) adds/updates the workflow as an MCP tool on the server; "undeploy" removes the workflow\'s tool from the server.', + enum: ['deploy', 'undeploy'], + }, parameterDescriptions: { type: 'array', description: 'Array of parameter descriptions for the tool', @@ -1024,7 +1250,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { description: 'True when a provider returned a non-empty result.', }, provider: { - type: 'string', + type: ['string', 'null'], description: 'Internal label of the provider that produced the result (billing/diagnostics only — do NOT surface it to the user), or null on no match.', }, @@ -1363,7 +1589,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { timeout: { type: 'number', description: - 'Maximum execution time in seconds. The sandbox stops execution and returns a timeout error after this duration. Defaults to 10 seconds; the platform execution limit still applies.', + 'Maximum execution time in SECONDS (Sim converts to milliseconds). The sandbox stops execution and returns a timeout error after this duration. Defaults to 10 seconds and is capped at 300 seconds regardless of plan.', default: 10, }, title: { @@ -1941,13 +2167,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_platform_actions: { - parameters: { - type: 'object', - properties: {}, - }, - resultSchema: undefined, - }, get_scheduled_task_logs: { parameters: { type: 'object', @@ -2017,7 +2236,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { toolTitle: { type: 'string', description: - 'Optional target-only UI phrase for the search row. The UI verb is supplied for you, so pass text like "workflow configs" or "knowledge bases", not a full sentence like "Finding workflow configs".', + 'Required target-only UI phrase for the search row. The UI verb is supplied for you, so pass text like "workflow configs" or "knowledge bases", not a full sentence like "Finding workflow configs".', }, }, required: ['pattern', 'toolTitle'], @@ -2031,7 +2250,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { context: { type: 'number', description: - "Number of lines to show before and after each match. Only applies to output_mode 'content'.", + "Number of lines to show before and after each match (default 0). Only applies to output_mode 'content'.", }, ignoreCase: { type: 'boolean', @@ -2060,12 +2279,12 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { pattern: { type: 'string', description: - "Regex pattern to search for. Searches VFS map entries (workflow JSON, metadata, plans, memories) by default; searches a single file's extracted text when path is one files/ or uploads/ file leaf.", + "Regex pattern to search for. Searches VFS map entries (workflow JSON, metadata, memories) by default; searches a single file's extracted text when path is one files/ or uploads/ file leaf.", }, toolTitle: { type: 'string', description: - 'Optional target-only UI phrase for the search row. The UI verb is supplied for you, so pass text like "Slack integrations" or "deployed workflows", not a full sentence like "Searching for Slack integrations".', + 'Required target-only UI phrase for the search row. The UI verb is supplied for you, so pass text like "Slack integrations" or "deployed workflows", not a full sentence like "Searching for Slack integrations".', }, }, required: ['pattern', 'toolTitle'], @@ -2242,7 +2461,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { 'query', 'add_file', 'update', - 'delete', 'delete_document', 'update_document', 'list_tags', @@ -2263,8 +2481,9 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'object', properties: { data: { - type: 'object', - description: 'Operation-specific result payload.', + type: ['object', 'array'], + description: + 'Operation-specific result payload. An object for most operations; list_tags and get_tag_usage return an array of tag definitions.', }, message: { type: 'string', @@ -2348,6 +2567,20 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + load_skill: { + parameters: { + type: 'object', + properties: { + name: { + type: 'string', + description: + "Skill name exactly as it appears in the Loadable Skills index (e.g. 'pptx-writing').", + }, + }, + required: ['name'], + }, + resultSchema: undefined, + }, manage_credential: { parameters: { type: 'object', @@ -2457,30 +2690,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - manage_folder: { - parameters: { - type: 'object', - properties: { - folderId: { - type: 'string', - description: - 'Target folder ID, used as a fallback when path is not given. Readable from a contained workflow\'s meta.json "folderId".', - }, - operation: { - type: 'string', - description: 'The operation to perform.', - enum: ['delete'], - }, - path: { - type: 'string', - description: - 'Target folder\'s VFS path (e.g. "workflows/Marketing/Q3 Campaigns"), per-segment percent-encoded like every VFS path.', - }, - }, - required: ['operation'], - }, - resultSchema: undefined, - }, manage_mcp_tool: { parameters: { type: 'object', @@ -2545,7 +2754,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { cron: { type: 'string', description: - "Cron expression for a recurring scheduled task (e.g. '0 9 * * *'). Set exactly one of cron or time: recurring -> cron; one-time -> time.", + "Cron expression for a recurring scheduled task (e.g. '0 9 * * *'). Provide cron, time, or both — with both, time anchors the recurring task's first fire.", }, jobId: { type: 'string', @@ -3141,6 +3350,28 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + rm: { + parameters: { + type: 'object', + properties: { + paths: { + type: 'array', + description: + 'Canonical VFS paths to delete, e.g. ["files/Reports/draft.md"]. Copy paths verbatim from glob/grep/read output. Paths from different categories may be mixed in one call.', + items: { + type: 'string', + }, + }, + toolTitle: { + type: 'string', + description: + 'Target-only UI phrase for the action row, e.g. "draft.md" or "3 files", not a full sentence like "Deleting draft.md".', + }, + }, + required: ['paths', 'toolTitle'], + }, + resultSchema: undefined, + }, run: { parameters: { properties: { @@ -3459,7 +3690,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, topK: { type: 'number', - description: 'Number of results (default 10, max 25)', + description: 'Number of results (default 5, max 25)', }, }, required: ['query'], @@ -3525,8 +3756,9 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'object', properties: { data: { - type: 'object', - description: 'Operation-specific result payload.', + type: ['object', 'array'], + description: + 'Operation-specific result payload. An object for search results; list_tags returns an array of tag definitions.', }, message: { type: 'string', @@ -3554,7 +3786,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, version: { type: 'string', - description: "Specific version (optional, e.g., '14', 'v2')", + description: + "Specific version, numeric only and WITHOUT a leading 'v' (e.g. '14', '2', '2.1') — the 'v' is added for you, so 'v2' resolves to nothing.", }, }, required: ['library_name', 'query'], @@ -3607,7 +3840,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { properties: { limit: { type: 'integer', - description: 'Maximum number of unique pattern examples to return (defaults to 3).', + description: 'Maximum number of pattern examples to return per query (defaults to 3).', }, queries: { type: 'array', @@ -3699,12 +3932,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, type: { type: 'string', - description: 'Variable type. Required for add/edit; ignored for delete.', + description: + 'Variable type for add/edit. Defaults to the variable\'s existing type, or "plain" for a new one. Ignored for delete.', enum: ['plain', 'number', 'boolean', 'array', 'object'], }, value: { type: 'string', - description: 'Variable value. Required for add/edit; ignored for delete.', + description: + 'Variable value for add/edit, coerced to the declared type. Omitting it leaves the variable with no value. Ignored for delete.', }, }, required: ['operation', 'name'], @@ -3789,6 +4024,120 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + terminal: { + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Inputs for the operation. Pass only the fields that operation uses.', + properties: { + command: { + type: 'string', + description: + 'For run: the command line, exactly as it would be typed at the prompt. Shell syntax (pipes, &&, quoting, redirection) works because a real shell interprets it.', + }, + cwd: { + type: 'string', + description: + "For new: absolute path to open in. Defaults to the active terminal's directory.", + }, + key: { + type: 'string', + description: + 'For input: a single key to press instead of text. Use "enter" to submit something already typed.', + enum: [ + 'ctrl-c', + 'ctrl-d', + 'ctrl-z', + 'enter', + 'up', + 'down', + 'left', + 'right', + 'escape', + 'tab', + ], + }, + keys: { + type: 'array', + description: + 'For input: several keys pressed in order, e.g. ["down","down","enter"] to walk down a menu and choose. Each is a real keypress with a pause between, so the program redraws as it would under a person\'s hands. Only batch when you already know where the highlight is — read the screen first, and press one key at a time when you do not. Max 20.', + items: { + type: 'string', + enum: [ + 'ctrl-c', + 'ctrl-d', + 'ctrl-z', + 'enter', + 'up', + 'down', + 'left', + 'right', + 'escape', + 'tab', + ], + }, + }, + lines: { + type: 'number', + description: 'For read: how many trailing lines to return. Defaults to 200.', + }, + pane: { + type: 'string', + description: + "Which tmux pane to act on, as a target from the panes operation (session:window.pane). Defaults to that session's active pane. Ignored when the terminal is a plain shell.", + }, + reason: { + type: 'string', + description: + 'For handoff: what the user needs to do, shown on the button they click (e.g. "Enter your sudo password"). Say what is being asked, not that you are waiting.', + }, + signal: { + type: 'string', + description: + 'For kill: which signal. Defaults to SIGINT, the equivalent of the user pressing Ctrl-C.', + enum: ['SIGINT', 'SIGTERM', 'SIGKILL'], + }, + terminalId: { + type: 'string', + description: + 'Which terminal to act on, from the list operation. Defaults to the active one, which is what the user is looking at. Required by switch and close.', + }, + text: { + type: 'string', + description: + 'For input: literal text to type. A trailing newline submits it. Check the returned screen to confirm it submitted rather than sitting unsent in an input box.', + }, + waitSeconds: { + type: 'number', + description: + 'For run: how long to wait before handing back a still-running command. Defaults to 30, capped at 120. Raising it does not make a command finish sooner, it only delays your first look at it.', + }, + }, + }, + operation: { + type: 'string', + description: 'What to do.', + enum: [ + 'run', + 'read', + 'input', + 'kill', + 'cwd', + 'list', + 'new', + 'switch', + 'close', + 'panes', + 'handoff', + ], + }, + }, + required: ['operation'], + }, + resultSchema: undefined, + }, update_deployment_version: { parameters: { type: 'object', @@ -3881,7 +4230,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, column: { type: 'object', - description: 'Column definition for add_column: { name, type, unique?, position? }', + description: + 'Column definition for add_column: { name, type, unique?, position? }. For a select (enum) column also pass { options: ["Open", "Closed"], multiple?: true } — options is a list of display names and is required for select.', }, columnName: { type: 'string', @@ -3912,6 +4262,12 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, }, }, + deploymentMode: { + type: 'string', + description: + "Which version of the backing workflow this group's per-row runs execute, for add_workflow_group and update_workflow_group. 'live' (default) runs the editable draft, so later edits take effect immediately. 'deployed' runs the workflow's latest active deployment, pinning rows to a published version — if that workflow has never been deployed the cell fails rather than falling back to the draft. Only meaningful for workflow groups; enrichment groups have no backing workflow.", + enum: ['live', 'deployed'], + }, description: { type: 'string', description: "Table description (optional for 'create')", @@ -4008,6 +4364,11 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { "Import mode for import_file. 'append' (default) adds rows; 'replace' truncates existing rows in a transaction before inserting the new rows.", enum: ['append', 'replace'], }, + multiple: { + type: 'boolean', + description: + 'Whether a select (enum) cell may hold several options (default false). Switching an existing column from true to false fails if any row has more than one option selected.', + }, name: { type: 'string', description: @@ -4021,12 +4382,20 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { newType: { type: 'string', description: - 'New column type (optional for update_column). Types: string, number, boolean, date, json', + 'New column type (optional for update_column). Types: string, number, boolean, date, json, select. Converting a column to select also requires options; the conversion fails if any existing cell value doesn\'t match one of them. Converting to a multiple: true select also accepts a comma-separated cell ("Open, Urgent"), which is the form a multi column converts to text as — so multiselect → text → multiselect round-trips.', }, offset: { type: 'number', description: 'Number of rows to skip (optional for query_rows, default 0)', }, + options: { + type: 'array', + description: + 'Choices for a select (enum) column, as a list of display names, e.g. ["Open", "Closed"]. Required when creating or converting to a select column. On update_column this REPLACES the option list and is matched against the current one BY NAME: a name still present keeps its cells, a name no longer present is removed and cleared from every cell that held it. Send the full list including the options you are keeping — omitting one deletes it. There is no in-place rename, so re-sending an option under a new name clears the cells that held the old one. Max 100.', + items: { + type: 'string', + }, + }, outputColumnNames: { type: 'object', description: @@ -4039,13 +4408,13 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { outputFormat: { type: 'string', description: - 'Explicit format override for outputPath. Usually unnecessary — the file extension determines the format automatically. Only use this to force a different format than what the extension implies.', + 'Explicit format override for outputPath. Only "csv" changes the file\'s CONTENT (rows serialized as a CSV table); "json", "txt", "md" and "html" all write the same pretty-printed JSON and change only the stored MIME type. Usually unnecessary — the extension already selects the format.', enum: ['json', 'csv', 'txt', 'md', 'html'], }, outputPath: { type: 'string', description: - 'Pipe query_rows results directly to a NEW workspace file. The format is auto-inferred from the file extension: .csv → CSV, .json → JSON, .md → Markdown, etc. Use a root output path like "files/export.csv" — nested output paths are not supported.', + 'Write this call\'s result to a NEW workspace file instead of returning it. Applies to EVERY user_table operation, not just query_rows: on success the tool result is REPLACED by a file receipt (fileId, vfsPath, size), so the operation\'s own payload is no longer visible to you — set it only when the file IS the goal. Only ".csv" changes serialization (query_rows rows become a CSV table); ".json", ".txt", ".md" and ".html" all write pretty-printed JSON of the full { success, message, data } envelope and differ only in stored MIME type. Nested paths like "files/Reports/export.csv" work — missing parent folders are created automatically, and an existing path fails.', }, outputs: { type: 'array', @@ -4085,14 +4454,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { description: 'Zero-based index at which to insert the row (optional, insert_row only). Rows at and below that index shift down. Omit to append at the end.', }, - positions: { - type: 'array', - description: - 'Per-row insertion indices for batch_insert_rows (optional). Must be the same length as rows and contain no duplicates. Values are final positions in the resulting table — lower-index shifts are applied automatically. Omit to append all rows at the end.', - items: { - type: 'integer', - }, - }, rowId: { type: 'string', description: @@ -4119,7 +4480,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { schema: { type: 'object', description: - "Table schema with columns array (required for 'create'). Each column: { name, type, unique? }", + 'Table schema with columns array (required for \'create\'). Each column: { name, type, unique? }. A select (enum) column also takes { options: ["Open", "Closed"], multiple?: true } — options is a list of display names and is required for select.', }, scope: { type: 'string', @@ -4174,7 +4535,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { 'import_file', 'get', 'get_schema', - 'delete', 'rename', 'insert_row', 'batch_insert_rows', @@ -4224,6 +4584,24 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { required: ['success', 'message'], }, }, + wait: { + parameters: { + type: 'object', + properties: { + reason: { + type: 'string', + description: + 'What you are waiting for, in a few words (e.g. "the test suite to finish"). Shown to the user so the pause is not unexplained.', + }, + seconds: { + type: 'number', + description: 'How long to pause, in seconds. Capped at 120.', + }, + }, + required: ['seconds'], + }, + resultSchema: undefined, + }, workflow: { parameters: { properties: { diff --git a/apps/sim/lib/copilot/tool-executor/register-handlers.ts b/apps/sim/lib/copilot/tool-executor/register-handlers.ts index f2f9eb6d304..b5b9768ac14 100644 --- a/apps/sim/lib/copilot/tool-executor/register-handlers.ts +++ b/apps/sim/lib/copilot/tool-executor/register-handlers.ts @@ -16,7 +16,6 @@ import { GetBlockUpstreamReferences, GetDeployedWorkflowState, GetDeploymentLog, - GetPlatformActions, GetWorkflowData, GetWorkflowRunOptions, Glob as GlobTool, @@ -81,7 +80,6 @@ import { executeManageSandbox } from '../tools/handlers/management/manage-sandbo import { executeManageSkill } from '../tools/handlers/management/manage-skill' import { executeMaterializeFile } from '../tools/handlers/materialize-file' import { executeOAuthGetAuthLink, executeOAuthRequestAccess } from '../tools/handlers/oauth' -import { executeGetPlatformActions } from '../tools/handlers/platform' import { executeOpenResource } from '../tools/handlers/resources' import { executeRestoreResource } from '../tools/handlers/restore-resource' import { executeRunCode } from '../tools/handlers/run-code' @@ -192,7 +190,6 @@ function buildHandlerMap(): Record { [OauthRequestAccess.id]: h(executeOAuthRequestAccess), [OpenResource.id]: h(executeOpenResource), [RestoreResource.id]: h(executeRestoreResource), - [GetPlatformActions.id]: h(executeGetPlatformActions), [ListIntegrationTools.id]: h(executeListIntegrationTools), [MaterializeFile.id]: h(executeMaterializeFile), [FunctionExecute.id]: h(executeFunctionExecute), diff --git a/apps/sim/lib/copilot/tools/handlers/platform-actions.ts b/apps/sim/lib/copilot/tools/handlers/platform-actions.ts deleted file mode 100644 index c3c3ac14384..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/platform-actions.ts +++ /dev/null @@ -1,118 +0,0 @@ -/** - * Static content for the get_platform_actions tool. - * Contains the Sim platform quick reference and keyboard shortcuts. - */ -export const PLATFORM_ACTIONS_CONTENT = `# Sim Platform Quick Reference & Keyboard Shortcuts - -## Keyboard Shortcuts -**Mod** = Cmd (macOS) / Ctrl (Windows/Linux). Shortcuts work when canvas is focused. - -### Workflow Actions -| Shortcut | Action | -|----------|--------| -| Mod+Enter | Run workflow (or cancel if running) | -| Mod+Z | Undo | -| Mod+Shift+Z | Redo | -| Mod+C | Copy selected blocks | -| Mod+X | Cut selected blocks | -| Mod+V | Paste blocks | -| Delete/Backspace | Delete selected blocks or edges | -| Shift+L | Auto-layout canvas | -| Mod+Shift+F | Fit to view | -| Mod+Shift+Enter | Accept Copilot changes | - -### Panel Navigation -| Shortcut | Action | -|----------|--------| -| Mod+F | Open workflow search and replace | -| Mod+Alt+F | Focus Toolbar search | - -### Global Navigation -| Shortcut | Action | -|----------|--------| -| Mod+K | Open search | -| Mod+Shift+A | Add new agent workflow | -| Mod+Shift+P | Create workflow | -| Mod+B | Toggle sidebar | -| Mod+L | Go to logs | - -### Utility -| Shortcut | Action | -|----------|--------| -| Mod+D | Clear terminal console | - -### Mouse Controls -| Action | Control | -|--------|---------| -| Pan/move canvas | Left-drag on empty space (hand mode, the default), middle-drag, scroll, or trackpad | -| Select multiple blocks | Shift+drag to draw a selection box. In cursor mode, left-drag on empty space draws it instead | -| Drag block | Left-drag on block header | -| Add to selection | Mod+Click or Shift+Click on blocks | - -## Quick Reference — Workspaces -| Action | How | -|--------|-----| -| Create workspace | Click workspace dropdown → New Workspace | -| Switch workspaces | Click workspace dropdown → Select workspace | -| Invite teammates | Sidebar → Invite | -| Rename/Duplicate/Export/Delete workspace | Right-click workspace → action | - -## Quick Reference — Workflows -| Action | How | -|--------|-----| -| Create workflow | Click + button in sidebar | -| Reorder/move workflows | Drag workflow up/down or onto a folder | -| Import workflow | Click import button in sidebar → Select file | -| Multi-select workflows | Mod+Click or Shift+Click workflows in sidebar | -| Open in new tab | Right-click workflow → Open in New Tab | -| Rename/Duplicate/Export/Delete | Right-click workflow → action | - -## Quick Reference — Blocks -| Action | How | -|--------|-----| -| Add a block | Drag from Toolbar panel, or right-click canvas → Add Block | -| Multi-select blocks | Mod+Click or Shift+Click additional blocks, or Shift+drag a selection box | -| Copy/Paste blocks | Mod+C / Mod+V | -| Duplicate/Delete blocks | Right-click → action | -| Rename a block | Click block name in header | -| Enable/Disable block | Right-click → Enable/Disable | -| Lock/Unlock block | Hover block → Click lock icon (Admin only) | -| Toggle handle orientation | Right-click → Toggle Handles | -| Open a block in the Editor panel | Right-click → Open Editor | -| Move a block out of a loop/parallel | Right-click → Remove from Subflow | -| Configure a block | Select block → use Editor panel on right | - -## Quick Reference — Connections -| Action | How | -|--------|-----| -| Create connection | Drag from output handle to input handle | -| Delete connection | Click edge to select → Delete key | -| Use output in another block | Drag connection tag into input field | - -## Quick Reference — Running & Testing -| Action | How | -|--------|-----| -| Run workflow | Click Run Workflow button or Mod+Enter | -| Stop workflow | Click Stop button or Mod+Enter while running | -| Test with chat | Use Chat panel on the right side | -| Run from block | Hover block → Click play button, or right-click → Run from block | -| Run until block | Right-click block → Run until block | -| View execution logs | Open terminal panel at bottom, or Mod+L | -| Filter/Search/Copy/Clear logs | Terminal panel controls | - -## Quick Reference — Deployment -| Action | How | -|--------|-----| -| Deploy workflow | Click Deploy button in panel | -| Update deployment | Click Update when changes are detected | -| Revert deployment | Previous versions in Deploy tab → Promote to live | -| Copy API endpoint | Deploy tab → API → Copy API cURL | - -## Quick Reference — Variables -| Action | How | -|--------|-----| -| Add/Edit/Delete workflow variable | Panel → Variables → Add Variable | -| Add environment variable | Settings → Environment Variables → Add | -| Reference workflow variable | Use syntax | -| Reference environment variable | Use {{ENV_VAR}} syntax | -` diff --git a/apps/sim/lib/copilot/tools/handlers/platform.ts b/apps/sim/lib/copilot/tools/handlers/platform.ts deleted file mode 100644 index f5cc43f910b..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/platform.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { PLATFORM_ACTIONS_CONTENT } from './platform-actions' - -export async function executeGetPlatformActions( - _rawParams: Record, - _context: ExecutionContext -): Promise { - return { success: true, output: { content: PLATFORM_ACTIONS_CONTENT } } -} diff --git a/apps/sim/lib/copilot/tools/server/docs/search-docs-dispatch.test.ts b/apps/sim/lib/copilot/tools/server/docs/search-docs-dispatch.test.ts new file mode 100644 index 00000000000..c3f406c56b9 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/docs/search-docs-dispatch.test.ts @@ -0,0 +1,45 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { TOOL_CATALOG } from '@/lib/copilot/generated/tool-catalog-v1' +import { isKnownTool, isSimExecuted } from '@/lib/copilot/tool-executor/router' +import { getHiddenToolNames } from '@/lib/copilot/tools/client/hidden-tools' +import { getRegisteredServerToolNames } from '@/lib/copilot/tools/server/router' + +/** + * `executeTool` gates on `isKnownTool` (catalog membership) before it ever + * consults the handler registry, so a sim-routed tool needs every link of this + * chain or dispatch rejects it before the handler is reached. These assertions + * pin that chain for search_docs. + */ +describe('search_docs dispatch chain', () => { + it('is in the catalog, so dispatch does not reject it as unknown', () => { + expect(isKnownTool('search_docs')).toBe(true) + }) + + it('routes to sim, so dispatch reaches the server tool registry', () => { + expect(isSimExecuted('search_docs')).toBe(true) + }) + + it('has a registered server handler', () => { + expect(getRegisteredServerToolNames()).toContain('search_docs') + }) +}) + +/** + * The retired ids are fully unregistered server-side — no catalog entry, no + * handler, no alias. Only the client-side chip suppression survives, forever, + * so historical persisted chats replay without rendering chips for tools that + * no longer exist (the load_agent_skill precedent). + */ +describe('retired docs-tool ids', () => { + for (const retired of ['search_documentation', 'get_platform_actions']) { + it(`${retired} is gone from the catalog and server registry but stays chip-hidden`, () => { + expect(TOOL_CATALOG[retired]).toBeUndefined() + expect(isKnownTool(retired)).toBe(false) + expect(getRegisteredServerToolNames()).not.toContain(retired) + expect(getHiddenToolNames().has(retired)).toBe(true) + }) + } +}) diff --git a/apps/sim/lib/copilot/tools/server/router.ts b/apps/sim/lib/copilot/tools/server/router.ts index b652bda2770..6645647d94d 100644 --- a/apps/sim/lib/copilot/tools/server/router.ts +++ b/apps/sim/lib/copilot/tools/server/router.ts @@ -156,11 +156,6 @@ const baseServerToolRegistry: Record = { [editWorkflowServerTool.name]: editWorkflowServerTool, [queryLogsServerTool.name]: queryLogsServerTool, [searchDocsServerTool.name]: searchDocsServerTool, - // Transitional alias: sim and mothership deploy independently, so during the - // rollout of the search_documentation -> search_docs rename one side is still - // emitting the old id. The old params are a subset of the new, so routing them - // here is safe. Remove once both repos have shipped the rename. - search_documentation: searchDocsServerTool, [searchOnlineServerTool.name]: searchOnlineServerTool, [setEnvironmentVariablesServerTool.name]: setEnvironmentVariablesServerTool, [getCredentialsServerTool.name]: getCredentialsServerTool, From 381bfb7203841ba5ae639de911e8c64eb37b46c3 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:43:40 -0700 Subject: [PATCH 16/24] changed search_docs tool title to Searching Sim docs --- apps/sim/lib/copilot/tools/tool-display.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index c8803a3d42e..cf0a4a83606 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -498,7 +498,7 @@ const TOOL_TITLES: Record = { restore_resource: 'Restoring resource', run_block: 'Running block', scheduled_task: 'Managing scheduled task', - search_docs: 'Searching docs', + search_docs: 'Searching Sim docs', search_patterns: 'Searching patterns', set_block_enabled: 'Toggling block', set_environment_variables: 'Setting environment variables', From bbb4cfb745c1448e7edf61ba851d7d9926ebda5e Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:45:45 -0700 Subject: [PATCH 17/24] fix(copilot): apply the Searching Sim docs rename to the dynamic title case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The static TOOL_TITLES entry is unreachable for search_docs — the dynamic switch case returns first so it can include the query — so the rename only takes effect there. Tests updated to the new wording. Co-Authored-By: Claude Fable 5 --- apps/sim/lib/copilot/tools/tool-display.test.ts | 8 ++++---- apps/sim/lib/copilot/tools/tool-display.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/sim/lib/copilot/tools/tool-display.test.ts b/apps/sim/lib/copilot/tools/tool-display.test.ts index b5c7a86b738..07d7de66970 100644 --- a/apps/sim/lib/copilot/tools/tool-display.test.ts +++ b/apps/sim/lib/copilot/tools/tool-display.test.ts @@ -79,23 +79,23 @@ describe('getToolDisplayTitle natural-language coverage', () => { }) it('includes the query in search_docs titles', () => { - expect(getToolDisplayTitle('search_docs')).toBe('Searching docs') + expect(getToolDisplayTitle('search_docs')).toBe('Searching Sim docs') expect(getToolDisplayTitle('search_docs', { query: 'loop blocks iteration' })).toBe( - 'Searching docs for "loop blocks iteration"' + 'Searching Sim docs for "loop blocks iteration"' ) // The completed-state flip must keep the suffix, not drop back to the bare label. expect( getToolCompletedTitle( getToolDisplayTitle('search_docs', { query: 'how to read workflow logs' }) ) - ).toBe('Searched docs for "how to read workflow logs"') + ).toBe('Searched Sim docs for "how to read workflow logs"') // A long agent-written query is truncated rather than blowing out the chip. expect( getToolDisplayTitle('search_docs', { query: 'reference block outputs connection tags blockname.field pass data between blocks in a workflow', })?.length - ).toBeLessThanOrEqual('Searching docs for ""'.length + 60 + '...'.length) + ).toBeLessThanOrEqual('Searching Sim docs for ""'.length + 60 + '...'.length) }) it('falls back to running code for function_execute without a title', () => { diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index cf0a4a83606..9ce2a848621 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -795,7 +795,7 @@ export function getToolDisplayTitle(name: string, args?: Record } case 'search_docs': { const target = firstStringArg(args, 'toolTitle', 'title', 'query') - return target ? `Searching docs for "${truncate(target, 60)}"` : 'Searching docs' + return target ? `Searching Sim docs for "${truncate(target, 60)}"` : 'Searching Sim docs' } case 'grep': { const target = firstStringArg(args, 'toolTitle', 'title') From 6c3f5b272306bba509612b289a75c5b911e6589f Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:26:59 -0700 Subject: [PATCH 18/24] improvement(copilot): retry docs fetches and grep docs directories in parallel Two robustness upgrades to the docs corpus. Page fetches from docs.sim.ai now retry transient failures (5xx, 429, network, timeout) with jittered backoff over three 3s attempts instead of a single 10s attempt, so a momentary stall recovers in seconds instead of failing the tool call. And grep now accepts a docs directory path: it fans out to every manifest page under the directory with bounded concurrency and runs one multi-file grep, replacing the single-page restriction that forced agents into per-page call sweeps. Pages the site no longer serves are skipped; an unreachable page fails the whole grep so a partial result is never mistaken for "not documented". Co-Authored-By: Claude Fable 5 --- apps/sim/lib/copilot/docs/docs-corpus.test.ts | 93 ++++++++++++++++--- apps/sim/lib/copilot/docs/docs-corpus.ts | 80 ++++++++++++---- apps/sim/lib/copilot/tools/handlers/vfs.ts | 4 +- .../server/docs/search-docs-dispatch.test.ts | 20 ++-- 4 files changed, 151 insertions(+), 46 deletions(-) diff --git a/apps/sim/lib/copilot/docs/docs-corpus.test.ts b/apps/sim/lib/copilot/docs/docs-corpus.test.ts index f3dd5a8f4ad..33211bb7b9b 100644 --- a/apps/sim/lib/copilot/docs/docs-corpus.test.ts +++ b/apps/sim/lib/copilot/docs/docs-corpus.test.ts @@ -2,15 +2,21 @@ * @vitest-environment node */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@sim/utils/helpers', () => ({ + sleep: vi.fn(() => Promise.resolve()), +})) + import { couldMatchDocsScope, DocsCorpusError, globDocs, - grepDocsPage, + grepDocs, isDocsPath, readDocsPage, } from '@/lib/copilot/docs/docs-corpus' import { DOCS_MANIFEST } from '@/lib/copilot/generated/docs-manifest' +import type { GrepMatch } from '@/lib/copilot/vfs/operations' const SAMPLE_PAGE = DOCS_MANIFEST.find((path) => path === 'workflows/blocks/agent.mdx') @@ -98,33 +104,52 @@ describe('readDocsPage', () => { expect(fetchMock).not.toHaveBeenCalled() }) - it('surfaces a docs-site outage as a retryable error', async () => { + it('surfaces a docs-site outage as a retryable error after exhausting retries', async () => { fetchMock.mockResolvedValue({ ok: false, status: 502, text: async () => '' }) - await expect(readDocsPage(`docs/${SAMPLE_PAGE}`)).rejects.toThrow(/temporarily unavailable/) + await expect(readDocsPage(`docs/${SAMPLE_PAGE}`)).rejects.toThrow(/could not be reached/) + expect(fetchMock).toHaveBeenCalledTimes(3) }) it('treats a network failure as retryable', async () => { fetchMock.mockRejectedValue(new Error('socket hang up')) - await expect(readDocsPage(`docs/${SAMPLE_PAGE}`)).rejects.toThrow(/temporarily unavailable/) + await expect(readDocsPage(`docs/${SAMPLE_PAGE}`)).rejects.toThrow(/could not be reached/) + expect(fetchMock).toHaveBeenCalledTimes(3) + }) + + it('recovers when a transient failure clears on retry', async () => { + fetchMock + .mockRejectedValueOnce(new Error('socket hang up')) + .mockResolvedValue({ ok: true, status: 200, text: async () => '# Agent\n\nbody' }) + + const page = await readDocsPage(`docs/${SAMPLE_PAGE}`) + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(page).toEqual({ content: '# Agent\n\nbody', totalLines: 3 }) }) - it('reports a page the site no longer serves as permanent, not retryable', async () => { + it('reports a page the site no longer serves as permanent, without retrying', async () => { fetchMock.mockResolvedValue({ ok: false, status: 404, text: async () => '' }) const error = await readDocsPage(`docs/${SAMPLE_PAGE}`).catch((e) => e) expect(error).toBeInstanceOf(DocsCorpusError) expect(error.message).toMatch(/does not serve it/) expect(error.message).toMatch(/retrying will not help/) - expect(error.message).not.toMatch(/temporarily unavailable/) + expect(error.message).not.toMatch(/could not be reached/) + expect(fetchMock).toHaveBeenCalledOnce() }) it('still treats 429 as retryable rather than permanent', async () => { fetchMock.mockResolvedValue({ ok: false, status: 429, text: async () => '' }) - await expect(readDocsPage(`docs/${SAMPLE_PAGE}`)).rejects.toThrow(/temporarily unavailable/) + await expect(readDocsPage(`docs/${SAMPLE_PAGE}`)).rejects.toThrow(/could not be reached/) + expect(fetchMock).toHaveBeenCalledTimes(3) }) }) -describe('grepDocsPage', () => { +describe('grepDocs', () => { const fetchMock = vi.fn() + const SECTION_DIR = 'docs/workflows/blocks' + const SECTION_PAGES = DOCS_MANIFEST.filter((path) => path.startsWith('workflows/blocks/')).map( + (path) => `docs/${path}` + ) beforeEach(() => { fetchMock.mockReset() @@ -135,14 +160,14 @@ describe('grepDocsPage', () => { vi.unstubAllGlobals() }) - it('greps exactly one page', async () => { + it('greps exactly one page for a page path', async () => { fetchMock.mockResolvedValue({ ok: true, status: 200, text: async () => 'intro line\nsystemPrompt matters\ntail', }) - const matches = await grepDocsPage(`docs/${SAMPLE_PAGE}`, 'systemPrompt') + const matches = await grepDocs(`docs/${SAMPLE_PAGE}`, 'systemPrompt') expect(fetchMock).toHaveBeenCalledOnce() expect(matches).toEqual([ @@ -150,9 +175,51 @@ describe('grepDocsPage', () => { ]) }) - it('refuses a multi-page scope so one grep is never hundreds of fetches', async () => { - await expect(grepDocsPage('docs/', 'cron')).rejects.toThrow(/single page/) - await expect(grepDocsPage('docs/workflows', 'cron')).rejects.toThrow(/single page/) + it('greps a directory by fetching every page under it', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + text: async () => 'intro\ncron marker line\ntail', + }) + expect(SECTION_PAGES.length).toBeGreaterThan(1) + + const matches = (await grepDocs(SECTION_DIR, 'cron marker', { + maxResults: 10_000, + })) as GrepMatch[] + + expect(fetchMock).toHaveBeenCalledTimes(SECTION_PAGES.length) + expect(matches.map((match) => match.path)).toEqual(SECTION_PAGES) + }) + + it('skips pages the site no longer serves instead of failing the directory grep', async () => { + const missingUrl = `https://docs.sim.ai/${SECTION_PAGES[0].slice('docs/'.length)}` + fetchMock.mockImplementation(async (url: string) => + url === missingUrl + ? { ok: false, status: 404, text: async () => '' } + : { ok: true, status: 200, text: async () => 'cron marker line' } + ) + + const matches = (await grepDocs(SECTION_DIR, 'cron marker', { + maxResults: 10_000, + })) as GrepMatch[] + + expect(matches.map((match) => match.path)).toEqual(SECTION_PAGES.slice(1)) + }) + + it('fails the whole directory grep when a page cannot be reached', async () => { + fetchMock.mockImplementation(async (url: string) => + url.endsWith(`/${SAMPLE_PAGE}`) + ? { ok: false, status: 502, text: async () => '' } + : { ok: true, status: 200, text: async () => 'cron marker line' } + ) + + await expect(grepDocs(SECTION_DIR, 'cron marker')).rejects.toThrow(/Retry shortly/) + }) + + it('rejects a path that is neither a page nor a directory without fetching', async () => { + await expect(grepDocs('docs/not-a-real-page.mdx', 'cron')).rejects.toThrow( + /not a docs page or directory/ + ) expect(fetchMock).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/lib/copilot/docs/docs-corpus.ts b/apps/sim/lib/copilot/docs/docs-corpus.ts index 5b8bcbc3213..d02d07aa7ce 100644 --- a/apps/sim/lib/copilot/docs/docs-corpus.ts +++ b/apps/sim/lib/copilot/docs/docs-corpus.ts @@ -1,9 +1,12 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' +import { backoffWithJitter } from '@sim/utils/retry' import { foldDocsIndexPath } from '@/lib/copilot/docs/docs-path' import { DOCS_MANIFEST } from '@/lib/copilot/generated/docs-manifest' import type { GrepCountEntry, GrepMatch, GrepOptions } from '@/lib/copilot/vfs/operations' -import { glob as globPaths, grepReadResult } from '@/lib/copilot/vfs/operations' +import { glob as globPaths, grep, grepReadResult } from '@/lib/copilot/vfs/operations' +import { mapWithConcurrency } from '@/lib/core/utils/concurrency' const logger = createLogger('DocsCorpus') @@ -13,7 +16,12 @@ const DOCS_BASE_URL = 'https://docs.sim.ai' /** VFS prefix the docs corpus is mounted at. */ const DOCS_PREFIX = 'docs/' -const FETCH_TIMEOUT_MS = 10_000 +/** Per-attempt budget — the site is CDN-cached and normally answers in well under a second. */ +const FETCH_ATTEMPT_TIMEOUT_MS = 3_000 +const FETCH_MAX_ATTEMPTS = 3 + +/** Parallel page fetches for a directory-scoped grep. */ +const GREP_FETCH_CONCURRENCY = 8 /** * Thrown for expected, user-facing docs-corpus conditions (unknown page, @@ -108,8 +116,9 @@ export interface DocsPage { * Fetch one docs page's raw markdown from the live site. The manifest path IS * the URL path (`docs/workflows/blocks/agent.mdx` → * `https://docs.sim.ai/workflows/blocks/agent.mdx`, which the docs app rewrites - * to its raw-markdown route), so no mapping table is needed. Returns null when - * the page is not in the manifest or the site does not serve it. + * to its raw-markdown route), so no mapping table is needed. Transient failures + * (5xx, 429, network error, timeout) are retried with jittered backoff before + * being reported as unavailable. */ type DocsFetchResult = | { outcome: 'ok'; content: string } @@ -118,13 +127,10 @@ type DocsFetchResult = /** Transient: 5xx, 429, network error, or timeout. */ | { outcome: 'unavailable' } -async function fetchDocsPage(path: string): Promise { - const key = normalize(path) - if (!docsKeyView.has(key)) return { outcome: 'missing' } - const url = `${DOCS_BASE_URL}/${key.slice(DOCS_PREFIX.length)}` +async function fetchDocsPageOnce(url: string): Promise { try { const response = await fetch(url, { - signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + signal: AbortSignal.timeout(FETCH_ATTEMPT_TIMEOUT_MS), headers: { Accept: 'text/markdown, text/plain' }, }) if (!response.ok) { @@ -139,6 +145,17 @@ async function fetchDocsPage(path: string): Promise { } } +async function fetchDocsPage(path: string): Promise { + const key = normalize(path) + if (!docsKeyView.has(key)) return { outcome: 'missing' } + const url = `${DOCS_BASE_URL}/${key.slice(DOCS_PREFIX.length)}` + for (let attempt = 1; ; attempt++) { + const result = await fetchDocsPageOnce(url) + if (result.outcome !== 'unavailable' || attempt >= FETCH_MAX_ATTEMPTS) return result + await sleep(backoffWithJitter(attempt, null)) + } +} + /** * Read one docs page. Throws {@link DocsCorpusError} for the expected user-facing * conditions (directory path, unknown page, site unreachable) so the handler can @@ -163,28 +180,55 @@ export async function readDocsPage(path: string): Promise { } if (result.outcome === 'unavailable') { throw new DocsCorpusError( - `Could not load ${key} from ${DOCS_BASE_URL} — the docs site is temporarily unavailable. Retry shortly.` + `Could not load ${key} from ${DOCS_BASE_URL} — the docs site could not be reached. Retry shortly.` ) } return { content: result.content, totalLines: result.content.split('\n').length } } /** - * Grep ONE docs page, mirroring how grep over `files/` works: each page is a - * separate fetch from the docs site, so a multi-page grep would mean hundreds of - * requests. A path that is not a single page throws. + * Grep the docs corpus. A single page greps just that page. A directory path + * (`docs`, `docs/files`) fans out to every manifest page under it: pages are + * fetched in parallel and searched as one multi-file grep, so results follow + * manifest order and `maxResults` applies across pages. Pages the site no + * longer serves are skipped; a page that cannot be reached after retries fails + * the whole grep, because a silent partial result would misread as "not + * documented". */ -export async function grepDocsPage( +export async function grepDocs( path: string, pattern: string, options?: GrepOptions ): Promise { const key = normalize(path) - if (!docsKeyView.has(key)) { + if (docsKeyView.has(key)) { + const page = await readDocsPage(key) + return grepReadResult(key, page, pattern, key, options) + } + if (!isDocsDir(key)) { + throw new DocsCorpusError( + `"${path}" is not a docs page or directory. Use glob("docs/**") to list the docs corpus.` + ) + } + const dir = `${key}/` + const pages = [...docsKeyView.keys()].filter((pageKey) => pageKey.startsWith(dir)) + let unreachable = 0 + const results = await mapWithConcurrency(pages, GREP_FETCH_CONCURRENCY, async (pageKey) => { + // Once any page is unreachable the grep is going to fail — skip the + // remaining fetches instead of hammering a site that is not answering. + if (unreachable > 0) return null + const result = await fetchDocsPage(pageKey) + if (result.outcome === 'unavailable') unreachable++ + return result + }) + if (unreachable > 0) { throw new DocsCorpusError( - `Grep over the docs corpus must target a single page (e.g. path: "docs/workflows/blocks/agent.mdx"). "${path}" is not a docs page. Use glob("docs/**") to find the exact path, then grep that one page.` + `Could not load every page under ${dir} from ${DOCS_BASE_URL} — a partial grep could misread as "not documented". Retry shortly.` ) } - const page = await readDocsPage(key) - return grepReadResult(key, page, pattern, key, options) + const contents = new Map() + results.forEach((result, index) => { + if (result?.outcome === 'ok') contents.set(pages[index], result.content) + }) + return grep(contents, pattern, undefined, options) } diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.ts b/apps/sim/lib/copilot/tools/handlers/vfs.ts index 28ca0e35082..25dc04e7791 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.ts @@ -6,7 +6,7 @@ import { couldMatchDocsScope, DocsCorpusError, globDocs, - grepDocsPage, + grepDocs, isDocsPath, readDocsPage, } from '@/lib/copilot/docs/docs-corpus' @@ -186,7 +186,7 @@ export async function executeVfsGrep( let result: GrepMatch[] | string[] | GrepCountEntry[] let provenanceFile: WorkspaceFileSecretProvenanceIdentity | undefined if (rawPath !== undefined && isDocsPath(rawPath)) { - result = await grepDocsPage(rawPath, pattern, grepOptions) + result = await grepDocs(rawPath, pattern, grepOptions) } else if (isChatUploadGrepPath(rawPath)) { if (!context.chatId) { return { success: false, error: 'No chat context available for uploads/' } diff --git a/apps/sim/lib/copilot/tools/server/docs/search-docs-dispatch.test.ts b/apps/sim/lib/copilot/tools/server/docs/search-docs-dispatch.test.ts index c3f406c56b9..3634648b5b8 100644 --- a/apps/sim/lib/copilot/tools/server/docs/search-docs-dispatch.test.ts +++ b/apps/sim/lib/copilot/tools/server/docs/search-docs-dispatch.test.ts @@ -27,19 +27,13 @@ describe('search_docs dispatch chain', () => { }) }) -/** - * The retired ids are fully unregistered server-side — no catalog entry, no - * handler, no alias. Only the client-side chip suppression survives, forever, - * so historical persisted chats replay without rendering chips for tools that - * no longer exist (the load_agent_skill precedent). - */ -describe('retired docs-tool ids', () => { - for (const retired of ['search_documentation', 'get_platform_actions']) { - it(`${retired} is gone from the catalog and server registry but stays chip-hidden`, () => { - expect(TOOL_CATALOG[retired]).toBeUndefined() - expect(isKnownTool(retired)).toBe(false) - expect(getRegisteredServerToolNames()).not.toContain(retired) - expect(getHiddenToolNames().has(retired)).toBe(true) +describe('removed docs-tool ids', () => { + for (const removed of ['search_documentation', 'get_platform_actions']) { + it(`${removed} is absent from the catalog, registries, and hidden-tool set`, () => { + expect(TOOL_CATALOG[removed]).toBeUndefined() + expect(isKnownTool(removed)).toBe(false) + expect(getRegisteredServerToolNames()).not.toContain(removed) + expect(getHiddenToolNames().has(removed)).toBe(false) }) } }) From 5c7fc0fafcacf31916a490f292d791c5bb6b6072 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:27:36 -0700 Subject: [PATCH 19/24] chore(copilot): drop the retired search_documentation test The tool was retired outright with the search_docs replacement; its test outlived the module on staging and no longer resolves. Co-Authored-By: Claude Fable 5 --- .../server/docs/search-documentation.test.ts | 56 ------------------- 1 file changed, 56 deletions(-) delete mode 100644 apps/sim/lib/copilot/tools/server/docs/search-documentation.test.ts diff --git a/apps/sim/lib/copilot/tools/server/docs/search-documentation.test.ts b/apps/sim/lib/copilot/tools/server/docs/search-documentation.test.ts deleted file mode 100644 index f61622fafff..00000000000 --- a/apps/sim/lib/copilot/tools/server/docs/search-documentation.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * @vitest-environment node - */ -import { loggerMock } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockGenerateSearchEmbedding } = vi.hoisted(() => ({ - mockGenerateSearchEmbedding: vi.fn(), -})) - -vi.mock('@/lib/copilot/generated/tool-catalog-v1', () => ({ - SearchDocumentation: { id: 'search_documentation' }, -})) -vi.mock('@/lib/knowledge/embeddings', () => ({ - generateSearchEmbedding: mockGenerateSearchEmbedding, -})) - -import { searchDocumentationServerTool } from '@/lib/copilot/tools/server/docs/search-documentation' -import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' - -describe('documentation search model boundary', () => { - beforeEach(() => { - vi.clearAllMocks() - mockGenerateSearchEmbedding.mockResolvedValue({ embedding: [], isBYOK: false }) - }) - - it('projects the query immediately before embedding without logging plaintext', async () => { - const registry = new ResolvedSecretTraceRegistry([ - { - name: 'DOCS_QUERY', - plaintext: 'private documentation query', - encryptedValue: 'encrypted-query', - }, - ]) - registry.recordResolved('DOCS_QUERY', 'private documentation query') - - const result = await searchDocumentationServerTool.execute( - { query: 'private documentation query' }, - { userId: 'user-1', resolvedSecretTraceRegistry: registry } - ) - - expect(mockGenerateSearchEmbedding).toHaveBeenCalledWith('{{DOCS_QUERY}}') - expect(result).toEqual({ - results: [], - query: 'private documentation query', - totalResults: 0, - }) - - const logger = loggerMock.createLogger.mock.results.at(-1)?.value - expect(logger?.info).toHaveBeenCalledWith('Executing docs search', { - queryLength: 'private documentation query'.length, - topK: 10, - }) - expect(JSON.stringify(logger?.info.mock.calls)).not.toContain('private documentation query') - }) -}) From bbb7ca76ec2de661f7bb3d998914549bfb195c8a Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:28:59 -0700 Subject: [PATCH 20/24] test(copilot): cover directory-scoped docs grep at the handler level The vfs handler test still pinned the retired single-page restriction; directory grep now succeeds with a parallel page fan-out, and an invalid path (neither page nor directory) is the remaining rejection. Co-Authored-By: Claude Fable 5 --- apps/sim/lib/copilot/tools/handlers/vfs.test.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.test.ts b/apps/sim/lib/copilot/tools/handlers/vfs.test.ts index 70e3c2bad7c..fdd9e9e4c74 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.test.ts @@ -611,7 +611,7 @@ describe('vfs handlers docs corpus routing', () => { expect(fetchMock).not.toHaveBeenCalled() }) - it('greps exactly one docs page and rejects multi-page scopes verbatim', async () => { + it('greps one docs page or a docs directory without touching the workspace VFS', async () => { fetchMock.mockResolvedValue({ ok: true, status: 200, @@ -621,9 +621,16 @@ describe('vfs handlers docs corpus routing', () => { const single = await executeVfsGrep({ pattern: 'cron', path: DOCS_PAGE }, GREP_CTX) expect(single.success).toBe(true) - const multi = await executeVfsGrep({ pattern: 'cron', path: 'docs/workflows' }, GREP_CTX) - expect(multi.success).toBe(false) - expect(multi.error).toContain('single page') + const multi = await executeVfsGrep( + { pattern: 'cron', path: 'docs/workflows', maxResults: 10_000 }, + GREP_CTX + ) + expect(multi.success).toBe(true) + expect(fetchMock.mock.calls.length).toBeGreaterThan(1) + + const invalid = await executeVfsGrep({ pattern: 'cron', path: 'docs/not-a-page.mdx' }, GREP_CTX) + expect(invalid.success).toBe(false) + expect(invalid.error).toContain('not a docs page or directory') expect(getOrMaterializeVFS).not.toHaveBeenCalled() }) From 35a420a52449df88802be097cabb32a8a66657e9 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:49:37 -0700 Subject: [PATCH 21/24] chore(copilot): resync the generated tool catalog from mothership contracts The rebase resolutions carried arc-era generated output missing staging's browser and terminal tools; resync from the regenerated mothership contract so the mirror matches. Co-Authored-By: Claude Fable 5 --- .../lib/copilot/generated/tool-catalog-v1.ts | 1702 +++++++++++++---- .../lib/copilot/generated/tool-schemas-v1.ts | 1035 ++++++++-- 2 files changed, 2220 insertions(+), 517 deletions(-) diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 9992ea34d61..5e93bb2f17c 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -9,17 +9,35 @@ export interface ToolCatalogEntry { id: | 'agent' | 'auth' + | 'browser' + | 'browser_click' + | 'browser_close_tab' + | 'browser_extract' + | 'browser_go_back' + | 'browser_go_forward' + | 'browser_hover' + | 'browser_list_sessions' + | 'browser_list_tabs' + | 'browser_navigate' + | 'browser_open_tab' + | 'browser_open_url' + | 'browser_press_key' + | 'browser_read_text' + | 'browser_request_takeover' + | 'browser_screenshot' + | 'browser_scroll' + | 'browser_select_option' + | 'browser_snapshot' + | 'browser_switch_tab' + | 'browser_type' + | 'browser_wait_for' | 'call_integration_tool' | 'check_deployment_status' - | 'complete_scheduled_task' | 'cp' | 'crawl_website' | 'create_file' | 'create_workflow' | 'create_workspace_mcp_server' - | 'delete_file' - | 'delete_file_folder' - | 'delete_workflow' | 'delete_workspace_mcp_server' | 'deploy' | 'deploy_api' @@ -43,7 +61,6 @@ export interface ToolCatalogEntry { | 'get_deployed_workflow_state' | 'get_deployment_log' | 'get_page_contents' - | 'get_scheduled_task_logs' | 'get_workflow_data' | 'get_workflow_run_options' | 'glob' @@ -55,11 +72,11 @@ export interface ToolCatalogEntry { | 'list_workspace_mcp_servers' | 'load_deployment' | 'load_integration_tool' + | 'load_skill' | 'manage_credential' | 'manage_custom_tool' - | 'manage_folder' | 'manage_mcp_tool' - | 'manage_scheduled_task' + | 'manage_sandbox' | 'manage_skill' | 'materialize_file' | 'media' @@ -75,13 +92,13 @@ export interface ToolCatalogEntry { | 'redeploy' | 'respond' | 'restore_resource' + | 'rm' | 'run' | 'run_block' | 'run_code' | 'run_from_block' | 'run_workflow' | 'run_workflow_until_block' - | 'scheduled_task' | 'scrape_page' | 'search' | 'search_docs' @@ -95,10 +112,11 @@ export interface ToolCatalogEntry { | 'set_global_workflow_variables' | 'share_file' | 'table' + | 'terminal' | 'update_deployment_version' - | 'update_scheduled_task_history' | 'update_workspace_mcp_server' | 'user_table' + | 'wait' | 'workflow' | 'workspace_file' internal?: boolean @@ -106,17 +124,35 @@ export interface ToolCatalogEntry { name: | 'agent' | 'auth' + | 'browser' + | 'browser_click' + | 'browser_close_tab' + | 'browser_extract' + | 'browser_go_back' + | 'browser_go_forward' + | 'browser_hover' + | 'browser_list_sessions' + | 'browser_list_tabs' + | 'browser_navigate' + | 'browser_open_tab' + | 'browser_open_url' + | 'browser_press_key' + | 'browser_read_text' + | 'browser_request_takeover' + | 'browser_screenshot' + | 'browser_scroll' + | 'browser_select_option' + | 'browser_snapshot' + | 'browser_switch_tab' + | 'browser_type' + | 'browser_wait_for' | 'call_integration_tool' | 'check_deployment_status' - | 'complete_scheduled_task' | 'cp' | 'crawl_website' | 'create_file' | 'create_workflow' | 'create_workspace_mcp_server' - | 'delete_file' - | 'delete_file_folder' - | 'delete_workflow' | 'delete_workspace_mcp_server' | 'deploy' | 'deploy_api' @@ -140,7 +176,6 @@ export interface ToolCatalogEntry { | 'get_deployed_workflow_state' | 'get_deployment_log' | 'get_page_contents' - | 'get_scheduled_task_logs' | 'get_workflow_data' | 'get_workflow_run_options' | 'glob' @@ -152,11 +187,11 @@ export interface ToolCatalogEntry { | 'list_workspace_mcp_servers' | 'load_deployment' | 'load_integration_tool' + | 'load_skill' | 'manage_credential' | 'manage_custom_tool' - | 'manage_folder' | 'manage_mcp_tool' - | 'manage_scheduled_task' + | 'manage_sandbox' | 'manage_skill' | 'materialize_file' | 'media' @@ -172,13 +207,13 @@ export interface ToolCatalogEntry { | 'redeploy' | 'respond' | 'restore_resource' + | 'rm' | 'run' | 'run_block' | 'run_code' | 'run_from_block' | 'run_workflow' | 'run_workflow_until_block' - | 'scheduled_task' | 'scrape_page' | 'search' | 'search_docs' @@ -192,25 +227,27 @@ export interface ToolCatalogEntry { | 'set_global_workflow_variables' | 'share_file' | 'table' + | 'terminal' | 'update_deployment_version' - | 'update_scheduled_task_history' | 'update_workspace_mcp_server' | 'user_table' + | 'wait' | 'workflow' | 'workspace_file' parameters: unknown requiredPermission?: 'admin' | 'write' + requiresApproval?: boolean resultSchema?: unknown route: 'client' | 'go' | 'sim' | 'subagent' subagentId?: | 'agent' | 'auth' + | 'browser' | 'deploy' | 'file' | 'knowledge' | 'media' | 'run' - | 'scheduled_task' | 'search' | 'table' | 'workflow' @@ -249,6 +286,910 @@ export const Auth: ToolCatalogEntry = { internal: true, } +export const Browser: ToolCatalogEntry = { + id: 'browser', + name: 'browser', + route: 'subagent', + mode: 'async', + parameters: { + properties: { + task: { + description: + 'The web task to complete, in plain language (include the target site/URL if known).', + type: 'string', + }, + }, + required: ['task'], + type: 'object', + }, + subagentId: 'browser', + internal: true, +} + +export const BrowserClick: ToolCatalogEntry = { + id: 'browser_click', + name: 'browser_click', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + elementId: { + type: 'number', + description: + "The element id to act on (from the current tab's most recent browser_snapshot). Treat refs as invalid across tab switches or later snapshots.", + }, + }, + required: ['elementId'], + }, + resultSchema: { + type: 'object', + properties: { + activation: { + type: 'string', + description: 'native-pointer, native-keyboard, or synthetic-pointer.', + }, + activeTab: { + type: 'object', + description: 'New active tab after a tab-changing click.', + properties: { + tabId: { type: 'string', description: 'Stable browser tab id.' }, + url: { type: 'string', description: 'New active tab URL.' }, + }, + }, + dialogs: { + type: 'array', + description: 'Visible DOM dialogs remaining after the click.', + items: { type: 'string' }, + }, + dispatched: { type: 'boolean', description: 'Whether input dispatch completed.' }, + effect: { + type: 'object', + description: + 'Detailed postcondition signals; generic title/DOM/scroll churn is weak evidence unless the tool documents otherwise.', + properties: { + dialogChanged: { type: 'boolean', description: 'The visible DOM dialog set changed.' }, + domChanged: { + type: 'boolean', + description: 'The DOM mutation revision changed; weak evidence on its own.', + }, + fieldChanged: { + type: 'boolean', + description: 'The safely inspectable focused-field state changed.', + }, + focusChanged: { type: 'boolean', description: 'The focused element changed.' }, + popupChanged: { type: 'boolean', description: 'The visible popup/menu set changed.' }, + scrollChanged: { + type: 'boolean', + description: 'A tracked scroll offset changed; weak evidence except for scroll keys.', + }, + tabChanged: { type: 'boolean', description: 'The active browser tab changed.' }, + targetChanged: { + type: 'boolean', + description: "The requested target's checked/selected/expanded/open state changed.", + }, + titleChanged: { + type: 'boolean', + description: 'The document title changed; weak evidence on its own.', + }, + urlChanged: { type: 'boolean', description: 'The observed URL changed.' }, + }, + }, + effectObserved: { + type: 'boolean', + description: + 'Whether a URL/tab/dialog/popup/target-state change, or editable-target focus change, was observed.', + }, + element: { type: 'string', description: 'Resolved target element kind.' }, + note: { type: 'string', description: 'Postcondition or recovery guidance.' }, + notices: { + type: 'array', + description: + 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', + items: { type: 'string' }, + }, + obstructedAfterNavigation: { + type: 'boolean', + description: 'Navigation/tab change occurred while a visible DOM dialog remained.', + }, + possibleEffectObserved: { + type: 'boolean', + description: 'Includes weak title/DOM/scroll churn; never treat this alone as success.', + }, + refRecovered: { + type: 'boolean', + description: + 'Whether a stale detached ref was safely rebound to one unique semantic match.', + }, + trusted: { + type: 'boolean', + description: 'Whether Chromium trusted pointer/keyboard input was used.', + }, + }, + required: ['dispatched'], + }, + clientExecutable: true, +} + +export const BrowserCloseTab: ToolCatalogEntry = { + id: 'browser_close_tab', + name: 'browser_close_tab', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + tabId: { + type: 'string', + description: 'The id of the tab to close (from browser_list_tabs).', + }, + }, + required: ['tabId'], + }, + clientExecutable: true, +} + +export const BrowserExtract: ToolCatalogEntry = { + id: 'browser_extract', + name: 'browser_extract', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + instruction: { + type: 'string', + description: + 'What you intend to extract, in plain language. Echoed back unchanged; it does not filter or shape the returned text.', + }, + }, + required: ['instruction'], + }, + resultSchema: { + type: 'object', + properties: { + instruction: { type: 'string', description: 'The extraction instruction echoed unchanged.' }, + notices: { + type: 'array', + description: + 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', + items: { type: 'string' }, + }, + page: { + type: 'object', + description: 'Bounded visible page/frame text result.', + properties: { + framesRead: { + type: 'number', + description: 'Visible child frames whose text was appended.', + }, + hiddenFrames: { + type: 'number', + description: + 'Eligible child frames skipped because their embedding surface was not visible.', + }, + text: { + type: 'string', + description: + 'Visible text, capped across the top page and eligible visible child frames.', + }, + title: { type: 'string', description: 'Top-page title when available.' }, + truncated: { + type: 'boolean', + description: 'Whether a page, frame, or combined character cap omitted text.', + }, + unreadableFrames: { + type: 'number', + description: 'Eligible child frames whose text could not be read.', + }, + url: { type: 'string', description: 'Top-page URL.' }, + }, + }, + }, + }, + clientExecutable: true, +} + +export const BrowserGoBack: ToolCatalogEntry = { + id: 'browser_go_back', + name: 'browser_go_back', + route: 'client', + mode: 'async', + parameters: { type: 'object', properties: {} }, + clientExecutable: true, +} + +export const BrowserGoForward: ToolCatalogEntry = { + id: 'browser_go_forward', + name: 'browser_go_forward', + route: 'client', + mode: 'async', + parameters: { type: 'object', properties: {} }, + clientExecutable: true, +} + +export const BrowserHover: ToolCatalogEntry = { + id: 'browser_hover', + name: 'browser_hover', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + elementId: { + type: 'number', + description: + "The element id to act on (from the current tab's most recent browser_snapshot). Treat refs as invalid across tab switches or later snapshots.", + }, + }, + required: ['elementId'], + }, + resultSchema: { + type: 'object', + properties: { + effect: { + type: 'object', + description: + 'Detailed postcondition signals; generic title/DOM/scroll churn is weak evidence unless the tool documents otherwise.', + properties: { + dialogChanged: { type: 'boolean', description: 'The visible DOM dialog set changed.' }, + domChanged: { + type: 'boolean', + description: 'The DOM mutation revision changed; weak evidence on its own.', + }, + fieldChanged: { + type: 'boolean', + description: 'The safely inspectable focused-field state changed.', + }, + focusChanged: { type: 'boolean', description: 'The focused element changed.' }, + popupChanged: { type: 'boolean', description: 'The visible popup/menu set changed.' }, + scrollChanged: { + type: 'boolean', + description: 'A tracked scroll offset changed; weak evidence except for scroll keys.', + }, + tabChanged: { type: 'boolean', description: 'The active browser tab changed.' }, + targetChanged: { + type: 'boolean', + description: "The requested target's checked/selected/expanded/open state changed.", + }, + titleChanged: { + type: 'boolean', + description: 'The document title changed; weak evidence on its own.', + }, + urlChanged: { type: 'boolean', description: 'The observed URL changed.' }, + }, + }, + effectObserved: { + type: 'boolean', + description: 'Whether a URL/dialog/popup/target-state change was observed.', + }, + element: { type: 'string', description: 'Resolved target element kind when available.' }, + hovered: { type: 'boolean', description: 'Whether hover input was dispatched.' }, + note: { type: 'string', description: 'Guidance when no tooltip/menu was confirmed.' }, + notices: { + type: 'array', + description: + 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', + items: { type: 'string' }, + }, + possibleEffectObserved: { + type: 'boolean', + description: 'Includes weak title/DOM/scroll churn; not proof of success.', + }, + refRecovered: { + type: 'boolean', + description: + 'Whether a stale detached ref was safely rebound to one unique semantic match.', + }, + trusted: { + type: 'boolean', + description: 'Whether Chromium trusted pointer movement was used.', + }, + }, + required: ['hovered'], + }, + clientExecutable: true, +} + +export const BrowserListSessions: ToolCatalogEntry = { + id: 'browser_list_sessions', + name: 'browser_list_sessions', + route: 'client', + mode: 'async', + parameters: { type: 'object', properties: {} }, + clientExecutable: true, +} + +export const BrowserListTabs: ToolCatalogEntry = { + id: 'browser_list_tabs', + name: 'browser_list_tabs', + route: 'client', + mode: 'async', + parameters: { type: 'object', properties: {} }, + clientExecutable: true, +} + +export const BrowserNavigate: ToolCatalogEntry = { + id: 'browser_navigate', + name: 'browser_navigate', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + url: { + type: 'string', + description: + 'The absolute URL to navigate to, including scheme (https:// or http://). Must resolve to a public address — localhost and private/internal hosts are rejected.', + }, + }, + required: ['url'], + }, + clientExecutable: true, +} + +export const BrowserOpenTab: ToolCatalogEntry = { + id: 'browser_open_tab', + name: 'browser_open_tab', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { url: { type: 'string', description: 'Optional URL to open the new tab at.' } }, + }, + clientExecutable: true, +} + +export const BrowserOpenUrl: ToolCatalogEntry = { + id: 'browser_open_url', + name: 'browser_open_url', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + url: { + type: 'string', + description: + 'The absolute URL to open, including scheme (https:// or http:// — localhost/local dev URLs are supported).', + }, + }, + required: ['url'], + }, + clientExecutable: true, +} + +export const BrowserPressKey: ToolCatalogEntry = { + id: 'browser_press_key', + name: 'browser_press_key', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + key: { + type: 'string', + description: + "Key or combination. Named keys (case-insensitive): Enter, Escape (Esc), Tab, Backspace, Delete, Space, ArrowUp/ArrowDown/ArrowLeft/ArrowRight (or Up/Down/Left/Right), Home, End, PageUp, PageDown. Any single character also works ('a', '5', '/', ','). Anything else — 'F5', 'Return', 'Insert' — is rejected. Join modifiers with '+'. Use Mod (aliases Primary, ControlOrMeta, CommandOrControl) for the platform primary modifier, e.g. Mod+K or Mod+,. Raw Control/Ctrl and Cmd/Command/Meta remain available; Control is not generally Cmd on macOS. Check effectObserved and primaryModifier in the result.", + }, + }, + required: ['key'], + }, + resultSchema: { + type: 'object', + properties: { + activeElement: { type: 'string', description: 'Focused element kind after the action.' }, + dialogs: { + type: 'array', + description: 'Visible DOM dialogs after the key.', + items: { type: 'string' }, + }, + effect: { + type: 'object', + description: + 'Detailed postcondition signals; generic title/DOM/scroll churn is weak evidence unless the tool documents otherwise.', + properties: { + dialogChanged: { type: 'boolean', description: 'The visible DOM dialog set changed.' }, + domChanged: { + type: 'boolean', + description: 'The DOM mutation revision changed; weak evidence on its own.', + }, + fieldChanged: { + type: 'boolean', + description: 'The safely inspectable focused-field state changed.', + }, + focusChanged: { type: 'boolean', description: 'The focused element changed.' }, + popupChanged: { type: 'boolean', description: 'The visible popup/menu set changed.' }, + scrollChanged: { + type: 'boolean', + description: 'A tracked scroll offset changed; weak evidence except for scroll keys.', + }, + tabChanged: { type: 'boolean', description: 'The active browser tab changed.' }, + targetChanged: { + type: 'boolean', + description: "The requested target's checked/selected/expanded/open state changed.", + }, + titleChanged: { + type: 'boolean', + description: 'The document title changed; weak evidence on its own.', + }, + urlChanged: { type: 'boolean', description: 'The observed URL changed.' }, + }, + }, + effectObserved: { type: 'boolean', description: 'A strong targeted effect was observed.' }, + note: { type: 'string', description: 'No-op/fallback guidance.' }, + notices: { + type: 'array', + description: + 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', + items: { type: 'string' }, + }, + possibleEffectObserved: { + type: 'boolean', + description: 'Includes weak title/DOM/scroll churn; not proof of success.', + }, + pressed: { type: 'string', description: 'Requested key/combo whose dispatch completed.' }, + primaryModifier: { type: 'string', description: 'Cmd on macOS, Control elsewhere.' }, + redacted: { + type: 'boolean', + description: 'Whether sensitive focused-field details were withheld.', + }, + selectedChars: { + type: 'number', + description: 'Number of selected characters when safely inspectable.', + }, + target: { + type: 'string', + description: 'Synthetic fallback target element kind, when applicable.', + }, + trusted: { type: 'boolean', description: 'Whether Chromium trusted key input was used.' }, + valueLength: { + type: 'number', + description: 'Focused non-secret field length when safely inspectable.', + }, + valuePreview: { + type: 'string', + description: 'Bounded focused non-secret field preview when safely inspectable.', + }, + }, + required: ['pressed'], + }, + clientExecutable: true, +} + +export const BrowserReadText: ToolCatalogEntry = { + id: 'browser_read_text', + name: 'browser_read_text', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + elementId: { + type: 'number', + description: + "Optional element id from the current tab's most recent browser_snapshot. Treat refs as invalid across tab switches or later snapshots. Omit to read the whole page.", + }, + }, + }, + resultSchema: { + type: 'object', + properties: { + framesRead: { type: 'number', description: 'Visible child frames whose text was appended.' }, + hiddenFrames: { + type: 'number', + description: + 'Eligible child frames skipped because their embedding surface was not visible.', + }, + notices: { + type: 'array', + description: + 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', + items: { type: 'string' }, + }, + text: { + type: 'string', + description: 'Visible text, capped across the top page and eligible visible child frames.', + }, + title: { type: 'string', description: 'Top-page title when available.' }, + truncated: { + type: 'boolean', + description: 'Whether a page, frame, or combined character cap omitted text.', + }, + unreadableFrames: { + type: 'number', + description: 'Eligible child frames whose text could not be read.', + }, + url: { type: 'string', description: 'Top-page URL.' }, + }, + }, + clientExecutable: true, +} + +export const BrowserRequestTakeover: ToolCatalogEntry = { + id: 'browser_request_takeover', + name: 'browser_request_takeover', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + purpose: { + type: 'string', + description: + 'Why takeover is needed. Set sign_in for a login/password flow so the desktop can remember a privacy-preserving session hint after the user finishes.', + enum: ['sign_in', 'captcha', 'payment', 'sensitive_confirmation', 'other'], + }, + reason: { + type: 'string', + description: + "Short explanation shown to the user of what they need to do (e.g. 'Sign in to Notion').", + }, + }, + required: ['reason'], + }, + clientExecutable: true, +} + +export const BrowserScreenshot: ToolCatalogEntry = { + id: 'browser_screenshot', + name: 'browser_screenshot', + route: 'client', + mode: 'async', + parameters: { type: 'object', properties: {} }, + clientExecutable: true, +} + +export const BrowserScroll: ToolCatalogEntry = { + id: 'browser_scroll', + name: 'browser_scroll', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + amount: { + type: 'number', + description: + 'Optional distance to scroll in pixels (default: 85% of the viewport height, so a little context carries over).', + }, + direction: { type: 'string', description: 'Scroll direction.', enum: ['up', 'down'] }, + elementId: { + type: 'number', + description: + "The element id to act on (from the current tab's most recent browser_snapshot). Treat refs as invalid across tab switches or later snapshots.", + }, + }, + required: ['direction'], + }, + resultSchema: { + type: 'object', + properties: { + atBottom: { + type: 'boolean', + description: 'Whether the selected region is at its bottom boundary.', + }, + atTop: { + type: 'boolean', + description: 'Whether the selected region is at its top boundary.', + }, + clientHeight: { type: 'number', description: 'Region viewport height.' }, + movedBy: { + type: 'number', + description: 'Actual signed movement; zero means the target did not move.', + }, + notices: { + type: 'array', + description: + 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', + items: { type: 'string' }, + }, + scrollHeight: { type: 'number', description: 'Region content height.' }, + scrollTop: { type: 'number', description: 'Resulting region scroll offset.' }, + target: { type: 'string', description: 'Chosen scroll region label.' }, + targetSource: { + type: 'string', + description: + 'element, element-boundary, focus, focus-boundary, viewport-center, viewport-center-boundary, largest-visible, or page.', + }, + windowScrollY: { + type: 'number', + description: 'Top-page window scroll offset after the region scroll.', + }, + }, + required: ['atTop', 'atBottom'], + }, + clientExecutable: true, +} + +export const BrowserSelectOption: ToolCatalogEntry = { + id: 'browser_select_option', + name: 'browser_select_option', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + elementId: { + type: 'number', + description: + "The element id to act on (from the current tab's most recent browser_snapshot). Treat refs as invalid across tab switches or later snapshots.", + }, + value: { type: 'string', description: "The option's visible label or its value." }, + }, + required: ['elementId', 'value'], + }, + resultSchema: { + type: 'object', + properties: { + effectObserved: { + type: 'boolean', + description: 'Whether the settled readback retained the requested selection.', + }, + note: { type: 'string', description: 'Guidance when the page reverted the selection.' }, + notices: { + type: 'array', + description: + 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', + items: { type: 'string' }, + }, + readback: { + type: 'object', + description: 'Settled selected label and value.', + properties: { + selected: { type: 'string', description: 'Settled visible option label.' }, + value: { type: 'string', description: 'Settled option value.' }, + }, + }, + refRecovered: { + type: 'boolean', + description: + 'Whether a stale detached ref was safely rebound to one unique semantic match.', + }, + selected: { type: 'string', description: 'Canonical visible label of the matched option.' }, + value: { type: 'string', description: 'Canonical value of the matched option.' }, + }, + required: ['selected'], + }, + clientExecutable: true, +} + +export const BrowserSnapshot: ToolCatalogEntry = { + id: 'browser_snapshot', + name: 'browser_snapshot', + route: 'client', + mode: 'async', + parameters: { type: 'object', properties: {} }, + resultSchema: { + type: 'object', + properties: { + capturedCrossOriginFrames: { + type: 'number', + description: 'Number of non-empty eligible cross-origin frames appended.', + }, + hiddenCrossOriginFrames: { + type: 'number', + description: + 'Eligible cross-origin frames skipped because their embedding surface was hidden, offscreen, or covered.', + }, + notices: { + type: 'array', + description: + 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', + items: { type: 'string' }, + }, + outline: { + type: 'string', + description: 'Mounted DOM/frame outline containing model-visible [ref=N] ids.', + }, + pageHeight: { type: 'number', description: 'Top-page document height.' }, + scrollY: { type: 'number', description: 'Top-page window scroll offset.' }, + title: { type: 'string', description: 'Captured top-page title.' }, + truncated: { + type: 'boolean', + description: 'True when page/ref/frame/combined output caps omitted content.', + }, + unreadableCrossOriginFrames: { + type: 'number', + description: 'Eligible cross-origin frames that could not be captured.', + }, + url: { type: 'string', description: 'Captured top-page URL.' }, + viewportHeight: { type: 'number', description: 'Top-page viewport height.' }, + viewportWidth: { type: 'number', description: 'Top-page viewport width.' }, + }, + required: ['outline', 'truncated'], + }, + clientExecutable: true, +} + +export const BrowserSwitchTab: ToolCatalogEntry = { + id: 'browser_switch_tab', + name: 'browser_switch_tab', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + tabId: { + type: 'string', + description: 'The id of the tab to activate (from browser_list_tabs).', + }, + }, + required: ['tabId'], + }, + clientExecutable: true, +} + +export const BrowserType: ToolCatalogEntry = { + id: 'browser_type', + name: 'browser_type', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + elementId: { + type: 'number', + description: + "The element id to act on (from the current tab's most recent browser_snapshot). Treat refs as invalid across tab switches or later snapshots.", + }, + submit: { type: 'boolean', description: 'Press Enter after typing. Default false.' }, + text: { + type: 'string', + description: + "The text to type. Replaces the element's current content. Must be non-empty — an empty string is rejected as a missing parameter; to clear a field, press Mod+A then Backspace with browser_press_key.", + }, + }, + required: ['elementId', 'text'], + }, + resultSchema: { + type: 'object', + properties: { + activeElement: { type: 'string', description: 'Focused element kind after the action.' }, + dispatched: { type: 'boolean', description: 'Whether text dispatch completed.' }, + effect: { + type: 'object', + description: + 'Detailed postcondition signals; generic title/DOM/scroll churn is weak evidence unless the tool documents otherwise.', + properties: { + dialogChanged: { type: 'boolean', description: 'The visible DOM dialog set changed.' }, + domChanged: { + type: 'boolean', + description: 'The DOM mutation revision changed; weak evidence on its own.', + }, + fieldChanged: { + type: 'boolean', + description: 'The safely inspectable focused-field state changed.', + }, + focusChanged: { type: 'boolean', description: 'The focused element changed.' }, + popupChanged: { type: 'boolean', description: 'The visible popup/menu set changed.' }, + scrollChanged: { + type: 'boolean', + description: 'A tracked scroll offset changed; weak evidence except for scroll keys.', + }, + tabChanged: { type: 'boolean', description: 'The active browser tab changed.' }, + targetChanged: { + type: 'boolean', + description: "The requested target's checked/selected/expanded/open state changed.", + }, + titleChanged: { + type: 'boolean', + description: 'The document title changed; weak evidence on its own.', + }, + urlChanged: { type: 'boolean', description: 'The observed URL changed.' }, + }, + }, + effectObserved: { type: 'boolean', description: 'A strong field/page effect was observed.' }, + note: { + type: 'string', + description: 'Postcondition guidance when readback did not prove a change.', + }, + notices: { + type: 'array', + description: + 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', + items: { type: 'string' }, + }, + possibleEffectObserved: { + type: 'boolean', + description: 'Includes weak title/DOM/scroll churn; not proof of success.', + }, + redacted: { + type: 'boolean', + description: 'Whether sensitive focused-field details were withheld.', + }, + refRecovered: { + type: 'boolean', + description: + 'Whether a stale detached ref was safely rebound to one unique semantic match.', + }, + replacedExisting: { + type: 'boolean', + description: "Whether the operation replaced the field's existing content.", + }, + selectedChars: { + type: 'number', + description: 'Number of selected characters when safely inspectable.', + }, + submissionEffectObserved: { + type: 'boolean', + description: + 'Whether a strong effect was observed after Enter, separately from the text write.', + }, + submitDispatched: { + type: 'boolean', + description: + 'Whether Enter dispatch acknowledged completion; this alone is not proof of submission.', + }, + submitRequested: { type: 'boolean', description: 'Whether submit=true was requested.' }, + submitUncertain: { + type: 'boolean', + description: + 'Whether Enter key-down may have landed but dispatch did not acknowledge completion.', + }, + submitted: { + type: 'boolean', + description: + 'Whether Enter dispatch completed and a strong submission effect was observed.', + }, + trusted: { type: 'boolean', description: 'Whether native Chromium input was used.' }, + valueLength: { + type: 'number', + description: 'Focused non-secret field length when safely inspectable.', + }, + valuePreview: { + type: 'string', + description: 'Bounded focused non-secret field preview when safely inspectable.', + }, + }, + required: ['dispatched'], + }, + clientExecutable: true, +} + +export const BrowserWaitFor: ToolCatalogEntry = { + id: 'browser_wait_for', + name: 'browser_wait_for', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + text: { type: 'string', description: 'Optional visible text to wait for.' }, + timeoutMs: { + type: 'number', + description: 'Maximum time to wait, in milliseconds (default 10000, capped at 120000).', + }, + }, + }, + resultSchema: { + type: 'object', + properties: { + elapsedMs: { type: 'number', description: 'Elapsed wait duration.' }, + found: { + type: 'boolean', + description: 'Whether the requested text appeared before timeout.', + }, + foundInFrame: { + type: 'boolean', + description: 'Whether the match was found in an eligible visible child frame.', + }, + note: { type: 'string', description: 'Timeout/recovery guidance.' }, + notices: { + type: 'array', + description: + 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', + items: { type: 'string' }, + }, + waitedMs: { + type: 'number', + description: 'Completed sleep duration when no text was requested.', + }, + }, + }, + clientExecutable: true, +} + export const CallIntegrationTool: ToolCatalogEntry = { id: 'call_integration_tool', name: 'call_integration_tool', @@ -276,6 +1217,7 @@ export const CallIntegrationTool: ToolCatalogEntry = { required: ['toolId', 'description', 'arguments'], type: 'object', }, + requiresApproval: true, } export const CheckDeploymentStatus: ToolCatalogEntry = { @@ -294,20 +1236,6 @@ export const CheckDeploymentStatus: ToolCatalogEntry = { }, } -export const CompleteScheduledTask: ToolCatalogEntry = { - id: 'complete_scheduled_task', - name: 'complete_scheduled_task', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - jobId: { type: 'string', description: 'The ID of the scheduled task to mark as completed.' }, - }, - required: ['jobId'], - }, -} - export const Cp: ToolCatalogEntry = { id: 'cp', name: 'cp', @@ -482,72 +1410,6 @@ export const CreateWorkspaceMcpServer: ToolCatalogEntry = { requiredPermission: 'admin', } -export const DeleteFile: ToolCatalogEntry = { - id: 'delete_file', - name: 'delete_file', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - paths: { - type: 'array', - description: - 'Canonical workspace file VFS paths to delete, e.g. ["files/Reports/draft.md"].', - items: { type: 'string' }, - }, - }, - required: ['paths'], - }, - resultSchema: { - type: 'object', - properties: { - message: { type: 'string', description: 'Human-readable outcome.' }, - success: { type: 'boolean', description: 'Whether the delete succeeded.' }, - }, - required: ['success', 'message'], - }, - requiredPermission: 'write', -} - -export const DeleteFileFolder: ToolCatalogEntry = { - id: 'delete_file_folder', - name: 'delete_file_folder', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - paths: { - type: 'array', - description: 'Canonical folder VFS paths to delete, e.g. ["files/Archive"].', - items: { type: 'string' }, - }, - }, - required: ['paths'], - }, - requiredPermission: 'write', -} - -export const DeleteWorkflow: ToolCatalogEntry = { - id: 'delete_workflow', - name: 'delete_workflow', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - workflowIds: { - type: 'array', - description: 'The workflow IDs to delete.', - items: { type: 'string' }, - }, - }, - required: ['workflowIds'], - }, - requiredPermission: 'write', -} - export const DeleteWorkspaceMcpServer: ToolCatalogEntry = { id: 'delete_workspace_mcp_server', name: 'delete_workspace_mcp_server', @@ -561,6 +1423,7 @@ export const DeleteWorkspaceMcpServer: ToolCatalogEntry = { required: ['serverId'], }, requiredPermission: 'admin', + requiresApproval: true, } export const Deploy: ToolCatalogEntry = { @@ -661,6 +1524,7 @@ export const DeployApi: ToolCatalogEntry = { ], }, requiredPermission: 'admin', + requiresApproval: true, } export const DeployChat: ToolCatalogEntry = { @@ -806,6 +1670,7 @@ export const DeployChat: ToolCatalogEntry = { ], }, requiredPermission: 'admin', + requiresApproval: true, } export const DeployCustomBlock: ToolCatalogEntry = { @@ -868,11 +1733,10 @@ export const DeployCustomBlock: ToolCatalogEntry = { name: { type: 'string', description: - 'Display name for the block, max 60 characters. When republishing an existing block, pass the current name to keep it or a new name to rename.', + 'Display name for the block, max 60 characters. REQUIRED the first time a workflow is published. When republishing an existing block, omit it to keep the current name or pass a new one to rename. Ignored for undeploy.', }, workflowId: { type: 'string', description: 'Workflow ID (defaults to active workflow)' }, }, - required: ['name'], }, resultSchema: { type: 'object', @@ -929,6 +1793,12 @@ export const DeployMcp: ToolCatalogEntry = { parameters: { type: 'object', properties: { + action: { + type: 'string', + description: + '"deploy" (default) adds/updates the workflow as an MCP tool on the server; "undeploy" removes the workflow\'s tool from the server.', + enum: ['deploy', 'undeploy'], + }, parameterDescriptions: { type: 'array', description: 'Array of parameter descriptions for the tool', @@ -1011,6 +1881,7 @@ export const DeployMcp: ToolCatalogEntry = { required: ['deploymentType', 'deploymentStatus'], }, requiredPermission: 'admin', + requiresApproval: true, } export const DiffWorkflows: ToolCatalogEntry = { @@ -1207,7 +2078,7 @@ export const EnrichmentRun: ToolCatalogEntry = { description: 'True when a provider returned a non-empty result.', }, provider: { - type: 'string', + type: ['string', 'null'], description: 'Internal label of the provider that produced the result (billing/diagnostics only — do NOT surface it to the user), or null on no match.', }, @@ -1418,7 +2289,7 @@ export const FunctionExecute: ToolCatalogEntry = { code: { type: 'string', description: - 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with access to pre-installed CLI tools and workspace env vars as $VAR_NAME.', + 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with pre-installed CLI tools. Use each needed secret as {{VAR_NAME}}; the reference resolves to the value exactly as stored.', }, inputs: { type: 'object', @@ -1534,10 +2405,15 @@ export const FunctionExecute: ToolCatalogEntry = { }, }, }, + sandboxId: { + type: 'string', + description: + 'Optional Sim sandbox id from agent/sandboxes/{name}.json. DEFAULT-FIRST: omit this whenever the documented default function_execute environment can do the job. Select a ready existing Sim sandbox only when a required third-party dependency, Debian system package, or managed CLI is known to be absent, or a default attempt failed specifically because it was missing. Never guess an id.', + }, timeout: { type: 'number', description: - 'Maximum execution time in seconds. The sandbox stops execution and returns a timeout error after this duration. Defaults to 10 seconds; the platform execution limit still applies.', + 'Maximum execution time in SECONDS (Sim converts to milliseconds). The sandbox stops execution and returns a timeout error after this duration. Defaults to 10 seconds and is capped at 300 seconds regardless of plan.', default: 10, }, title: { @@ -1549,6 +2425,7 @@ export const FunctionExecute: ToolCatalogEntry = { required: ['code'], }, requiredPermission: 'write', + requiresApproval: true, capabilities: ['file_input', 'directory_input', 'file_output', 'table_input', 'table_output'], } @@ -2125,26 +3002,6 @@ export const GetPageContents: ToolCatalogEntry = { }, } -export const GetScheduledTaskLogs: ToolCatalogEntry = { - id: 'get_scheduled_task_logs', - name: 'get_scheduled_task_logs', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - executionId: { type: 'string', description: 'Optional execution ID for a specific run.' }, - includeDetails: { - type: 'boolean', - description: 'Include tool calls, outputs, and cost details.', - }, - jobId: { type: 'string', description: 'The scheduled task (schedule) ID to get logs for.' }, - limit: { type: 'number', description: 'Max number of entries (default: 3, max: 5)' }, - }, - required: ['jobId'], - }, -} - export const GetWorkflowData: ToolCatalogEntry = { id: 'get_workflow_data', name: 'get_workflow_data', @@ -2199,7 +3056,7 @@ export const Glob: ToolCatalogEntry = { toolTitle: { type: 'string', description: - 'Optional target-only UI phrase for the search row. The UI verb is supplied for you, so pass text like "workflow configs" or "knowledge bases", not a full sentence like "Finding workflow configs".', + 'Required target-only UI phrase for the search row. The UI verb is supplied for you, so pass text like "workflow configs" or "knowledge bases", not a full sentence like "Finding workflow configs".', }, }, required: ['pattern', 'toolTitle'], @@ -2217,7 +3074,7 @@ export const Grep: ToolCatalogEntry = { context: { type: 'number', description: - "Number of lines to show before and after each match. Only applies to output_mode 'content'.", + "Number of lines to show before and after each match (default 0). Only applies to output_mode 'content'.", }, ignoreCase: { type: 'boolean', description: 'Case insensitive search (default false).' }, lineNumbers: { @@ -2243,12 +3100,12 @@ export const Grep: ToolCatalogEntry = { pattern: { type: 'string', description: - "Regex pattern to search for. Searches VFS map entries (workflow JSON, metadata, plans, memories) by default; searches a single file's extracted text when path is one files/ or uploads/ file leaf.", + "Regex pattern to search for. Searches VFS map entries (workflow JSON, metadata, memories) by default; searches a single file's extracted text when path is one files/ or uploads/ file leaf.", }, toolTitle: { type: 'string', description: - 'Optional target-only UI phrase for the search row. The UI verb is supplied for you, so pass text like "Slack integrations" or "deployed workflows", not a full sentence like "Searching for Slack integrations".', + 'Required target-only UI phrase for the search row. The UI verb is supplied for you, so pass text like "Slack integrations" or "deployed workflows", not a full sentence like "Searching for Slack integrations".', }, }, required: ['pattern', 'toolTitle'], @@ -2420,7 +3277,6 @@ export const KnowledgeBase: ToolCatalogEntry = { 'query', 'add_file', 'update', - 'delete', 'delete_document', 'update_document', 'list_tags', @@ -2440,7 +3296,11 @@ export const KnowledgeBase: ToolCatalogEntry = { resultSchema: { type: 'object', properties: { - data: { type: 'object', description: 'Operation-specific result payload.' }, + data: { + type: ['object', 'array'], + description: + 'Operation-specific result payload. An object for most operations; list_tags and get_tag_usage return an array of tag definitions.', + }, message: { type: 'string', description: 'Human-readable outcome summary.' }, success: { type: 'boolean', description: 'Whether the operation succeeded.' }, }, @@ -2451,8 +3311,8 @@ export const KnowledgeBase: ToolCatalogEntry = { export const ListIntegrationTools: ToolCatalogEntry = { id: 'list_integration_tools', name: 'list_integration_tools', - route: 'sim', - mode: 'async', + route: 'go', + mode: 'sync', parameters: { properties: { integration: { @@ -2517,19 +3377,37 @@ export const LoadDeployment: ToolCatalogEntry = { export const LoadIntegrationTool: ToolCatalogEntry = { id: 'load_integration_tool', name: 'load_integration_tool', - route: 'sim', - mode: 'async', + route: 'go', + mode: 'sync', parameters: { properties: { tool_ids: { description: - 'Exact integration tool ids to load before calling them, e.g. ["gmail_send_v2"]. Copy the "id" field verbatim from components/integrations/{service}/{operation}.json (including any version suffix).', - items: { type: 'string' }, - type: 'array', + 'Exact integration tool ids to load before calling them, e.g. ["gmail_send_v2"]. Copy the "id" field verbatim from components/integrations/{service}/{operation}.json (including any version suffix).', + items: { type: 'string' }, + type: 'array', + }, + }, + required: ['tool_ids'], + type: 'object', + }, +} + +export const LoadSkill: ToolCatalogEntry = { + id: 'load_skill', + name: 'load_skill', + route: 'go', + mode: 'sync', + parameters: { + type: 'object', + properties: { + name: { + type: 'string', + description: + "Skill name exactly as it appears in the Loadable Skills index (e.g. 'pptx-writing').", }, }, - required: ['tool_ids'], - type: 'object', + required: ['name'], }, } @@ -2575,7 +3453,7 @@ export const ManageCustomTool: ToolCatalogEntry = { operation: { type: 'string', description: - "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — manage_scheduled_task uses create/update instead of add/edit.", + "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — other manage_* tools may use create/update instead of add/edit.", enum: ['add', 'edit', 'delete', 'list'], }, schema: { @@ -2628,31 +3506,6 @@ export const ManageCustomTool: ToolCatalogEntry = { requiredPermission: 'write', } -export const ManageFolder: ToolCatalogEntry = { - id: 'manage_folder', - name: 'manage_folder', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - folderId: { - type: 'string', - description: - 'Target folder ID, used as a fallback when path is not given. Readable from a contained workflow\'s meta.json "folderId".', - }, - operation: { type: 'string', description: 'The operation to perform.', enum: ['delete'] }, - path: { - type: 'string', - description: - 'Target folder\'s VFS path (e.g. "workflows/Marketing/Q3 Campaigns"), per-segment percent-encoded like every VFS path.', - }, - }, - required: ['operation'], - }, - requiredPermission: 'write', -} - export const ManageMcpTool: ToolCatalogEntry = { id: 'manage_mcp_tool', name: 'manage_mcp_tool', @@ -2690,7 +3543,7 @@ export const ManageMcpTool: ToolCatalogEntry = { operation: { type: 'string', description: - "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — manage_scheduled_task uses create/update instead of add/edit.", + "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — other manage_* tools may use create/update instead of add/edit.", enum: ['add', 'edit', 'delete', 'list'], }, serverId: { @@ -2704,78 +3557,57 @@ export const ManageMcpTool: ToolCatalogEntry = { requiredPermission: 'write', } -export const ManageScheduledTask: ToolCatalogEntry = { - id: 'manage_scheduled_task', - name: 'manage_scheduled_task', +export const ManageSandbox: ToolCatalogEntry = { + id: 'manage_sandbox', + name: 'manage_sandbox', route: 'sim', mode: 'async', parameters: { type: 'object', properties: { - args: { - type: 'object', + cliTools: { + type: 'array', description: - 'Operation-specific arguments. For create: {title, prompt, cron?, time?, timezone?, lifecycle?, successCondition?, maxRuns?}. For get/delete: {jobId}. For update: {jobId, title?, prompt?, cron?, timezone?, status?, lifecycle?, successCondition?, maxRuns?}. For list: no args needed.', - properties: { - cron: { - type: 'string', - description: - "Cron expression for a recurring scheduled task (e.g. '0 9 * * *'). Set exactly one of cron or time: recurring -> cron; one-time -> time.", - }, - jobId: { type: 'string', description: 'Scheduled task ID (required for get, update)' }, - jobIds: { - type: 'array', - description: 'Array of scheduled task IDs (for batch delete)', - items: { type: 'string' }, - }, - lifecycle: { - type: 'string', - description: - "'persistent' (default) or 'until_complete'. Until_complete scheduled tasks stop when complete_scheduled_task is called.", - enum: ['persistent', 'until_complete'], - }, - maxRuns: { - type: 'integer', - description: 'Max executions before auto-completing. Safety limit.', - }, - prompt: { - type: 'string', - description: 'The prompt to execute when the scheduled task fires', - }, - status: { - type: 'string', - description: 'Scheduled task status: active, paused', - enum: ['active', 'paused'], - }, - successCondition: { - type: 'string', - description: - 'What must happen for the scheduled task to be considered complete (until_complete lifecycle).', - }, - time: { - type: 'string', - description: - "ISO 8601 datetime. One-time scheduled task -> set time and omit cron. May also anchor a recurring cron task's first-fire time.", - }, - timezone: { - type: 'string', - description: 'IANA timezone (e.g. America/New_York). Defaults to UTC.', - }, - title: { - type: 'string', - description: "Short descriptive title for the scheduled task (e.g. 'Email Poller')", - }, - }, + 'Complete managed CLI id list (maximum 10). Use exact pinned ids returned by list. On edit, passing this replaces the whole list; pass [] to clear it.', + items: { type: 'string' }, + }, + dependencies: { + type: 'array', + description: + 'Complete npm or PyPI dependency list (maximum 50). On edit, passing this replaces the whole list; pass [] to clear it.', + items: { type: 'string' }, + }, + language: { + type: 'string', + description: + 'Dependency language. javascript installs from npm; python installs from PyPI. Required for add; optional for edit.', + enum: ['javascript', 'python'], + }, + name: { + type: 'string', + description: + 'Workspace-unique Sim sandbox name (1-64 characters). Required for add; optional for edit.', }, operation: { + type: 'string', + description: "The operation to perform: 'add', 'edit', 'list', or 'delete'.", + enum: ['add', 'edit', 'delete', 'list'], + }, + sandboxId: { type: 'string', description: - 'The operation to perform: create, list, get, update, delete. These verbs are tool-specific — the custom-tool/MCP/skill managers use add/edit instead of create/update.', - enum: ['create', 'list', 'get', 'update', 'delete'], + 'The Sim sandbox id. Get it from list or the inner id field in agent/sandboxes/{name}.json; never guess it. Required for edit and delete.', + }, + systemPackages: { + type: 'array', + description: + 'Complete Debian package-coordinate list in package[:architecture][=version] form (maximum 50). On edit, passing this replaces the whole list; pass [] to clear it.', + items: { type: 'string' }, }, }, required: ['operation'], }, + requiredPermission: 'admin', } export const ManageSkill: ToolCatalogEntry = { @@ -2802,7 +3634,7 @@ export const ManageSkill: ToolCatalogEntry = { operation: { type: 'string', description: - "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — manage_scheduled_task uses create/update instead of add/edit.", + "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — other manage_* tools may use create/update instead of add/edit.", enum: ['add', 'edit', 'delete', 'list'], }, skillId: { @@ -2982,7 +3814,7 @@ export const OpenResource: ToolCatalogEntry = { type: { type: 'string', description: 'The resource type.', - enum: ['workflow', 'table', 'knowledgebase', 'file', 'log', 'scheduledtask'], + enum: ['workflow', 'table', 'knowledgebase', 'file', 'log'], }, }, required: ['type'], @@ -3014,6 +3846,7 @@ export const PromoteToLive: ToolCatalogEntry = { required: ['version'], }, requiredPermission: 'admin', + requiresApproval: true, } export const QueryLogs: ToolCatalogEntry = { @@ -3133,21 +3966,27 @@ export const QueryUserTable: ToolCatalogEntry = { type: 'object', description: 'Arguments for the operation', properties: { - filter: { type: 'object', description: 'MongoDB-style filter for query_rows' }, - limit: { - type: 'number', - description: 'Maximum rows to return (optional, default 100, max 1000 per call)', + cursor: { + type: 'string', + description: + 'Opaque pagination cursor for query_rows (optional). Omit for the first page; to fetch the next page, pass back the nextCursor from the previous result\'s "more available" message verbatim. Cannot be combined with a fresh order — the cursor already encodes the paging position.', + }, + filter: { + type: 'object', + description: + 'Predicate filter object for query_rows. A predicate is a tree: {"all":[...]} (AND) or {"any":[...]} (OR); members are leaves {field, op, value} or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (use * as the wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array value. Examples: {"all":[{"field":"status","op":"eq","value":"active"}]}; {"any":[{"field":"status","op":"eq","value":"active"},{"field":"status","op":"eq","value":"pending"}]}; {"all":[{"field":"name","op":"ilike","value":"*jo*"}]}.', }, - offset: { + limit: { type: 'number', - description: 'Number of rows to skip (optional for query_rows, default 0)', + description: + 'Maximum rows per page for query_rows (optional). Omit to fetch the ENTIRE matching result in one response — the call fails if the result exceeds the 5MB budget (narrow with a filter or set a limit). With a limit, a page may end early at the byte budget with more remaining; a non-null nextCursor in the result means more rows exist (continue with cursor).', }, - rowId: { type: 'string', description: 'Row ID (required for get_row)' }, - sort: { - type: 'object', + order: { + type: 'array', description: - "Sort specification as { field: 'asc' | 'desc' } (optional for query_rows)", + 'Sort spec for query_rows (optional). Ordered list of {field, direction} where direction is asc or desc, e.g. [{"field":"wins","direction":"desc"},{"field":"name","direction":"asc"}].', }, + rowId: { type: 'string', description: 'Row ID (required for get_row)' }, tableId: { type: 'string', description: 'Table ID (required for all operations)' }, }, }, @@ -3268,6 +4107,7 @@ export const Redeploy: ToolCatalogEntry = { ], }, requiredPermission: 'admin', + requiresApproval: true, } export const Respond: ToolCatalogEntry = { @@ -3319,6 +4159,31 @@ export const RestoreResource: ToolCatalogEntry = { requiredPermission: 'admin', } +export const Rm: ToolCatalogEntry = { + id: 'rm', + name: 'rm', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + paths: { + type: 'array', + description: + 'Canonical VFS paths to delete, e.g. ["files/Reports/draft.md"]. Copy paths verbatim from glob/grep/read output. Paths from different categories may be mixed in one call.', + items: { type: 'string' }, + }, + toolTitle: { + type: 'string', + description: + 'Target-only UI phrase for the action row, e.g. "draft.md" or "3 files", not a full sentence like "Deleting draft.md".', + }, + }, + required: ['paths', 'toolTitle'], + }, + requiredPermission: 'write', +} + export const Run: ToolCatalogEntry = { id: 'run', name: 'run', @@ -3384,7 +4249,7 @@ export const RunCode: ToolCatalogEntry = { code: { type: 'string', description: - 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with access to pre-installed CLI tools and workspace env vars as $VAR_NAME.', + 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with pre-installed CLI tools. Use each needed secret as {{VAR_NAME}}; the reference resolves to the value exactly as stored.', }, inputs: { type: 'object', @@ -3463,6 +4328,7 @@ export const RunCode: ToolCatalogEntry = { required: ['code'], }, requiredPermission: 'write', + requiresApproval: true, capabilities: ['file_input', 'directory_input', 'table_input'], } @@ -3508,6 +4374,11 @@ export const RunWorkflow: ToolCatalogEntry = { parameters: { type: 'object', properties: { + async: { + type: 'boolean', + description: + 'Queue the deployed workflow and return its execution ID immediately. Default: false. Set true only when explicitly asked for a background run, or when the three most recent completed runs each exceeded 30 minutes. Fails if the current workflow differs from its deployed version. Missing history, complexity, or one slow run never justify async; check completion later with query_logs.', + }, inputFromExecutionId: { type: 'string', description: @@ -3541,6 +4412,7 @@ export const RunWorkflow: ToolCatalogEntry = { }, }, clientExecutable: true, + requiresApproval: true, } export const RunWorkflowUntilBlock: ToolCatalogEntry = { @@ -3589,22 +4461,7 @@ export const RunWorkflowUntilBlock: ToolCatalogEntry = { required: ['stopAfterBlockId'], }, clientExecutable: true, -} - -export const ScheduledTask: ToolCatalogEntry = { - id: 'scheduled_task', - name: 'scheduled_task', - route: 'subagent', - mode: 'async', - parameters: { - properties: { - request: { description: 'What scheduled task action is needed.', type: 'string' }, - }, - required: ['request'], - type: 'object', - }, - subagentId: 'scheduled_task', - internal: true, + requiresApproval: true, } export const ScrapePage: ToolCatalogEntry = { @@ -3663,7 +4520,7 @@ export const SearchDocs: ToolCatalogEntry = { 'Optional docs/ VFS path (a page such as docs/workflows/blocks/agent.mdx, or a section such as docs/workflows) that limits the search scope', }, query: { type: 'string', description: 'The search query' }, - topK: { type: 'number', description: 'Number of results (default 10, max 25)' }, + topK: { type: 'number', description: 'Number of results (default 5, max 25)' }, }, required: ['query'], }, @@ -3732,7 +4589,11 @@ export const SearchKnowledgeBase: ToolCatalogEntry = { resultSchema: { type: 'object', properties: { - data: { type: 'object', description: 'Operation-specific result payload.' }, + data: { + type: ['object', 'array'], + description: + 'Operation-specific result payload. An object for search results; list_tags returns an array of tag definitions.', + }, message: { type: 'string', description: 'Human-readable outcome summary.' }, success: { type: 'boolean', description: 'Whether the operation succeeded.' }, }, @@ -3756,7 +4617,11 @@ export const SearchLibraryDocs: ToolCatalogEntry = { type: 'string', description: 'The question or topic to find documentation for - be specific', }, - version: { type: 'string', description: "Specific version (optional, e.g., '14', 'v2')" }, + version: { + type: 'string', + description: + "Specific version, numeric only and WITHOUT a leading 'v' (e.g. '14', '2', '2.1') — the 'v' is added for you, so 'v2' resolves to nothing.", + }, }, required: ['library_name', 'query'], }, @@ -3807,7 +4672,7 @@ export const SearchPatterns: ToolCatalogEntry = { properties: { limit: { type: 'integer', - description: 'Maximum number of unique pattern examples to return (defaults to 3).', + description: 'Maximum number of pattern examples to return per query (defaults to 3).', }, queries: { type: 'array', @@ -3901,12 +4766,14 @@ export const SetGlobalWorkflowVariables: ToolCatalogEntry = { operation: { type: 'string', enum: ['add', 'delete', 'edit'] }, type: { type: 'string', - description: 'Variable type. Required for add/edit; ignored for delete.', + description: + 'Variable type for add/edit. Defaults to the variable\'s existing type, or "plain" for a new one. Ignored for delete.', enum: ['plain', 'number', 'boolean', 'array', 'object'], }, value: { type: 'string', - description: 'Variable value. Required for add/edit; ignored for delete.', + description: + 'Variable value for add/edit, coerced to the declared type. Omitting it leaves the variable with no value. Ignored for delete.', }, }, required: ['operation', 'name'], @@ -3990,6 +4857,126 @@ export const Table: ToolCatalogEntry = { internal: true, } +export const Terminal: ToolCatalogEntry = { + id: 'terminal', + name: 'terminal', + route: 'client', + mode: 'async', + parameters: { + type: 'object', + properties: { + args: { + type: 'object', + description: 'Inputs for the operation. Pass only the fields that operation uses.', + properties: { + command: { + type: 'string', + description: + 'For run: the command line, exactly as it would be typed at the prompt. Shell syntax (pipes, &&, quoting, redirection) works because a real shell interprets it.', + }, + cwd: { + type: 'string', + description: + "For new: absolute path to open in. Defaults to the active terminal's directory.", + }, + key: { + type: 'string', + description: + 'For input: a single key to press instead of text. Use "enter" to submit something already typed.', + enum: [ + 'ctrl-c', + 'ctrl-d', + 'ctrl-z', + 'enter', + 'up', + 'down', + 'left', + 'right', + 'escape', + 'tab', + ], + }, + keys: { + type: 'array', + description: + 'For input: several keys pressed in order, e.g. ["down","down","enter"] to walk down a menu and choose. Each is a real keypress with a pause between, so the program redraws as it would under a person\'s hands. Only batch when you already know where the highlight is — read the screen first, and press one key at a time when you do not. Max 20.', + items: { + type: 'string', + enum: [ + 'ctrl-c', + 'ctrl-d', + 'ctrl-z', + 'enter', + 'up', + 'down', + 'left', + 'right', + 'escape', + 'tab', + ], + }, + }, + lines: { + type: 'number', + description: 'For read: how many trailing lines to return. Defaults to 200.', + }, + pane: { + type: 'string', + description: + "Which tmux pane to act on, as a target from the panes operation (session:window.pane). Defaults to that session's active pane. Ignored when the terminal is a plain shell.", + }, + reason: { + type: 'string', + description: + 'For handoff: what the user needs to do, shown on the button they click (e.g. "Enter your sudo password"). Say what is being asked, not that you are waiting.', + }, + signal: { + type: 'string', + description: + 'For kill: which signal. Defaults to SIGINT, the equivalent of the user pressing Ctrl-C.', + enum: ['SIGINT', 'SIGTERM', 'SIGKILL'], + }, + terminalId: { + type: 'string', + description: + 'Which terminal to act on, from the list operation. Defaults to the active one, which is what the user is looking at. Required by switch and close.', + }, + text: { + type: 'string', + description: + 'For input: literal text to type. A trailing newline submits it. Check the returned screen to confirm it submitted rather than sitting unsent in an input box.', + }, + waitSeconds: { + type: 'number', + description: + 'For run: how long to wait before handing back a still-running command. Defaults to 30, capped at 120. Raising it does not make a command finish sooner, it only delays your first look at it.', + }, + }, + }, + operation: { + type: 'string', + description: 'What to do.', + enum: [ + 'run', + 'read', + 'input', + 'kill', + 'cwd', + 'list', + 'new', + 'switch', + 'close', + 'panes', + 'handoff', + ], + }, + }, + required: ['operation'], + }, + clientExecutable: true, + requiresApproval: true, +} + export const UpdateDeploymentVersion: ToolCatalogEntry = { id: 'update_deployment_version', name: 'update_deployment_version', @@ -4021,25 +5008,6 @@ export const UpdateDeploymentVersion: ToolCatalogEntry = { requiredPermission: 'write', } -export const UpdateScheduledTaskHistory: ToolCatalogEntry = { - id: 'update_scheduled_task_history', - name: 'update_scheduled_task_history', - route: 'sim', - mode: 'async', - parameters: { - type: 'object', - properties: { - jobId: { type: 'string', description: 'The scheduled task ID.' }, - summary: { - type: 'string', - description: - "A concise summary of what was done this run (e.g., 'Sent follow-up emails to 3 leads: Alice, Bob, Carol').", - }, - }, - required: ['jobId', 'summary'], - }, -} - export const UpdateWorkspaceMcpServer: ToolCatalogEntry = { id: 'update_workspace_mcp_server', name: 'update_workspace_mcp_server', @@ -4081,7 +5049,8 @@ export const UserTable: ToolCatalogEntry = { }, column: { type: 'object', - description: 'Column definition for add_column: { name, type, unique?, position? }', + description: + 'Column definition for add_column: { name, type, unique?, position? }. For a select (enum) column also pass { options: ["Open", "Closed"], multiple?: true } — options is a list of display names and is required for select.', }, columnName: { type: 'string', @@ -4093,6 +5062,11 @@ export const UserTable: ToolCatalogEntry = { description: 'Array of column names to delete at once (for delete_column). Preferred over columnName when deleting multiple columns.', }, + cursor: { + type: 'string', + description: + 'Opaque pagination cursor for query_rows (optional). Omit for the first page; to fetch the next page, pass back the nextCursor from the previous result\'s "more available" message verbatim. Cannot be combined with a fresh order — the cursor already encodes the paging position.', + }, data: { type: 'object', description: 'Row data as key-value pairs (required for insert_row, update_row)', @@ -4110,6 +5084,12 @@ export const UserTable: ToolCatalogEntry = { }, }, }, + deploymentMode: { + type: 'string', + description: + "Which version of the backing workflow this group's per-row runs execute, for add_workflow_group and update_workflow_group. 'live' (default) runs the editable draft, so later edits take effect immediately. 'deployed' runs the workflow's latest active deployment, pinning rows to a published version — if that workflow has never been deployed the cell fails rather than falling back to the draft. Only meaningful for workflow groups; enrichment groups have no backing workflow.", + enum: ['live', 'deployed'], + }, description: { type: 'string', description: "Table description (optional for 'create')" }, enrichmentId: { type: 'string', @@ -4124,7 +5104,7 @@ export const UserTable: ToolCatalogEntry = { filter: { type: 'object', description: - 'MongoDB-style filter for query_rows, update_rows_by_filter, delete_rows_by_filter', + 'Predicate filter object for query_rows, update_rows_by_filter, delete_rows_by_filter. A predicate is a tree: {"all":[...]} (AND) or {"any":[...]} (OR); members are leaves {field, op, value} or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (use * as the wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array value. Examples: {"all":[{"field":"status","op":"eq","value":"active"}]}; {"all":[{"field":"wins","op":"gte","value":18},{"field":"status","op":"eq","value":"pending"}]}; {"any":[{"field":"status","op":"eq","value":"active"},{"field":"status","op":"eq","value":"pending"}]}; {"all":[{"field":"name","op":"ilike","value":"*jo*"}]}; {"all":[{"field":"slack_user_id","op":"in","value":["U1","U2"]}]}.', }, groupId: { type: 'string', @@ -4159,7 +5139,7 @@ export const UserTable: ToolCatalogEntry = { limit: { type: 'number', description: - 'Maximum rows to return or affect (optional, default 100). Omit on update_rows_by_filter / delete_rows_by_filter to act on every match.', + 'Maximum rows per page for query_rows (optional). Omit to fetch the ENTIRE matching result in one response — the call fails if the result exceeds the 5MB budget (narrow with a filter or set a limit). With a limit, a page may end early at the byte budget with more remaining; a non-null nextCursor in the result means more rows exist (continue with cursor). On update_rows_by_filter / delete_rows_by_filter, caps affected rows; omit to act on every match.', }, mapping: { type: 'object', @@ -4195,6 +5175,11 @@ export const UserTable: ToolCatalogEntry = { "Import mode for import_file. 'append' (default) adds rows; 'replace' truncates existing rows in a transaction before inserting the new rows.", enum: ['append', 'replace'], }, + multiple: { + type: 'boolean', + description: + 'Whether a select (enum) cell may hold several options (default false). Switching an existing column from true to false fails if any row has more than one option selected.', + }, name: { type: 'string', description: @@ -4208,11 +5193,18 @@ export const UserTable: ToolCatalogEntry = { newType: { type: 'string', description: - 'New column type (optional for update_column). Types: string, number, boolean, date, json', + 'New column type (optional for update_column). Types: string, number, boolean, date, json, select. Converting a column to select also requires options; the conversion fails if any existing cell value doesn\'t match one of them. Converting to a multiple: true select also accepts a comma-separated cell ("Open, Urgent"), which is the form a multi column converts to text as — so multiselect → text → multiselect round-trips.', }, - offset: { - type: 'number', - description: 'Number of rows to skip (optional for query_rows, default 0)', + options: { + type: 'array', + description: + 'Choices for a select (enum) column, as a list of display names, e.g. ["Open", "Closed"]. Required when creating or converting to a select column. On update_column this REPLACES the option list and is matched against the current one BY NAME: a name still present keeps its cells, a name no longer present is removed and cleared from every cell that held it. Send the full list including the options you are keeping — omitting one deletes it. There is no in-place rename, so re-sending an option under a new name clears the cells that held the old one. Max 100.', + items: { type: 'string' }, + }, + order: { + type: 'array', + description: + 'Sort spec for query_rows (optional). Ordered list of {field, direction} where direction is asc or desc, e.g. [{"field":"wins","direction":"desc"},{"field":"name","direction":"asc"}].', }, outputColumnNames: { type: 'object', @@ -4226,13 +5218,13 @@ export const UserTable: ToolCatalogEntry = { outputFormat: { type: 'string', description: - 'Explicit format override for outputPath. Usually unnecessary — the file extension determines the format automatically. Only use this to force a different format than what the extension implies.', + 'Explicit format override for outputPath. Only "csv" changes the file\'s CONTENT (rows serialized as a CSV table); "json", "txt", "md" and "html" all write the same pretty-printed JSON and change only the stored MIME type. Usually unnecessary — the extension already selects the format.', enum: ['json', 'csv', 'txt', 'md', 'html'], }, outputPath: { type: 'string', description: - 'Pipe query_rows results directly to a NEW workspace file. The format is auto-inferred from the file extension: .csv → CSV, .json → JSON, .md → Markdown, etc. Use a root output path like "files/export.csv" — nested output paths are not supported.', + 'Write this call\'s result to a NEW workspace file instead of returning it. Applies to EVERY user_table operation, not just query_rows: on success the tool result is REPLACED by a file receipt (fileId, vfsPath, size), so the operation\'s own payload is no longer visible to you — set it only when the file IS the goal. Only ".csv" changes serialization (query_rows rows become a CSV table); ".json", ".txt", ".md" and ".html" all write pretty-printed JSON of the full { success, message, data } envelope and differ only in stored MIME type. Nested paths like "files/Reports/export.csv" work — missing parent folders are created automatically, and an existing path fails.', }, outputs: { type: 'array', @@ -4266,12 +5258,6 @@ export const UserTable: ToolCatalogEntry = { description: 'Zero-based index at which to insert the row (optional, insert_row only). Rows at and below that index shift down. Omit to append at the end.', }, - positions: { - type: 'array', - description: - 'Per-row insertion indices for batch_insert_rows (optional). Must be the same length as rows and contain no duplicates. Values are final positions in the resulting table — lower-index shifts are applied automatically. Omit to append all rows at the end.', - items: { type: 'integer' }, - }, rowId: { type: 'string', description: @@ -4296,7 +5282,7 @@ export const UserTable: ToolCatalogEntry = { schema: { type: 'object', description: - "Table schema with columns array (required for 'create'). Each column: { name, type, unique? }", + 'Table schema with columns array (required for \'create\'). Each column: { name, type, unique? }. A select (enum) column also takes { options: ["Open", "Closed"], multiple?: true } — options is a list of display names and is required for select.', }, scope: { type: 'string', @@ -4304,11 +5290,6 @@ export const UserTable: ToolCatalogEntry = { "Cancellation scope for cancel_table_runs. 'all' cancels in-flight runs across the whole table; 'row' cancels only the row identified by rowId.", enum: ['all', 'row'], }, - sort: { - type: 'object', - description: - "Sort specification as { field: 'asc' | 'desc' } (optional for query_rows)", - }, tableId: { type: 'string', description: @@ -4349,7 +5330,6 @@ export const UserTable: ToolCatalogEntry = { 'import_file', 'get', 'get_schema', - 'delete', 'rename', 'insert_row', 'batch_insert_rows', @@ -4391,6 +5371,25 @@ export const UserTable: ToolCatalogEntry = { }, } +export const Wait: ToolCatalogEntry = { + id: 'wait', + name: 'wait', + route: 'go', + mode: 'sync', + parameters: { + type: 'object', + properties: { + reason: { + type: 'string', + description: + 'What you are waiting for, in a few words (e.g. "the test suite to finish"). Shown to the user so the pause is not unexplained.', + }, + seconds: { type: 'number', description: 'How long to pause, in seconds. Capped at 120.' }, + }, + required: ['seconds'], + }, +} + export const Workflow: ToolCatalogEntry = { id: 'workflow', name: 'workflow', @@ -4584,7 +5583,6 @@ export const KnowledgeBaseOperation = { query: 'query', addFile: 'add_file', update: 'update', - delete: 'delete', deleteDocument: 'delete_document', updateDocument: 'update_document', listTags: 'list_tags', @@ -4607,7 +5605,6 @@ export const KnowledgeBaseOperationValues = [ KnowledgeBaseOperation.query, KnowledgeBaseOperation.addFile, KnowledgeBaseOperation.update, - KnowledgeBaseOperation.delete, KnowledgeBaseOperation.deleteDocument, KnowledgeBaseOperation.updateDocument, KnowledgeBaseOperation.listTags, @@ -4651,15 +5648,6 @@ export const ManageCustomToolOperationValues = [ ManageCustomToolOperation.list, ] as const -export const ManageFolderOperation = { - delete: 'delete', -} as const - -export type ManageFolderOperation = - (typeof ManageFolderOperation)[keyof typeof ManageFolderOperation] - -export const ManageFolderOperationValues = [ManageFolderOperation.delete] as const - export const ManageMcpToolOperation = { add: 'add', edit: 'edit', @@ -4677,23 +5665,21 @@ export const ManageMcpToolOperationValues = [ ManageMcpToolOperation.list, ] as const -export const ManageScheduledTaskOperation = { - create: 'create', - list: 'list', - get: 'get', - update: 'update', +export const ManageSandboxOperation = { + add: 'add', + edit: 'edit', delete: 'delete', + list: 'list', } as const -export type ManageScheduledTaskOperation = - (typeof ManageScheduledTaskOperation)[keyof typeof ManageScheduledTaskOperation] +export type ManageSandboxOperation = + (typeof ManageSandboxOperation)[keyof typeof ManageSandboxOperation] -export const ManageScheduledTaskOperationValues = [ - ManageScheduledTaskOperation.create, - ManageScheduledTaskOperation.list, - ManageScheduledTaskOperation.get, - ManageScheduledTaskOperation.update, - ManageScheduledTaskOperation.delete, +export const ManageSandboxOperationValues = [ + ManageSandboxOperation.add, + ManageSandboxOperation.edit, + ManageSandboxOperation.delete, + ManageSandboxOperation.list, ] as const export const ManageSkillOperation = { @@ -4759,13 +5745,42 @@ export const SearchKnowledgeBaseOperationValues = [ SearchKnowledgeBaseOperation.listTags, ] as const +export const TerminalOperation = { + run: 'run', + read: 'read', + input: 'input', + kill: 'kill', + cwd: 'cwd', + list: 'list', + new: 'new', + switch: 'switch', + close: 'close', + panes: 'panes', + handoff: 'handoff', +} as const + +export type TerminalOperation = (typeof TerminalOperation)[keyof typeof TerminalOperation] + +export const TerminalOperationValues = [ + TerminalOperation.run, + TerminalOperation.read, + TerminalOperation.input, + TerminalOperation.kill, + TerminalOperation.cwd, + TerminalOperation.list, + TerminalOperation.new, + TerminalOperation.switch, + TerminalOperation.close, + TerminalOperation.panes, + TerminalOperation.handoff, +] as const + export const UserTableOperation = { create: 'create', createFromFile: 'create_from_file', importFile: 'import_file', get: 'get', getSchema: 'get_schema', - delete: 'delete', rename: 'rename', insertRow: 'insert_row', batchInsertRows: 'batch_insert_rows', @@ -4801,7 +5816,6 @@ export const UserTableOperationValues = [ UserTableOperation.importFile, UserTableOperation.get, UserTableOperation.getSchema, - UserTableOperation.delete, UserTableOperation.rename, UserTableOperation.insertRow, UserTableOperation.batchInsertRows, @@ -4847,17 +5861,35 @@ export const WorkspaceFileOperationValues = [ export const TOOL_CATALOG: Record = { [Agent.id]: Agent, [Auth.id]: Auth, + [Browser.id]: Browser, + [BrowserClick.id]: BrowserClick, + [BrowserCloseTab.id]: BrowserCloseTab, + [BrowserExtract.id]: BrowserExtract, + [BrowserGoBack.id]: BrowserGoBack, + [BrowserGoForward.id]: BrowserGoForward, + [BrowserHover.id]: BrowserHover, + [BrowserListSessions.id]: BrowserListSessions, + [BrowserListTabs.id]: BrowserListTabs, + [BrowserNavigate.id]: BrowserNavigate, + [BrowserOpenTab.id]: BrowserOpenTab, + [BrowserOpenUrl.id]: BrowserOpenUrl, + [BrowserPressKey.id]: BrowserPressKey, + [BrowserReadText.id]: BrowserReadText, + [BrowserRequestTakeover.id]: BrowserRequestTakeover, + [BrowserScreenshot.id]: BrowserScreenshot, + [BrowserScroll.id]: BrowserScroll, + [BrowserSelectOption.id]: BrowserSelectOption, + [BrowserSnapshot.id]: BrowserSnapshot, + [BrowserSwitchTab.id]: BrowserSwitchTab, + [BrowserType.id]: BrowserType, + [BrowserWaitFor.id]: BrowserWaitFor, [CallIntegrationTool.id]: CallIntegrationTool, [CheckDeploymentStatus.id]: CheckDeploymentStatus, - [CompleteScheduledTask.id]: CompleteScheduledTask, [Cp.id]: Cp, [CrawlWebsite.id]: CrawlWebsite, [CreateFile.id]: CreateFile, [CreateWorkflow.id]: CreateWorkflow, [CreateWorkspaceMcpServer.id]: CreateWorkspaceMcpServer, - [DeleteFile.id]: DeleteFile, - [DeleteFileFolder.id]: DeleteFileFolder, - [DeleteWorkflow.id]: DeleteWorkflow, [DeleteWorkspaceMcpServer.id]: DeleteWorkspaceMcpServer, [Deploy.id]: Deploy, [DeployApi.id]: DeployApi, @@ -4881,7 +5913,6 @@ export const TOOL_CATALOG: Record = { [GetDeployedWorkflowState.id]: GetDeployedWorkflowState, [GetDeploymentLog.id]: GetDeploymentLog, [GetPageContents.id]: GetPageContents, - [GetScheduledTaskLogs.id]: GetScheduledTaskLogs, [GetWorkflowData.id]: GetWorkflowData, [GetWorkflowRunOptions.id]: GetWorkflowRunOptions, [Glob.id]: Glob, @@ -4893,11 +5924,11 @@ export const TOOL_CATALOG: Record = { [ListWorkspaceMcpServers.id]: ListWorkspaceMcpServers, [LoadDeployment.id]: LoadDeployment, [LoadIntegrationTool.id]: LoadIntegrationTool, + [LoadSkill.id]: LoadSkill, [ManageCredential.id]: ManageCredential, [ManageCustomTool.id]: ManageCustomTool, - [ManageFolder.id]: ManageFolder, [ManageMcpTool.id]: ManageMcpTool, - [ManageScheduledTask.id]: ManageScheduledTask, + [ManageSandbox.id]: ManageSandbox, [ManageSkill.id]: ManageSkill, [MaterializeFile.id]: MaterializeFile, [Media.id]: Media, @@ -4913,13 +5944,13 @@ export const TOOL_CATALOG: Record = { [Redeploy.id]: Redeploy, [Respond.id]: Respond, [RestoreResource.id]: RestoreResource, + [Rm.id]: Rm, [Run.id]: Run, [RunBlock.id]: RunBlock, [RunCode.id]: RunCode, [RunFromBlock.id]: RunFromBlock, [RunWorkflow.id]: RunWorkflow, [RunWorkflowUntilBlock.id]: RunWorkflowUntilBlock, - [ScheduledTask.id]: ScheduledTask, [ScrapePage.id]: ScrapePage, [Search.id]: Search, [SearchDocs.id]: SearchDocs, @@ -4933,10 +5964,11 @@ export const TOOL_CATALOG: Record = { [SetGlobalWorkflowVariables.id]: SetGlobalWorkflowVariables, [ShareFile.id]: ShareFile, [Table.id]: Table, + [Terminal.id]: Terminal, [UpdateDeploymentVersion.id]: UpdateDeploymentVersion, - [UpdateScheduledTaskHistory.id]: UpdateScheduledTaskHistory, [UpdateWorkspaceMcpServer.id]: UpdateWorkspaceMcpServer, [UserTable.id]: UserTable, + [Wait.id]: Wait, [Workflow.id]: Workflow, [WorkspaceFile.id]: WorkspaceFile, } diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index c28b6528b18..d1cee1640e7 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -56,12 +56,132 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { properties: { elementId: { type: 'number', - description: 'The element id to act on (from the most recent browser_snapshot).', + description: + "The element id to act on (from the current tab's most recent browser_snapshot). Treat refs as invalid across tab switches or later snapshots.", }, }, required: ['elementId'], }, - resultSchema: undefined, + resultSchema: { + type: 'object', + properties: { + activation: { + type: 'string', + description: 'native-pointer, native-keyboard, or synthetic-pointer.', + }, + activeTab: { + type: 'object', + description: 'New active tab after a tab-changing click.', + properties: { + tabId: { + type: 'string', + description: 'Stable browser tab id.', + }, + url: { + type: 'string', + description: 'New active tab URL.', + }, + }, + }, + dialogs: { + type: 'array', + description: 'Visible DOM dialogs remaining after the click.', + items: { + type: 'string', + }, + }, + dispatched: { + type: 'boolean', + description: 'Whether input dispatch completed.', + }, + effect: { + type: 'object', + description: + 'Detailed postcondition signals; generic title/DOM/scroll churn is weak evidence unless the tool documents otherwise.', + properties: { + dialogChanged: { + type: 'boolean', + description: 'The visible DOM dialog set changed.', + }, + domChanged: { + type: 'boolean', + description: 'The DOM mutation revision changed; weak evidence on its own.', + }, + fieldChanged: { + type: 'boolean', + description: 'The safely inspectable focused-field state changed.', + }, + focusChanged: { + type: 'boolean', + description: 'The focused element changed.', + }, + popupChanged: { + type: 'boolean', + description: 'The visible popup/menu set changed.', + }, + scrollChanged: { + type: 'boolean', + description: 'A tracked scroll offset changed; weak evidence except for scroll keys.', + }, + tabChanged: { + type: 'boolean', + description: 'The active browser tab changed.', + }, + targetChanged: { + type: 'boolean', + description: "The requested target's checked/selected/expanded/open state changed.", + }, + titleChanged: { + type: 'boolean', + description: 'The document title changed; weak evidence on its own.', + }, + urlChanged: { + type: 'boolean', + description: 'The observed URL changed.', + }, + }, + }, + effectObserved: { + type: 'boolean', + description: + 'Whether a URL/tab/dialog/popup/target-state change, or editable-target focus change, was observed.', + }, + element: { + type: 'string', + description: 'Resolved target element kind.', + }, + note: { + type: 'string', + description: 'Postcondition or recovery guidance.', + }, + notices: { + type: 'array', + description: + 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', + items: { + type: 'string', + }, + }, + obstructedAfterNavigation: { + type: 'boolean', + description: 'Navigation/tab change occurred while a visible DOM dialog remained.', + }, + possibleEffectObserved: { + type: 'boolean', + description: 'Includes weak title/DOM/scroll churn; never treat this alone as success.', + }, + refRecovered: { + type: 'boolean', + description: + 'Whether a stale detached ref was safely rebound to one unique semantic match.', + }, + trusted: { + type: 'boolean', + description: 'Whether Chromium trusted pointer/keyboard input was used.', + }, + }, + required: ['dispatched'], + }, }, browser_close_tab: { parameters: { @@ -88,7 +208,59 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, required: ['instruction'], }, - resultSchema: undefined, + resultSchema: { + type: 'object', + properties: { + instruction: { + type: 'string', + description: 'The extraction instruction echoed unchanged.', + }, + notices: { + type: 'array', + description: + 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', + items: { + type: 'string', + }, + }, + page: { + type: 'object', + description: 'Bounded visible page/frame text result.', + properties: { + framesRead: { + type: 'number', + description: 'Visible child frames whose text was appended.', + }, + hiddenFrames: { + type: 'number', + description: + 'Eligible child frames skipped because their embedding surface was not visible.', + }, + text: { + type: 'string', + description: + 'Visible text, capped across the top page and eligible visible child frames.', + }, + title: { + type: 'string', + description: 'Top-page title when available.', + }, + truncated: { + type: 'boolean', + description: 'Whether a page, frame, or combined character cap omitted text.', + }, + unreadableFrames: { + type: 'number', + description: 'Eligible child frames whose text could not be read.', + }, + url: { + type: 'string', + description: 'Top-page URL.', + }, + }, + }, + }, + }, }, browser_go_back: { parameters: { @@ -110,12 +282,102 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { properties: { elementId: { type: 'number', - description: 'The element id to act on (from the most recent browser_snapshot).', + description: + "The element id to act on (from the current tab's most recent browser_snapshot). Treat refs as invalid across tab switches or later snapshots.", }, }, required: ['elementId'], }, - resultSchema: undefined, + resultSchema: { + type: 'object', + properties: { + effect: { + type: 'object', + description: + 'Detailed postcondition signals; generic title/DOM/scroll churn is weak evidence unless the tool documents otherwise.', + properties: { + dialogChanged: { + type: 'boolean', + description: 'The visible DOM dialog set changed.', + }, + domChanged: { + type: 'boolean', + description: 'The DOM mutation revision changed; weak evidence on its own.', + }, + fieldChanged: { + type: 'boolean', + description: 'The safely inspectable focused-field state changed.', + }, + focusChanged: { + type: 'boolean', + description: 'The focused element changed.', + }, + popupChanged: { + type: 'boolean', + description: 'The visible popup/menu set changed.', + }, + scrollChanged: { + type: 'boolean', + description: 'A tracked scroll offset changed; weak evidence except for scroll keys.', + }, + tabChanged: { + type: 'boolean', + description: 'The active browser tab changed.', + }, + targetChanged: { + type: 'boolean', + description: "The requested target's checked/selected/expanded/open state changed.", + }, + titleChanged: { + type: 'boolean', + description: 'The document title changed; weak evidence on its own.', + }, + urlChanged: { + type: 'boolean', + description: 'The observed URL changed.', + }, + }, + }, + effectObserved: { + type: 'boolean', + description: 'Whether a URL/dialog/popup/target-state change was observed.', + }, + element: { + type: 'string', + description: 'Resolved target element kind when available.', + }, + hovered: { + type: 'boolean', + description: 'Whether hover input was dispatched.', + }, + note: { + type: 'string', + description: 'Guidance when no tooltip/menu was confirmed.', + }, + notices: { + type: 'array', + description: + 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', + items: { + type: 'string', + }, + }, + possibleEffectObserved: { + type: 'boolean', + description: 'Includes weak title/DOM/scroll churn; not proof of success.', + }, + refRecovered: { + type: 'boolean', + description: + 'Whether a stale detached ref was safely rebound to one unique semantic match.', + }, + trusted: { + type: 'boolean', + description: 'Whether Chromium trusted pointer movement was used.', + }, + }, + required: ['hovered'], + }, }, browser_list_sessions: { parameters: { @@ -178,12 +440,127 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { key: { type: 'string', description: - "Key or combination. Named keys (case-insensitive): Enter, Escape (Esc), Tab, Backspace, Delete, Space, ArrowUp/ArrowDown/ArrowLeft/ArrowRight (or Up/Down/Left/Right), Home, End, PageUp, PageDown. Any single character also works ('a', '5', '/'). Anything else — 'F5', 'Return', 'Insert' — is rejected. Join modifiers with '+': Control (Ctrl), Cmd (Command, Meta), Shift, Alt (Option), e.g. 'Cmd+A' or 'Control+Shift+K'. On macOS, Control maps to Cmd for the editing shortcuts A, C, X, V, and Z only, so 'Control+A' selects all on every platform.", + "Key or combination. Named keys (case-insensitive): Enter, Escape (Esc), Tab, Backspace, Delete, Space, ArrowUp/ArrowDown/ArrowLeft/ArrowRight (or Up/Down/Left/Right), Home, End, PageUp, PageDown. Any single character also works ('a', '5', '/', ','). Anything else — 'F5', 'Return', 'Insert' — is rejected. Join modifiers with '+'. Use Mod (aliases Primary, ControlOrMeta, CommandOrControl) for the platform primary modifier, e.g. Mod+K or Mod+,. Raw Control/Ctrl and Cmd/Command/Meta remain available; Control is not generally Cmd on macOS. Check effectObserved and primaryModifier in the result.", }, }, required: ['key'], }, - resultSchema: undefined, + resultSchema: { + type: 'object', + properties: { + activeElement: { + type: 'string', + description: 'Focused element kind after the action.', + }, + dialogs: { + type: 'array', + description: 'Visible DOM dialogs after the key.', + items: { + type: 'string', + }, + }, + effect: { + type: 'object', + description: + 'Detailed postcondition signals; generic title/DOM/scroll churn is weak evidence unless the tool documents otherwise.', + properties: { + dialogChanged: { + type: 'boolean', + description: 'The visible DOM dialog set changed.', + }, + domChanged: { + type: 'boolean', + description: 'The DOM mutation revision changed; weak evidence on its own.', + }, + fieldChanged: { + type: 'boolean', + description: 'The safely inspectable focused-field state changed.', + }, + focusChanged: { + type: 'boolean', + description: 'The focused element changed.', + }, + popupChanged: { + type: 'boolean', + description: 'The visible popup/menu set changed.', + }, + scrollChanged: { + type: 'boolean', + description: 'A tracked scroll offset changed; weak evidence except for scroll keys.', + }, + tabChanged: { + type: 'boolean', + description: 'The active browser tab changed.', + }, + targetChanged: { + type: 'boolean', + description: "The requested target's checked/selected/expanded/open state changed.", + }, + titleChanged: { + type: 'boolean', + description: 'The document title changed; weak evidence on its own.', + }, + urlChanged: { + type: 'boolean', + description: 'The observed URL changed.', + }, + }, + }, + effectObserved: { + type: 'boolean', + description: 'A strong targeted effect was observed.', + }, + note: { + type: 'string', + description: 'No-op/fallback guidance.', + }, + notices: { + type: 'array', + description: + 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', + items: { + type: 'string', + }, + }, + possibleEffectObserved: { + type: 'boolean', + description: 'Includes weak title/DOM/scroll churn; not proof of success.', + }, + pressed: { + type: 'string', + description: 'Requested key/combo whose dispatch completed.', + }, + primaryModifier: { + type: 'string', + description: 'Cmd on macOS, Control elsewhere.', + }, + redacted: { + type: 'boolean', + description: 'Whether sensitive focused-field details were withheld.', + }, + selectedChars: { + type: 'number', + description: 'Number of selected characters when safely inspectable.', + }, + target: { + type: 'string', + description: 'Synthetic fallback target element kind, when applicable.', + }, + trusted: { + type: 'boolean', + description: 'Whether Chromium trusted key input was used.', + }, + valueLength: { + type: 'number', + description: 'Focused non-secret field length when safely inspectable.', + }, + valuePreview: { + type: 'string', + description: 'Bounded focused non-secret field preview when safely inspectable.', + }, + }, + required: ['pressed'], + }, }, browser_read_text: { parameters: { @@ -192,11 +569,53 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { elementId: { type: 'number', description: - 'Optional element id (from browser_snapshot) to read text from. Omit to read the whole page.', + "Optional element id from the current tab's most recent browser_snapshot. Treat refs as invalid across tab switches or later snapshots. Omit to read the whole page.", + }, + }, + }, + resultSchema: { + type: 'object', + properties: { + framesRead: { + type: 'number', + description: 'Visible child frames whose text was appended.', + }, + hiddenFrames: { + type: 'number', + description: + 'Eligible child frames skipped because their embedding surface was not visible.', + }, + notices: { + type: 'array', + description: + 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', + items: { + type: 'string', + }, + }, + text: { + type: 'string', + description: + 'Visible text, capped across the top page and eligible visible child frames.', + }, + title: { + type: 'string', + description: 'Top-page title when available.', + }, + truncated: { + type: 'boolean', + description: 'Whether a page, frame, or combined character cap omitted text.', + }, + unreadableFrames: { + type: 'number', + description: 'Eligible child frames whose text could not be read.', + }, + url: { + type: 'string', + description: 'Top-page URL.', }, }, }, - resultSchema: undefined, }, browser_request_takeover: { parameters: { @@ -239,10 +658,65 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { description: 'Scroll direction.', enum: ['up', 'down'], }, + elementId: { + type: 'number', + description: + "The element id to act on (from the current tab's most recent browser_snapshot). Treat refs as invalid across tab switches or later snapshots.", + }, }, required: ['direction'], }, - resultSchema: undefined, + resultSchema: { + type: 'object', + properties: { + atBottom: { + type: 'boolean', + description: 'Whether the selected region is at its bottom boundary.', + }, + atTop: { + type: 'boolean', + description: 'Whether the selected region is at its top boundary.', + }, + clientHeight: { + type: 'number', + description: 'Region viewport height.', + }, + movedBy: { + type: 'number', + description: 'Actual signed movement; zero means the target did not move.', + }, + notices: { + type: 'array', + description: + 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', + items: { + type: 'string', + }, + }, + scrollHeight: { + type: 'number', + description: 'Region content height.', + }, + scrollTop: { + type: 'number', + description: 'Resulting region scroll offset.', + }, + target: { + type: 'string', + description: 'Chosen scroll region label.', + }, + targetSource: { + type: 'string', + description: + 'element, element-boundary, focus, focus-boundary, viewport-center, viewport-center-boundary, largest-visible, or page.', + }, + windowScrollY: { + type: 'number', + description: 'Top-page window scroll offset after the region scroll.', + }, + }, + required: ['atTop', 'atBottom'], + }, }, browser_select_option: { parameters: { @@ -250,23 +724,130 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { properties: { elementId: { type: 'number', - description: 'The element id to act on (from the most recent browser_snapshot).', + description: + "The element id to act on (from the current tab's most recent browser_snapshot). Treat refs as invalid across tab switches or later snapshots.", + }, + value: { + type: 'string', + description: "The option's visible label or its value.", + }, + }, + required: ['elementId', 'value'], + }, + resultSchema: { + type: 'object', + properties: { + effectObserved: { + type: 'boolean', + description: 'Whether the settled readback retained the requested selection.', + }, + note: { + type: 'string', + description: 'Guidance when the page reverted the selection.', + }, + notices: { + type: 'array', + description: + 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', + items: { + type: 'string', + }, + }, + readback: { + type: 'object', + description: 'Settled selected label and value.', + properties: { + selected: { + type: 'string', + description: 'Settled visible option label.', + }, + value: { + type: 'string', + description: 'Settled option value.', + }, + }, + }, + refRecovered: { + type: 'boolean', + description: + 'Whether a stale detached ref was safely rebound to one unique semantic match.', + }, + selected: { + type: 'string', + description: 'Canonical visible label of the matched option.', + }, + value: { + type: 'string', + description: 'Canonical value of the matched option.', + }, + }, + required: ['selected'], + }, + }, + browser_snapshot: { + parameters: { + type: 'object', + properties: {}, + }, + resultSchema: { + type: 'object', + properties: { + capturedCrossOriginFrames: { + type: 'number', + description: 'Number of non-empty eligible cross-origin frames appended.', + }, + hiddenCrossOriginFrames: { + type: 'number', + description: + 'Eligible cross-origin frames skipped because their embedding surface was hidden, offscreen, or covered.', + }, + notices: { + type: 'array', + description: + 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', + items: { + type: 'string', + }, + }, + outline: { + type: 'string', + description: 'Mounted DOM/frame outline containing model-visible [ref=N] ids.', + }, + pageHeight: { + type: 'number', + description: 'Top-page document height.', + }, + scrollY: { + type: 'number', + description: 'Top-page window scroll offset.', + }, + title: { + type: 'string', + description: 'Captured top-page title.', + }, + truncated: { + type: 'boolean', + description: 'True when page/ref/frame/combined output caps omitted content.', + }, + unreadableCrossOriginFrames: { + type: 'number', + description: 'Eligible cross-origin frames that could not be captured.', }, - value: { + url: { type: 'string', - description: "The option's visible label or its value.", + description: 'Captured top-page URL.', + }, + viewportHeight: { + type: 'number', + description: 'Top-page viewport height.', + }, + viewportWidth: { + type: 'number', + description: 'Top-page viewport width.', }, }, - required: ['elementId', 'value'], - }, - resultSchema: undefined, - }, - browser_snapshot: { - parameters: { - type: 'object', - properties: {}, + required: ['outline', 'truncated'], }, - resultSchema: undefined, }, browser_switch_tab: { parameters: { @@ -287,7 +868,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { properties: { elementId: { type: 'number', - description: 'The element id to act on (from the most recent browser_snapshot).', + description: + "The element id to act on (from the current tab's most recent browser_snapshot). Treat refs as invalid across tab switches or later snapshots.", }, submit: { type: 'boolean', @@ -296,12 +878,145 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { text: { type: 'string', description: - "The text to type. Replaces the element's current content. Must be non-empty — an empty string is rejected as a missing parameter; to clear a field, press Cmd+A then Backspace with browser_press_key.", + "The text to type. Replaces the element's current content. Must be non-empty — an empty string is rejected as a missing parameter; to clear a field, press Mod+A then Backspace with browser_press_key.", }, }, required: ['elementId', 'text'], }, - resultSchema: undefined, + resultSchema: { + type: 'object', + properties: { + activeElement: { + type: 'string', + description: 'Focused element kind after the action.', + }, + dispatched: { + type: 'boolean', + description: 'Whether text dispatch completed.', + }, + effect: { + type: 'object', + description: + 'Detailed postcondition signals; generic title/DOM/scroll churn is weak evidence unless the tool documents otherwise.', + properties: { + dialogChanged: { + type: 'boolean', + description: 'The visible DOM dialog set changed.', + }, + domChanged: { + type: 'boolean', + description: 'The DOM mutation revision changed; weak evidence on its own.', + }, + fieldChanged: { + type: 'boolean', + description: 'The safely inspectable focused-field state changed.', + }, + focusChanged: { + type: 'boolean', + description: 'The focused element changed.', + }, + popupChanged: { + type: 'boolean', + description: 'The visible popup/menu set changed.', + }, + scrollChanged: { + type: 'boolean', + description: 'A tracked scroll offset changed; weak evidence except for scroll keys.', + }, + tabChanged: { + type: 'boolean', + description: 'The active browser tab changed.', + }, + targetChanged: { + type: 'boolean', + description: "The requested target's checked/selected/expanded/open state changed.", + }, + titleChanged: { + type: 'boolean', + description: 'The document title changed; weak evidence on its own.', + }, + urlChanged: { + type: 'boolean', + description: 'The observed URL changed.', + }, + }, + }, + effectObserved: { + type: 'boolean', + description: 'A strong field/page effect was observed.', + }, + note: { + type: 'string', + description: 'Postcondition guidance when readback did not prove a change.', + }, + notices: { + type: 'array', + description: + 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', + items: { + type: 'string', + }, + }, + possibleEffectObserved: { + type: 'boolean', + description: 'Includes weak title/DOM/scroll churn; not proof of success.', + }, + redacted: { + type: 'boolean', + description: 'Whether sensitive focused-field details were withheld.', + }, + refRecovered: { + type: 'boolean', + description: + 'Whether a stale detached ref was safely rebound to one unique semantic match.', + }, + replacedExisting: { + type: 'boolean', + description: "Whether the operation replaced the field's existing content.", + }, + selectedChars: { + type: 'number', + description: 'Number of selected characters when safely inspectable.', + }, + submissionEffectObserved: { + type: 'boolean', + description: + 'Whether a strong effect was observed after Enter, separately from the text write.', + }, + submitDispatched: { + type: 'boolean', + description: + 'Whether Enter dispatch acknowledged completion; this alone is not proof of submission.', + }, + submitRequested: { + type: 'boolean', + description: 'Whether submit=true was requested.', + }, + submitUncertain: { + type: 'boolean', + description: + 'Whether Enter key-down may have landed but dispatch did not acknowledge completion.', + }, + submitted: { + type: 'boolean', + description: + 'Whether Enter dispatch completed and a strong submission effect was observed.', + }, + trusted: { + type: 'boolean', + description: 'Whether native Chromium input was used.', + }, + valueLength: { + type: 'number', + description: 'Focused non-secret field length when safely inspectable.', + }, + valuePreview: { + type: 'string', + description: 'Bounded focused non-secret field preview when safely inspectable.', + }, + }, + required: ['dispatched'], + }, }, browser_wait_for: { parameters: { @@ -317,7 +1032,39 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, }, }, - resultSchema: undefined, + resultSchema: { + type: 'object', + properties: { + elapsedMs: { + type: 'number', + description: 'Elapsed wait duration.', + }, + found: { + type: 'boolean', + description: 'Whether the requested text appeared before timeout.', + }, + foundInFrame: { + type: 'boolean', + description: 'Whether the match was found in an eligible visible child frame.', + }, + note: { + type: 'string', + description: 'Timeout/recovery guidance.', + }, + notices: { + type: 'array', + description: + 'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.', + items: { + type: 'string', + }, + }, + waitedMs: { + type: 'number', + description: 'Completed sleep duration when no text was requested.', + }, + }, + }, }, call_integration_tool: { parameters: { @@ -359,19 +1106,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - complete_scheduled_task: { - parameters: { - type: 'object', - properties: { - jobId: { - type: 'string', - description: 'The ID of the scheduled task to mark as completed.', - }, - }, - required: ['jobId'], - }, - resultSchema: undefined, - }, cp: { parameters: { type: 'object', @@ -1464,7 +2198,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { code: { type: 'string', description: - 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with access to pre-installed CLI tools and workspace env vars as $VAR_NAME.', + 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with pre-installed CLI tools. Use each needed secret as {{VAR_NAME}}; the reference resolves to the value exactly as stored.', }, inputs: { type: 'object', @@ -1586,6 +2320,11 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, }, }, + sandboxId: { + type: 'string', + description: + 'Optional Sim sandbox id from agent/sandboxes/{name}.json. DEFAULT-FIRST: omit this whenever the documented default function_execute environment can do the job. Select a ready existing Sim sandbox only when a required third-party dependency, Debian system package, or managed CLI is known to be absent, or a default attempt failed specifically because it was missing. Never guess an id.', + }, timeout: { type: 'number', description: @@ -2167,31 +2906,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - get_scheduled_task_logs: { - parameters: { - type: 'object', - properties: { - executionId: { - type: 'string', - description: 'Optional execution ID for a specific run.', - }, - includeDetails: { - type: 'boolean', - description: 'Include tool calls, outputs, and cost details.', - }, - jobId: { - type: 'string', - description: 'The scheduled task (schedule) ID to get logs for.', - }, - limit: { - type: 'number', - description: 'Max number of entries (default: 3, max: 5)', - }, - }, - required: ['jobId'], - }, - resultSchema: undefined, - }, get_workflow_data: { parameters: { type: 'object', @@ -2622,7 +3336,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { operation: { type: 'string', description: - "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — manage_scheduled_task uses create/update instead of add/edit.", + "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — other manage_* tools may use create/update instead of add/edit.", enum: ['add', 'edit', 'delete', 'list'], }, schema: { @@ -2729,7 +3443,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { operation: { type: 'string', description: - "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — manage_scheduled_task uses create/update instead of add/edit.", + "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — other manage_* tools may use create/update instead of add/edit.", enum: ['add', 'edit', 'delete', 'list'], }, serverId: { @@ -2742,75 +3456,54 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - manage_scheduled_task: { + manage_sandbox: { parameters: { type: 'object', properties: { - args: { - type: 'object', + cliTools: { + type: 'array', description: - 'Operation-specific arguments. For create: {title, prompt, cron?, time?, timezone?, lifecycle?, successCondition?, maxRuns?}. For get/delete: {jobId}. For update: {jobId, title?, prompt?, cron?, timezone?, status?, lifecycle?, successCondition?, maxRuns?}. For list: no args needed.', - properties: { - cron: { - type: 'string', - description: - "Cron expression for a recurring scheduled task (e.g. '0 9 * * *'). Provide cron, time, or both — with both, time anchors the recurring task's first fire.", - }, - jobId: { - type: 'string', - description: 'Scheduled task ID (required for get, update)', - }, - jobIds: { - type: 'array', - description: 'Array of scheduled task IDs (for batch delete)', - items: { - type: 'string', - }, - }, - lifecycle: { - type: 'string', - description: - "'persistent' (default) or 'until_complete'. Until_complete scheduled tasks stop when complete_scheduled_task is called.", - enum: ['persistent', 'until_complete'], - }, - maxRuns: { - type: 'integer', - description: 'Max executions before auto-completing. Safety limit.', - }, - prompt: { - type: 'string', - description: 'The prompt to execute when the scheduled task fires', - }, - status: { - type: 'string', - description: 'Scheduled task status: active, paused', - enum: ['active', 'paused'], - }, - successCondition: { - type: 'string', - description: - 'What must happen for the scheduled task to be considered complete (until_complete lifecycle).', - }, - time: { - type: 'string', - description: - "ISO 8601 datetime. One-time scheduled task -> set time and omit cron. May also anchor a recurring cron task's first-fire time.", - }, - timezone: { - type: 'string', - description: 'IANA timezone (e.g. America/New_York). Defaults to UTC.', - }, - title: { - type: 'string', - description: "Short descriptive title for the scheduled task (e.g. 'Email Poller')", - }, + 'Complete managed CLI id list (maximum 10). Use exact pinned ids returned by list. On edit, passing this replaces the whole list; pass [] to clear it.', + items: { + type: 'string', + }, + }, + dependencies: { + type: 'array', + description: + 'Complete npm or PyPI dependency list (maximum 50). On edit, passing this replaces the whole list; pass [] to clear it.', + items: { + type: 'string', }, }, + language: { + type: 'string', + description: + 'Dependency language. javascript installs from npm; python installs from PyPI. Required for add; optional for edit.', + enum: ['javascript', 'python'], + }, + name: { + type: 'string', + description: + 'Workspace-unique Sim sandbox name (1-64 characters). Required for add; optional for edit.', + }, operation: { type: 'string', + description: "The operation to perform: 'add', 'edit', 'list', or 'delete'.", + enum: ['add', 'edit', 'delete', 'list'], + }, + sandboxId: { + type: 'string', + description: + 'The Sim sandbox id. Get it from list or the inner id field in agent/sandboxes/{name}.json; never guess it. Required for edit and delete.', + }, + systemPackages: { + type: 'array', description: - 'The operation to perform: create, list, get, update, delete. These verbs are tool-specific — the custom-tool/MCP/skill managers use add/edit instead of create/update.', - enum: ['create', 'list', 'get', 'update', 'delete'], + 'Complete Debian package-coordinate list in package[:architecture][=version] form (maximum 50). On edit, passing this replaces the whole list; pass [] to clear it.', + items: { + type: 'string', + }, }, }, required: ['operation'], @@ -2837,7 +3530,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { operation: { type: 'string', description: - "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — manage_scheduled_task uses create/update instead of add/edit.", + "The operation to perform: 'add', 'edit', 'list', or 'delete'. These verbs are tool-specific — other manage_* tools may use create/update instead of add/edit.", enum: ['add', 'edit', 'delete', 'list'], }, skillId: { @@ -2992,7 +3685,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: { type: 'string', description: 'The resource type.', - enum: ['workflow', 'table', 'knowledgebase', 'file', 'log', 'scheduledtask'], + enum: ['workflow', 'table', 'knowledgebase', 'file', 'log'], }, }, required: ['type'], @@ -3141,27 +3834,30 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'object', description: 'Arguments for the operation', properties: { + cursor: { + type: 'string', + description: + 'Opaque pagination cursor for query_rows (optional). Omit for the first page; to fetch the next page, pass back the nextCursor from the previous result\'s "more available" message verbatim. Cannot be combined with a fresh order — the cursor already encodes the paging position.', + }, filter: { type: 'object', - description: 'MongoDB-style filter for query_rows', + description: + 'Predicate filter object for query_rows. A predicate is a tree: {"all":[...]} (AND) or {"any":[...]} (OR); members are leaves {field, op, value} or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (use * as the wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array value. Examples: {"all":[{"field":"status","op":"eq","value":"active"}]}; {"any":[{"field":"status","op":"eq","value":"active"},{"field":"status","op":"eq","value":"pending"}]}; {"all":[{"field":"name","op":"ilike","value":"*jo*"}]}.', }, limit: { type: 'number', - description: 'Maximum rows to return (optional, default 100, max 1000 per call)', + description: + 'Maximum rows per page for query_rows (optional). Omit to fetch the ENTIRE matching result in one response — the call fails if the result exceeds the 5MB budget (narrow with a filter or set a limit). With a limit, a page may end early at the byte budget with more remaining; a non-null nextCursor in the result means more rows exist (continue with cursor).', }, - offset: { - type: 'number', - description: 'Number of rows to skip (optional for query_rows, default 0)', + order: { + type: 'array', + description: + 'Sort spec for query_rows (optional). Ordered list of {field, direction} where direction is asc or desc, e.g. [{"field":"wins","direction":"desc"},{"field":"name","direction":"asc"}].', }, rowId: { type: 'string', description: 'Row ID (required for get_row)', }, - sort: { - type: 'object', - description: - "Sort specification as { field: 'asc' | 'desc' } (optional for query_rows)", - }, tableId: { type: 'string', description: 'Table ID (required for all operations)', @@ -3428,7 +4124,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { code: { type: 'string', description: - 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with access to pre-installed CLI tools and workspace env vars as $VAR_NAME.', + 'Code to execute. For JS: raw statements auto-wrapped in async context. For Python: full script. For shell: bash script with pre-installed CLI tools. Use each needed secret as {{VAR_NAME}}; the reference resolves to the value exactly as stored.', }, inputs: { type: 'object', @@ -3550,6 +4246,11 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { parameters: { type: 'object', properties: { + async: { + type: 'boolean', + description: + 'Queue the deployed workflow and return its execution ID immediately. Default: false. Set true only when explicitly asked for a background run, or when the three most recent completed runs each exceeded 30 minutes. Fails if the current workflow differs from its deployed version. Missing history, complexity, or one slow run never justify async; check completion later with query_logs.', + }, inputFromExecutionId: { type: 'string', description: @@ -3627,19 +4328,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - scheduled_task: { - parameters: { - properties: { - request: { - description: 'What scheduled task action is needed.', - type: 'string', - }, - }, - required: ['request'], - type: 'object', - }, - resultSchema: undefined, - }, scrape_page: { parameters: { type: 'object', @@ -4167,24 +4855,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, - update_scheduled_task_history: { - parameters: { - type: 'object', - properties: { - jobId: { - type: 'string', - description: 'The scheduled task ID.', - }, - summary: { - type: 'string', - description: - "A concise summary of what was done this run (e.g., 'Sent follow-up emails to 3 leads: Alice, Bob, Carol').", - }, - }, - required: ['jobId', 'summary'], - }, - resultSchema: undefined, - }, update_workspace_mcp_server: { parameters: { type: 'object', @@ -4243,6 +4913,11 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { description: 'Array of column names to delete at once (for delete_column). Preferred over columnName when deleting multiple columns.', }, + cursor: { + type: 'string', + description: + 'Opaque pagination cursor for query_rows (optional). Omit for the first page; to fetch the next page, pass back the nextCursor from the previous result\'s "more available" message verbatim. Cannot be combined with a fresh order — the cursor already encodes the paging position.', + }, data: { type: 'object', description: 'Row data as key-value pairs (required for insert_row, update_row)', @@ -4285,7 +4960,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { filter: { type: 'object', description: - 'MongoDB-style filter for query_rows, update_rows_by_filter, delete_rows_by_filter', + 'Predicate filter object for query_rows, update_rows_by_filter, delete_rows_by_filter. A predicate is a tree: {"all":[...]} (AND) or {"any":[...]} (OR); members are leaves {field, op, value} or nested groups. Ops: eq, ne, gt, gte, lt, lte, in, nin, like, ilike (use * as the wildcard), nlike, nilike, contains, ncontains, startsWith, endsWith, isNull, isNotNull, isEmpty, isNotEmpty. in/nin take a non-empty array value. Examples: {"all":[{"field":"status","op":"eq","value":"active"}]}; {"all":[{"field":"wins","op":"gte","value":18},{"field":"status","op":"eq","value":"pending"}]}; {"any":[{"field":"status","op":"eq","value":"active"},{"field":"status","op":"eq","value":"pending"}]}; {"all":[{"field":"name","op":"ilike","value":"*jo*"}]}; {"all":[{"field":"slack_user_id","op":"in","value":["U1","U2"]}]}.', }, groupId: { type: 'string', @@ -4322,7 +4997,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { limit: { type: 'number', description: - 'Maximum rows to return or affect (optional, default 100). Omit on update_rows_by_filter / delete_rows_by_filter to act on every match.', + 'Maximum rows per page for query_rows (optional). Omit to fetch the ENTIRE matching result in one response — the call fails if the result exceeds the 5MB budget (narrow with a filter or set a limit). With a limit, a page may end early at the byte budget with more remaining; a non-null nextCursor in the result means more rows exist (continue with cursor). On update_rows_by_filter / delete_rows_by_filter, caps affected rows; omit to act on every match.', }, mapping: { type: 'object', @@ -4384,10 +5059,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { description: 'New column type (optional for update_column). Types: string, number, boolean, date, json, select. Converting a column to select also requires options; the conversion fails if any existing cell value doesn\'t match one of them. Converting to a multiple: true select also accepts a comma-separated cell ("Open, Urgent"), which is the form a multi column converts to text as — so multiselect → text → multiselect round-trips.', }, - offset: { - type: 'number', - description: 'Number of rows to skip (optional for query_rows, default 0)', - }, options: { type: 'array', description: @@ -4396,6 +5067,11 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'string', }, }, + order: { + type: 'array', + description: + 'Sort spec for query_rows (optional). Ordered list of {field, direction} where direction is asc or desc, e.g. [{"field":"wins","direction":"desc"},{"field":"name","direction":"asc"}].', + }, outputColumnNames: { type: 'object', description: @@ -4488,11 +5164,6 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { "Cancellation scope for cancel_table_runs. 'all' cancels in-flight runs across the whole table; 'row' cancels only the row identified by rowId.", enum: ['all', 'row'], }, - sort: { - type: 'object', - description: - "Sort specification as { field: 'asc' | 'desc' } (optional for query_rows)", - }, tableId: { type: 'string', description: From 634196b8d3942ccc6d97342f9a8e84344c9238a5 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:36:33 -0700 Subject: [PATCH 22/24] chore(copilot): regenerate the docs manifest for staging docs content Staging added docs pages since the manifest was generated; the CI freshness check (docs-manifest:check) catches exactly this drift. Co-Authored-By: Claude Fable 5 --- .../lib/copilot/generated/docs-manifest.ts | 36 +- .../2026-08-03-platform-agent-ideation.html | 511 ++++++++++++++++++ 2 files changed, 539 insertions(+), 8 deletions(-) create mode 100644 docs/ideation/2026-08-03-platform-agent-ideation.html diff --git a/apps/sim/lib/copilot/generated/docs-manifest.ts b/apps/sim/lib/copilot/generated/docs-manifest.ts index 720d5371947..6c747f97593 100644 --- a/apps/sim/lib/copilot/generated/docs-manifest.ts +++ b/apps/sim/lib/copilot/generated/docs-manifest.ts @@ -15,6 +15,14 @@ export const DOCS_MANIFEST: readonly string[] = [ 'agents/custom-tools.mdx', 'agents/mcp.mdx', 'agents/skills.mdx', + 'chat.mdx', + 'chat/files.mdx', + 'chat/knowledge.mdx', + 'chat/mailer.mdx', + 'chat/research.mdx', + 'chat/tables.mdx', + 'chat/tasks.mdx', + 'chat/workflows.mdx', 'files.mdx', 'files/editor.mdx', 'files/generating.mdx', @@ -88,6 +96,7 @@ export const DOCS_MANIFEST: readonly string[] = [ 'integrations/elasticsearch.mdx', 'integrations/elevenlabs.mdx', 'integrations/emailbison.mdx', + 'integrations/embeddings.mdx', 'integrations/enrich.mdx', 'integrations/enrichment.mdx', 'integrations/enrow.mdx', @@ -161,11 +170,13 @@ export const DOCS_MANIFEST: readonly string[] = [ 'integrations/linkedin.mdx', 'integrations/linkup.mdx', 'integrations/linq.mdx', + 'integrations/logfire.mdx', 'integrations/logs.mdx', 'integrations/loops.mdx', 'integrations/luma.mdx', 'integrations/mailchimp.mdx', 'integrations/mailgun.mdx', + 'integrations/managed_agent.mdx', 'integrations/mem0.mdx', 'integrations/memory.mdx', 'integrations/microsoft_ad.mdx', @@ -237,6 +248,7 @@ export const DOCS_MANIFEST: readonly string[] = [ 'integrations/similarweb.mdx', 'integrations/sixtyfour.mdx', 'integrations/slack.mdx', + 'integrations/smartlead.mdx', 'integrations/smtp.mdx', 'integrations/sportmonks.mdx', 'integrations/sqs.mdx', @@ -253,6 +265,7 @@ export const DOCS_MANIFEST: readonly string[] = [ 'integrations/temporal.mdx', 'integrations/textract.mdx', 'integrations/thrive.mdx', + 'integrations/tiktok.mdx', 'integrations/tinybird.mdx', 'integrations/trello-service-account.mdx', 'integrations/trello.mdx', @@ -279,6 +292,8 @@ export const DOCS_MANIFEST: readonly string[] = [ 'integrations/zendesk.mdx', 'integrations/zep.mdx', 'integrations/zerobounce.mdx', + 'integrations/zoho-desk-service-account.mdx', + 'integrations/zoho_desk.mdx', 'integrations/zoom-service-account.mdx', 'integrations/zoom.mdx', 'integrations/zoominfo.mdx', @@ -293,14 +308,6 @@ export const DOCS_MANIFEST: readonly string[] = [ 'logs-debugging.mdx', 'logs-debugging/alerts.mdx', 'logs-debugging/logging.mdx', - 'mothership.mdx', - 'mothership/files.mdx', - 'mothership/knowledge.mdx', - 'mothership/mailer.mdx', - 'mothership/research.mdx', - 'mothership/tables.mdx', - 'mothership/tasks.mdx', - 'mothership/workflows.mdx', 'platform/costs.mdx', 'platform/credentials.mdx', 'platform/enterprise.mdx', @@ -310,6 +317,7 @@ export const DOCS_MANIFEST: readonly string[] = [ 'platform/enterprise/data-drains.mdx', 'platform/enterprise/data-retention.mdx', 'platform/enterprise/forks.mdx', + 'platform/enterprise/self-hosted.mdx', 'platform/enterprise/session-policies.mdx', 'platform/enterprise/sso.mdx', 'platform/enterprise/verified-domains.mdx', @@ -317,12 +325,24 @@ export const DOCS_MANIFEST: readonly string[] = [ 'platform/organization.mdx', 'platform/permissions.mdx', 'platform/self-hosting.mdx', + 'platform/self-hosting/architecture.mdx', + 'platform/self-hosting/authentication.mdx', + 'platform/self-hosting/background-jobs.mdx', 'platform/self-hosting/docker.mdx', + 'platform/self-hosting/email.mdx', 'platform/self-hosting/environment-variables.mdx', + 'platform/self-hosting/integrations-oauth.mdx', 'platform/self-hosting/kubernetes.mdx', + 'platform/self-hosting/networking.mdx', 'platform/self-hosting/object-storage.mdx', + 'platform/self-hosting/observability.mdx', 'platform/self-hosting/platforms.mdx', + 'platform/self-hosting/redis.mdx', + 'platform/self-hosting/scaling.mdx', + 'platform/self-hosting/security.mdx', 'platform/self-hosting/troubleshooting.mdx', + 'platform/self-hosting/upgrades.mdx', + 'platform/self-hosting/verify.mdx', 'platform/workspaces.mdx', 'quick-reference.mdx', 'tables.mdx', diff --git a/docs/ideation/2026-08-03-platform-agent-ideation.html b/docs/ideation/2026-08-03-platform-agent-ideation.html new file mode 100644 index 00000000000..cfa784716f5 --- /dev/null +++ b/docs/ideation/2026-08-03-platform-agent-ideation.html @@ -0,0 +1,511 @@ + + + + + + Platform agent — ideation + + + +
+
+

Ideation · Platform intelligence

+

Turn the docs agent into a trusted platform operator

+

The strongest direction is not an omniscient agent. It is a source-aware agent that knows the user’s operating context, fetches private state only when needed, explains access and billing in product language, and leaves evidence behind whenever it reads sensitive data.

+ + + +
+
30raw candidates
+
12deduped directions
+
6ranked survivors
+
4topic axes covered
+
+ + +
+ +
+

What the codebase already gives us

+

Grounding Context

+

The branch introduces a dedicated platform child that is intentionally isolated from parent conversation and restricted to documentation search, VFS reads, and response. That isolation is useful, but the runtime already has stronger seams than the prompt admits.

+ +
+
+

Trusted request context already exists

+

Child execution carries trusted user/workspace IDs, effective permission, entitlements, timezone, and workspace/session/workflow bootstrap. Human-readable UserMetadata is the notable omission.

+
+
+

Central handlers are the security seam

+

Sim-side tool handlers receive authenticated actor/workspace context and can enforce permission before returning data. Model-supplied IDs do not need to become authority.

+
+
+

Most live data services already exist

+

Billing, permission groups, audit events, execution logs and metrics, and metadata-only subagent invocation records already expose the underlying facts with distinct gates.

+
+
+

Prior art converges on the same split

+

Microsoft, AWS, and Intercom separate ambient identity from permission-trimmed retrieval and persona-specific behavior.

+
+
+ +
+ + Four source-of-truth layers feeding the platform agent + Injected context, live tools, public docs, and component schemas each answer a different class of question. The platform agent synthesizes them into a scoped answer with provenance. + + + + + + + Injected context + who · where · current role + + + Live Sim tools + private · mutable · scoped + + + Product docs + behavior · limits · UI + + + Component schemas + fields · enums · tool IDs + + + + + + + + Platform agent + chooses authority by question + + + scoped + cited + fresh + + Answer with provenance + +
Directional overview: each source is authoritative for a different kind of fact. The model chooses among them; authorization remains in Sim.
+
+
+ +
+

Surface map

+

Topic Axes

+
+

1. Identity and current context

Who is asking, where they are operating, and what request-local context is safe to carry ambiently.

+

2. Access and resource visibility

What the viewer may discover or do, why something is unavailable, and how to avoid resource-existence leaks.

+

3. Plan, billing, and usage

Personal plan, effective coverage, exact workspace payer, usage gates, limits, credits, and management authority.

+

4. Activity, audit, and operational health

What changed, what failed, which evidence source applies, and how private reads become inspectable.

+
+
+ +
+

Qualified directions

+

Ranked Ideas

+ + +
+
+
1

Idea 1. Context passport + source hierarchy

+
Confidence · 94%Complexity · Low
+
+

Description: Inject a small trusted Current Platform Context block into the child: display name, timezone, workspace name/ID, current workflow or selected resource, effective read|write|admin, broad entitlements, and an asOf value. Rewrite the prompt around four authorities: this passport for orientation, live tools for private or mutable facts, docs for product behavior, and component schemas for exact configuration.

+
+
Axis
Identity and current context
+
Basis
direct: The request already threads trusted workspace, permission, entitlement, timezone, session/workflow bootstrap, and VFS inventory to the child, but not human-readable UserMetadata. The current prompt already distinguishes docs behavior from schema truth, so this adds the missing live-data tier rather than replacing the model.
+
Rationale
It removes repeated disambiguation while creating a crisp rule for stale, conflicting, or private facts. This is the smallest change that makes every later tool safer and easier to use.
+
Downsides
The passport becomes a compatibility contract and must stay deliberately small. Current page/resource context needs careful selection so it does not leak browser state to children unnecessarily.
+
+
+ +
+
+
2

Idea 2. Capability/access explainer

+
Confidence · 92%Complexity · Medium
+
+

Description: Add explain_capability(action, resourceType?). It returns available, needs_write, needs_admin, blocked_by_policy, not_entitled, or not_configured, identifies the controlling layer, and gives a safe next step. It never returns names, counts, or existence signals for hidden resources.

+
+
Axis
Access and resource visibility
+
Basis
direct: Sim already combines workspace permission, organization role, permission-group restrictions, integration/model/tool allowlists, and per-viewer feature visibility. Handler-side enforcement and trusted execution context are already the normal boundary.
+
Rationale
This turns “the docs say I can” into “here is whether you can, why, and what legitimate path exists.” It can absorb the useful part of a buildability map without exposing a broad hidden-feature manifest.
+
Downsides
A stable causal vocabulary is product work, not just plumbing. Incorrect denial explanations are worse than a generic denial, so the tool must reuse the same policy decisions as execution rather than reimplementing them.
+
+
+ +
+
+
3

Idea 3. Three-lens billing snapshot + run preflight

+
Confidence · 91%Complexity · Medium
+
+

Description: Add one billing tool with explicit lenses: personal_subscription, effective_user_coverage, and current_workspace_payer. Return only decision-ready fields—plan/status, usable/block state, usage and limit, credits, period, management authority, freshness—and an optional operation preflight that reports the first live gate and user-appropriate remediation.

+
+
Personal

What the user personally owns or pays for.

+
Effective

What coverage the user currently receives.

+
Workspace payer

Which billing pool governs work here.

+
+
+
Axis
Plan, billing, and usage
+
Basis
direct: Those three meanings deliberately differ in the billing code. Billing status also differs from product-usable access, and enforcement-grade reads have stronger freshness requirements than display reads.
+
Rationale
A naïve get_plan would encode the wrong product semantics. A lens-based projection answers “what plan am I on?”, “who pays for this?”, and “why is this run blocked?” without exposing raw subscriptions, Stripe identifiers, invoices, or other members’ usage.
+
Downsides
Organizations and personal accounts need different redaction and management guidance. Live preflight may cost more than a replica-backed informational answer, so freshness must be explicit.
+
+
+ +
+
+
4

Idea 4. Evidence-routed activity investigator

+
Confidence · 88%Complexity · High
+
+

Description: Add investigate_activity(question, timeRange). It classifies the symptom and queries only the authorized evidence family: execution percentiles for latency, workflow logs for failures, organization audit events for “who changed this?”, and metadata-only subagent invocation records for delegation health. It returns a bounded timeline, saved filters or deep links, truncation/freshness notices, and facts clearly separated from hypotheses.

+
+
Axis
Activity, audit, and operational health
+
Basis
direct: Sim already has each source with separate authorization, filter, pagination, and payload semantics. external: Azure copilots use reviewable queries and deep links rather than becoming a parallel source of truth.
+
Rationale
This is the step-function move: the platform agent becomes a credible first responder for “what changed?” and “why did this fail?” while preserving the authority of existing observability surfaces.
+
Downsides
Joining evidence can create false causality. The first version should route and summarize rather than claim root cause, and enterprise audit access must stay independently gated.
+
+
+ +
+
+
5

Idea 5. Sensitive-read receipts

+
Confidence · 87%Complexity · Medium
+
+

Description: Treat read-only billing, audit, member, and execution-data access as sensitive. Every lookup emits a metadata-only receipt containing actor, scope, tool, authorization result, reason or query hash, timestamp, and trace linkage—never the returned private body. The prompt briefly discloses when private records were inspected and offers an inspectable activity link.

+
+
Axis
Activity, audit, and operational health
+
Basis
direct: Sim already records audit metadata and durable subagent-invocation metadata without conversational content. external: AWS and Google log agent-mediated or admin data reads, including dry-run permission checks.
+
Rationale
This is the trust foundation for every private-data tool. It makes agent access governable and answers the security question “what did the agent look at?” without storing sensitive outputs twice.
+
Downsides
Receipts create volume, retention, and user-experience questions. Query hashes and reason fields must avoid becoming a new content-leak channel.
+
+
+ +
+
+
6

Idea 6. Persona/access evaluation matrix

+
Confidence · 85%Complexity · Medium
+
+

Description: Evaluate the same platform questions as free/paid, member/admin/owner, billing-manager/non-manager, policy-restricted/unrestricted, and resource-access/no-access personas. Assert the answer, visible tools, denial wording, non-disclosure, citations, freshness labels, and sensitive-read receipts—not only whether a handler returns 200 or 403.

+
+
Axis
Access and resource visibility
+
Basis
external: Intercom tests Fin as real or synthetic users, plans, audiences, and brands while inspecting triggered behavior. direct: Sim’s access semantics span enough independent layers that isolated handler tests cannot validate what the model ultimately says.
+
Rationale
This converts permission awareness from an architectural claim into product behavior that can be regression-tested. It is especially valuable for “must not reveal” cases where a function-level authorization test can pass while the answer leaks context.
+
Downsides
Model-evaluation stability and fixture maintenance are real costs. Start with a small invariant suite around identity, capability denials, billing lenses, and audit authorization.
+
+
Useful invariantThe same question should produce different, correct answers for a member and an admin—without either answer mentioning what the other persona can see.
+
+
+ +
+

What did not survive intact

+

Rejection Summary

+ + + + + + + + + + + +
#IdeaReason rejected or merged
1Viewer-specific buildability mapThe proposed breadth outran current evidence; its supported capability categories were folded into Idea 2.
2Standalone run-capability preflightStrong but duplicate; merged into the exact-payer billing semantics in Idea 3.
3Usage-driver narrativeReduced public logs do not support detailed workflow attribution without crossing payer-sensitive boundaries.
4Standalone source hierarchyStrong but inseparable from ambient context design; merged into Idea 1.
5Intent-gated private-tool revealRequest-time permission filtering already exists; extra progressive revelation lacked demonstrated value.
6Standalone deep-link behaviorValuable response behavior rather than a product direction; merged into Idea 4.
7Repeated context, access, billing, and incident variantsFive independent lenses converged; duplicates were combined into the strongest source-aware forms above.
+
+ +
Composed by ce-ideate from the platform-agent enhancement prompt and the active Sim/Mothership worktrees.
+
+ + From 7199a89f34ff5964c58ff9eae7581dd9ece8a1b5 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:48:30 -0700 Subject: [PATCH 23/24] chore(copilot): resync the grep tool description from mothership contracts Mirrors the schema fix documenting the docs corpus grep mode. Co-Authored-By: Claude Fable 5 --- apps/sim/lib/copilot/generated/tool-catalog-v1.ts | 4 ++-- apps/sim/lib/copilot/generated/tool-schemas-v1.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 5e93bb2f17c..54b7cce13e7 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -3095,12 +3095,12 @@ export const Grep: ToolCatalogEntry = { path: { type: 'string', description: - "Optional scope. A prefix (e.g. 'workflows/', 'environment/', 'internal/') searches the VFS map under it. An exact single-file path under files/ or uploads/ (optionally with /content) searches that file's content only; folders and multi-file trees are rejected for content search.", + "Optional scope. A prefix (e.g. 'workflows/', 'environment/', 'internal/') searches the VFS map under it. An exact single-file path under files/ or uploads/ (optionally with /content) searches that file's content only; folders and multi-file trees there are rejected for content search. A docs/ page or directory searches live page text — a directory fans out to every docs page under it.", }, pattern: { type: 'string', description: - "Regex pattern to search for. Searches VFS map entries (workflow JSON, metadata, memories) by default; searches a single file's extracted text when path is one files/ or uploads/ file leaf.", + "Regex pattern to search for. Searches VFS map entries (workflow JSON, metadata, memories) by default; searches a single file's extracted text when path is one files/ or uploads/ file leaf, and live docs page text when path is a docs/ page or directory.", }, toolTitle: { type: 'string', diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index d1cee1640e7..09fed92ed9d 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -2988,12 +2988,12 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { path: { type: 'string', description: - "Optional scope. A prefix (e.g. 'workflows/', 'environment/', 'internal/') searches the VFS map under it. An exact single-file path under files/ or uploads/ (optionally with /content) searches that file's content only; folders and multi-file trees are rejected for content search.", + "Optional scope. A prefix (e.g. 'workflows/', 'environment/', 'internal/') searches the VFS map under it. An exact single-file path under files/ or uploads/ (optionally with /content) searches that file's content only; folders and multi-file trees there are rejected for content search. A docs/ page or directory searches live page text — a directory fans out to every docs page under it.", }, pattern: { type: 'string', description: - "Regex pattern to search for. Searches VFS map entries (workflow JSON, metadata, memories) by default; searches a single file's extracted text when path is one files/ or uploads/ file leaf.", + "Regex pattern to search for. Searches VFS map entries (workflow JSON, metadata, memories) by default; searches a single file's extracted text when path is one files/ or uploads/ file leaf, and live docs page text when path is a docs/ page or directory.", }, toolTitle: { type: 'string', From dac328442f59e2b89d5ee1213e00650790180bfa Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:57:49 -0700 Subject: [PATCH 24/24] improvement(copilot): stamp docs grep fan-out size on the grep span A directory-scoped docs grep now records copilot.vfs.grep.docs_page_count (pages fetched from the live site) on the active tool span, mirroring the new contract attribute. Co-Authored-By: Claude Fable 5 --- apps/sim/lib/copilot/docs/docs-corpus.ts | 3 +++ apps/sim/lib/copilot/generated/trace-attributes-v1.ts | 2 ++ 2 files changed, 5 insertions(+) diff --git a/apps/sim/lib/copilot/docs/docs-corpus.ts b/apps/sim/lib/copilot/docs/docs-corpus.ts index d02d07aa7ce..11462895bb8 100644 --- a/apps/sim/lib/copilot/docs/docs-corpus.ts +++ b/apps/sim/lib/copilot/docs/docs-corpus.ts @@ -1,9 +1,11 @@ +import { trace } from '@opentelemetry/api' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' import { backoffWithJitter } from '@sim/utils/retry' import { foldDocsIndexPath } from '@/lib/copilot/docs/docs-path' import { DOCS_MANIFEST } from '@/lib/copilot/generated/docs-manifest' +import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' import type { GrepCountEntry, GrepMatch, GrepOptions } from '@/lib/copilot/vfs/operations' import { glob as globPaths, grep, grepReadResult } from '@/lib/copilot/vfs/operations' import { mapWithConcurrency } from '@/lib/core/utils/concurrency' @@ -212,6 +214,7 @@ export async function grepDocs( } const dir = `${key}/` const pages = [...docsKeyView.keys()].filter((pageKey) => pageKey.startsWith(dir)) + trace.getActiveSpan()?.setAttribute(TraceAttr.CopilotVfsGrepDocsPageCount, pages.length) let unreachable = 0 const results = await mapWithConcurrency(pages, GREP_FETCH_CONCURRENCY, async (pageKey) => { // Once any page is unreachable the grep is going to fail — skip the diff --git a/apps/sim/lib/copilot/generated/trace-attributes-v1.ts b/apps/sim/lib/copilot/generated/trace-attributes-v1.ts index 6db17a6329d..2657746be49 100644 --- a/apps/sim/lib/copilot/generated/trace-attributes-v1.ts +++ b/apps/sim/lib/copilot/generated/trace-attributes-v1.ts @@ -278,6 +278,7 @@ export const TraceAttr = { CopilotVfsFileMediaType: 'copilot.vfs.file.media_type', CopilotVfsFileName: 'copilot.vfs.file.name', CopilotVfsFileSizeBytes: 'copilot.vfs.file.size_bytes', + CopilotVfsGrepDocsPageCount: 'copilot.vfs.grep.docs_page_count', CopilotVfsHasAlpha: 'copilot.vfs.has_alpha', CopilotVfsInputBytes: 'copilot.vfs.input.bytes', CopilotVfsInputHeight: 'copilot.vfs.input.height', @@ -922,6 +923,7 @@ export const TraceAttrValues: readonly TraceAttrValue[] = [ 'copilot.vfs.file.media_type', 'copilot.vfs.file.name', 'copilot.vfs.file.size_bytes', + 'copilot.vfs.grep.docs_page_count', 'copilot.vfs.has_alpha', 'copilot.vfs.input.bytes', 'copilot.vfs.input.height',