'use strict'; import { DirectReturnError } from './Tool.js'; /** Always produce a proper DOMException('...', 'AbortError') from any abort reason. */ function ensureAbortError(reason) { if (reason instanceof DOMException && reason.name === 'AbortError') return reason; const err = new DOMException( typeof reason === 'string' ? reason : (reason?.message || 'LLMOrbiter run aborted'), 'AbortError', ); if (reason != null && typeof reason !== 'string') err.cause = reason; return err; } /** * LLMOrbiter — main orchestrator. * * Usage: * const orbiter = new LLMOrbiter(provider, { onStatus, onToolCall }); * const result = await orbiter.run({ prompt, messages, model, tools }); * orbiter.abort(); // abort the active request/tool loop * * `messages` is the conversation transcript (the system prompt is simply its first * entry, if any) and is returned on the result for the next run to continue from. * * The run loop: * 1. Send messages + tool specs to the provider * 2. If the LLM returns tool_calls → invoke local implementations, feed results back * 3. Repeat until the LLM finishes (no more tool_calls) or maxIterations is hit * 4. Return { content, toolResults, usage } */ class LLMOrbiter { /** * @param {Object} provider — must implement chatCompletions({ model, messages, tools, signal, onStatus, onContent, onReasoning }) * @param {Object} [opts] * @param {Function} [opts.onLLMCall] — called before LLM request ({ model, toolCount, promptSize, messageCount, iteration }) * @param {Function} [opts.onUsage] — called with usage info ({ cached, input, output, cost }) * @param {Function} [opts.onToolCall] — called with tool call info ({ name, args }) * @param {Function} [opts.onToolError] — called with tool error info ({ name, error, args }) * @param {Function} [opts.onToolResult] — called with tool result info ({ name, result }) * @param {Function} [opts.onStatus] — called with status string (e.g. 'connected', 'streaming') * @param {Function} [opts.onContent] — called with streamed content chunks * @param {Function} [opts.onReasoning] — called with streamed reasoning text */ constructor(provider, opts = {}) { if (!provider) throw new Error('LLMOrbiter requires a provider'); this.provider = provider; this._activeController = null; Object.assign(this, opts); } /** * Abort the currently active run, if any. * Note: with concurrent runs, only the most recent run is aborted. */ abort(reason) { this._activeController?.abort(ensureAbortError(reason)); } /** * Run an LLM conversation with tool-calling loop. * * @param {Object} opts * @param {string} opts.prompt — user message to append (omit/null to continue * from `messages` without adding one) * @param {Array} [opts.messages] — conversation transcript to continue from (e.g. a * previous result.messages). Used as-is; a leading * { role: 'system', content } entry is the system * prompt. Omit to start a fresh conversation. * @param {string} [opts.model] — model identifier (provider-specific default if omitted) * @param {Array} [opts.tools] — array of Tool objects from defineTool() * @param {number} [opts.maxIterations=10] — safety limit for tool-call loops * @param {Object} [opts.extraBody] — extra fields merged into the request body (e.g. reasoning) * @param {AbortSignal} [opts.signal] — optional external abort signal * @returns {Promise} */ async run(opts) { const { prompt, messages: seedMessages, model, tools = [], maxIterations = 10, extraBody = {}, signal, } = opts; if (seedMessages != null && !Array.isArray(seedMessages)) { throw new TypeError('run({ messages }): messages must be an array, or omit it for a fresh conversation'); } const { onLLMCall, onUsage, onToolCall, onToolError, onToolResult, onStatus, onContent, onReasoning } = this; // Keep an internal controller so callers can cancel a run through orbiter.abort(). // The external signal remains supported and is forwarded into the same controller. const controller = new AbortController(); const abortFromExternalSignal = () => controller.abort(ensureAbortError(signal.reason)); if (signal) { if (signal.aborted) abortFromExternalSignal(); else signal.addEventListener('abort', abortFromExternalSignal, { once: true }); } this._activeController = controller; const runSignal = controller.signal; const call = async (fn, ...args) => { if (fn) { try { await fn(...args); } catch (_) { } } }; // Record a tool response into the transcript. Every tool_call in an assistant // message must have a matching tool message, so this is the single place that // writes them (including the direct-return/skip cases). const recordToolResult = (callItem, result) => { const content = typeof result === 'string' ? result : JSON.stringify(result); messages.push({ role: 'tool', tool_call_id: callItem.id, content }); }; // Build tool lookup: name -> invoke const toolMap = new Map(); const toolSpecs = []; for (const t of tools) { toolMap.set(t.name, t.invoke); toolSpecs.push(t.spec); } // Seed messages: continue from a prior transcript, or start fresh. // A leading { role: 'system' } entry in the seed is the system prompt. const messages = seedMessages?.length ? [...seedMessages] : []; if (prompt != null) { messages.push({ role: 'user', content: prompt }); } const allToolResults = {}; // toolName -> [result, ...] const usage = { input: 0, output: 0, cached: 0, tokenIn: 0, tokenOut: 0, tokenCached: 0, costTotal: 0, }; const rawUsages = []; let finalContent = null; let iteration = 0; let directReturn = null; let hasDirectReturn = false; const promptStr = typeof prompt === 'string' ? prompt : JSON.stringify(prompt || ''); const promptSize = promptStr.length; const toolCount = toolSpecs.length; try { while (iteration < maxIterations) { if (runSignal.aborted) throw ensureAbortError(runSignal.reason); iteration++; const messageFlow = messages.flatMap((m) => { if (m.role === 'assistant' && m.tool_calls?.length) { return ['assistant', 'tool']; } if (m.role === 'tool') { return ['tool_response']; } return [m.role]; }).join(','); await call(onLLMCall, { model, toolCount, tools: toolCount, promptSize, messageCount: messages.length, messages: messageFlow, iteration, }); const completion = await this.provider.chatCompletions({ model, messages, tools: toolSpecs.length ? toolSpecs : undefined, signal: runSignal, extraBody, onStatus: (s) => call(onStatus, s), onContent: (...args) => call(onContent, ...args), onReasoning: (r) => call(onReasoning, r), }); // Accumulate usage & emit event per LLM call completed const callUsage = this._addUsage(usage, completion.usage); if (completion.usage) { rawUsages.push(completion.usage); } await call(onUsage, callUsage); const toolCalls = completion.tool_calls; const content = completion.content; const hasToolCalls = Array.isArray(toolCalls) && toolCalls.length > 0; const hasContent = typeof content === 'string' && content.trim().length > 0; // When there is no response (neither tool calls nor content), the loop shall continue. if (!hasToolCalls && !hasContent) { continue; } // Push assistant message messages.push({ role: 'assistant', content: content || null, ...(hasToolCalls ? { tool_calls: toolCalls } : {}), ...(completion.reasoning_details?.length ? { reasoning_details: completion.reasoning_details } : {}), ...(completion.reasoning ? { reasoning: completion.reasoning } : {}), }); // No tool calls → done if (!hasToolCalls) { finalContent = content; break; } const pendingAppendedMessages = []; for (let callIdx = 0; callIdx < toolCalls.length; callIdx++) { const callItem = toolCalls[callIdx]; const fnName = callItem.function.name; const invokeFn = toolMap.get(fnName); let result; let isError = false; let errorMsg = null; if (!invokeFn) { isError = true; errorMsg = `Unknown tool: ${fnName}`; result = { error: errorMsg }; await call(onToolError, { name: fnName, error: errorMsg }); } else { let args; try { args = JSON.parse(callItem.function.arguments || '{}'); } catch (e) { isError = true; errorMsg = `Invalid JSON arguments: ${e.message}`; result = { error: errorMsg }; await call(onToolError, { name: fnName, error: errorMsg }); } if (!isError) { await call(onToolCall, { name: fnName, args, id: callItem.id, callItem, tool_calls: toolCalls, reasoning_details: completion.reasoning_details, reasoning: completion.reasoning, content: content || null }); try { result = await invokeFn(args, { signal: runSignal, messages, appendMessage: (msg) => pendingAppendedMessages.push(msg), }); } catch (err) { // An abort must propagate, not be recorded as a tool error. if (runSignal.aborted) throw ensureAbortError(runSignal.reason); if (err instanceof DirectReturnError) { // Stop the loop and return the data to the caller. directReturn = err.data; hasDirectReturn = true; // Record this call's response and close out any remaining // calls in the batch with synthetic tool messages, so the // returned transcript stays valid to send back to the API // (every tool_call must have a matching tool response). if (err.recordResult !== false) { recordToolResult(callItem, err.data); for (let j = callIdx + 1; j < toolCalls.length; j++) { const pending = toolCalls[j]; recordToolResult(pending, 'Skipped: run terminated by direct return'); } } break; } isError = true; if (err instanceof Error) { errorMsg = err.message ? `${err.name !== 'Error' && err.name !== 'ToolError' ? `${err.name}: ` : ''}${err.message}` : `${err.name || 'Error'} (unspecified crash)`; } else if (err != null && String(err).trim()) { errorMsg = `Tool crashed: ${String(err)}`; } else { errorMsg = 'Tool crashed due to an unspecified internal error'; } result = { error: errorMsg }; await call(onToolError, { name: fnName, error: errorMsg, args, rawError: err, id: callItem.id, callItem }); } // Also handle tools that return { error: ... } instead of throwing if (!isError && result && typeof result === 'object' && 'error' in result && result.error) { isError = true; errorMsg = typeof result.error === 'string' ? result.error : JSON.stringify(result.error); await call(onToolError, { name: fnName, error: errorMsg, args }); } if (result && typeof result === 'object' && Array.isArray(result.appendMessages)) { for (const msg of result.appendMessages) { pendingAppendedMessages.push(msg); } } } } if (hasDirectReturn) break; // Record result if (!allToolResults[fnName]) allToolResults[fnName] = []; allToolResults[fnName].push(result); if (!isError) { await call(onToolResult, { name: fnName, result, id: callItem.id, callItem }); } // Feed result back to conversation recordToolResult(callItem, result); } if (hasDirectReturn) break; if (pendingAppendedMessages.length > 0) { for (const msg of pendingAppendedMessages) { messages.push(msg); } } } usage.raw = rawUsages.length === 1 ? rawUsages[0] : rawUsages; return { content: finalContent, toolResults: allToolResults, usage, rawUsage: rawUsages.length === 1 ? rawUsages[0] : rawUsages, rawUsages, iterations: iteration, messages, ...(directReturn !== null ? { directReturn: true, data: directReturn } : {}), }; } finally { if (signal) signal.removeEventListener('abort', abortFromExternalSignal); if (this._activeController === controller) this._activeController = null; } } /** @private */ _addUsage(totals, u) { const cached = u?.prompt_tokens_details?.cached_tokens || 0; const input = (u?.prompt_tokens || 0) - cached; const output = u?.completion_tokens || 0; totals.tokenCached += cached; totals.tokenIn += input; totals.tokenOut += output; totals.cached = totals.tokenCached; totals.input = totals.tokenIn; totals.output = totals.tokenOut; let cost = 0; if (u?.cost != null) { cost = u.cost; } else if (u?.cost_details) { const d = u.cost_details; cost = (d.upstream_inference_prompt_cost || 0) + (d.upstream_inference_completions_cost || 0); } totals.costTotal += cost; totals.cost = totals.costTotal; const callUsage = { cached, input, output }; if (cost) callUsage.cost = cost; if (u) { Object.defineProperty(callUsage, 'raw', { value: u, enumerable: false, writable: true, configurable: true, }); } return callUsage; } } export { LLMOrbiter };