mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
refactor: retire AIAgentLogViewer in favour of the transcript
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f4ea3c22f1
commit
082a035613
@@ -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}
|
||||
@@ -2,10 +2,9 @@
|
||||
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'
|
||||
import AgentTranscript from './AgentTranscript.svelte'
|
||||
import { parseAgentResult } from './aiAgentResult'
|
||||
|
||||
interface Props {
|
||||
waitingForExecutor?: boolean
|
||||
@@ -21,12 +20,6 @@
|
||||
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 {
|
||||
@@ -41,9 +34,12 @@
|
||||
tag = undefined,
|
||||
workspaceId = undefined,
|
||||
downloadLogs = true,
|
||||
tagLabel = undefined,
|
||||
aiAgentStatus = undefined
|
||||
tagLabel = undefined
|
||||
}: Props = $props()
|
||||
|
||||
// An agent step's own logs are worker chatter; what happened is its conversation.
|
||||
// Derived from the result rather than passed in, so every caller gets it.
|
||||
let agentResult = $derived(parseAgentResult(result))
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -69,9 +65,11 @@
|
||||
</div>
|
||||
</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 />
|
||||
<span class="text-emphasis text-xs font-semibold">{agentResult ? 'Transcript' : 'Logs'}</span>
|
||||
{#if agentResult}
|
||||
<div class="rounded-md grow min-h-0 border bg-surface-tertiary overflow-auto p-2">
|
||||
<AgentTranscript messages={agentResult.messages} {workspaceId} />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-md grow min-h-0 border bg-surface-tertiary overflow-hidden">
|
||||
<LogViewer
|
||||
|
||||
@@ -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,7 +125,7 @@
|
||||
function getStepProgress(job: RootJobData | undefined, totalSteps: number): string {
|
||||
if (!job || totalSteps === 0) return ''
|
||||
|
||||
const stepWord = mode === 'aiagent' ? 'action' : 'step'
|
||||
const stepWord = 'step'
|
||||
|
||||
// If flow is completed, show total steps
|
||||
if (job.type === 'CompletedJob') {
|
||||
@@ -558,7 +556,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 +701,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 {
|
||||
@@ -30,7 +29,6 @@
|
||||
workspaceId,
|
||||
render,
|
||||
onSelectedIteration,
|
||||
mode = 'flow'
|
||||
}: 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
|
||||
@@ -2142,15 +2141,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"
|
||||
@@ -2245,30 +2235,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>
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
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'
|
||||
import AgentTranscript from './AgentTranscript.svelte'
|
||||
import { parseAgentResult } from './aiAgentResult'
|
||||
|
||||
interface Props {
|
||||
lang: Script['language']
|
||||
@@ -27,9 +27,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 +42,7 @@
|
||||
disableHistory = false,
|
||||
onUpdateMock,
|
||||
loadingJob = false,
|
||||
tagLabel = undefined,
|
||||
linkedAgentTools = undefined
|
||||
tagLabel = undefined
|
||||
}: Props = $props()
|
||||
|
||||
const { stepsInputArgs, flowStateStore } = getContext<FlowEditorContext>('FlowEditorContext')
|
||||
@@ -61,6 +57,10 @@
|
||||
)
|
||||
const logJob = $derived(testJob ?? selectedJob)
|
||||
const preview = $derived.by(() => outputPickerInner?.getPreview?.())
|
||||
// The trace of an agent step is its conversation, which its own result carries.
|
||||
const agentResult = $derived(
|
||||
logJob?.type === 'CompletedJob' ? parseAgentResult(logJob.result) : undefined
|
||||
)
|
||||
</script>
|
||||
|
||||
<Splitpanes horizontal>
|
||||
@@ -105,15 +105,10 @@
|
||||
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 if agentResult}
|
||||
<div class="h-full overflow-auto p-2">
|
||||
<AgentTranscript messages={agentResult.messages} workspaceId={logJob?.workspace_id} />
|
||||
</div>
|
||||
{:else}
|
||||
<LogViewer
|
||||
small
|
||||
|
||||
@@ -1469,12 +1469,6 @@
|
||||
{testJob}
|
||||
{scriptProgress}
|
||||
mod={flowModule}
|
||||
linkedAgentTools={agentLinked
|
||||
? getLinkedAgentTools(
|
||||
linkedToolsScope(opWs, $pathStore),
|
||||
linkedToolsModuleId
|
||||
)
|
||||
: undefined}
|
||||
{testIsLoading}
|
||||
disableMock={preprocessorModule || failureModule}
|
||||
disableHistory={failureModule}
|
||||
|
||||
Reference in New Issue
Block a user