From 7d870443725907aa2a77705b85e4d64aa6cc5bdd Mon Sep 17 00:00:00 2001 From: Sasha Malahov Date: Thu, 9 Jul 2026 16:49:37 -0400 Subject: [PATCH] fix(harness): add retry, fix SSE tool-call parsing, remove dead AWS deps - Wrap network fetch in retryWithBackoff (3 retries, exponential backoff) - Fix SSE parser to handle args-before-name model output ordering - Accept tool calls without explicit ID (generated fallback) - Remove unused @smithy/eventstream-codec, @smithy/util-utf8, aws4fetch deps - Export retryWithBackoff from public API Also removes ~5000 lines of tracked node_modules from git history. --- package.json | 3 -- src/client.ts | 4 ++- src/index.ts | 4 +++ src/protocols/sse-parser.ts | 62 +++++++++++++++++++++++++------------ src/retry.ts | 35 +++++++++++++++++++++ 5 files changed, 84 insertions(+), 24 deletions(-) create mode 100644 src/retry.ts diff --git a/package.json b/package.json index 5333fd9..5666df8 100644 --- a/package.json +++ b/package.json @@ -19,9 +19,6 @@ "prepare": "npm run build" }, "dependencies": { - "@smithy/eventstream-codec": "4.2.14", - "@smithy/util-utf8": "4.2.2", - "aws4fetch": "1.0.20", "effect": "4.0.0-beta.91" }, "devDependencies": { diff --git a/src/client.ts b/src/client.ts index a5206d5..413a37f 100644 --- a/src/client.ts +++ b/src/client.ts @@ -43,6 +43,7 @@ type Usage = Schema.Schema.Type import { buildOpenAIChatBody, buildOpenAIChatURL, buildOpenAIChatHeaders, buildOpenAIChatStreamBody } from "./protocols/openai-chat.js" import { streamFromBody, mapFinishReason } from "./protocols/sse-parser.js" import { isRecord } from "./utils/record.js" +import { retryWithBackoff } from "./retry.js" // --- Configuration --- @@ -197,7 +198,7 @@ export const makeLLMClient = Layer.effect( const url = buildOpenAIChatURL(baseUrl) const headers = buildOpenAIChatHeaders(apiKey) - return Effect.tryPromise({ + return retryWithBackoff(Effect.tryPromise({ try: () => fetch(url, { method: "POST", headers, body: JSON.stringify(finalBody) }), catch: (error) => ({ _tag: "APIError" as const, @@ -283,6 +284,7 @@ export const makeLLMClient = Layer.effect( })) }), ) + ) } const generateObject = (request: LLMRequest): Effect.Effect => { diff --git a/src/index.ts b/src/index.ts index f97ae7a..d876891 100644 --- a/src/index.ts +++ b/src/index.ts @@ -82,6 +82,10 @@ export { export { LLMClient, LLMClientLayer, LLMConfig, simpleRequest, simpleStream, makeLLMClient } from "./client.js" export type { LLMClientShape } from "./client.js" +// Retry +export { retryWithBackoff } from "./retry.js" +export type { RetryConfig } from "./retry.js" + // Cache export { Cache, CacheLayer, makeCache } from "./cache.js" export type { CacheShape } from "./cache.js" diff --git a/src/protocols/sse-parser.ts b/src/protocols/sse-parser.ts index 14ef666..bfcd78f 100644 --- a/src/protocols/sse-parser.ts +++ b/src/protocols/sse-parser.ts @@ -120,32 +120,43 @@ function parseChunk(chunk: OpenAISSEChunk, state: ParseState): LLMEvent[] { const key = `${tc.index}` let toolCall = state.toolCalls.get(key) - // New tool call starting - if (tc.id && !toolCall) { - toolCall = { - id: tc.id, - name: tc.function?.name ?? "", - args: "", - } - state.toolCalls.set(key, toolCall) - - events.push({ - type: "tool-input-start", - id: ToolCallID.make(tc.id), - name: tc.function?.name ?? "unknown", - }) + // New tool call starting — accept either explicit id or name+arguments as signal + if (!toolCall) { + const hasName = !!tc.function?.name + const hasArgs = !!tc.function?.arguments + const hasId = !!tc.id + + // If we have a tool call with name but no registered state, create it + if (hasName || hasArgs) { + const generatedId = tc.id ?? `tool-${state.stepIndex}-${key}` + toolCall = { + id: generatedId, + name: tc.function?.name ?? "unknown", + args: "", + } + state.toolCalls.set(key, toolCall) - if (tc.function?.name) { events.push({ - type: "tool-call", - id: ToolCallID.make(tc.id), - name: tc.function.name, - input: {}, + type: "tool-input-start", + id: ToolCallID.make(generatedId), + name: toolCall.name, }) + + if (hasName) { + events.push({ + type: "tool-call", + id: ToolCallID.make(generatedId), + name: toolCall.name, + input: {}, + }) + } + } else if (!hasId && !hasName && !hasArgs) { + // No useful data — skip this delta + continue } } - // Tool call continuing + // Tool call continuing — accumulate arguments and fill in missing name if (toolCall && tc.function?.arguments) { toolCall.args += tc.function.arguments events.push({ @@ -155,6 +166,17 @@ function parseChunk(chunk: OpenAISSEChunk, state: ParseState): LLMEvent[] { text: tc.function.arguments, }) } + + // Update name if it wasn't known at creation time (model sends args before name) + if (toolCall && tc.function?.name && toolCall.name === "unknown") { + toolCall.name = tc.function.name + events.push({ + type: "tool-call", + id: ToolCallID.make(toolCall.id), + name: toolCall.name, + input: {}, + }) + } } } diff --git a/src/retry.ts b/src/retry.ts new file mode 100644 index 0000000..767cab3 --- /dev/null +++ b/src/retry.ts @@ -0,0 +1,35 @@ +/** + * HTTP retry with exponential backoff. + * Wraps an Effect and retries transient failures (isRetryable=true). + */ + +import * as Effect from "effect/Effect" +import type { LLMError } from "./schema/index.js" + +export interface RetryConfig { + /** Maximum retries before giving up (default: 3) */ + readonly maxRetries?: number + /** Base delay in ms — doubles each attempt (default: 1000) */ + readonly baseDelayMs?: number +} + +export function retryWithBackoff( + effect: Effect.Effect, + config?: RetryConfig, +): Effect.Effect { + const maxRetries = config?.maxRetries ?? 3 + const baseDelay = config?.baseDelayMs ?? 1000 + + const attempt = (n: number): Effect.Effect => + effect.pipe( + Effect.catch((error: LLMError) => { + if (typeof (error as any).isRetryable !== "undefined" && (error as any).isRetryable === true && n < maxRetries) { + const delay = Math.pow(2, n - 1) * baseDelay + console.log(`[harness:retry] ${error.message} — retrying in ${delay}ms (attempt ${n + 1}/${maxRetries})`) + return Effect.sleep(delay as number).pipe(Effect.flatMap(() => attempt(n + 1))) + } + return Effect.fail(error) + }), + ) + return attempt(1) +}