Merge pull request #4453 from code-yeongyu/fix-4419-atlas-subagent-fallback
fix(background-agent): atlas subagent fallback retry on retryable model errors (#4419)
This commit is contained in:
@@ -0,0 +1,18 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { shouldRetryError } from "./model-error-classifier"
|
||||||
|
|
||||||
|
describe("model-error-classifier OpenAI usage_limit_reached", () => {
|
||||||
|
test("treats OpenAI usage_limit_reached response bodies as retryable provider exhaustion", () => {
|
||||||
|
//#given
|
||||||
|
const error = {
|
||||||
|
name: "AI_APICallError",
|
||||||
|
message: '{"error":{"type":"usage_limit_reached","message":"The usage limit has been reached"}}',
|
||||||
|
}
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const result = shouldRetryError(error)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -172,7 +172,7 @@ describe("model-error-classifier", () => {
|
|||||||
expect(result).toBe(false)
|
expect(result).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("treats usage limit reached message as non-retryable STOP error (no error name)", () => {
|
test("treats provider usage limit reached message as retryable fallback signal", () => {
|
||||||
//#given
|
//#given
|
||||||
const error = { message: "usage limit has been reached for your account" }
|
const error = { message: "usage limit has been reached for your account" }
|
||||||
|
|
||||||
@@ -180,7 +180,7 @@ describe("model-error-classifier", () => {
|
|||||||
const result = shouldRetryError(error)
|
const result = shouldRetryError(error)
|
||||||
|
|
||||||
//#then
|
//#then
|
||||||
expect(result).toBe(false)
|
expect(result).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("treats insufficient credits message as non-retryable STOP error (no error name)", () => {
|
test("treats insufficient credits message as non-retryable STOP error (no error name)", () => {
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ const NON_RETRYABLE_ERROR_NAMES = new Set([
|
|||||||
const RETRYABLE_MESSAGE_PATTERNS = [
|
const RETRYABLE_MESSAGE_PATTERNS = [
|
||||||
"rate_limit",
|
"rate_limit",
|
||||||
"rate limit",
|
"rate limit",
|
||||||
|
"usage_limit_reached",
|
||||||
|
"usage limit has been reached",
|
||||||
"quota",
|
"quota",
|
||||||
"all credentials for model",
|
"all credentials for model",
|
||||||
"cooling down",
|
"cooling down",
|
||||||
@@ -92,7 +94,6 @@ const RETRYABLE_MESSAGE_PATTERNS = [
|
|||||||
const STOP_MESSAGE_PATTERNS = [
|
const STOP_MESSAGE_PATTERNS = [
|
||||||
"quota will reset after",
|
"quota will reset after",
|
||||||
"quota exceeded",
|
"quota exceeded",
|
||||||
"usage limit has been reached",
|
|
||||||
"free usage limit",
|
"free usage limit",
|
||||||
"billing limit",
|
"billing limit",
|
||||||
"billing hard limit",
|
"billing hard limit",
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
/// <reference types="bun-types" />
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||||
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
|
import { _resetMemCacheForTesting as resetConnectedProvidersCacheForTesting } from "../../shared/connected-providers-cache"
|
||||||
|
import { releaseAllPromptAsyncReservationsForTesting } from "../../shared/prompt-async-gate"
|
||||||
|
import {
|
||||||
|
getSessionAgent,
|
||||||
|
_resetForTesting as resetClaudeCodeSessionState,
|
||||||
|
subagentSessions,
|
||||||
|
} from "../claude-code-session-state"
|
||||||
|
import { BackgroundManager } from "./manager"
|
||||||
|
import { clearBackgroundTaskRegistryForTesting } from "./task-registry"
|
||||||
|
|
||||||
|
type SessionGetArgs = { readonly path: { readonly id: string } }
|
||||||
|
type SessionCreateArgs = {
|
||||||
|
readonly body?: {
|
||||||
|
readonly parentID?: string
|
||||||
|
readonly model?: { readonly providerID?: string; readonly id?: string; readonly variant?: string }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type PromptCall = { readonly path: { readonly id: string }; readonly body?: unknown }
|
||||||
|
|
||||||
|
const originalXdgCacheHome = process.env.XDG_CACHE_HOME
|
||||||
|
const testDirectory = "/tmp/omo-atlas-fallback-test"
|
||||||
|
let cacheCounter = 0
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.XDG_CACHE_HOME = `${testDirectory}/cache-${cacheCounter}`
|
||||||
|
cacheCounter += 1
|
||||||
|
resetConnectedProvidersCacheForTesting()
|
||||||
|
resetClaudeCodeSessionState()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalXdgCacheHome === undefined) {
|
||||||
|
delete process.env.XDG_CACHE_HOME
|
||||||
|
} else {
|
||||||
|
process.env.XDG_CACHE_HOME = originalXdgCacheHome
|
||||||
|
}
|
||||||
|
resetConnectedProvidersCacheForTesting()
|
||||||
|
resetClaudeCodeSessionState()
|
||||||
|
clearBackgroundTaskRegistryForTesting()
|
||||||
|
releaseAllPromptAsyncReservationsForTesting()
|
||||||
|
})
|
||||||
|
|
||||||
|
function createPluginInput(client: unknown, directory: string): PluginInput {
|
||||||
|
return { client, directory } as PluginInput
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushAsyncWork(cycles = 30): Promise<void> {
|
||||||
|
for (let index = 0; index < cycles; index++) {
|
||||||
|
await Promise.resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAtlasHarness(): {
|
||||||
|
readonly manager: BackgroundManager
|
||||||
|
readonly createdSessions: Array<{ readonly id: string; readonly body: SessionCreateArgs["body"] }>
|
||||||
|
readonly promptCalls: PromptCall[]
|
||||||
|
readonly markSessionMissing: (sessionID: string) => void
|
||||||
|
} {
|
||||||
|
const directory = testDirectory
|
||||||
|
const sessionAlive = new Map<string, boolean>([["atlas-parent", true]])
|
||||||
|
const createdSessions: Array<{ readonly id: string; readonly body: SessionCreateArgs["body"] }> = []
|
||||||
|
const promptCalls: PromptCall[] = []
|
||||||
|
const sessionIDs = ["ses_primary", "ses_fallback"]
|
||||||
|
|
||||||
|
const client = {
|
||||||
|
session: {
|
||||||
|
get: async ({ path }: SessionGetArgs) => {
|
||||||
|
if (path.id === "atlas-parent") {
|
||||||
|
return { data: { id: path.id, directory, parentID: undefined } }
|
||||||
|
}
|
||||||
|
if (sessionAlive.get(path.id)) {
|
||||||
|
return { data: { id: path.id, directory, parentID: "atlas-parent" } }
|
||||||
|
}
|
||||||
|
return { error: { status: 404, message: `session ${path.id} not found` } }
|
||||||
|
},
|
||||||
|
create: async (args: SessionCreateArgs) => {
|
||||||
|
const id = sessionIDs[createdSessions.length] ?? `ses_extra_${createdSessions.length}`
|
||||||
|
createdSessions.push({ id, body: args.body })
|
||||||
|
sessionAlive.set(id, true)
|
||||||
|
return { data: { id } }
|
||||||
|
},
|
||||||
|
promptAsync: async (args: PromptCall) => {
|
||||||
|
promptCalls.push(args)
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
abort: async ({ path }: SessionGetArgs) => {
|
||||||
|
sessionAlive.set(path.id, false)
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const manager = new BackgroundManager({ pluginContext: createPluginInput(client, directory) })
|
||||||
|
|
||||||
|
return {
|
||||||
|
manager,
|
||||||
|
createdSessions,
|
||||||
|
promptCalls,
|
||||||
|
markSessionMissing: (sessionID: string) => sessionAlive.set(sessionID, false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function launchAtlasOracleSubagent(manager: BackgroundManager): Promise<string> {
|
||||||
|
const task = await manager.launch({
|
||||||
|
description: "Atlas oracle subagent",
|
||||||
|
prompt: "Investigate fallback behavior",
|
||||||
|
agent: "oracle",
|
||||||
|
parentSessionId: "atlas-parent",
|
||||||
|
parentMessageId: "atlas-message",
|
||||||
|
parentAgent: "atlas",
|
||||||
|
model: { providerID: "openai", modelID: "gpt-5.5", variant: "high" },
|
||||||
|
fallbackChain: [
|
||||||
|
{ providers: ["github-copilot"], model: "claude-sonnet-4.6", variant: "high" },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
await flushAsyncWork()
|
||||||
|
return task.id
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitUsageLimitError(manager: BackgroundManager, sessionID: string): void {
|
||||||
|
manager.handleEvent({
|
||||||
|
type: "session.error",
|
||||||
|
properties: {
|
||||||
|
sessionID,
|
||||||
|
error: {
|
||||||
|
name: "AI_APICallError",
|
||||||
|
data: {
|
||||||
|
error: {
|
||||||
|
type: "usage_limit_reached",
|
||||||
|
message: "The usage limit has been reached",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Atlas-spawned subagent runtime fallback", () => {
|
||||||
|
test("retries oracle subagent on OpenAI usage_limit_reached and registers the fallback session", async () => {
|
||||||
|
//#given
|
||||||
|
const { manager, createdSessions, promptCalls } = createAtlasHarness()
|
||||||
|
const taskID = await launchAtlasOracleSubagent(manager)
|
||||||
|
|
||||||
|
//#when
|
||||||
|
emitUsageLimitError(manager, "ses_primary")
|
||||||
|
await flushAsyncWork(60)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
const task = manager.getTask(taskID)
|
||||||
|
expect(task?.status).toBe("running")
|
||||||
|
expect(task?.sessionId).toBe("ses_fallback")
|
||||||
|
expect(task?.model).toEqual({ providerID: "github-copilot", modelID: "claude-sonnet-4.6", variant: "high" })
|
||||||
|
expect(task?.attemptCount).toBe(1)
|
||||||
|
expect(createdSessions).toHaveLength(2)
|
||||||
|
expect(createdSessions[1]?.body?.model).toEqual({ providerID: "github-copilot", id: "claude-sonnet-4.6", variant: "high" })
|
||||||
|
expect(promptCalls).toHaveLength(2)
|
||||||
|
expect(subagentSessions.has("ses_primary")).toBe(false)
|
||||||
|
expect(subagentSessions.has("ses_fallback")).toBe(true)
|
||||||
|
expect(getSessionAgent("ses_fallback")).toBe("oracle")
|
||||||
|
|
||||||
|
manager.shutdown()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("surfaces non-retryable oracle subagent errors without creating a fallback session", async () => {
|
||||||
|
//#given
|
||||||
|
const { manager, createdSessions, markSessionMissing } = createAtlasHarness()
|
||||||
|
const taskID = await launchAtlasOracleSubagent(manager)
|
||||||
|
markSessionMissing("ses_primary")
|
||||||
|
|
||||||
|
//#when
|
||||||
|
manager.handleEvent({
|
||||||
|
type: "session.error",
|
||||||
|
properties: {
|
||||||
|
sessionID: "ses_primary",
|
||||||
|
error: { name: "PermissionDeniedError", data: { message: "permission denied" } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await flushAsyncWork(60)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
const task = manager.getTask(taskID)
|
||||||
|
expect(task?.status).toBe("error")
|
||||||
|
expect(task?.error).toBe("permission denied")
|
||||||
|
expect(createdSessions).toHaveLength(1)
|
||||||
|
|
||||||
|
manager.shutdown()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("marks oracle subagent errored when usage_limit_reached exhausts all fallbacks", async () => {
|
||||||
|
//#given
|
||||||
|
const { manager, createdSessions, markSessionMissing } = createAtlasHarness()
|
||||||
|
const taskID = await launchAtlasOracleSubagent(manager)
|
||||||
|
emitUsageLimitError(manager, "ses_primary")
|
||||||
|
await flushAsyncWork(60)
|
||||||
|
markSessionMissing("ses_fallback")
|
||||||
|
|
||||||
|
//#when
|
||||||
|
emitUsageLimitError(manager, "ses_fallback")
|
||||||
|
await flushAsyncWork(60)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
const task = manager.getTask(taskID)
|
||||||
|
expect(task?.status).toBe("error")
|
||||||
|
expect(task?.error).toBe("The usage limit has been reached")
|
||||||
|
expect(task?.attemptCount).toBe(1)
|
||||||
|
expect(createdSessions).toHaveLength(2)
|
||||||
|
|
||||||
|
manager.shutdown()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -104,6 +104,15 @@ export function getSessionErrorMessage(properties: EventPropertiesLike): string
|
|||||||
if (isRecord(dataRaw)) {
|
if (isRecord(dataRaw)) {
|
||||||
const message = dataRaw["message"]
|
const message = dataRaw["message"]
|
||||||
if (typeof message === "string") return message
|
if (typeof message === "string") return message
|
||||||
|
|
||||||
|
const nestedError = dataRaw["error"]
|
||||||
|
if (isRecord(nestedError)) {
|
||||||
|
const nestedMessage = nestedError["message"]
|
||||||
|
if (typeof nestedMessage === "string") return nestedMessage
|
||||||
|
|
||||||
|
const nestedType = nestedError["type"]
|
||||||
|
if (typeof nestedType === "string") return nestedType
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = errorRaw["message"]
|
const message = errorRaw["message"]
|
||||||
|
|||||||
Reference in New Issue
Block a user