Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/docs/content/docs/search/ask-questions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
const evidence = collectRetrievalCitationEvidence([
{ toolCall: { name: tool.toolName, status: tool.status, result: tool.result } },
])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -126,4 +126,42 @@ describe('evidence-linked citations', () => {
resolveMessageCitations(blocks(), `<source>{"id":"${id}=="}</source>`, 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 <source>{"id":"web:page-handle"}</source>' },
]
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 ')
})
})
2 changes: 1 addition & 1 deletion apps/sim/lib/knowledge/application/chat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
4 changes: 2 additions & 2 deletions apps/sim/lib/knowledge/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
inputSchema: chatSearchMcpSchema,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
openWorldHint: true,
},
},
async ({ query, ...filters }, extra) =>
Expand Down
45 changes: 37 additions & 8 deletions apps/sim/lib/mothership/chat/citation-evidence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
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<string, unknown>
): Record<string, unknown> {
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<string, unknown> | null {
if (typeof value === 'string') {
try {
Expand All @@ -25,24 +58,20 @@ export function parseCitationRecord(value: unknown): Record<string, unknown> | 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<string, RetrievalCitationSource>()
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' ||
Expand Down
24 changes: 24 additions & 0 deletions apps/sim/lib/mothership/chat/retrieval-citations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})
11 changes: 9 additions & 2 deletions apps/sim/lib/mothership/chat/retrieval-citations.ts
Original file line number Diff line number Diff line change
@@ -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
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
let output: unknown = raw
if (typeof output === 'string') {
try {
Expand All @@ -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' ||
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/slack-search/assistant-stream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/lib/slack-search/assistant-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ const FAILURE_BLOCKS: Record<string, unknown>[] = [
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<SlackStreamChunk, { type: 'task_update' }>
Expand Down