char-fork/llama.cpp

git clone https://git.t4t.associates/char-fork/llama.cpp

Aleksander Grygierui: Replace per-conversation MCP overrides with per-conversation tool policy (#27745)fe235f434

char/aliceai-t5-moe
20.0 KiB719 linesraw
1/**
2 * toolsStore - Tool registry and enablement
3 *
4 * Owns the server tool listing (with working-directory resolution), built-in
5 * browser tools, MCP tools and per-tool enablement, exposed as a unified
6 * tool set for the LLM and the tools UI. Consumed by the agentic loop and
7 * the chat flows.
8 */
9
10import { browser } from '$app/environment';
11import {
12	buildBrowserInfoToolDefinition,
13	buildGetDatetimeToolDefinition,
14	buildReadMediaToolDefinition,
15	DISABLED_TOOL_CATEGORIES_LOCALSTORAGE_KEY,
16	DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY,
17	HOME_TILDE,
18	TOOL_GROUP_LABELS,
19	TOOL_SERVER_LABELS
20} from '$lib/constants';
21import {
22	BuiltInTool,
23	GlobSearchType,
24	HealthCheckStatus,
25	JsonSchemaType,
26	ToolCallType,
27	ToolSource
28} from '$lib/enums';
29import { ToolsService } from '$lib/services/tools.service';
30// direct imports between stores, not via the barrel, to avoid circular deps
31import { mcpStore } from '$lib/stores/mcp/index.svelte';
32import { modelsStore } from '$lib/stores/models/index.svelte';
33import { settingsStore } from '$lib/stores/settings/index.svelte';
34import type { OpenAIToolDefinition, ToolEntry, ToolGroup } from '$lib/types';
35import { buildSandboxToolDefinition } from '$lib/utils';
36import { SvelteMap, SvelteSet } from 'svelte/reactivity';
37
38/** Stable selection identity for a tool, shared by the disabled set and the permission store */
39
40class ToolsStore {
41	// default disabled tool categories, seeded into newly created conversations;
42	// the per-conversation policy lives on the conversation row
43	private _disabledToolCategories = $state(new SvelteSet<ToolSource>());
44	private _disabledTools = $state(new SvelteSet<string>());
45	private _error = $state<string | null>(null);
46	private _loading = $state(false);
47	private _serverHome = $state<string | null | undefined>(undefined);
48	private _serverTools = $state<OpenAIToolDefinition[]>([]);
49	private _toolsEndpointUnreachable = $state(false);
50	// server tools that resolve their paths against the working directory,
51	// as declared by the server in its `/tools` listing
52	private cwdAwareTools = $state(new SvelteSet<string>());
53
54	get allToolDefinitions(): OpenAIToolDefinition[] {
55		return this.allTools.map((t) => t.definition);
56	}
57
58	/** Canonical flat list of tool entries with source metadata and stable keys, deduped by key */
59	get allTools(): ToolEntry[] {
60		const entries: ToolEntry[] = [];
61		const seen = new SvelteSet<string>();
62		const push = (entry: ToolEntry) => {
63			if (seen.has(entry.key)) return;
64
65			seen.add(entry.key);
66			entries.push(entry);
67		};
68
69		for (const def of this._serverTools) {
70			const name = def.function.name;
71
72			push({
73				definition: def,
74				key: this.toolKey(ToolSource.SERVER, name),
75				source: ToolSource.SERVER
76			});
77		}
78
79		for (const def of this.browserTools) {
80			const name = def.function.name;
81
82			push({
83				definition: def,
84				key: this.toolKey(ToolSource.BROWSER, name),
85				source: ToolSource.BROWSER
86			});
87		}
88
89		for (const { definition, serverId, serverName } of this.mcpEntries()) {
90			const name = definition.function.name;
91
92			push({
93				definition,
94				key: this.toolKey(ToolSource.MCP, name, serverId),
95				serverId,
96				serverName,
97				source: ToolSource.MCP
98			});
99		}
100
101		for (const def of this.customTools) {
102			const name = def.function.name;
103
104			push({
105				definition: def,
106				key: this.toolKey(ToolSource.CUSTOM, name),
107				source: ToolSource.CUSTOM
108			});
109		}
110
111		return entries;
112	}
113
114	get browserTools(): OpenAIToolDefinition[] {
115		const tools: OpenAIToolDefinition[] = [buildGetDatetimeToolDefinition()];
116
117		if (settingsStore.config.jsSandboxEnabled) {
118			tools.push(buildSandboxToolDefinition(!!settingsStore.config.symbolicMathEnabled));
119		}
120
121		const readMedia = this.readMediaTool();
122
123		if (readMedia) tools.push(readMedia);
124
125		// provide browser's get_info tool if server doesn't provide one
126		if (!this.hasServerTool(BuiltInTool.SERVER_GET_INFO)) {
127			tools.push(buildBrowserInfoToolDefinition());
128		}
129
130		return tools;
131	}
132
133	get customTools(): OpenAIToolDefinition[] {
134		const raw = settingsStore.config.customJson;
135
136		if (!raw || typeof raw !== 'string') return [];
137
138		try {
139			const parsed = JSON.parse(raw);
140
141			if (!Array.isArray(parsed)) return [];
142
143			return parsed.filter(
144				(t: unknown): t is OpenAIToolDefinition =>
145					typeof t === 'object' &&
146					t !== null &&
147					'type' in t &&
148					(t as OpenAIToolDefinition).type === 'function' &&
149					'function' in t &&
150					typeof (t as OpenAIToolDefinition).function?.name === 'string'
151			);
152		} catch {
153			return [];
154		}
155	}
156
157	get disabledToolCategories(): ReadonlySet<ToolSource> {
158		return this._disabledToolCategories;
159	}
160
161	get disabledTools(): SvelteSet<string> {
162		return this._disabledTools;
163	}
164
165	get error(): string | null {
166		return this._error;
167	}
168
169	get isToolsEndpointUnreachable(): boolean {
170		return this._toolsEndpointUnreachable;
171	}
172
173	get loading(): boolean {
174		return this._loading;
175	}
176
177	get mcpTools(): OpenAIToolDefinition[] {
178		return this.mcpEntries().map((e) => e.definition);
179	}
180
181	get serverHome(): string | null {
182		return this._serverHome ?? null;
183	}
184
185	get serverTools(): OpenAIToolDefinition[] {
186		return this._serverTools;
187	}
188
189	/** Tools grouped by category for tree display, derived from the canonical entries */
190	get toolGroups(): ToolGroup[] {
191		const groups: ToolGroup[] = [];
192		const byKey = new SvelteMap<string, ToolGroup>();
193
194		for (const entry of this.allTools) {
195			const groupKey =
196				entry.source === ToolSource.MCP ? `mcp:${entry.serverId ?? ''}` : entry.source;
197
198			let group = byKey.get(groupKey);
199
200			if (!group) {
201				group = {
202					key: groupKey,
203					label: this.groupLabel(entry),
204					serverId: entry.serverId,
205					source: entry.source,
206					tools: []
207				};
208				byKey.set(groupKey, group);
209				groups.push(group);
210			}
211
212			group.tools.push(entry);
213		}
214
215		return groups;
216	}
217
218	/** Enable all tools belonging to a specific MCP server */
219	enableAllToolsForServer(serverId: string): void {
220		const connection = mcpStore.getConnections().get(serverId);
221
222		if (!connection) return;
223
224		// the server-scoped group key disables every tool regardless of per-tool keys
225		this._disabledTools.delete(this.getMcpServerToolsKey(serverId));
226
227		for (const tool of connection.tools) {
228			this._disabledTools.delete(this.toolKey(ToolSource.MCP, tool.name, serverId));
229		}
230
231		this.persistDisabledTools();
232	}
233
234	async fetchServerTools(): Promise<void> {
235		if (this._loading) return;
236
237		this._loading = true;
238		this._error = null;
239		this._toolsEndpointUnreachable = false;
240
241		try {
242			const toolInfos = await ToolsService.list();
243
244			this._serverTools = toolInfos.map((info) => info.definition);
245			this.cwdAwareTools = new SvelteSet(
246				toolInfos.filter((info) => info.uses_cwd).map((info) => info.tool)
247			);
248		} catch (err) {
249			const errorMessage = err instanceof Error ? err.message : String(err);
250
251			this._error = errorMessage;
252
253			// 403 from /tools means the server was started without --tools
254			// TODO: check status code instead of relying on message
255			if (errorMessage.includes('this feature is disabled')) {
256				this._toolsEndpointUnreachable = true;
257				console.info('[ToolsStore] Server tools are disabled on the server');
258			} else {
259				console.error('[ToolsStore] Failed to fetch server tools:', err);
260			}
261		} finally {
262			this._loading = false;
263		}
264	}
265
266	/**
267	 * Enabled tool definitions for sending to the LLM. Callers pass an
268	 * explicit policy (the active conversation's, resolved with global
269	 * defaults when absent); without arguments the store defaults apply.
270	 * MCP tool schemas are normalized here so the wire payload is consistent
271	 * across all four sources (server, browser/sandbox, MCP, custom JSON).
272	 * The API identifies tools by name, so a name is sent at most once.
273	 */
274	getEnabledToolsForLLM(
275		disabledTools: ReadonlySet<string> = this._disabledTools,
276		disabledCategories: ReadonlySet<ToolSource> = this._disabledToolCategories
277	): OpenAIToolDefinition[] {
278		const enabledNames = new SvelteSet<string>();
279
280		for (const entry of this.allTools) {
281			if (this.isEntryEnabled(entry, disabledTools, disabledCategories)) {
282				enabledNames.add(entry.definition.function.name);
283			}
284		}
285
286		const result: OpenAIToolDefinition[] = [];
287		const seen = new SvelteSet<string>();
288		const take = (def: OpenAIToolDefinition) => {
289			const name = def.function.name;
290
291			if (!enabledNames.has(name) || seen.has(name)) return;
292
293			seen.add(name);
294			result.push(def);
295		};
296
297		for (const def of this._serverTools) take(def);
298		for (const def of this.browserTools) take(def);
299		// mcpEntries() over mcpStore directly so wire shape stays normalized and aligned with the tools UI.
300		for (const entry of this.mcpEntries()) take(entry.definition);
301		for (const def of this.customTools) take(def);
302
303		return result;
304	}
305
306	/** Server-scoped tool key: disabling it disables all of that server's tools. */
307	getMcpServerToolsKey(serverId: string): string {
308		return `mcp:${serverId}`;
309	}
310
311	/** Permission key for a tool name, identical to the selection key */
312	getPermissionKey(toolName: string): string | null {
313		return this.findEntryByName(toolName)?.key ?? null;
314	}
315
316	/** Get the display label for the server that owns a given tool */
317	getToolServerLabel(toolName: string): string {
318		const entry = this.findEntryByName(toolName);
319
320		if (!entry) return '';
321
322		if (entry.serverName) return mcpStore.getServerDisplayName(entry.serverName);
323
324		if (entry.source === ToolSource.SERVER) return TOOL_SERVER_LABELS[ToolSource.SERVER];
325
326		if (entry.source === ToolSource.CUSTOM) return TOOL_SERVER_LABELS[ToolSource.CUSTOM];
327
328		if (entry.source === ToolSource.BROWSER) return TOOL_SERVER_LABELS[ToolSource.BROWSER];
329
330		return '';
331	}
332
333	/** Determine the source of a tool by its name */
334	getToolSource(toolName: string): ToolSource | null {
335		return this.findEntryByName(toolName)?.source ?? null;
336	}
337
338	/**
339	 * Check if a working directory is worth setting: at least one server tool
340	 * that reads it is both served and left enabled by the given policy
341	 * (defaults to the global defaults).
342	 */
343	hasEnabledCwdTools(
344		disabledTools: ReadonlySet<string> = this._disabledTools,
345		disabledCategories: ReadonlySet<ToolSource> = this._disabledToolCategories
346	): boolean {
347		if (disabledCategories.has(ToolSource.SERVER)) return false;
348
349		return this._serverTools.some((def) => {
350			const name = def.function.name;
351
352			return (
353				this.cwdAwareTools.has(name) && !disabledTools.has(this.toolKey(ToolSource.SERVER, name))
354			);
355		});
356	}
357
358	/**
359	 * Load persisted disabled tools and fetch the builtin tool list.
360	 * Called by initStores() after migrations have run.
361	 */
362	initialize(): void {
363		// browser-only init: skip on SSR to avoid localStorage/fetch side effects
364		if (!browser) return;
365
366		try {
367			const stored = localStorage.getItem(DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY);
368
369			if (stored) {
370				const parsed = JSON.parse(stored);
371
372				if (Array.isArray(parsed)) {
373					for (const key of parsed) {
374						if (typeof key === 'string') this._disabledTools.add(key);
375					}
376				}
377			}
378		} catch (err) {
379			console.error('[ToolsStore] Failed to load disabled tools from localStorage:', err);
380		}
381
382		try {
383			const stored = localStorage.getItem(DISABLED_TOOL_CATEGORIES_LOCALSTORAGE_KEY);
384
385			if (stored) {
386				const parsed = JSON.parse(stored);
387
388				if (Array.isArray(parsed)) {
389					for (const key of parsed) {
390						if (Object.values(ToolSource).includes(key)) {
391							this._disabledToolCategories.add(key as ToolSource);
392						}
393					}
394				}
395			}
396		} catch (err) {
397			console.error('[ToolsStore] Failed to load disabled tool categories from localStorage:', err);
398		}
399
400		this.fetchServerTools();
401	}
402
403	isCategoryEnabled(source: ToolSource): boolean {
404		return !this._disabledToolCategories.has(source);
405	}
406
407	isEntryEnabled(
408		entry: ToolEntry,
409		disabledTools: ReadonlySet<string>,
410		disabledCategories: ReadonlySet<ToolSource>
411	): boolean {
412		if (disabledCategories.has(entry.source)) return false;
413
414		if (disabledTools.has(entry.key)) return false;
415
416		if (entry.source === ToolSource.MCP && entry.serverId) {
417			return !disabledTools.has(this.getMcpServerToolsKey(entry.serverId));
418		}
419
420		return true;
421	}
422
423	isToolEnabled(key: string): boolean {
424		return !this._disabledTools.has(key);
425	}
426
427	/**
428	 * Absolute home directory on the server, resolved once per session via
429	 * file_glob_search's `base` field (the server expands `~`). Anchors the
430	 * directory picker's search scope and the `~` abbreviation of cwd
431	 * displays. Returns null when tools are unavailable.
432	 */
433	async resolveServerHome(): Promise<string | null> {
434		if (this._serverHome !== undefined) return this._serverHome;
435
436		try {
437			const res = await ToolsService.executeToolRaw(BuiltInTool.SERVER_FILE_GLOB_SEARCH, {
438				limit: 1,
439				max_depth: 1,
440				path: HOME_TILDE,
441				type: GlobSearchType.DIR
442			});
443
444			this._serverHome = typeof res.base === 'string' ? res.base : null;
445		} catch {
446			// searches still work via a literal `~`, only `~` abbreviation degrades
447			this._serverHome = null;
448		}
449
450		return this._serverHome;
451	}
452
453	setCategoryEnabled(source: ToolSource, enabled: boolean): void {
454		if (enabled) {
455			this._disabledToolCategories.delete(source);
456		} else {
457			this._disabledToolCategories.add(source);
458		}
459
460		this.persistDisabledToolCategories();
461	}
462
463	setToolEnabled(key: string, enabled: boolean): void {
464		if (enabled) {
465			this._disabledTools.delete(key);
466		} else {
467			this._disabledTools.add(key);
468		}
469
470		this.persistDisabledTools();
471	}
472
473	toggleCategory(source: ToolSource): void {
474		this.setCategoryEnabled(source, !this.isCategoryEnabled(source));
475	}
476
477	toggleTool(key: string): void {
478		this.setToolEnabled(key, !this.isToolEnabled(key));
479	}
480
481	/** First canonical entry matching a tool name, runtime tool calls resolve by name */
482	private findEntryByName(toolName: string): ToolEntry | null {
483		for (const entry of this.allTools) {
484			if (entry.definition.function.name === toolName) return entry;
485		}
486
487		return null;
488	}
489
490	/** Get MCP tools from health check data, used when live connections aren't established yet */
491	private getMcpToolsFromHealthChecks(): {
492		serverId: string;
493		serverName: string;
494		tools: { name: string; description?: string }[];
495	}[] {
496		const result: ReturnType<ToolsStore['getMcpToolsFromHealthChecks']> = [];
497
498		for (const server of mcpStore.getServers()) {
499			if (!server.enabled) continue;
500
501			const health = mcpStore.getHealthCheckState(server.id);
502
503			if (health.status === HealthCheckStatus.SUCCESS && health.tools.length > 0) {
504				result.push({
505					serverId: server.id,
506					serverName: mcpStore.getServerLabel(server),
507					tools: health.tools
508				});
509			}
510		}
511
512		return result;
513	}
514
515	private groupLabel(entry: ToolEntry): string {
516		switch (entry.source) {
517			case ToolSource.MCP:
518				return entry.serverName ?? '';
519			case ToolSource.CUSTOM:
520				return TOOL_GROUP_LABELS[ToolSource.CUSTOM];
521			case ToolSource.BROWSER:
522				return TOOL_GROUP_LABELS[ToolSource.BROWSER];
523			default:
524				return TOOL_GROUP_LABELS[ToolSource.SERVER];
525		}
526	}
527
528	private hasServerTool(name: BuiltInTool): boolean {
529		return this._serverTools.some((def) => def.function.name === name);
530	}
531
532	private inferTypeFromDefault(value: unknown): string | undefined {
533		if (typeof value === 'string') return 'string';
534
535		if (typeof value === 'boolean') return 'boolean';
536
537		if (typeof value === 'number') return Number.isInteger(value) ? 'integer' : 'number';
538
539		if (Array.isArray(value)) return 'array';
540
541		if (value !== null && typeof value === 'object') return 'object';
542
543		return undefined;
544	}
545
546	private mcpDefinition(
547		name: string,
548		description: string | undefined,
549		schema?: Record<string, unknown>
550	): OpenAIToolDefinition {
551		return {
552			function: {
553				description,
554				name,
555				parameters: schema ?? { properties: {}, required: [], type: JsonSchemaType.OBJECT }
556			},
557			type: ToolCallType.FUNCTION
558		};
559	}
560
561	/** Normalize MCP tools from live connections when available, fall back to health check data */
562	private mcpEntries(): {
563		serverId: string;
564		serverName: string;
565		definition: OpenAIToolDefinition;
566	}[] {
567		const out: { serverId: string; serverName: string; definition: OpenAIToolDefinition }[] = [];
568		const connections = mcpStore.getConnections();
569
570		if (connections.size > 0) {
571			for (const [serverId, connection] of connections) {
572				const serverName = mcpStore.getServerDisplayName(serverId);
573
574				for (const tool of connection.tools) {
575					const rawSchema = (tool.inputSchema as Record<string, unknown>) ?? {
576						properties: {},
577						required: [],
578						type: JsonSchemaType.OBJECT
579					};
580
581					out.push({
582						definition: {
583							function: {
584								description: tool.description,
585								name: tool.name,
586								parameters: this.normalizeJsonSchema(rawSchema)
587							},
588							type: ToolCallType.FUNCTION
589						},
590						serverId,
591						serverName
592					});
593				}
594			}
595		} else {
596			for (const { serverId, serverName, tools } of this.getMcpToolsFromHealthChecks()) {
597				for (const tool of tools) {
598					out.push({
599						definition: this.mcpDefinition(tool.name, tool.description),
600						serverId,
601						serverName
602					});
603				}
604			}
605		}
606
607		return out;
608	}
609
610	/**
611	 * Recursively normalize a JSON Schema object: infers `type` from `default`
612	 * for properties / items that omit it, and descends into nested `properties`
613	 * and `items`. Returns a new object -- does not mutate the input.
614	 */
615	private normalizeJsonSchema(schema: Record<string, unknown>): Record<string, unknown> {
616		if (!schema || typeof schema !== 'object') return schema;
617
618		const normalized: Record<string, unknown> = { ...schema };
619
620		if (normalized.properties && typeof normalized.properties === 'object') {
621			const props = normalized.properties as Record<string, Record<string, unknown>>;
622			const normalizedProps: Record<string, Record<string, unknown>> = {};
623
624			for (const [key, prop] of Object.entries(props)) {
625				if (!prop || typeof prop !== 'object') {
626					normalizedProps[key] = prop;
627
628					continue;
629				}
630
631				const normalizedProp: Record<string, unknown> = { ...prop };
632
633				if (!normalizedProp.type && normalizedProp.default !== undefined) {
634					const inferred = this.inferTypeFromDefault(normalizedProp.default);
635
636					if (inferred) normalizedProp.type = inferred;
637				}
638
639				if (normalizedProp.properties) {
640					Object.assign(
641						normalizedProp,
642						this.normalizeJsonSchema(normalizedProp as Record<string, unknown>)
643					);
644				}
645
646				if (normalizedProp.items && typeof normalizedProp.items === 'object') {
647					normalizedProp.items = this.normalizeJsonSchema(
648						normalizedProp.items as Record<string, unknown>
649					);
650				}
651
652				normalizedProps[key] = normalizedProp;
653			}
654			normalized.properties = normalizedProps;
655		}
656
657		return normalized;
658	}
659
660	private persistDisabledToolCategories(): void {
661		try {
662			localStorage.setItem(
663				DISABLED_TOOL_CATEGORIES_LOCALSTORAGE_KEY,
664				JSON.stringify([...this._disabledToolCategories])
665			);
666		} catch {
667			// ignore storage errors
668		}
669	}
670
671	private persistDisabledTools(): void {
672		try {
673			localStorage.setItem(
674				DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY,
675				JSON.stringify([...this._disabledTools])
676			);
677		} catch {
678			// ignore storage errors
679		}
680	}
681
682	/**
683	 * `read_media` runs in the browser on top of the server's `read_file`, so it
684	 * exists only when that tool is served and the active model can perceive the
685	 * bytes. The server cannot make this call - it does not know which model the
686	 * conversation uses.
687	 */
688	private readMediaTool(): OpenAIToolDefinition | null {
689		if (!this.hasServerTool(BuiltInTool.SERVER_READ_FILE)) return null;
690
691		const model = modelsStore.selectedModelName ?? modelsStore.models[0]?.model ?? '';
692
693		if (!model) return null;
694
695		const vision = modelsStore.props.modelSupportsVision(model);
696		const audio = modelsStore.props.modelSupportsAudio(model);
697
698		if (!vision && !audio) return null;
699
700		return buildReadMediaToolDefinition(vision, audio);
701	}
702
703	private toolKey(source: ToolSource, name: string, serverId?: string): string {
704		switch (source) {
705			case ToolSource.MCP:
706				// with a serverId this is a per-tool key; without one it hits the
707				// server group key shape, which no MCP entry ever does
708				return serverId ? `mcp-${serverId}:${name}` : this.getMcpServerToolsKey(name);
709			case ToolSource.CUSTOM:
710				return `custom:${name}`;
711			case ToolSource.BROWSER:
712				return `browser:${name}`;
713			default:
714				return `server:${name}`;
715		}
716	}
717}
718
719export const toolsStore = new ToolsStore();