Skip to content

Commit 079e11d

Browse files
authored
fix(core): omit tool call chunks without tool call id (langchain-ai#8994)
1 parent 1519a97 commit 079e11d

File tree

4 files changed

+242
-53
lines changed

4 files changed

+242
-53
lines changed

.changeset/honest-hats-brush.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@langchain/core": patch
3+
---
4+
5+
omit tool call chunks without tool call id

langchain-core/src/messages/ai.ts

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -271,30 +271,50 @@ export class AIMessageChunk extends BaseMessageChunk {
271271
: undefined,
272272
};
273273
} else {
274-
const groupedToolCallChunk = fields.tool_call_chunks.reduce(
274+
const groupedToolCallChunks = fields.tool_call_chunks.reduce(
275275
(acc, chunk) => {
276-
// Assign a fallback ID if the chunk doesn't have one
277-
// This can happen with tools that have empty schemas
278-
const chunkId = chunk.id || `fallback-${chunk.index || 0}`;
279-
acc[chunkId] = acc[chunkId] ?? [];
280-
acc[chunkId].push(chunk);
276+
const matchedChunkIndex = acc.findIndex(([match]) => {
277+
// If chunk has an id and index, match if both are present
278+
if (
279+
"id" in chunk &&
280+
chunk.id &&
281+
"index" in chunk &&
282+
chunk.index !== undefined
283+
) {
284+
return chunk.id === match.id && chunk.index === match.index;
285+
}
286+
// If chunk has an id, we match on id
287+
if ("id" in chunk && chunk.id) {
288+
return chunk.id === match.id;
289+
}
290+
// If chunk has an index, we match on index
291+
if ("index" in chunk && chunk.index !== undefined) {
292+
return chunk.index === match.index;
293+
}
294+
return false;
295+
});
296+
if (matchedChunkIndex !== -1) {
297+
acc[matchedChunkIndex].push(chunk);
298+
} else {
299+
acc.push([chunk]);
300+
}
281301
return acc;
282302
},
283-
{} as Record<string, ToolCallChunk[]>
303+
[] as ToolCallChunk[][]
284304
);
285305

286306
const toolCalls: ToolCall[] = [];
287307
const invalidToolCalls: InvalidToolCall[] = [];
288-
for (const [id, chunks] of Object.entries(groupedToolCallChunk)) {
308+
for (const chunks of groupedToolCallChunks) {
289309
let parsedArgs = {};
290310
const name = chunks[0]?.name ?? "";
291311
const joinedArgs = chunks.map((c) => c.args || "").join("");
292312
const argsStr = joinedArgs.length ? joinedArgs : "{}";
293-
// Use the original ID from the first chunk if it exists, otherwise use the grouped ID
294-
const originalId = chunks[0]?.id || id;
313+
const id = chunks[0]?.id;
295314
try {
296315
parsedArgs = parsePartialJson(argsStr);
297316
if (
317+
!id ||
298318
parsedArgs === null ||
299319
typeof parsedArgs !== "object" ||
300320
Array.isArray(parsedArgs)
@@ -304,14 +324,14 @@ export class AIMessageChunk extends BaseMessageChunk {
304324
toolCalls.push({
305325
name,
306326
args: parsedArgs,
307-
id: originalId,
327+
id,
308328
type: "tool_call",
309329
});
310330
} catch (e) {
311331
invalidToolCalls.push({
312332
name,
313333
args: argsStr,
314-
id: originalId,
334+
id,
315335
error: "Malformed args.",
316336
type: "invalid_tool_call",
317337
});

langchain-core/src/messages/base.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -418,7 +418,9 @@ export function _mergeDicts(
418418
if (key === "type") {
419419
// Do not merge 'type' fields
420420
continue;
421-
} else if (["id", "output_version", "model_provider"].includes(key)) {
421+
} else if (
422+
["id", "name", "output_version", "model_provider"].includes(key)
423+
) {
422424
// Keep the incoming value for these fields
423425
merged[key] = value;
424426
} else {

langchain-core/src/messages/tests/base_message.test.ts

Lines changed: 202 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -467,56 +467,218 @@ describe("Complex AIMessageChunk concat", () => {
467467
});
468468

469469
it("concatenates tool call chunks without IDs", () => {
470-
const chunks: ToolCallChunk[] = [
471-
{
472-
name: "get_current_time",
473-
type: "tool_call_chunk",
474-
index: 0,
475-
// no `id` provided
476-
},
470+
const chunks = [
471+
new AIMessageChunk({
472+
id: "chatcmpl-x",
473+
content: "",
474+
tool_call_chunks: [
475+
{
476+
name: "get_weather",
477+
args: "",
478+
id: "call_q6ZzjkLjKNYb4DizyMOaqpfW",
479+
index: 0,
480+
type: "tool_call_chunk",
481+
},
482+
],
483+
}),
484+
new AIMessageChunk({
485+
id: "chatcmpl-x",
486+
content: "",
487+
tool_call_chunks: [
488+
{
489+
args: '{"',
490+
index: 0,
491+
type: "tool_call_chunk",
492+
},
493+
],
494+
}),
495+
new AIMessageChunk({
496+
id: "chatcmpl-x",
497+
content: "",
498+
tool_call_chunks: [
499+
{
500+
args: "location",
501+
index: 0,
502+
type: "tool_call_chunk",
503+
},
504+
],
505+
}),
506+
new AIMessageChunk({
507+
id: "chatcmpl-x",
508+
content: "",
509+
tool_call_chunks: [
510+
{
511+
args: '":"',
512+
index: 0,
513+
type: "tool_call_chunk",
514+
},
515+
],
516+
}),
517+
new AIMessageChunk({
518+
id: "chatcmpl-x",
519+
content: "",
520+
tool_call_chunks: [
521+
{
522+
args: "San",
523+
index: 0,
524+
type: "tool_call_chunk",
525+
},
526+
],
527+
}),
528+
new AIMessageChunk({
529+
id: "chatcmpl-x",
530+
content: "",
531+
tool_call_chunks: [
532+
{
533+
args: " Francisco",
534+
index: 0,
535+
type: "tool_call_chunk",
536+
},
537+
],
538+
}),
539+
new AIMessageChunk({
540+
id: "chatcmpl-x",
541+
content: "",
542+
tool_call_chunks: [
543+
{
544+
args: '"}',
545+
index: 0,
546+
type: "tool_call_chunk",
547+
},
548+
],
549+
}),
477550
];
478-
479-
const result = new AIMessageChunk({
480-
content: "",
481-
tool_call_chunks: chunks,
482-
});
483-
484-
expect(result.tool_calls?.length).toBe(1);
485-
expect(result.invalid_tool_calls?.length).toBe(0);
486-
expect(result.tool_calls).toEqual([
551+
let finalChunk = new AIMessageChunk("");
552+
for (const chunk of chunks) {
553+
finalChunk = finalChunk.concat(chunk);
554+
}
555+
expect(finalChunk.tool_calls).toHaveLength(1);
556+
expect(finalChunk.tool_calls).toEqual([
487557
{
488-
id: "fallback-0", // Should get fallback ID
489-
name: "get_current_time",
490-
args: {},
491558
type: "tool_call",
559+
name: "get_weather",
560+
args: {
561+
location: "San Francisco",
562+
},
563+
id: "call_q6ZzjkLjKNYb4DizyMOaqpfW",
492564
},
493565
]);
494566
});
567+
});
495568

496-
it("concatenates tool call chunks without IDs and no index", () => {
497-
const chunks: ToolCallChunk[] = [
498-
{
499-
name: "get_current_time",
500-
type: "tool_call_chunk",
501-
// no `id` or `index` provided
502-
},
503-
];
569+
describe("AIMessageChunk", () => {
570+
describe("constructor", () => {
571+
it("omits tool call chunks without IDs", () => {
572+
const chunks: ToolCallChunk[] = [
573+
{
574+
name: "get_current_time",
575+
type: "tool_call_chunk",
576+
index: 0,
577+
// no `id` provided
578+
},
579+
];
504580

505-
const result = new AIMessageChunk({
506-
content: "",
507-
tool_call_chunks: chunks,
581+
const result = new AIMessageChunk({
582+
content: "",
583+
tool_call_chunks: chunks,
584+
});
585+
586+
expect(result.tool_calls?.length).toBe(0);
587+
expect(result.invalid_tool_calls?.length).toBe(1);
588+
expect(result.invalid_tool_calls).toEqual([
589+
{
590+
type: "invalid_tool_call",
591+
id: undefined,
592+
name: "get_current_time",
593+
args: "{}",
594+
error: "Malformed args.",
595+
},
596+
]);
508597
});
509598

510-
expect(result.tool_calls?.length).toBe(1);
511-
expect(result.invalid_tool_calls?.length).toBe(0);
512-
expect(result.tool_calls).toEqual([
513-
{
514-
id: "fallback-0", // Should get fallback ID with index 0
515-
name: "get_current_time",
516-
args: {},
517-
type: "tool_call",
518-
},
519-
]);
599+
it("omits tool call chunks without IDs and no index", () => {
600+
const chunks: ToolCallChunk[] = [
601+
{
602+
name: "get_current_time",
603+
type: "tool_call_chunk",
604+
// no `id` or `index` provided
605+
},
606+
];
607+
608+
const result = new AIMessageChunk({
609+
content: "",
610+
tool_call_chunks: chunks,
611+
});
612+
613+
expect(result.tool_calls?.length).toBe(0);
614+
expect(result.invalid_tool_calls?.length).toBe(1);
615+
expect(result.invalid_tool_calls).toEqual([
616+
{
617+
type: "invalid_tool_call",
618+
id: undefined,
619+
name: "get_current_time",
620+
args: "{}",
621+
error: "Malformed args.",
622+
},
623+
]);
624+
});
625+
626+
it("can concatenate tool call chunks without IDs", () => {
627+
const chunk = new AIMessageChunk({
628+
id: "chatcmpl-x",
629+
content: "",
630+
tool_call_chunks: [
631+
{
632+
name: "get_weather",
633+
args: "",
634+
id: "call_q6ZzjkLjKNYb4DizyMOaqpfW",
635+
index: 0,
636+
type: "tool_call_chunk",
637+
},
638+
{
639+
args: '{"',
640+
index: 0,
641+
type: "tool_call_chunk",
642+
},
643+
{
644+
args: "location",
645+
index: 0,
646+
type: "tool_call_chunk",
647+
},
648+
{
649+
args: '":"',
650+
index: 0,
651+
type: "tool_call_chunk",
652+
},
653+
{
654+
args: "San",
655+
index: 0,
656+
type: "tool_call_chunk",
657+
},
658+
{
659+
args: " Francisco",
660+
index: 0,
661+
type: "tool_call_chunk",
662+
},
663+
{
664+
args: '"}',
665+
index: 0,
666+
type: "tool_call_chunk",
667+
},
668+
],
669+
});
670+
expect(chunk.tool_calls).toHaveLength(1);
671+
expect(chunk.tool_calls).toEqual([
672+
{
673+
type: "tool_call",
674+
name: "get_weather",
675+
args: {
676+
location: "San Francisco",
677+
},
678+
id: "call_q6ZzjkLjKNYb4DizyMOaqpfW",
679+
},
680+
]);
681+
});
520682
});
521683
});
522684

0 commit comments

Comments
 (0)