mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
feat: render an AI agent result as its answer, not as raw JSON
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2a061f1fb0
commit
d189e52842
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
import Markdown from 'svelte-exmarkdown'
|
||||
import { gfmPlugin } from 'svelte-exmarkdown/gfm'
|
||||
import { Bot } from 'lucide-svelte'
|
||||
import type { Snippet } from 'svelte'
|
||||
import { Badge } from '$lib/components/common'
|
||||
import { markdownProse } from './markdownProse'
|
||||
import { formatTokenCount, summarizeAgentResult, type AgentResult } from './aiAgentResult'
|
||||
|
||||
interface Props {
|
||||
result: AgentResult
|
||||
/**
|
||||
* 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
|
||||
* would do with that object on its own — a table for rows, the file viewer
|
||||
* for an S3 object. Passed in rather than imported so this component does not
|
||||
* have to reach back into the viewer that renders it.
|
||||
*/
|
||||
structuredOutput: Snippet<[unknown]>
|
||||
}
|
||||
|
||||
let { result, structuredOutput }: Props = $props()
|
||||
|
||||
let summary = $derived(summarizeAgentResult(result))
|
||||
let textOutput = $derived(typeof result.output === 'string' ? result.output : undefined)
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-2 w-full">
|
||||
<div class="flex items-center gap-2 flex-wrap text-xs">
|
||||
<Bot size={14} class="text-tertiary shrink-0" />
|
||||
{#if summary.toolCalls > 0}
|
||||
<Badge color="blue">
|
||||
{summary.toolCalls}
|
||||
{summary.toolCalls === 1 ? 'tool call' : 'tool calls'}
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if summary.webSearches > 0}
|
||||
<Badge color="blue">
|
||||
{summary.webSearches}
|
||||
{summary.webSearches === 1 ? 'web search' : 'web searches'}
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if summary.tokens !== undefined}
|
||||
<Badge color="gray">{formatTokenCount(summary.tokens)} tokens</Badge>
|
||||
{/if}
|
||||
{#if summary.cachedTokens}
|
||||
<Badge color="gray">{formatTokenCount(summary.cachedTokens)} cached</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if textOutput !== undefined}
|
||||
{#if textOutput === ''}
|
||||
<span class="text-tertiary text-xs">The agent returned no answer</span>
|
||||
{:else}
|
||||
<div class={markdownProse.sm}>
|
||||
<Markdown md={textOutput} plugins={[gfmPlugin()]} />
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
{@render structuredOutput(result.output)}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import Markdown from 'svelte-exmarkdown'
|
||||
import { gfmPlugin } from 'svelte-exmarkdown/gfm'
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
import { markdownProse } from './markdownProse'
|
||||
import type { AgentStream } from './aiAgentResult'
|
||||
|
||||
interface Props {
|
||||
stream: AgentStream
|
||||
}
|
||||
|
||||
let { stream }: Props = $props()
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-2 w-full">
|
||||
{#if stream.tool}
|
||||
<div class="flex items-center gap-2 text-secondary text-xs">
|
||||
{#if stream.tool.running}
|
||||
<Loader2 class="animate-spin shrink-0" size={14} />
|
||||
{/if}
|
||||
<span class="font-mono truncate">{stream.tool.name}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if stream.answer !== ''}
|
||||
<div class={markdownProse.sm}>
|
||||
<Markdown md={stream.answer} plugins={[gfmPlugin()]} />
|
||||
</div>
|
||||
{:else if stream.reasoning !== ''}
|
||||
<!-- Reasoning arrives before the answer, so on its own it means the model is
|
||||
still thinking rather than that this run has no answer. -->
|
||||
<div class="{markdownProse.xs} text-secondary">
|
||||
<Markdown md={stream.reasoning} plugins={[gfmPlugin()]} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -19,6 +19,7 @@
|
||||
Braces,
|
||||
Highlighter,
|
||||
ArrowDownFromLine,
|
||||
Bot,
|
||||
Database,
|
||||
Loader2
|
||||
} from 'lucide-svelte'
|
||||
@@ -54,6 +55,9 @@
|
||||
import DOMPurify from 'dompurify'
|
||||
import MarkupApprovalGate from './MarkupApprovalGate.svelte'
|
||||
import type { MarkupTrust } from './apps/markupTrust'
|
||||
import AgentResultDisplay from './AgentResultDisplay.svelte'
|
||||
import AgentStreamDisplay from './AgentStreamDisplay.svelte'
|
||||
import { parseAgentResult, parseAgentStream } from './aiAgentResult'
|
||||
|
||||
const TABLE_MAX_SIZE = 5000000
|
||||
const DISPLAY_MAX_SIZE = 100000
|
||||
@@ -85,6 +89,7 @@
|
||||
| 'map'
|
||||
| 'nondisplayable'
|
||||
| 'pdf'
|
||||
| 'aiagent'
|
||||
| undefined
|
||||
let resultKind: ResultKind = $state()
|
||||
/** Kinds whose renderer leaves the page: S3/ducklake previews fetch the file or
|
||||
@@ -96,7 +101,7 @@
|
||||
* `<img src>` and SVG `<image href>`, and `map` tiles are requests by
|
||||
* construction. Kinds absent here carry their bytes as `data:` and reach nothing.
|
||||
* Inert only on the public page, which promises to issue no requests. */
|
||||
const OFFLINE_INERT_KINDS: ResultKind[] = ['markdown', 'html', 'svg', 'map']
|
||||
const OFFLINE_INERT_KINDS: ResultKind[] = ['markdown', 'html', 'svg', 'map', 'aiagent']
|
||||
let length = $state(1)
|
||||
|
||||
let hasBigInt = $state(false)
|
||||
@@ -293,6 +298,17 @@
|
||||
return 'materialized'
|
||||
}
|
||||
|
||||
// Classified before the size caps below: an agent's answer stays small
|
||||
// however long its conversation grows, so a run with a big transcript
|
||||
// must not fall back to the JSON tree that hides the answer inside it.
|
||||
// `largeObject` is still set honestly, so switching to JSON gets the
|
||||
// same too-big handling as any other oversized result.
|
||||
if (parseAgentResult(result)) {
|
||||
is_render_all = false
|
||||
largeObject = roughSizeOfObject(result) > DISPLAY_MAX_SIZE
|
||||
return 'aiagent'
|
||||
}
|
||||
|
||||
is_render_all =
|
||||
keys.length == 1 && keys.includes('render_all') && Array.isArray(result['render_all'])
|
||||
|
||||
@@ -727,11 +743,18 @@
|
||||
{/if}
|
||||
|
||||
{#if result_stream && result == undefined}
|
||||
{@const agentStream = parseAgentStream(result_stream)}
|
||||
<div class="flex flex-col w-full gap-2">
|
||||
<div class="flex items-center gap-2 text-secondary text-xs">
|
||||
<Loader2 class="animate-spin" size={14} /> Streaming result
|
||||
</div>
|
||||
<ResultStreamDisplay {result_stream} />
|
||||
{#if agentStream}
|
||||
<!-- An agent streams one JSON event per line, so the raw stream is a wall of
|
||||
event objects rather than the answer being written. -->
|
||||
<AgentStreamDisplay stream={agentStream} />
|
||||
{:else}
|
||||
<ResultStreamDisplay {result_stream} />
|
||||
{/if}
|
||||
</div>
|
||||
{:else if is_render_all}
|
||||
<div class="flex flex-col w-full gap-2">
|
||||
@@ -793,6 +816,8 @@
|
||||
{#snippet children({ item })}
|
||||
{#if ['table-col', 'table-row', 'table-row-object'].includes(resultKind ?? '')}
|
||||
<ToggleButton size="sm" value="table" label="Table" icon={Table2} {item} />
|
||||
{:else if resultKind === 'aiagent'}
|
||||
<ToggleButton size="sm" value="pretty" label="Answer" icon={Bot} {item} />
|
||||
{:else}
|
||||
<ToggleButton size="sm" value="pretty" label="Pretty" icon={Highlighter} {item} />
|
||||
{/if}
|
||||
@@ -1229,6 +1254,27 @@
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else if !forceJson && resultKind === 'aiagent'}
|
||||
{@const agentResult = parseAgentResult(result)}
|
||||
{#if agentResult}
|
||||
<AgentResultDisplay result={agentResult}>
|
||||
{#snippet structuredOutput(output)}
|
||||
<DisplayResult
|
||||
noControls
|
||||
hideAsJson
|
||||
result={output}
|
||||
{markupTrust}
|
||||
{filename}
|
||||
{disableExpand}
|
||||
{jobId}
|
||||
{nodeId}
|
||||
{workspaceId}
|
||||
{appPath}
|
||||
growVertical
|
||||
/>
|
||||
{/snippet}
|
||||
</AgentResultDisplay>
|
||||
{/if}
|
||||
{:else if !forceJson && resultKind === 'markdown'}
|
||||
<div class={markdownProse.sm}>
|
||||
<Markdown md={result?.md ?? result?.markdown} />
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
formatTokenCount,
|
||||
parseAgentErrorMessages,
|
||||
parseAgentResult,
|
||||
parseAgentStream
|
||||
} from './aiAgentResult'
|
||||
|
||||
const envelope = {
|
||||
output: 'the answer',
|
||||
messages: [
|
||||
{ role: 'user', content: 'ask' },
|
||||
{ role: 'assistant', content: 'the answer', agent_action: { type: 'message' } }
|
||||
],
|
||||
usage: { input_tokens: 10, output_tokens: 2, total_tokens: 12 }
|
||||
}
|
||||
|
||||
describe('parseAgentResult', () => {
|
||||
it('accepts the envelope with and without its optional keys', () => {
|
||||
expect(parseAgentResult(envelope)?.output).toBe('the answer')
|
||||
expect(parseAgentResult({ output: 1, messages: [{ role: 'user' }] })?.messages).toHaveLength(1)
|
||||
})
|
||||
|
||||
// The signature is the only thing separating an agent result from any other
|
||||
// object, so each of these near-misses has to stay a miss.
|
||||
it.each([
|
||||
['a key outside the envelope', { ...envelope, retries: 2 }],
|
||||
['no output', { messages: envelope.messages }],
|
||||
['messages that are not a list', { output: 'a', messages: { role: 'user' } }],
|
||||
['no messages at all', { output: 'a', messages: [] }],
|
||||
['a message without a role', { output: 'a', messages: [{ content: 'ask' }] }],
|
||||
['an array', [envelope]],
|
||||
['a string', 'output'],
|
||||
['null', null]
|
||||
])('rejects %s', (_label, value) => {
|
||||
expect(parseAgentResult(value)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseAgentErrorMessages', () => {
|
||||
it('reads the partial transcript a max-iterations failure carries', () => {
|
||||
const messages = parseAgentErrorMessages({
|
||||
error: {
|
||||
name: 'ExecutionErr',
|
||||
message: 'AI agent reached max iterations (10)',
|
||||
result: { messages: [{ role: 'user', content: 'ask' }] }
|
||||
}
|
||||
})
|
||||
expect(messages).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('ignores an error that carries no transcript', () => {
|
||||
expect(
|
||||
parseAgentErrorMessages({ error: { name: 'ExecutionErr', message: 'boom' } })
|
||||
).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseAgentStream', () => {
|
||||
const events = [
|
||||
'{"type":"tool_call","call_id":"c1","function_name":"query_metrics"}',
|
||||
'{"type":"tool_result","call_id":"c1","function_name":"query_metrics","result":"{}","success":true}',
|
||||
'{"type":"reasoning_token_delta","content":"checking"}',
|
||||
'{"type":"token_delta","content":"eu-central-1"}',
|
||||
'{"type":"token_delta","content":" is down"}'
|
||||
].join('\n')
|
||||
|
||||
it('joins the token deltas into the answer so far', () => {
|
||||
const stream = parseAgentStream(events)
|
||||
expect(stream?.answer).toBe('eu-central-1 is down')
|
||||
expect(stream?.reasoning).toBe('checking')
|
||||
expect(stream?.tool).toEqual({ name: 'query_metrics', running: false, success: true })
|
||||
})
|
||||
|
||||
it('marks a tool still running', () => {
|
||||
expect(
|
||||
parseAgentStream('{"type":"tool_execution","call_id":"c1","function_name":"fetch"}')?.tool
|
||||
).toEqual({ name: 'fetch', running: true, success: undefined })
|
||||
})
|
||||
|
||||
// A poll can cut the last event in half, and any other job may stream something
|
||||
// that is not an agent's events at all.
|
||||
it('survives a truncated trailing line', () => {
|
||||
expect(parseAgentStream(`${events}\n{"type":"token_de`)?.answer).toBe('eu-central-1 is down')
|
||||
})
|
||||
|
||||
it('ignores a stream that carries no agent events', () => {
|
||||
expect(parseAgentStream('processing row 1\nprocessing row 2')).toBeUndefined()
|
||||
expect(parseAgentStream('{"level":"info","msg":"hello"}')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatTokenCount', () => {
|
||||
it('keeps small counts exact and rounds the rest', () => {
|
||||
expect(formatTokenCount(940)).toBe('940')
|
||||
expect(formatTokenCount(8933)).toBe('8.9k')
|
||||
expect(formatTokenCount(48211)).toBe('48k')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,201 @@
|
||||
import type { FlowStatusModule } from '$lib/gen'
|
||||
|
||||
/** The `agent_action` tag the worker puts on every message it records. */
|
||||
export type AgentAction = NonNullable<FlowStatusModule['agent_actions']>[number]
|
||||
|
||||
export type AgentTokenUsage = {
|
||||
input_tokens?: number
|
||||
output_tokens?: number
|
||||
total_tokens?: number
|
||||
cache_read_input_tokens?: number
|
||||
cache_write_input_tokens?: number
|
||||
}
|
||||
|
||||
export type AgentMessage = {
|
||||
role: string
|
||||
content?: unknown
|
||||
tool_calls?: Array<{
|
||||
id?: string
|
||||
type?: string
|
||||
function?: { name?: string; arguments?: string }
|
||||
}>
|
||||
tool_call_id?: string
|
||||
agent_action?: AgentAction
|
||||
annotations?: Array<{ url: string; title?: string; start_index?: number; end_index?: number }>
|
||||
}
|
||||
|
||||
/** The envelope every AI agent step returns, built by `AIAgentResult`. */
|
||||
export type AgentResult = {
|
||||
output: unknown
|
||||
messages: AgentMessage[]
|
||||
usage?: AgentTokenUsage
|
||||
wm_stream?: string
|
||||
}
|
||||
|
||||
/** Every key `AIAgentResult` can serialize. `usage` and `wm_stream` are skipped when empty. */
|
||||
const ENVELOPE_KEYS = ['output', 'messages', 'usage', 'wm_stream']
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function hasRole(message: unknown): boolean {
|
||||
return isRecord(message) && typeof message.role === 'string'
|
||||
}
|
||||
|
||||
/**
|
||||
* Recognise the envelope by its shape rather than by a marker key the worker
|
||||
* would have to add: sniffing works on runs that already completed, and the
|
||||
* envelope is also what a nested agent hands back, where an added key would
|
||||
* travel into the parent's conversation.
|
||||
*
|
||||
* The signature is deliberately closed — no key outside `ENVELOPE_KEYS`, and
|
||||
* every message carrying a `role` — so an ordinary result that happens to have
|
||||
* an `output` field cannot claim it.
|
||||
*/
|
||||
export function parseAgentResult(result: unknown): AgentResult | undefined {
|
||||
if (!isRecord(result)) {
|
||||
return undefined
|
||||
}
|
||||
const keys = Object.keys(result)
|
||||
if (!keys.every((key) => ENVELOPE_KEYS.includes(key))) {
|
||||
return undefined
|
||||
}
|
||||
if (!('output' in result) || !Array.isArray(result.messages)) {
|
||||
return undefined
|
||||
}
|
||||
// An agent always records at least the message it was asked, so an empty list
|
||||
// is someone else's result rather than a run that did nothing.
|
||||
if (result.messages.length === 0 || !result.messages.every(hasRole)) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
output: result.output,
|
||||
messages: result.messages as AgentMessage[],
|
||||
usage: isRecord(result.usage) ? (result.usage as AgentTokenUsage) : undefined,
|
||||
wm_stream: typeof result.wm_stream === 'string' ? result.wm_stream : 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
|
||||
tokens: number | undefined
|
||||
cachedTokens: number | undefined
|
||||
}
|
||||
|
||||
function actionType(message: AgentMessage): string | undefined {
|
||||
return message.agent_action?.type
|
||||
}
|
||||
|
||||
export function summarizeAgentResult(result: AgentResult): AgentResultSummary {
|
||||
let toolCalls = 0
|
||||
let webSearches = 0
|
||||
for (const message of result.messages) {
|
||||
const type = actionType(message)
|
||||
if (type === 'tool_call' || type === 'mcp_tool_call') {
|
||||
toolCalls++
|
||||
} else if (type === 'web_search') {
|
||||
webSearches++
|
||||
}
|
||||
}
|
||||
const usage = result.usage
|
||||
// `total_tokens` is what providers report when they report anything; fall back
|
||||
// to the parts so a provider that only sends the split still shows a count.
|
||||
const tokens =
|
||||
usage?.total_tokens ??
|
||||
(usage?.input_tokens !== undefined || usage?.output_tokens !== undefined
|
||||
? (usage?.input_tokens ?? 0) + (usage?.output_tokens ?? 0)
|
||||
: undefined)
|
||||
return {
|
||||
toolCalls,
|
||||
webSearches,
|
||||
tokens,
|
||||
cachedTokens: usage?.cache_read_input_tokens
|
||||
}
|
||||
}
|
||||
|
||||
export type AgentStream = {
|
||||
answer: string
|
||||
reasoning: string
|
||||
/** The most recent tool the run touched, so a stream that is mid-call says so. */
|
||||
tool?: { name: string; running: boolean; success?: boolean }
|
||||
}
|
||||
|
||||
const STREAM_EVENT_TYPES = [
|
||||
'token_delta',
|
||||
'reasoning_token_delta',
|
||||
'tool_call',
|
||||
'tool_call_arguments',
|
||||
'tool_execution',
|
||||
'tool_result'
|
||||
]
|
||||
|
||||
/**
|
||||
* `result_stream` carries one `StreamingEvent` per line while an agent runs.
|
||||
* Returns undefined for a stream that is not an agent's, so any other streaming
|
||||
* result keeps being shown verbatim.
|
||||
*/
|
||||
export function parseAgentStream(raw: string): AgentStream | undefined {
|
||||
let sawEvent = false
|
||||
const stream: AgentStream = { answer: '', reasoning: '' }
|
||||
for (const line of raw.split('\n')) {
|
||||
if (line.trim() === '') {
|
||||
continue
|
||||
}
|
||||
let event: unknown
|
||||
try {
|
||||
event = JSON.parse(line)
|
||||
} catch {
|
||||
// A trailing partial line is normal: the poll can cut an event in half.
|
||||
continue
|
||||
}
|
||||
if (!isRecord(event) || typeof event.type !== 'string') {
|
||||
continue
|
||||
}
|
||||
if (!STREAM_EVENT_TYPES.includes(event.type)) {
|
||||
continue
|
||||
}
|
||||
sawEvent = true
|
||||
if (event.type === 'token_delta' && typeof event.content === 'string') {
|
||||
stream.answer += event.content
|
||||
} else if (event.type === 'reasoning_token_delta' && typeof event.content === 'string') {
|
||||
stream.reasoning += event.content
|
||||
} else if (typeof event.function_name === 'string') {
|
||||
stream.tool = {
|
||||
name: event.function_name,
|
||||
running: event.type !== 'tool_result',
|
||||
success: event.type === 'tool_result' ? event.success === true : undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
return sawEvent ? stream : undefined
|
||||
}
|
||||
|
||||
/** Token counts run to five and six figures, where the exact digit is noise. */
|
||||
export function formatTokenCount(count: number): string {
|
||||
if (count < 1000) {
|
||||
return String(count)
|
||||
}
|
||||
const thousands = count / 1000
|
||||
return `${thousands < 10 ? thousands.toFixed(1) : Math.round(thousands)}k`
|
||||
}
|
||||
Reference in New Issue
Block a user