From edfe320143af186cbdef9c23c03450cc70e2031a 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/12] 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 | 59 ---------- apps/sim/lib/copilot/tools/server/router.ts | 4 +- apps/sim/lib/copilot/tools/tool-display.ts | 2 +- 7 files changed, 181 insertions(+), 65 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 610e79aa8ed..468f1de6b4e 100644 --- a/apps/sim/lib/copilot/chat/process-contents.ts +++ b/apps/sim/lib/copilot/chat/process-contents.ts @@ -204,12 +204,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 db8f9e73da8..00000000000 --- a/apps/sim/lib/copilot/tools/server/docs/search-documentation.ts +++ /dev/null @@ -1,59 +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 } from '@/lib/copilot/tools/server/base-tool' -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): 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', { query, topK }) - - const similarityThreshold = threshold ?? DEFAULT_DOCS_SIMILARITY_THRESHOLD - - 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) - .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 fbd1dcbdac2..f7abdf2ed2b 100644 --- a/apps/sim/lib/copilot/tools/server/router.ts +++ b/apps/sim/lib/copilot/tools/server/router.ts @@ -25,7 +25,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 { deleteFileServerTool } from '@/lib/copilot/tools/server/files/delete-file' @@ -164,7 +164,7 @@ const baseServerToolRegistry: Record = { [editWorkflowServerTool.name]: editWorkflowServerTool, [queryLogsServerTool.name]: queryLogsServerTool, [getJobLogsServerTool.name]: getJobLogsServerTool, - [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 5aee46abeb9..cdf83f2878a 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -465,7 +465,7 @@ const TOOL_TITLES: Record = { rename_workflow: 'Renaming workflow', restore_resource: 'Restoring resource', run_block: 'Running block', - search_documentation: 'Searching documentation', + search_docs: 'Searching docs', search_patterns: 'Searching patterns', set_block_enabled: 'Toggling block', set_environment_variables: 'Setting environment variables', From f5538e99dd032251298f419ceb882b74c07a5a16 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/12] 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 92bbeb6c572..91235e5ecd3 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 9b5ead254a1cbdf9eb9547366b213e45877c7780 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/12] 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 da571a7cd01..bd47d1b72ac 100644 --- a/apps/sim/lib/copilot/tools/tool-display.test.ts +++ b/apps/sim/lib/copilot/tools/tool-display.test.ts @@ -77,6 +77,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 cdf83f2878a..b06f3f6168d 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. @@ -652,6 +652,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 bb0d3ab20673e7bcd04e154685efa4db78518234 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:15:34 -0700 Subject: [PATCH 04/12] improvement(copilot): log chat request validation failures server-side A 400 from the chat POST's ChatMessageSchema parse returned the zod issues to the client but logged nothing, making "message disappears on send" undiagnosable from server logs. Co-Authored-By: Claude Fable 5 --- apps/sim/lib/copilot/chat/post.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/sim/lib/copilot/chat/post.ts b/apps/sim/lib/copilot/chat/post.ts index 73b30948c4f..10ce9266a39 100644 --- a/apps/sim/lib/copilot/chat/post.ts +++ b/apps/sim/lib/copilot/chat/post.ts @@ -1137,6 +1137,7 @@ export async function handleUnifiedChatPost(req: NextRequest) { otelRoot?.finish('error', error) if (isZodError(error)) { + logger.warn(`[${requestId}] Chat request failed validation`, { issues: error.issues }) return validationErrorResponse(error, 'Invalid request data') } From db3ac9e28f14f32f958c6459b3ac37745314afea Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:23:12 -0700 Subject: [PATCH 05/12] fix(copilot): stop empty-id resource chips from bricking a chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A workspace_resource tag emitted with id:"" resolved through `??` chains (which keep empty strings), got persisted onto the chat's resources, and then every send attached it and failed the chat request's id min-length validation — the message vanished on send, forever. Resolve chip ids by first non-empty candidate and drop id-less resources when building request attachments. Co-Authored-By: Claude Fable 5 --- .../message-content/components/special-tags/special-tags.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx index 60227897d94..78273a578fe 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx @@ -746,7 +746,7 @@ export function WorkspaceResourceDisplay({ return { type: toMothershipResourceType(data.type), - id: data.id ?? fileFromPath?.id ?? data.path ?? '', + id: [data.id, fileFromPath?.id, data.path].find((value) => value?.trim()) ?? '', title, ...(data.type === 'file' && data.path ? { path: data.path } : {}), } From 611d3c9498f2592f40862518fc58859b85768b82 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 06/12] 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 | 19 +- .../lib/copilot/generated/tool-schemas-v1.ts | 9 +- .../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, 1194 insertions(+), 278 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 1d1fa2d2f62..1deff98b180 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -150,6 +150,9 @@ jobs: - name: Verify agent stream capability docs are in sync run: bun run agent-stream-docs:check + - 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 468f1de6b4e..93e2996c444 100644 --- a/apps/sim/lib/copilot/chat/process-contents.ts +++ b/apps/sim/lib/copilot/chat/process-contents.ts @@ -30,7 +30,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 { ChatContext } from '@/stores/panel' type AgentContextType = @@ -69,7 +68,8 @@ const logger = createLogger('ProcessContents') 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 { @@ -202,21 +202,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 }) @@ -238,53 +226,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 77fa9e44091..69396f03f47 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -85,7 +85,7 @@ export interface ToolCatalogEntry { | 'scheduled_task' | 'scrape_page' | 'search' - | 'search_documentation' + | 'search_docs' | 'search_integration_tools' | 'search_knowledge_base' | 'search_library_docs' @@ -183,7 +183,7 @@ export interface ToolCatalogEntry { | 'scheduled_task' | 'scrape_page' | 'search' - | 'search_documentation' + | 'search_docs' | 'search_integration_tools' | 'search_knowledge_base' | 'search_library_docs' @@ -3659,16 +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: { + 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 (max 10)' }, + topK: { type: 'number', description: 'Number of results (default 10, max 25)' }, }, required: ['query'], }, @@ -4928,7 +4933,7 @@ export const TOOL_CATALOG: Record = { [ScheduledTask.id]: ScheduledTask, [ScrapePage.id]: ScrapePage, [Search.id]: Search, - [SearchDocumentation.id]: SearchDocumentation, + [SearchDocs.id]: SearchDocs, [SearchIntegrationTools.id]: SearchIntegrationTools, [SearchKnowledgeBase.id]: SearchKnowledgeBase, [SearchLibraryDocs.id]: SearchLibraryDocs, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 00646f08622..3a2fd5e1006 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -3444,17 +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 (max 10)', + description: 'Number of results (default 10, max 25)', }, }, required: ['query'], 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 91235e5ecd3..4260f17823a 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 fd4a515a8af..073d638d82e 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 { getOrMaterializeVFS } from '@/lib/copilot/vfs' import type { GrepCountEntry, GrepMatch } from '@/lib/copilot/vfs/operations' @@ -107,13 +115,16 @@ 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[] - 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/' } } @@ -157,8 +168,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, @@ -189,6 +200,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) let files = vfs.glob(pattern) @@ -248,6 +268,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 @@ -364,6 +399,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 bd47d1b72ac..da571a7cd01 100644 --- a/apps/sim/lib/copilot/tools/tool-display.test.ts +++ b/apps/sim/lib/copilot/tools/tool-display.test.ts @@ -77,19 +77,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 b06f3f6168d..cdf83f2878a 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. @@ -652,10 +652,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 1689f6e38d5..cdfd2b6bc58 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,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", "skills:sync": "bun run scripts/sync-skills.ts", 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 b8409d58cb9c85557f378b714faee292ba4ccd5a 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 07/12] 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 93e2996c444..562c8a8220e 100644 --- a/apps/sim/lib/copilot/chat/process-contents.ts +++ b/apps/sim/lib/copilot/chat/process-contents.ts @@ -42,7 +42,6 @@ type AgentContextType = | 'table' | 'file' | '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 073d638d82e..c38830edda8 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.ts @@ -87,6 +87,33 @@ function hasModelAttachment(result: unknown): boolean { ) } +/** + * 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 @@ -274,10 +301,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 f7abdf2ed2b..06b8dcaf2ca 100644 --- a/apps/sim/lib/copilot/tools/server/router.ts +++ b/apps/sim/lib/copilot/tools/server/router.ts @@ -165,6 +165,11 @@ const baseServerToolRegistry: Record = { [queryLogsServerTool.name]: queryLogsServerTool, [getJobLogsServerTool.name]: getJobLogsServerTool, [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 b8d12bb8b39d8701cf8becc331f44ee153bc7297 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 08/12] 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 06f3a3756f92b25ddb024d3b5982f9388a015c1a Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:54:53 -0700 Subject: [PATCH 09/12] fix(copilot): make the search_documentation alias actually reachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The registry alias added for the rename never fired: executeTool gates on isKnownTool(toolId) — membership in the generated TOOL_CATALOG — before it consults baseServerToolRegistry, so an id absent from the catalog is rejected as unknown and routed to the app-tool path instead. The alias was dead code, and worse, it advertised a mixed-version safety net that did not exist. The companion PR restores search_documentation to the contract as a hidden, deprecated entry, so the id now passes the catalog gate and reaches this alias. Marks it hidden in the UI set too — a call only ever arrives from an older Mothership build and renders as the search_docs it maps onto. Adds a test pinning every link in the dispatch chain (catalog membership, sim route, registered handler, hidden, param shape) — the assertion that would have caught this. Remove all of it once search_docs is live in prod on both sides. --- .../lib/copilot/generated/tool-catalog-v1.ts | 19 ++++++ .../lib/copilot/generated/tool-schemas-v1.ts | 17 +++++ .../lib/copilot/tools/client/hidden-tools.ts | 5 ++ .../server/docs/search-docs-alias.test.ts | 63 +++++++++++++++++++ 4 files changed, 104 insertions(+) create mode 100644 apps/sim/lib/copilot/tools/server/docs/search-docs-alias.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 69396f03f47..a70b01c8c30 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -86,6 +86,7 @@ export interface ToolCatalogEntry { | 'scrape_page' | 'search' | 'search_docs' + | 'search_documentation' | 'search_integration_tools' | 'search_knowledge_base' | 'search_library_docs' @@ -184,6 +185,7 @@ export interface ToolCatalogEntry { | 'scrape_page' | 'search' | 'search_docs' + | 'search_documentation' | 'search_integration_tools' | 'search_knowledge_base' | 'search_library_docs' @@ -3679,6 +3681,22 @@ export const SearchDocs: ToolCatalogEntry = { }, } +export const SearchDocumentation: ToolCatalogEntry = { + id: 'search_documentation', + name: 'search_documentation', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + query: { type: 'string', description: 'The search query' }, + topK: { type: 'number', description: 'Number of results (default 10, max 25)' }, + }, + required: ['query'], + }, + hidden: true, +} + export const SearchIntegrationTools: ToolCatalogEntry = { id: 'search_integration_tools', name: 'search_integration_tools', @@ -4934,6 +4952,7 @@ export const TOOL_CATALOG: Record = { [ScrapePage.id]: ScrapePage, [Search.id]: Search, [SearchDocs.id]: SearchDocs, + [SearchDocumentation.id]: SearchDocumentation, [SearchIntegrationTools.id]: SearchIntegrationTools, [SearchKnowledgeBase.id]: SearchKnowledgeBase, [SearchLibraryDocs.id]: SearchLibraryDocs, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 3a2fd5e1006..b65ebc227b5 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -3466,6 +3466,23 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + search_documentation: { + parameters: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'The search query', + }, + topK: { + type: 'number', + description: 'Number of results (default 10, max 25)', + }, + }, + required: ['query'], + }, + resultSchema: undefined, + }, search_integration_tools: { parameters: { properties: { diff --git a/apps/sim/lib/copilot/tools/client/hidden-tools.ts b/apps/sim/lib/copilot/tools/client/hidden-tools.ts index dbe06363d7a..6d0c0b8819d 100644 --- a/apps/sim/lib/copilot/tools/client/hidden-tools.ts +++ b/apps/sim/lib/copilot/tools/client/hidden-tools.ts @@ -2,11 +2,16 @@ // longer emitted now that internal skills autoload. // search_integration_tools is gateway plumbing: the discovery step is not a // user-meaningful action, only the resolved call_integration_tool row is. +// search_documentation is the deprecated pre-rename id of search_docs, kept +// resolvable for one release so a mixed-version deploy works. A call only ever +// arrives from an older Mothership build and renders as the search_docs it maps +// onto, so it needs no chip of its own. Remove with the rest of the shim. const HIDDEN_TOOL_NAMES = new Set([ 'load_agent_skill', 'load_custom_tool', 'load_integration_tool', 'search_integration_tools', + 'search_documentation', ]) export function isToolHiddenInUi(toolName: string | undefined): boolean { diff --git a/apps/sim/lib/copilot/tools/server/docs/search-docs-alias.test.ts b/apps/sim/lib/copilot/tools/server/docs/search-docs-alias.test.ts new file mode 100644 index 00000000000..eab62012888 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/docs/search-docs-alias.test.ts @@ -0,0 +1,63 @@ +/** + * @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' + +/** + * `search_documentation` is the pre-rename id of `search_docs`, kept alive for + * one release so a mixed-version deploy (Sim shipped, Mothership not yet) does + * not break docs lookup. + * + * A server-side registry alias alone is NOT enough: `executeTool` gates on + * `isKnownTool(toolId)` — catalog membership — before it ever consults the + * handler registry, so an id missing from the catalog is rejected as unknown + * and falls through to the app-tool path. These assertions pin every link in + * that chain; drop them together with the shim. + */ +describe('search_documentation transitional alias', () => { + it('is in the catalog, so dispatch does not reject it as unknown', () => { + expect(isKnownTool('search_documentation')).toBe(true) + }) + + it('routes to sim, so dispatch reaches the server tool registry', () => { + expect(isSimExecuted('search_documentation')).toBe(true) + }) + + it('has a registered server handler', () => { + expect(getRegisteredServerToolNames()).toContain('search_documentation') + }) + + it('is hidden, so it is never offered or rendered as its own action', () => { + expect(TOOL_CATALOG.search_documentation?.hidden).toBe(true) + expect(getHiddenToolNames().has('search_documentation')).toBe(true) + }) + + it('accepts the old parameter set — the old params are a subset of the new', () => { + const properties = (TOOL_CATALOG.search_documentation?.parameters as { properties?: object }) + ?.properties + expect(Object.keys(properties ?? {}).sort()).toEqual(['query', 'topK']) + }) +}) + +/** + * The failure this whole shim exists to prevent, stated generally: an id the + * Mothership can emit must resolve on the Sim side. Catalog membership is the + * gate, so every sim-routed catalog entry needs a handler behind it. + */ +describe('sim-routed catalog entries are dispatchable', () => { + it('every sim-routed, non-hidden catalog tool has a registered handler or a dedicated one', () => { + const registered = new Set(getRegisteredServerToolNames()) + const simRouted = Object.entries(TOOL_CATALOG) + .filter(([, entry]) => entry.route === 'sim') + .map(([name]) => name) + expect(simRouted.length).toBeGreaterThan(0) + // Not every sim-routed tool lives in baseServerToolRegistry — many have + // dedicated handlers registered in register-handlers.ts — so this asserts + // the alias specifically rather than the whole set. + expect(registered.has('search_documentation')).toBe(true) + }) +}) From e52205d80c06964543a488f41f77b626db7a56c7 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 10/12] 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 6a1a859a97548fc6bcc8a8caeab6034d34f5fc4d 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 11/12] 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 01c4d703770ad900dd36101b921c65abc733c894 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 12/12] 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 da571a7cd01..a73167dddc8 100644 --- a/apps/sim/lib/copilot/tools/tool-display.test.ts +++ b/apps/sim/lib/copilot/tools/tool-display.test.ts @@ -77,6 +77,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 cdf83f2878a..b06f3f6168d 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. @@ -652,6 +652,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'