diff --git a/.changeset/model-provider-errors.md b/.changeset/model-provider-errors.md new file mode 100644 index 0000000000..4e329edc21 --- /dev/null +++ b/.changeset/model-provider-errors.md @@ -0,0 +1,6 @@ +--- +"@electric-ax/agents-runtime": patch +"@electric-ax/agents-server-ui": patch +--- + +Add default model-provider timeout/error handling for agent runs and render durable run errors in the UI. diff --git a/packages/agents-runtime/src/context-factory.ts b/packages/agents-runtime/src/context-factory.ts index d0c8354fd0..9a48b040d4 100644 --- a/packages/agents-runtime/src/context-factory.ts +++ b/packages/agents-runtime/src/context-factory.ts @@ -546,6 +546,9 @@ export function createHandlerContext( getApiKey: activeAgentConfig.getApiKey, onPayload: activeAgentConfig.onPayload, + + modelTimeoutMs: activeAgentConfig.modelTimeoutMs, + modelMaxRetries: activeAgentConfig.modelMaxRetries, }) const handle = adapterFactory({ entityUrl: config.entityUrl, diff --git a/packages/agents-runtime/src/index.ts b/packages/agents-runtime/src/index.ts index c593d6ac84..172be74b9e 100644 --- a/packages/agents-runtime/src/index.ts +++ b/packages/agents-runtime/src/index.ts @@ -163,6 +163,13 @@ export type { export { buildSections, buildTimelineEntries } from './use-chat' export type { EntityTimelineEntry } from './use-chat' export { appendPathToUrl } from './url' +export { + ModelProviderError, + classifyModelProviderError, + modelProviderErrorMessage, + toModelProviderError, +} from './model-provider-error' +export type { ModelProviderErrorCode } from './model-provider-error' export { defaultProjection, diff --git a/packages/agents-runtime/src/model-provider-error.ts b/packages/agents-runtime/src/model-provider-error.ts new file mode 100644 index 0000000000..810891b263 --- /dev/null +++ b/packages/agents-runtime/src/model-provider-error.ts @@ -0,0 +1,145 @@ +export type ModelProviderErrorCode = + | `MODEL_PROVIDER_TIMEOUT` + | `MODEL_PROVIDER_UNREACHABLE` + | `MODEL_PROVIDER_AUTH_FAILED` + | `MODEL_PROVIDER_RATE_LIMITED` + | `MODEL_PROVIDER_UNAVAILABLE` + | `MODEL_PROVIDER_ERROR` + +export class ModelProviderError extends Error { + readonly code: ModelProviderErrorCode + readonly provider?: string + readonly model?: string + + constructor(opts: { + code: ModelProviderErrorCode + message: string + provider?: string + model?: string + cause?: unknown + }) { + super( + opts.message, + opts.cause === undefined ? undefined : { cause: opts.cause } + ) + this.name = `ModelProviderError` + this.code = opts.code + this.provider = opts.provider + this.model = opts.model + } +} + +function stringifyError(error: unknown): string { + if (error instanceof Error) { + const cause = (error as { cause?: unknown }).cause + return [ + error.name, + error.message, + cause === undefined ? `` : stringifyError(cause), + ] + .filter(Boolean) + .join(` `) + } + return String(error) +} + +export function classifyModelProviderError( + error: unknown +): ModelProviderErrorCode { + const text = stringifyError(error).toLowerCase() + + if ( + /\b(aborterror|timeouterror)\b/.test(text) || + text.includes(`timeout`) || + text.includes(`timed out`) + ) { + return `MODEL_PROVIDER_TIMEOUT` + } + + if ( + text.includes(`401`) || + text.includes(`invalid api key`) || + text.includes(`authentication`) || + text.includes(`unauthorized`) + ) { + return `MODEL_PROVIDER_AUTH_FAILED` + } + + if (text.includes(`429`) || text.includes(`rate limit`)) { + return `MODEL_PROVIDER_RATE_LIMITED` + } + + if ( + text.includes(`502`) || + text.includes(`503`) || + text.includes(`504`) || + text.includes(`overloaded`) || + text.includes(`unavailable`) + ) { + return `MODEL_PROVIDER_UNAVAILABLE` + } + + if ( + text.includes(`enotfound`) || + text.includes(`econnrefused`) || + text.includes(`econnreset`) || + text.includes(`eai_again`) || + text.includes(`fetch failed`) || + text.includes(`failed to fetch`) || + text.includes(`network`) + ) { + return `MODEL_PROVIDER_UNREACHABLE` + } + + return `MODEL_PROVIDER_ERROR` +} + +export function modelProviderErrorMessage(opts: { + code: ModelProviderErrorCode + provider?: string +}): string { + const provider = opts.provider + ? displayProvider(opts.provider) + : `the model provider` + switch (opts.code) { + case `MODEL_PROVIDER_TIMEOUT`: + return `${provider} did not respond before the timeout. Check your Internet connection or provider status.` + case `MODEL_PROVIDER_UNREACHABLE`: + return `Could not reach ${provider}. Check your Internet connection or ${provider} status.` + case `MODEL_PROVIDER_AUTH_FAILED`: + return `${provider} rejected the API key. Check your model provider credentials.` + case `MODEL_PROVIDER_RATE_LIMITED`: + return `${provider} rate limited the request. Please wait and try again.` + case `MODEL_PROVIDER_UNAVAILABLE`: + return `${provider} is currently unavailable. Check provider status and try again.` + case `MODEL_PROVIDER_ERROR`: + return `${provider} returned an error. Check the runtime logs for provider details.` + } +} + +export function toModelProviderError( + error: unknown, + opts: { provider?: string; model?: string } +): ModelProviderError { + if (error instanceof ModelProviderError) return error + const code = classifyModelProviderError(error) + const detail = error instanceof Error ? error.message : String(error) + return new ModelProviderError({ + code, + provider: opts.provider, + model: opts.model, + message: `${modelProviderErrorMessage({ code, provider: opts.provider })} (${detail})`, + cause: error, + }) +} + +function displayProvider(provider: string): string { + switch (provider.toLowerCase()) { + case `anthropic`: + return `Anthropic` + case `openai`: + return `OpenAI` + default: + return provider + } +} diff --git a/packages/agents-runtime/src/outbound-bridge.ts b/packages/agents-runtime/src/outbound-bridge.ts index 2c81851df1..22229083ee 100644 --- a/packages/agents-runtime/src/outbound-bridge.ts +++ b/packages/agents-runtime/src/outbound-bridge.ts @@ -100,6 +100,7 @@ export async function loadOutboundIdSeed( export interface OutboundBridge { onRunStart: () => void onRunEnd: (opts?: { finishReason?: string }) => void + onError: (opts: { errorCode: string; message: string }) => void onStepStart: (opts?: { modelProvider?: string; modelId?: string }) => void onStepEnd: (opts?: { finishReason?: string @@ -193,6 +194,21 @@ export function createOutboundBridge( currentRunKey = null }, + onError(opts: { errorCode: string; message: string }) { + if (!currentRunKey) return + writeEvent( + entityStateSchema.errors.insert({ + key: `${currentRunKey}:error-${crypto.randomUUID()}`, + value: { + error_code: opts.errorCode, + message: opts.message, + run_id: currentRunKey, + ...(currentStepKey ? { step_id: currentStepKey } : {}), + } as never, + }) as ChangeEvent + ) + }, + onStepStart(opts?: { modelProvider?: string; modelId?: string }) { const runKey = requireActiveRun(`onStepStart`) currentStepKey = `step-${counters.step++}` diff --git a/packages/agents-runtime/src/pi-adapter.ts b/packages/agents-runtime/src/pi-adapter.ts index 71c4d0f99d..5d3d745b6f 100644 --- a/packages/agents-runtime/src/pi-adapter.ts +++ b/packages/agents-runtime/src/pi-adapter.ts @@ -8,10 +8,14 @@ */ import { Agent } from '@mariozechner/pi-agent-core' -import { getModel } from '@mariozechner/pi-ai' +import { getModel, streamSimple } from '@mariozechner/pi-ai' import { createOutboundBridge } from './outbound-bridge' import { MOONSHOT_PROVIDER, getMoonshotModel } from './moonshot-models' import { runtimeLog } from './log' +import { + ModelProviderError, + toModelProviderError, +} from './model-provider-error' import type { OutboundIdSeed } from './outbound-bridge' import type { ChangeEvent } from '@durable-streams/state' import type { @@ -42,8 +46,13 @@ export interface PiAdapterOptions { provider: string ) => Promise | string | undefined onPayload?: SimpleStreamOptions[`onPayload`] + modelTimeoutMs?: number + modelMaxRetries?: number } +const DEFAULT_MODEL_TIMEOUT_MS = 30_000 +const DEFAULT_MODEL_MAX_RETRIES = 0 + interface PiAgentAdapterConfig { entityUrl: string epoch: number @@ -227,18 +236,32 @@ export function createPiAgentAdapter( model: opts.model, ...(opts.provider && { provider: opts.provider }), }) + const modelTimeoutMs = opts.modelTimeoutMs ?? DEFAULT_MODEL_TIMEOUT_MS + const modelMaxRetries = opts.modelMaxRetries ?? DEFAULT_MODEL_MAX_RETRIES + + const baseStreamFn = opts.streamFn ?? streamSimple + const streamFn: StreamFn = (streamModel, context, streamOptions) => + baseStreamFn(streamModel, context, { + ...streamOptions, + timeoutMs: modelTimeoutMs, + maxRetries: modelMaxRetries, + }) - const agent = new Agent({ + const agentOptions = { initialState: { systemPrompt: opts.systemPrompt, tools: opts.tools as Array, messages: history as Array, model, }, - ...(opts.streamFn && { streamFn: opts.streamFn }), + streamFn, ...(opts.getApiKey && { getApiKey: opts.getApiKey }), ...(opts.onPayload && { onPayload: opts.onPayload }), - }) + } + + const agent = new Agent( + agentOptions as ConstructorParameters[0] + ) function processAgentEvents( resolveWhenDone: () => void, @@ -361,8 +384,11 @@ export function createPiAgentAdapter( }) if (isError) { - throw new Error( - `pi-agent message_end error: ${msg.errorMessage ?? `unknown error`} (stopReason=${msg.stopReason ?? `none`})` + throw toModelProviderError( + new Error( + `pi-agent message_end error: ${msg.errorMessage ?? `unknown error`} (stopReason=${msg.stopReason ?? `none`})` + ), + { provider: model.provider, model: model.id } ) } break @@ -388,6 +414,20 @@ export function createPiAgentAdapter( } case `agent_end`: { + const messages = (event as Record).messages as + | Array<{ stopReason?: string; errorMessage?: string }> + | undefined + const errorMessage = messages?.find( + (message) => + message.stopReason === `error` && !!message.errorMessage + )?.errorMessage + if (errorMessage) { + throw toModelProviderError( + new Error(`pi-agent agent_end error: ${errorMessage}`), + { provider: model.provider, model: model.id } + ) + } + bridge.onRunEnd({ finishReason: abortedRun ? `aborted` : `stop`, }) @@ -451,6 +491,17 @@ export function createPiAgentAdapter( unsubscribe() bridge.onRunEnd({ finishReason }) } + const failWithProviderError = (err: unknown): ModelProviderError => { + const providerError = toModelProviderError(err, { + provider: model.provider, + model: model.id, + }) + bridge.onError({ + errorCode: providerError.code, + message: providerError.message, + }) + return providerError + } const abortRun = (): void => { if (settled) return abortedRun = true @@ -476,8 +527,9 @@ export function createPiAgentAdapter( }, (err) => { if (settled) return + const providerError = failWithProviderError(err) finish(`error`) - reject(err) + reject(providerError) } ) @@ -491,8 +543,9 @@ export function createPiAgentAdapter( Promise.resolve(runPromise).catch((err: Error) => { if (settled) return if (abortedRun) return + const providerError = failWithProviderError(err) finish(`error`) - reject(err) + reject(providerError) }) }) }, diff --git a/packages/agents-runtime/src/process-wake.ts b/packages/agents-runtime/src/process-wake.ts index 4b1e3d7df4..bbb6a02b18 100644 --- a/packages/agents-runtime/src/process-wake.ts +++ b/packages/agents-runtime/src/process-wake.ts @@ -13,6 +13,7 @@ import { unrestrictedSandbox } from './sandbox/unrestricted' import { resolveSandboxIdentity } from './sandbox/identity' import { appendPathToUrl } from './url' import { manifestChildKey } from './manifest-helpers' +import { ModelProviderError } from './model-provider-error' import { buildHydratedEventSourceWake, eventSourceWakeInfoFromManifests, @@ -2078,13 +2079,18 @@ export async function processWake( await waitForSignalHandlers() activeSignalHandler = null wakeSession.rollbackManifestEntries() - const errMsg = toError(setupErr).message + const err = toError(setupErr) + const errMsg = err.message + const errCode = + setupErr instanceof ModelProviderError + ? setupErr.code + : `HANDLER_FAILED` log.error(`handler failed for ${entityUrl}:`, errMsg) writeEvent( entityStateSchema.errors.insert({ key: `error-${epoch}-${crypto.randomUUID()}`, value: { - error_code: `HANDLER_FAILED`, + error_code: errCode, message: errMsg, } as never, }) as ChangeEvent diff --git a/packages/agents-runtime/src/types.ts b/packages/agents-runtime/src/types.ts index c10d9063c2..9f0509071f 100644 --- a/packages/agents-runtime/src/types.ts +++ b/packages/agents-runtime/src/types.ts @@ -858,6 +858,8 @@ export interface AgentConfig { provider: string ) => Promise | string | undefined onPayload?: SimpleStreamOptions[`onPayload`] + modelTimeoutMs?: number + modelMaxRetries?: number testResponses?: TestResponses } diff --git a/packages/agents-runtime/test/model-provider-error.test.ts b/packages/agents-runtime/test/model-provider-error.test.ts new file mode 100644 index 0000000000..cfb31349da --- /dev/null +++ b/packages/agents-runtime/test/model-provider-error.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { + classifyModelProviderError, + modelProviderErrorMessage, + toModelProviderError, +} from '../src/model-provider-error' + +describe(`model provider error classification`, () => { + it.each([ + [new Error(`fetch failed`), `MODEL_PROVIDER_UNREACHABLE`], + [new Error(`ENOTFOUND api.anthropic.com`), `MODEL_PROVIDER_UNREACHABLE`], + [new Error(`timeout`), `MODEL_PROVIDER_TIMEOUT`], + [new Error(`request timed out`), `MODEL_PROVIDER_TIMEOUT`], + [new Error(`401 invalid api key`), `MODEL_PROVIDER_AUTH_FAILED`], + [new Error(`authentication failed`), `MODEL_PROVIDER_AUTH_FAILED`], + [new Error(`429 rate limit`), `MODEL_PROVIDER_RATE_LIMITED`], + [new Error(`503 overloaded`), `MODEL_PROVIDER_UNAVAILABLE`], + [new Error(`something unexpected`), `MODEL_PROVIDER_ERROR`], + ] as const)(`classifies %s as %s`, (error, code) => { + expect(classifyModelProviderError(error)).toBe(code) + }) + + it(`creates friendly provider-specific messages with original detail`, () => { + const error = toModelProviderError(new Error(`fetch failed`), { + provider: `anthropic`, + model: `claude-sonnet-4-5`, + }) + + expect(error.code).toBe(`MODEL_PROVIDER_UNREACHABLE`) + expect(error.message).toContain(`Could not reach Anthropic`) + expect(error.message).toContain(`fetch failed`) + }) + + it(`has a timeout message`, () => { + expect( + modelProviderErrorMessage({ + code: `MODEL_PROVIDER_TIMEOUT`, + provider: `openai`, + }) + ).toContain(`OpenAI did not respond`) + }) +}) diff --git a/packages/agents-runtime/test/pi-adapter.test.ts b/packages/agents-runtime/test/pi-adapter.test.ts index 3c33b6cb71..7ce3fd7b4d 100644 --- a/packages/agents-runtime/test/pi-adapter.test.ts +++ b/packages/agents-runtime/test/pi-adapter.test.ts @@ -5,9 +5,11 @@ import { toAgentHistory, } from '../src/pi-adapter' import { createAssistantMessageEventStream } from '@mariozechner/pi-ai' +import { Type } from '@sinclair/typebox' import type { OutboundIdSeed } from '../src/outbound-bridge' import type { LLMMessage } from '../src/types' import type { ChangeEvent } from '@durable-streams/state' +import type { AgentTool } from '@mariozechner/pi-agent-core' import type { AssistantMessage, Model, @@ -125,6 +127,333 @@ describe(`createPiAgentAdapter`, () => { expect(handle.isRunning()).toBe(false) }) + it(`passes timeout and retry options to each provider stream call`, async () => { + const seenOptions: Array<{ timeoutMs?: number; maxRetries?: number }> = [] + const completedMessage: AssistantMessage = { + role: `assistant`, + content: [{ type: `text`, text: `ok` }], + api: `anthropic-messages`, + provider: `anthropic`, + model: `claude-sonnet-4-5-20250929`, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }, + stopReason: `stop`, + timestamp: Date.now(), + } + + const factory = createPiAgentAdapter({ + systemPrompt: `Test system prompt`, + model: `claude-sonnet-4-5-20250929`, + tools: [], + modelTimeoutMs: 1234, + modelMaxRetries: 2, + streamFn: (_model, _context, options) => { + seenOptions.push({ + timeoutMs: options?.timeoutMs, + maxRetries: options?.maxRetries, + }) + const stream = createAssistantMessageEventStream() + queueMicrotask(() => stream.end(completedMessage)) + return stream + }, + }) + + const handle = factory({ + entityUrl: `test/entity-1`, + epoch: 1, + messages: [], + outboundIdSeed: { run: 0, step: 0, msg: 0, tc: 0 }, + writeEvent: (_event: ChangeEvent) => {}, + }) + + await handle.run(`hello`) + + expect(seenOptions).toEqual([{ timeoutMs: 1234, maxRetries: 2 }]) + }) + + it(`preserves existing stream options while injecting timeout and retry options`, async () => { + let capturedSignal: AbortSignal | undefined + const completedMessage: AssistantMessage = { + role: `assistant`, + content: [{ type: `text`, text: `ok` }], + api: `anthropic-messages`, + provider: `anthropic`, + model: `claude-sonnet-4-5-20250929`, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }, + stopReason: `stop`, + timestamp: Date.now(), + } + + const factory = createPiAgentAdapter({ + systemPrompt: `Test system prompt`, + model: `claude-sonnet-4-5-20250929`, + tools: [], + modelTimeoutMs: 1234, + modelMaxRetries: 2, + streamFn: (_model, _context, options) => { + capturedSignal = options?.signal + expect(options?.timeoutMs).toBe(1234) + expect(options?.maxRetries).toBe(2) + const stream = createAssistantMessageEventStream() + queueMicrotask(() => stream.end(completedMessage)) + return stream + }, + }) + + const handle = factory({ + entityUrl: `test/entity-1`, + epoch: 1, + messages: [], + outboundIdSeed: { run: 0, step: 0, msg: 0, tc: 0 }, + writeEvent: (_event: ChangeEvent) => {}, + }) + + await handle.run(`hello`) + + expect(capturedSignal).toBeInstanceOf(AbortSignal) + }) + + it(`converts stream-encoded provider errors into model provider errors`, async () => { + const events: Array = [] + const errorMessage: AssistantMessage = { + role: `assistant`, + content: [{ type: `text`, text: `` }], + api: `anthropic-messages`, + provider: `anthropic`, + model: `claude-sonnet-4-5-20250929`, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }, + stopReason: `error`, + errorMessage: `request timed out`, + timestamp: Date.now(), + } + + const factory = createPiAgentAdapter({ + systemPrompt: `Test system prompt`, + model: `claude-sonnet-4-5-20250929`, + tools: [], + streamFn: () => { + const stream = createAssistantMessageEventStream() + queueMicrotask(() => stream.end(errorMessage)) + return stream + }, + }) + + const handle = factory({ + entityUrl: `test/entity-1`, + epoch: 1, + messages: [], + outboundIdSeed: { run: 0, step: 0, msg: 0, tc: 0 }, + writeEvent: (event: ChangeEvent) => events.push(event), + }) + + await expect(handle.run(`hello`)).rejects.toMatchObject({ + name: `ModelProviderError`, + code: `MODEL_PROVIDER_TIMEOUT`, + }) + + expect(events).toContainEqual( + expect.objectContaining({ + type: `error`, + headers: expect.objectContaining({ operation: `insert` }), + value: expect.objectContaining({ + error_code: `MODEL_PROVIDER_TIMEOUT`, + run_id: `run-0`, + step_id: expect.stringMatching(/^step-/), + }), + }) + ) + expect(events).toContainEqual( + expect.objectContaining({ + type: `run`, + headers: expect.objectContaining({ operation: `update` }), + key: `run-0`, + value: expect.objectContaining({ + status: `failed`, + finish_reason: `error`, + }), + }) + ) + }) + + it(`converts thrown provider errors into model provider errors`, async () => { + const events: Array = [] + const factory = createPiAgentAdapter({ + systemPrompt: `Test system prompt`, + model: `claude-sonnet-4-5-20250929`, + tools: [], + streamFn: () => { + throw new Error(`request timed out`) + }, + }) + + const handle = factory({ + entityUrl: `test/entity-1`, + epoch: 1, + messages: [], + outboundIdSeed: { run: 0, step: 0, msg: 0, tc: 0 }, + writeEvent: (event: ChangeEvent) => events.push(event), + }) + + await expect(handle.run(`hello`)).rejects.toMatchObject({ + name: `ModelProviderError`, + code: `MODEL_PROVIDER_TIMEOUT`, + }) + + expect(events).toContainEqual( + expect.objectContaining({ + type: `error`, + headers: expect.objectContaining({ operation: `insert` }), + value: expect.objectContaining({ + error_code: `MODEL_PROVIDER_TIMEOUT`, + run_id: `run-0`, + }), + }) + ) + expect(events).toContainEqual( + expect.objectContaining({ + type: `run`, + headers: expect.objectContaining({ operation: `update` }), + key: `run-0`, + value: expect.objectContaining({ + status: `failed`, + finish_reason: `error`, + }), + }) + ) + }) + + it(`does not treat model timeout as a whole-run timeout during tool execution`, async () => { + const seenOptions: Array<{ timeoutMs?: number; maxRetries?: number }> = [] + const usage = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + } + const toolCallMessage: AssistantMessage = { + role: `assistant`, + content: [ + { + type: `toolCall`, + id: `call-1`, + name: `slow_tool`, + arguments: {}, + }, + ], + api: `anthropic-messages`, + provider: `anthropic`, + model: `claude-sonnet-4-5-20250929`, + usage, + stopReason: `toolUse`, + timestamp: Date.now(), + } + const completedMessage: AssistantMessage = { + role: `assistant`, + content: [{ type: `text`, text: `done` }], + api: `anthropic-messages`, + provider: `anthropic`, + model: `claude-sonnet-4-5-20250929`, + usage, + stopReason: `stop`, + timestamp: Date.now(), + } + const tools: Array = [ + { + name: `slow_tool`, + label: `Slow tool`, + description: `Sleeps longer than the model timeout`, + parameters: Type.Object({}), + execute: async () => { + await new Promise((resolve) => setTimeout(resolve, 30)) + return { + content: [{ type: `text`, text: `tool result` }], + details: {}, + } + }, + }, + ] + + const factory = createPiAgentAdapter({ + systemPrompt: `Test system prompt`, + model: `claude-sonnet-4-5-20250929`, + tools, + modelTimeoutMs: 5, + modelMaxRetries: 2, + streamFn: (_model, _context, options) => { + seenOptions.push({ + timeoutMs: options?.timeoutMs, + maxRetries: options?.maxRetries, + }) + const stream = createAssistantMessageEventStream() + const message = + seenOptions.length === 1 ? toolCallMessage : completedMessage + queueMicrotask(() => stream.end(message)) + return stream + }, + }) + + const handle = factory({ + entityUrl: `test/entity-1`, + epoch: 1, + messages: [], + outboundIdSeed: { run: 0, step: 0, msg: 0, tc: 0 }, + writeEvent: (_event: ChangeEvent) => {}, + }) + + await expect(handle.run(`hello`)).resolves.toBeUndefined() + + expect(seenOptions).toEqual([ + { timeoutMs: 5, maxRetries: 2 }, + { timeoutMs: 5, maxRetries: 2 }, + ]) + }) + it(`settles an aborted run even if the model stream does not emit completion`, async () => { const factory = createPiAgentAdapter({ systemPrompt: `Test system prompt`, diff --git a/packages/agents-server-ui/src/components/AgentResponse.tsx b/packages/agents-server-ui/src/components/AgentResponse.tsx index 722f56fef8..2fadacfb84 100644 --- a/packages/agents-server-ui/src/components/AgentResponse.tsx +++ b/packages/agents-server-ui/src/components/AgentResponse.tsx @@ -322,16 +322,14 @@ function liveRunItemsToContentItems( return contentItems } +function formatError(error: EntityTimelineErrorItem): string { + return error.error_code + ? `${error.error_code}: ${error.message}` + : error.message +} + function errorText(errors: Array): string | undefined { - return errors.length > 0 - ? errors - .map((error) => - error.error_code - ? `${error.error_code}: ${error.message}` - : error.message - ) - .join(`; `) - : undefined + return errors.length > 0 ? errors.map(formatError).join(`; `) : undefined } function failedRunText( @@ -714,18 +712,6 @@ export const AgentResponse = memo(function AgentResponse({ )} - {section.done && copyText && ( - - )} )