diff --git a/Dockerfile b/Dockerfile index bee2a39..0c3f8ec 100644 --- a/Dockerfile +++ b/Dockerfile @@ -89,7 +89,5 @@ VOLUME /debug # disable annoying feature of BUN that makes it so source lines show up in error output ENV BUN_DISABLE_SOURCE_CODE_PREVIEW=1 -# byte-level socket idle backstop; kept above the agent-loop idle timer (300s) so the agent-loop's non-throwing retry always fires first on stalled streams -ENV BUN_CONFIG_HTTP_IDLE_TIMEOUT=600 ENTRYPOINT ["bun", "/ci-agent/source/index.mts"] diff --git a/source/agent-loop.mts b/source/agent-loop.mts index cc4cf1e..2d6be35 100644 --- a/source/agent-loop.mts +++ b/source/agent-loop.mts @@ -90,8 +90,8 @@ function toWireTools(tools: readonly Tool[]): CompletionsRequest['tools'] { }) } -export async function* agentLoop(dependencies: { fetch: Fetch }, model: string, messages: readonly CompletionsMessage[], tools: readonly Tool[], profile: ProviderProfile, signal?: AbortSignal, outputValidator?: OutputValidator, idleTimeoutMilliseconds: number = 300_000): AsyncGenerator { - // Must stay below BUN_CONFIG_HTTP_IDLE_TIMEOUT in Dockerfile so this non-throwing retry fires before Bun's socket timer throws a DOMException. +export async function* agentLoop(dependencies: { fetch: Fetch }, model: string, messages: readonly CompletionsMessage[], tools: readonly Tool[], profile: ProviderProfile, signal?: AbortSignal, outputValidator?: OutputValidator, idleTimeoutMilliseconds: number = 240_000): AsyncGenerator { + // Must stay below Bun's socket idle timeout (hard-coded 300s on 1.3.12; tunable via BUN_CONFIG_HTTP_IDLE_TIMEOUT in a future Bun) so this non-throwing retry fires first on a stalled stream. const toolMap = new Map(tools.map(tool => [tool.name, tool])) const wireTools = toWireTools(tools) const mutableMessages: CompletionsMessage[] = [...messages] diff --git a/source/ai.mts b/source/ai.mts index 2328e65..adce05d 100644 --- a/source/ai.mts +++ b/source/ai.mts @@ -41,8 +41,8 @@ export function parseAiConfiguration(environment: Record(base: T, override: T): T { // DOMException is instanceof Error but Bun's console renders it as a generic object dump, so rethrow error-likes as real Errors. export async function normalizeFetchError(promise: Promise): Promise { + // Native fetch DOMExceptions carry no JS stack and `await` unwinds the caller chain, so capture it here before suspending. + const entryStack = new Error().stack try { return await promise } catch (error) { if (error instanceof Error && !(error instanceof DOMException)) throw error if (isRecord(error) && isString(error.message)) { const name = isString(error.name) ? error.name : undefined - throw new Error(name !== undefined ? `${name}: ${error.message}` : error.message, { cause: error }) + const normalized = new Error(name !== undefined ? `${name}: ${error.message}` : error.message, { cause: error }) + if (isString(entryStack)) normalized.stack = `${normalized.toString()}\n${entryStack.slice(entryStack.indexOf('\n') + 1)}` + throw normalized } - throw new Error(String(error), { cause: error }) + const fallback = new Error(String(error), { cause: error }) + if (isString(entryStack)) fallback.stack = `${fallback.toString()}\n${entryStack.slice(entryStack.indexOf('\n') + 1)}` + throw fallback } } diff --git a/tests/agent-loop.test.mts b/tests/agent-loop.test.mts index f6dd5d6..ca7df8a 100644 --- a/tests/agent-loop.test.mts +++ b/tests/agent-loop.test.mts @@ -994,7 +994,7 @@ describe("agentLoop", () => { }) describe("default idle timeout", () => { - it("defaults to 300000ms", async () => { + it("defaults to 240000ms", async () => { const { fetch } = createFetchWithSignal(() => buildSse([chunk({ content: "ok" }), chunk({}, "stop")]), ) diff --git a/tests/ai.test.mts b/tests/ai.test.mts index 91986ff..d67bc9b 100644 --- a/tests/ai.test.mts +++ b/tests/ai.test.mts @@ -466,7 +466,7 @@ describe("createFetch", () => { const farFutureEpochSeconds = (BASE_TIME + 10 * 60 * 1000) / 1000 const { fetch, delays } = buildFetch([response(429, { "X-RateLimit-Reset": String(farFutureEpochSeconds) }), response(200)]) await fetch(new AbortController().signal, "body") - expect(delays()).toEqual([240_000]) + expect(delays()).toEqual([180_000]) }) it("returns the 429 without sleeping when the deadline is already exhausted after a fetch", async () => {