mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
feat: add a transcript view of an agent run's conversation
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3d667a6c9b
commit
a04ec30291
@@ -3,10 +3,14 @@
|
||||
import type { Snippet } from 'svelte'
|
||||
import { Badge } from '$lib/components/common'
|
||||
import GfmMarkdown from './GfmMarkdown.svelte'
|
||||
import AgentTranscript from './AgentTranscript.svelte'
|
||||
import { formatTokenCount, summarizeAgentResult, type AgentResult } from './aiAgentResult'
|
||||
|
||||
interface Props {
|
||||
result: AgentResult
|
||||
/** Answer or the conversation behind it; JSON is the viewer's own toggle. */
|
||||
view: 'answer' | 'transcript'
|
||||
workspaceId?: string
|
||||
/**
|
||||
* How to render an answer that is not text. An `output_schema` makes `output`
|
||||
* an object, and the right rendering for it is whatever the result viewer
|
||||
@@ -17,7 +21,7 @@
|
||||
structuredOutput: Snippet<[unknown]>
|
||||
}
|
||||
|
||||
let { result, structuredOutput }: Props = $props()
|
||||
let { result, view, workspaceId, structuredOutput }: Props = $props()
|
||||
|
||||
let summary = $derived(summarizeAgentResult(result))
|
||||
let textOutput = $derived(typeof result.output === 'string' ? result.output : undefined)
|
||||
@@ -48,7 +52,9 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if textOutput !== undefined}
|
||||
{#if view === 'transcript'}
|
||||
<AgentTranscript messages={result.messages} {workspaceId} />
|
||||
{:else if textOutput !== undefined}
|
||||
{#if textOutput === ''}
|
||||
<span class="text-tertiary text-xs">The agent returned no answer</span>
|
||||
{:else}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
<script lang="ts">
|
||||
import { ExternalLink, Globe } from 'lucide-svelte'
|
||||
import { JobService, type Job } from '$lib/gen'
|
||||
import { base } from '$lib/base'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { msToReadableTimeShort } from '$lib/utils'
|
||||
import ChatCollapsibleCard from './copilot/chat/ChatCollapsibleCard.svelte'
|
||||
import ToolContentDisplay from './copilot/chat/ToolContentDisplay.svelte'
|
||||
import WebSearchSourcesDisplay from './copilot/chat/WebSearchSourcesDisplay.svelte'
|
||||
import GfmMarkdown from './GfmMarkdown.svelte'
|
||||
import { buildTranscript, type TranscriptEntry } from './agentTranscript'
|
||||
import type { AgentMessage } from './aiAgentResult'
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity'
|
||||
|
||||
interface Props {
|
||||
messages: AgentMessage[]
|
||||
workspaceId?: string
|
||||
}
|
||||
|
||||
let { messages, workspaceId }: Props = $props()
|
||||
|
||||
let entries = $derived(buildTranscript(messages))
|
||||
|
||||
let expanded = new SvelteSet<number>()
|
||||
// A tool's own job holds what the envelope does not: its logs, how long it
|
||||
// took, and whether it succeeded. Fetched when a row is opened rather than
|
||||
// upfront, so a run with twenty calls does not issue twenty requests to draw
|
||||
// a list of names.
|
||||
let jobs = new SvelteMap<string, Job | 'loading' | 'failed'>()
|
||||
|
||||
async function loadJob(jobId: string) {
|
||||
if (jobs.has(jobId)) {
|
||||
return
|
||||
}
|
||||
jobs.set(jobId, 'loading')
|
||||
try {
|
||||
jobs.set(
|
||||
jobId,
|
||||
await JobService.getJob({ id: jobId, workspace: workspaceId ?? $workspaceStore! })
|
||||
)
|
||||
} catch {
|
||||
// A tool job can be gone (retention) or unreadable. The row still has its
|
||||
// arguments and result from the envelope, so only the extras are lost.
|
||||
jobs.set(jobId, 'failed')
|
||||
}
|
||||
}
|
||||
|
||||
function toggle(index: number, entry: TranscriptEntry) {
|
||||
if (expanded.delete(index)) {
|
||||
return
|
||||
}
|
||||
expanded.add(index)
|
||||
if (entry.kind === 'tool' && entry.jobId) {
|
||||
loadJob(entry.jobId)
|
||||
}
|
||||
}
|
||||
|
||||
function jobOf(jobId: string | undefined): Job | undefined {
|
||||
if (!jobId) return undefined
|
||||
const job = jobs.get(jobId)
|
||||
return typeof job === 'object' ? job : undefined
|
||||
}
|
||||
|
||||
function toolLabel(entry: Extract<TranscriptEntry, { kind: 'tool' }>): string {
|
||||
const job = jobOf(entry.jobId)
|
||||
const duration = job?.['duration_ms']
|
||||
return duration === undefined ? entry.name : `${entry.name} · ${msToReadableTimeShort(duration)}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col w-full min-w-0">
|
||||
{#each entries as entry, index (index)}
|
||||
{#if entry.kind === 'user'}
|
||||
<!-- The session chat's user bubble, so a run reads the same here as it does
|
||||
in a chat. Read-only: there is nothing to edit on a finished run. -->
|
||||
<div class="py-1 px-2 mt-4 mb-6 first:mt-0">
|
||||
<div
|
||||
class="text-xs px-3 py-2 w-fit max-w-[min(32rem,100%)] bg-surface-accent-selected text-accent rounded-lg whitespace-pre-wrap break-words"
|
||||
>
|
||||
{entry.content}
|
||||
</div>
|
||||
</div>
|
||||
{:else if entry.kind === 'assistant'}
|
||||
<div class="py-1 px-2 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 === 'system'}
|
||||
<div class="px-2 mb-1">
|
||||
<ChatCollapsibleCard
|
||||
label="System prompt"
|
||||
expanded={expanded.has(index)}
|
||||
onToggle={() => toggle(index, entry)}
|
||||
>
|
||||
<div class="whitespace-pre-wrap text-2xs text-primary">{entry.content}</div>
|
||||
</ChatCollapsibleCard>
|
||||
</div>
|
||||
{:else if entry.kind === 'search'}
|
||||
<div class="px-2 mb-1">
|
||||
<ChatCollapsibleCard
|
||||
label="Web search"
|
||||
expanded={expanded.has(index)}
|
||||
onToggle={() => toggle(index, entry)}
|
||||
>
|
||||
{#if entry.sources}
|
||||
<WebSearchSourcesDisplay sources={entry.sources} />
|
||||
{:else}
|
||||
<ToolContentDisplay title="Result" content={entry.content} />
|
||||
{/if}
|
||||
</ChatCollapsibleCard>
|
||||
</div>
|
||||
{:else}
|
||||
{@const job = jobOf(entry.jobId)}
|
||||
<div class="px-2 mb-1">
|
||||
<ChatCollapsibleCard
|
||||
label={toolLabel(entry)}
|
||||
expanded={expanded.has(index)}
|
||||
onToggle={() => toggle(index, entry)}
|
||||
contentClass="space-y-3"
|
||||
>
|
||||
{#if entry.args}
|
||||
<ToolContentDisplay title="Parameters" content={entry.args} toolName={entry.name} />
|
||||
{/if}
|
||||
{#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 entry.resourcePath}
|
||||
<div class="text-2xs text-hint flex items-center gap-1">
|
||||
<Globe size={11} />
|
||||
{entry.resourcePath}
|
||||
</div>
|
||||
{:else if entry.jobId}
|
||||
<a
|
||||
class="text-2xs text-accent inline-flex items-center gap-1 w-fit hover:underline"
|
||||
href="{base}/run/{entry.jobId}?workspace={workspaceId ?? $workspaceStore}"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<ExternalLink size={11} />
|
||||
Open job
|
||||
</a>
|
||||
{/if}
|
||||
</ChatCollapsibleCard>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
@@ -20,6 +20,7 @@
|
||||
Highlighter,
|
||||
ArrowDownFromLine,
|
||||
Bot,
|
||||
MessagesSquare,
|
||||
Database,
|
||||
Loader2
|
||||
} from 'lucide-svelte'
|
||||
@@ -162,6 +163,8 @@
|
||||
growVertical = false
|
||||
}: Props = $props()
|
||||
let s3FileDisplayRawMode = $state(false)
|
||||
/** Which half of an agent result is showing; JSON is `forceJson`, as for any kind. */
|
||||
let agentView: 'answer' | 'transcript' = $state('answer')
|
||||
|
||||
// Build the image/PDF source URL for an S3 object. When `appPath` is set
|
||||
// (deployed app view) the read is authorized on-behalf of the app author via
|
||||
@@ -810,9 +813,18 @@
|
||||
bind:clientHeight={resultHeaderHeight}
|
||||
>
|
||||
{#if !hideAsJson && !['json', 's3object'].includes(resultKind ?? '') && typeof result === 'object'}<ToggleButtonGroup
|
||||
selected={forceJson ? 'json' : resultKind?.startsWith('table-') ? 'table' : 'pretty'}
|
||||
selected={forceJson
|
||||
? 'json'
|
||||
: agentView === 'transcript' && resultKind === 'aiagent'
|
||||
? 'transcript'
|
||||
: resultKind?.startsWith('table-')
|
||||
? 'table'
|
||||
: 'pretty'}
|
||||
on:selected={(ev) => {
|
||||
forceJson = ev.detail === 'json'
|
||||
if (ev.detail === 'transcript' || ev.detail === 'pretty') {
|
||||
agentView = ev.detail === 'transcript' ? 'transcript' : 'answer'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
@@ -820,6 +832,13 @@
|
||||
<ToggleButton size="sm" value="table" label="Table" icon={Table2} {item} />
|
||||
{:else if resultKind === 'aiagent'}
|
||||
<ToggleButton size="sm" value="pretty" label="Answer" icon={Bot} {item} />
|
||||
<ToggleButton
|
||||
size="sm"
|
||||
value="transcript"
|
||||
label="Transcript"
|
||||
icon={MessagesSquare}
|
||||
{item}
|
||||
/>
|
||||
{:else}
|
||||
<ToggleButton size="sm" value="pretty" label="Pretty" icon={Highlighter} {item} />
|
||||
{/if}
|
||||
@@ -1259,7 +1278,7 @@
|
||||
{:else if !forceJson && resultKind === 'aiagent'}
|
||||
{@const agentResult = parseAgentResult(result)}
|
||||
{#if agentResult}
|
||||
<AgentResultDisplay result={agentResult}>
|
||||
<AgentResultDisplay result={agentResult} view={agentView} {workspaceId}>
|
||||
{#snippet structuredOutput(output)}
|
||||
<DisplayResult
|
||||
noControls
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildTranscript } from './agentTranscript'
|
||||
import type { AgentMessage } from './aiAgentResult'
|
||||
|
||||
// The worker splits one tool call across two messages: the assistant message
|
||||
// carries the arguments and no action tag, the `tool` message answering it
|
||||
// carries the result and the `tool_call` tag naming the job. Joining them on
|
||||
// `tool_call_id` is the whole contract, and reading it off the wrong message
|
||||
// yields a row with no parameters.
|
||||
const messages: AgentMessage[] = [
|
||||
{ role: 'system', content: 'You are an SRE assistant.' },
|
||||
{ role: 'user', content: 'Which region is broken?' },
|
||||
{
|
||||
role: 'assistant',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: { name: 'query_metrics', arguments: '{"w":"30m"}' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
tool_call_id: 'call_1',
|
||||
content: '{"eu-central-1":0.184}',
|
||||
agent_action: {
|
||||
type: 'tool_call',
|
||||
job_id: '0199-job',
|
||||
module_id: 'b',
|
||||
function_name: 'query_metrics'
|
||||
}
|
||||
},
|
||||
{ role: 'assistant', content: 'eu-central-1 is down.', agent_action: { type: 'message' } }
|
||||
]
|
||||
|
||||
describe('buildTranscript', () => {
|
||||
it('joins a tool call to the arguments on the message that requested it', () => {
|
||||
expect(buildTranscript(messages)).toEqual([
|
||||
{ kind: 'system', content: 'You are an SRE assistant.' },
|
||||
{ kind: 'user', content: 'Which region is broken?' },
|
||||
{
|
||||
kind: 'tool',
|
||||
name: 'query_metrics',
|
||||
args: '{"w":"30m"}',
|
||||
result: '{"eu-central-1":0.184}',
|
||||
jobId: '0199-job'
|
||||
},
|
||||
{ kind: 'assistant', content: 'eu-central-1 is down.', sources: undefined }
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps an MCP call, whose arguments live on the action itself', () => {
|
||||
const entries = buildTranscript([
|
||||
{
|
||||
role: 'tool',
|
||||
content: 'sunny',
|
||||
agent_action: {
|
||||
type: 'mcp_tool_call',
|
||||
call_id: 'c1',
|
||||
function_name: 'get_weather',
|
||||
resource_path: 'f/mcp/weather',
|
||||
arguments: { city: 'Paris' }
|
||||
}
|
||||
}
|
||||
])
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
kind: 'tool',
|
||||
name: 'get_weather',
|
||||
args: '{\n "city": "Paris"\n}',
|
||||
result: 'sunny',
|
||||
resourcePath: 'f/mcp/weather'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('carries web search citations onto the entry', () => {
|
||||
const entries = buildTranscript([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'Postgres 17 changed the default.',
|
||||
annotations: [{ url: 'https://postgresql.org/docs', title: 'Release notes' }],
|
||||
agent_action: { type: 'web_search' }
|
||||
}
|
||||
])
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
kind: 'search',
|
||||
content: 'Postgres 17 changed the default.',
|
||||
sources: [{ url: 'https://postgresql.org/docs', title: 'Release notes' }]
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
// Memory replays messages back without their tags, and an assistant message
|
||||
// that only asked for a tool has no text of its own.
|
||||
it('drops messages with nothing to show', () => {
|
||||
expect(
|
||||
buildTranscript([
|
||||
{ role: 'assistant', tool_calls: [{ id: 'c1', function: { name: 'x', arguments: '{}' } }] },
|
||||
{ role: 'assistant', content: '' }
|
||||
])
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { WebSearchSource } from './copilot/chat/shared'
|
||||
import type { AgentMessage } from './aiAgentResult'
|
||||
|
||||
/**
|
||||
* One readable turn of an agent run. Built from the envelope alone: the tool
|
||||
* arguments come from the assistant message that asked for the call, and the
|
||||
* result from the `tool` message that answered it, so a transcript renders
|
||||
* without waiting on any request. A tool's child job is enrichment (logs,
|
||||
* duration, whether it succeeded), not what makes the row.
|
||||
*/
|
||||
export type TranscriptEntry =
|
||||
| { kind: 'system'; content: string }
|
||||
| { kind: 'user'; content: string }
|
||||
| { kind: 'assistant'; content: string; sources?: WebSearchSource[] }
|
||||
| { kind: 'search'; content: string; sources?: WebSearchSource[] }
|
||||
| {
|
||||
kind: 'tool'
|
||||
name: string
|
||||
args?: string
|
||||
result: string
|
||||
/** Present for a flow-module tool, which runs as its own job. */
|
||||
jobId?: string
|
||||
/** An MCP tool runs in the worker, so it names its server instead. */
|
||||
resourcePath?: string
|
||||
}
|
||||
|
||||
/** `content` is a string for text messages and a part list once images are involved. */
|
||||
function contentText(content: unknown): string {
|
||||
if (typeof content === 'string') {
|
||||
return content
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((part) =>
|
||||
part && typeof part === 'object' && typeof (part as { text?: unknown }).text === 'string'
|
||||
? (part as { text: string }).text
|
||||
: ''
|
||||
)
|
||||
.join('')
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function sourcesOf(message: AgentMessage): WebSearchSource[] | undefined {
|
||||
const annotations = message.annotations
|
||||
if (!annotations?.length) {
|
||||
return undefined
|
||||
}
|
||||
const sources = annotations
|
||||
.filter((a) => typeof a?.url === 'string')
|
||||
.map((a) => ({ url: a.url, title: a.title }))
|
||||
return sources.length > 0 ? sources : undefined
|
||||
}
|
||||
|
||||
export function buildTranscript(messages: AgentMessage[]): TranscriptEntry[] {
|
||||
// The arguments live on the assistant message that requested the call, while
|
||||
// the action tag and the result live on the `tool` message answering it, so
|
||||
// the two are joined by `tool_call_id`.
|
||||
const argsByCallId = new Map<string, string>()
|
||||
for (const message of messages) {
|
||||
for (const call of message.tool_calls ?? []) {
|
||||
if (call.id && typeof call.function?.arguments === 'string') {
|
||||
argsByCallId.set(call.id, call.function.arguments)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const entries: TranscriptEntry[] = []
|
||||
for (const message of messages) {
|
||||
const action = message.agent_action
|
||||
if (action?.type === 'tool_call') {
|
||||
entries.push({
|
||||
kind: 'tool',
|
||||
name: action.function_name,
|
||||
args: message.tool_call_id ? argsByCallId.get(message.tool_call_id) : undefined,
|
||||
result: contentText(message.content),
|
||||
jobId: action.job_id
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (action?.type === 'mcp_tool_call') {
|
||||
entries.push({
|
||||
kind: 'tool',
|
||||
name: action.function_name,
|
||||
// An MCP call records its arguments on the action itself: it never
|
||||
// became a job, so there is nowhere else for them to live.
|
||||
args: action.arguments ? JSON.stringify(action.arguments, null, 2) : undefined,
|
||||
result: contentText(message.content),
|
||||
resourcePath: action.resource_path
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (action?.type === 'web_search') {
|
||||
entries.push({
|
||||
kind: 'search',
|
||||
content: contentText(message.content),
|
||||
sources: sourcesOf(message)
|
||||
})
|
||||
continue
|
||||
}
|
||||
// Messages with no action are the conversation itself: the prompt, what the
|
||||
// user asked, and anything loaded back from memory.
|
||||
const content = contentText(message.content)
|
||||
if (content === '') {
|
||||
continue
|
||||
}
|
||||
if (message.role === 'system') {
|
||||
entries.push({ kind: 'system', content })
|
||||
} else if (message.role === 'user') {
|
||||
entries.push({ kind: 'user', content })
|
||||
} else if (message.role === 'assistant') {
|
||||
entries.push({ kind: 'assistant', content, sources: sourcesOf(message) })
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
export function transcriptJobIds(entries: TranscriptEntry[]): string[] {
|
||||
return entries.flatMap((entry) => (entry.kind === 'tool' && entry.jobId ? [entry.jobId] : []))
|
||||
}
|
||||
@@ -85,6 +85,25 @@ export function parseAgentResult(result: unknown): AgentResult | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A run stopped by `max_iterations` fails, so it returns an error rather than an
|
||||
* envelope — but the worker attaches the conversation so far to it. That partial
|
||||
* transcript is the whole reason to look at a run that hit the cap.
|
||||
*/
|
||||
export function parseAgentErrorMessages(result: unknown): AgentMessage[] | undefined {
|
||||
if (!isRecord(result) || !isRecord(result.error)) {
|
||||
return undefined
|
||||
}
|
||||
const inner = result.error.result
|
||||
if (!isRecord(inner) || !Array.isArray(inner.messages)) {
|
||||
return undefined
|
||||
}
|
||||
if (inner.messages.length === 0 || !inner.messages.every(hasRole)) {
|
||||
return undefined
|
||||
}
|
||||
return inner.messages as AgentMessage[]
|
||||
}
|
||||
|
||||
export type AgentResultSummary = {
|
||||
toolCalls: number
|
||||
webSearches: number
|
||||
|
||||
Reference in New Issue
Block a user