u
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user