199 lines
6.2 KiB
TypeScript
199 lines
6.2 KiB
TypeScript
// 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<string, string>): 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<void> {
|
|
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<IAgentRunResult> {
|
|
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;
|
|
}
|
|
}
|
|
}
|