Skip to content

Commit 6ff4f32

Browse files
authored
improvement(search): cite public web results like connected sources in Search Assistant (#9647)
* improvement(search): cite public web results like connected sources in Search Assistant * fix(search): retarget unrelated-tool fixtures, type the web citation test, mark chat open-world
1 parent 49c4bd1 commit 6ff4f32

11 files changed

Lines changed: 150 additions & 18 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: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { normalizeMessage } from '@/lib/mothership/chat/persisted-message'
44
import { compactRetrievalCitations } from '@/lib/mothership/chat/retrieval-citations'
55
import { collectCitedMessageSources } from '@/app/workspace/[workspaceId]/home/components/message-content/message-sources'
66
import { resolveMessageCitations } from '@/app/workspace/[workspaceId]/home/components/message-content/resolve-citations'
7-
import type { ContentBlock } from '@/app/workspace/[workspaceId]/home/types'
7+
import type { ContentBlock, ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types'
88

99
const output = {
1010
success: true,
@@ -126,4 +126,42 @@ 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 = (
142+
status: ToolCallStatus,
143+
success: boolean,
144+
result: unknown
145+
): ContentBlock[] => [
146+
{
147+
type: 'tool_call',
148+
toolCall: { id: 'web', name: 'web_search', status, result: { success, output: result } },
149+
},
150+
{ type: 'text', content: 'Install it <source>{"id":"web:page-handle"}</source>' },
151+
]
152+
const live = resolveMessageCitations(webBlocks('success', true, webOutput), '', true)
153+
expect(live.sources).toEqual([
154+
{ url: 'https://docs.example.test/cli', title: 'Install the Sim CLI' },
155+
])
156+
expect(live.blocks[1].content).toContain('https://docs.example.test/cli')
157+
const persisted = resolveMessageCitations(
158+
webBlocks('success', true, compactRetrievalCitations('web_search', webOutput)),
159+
'',
160+
true
161+
)
162+
expect(persisted.sources).toEqual(live.sources)
163+
expect(
164+
resolveMessageCitations(webBlocks('error', false, webOutput), '', true).blocks[1].content
165+
).toBe('Install it ')
166+
})
129167
})

‎apps/sim/lib/knowledge/application/chat.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,7 @@ describe('Search Assistant citations', () => {
276276
it.each([
277277
tool({ data: citation }, { status: 'error' }),
278278
tool({ success: false, data: citation }),
279-
tool({ data: citation }, { name: 'web_search' }),
279+
tool({ data: citation }, { name: 'call_integration_tool' }),
280280
tool({ data: { ...citation, citationUrl: 'javascript:alert(1)' } }),
281281
tool({ data: { ...citation, citationUrl: 'https://user:password@docs.example' } }),
282282
])('does not cite failed, unrelated, or unsafe evidence', (result) => {

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -240,13 +240,13 @@ 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,
247247
destructiveHint: false,
248248
idempotentHint: false,
249-
openWorldHint: false,
249+
openWorldHint: true,
250250
},
251251
},
252252
async ({ query, ...filters }, extra) =>

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

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,39 @@ 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+
/** Whether a successful result of this tool may supply source destinations. */
22+
export function isCitationTool(toolName: string): boolean {
23+
return CITATION_TOOLS.has(toolName)
24+
}
25+
26+
/** Web research only cites in Search Assistant; elsewhere its results carry no citation handles. */
27+
export function isWebResearchTool(toolName: string): boolean {
28+
return WEB_TOOLS.has(toolName)
29+
}
30+
31+
/**
32+
* A result in retrieval's citation fields. A web page names its address `url` and its heading
33+
* `title`, and leaves its site name and summary to the link preview; a compacted page already
34+
* carries the citation fields.
35+
*/
36+
export function toCitationResult(
37+
toolName: string,
38+
result: Record<string, unknown>
39+
): Record<string, unknown> {
40+
if (!isWebResearchTool(toolName) || typeof result.citationUrl === 'string') return result
41+
return {
42+
citationId: result.citationId,
43+
citationUrl: result.url,
44+
...(typeof result.title === 'string' && result.title.trim()
45+
? { documentName: result.title }
46+
: {}),
47+
}
48+
}
49+
1750
export function parseCitationRecord(value: unknown): Record<string, unknown> | null {
1851
if (typeof value === 'string') {
1952
try {
@@ -25,24 +58,20 @@ export function parseCitationRecord(value: unknown): Record<string, unknown> | n
2558
return toRecordOrNull(value)
2659
}
2760

28-
/** Only successful retrieval tool results may supply source destinations. */
61+
/** Only successful retrieval and web research results may supply source destinations. */
2962
export function collectRetrievalCitationEvidence(blocks: readonly RetrievalCitationBlock[]) {
3063
const evidence = new Map<string, RetrievalCitationSource>()
3164
for (const block of blocks) {
3265
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-
)
66+
if (!call || !isCitationTool(call.name) || call.status !== 'success' || !call.result?.success)
3967
continue
4068
const output = parseCitationRecord(call.result.output)
4169
if (!output || output.success === false) continue
4270
const data = parseCitationRecord(output.data) ?? output
4371
const results = Array.isArray(data.results) ? data.results : [data]
4472
for (const raw of results) {
45-
const result = parseCitationRecord(raw)
73+
const record = parseCitationRecord(raw)
74+
const result = record && toCitationResult(call.name, record)
4675
if (
4776
!result ||
4877
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) => {

0 commit comments

Comments
 (0)