diff --git a/src/handlers/chat.js b/src/handlers/chat.js index c4937fde..1cfb9293 100644 --- a/src/handlers/chat.js +++ b/src/handlers/chat.js @@ -41,7 +41,7 @@ import { normalizeMessagesForCascade, ToolCallStreamParser, parseToolCallsFromText, stripToolMarkupFromText, buildToolPreambleForProto, buildCompactToolPreambleForProto, buildSchemaCompactToolPreambleForProto, buildSkinnyToolPreambleForProto, - trimToolsForWeakModel, isWeakEmulationModel, + trimToolsForWeakModel, isWeakEmulationModel, interleaveParallelToolMessages, } from './tool-emulation.js'; import { getNativeBridgeDecision, buildReverseLookup, @@ -3310,7 +3310,7 @@ async function _handleChatCompletionsInner(body, context = {}) { const nativeStructured = nativeDefsOn && nativeCallsOn; let connectMessages = emulateTools ? normalizeMessagesForCascade(messages, connectTools, { modelKey: reqModelName, provider: null, route: 'devin_connect', toolChoice: tool_choice, injectUserPreamble: !suppressPreamble, stripOrphans: nativeDefsOn, nativeStructured }) - : messages; + : interleaveParallelToolMessages(messages); // HYBRID native path: DEVIN_CONNECT has no proto tool_calling_section // slot, so the description-only preamble (buildToolPreambleForProto with // nativeStructured:true) is never built by normalizeMessagesForCascade — diff --git a/src/handlers/tool-emulation.js b/src/handlers/tool-emulation.js index 6078d248..4ccbb2d4 100644 --- a/src/handlers/tool-emulation.js +++ b/src/handlers/tool-emulation.js @@ -1034,8 +1034,79 @@ export function stripOrphanedToolResults(messages) { return dropped ? out : messages; } +export function interleaveParallelToolMessages(messages) { + if (!Array.isArray(messages)) return messages; + const out = []; + let i = 0; + while (i < messages.length) { + const m = messages[i]; + if (m?.role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length > 1) { + let j = i + 1; + const toolMsgs = []; + while (j < messages.length && messages[j]?.role === 'tool') { + toolMsgs.push(messages[j]); + j++; + } + + let hasMatches = false; + const usedIndices = new Set(); + for (const tc of m.tool_calls) { + const tcid = String(tc?.id ?? ''); + if (tcid && toolMsgs.some((tm) => String(tm?.tool_call_id ?? '') === tcid)) { + hasMatches = true; + break; + } + } + + if (hasMatches) { + let first = true; + for (const tc of m.tool_calls) { + const singleAssistant = { + ...m, + content: first ? (m.content || null) : null, + tool_calls: [tc], + }; + if (!first) { + delete singleAssistant.reasoning_content; + delete singleAssistant.reasoning; + } + out.push(singleAssistant); + + const tcid = String(tc?.id ?? ''); + const matchIdx = toolMsgs.findIndex( + (tm, idx) => !usedIndices.has(idx) && String(tm?.tool_call_id ?? '') === tcid, + ); + if (matchIdx !== -1) { + usedIndices.add(matchIdx); + out.push(toolMsgs[matchIdx]); + } + first = false; + } + for (let idx = 0; idx < toolMsgs.length; idx++) { + if (!usedIndices.has(idx)) { + out.push(toolMsgs[idx]); + } + } + i = j; + continue; + } + } + out.push(m); + i++; + } + return out; +} + export function normalizeMessagesForCascade(messages, tools, options = {}) { if (!Array.isArray(messages)) return messages; + // Devin Connect & Cascade multi-turn: when nativeStructured mode is engaged + // (native #10 ToolDef + #6 ChatToolCall on DEVIN_CONNECT), the upstream gRPC + // state machine requires tool_calls and results to be interleaved per turn. + // In text-emulation mode (nativeStructured: false, e.g. Kimi dialect), + // dialect-specific batch formatters handle batch history. + if (options.nativeStructured === true) { + messages = interleaveParallelToolMessages(messages); + } // Orphan-stripping is OPT-IN (options.stripOrphans) — NOT the default. Doing it // unconditionally would gut a legitimate continuation that carries a // tool_result whose parent tool_call lived in an earlier, client-truncated diff --git a/test/tool-emulation.test.js b/test/tool-emulation.test.js index 38fbf706..5e0e457a 100644 --- a/test/tool-emulation.test.js +++ b/test/tool-emulation.test.js @@ -12,6 +12,7 @@ import { buildSkinnyToolPreambleForProto, normalizeMessagesForCascade, pickToolDialect, + interleaveParallelToolMessages, } from '../src/handlers/tool-emulation.js'; describe('ToolCallStreamParser', () => { @@ -945,3 +946,79 @@ describe('repairToolCallArguments', () => { assert.equal(JSON.parse(repaired.argumentsJson).command, 'npm test'); }); }); + +describe('interleaveParallelToolMessages', () => { + it('splits batch tool_calls with matching results into alternating pairs', () => { + const messages = [ + { role: 'user', content: 'run two commands' }, + { + role: 'assistant', + content: 'Executing both tools', + reasoning_content: 'Need to run tool 1 then tool 2', + tool_calls: [ + { id: 'call_1', type: 'function', function: { name: 'bash', arguments: '{"command":"pwd"}' } }, + { id: 'call_2', type: 'function', function: { name: 'bash', arguments: '{"command":"ls"}' } }, + ], + }, + { role: 'tool', tool_call_id: 'call_1', content: '/workspace' }, + { role: 'tool', tool_call_id: 'call_2', content: 'file.txt' }, + { role: 'user', content: 'next' }, + ]; + + const out = interleaveParallelToolMessages(messages); + assert.equal(out.length, 6, '1 user + 2 pairs (4 msgs) + 1 user = 6 messages'); + + // Turn 1 + assert.equal(out[1].role, 'assistant'); + assert.equal(out[1].content, 'Executing both tools'); + assert.equal(out[1].reasoning_content, 'Need to run tool 1 then tool 2'); + assert.deepEqual(out[1].tool_calls.map((t) => t.id), ['call_1']); + assert.equal(out[2].role, 'tool'); + assert.equal(out[2].tool_call_id, 'call_1'); + + // Turn 2 + assert.equal(out[3].role, 'assistant'); + assert.equal(out[3].content, null, 'subsequent turns should have null content'); + assert.equal(out[3].reasoning_content, undefined, 'reasoning stripped on subsequent turns'); + assert.deepEqual(out[3].tool_calls.map((t) => t.id), ['call_2']); + assert.equal(out[4].role, 'tool'); + assert.equal(out[4].tool_call_id, 'call_2'); + + // Subsequent user message + assert.equal(out[5].content, 'next'); + }); + + it('preserves unmatched tool results and non-parallel messages intact', () => { + const messages = [ + { + role: 'assistant', + tool_calls: [ + { id: 'c1', type: 'function', function: { name: 'f1', arguments: '{}' } }, + { id: 'c2', type: 'function', function: { name: 'f2', arguments: '{}' } }, + ], + }, + { role: 'tool', tool_call_id: 'c1', content: 'r1' }, + { role: 'tool', tool_call_id: 'c_extra', content: 'r_extra' }, + ]; + + const out = interleaveParallelToolMessages(messages); + assert.equal(out.length, 4); + assert.equal(out[0].tool_calls[0].id, 'c1'); + assert.equal(out[1].tool_call_id, 'c1'); + assert.equal(out[2].tool_calls[0].id, 'c2'); + assert.equal(out[3].tool_call_id, 'c_extra', 'unmatched tool result appended without drop'); + }); + + it('leaves single-tool calls or unresponded calls untouched', () => { + const single = [ + { role: 'assistant', tool_calls: [{ id: 'c1' }] }, + { role: 'tool', tool_call_id: 'c1', content: 'ok' }, + ]; + assert.deepEqual(interleaveParallelToolMessages(single), single); + + const pending = [ + { role: 'assistant', tool_calls: [{ id: 'c1' }, { id: 'c2' }] }, + ]; + assert.deepEqual(interleaveParallelToolMessages(pending), pending); + }); +});