Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/run-error-rendering.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@electric-ax/agents-runtime': patch
'@electric-ax/agents-server-ui': patch
---

Persist handler errors on the run that produced them and render long run error messages in the agents UI as expandable details.
14 changes: 14 additions & 0 deletions packages/agents-runtime/src/process-wake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,15 @@ function toError(err: unknown): Error {
return err instanceof Error ? err : new Error(String(err))
}

function latestNewRunKey(
db: EntityStreamDBWithActions,
existingRunKeys: ReadonlySet<string>
): string | undefined {
return db.collections.runs.toArray
.filter((run) => !existingRunKeys.has(run.key))
.at(-1)?.key
}

async function resolveHeadersProvider(
provider: ProcessWakeConfig[`claimHeaders`]
): Promise<Record<string, string> | undefined> {
Expand Down Expand Up @@ -2158,6 +2167,9 @@ export async function processWake(
})

let sleepRequested = false
const existingRunKeys = new Set(
db.collections.runs.toArray.map((run) => run.key)
)

try {
await wirePendingSharedStates()
Expand Down Expand Up @@ -2208,12 +2220,14 @@ export async function processWake(
? setupErr.code
: `HANDLER_FAILED`
log.error(`handler failed for ${entityUrl}:`, errMsg)
const failedRunKey = latestNewRunKey(db, existingRunKeys)
writeEvent(
entityStateSchema.errors.insert({
key: `error-${epoch}-${crypto.randomUUID()}`,
value: {
error_code: errCode,
message: errMsg,
...(failedRunKey ? { run_id: failedRunKey } : {}),
} as never,
}) as ChangeEvent
)
Expand Down
59 changes: 50 additions & 9 deletions packages/agents-server-ui/src/components/AgentResponse.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Check, Copy, GitFork } from 'lucide-react'
import { Check, Copy, CircleAlert, GitFork } from 'lucide-react'
import {
memo,
useEffect,
Expand All @@ -23,12 +23,14 @@ import {
} from '../lib/streamdownConfig'
import { Icon, IconButton, Stack, Text, Tooltip } from '../ui'
import { ToolCallView } from './ToolCallView'
import { InlineEventCard } from './InlineEventCard'
import { TimeText } from './TimeText'
import { ThinkingIndicator } from './ThinkingIndicator'
import { ElapsedTime } from './ElapsedTime'
import { TokenUsage } from './TokenUsage'
import { formatElapsedDuration, toMillis } from '../lib/formatTime'
import styles from './AgentResponse.module.css'
import toolBlock from './toolBlock.module.css'
import type { ForkFromHereAction } from './UserMessage'
import type {
EntityTimelineContentItem,
Expand Down Expand Up @@ -334,6 +336,41 @@ function errorText(errors: Array<EntityTimelineErrorItem>): string | undefined {
return errors.length > 0 ? errors.map(formatError).join(`; `) : undefined
}

const RUN_ERROR_SUMMARY_LENGTH = 180

function isLongRunError(message: string): boolean {
return message.length > RUN_ERROR_SUMMARY_LENGTH || message.includes(`\n`)
}

function runErrorSummary(message: string): string {
const normalized = message.replace(/\s+/g, ` `)
return normalized.length > RUN_ERROR_SUMMARY_LENGTH
? `${normalized.slice(0, RUN_ERROR_SUMMARY_LENGTH)}…`
: normalized
}

function RunErrorInline({ message }: { message: string }): React.ReactElement {
return (
<Text size={1} tone="danger" truncate>
✗ {message}
</Text>
)
}

function RunErrorCard({ message }: { message: string }): React.ReactElement {
return (
<InlineEventCard
icon={CircleAlert}
title="run error"
summary={runErrorSummary(message)}
defaultExpanded={false}
headerSurface
>
<pre className={toolBlock.codeBlock}>{message}</pre>
</InlineEventCard>
)
}

function failedRunText(
run: EntityTimelineRunRow,
items: Array<EntityTimelineRunItem>
Expand Down Expand Up @@ -521,6 +558,10 @@ export const AgentResponseLive = memo(function AgentResponseLive({
)
})}

{failureText && isLongRunError(failureText) && (
<RunErrorCard message={failureText} />
)}

<Stack align="center" gap={2} className={styles.metaRow}>
{showThinking && <ThinkingIndicator />}
{done && (
Expand All @@ -530,10 +571,8 @@ export const AgentResponseLive = memo(function AgentResponseLive({
: `✓ done`}
</Text>
)}
{failureText && (
<Text size={1} tone="danger">
✗ {failureText}
</Text>
{failureText && !isLongRunError(failureText) && (
<RunErrorInline message={failureText} />
)}
{/* Elapsed-time ticker — visible while the response is still
in flight so the user can see how long the model has been
Expand Down Expand Up @@ -739,6 +778,10 @@ export const AgentResponse = memo(function AgentResponse({
return <ToolCallView key={item.toolCallId} item={item} />
})}

{section.error && isLongRunError(section.error) && (
<RunErrorCard message={section.error} />
)}

<Stack align="center" gap={2} className={styles.metaRow}>
{showThinking && <ThinkingIndicator />}
{section.done && (
Expand All @@ -748,10 +791,8 @@ export const AgentResponse = memo(function AgentResponse({
: `✓ done`}
</Text>
)}
{section.error && (
<Text size={1} tone="danger">
✗ {section.error}
</Text>
{section.error && !isLongRunError(section.error) && (
<RunErrorInline message={section.error} />
)}
{/* Elapsed-time ticker — kept in sync with the live variant
above so cached sections (rare during streaming, but the
Expand Down
Loading