diff --git a/frontend/src/lib/components/AgentResultDisplay.svelte b/frontend/src/lib/components/AgentResultDisplay.svelte
new file mode 100644
index 0000000000..1e1adb3e4d
--- /dev/null
+++ b/frontend/src/lib/components/AgentResultDisplay.svelte
@@ -0,0 +1,62 @@
+
+
+
`, 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)}
Streaming result
-
+ {#if agentStream}
+
+
+ {:else}
+
+ {/if}
{:else if is_render_all}
@@ -793,6 +816,8 @@
{#snippet children({ item })}
{#if ['table-col', 'table-row', 'table-row-object'].includes(resultKind ?? '')}
+ {:else if resultKind === 'aiagent'}
+
{:else}
{/if}
@@ -1229,6 +1254,27 @@
{/each}
+ {:else if !forceJson && resultKind === 'aiagent'}
+ {@const agentResult = parseAgentResult(result)}
+ {#if agentResult}
+
+ {#snippet structuredOutput(output)}
+
+ {/snippet}
+
+ {/if}
{:else if !forceJson && resultKind === 'markdown'}
diff --git a/frontend/src/lib/components/aiAgentResult.test.ts b/frontend/src/lib/components/aiAgentResult.test.ts
new file mode 100644
index 0000000000..114caf21d2
--- /dev/null
+++ b/frontend/src/lib/components/aiAgentResult.test.ts
@@ -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')
+ })
+})
diff --git a/frontend/src/lib/components/aiAgentResult.ts b/frontend/src/lib/components/aiAgentResult.ts
new file mode 100644
index 0000000000..ac895938d4
--- /dev/null
+++ b/frontend/src/lib/components/aiAgentResult.ts
@@ -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[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 {
+ 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`
+}