mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-30 16:17:25 +00:00
feat: render a tool's web search result as the session web search card (#11313)
* feat: render a tool's web search result as the session web search card
Any tool whose result is exactly `{ sources: [{ url, title? }], query? }`
now renders as the card the AI session shows for a provider-side web
search, in the agent step trace and in the flow chat. The provider-side
search itself gets the same card on both surfaces: its citations, which
the worker records on the assistant turn that follows, move onto the
search row.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: keep the trace tool label off the lazily fetched job
The web search card's label read the tool's job, which is only fetched
once the row is expanded, so the query suffix appeared only after the
row had been opened. The shape of the result decides the label; the
job's failure still decides whether the sources or the error show.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: keep unrenderable sources and tool hostnames out of the search card
A result whose urls the card cannot render — relative, or javascript: —
matched the shape and replaced the tool's own output with an empty list,
so the predicate now requires what the renderer accepts. A tool's sources
can name internal services, so they no longer resolve a favicon through
Google's service; provider-side search results still do. An absent
optional reaches JSON as null from a Python tool, which now reads as
absent rather than rejecting the whole result.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: name the host when a source carries a blank title
A source whose title is an empty string rendered as a link with no text
at all, the empty title also suppressing the hostname beside it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,6 @@
|
||||
import AgentTrace from './AgentTrace.svelte'
|
||||
import ChatCollapsibleCard from './copilot/chat/ChatCollapsibleCard.svelte'
|
||||
import LabeledDivider from './LabeledDivider.svelte'
|
||||
import WebSearchSourcesDisplay from './copilot/chat/WebSearchSourcesDisplay.svelte'
|
||||
import { buildAgentTrace, splitFinalAnswer } from './agentTrace'
|
||||
import { runPane } from './agentScroll'
|
||||
import { createBottomSticker } from './stickToBottom'
|
||||
@@ -32,9 +31,8 @@
|
||||
|
||||
// The turn that produced `output` is not a trace row: the output block below is
|
||||
// that same text, and printing it twice in one scroll reads as the agent having
|
||||
// answered itself. Its citations move down with it.
|
||||
let answer = $derived(splitFinalAnswer(buildAgentTrace(result.messages), result.output))
|
||||
let trace = $derived(answer.trace)
|
||||
// answered itself. Its citations stay in the trace, on the search that found them.
|
||||
let trace = $derived(splitFinalAnswer(buildAgentTrace(result.messages), result.output))
|
||||
let reasoning = $derived(result.reasoning?.trim())
|
||||
let reasoningExpanded = $state(false)
|
||||
|
||||
@@ -85,11 +83,6 @@
|
||||
{:else}
|
||||
{@render structuredOutput(result.output)}
|
||||
{/if}
|
||||
{#if answer.sources}
|
||||
<div class="mt-2">
|
||||
<WebSearchSourcesDisplay sources={answer.sources} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- What the run cost, as a footnote to what it produced. -->
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import ChatCollapsibleCard from './copilot/chat/ChatCollapsibleCard.svelte'
|
||||
import ToolContentDisplay from './copilot/chat/ToolContentDisplay.svelte'
|
||||
import WebSearchSourcesDisplay from './copilot/chat/WebSearchSourcesDisplay.svelte'
|
||||
import { webSearchResultOf, type WebSearchResult } from './copilot/chat/shared'
|
||||
import GfmMarkdown from './GfmMarkdown.svelte'
|
||||
import type { AgentTraceEntry } from './agentTrace'
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity'
|
||||
@@ -21,11 +22,14 @@
|
||||
let { entries, workspaceId }: Props = $props()
|
||||
|
||||
let expanded = new SvelteSet<number>()
|
||||
// A search opens on its sources, so for it the reader's toggle records a close.
|
||||
let collapsedSearches = new SvelteSet<number>()
|
||||
// Row state is keyed by position, which means nothing once the viewer is handed
|
||||
// another run: row 0 would stay open showing the previous run's job.
|
||||
$effect(() => {
|
||||
entries
|
||||
expanded.clear()
|
||||
collapsedSearches.clear()
|
||||
jobs.clear()
|
||||
})
|
||||
// A tool's own job holds what the envelope does not: its logs, how long it
|
||||
@@ -67,12 +71,18 @@
|
||||
return typeof job === 'object' ? job : undefined
|
||||
}
|
||||
|
||||
function toolLabel(entry: Extract<AgentTraceEntry, { kind: 'tool' }>): string {
|
||||
const job = jobOf(entry.jobId)
|
||||
const duration = job?.['duration_ms']
|
||||
return duration === undefined
|
||||
? entry.name
|
||||
: `${entry.name} · ${msToReadableTimeShort(duration)}`
|
||||
function toolLabel(
|
||||
entry: Extract<AgentTraceEntry, { kind: 'tool' }>,
|
||||
search: WebSearchResult | undefined
|
||||
): string {
|
||||
const duration = jobOf(entry.jobId)?.['duration_ms']
|
||||
return [
|
||||
entry.name,
|
||||
search?.query !== undefined ? `"${search.query}"` : undefined,
|
||||
duration !== undefined ? msToReadableTimeShort(duration) : undefined
|
||||
]
|
||||
.filter((part) => part !== undefined)
|
||||
.join(' · ')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -81,25 +91,29 @@
|
||||
{#if entry.kind === 'assistant'}
|
||||
<div class="min-w-0">
|
||||
<GfmMarkdown md={entry.content} noPadding />
|
||||
{#if entry.sources}
|
||||
<div class="mt-2">
|
||||
<WebSearchSourcesDisplay sources={entry.sources} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if entry.kind === 'search'}
|
||||
<!-- Nothing to reveal: the worker records only that a search ran, and its
|
||||
citations render under the assistant turn that follows. -->
|
||||
<!-- Open on its sources, as the session chat shows a search. A tool that returns the
|
||||
same shape stays closed like every other tool row: the agent may call it twenty
|
||||
times, and the reader came for the run, not for its bibliography. -->
|
||||
<ChatCollapsibleCard
|
||||
label="Web search"
|
||||
expanded={false}
|
||||
toggleable={false}
|
||||
onToggle={() => {}}
|
||||
/>
|
||||
label="Searched the web"
|
||||
expanded={Boolean(entry.sources) && !collapsedSearches.has(index)}
|
||||
toggleable={Boolean(entry.sources)}
|
||||
onToggle={() => {
|
||||
if (!collapsedSearches.delete(index)) collapsedSearches.add(index)
|
||||
}}
|
||||
>
|
||||
{#if entry.sources}
|
||||
<WebSearchSourcesDisplay sources={entry.sources} />
|
||||
{/if}
|
||||
</ChatCollapsibleCard>
|
||||
{:else}
|
||||
{@const job = jobOf(entry.jobId)}
|
||||
{@const failed = job?.type === 'CompletedJob' && !job.success}
|
||||
{@const search = webSearchResultOf(entry.result)}
|
||||
<ChatCollapsibleCard
|
||||
label={toolLabel(entry)}
|
||||
label={toolLabel(entry, search)}
|
||||
expanded={expanded.has(index)}
|
||||
onToggle={() => toggle(index, entry)}
|
||||
contentClass="space-y-3"
|
||||
@@ -110,11 +124,15 @@
|
||||
{#if job?.logs}
|
||||
<ToolContentDisplay title="Logs" content={job.logs} />
|
||||
{/if}
|
||||
<ToolContentDisplay
|
||||
title="Result"
|
||||
content={entry.result}
|
||||
error={job?.type === 'CompletedJob' && !job.success ? entry.result : undefined}
|
||||
/>
|
||||
{#if search && !failed}
|
||||
<WebSearchSourcesDisplay sources={search.sources} favicons={false} />
|
||||
{:else}
|
||||
<ToolContentDisplay
|
||||
title="Result"
|
||||
content={entry.result}
|
||||
error={failed ? entry.result : undefined}
|
||||
/>
|
||||
{/if}
|
||||
{#if entry.resourcePath}
|
||||
<div class="text-2xs text-hint flex items-center gap-1">
|
||||
<Globe size={11} />
|
||||
|
||||
@@ -45,7 +45,7 @@ describe('buildAgentTrace', () => {
|
||||
result: '{"eu-central-1":0.184}',
|
||||
jobId: '0199-job'
|
||||
},
|
||||
{ kind: 'assistant', content: 'eu-central-1 is down.', sources: undefined }
|
||||
{ kind: 'assistant', content: 'eu-central-1 is down.' }
|
||||
])
|
||||
})
|
||||
|
||||
@@ -76,8 +76,8 @@ describe('buildAgentTrace', () => {
|
||||
|
||||
// The worker splits a search the same way: a `tool` message tagged web_search
|
||||
// carrying a constant sentence, then the assistant turn that carries the
|
||||
// citations. The search row therefore has nothing of its own to show.
|
||||
it('records the search and puts its citations on the turn that follows', () => {
|
||||
// citations, which move onto the search so it shows what it found.
|
||||
it('puts the citations of the turn that follows on the search', () => {
|
||||
const entries = buildAgentTrace([
|
||||
{
|
||||
role: 'tool',
|
||||
@@ -92,12 +92,11 @@ describe('buildAgentTrace', () => {
|
||||
}
|
||||
])
|
||||
expect(entries).toEqual([
|
||||
{ kind: 'search' },
|
||||
{
|
||||
kind: 'assistant',
|
||||
content: 'Postgres 17 changed the default.',
|
||||
kind: 'search',
|
||||
sources: [{ url: 'https://postgresql.org/docs', title: 'Release notes' }]
|
||||
}
|
||||
},
|
||||
{ kind: 'assistant', content: 'Postgres 17 changed the default.' }
|
||||
])
|
||||
})
|
||||
|
||||
@@ -118,7 +117,7 @@ describe('buildAgentTrace', () => {
|
||||
})
|
||||
|
||||
describe('splitFinalAnswer', () => {
|
||||
it('moves the answering turn out of the trace, with its citations', () => {
|
||||
it('moves the answering turn out of the trace, leaving its citations on the search', () => {
|
||||
const entries = buildAgentTrace([
|
||||
{ role: 'tool', content: 'Used websearch tool', agent_action: { type: 'web_search' } },
|
||||
{
|
||||
@@ -128,10 +127,12 @@ describe('splitFinalAnswer', () => {
|
||||
agent_action: { type: 'message' }
|
||||
}
|
||||
])
|
||||
expect(splitFinalAnswer(entries, 'Postgres 17 changed the default.')).toEqual({
|
||||
trace: [{ kind: 'search' }],
|
||||
sources: [{ url: 'https://postgresql.org/docs', title: 'Release notes' }]
|
||||
})
|
||||
expect(splitFinalAnswer(entries, 'Postgres 17 changed the default.')).toEqual([
|
||||
{
|
||||
kind: 'search',
|
||||
sources: [{ url: 'https://postgresql.org/docs', title: 'Release notes' }]
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
// A run whose last turn returned a tool call and no text leaves its answer
|
||||
@@ -152,12 +153,12 @@ describe('splitFinalAnswer', () => {
|
||||
}
|
||||
}
|
||||
])
|
||||
expect(splitFinalAnswer(entries, 'Let me check.').trace).toEqual([entries[1]])
|
||||
expect(splitFinalAnswer(entries, 'Let me check.')).toEqual([entries[1]])
|
||||
})
|
||||
|
||||
it('leaves the trace whole when the output is not a turn of its own', () => {
|
||||
const entries = buildAgentTrace(messages)
|
||||
expect(splitFinalAnswer(entries, { rows: 3 })).toEqual({ trace: entries })
|
||||
expect(splitFinalAnswer(entries, { rows: 3 })).toEqual(entries)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -8,10 +8,10 @@ import type { AgentMessage } from './aiAgentResult'
|
||||
* are shown as inputs, while the trace is what the agent did with them.
|
||||
*/
|
||||
export type AgentTraceEntry =
|
||||
| { kind: 'assistant'; content: string; sources?: WebSearchSource[] }
|
||||
/** A search records only that one happened: the worker writes a constant
|
||||
* sentence, and the citations ride on the assistant turn that follows. */
|
||||
| { kind: 'search' }
|
||||
| { kind: 'assistant'; content: string }
|
||||
/** The worker records only that a search happened. Its citations ride on the assistant
|
||||
* turn that follows, and are moved here so the search shows what it found. */
|
||||
| { kind: 'search'; sources?: WebSearchSource[] }
|
||||
| {
|
||||
kind: 'tool'
|
||||
name: string
|
||||
@@ -99,8 +99,14 @@ export function buildAgentTrace(messages: AgentMessage[]): AgentTraceEntry[] {
|
||||
continue
|
||||
}
|
||||
const content = contentText(message.content)
|
||||
// The worker writes a provider call's search right before that call's answer, and
|
||||
// only a call that searched carries annotations.
|
||||
const previous = entries[entries.length - 1]
|
||||
if (message.role === 'assistant' && previous?.kind === 'search' && !previous.sources) {
|
||||
previous.sources = sourcesOf(message)
|
||||
}
|
||||
if (message.role === 'assistant' && content !== '') {
|
||||
entries.push({ kind: 'assistant', content, sources: sourcesOf(message) })
|
||||
entries.push({ kind: 'assistant', content })
|
||||
}
|
||||
}
|
||||
return entries
|
||||
@@ -108,27 +114,21 @@ export function buildAgentTrace(messages: AgentMessage[]): AgentTraceEntry[] {
|
||||
|
||||
/**
|
||||
* Separates the turn that produced the output from the rest of the trace, so the
|
||||
* answer is rendered once with the citations that belong to it. Found by content
|
||||
* and searched from the end: a run whose last turn returned a tool call leaves its
|
||||
* answer mid-trace, where inspecting only the final entry prints it twice.
|
||||
* answer is rendered once. Found by content and searched from the end: a run whose
|
||||
* last turn returned a tool call leaves its answer mid-trace, where inspecting only
|
||||
* the final entry prints it twice.
|
||||
*/
|
||||
export function splitFinalAnswer(
|
||||
entries: AgentTraceEntry[],
|
||||
output: unknown
|
||||
): { trace: AgentTraceEntry[]; sources?: WebSearchSource[] } {
|
||||
export function splitFinalAnswer(entries: AgentTraceEntry[], output: unknown): AgentTraceEntry[] {
|
||||
if (typeof output !== 'string') {
|
||||
// A schema-shaped output is rendered by the result viewer itself and matches
|
||||
// no turn, so the whole trace stands.
|
||||
return { trace: entries }
|
||||
return entries
|
||||
}
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
const entry = entries[i]
|
||||
if (entry.kind === 'assistant' && entry.content === output) {
|
||||
// The citations are an annotation on that turn, so moving the turn without
|
||||
// them would leave a run that shows a web search ran and no source for
|
||||
// what it answered.
|
||||
return { trace: [...entries.slice(0, i), ...entries.slice(i + 1)], sources: entry.sources }
|
||||
return [...entries.slice(0, i), ...entries.slice(i + 1)]
|
||||
}
|
||||
}
|
||||
return { trace: entries }
|
||||
return entries
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
import { getChatViewHost } from './chatViewHost'
|
||||
|
||||
const chatHost = getChatViewHost()
|
||||
import { isActiveUserQuestion, type ToolDisplayMessage } from './shared'
|
||||
import { isActiveUserQuestion, webSearchResultOf, type ToolDisplayMessage } from './shared'
|
||||
import ChatCollapsibleCard from './ChatCollapsibleCard.svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { slide } from 'svelte/transition'
|
||||
@@ -140,6 +140,18 @@
|
||||
message.previewCard && !message.isLoading && !message.error && !message.needsConfirmation
|
||||
)
|
||||
)
|
||||
|
||||
// A provider-side search sets `webSearchSources` and words its own header; any other tool
|
||||
// gets the same card by returning the web search result shape.
|
||||
const searchResult = $derived(
|
||||
message.webSearchSources || message.error ? undefined : webSearchResultOf(message.result)
|
||||
)
|
||||
const sources = $derived(message.webSearchSources ?? searchResult?.sources)
|
||||
const label = $derived(
|
||||
searchResult?.query !== undefined
|
||||
? `${message.content} · "${searchResult.query}"`
|
||||
: message.content
|
||||
)
|
||||
</script>
|
||||
|
||||
{#if activeUserQuestion}
|
||||
@@ -281,7 +293,7 @@
|
||||
weight alone: queued calls (waiting their turn behind the executing tool)
|
||||
are faded, the running one sweeps, a settled one is plain. -->
|
||||
<ChatCollapsibleCard
|
||||
label={message.content}
|
||||
{label}
|
||||
expanded={isExpanded}
|
||||
onToggle={() => (isExpanded = !isExpanded)}
|
||||
toggleable={detailsAvailable || message.isStreamingArguments === true}
|
||||
@@ -343,8 +355,8 @@
|
||||
|
||||
{#if visibleActions.length > 0}
|
||||
<ToolMessageActions actions={visibleActions} />
|
||||
{:else if message.webSearchSources?.length && !message.error}
|
||||
<WebSearchSourcesDisplay sources={message.webSearchSources} />
|
||||
{:else if sources?.length && !message.error}
|
||||
<WebSearchSourcesDisplay {sources} favicons={message.webSearchSources !== undefined} />
|
||||
{:else}
|
||||
<ToolContentDisplay
|
||||
title="Result"
|
||||
|
||||
@@ -1,31 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { Globe } from 'lucide-svelte'
|
||||
import { SvelteSet } from 'svelte/reactivity'
|
||||
import type { WebSearchSource } from './shared'
|
||||
import { isRenderableSourceUrl, type WebSearchSource } from './shared'
|
||||
import { isOfflineReplay } from '$lib/components/recording/offlineReplay.svelte'
|
||||
|
||||
interface Props {
|
||||
sources: WebSearchSource[]
|
||||
/** Whether a hostname may be sent to the favicon service. False for the sources a
|
||||
* workspace's own tool returned: those can name internal services, and disclosing
|
||||
* them to a third party is the tool author's call to make, not this card's. */
|
||||
favicons?: boolean
|
||||
}
|
||||
|
||||
let { sources }: Props = $props()
|
||||
let { sources, favicons = true }: Props = $props()
|
||||
|
||||
// The URLs come from the provider's response: only render absolute http(s)
|
||||
// ones — anything else (javascript:, data:, relative) must not become an
|
||||
// href. Also dedupes; providers can surface the same page several times.
|
||||
// The URLs come from the provider's response or from a tool's own result: only render
|
||||
// absolute http(s) ones — anything else (javascript:, data:, relative) must not become
|
||||
// an href. Also dedupes; providers can surface the same page several times.
|
||||
const uniqueSources = $derived(
|
||||
Array.from(
|
||||
new Map(
|
||||
sources
|
||||
.filter((s) => {
|
||||
try {
|
||||
return ['http:', 'https:'].includes(new URL(s.url).protocol)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
.map((s) => [s.url, s])
|
||||
).values()
|
||||
new Map(sources.filter((s) => isRenderableSourceUrl(s.url)).map((s) => [s.url, s])).values()
|
||||
)
|
||||
)
|
||||
|
||||
@@ -43,7 +37,7 @@
|
||||
// to issue none — a recording comes from an arbitrary origin, so its cited
|
||||
// hostnames must not leak from a viewer's browser either. Degrades to the same
|
||||
// Globe the blocked/failed case already uses.
|
||||
const noFavicons = $derived(isOfflineReplay())
|
||||
const noFavicons = $derived(!favicons || isOfflineReplay())
|
||||
|
||||
// Favicons come from Google's public favicon service, which discloses each
|
||||
// consulted hostname to a third party from the user's browser — an accepted
|
||||
@@ -62,6 +56,9 @@
|
||||
<div class="flex flex-col max-h-40 overflow-y-auto">
|
||||
{#each uniqueSources as source (source.url)}
|
||||
{@const hostname = hostnameOf(source.url)}
|
||||
<!-- A blank title is a title the row cannot show, so it names the host instead of
|
||||
rendering a link with no text. -->
|
||||
{@const title = source.title?.trim() || undefined}
|
||||
<a
|
||||
href={source.url}
|
||||
target="_blank"
|
||||
@@ -80,8 +77,8 @@
|
||||
onerror={() => failedFavicons.add(hostname)}
|
||||
/>
|
||||
{/if}
|
||||
<span class="text-2xs text-primary truncate">{source.title ?? hostname}</span>
|
||||
{#if source.title}
|
||||
<span class="text-2xs text-primary truncate">{title ?? hostname}</span>
|
||||
{#if title}
|
||||
<span class="text-2xs text-tertiary truncate shrink-0 max-w-32">{hostname}</span>
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
import type { DisplayMessage, ToolDisplayMessage } from './shared'
|
||||
import { openItemPreviewAction } from './shared'
|
||||
import { openItemPreviewAction, webSearchResultOf } from './shared'
|
||||
|
||||
vi.mock('monaco-editor', () => ({
|
||||
editor: {}
|
||||
@@ -1869,3 +1869,38 @@ describe('processToolCall confirmation hooks', () => {
|
||||
expect(tool.fn).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Any tool gets the web search card by returning this shape. The card renders urls and
|
||||
// titles only, so a result carrying anything more must keep its JSON pane.
|
||||
describe('webSearchResultOf', () => {
|
||||
it('reads the shape, from the value or its JSON text', () => {
|
||||
const result = { sources: [{ url: 'https://a.dev', title: 'A' }], query: 'a' }
|
||||
expect(webSearchResultOf(result)).toEqual(result)
|
||||
expect(webSearchResultOf(JSON.stringify(result))).toEqual(result)
|
||||
})
|
||||
|
||||
// A Python tool serializes an absent optional as null, and the card has nothing to tell
|
||||
// its author why an almost-right result fell back to JSON.
|
||||
it('reads null on an optional field as absent', () => {
|
||||
expect(
|
||||
webSearchResultOf({ sources: [{ url: 'https://a.dev', title: null }], query: null })
|
||||
).toEqual({
|
||||
sources: [{ url: 'https://a.dev', title: undefined }],
|
||||
query: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['an extra key', { sources: [{ url: 'https://a.dev' }], summary: 'x' }],
|
||||
['an extra source field', { sources: [{ url: 'https://a.dev', snippet: 'x' }] }],
|
||||
['a source without url', { sources: [{ title: 'A' }] }],
|
||||
// The card renders no relative or javascript: link, so a result whose urls it would
|
||||
// drop keeps its own JSON rather than showing an empty source list.
|
||||
['a relative url', { sources: [{ url: '/docs/pg17', title: 'PG 17' }] }],
|
||||
['a javascript: url', { sources: [{ url: 'javascript:alert(1)' }] }],
|
||||
['no sources', { sources: [] }],
|
||||
['a bare list of links', [{ url: 'https://a.dev' }]]
|
||||
])('rejects %s', (_, result) => {
|
||||
expect(webSearchResultOf(result)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -610,6 +610,77 @@ export type WebSearchSource = {
|
||||
title?: string
|
||||
}
|
||||
|
||||
/** The result shape any tool returns to have it rendered as a web search card. */
|
||||
export type WebSearchResult = {
|
||||
sources: WebSearchSource[]
|
||||
query?: string
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
/** A link the card can render: the source list drops everything else, so a result whose
|
||||
* urls are relative or `javascript:` would show as an empty list in place of its own output. */
|
||||
export function isRenderableSourceUrl(url: string): boolean {
|
||||
try {
|
||||
return ['http:', 'https:'].includes(new URL(url).protocol)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** An absent optional field reaches JSON as `null` from a Python tool and as nothing from a
|
||||
* TypeScript one, so both read as absent. */
|
||||
function isOptionalString(value: unknown): boolean {
|
||||
return value === null || typeof value === 'string'
|
||||
}
|
||||
|
||||
function isWebSearchSource(value: unknown): value is { url: string; title?: unknown } {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
typeof value.url === 'string' &&
|
||||
isRenderableSourceUrl(value.url) &&
|
||||
Object.entries(value).every(
|
||||
([key, v]) => key === 'url' || (key === 'title' && isOptionalString(v))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A tool result as a web search, or undefined when it is anything else. The keys must be
|
||||
* exactly `sources` and an optional `query`: the card renders only urls and titles, so a
|
||||
* result carrying more would lose it. Accepts the result as its JSON text too.
|
||||
*/
|
||||
export function webSearchResultOf(result: unknown): WebSearchResult | undefined {
|
||||
if (typeof result === 'string') {
|
||||
try {
|
||||
result = JSON.parse(result)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
if (!isRecord(result) || !Array.isArray(result.sources) || result.sources.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
const sources = result.sources
|
||||
if (
|
||||
!sources.every(isWebSearchSource) ||
|
||||
!Object.entries(result).every(
|
||||
([key, v]) => key === 'sources' || (key === 'query' && isOptionalString(v))
|
||||
)
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
sources: sources.map((source) => ({
|
||||
url: source.url,
|
||||
title: typeof source.title === 'string' ? source.title : undefined
|
||||
})),
|
||||
query: typeof result.query === 'string' ? result.query : undefined
|
||||
}
|
||||
}
|
||||
|
||||
export type ToolDisplayMessage = {
|
||||
role: 'tool'
|
||||
tool_call_id: string
|
||||
|
||||
@@ -3,7 +3,7 @@ import type {
|
||||
ChatSendRequestOptions,
|
||||
ChatViewHost
|
||||
} from '$lib/components/copilot/chat/chatViewHost'
|
||||
import type { DisplayMessage } from '$lib/components/copilot/chat/shared'
|
||||
import type { DisplayMessage, WebSearchResult } from '$lib/components/copilot/chat/shared'
|
||||
import type { AIAutonomyMode } from '$lib/components/copilot/chat/AIChatManager.svelte'
|
||||
import { isPlanCardTool } from '$lib/components/copilot/chat/planMode'
|
||||
import { AttachedFilesStore } from '$lib/components/copilot/chat/files/attachedFiles.svelte'
|
||||
@@ -125,6 +125,33 @@ function toolErrorText(result: unknown): string | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider-side search's row as the web search result shape, or undefined for any other
|
||||
* row. The worker words it like a tool named `websearch`, which an agent tool may also be
|
||||
* called; what tells them apart is that every agent tool's row stores its arguments, and the
|
||||
* search stores none, only the citations it produced.
|
||||
*/
|
||||
function builtInWebSearch(message: ChatMessage): WebSearchResult | undefined {
|
||||
const tool = message.tool
|
||||
if (tool?.name !== 'websearch' || tool.arguments !== undefined || message.pending) {
|
||||
return undefined
|
||||
}
|
||||
const citations = parseToolPayload(tool.result)
|
||||
if (citations === undefined) return { sources: [] }
|
||||
if (
|
||||
!Array.isArray(citations) ||
|
||||
!citations.every((c) => c && typeof c === 'object' && typeof c.url === 'string')
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
sources: citations.map((c: { url: string; title?: unknown }) => ({
|
||||
url: c.url,
|
||||
title: typeof c.title === 'string' ? c.title : undefined
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/** The name the worker gives the structured-output tool: suffixed when an agent tool already has it. */
|
||||
const STRUCTURED_OUTPUT_CALL = /^structured_output(_\d+)?$/
|
||||
|
||||
@@ -168,6 +195,25 @@ export function toDisplayMessages(
|
||||
]
|
||||
}
|
||||
case 'tool': {
|
||||
const search = builtInWebSearch(message)
|
||||
if (search) {
|
||||
const found = search.sources.length > 0
|
||||
// `autoCollapseDetails: false` opens it on its sources, as the session chat
|
||||
// shows a search. A tool that returns the same shape stays collapsed like
|
||||
// every other tool row, however many times the agent called it.
|
||||
return [
|
||||
{
|
||||
role: 'tool',
|
||||
tool_call_id: message.id,
|
||||
content: 'Searched the web',
|
||||
toolName: 'web_search',
|
||||
webSearchSources: found ? search.sources : undefined,
|
||||
showDetails: found,
|
||||
autoCollapseDetails: false,
|
||||
jobId: message.jobId
|
||||
}
|
||||
]
|
||||
}
|
||||
// The model's call and what the tool sent back, as the stream carried them or the
|
||||
// worker stored them on the row. A row stored without them shows its name and job.
|
||||
const toolName = message.tool?.name
|
||||
|
||||
@@ -125,6 +125,42 @@ describe('toDisplayMessages', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// The worker words the provider-side search like a tool named `websearch`, which an agent
|
||||
// tool may also be called. The search stores no arguments, and an agent tool always does.
|
||||
it('renders the provider-side search as a web search card, not an agent tool of that name', () => {
|
||||
const citations = JSON.stringify([
|
||||
{ start_index: 0, end_index: 4, url: 'https://postgresql.org/docs', title: 'Release notes' }
|
||||
])
|
||||
const [search, agentTool, notCitations] = toDisplayMessages([
|
||||
message({
|
||||
role: 'tool',
|
||||
content: 'Used websearch tool',
|
||||
tool: { name: 'websearch', status: 'success', result: citations }
|
||||
}),
|
||||
message({
|
||||
role: 'tool',
|
||||
content: 'Used websearch tool',
|
||||
tool: { name: 'websearch', status: 'success', arguments: '{}', result: citations }
|
||||
}),
|
||||
// The safety valve of a name-based rule: a row that is not the provider's search
|
||||
// keeps its result, whatever the tool is called.
|
||||
message({
|
||||
role: 'tool',
|
||||
content: 'Used websearch tool',
|
||||
tool: { name: 'websearch', status: 'success', result: '{"hits":3}' }
|
||||
})
|
||||
])
|
||||
expect(search).toMatchObject({
|
||||
content: 'Searched the web',
|
||||
webSearchSources: [{ url: 'https://postgresql.org/docs', title: 'Release notes' }],
|
||||
showDetails: true,
|
||||
autoCollapseDetails: false
|
||||
})
|
||||
expect(agentTool).toMatchObject({ content: 'Used websearch tool', toolName: 'websearch' })
|
||||
expect(agentTool).not.toHaveProperty('webSearchSources')
|
||||
expect(notCitations).toMatchObject({ toolName: 'websearch', result: { hits: 3 } })
|
||||
})
|
||||
|
||||
it('shows a call the turn finished without as an error, not as still running', () => {
|
||||
const display = toDisplayMessages([
|
||||
message({ role: 'user', content: 'hi' }),
|
||||
|
||||
Reference in New Issue
Block a user