mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
fix(ai-agent): keep the action tags on a max-iterations failure
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2bc9069838
commit
25d429a41a
@@ -1614,9 +1614,13 @@ pub async fn run_agent(
|
||||
step_id: Option<&'a str>,
|
||||
result: MaxIterPartialResult<'a>,
|
||||
}
|
||||
// Through the same wrapper the success envelope uses: `agent_action`
|
||||
// is `skip_serializing` on `OpenAIMessage`, so serializing these raw
|
||||
// would drop every tool name and job id and leave the partial run
|
||||
// unreadable — which is the one thing worth having on this path.
|
||||
#[derive(serde::Serialize)]
|
||||
struct MaxIterPartialResult<'a> {
|
||||
messages: &'a [OpenAIMessage],
|
||||
messages: Vec<Message<'a>>,
|
||||
}
|
||||
return Err(Error::ExecutionRawError(
|
||||
serde_json::value::to_raw_value(&MaxIterError {
|
||||
@@ -1626,7 +1630,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(),
|
||||
},
|
||||
})?,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -82,17 +82,14 @@
|
||||
{/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={expanded.has(index)}
|
||||
onToggle={() => toggle(index, entry)}
|
||||
>
|
||||
{#if entry.sources}
|
||||
<WebSearchSourcesDisplay sources={entry.sources} />
|
||||
{:else}
|
||||
<ToolContentDisplay title="Result" content={entry.content} />
|
||||
{/if}
|
||||
</ChatCollapsibleCard>
|
||||
expanded={false}
|
||||
toggleable={false}
|
||||
onToggle={() => {}}
|
||||
/>
|
||||
{:else}
|
||||
{@const job = jobOf(entry.jobId)}
|
||||
<ChatCollapsibleCard
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
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
|
||||
@@ -166,8 +167,16 @@
|
||||
let s3FileDisplayRawMode = $state(false)
|
||||
/** Which half of an agent result is showing; JSON is `forceJson`, as for any kind. */
|
||||
let agentView: 'answer' | 'trace' = $state('answer')
|
||||
/** The partial conversation a max-iterations failure carries, if this is one. */
|
||||
let agentErrorMessages = $derived(parseAgentErrorMessages(result))
|
||||
/** 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 ? messages : 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
|
||||
@@ -1016,14 +1025,14 @@
|
||||
{/if}
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{#if agentErrorMessages}
|
||||
{#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 messages={agentErrorMessages} {workspaceId} />
|
||||
<AgentTrace messages={agentErrorTrace} {workspaceId} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if !isTest && language === 'bun'}
|
||||
|
||||
@@ -125,18 +125,16 @@
|
||||
function getStepProgress(job: RootJobData | undefined, totalSteps: number): string {
|
||||
if (!job || totalSteps === 0) return ''
|
||||
|
||||
const stepWord = '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 ''
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
localDurationStatuses,
|
||||
workspaceId,
|
||||
render,
|
||||
onSelectedIteration,
|
||||
onSelectedIteration
|
||||
}: Props = $props()
|
||||
|
||||
// State for tracking expanded rows - using Record to allow explicit control
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<!-- 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 autosize from '$lib/autosize'
|
||||
|
||||
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 }}
|
||||
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>
|
||||
<textarea
|
||||
class="text-xs text-primary font-normal"
|
||||
disabled={!canWrite}
|
||||
use:autosize
|
||||
bind:value={description}
|
||||
{placeholder}
|
||||
></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>
|
||||
@@ -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 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildAgentTrace } from './agentTrace'
|
||||
import { parseAgentErrorMessages } from './aiAgentResult'
|
||||
import type { AgentMessage } from './aiAgentResult'
|
||||
|
||||
// The worker splits one tool call across two messages: the assistant message
|
||||
@@ -73,18 +74,27 @@ describe('buildAgentTrace', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('carries web search citations onto the entry', () => {
|
||||
// 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: 'web_search' }
|
||||
agent_action: { type: 'message' }
|
||||
}
|
||||
])
|
||||
expect(entries).toEqual([
|
||||
{ kind: 'search' },
|
||||
{
|
||||
kind: 'search',
|
||||
kind: 'assistant',
|
||||
content: 'Postgres 17 changed the default.',
|
||||
sources: [{ url: 'https://postgresql.org/docs', title: 'Release notes' }]
|
||||
}
|
||||
@@ -106,3 +116,31 @@ describe('buildAgentTrace', () => {
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// 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 }
|
||||
}
|
||||
})
|
||||
expect(partial).toBeDefined()
|
||||
expect(buildAgentTrace(partial!)).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 }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,7 +13,9 @@ import type { AgentMessage } from './aiAgentResult'
|
||||
*/
|
||||
export type AgentTraceEntry =
|
||||
| { kind: 'assistant'; content: string; sources?: WebSearchSource[] }
|
||||
| { kind: 'search'; 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
|
||||
@@ -92,11 +94,7 @@ export function buildAgentTrace(messages: AgentMessage[]): AgentTraceEntry[] {
|
||||
continue
|
||||
}
|
||||
if (action?.type === 'web_search') {
|
||||
entries.push({
|
||||
kind: 'search',
|
||||
content: contentText(message.content),
|
||||
sources: sourcesOf(message)
|
||||
})
|
||||
entries.push({ kind: 'search' })
|
||||
continue
|
||||
}
|
||||
// Every message this run produced is tagged, including the agent narrating
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
<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 Label from '$lib/components/Label.svelte'
|
||||
import Required from '$lib/components/Required.svelte'
|
||||
import ResourcePathHint from '$lib/components/ResourcePathHint.svelte'
|
||||
import GfmMarkdown from '$lib/components/GfmMarkdown.svelte'
|
||||
import autosize from '$lib/autosize'
|
||||
import { ResourceService, type InputTransform, type Resource } from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { Bot, ChevronDown, ChevronUp, Pen, Save, Unlink, Pencil } from 'lucide-svelte'
|
||||
import { Bot, ChevronDown, ChevronUp, Save, Unlink, Pencil } from 'lucide-svelte'
|
||||
import {
|
||||
AGENT_BRAIN_KEYS,
|
||||
AGENT_FLOW_LOCAL_KEYS,
|
||||
@@ -92,7 +90,6 @@
|
||||
let newPath = $state('')
|
||||
let pathError = $state('')
|
||||
let description = $state('')
|
||||
let editDescription = $state(false)
|
||||
let saving = $state(false)
|
||||
|
||||
type LinkedInfo = {
|
||||
@@ -693,33 +690,11 @@
|
||||
workspaceOverride={ws}
|
||||
/>
|
||||
</Label>
|
||||
<div class="flex flex-col gap-1">
|
||||
<h4 class="inline-flex items-center gap-2 text-xs text-emphasis font-semibold"
|
||||
>Description <Required required={false} />
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="xs"
|
||||
btnClasses={editDescription ? 'bg-surface-hover' : ''}
|
||||
startIcon={{ icon: Pen }}
|
||||
on:click={() => (editDescription = !editDescription)}
|
||||
/>
|
||||
</h4>
|
||||
{#if 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"
|
||||
use:autosize
|
||||
bind:value={description}
|
||||
placeholder="Describe what this agent does"
|
||||
></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
|
||||
label="Description"
|
||||
placeholder="Describe what this agent does"
|
||||
/>
|
||||
{#if providerSaveError}
|
||||
<p class="text-2xs text-red-600 dark:text-red-400">
|
||||
{providerSaveError}
|
||||
|
||||
Reference in New Issue
Block a user