mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
feat: show an agent run as one scroll ending in its output
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
38304f6653
commit
1261fd5f0c
@@ -3,15 +3,15 @@
|
||||
import { Badge } from '$lib/components/common'
|
||||
import GfmMarkdown from './GfmMarkdown.svelte'
|
||||
import AgentTrace from './AgentTrace.svelte'
|
||||
import { buildAgentTrace } from './agentTrace'
|
||||
import { scrollPaneToEnd } from './agentScroll'
|
||||
import { formatTokenCount, summarizeAgentResult, type AgentResult } from './aiAgentResult'
|
||||
|
||||
interface Props {
|
||||
result: AgentResult
|
||||
/** The answer, or what the agent did to get there; JSON is the viewer's own toggle. */
|
||||
view: 'answer' | 'trace'
|
||||
workspaceId?: string
|
||||
/**
|
||||
* How to render an answer that is not text. An `output_schema` makes `output`
|
||||
* How to render an output 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
|
||||
@@ -20,32 +20,53 @@
|
||||
structuredOutput: Snippet<[unknown]>
|
||||
}
|
||||
|
||||
let { result, view, workspaceId, structuredOutput }: Props = $props()
|
||||
let { result, workspaceId, structuredOutput }: Props = $props()
|
||||
|
||||
let summary = $derived(summarizeAgentResult(result))
|
||||
let textOutput = $derived(typeof result.output === 'string' ? result.output : undefined)
|
||||
|
||||
// The run's last message is what produced `output`, so it is dropped from the
|
||||
// trace: the output block below is that same text, and printing it twice in
|
||||
// one scroll reads as the agent having answered itself.
|
||||
let trace = $derived.by(() => {
|
||||
const entries = buildAgentTrace(result.messages)
|
||||
return entries.at(-1)?.kind === 'assistant' ? entries.slice(0, -1) : entries
|
||||
})
|
||||
|
||||
let anchor: HTMLElement | undefined = $state()
|
||||
$effect(() => {
|
||||
// Opening a run lands on its output rather than on how it got there.
|
||||
anchor
|
||||
scrollPaneToEnd(anchor)
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-2 w-full pt-1">
|
||||
{#if view === 'trace'}
|
||||
<AgentTrace messages={result.messages} {workspaceId} />
|
||||
{:else if textOutput !== undefined}
|
||||
{#if textOutput === ''}
|
||||
<span class="text-tertiary text-xs">The agent returned no answer</span>
|
||||
{:else}
|
||||
<!-- A model writes this answer, and what it writes is steerable by whatever
|
||||
reached its context — a user message, a tool's output. So it is
|
||||
untrusted input and goes through the shared sanitizing chain, which is
|
||||
also what makes it inert on the public replay page. -->
|
||||
<GfmMarkdown md={textOutput} noPadding />
|
||||
{/if}
|
||||
{:else}
|
||||
{@render structuredOutput(result.output)}
|
||||
<div class="flex flex-col w-full pt-1">
|
||||
{#if trace.length > 0}
|
||||
<AgentTrace entries={trace} {workspaceId} />
|
||||
{/if}
|
||||
|
||||
<!-- What the run cost sits under what it produced: it is the footnote to the
|
||||
answer, not the heading above it. -->
|
||||
<div class="flex items-center gap-2 flex-wrap text-xs">
|
||||
<div class={trace.length > 0 ? 'mt-4 pt-3 border-t border-border-light' : ''}>
|
||||
<span class="text-2xs text-hint">Output</span>
|
||||
<div class="mt-1">
|
||||
{#if textOutput !== undefined}
|
||||
{#if textOutput === ''}
|
||||
<span class="text-tertiary text-xs">The agent returned no answer</span>
|
||||
{:else}
|
||||
<!-- A model writes this, and what it writes is steerable by whatever
|
||||
reached its context — a user message, a tool's output. So it is
|
||||
untrusted input and goes through the shared sanitizing chain, which
|
||||
is also what makes it inert on the public replay page. -->
|
||||
<GfmMarkdown md={textOutput} noPadding />
|
||||
{/if}
|
||||
{:else}
|
||||
{@render structuredOutput(result.output)}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- What the run cost, as a footnote to what it produced. -->
|
||||
<div class="flex items-center gap-2 flex-wrap text-xs mt-3">
|
||||
{#if summary.toolCalls > 0}
|
||||
<Badge color="blue">
|
||||
{summary.toolCalls}
|
||||
@@ -65,4 +86,5 @@
|
||||
<Badge color="gray">{formatTokenCount(summary.cachedTokens)} cached</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
<div bind:this={anchor}></div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { CircleX, Loader2 } from 'lucide-svelte'
|
||||
import { untrack } from 'svelte'
|
||||
import ChatCollapsibleCard from './copilot/chat/ChatCollapsibleCard.svelte'
|
||||
import GfmMarkdown from './GfmMarkdown.svelte'
|
||||
import { scrollPaneToEnd } from './agentScroll'
|
||||
import {
|
||||
advanceAgentStream,
|
||||
emptyAgentStreamProgress,
|
||||
@@ -32,32 +33,48 @@
|
||||
})
|
||||
|
||||
let stream = $derived(progress.stream)
|
||||
|
||||
let anchor: HTMLElement | undefined = $state()
|
||||
$effect(() => {
|
||||
// Follow the text as it is written, the same way the finished run opens on
|
||||
// its output: both put what you came for at the bottom.
|
||||
stream.answer
|
||||
stream.reasoning
|
||||
stream.tools.length
|
||||
scrollPaneToEnd(anchor)
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-2 w-full">
|
||||
{#if stream.tool}
|
||||
<div
|
||||
class="flex items-center gap-2 text-xs {stream.tool.success === false
|
||||
? 'text-red-500'
|
||||
: 'text-secondary'}"
|
||||
>
|
||||
{#if stream.tool.running}
|
||||
<Loader2 class="animate-spin shrink-0" size={14} />
|
||||
{:else if stream.tool.success === false}
|
||||
<CircleX class="shrink-0" size={14} />
|
||||
<!-- Deliberately the same order the finished run uses — what it did, then what it
|
||||
produced — so the view does not rearrange itself when the result lands. -->
|
||||
<div class="flex flex-col w-full pt-1">
|
||||
{#each stream.tools as tool (tool.callId)}
|
||||
<ChatCollapsibleCard
|
||||
label={tool.name}
|
||||
expanded={false}
|
||||
toggleable={false}
|
||||
shimmer={tool.running}
|
||||
onToggle={() => {}}
|
||||
labelClass={tool.success === false ? 'text-red-500' : ''}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
<div class={stream.tools.length > 0 ? 'mt-4 pt-3 border-t border-border-light' : ''}>
|
||||
<span class="text-2xs text-hint">Output</span>
|
||||
<div class="mt-1">
|
||||
<!-- Same sanitizing chain as the finished output: a partial answer is
|
||||
written by the same model and is no more trusted for arriving in
|
||||
pieces. -->
|
||||
{#if stream.answer !== ''}
|
||||
<GfmMarkdown md={stream.answer} noPadding />
|
||||
{: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="text-secondary">
|
||||
<GfmMarkdown md={stream.reasoning} prose="xs" noPadding />
|
||||
</div>
|
||||
{/if}
|
||||
<span class="font-mono truncate">{stream.tool.name}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Same sanitizing chain as the finished answer: a partial answer is written by
|
||||
the same model and is no more trusted for arriving in pieces. -->
|
||||
{#if stream.answer !== ''}
|
||||
<GfmMarkdown md={stream.answer} noPadding />
|
||||
{: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="text-secondary">
|
||||
<GfmMarkdown md={stream.reasoning} prose="xs" noPadding />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div bind:this={anchor}></div>
|
||||
</div>
|
||||
|
||||
@@ -8,18 +8,15 @@
|
||||
import ToolContentDisplay from './copilot/chat/ToolContentDisplay.svelte'
|
||||
import WebSearchSourcesDisplay from './copilot/chat/WebSearchSourcesDisplay.svelte'
|
||||
import GfmMarkdown from './GfmMarkdown.svelte'
|
||||
import { buildAgentTrace, type AgentTraceEntry } from './agentTrace'
|
||||
import type { AgentMessage } from './aiAgentResult'
|
||||
import type { AgentTraceEntry } from './agentTrace'
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity'
|
||||
|
||||
interface Props {
|
||||
messages: AgentMessage[]
|
||||
entries: AgentTraceEntry[]
|
||||
workspaceId?: string
|
||||
}
|
||||
|
||||
let { messages, workspaceId }: Props = $props()
|
||||
|
||||
const entries = $derived(buildAgentTrace(messages))
|
||||
let { entries, workspaceId }: Props = $props()
|
||||
|
||||
let expanded = new SvelteSet<number>()
|
||||
// A tool's own job holds what the envelope does not: its logs, how long it
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
Highlighter,
|
||||
ArrowDownFromLine,
|
||||
Bot,
|
||||
ListTree,
|
||||
Database,
|
||||
Loader2
|
||||
} from 'lucide-svelte'
|
||||
@@ -165,8 +164,6 @@
|
||||
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' | 'trace' = $state('answer')
|
||||
/** What a max-iterations failure got through before it gave up, if this is one.
|
||||
* Empty for a run that failed before the worker tagged anything, and for one
|
||||
* that predates the tags reaching this payload at all — in which case the
|
||||
@@ -175,7 +172,7 @@
|
||||
const messages = parseAgentErrorMessages(result)
|
||||
if (!messages) return undefined
|
||||
const entries = buildAgentTrace(messages)
|
||||
return entries.length > 0 ? messages : undefined
|
||||
return entries.length > 0 ? entries : undefined
|
||||
})
|
||||
|
||||
// Build the image/PDF source URL for an S3 object. When `appPath` is set
|
||||
@@ -825,26 +822,16 @@
|
||||
bind:clientHeight={resultHeaderHeight}
|
||||
>
|
||||
{#if !hideAsJson && !['json', 's3object'].includes(resultKind ?? '') && typeof result === 'object'}<ToggleButtonGroup
|
||||
selected={forceJson
|
||||
? 'json'
|
||||
: agentView === 'trace' && resultKind === 'aiagent'
|
||||
? 'trace'
|
||||
: resultKind?.startsWith('table-')
|
||||
? 'table'
|
||||
: 'pretty'}
|
||||
selected={forceJson ? 'json' : resultKind?.startsWith('table-') ? 'table' : 'pretty'}
|
||||
on:selected={(ev) => {
|
||||
forceJson = ev.detail === 'json'
|
||||
if (ev.detail === 'trace' || ev.detail === 'pretty') {
|
||||
agentView = ev.detail === 'trace' ? 'trace' : 'answer'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#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} />
|
||||
<ToggleButton size="sm" value="trace" label="Trace" icon={ListTree} {item} />
|
||||
<ToggleButton size="sm" value="pretty" label="Run" icon={Bot} {item} />
|
||||
{:else}
|
||||
<ToggleButton size="sm" value="pretty" label="Pretty" icon={Highlighter} {item} />
|
||||
{/if}
|
||||
@@ -1032,7 +1019,7 @@
|
||||
rather than replacing it. -->
|
||||
<div class="flex flex-col gap-1 pt-4 w-full min-w-0">
|
||||
<span class="text-emphasis text-xs font-semibold">Trace</span>
|
||||
<AgentTrace messages={agentErrorTrace} {workspaceId} />
|
||||
<AgentTrace entries={agentErrorTrace} {workspaceId} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if !isTest && language === 'bun'}
|
||||
@@ -1294,7 +1281,7 @@
|
||||
{:else if !forceJson && resultKind === 'aiagent'}
|
||||
{@const agentResult = parseAgentResult(result)}
|
||||
{#if agentResult}
|
||||
<AgentResultDisplay result={agentResult} view={agentView} {workspaceId}>
|
||||
<AgentResultDisplay result={agentResult} {workspaceId}>
|
||||
{#snippet structuredOutput(output)}
|
||||
<DisplayResult
|
||||
noControls
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Scroll the pane an agent run is rendered in to its end.
|
||||
*
|
||||
* A run reads in the order it happened, so its output is the last thing in the
|
||||
* list — landing at the bottom is landing on the answer, and while it streams
|
||||
* that is also where the text is arriving.
|
||||
*
|
||||
* Deliberately the nearest *scrollable* ancestor rather than `scrollIntoView`:
|
||||
* the result viewer sits inside pages that scroll themselves, and yanking a
|
||||
* whole run page to the middle because a step returned an agent result would be
|
||||
* worse than not scrolling at all.
|
||||
*/
|
||||
export function scrollPaneToEnd(anchor: HTMLElement | undefined | null) {
|
||||
let node = anchor?.parentElement
|
||||
while (node && node !== document.body) {
|
||||
const overflowY = getComputedStyle(node).overflowY
|
||||
if ((overflowY === 'auto' || overflowY === 'scroll') && node.scrollHeight > node.clientHeight) {
|
||||
node.scrollTop = node.scrollHeight
|
||||
return
|
||||
}
|
||||
node = node.parentElement
|
||||
}
|
||||
}
|
||||
@@ -84,7 +84,9 @@ describe('agent stream', () => {
|
||||
const { stream } = advanceAgentStream(events, emptyAgentStreamProgress())
|
||||
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 })
|
||||
expect(stream.tools).toEqual([
|
||||
{ callId: 'c1', name: 'query_metrics', running: false, success: true }
|
||||
])
|
||||
})
|
||||
|
||||
// The stream only grows, so each poll must fold in the new lines and re-read
|
||||
@@ -107,13 +109,18 @@ describe('agent stream', () => {
|
||||
it('marks a tool still running, and a failed one', () => {
|
||||
const started = '{"type":"tool_execution","call_id":"c1","function_name":"fetch"}\n'
|
||||
const running = advanceAgentStream(started, emptyAgentStreamProgress())
|
||||
expect(running.stream.tool).toEqual({ name: 'fetch', running: true, success: undefined })
|
||||
expect(running.stream.tools).toEqual([
|
||||
{ callId: 'c1', name: 'fetch', running: true, success: undefined }
|
||||
])
|
||||
const failed = advanceAgentStream(
|
||||
started +
|
||||
'{"type":"tool_result","call_id":"c1","function_name":"fetch","result":"boom","success":false}\n',
|
||||
running
|
||||
)
|
||||
expect(failed.stream.tool).toEqual({ name: 'fetch', running: false, success: false })
|
||||
// One row for the call, not one per event about it.
|
||||
expect(failed.stream.tools).toEqual([
|
||||
{ callId: 'c1', name: 'fetch', running: false, success: false }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -187,18 +187,21 @@ export function summarizeAgentResult(result: AgentResult): AgentResultSummary {
|
||||
}
|
||||
}
|
||||
|
||||
export type AgentStreamTool = { callId: string; name: string; running: boolean; success?: boolean }
|
||||
|
||||
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 }
|
||||
/** Every tool the run has touched, in order, so a stream shows the same rows
|
||||
* the finished trace will. */
|
||||
tools: AgentStreamTool[]
|
||||
}
|
||||
|
||||
/** How much of the stream has been folded in, so the next poll starts there. */
|
||||
export type AgentStreamProgress = { consumed: number; stream: AgentStream }
|
||||
|
||||
export function emptyAgentStreamProgress(): AgentStreamProgress {
|
||||
return { consumed: 0, stream: { answer: '', reasoning: '' } }
|
||||
return { consumed: 0, stream: { answer: '', reasoning: '', tools: [] } }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -273,7 +276,7 @@ export function advanceAgentStream(
|
||||
if (complete <= previous.consumed) {
|
||||
return previous
|
||||
}
|
||||
const stream: AgentStream = { ...previous.stream }
|
||||
const stream: AgentStream = { ...previous.stream, tools: [...previous.stream.tools] }
|
||||
for (const line of raw.slice(previous.consumed, complete).split('\n')) {
|
||||
if (line.trim() === '') {
|
||||
continue
|
||||
@@ -295,10 +298,21 @@ export function advanceAgentStream(
|
||||
stream.answer = ''
|
||||
stream.reasoning = ''
|
||||
}
|
||||
stream.tool = {
|
||||
name: event.function_name,
|
||||
running: event.type !== 'tool_result',
|
||||
success: event.type === 'tool_result' ? event.success === true : undefined
|
||||
// The same call is announced, then argued, then executed, then answered.
|
||||
// Keyed on `call_id` so those four events are one row rather than four.
|
||||
const callId = typeof event.call_id === 'string' ? event.call_id : event.function_name
|
||||
const existing = stream.tools.find((t) => t.callId === callId)
|
||||
const settled = event.type === 'tool_result'
|
||||
if (existing) {
|
||||
existing.running = !settled
|
||||
existing.success = settled ? event.success === true : existing.success
|
||||
} else {
|
||||
stream.tools.push({
|
||||
callId,
|
||||
name: event.function_name,
|
||||
running: !settled,
|
||||
success: settled ? event.success === true : undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user