feat: render an AI agent result as its answer, not as raw JSON (#11051)

* feat: render an AI agent result as its answer, not as raw JSON

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: sanitize agent markdown through the shared plugin chain

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: fold agent stream events incrementally per poll

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: separate the agent meta line from the result toggle group

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: add a transcript view of an agent run's conversation

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: retire AIAgentLogViewer in favour of the transcript

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: show the partial transcript a max-iterations failure carries

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: move the agent meta line and system prompt below the conversation

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: show an agent run as what it did, not as a conversation

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: name the agent run breakdown a trace

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ai-agent): label the save-as-agent form fields per the guidelines

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ai-agent): reuse the resource form's path and description fields

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ai-agent): keep the action tags on a max-iterations failure

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ai-agent): keep offline replay inert and the streamed answer to one turn

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ai-agent): reset the streamed answer on providers that skip tool_call

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: carry the event type narrowing through the stream parser

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: survive a malformed message rather than take the viewer down

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: coerce agent messages once at the parse boundary

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: show an agent run as one scroll ending in its output

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep every streamed turn instead of dropping the narration

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: share the chat divider and drop the unsafe run auto-scroll

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: give the labelled divider a border colour and the standard pretty icon

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: pad the agent run below its badges as well as above

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: follow a streaming run's pane without moving the page

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: share the chat's stick-to-bottom mechanics with the agent run

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep the answer's citations and end a turn's reasoning with the turn

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: read the agent stream through the windmill-chat sdk parser

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: show an agent run's thinking instead of falling back to raw json

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: stop a stream the fold cannot use from claiming the run pane

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: identify an agent run by more than the job id the replay withholds

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: drop the tests and comment lines that were not earning their place

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: stop a streamed turn's text shifting when a tool call closes it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-09-18 14:38:46 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 48f00259c5
commit a08992834d
27 changed files with 1633 additions and 493 deletions
+45 -5
View File
@@ -79,6 +79,15 @@ lazy_static::lazy_static! {
const DEFAULT_MAX_AGENT_ITERATIONS: usize = 10;
const HARD_MAX_AGENT_ITERATIONS: usize = 1000;
/// What a run stopped by `max_iterations` reports back. `Message` rather than
/// `OpenAIMessage` is load-bearing: `agent_action` is `skip_serializing` on the
/// latter and reaches JSON only through this wrapper, so serializing these raw
/// drops every tool name and job id and leaves the partial run unreadable.
#[derive(serde::Serialize)]
struct MaxIterPartialResult<'a> {
messages: Vec<Message<'a>>,
}
fn strip_system_messages(messages: &[OpenAIMessage]) -> Vec<OpenAIMessage> {
messages
.iter()
@@ -1785,10 +1794,6 @@ pub async fn run_agent(
step_id: Option<&'a str>,
result: MaxIterPartialResult<'a>,
}
#[derive(serde::Serialize)]
struct MaxIterPartialResult<'a> {
messages: &'a [OpenAIMessage],
}
return Err(Error::ExecutionRawError(
serde_json::value::to_raw_value(&MaxIterError {
message: format!(
@@ -1797,7 +1802,15 @@ pub async fn run_agent(
),
name: "ExecutionErr",
step_id: effective_flow_step_id,
result: MaxIterPartialResult { messages: &messages },
result: MaxIterPartialResult {
messages: messages
.iter()
.map(|m| Message {
message: m,
agent_action: m.agent_action.as_ref(),
})
.collect(),
},
})?,
));
}
@@ -2322,6 +2335,33 @@ mod tests {
assert!(!streaming_requested(Some(false)));
}
#[test]
fn max_iterations_partial_result_keeps_the_action_tags() {
let messages = vec![OpenAIMessage {
role: "tool".to_string(),
content: Some(OpenAIContent::Text("{\"rows\":2}".to_string())),
tool_call_id: Some("call_1".to_string()),
agent_action: Some(AgentAction::ToolCall {
job_id: uuid::Uuid::nil(),
function_name: "list_payouts".to_string(),
module_id: "b".to_string(),
}),
..Default::default()
}];
let partial = MaxIterPartialResult {
messages: messages
.iter()
.map(|m| Message { message: m, agent_action: m.agent_action.as_ref() })
.collect(),
};
let json = serde_json::to_value(&partial).unwrap();
let action = &json["messages"][0]["agent_action"];
assert_eq!(action["type"], "tool_call");
assert_eq!(action["function_name"], "list_payouts");
}
/// Over 64 characters OpenAI rejects the key outright, which costs a wasted round
/// trip per run and silently leaves that step with no prompt caching at all.
#[test]
@@ -1,281 +0,0 @@
<script lang="ts">
import type { GraphModuleState } from './graph'
import {
JobService,
type CompletedJob,
type FlowModule,
type FlowStatusModule,
type Job
} from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import FlowLogViewerWrapper from './FlowLogViewerWrapper.svelte'
import { z } from 'zod'
import { untrack } from 'svelte'
import type { AgentTool } from './flows/agentToolUtils'
type AgentActionWithContent = NonNullable<FlowStatusModule['agent_actions']>[number] & {
content?: unknown
}
const resultSchema = z.object({
messages: z.array(
z.object({
role: z.string(),
content: z.unknown(),
agent_action: z
.union([
z.object({
type: z.literal('tool_call'),
job_id: z.string(),
module_id: z.string(),
function_name: z.string()
}),
z.object({
type: z.literal('mcp_tool_call'),
call_id: z.string(),
function_name: z.string(),
resource_path: z.string(),
arguments: z.record(z.any(), z.any()).optional()
}),
z.object({
type: z.literal('message')
}),
z.object({
type: z.literal('web_search')
})
])
.optional()
})
)
})
interface Props {
tools: AgentTool[]
agentJob: Partial<CompletedJob> & Pick<CompletedJob, 'id'> & { type: 'CompletedJob' }
workspaceId?: string | undefined
storedToolCallJobs?: Record<number, Job>
onToolJobLoaded?: (job: Job, idx: number) => void
noPadding?: boolean
}
let {
tools,
agentJob,
workspaceId,
onToolJobLoaded,
storedToolCallJobs,
noPadding = false
}: Props = $props()
const fakeModuleStates: Record<string, GraphModuleState> = $state({})
async function loadMissingJobs(
agentActions: AgentActionWithContent[],
gen: number
): Promise<Record<string, GraphModuleState>> {
const states: Record<string, GraphModuleState> = {}
const promises = agentActions.map(async (toolCall, idx) => {
if (toolCall.type === 'tool_call') {
let job: Job | undefined = storedToolCallJobs?.[idx]
if (!job || job.type !== 'CompletedJob') {
job = await JobService.getJob({
id: toolCall.job_id,
workspace: workspaceId ?? $workspaceStore!
})
}
states[idx.toString()] = {
args: job.args,
type: job['success'] ? 'Success' : 'Failure',
logs: job.logs,
result: job['result'],
job_id: toolCall.job_id
}
// Keyed by index in the parent's cache, so a superseded run must not write into it.
if (gen === loadGen) {
onToolJobLoaded?.(job, idx)
}
} else if (toolCall.type === 'mcp_tool_call') {
states[idx.toString()] = {
type: 'Success',
args: toolCall.arguments ?? {},
logs: '',
result: toolCall.content
}
} else if (toolCall.type === 'web_search') {
states[idx.toString()] = {
type: 'Success',
args: {},
logs: '',
result: toolCall.content
}
} else {
states[idx.toString()] = {
type: 'Success',
args: {},
logs: '',
result: toolCall.content
}
}
})
await Promise.all(promises)
return states
}
let job: Partial<Job> | undefined = $state(undefined)
// Every prop change starts another load; only the newest may write the shared view, else a
// slower reload for a previous run restores its logs over the one now selected.
let loadGen = 0
async function loadToolCalls(agentJob: Props['agentJob'], tools: AgentTool[]) {
const gen = ++loadGen
let parsedResult = resultSchema.safeParse(agentJob.result)
if (!parsedResult.success) {
console.error('Invalid result', parsedResult.error)
// A failed agent job has no parseable action list. Drop the view rather than leave the
// previously selected step's tool tree rendered under this one's header.
if (gen === loadGen) {
job = undefined
for (const key of Object.keys(fakeModuleStates)) {
delete fakeModuleStates[key]
}
}
return
}
let agentActions = parsedResult.data.messages
.map(
(m) =>
(m.agent_action?.type === 'message'
? {
type: 'message',
content: m.content
}
: m.agent_action?.type === 'tool_call'
? {
type: 'tool_call',
job_id: m.agent_action.job_id,
module_id: m.agent_action.module_id,
function_name: m.agent_action.function_name
}
: m.agent_action?.type === 'mcp_tool_call'
? {
type: 'mcp_tool_call',
content: m.content,
call_id: m.agent_action.call_id,
function_name: m.agent_action.function_name,
arguments: m.agent_action.arguments
}
: m.agent_action?.type === 'web_search'
? {
type: 'web_search',
content: m.content
}
: undefined) as AgentActionWithContent | undefined
)
.filter((m) => m !== undefined)
const states = await loadMissingJobs(agentActions, gen)
if (gen !== loadGen) {
return
}
for (const key of Object.keys(fakeModuleStates)) {
delete fakeModuleStates[key]
}
Object.assign(fakeModuleStates, states)
job = {
...agentJob,
raw_flow: {
modules: agentActions
.map((toolCall, idx) => {
if (toolCall.type === 'message') {
return {
id: idx.toString(),
value: {
type: 'identity' as const
}
}
} else if (toolCall.type === 'mcp_tool_call') {
return {
id: idx.toString(),
value: {
type: 'identity' as const
},
summary: toolCall.function_name,
arguments: toolCall.arguments
}
} else if (toolCall.type === 'web_search') {
return {
id: idx.toString(),
value: {
type: 'identity' as const
},
summary: 'Web Search'
}
} else {
const module = tools.find((m) => m.summary === toolCall.function_name)
// A definition can be missing for a call that did run: the tool was renamed or
// removed since, or it belongs to a linked agent whose resource is no longer
// readable. Keep the recorded call — its args, logs and result come from the
// child job — rather than dropping it from the history.
return module
? ({
...module,
id: idx.toString()
} as FlowModule)
: ({
id: idx.toString(),
value: { type: 'identity' as const },
summary: toolCall.function_name
} as FlowModule)
}
})
}
}
}
// Identity, not a summary digest: a refreshed resource can change a tool's path, code or id while
// keeping its name and count. The store swaps the array only when its contents actually differ,
// so one version per array instance tracks that exactly. An empty list is always the same key,
// since callers hand out a fresh [] for it on every render.
const toolsVersions = new WeakMap<object, number>()
let nextToolsVersion = 0
function toolsIdentity(list: AgentTool[]): string {
if (list.length === 0) {
return 'empty'
}
let version = toolsVersions.get(list)
if (version === undefined) {
version = ++nextToolsVersion
toolsVersions.set(list, version)
}
return String(version)
}
// Rebuild when the inputs change, not only on mount: a linked agent's tools resolve
// asynchronously after the first render, and switching between completed runs reuses this
// component — either would otherwise keep the first snapshot. Keyed by value, because callers
// rebuild the `agentJob` object on every render and identity alone would reload in a loop.
let reloadKey = $derived(`${agentJob?.id ?? ''}|${toolsIdentity(tools)}`)
$effect(() => {
reloadKey
untrack(() => {
if (agentJob) {
loadToolCalls(agentJob, tools)
}
})
})
</script>
{#if job}
<div class={noPadding ? '' : 'p-2'}>
<FlowLogViewerWrapper
{job}
localModuleStates={fakeModuleStates}
{workspaceId}
render={true}
onSelectedIteration={async () => {}}
mode="aiagent"
/>
</div>
{/if}
@@ -0,0 +1,116 @@
<script lang="ts">
import type { Snippet } from 'svelte'
import { Badge } from '$lib/components/common'
import GfmMarkdown from './GfmMarkdown.svelte'
import AgentTrace from './AgentTrace.svelte'
import ChatCollapsibleCard from './copilot/chat/ChatCollapsibleCard.svelte'
import LabeledDivider from './LabeledDivider.svelte'
import WebSearchSourcesDisplay from './copilot/chat/WebSearchSourcesDisplay.svelte'
import { buildAgentTrace, splitFinalAnswer } from './agentTrace'
import { runPane } from './agentScroll'
import { createBottomSticker } from './stickToBottom'
import { formatTokenCount, summarizeAgentResult, type AgentResult } from './aiAgentResult'
interface Props {
result: AgentResult
workspaceId?: string
/** Identifies the run, so a reused viewer lands on the new one's output. */
runKey?: string
/**
* How to render an output that is not text: whatever the result viewer would do
* with that object on its own, a table for rows or the file viewer for an S3
* object. Passed in rather than imported so this does not reach back into the
* viewer rendering it.
*/
structuredOutput: Snippet<[unknown]>
}
let { result, workspaceId, runKey, structuredOutput }: Props = $props()
let summary = $derived(summarizeAgentResult(result))
let textOutput = $derived(typeof result.output === 'string' ? result.output : undefined)
// The turn that produced `output` is not a trace row: the output block below is
// that same text, and printing it twice in one scroll reads as the agent having
// answered itself. Its citations move down with it.
let answer = $derived(splitFinalAnswer(buildAgentTrace(result.messages), result.output))
let trace = $derived(answer.trace)
let reasoning = $derived(result.reasoning?.trim())
let reasoningExpanded = $state(false)
let anchor: HTMLElement | undefined = $state()
const sticker = createBottomSticker()
$effect(() => {
// Also on arrival from a stream: the run finishing adds the output separator,
// so the end has moved from wherever the stream had the reader parked.
runKey
trace.length
reasoning
sticker.scrollToEnd(runPane(anchor))
})
</script>
<div bind:this={anchor} class="flex flex-col w-full py-3">
{#if trace.length > 0}
<AgentTrace entries={trace} {workspaceId} />
{/if}
{#if reasoning}
<!-- The whole run's thinking, which the worker hands back joined rather than
per iteration, so it reads as one block above the answer it led to. -->
<ChatCollapsibleCard
label="Thinking"
expanded={reasoningExpanded}
onToggle={() => (reasoningExpanded = !reasoningExpanded)}
contentClass="font-main"
>
<GfmMarkdown md={reasoning} prose="xs" noPadding />
</ChatCollapsibleCard>
{/if}
{#if trace.length > 0 || reasoning}
<LabeledDivider class="my-3">
<span class="text-2xs text-hint">Output</span>
</LabeledDivider>
{/if}
<div>
{#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}
{#if answer.sources}
<div class="mt-2">
<WebSearchSourcesDisplay sources={answer.sources} />
</div>
{/if}
</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}
{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>
</div>
@@ -0,0 +1,111 @@
<script lang="ts">
import { untrack } from 'svelte'
import ChatCollapsibleCard from './copilot/chat/ChatCollapsibleCard.svelte'
import GfmMarkdown from './GfmMarkdown.svelte'
import { runPane } from './agentScroll'
import { createBottomSticker } from './stickToBottom'
import {
advanceAgentStream,
emptyAgentStreamProgress,
type AgentStreamProgress
} from './aiAgentResult'
interface Props {
/** The raw `result_stream` buffer, which only ever grows for a given run. */
raw: string
/** Identifies the run, so a viewer reused for another one starts over. */
streamKey?: string
}
let { raw, streamKey }: Props = $props()
let progress: AgentStreamProgress & { key?: string } = $state(emptyAgentStreamProgress())
$effect(() => {
raw
streamKey
untrack(() => {
// A shorter buffer than what was already folded in cannot be a longer
// version of the same stream, so treat it as a different one.
const continues = progress.key === streamKey && raw.length >= progress.consumed
const base = continues ? progress : emptyAgentStreamProgress()
progress = { ...advanceAgentStream(raw, base), key: streamKey }
if (!continues) {
// Another run, so the reader's decision to stop following the previous
// one does not carry over: this one starts at its latest output.
following = true
}
})
})
let stream = $derived(progress.stream)
let anchor: HTMLElement | undefined = $state()
// Carry the reader along as the text is written, unless they have scrolled up
// to read something — then the pane is theirs until they come back to the end.
let following = true
const sticker = createBottomSticker()
// The listener goes on the pane, not on this element: a scroll event fires on
// whatever actually scrolled and does not bubble, so a handler here would never
// run and the reader would be dragged back on every poll.
$effect(() => {
const pane = runPane(anchor)
if (!pane) {
return
}
const onScroll = () => {
if (!sticker.isOwnScroll()) {
following = sticker.isAtEnd(pane)
}
}
pane.addEventListener('scroll', onScroll, { passive: true })
return () => pane.removeEventListener('scroll', onScroll)
})
$effect(() => {
stream.current
stream.reasoning
stream.entries.length
if (following) {
sticker.scrollToEnd(runPane(anchor))
}
})
</script>
<!-- One gap on the container rather than margins per child, and the same gap the
finished trace uses: a turn's text must not shift when a tool call turns it from
the live text into a row. The text at the bottom is deliberately unlabelled — a
turn that goes on to call a tool was narration, and the run's end settles which. -->
<div bind:this={anchor} class="flex flex-col w-full py-3 gap-1">
{#each stream.entries as entry, index (entry.kind === 'tool' ? entry.callId : index)}
{#if entry.kind === 'tool'}
<ChatCollapsibleCard
label={entry.name}
expanded={false}
toggleable={false}
shimmer={entry.running}
onToggle={() => {}}
labelClass={entry.success === false ? 'text-red-500' : ''}
/>
{:else}
<div>
<GfmMarkdown md={entry.content} noPadding />
</div>
{/if}
{/each}
{#if stream.current !== ''}
<div>
<!-- Same sanitizing chain as a finished output: a partial answer is written
by the same model and is no more trusted for arriving in pieces. -->
<GfmMarkdown md={stream.current} noPadding />
</div>
{:else if stream.reasoning !== ''}
<!-- Reasoning arrives before the text, so on its own it means the model is
still thinking rather than that this run has nothing to say. -->
<div class="text-secondary">
<GfmMarkdown md={stream.reasoning} prose="xs" noPadding />
</div>
{/if}
</div>
@@ -0,0 +1,135 @@
<script lang="ts">
import { ExternalLink, Globe } from 'lucide-svelte'
import { JobService, type Job } from '$lib/gen'
import { base } from '$lib/base'
import { workspaceStore } from '$lib/stores'
import { msToReadableTimeShort } from '$lib/utils'
import ChatCollapsibleCard from './copilot/chat/ChatCollapsibleCard.svelte'
import ToolContentDisplay from './copilot/chat/ToolContentDisplay.svelte'
import WebSearchSourcesDisplay from './copilot/chat/WebSearchSourcesDisplay.svelte'
import GfmMarkdown from './GfmMarkdown.svelte'
import type { AgentTraceEntry } from './agentTrace'
import { SvelteMap, SvelteSet } from 'svelte/reactivity'
interface Props {
entries: AgentTraceEntry[]
workspaceId?: string
}
let { entries, workspaceId }: Props = $props()
let expanded = new SvelteSet<number>()
// Row state is keyed by position, which means nothing once the viewer is handed
// another run: row 0 would stay open showing the previous run's job.
$effect(() => {
entries
expanded.clear()
jobs.clear()
})
// A tool's own job holds what the envelope does not: its logs, how long it
// took, and whether it succeeded. Fetched when a row is opened rather than
// upfront, so a run with twenty calls does not issue twenty requests to draw
// a list of names.
let jobs = new SvelteMap<string, Job | 'loading' | 'failed'>()
async function loadJob(jobId: string) {
if (jobs.has(jobId)) {
return
}
jobs.set(jobId, 'loading')
try {
jobs.set(
jobId,
await JobService.getJob({ id: jobId, workspace: workspaceId ?? $workspaceStore! })
)
} catch {
// A tool job can be gone (retention) or unreadable. The row still has its
// arguments and result from the envelope, so only the extras are lost.
jobs.set(jobId, 'failed')
}
}
function toggle(index: number, entry: AgentTraceEntry) {
if (expanded.delete(index)) {
return
}
expanded.add(index)
if (entry.kind === 'tool' && entry.jobId) {
loadJob(entry.jobId)
}
}
function jobOf(jobId: string | undefined): Job | undefined {
if (!jobId) return undefined
const job = jobs.get(jobId)
return typeof job === 'object' ? job : undefined
}
function toolLabel(entry: Extract<AgentTraceEntry, { kind: 'tool' }>): string {
const job = jobOf(entry.jobId)
const duration = job?.['duration_ms']
return duration === undefined
? entry.name
: `${entry.name} · ${msToReadableTimeShort(duration)}`
}
</script>
<div class="flex flex-col w-full min-w-0 gap-1">
{#each entries as entry, index (index)}
{#if entry.kind === 'assistant'}
<div class="min-w-0">
<GfmMarkdown md={entry.content} noPadding />
{#if entry.sources}
<div class="mt-2">
<WebSearchSourcesDisplay sources={entry.sources} />
</div>
{/if}
</div>
{:else if entry.kind === 'search'}
<!-- Nothing to reveal: the worker records only that a search ran, and its
citations render under the assistant turn that follows. -->
<ChatCollapsibleCard
label="Web search"
expanded={false}
toggleable={false}
onToggle={() => {}}
/>
{:else}
{@const job = jobOf(entry.jobId)}
<ChatCollapsibleCard
label={toolLabel(entry)}
expanded={expanded.has(index)}
onToggle={() => toggle(index, entry)}
contentClass="space-y-3"
>
{#if entry.args}
<ToolContentDisplay title="Parameters" content={entry.args} toolName={entry.name} />
{/if}
{#if job?.logs}
<ToolContentDisplay title="Logs" content={job.logs} />
{/if}
<ToolContentDisplay
title="Result"
content={entry.result}
error={job?.type === 'CompletedJob' && !job.success ? entry.result : undefined}
/>
{#if entry.resourcePath}
<div class="text-2xs text-hint flex items-center gap-1">
<Globe size={11} />
{entry.resourcePath}
</div>
{:else if entry.jobId}
<a
class="text-2xs text-accent inline-flex items-center gap-1 w-fit hover:underline"
href="{base}/run/{entry.jobId}?workspace={workspaceId ?? $workspaceStore}"
target="_blank"
rel="noreferrer"
>
<ExternalLink size={11} />
Open job
</a>
{/if}
</ChatCollapsibleCard>
{/if}
{/each}
</div>
@@ -54,6 +54,11 @@
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 AgentTrace from './AgentTrace.svelte'
import { isAgentStream, parseAgentErrorMessages, parseAgentResult } from './aiAgentResult'
import { buildAgentTrace } from './agentTrace'
const TABLE_MAX_SIZE = 5000000
const DISPLAY_MAX_SIZE = 100000
@@ -85,6 +90,7 @@
| 'map'
| 'nondisplayable'
| 'pdf'
| 'aiagent'
| undefined
let resultKind: ResultKind = $state()
/** Kinds whose renderer leaves the page: S3/ducklake previews fetch the file or
@@ -94,7 +100,10 @@
const REPLAY_INERT_KINDS: ResultKind[] = ['s3object', 's3object-list', 'materialized', 'approval']
/** Kinds whose markup pulls subresources: DOMPurify stops scripting but keeps
* `<img src>` and SVG `<image href>`, and `map` tiles are requests by
* construction. Kinds absent here carry their bytes as `data:` and reach nothing.
* construction. Kinds absent here carry their bytes as `data:` and reach nothing,
* or render through a component that is itself inert on the public page —
* `aiagent` is the second case, via `GfmMarkdown`, which is why it renders
* markdown yet is not listed while `markdown` still is.
* Inert only on the public page, which promises to issue no requests. */
const OFFLINE_INERT_KINDS: ResultKind[] = ['markdown', 'html', 'svg', 'map']
let length = $state(1)
@@ -110,6 +119,12 @@
filename?: string | undefined
disableExpand?: boolean
jobId?: string | undefined
/**
* Which run this result belongs to. Separate from `jobId`, which the replay
* page withholds so nothing fetches: the agent views still have to tell one
* run from the next, or a second one continues the first one's fold.
*/
runKey?: string | undefined
workspaceId?: string | undefined
hideAsJson?: boolean
noControls?: boolean
@@ -135,6 +150,7 @@
filename = undefined,
disableExpand = false,
jobId = undefined,
runKey = undefined,
workspaceId = undefined,
hideAsJson = false,
noControls = false,
@@ -154,6 +170,16 @@
growVertical = false
}: Props = $props()
let s3FileDisplayRawMode = $state(false)
/** 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
* section is not rendered rather than heading an empty box. */
let agentErrorTrace = $derived.by(() => {
const messages = parseAgentErrorMessages(result)
if (!messages) return undefined
const entries = buildAgentTrace(messages)
return entries.length > 0 ? entries : undefined
})
// Build the image/PDF source URL for an S3 object. When `appPath` is set
// (deployed app view) the read is authorized on-behalf of the app author via
@@ -293,6 +319,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 long trace
// 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'])
@@ -731,7 +768,13 @@
<div class="flex items-center gap-2 text-secondary text-xs">
<Loader2 class="animate-spin" size={14} /> Streaming result
</div>
<ResultStreamDisplay {result_stream} />
{#if isAgentStream(result_stream)}
<!-- 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 raw={result_stream} streamKey={runKey ?? jobId} />
{:else}
<ResultStreamDisplay {result_stream} />
{/if}
</div>
{:else if is_render_all}
<div class="flex flex-col w-full gap-2">
@@ -973,6 +1016,16 @@
{/if}
{@render children?.()}
</div>
{#if agentErrorTrace}
<!-- A run stopped by max_iterations fails, so the error above is what it
returned. What it managed to do rides inside that error and is the
whole reason to look at such a run, so it is added under the error
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 entries={agentErrorTrace} {workspaceId} />
</div>
{/if}
{#if !isTest && language === 'bun'}
<div class="pt-20"></div>
<Alert size="xs" type="info" title="Seeing an odd error?">
@@ -1229,6 +1282,27 @@
{/each}
</div>
</div>
{:else if !forceJson && resultKind === 'aiagent'}
{@const agentResult = parseAgentResult(result)}
{#if agentResult}
<AgentResultDisplay result={agentResult} {workspaceId} runKey={runKey ?? jobId}>
{#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} />
@@ -2,10 +2,7 @@
import { Loader2 } from 'lucide-svelte'
import DisplayResult from './DisplayResult.svelte'
import LogViewer from './LogViewer.svelte'
import type { CompletedJob, Job } from '$lib/gen'
import AiAgentLogViewer from './AIAgentLogViewer.svelte'
import { twMerge } from 'tailwind-merge'
import type { AgentTool } from './flows/agentToolUtils'
interface Props {
waitingForExecutor?: boolean
@@ -16,17 +13,13 @@
loading: boolean
filename?: string | undefined
jobId?: string | undefined
/** Identifies the run, which `jobId` cannot on the replay page. */
runKey?: string | undefined
tag?: string | undefined
workspaceId?: string | undefined
refreshLog?: boolean
downloadLogs?: boolean
tagLabel?: string | undefined
aiAgentStatus?: {
tools: AgentTool[]
agentJob: Partial<CompletedJob> & Pick<CompletedJob, 'id'> & { type: 'CompletedJob' }
storedToolCallJobs?: Record<number, Job>
onToolJobLoaded?: (job: Job, idx: number) => void
}
}
let {
@@ -38,11 +31,11 @@
loading,
filename = undefined,
jobId = undefined,
runKey = undefined,
tag = undefined,
workspaceId = undefined,
downloadLogs = true,
tagLabel = undefined,
aiAgentStatus = undefined
tagLabel = undefined
}: Props = $props()
</script>
@@ -60,7 +53,15 @@
: 'max-h-80'} overflow-auto rounded-md grow min-h-0 border bg-surface-tertiary p-2"
>
{#if result !== undefined || result_stream !== undefined}
<DisplayResult {workspaceId} {jobId} {filename} {result} {result_stream} growVertical />
<DisplayResult
{workspaceId}
{jobId}
{runKey}
{filename}
{result}
{result_stream}
growVertical
/>
{:else if loading}
<Loader2 class="animate-spin" />
{:else}
@@ -70,19 +71,15 @@
</div>
<div class="relative flex flex-col gap-1">
<span class="text-emphasis text-xs font-semibold">Logs</span>
{#if aiAgentStatus}
<AiAgentLogViewer {...aiAgentStatus} {workspaceId} noPadding />
{:else}
<div class="rounded-md grow min-h-0 border bg-surface-tertiary overflow-hidden">
<LogViewer
{tagLabel}
download={downloadLogs}
content={logs ?? ''}
{jobId}
isLoading={waitingForExecutor}
{tag}
/>
</div>
{/if}
<div class="rounded-md grow min-h-0 border bg-surface-tertiary overflow-hidden">
<LogViewer
{tagLabel}
download={downloadLogs}
content={logs ?? ''}
{jobId}
isLoading={waitingForExecutor}
{tag}
/>
</div>
</div>
</div>
@@ -49,7 +49,6 @@
) => Promise<void>
getSelectedIteration: (stepId: string) => number
flowSummary?: string
mode?: 'flow' | 'aiagent'
currentId?: string | null
navigationChain?: NavigationChain
select: (id: string) => void
@@ -81,7 +80,6 @@
onSelectedIteration,
getSelectedIteration,
flowSummary,
mode = 'flow',
currentId,
navigationChain = $bindable(),
select,
@@ -127,18 +125,16 @@
function getStepProgress(job: RootJobData | undefined, totalSteps: number): string {
if (!job || totalSteps === 0) return ''
const stepWord = mode === 'aiagent' ? 'action' : 'step'
// If flow is completed, show total steps
if (job.type === 'CompletedJob') {
return ` (${totalSteps} ${stepWord}${totalSteps === 1 ? '' : 's'})`
return ` (${totalSteps} step${totalSteps === 1 ? '' : 's'})`
}
// If flow is running, use flow_status.step if available (like JobStatus.svelte)
if (job.type === 'QueuedJob') {
if (job.flow_status?.step !== undefined) {
const currentStep = (job.flow_status.step ?? 0) + 1
return ` (${stepWord} ${currentStep} of ${totalSteps})`
return ` (step ${currentStep} of ${totalSteps})`
}
return ''
@@ -558,7 +554,7 @@
{@render flowIcon(getFlowStatus(rootJob), flowInfo?.hasErrors)}
<div class="text-xs text-left font-mono">
{mode === 'aiagent' ? 'AI Agent' : level == 0 ? 'Flow' : 'Subflow'}
{level == 0 ? 'Flow' : 'Subflow'}
{#if flowInfo?.label}
: {flowInfo.label}
{/if}
@@ -703,32 +699,22 @@
<div class="flex items-center gap-2">
<span class="text-xs font-mono text-left">
<b class="flex items-center gap-1">
{#if mode === 'aiagent'}
{#if module.summary}
Tool call: {module.summary}
{:else}
Message
{/if}
{:else}
{module.id}
{/if}
{module.id}
</b>
{#if mode === 'flow'}
{#if module.value.type === 'forloopflow'}
For loop
{:else if module.value.type === 'whileloopflow'}
While loop
{:else if module.value.type === 'branchall'}
Branch to all
{:else if module.value.type === 'branchone'}
Branch to one
{:else if module.value.type === 'flow'}
Subflow
{:else}
Step
{/if}
{#if module.value.type === 'forloopflow'}
For loop
{:else if module.value.type === 'whileloopflow'}
While loop
{:else if module.value.type === 'branchall'}
Branch to all
{:else if module.value.type === 'branchone'}
Branch to one
{:else if module.value.type === 'flow'}
Subflow
{:else}
Step
{/if}
{#if module.summary && mode !== 'aiagent'}
{#if module.summary}
: {module.summary}
{/if}
{#if hasEmptySubflowValue}
@@ -20,7 +20,6 @@
| { id: string; index: number; manuallySet: true; moduleId: string }
| { manuallySet: false; moduleId: string }
) => Promise<void>
mode?: 'flow' | 'aiagent'
}
let {
@@ -29,8 +28,7 @@
localDurationStatuses,
workspaceId,
render,
onSelectedIteration,
mode = 'flow'
onSelectedIteration
}: Props = $props()
// State for tracking expanded rows - using Record to allow explicit control
@@ -180,7 +178,6 @@
{render}
{getSelectedIteration}
flowId="root"
{mode}
{currentId}
bind:navigationChain
{select}
@@ -65,7 +65,6 @@
import { getActiveReplay } from './recording/replay.svelte'
import { publishLinkedAgentTools } from './flows/flowState'
import {
getLinkedAgentTools,
linkedToolsScope,
releaseLinkedToolsScope,
retainLinkedToolsScope
@@ -2121,6 +2120,7 @@
tagLabel={customUi?.tagLabel}
workspaceId={isReplay ? undefined : job?.workspace_id}
jobId={isReplay ? undefined : job?.id}
runKey={job?.id}
filename={job.id}
loading={job['running']}
tag={job?.tag}
@@ -2142,15 +2142,6 @@
<p class="text-secondary">No arguments</p>
{/if}
{:else if node}
{@const module =
stepDetail && typeof stepDetail !== 'string' ? stepDetail : undefined}
{@const agentTools =
module && module.value.type === 'aiagent'
? module.value.agent
? getLinkedAgentTools(linkedToolsViewScope, module.id)
: (module.value.tools ?? [])
: undefined}
{@const parentLoopsPrefix = getParentLoopsPrefix(module?.id ?? '')}
{#if node.flow_jobs_results}
<div>
<span class="pl-1 text-emphasis text-xs font-medium"
@@ -2236,6 +2227,7 @@
tagLabel={customUi?.tagLabel}
workspaceId={isReplay ? undefined : job?.workspace_id}
jobId={isReplay ? undefined : node.job_id}
runKey={node.job_id}
loading={node.type != 'Success' && node.type != 'Failure'}
waitingForExecutor={node.type == 'WaitingForExecutor'}
refreshLog={node.type == 'InProgress'}
@@ -2245,30 +2237,6 @@
tag={node.tag}
logs={node.logs}
downloadLogs={!hideDownloadLogs && !isReplay}
aiAgentStatus={agentTools &&
node?.job_id &&
(node.type === 'Success' || node.type === 'Failure')
? {
tools: agentTools,
agentJob: {
id: node.job_id,
result: node.result,
logs: node.logs,
args: node.args,
success: node.type === 'Success',
type: 'CompletedJob'
},
storedToolCallJobs: module
? toolCallStore?.getLocalToolCallJobs(parentLoopsPrefix)
: undefined,
onToolJobLoaded: (job, idx) => {
if (module) {
const storeKey = parentLoopsPrefix + module.id + '-' + idx
toolCallStore?.setStoredToolCallJob(storeKey, job)
}
}
}
: undefined}
/>
</div>
</div>
@@ -0,0 +1,19 @@
<!-- A horizontal rule with something centred in it: the login page's "or" and the
agent run's output separator. Takes a snippet rather than a string because the
middle is sometimes a button, not a label. -->
<script lang="ts">
import { twMerge } from 'tailwind-merge'
interface Props {
class?: string
children: import('svelte').Snippet
}
let { class: clazz, children }: Props = $props()
</script>
<div class={twMerge('flex items-center gap-2', clazz)}>
<div class="h-px flex-1 bg-border-light"></div>
{@render children()}
<div class="h-px flex-1 bg-border-light"></div>
</div>
+3 -4
View File
@@ -1,5 +1,6 @@
<script module lang="ts">
import { noteSessionEmail } from '$lib/onboardingProfile'
import LabeledDivider from './LabeledDivider.svelte'
import type { LastLoginMethod } from '$lib/lastLoginMethod'
/** Feeds the login card a fixed instance configuration instead of the live one.
@@ -748,11 +749,9 @@
{/snippet}
{#snippet orDivider()}
<div class="flex items-center gap-3 my-6">
<div class="h-px flex-1 bg-border-light"></div>
<LabeledDivider class="gap-3 my-6">
<span class="text-2xs uppercase text-secondary">or</span>
<div class="h-px flex-1 bg-border-light"></div>
</div>
</LabeledDivider>
{/snippet}
<div class="bg-surface px-4 py-8 border sm:rounded-lg sm:px-10">
@@ -8,10 +8,8 @@
import OutputPickerInner from '$lib/components/flows/propPicker/OutputPickerInner.svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import type { FlowEditorContext, OutputViewerJob } from './flows/types'
import type { AgentTool } from './flows/agentToolUtils'
import { getContext } from 'svelte'
import { getStringError } from './copilot/chat/utils'
import AiAgentLogViewer from './AIAgentLogViewer.svelte'
interface Props {
lang: Script['language']
@@ -27,9 +25,6 @@
onUpdateMock?: (mock: { enabled: boolean; return_value?: unknown }) => void
loadingJob?: boolean
tagLabel?: string
// A linked agent persists no tools of its own; its resolved resource tools are passed here so
// the log viewer can label each tool_call with the definition that ran.
linkedAgentTools?: AgentTool[]
}
let {
@@ -45,8 +40,7 @@
disableHistory = false,
onUpdateMock,
loadingJob = false,
tagLabel = undefined,
linkedAgentTools = undefined
tagLabel = undefined
}: Props = $props()
const { stepsInputArgs, flowStateStore } = getContext<FlowEditorContext>('FlowEditorContext')
@@ -105,15 +99,6 @@
customEmptyMessage="Using pinned data"
{tagLabel}
/>
{:else if mod.value.type === 'aiagent' && logJob?.type === 'CompletedJob'}
<AiAgentLogViewer
tools={linkedAgentTools ?? mod.value.tools ?? []}
agentJob={{
...logJob,
type: 'CompletedJob'
}}
workspaceId={logJob.workspace_id}
/>
{:else}
<LogViewer
small
@@ -0,0 +1,59 @@
<!-- The description field of a resource, wherever one is created or edited: the
resource form and the save-as-reusable-agent drawer. One component, for the same
reason as ResourcePathHint next to it — two copies drift. -->
<script lang="ts">
import { Pen } from 'lucide-svelte'
import { Button } from '$lib/components/common'
import GfmMarkdown from './GfmMarkdown.svelte'
import Required from './Required.svelte'
import TextInput from './text_input/TextInput.svelte'
interface Props {
description: string
label?: string
placeholder?: string
canWrite?: boolean
}
let {
description = $bindable(),
label = 'Resource description',
placeholder = 'Describe what this resource is for',
canWrite = true
}: Props = $props()
let editing = $state(false)
</script>
<div class="flex flex-col gap-1">
<h4 class="inline-flex items-center gap-2 text-xs text-emphasis font-semibold"
>{label}
<Required required={false} />
{#if canWrite}
<Button
variant="subtle"
unifiedSize="xs"
btnClasses={editing ? 'bg-surface-hover' : ''}
startIcon={{ icon: Pen }}
iconOnly
title={editing ? 'Stop editing the description' : 'Edit the description'}
aria-label={editing ? 'Stop editing the description' : 'Edit the description'}
on:click={() => (editing = !editing)}
/>
{/if}
</h4>
{#if canWrite && editing}
<div class="relative">
<div class="text-2xs text-primary absolute -top-4 right-0">GH Markdown</div>
<TextInput
underlyingInputEl="textarea"
bind:value={description}
inputProps={{ placeholder, 'aria-label': label, disabled: !canWrite }}
/>
</div>
{:else if description == undefined || description == ''}
<div class="text-xs text-secondary font-normal">No description provided</div>
{:else}
<GfmMarkdown md={description} prose="sm" noPadding />
{/if}
</div>
@@ -1,4 +1,5 @@
<script lang="ts">
import ResourceDescriptionField from './ResourceDescriptionField.svelte'
import type { Schema } from '$lib/common'
import type { Resource, ResourceType } from '$lib/gen'
import { onDestroy } from 'svelte'
@@ -7,20 +8,16 @@
import { Alert, Skeleton } from './common'
import Path from './Path.svelte'
import LabelsInput from './LabelsInput.svelte'
import Required from './Required.svelte'
import { workspaceStore, type UserExt } from '$lib/stores'
import SchemaForm from './SchemaForm.svelte'
import SimpleEditor from './SimpleEditor.svelte'
import FilesetEditor from './FilesetEditor.svelte'
import Toggle from './Toggle.svelte'
import TestConnection from './TestConnection.svelte'
import { Pen } from 'lucide-svelte'
import autosize from '$lib/autosize'
import GfmMarkdown from './GfmMarkdown.svelte'
import TestTriggerConnection from './triggers/TestTriggerConnection.svelte'
import GitHubAppIntegration from './GitHubAppIntegration.svelte'
import GitLabIntegration from './GitLabIntegration.svelte'
import Button from './common/button/Button.svelte'
import ResourceGen from './copilot/ResourceGen.svelte'
import SyncResourceTypes from './SyncResourceTypes.svelte'
import Label from './Label.svelte'
@@ -86,7 +83,6 @@
let ws = $derived(workspace ?? $workspaceStore)
let editDescription = $state(false)
let rawCode: string | undefined = $state(undefined)
let textFileContent: string = $state('')
@@ -193,36 +189,7 @@
</Label>
{/if}
<div class="flex flex-col gap-1">
<h4 class="inline-flex items-center gap-2 text-xs text-emphasis font-semibold"
>Resource description <Required required={false} />
{#if can_write}
<Button
variant="subtle"
unifiedSize="xs"
btnClasses={editDescription ? 'bg-surface-hover' : ''}
startIcon={{ icon: Pen }}
on:click={() => (editDescription = !editDescription)}
/>
{/if}
</h4>
{#if can_write && editDescription}
<div class="relative">
<div class="text-2xs text-primary absolute -top-4 right-0">GH Markdown</div>
<textarea
class="text-xs text-primary font-normal"
disabled={!can_write}
use:autosize
bind:value={description}
placeholder="Describe what this resource is for"
></textarea>
</div>
{:else if description == undefined || description == ''}
<div class="text-xs text-secondary font-normal">No description provided</div>
{:else}
<GfmMarkdown md={description} prose="sm" noPadding />
{/if}
</div>
<ResourceDescriptionField bind:description canWrite={can_write} />
<div class="flex flex-col gap-1">
<div class="w-full flex gap-4 flex-row-reverse items-center">
@@ -1,5 +1,6 @@
<!-- Shown above the Path input wherever a resource is created: the resource form and the
connect drawer's last step. One component so the two screens cannot drift apart. -->
<!-- Shown above the Path input wherever a resource is created: the resource form, the
connect drawer's last step, and the save-as-reusable-agent drawer. One component so
those screens cannot drift apart. -->
<div class="text-xs text-secondary font-normal mb-1">
The path sets who can access this resource: a <code>u/</code> path is private to that user, an
<code>f/</code> path follows the folder's permissions — read access lets people use the resource, write
@@ -0,0 +1,17 @@
/**
* The pane an agent run is rendered in. Selected on `overflow-y` alone, never on
* whether the element currently overflows: a pane that fits its content is still
* the pane, and an overflow test walks past it into `#content`, which overflows
* merely because the document scrolls scrolling that drops the whole page.
*/
export function runPane(node: HTMLElement | undefined | null): HTMLElement | undefined {
let current = node?.parentElement
while (current && current !== document.body && current.id !== 'content') {
const overflowY = getComputedStyle(current).overflowY
if (overflowY === 'auto' || overflowY === 'scroll') {
return current
}
current = current.parentElement
}
return undefined
}
@@ -0,0 +1,182 @@
import { describe, expect, it } from 'vitest'
import { buildAgentTrace, splitFinalAnswer } from './agentTrace'
import { parseAgentErrorMessages } from './aiAgentResult'
import type { AgentMessage } from './aiAgentResult'
// The worker splits one tool call across two messages: the assistant message
// carries the arguments and no action tag, the `tool` message answering it
// carries the result and the `tool_call` tag naming the job. Joining them on
// `tool_call_id` is the whole contract, and reading it off the wrong message
// yields a row with no parameters.
const messages: AgentMessage[] = [
{ role: 'system', content: 'You are an SRE assistant.' },
{ role: 'user', content: 'Which region is broken?' },
{
role: 'assistant',
tool_calls: [
{
id: 'call_1',
type: 'function',
function: { name: 'query_metrics', arguments: '{"w":"30m"}' }
}
]
},
{
role: 'tool',
tool_call_id: 'call_1',
content: '{"eu-central-1":0.184}',
agent_action: {
type: 'tool_call',
job_id: '0199-job',
module_id: 'b',
function_name: 'query_metrics'
}
},
{ role: 'assistant', content: 'eu-central-1 is down.', agent_action: { type: 'message' } }
]
describe('buildAgentTrace', () => {
it('joins a tool call to the arguments on the message that requested it', () => {
expect(buildAgentTrace(messages)).toEqual([
{
kind: 'tool',
name: 'query_metrics',
args: '{"w":"30m"}',
result: '{"eu-central-1":0.184}',
jobId: '0199-job'
},
{ kind: 'assistant', content: 'eu-central-1 is down.', sources: undefined }
])
})
it('keeps an MCP call, whose arguments live on the action itself', () => {
const entries = buildAgentTrace([
{
role: 'tool',
content: 'sunny',
agent_action: {
type: 'mcp_tool_call',
call_id: 'c1',
function_name: 'get_weather',
resource_path: 'f/mcp/weather',
arguments: { city: 'Paris' }
}
}
])
expect(entries).toEqual([
{
kind: 'tool',
name: 'get_weather',
args: '{\n "city": "Paris"\n}',
result: 'sunny',
resourcePath: 'f/mcp/weather'
}
])
})
// The worker splits a search the same way: a `tool` message tagged web_search
// carrying a constant sentence, then the assistant turn that carries the
// citations. The search row therefore has nothing of its own to show.
it('records the search and puts its citations on the turn that follows', () => {
const entries = buildAgentTrace([
{
role: 'tool',
content: 'Used websearch tool successfully',
agent_action: { type: 'web_search' }
},
{
role: 'assistant',
content: 'Postgres 17 changed the default.',
annotations: [{ url: 'https://postgresql.org/docs', title: 'Release notes' }],
agent_action: { type: 'message' }
}
])
expect(entries).toEqual([
{ kind: 'search' },
{
kind: 'assistant',
content: 'Postgres 17 changed the default.',
sources: [{ url: 'https://postgresql.org/docs', title: 'Release notes' }]
}
])
})
// The prompt and the question are the step's inputs, shown as inputs. A replayed
// turn comes back from memory without its tag, and crediting this run with an
// answer a previous one gave would be a lie about what happened.
it('traces only what this run did', () => {
expect(
buildAgentTrace([
{ role: 'system', content: 'You are an SRE assistant.' },
{ role: 'user', content: 'Which region is broken?' },
{ role: 'assistant', content: 'Answered in an earlier turn, replayed from memory.' },
{ role: 'assistant', tool_calls: [{ id: 'c1', function: { name: 'x', arguments: '{}' } }] },
{ role: 'assistant', content: '', agent_action: { type: 'message' } }
])
).toEqual([])
})
})
describe('splitFinalAnswer', () => {
it('moves the answering turn out of the trace, with its citations', () => {
const entries = buildAgentTrace([
{ role: 'tool', content: 'Used websearch tool', agent_action: { type: 'web_search' } },
{
role: 'assistant',
content: 'Postgres 17 changed the default.',
annotations: [{ url: 'https://postgresql.org/docs', title: 'Release notes' }],
agent_action: { type: 'message' }
}
])
expect(splitFinalAnswer(entries, 'Postgres 17 changed the default.')).toEqual({
trace: [{ kind: 'search' }],
sources: [{ url: 'https://postgresql.org/docs', title: 'Release notes' }]
})
})
// A run whose last turn returned a tool call and no text leaves its answer
// mid-trace. Looking only at the final entry finds nothing to move and prints
// that answer as a row and again under the output.
it('finds the answering turn even when it is not the last one', () => {
const entries = buildAgentTrace([
{ role: 'assistant', content: 'Let me check.', agent_action: { type: 'message' } },
{
role: 'tool',
tool_call_id: 'call_1',
content: '{}',
agent_action: {
type: 'tool_call',
job_id: '0199-job',
module_id: 'b',
function_name: 'query_metrics'
}
}
])
expect(splitFinalAnswer(entries, 'Let me check.').trace).toEqual([entries[1]])
})
it('leaves the trace whole when the output is not a turn of its own', () => {
const entries = buildAgentTrace(messages)
expect(splitFinalAnswer(entries, { rows: 3 })).toEqual({ trace: entries })
})
})
// A run stopped by max_iterations serializes its partial messages itself rather
// than reusing the success envelope's writer. `agent_action` is `skip_serializing`
// on `OpenAIMessage`, so if that path ever stops wrapping them the tags vanish and
// this trace silently empties — which is the one run worth reading.
describe('the max-iterations path', () => {
it('traces the partial messages the error carries', () => {
const partial = parseAgentErrorMessages({
error: {
name: 'ExecutionErr',
message: 'AI agent reached max iterations (10)',
step_id: 'd',
result: { messages }
}
})
// The trace itself is `buildAgentTrace`'s, pinned above; what this path can
// lose is the tags it reads, and an untagged conversation traces to nothing.
expect(buildAgentTrace(partial ?? [])).toHaveLength(2)
})
})
+134
View File
@@ -0,0 +1,134 @@
import type { WebSearchSource } from './copilot/chat/shared'
import type { AgentMessage } from './aiAgentResult'
/**
* One entry in the trace of an agent run, built from the envelope alone so it
* renders without waiting on any request; a tool's child job is enrichment. The
* prompt and the question are deliberately absent: they are the step's inputs and
* are shown as inputs, while the trace is what the agent did with them.
*/
export type AgentTraceEntry =
| { kind: 'assistant'; content: string; sources?: WebSearchSource[] }
/** A search records only that one happened: the worker writes a constant
* sentence, and the citations ride on the assistant turn that follows. */
| { kind: 'search' }
| {
kind: 'tool'
name: string
args?: string
result: string
/** Present for a flow-module tool, which runs as its own job. */
jobId?: string
/** An MCP tool runs in the worker, so it names its server instead. */
resourcePath?: string
}
/** `content` is a string for text messages and a part list once images are involved. */
function contentText(content: unknown): string {
if (typeof content === 'string') {
return content
}
if (Array.isArray(content)) {
return content
.map((part) =>
part && typeof part === 'object' && typeof (part as { text?: unknown }).text === 'string'
? (part as { text: string }).text
: ''
)
.join('')
}
return ''
}
function sourcesOf(message: AgentMessage): WebSearchSource[] | undefined {
const annotations = message.annotations
if (!annotations?.length) {
return undefined
}
return annotations.map((a) => ({ url: a.url, title: a.title }))
}
export function buildAgentTrace(messages: AgentMessage[]): AgentTraceEntry[] {
// The arguments live on the assistant message that requested the call, while
// the action tag and the result live on the `tool` message answering it, so
// the two are joined by `tool_call_id`.
const argsByCallId = new Map<string, string>()
for (const message of messages) {
for (const call of message.tool_calls ?? []) {
if (call.id && typeof call.function?.arguments === 'string') {
argsByCallId.set(call.id, call.function.arguments)
}
}
}
const entries: AgentTraceEntry[] = []
for (const message of messages) {
const action = message.agent_action
if (action?.type === 'tool_call') {
entries.push({
kind: 'tool',
name: action.function_name,
args: message.tool_call_id ? argsByCallId.get(message.tool_call_id) : undefined,
result: contentText(message.content),
jobId: action.job_id
})
continue
}
if (action?.type === 'mcp_tool_call') {
entries.push({
kind: 'tool',
name: action.function_name,
// An MCP call records its arguments on the action itself: it never
// became a job, so there is nowhere else for them to live.
args: action.arguments ? JSON.stringify(action.arguments, null, 2) : undefined,
result: contentText(message.content),
resourcePath: action.resource_path
})
continue
}
if (action?.type === 'web_search') {
entries.push({ kind: 'search' })
continue
}
// Every message this run produced is tagged, including the agent narrating
// its next move and its final answer. Untagged ones are the prompt, the
// question, or a previous turn replayed out of memory — history loses its
// tags on the way back, and attributing it to this run would credit it with
// answers it never gave.
if (action?.type !== 'message') {
continue
}
const content = contentText(message.content)
if (message.role === 'assistant' && content !== '') {
entries.push({ kind: 'assistant', content, sources: sourcesOf(message) })
}
}
return entries
}
/**
* Separates the turn that produced the output from the rest of the trace, so the
* answer is rendered once with the citations that belong to it. Found by content
* and searched from the end: a run whose last turn returned a tool call leaves its
* answer mid-trace, where inspecting only the final entry prints it twice.
*/
export function splitFinalAnswer(
entries: AgentTraceEntry[],
output: unknown
): { trace: AgentTraceEntry[]; sources?: WebSearchSource[] } {
if (typeof output !== 'string') {
// A schema-shaped output is rendered by the result viewer itself and matches
// no turn, so the whole trace stands.
return { trace: entries }
}
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i]
if (entry.kind === 'assistant' && entry.content === output) {
// The citations are an annotation on that turn, so moving the turn without
// them would leave a run that shows a web search ran and no source for
// what it answered.
return { trace: [...entries.slice(0, i), ...entries.slice(i + 1)], sources: entry.sources }
}
}
return { trace: entries }
}
@@ -0,0 +1,257 @@
import { describe, expect, it } from 'vitest'
import { buildAgentTrace } from './agentTrace'
import {
advanceAgentStream,
emptyAgentStreamProgress,
formatTokenCount,
isAgentStream,
parseAgentResult,
summarizeAgentResult
} 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)
// A key the worker serializes and the signature does not know costs every run
// carrying it the pretty display, so each one is pinned here.
expect(parseAgentResult({ ...envelope, reasoning: 'let me think' })?.reasoning).toBe(
'let me think'
)
})
// 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' }] }],
['null', null]
])('rejects %s', (_label, value) => {
expect(parseAgentResult(value)).toBeUndefined()
})
})
describe('summarizeAgentResult', () => {
it('counts the actions and falls back to the parts when no total is reported', () => {
const summary = summarizeAgentResult({
output: '',
messages: [
{ role: 'user' },
{ role: 'assistant', agent_action: { type: 'tool_call' } as any },
{ role: 'assistant', agent_action: { type: 'mcp_tool_call' } as any },
{ role: 'assistant', agent_action: { type: 'web_search' } },
{ role: 'assistant', agent_action: { type: 'message' } }
],
usage: { input_tokens: 8421, output_tokens: 512, cache_read_input_tokens: 6144 }
})
expect(summary).toEqual({
toolCalls: 2,
webSearches: 1,
tokens: 8933,
cachedTokens: 6144
})
})
})
describe('agent stream', () => {
const lines = [
'{"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"}'
]
const events = lines.join('\n') + '\n'
it('recognises an agent stream from its first line only', () => {
expect(isAgentStream(events)).toBe(true)
expect(isAgentStream('processing row 1\nprocessing row 2\n')).toBe(false)
expect(isAgentStream('{"level":"info","msg":"hello"}\n')).toBe(false)
// No newline yet, so the first line may still be half-written.
expect(isAgentStream('{"type":"token_delta","content":"a"}')).toBe(false)
// A provider that does not filter its empty deltas opens with one, and the
// run is an agent's all the same.
expect(isAgentStream('{"type":"token_delta","content":""}\n')).toBe(true)
// What the fold cannot use must not claim the pane either, or the script's
// own output is replaced by a view with nothing to draw.
expect(isAgentStream('{"type":"tool_call","function_name":"q"}\n')).toBe(false)
})
// The stream only grows, so each poll must fold in the new lines and re-read
// none of the old ones — the reason this is incremental at all.
it('resumes where the previous poll stopped', () => {
const firstPoll = advanceAgentStream(
lines.slice(0, 3).join('\n') + '\n',
emptyAgentStreamProgress()
)
const secondPoll = advanceAgentStream(events, firstPoll)
expect(secondPoll.consumed).toBe(events.length)
expect(secondPoll.stream.current).toBe('eu-central-1 is down')
expect(secondPoll.stream.reasoning).toBe('checking')
})
it('leaves a half-written trailing line for the next poll', () => {
const partial = advanceAgentStream(`${events}{"type":"token_de`, emptyAgentStreamProgress())
expect(partial.stream.current).toBe('eu-central-1 is down')
const completed = advanceAgentStream(`${events}{"type":"token_delta","content":"!"}\n`, partial)
expect(completed.stream.current).toBe('eu-central-1 is down!')
})
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.entries).toEqual([
{ kind: 'tool', 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
)
// One row for the call, not one per event about it.
expect(failed.stream.entries).toEqual([
{ kind: 'tool', callId: 'c1', name: 'fetch', running: false, success: false }
])
})
})
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')
})
})
// The worker streams every iteration's text, and a turn may narrate and call a
// tool at once. Appending across that boundary makes the live answer read
// "I'll checkThe issue is..." and never converge on the finished output.
describe('a stream that narrates before calling a tool', () => {
it('turns the narration into a row and starts the answer fresh', () => {
const raw = [
'{"type":"token_delta","content":"Let me check the metrics."}',
'{"type":"tool_call","call_id":"c1","function_name":"query_metrics"}',
'{"type":"tool_result","call_id":"c1","function_name":"query_metrics","result":"{}","success":true}',
'{"type":"token_delta","content":"eu-central-1 is down."}',
''
].join('\n')
const { stream } = advanceAgentStream(raw, emptyAgentStreamProgress())
expect(stream.current).toBe('eu-central-1 is down.')
// The narration became a row rather than disappearing.
expect(stream.entries[0]).toEqual({ kind: 'assistant', content: 'Let me check the metrics.' })
})
it('closes the turn at the boundary even when polls split it', () => {
const first = '{"type":"token_delta","content":"Let me check."}\n'
const afterCall = first + '{"type":"tool_call","call_id":"c1","function_name":"q"}\n'
const poll1 = advanceAgentStream(first, emptyAgentStreamProgress())
expect(poll1.stream.current).toBe('Let me check.')
const poll2 = advanceAgentStream(afterCall, poll1)
expect(poll2.stream.current).toBe('')
expect(poll2.stream.entries[0]).toEqual({ kind: 'assistant', content: 'Let me check.' })
const poll3 = advanceAgentStream(
afterCall + '{"type":"token_delta","content":"Done."}\n',
poll2
)
expect(poll3.stream.current).toBe('Done.')
})
// Extended thinking emits reasoning with no narration before the tool call, so
// the turn boundary is the only thing that can end it.
it('ends a turn that thought without narrating', () => {
const raw = [
'{"type":"reasoning_token_delta","content":"The user wants the metrics."}',
'{"type":"tool_call","call_id":"c1","function_name":"q"}',
'{"type":"tool_result","call_id":"c1","function_name":"q","result":"{}","success":true}',
'{"type":"reasoning_token_delta","content":"eu-central-1 looks down."}',
''
].join('\n')
const { stream } = advanceAgentStream(raw, emptyAgentStreamProgress())
expect(stream.reasoning).toBe('eu-central-1 looks down.')
})
// Bedrock has its own streaming implementation rather than the shared SSE
// parsers, and never announces `tool_call` — only the arguments, then the
// worker's `tool_execution`. Keying the reset on `tool_call` alone leaves the
// narration in place for that provider.
it('resets on a provider that never announces the call itself', () => {
const raw = [
'{"type":"token_delta","content":"Let me check the metrics."}',
'{"type":"tool_call_arguments","call_id":"c1","function_name":"q","arguments":"{}"}',
'{"type":"tool_execution","call_id":"c1","function_name":"q"}',
'{"type":"tool_result","call_id":"c1","function_name":"q","result":"{}","success":true}',
'{"type":"token_delta","content":"eu-central-1 is down."}',
''
].join('\n')
const { stream } = advanceAgentStream(raw, emptyAgentStreamProgress())
expect(stream.current).toBe('eu-central-1 is down.')
// The narration became a row rather than disappearing.
expect(stream.entries[0]).toEqual({ kind: 'assistant', content: 'Let me check the metrics.' })
})
// A script can write anything to `result_stream`, so an event is not guaranteed
// the fields its type declares.
it('ignores an event with nothing to key a row by, or no text to add', () => {
const raw =
'{"type":"token_delta","content":"Hi."}\n{"type":"token_delta"}\n{"type":"tool_call"}\n'
const { stream } = advanceAgentStream(raw, emptyAgentStreamProgress())
expect(stream.entries).toEqual([])
expect(stream.current).toBe('Hi.')
})
})
// A result is whatever a script returned, so a message that has a `role` still has
// arbitrary anything underneath. Coercing once here is what lets every reader
// treat `AgentMessage` as true; a bad value reaching them throws mid-render and
// takes the result viewer down, including the plain error it usually rides on.
describe('coercing messages at the boundary', () => {
function messagesOf(raw: unknown) {
return parseAgentResult({ output: '', messages: raw })?.messages
}
it.each([
['tool_calls that are not a list', { role: 'assistant', tool_calls: {} }],
['a null entry inside tool_calls', { role: 'assistant', tool_calls: [null] }],
[
'a tool call whose function is a string',
{ role: 'assistant', tool_calls: [{ id: 'c1', function: 'q' }] }
],
[
'non-string arguments',
{ role: 'assistant', tool_calls: [{ id: 'c1', function: { arguments: 3 } }] }
],
['annotations that are not a list', { role: 'assistant', annotations: 'abc' }],
['a null entry inside annotations', { role: 'assistant', annotations: [null] }],
['an annotation with no url', { role: 'assistant', annotations: [{ title: 'x' }] }],
['an agent_action that is not an object', { role: 'tool', agent_action: 'tool_call' }],
['an agent_action with no type', { role: 'tool', agent_action: {} }]
])('survives %s', (_label, message) => {
const parsed = messagesOf([message])
expect(parsed).toHaveLength(1)
expect(() => buildAgentTrace(parsed!)).not.toThrow()
})
it('keeps a well-formed call intact', () => {
const parsed = messagesOf([
{
role: 'assistant',
tool_calls: [{ id: 'c1', type: 'function', function: { name: 'q', arguments: '{}' } }]
}
])
expect(parsed?.[0].tool_calls).toEqual([
{ id: 'c1', type: 'function', function: { name: 'q', arguments: '{}' } }
])
})
})
@@ -0,0 +1,341 @@
import type { FlowStatusModule } from '$lib/gen'
import { parseStreamEvents, type AgentStreamEvent } from 'windmill-chat'
/** 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
/** The model's thinking across every iteration, blank-line separated. */
reasoning?: string
}
/**
* Every key `AIAgentResult` can serialize; the optional ones are skipped when
* empty. The signature below is closed, so a key the worker gains and this list
* does not makes every run carrying it fall back to the raw JSON.
*/
const ENVELOPE_KEYS = ['output', 'messages', 'usage', 'wm_stream', 'reasoning']
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'
}
/**
* A job result is whatever its script returned, so a message that passed the shape
* check still has anything underneath. Coerced once here rather than guarded at
* each reader: a wrong type reaching one throws mid-render and takes the whole
* result viewer down, including the plain error it usually rides on.
*/
function toAgentMessage(raw: Record<string, unknown>): AgentMessage {
const toolCalls = Array.isArray(raw.tool_calls)
? raw.tool_calls.filter(isRecord).map((call) => ({
id: typeof call.id === 'string' ? call.id : undefined,
type: typeof call.type === 'string' ? call.type : undefined,
function: isRecord(call.function)
? {
name: typeof call.function.name === 'string' ? call.function.name : undefined,
arguments:
typeof call.function.arguments === 'string' ? call.function.arguments : undefined
}
: undefined
}))
: undefined
const annotations = Array.isArray(raw.annotations)
? raw.annotations.filter(
(a): a is Record<string, unknown> => isRecord(a) && typeof a.url === 'string'
)
: undefined
return {
role: raw.role as string,
content: raw.content,
tool_calls: toolCalls,
tool_call_id: typeof raw.tool_call_id === 'string' ? raw.tool_call_id : undefined,
// The union is discriminated on `type`; an action without a string one
// matches no branch and is treated as untagged.
agent_action:
isRecord(raw.agent_action) && typeof raw.agent_action.type === 'string'
? (raw.agent_action as unknown as AgentAction)
: undefined,
annotations: annotations as AgentMessage['annotations']
}
}
function toAgentMessages(raw: unknown[]): AgentMessage[] {
return raw.map((message) => toAgentMessage(message as Record<string, unknown>))
}
/**
* Recognised by shape, not a marker key: sniffing works on completed runs, and an
* added key would travel into a parent agent's conversation. Deliberately not also
* requiring a tagged `agent_action` a run answering through a structured-output
* tool tags nothing, and hiding a real answer costs more than claiming a lookalike.
*/
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: toAgentMessages(result.messages),
usage: isRecord(result.usage) ? (result.usage as AgentTokenUsage) : undefined,
wm_stream: typeof result.wm_stream === 'string' ? result.wm_stream : undefined,
reasoning: typeof result.reasoning === 'string' ? result.reasoning : 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 toAgentMessages(inner.messages)
}
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 AgentStreamEntry =
| { kind: 'assistant'; content: string }
| { kind: 'tool'; callId: string; name: string; running: boolean; success?: boolean }
export type AgentStream = {
/**
* What the run has finished doing, in order the same rows the completed
* trace will show, so nothing on screen moves when the result lands.
*/
entries: AgentStreamEntry[]
/**
* The text of the turn being written. It is not yet the output: a turn that
* goes on to call a tool was narration, and only the run ending decides which
* this was. So it stays unlabelled here and becomes one or the other.
*/
current: string
reasoning: string
}
/** 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: { entries: [], current: '', reasoning: '' } }
}
/**
* Any of these means a tool call is beginning, and which one arrives depends on
* the provider: the SSE parsers announce `tool_call`, while Bedrock streams only
* the arguments and the worker follows with `tool_execution`. Resetting on all
* three is idempotent and keeps the rule provider-independent.
*/
const TOOL_TURN_STARTED: AgentStreamEvent['type'][] = [
'tool_call',
'tool_call_arguments',
'tool_execution'
]
/**
* Whether an event carries what its type declares: a delta its text, a tool event
* the `call_id` its row is keyed on and the name that labels it. `result_stream` is
* whatever the job wrote, so detection and the fold ask the same question were
* they to differ, a stream would be claimed and then render nothing.
*/
function isWellFormedEvent(event: AgentStreamEvent): boolean {
if (event.type === 'token_delta' || event.type === 'reasoning_token_delta') {
return typeof event.content === 'string'
}
return (
typeof event.call_id === 'string' &&
event.call_id !== '' &&
typeof event.function_name === 'string' &&
event.function_name !== ''
)
}
/**
* Whether `result_stream` is an agent's event stream rather than something a
* script printed. Reads only the first complete line, because it runs on every
* poll of a running job.
*/
export function isAgentStream(raw: string): boolean {
let start = 0
while (start < raw.length) {
const end = raw.indexOf('\n', start)
if (end === -1) {
// Only a partial first line so far; wait for the poll that completes it.
return false
}
const line = raw.slice(start, end)
if (line.trim() !== '') {
return parseStreamEvents(line).some(isWellFormedEvent)
}
start = end + 1
}
return false
}
/**
* Fold the events that arrived since `previous` into the answer so far. Incremental
* rather than a parse of the whole buffer: the stream only grows, a poll can arrive
* every 50ms, and a `tool_result` carries the tool's entire output, so re-reading it
* all each tick is quadratic with a large constant.
*/
export function advanceAgentStream(
raw: string,
previous: AgentStreamProgress
): AgentStreamProgress {
// A trailing line with no newline yet is still being written, so it stays
// unconsumed until the poll that completes it.
const complete = raw.lastIndexOf('\n') + 1
if (complete <= previous.consumed) {
return previous
}
const stream: AgentStream = { ...previous.stream, entries: [...previous.stream.entries] }
for (const event of parseStreamEvents(raw.slice(previous.consumed, complete))) {
if (!isWellFormedEvent(event)) {
continue
}
if (event.type === 'token_delta') {
stream.current += event.content
continue
}
if (event.type === 'reasoning_token_delta') {
stream.reasoning += event.content
continue
}
if (TOOL_TURN_STARTED.includes(event.type)) {
if (stream.current !== '') {
// A model can narrate and request a tool in the same turn. The call
// settles what that text was: narration, not the output. It becomes a
// row rather than being dropped, so nothing vanishes from the screen
// only to reappear when the result lands.
stream.entries.push({ kind: 'assistant', content: stream.current })
stream.current = ''
}
// Thinking belongs to the turn that produced it, and a turn can think
// without narrating — extended thinking before a tool call is exactly
// that shape. So this clears on the boundary itself, not with the
// narration, or one turn's thoughts run into the next turn's.
stream.reasoning = ''
}
// 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 existing = stream.entries.find(
(e): e is Extract<AgentStreamEntry, { kind: 'tool' }> =>
e.kind === 'tool' && e.callId === event.call_id
)
const settled = event.type === 'tool_result'
if (existing) {
existing.running = !settled
existing.success = settled ? event.success === true : existing.success
} else {
stream.entries.push({
kind: 'tool',
callId: event.call_id,
name: event.function_name,
running: !settled,
success: settled ? event.success === true : undefined
})
}
}
return { consumed: complete, stream }
}
/**
* Token counts run to five and six figures, where the exact digit is noise. The
* millions branch is not decoration: usage accumulates over every loop
* iteration, and each one re-sends the whole context.
*/
export function formatTokenCount(count: number): string {
if (count < 1000) {
return String(count)
}
const [scaled, unit] = count < 1_000_000 ? [count / 1000, 'k'] : [count / 1_000_000, 'M']
return `${scaled < 10 ? scaled.toFixed(1) : Math.round(scaled)}${unit}`
}
@@ -1,4 +1,5 @@
<script lang="ts">
import { createBottomSticker } from '$lib/components/stickToBottom'
import AIChatMessage from './AIChatMessage.svelte'
import AppAvailableContextList from './AppAvailableContextList.svelte'
import ChatContextPicker from './ChatContextPicker.svelte'
@@ -266,21 +267,11 @@
return () => window.removeEventListener('keydown', onWindowKeydownCapture, true)
})
// Programmatic-scroll guard. `scrollDown()` triggers an async `scroll`
// event; if a token-append between the scrollTo and the dispatch makes
// scrollHeight grow, the gap can briefly exceed STICK_TO_BOTTOM_PX and
// disengage auto-scroll mid-stream. A short cooldown after our own
// scroll swallows that spurious event without affecting genuine user
// scrolls (wheel/touch/keyboard are reaction-time orders of magnitude
// slower than the cooldown).
const PROGRAMMATIC_SCROLL_COOLDOWN_MS = 120
let programmaticScrollAt: number | undefined
// Instant scroll — smooth would animate every token append, racing with
// the next scrollDown and confusing the onscroll bottom-detection below.
// Shared with the agent run viewer, which needs the same programmatic-scroll
// guard for the same reason.
const sticker = createBottomSticker()
function scrollDown() {
if (!scrollElement) return
programmaticScrollAt = Date.now()
scrollElement.scrollTo({ top: scrollElement.scrollHeight, behavior: 'auto' })
sticker.scrollToEnd(scrollElement)
}
let height = $state(0)
@@ -299,10 +290,6 @@
}
})
// Pixel distance from the bottom under which we treat the user as
// "stuck to the bottom" and re-enable automatic scroll. 8px allows for
// sub-pixel rounding from scrollTo + the occasional overscroll bounce.
const STICK_TO_BOTTOM_PX = 8
// Show the "scroll to latest" arrow only once the user has scrolled
// meaningfully away from the tail — a couple of message-heights up. Avoids
// flicker when the auto-scroll lags by a few px during streaming.
@@ -317,13 +304,10 @@
// whose only event would otherwise be swallowed, leaving the arrow
// stuck visible after we already reached the bottom.
showScrollToLatest = distance > SCROLL_TO_LATEST_THRESHOLD_PX
if (
programmaticScrollAt !== undefined &&
Date.now() - programmaticScrollAt < PROGRAMMATIC_SCROLL_COOLDOWN_MS
) {
if (sticker.isOwnScroll()) {
return
}
if (distance <= STICK_TO_BOTTOM_PX) {
if (sticker.isAtEnd(scrollElement)) {
chatHost.enableAutomaticScroll()
} else {
chatHost.disableAutomaticScroll()
@@ -2,6 +2,7 @@
import { Button } from '$lib/components/common'
import { ChevronDown, ChevronRight, History } from 'lucide-svelte'
import type { AttachedTextFile } from './textFileUtils'
import LabeledDivider from '$lib/components/LabeledDivider.svelte'
let { content, files }: { content: string; files?: AttachedTextFile[] } = $props()
@@ -9,8 +10,7 @@
</script>
<div class="my-4 px-2">
<div class="flex items-center gap-2">
<div class="h-px flex-1 bg-surface-selected"></div>
<LabeledDivider>
<Button
variant="subtle"
size="xs2"
@@ -22,8 +22,7 @@
Summarized earlier conversation
</span>
</Button>
<div class="h-px flex-1 bg-surface-selected"></div>
</div>
</LabeledDivider>
{#if expanded}
<div
class="mt-2 max-h-80 overflow-y-auto whitespace-pre-wrap rounded-md bg-surface-secondary p-3 text-xs text-secondary"
@@ -2,6 +2,7 @@
import { Globe } from 'lucide-svelte'
import { SvelteSet } from 'svelte/reactivity'
import type { WebSearchSource } from './shared'
import { isOfflineReplay } from '$lib/components/recording/offlineReplay.svelte'
interface Props {
sources: WebSearchSource[]
@@ -38,6 +39,12 @@
const failedFavicons = new SvelteSet<string>()
// The favicon is a request to a third party, and the public replay page promises
// to issue none — a recording comes from an arbitrary origin, so its cited
// hostnames must not leak from a viewer's browser either. Degrades to the same
// Globe the blocked/failed case already uses.
const noFavicons = $derived(isOfflineReplay())
// Favicons come from Google's public favicon service, which discloses each
// consulted hostname to a third party from the user's browser — an accepted
// tradeoff for now (blocked/air-gapped environments degrade to the Globe
@@ -62,7 +69,7 @@
title={source.url}
class="flex items-center gap-2 py-1 px-1.5 rounded hover:bg-surface-hover min-w-0"
>
{#if failedFavicons.has(hostname)}
{#if noFavicons || failedFavicons.has(hostname)}
<Globe class="w-3.5 h-3.5 shrink-0 text-tertiary" />
{:else}
<img
@@ -1,9 +1,11 @@
<script lang="ts">
import ResourceDescriptionField from '$lib/components/ResourceDescriptionField.svelte'
import { Button, Drawer, DrawerContent } from '$lib/components/common'
import Alert from '$lib/components/common/alert/Alert.svelte'
import Badge from '$lib/components/common/badge/Badge.svelte'
import Path from '$lib/components/Path.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import Label from '$lib/components/Label.svelte'
import ResourcePathHint from '$lib/components/ResourcePathHint.svelte'
import { ResourceService, type InputTransform, type Resource } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
@@ -700,27 +702,30 @@
<div class="flex flex-col gap-4">
<p class="text-xs text-secondary">
Save this AI agent's configuration and tools as a reusable resource. Other flows can then
link to it, updates propagate automatically, and it gains a dataset of eval cases of its
own.
link to it, and updates propagate automatically.
</p>
<Path
bind:path={newPath}
bind:error={pathError}
initialPath=""
namePlaceholder="my_agent"
kind="resource"
workspaceOverride={ws}
/>
<label class="flex flex-col gap-1 text-xs">
<span class="text-secondary">Description</span>
<TextInput
bind:value={description}
inputProps={{ placeholder: 'What this agent does' }}
size="sm"
<!-- The path and description are the resource form's, field for field: this
drawer creates a resource too, and ResourcePathHint exists so the screens
that do cannot drift apart. -->
<Label label="Path">
<ResourcePathHint />
<Path
bind:path={newPath}
bind:error={pathError}
initialPath=""
namePlaceholder="my_agent"
kind="resource"
workspaceOverride={ws}
/>
</label>
</Label>
<ResourceDescriptionField
bind:description
label="Description"
placeholder="Describe what this agent does"
/>
{#if providerSaveError ?? legacyMemorySaveError}
<p class="text-xs text-red-600 dark:text-red-400">
<!-- Validation is `text-2xs` per the guidelines; the red is the feedback colour. -->
<p class="text-2xs text-red-600 dark:text-red-400">
{providerSaveError ?? legacyMemorySaveError}
</p>
{/if}
@@ -1474,12 +1474,6 @@
{testJob}
{scriptProgress}
mod={flowModule}
linkedAgentTools={agentLinked
? getLinkedAgentTools(
linkedToolsScope(opWs, $pathStore),
linkedToolsModuleId
)
: undefined}
{testIsLoading}
disableMock={preprocessorModule || failureModule}
disableHistory={failureModule}
@@ -0,0 +1,47 @@
/**
* Keeps a growing pane pinned to its end, for the chat transcript and the agent run
* viewer. Shared for one non-obvious guard: a programmatic scroll dispatches its
* `scroll` event asynchronously, so content landing in between widens the gap for a
* tick, which reads as the reader scrolling away and disengages the follow.
*/
/**
* Distance from the end within which a reader counts as still following. Allows
* for sub-pixel rounding from `scrollTo` and the occasional overscroll bounce.
*/
const STICK_TO_BOTTOM_PX = 8
/** A scroll event this close after our own scroll is ours, not the reader's. */
const OWN_SCROLL_WINDOW_MS = 120
export type BottomSticker = {
/** Jump to the end. Instant: smooth would animate every append and race the next. */
scrollToEnd: (pane: HTMLElement | undefined | null) => void
/** Whether the pane is at its end, i.e. the reader wants to be carried along. */
isAtEnd: (pane: HTMLElement | undefined | null) => boolean
/** Whether the scroll event being handled was one we caused. */
isOwnScroll: () => boolean
}
export function createBottomSticker(): BottomSticker {
let scrolledAt: number | undefined
return {
scrollToEnd(pane) {
if (!pane) {
return
}
scrolledAt = Date.now()
pane.scrollTo({ top: pane.scrollHeight, behavior: 'auto' })
},
isAtEnd(pane) {
if (!pane) {
return false
}
return pane.scrollHeight - pane.scrollTop - pane.clientHeight <= STICK_TO_BOTTOM_PX
},
isOwnScroll() {
return scrolledAt !== undefined && Date.now() - scrolledAt < OWN_SCROLL_WINDOW_MS
}
}
}