From a58991b9f8e54a5e485b2aa8535db4e57294e04e Mon Sep 17 00:00:00 2001 From: seb Date: Thu, 1 Oct 2026 10:18:57 +0200 Subject: [PATCH] u --- AGENTS.md | 2 +- src/LLMOrbiter.js | 11 ++++- test/orbiter.test.js | 104 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 114 insertions(+), 3 deletions(-) create mode 100644 test/orbiter.test.js diff --git a/AGENTS.md b/AGENTS.md index db06e67..1ed59de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,7 +56,7 @@ Exports: `LLMOrbiter`, `OpenRouterProvider`, `defineTool`, `ToolError`, `DirectR - `signal`: external `AbortSignal`. 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: - `content`: final assistant message text (or `null`). diff --git a/src/LLMOrbiter.js b/src/LLMOrbiter.js index e840f13..b293d8f 100644 --- a/src/LLMOrbiter.js +++ b/src/LLMOrbiter.js @@ -200,16 +200,23 @@ class LLMOrbiter { 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, - ...(toolCalls?.length ? { tool_calls: toolCalls } : {}), + ...(hasToolCalls ? { tool_calls: toolCalls } : {}), }); // No tool calls → done - if (!toolCalls?.length) { + if (!hasToolCalls) { finalContent = content; break; } diff --git a/test/orbiter.test.js b/test/orbiter.test.js new file mode 100644 index 0000000..462a653 --- /dev/null +++ b/test/orbiter.test.js @@ -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!'); +});