diff --git a/AGENTS.md b/AGENTS.md index 8efc4af..1ac4059 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,13 +45,12 @@ Exports: `LLMOrbiter`, `OpenRouterProvider`, `defineTool`, `ToolError`, `DirectR ### `orbiter.run(opts)` -`{ prompt, messages, model, tools, maxIterations=10, tool_choice, extraBody, signal }`. +`{ prompt, messages, model, tools, maxIterations=10, extraBody, signal }`. - `prompt`: user message string to append (omit or pass `null` to continue without adding one). - `messages`: transcript array to seed from; a leading `{ role: 'system' }` is the system prompt. - `tools`: array of tool objects created with `defineTool()`. - `maxIterations`: safety limit on tool-calling loop iterations (default: `10`). -- `tool_choice`: tool choice setting (defaults to `'auto'` when tools are present). - `extraBody`: extra parameters merged into provider request payload. - `signal`: external `AbortSignal`. @@ -80,7 +79,7 @@ Returns: - `sessionId` sets session ID / cache key (defaults to a process-lifetime UUID created when the app starts; sent via `x-session-id` HTTP header and `session_id` body parameter). Method: -`chatCompletions({ model, messages, tools, tool_choice, signal, extraBody, onStatus, onContent, onReasoning, sessionId })` +`chatCompletions({ model, messages, tools, signal, extraBody, onStatus, onContent, onReasoning, sessionId })` — streams SSE, assembles `{ content, tool_calls, finish_reason, usage, reasoning_details, reasoning }`. - If reasoning was returned, automatically appends `{ role: 'assistant', content: result.reasoning }` to `messages`. - Delta handling: diff --git a/src/LLMOrbiter.js b/src/LLMOrbiter.js index 5db657f..ca8a607 100644 --- a/src/LLMOrbiter.js +++ b/src/LLMOrbiter.js @@ -32,7 +32,7 @@ function ensureAbortError(reason) { */ class LLMOrbiter { /** - * @param {Object} provider — must implement chatCompletions({ model, messages, tools, tool_choice, signal, onStatus, onContent, onReasoning }) + * @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 }) @@ -71,7 +71,6 @@ class LLMOrbiter { * @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.tool_choice] — force a specific tool or "auto" * @param {Object} [opts.extraBody] — extra fields merged into the request body (e.g. reasoning) * @param {AbortSignal} [opts.signal] — optional external abort signal * @returns {Promise} @@ -83,7 +82,6 @@ class LLMOrbiter { model, tools = [], maxIterations = 10, - tool_choice, extraBody = {}, signal, } = opts; @@ -105,9 +103,9 @@ class LLMOrbiter { this._activeController = controller; const runSignal = controller.signal; - const call = (fn, ...args) => { + const call = async (fn, ...args) => { if (fn) { - try { fn(...args); } catch (_) {} + try { await fn(...args); } catch (_) { } } }; @@ -160,175 +158,174 @@ class LLMOrbiter { 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]; + if (m.role === 'assistant' && m.tool_calls?.length) { + return ['assistant', 'tool']; + } + if (m.role === 'tool') { + return ['tool_response']; + } + return [m.role]; }).join(','); - call(onLLMCall, { - model, - toolCount, - tools: toolCount, - promptSize, - messageCount: messages.length, - messages: messageFlow, - iteration, + 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, - tool_choice: toolSpecs.length ? (tool_choice || 'auto') : undefined, - signal: runSignal, - extraBody, - onStatus: (s) => call(onStatus, s), - onContent: (...args) => call(onContent, ...args), - onReasoning: (r) => call(onReasoning, r), + 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); - } - call(onUsage, callUsage); + // 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; + 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; - } + // 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 } : {}), - }); + // 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; - } + // 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; + 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 }; - call(onToolError, { name: fnName, error: errorMsg }); - } else { - let args; - try { - args = JSON.parse(callItem.function.arguments || '{}'); - } catch (e) { + if (!invokeFn) { isError = true; - errorMsg = `Invalid JSON arguments: ${e.message}`; + errorMsg = `Unknown tool: ${fnName}`; result = { error: errorMsg }; - call(onToolError, { name: fnName, 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) { - 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 }; - 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); - 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); - } - } + await call(onToolResult, { name: fnName, result, id: callItem.id, callItem }); } + + // Feed result back to conversation + recordToolResult(callItem, result); } if (hasDirectReturn) break; - // Record result - if (!allToolResults[fnName]) allToolResults[fnName] = []; - allToolResults[fnName].push(result); - - if (!isError) { - 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); + if (pendingAppendedMessages.length > 0) { + for (const msg of pendingAppendedMessages) { + messages.push(msg); + } } } - } usage.raw = rawUsages.length === 1 ? rawUsages[0] : rawUsages; diff --git a/src/providers/OpenRouterProvider.js b/src/providers/OpenRouterProvider.js index a04cf6f..5743005 100644 --- a/src/providers/OpenRouterProvider.js +++ b/src/providers/OpenRouterProvider.js @@ -41,7 +41,7 @@ class OpenRouterProvider { * Streaming chat completion against OpenRouter. * Returns assembled { content, tool_calls, finish_reason, usage, reasoning_details }. */ - async chatCompletions({ model, messages, tools, tool_choice, signal, extraBody = {}, onStatus, onContent, onReasoning, sessionId }) { + async chatCompletions({ model, messages, tools, signal, extraBody = {}, onStatus, onContent, onReasoning, sessionId }) { if (!model) throw new Error('model is required'); const effectiveSessionId = sessionId || extraBody.session_id || this.sessionId; const body = { @@ -55,7 +55,6 @@ class OpenRouterProvider { ...extraBody, }; if (tools?.length) body.tools = tools; - if (tool_choice) body.tool_choice = tool_choice; const headers = { 'Content-Type': 'application/json', @@ -117,7 +116,7 @@ class OpenRouterProvider { } } - + return result; }