🐛 fix: make tool and skill activation case-insensitive

LLMs sometimes generate identifiers with different casing (e.g. "lobehub"
instead of "LobeHub", or "Lobe-Web-Browsing" instead of "lobe-web-browsing"),
causing activation to fail with "Not found". Normalize identifiers to lowercase
before matching in all activator runtimes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
ONLY-yours 2026-04-21 12:07:24 +08:00
parent c5db823a69
commit 017c1fcaac
5 changed files with 55 additions and 13 deletions

View file

@ -50,12 +50,16 @@ export class ActivatorExecutionRuntime {
}
try {
// Normalize identifiers to lowercase for case-insensitive matching
const normalizedIdentifiers = identifiers.map((id) => id.toLowerCase());
const alreadyActive = this.service.getActivatedToolIds();
const alreadyActiveLower = new Set(alreadyActive.map((id) => id.toLowerCase()));
const toActivate: string[] = [];
const alreadyActiveList: string[] = [];
for (const id of identifiers) {
if (alreadyActive.includes(id)) {
for (const id of normalizedIdentifiers) {
if (alreadyActiveLower.has(id)) {
alreadyActiveList.push(id);
} else {
toActivate.push(id);
@ -65,7 +69,7 @@ export class ActivatorExecutionRuntime {
// Fetch manifests for tools to activate
const manifests = await this.service.getToolManifests(toActivate);
const foundIdentifiers = new Set(manifests.map((m) => m.identifier));
const foundIdentifiers = new Set(manifests.map((m) => m.identifier.toLowerCase()));
const notFound = toActivate.filter((id) => !foundIdentifiers.has(id));
const activatedTools: ActivatedToolInfo[] = manifests.map((m) => ({

View file

@ -177,8 +177,9 @@ export class SkillsExecutionRuntime {
}
try {
// Check builtin skills first
const builtinSkill = this.builtinSkills.find((s) => s.name === id);
// Check builtin skills first (case-insensitive)
const idLower = id.toLowerCase();
const builtinSkill = this.builtinSkills.find((s) => s.name.toLowerCase() === idLower);
if (builtinSkill?.resources) {
const meta = builtinSkill.resources[path];
if (meta?.content !== undefined) {
@ -231,8 +232,9 @@ export class SkillsExecutionRuntime {
async activateSkill(args: ActivateSkillParams): Promise<BuiltinServerRuntimeOutput> {
const { name } = args;
// Check builtin skills first — no DB query needed
const builtinSkill = this.builtinSkills.find((s) => s.name === name);
// Check builtin skills first — no DB query needed (case-insensitive)
const nameLower = name.toLowerCase();
const builtinSkill = this.builtinSkills.find((s) => s.name.toLowerCase() === nameLower);
if (builtinSkill) {
let content = builtinSkill.content;
const hasResources = !!(

View file

@ -45,8 +45,15 @@ export const activatorRuntime: ServerRuntimeRegistration = {
// The caller is responsible for scoping this map to exclude hidden/internal tools.
const results: ToolManifestInfo[] = [];
// Build a case-insensitive lookup map for tool manifests
const manifestMapLower: Record<string, string> = {};
for (const key of Object.keys(context.toolManifestMap)) {
manifestMapLower[key.toLowerCase()] = key;
}
for (const id of identifiers) {
const manifest = context.toolManifestMap[id];
const actualKey = manifestMapLower[id.toLowerCase()];
const manifest = actualKey ? context.toolManifestMap[actualKey] : undefined;
if (!manifest) continue;
results.push({

View file

@ -96,6 +96,31 @@ describe('lobe-activator executor discovery allowlist', () => {
expect(activatedIds).toHaveLength(0);
});
it('should match identifiers case-insensitively', async () => {
const tool = makeBuiltinTool('lobe-web-browsing');
mockGetState.mockReturnValue({
builtinTools: [tool],
installedPlugins: [],
});
mockAvailableToolsForDiscovery.mockReturnValue([
{ description: 'desc', identifier: 'lobe-web-browsing', name: 'lobe-web-browsing' },
]);
const result = await activatorExecutor.invoke(
'activateTools',
{ identifiers: ['Lobe-Web-Browsing'] },
{ messageId: 'msg-1', operationId: 'op-1' },
);
expect(result.success).toBe(true);
const state = result.state as any;
const activatedIds = state.activatedTools?.map((t: any) => t.identifier) ?? [];
expect(activatedIds).toContain('lobe-web-browsing');
});
it('should allow discoverable plugins', async () => {
const plugin = makePlugin('community-plugin');

View file

@ -43,15 +43,17 @@ const service: ActivatorRuntimeService = {
// Only allow activation of tools that passed discovery filters
// (discoverable, platform-available, not internal/hidden)
const discoverable = new Set(
toolSelectors.availableToolsForDiscovery(s).map((t) => t.identifier),
toolSelectors.availableToolsForDiscovery(s).map((t) => t.identifier.toLowerCase()),
);
const allowedIds = identifiers.filter((id) => discoverable.has(id));
const allowedIds = identifiers.filter((id) => discoverable.has(id.toLowerCase()));
const results: ToolManifestInfo[] = [];
for (const id of allowedIds) {
const idLower = id.toLowerCase();
// Search builtin tools
const builtin = s.builtinTools.find((t) => t.identifier === id);
const builtin = s.builtinTools.find((t) => t.identifier.toLowerCase() === idLower);
if (builtin) {
results.push({
apiDescriptions: builtin.manifest.api.map((a) => ({
@ -67,7 +69,7 @@ const service: ActivatorRuntimeService = {
}
// Search installed plugins
const plugin = s.installedPlugins.find((p) => p.identifier === id);
const plugin = s.installedPlugins.find((p) => p.identifier.toLowerCase() === idLower);
if (plugin?.manifest) {
results.push({
apiDescriptions: (plugin.manifest.api || []).map((a) => ({
@ -84,7 +86,9 @@ const service: ActivatorRuntimeService = {
// Search LobeHub Skill servers
const lobehubSkillServer = s.lobehubSkillServers?.find(
(server) => server.identifier === id && server.status === LobehubSkillStatus.CONNECTED,
(server) =>
server.identifier.toLowerCase() === idLower &&
server.status === LobehubSkillStatus.CONNECTED,
);
if (lobehubSkillServer?.tools) {
results.push({