From 46003b41a42ee51592e7b629ade928330764f663 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Wed, 9 Sep 2026 17:26:47 +0200 Subject: [PATCH] feat: render an AI agent result as its answer, not as raw JSON Co-Authored-By: Claude Opus 5 (1M context) --- .../lib/components/AgentResultDisplay.svelte | 62 ++++++ .../lib/components/AgentStreamDisplay.svelte | 35 +++ .../src/lib/components/DisplayResult.svelte | 50 ++++- .../src/lib/components/aiAgentResult.test.ts | 99 +++++++++ frontend/src/lib/components/aiAgentResult.ts | 201 ++++++++++++++++++ 5 files changed, 445 insertions(+), 2 deletions(-) create mode 100644 frontend/src/lib/components/AgentResultDisplay.svelte create mode 100644 frontend/src/lib/components/AgentStreamDisplay.svelte create mode 100644 frontend/src/lib/components/aiAgentResult.test.ts create mode 100644 frontend/src/lib/components/aiAgentResult.ts 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 @@ + + +
+
+ + {#if summary.toolCalls > 0} + + {summary.toolCalls} + {summary.toolCalls === 1 ? 'tool call' : 'tool calls'} + + {/if} + {#if summary.webSearches > 0} + + {summary.webSearches} + {summary.webSearches === 1 ? 'web search' : 'web searches'} + + {/if} + {#if summary.tokens !== undefined} + {formatTokenCount(summary.tokens)} tokens + {/if} + {#if summary.cachedTokens} + {formatTokenCount(summary.cachedTokens)} cached + {/if} +
+ + {#if textOutput !== undefined} + {#if textOutput === ''} + The agent returned no answer + {:else} +
+ +
+ {/if} + {:else} + {@render structuredOutput(result.output)} + {/if} +
diff --git a/frontend/src/lib/components/AgentStreamDisplay.svelte b/frontend/src/lib/components/AgentStreamDisplay.svelte new file mode 100644 index 0000000000..3a847450ea --- /dev/null +++ b/frontend/src/lib/components/AgentStreamDisplay.svelte @@ -0,0 +1,35 @@ + + +
+ {#if stream.tool} +
+ {#if stream.tool.running} + + {/if} + {stream.tool.name} +
+ {/if} + {#if stream.answer !== ''} +
+ +
+ {:else if stream.reasoning !== ''} + +
+ +
+ {/if} +
diff --git a/frontend/src/lib/components/DisplayResult.svelte b/frontend/src/lib/components/DisplayResult.svelte index 47bafa4d5a..ba406bfc11 100644 --- a/frontend/src/lib/components/DisplayResult.svelte +++ b/frontend/src/lib/components/DisplayResult.svelte @@ -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 @@ * `` and SVG ``, 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` +}