This commit is contained in:
seb
2026-10-01 10:18:57 +02:00
parent 5dee37cb2b
commit a58991b9f8
3 changed files with 114 additions and 3 deletions

View File

@@ -56,7 +56,7 @@ Exports: `LLMOrbiter`, `OpenRouterProvider`, `defineTool`, `ToolError`, `DirectR
- `signal`: external `AbortSignal`. - `signal`: external `AbortSignal`.
Loop: send messages + tool specs -> if `tool_calls` come back, invoke locals and feed results Loop: send messages + tool specs -> if `tool_calls` come back, invoke locals and feed results
back -> repeat until no more tool calls or `maxIterations`. back -> if an iteration returns neither tool calls nor content (no response), the loop continues -> repeat until a response is produced or `maxIterations`.
Returns: Returns:
- `content`: final assistant message text (or `null`). - `content`: final assistant message text (or `null`).

View File

@@ -200,16 +200,23 @@ class LLMOrbiter {
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 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 // Push assistant message
messages.push({ messages.push({
role: 'assistant', role: 'assistant',
content: content || null, content: content || null,
...(toolCalls?.length ? { tool_calls: toolCalls } : {}), ...(hasToolCalls ? { tool_calls: toolCalls } : {}),
}); });
// No tool calls → done // No tool calls → done
if (!toolCalls?.length) { if (!hasToolCalls) {
finalContent = content; finalContent = content;
break; break;
} }

104
test/orbiter.test.js Normal file
View File

@@ -0,0 +1,104 @@
'use strict';
import test from 'node:test';
import assert from 'node:assert/strict';
import { LLMOrbiter, defineTool } from '../src/index.js';
class MockProvider {
constructor(responses) {
this.responses = [...responses];
this.calls = [];
}
async chatCompletions(args) {
this.calls.push(args);
const next = this.responses.shift();
if (!next) throw new Error('MockProvider ran out of mock responses');
return next;
}
}
test('LLMOrbiter loop continues when provider returns no response (no tool calls and no content)', async () => {
let toolInvoked = false;
const testTool = defineTool({
name: 'test_tool',
description: 'Test tool',
parameters: { type: 'object', properties: {} },
invoke: async () => {
toolInvoked = true;
return { ok: true };
},
});
// Sequence:
// 1. Tool call
// 2. No response (no tool calls, empty content) -> should continue loop!
// 3. Final content
const mock = new MockProvider([
{
tool_calls: [
{
id: 'call_1',
type: 'function',
function: { name: 'test_tool', arguments: '{}' },
},
],
content: null,
},
{
tool_calls: undefined,
content: '',
},
{
tool_calls: undefined,
content: 'Task completed successfully.',
},
]);
const orbiter = new LLMOrbiter(mock);
const result = await orbiter.run({
prompt: 'Do something',
tools: [testTool],
maxIterations: 10,
});
assert.equal(toolInvoked, true, 'Tool should have been invoked');
assert.equal(result.iterations, 3, 'Should have taken 3 iterations (tool call, no response, final answer)');
assert.equal(result.content, 'Task completed successfully.');
assert.equal(mock.calls.length, 3, 'Provider should have been called 3 times');
});
test('LLMOrbiter stops at maxIterations if provider continually returns no response', async () => {
const mock = new MockProvider([
{ tool_calls: undefined, content: '' },
{ tool_calls: undefined, content: null },
{ tool_calls: undefined, content: ' ' },
]);
const orbiter = new LLMOrbiter(mock);
const result = await orbiter.run({
prompt: 'Hello',
tools: [],
maxIterations: 3,
});
assert.equal(result.iterations, 3, 'Should have iterated up to maxIterations');
assert.equal(result.content, null, 'Should return content null when no response was ever given');
});
test('LLMOrbiter finishes immediately in 1 iteration when content is returned with no tool calls', async () => {
const mock = new MockProvider([
{ tool_calls: undefined, content: 'Hello there!' },
]);
const orbiter = new LLMOrbiter(mock);
const result = await orbiter.run({
prompt: 'Hi',
tools: [],
maxIterations: 10,
});
assert.equal(result.iterations, 1);
assert.equal(result.content, 'Hello there!');
});