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:
hugocasa
2026-09-14 09:10:51 +02:00
co-authored by Claude Opus 5
parent 2a061f1fb0
commit d189e52842
5 changed files with 445 additions and 2 deletions
@@ -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`
}