Skip to content

Commit 6da58e5

Browse files
committed
improvement(search): cite public web results like connected sources in Search Assistant
1 parent 9e1f122 commit 6da58e5

10 files changed

Lines changed: 142 additions & 15 deletions

File tree

‎apps/docs/content/docs/search/ask-questions.mdx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@ Include the project, topic, or timeframe that matters. If you know where the ans
3232

3333
> Find our launch plan in Confluence. Summarize the scope and the launch date, with sources.
3434
35-
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.
35+
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.
36+
37+
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.
3638

3739
## Check the sources
3840

‎apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity-details.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ import type { SourceTagData } from '@/app/workspace/[workspaceId]/home/component
33
import { indexSourcesByUrl } from '@/app/workspace/[workspaceId]/home/components/message-content/sources-by-url'
44
import type { ToolCallData } from '@/app/workspace/[workspaceId]/home/types'
55

6-
/** Only searches with safe sources have displayable details. */
6+
/** Only searches with safe sources have displayable details, for connected sources and the web alike. */
77
export function getSearchActivitySources(tool: ToolCallData): SourceTagData[] | undefined {
8-
if (tool.toolName !== 'search_workspace') return undefined
8+
if (tool.toolName !== 'search_workspace' && tool.toolName !== 'web_search') return undefined
99
const evidence = collectRetrievalCitationEvidence([
1010
{ toolCall: { name: tool.toolName, status: tool.status, result: tool.result } },
1111
])

‎apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group.test.tsx‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,36 @@ describe('ToolActivityGroup search disclosure', () => {
129129
expect(container.querySelector('a[href="https://example.test/guide"]')).not.toBeNull()
130130
expect(container.textContent).not.toContain('No results')
131131
})
132+
133+
it('lists cited web results like connected-source results, and leaves uncited web research plain', () => {
134+
const webSearch = (citationId?: string): ToolCallData => ({
135+
id: 'web-1',
136+
toolName: 'web_search',
137+
displayTitle: 'Searching online',
138+
status: 'success',
139+
result: {
140+
success: true,
141+
output: {
142+
query: 'guide',
143+
results: [
144+
{
145+
title: 'Public guide',
146+
url: 'https://public.example.test/guide',
147+
...(citationId ? { citationId } : {}),
148+
},
149+
],
150+
},
151+
},
152+
})
153+
154+
render([webSearch('web:guide')], true)
155+
expect(disclosure().getAttribute('aria-expanded')).toBe('true')
156+
expect(container.querySelector('a[href="https://public.example.test/guide"]')).not.toBeNull()
157+
158+
render([webSearch()], true)
159+
expect(container.querySelector('[role="button"][aria-expanded]')).toBeNull()
160+
expect(container.querySelector('a[href="https://public.example.test/guide"]')).toBeNull()
161+
})
132162
})
133163

134164
describe('in-progress activity header', () => {

‎apps/sim/app/workspace/[workspaceId]/home/components/message-content/resolve-citations.test.ts‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,4 +126,38 @@ describe('evidence-linked citations', () => {
126126
resolveMessageCitations(blocks(), `<source>{"id":"${id}=="}</source>`, true).fallbackContent
127127
).toBe('')
128128
})
129+
it('resolves public web citations only from a successful cited web result, through persistence', () => {
130+
const webOutput = {
131+
query: 'sim cli install',
132+
results: [
133+
{
134+
title: 'Install the Sim CLI',
135+
url: 'https://docs.example.test/cli',
136+
citationId: 'web:page-handle',
137+
text: 'x'.repeat(4000),
138+
},
139+
],
140+
}
141+
const webBlocks = (status: string, success: boolean, result: unknown): ContentBlock[] => [
142+
{
143+
type: 'tool_call',
144+
toolCall: { id: 'web', name: 'web_search', status, result: { success, output: result } },
145+
},
146+
{ type: 'text', content: 'Install it <source>{"id":"web:page-handle"}</source>' },
147+
]
148+
const live = resolveMessageCitations(webBlocks('success', true, webOutput), '', true)
149+
expect(live.sources).toEqual([
150+
{ url: 'https://docs.example.test/cli', title: 'Install the Sim CLI' },
151+
])
152+
expect(live.blocks[1].content).toContain('https://docs.example.test/cli')
153+
const persisted = resolveMessageCitations(
154+
webBlocks('success', true, compactRetrievalCitations('web_search', webOutput)),
155+
'',
156+
true
157+
)
158+
expect(persisted.sources).toEqual(live.sources)
159+
expect(
160+
resolveMessageCitations(webBlocks('error', false, webOutput), '', true).blocks[1].content
161+
).toBe('Install it ')
162+
})
129163
})

‎apps/sim/lib/knowledge/mcp/server.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ export function createKnowledgeMcpServer(context: KnowledgeMcpContext): McpServe
240240
{
241241
title: 'Chat',
242242
description:
243-
'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.',
243+
'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.',
244244
inputSchema: chatSearchMcpSchema,
245245
annotations: {
246246
readOnlyHint: false,

‎apps/sim/lib/mothership/chat/citation-evidence.ts‎

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,38 @@ type RetrievalCitationSource = {
1414
snippet?: string
1515
}
1616

17+
/** Tools whose successful results supply sources: connected-source retrieval and public web research. */
18+
const WEB_TOOLS = new Set(['web_search', 'web_fetch'])
19+
const CITATION_TOOLS = new Set(['search_workspace', 'read_document', ...WEB_TOOLS])
20+
21+
export function isCitationTool(toolName: string): boolean {
22+
return CITATION_TOOLS.has(toolName)
23+
}
24+
25+
/** Web research only cites in Search Assistant; elsewhere its results carry no citation handles. */
26+
export function isWebResearchTool(toolName: string): boolean {
27+
return WEB_TOOLS.has(toolName)
28+
}
29+
30+
/**
31+
* A result in retrieval's citation fields. A web page names its address `url` and its heading
32+
* `title`, and leaves its site name and summary to the link preview; a compacted page already
33+
* carries the citation fields.
34+
*/
35+
export function toCitationResult(
36+
toolName: string,
37+
result: Record<string, unknown>
38+
): Record<string, unknown> {
39+
if (!isWebResearchTool(toolName) || typeof result.citationUrl === 'string') return result
40+
return {
41+
citationId: result.citationId,
42+
citationUrl: result.url,
43+
...(typeof result.title === 'string' && result.title.trim()
44+
? { documentName: result.title }
45+
: {}),
46+
}
47+
}
48+
1749
export function parseCitationRecord(value: unknown): Record<string, unknown> | null {
1850
if (typeof value === 'string') {
1951
try {
@@ -25,24 +57,20 @@ export function parseCitationRecord(value: unknown): Record<string, unknown> | n
2557
return toRecordOrNull(value)
2658
}
2759

28-
/** Only successful retrieval tool results may supply source destinations. */
60+
/** Only successful retrieval and web research results may supply source destinations. */
2961
export function collectRetrievalCitationEvidence(blocks: readonly RetrievalCitationBlock[]) {
3062
const evidence = new Map<string, RetrievalCitationSource>()
3163
for (const block of blocks) {
3264
const call = block.toolCall
33-
if (
34-
!call ||
35-
!['search_workspace', 'read_document'].includes(call.name) ||
36-
call.status !== 'success' ||
37-
!call.result?.success
38-
)
65+
if (!call || !isCitationTool(call.name) || call.status !== 'success' || !call.result?.success)
3966
continue
4067
const output = parseCitationRecord(call.result.output)
4168
if (!output || output.success === false) continue
4269
const data = parseCitationRecord(output.data) ?? output
4370
const results = Array.isArray(data.results) ? data.results : [data]
4471
for (const raw of results) {
45-
const result = parseCitationRecord(raw)
72+
const record = parseCitationRecord(raw)
73+
const result = record && toCitationResult(call.name, record)
4674
if (
4775
!result ||
4876
typeof result.citationId !== 'string' ||

‎apps/sim/lib/mothership/chat/retrieval-citations.test.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,28 @@ describe('persisted retrieval citations', () => {
4949
})
5050
).toEqual({ success: true, data: { results: [] } })
5151
})
52+
it('keeps cited web pages as bounded citations and drops uncited web research entirely', () => {
53+
const page = { title: 'Guide', url: 'https://docs.example.test/guide', text: 'y'.repeat(4000) }
54+
expect(
55+
compactRetrievalCitations('web_fetch', {
56+
results: [{ ...page, citationId: 'web:guide', links: ['https://drop.test'] }],
57+
notice: 'drop',
58+
})
59+
).toEqual({
60+
success: true,
61+
data: {
62+
results: [
63+
{
64+
citationId: 'web:guide',
65+
citationUrl: 'https://docs.example.test/guide',
66+
documentName: 'Guide',
67+
},
68+
],
69+
},
70+
})
71+
expect(
72+
compactRetrievalCitations('web_search', { query: 'guide', results: [page] })
73+
).toBeUndefined()
74+
expect(compactRetrievalCitations('web_search', { error: 'Search failed' })).toBeUndefined()
75+
})
5276
})

‎apps/sim/lib/mothership/chat/retrieval-citations.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
import { isRecordLike } from '@sim/utils/object'
2+
import {
3+
isCitationTool,
4+
isWebResearchTool,
5+
toCitationResult,
6+
} from '@/lib/mothership/chat/citation-evidence'
27

38
/**
49
* Keeps only bounded display evidence when large retrieval results leave the live stream,
510
* plus a partial-coverage marker so an empty timed-out search never reads as "No results".
611
*/
712
export function compactRetrievalCitations(toolName: string, raw: unknown): unknown {
8-
if (!['search_workspace', 'read_document'].includes(toolName)) return undefined
13+
if (!isCitationTool(toolName)) return undefined
914
let output: unknown = raw
1015
if (typeof output === 'string') {
1116
try {
@@ -18,7 +23,8 @@ export function compactRetrievalCitations(toolName: string, raw: unknown): unkno
1823
const data = isRecordLike(output.data) ? output.data : output
1924
const results = Array.isArray(data.results) ? data.results : [data]
2025
// A live document reference permits 4,000 characters, plus the document: citation prefix.
21-
const citations = results.slice(0, 50).flatMap((item) => {
26+
const citations = results.slice(0, 50).flatMap((raw) => {
27+
const item = isRecordLike(raw) ? toCitationResult(toolName, raw) : raw
2228
if (
2329
!isRecordLike(item) ||
2430
typeof item.citationId !== 'string' ||
@@ -46,6 +52,7 @@ export function compactRetrievalCitations(toolName: string, raw: unknown): unkno
4652
}
4753
return [citation]
4854
})
55+
if (citations.length === 0 && isWebResearchTool(toolName)) return undefined
4956
const partial = isRecordLike(data.retrieval) && data.retrieval.status === 'partial'
5057
return {
5158
success: true,

‎apps/sim/lib/slack-search/assistant-stream.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -498,7 +498,7 @@ describe('Slack Assistant delivery', () => {
498498
)
499499
it.each([
500500
['failed retrieval', 'search_workspace', false, 'https://example.com/document'],
501-
['unrelated tool', 'web_search', true, 'https://example.com/document'],
501+
['unrelated tool', 'call_integration_tool', true, 'https://example.com/document'],
502502
['non-web URL', 'read_document', true, 'javascript:alert(1)'],
503503
['embedded credentials', 'read_document', true, 'https://user:password@example.com/document'],
504504
])('does not link %s', async (_name, tool, success, url) => {

‎apps/sim/lib/slack-search/assistant-stream.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,8 @@ const FAILURE_BLOCKS: Record<string, unknown>[] = [
127127
const TOOL_PROGRESS_TITLES = new Map([
128128
['search_workspace', 'Searching documents…'],
129129
['read_document', 'Reading documents…'],
130+
['web_search', 'Searching the web…'],
131+
['web_fetch', 'Reading web pages…'],
130132
])
131133

132134
type ToolProgress = Extract<SlackStreamChunk, { type: 'task_update' }>

0 commit comments

Comments
 (0)