From cc4407e1078344c565344ec309df915e97a2a20b Mon Sep 17 00:00:00 2001 From: Varun S G Date: Sat, 5 Sep 2026 00:19:19 +0530 Subject: [PATCH] fix(agent): support tool calls in fromChatMessages and fix input typing (#11, #41) --- .changeset/from-chat-messages-tool-calls.md | 5 + packages/agent/src/lib/async-params.ts | 2 +- packages/agent/src/lib/chat-compat.test.ts | 192 +++++++++++++++++++- packages/agent/src/lib/chat-compat.ts | 64 +++++-- 4 files changed, 250 insertions(+), 13 deletions(-) create mode 100644 .changeset/from-chat-messages-tool-calls.md diff --git a/.changeset/from-chat-messages-tool-calls.md b/.changeset/from-chat-messages-tool-calls.md new file mode 100644 index 00000000..dac3f8b9 --- /dev/null +++ b/.changeset/from-chat-messages-tool-calls.md @@ -0,0 +1,5 @@ +--- +'@openrouter/agent': patch +--- + +Fix `fromChatMessages` to convert assistant `toolCalls` to `function_call` items, and widen `callModel`'s `input` type to accept `models.InputsUnion`. diff --git a/packages/agent/src/lib/async-params.ts b/packages/agent/src/lib/async-params.ts index f2f72e71..59fb4c9c 100644 --- a/packages/agent/src/lib/async-params.ts +++ b/packages/agent/src/lib/async-params.ts @@ -82,7 +82,7 @@ type BaseCallModelInput< models.ResponsesRequest[K] >; } & { - input: FieldOrAsyncFunction | string; + input: FieldOrAsyncFunction; tools?: TTools; /** * Optional filter restricting which tools are exposed to the model for this diff --git a/packages/agent/src/lib/chat-compat.test.ts b/packages/agent/src/lib/chat-compat.test.ts index e35d480b..5a37ff4e 100644 --- a/packages/agent/src/lib/chat-compat.test.ts +++ b/packages/agent/src/lib/chat-compat.test.ts @@ -1,6 +1,7 @@ +import type { OpenRouterCore } from '@openrouter/sdk/core'; import type * as models from '@openrouter/sdk/models'; - import { describe, expect, it } from 'vitest'; +import { callModel } from '../inner-loop/call-model.js'; import { fromChatMessages, toChatMessage } from './chat-compat.js'; /** @@ -203,6 +204,195 @@ describe('fromChatMessages', () => { }); }); + describe('assistant message tool calls conversion', () => { + it('converts assistant message with toolCalls and null content to function_call item', () => { + const messages: models.ChatMessages[] = [ + { + role: 'user', + content: 'What is the weather?', + }, + { + role: 'assistant', + content: null, + toolCalls: [ + { + id: 'call_123', + type: 'function', + function: { + name: 'get_weather', + arguments: '{"location":"Paris"}', + }, + }, + ], + }, + { + role: 'tool', + toolCallId: 'call_123', + content: '{"temperature": 20}', + }, + ]; + + const result = fromChatMessages(messages); + + expect(result).toEqual([ + { + role: 'user', + content: 'What is the weather?', + }, + { + type: 'function_call', + callId: 'call_123', + name: 'get_weather', + arguments: '{"location":"Paris"}', + }, + { + type: 'function_call_output', + callId: 'call_123', + output: '{"temperature": 20}', + }, + ]); + }); + + it('converts assistant message with both content and toolCalls', () => { + const messages: models.ChatMessages[] = [ + { + role: 'assistant', + content: 'Let me check that for you.', + toolCalls: [ + { + id: 'call_123', + type: 'function', + function: { + name: 'get_weather', + arguments: '{"location":"Paris"}', + }, + }, + ], + }, + ]; + + const result = fromChatMessages(messages); + + expect(result).toEqual([ + { + role: 'assistant', + content: 'Let me check that for you.', + }, + { + type: 'function_call', + callId: 'call_123', + name: 'get_weather', + arguments: '{"location":"Paris"}', + }, + ]); + }); + + it('supports snake_case tool_calls and tool_call_id', () => { + const messages = [ + { + role: 'assistant' as const, + content: null, + tool_calls: [ + { + id: 'call_456', + type: 'function' as const, + function: { + name: 'search', + arguments: '{"query":"vitest"}', + }, + }, + ], + }, + { + role: 'tool' as const, + tool_call_id: 'call_456', + content: 'Found results', + }, + ]; + + const result = fromChatMessages(messages as unknown as models.ChatMessages[]); + + expect(result).toEqual([ + { + type: 'function_call', + callId: 'call_456', + name: 'search', + arguments: '{"query":"vitest"}', + }, + { + type: 'function_call_output', + callId: 'call_456', + output: 'Found results', + }, + ]); + }); + + it('stringifies object arguments on tool calls', () => { + const messages = [ + { + role: 'assistant' as const, + content: null, + toolCalls: [ + { + id: 'call_789', + type: 'function' as const, + function: { + name: 'calculate', + arguments: { + a: 1, + b: 2, + } as unknown as string, + }, + }, + ], + }, + ]; + + const result = fromChatMessages(messages as unknown as models.ChatMessages[]); + + expect(result).toEqual([ + { + type: 'function_call', + callId: 'call_789', + name: 'calculate', + arguments: JSON.stringify({ + a: 1, + b: 2, + }), + }, + ]); + }); + + it('produces input accepted by callModel without type errors', () => { + const chatMessages: models.ChatMessages[] = [ + { + role: 'system', + content: 'You are a helpful assistant.', + }, + { + role: 'user', + content: 'Hello!', + }, + { + role: 'assistant', + content: 'Hi there! How can I help you?', + }, + { + role: 'user', + content: 'What is the weather like?', + }, + ]; + + const fakeClient = {} as OpenRouterCore; + const result = callModel(fakeClient, { + model: 'openai/gpt-5-nano', + input: fromChatMessages(chatMessages), + }); + + expect(result).toBeDefined(); + }); + }); + describe('content array handling', () => { it('stringifies array content for user messages', () => { const messages: models.ChatMessages[] = [ diff --git a/packages/agent/src/lib/chat-compat.ts b/packages/agent/src/lib/chat-compat.ts index e58caa4f..15eb6616 100644 --- a/packages/agent/src/lib/chat-compat.ts +++ b/packages/agent/src/lib/chat-compat.ts @@ -80,28 +80,70 @@ function contentToString(content: unknown): string { * ``` */ export function fromChatMessages(messages: models.ChatMessages[]): models.InputsUnion { - return messages.map((msg): models.EasyInputMessage | models.FunctionCallOutputItem => { + const result: ( + | models.EasyInputMessage + | models.FunctionCallOutputItem + | models.OutputFunctionCallItem + )[] = []; + + for (const msg of messages) { if (isToolResponseMessage(msg)) { - return { + result.push({ type: 'function_call_output' as const, - callId: msg.toolCallId, + callId: + msg.toolCallId ?? + ( + msg as { + tool_call_id?: string; + } + ).tool_call_id ?? + '', output: contentToString(msg.content), - }; + }); + continue; } if (isAssistantMessage(msg)) { - return { - role: mapChatRole('assistant'), - content: contentToString(msg.content), - }; + const toolCalls = + msg.toolCalls ?? + ( + msg as { + tool_calls?: models.ChatToolCall[]; + } + ).tool_calls; + const content = contentToString(msg.content); + + if (content.length > 0 || !toolCalls?.length) { + result.push({ + role: mapChatRole('assistant'), + content, + }); + } + + if (toolCalls?.length) { + for (const tc of toolCalls) { + result.push({ + type: 'function_call' as const, + callId: tc.id, + name: tc.function.name, + arguments: + typeof tc.function.arguments === 'string' + ? tc.function.arguments + : JSON.stringify(tc.function.arguments), + }); + } + } + continue; } // System, user, developer messages - return { + result.push({ role: mapChatRole(msg.role), content: contentToString(msg.content), - }; - }); + }); + } + + return result; } /**