Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions src/ai-providers/vercel-ai-sdk/agent.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.'),
Expand Down
33 changes: 33 additions & 0 deletions src/core/__tests__/pipeline/persistence.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
[
Expand Down
28 changes: 18 additions & 10 deletions src/core/agent-center.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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
Expand Down
5 changes: 5 additions & 0 deletions ui/src/components/ChannelConfigModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,11 @@ export function ChannelConfigModal({ channel, onClose, onSaved }: ChannelConfigM
placeholder="https://api.openai.com/v1"
className={inputClass}
/>
{vModelProvider === 'anthropic' && (
<p className="mt-1 text-xs text-text-muted/60">
Anthropic-compatible APIs: URL must end with <code className="font-mono">/v1</code> — e.g. <code className="font-mono">https://example.com/anthropic/v1</code>
</p>
)}
</div>

<div>
Expand Down
Loading