This commit is contained in:
seb
2026-10-05 12:03:05 +02:00
parent 9b24e8ece4
commit 8609a8f26d
3 changed files with 150 additions and 155 deletions

View File

@@ -45,13 +45,12 @@ Exports: `LLMOrbiter`, `OpenRouterProvider`, `defineTool`, `ToolError`, `DirectR
### `orbiter.run(opts)` ### `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). - `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. - `messages`: transcript array to seed from; a leading `{ role: 'system' }` is the system prompt.
- `tools`: array of tool objects created with `defineTool()`. - `tools`: array of tool objects created with `defineTool()`.
- `maxIterations`: safety limit on tool-calling loop iterations (default: `10`). - `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. - `extraBody`: extra parameters merged into provider request payload.
- `signal`: external `AbortSignal`. - `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). - `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: 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 }`. — 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`. - If reasoning was returned, automatically appends `{ role: 'assistant', content: result.reasoning }` to `messages`.
- Delta handling: - Delta handling:

View File

@@ -32,7 +32,7 @@ function ensureAbortError(reason) {
*/ */
class LLMOrbiter { 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 {Object} [opts]
* @param {Function} [opts.onLLMCall] — called before LLM request ({ model, toolCount, promptSize, messageCount, iteration }) * @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.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 {string} [opts.model] — model identifier (provider-specific default if omitted)
* @param {Array} [opts.tools] — array of Tool objects from defineTool() * @param {Array} [opts.tools] — array of Tool objects from defineTool()
* @param {number} [opts.maxIterations=10] — safety limit for tool-call loops * @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 {Object} [opts.extraBody] — extra fields merged into the request body (e.g. reasoning)
* @param {AbortSignal} [opts.signal] — optional external abort signal * @param {AbortSignal} [opts.signal] — optional external abort signal
* @returns {Promise<RunResult>} * @returns {Promise<RunResult>}
@@ -83,7 +82,6 @@ class LLMOrbiter {
model, model,
tools = [], tools = [],
maxIterations = 10, maxIterations = 10,
tool_choice,
extraBody = {}, extraBody = {},
signal, signal,
} = opts; } = opts;
@@ -105,9 +103,9 @@ class LLMOrbiter {
this._activeController = controller; this._activeController = controller;
const runSignal = controller.signal; const runSignal = controller.signal;
const call = (fn, ...args) => { const call = async (fn, ...args) => {
if (fn) { if (fn) {
try { fn(...args); } catch (_) {} try { await fn(...args); } catch (_) { }
} }
}; };
@@ -160,175 +158,174 @@ class LLMOrbiter {
iteration++; iteration++;
const messageFlow = messages.flatMap((m) => { const messageFlow = messages.flatMap((m) => {
if (m.role === 'assistant' && m.tool_calls?.length) { if (m.role === 'assistant' && m.tool_calls?.length) {
return ['assistant', 'tool']; return ['assistant', 'tool'];
} }
if (m.role === 'tool') { if (m.role === 'tool') {
return ['tool_response']; return ['tool_response'];
} }
return [m.role]; return [m.role];
}).join(','); }).join(',');
call(onLLMCall, { await call(onLLMCall, {
model, model,
toolCount, toolCount,
tools: toolCount, tools: toolCount,
promptSize, promptSize,
messageCount: messages.length, messageCount: messages.length,
messages: messageFlow, messages: messageFlow,
iteration, iteration,
}); });
const completion = await this.provider.chatCompletions({ const completion = await this.provider.chatCompletions({
model, model,
messages, messages,
tools: toolSpecs.length ? toolSpecs : undefined, tools: toolSpecs.length ? toolSpecs : undefined,
tool_choice: toolSpecs.length ? (tool_choice || 'auto') : undefined, signal: runSignal,
signal: runSignal, extraBody,
extraBody, onStatus: (s) => call(onStatus, s),
onStatus: (s) => call(onStatus, s), onContent: (...args) => call(onContent, ...args),
onContent: (...args) => call(onContent, ...args), onReasoning: (r) => call(onReasoning, r),
onReasoning: (r) => call(onReasoning, r),
}); });
// Accumulate usage & emit event per LLM call completed // Accumulate usage & emit event per LLM call completed
const callUsage = this._addUsage(usage, completion.usage); const callUsage = this._addUsage(usage, completion.usage);
if (completion.usage) { if (completion.usage) {
rawUsages.push(completion.usage); rawUsages.push(completion.usage);
} }
call(onUsage, callUsage); await call(onUsage, callUsage);
const toolCalls = completion.tool_calls; const toolCalls = completion.tool_calls;
const content = completion.content; const content = completion.content;
const hasToolCalls = Array.isArray(toolCalls) && toolCalls.length > 0; const hasToolCalls = Array.isArray(toolCalls) && toolCalls.length > 0;
const hasContent = typeof content === 'string' && content.trim().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. // When there is no response (neither tool calls nor content), the loop shall continue.
if (!hasToolCalls && !hasContent) { if (!hasToolCalls && !hasContent) {
continue; continue;
} }
// Push assistant message // Push assistant message
messages.push({ messages.push({
role: 'assistant', role: 'assistant',
content: content || null, content: content || null,
...(hasToolCalls ? { tool_calls: toolCalls } : {}), ...(hasToolCalls ? { tool_calls: toolCalls } : {}),
...(completion.reasoning_details?.length ? { reasoning_details: completion.reasoning_details } : {}), ...(completion.reasoning_details?.length ? { reasoning_details: completion.reasoning_details } : {}),
...(completion.reasoning ? { reasoning: completion.reasoning } : {}), ...(completion.reasoning ? { reasoning: completion.reasoning } : {}),
}); });
// No tool calls → done // No tool calls → done
if (!hasToolCalls) { if (!hasToolCalls) {
finalContent = content; finalContent = content;
break; break;
} }
const pendingAppendedMessages = []; const pendingAppendedMessages = [];
for (let callIdx = 0; callIdx < toolCalls.length; callIdx++) { for (let callIdx = 0; callIdx < toolCalls.length; callIdx++) {
const callItem = toolCalls[callIdx]; const callItem = toolCalls[callIdx];
const fnName = callItem.function.name; const fnName = callItem.function.name;
const invokeFn = toolMap.get(fnName); const invokeFn = toolMap.get(fnName);
let result; let result;
let isError = false; let isError = false;
let errorMsg = null; let errorMsg = null;
if (!invokeFn) { 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) {
isError = true; isError = true;
errorMsg = `Invalid JSON arguments: ${e.message}`; errorMsg = `Unknown tool: ${fnName}`;
result = { error: errorMsg }; 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) { 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 }); await call(onToolResult, { name: fnName, result, id: callItem.id, callItem });
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);
}
}
} }
// Feed result back to conversation
recordToolResult(callItem, result);
} }
if (hasDirectReturn) break; if (hasDirectReturn) break;
// Record result if (pendingAppendedMessages.length > 0) {
if (!allToolResults[fnName]) allToolResults[fnName] = []; for (const msg of pendingAppendedMessages) {
allToolResults[fnName].push(result); messages.push(msg);
}
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);
} }
} }
}
usage.raw = rawUsages.length === 1 ? rawUsages[0] : rawUsages; usage.raw = rawUsages.length === 1 ? rawUsages[0] : rawUsages;

View File

@@ -41,7 +41,7 @@ class OpenRouterProvider {
* Streaming chat completion against OpenRouter. * Streaming chat completion against OpenRouter.
* Returns assembled { content, tool_calls, finish_reason, usage, reasoning_details }. * 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'); if (!model) throw new Error('model is required');
const effectiveSessionId = sessionId || extraBody.session_id || this.sessionId; const effectiveSessionId = sessionId || extraBody.session_id || this.sessionId;
const body = { const body = {
@@ -55,7 +55,6 @@ class OpenRouterProvider {
...extraBody, ...extraBody,
}; };
if (tools?.length) body.tools = tools; if (tools?.length) body.tools = tools;
if (tool_choice) body.tool_choice = tool_choice;
const headers = { const headers = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -117,7 +116,7 @@ class OpenRouterProvider {
} }
} }
return result; return result;
} }