From 29c1b843a4bf0fe58053725a4278915e804c0b6a Mon Sep 17 00:00:00 2001 From: "ayush.patel" Date: Wed, 8 Jul 2026 16:07:29 +0000 Subject: [PATCH] fix(agent): stop tool loop on unresolved manual tool calls instead of sending broken history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a round mixes auto-executed tools with manual (execute-less) client tools, executeToolRound returns no output for the manual calls, yet the loop still made a follow-up request whose input carried the manual function_call with no matching function_call_output. Providers reject that history with 400 invalid_request_error "No tool output found for function call ...". Break the loop and surface the response when any tool call in the round has no output — mirroring the existing all-manual hasExecutableToolCalls guard — so the caller can execute its manual calls and continue. Ports the openrouter-web patchedDependencies stopgap (OpenRouterTeam/openrouter-web#27541) upstream. --- .changeset/orange-drinks-smile.md | 5 + packages/agent/src/lib/model-result.ts | 16 ++ .../unit/mixed-manual-tool-round.test.ts | 197 ++++++++++++++++++ 3 files changed, 218 insertions(+) create mode 100644 .changeset/orange-drinks-smile.md create mode 100644 packages/agent/tests/unit/mixed-manual-tool-round.test.ts diff --git a/.changeset/orange-drinks-smile.md b/.changeset/orange-drinks-smile.md new file mode 100644 index 0000000..91d90fa --- /dev/null +++ b/.changeset/orange-drinks-smile.md @@ -0,0 +1,5 @@ +--- +'@openrouter/agent': patch +--- + +Stop the tool-execution loop when a round contains unresolved manual (client-executed) tool calls, instead of sending a follow-up request whose input carries a `function_call` with no matching `function_call_output` — a history providers reject with a 400 "No tool output found for function call ...". The response is surfaced so the caller can execute the manual calls and continue, mirroring the existing all-manual behavior. diff --git a/packages/agent/src/lib/model-result.ts b/packages/agent/src/lib/model-result.ts index e9f2bff..5e64728 100644 --- a/packages/agent/src/lib/model-result.ts +++ b/packages/agent/src/lib/model-result.ts @@ -2005,6 +2005,22 @@ export class ModelResult< return; } + // Manual (client-executed) tools produce no output this round — + // `executeToolRound` returns nothing for them — so a mixed round of + // auto-executed and manual calls would otherwise send a follow-up + // request whose input contains a `function_call` with no matching + // `function_call_output`. Providers reject that history with a 400 + // ("No tool output found for function call ..."). Stop the loop and + // surface the response instead, so the caller can execute the manual + // calls and continue — mirroring the all-manual behavior of the + // `hasExecutableToolCalls` guards. Also covers calls to tool names + // not present in `options.tools` at all. + const resolvedCallIds = new Set(toolResults.map((r) => r.callId)); + const hasUnresolvedToolCalls = currentToolCalls.some((tc) => !resolvedCallIds.has(tc.id)); + if (hasUnresolvedToolCalls) { + break; + } + // Apply nextTurnParams await this.applyNextTurnParams(currentToolCalls); diff --git a/packages/agent/tests/unit/mixed-manual-tool-round.test.ts b/packages/agent/tests/unit/mixed-manual-tool-round.test.ts new file mode 100644 index 0000000..920c2f2 --- /dev/null +++ b/packages/agent/tests/unit/mixed-manual-tool-round.test.ts @@ -0,0 +1,197 @@ +import type { OpenRouterCore } from '@openrouter/sdk/core'; +import type * as models from '@openrouter/sdk/models'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { z } from 'zod/v4'; + +const mockBetaResponsesSend = vi.hoisted(() => vi.fn()); + +vi.mock('@openrouter/sdk/funcs/betaResponsesSend', () => ({ + betaResponsesSend: mockBetaResponsesSend, +})); + +import { callModel } from '../../src/inner-loop/call-model.js'; +import { ToolType } from '../../src/lib/tool-types.js'; + +function functionCallItem( + callId: string, + name: string, + args: string, +): models.OutputFunctionCallItem { + return { + type: 'function_call', + id: `fc_${callId}`, + callId, + name, + arguments: args, + status: 'completed', + }; +} + +function makeResponse( + id: string, + output: models.OpenResponsesResult['output'], +): models.OpenResponsesResult { + return { + id, + object: 'response', + createdAt: 0, + model: 'test-model', + status: 'completed', + completedAt: 0, + output, + error: null, + incompleteDetails: null, + temperature: null, + topP: null, + presencePenalty: null, + frequencyPenalty: null, + metadata: null, + instructions: null, + tools: [], + toolChoice: 'auto', + parallelToolCalls: false, + } as models.OpenResponsesResult; +} + +function textResponse(id: string, text: string): models.OpenResponsesResult { + return makeResponse(id, [ + { + id: 'msg_text', + type: 'message', + role: 'assistant', + status: 'completed', + content: [ + { + type: 'output_text', + text, + annotations: [], + }, + ], + }, + ]); +} + +const autoTool = { + type: ToolType.Function, + function: { + name: 'auto_search', + description: 'Auto-executed tool.', + inputSchema: z.object({ + query: z.string(), + }), + outputSchema: z.object({ + result: z.string(), + }), + execute: async (_params: { query: string }) => ({ + result: 'found it', + }), + }, +} as const; + +// No `execute` — the client is responsible for running this tool. +const manualTool = { + type: ToolType.Function, + function: { + name: 'exec_command', + description: 'Manual client-executed tool.', + inputSchema: z.object({ + command: z.string(), + }), + outputSchema: z.object({ + stdout: z.string(), + }), + }, +} as const; + +const client = {} as OpenRouterCore; + +describe('mixed auto + manual tool round', () => { + beforeEach(() => { + mockBetaResponsesSend.mockReset(); + }); + + it('stops the loop instead of sending a follow-up with an orphaned function_call', async () => { + const mixedRoundResponse = makeResponse('resp_mixed', [ + functionCallItem('call_auto_1', 'auto_search', '{"query":"docs"}'), + functionCallItem('call_manual_1', 'exec_command', '{"command":"ls"}'), + ]); + + mockBetaResponsesSend.mockResolvedValueOnce({ + ok: true, + value: mixedRoundResponse, + }); + + const result = callModel(client, { + model: 'test-model', + input: 'do both things', + tools: [ + autoTool, + manualTool, + ] as const, + }); + + const response = await result.getResponse(); + + // The response with the unresolved manual call is surfaced so the caller + // can execute it and continue the conversation. + expect(response.id).toBe('resp_mixed'); + // No follow-up request was made: its input would have contained + // exec_command's function_call with no matching function_call_output, + // which providers reject with a 400 ("No tool output found for function + // call ..."). + expect(mockBetaResponsesSend).toHaveBeenCalledTimes(1); + }); + + it('still loops when every tool call in the round resolves', async () => { + const autoOnlyResponse = makeResponse('resp_auto', [ + functionCallItem('call_auto_1', 'auto_search', '{"query":"docs"}'), + ]); + + mockBetaResponsesSend + .mockResolvedValueOnce({ + ok: true, + value: autoOnlyResponse, + }) + .mockResolvedValueOnce({ + ok: true, + value: textResponse('resp_final', 'All done.'), + }); + + const result = callModel(client, { + model: 'test-model', + input: 'search the docs', + tools: [ + autoTool, + manualTool, + ] as const, + }); + + const text = await result.getText(); + + expect(text).toBe('All done.'); + expect(mockBetaResponsesSend).toHaveBeenCalledTimes(2); + + // The follow-up request pairs the executed call with its real output. + const followupInput = mockBetaResponsesSend.mock.calls[1]?.[1]?.responsesRequest + ?.input as unknown[]; + expect(Array.isArray(followupInput)).toBe(true); + const fnCallOutput = followupInput.find( + ( + i, + ): i is { + type: string; + callId: string; + output: string; + } => + typeof i === 'object' && + i !== null && + ( + i as { + type?: string; + } + ).type === 'function_call_output', + ); + expect(fnCallOutput?.callId).toBe('call_auto_1'); + expect(fnCallOutput?.output).toContain('found it'); + }); +});