Skip to content
Open
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
4 changes: 2 additions & 2 deletions src/handlers/chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 —
Expand Down
71 changes: 71 additions & 0 deletions src/handlers/tool-emulation.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
77 changes: 77 additions & 0 deletions test/tool-emulation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
buildSkinnyToolPreambleForProto,
normalizeMessagesForCascade,
pickToolDialect,
interleaveParallelToolMessages,
} from '../src/handlers/tool-emulation.js';

describe('ToolCallStreamParser', () => {
Expand Down Expand Up @@ -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);
});
});