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:
hugocasa
2026-09-14 09:10:52 +02:00
co-authored by Claude Opus 5
parent 32f3beda46
commit bc8e0d4a3a
10 changed files with 148 additions and 96 deletions
+14 -2
View File
@@ -1535,9 +1535,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 {
@@ -1547,7 +1551,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 { userStore, workspaceStore } 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'
@@ -78,7 +75,6 @@
let ws = $derived(workspace ?? $workspaceStore)
let editDescription = $state(false)
let rawCode: string | undefined = $state(undefined)
let textFileContent: string = $state('')
@@ -184,36 +180,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">
+41 -3
View File
@@ -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 }
])
})
})
+4 -6
View File
@@ -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,
@@ -88,7 +86,6 @@
let newPath = $state('')
let pathError = $state('')
let description = $state('')
let editDescription = $state(false)
let saving = $state(false)
type LinkedInfo = {
@@ -630,33 +627,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}