]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
webui: fix tool selector toggle/counter, key tools by stable identity (#24065)
authorPascal <redacted>
Thu, 4 Jun 2026 11:09:49 +0000 (13:09 +0200)
committerGitHub <redacted>
Thu, 4 Jun 2026 11:09:49 +0000 (13:09 +0200)
* webui: fix tool selector toggle/counter, key tools by stable identity

Key the disabled set, counts and toggles by a stable per-tool key
instead of bare function name, deduped from one canonical list. Per-tool
checkboxes become presentational (single row handler, no nested button),
category checkboxes drop the tristate (n/total carries partial). One
getEnabledToolsForLLM keeps normalized MCP schemas and dedupes by name.

* ui: use SvelteSet and SvelteMap for local tool collections to satisfy svelte/prefer-svelte-reactivity

tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddSheet.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddToolsSubmenu.svelte
tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChatToolsTab.svelte
tools/ui/src/lib/constants/storage.ts
tools/ui/src/lib/hooks/use-tools-panel.svelte.ts
tools/ui/src/lib/stores/tools.svelte.ts
tools/ui/src/lib/types/tools.d.ts

index 9adb9eb89d87f14fe894c28c7687f1bc889f4948..c4069163f61b2a5110012761c93adc24e850ccf0 100644 (file)
                                                <Collapsible.Content>
                                                        <div class="flex flex-col gap-0.5 pl-4">
                                                                {#each toolsPanel.activeGroups as group (group.label)}
-                                                                       {@const { checked, indeterminate } = toolsPanel.getGroupCheckedState(group)}
+                                                                       {@const checked = toolsPanel.isGroupChecked(group)}
                                                                        {@const enabledCount = toolsPanel.getEnabledToolCount(group)}
                                                                        {@const favicon = toolsPanel.getFavicon(group)}
 
 
                                                                                <Checkbox
                                                                                        {checked}
-                                                                                       {indeterminate}
                                                                                        class="h-4 w-4 shrink-0"
                                                                                        onclick={(e) => e.stopPropagation()}
                                                                                        onCheckedChange={() => toolsPanel.toggleGroupByLabel(group.label)}
index 813227fbce08dac82dcabb41f327ce520363eb12..9a5b0cbe8621e425c48f329ca63e1503eafaa70c 100644 (file)
@@ -1,5 +1,5 @@
 <script lang="ts">
-       import { PencilRuler, ChevronDown, ChevronRight, Loader2, Info } from '@lucide/svelte';
+       import { PencilRuler, ChevronDown, ChevronRight, Loader2, Info, Check } from '@lucide/svelte';
        import { Checkbox } from '$lib/components/ui/checkbox';
        import * as Collapsible from '$lib/components/ui/collapsible';
        import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
@@ -65,7 +65,7 @@
                        <div class="max-h-80 overflow-y-auto p-2 pr-1">
                                {#each toolsPanel.activeGroups as group (group.label)}
                                        {@const isExpanded = toolsPanel.expandedGroups.has(group.label)}
-                                       {@const { checked, indeterminate } = toolsPanel.getGroupCheckedState(group)}
+                                       {@const checked = toolsPanel.isGroupChecked(group)}
                                        {@const favicon = toolsPanel.getFavicon(group)}
 
                                        <Collapsible.Root
 
                                                        <Tooltip.Root>
                                                                <Tooltip.Trigger>
-                                                                       <Checkbox
-                                                                               {checked}
-                                                                               {indeterminate}
-                                                                               onCheckedChange={() => toolsPanel.toggleGroupByLabel(group.label)}
-                                                                               class="mr-2 h-4 w-4 shrink-0"
-                                                                       />
+                                                                       {#snippet child({ props })}
+                                                                               <Checkbox
+                                                                                       {...props}
+                                                                                       {checked}
+                                                                                       onCheckedChange={() => toolsPanel.toggleGroupByLabel(group.label)}
+                                                                                       class="mr-2 h-4 w-4 shrink-0"
+                                                                               />
+                                                                       {/snippet}
                                                                </Tooltip.Trigger>
 
                                                                <Tooltip.Content side="right">
 
                                                <Collapsible.Content>
                                                        <div class="ml-4 flex flex-col gap-0.5 border-l border-border/50 pl-2">
-                                                               {#each group.tools as tool (tool.function.name)}
+                                                               {#each group.tools as entry (entry.key)}
+                                                                       {@const enabled = toolsStore.isToolEnabled(entry.key)}
                                                                        <button
                                                                                type="button"
                                                                                class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm transition-colors hover:bg-muted/50"
-                                                                               onclick={() => toolsStore.toggleTool(tool.function.name)}
+                                                                               onclick={() => toolsStore.toggleTool(entry.key)}
                                                                        >
-                                                                               <Checkbox
-                                                                                       checked={toolsStore.isToolEnabled(tool.function.name)}
-                                                                                       onCheckedChange={() => toolsStore.toggleTool(tool.function.name)}
-                                                                                       class="h-4 w-4 shrink-0"
-                                                                               />
+                                                                               <span
+                                                                                       data-slot="checkbox"
+                                                                                       data-state={enabled ? 'checked' : 'unchecked'}
+                                                                                       class="flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
+                                                                               >
+                                                                                       {#if enabled}
+                                                                                               <Check class="size-3.5" />
+                                                                                       {/if}
+                                                                               </span>
 
                                                                                <span class="min-w-0 flex-1 truncate font-mono text-[12px]">
-                                                                                       {tool.function.name}
+                                                                                       {entry.definition.function.name}
                                                                                </span>
                                                                        </button>
                                                                {/each}
index 5857254d80e46c0b845f7918a1ac08b4a83e8011..b568324965802a5de3923c9a84728096d4027b69 100644 (file)
                                                        <span class="w-20 shrink-0 text-center">Always allow</span>
                                                </div>
 
-                                               {#each group.tools as tool (tool.function.name)}
-                                                       {@const toolName = tool.function.name}
-                                                       {@const isEnabled = toolsStore.isToolEnabled(toolName)}
-                                                       {@const permissionKey = toolsStore.getPermissionKey(toolName)}
-                                                       {@const isAlwaysAllowed = permissionKey
-                                                               ? permissionsStore.hasTool(permissionKey)
-                                                               : false}
+                                               {#each group.tools as entry (entry.key)}
+                                                       {@const toolName = entry.definition.function.name}
+                                                       {@const isEnabled = toolsStore.isToolEnabled(entry.key)}
+                                                       {@const permissionKey = entry.key}
+                                                       {@const isAlwaysAllowed = permissionsStore.hasTool(permissionKey)}
 
                                                        <div class="flex items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted/50">
                                                                <TruncatedText text={toolName} class="flex-1" showTooltip={true} />
@@ -76,7 +74,7 @@
                                                                <div class="flex w-16 shrink-0 justify-center">
                                                                        <Checkbox
                                                                                checked={isEnabled}
-                                                                               onCheckedChange={() => toolsStore.toggleTool(toolName)}
+                                                                               onCheckedChange={() => toolsStore.toggleTool(entry.key)}
                                                                                class="h-4 w-4"
                                                                        />
                                                                </div>
@@ -86,9 +84,9 @@
                                                                                checked={isAlwaysAllowed}
                                                                                onCheckedChange={() => {
                                                                                        if (isAlwaysAllowed) {
-                                                                                               permissionsStore.revokeTool(permissionKey!);
+                                                                                               permissionsStore.revokeTool(permissionKey);
                                                                                        } else {
-                                                                                               permissionsStore.allowTool(permissionKey!);
+                                                                                               permissionsStore.allowTool(permissionKey);
                                                                                        }
                                                                                }}
                                                                                class="h-4 w-4"
index 5d33e82f30d05ef8570009e3d88659444b1154d3..1bfe1b5f4a8b474c34d7af3bd5153365ae019612 100644 (file)
@@ -17,6 +17,9 @@ export const DB_APP_NAME_DEPRECATED = 'LlamacppWebui';
 export const ALWAYS_ALLOWED_TOOLS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.alwaysAllowedTools`;
 export const CONFIG_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.config`;
 export const DISABLED_TOOLS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.disabledTools`;
+
+/** Disabled tools keyed by stable selection identity, no migration from the name based key */
+export const DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.disabledToolKeys`;
 export const FAVORITE_MODELS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.favoriteModels`;
 export const MCP_DEFAULT_ENABLED_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.mcpDefaultEnabled`;
 export const THINKING_ENABLED_DEFAULT_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.thinkingEnabledDefault`;
index 9a8acec0fa7450c92d68326ce28089358fccbeaa..9f99d91d9eb66cd4c71ca3bc55005c1a8278ad0c 100644 (file)
@@ -12,9 +12,9 @@ export interface UseToolsPanelReturn {
        readonly activeGroups: ToolGroup[];
        readonly totalToolCount: number;
        readonly noToolsInfoMessage: string | null;
-       getGroupCheckedState(group: ToolGroup): { checked: boolean; indeterminate: boolean };
+       isGroupChecked(group: ToolGroup): boolean;
        getEnabledToolCount(group: ToolGroup): number;
-       getFavicon(group: { source: ToolSource; label: string }): string | null;
+       getFavicon(group: ToolGroup): string | null;
        isGroupDisabled(group: ToolGroup): boolean;
        toggleGroupExpanded(label: string): void;
        /** Toggle all tools in a group by label (avoids stale group object references). */
@@ -54,27 +54,18 @@ export function useToolsPanel(): UseToolsPanelReturn {
                return `To enable Built-In Tools you need to run llama-server with ${CLI_FLAGS.TOOLS} all or ${CLI_FLAGS.TOOLS} <name> flag. To see MCP Tools you need to add / enable MCP Server(s).`;
        });
 
-       function getGroupCheckedState(group: ToolGroup): { checked: boolean; indeterminate: boolean } {
-               return {
-                       checked: toolsStore.isGroupFullyEnabled(group),
-                       indeterminate: toolsStore.isGroupPartiallyEnabled(group)
-               };
+       function isGroupChecked(group: ToolGroup): boolean {
+               return toolsStore.isGroupFullyEnabled(group);
        }
 
        function getEnabledToolCount(group: ToolGroup): number {
-               return group.tools.filter((tool) => toolsStore.isToolEnabled(tool.function.name)).length;
+               return group.tools.filter((tool) => toolsStore.isToolEnabled(tool.key)).length;
        }
 
-       function getFavicon(group: { source: ToolSource; label: string }): string | null {
-               if (group.source !== ToolSource.MCP) return null;
+       function getFavicon(group: ToolGroup): string | null {
+               if (group.source !== ToolSource.MCP || !group.serverId) return null;
 
-               for (const server of mcpStore.getServersSorted()) {
-                       if (mcpStore.getServerLabel(server) === group.label) {
-                               return mcpStore.getServerFavicon(server.id);
-                       }
-               }
-
-               return null;
+               return mcpStore.getServerFavicon(group.serverId);
        }
 
        function isGroupDisabled(group: ToolGroup): boolean {
@@ -121,7 +112,7 @@ export function useToolsPanel(): UseToolsPanelReturn {
                get noToolsInfoMessage() {
                        return noToolsInfoMessage;
                },
-               getGroupCheckedState,
+               isGroupChecked,
                getEnabledToolCount,
                getFavicon,
                isGroupDisabled,
index 3ac44aedf709f3415c04d0a08986f7485c8f3a90..82e41f0bf5b6581eb43c208194072c4b45578527 100644 (file)
@@ -4,12 +4,39 @@ import { mcpStore } from '$lib/stores/mcp.svelte';
 import { HealthCheckStatus, JsonSchemaType, ToolCallType, ToolSource } from '$lib/enums';
 import { config } from '$lib/stores/settings.svelte';
 import {
-       DISABLED_TOOLS_LOCALSTORAGE_KEY,
+       DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY,
        TOOL_GROUP_LABELS,
        TOOL_SERVER_LABELS
 } from '$lib/constants';
 
-import { SvelteSet } from 'svelte/reactivity';
+import { SvelteMap, SvelteSet } from 'svelte/reactivity';
+
+/** Stable selection identity for a tool, shared by the disabled set and the permission store */
+function toolKey(source: ToolSource, name: string, serverId?: string): string {
+       switch (source) {
+               case ToolSource.MCP:
+                       return serverId ? `mcp-${serverId}:${name}` : `mcp:${name}`;
+               case ToolSource.CUSTOM:
+                       return `custom:${name}`;
+               default:
+                       return `builtin:${name}`;
+       }
+}
+
+function mcpDefinition(
+       name: string,
+       description: string | undefined,
+       schema?: Record<string, unknown>
+): OpenAIToolDefinition {
+       return {
+               type: ToolCallType.FUNCTION,
+               function: {
+                       name,
+                       description,
+                       parameters: schema ?? { type: JsonSchemaType.OBJECT, properties: {}, required: [] }
+               }
+       };
+}
 
 class ToolsStore {
        private _builtinTools = $state<OpenAIToolDefinition[]>([]);
@@ -20,12 +47,12 @@ class ToolsStore {
 
        constructor() {
                try {
-                       const stored = localStorage.getItem(DISABLED_TOOLS_LOCALSTORAGE_KEY);
+                       const stored = localStorage.getItem(DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY);
                        if (stored) {
                                const parsed = JSON.parse(stored);
                                if (Array.isArray(parsed)) {
-                                       for (const name of parsed) {
-                                               if (typeof name === 'string') this._disabledTools.add(name);
+                                       for (const key of parsed) {
+                                               if (typeof key === 'string') this._disabledTools.add(key);
                                        }
                                }
                        }
@@ -33,14 +60,13 @@ class ToolsStore {
                        console.error('[ToolsStore] Failed to load disabled tools from localStorage:', err);
                }
 
-               // Initialize builtin tools on startup
                this.fetchBuiltinTools();
        }
 
        private persistDisabledTools(): void {
                try {
                        localStorage.setItem(
-                               DISABLED_TOOLS_LOCALSTORAGE_KEY,
+                               DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY,
                                JSON.stringify([...this._disabledTools])
                        );
                } catch {
@@ -78,167 +104,141 @@ class ToolsStore {
                }
        }
 
-       /** Flat list of all tool entries with source metadata */
-       get allTools(): ToolEntry[] {
-               const entries: ToolEntry[] = [];
-
-               for (const def of this._builtinTools) {
-                       entries.push({ source: ToolSource.BUILTIN, definition: def });
-               }
+       /** Normalize MCP tools from live connections when available, fall back to health check data */
+       private mcpEntries(): {
+               serverId: string;
+               serverName: string;
+               definition: OpenAIToolDefinition;
+       }[] {
+               const out: { serverId: string; serverName: string; definition: OpenAIToolDefinition }[] = [];
 
-               // Use live connections when available (full schema), fall back to health check data
                const connections = mcpStore.getConnections();
                if (connections.size > 0) {
                        for (const [serverId, connection] of connections) {
                                const serverName = mcpStore.getServerDisplayName(serverId);
                                for (const tool of connection.tools) {
-                                       const rawSchema = (tool.inputSchema as Record<string, unknown>) ?? {
-                                               type: JsonSchemaType.OBJECT,
-                                               properties: {},
-                                               required: []
-                                       };
-                                       entries.push({
-                                               source: ToolSource.MCP,
-                                               serverName,
+                                       const schema = (tool.inputSchema as Record<string, unknown>) ?? undefined;
+                                       out.push({
                                                serverId,
-                                               definition: {
-                                                       type: ToolCallType.FUNCTION,
-                                                       function: {
-                                                               name: tool.name,
-                                                               description: tool.description,
-                                                               parameters: rawSchema
-                                                       }
-                                               }
+                                               serverName,
+                                               definition: mcpDefinition(tool.name, tool.description, schema)
                                        });
                                }
                        }
                } else {
                        for (const { serverId, serverName, tools } of this.getMcpToolsFromHealthChecks()) {
                                for (const tool of tools) {
-                                       entries.push({
-                                               source: ToolSource.MCP,
-                                               serverName,
+                                       out.push({
                                                serverId,
-                                               definition: {
-                                                       type: ToolCallType.FUNCTION,
-                                                       function: {
-                                                               name: tool.name,
-                                                               description: tool.description,
-                                                               parameters: {
-                                                                       type: JsonSchemaType.OBJECT,
-                                                                       properties: {},
-                                                                       required: []
-                                                               }
-                                                       }
-                                               }
+                                               serverName,
+                                               definition: mcpDefinition(tool.name, tool.description)
                                        });
                                }
                        }
                }
 
+               return out;
+       }
+
+       /** Canonical flat list of tool entries with source metadata and stable keys, deduped by key */
+       get allTools(): ToolEntry[] {
+               const entries: ToolEntry[] = [];
+               const seen = new SvelteSet<string>();
+
+               const push = (entry: ToolEntry) => {
+                       if (seen.has(entry.key)) return;
+                       seen.add(entry.key);
+                       entries.push(entry);
+               };
+
+               for (const def of this._builtinTools) {
+                       const name = def.function.name;
+                       push({ source: ToolSource.BUILTIN, key: toolKey(ToolSource.BUILTIN, name), definition: def });
+               }
+
+               for (const { serverId, serverName, definition } of this.mcpEntries()) {
+                       const name = definition.function.name;
+                       push({
+                               source: ToolSource.MCP,
+                               serverId,
+                               serverName,
+                               key: toolKey(ToolSource.MCP, name, serverId),
+                               definition
+                       });
+               }
+
                for (const def of this.customTools) {
-                       entries.push({ source: ToolSource.CUSTOM, definition: def });
+                       const name = def.function.name;
+                       push({ source: ToolSource.CUSTOM, key: toolKey(ToolSource.CUSTOM, name), definition: def });
                }
 
                return entries;
        }
 
-       /** Tools grouped by category for tree display */
+       /** Tools grouped by category for tree display, derived from the canonical entries */
        get toolGroups(): ToolGroup[] {
                const groups: ToolGroup[] = [];
+               const byKey = new SvelteMap<string, ToolGroup>();
 
-               if (this._builtinTools.length > 0) {
-                       groups.push({
-                               source: ToolSource.BUILTIN,
-                               label: TOOL_GROUP_LABELS[ToolSource.BUILTIN],
-                               tools: this._builtinTools
-                       });
-               }
-
-               // Use live connections when available, fall back to health check data
-               const connections = mcpStore.getConnections();
-               if (connections.size > 0) {
-                       for (const [serverId, connection] of connections) {
-                               if (connection.tools.length === 0) continue;
-                               const label = mcpStore.getServerDisplayName(serverId);
-                               const tools: OpenAIToolDefinition[] = connection.tools.map((tool) => {
-                                       const rawSchema = (tool.inputSchema as Record<string, unknown>) ?? {
-                                               type: JsonSchemaType.OBJECT,
-                                               properties: {},
-                                               required: []
-                                       };
-                                       return {
-                                               type: ToolCallType.FUNCTION,
-                                               function: {
-                                                       name: tool.name,
-                                                       description: tool.description,
-                                                       parameters: rawSchema
-                                               }
-                                       };
-                               });
-                               groups.push({ source: ToolSource.MCP, label, serverId, tools });
-                       }
-               } else {
-                       for (const { serverId, serverName, tools } of this.getMcpToolsFromHealthChecks()) {
-                               if (tools.length === 0) continue;
-                               const defs: OpenAIToolDefinition[] = tools.map((tool) => ({
-                                       type: ToolCallType.FUNCTION,
-                                       function: {
-                                               name: tool.name,
-                                               description: tool.description,
-                                               parameters: { type: JsonSchemaType.OBJECT, properties: {}, required: [] }
-                                       }
-                               }));
-                               groups.push({ source: ToolSource.MCP, label: serverName, serverId, tools: defs });
+               for (const entry of this.allTools) {
+                       const groupKey =
+                               entry.source === ToolSource.MCP ? `mcp:${entry.serverId ?? ''}` : entry.source;
+
+                       let group = byKey.get(groupKey);
+                       if (!group) {
+                               group = {
+                                       source: entry.source,
+                                       label: this.groupLabel(entry),
+                                       serverId: entry.serverId,
+                                       tools: []
+                               };
+                               byKey.set(groupKey, group);
+                               groups.push(group);
                        }
-               }
 
-               const custom = this.customTools;
-               if (custom.length > 0) {
-                       groups.push({
-                               source: ToolSource.CUSTOM,
-                               label: TOOL_GROUP_LABELS[ToolSource.CUSTOM],
-                               tools: custom
-                       });
+                       group.tools.push(entry);
                }
 
                return groups;
        }
 
-       /** Only enabled tool definitions (for sending to the API) */
-       get enabledToolDefinitions(): OpenAIToolDefinition[] {
-               return this.allTools
-                       .filter((t) => !this._disabledTools.has(t.definition.function.name))
-                       .map((t) => t.definition);
+       private groupLabel(entry: ToolEntry): string {
+               switch (entry.source) {
+                       case ToolSource.MCP:
+                               return entry.serverName ?? '';
+                       case ToolSource.CUSTOM:
+                               return TOOL_GROUP_LABELS[ToolSource.CUSTOM];
+                       default:
+                               return TOOL_GROUP_LABELS[ToolSource.BUILTIN];
+               }
        }
 
        /**
-        * Returns enabled tool definitions for sending to the LLM.
-        * MCP tools use properly normalized schemas from mcpStore.
-        * Filters out tools disabled via the UI checkboxes.
+        * Enabled tool definitions for sending to the LLM.
+        * MCP tools keep their normalized schemas from mcpStore.
+        * The API identifies tools by name, so a name is sent at most once.
         */
        getEnabledToolsForLLM(): OpenAIToolDefinition[] {
-               const disabled = this._disabledTools;
-               const result: OpenAIToolDefinition[] = [];
-
-               for (const tool of this._builtinTools) {
-                       if (!disabled.has(tool.function.name)) {
-                               result.push(tool);
+               const enabledNames = new SvelteSet<string>();
+               for (const entry of this.allTools) {
+                       if (!this._disabledTools.has(entry.key)) {
+                               enabledNames.add(entry.definition.function.name);
                        }
                }
 
-               // MCP tools with properly normalized schemas
-               for (const tool of mcpStore.getToolDefinitionsForLLM()) {
-                       if (!disabled.has(tool.function.name)) {
-                               result.push(tool);
-                       }
-               }
+               const result: OpenAIToolDefinition[] = [];
+               const seen = new SvelteSet<string>();
 
-               for (const tool of this.customTools) {
-                       if (!disabled.has(tool.function.name)) {
-                               result.push(tool);
-                       }
-               }
+               const take = (def: OpenAIToolDefinition) => {
+                       const name = def.function.name;
+                       if (!enabledNames.has(name) || seen.has(name)) return;
+                       seen.add(name);
+                       result.push(def);
+               };
+
+               for (const def of this._builtinTools) take(def);
+               for (const def of mcpStore.getToolDefinitionsForLLM()) take(def);
+               for (const def of this.customTools) take(def);
 
                return result;
        }
@@ -263,61 +263,50 @@ class ToolsStore {
                return this._disabledTools;
        }
 
-       isToolEnabled(toolName: string): boolean {
-               return !this._disabledTools.has(toolName);
+       isToolEnabled(key: string): boolean {
+               return !this._disabledTools.has(key);
        }
 
-       toggleTool(toolName: string): void {
-               if (this._disabledTools.has(toolName)) {
-                       this._disabledTools.delete(toolName);
+       toggleTool(key: string): void {
+               if (this._disabledTools.has(key)) {
+                       this._disabledTools.delete(key);
                } else {
-                       this._disabledTools.add(toolName);
+                       this._disabledTools.add(key);
                }
                this.persistDisabledTools();
        }
 
-       setToolEnabled(toolName: string, enabled: boolean): void {
+       setToolEnabled(key: string, enabled: boolean): void {
                if (enabled) {
-                       this._disabledTools.delete(toolName);
+                       this._disabledTools.delete(key);
                } else {
-                       this._disabledTools.add(toolName);
+                       this._disabledTools.add(key);
                }
        }
 
-       /**
-        * Enable all tools belonging to a specific MCP server.
-        * Called when a server is enabled for a conversation.
-        */
+       /** Enable all tools belonging to a specific MCP server */
        enableAllToolsForServer(serverId: string): void {
                const connection = mcpStore.getConnections().get(serverId);
                if (!connection) return;
                for (const tool of connection.tools) {
-                       this._disabledTools.delete(tool.name);
+                       this._disabledTools.delete(toolKey(ToolSource.MCP, tool.name, serverId));
                }
                this.persistDisabledTools();
        }
 
        toggleGroup(group: ToolGroup): void {
-               const allEnabled = group.tools.every((t) => this.isToolEnabled(t.function.name));
+               const allEnabled = group.tools.every((t) => this.isToolEnabled(t.key));
                for (const tool of group.tools) {
-                       this.setToolEnabled(tool.function.name, !allEnabled);
+                       this.setToolEnabled(tool.key, !allEnabled);
                }
                this.persistDisabledTools();
        }
 
        isGroupFullyEnabled(group: ToolGroup): boolean {
-               return group.tools.length > 0 && group.tools.every((t) => this.isToolEnabled(t.function.name));
-       }
-
-       isGroupPartiallyEnabled(group: ToolGroup): boolean {
-               const enabledCount = group.tools.filter((t) => this.isToolEnabled(t.function.name)).length;
-               return enabledCount > 0 && enabledCount < group.tools.length;
+               return group.tools.length > 0 && group.tools.every((t) => this.isToolEnabled(t.key));
        }
 
-       /**
-        * Get MCP tools from health check data (reactive).
-        * Used when live connections aren't established yet.
-        */
+       /** Get MCP tools from health check data, used when live connections aren't established yet */
        private getMcpToolsFromHealthChecks(): {
                serverId: string;
                serverName: string;
@@ -337,60 +326,35 @@ class ToolsStore {
                return result;
        }
 
-       /** Determine the source of a tool by its name. */
-       getToolSource(toolName: string): ToolSource | null {
-               if (this._builtinTools.some((t) => t.function.name === toolName)) {
-                       return ToolSource.BUILTIN;
-               }
+       /** First canonical entry matching a tool name, runtime tool calls resolve by name */
+       private findEntryByName(toolName: string): ToolEntry | null {
                for (const entry of this.allTools) {
-                       if (entry.definition.function.name === toolName) {
-                               return entry.source;
-                       }
+                       if (entry.definition.function.name === toolName) return entry;
                }
                return null;
        }
 
-       /** Get the display label for the server that owns a given tool. */
+       /** Determine the source of a tool by its name */
+       getToolSource(toolName: string): ToolSource | null {
+               return this.findEntryByName(toolName)?.source ?? null;
+       }
+
+       /** Get the display label for the server that owns a given tool */
        getToolServerLabel(toolName: string): string {
-               for (const entry of this.allTools) {
-                       if (entry.definition.function.name === toolName) {
-                               if (entry.serverName) {
-                                       return mcpStore.getServerDisplayName(entry.serverName);
-                               }
-                               if (entry.source === ToolSource.BUILTIN) {
-                                       return TOOL_SERVER_LABELS[ToolSource.BUILTIN];
-                               }
-                               if (entry.source === ToolSource.CUSTOM) {
-                                       return TOOL_SERVER_LABELS[ToolSource.CUSTOM];
-                               }
-                       }
-               }
+               const entry = this.findEntryByName(toolName);
+               if (!entry) return '';
+               if (entry.serverName) return mcpStore.getServerDisplayName(entry.serverName);
+               if (entry.source === ToolSource.BUILTIN) return TOOL_SERVER_LABELS[ToolSource.BUILTIN];
+               if (entry.source === ToolSource.CUSTOM) return TOOL_SERVER_LABELS[ToolSource.CUSTOM];
                return '';
        }
 
-       /** Build a permission key with category prefix, e.g. "mcp-<serverId>:tool_name" */
+       /** Permission key for a tool name, identical to the selection key */
        getPermissionKey(toolName: string): string | null {
-               for (const entry of this.allTools) {
-                       if (entry.definition.function.name === toolName) {
-                               switch (entry.source) {
-                                       case ToolSource.BUILTIN:
-                                               return `builtin:${toolName}`;
-                                       case ToolSource.CUSTOM:
-                                               return `custom:${toolName}`;
-                                       case ToolSource.MCP:
-                                               if (entry.serverId) {
-                                                       return `mcp-${entry.serverId}:${toolName}`;
-                                               }
-                                               return `mcp:${toolName}`;
-                                       default:
-                                               return null;
-                               }
-                       }
-               }
-               return null;
+               return this.findEntryByName(toolName)?.key ?? null;
        }
 
-       /** Check if there are any enabled tools available (builtin, MCP, or custom). */
+       /** Check if there are any enabled tools available (builtin, MCP, or custom) */
        get hasEnabledTools(): boolean {
                return this.getEnabledToolsForLLM().length > 0;
        }
@@ -423,5 +387,4 @@ export const toolsStore = new ToolsStore();
 
 export const allTools = () => toolsStore.allTools;
 export const allToolDefinitions = () => toolsStore.allToolDefinitions;
-export const enabledToolDefinitions = () => toolsStore.enabledToolDefinitions;
 export const toolGroups = () => toolsStore.toolGroups;
index a17a0c9a9eb0e4f9a60e6974d6fb82759f5e0b6f..50561a4c578b52a94e4a8c2d88f373e4efb8413e 100644 (file)
@@ -7,6 +7,8 @@ export interface ToolEntry {
        serverName?: string;
        /** For MCP tools, the server ID (used for permission keys) */
        serverId?: string;
+       /** Stable selection identity: builtin:name, mcp-<serverId>:name, mcp:name, custom:name */
+       key: string;
        definition: OpenAIToolDefinition;
 }
 
@@ -15,5 +17,5 @@ export interface ToolGroup {
        label: string;
        /** For MCP groups, the server ID */
        serverId?: string;
-       tools: OpenAIToolDefinition[];
+       tools: ToolEntry[];
 }