diff --git a/apps/docs/content/docs/search/ask-questions.mdx b/apps/docs/content/docs/search/ask-questions.mdx index 507654e8635..b06a49aabc9 100644 --- a/apps/docs/content/docs/search/ask-questions.mdx +++ b/apps/docs/content/docs/search/ask-questions.mdx @@ -32,7 +32,9 @@ Include the project, topic, or timeframe that matters. If you know where the ans > Find our launch plan in Confluence. Summarize the scope and the launch date, with sources. -Assistant finds and reads information. You can also ask it to [draft content from the findings](/search/ask-questions#draft-from-your-sources); the draft stays in the conversation until you choose where to use it. +Assistant finds and reads information. When your sources don't have the answer and it's likely public, such as a product's download page or a published standard, Assistant can search the public web if your deployment enables web search. It checks your sources first, sends only public terms to the web, and cites web pages the same way as your documents. + +You can also ask it to [draft content from the findings](/search/ask-questions#draft-from-your-sources); the draft stays in the conversation until you choose where to use it. ## Check the sources diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity-details.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity-details.ts index 08bf945a4bc..d36fef03d42 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity-details.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity-details.ts @@ -3,9 +3,9 @@ import type { SourceTagData } from '@/app/workspace/[workspaceId]/home/component import { indexSourcesByUrl } from '@/app/workspace/[workspaceId]/home/components/message-content/sources-by-url' import type { ToolCallData } from '@/app/workspace/[workspaceId]/home/types' -/** Only searches with safe sources have displayable details. */ +/** Only searches with safe sources have displayable details, for connected sources and the web alike. */ export function getSearchActivitySources(tool: ToolCallData): SourceTagData[] | undefined { - if (tool.toolName !== 'search_workspace') return undefined + if (tool.toolName !== 'search_workspace' && tool.toolName !== 'web_search') return undefined const evidence = collectRetrievalCitationEvidence([ { toolCall: { name: tool.toolName, status: tool.status, result: tool.result } }, ]) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.test.tsx index 95037318ea2..40fd96e8520 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.test.tsx @@ -129,6 +129,36 @@ describe('ToolActivityGroup search disclosure', () => { expect(container.querySelector('a[href="https://example.test/guide"]')).not.toBeNull() expect(container.textContent).not.toContain('No results') }) + + it('lists cited web results like connected-source results, and leaves uncited web research plain', () => { + const webSearch = (citationId?: string): ToolCallData => ({ + id: 'web-1', + toolName: 'web_search', + displayTitle: 'Searching online', + status: 'success', + result: { + success: true, + output: { + query: 'guide', + results: [ + { + title: 'Public guide', + url: 'https://public.example.test/guide', + ...(citationId ? { citationId } : {}), + }, + ], + }, + }, + }) + + render([webSearch('web:guide')], true) + expect(disclosure().getAttribute('aria-expanded')).toBe('true') + expect(container.querySelector('a[href="https://public.example.test/guide"]')).not.toBeNull() + + render([webSearch()], true) + expect(container.querySelector('[role="button"][aria-expanded]')).toBeNull() + expect(container.querySelector('a[href="https://public.example.test/guide"]')).toBeNull() + }) }) describe('in-progress activity header', () => { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/resolve-citations.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/resolve-citations.test.ts index f5de894dd92..05bf07b7752 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/resolve-citations.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/resolve-citations.test.ts @@ -4,7 +4,7 @@ import { normalizeMessage } from '@/lib/mothership/chat/persisted-message' import { compactRetrievalCitations } from '@/lib/mothership/chat/retrieval-citations' import { collectCitedMessageSources } from '@/app/workspace/[workspaceId]/home/components/message-content/message-sources' import { resolveMessageCitations } from '@/app/workspace/[workspaceId]/home/components/message-content/resolve-citations' -import type { ContentBlock } from '@/app/workspace/[workspaceId]/home/types' +import type { ContentBlock, ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types' const output = { success: true, @@ -126,4 +126,42 @@ describe('evidence-linked citations', () => { resolveMessageCitations(blocks(), `{"id":"${id}=="}`, true).fallbackContent ).toBe('') }) + it('resolves public web citations only from a successful cited web result, through persistence', () => { + const webOutput = { + query: 'sim cli install', + results: [ + { + title: 'Install the Sim CLI', + url: 'https://docs.example.test/cli', + citationId: 'web:page-handle', + text: 'x'.repeat(4000), + }, + ], + } + const webBlocks = ( + status: ToolCallStatus, + success: boolean, + result: unknown + ): ContentBlock[] => [ + { + type: 'tool_call', + toolCall: { id: 'web', name: 'web_search', status, result: { success, output: result } }, + }, + { type: 'text', content: 'Install it {"id":"web:page-handle"}' }, + ] + const live = resolveMessageCitations(webBlocks('success', true, webOutput), '', true) + expect(live.sources).toEqual([ + { url: 'https://docs.example.test/cli', title: 'Install the Sim CLI' }, + ]) + expect(live.blocks[1].content).toContain('https://docs.example.test/cli') + const persisted = resolveMessageCitations( + webBlocks('success', true, compactRetrievalCitations('web_search', webOutput)), + '', + true + ) + expect(persisted.sources).toEqual(live.sources) + expect( + resolveMessageCitations(webBlocks('error', false, webOutput), '', true).blocks[1].content + ).toBe('Install it ') + }) }) diff --git a/apps/sim/lib/knowledge/application/chat.test.ts b/apps/sim/lib/knowledge/application/chat.test.ts index d79433af8a6..18bc7e4356e 100644 --- a/apps/sim/lib/knowledge/application/chat.test.ts +++ b/apps/sim/lib/knowledge/application/chat.test.ts @@ -276,7 +276,7 @@ describe('Search Assistant citations', () => { it.each([ tool({ data: citation }, { status: 'error' }), tool({ success: false, data: citation }), - tool({ data: citation }, { name: 'web_search' }), + tool({ data: citation }, { name: 'call_integration_tool' }), tool({ data: { ...citation, citationUrl: 'javascript:alert(1)' } }), tool({ data: { ...citation, citationUrl: 'https://user:password@docs.example' } }), ])('does not cite failed, unrelated, or unsafe evidence', (result) => { diff --git a/apps/sim/lib/knowledge/mcp/server.ts b/apps/sim/lib/knowledge/mcp/server.ts index e37d68aaf09..d7721f78117 100644 --- a/apps/sim/lib/knowledge/mcp/server.ts +++ b/apps/sim/lib/knowledge/mcp/server.ts @@ -240,13 +240,13 @@ export function createKnowledgeMcpServer(context: KnowledgeMcpContext): McpServe { title: 'Chat', description: - 'Ask the Sim Assistant to research a question in your accessible organization sources and write a cited answer. It runs its own multi-step search, so it usually takes 30 to 60 seconds and returns a synthesized answer rather than the sources. To answer a question yourself, use search and read_document, which return sources in seconds; use chat only when the user asks for the Sim Assistant’s answer. Starts a new private conversation. Use source, modifiedAfter, or documentIds to narrow the evidence. No web search or source changes.', + 'Ask the Sim Assistant to research a question in your accessible organization sources and write a cited answer. It runs its own multi-step search, so it usually takes 30 to 60 seconds and returns a synthesized answer rather than the sources. To answer a question yourself, use search and read_document, which return sources in seconds; use chat only when the user asks for the Sim Assistant’s answer. Starts a new private conversation. Use source, modifiedAfter, or documentIds to narrow the evidence. It searches the public web only when your sources lack the answer, and makes no source changes.', inputSchema: chatSearchMcpSchema, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, - openWorldHint: false, + openWorldHint: true, }, }, async ({ query, ...filters }, extra) => diff --git a/apps/sim/lib/mothership/chat/citation-evidence.ts b/apps/sim/lib/mothership/chat/citation-evidence.ts index 8cd1f0c72b0..e3e5d2593c2 100644 --- a/apps/sim/lib/mothership/chat/citation-evidence.ts +++ b/apps/sim/lib/mothership/chat/citation-evidence.ts @@ -14,6 +14,39 @@ type RetrievalCitationSource = { snippet?: string } +/** Tools whose successful results supply sources: connected-source retrieval and public web research. */ +const WEB_TOOLS = new Set(['web_search', 'web_fetch']) +const CITATION_TOOLS = new Set(['search_workspace', 'read_document', ...WEB_TOOLS]) + +/** Whether a successful result of this tool may supply source destinations. */ +export function isCitationTool(toolName: string): boolean { + return CITATION_TOOLS.has(toolName) +} + +/** Web research only cites in Search Assistant; elsewhere its results carry no citation handles. */ +export function isWebResearchTool(toolName: string): boolean { + return WEB_TOOLS.has(toolName) +} + +/** + * A result in retrieval's citation fields. A web page names its address `url` and its heading + * `title`, and leaves its site name and summary to the link preview; a compacted page already + * carries the citation fields. + */ +export function toCitationResult( + toolName: string, + result: Record +): Record { + if (!isWebResearchTool(toolName) || typeof result.citationUrl === 'string') return result + return { + citationId: result.citationId, + citationUrl: result.url, + ...(typeof result.title === 'string' && result.title.trim() + ? { documentName: result.title } + : {}), + } +} + export function parseCitationRecord(value: unknown): Record | null { if (typeof value === 'string') { try { @@ -25,24 +58,20 @@ export function parseCitationRecord(value: unknown): Record | n return toRecordOrNull(value) } -/** Only successful retrieval tool results may supply source destinations. */ +/** Only successful retrieval and web research results may supply source destinations. */ export function collectRetrievalCitationEvidence(blocks: readonly RetrievalCitationBlock[]) { const evidence = new Map() for (const block of blocks) { const call = block.toolCall - if ( - !call || - !['search_workspace', 'read_document'].includes(call.name) || - call.status !== 'success' || - !call.result?.success - ) + if (!call || !isCitationTool(call.name) || call.status !== 'success' || !call.result?.success) continue const output = parseCitationRecord(call.result.output) if (!output || output.success === false) continue const data = parseCitationRecord(output.data) ?? output const results = Array.isArray(data.results) ? data.results : [data] for (const raw of results) { - const result = parseCitationRecord(raw) + const record = parseCitationRecord(raw) + const result = record && toCitationResult(call.name, record) if ( !result || typeof result.citationId !== 'string' || diff --git a/apps/sim/lib/mothership/chat/retrieval-citations.test.ts b/apps/sim/lib/mothership/chat/retrieval-citations.test.ts index b40d815cb5d..beb3319e87d 100644 --- a/apps/sim/lib/mothership/chat/retrieval-citations.test.ts +++ b/apps/sim/lib/mothership/chat/retrieval-citations.test.ts @@ -49,4 +49,28 @@ describe('persisted retrieval citations', () => { }) ).toEqual({ success: true, data: { results: [] } }) }) + it('keeps cited web pages as bounded citations and drops uncited web research entirely', () => { + const page = { title: 'Guide', url: 'https://docs.example.test/guide', text: 'y'.repeat(4000) } + expect( + compactRetrievalCitations('web_fetch', { + results: [{ ...page, citationId: 'web:guide', links: ['https://drop.test'] }], + notice: 'drop', + }) + ).toEqual({ + success: true, + data: { + results: [ + { + citationId: 'web:guide', + citationUrl: 'https://docs.example.test/guide', + documentName: 'Guide', + }, + ], + }, + }) + expect( + compactRetrievalCitations('web_search', { query: 'guide', results: [page] }) + ).toBeUndefined() + expect(compactRetrievalCitations('web_search', { error: 'Search failed' })).toBeUndefined() + }) }) diff --git a/apps/sim/lib/mothership/chat/retrieval-citations.ts b/apps/sim/lib/mothership/chat/retrieval-citations.ts index 39b320b05ec..9a7c2c0a580 100644 --- a/apps/sim/lib/mothership/chat/retrieval-citations.ts +++ b/apps/sim/lib/mothership/chat/retrieval-citations.ts @@ -1,11 +1,16 @@ import { isRecordLike } from '@sim/utils/object' +import { + isCitationTool, + isWebResearchTool, + toCitationResult, +} from '@/lib/mothership/chat/citation-evidence' /** * Keeps only bounded display evidence when large retrieval results leave the live stream, * plus a partial-coverage marker so an empty timed-out search never reads as "No results". */ export function compactRetrievalCitations(toolName: string, raw: unknown): unknown { - if (!['search_workspace', 'read_document'].includes(toolName)) return undefined + if (!isCitationTool(toolName)) return undefined let output: unknown = raw if (typeof output === 'string') { try { @@ -18,7 +23,8 @@ export function compactRetrievalCitations(toolName: string, raw: unknown): unkno const data = isRecordLike(output.data) ? output.data : output const results = Array.isArray(data.results) ? data.results : [data] // A live document reference permits 4,000 characters, plus the document: citation prefix. - const citations = results.slice(0, 50).flatMap((item) => { + const citations = results.slice(0, 50).flatMap((raw) => { + const item = isRecordLike(raw) ? toCitationResult(toolName, raw) : raw if ( !isRecordLike(item) || typeof item.citationId !== 'string' || @@ -46,6 +52,7 @@ export function compactRetrievalCitations(toolName: string, raw: unknown): unkno } return [citation] }) + if (citations.length === 0 && isWebResearchTool(toolName)) return undefined const partial = isRecordLike(data.retrieval) && data.retrieval.status === 'partial' return { success: true, diff --git a/apps/sim/lib/slack-search/assistant-stream.test.ts b/apps/sim/lib/slack-search/assistant-stream.test.ts index 546ade764c4..fd2985708b4 100644 --- a/apps/sim/lib/slack-search/assistant-stream.test.ts +++ b/apps/sim/lib/slack-search/assistant-stream.test.ts @@ -498,7 +498,7 @@ describe('Slack Assistant delivery', () => { ) it.each([ ['failed retrieval', 'search_workspace', false, 'https://example.com/document'], - ['unrelated tool', 'web_search', true, 'https://example.com/document'], + ['unrelated tool', 'call_integration_tool', true, 'https://example.com/document'], ['non-web URL', 'read_document', true, 'javascript:alert(1)'], ['embedded credentials', 'read_document', true, 'https://user:password@example.com/document'], ])('does not link %s', async (_name, tool, success, url) => { diff --git a/apps/sim/lib/slack-search/assistant-stream.ts b/apps/sim/lib/slack-search/assistant-stream.ts index 14b9c09a108..6b6d3ea48a5 100644 --- a/apps/sim/lib/slack-search/assistant-stream.ts +++ b/apps/sim/lib/slack-search/assistant-stream.ts @@ -127,6 +127,8 @@ const FAILURE_BLOCKS: Record[] = [ const TOOL_PROGRESS_TITLES = new Map([ ['search_workspace', 'Searching documents…'], ['read_document', 'Reading documents…'], + ['web_search', 'Searching the web…'], + ['web_fetch', 'Reading web pages…'], ]) type ToolProgress = Extract