mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
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:
co-authored by
Claude Opus 5
parent
48f00259c5
commit
a08992834d
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user