mirror of
https://github.com/JackChen-me/open-multi-agent.git
synced 2026-10-19 01:29:57 -05:00
Follow-up to #548, which introduced VideoBlock. Two gaps were missed when that PR was reviewed. The four adapters that cannot map video threw a bare Error. isRetryableError returns true for any Error without a status, so executeWithRetry treated a deterministic capability gap as transient: it ran the full backoff ladder and rewrote a checkpoint per attempt before failing the same way, and the failure was recorded as retryable. UnsupportedContentBlockError makes that terminal, names the adapter that rejected the block, and carries a code alongside the existing UnsupportedToolResultContentError. VideoBlock is the first top-level content block with a URL source, so it inherited no validation. That source is dereferenced by the provider rather than by OMA, and a file: path or a data URL pasted into the field was forwarded verbatim; an unvalidated media_type could also alter the data URL built for inline bytes. Public entry points now run video sources through the same copyMediaSource rules already applied to tool-result media, so the documented HTTP(S) constraint is enforced rather than aspirational. Provider-private upload handles such as MiniMax's mm_file:// are rejected: supporting them needs a source variant of its own, not an unchecked string. Also from the same review: - estimateTokens charged an inline video the 64-character fixed cost images get, hiding a multi-megabyte payload from every context strategy keyed off the estimate. It is now sized like rich tool-result media, so the attachment that needs compacting is the one that triggers compaction. - stripImageBlocksForSummary handles video too, so it is now stripMediaBlocksForSummary. - The two double casts in openai-common are narrowed to a single seam, and the video_url extension type now records that it is not an OpenAI part. - docs/structured-input.md states the rule the adapters actually follow: OpenAI-shaped adapters forward and let the provider decide, adapters with a typed content union reject locally. Bedrock and Gemini support video upstream, so the gap there is OMA's mapping rather than theirs.
107 lines
3.8 KiB
TypeScript
107 lines
3.8 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import OpenAI from 'openai'
|
|
import {
|
|
isRetryableError,
|
|
InvalidTaskRequirementsError,
|
|
TokenBudgetExceededError,
|
|
InvalidMessageError,
|
|
LLMCallTimeoutError,
|
|
StructuredOutputValidationError,
|
|
UnsupportedToolCallError,
|
|
UnsupportedToolResultContentError,
|
|
UnsupportedContentBlockError,
|
|
} from '../src/errors.js'
|
|
import { classifyRunFailure } from '../src/observability/status.js'
|
|
|
|
describe('isRetryableError', () => {
|
|
it('treats terminal 4xx client errors as non-retryable', () => {
|
|
for (const status of [400, 401, 403, 404, 422]) {
|
|
expect(isRetryableError({ status })).toBe(false)
|
|
}
|
|
})
|
|
|
|
it('treats 408 / 409 / 429 as retryable', () => {
|
|
for (const status of [408, 409, 429]) {
|
|
expect(isRetryableError({ status })).toBe(true)
|
|
}
|
|
})
|
|
|
|
it('treats 5xx server errors as retryable', () => {
|
|
for (const status of [500, 502, 503, 504]) {
|
|
expect(isRetryableError({ status })).toBe(true)
|
|
}
|
|
})
|
|
|
|
it('treats network / no-status errors as retryable', () => {
|
|
expect(isRetryableError(new Error('ECONNRESET'))).toBe(true)
|
|
expect(isRetryableError(new Error('socket hang up'))).toBe(true)
|
|
expect(isRetryableError('some string')).toBe(true)
|
|
expect(isRetryableError(undefined)).toBe(true)
|
|
})
|
|
|
|
it('reads status from `.statusCode` as well as `.status`', () => {
|
|
expect(isRetryableError({ statusCode: 401 })).toBe(false)
|
|
expect(isRetryableError({ statusCode: 503 })).toBe(true)
|
|
})
|
|
|
|
it('classifies budget, invalid-message, and task-requirement errors as terminal', () => {
|
|
expect(isRetryableError(new TokenBudgetExceededError('a', 100, 50))).toBe(false)
|
|
expect(isRetryableError(new InvalidMessageError('bad'))).toBe(false)
|
|
expect(isRetryableError(new StructuredOutputValidationError())).toBe(false)
|
|
expect(isRetryableError(new UnsupportedToolCallError('openai', 'custom'))).toBe(false)
|
|
expect(isRetryableError(
|
|
new UnsupportedToolResultContentError('openai', 'file-url'),
|
|
)).toBe(false)
|
|
expect(isRetryableError(new InvalidTaskRequirementsError([{
|
|
code: 'NO_ELIGIBLE_AGENT',
|
|
taskId: 'task-1',
|
|
taskTitle: 'Restricted',
|
|
reasons: ['worker excluded'],
|
|
}]))).toBe(false)
|
|
})
|
|
|
|
it('classifies an unsupported content block as terminal', () => {
|
|
// A block the adapter has no mapping for cannot become mappable on a
|
|
// second attempt, so retrying only spends the backoff ladder and a
|
|
// checkpoint rewrite per attempt before failing the same way.
|
|
const error = new UnsupportedContentBlockError('Google Gemini', 'video')
|
|
expect(isRetryableError(error)).toBe(false)
|
|
expect(error.code).toBe('UNSUPPORTED_CONTENT_BLOCK')
|
|
expect(error.message).toContain('Google Gemini')
|
|
expect(error.message).toContain('video')
|
|
expect(classifyRunFailure(error).errorInfo.retryable).toBe(false)
|
|
})
|
|
|
|
it('classifies a per-call timeout as retryable', () => {
|
|
expect(isRetryableError(new LLMCallTimeoutError(1000, 'agent'))).toBe(true)
|
|
})
|
|
|
|
it('classifies an AbortError as terminal', () => {
|
|
const err = new Error('aborted')
|
|
err.name = 'AbortError'
|
|
expect(isRetryableError(err)).toBe(false)
|
|
})
|
|
|
|
it('classifies the OpenAI SDK APIUserAbortError as terminal', () => {
|
|
const err = new OpenAI.APIUserAbortError()
|
|
expect(isRetryableError(err)).toBe(false)
|
|
})
|
|
|
|
it('classifies the real OpenAI SDK abort as cancellation', () => {
|
|
const err = new OpenAI.APIUserAbortError()
|
|
expect(classifyRunFailure(err, { provider: 'openai' })).toMatchObject({
|
|
status: { code: 'cancelled' },
|
|
errorInfo: {
|
|
kind: 'cancellation',
|
|
retryable: false,
|
|
provider: 'openai',
|
|
},
|
|
})
|
|
})
|
|
|
|
it('ignores a non-numeric status (defaults to retryable)', () => {
|
|
expect(isRetryableError({ status: 'nope' })).toBe(true)
|
|
expect(isRetryableError({ status: NaN })).toBe(true)
|
|
})
|
|
})
|