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
103 changes: 51 additions & 52 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 7 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -218,12 +218,12 @@
"@aws-sdk/client-bedrock-runtime": "^3.1013.0",
"@langchain/anthropic": "^1.3.28",
"@langchain/aws": "^1.3.5",
"@langchain/core": "1.1.44",
"@langchain/core": "1.1.48",
"@langchain/deepseek": "^1.0.25",
"@langchain/google-common": "2.1.28",
"@langchain/google-gauth": "2.1.28",
"@langchain/google-genai": "2.1.28",
"@langchain/google-vertexai": "2.1.28",
"@langchain/google-common": "2.1.31",
"@langchain/google-gauth": "2.1.31",
"@langchain/google-genai": "2.1.31",
"@langchain/google-vertexai": "2.1.31",
"@langchain/langgraph": "^1.2.9",
"@langchain/mistralai": "^1.0.8",
"@langchain/openai": "1.4.5",
Expand All @@ -249,7 +249,7 @@
"uuid": "^11.1.1"
},
"peerDependencies": {
"@anthropic-ai/sandbox-runtime": "^0.0.49"
"@anthropic-ai/sandbox-runtime": "^0.0.54"
},
"peerDependenciesMeta": {
"@anthropic-ai/sandbox-runtime": {
Expand All @@ -261,7 +261,7 @@
"~/*": "./*"
},
"devDependencies": {
"@anthropic-ai/sandbox-runtime": "^0.0.49",
"@anthropic-ai/sandbox-runtime": "^0.0.54",
"@anthropic-ai/vertex-sdk": "^0.12.0",
"@eslint/compat": "^1.2.7",
"@rollup/plugin-alias": "^5.1.0",
Expand Down
36 changes: 36 additions & 0 deletions src/llm/anthropic/llm.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3199,3 +3199,39 @@ describe('Opus 4.6', () => {
});
});
});

describe('Tool input survives message conversion', () => {
// Adapted from @langchain/anthropic's "converting messages doesn't drop tool input".
// Guards the core >= 1.1.46 streaming-aggregation regression where the tool_use
// content block's input was emptied (and re-serialization dropped it).
test('converting messages does not drop tool input (live)', async () => {
const jokeTool = {
name: 'generate_random_joke',
description: 'Generate a random joke.',
schema: z.object({
prompt: z.string().describe('The prompt to generate the joke for.'),
}),
};
const model = new ChatAnthropic({
model: 'claude-sonnet-4-5-20250929',
temperature: 0,
}).bindTools([jokeTool]);

const result = await model.invoke([
new HumanMessage(
'Generate three (3) random jokes. Use the generate_random_joke tool and call it three times before responding. This is very important.'
),
]);
expect(result.tool_calls?.length ?? 0).toBeGreaterThan(0);

const converted = _convertMessagesToAnthropicPayload([result]);
const toolUseBlocks = (
converted.messages[0].content as unknown as Array<Record<string, unknown>>
).filter((block) => block.type === 'tool_use');
expect(toolUseBlocks.length).toBeGreaterThan(0);
for (const block of toolUseBlocks) {
expect(block.input).toBeDefined();
expect((block.input as Record<string, unknown>).prompt).toBeDefined();
}
});
});
48 changes: 45 additions & 3 deletions src/llm/anthropic/utils/message_inputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,17 @@ function _formatContent(message: BaseMessage) {
return null;
}

// Core's v1 streaming aggregation can leave a partial tool-input delta as a
// standalone block typed `text` carrying `input` but no `text`. The assembled
// input is restored on the tool_use block from `message.tool_calls`, so drop it.
if (
contentPart.type === 'text' &&
'input' in contentPart &&
!('text' in contentPart)
) {
return null;
}

if (isDataContentBlock(contentPart)) {
return convertToProviderContentBlock(
contentPart,
Expand Down Expand Up @@ -617,9 +628,9 @@ function _formatContent(message: BaseMessage) {
}

if (contentPartCopy.type === 'input_json_delta') {
// `input_json_delta` type only represents yielding partial tool inputs
// and is not a valid type for Anthropic messages.
contentPartCopy.type = 'tool_use';
// Orphaned partial tool-input delta with no id of its own. The assembled
// input is restored on the tool_use block from `message.tool_calls`; drop it.
return null;
}

if (
Expand All @@ -631,6 +642,37 @@ function _formatContent(message: BaseMessage) {
contentPartCopy.type = 'server_tool_use';
}

// Core's streaming aggregation can leave the inline tool_use input empty
// (the assembled arguments live in `message.tool_calls` or, for persisted
// messages, in sibling input_json_delta blocks). Restore it when missing.
if (
contentPartCopy.type === 'tool_use' &&
typeof contentPartCopy.id === 'string' &&
(contentPartCopy.input === '' || contentPartCopy.input == null)
) {
const matchingToolCall = isAIMessage(message)
? message.tool_calls?.find((toolCall) => toolCall.id === contentPartCopy.id)
: undefined;
if (matchingToolCall) {
contentPartCopy.input = matchingToolCall.args;
} else {
const blockIndex = (contentPart as Record<string, unknown>).index;
const merged = contentParts
.filter((part) => {
const p = part as Record<string, unknown>;
return (
p.type === 'input_json_delta' &&
p.index === blockIndex &&
typeof p.input === 'string'
);
})
.reduce((acc, part) => acc + (part as Record<string, unknown>).input, '');
if (merged !== '') {
contentPartCopy.input = merged;
}
}
}

if ('input' in contentPartCopy) {
// Anthropic tool use inputs should be valid objects, when applicable.
if (typeof contentPartCopy.input === 'string') {
Expand Down
8 changes: 6 additions & 2 deletions src/llm/anthropic/utils/message_outputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,10 +203,14 @@ export function _makeMessageChunkFromAnthropicEvent(
) {
const content = [
{
// No `type`: core's streaming aggregation merges this partial input into the
// sibling tool_use/server_tool_use block at the same index, keeping its type.
// A typed delta block won't merge under core >= 1.1.46 ("keep different block
// types separate"), which would orphan the input and empty the tool_use input.
index: data.index,
input: data.delta.partial_json,
type: data.delta.type,
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
];
return {
chunk: new AIMessageChunk({
Expand Down
Loading
Loading