// Retry backoff and context overflow logic derived from opencode (MIT) — https://github.com/sst/opencode import * as plugins from './plugins.js'; import type { IAgentRunOptions, IAgentRunResult } from './smartagent.interfaces.js'; import { ContextOverflowError } from './smartagent.interfaces.js'; // Retry constants const RETRY_INITIAL_DELAY = 2000; const RETRY_BACKOFF_FACTOR = 2; const RETRY_MAX_DELAY = 30_000; const MAX_RETRY_ATTEMPTS = 8; function retryDelay(attempt: number, headers?: Record): number { if (headers) { const ms = headers['retry-after-ms']; if (ms) { const n = parseFloat(ms); if (!isNaN(n)) return n; } const after = headers['retry-after']; if (after) { const secs = parseFloat(after); if (!isNaN(secs)) return Math.ceil(secs * 1000); const date = Date.parse(after) - Date.now(); if (!isNaN(date) && date > 0) return Math.ceil(date); } } return Math.min( RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1), RETRY_MAX_DELAY, ); } async function sleep(ms: number, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { if (signal?.aborted) { reject(new DOMException('Aborted', 'AbortError')); return; } const t = setTimeout(resolve, ms); signal?.addEventListener( 'abort', () => { clearTimeout(t); reject(new DOMException('Aborted', 'AbortError')); }, { once: true }, ); }); } function isRetryableError(err: unknown): boolean { const status = (err as any)?.status ?? (err as any)?.statusCode; if (status === 429 || status === 529 || status === 503) return true; if (err instanceof Error) { const msg = err.message.toLowerCase(); if (msg.includes('rate limit') || msg.includes('overloaded') || msg.includes('too many requests')) { return true; } } return false; } function isContextOverflow(err: unknown): boolean { if (err instanceof Error) { const msg = err.message.toLowerCase(); return ( msg.includes('context_length_exceeded') || msg.includes('context window') || msg.includes('maximum context length') || msg.includes('too many tokens') || msg.includes('input is too long') || (err as any)?.name === 'AI_ContextWindowExceededError' ); } return false; } export async function runAgent(options: IAgentRunOptions): Promise { let stepCount = 0; let attempt = 0; let totalInput = 0; let totalOutput = 0; const tools = options.tools ?? {}; // Add a no-op sink for repaired-but-unrecognised tool calls const allTools: plugins.ToolSet = { ...tools, invalid: plugins.tool({ description: 'Sink for unrecognised tool calls — returns an error message to the model', inputSchema: plugins.z.object({ tool: plugins.z.string(), error: plugins.z.string(), }), execute: async ({ tool, error }: { tool: string; error: string }) => `Unknown tool "${tool}": ${error}`, }), }; // Build messages — streamText requires either prompt OR messages, not both let messages: plugins.ModelMessage[] = options.messages ? [...options.messages, { role: 'user' as const, content: options.prompt }] : [{ role: 'user' as const, content: options.prompt }]; while (true) { try { const result = plugins.streamText({ model: options.model, system: options.system, messages, tools: allTools, stopWhen: plugins.stepCountIs(options.maxSteps ?? 20), maxRetries: 0, // handled manually below abortSignal: options.abort, experimental_repairToolCall: async ({ toolCall, tools: availableTools, error }) => { const lower = toolCall.toolName.toLowerCase(); if (lower !== toolCall.toolName && (availableTools as any)[lower]) { return { ...toolCall, toolName: lower }; } return { ...toolCall, toolName: 'invalid', args: JSON.stringify({ tool: toolCall.toolName, error: String(error), }), }; }, onChunk: ({ chunk }) => { if (chunk.type === 'text-delta' && options.onToken) { options.onToken((chunk as any).textDelta ?? (chunk as any).text ?? ''); } }, experimental_onToolCallStart: options.onToolCall ? ({ toolCall }) => { options.onToolCall!(toolCall.toolName, (toolCall as any).input ?? (toolCall as any).args); } : undefined, experimental_onToolCallFinish: options.onToolResult ? ({ toolCall, output }) => { options.onToolResult!(toolCall.toolName, output); } : undefined, onStepFinish: ({ usage }) => { stepCount++; totalInput += usage?.inputTokens ?? 0; totalOutput += usage?.outputTokens ?? 0; }, }); // Consume the stream and collect results const text = await result.text; const finishReason = await result.finishReason; const responseData = await result.response; attempt = 0; // reset on success return { text, messages: responseData.messages as plugins.ModelMessage[], steps: stepCount, finishReason, usage: { inputTokens: totalInput, outputTokens: totalOutput, totalTokens: totalInput + totalOutput, }, }; } catch (err: unknown) { // Abort — don't retry if (err instanceof DOMException && err.name === 'AbortError') throw err; // Rate limit / overload — retry with backoff if (isRetryableError(err) && attempt < MAX_RETRY_ATTEMPTS) { attempt++; const headers = (err as any)?.responseHeaders ?? (err as any)?.headers; const delay = retryDelay(attempt, headers); await sleep(delay, options.abort); continue; } // Context overflow — compact and retry if handler provided if (isContextOverflow(err)) { if (!options.onContextOverflow) throw new ContextOverflowError(); messages = await options.onContextOverflow(messages); continue; } throw err; } } }