🐛 fix: fix tools calling long name length >64 issue (#9697)

* add ToolNameResolver

* fix ToolName generate tests

* fix tests
This commit is contained in:
Arvin Xu
2025-10-14 04:54:40 +02:00
committed by GitHub
parent ad0fae3c2a
commit cb986040d5
14 changed files with 784 additions and 271 deletions
+2 -1
View File
@@ -23,7 +23,8 @@
"@lobechat/utils": "workspace:*",
"debug": "^4.3.4",
"immer": "^10.0.3",
"lodash-es": "^4.17.21"
"lodash-es": "^4.17.21",
"ts-md5": "^2.0.1"
},
"devDependencies": {
"@types/debug": "^4.1.12",
+1 -1
View File
@@ -38,4 +38,4 @@ export type {
ToolsGenerationContext,
ToolsGenerationResult,
} from './tools';
export { filterValidManifests, ToolsEngine, validateManifest } from './tools';
export { filterValidManifests, ToolNameResolver, ToolsEngine, validateManifest } from './tools';
@@ -0,0 +1,105 @@
import { ChatToolPayload, MessageToolCall } from '@lobechat/types';
import { Md5 } from 'ts-md5';
import { LobeChatPluginApi, LobeChatPluginManifest } from './types';
// Tool naming constants
const PLUGIN_SCHEMA_SEPARATOR = '____';
const PLUGIN_SCHEMA_API_MD5_PREFIX = 'MD5HASH_';
/**
* Tool Name Resolver
* Handles tool name generation and resolution for function calling
*/
export class ToolNameResolver {
/**
* Generate MD5 hash for tool name shortening
* @private
*/
private genHash(name: string): string {
return Md5.hashStr(name).toString().slice(0, 12);
}
/**
* Generate tool calling name
* @param identifier - Plugin identifier
* @param name - API name
* @param type - Plugin type (default: 'default')
* @returns Generated tool name (max 64 characters)
*/
generate(identifier: string, name: string, type: string = 'default'): string {
const pluginType = type && type !== 'default' ? `${PLUGIN_SCHEMA_SEPARATOR}${type}` : '';
// Step 1: Try normal format
let apiName = identifier + PLUGIN_SCHEMA_SEPARATOR + name + pluginType;
// OpenAI GPT function_call name can't be longer than 64 characters
// Step 2: If >= 64, hash the name part
if (apiName.length >= 64) {
const nameHash = PLUGIN_SCHEMA_API_MD5_PREFIX + this.genHash(name);
apiName = identifier + PLUGIN_SCHEMA_SEPARATOR + nameHash + pluginType;
// Step 3: If still >= 64, also hash the identifier
if (apiName.length >= 64) {
const identifierHash = PLUGIN_SCHEMA_API_MD5_PREFIX + this.genHash(identifier);
apiName = identifierHash + PLUGIN_SCHEMA_SEPARATOR + nameHash + pluginType;
}
}
return apiName;
}
/**
* Resolve tool calls from AI response back to original tool information
* @param toolCalls - Tool calls from AI model response
* @param manifests - Available tool manifests mapped by identifier
* @returns Resolved tool payloads
*/
resolve(
toolCalls: MessageToolCall[],
manifests: Record<string, LobeChatPluginManifest>,
): ChatToolPayload[] {
return toolCalls
.map((toolCall): ChatToolPayload | null => {
let [identifier, apiName, type] = toolCall.function.name.split(PLUGIN_SCHEMA_SEPARATOR);
if (!apiName) return null;
// Step 1: Resolve hashed identifier if needed
if (identifier.startsWith(PLUGIN_SCHEMA_API_MD5_PREFIX)) {
const identifierMd5 = identifier.replace(PLUGIN_SCHEMA_API_MD5_PREFIX, '');
// Find the manifest by hashed identifier
const foundIdentifier = Object.keys(manifests).find(
(id) => this.genHash(id) === identifierMd5,
);
if (foundIdentifier) {
identifier = foundIdentifier;
}
}
let payload: ChatToolPayload = {
apiName,
arguments: toolCall.function.arguments,
id: toolCall.id,
identifier,
type: (type ?? 'default') as any,
};
// Step 2: Resolve hashed apiName if needed
if (apiName.startsWith(PLUGIN_SCHEMA_API_MD5_PREFIX) && manifests[identifier]) {
const md5 = apiName.replace(PLUGIN_SCHEMA_API_MD5_PREFIX, '');
const manifest = manifests[identifier];
const api = manifest?.api.find(
(api: LobeChatPluginApi) => this.genHash(api.name) === md5,
);
if (api) {
payload.apiName = api.name;
}
}
return payload;
})
.filter(Boolean) as ChatToolPayload[];
}
}
@@ -0,0 +1,615 @@
import { describe, expect, it } from 'vitest';
import { ToolNameResolver } from '../ToolNameResolver';
describe('ToolNameResolver', () => {
const resolver = new ToolNameResolver();
describe('generate - basic functionality', () => {
it('should generate tool name with identifier and api name', () => {
const result = resolver.generate('test-plugin', 'myAction');
expect(result).toBe('test-plugin____myAction');
});
it('should generate tool name with type suffix', () => {
const result = resolver.generate('test-plugin', 'myAction', 'builtin');
expect(result).toBe('test-plugin____myAction____builtin');
});
it('should handle default type', () => {
const result = resolver.generate('test-plugin', 'myAction', 'default');
expect(result).toBe('test-plugin____myAction');
});
it('should handle undefined type as default', () => {
const result = resolver.generate('test-plugin', 'myAction');
expect(result).toBe('test-plugin____myAction');
});
});
describe('generate - long name handling', () => {
it('should shorten long action names using hash', () => {
// Create a normal identifier with a very long action name
const identifier = 'my-plugin';
const longActionName =
'very-long-action-name-that-will-cause-the-total-length-to-exceed-64-characters';
const result = resolver.generate(identifier, longActionName, 'builtin');
// The result should be shorter than the original would have been
const originalLength = `${identifier}____${longActionName}____builtin`.length;
expect(result.length).toBeLessThan(originalLength);
// Should contain the identifier, MD5HASH prefix, and type
expect(result).toContain(identifier);
expect(result).toContain('MD5HASH_');
expect(result).toContain('____builtin');
expect(result).toMatch(/^my-plugin____MD5HASH_[a-f0-9]+____builtin$/);
});
it('should handle identifier that is itself long', () => {
// Test when identifier itself is very long
const veryLongIdentifier = 'very-long-plugin-identifier-that-will-cause-overflow';
const actionName = 'action';
const result = resolver.generate(veryLongIdentifier, actionName, 'standalone');
// When both identifier and name cause total >= 64, both get hashed
expect(result).toContain('MD5HASH_');
expect(result).toContain('____standalone');
// Result should be shortened
const originalLength = `${veryLongIdentifier}____${actionName}____standalone`.length;
expect(result.length).toBeLessThan(originalLength);
// With 12-char hashes: MD5HASH_xxx(20) + ____(4) + MD5HASH_xxx(20) + ____(4) + standalone(10) = 58
expect(result.length).toBeLessThan(64);
});
it('should keep short names unchanged', () => {
const result = resolver.generate('short', 'action', 'type');
expect(result).toBe('short____action____type');
expect(result.length).toBeLessThan(64);
});
it('should handle edge case at exactly 64 characters', () => {
// Create a name that's exactly 64 characters
const identifier = 'short-id';
const actionName = 'b'.repeat(44);
const type = 'type'; // 8 + 4 + 44 + 4 + 4 = 64
const result = resolver.generate(identifier, actionName, type);
// When total length >= 64, action name should be hashed
// Result format: identifier____MD5HASH_xxx____type
expect(result).toContain(identifier);
expect(result).toContain('MD5HASH_');
expect(result).toContain(type);
// The result should be shorter than the original would have been
const originalLength = `${identifier}____${actionName}____${type}`.length;
expect(result.length).toBeLessThan(originalLength);
});
});
describe('generate - special characters and edge cases', () => {
it('should handle identifiers with special characters', () => {
const result = resolver.generate('my-plugin_v2', 'action-name', 'builtin');
expect(result).toBe('my-plugin_v2____action-name____builtin');
});
it('should handle empty action name', () => {
const result = resolver.generate('plugin', '', 'builtin');
expect(result).toBe('plugin________builtin');
});
it('should handle numeric identifiers and action names', () => {
const result = resolver.generate('plugin123', 'action456', 'type789');
expect(result).toBe('plugin123____action456____type789');
});
it('should be consistent for same inputs', () => {
const result1 = resolver.generate('plugin', 'action', 'type');
const result2 = resolver.generate('plugin', 'action', 'type');
expect(result1).toBe(result2);
});
it('should produce different results for different inputs', () => {
const result1 = resolver.generate('plugin1', 'action', 'type');
const result2 = resolver.generate('plugin2', 'action', 'type');
expect(result1).not.toBe(result2);
});
});
describe('generate - hash consistency', () => {
it('should generate consistent hash for same long action name', () => {
const identifier = 'plugin';
const longActionName = 'very-long-action-name-that-will-also-cause-overflow';
const result1 = resolver.generate(identifier, longActionName, 'builtin');
const result2 = resolver.generate(identifier, longActionName, 'builtin');
expect(result1).toBe(result2);
expect(result1).toContain('MD5HASH_');
});
it('should generate different hashes for different long action names', () => {
const identifier = 'plugin';
const longActionName1 = 'very-long-action-name-that-will-also-cause-overflow-1';
const longActionName2 = 'very-long-action-name-that-will-also-cause-overflow-2';
const result1 = resolver.generate(identifier, longActionName1, 'builtin');
const result2 = resolver.generate(identifier, longActionName2, 'builtin');
expect(result1).not.toBe(result2);
expect(result1).toContain('MD5HASH_');
expect(result2).toContain('MD5HASH_');
});
});
describe('generate - real-world examples', () => {
it('should handle builtin tools correctly', () => {
const result = resolver.generate('lobe-image-designer', 'text2image', 'builtin');
expect(result).toBe('lobe-image-designer____text2image____builtin');
});
it('should handle web browsing tools correctly', () => {
const result = resolver.generate('lobe-web-browsing', 'search', 'builtin');
expect(result).toBe('lobe-web-browsing____search____builtin');
});
it('should handle plugin tools correctly', () => {
const result = resolver.generate('custom-plugin', 'customAction');
expect(result).toBe('custom-plugin____customAction');
});
});
describe('resolve - basic functionality', () => {
it('should resolve normal tool calls without hashing', () => {
const toolCalls = [
{
function: {
arguments: '{"query": "test"}',
name: 'test-plugin____myAction____builtin',
},
id: 'call_1',
type: 'function',
},
];
const manifests = {
'test-plugin': {
api: [{ description: 'My action', name: 'myAction', parameters: {} }],
identifier: 'test-plugin',
meta: {},
type: 'builtin' as const,
},
};
const result = resolver.resolve(toolCalls, manifests);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
apiName: 'myAction',
arguments: '{"query": "test"}',
id: 'call_1',
identifier: 'test-plugin',
type: 'builtin' as const,
});
});
it('should handle default type correctly', () => {
const toolCalls = [
{
function: {
arguments: '{}',
name: 'plugin____action',
},
id: 'call_1',
type: 'function',
},
];
const manifests = {
plugin: {
api: [{ description: 'Action', name: 'action', parameters: {} }],
identifier: 'plugin',
meta: {},
},
};
const result = resolver.resolve(toolCalls, manifests);
expect(result[0].type).toBe('default');
});
it('should handle empty tool calls array', () => {
const result = resolver.resolve([], {});
expect(result).toEqual([]);
});
it('should handle multiple tool calls', () => {
const toolCalls = [
{
function: { arguments: '{}', name: 'plugin1____action1' },
id: 'call_1',
type: 'function',
},
{
function: { arguments: '{}', name: 'plugin2____action2____builtin' },
id: 'call_2',
type: 'function',
},
];
const manifests = {
plugin1: {
api: [{ description: '', name: 'action1', parameters: {} }],
identifier: 'plugin1',
meta: {},
},
plugin2: {
api: [{ description: '', name: 'action2', parameters: {} }],
identifier: 'plugin2',
meta: {},
type: 'builtin' as const,
},
};
const result = resolver.resolve(toolCalls, manifests);
expect(result).toHaveLength(2);
expect(result[0].identifier).toBe('plugin1');
expect(result[1].identifier).toBe('plugin2');
});
});
describe('resolve - hashed apiName', () => {
it('should resolve hashed apiName back to original', () => {
const identifier = 'my-plugin';
const longActionName =
'very-long-action-name-that-will-cause-the-total-length-to-exceed-64-characters';
// Generate a hashed tool name
const hashedToolName = resolver.generate(identifier, longActionName, 'builtin');
// Create tool call with hashed name
const toolCalls = [
{
function: {
arguments: '{"param": "value"}',
name: hashedToolName,
},
id: 'call_1',
type: 'function',
},
];
// Create manifest with original api name
const manifests = {
[identifier]: {
api: [{ description: 'Long action', name: longActionName, parameters: {} }],
identifier,
meta: {},
type: 'builtin' as const,
},
};
const result = resolver.resolve(toolCalls, manifests);
expect(result).toHaveLength(1);
expect(result[0].apiName).toBe(longActionName);
expect(result[0].identifier).toBe(identifier);
expect(result[0].type).toBe('builtin');
});
it('should keep hashed apiName if manifest not found', () => {
const toolCalls = [
{
function: {
arguments: '{}',
name: 'plugin____MD5HASH_abc123def456',
},
id: 'call_1',
type: 'function',
},
];
const result = resolver.resolve(toolCalls, {});
expect(result[0].apiName).toBe('MD5HASH_abc123def456');
});
it('should keep hashed apiName if api not found in manifest', () => {
const toolCalls = [
{
function: {
arguments: '{}',
name: 'plugin____MD5HASH_abc123def456',
},
id: 'call_1',
type: 'function',
},
];
const manifests = {
plugin: {
api: [{ description: '', name: 'differentAction', parameters: {} }],
identifier: 'plugin',
meta: {},
},
};
const result = resolver.resolve(toolCalls, manifests);
expect(result[0].apiName).toBe('MD5HASH_abc123def456');
});
});
describe('resolve - hashed identifier', () => {
it('should resolve hashed identifier back to original', () => {
const veryLongIdentifier = 'very-long-plugin-identifier-that-will-cause-overflow';
const actionName = 'action';
// Generate a hashed tool name (both identifier and name will be hashed)
const hashedToolName = resolver.generate(veryLongIdentifier, actionName, 'standalone');
// Create tool call with hashed name
const toolCalls = [
{
function: {
arguments: '{"test": true}',
name: hashedToolName,
},
id: 'call_1',
type: 'function',
},
];
// Create manifest with original identifier
const manifests = {
[veryLongIdentifier]: {
api: [{ description: 'Action', name: actionName, parameters: {} }],
identifier: veryLongIdentifier,
meta: {},
type: 'standalone' as const,
},
};
const result = resolver.resolve(toolCalls, manifests);
expect(result).toHaveLength(1);
expect(result[0].identifier).toBe(veryLongIdentifier);
expect(result[0].apiName).toBe(actionName);
expect(result[0].type).toBe('standalone');
});
it('should keep hashed identifier if not found in manifests', () => {
const toolCalls = [
{
function: {
arguments: '{}',
name: 'MD5HASH_abc123def456____action',
},
id: 'call_1',
type: 'function',
},
];
const manifests = {
'different-plugin': {
api: [{ description: '', name: 'action', parameters: {} }],
identifier: 'different-plugin',
meta: {},
},
};
const result = resolver.resolve(toolCalls, manifests);
expect(result[0].identifier).toBe('MD5HASH_abc123def456');
});
});
describe('resolve - both identifier and apiName hashed', () => {
it('should resolve both hashed identifier and apiName', () => {
const veryLongIdentifier = 'very-long-plugin-identifier-that-will-cause-overflow';
const veryLongActionName = 'very-long-action-name-that-will-also-cause-overflow';
// Generate hashed tool name (both will be hashed)
const hashedToolName = resolver.generate(
veryLongIdentifier,
veryLongActionName,
'standalone',
);
// Verify both are hashed
expect(hashedToolName).toContain('MD5HASH_');
expect(hashedToolName).not.toContain(veryLongIdentifier);
expect(hashedToolName).not.toContain(veryLongActionName);
// Create tool call with fully hashed name
const toolCalls = [
{
function: {
arguments: '{"data": "test"}',
name: hashedToolName,
},
id: 'call_1',
type: 'function',
},
];
// Create manifest
const manifests = {
[veryLongIdentifier]: {
api: [{ description: 'Long action', name: veryLongActionName, parameters: {} }],
identifier: veryLongIdentifier,
meta: {},
type: 'standalone' as const,
},
};
const result = resolver.resolve(toolCalls, manifests);
expect(result).toHaveLength(1);
expect(result[0].identifier).toBe(veryLongIdentifier);
expect(result[0].apiName).toBe(veryLongActionName);
expect(result[0].type).toBe('standalone');
});
});
describe('resolve - edge cases', () => {
it('should filter out invalid tool calls with missing apiName', () => {
const toolCalls = [
{
function: {
arguments: '{}',
name: 'invalid-name-without-separator',
},
id: 'call_1',
type: 'function',
},
];
const result = resolver.resolve(toolCalls, {});
expect(result).toEqual([]);
});
it('should handle tool calls with different types', () => {
const toolCalls = [
{
function: { arguments: '{}', name: 'plugin1____action1____builtin' },
id: 'call_1',
type: 'function',
},
{
function: { arguments: '{}', name: 'plugin2____action2____standalone' },
id: 'call_2',
type: 'function',
},
{
function: { arguments: '{}', name: 'plugin3____action3____mcp' },
id: 'call_3',
type: 'function',
},
];
const manifests = {
plugin1: {
api: [{ description: '', name: 'action1', parameters: {} }],
identifier: 'plugin1',
meta: {},
type: 'builtin' as const,
},
plugin2: {
api: [{ description: '', name: 'action2', parameters: {} }],
identifier: 'plugin2',
meta: {},
type: 'standalone' as const,
},
plugin3: {
api: [{ description: '', name: 'action3', parameters: {} }],
identifier: 'plugin3',
meta: {},
type: 'mcp' as const,
},
};
const result = resolver.resolve(toolCalls, manifests);
expect(result).toHaveLength(3);
expect(result[0].type).toBe('builtin');
expect(result[1].type).toBe('standalone');
expect(result[2].type).toBe('mcp');
});
});
describe('resolve - real-world integration', () => {
it('should handle complete generate-resolve roundtrip', () => {
const identifier = 'lobe-image-designer';
const apiName = 'text2image';
const type = 'builtin' as const;
// Generate tool name
const toolName = resolver.generate(identifier, apiName, type);
// Simulate tool call from AI
const toolCalls = [
{
function: {
arguments: '{"prompt": "a beautiful sunset", "size": "1024x1024"}',
name: toolName,
},
id: 'call_abc123',
type: 'function',
},
];
// Create manifest
const manifests = {
[identifier]: {
api: [
{
description: 'Generate image from text',
name: apiName,
parameters: {
properties: {
prompt: { type: 'string' },
size: { type: 'string' },
},
type: 'object',
},
},
],
identifier,
meta: { avatar: '', description: '', title: 'Image Designer' },
type: 'builtin' as const,
},
};
// Resolve tool calls
const result = resolver.resolve(toolCalls, manifests);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
apiName,
arguments: '{"prompt": "a beautiful sunset", "size": "1024x1024"}',
id: 'call_abc123',
identifier,
type,
});
});
it('should handle roundtrip with long names requiring hashing', () => {
const longIdentifier = 'very-long-plugin-identifier-that-exceeds-normal-length';
const longApiName = 'very-long-api-name-that-also-exceeds-normal-length-limits';
const type = 'standalone' as const;
// Generate hashed tool name
const toolName = resolver.generate(longIdentifier, longApiName, type);
expect(toolName.length).toBeLessThan(64);
// Create tool call
const toolCalls = [
{
function: { arguments: '{"input": "data"}', name: toolName },
id: 'call_xyz789',
type: 'function',
},
];
// Create manifest
const manifests = {
[longIdentifier]: {
api: [{ description: 'Long API', name: longApiName, parameters: {} }],
identifier: longIdentifier,
meta: {},
type: 'standalone' as const,
},
};
// Resolve should restore original names
const result = resolver.resolve(toolCalls, manifests);
expect(result).toHaveLength(1);
expect(result[0].identifier).toBe(longIdentifier);
expect(result[0].apiName).toBe(longApiName);
expect(result[0].type).toBe(type);
});
});
});
@@ -1,152 +0,0 @@
import { describe, expect, it } from 'vitest';
import { generateToolName } from '../utils';
describe('generateToolName', () => {
describe('basic functionality', () => {
it('should generate tool name with identifier and api name', () => {
const result = generateToolName('test-plugin', 'myAction');
expect(result).toBe('test-plugin____myAction');
});
it('should generate tool name with type suffix', () => {
const result = generateToolName('test-plugin', 'myAction', 'builtin');
expect(result).toBe('test-plugin____myAction____builtin');
});
it('should handle default type', () => {
const result = generateToolName('test-plugin', 'myAction', 'default');
expect(result).toBe('test-plugin____myAction');
});
it('should handle undefined type as default', () => {
const result = generateToolName('test-plugin', 'myAction');
expect(result).toBe('test-plugin____myAction');
});
});
describe('long name handling', () => {
it('should shorten long action names using hash', () => {
// Create a normal identifier with a very long action name
const identifier = 'my-plugin';
const longActionName = 'very-long-action-name-that-will-cause-the-total-length-to-exceed-64-characters';
const result = generateToolName(identifier, longActionName, 'builtin');
// The result should be shorter than the original would have been
const originalLength = `${identifier}____${longActionName}____builtin`.length;
expect(result.length).toBeLessThan(originalLength);
// Should contain the identifier, MD5HASH prefix, and type
expect(result).toContain(identifier);
expect(result).toContain('MD5HASH_');
expect(result).toContain('____builtin');
expect(result).toMatch(/^my-plugin____MD5HASH_[a-f0-9]+____builtin$/);
});
it('should handle identifier that is itself long', () => {
// Test the original limitation - when identifier itself is very long
const veryLongIdentifier = 'very-long-plugin-identifier-that-will-cause-overflow';
const actionName = 'action';
const result = generateToolName(veryLongIdentifier, actionName, 'builtin');
// When the total length exceeds 64, even short action names get hashed
expect(result).toContain(veryLongIdentifier);
expect(result).toContain('MD5HASH_');
expect(result).toContain('____builtin');
// Verify the pattern matches the expected format
expect(result).toMatch(new RegExp(`^${veryLongIdentifier.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}____MD5HASH_[a-f0-9]+____builtin$`));
});
it('should keep short names unchanged', () => {
const result = generateToolName('short', 'action', 'type');
expect(result).toBe('short____action____type');
expect(result.length).toBeLessThan(64);
});
it('should handle edge case at exactly 64 characters', () => {
// Create a name that's exactly 64 characters
const identifier = 'a'.repeat(20);
const actionName = 'b'.repeat(20);
const type = 'c'.repeat(16); // 20 + 4 + 20 + 4 + 16 = 64
const result = generateToolName(identifier, actionName, type);
// Should be shortened because it's >= 64
expect(result.length).toBeLessThan(64);
expect(result).toContain('MD5HASH_');
});
});
describe('special characters and edge cases', () => {
it('should handle identifiers with special characters', () => {
const result = generateToolName('my-plugin_v2', 'action-name', 'builtin');
expect(result).toBe('my-plugin_v2____action-name____builtin');
});
it('should handle empty action name', () => {
const result = generateToolName('plugin', '', 'builtin');
expect(result).toBe('plugin________builtin');
});
it('should handle numeric identifiers and action names', () => {
const result = generateToolName('plugin123', 'action456', 'type789');
expect(result).toBe('plugin123____action456____type789');
});
it('should be consistent for same inputs', () => {
const result1 = generateToolName('plugin', 'action', 'type');
const result2 = generateToolName('plugin', 'action', 'type');
expect(result1).toBe(result2);
});
it('should produce different results for different inputs', () => {
const result1 = generateToolName('plugin1', 'action', 'type');
const result2 = generateToolName('plugin2', 'action', 'type');
expect(result1).not.toBe(result2);
});
});
describe('hash consistency', () => {
it('should generate consistent hash for same long action name', () => {
const identifier = 'plugin';
const longActionName = 'very-long-action-name-that-will-also-cause-overflow';
const result1 = generateToolName(identifier, longActionName, 'builtin');
const result2 = generateToolName(identifier, longActionName, 'builtin');
expect(result1).toBe(result2);
expect(result1).toContain('MD5HASH_');
});
it('should generate different hashes for different long action names', () => {
const identifier = 'plugin';
const longActionName1 = 'very-long-action-name-that-will-also-cause-overflow-1';
const longActionName2 = 'very-long-action-name-that-will-also-cause-overflow-2';
const result1 = generateToolName(identifier, longActionName1, 'builtin');
const result2 = generateToolName(identifier, longActionName2, 'builtin');
expect(result1).not.toBe(result2);
expect(result1).toContain('MD5HASH_');
expect(result2).toContain('MD5HASH_');
});
});
describe('real-world examples', () => {
it('should handle builtin tools correctly', () => {
const result = generateToolName('lobe-image-designer', 'text2image', 'builtin');
expect(result).toBe('lobe-image-designer____text2image____builtin');
});
it('should handle web browsing tools correctly', () => {
const result = generateToolName('lobe-web-browsing', 'search');
expect(result).toBe('lobe-web-browsing____search');
});
it('should handle plugin tools correctly', () => {
const result = generateToolName('custom-plugin', 'customAction', 'plugin');
expect(result).toBe('custom-plugin____customAction____plugin');
});
});
});
+4 -1
View File
@@ -1,6 +1,9 @@
// Core ToolsEngine class
export { ToolsEngine } from './ToolsEngine';
// Tool Name Resolver
export { ToolNameResolver } from './ToolNameResolver';
// Types and interfaces
export type {
FunctionCallChecker,
@@ -13,4 +16,4 @@ export type {
} from './types';
// Utility functions
export { filterValidManifests, generateToolName, validateManifest } from './utils';
export { filterValidManifests, validateManifest } from './utils';
+10 -33
View File
@@ -1,42 +1,19 @@
import { ToolNameResolver } from './ToolNameResolver';
import { LobeChatPluginManifest } from './types';
// Tool naming constants
const PLUGIN_SCHEMA_SEPARATOR = '____';
const PLUGIN_SCHEMA_API_MD5_PREFIX = 'MD5HASH_';
/**
* Simple hash function for tool name shortening
*/
const genToolCallShortMD5Hash = (name: string): string => {
// Simple hash function for tool names (fallback if no crypto available)
let hash = 0;
for (let i = 0; i < name.length; i++) {
const char = name.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32bit integer
}
return Math.abs(hash).toString(16).slice(0, 16);
};
// Create a singleton instance for backward compatibility
const resolver = new ToolNameResolver();
/**
* Generate tool calling name
* Default tool name generation logic (copied from @lobechat/utils)
* @deprecated Use ToolNameResolver.generate() instead
*/
export const generateToolName = (identifier: string, name: string, type: string = 'default'): string => {
const pluginType = type && type !== 'default' ? `${PLUGIN_SCHEMA_SEPARATOR + type}` : '';
// Use plugin identifier as prefix to avoid conflicts
let apiName = identifier + PLUGIN_SCHEMA_SEPARATOR + name + pluginType;
// OpenAI GPT function_call name can't be longer than 64 characters
// So we need to use hash to shorten the name
// and then find the correct apiName in response by hash
if (apiName.length >= 64) {
const hashContent = PLUGIN_SCHEMA_API_MD5_PREFIX + genToolCallShortMD5Hash(name);
apiName = identifier + PLUGIN_SCHEMA_SEPARATOR + hashContent + pluginType;
}
return apiName;
export const generateToolName = (
identifier: string,
name: string,
type: string = 'default',
): string => {
return resolver.generate(identifier, name, type);
};
/**
-1
View File
@@ -8,7 +8,6 @@ export * from './parseModels';
export * from './pricing';
export * from './safeParseJSON';
export * from './sleep';
export * from './toolCall';
export * from './uriParser';
export * from './url';
export * from './uuid';
-24
View File
@@ -1,24 +0,0 @@
import { PLUGIN_SCHEMA_API_MD5_PREFIX, PLUGIN_SCHEMA_SEPARATOR } from '@lobechat/const';
import { Md5 } from 'ts-md5';
// OpenAI GPT function_call name can't be longer than 64 characters
// So we need to use md5 to shorten the name
export const genToolCallShortMD5Hash = (name: string) => Md5.hashStr(name).toString().slice(0, 16);
export const genToolCallingName = (identifier: string, name: string, type: string = 'default') => {
const pluginType = type && type !== 'default' ? `${PLUGIN_SCHEMA_SEPARATOR + type}` : '';
// 将插件的 identifier 作为前缀,避免重复
let apiName = identifier + PLUGIN_SCHEMA_SEPARATOR + name + pluginType;
// OpenAI GPT function_call name can't be longer than 64 characters
// So we need to use md5 to shorten the name
// and then find the correct apiName in response by md5
if (apiName.length >= 64) {
const md5Content = PLUGIN_SCHEMA_API_MD5_PREFIX + genToolCallShortMD5Hash(name);
apiName = identifier + PLUGIN_SCHEMA_SEPARATOR + md5Content + pluginType;
}
return apiName;
};
+1 -21
View File
@@ -1,11 +1,8 @@
import { ChatCompletionTool, OpenAIPluginManifest } from '@lobechat/types';
import { OpenAIPluginManifest } from '@lobechat/types';
import { LobeChatPluginManifest, pluginManifestSchema } from '@lobehub/chat-plugin-sdk';
import { uniqBy } from 'lodash-es';
import { API_ENDPOINTS } from '@/services/_url';
import { genToolCallingName } from './toolCall';
const fetchJSON = async <T = any>(url: string, proxy = false): Promise<T> => {
// 2. 发送请求
let res: Response;
@@ -127,20 +124,3 @@ export const getToolManifest = async (
return data;
};
/**
*
*/
export const convertPluginManifestToToolsCalling = (
manifests: LobeChatPluginManifest[],
): ChatCompletionTool[] => {
const list = manifests.flatMap((manifest) =>
manifest.api.map((m) => ({
description: m.description,
name: genToolCallingName(manifest.identifier, m.name, manifest.type),
parameters: m.parameters,
})),
);
return uniqBy(list, 'name').map((i) => ({ function: i, type: 'function' }));
};
+9 -2
View File
@@ -12,6 +12,7 @@ import {
SystemRoleInjector,
ToolCallProcessor,
ToolMessageReorder,
ToolNameResolver,
ToolSystemRoleProvider,
} from '@lobechat/context-engine';
import { historySummaryPrompt } from '@lobechat/prompts';
@@ -21,7 +22,6 @@ import { VARIABLE_GENERATORS } from '@lobechat/utils/client';
import { isCanUseFC } from '@/helpers/isCanUseFC';
import { getToolStoreState } from '@/store/tool';
import { toolSelectors } from '@/store/tool/selectors';
import { genToolCallingName } from '@/utils/toolCall';
import { isCanUseVideo, isCanUseVision } from './helper';
@@ -52,6 +52,8 @@ export const contextEngineering = async ({
sessionId,
isWelcomeQuestion,
}: ContextEngineeringContext): Promise<OpenAIChatMessage[]> => {
const toolNameResolver = new ToolNameResolver();
const pipeline = new ContextEngine({
pipeline: [
// 1. History truncation (MUST be first, before any message injection)
@@ -105,7 +107,12 @@ export const contextEngineering = async ({
}),
// 9. Tool call processing
new ToolCallProcessor({ genToolCallingName, isCanUseFC, model, provider }),
new ToolCallProcessor({
genToolCallingName: toolNameResolver.generate.bind(toolNameResolver),
isCanUseFC,
model,
provider,
}),
// 10. Tool message reordering
new ToolMessageReorder(),
+13 -4
View File
@@ -1,3 +1,4 @@
import { ToolNameResolver } from '@lobechat/context-engine';
import { act, renderHook } from '@testing-library/react';
import { Mock, afterEach, describe, expect, it, vi } from 'vitest';
@@ -11,7 +12,6 @@ import { useChatStore } from '@/store/chat/store';
import { messageMapKey } from '@/store/chat/utils/messageMapKey';
import { useToolStore } from '@/store/tool';
import { ChatMessage, ChatToolPayload, MessageToolCall } from '@/types/message';
import { genToolCallShortMD5Hash } from '@/utils/toolCall';
const invokeStandaloneTypePlugin = useChatStore.getState().invokeStandaloneTypePlugin;
@@ -1045,7 +1045,16 @@ describe('ChatPluginAction', () => {
it('should handle MD5 hashed API names', () => {
const apiName = 'testApi';
const md5Hash = genToolCallShortMD5Hash(apiName);
const resolver = new ToolNameResolver();
// Generate a very long name to force MD5 hashing
const longApiName =
'very-long-action-name-that-will-cause-the-total-length-to-exceed-64-characters';
const toolName = resolver.generate('plugin1', longApiName, 'default');
// Extract the MD5 part from the generated name
const parts = toolName.split(PLUGIN_SCHEMA_SEPARATOR);
const md5Hash = parts[1].replace(PLUGIN_SCHEMA_API_MD5_PREFIX, '');
const toolCalls: MessageToolCall[] = [
{
id: 'tool1',
@@ -1069,7 +1078,7 @@ describe('ChatPluginAction', () => {
identifier: 'plugin1',
api: [
{
name: apiName,
name: longApiName,
parameters: { type: 'object', properties: {} },
description: 'abc',
},
@@ -1085,7 +1094,7 @@ describe('ChatPluginAction', () => {
const transformed = result.current.internal_transformToolCalls(toolCalls);
expect(transformed[0].apiName).toBe(apiName);
expect(transformed[0].apiName).toBe(longApiName);
});
});
+20 -29
View File
@@ -1,12 +1,12 @@
/* eslint-disable sort-keys-fix/sort-keys-fix, typescript-sort-keys/interface */
import { ToolNameResolver } from '@lobechat/context-engine';
import { ChatErrorType } from '@lobechat/types';
import { PluginErrorType } from '@lobehub/chat-plugin-sdk';
import { LobeChatPluginManifest, PluginErrorType } from '@lobehub/chat-plugin-sdk';
import isEqual from 'fast-deep-equal';
import { t } from 'i18next';
import { StateCreator } from 'zustand/vanilla';
import { LOADING_FLAT } from '@/const/message';
import { PLUGIN_SCHEMA_API_MD5_PREFIX, PLUGIN_SCHEMA_SEPARATOR } from '@/const/plugin';
import { chatService } from '@/services/chat';
import { mcpService } from '@/services/mcp';
import { messageService } from '@/services/message';
@@ -25,7 +25,6 @@ import {
import { merge } from '@/utils/merge';
import { safeParseJSON } from '@/utils/safeParseJSON';
import { setNamespace } from '@/utils/storeDebug';
import { genToolCallShortMD5Hash } from '@/utils/toolCall';
import { chatSelectors } from '../message/selectors';
import { threadSelectors } from '../thread/selectors';
@@ -504,36 +503,28 @@ export const chatPlugin: StateCreator<
},
internal_transformToolCalls: (toolCalls) => {
return toolCalls
.map((toolCall): ChatToolPayload | null => {
let payload: ChatToolPayload;
const toolNameResolver = new ToolNameResolver();
const [identifier, apiName, type] = toolCall.function.name.split(PLUGIN_SCHEMA_SEPARATOR);
// Build manifests map from tool store
const toolStoreState = useToolStore.getState();
const manifests: Record<string, LobeChatPluginManifest> = {};
if (!apiName) return null;
// Get all installed plugins
const installedPlugins = pluginSelectors.installedPlugins(toolStoreState);
for (const plugin of installedPlugins) {
if (plugin.manifest) {
manifests[plugin.identifier] = plugin.manifest as LobeChatPluginManifest;
}
}
payload = {
apiName,
arguments: toolCall.function.arguments,
id: toolCall.id,
identifier,
type: (type ?? 'default') as any,
};
// Get all builtin tools
for (const tool of builtinTools) {
if (tool.manifest) {
manifests[tool.identifier] = tool.manifest as LobeChatPluginManifest;
}
}
// if the apiName is md5, try to find the correct apiName in the plugins
if (apiName.startsWith(PLUGIN_SCHEMA_API_MD5_PREFIX)) {
const md5 = apiName.replace(PLUGIN_SCHEMA_API_MD5_PREFIX, '');
const manifest = pluginSelectors.getToolManifestById(identifier)(useToolStore.getState());
const api = manifest?.api.find((api) => genToolCallShortMD5Hash(api.name) === md5);
if (api) {
payload.apiName = api.name;
}
}
return payload;
})
.filter(Boolean) as ChatToolPayload[];
return toolNameResolver.resolve(toolCalls, manifests);
},
internal_updatePluginError: async (id, error) => {
const { refreshMessages } = get();
+4 -2
View File
@@ -1,3 +1,4 @@
import { ToolNameResolver } from '@lobechat/context-engine';
import { pluginPrompts } from '@lobechat/prompts';
import { LobeChatPluginManifest } from '@lobehub/chat-plugin-sdk';
@@ -5,13 +6,14 @@ import { MetaData } from '@/types/meta';
import { LobeToolMeta } from '@/types/tool/tool';
import { globalAgentContextManager } from '@/utils/client/GlobalAgentContextManager';
import { hydrationPrompt } from '@/utils/promptTemplate';
import { genToolCallingName } from '@/utils/toolCall';
import { pluginHelpers } from '../helpers';
import { ToolStoreState } from '../initialState';
import { builtinToolSelectors } from '../slices/builtin/selectors';
import { pluginSelectors } from '../slices/plugin/selectors';
const toolNameResolver = new ToolNameResolver();
const enabledSystemRoles =
(tools: string[] = []) =>
(s: ToolStoreState) => {
@@ -36,7 +38,7 @@ const enabledSystemRoles =
return {
apis: manifest.api.map((m) => ({
desc: m.description,
name: genToolCallingName(manifest.identifier, m.name, manifest.type),
name: toolNameResolver.generate(manifest.identifier, m.name, manifest.type),
})),
identifier: manifest.identifier,
name: title,