From cb360ad57f39478ed134514fb2bca75e77e5af79 Mon Sep 17 00:00:00 2001 From: Ame Date: Sat, 14 Mar 2026 10:57:02 +0800 Subject: [PATCH 1/2] fix(pipeline): batch parallel tool results into single user message Parallel tool calls (multiple tool_use in one step) previously flushed each tool_result as a separate user message. On the next turn, toModelMessages() would reconstruct a history where the assistant had N tool calls but each result was its own message, causing Vercel AI SDK to throw MissingToolResultsError. Fix: accumulate tool results and only flush when the next round begins (new tool_use or text event), so all results land in one user message. Adds A16 (persistence) and parallel tool call test (agent.spec) to pin the correct behaviour. Fixes #50 Co-Authored-By: Claude Sonnet 4.6 --- src/ai-providers/vercel-ai-sdk/agent.spec.ts | 42 +++++++++++++++++++ .../__tests__/pipeline/persistence.spec.ts | 33 +++++++++++++++ src/core/agent-center.ts | 28 ++++++++----- 3 files changed, 93 insertions(+), 10 deletions(-) diff --git a/src/ai-providers/vercel-ai-sdk/agent.spec.ts b/src/ai-providers/vercel-ai-sdk/agent.spec.ts index 20d69489..80f24a84 100644 --- a/src/ai-providers/vercel-ai-sdk/agent.spec.ts +++ b/src/ai-providers/vercel-ai-sdk/agent.spec.ts @@ -141,6 +141,48 @@ describe('Trading Agent', () => { expect(tradeExecuted).toBe(true) }) + it('handles parallel tool calls in a single step', async () => { + const called: string[] = [] + + // Model returns two tool calls in one step (parallel), then a final text response + const model = sequentialModel([ + mockResult( + [ + { type: 'tool-call', toolCallId: 'c1', toolName: 'getPrice', input: JSON.stringify({ symbol: 'BTC' }) }, + { type: 'tool-call', toolCallId: 'c2', toolName: 'getPrice', input: JSON.stringify({ symbol: 'ETH' }) }, + ], + 'tool-calls', + ), + mockText('BTC and ETH prices fetched.'), + ]) + + const result = await generateText({ + model, + prompt: 'Get BTC and ETH prices', + tools: { + getPrice: tool({ + description: 'Get current price', + inputSchema: z.object({ symbol: z.string() }), + execute: async ({ symbol }) => { + called.push(symbol) + return { symbol, price: symbol === 'BTC' ? 95000 : 3200 } + }, + }), + }, + stopWhen: stepCountIs(10), + }) + + // Both tools executed in the same step + expect(called).toContain('BTC') + expect(called).toContain('ETH') + expect(result.steps[0].toolCalls).toHaveLength(2) + expect(result.steps[0].toolResults).toHaveLength(2) + // SDK emits tool_use IDs that match tool_result IDs in the same step + const callIds = result.steps[0].toolCalls.map(tc => tc.toolCallId) + const resultIds = result.steps[0].toolResults.map(tr => tr.toolCallId) + expect(callIds.sort()).toEqual(resultIds.sort()) + }) + it('agent stops when no tools are needed', async () => { const model = sequentialModel([ mockText('Current portfolio looks healthy, no action needed.'), diff --git a/src/core/__tests__/pipeline/persistence.spec.ts b/src/core/__tests__/pipeline/persistence.spec.ts index 43b5326d..e79da4ee 100644 --- a/src/core/__tests__/pipeline/persistence.spec.ts +++ b/src/core/__tests__/pipeline/persistence.spec.ts @@ -393,6 +393,39 @@ describe('AgentCenter — session persistence', () => { expect(blocks[1]).toMatchObject({ type: 'tool_use', name: 'lookup' }) }) + it('A16: parallel tool calls in one step land in a single user message', async () => { + // Simulates what Vercel AI SDK emits when the model calls multiple tools at once: + // all tool_use events come first, then all tool_result events (one step). + const provider = new FakeProvider([ + toolUseEvent('t1', 'get_price', { symbol: 'BTC' }), + toolUseEvent('t2', 'get_price', { symbol: 'ETH' }), + toolUseEvent('t3', 'get_price', { symbol: 'SOL' }), + toolResultEvent('t1', '95000'), + toolResultEvent('t2', '3200'), + toolResultEvent('t3', '140'), + textEvent('BTC $95k, ETH $3.2k, SOL $140'), + doneEvent('BTC $95k, ETH $3.2k, SOL $140'), + ]) + const ac = makeAgentCenter(provider) + const session = new MemorySessionStore() + + await ac.askWithSession('prices?', session) + + const entries = await session.readAll() + const toolResultEntries = userEntries(entries).filter(u => + Array.isArray(u.message.content) && + (u.message.content as ContentBlock[]).some(b => b.type === 'tool_result'), + ) + + // All 3 results must be in a single user message — not 3 separate ones. + // This is required by Vercel AI SDK: toModelMessages() will throw + // MissingToolResultsError if results are spread across multiple messages. + expect(toolResultEntries).toHaveLength(1) + const resultBlocks = blocksOf(toolResultEntries[0]).filter(b => b.type === 'tool_result') + expect(resultBlocks).toHaveLength(3) + expect(resultBlocks.map(b => (b as { tool_use_id: string }).tool_use_id)).toEqual(['t1', 't2', 't3']) + }) + it('A15: providerTag carries through to intermediate writes too', async () => { const provider = new FakeProvider( [ diff --git a/src/core/agent-center.ts b/src/core/agent-center.ts index b6599218..92b98b07 100644 --- a/src/core/agent-center.ts +++ b/src/core/agent-center.ts @@ -120,6 +120,11 @@ export class AgentCenter { for await (const event of source) { switch (event.type) { case 'tool_use': + // Flush any pending tool results before starting a new assistant round + if (currentUserBlocks.length > 0) { + intermediateMessages.push({ role: 'user', content: currentUserBlocks }) + currentUserBlocks = [] + } // Unified logging — all providers get this now logToolCall(event.name, event.input) currentAssistantBlocks.push({ @@ -135,26 +140,29 @@ export class AgentCenter { // Unified media extraction + image stripping media.push(...extractMediaFromToolResultContent(event.content)) const sessionContent = stripImageData(event.content) - currentUserBlocks.push({ - type: 'tool_result', - tool_use_id: event.tool_use_id, - content: sessionContent, - }) - // Flush assistant blocks before user blocks (tool_use → tool_result) + // Flush assistant blocks before accumulating tool results if (currentAssistantBlocks.length > 0) { intermediateMessages.push({ role: 'assistant', content: currentAssistantBlocks }) currentAssistantBlocks = [] } - if (currentUserBlocks.length > 0) { - intermediateMessages.push({ role: 'user', content: currentUserBlocks }) - currentUserBlocks = [] - } + // Accumulate — parallel tool calls produce multiple results that must + // land in a single user message, so we flush only when the next round starts. + currentUserBlocks.push({ + type: 'tool_result', + tool_use_id: event.tool_use_id, + content: sessionContent, + }) yield event break } case 'text': + // Flush any pending tool results before assistant text (new round) + if (currentUserBlocks.length > 0) { + intermediateMessages.push({ role: 'user', content: currentUserBlocks }) + currentUserBlocks = [] + } currentAssistantBlocks.push({ type: 'text', text: event.text }) yield event break From b581b69a538bdae3c8d6fb3ba8fa5793c534bc86 Mon Sep 17 00:00:00 2001 From: Ame Date: Sat, 14 Mar 2026 11:44:02 +0800 Subject: [PATCH 2/2] fix(ui): warn that Anthropic-compatible base URL must end with /v1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @ai-sdk/anthropic constructs request URLs as `${baseURL}/messages`, so the baseURL must already include /v1 — unlike the Anthropic SDK which appends /v1 automatically. Added an inline hint in the channel config modal when the anthropic provider is selected. Co-Authored-By: Claude Sonnet 4.6 --- ui/src/components/ChannelConfigModal.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ui/src/components/ChannelConfigModal.tsx b/ui/src/components/ChannelConfigModal.tsx index 6ff2a482..a690ded4 100644 --- a/ui/src/components/ChannelConfigModal.tsx +++ b/ui/src/components/ChannelConfigModal.tsx @@ -192,6 +192,11 @@ export function ChannelConfigModal({ channel, onClose, onSaved }: ChannelConfigM placeholder="https://api.openai.com/v1" className={inputClass} /> + {vModelProvider === 'anthropic' && ( +

+ Anthropic-compatible APIs: URL must end with /v1 — e.g. https://example.com/anthropic/v1 +

+ )}