Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
65 changes: 58 additions & 7 deletions src/llm/anthropic/utils/message_inputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
/**
* This util file contains functions for converting LangChain messages to Anthropic messages.
*/
import { createHash } from 'node:crypto';
import {
type BaseMessage,
type SystemMessage,
Expand Down Expand Up @@ -90,6 +91,49 @@ function _formatImage(imageUrl: string) {
);
}

const ANTHROPIC_TOOL_USE_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
const ANTHROPIC_TOOL_USE_ID_MAX_LENGTH = 64;
const ANTHROPIC_TOOL_USE_ID_HASH_LENGTH = 10;

/**
* Normalize a tool-call ID to satisfy Anthropic's `^[a-zA-Z0-9_-]+$` and 64-char
* constraints. Pure and deterministic — same input always yields the same output,
* so paired `tool_use.id` and `tool_result.tool_use_id` stay matched without
* needing a session map. IDs that already comply pass through unchanged.
*
* For non-compliant inputs we sanitize then append a short SHA-256 prefix of
* the original ID to preserve uniqueness when truncation would otherwise
* collapse distinct IDs to the same value (e.g. two long Responses-style IDs
* sharing a 64-char prefix). The hash is computed against the raw input so
* inputs that differ only after the truncation cutoff still produce distinct
* outputs.
*/
export function normalizeAnthropicToolCallId(id: string): string;
export function normalizeAnthropicToolCallId(
id: string | undefined
): string | undefined;
export function normalizeAnthropicToolCallId(
id: string | undefined
): string | undefined {
if (id == null) {
return id;
}
if (
id.length <= ANTHROPIC_TOOL_USE_ID_MAX_LENGTH &&
ANTHROPIC_TOOL_USE_ID_PATTERN.test(id)
) {
return id;
}
const sanitized = id.replace(/[^a-zA-Z0-9_-]/g, '_');
const hash = createHash('sha256')
.update(id)
.digest('hex')
.slice(0, ANTHROPIC_TOOL_USE_ID_HASH_LENGTH);
const prefixMaxLength =
ANTHROPIC_TOOL_USE_ID_MAX_LENGTH - ANTHROPIC_TOOL_USE_ID_HASH_LENGTH - 1;
return `${sanitized.slice(0, prefixMaxLength)}_${hash}`;
}

function _ensureMessageContents(
messages: BaseMessage[]
): (SystemMessage | HumanMessage | AIMessage)[] {
Expand All @@ -109,7 +153,9 @@ function _ensureMessageContents(
(previousMessage.content as MessageContentComplex[]).push({
type: 'tool_result',
content: message.content,
tool_use_id: (message as ToolMessage).tool_call_id,
tool_use_id: normalizeAnthropicToolCallId(
(message as ToolMessage).tool_call_id
),
});
} else {
// If not, we create a new human message with the tool result.
Expand All @@ -119,7 +165,9 @@ function _ensureMessageContents(
{
type: 'tool_result',
content: message.content,
tool_use_id: (message as ToolMessage).tool_call_id,
tool_use_id: normalizeAnthropicToolCallId(
(message as ToolMessage).tool_call_id
),
},
],
})
Expand All @@ -135,7 +183,9 @@ function _ensureMessageContents(
...(message.content != null
? { content: _formatContent(message) }
: {}),
tool_use_id: (message as ToolMessage).tool_call_id,
tool_use_id: normalizeAnthropicToolCallId(
(message as ToolMessage).tool_call_id
),
},
],
})
Expand All @@ -154,11 +204,12 @@ export function _convertLangChainToolCallToAnthropic(
if (toolCall.id === undefined) {
throw new Error('Anthropic requires all tool calls to have an "id".');
}
const isServerTool = toolCall.id.startsWith(
Constants.ANTHROPIC_SERVER_TOOL_PREFIX
);
return {
type: toolCall.id.startsWith(Constants.ANTHROPIC_SERVER_TOOL_PREFIX)
? 'server_tool_use'
: 'tool_use',
id: toolCall.id,
type: isServerTool ? 'server_tool_use' : 'tool_use',
id: isServerTool ? toolCall.id : normalizeAnthropicToolCallId(toolCall.id),
name: toolCall.name,
input: toolCall.args,
};
Expand Down
178 changes: 178 additions & 0 deletions src/llm/anthropic/utils/tool-id-normalization.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { AIMessage, HumanMessage, ToolMessage } from '@langchain/core/messages';
import {
_convertLangChainToolCallToAnthropic,
_convertMessagesToAnthropicPayload,
normalizeAnthropicToolCallId,
} from './message_inputs';

describe('normalizeAnthropicToolCallId', () => {
it('returns valid IDs unchanged', () => {
expect(normalizeAnthropicToolCallId('toolu_01ABcdEFgh')).toBe(
'toolu_01ABcdEFgh'
);
expect(normalizeAnthropicToolCallId('call_abc123XYZ')).toBe(
'call_abc123XYZ'
);
expect(normalizeAnthropicToolCallId('a-b_c-d')).toBe('a-b_c-d');
});

it('sanitizes invalid characters and appends a hash suffix', () => {
const out = normalizeAnthropicToolCallId(
'fc_67abc1234def567|call_abc123def456ghi789jkl0mnopqrs'
);
expect(/^[a-zA-Z0-9_-]+$/.test(out)).toBe(true);
expect(out.length).toBeLessThanOrEqual(64);
expect(
out.startsWith('fc_67abc1234def567_call_abc123def456ghi789jkl0mn')
).toBe(true);
// Suffix is `_<10-hex-char hash>`
expect(out).toMatch(/_[0-9a-f]{10}$/);
});

it('produces compliant output for IDs of any length', () => {
const long = 'fc_' + 'a'.repeat(80);
const out = normalizeAnthropicToolCallId(long);
expect(out).toHaveLength(64);
expect(/^[a-zA-Z0-9_-]+$/.test(out)).toBe(true);
});

it('produces uniquely distinguishable outputs for IDs that share a 64-char prefix', () => {
const sharedPrefix = 'fc_' + 'a'.repeat(80);
const idA = sharedPrefix + '|call_unique_A';
const idB = sharedPrefix + '|call_unique_B';

const outA = normalizeAnthropicToolCallId(idA);
const outB = normalizeAnthropicToolCallId(idB);

expect(outA).not.toBe(outB);
expect(outA).toHaveLength(64);
expect(outB).toHaveLength(64);
expect(/^[a-zA-Z0-9_-]+$/.test(outA)).toBe(true);
expect(/^[a-zA-Z0-9_-]+$/.test(outB)).toBe(true);
});

it('disambiguates short IDs that sanitize to the same value', () => {
expect(normalizeAnthropicToolCallId('a|b')).not.toBe(
normalizeAnthropicToolCallId('a.b')
);
});

it('handles combined length and character violations', () => {
const id = 'fc_' + 'x|'.repeat(100);
const out = normalizeAnthropicToolCallId(id);
expect(out).toHaveLength(64);
expect(/^[a-zA-Z0-9_-]+$/.test(out)).toBe(true);
});

it('is deterministic — same input always yields same output', () => {
const id = 'fc_a|b|c';
expect(normalizeAnthropicToolCallId(id)).toBe(
normalizeAnthropicToolCallId(id)
);
});

it('passes through undefined for the optional overload', () => {
expect(normalizeAnthropicToolCallId(undefined)).toBeUndefined();
});

it('handles empty string by producing a deterministic compliant output', () => {
const out = normalizeAnthropicToolCallId('');
expect(/^[a-zA-Z0-9_-]+$/.test(out)).toBe(true);
expect(out.length).toBeLessThanOrEqual(64);
expect(out).toBe(normalizeAnthropicToolCallId(''));
});
});

describe('_convertMessagesToAnthropicPayload — cross-provider ID normalization', () => {
it('normalizes Responses-style IDs on tool_use AND matching tool_result', () => {
const responsesId = 'fc_67abc1234def567|call_abc123def456ghi789jkl0mnopqrs';

const payload = _convertMessagesToAnthropicPayload([
new HumanMessage('weather?'),
new AIMessage({
content: '',
tool_calls: [
{
id: responsesId,
name: 'get_weather',
args: { location: 'Tokyo' },
type: 'tool_call',
},
],
}),
new ToolMessage({
tool_call_id: responsesId,
content: '{"temp": 21}',
}),
]);

const assistantMsg = payload.messages.find((m) => m.role === 'assistant')!;
const userToolResultMsg = payload.messages.find(
(m) =>
m.role === 'user' &&
Array.isArray(m.content) &&
(m.content as Array<{ type: string }>)[0]?.type === 'tool_result'
)!;

const toolUseBlock = (
assistantMsg.content as Array<{ type: string; id?: string }>
).find((b) => b.type === 'tool_use')!;
const toolResultBlock = (
userToolResultMsg.content as Array<{
type: string;
tool_use_id?: string;
}>
).find((b) => b.type === 'tool_result')!;

const expected = normalizeAnthropicToolCallId(responsesId);
expect(toolUseBlock.id).toBe(expected);
expect(toolResultBlock.tool_use_id).toBe(expected);
expect(toolUseBlock.id).toBe(toolResultBlock.tool_use_id);
expect(/^[a-zA-Z0-9_-]+$/.test(toolUseBlock.id!)).toBe(true);
expect(toolUseBlock.id!.length).toBeLessThanOrEqual(64);
});

it('passes through Anthropic-native IDs unchanged', () => {
const nativeId = 'toolu_01ABcdEFgh23ijKL';

const payload = _convertMessagesToAnthropicPayload([
new HumanMessage('hi'),
new AIMessage({
content: '',
tool_calls: [
{
id: nativeId,
name: 'noop',
args: {},
type: 'tool_call',
},
],
}),
new ToolMessage({
tool_call_id: nativeId,
content: 'ok',
}),
]);

const assistantMsg = payload.messages.find((m) => m.role === 'assistant')!;
const toolUseBlock = (
assistantMsg.content as Array<{ type: string; id?: string }>
).find((b) => b.type === 'tool_use')!;

expect(toolUseBlock.id).toBe(nativeId);
});

it('does not normalize server tool IDs (srvtoolu_ prefix)', () => {
const serverId = 'srvtoolu_01abcXYZ';

const block = _convertLangChainToolCallToAnthropic({
id: serverId,
name: 'web_search',
args: { query: 'x' },
type: 'tool_call',
});

expect(block.type).toBe('server_tool_use');
expect(block.id).toBe(serverId);
});
});
Loading
Loading