Blame Raw
Cohee · 51ad27fb · · 1163 lines (47.2 KB)
4 contributors
1import { DOMPurify } from '../lib.js';
2
3import { addOneMessage, chat, event_types, eventSource, getGeneratingApi, getGeneratingModel, main_api, saveChatConditional, system_avatar, systemUserName } from '../script.js';
4import { chat_completion_sources, custom_prompt_post_processing_types, getChatCompletionModel, model_list, oai_settings } from './openai.js';
5import { Popup } from './popup.js';
6import { SlashCommand } from './slash-commands/SlashCommand.js';
7import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js';
8import { SlashCommandClosure } from './slash-commands/SlashCommandClosure.js';
9import { enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js';
10import { enumTypes, SlashCommandEnumValue } from './slash-commands/SlashCommandEnumValue.js';
11import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
12import { slashCommandReturnHelper } from './slash-commands/SlashCommandReturnHelper.js';
13import { isTrueBoolean } from './utils.js';
14
15/**
16 * @typedef {object} ToolInvocation
17 * @property {string} id - A unique identifier for the tool invocation.
18 * @property {string} displayName - The display name of the tool.
19 * @property {string} name - The name of the tool.
20 * @property {string} parameters - The parameters for the tool invocation.
21 * @property {string} result - The result of the tool invocation.
22 * @property {string?} signature - The thought signature associated with the tool invocation.
23 * @property {string?} reasoning - The plaintext reasoning associated with this tool call turn.
24 * @property {boolean} [error] - Whether the tool invocation failed.
25 */
26
27/**
28 * @typedef {object} ToolInvocationResult
29 * @property {ToolInvocation[]} invocations Tool invocations (both successful and failed)
30 * @property {Error[]} errors Errors that occurred during tool invocation
31 * @property {string[]} stealthCalls Names of stealth tools that were invoked
32 */
33
34/**
35 * @typedef {object} ToolRegistration
36 * @property {string} name - The name of the tool.
37 * @property {string} displayName - The display name of the tool.
38 * @property {string} description - A description of the tool.
39 * @property {object} parameters - The parameters for the tool.
40 * @property {function} action - The action to perform when the tool is invoked.
41 * @property {function} [formatMessage] - A function to format the tool call message.
42 * @property {function} [shouldRegister] - A function to determine if the tool should be registered.
43 * @property {boolean} [stealth] - A tool call result will not be shown in the chat. No follow-up generation will be performed.
44 */
45
46/**
47 * @typedef {object} ToolDefinitionOpenAI
48 * @property {string} type - The type of the tool.
49 * @property {object} function - The function definition.
50 * @property {string} function.name - The name of the function.
51 * @property {string} function.description - The description of the function.
52 * @property {object} function.parameters - The parameters of the function.
53 * @property {function} toString - A function to convert the tool to a string.
54 */
55
56/**
57 * Assigns nested variables to a scope.
58 * @param {import('./slash-commands/SlashCommandScope.js').SlashCommandScope} scope The scope to assign variables to.
59 * @param {object} arg Object to assign variables from.
60 * @param {string} prefix Prefix for the variable names.
61 */
62function assignNestedVariables(scope, arg, prefix) {
63 Object.entries(arg).forEach(([key, value]) => {
64 const newPrefix = `${prefix}.${key}`;
65 if (typeof value === 'object' && value !== null) {
66 if (Array.isArray(value)) {
67 scope.letVariable(newPrefix, JSON.stringify(value));
68 }
69 assignNestedVariables(scope, value, newPrefix);
70 } else {
71 scope.letVariable(newPrefix, value);
72 }
73 });
74}
75
76/**
77 * Checks if a string is a valid JSON string.
78 * @param {string} str The string to check
79 * @returns {boolean} If the string is a valid JSON string
80 */
81function isJson(str) {
82 try {
83 JSON.parse(str);
84 return true;
85 } catch {
86 return false;
87 }
88}
89
90/**
91 * Tries to parse a string as JSON, returning the original string if parsing fails.
92 * @param {string} str The string to try to parse
93 * @returns {object|string} Parsed JSON or the original string
94 */
95function tryParse(str) {
96 try {
97 return JSON.parse(str);
98 } catch {
99 return str;
100 }
101}
102
103/**
104 * Stringifies an object if it is not already a string.
105 * @param {any} obj The object to stringify
106 * @returns {string} A JSON string representation of the object.
107 */
108function stringify(obj) {
109 return typeof obj === 'string' ? obj : JSON.stringify(obj);
110}
111
112/**
113 * A class that represents a tool definition.
114 */
115class ToolDefinition {
116 /**
117 * A unique name for the tool.
118 * @type {string}
119 */
120 #name;
121
122 /**
123 * A user-friendly display name for the tool.
124 * @type {string}
125 */
126 #displayName;
127
128 /**
129 * A description of what the tool does.
130 * @type {string}
131 */
132 #description;
133
134 /**
135 * A JSON schema for the parameters that the tool accepts.
136 * @type {object}
137 */
138 #parameters;
139
140 /**
141 * A function that will be called when the tool is executed.
142 * @type {function}
143 */
144 #action;
145
146 /**
147 * A function that will be called to format the tool call toast.
148 * @type {function}
149 */
150 #formatMessage;
151
152 /**
153 * A function that will be called to determine if the tool should be registered.
154 * @type {function}
155 */
156 #shouldRegister;
157
158 /**
159 * A tool call result will not be shown in the chat. No follow-up generation will be performed.
160 * @type {boolean}
161 */
162 #stealth;
163
164 /**
165 * Creates a new ToolDefinition.
166 * @param {string} name A unique name for the tool.
167 * @param {string} displayName A user-friendly display name for the tool.
168 * @param {string} description A description of what the tool does.
169 * @param {object} parameters A JSON schema for the parameters that the tool accepts.
170 * @param {function} action A function that will be called when the tool is executed.
171 * @param {function} formatMessage A function that will be called to format the tool call toast.
172 * @param {function} shouldRegister A function that will be called to determine if the tool should be registered.
173 * @param {boolean} stealth A tool call result will not be shown in the chat. No follow-up generation will be performed.
174 */
175 constructor(name, displayName, description, parameters, action, formatMessage, shouldRegister, stealth) {
176 this.#name = name;
177 this.#displayName = displayName;
178 this.#description = description;
179 this.#parameters = parameters;
180 this.#action = action;
181 this.#formatMessage = formatMessage;
182 this.#shouldRegister = shouldRegister;
183 this.#stealth = stealth;
184 }
185
186 /**
187 * Converts the ToolDefinition to an OpenAI API representation
188 * @returns {ToolDefinitionOpenAI} OpenAI API representation of the tool.
189 */
190 toFunctionOpenAI() {
191 return {
192 type: 'function',
193 function: {
194 name: this.#name,
195 description: this.#description,
196 parameters: this.#parameters,
197 },
198 toString: function () {
199 return `<div><b>${this.function.name}</b></div><div><small>${this.function.description}</small></div><pre class="justifyLeft wordBreakAll"><code class="flex padding5">${JSON.stringify(this.function.parameters, null, 2)}</code></pre><hr>`;
200 },
201 };
202 }
203
204 /**
205 * Invokes the tool with the given parameters.
206 * @param {object} parameters The parameters to pass to the tool.
207 * @returns {Promise<any>} The result of the tool's action function.
208 */
209 async invoke(parameters) {
210 return await this.#action(parameters);
211 }
212
213 /**
214 * Formats a message with the tool invocation.
215 * @param {object} parameters The parameters to pass to the tool.
216 * @returns {Promise<string>} The formatted message.
217 */
218 async formatMessage(parameters) {
219 return typeof this.#formatMessage === 'function'
220 ? await this.#formatMessage(parameters)
221 : `Invoking tool: ${this.#displayName || this.#name}`;
222 }
223
224 async shouldRegister() {
225 return typeof this.#shouldRegister === 'function'
226 ? await this.#shouldRegister()
227 : true;
228 }
229
230 get displayName() {
231 return this.#displayName;
232 }
233
234 get stealth() {
235 return this.#stealth;
236 }
237}
238
239/**
240 * A class that manages the registration and invocation of tools.
241 */
242export class ToolManager {
243 /**
244 * A map of tool names to tool definitions.
245 * @type {Map<string, ToolDefinition>}
246 */
247 static #tools = new Map();
248
249 static #INPUT_DELTA_KEY = '__input_json_delta';
250
251 /**
252 * The maximum number of times to recurse when parsing tool calls.
253 * @type {number}
254 */
255 static RECURSE_LIMIT = 5;
256
257 /**
258 * Returns an Array of all tools that have been registered.
259 * @type {ToolDefinition[]}
260 */
261 static get tools() {
262 return Array.from(this.#tools.values());
263 }
264
265 /**
266 * Registers a new tool with the tool registry.
267 * @param {ToolRegistration} tool The tool to register.
268 */
269 static registerFunctionTool({ name, displayName, description, parameters, action, formatMessage, shouldRegister, stealth }) {
270 // Convert WIP arguments
271 if (typeof arguments[0] !== 'object') {
272 [name, description, parameters, action] = arguments;
273 }
274
275 if (this.#tools.has(name)) {
276 console.warn(`[ToolManager] A tool with the name "${name}" has already been registered. The definition will be overwritten.`);
277 }
278
279 const definition = new ToolDefinition(
280 name,
281 displayName,
282 description,
283 parameters,
284 action,
285 formatMessage,
286 shouldRegister,
287 stealth,
288 );
289 this.#tools.set(name, definition);
290 console.log('[ToolManager] Registered function tool:', definition);
291 }
292
293 /**
294 * Removes a tool from the tool registry.
295 * @param {string} name The name of the tool to unregister.
296 */
297 static unregisterFunctionTool(name) {
298 if (!this.#tools.has(name)) {
299 return;
300 }
301
302 this.#tools.delete(name);
303 console.log(`[ToolManager] Unregistered function tool: ${name}`);
304 }
305
306 /**
307 * Parse tool call parameters -- they're usually JSON, but they can also be empty strings (which are not valid JSON apparently).
308 * @param {object} parameters The parameters for a tool call, usually a string with JSON inside
309 * @returns {object} The parsed parameters
310 */
311 static #parseParameters(parameters) {
312 return parameters === ''
313 ? {}
314 : typeof parameters === 'string'
315 ? JSON.parse(parameters)
316 : parameters;
317 }
318
319 /**
320 * Invokes a tool by name. Returns the result of the tool's action function.
321 * @param {string} name The name of the tool to invoke.
322 * @param {object} parameters Function parameters. For example, if the tool requires a "name" parameter, you would pass {name: "value"}.
323 * @returns {Promise<string|Error>} The result of the tool's action function. If an error occurs, null is returned. Non-string results are JSON-stringified.
324 */
325 static async invokeFunctionTool(name, parameters) {
326 try {
327 if (!this.#tools.has(name)) {
328 throw new Error(`No tool with the name "${name}" has been registered.`);
329 }
330
331 const invokeParameters = this.#parseParameters(parameters);
332 const tool = this.#tools.get(name);
333 const result = await tool.invoke(invokeParameters);
334 return typeof result === 'string' ? result : JSON.stringify(result);
335 } catch (error) {
336 console.error(`[ToolManager] An error occurred while invoking the tool "${name}":`, error);
337
338 if (error instanceof Error) {
339 error.cause = name;
340 return error;
341 }
342
343 return new Error('Unknown error occurred while invoking the tool.', { cause: name });
344 }
345 }
346
347 /**
348 * Checks if a tool is a stealth tool.
349 * @param {string} name The name of the tool to check.
350 * @returns {boolean} Whether the tool is a stealth tool.
351 */
352 static isStealthTool(name) {
353 if (!this.#tools.has(name)) {
354 return false;
355 }
356
357 const tool = this.#tools.get(name);
358 return !!tool.stealth;
359 }
360
361 /**
362 * Formats a message for a tool call by name.
363 * @param {string} name The name of the tool to format the message for.
364 * @param {object} parameters Function tool call parameters.
365 * @returns {Promise<string>} The formatted message for the tool call.
366 */
367 static async formatToolCallMessage(name, parameters) {
368 if (!this.#tools.has(name)) {
369 return `Invoked unknown tool: ${name}`;
370 }
371
372 try {
373 const tool = this.#tools.get(name);
374 const formatParameters = this.#parseParameters(parameters);
375 return await tool.formatMessage(formatParameters);
376 } catch (error) {
377 console.error(`[ToolManager] An error occurred while formatting the tool call message for "${name}":`, error);
378 return `Invoking tool: ${name}`;
379 }
380 }
381
382 /**
383 * Gets the display name of a tool by name.
384 * @param {string} name
385 * @returns {string} The display name of the tool.
386 */
387 static getDisplayName(name) {
388 if (!this.#tools.has(name)) {
389 return name;
390 }
391
392 const tool = this.#tools.get(name);
393 return tool.displayName || name;
394 }
395
396 /**
397 * Register function tools for the next chat completion request.
398 * @param {object} data Generation data
399 */
400 static async registerFunctionToolsOpenAI(data) {
401 const tools = [];
402
403 for (const tool of ToolManager.tools) {
404 const register = await tool.shouldRegister();
405 if (!register) {
406 console.log('[ToolManager] Skipping tool registration:', tool);
407 continue;
408 }
409 tools.push(tool.toFunctionOpenAI());
410 }
411
412 if (tools.length) {
413 console.log('[ToolManager] Registered function tools:', tools);
414
415 data.tools = tools;
416 data.tool_choice = 'auto';
417 }
418 }
419
420 /**
421 * Utility function to parse tool calls from a parsed response.
422 * @param {any[]} toolCalls The tool calls to update.
423 * @param {any} parsed The parsed response from the OpenAI API.
424 * @param {object} toolSignatures Optional mapping of tool call IDs to thought signatures.
425 * @returns {void}
426 */
427 static parseToolCalls(toolCalls, parsed, toolSignatures = {}) {
428 if (!this.isToolCallingSupported()) {
429 return;
430 }
431 if (Array.isArray(parsed?.choices)) {
432 for (const choice of parsed.choices) {
433 const choiceIndex = (typeof choice.index === 'number') ? choice.index : null;
434 const choiceDelta = choice.delta;
435
436 if (choiceIndex === null || !choiceDelta) {
437 continue;
438 }
439
440 const toolCallDeltas = choiceDelta?.tool_calls;
441
442 if (!Array.isArray(toolCallDeltas)) {
443 continue;
444 }
445
446 if (!Array.isArray(toolCalls[choiceIndex])) {
447 toolCalls[choiceIndex] = [];
448 }
449
450 for (const toolCallDelta of toolCallDeltas) {
451 const toolCallIndex = toolCallDelta?.index >= 0 ? toolCallDelta.index : toolCallDeltas.indexOf(toolCallDelta);
452
453 if (isNaN(toolCallIndex)) {
454 continue;
455 }
456
457 if (toolCalls[choiceIndex][toolCallIndex] === undefined) {
458 toolCalls[choiceIndex][toolCallIndex] = {};
459 }
460
461 const targetToolCall = toolCalls[choiceIndex][toolCallIndex];
462
463 ToolManager.#applyToolCallDelta(targetToolCall, toolCallDelta);
464
465 // Transfer thought signature if available
466 if (Object.hasOwn(toolSignatures, targetToolCall.id)) {
467 targetToolCall.signature = toolSignatures[targetToolCall.id];
468 }
469 }
470 }
471 }
472 const cohereToolEvents = ['message-start', 'tool-call-start', 'tool-call-delta', 'tool-call-end'];
473 if (cohereToolEvents.includes(parsed?.type) && typeof parsed?.delta?.message === 'object') {
474 const choiceIndex = 0;
475 const toolCallIndex = parsed?.index ?? 0;
476
477 if (!Array.isArray(toolCalls[choiceIndex])) {
478 toolCalls[choiceIndex] = [];
479 }
480
481 if (toolCalls[choiceIndex][toolCallIndex] === undefined) {
482 toolCalls[choiceIndex][toolCallIndex] = {};
483 }
484
485 const targetToolCall = toolCalls[choiceIndex][toolCallIndex];
486 ToolManager.#applyToolCallDelta(targetToolCall, parsed.delta.message);
487 }
488 if (typeof parsed?.content_block === 'object') {
489 const choiceIndex = 0;
490 const toolCallIndex = parsed?.index ?? 0;
491
492 if (parsed?.content_block?.type === 'tool_use') {
493 if (!Array.isArray(toolCalls[choiceIndex])) {
494 toolCalls[choiceIndex] = [];
495 }
496 if (toolCalls[choiceIndex][toolCallIndex] === undefined) {
497 toolCalls[choiceIndex][toolCallIndex] = {};
498 }
499 const targetToolCall = toolCalls[choiceIndex][toolCallIndex];
500 ToolManager.#applyToolCallDelta(targetToolCall, parsed.content_block);
501 }
502 }
503 if (typeof parsed?.delta === 'object') {
504 const choiceIndex = 0;
505 const toolCallIndex = parsed?.index ?? 0;
506 const targetToolCall = toolCalls[choiceIndex]?.[toolCallIndex];
507 if (targetToolCall) {
508 if (parsed?.delta?.type === 'input_json_delta') {
509 const jsonDelta = parsed?.delta?.partial_json;
510 if (!targetToolCall[this.#INPUT_DELTA_KEY]) {
511 targetToolCall[this.#INPUT_DELTA_KEY] = '';
512 }
513 targetToolCall[this.#INPUT_DELTA_KEY] += jsonDelta;
514 }
515 }
516 }
517 if (parsed?.type === 'content_block_stop') {
518 const choiceIndex = 0;
519 const toolCallIndex = parsed?.index ?? 0;
520 const targetToolCall = toolCalls[choiceIndex]?.[toolCallIndex];
521 if (targetToolCall) {
522 const jsonDeltaString = targetToolCall[this.#INPUT_DELTA_KEY];
523 if (jsonDeltaString) {
524 try {
525 const jsonDelta = { input: JSON.parse(jsonDeltaString) };
526 delete targetToolCall[this.#INPUT_DELTA_KEY];
527 ToolManager.#applyToolCallDelta(targetToolCall, jsonDelta);
528 } catch (error) {
529 console.warn('[ToolManager] Failed to apply input JSON delta:', error);
530 }
531 }
532 }
533 }
534 if (Array.isArray(parsed?.candidates)) {
535 for (let choiceIndex = 0; choiceIndex < parsed.candidates.length; choiceIndex++) {
536 const candidate = parsed.candidates[choiceIndex];
537 if (Array.isArray(candidate?.content?.parts)) {
538 for (let partIndex = 0; partIndex < candidate.content.parts.length; partIndex++) {
539 const part = candidate.content.parts[partIndex];
540 if (part.functionCall) {
541 if (!Array.isArray(toolCalls[choiceIndex])) {
542 toolCalls[choiceIndex] = [];
543 }
544 const toolCallIndex = toolCalls[choiceIndex].length;
545 if (toolCalls[choiceIndex][toolCallIndex] === undefined) {
546 toolCalls[choiceIndex][toolCallIndex] = {};
547 }
548 const targetToolCall = toolCalls[choiceIndex][toolCallIndex];
549 if (part.thoughtSignature) {
550 targetToolCall.thoughtSignature = part.thoughtSignature;
551 }
552 ToolManager.#applyToolCallDelta(targetToolCall, part.functionCall);
553 }
554 }
555 }
556 }
557 }
558 }
559
560 /**
561 * Apply a tool call delta to a target object.
562 * @param {object} target The target object to apply the delta to
563 * @param {object} delta The delta object to apply
564 */
565 static #applyToolCallDelta(target, delta) {
566 for (const key in delta) {
567 if (!Object.prototype.hasOwnProperty.call(delta, key)) continue;
568 if (key === '__proto__' || key === 'constructor') continue;
569
570 const deltaValue = delta[key];
571 const targetValue = target[key];
572
573 if (deltaValue === null || deltaValue === undefined) {
574 // Don't reset the value if it already exists
575 if (targetValue) {
576 continue;
577 }
578 target[key] = deltaValue;
579 continue;
580 }
581
582 if (typeof deltaValue === 'string') {
583 if (typeof targetValue === 'string') {
584 // Concatenate strings
585 target[key] = targetValue + deltaValue;
586 } else {
587 target[key] = deltaValue;
588 }
589 } else if (typeof deltaValue === 'object' && !Array.isArray(deltaValue)) {
590 if (typeof targetValue !== 'object' || targetValue === null || Array.isArray(targetValue)) {
591 target[key] = {};
592 }
593 // Recursively apply deltas to nested objects
594 ToolManager.#applyToolCallDelta(target[key], deltaValue);
595 } else {
596 // Assign other types directly
597 target[key] = deltaValue;
598 }
599 }
600 }
601
602 /**
603 * Checks if tool calling is supported for the current settings and generation type.
604 * @param {ChatCompletionSettings} settings Optional chat completion settings
605 * @param {string} model Optional model name
606 * @returns {boolean} Whether tool calling is supported for the given type
607 */
608 static isToolCallingSupported(settings = null, model = null) {
609 settings = settings ?? oai_settings;
610 model = model ?? getChatCompletionModel(settings);
611
612 if (main_api !== 'openai' || !settings.function_calling) {
613 return false;
614 }
615
616 // Post-processing will forcefully remove past tool calls from the prompt, making them useless
617 const { NONE, MERGE_TOOLS, SEMI_TOOLS, STRICT_TOOLS } = custom_prompt_post_processing_types;
618 const allowedPromptPostProcessing = [NONE, MERGE_TOOLS, SEMI_TOOLS, STRICT_TOOLS];
619 if (!allowedPromptPostProcessing.includes(settings.custom_prompt_post_processing)) {
620 return false;
621 }
622
623 const currentModel = Array.isArray(model_list) ? model_list.find(m => m.id === model) : null;
624 if (currentModel) {
625 switch (settings.chat_completion_source) {
626 case chat_completion_sources.POLLINATIONS:
627 return currentModel.tools;
628 case chat_completion_sources.FIREWORKS:
629 return currentModel.supports_tools;
630 case chat_completion_sources.OPENROUTER:
631 return currentModel.supported_parameters?.includes('tools');
632 case chat_completion_sources.MISTRALAI:
633 return currentModel.capabilities?.function_calling;
634 case chat_completion_sources.AIMLAPI:
635 return currentModel.features?.includes('openai/chat-completion.function');
636 case chat_completion_sources.CHUTES:
637 return currentModel.supported_features?.includes('tools');
638 case chat_completion_sources.ELECTRONHUB:
639 return currentModel.metadata?.function_call;
640 case chat_completion_sources.WORKERS_AI:
641 return Array.isArray(currentModel.properties) && currentModel.properties.some(p => p.property_id === 'function_calling' && p.value === 'true');
642 }
643 }
644
645 const supportedSources = [
646 chat_completion_sources.OPENAI,
647 chat_completion_sources.CUSTOM,
648 chat_completion_sources.MISTRALAI,
649 chat_completion_sources.CLAUDE,
650 chat_completion_sources.OPENROUTER,
651 chat_completion_sources.AIMLAPI,
652 chat_completion_sources.GROQ,
653 chat_completion_sources.COHERE,
654 chat_completion_sources.DEEPSEEK,
655 chat_completion_sources.MAKERSUITE,
656 chat_completion_sources.VERTEXAI,
657 chat_completion_sources.AI21,
658 chat_completion_sources.XAI,
659 chat_completion_sources.POLLINATIONS,
660 chat_completion_sources.MOONSHOT,
661 chat_completion_sources.FIREWORKS,
662 chat_completion_sources.COMETAPI,
663 chat_completion_sources.CHUTES,
664 chat_completion_sources.ELECTRONHUB,
665 chat_completion_sources.AZURE_OPENAI,
666 chat_completion_sources.ZAI,
667 chat_completion_sources.SILICONFLOW,
668 chat_completion_sources.NANOGPT,
669 chat_completion_sources.WORKERS_AI,
670 chat_completion_sources.MINIMAX,
671 ];
672 return supportedSources.includes(settings.chat_completion_source);
673 }
674
675 /**
676 * Checks if tool calls can be performed for the current settings and generation type.
677 * @param {string} type Generation type
678 * @param {ChatCompletionSettings} settings Optional chat completion settings
679 * @param {string} model Optional model name
680 * @returns {boolean} Whether tool calls can be performed for the given type
681 */
682 static canPerformToolCalls(type, settings = null, model = null) {
683 settings = settings ?? oai_settings;
684 model = model ?? getChatCompletionModel(settings);
685 const noToolCallTypes = ['impersonate', 'quiet', 'continue'];
686 const isSupported = ToolManager.isToolCallingSupported(settings, model);
687 return isSupported && !noToolCallTypes.includes(type);
688 }
689
690 /**
691 * Utility function to get tool calls from the response data.
692 * @param {any} data Response data
693 * @returns {any[]} Tool calls from the response data
694 */
695 static #getToolCallsFromData(data) {
696 const getRandomId = () => Math.random().toString(36).substring(2);
697 const isClaudeToolCall = c => Array.isArray(c) ? c.filter(x => x).every(isClaudeToolCall) : c?.input && c?.name && c?.id;
698 const isGoogleToolCall = c => Array.isArray(c) ? c.filter(x => x).every(isGoogleToolCall) : c?.name && c?.args;
699 const convertClaudeToolCall = c => ({ id: c.id, function: { name: c.name, arguments: c.input } });
700 const convertGoogleToolCall = (c, signature = null) => ({ id: getRandomId(), function: { name: c.name, arguments: c.args }, signature });
701
702 // Parsed tool calls from streaming data
703 if (Array.isArray(data) && data.length > 0 && Array.isArray(data[0])) {
704 if (isClaudeToolCall(data[0])) {
705 return data[0].filter(x => x).map(convertClaudeToolCall);
706 }
707
708 if (isGoogleToolCall(data[0])) {
709 return data[0].filter(x => x).map((c) => convertGoogleToolCall(c, c.thoughtSignature));
710 }
711
712 if (typeof data[0]?.[0]?.tool_calls === 'object') {
713 return Array.isArray(data[0]?.[0]?.tool_calls) ? data[0][0].tool_calls : [data[0][0].tool_calls];
714 }
715
716 return data[0];
717 }
718
719 // Google AI Studio tool calls
720 if (Array.isArray(data?.responseContent?.parts)) {
721 return data.responseContent.parts.filter(p => p.functionCall).map(p => convertGoogleToolCall(p.functionCall, p.thoughtSignature));
722 }
723
724 // Parsed tool calls from non-streaming data
725 if (Array.isArray(data?.choices)) {
726 // Find a choice with 0-index
727 const choice = data.choices.find(choice => choice.index === 0);
728
729 if (choice && typeof choice.message === 'object' && Array.isArray(choice.message.tool_calls)) {
730 // Add OpenRouter signatures
731 if (Array.isArray(choice.message.reasoning_details)) {
732 for (const toolCall of choice.message.tool_calls) {
733 const reasoningDetail = choice.message.reasoning_details.find(rd => rd.id === toolCall.id);
734 if (reasoningDetail && reasoningDetail.type === 'reasoning.encrypted' && reasoningDetail.data) {
735 toolCall.signature = reasoningDetail.data;
736 }
737 }
738 }
739
740 return choice.message.tool_calls;
741 }
742 }
743
744 // Claude tool calls to OpenAI tool calls
745 if (Array.isArray(data?.content)) {
746 const content = data.content.filter(c => c.type === 'tool_use').map(convertClaudeToolCall);
747
748 if (content) {
749 return content;
750 }
751 }
752
753 // Cohere tool calls
754 if (typeof data?.message?.tool_calls === 'object') {
755 return Array.isArray(data?.message?.tool_calls) ? data.message.tool_calls : [data.message.tool_calls];
756 }
757 }
758
759 /**
760 * Checks if the response data contains tool calls.
761 * @param {object} data Response data
762 * @returns {boolean} Whether the response data contains tool calls
763 */
764 static hasToolCalls(data) {
765 const toolCalls = ToolManager.#getToolCallsFromData(data);
766 return Array.isArray(toolCalls) && toolCalls.length > 0;
767 }
768
769 /**
770 * Check for function tool calls in the response data and invoke them.
771 * @param {any} data Reply data
772 * @returns {Promise<ToolInvocationResult>} Successful tool invocations
773 */
774 static async invokeFunctionTools(data, { reasoningText = null } = {}) {
775 /** @type {ToolInvocationResult} */
776 const result = {
777 invocations: [],
778 errors: [],
779 stealthCalls: [],
780 };
781 const toolCalls = ToolManager.#getToolCallsFromData(data);
782
783 if (!Array.isArray(toolCalls)) {
784 return result;
785 }
786
787 for (const toolCall of toolCalls) {
788 if (!toolCall || !toolCall.function || typeof toolCall.function !== 'object') {
789 continue;
790 }
791
792 console.log('[ToolManager] Function tool call:', toolCall);
793 const id = toolCall.id;
794 const parameters = toolCall.function.arguments;
795 const name = toolCall.function.name;
796 const displayName = ToolManager.getDisplayName(name);
797 const isStealth = ToolManager.isStealthTool(name);
798 const message = await ToolManager.formatToolCallMessage(name, parameters);
799 const toast = message && toastr.info(message, 'Tool Calling', { timeOut: 0 });
800 const toolResult = await ToolManager.invokeFunctionTool(name, parameters);
801 toastr.clear(toast);
802 console.log('[ToolManager] Function tool result:', result);
803
804 // Handle tool errors — still create an invocation so the LLM sees the failure
805 if (toolResult instanceof Error) {
806 result.errors.push(toolResult);
807 if (isStealth) {
808 result.stealthCalls.push(name);
809 } else {
810 result.invocations.push({
811 id,
812 displayName,
813 name,
814 parameters: stringify(parameters),
815 result: toolResult.toString(),
816 error: true,
817 signature: toolCall.signature || null,
818 reasoning: reasoningText || null,
819 });
820 }
821 continue;
822 }
823
824 // Don't save stealth tool invocations
825 if (isStealth) {
826 result.stealthCalls.push(name);
827 continue;
828 }
829
830 const invocation = {
831 id,
832 displayName,
833 name,
834 parameters: stringify(parameters),
835 result: toolResult,
836 error: false,
837 signature: toolCall.signature || null,
838 reasoning: reasoningText || null,
839 };
840 result.invocations.push(invocation);
841 }
842
843 return result;
844 }
845
846 /**
847 * Groups tool names by count.
848 * @param {string[]} toolNames Tool names
849 * @returns {string} Grouped tool names
850 */
851 static #groupToolNames(toolNames) {
852 const toolCounts = toolNames.reduce((acc, name) => {
853 acc[name] = (acc[name] || 0) + 1;
854 return acc;
855 }, {});
856 return Object.entries(toolCounts).map(([name, count]) => count > 1 ? `${name} (${count})` : name).join(', ');
857 }
858
859 /**
860 * Formats a message with tool invocations.
861 * @param {ToolInvocation[]} invocations Tool invocations.
862 * @returns {string} Formatted message with tool invocations.
863 */
864 static #formatToolInvocationMessage(invocations) {
865 const data = structuredClone(invocations);
866 const detailsElement = document.createElement('details');
867 const summaryElement = document.createElement('summary');
868 const preElement = document.createElement('pre');
869 const codeElement = document.createElement('code');
870 codeElement.classList.add('language-json');
871 data.forEach(i => {
872 i.parameters = tryParse(i.parameters);
873 i.result = tryParse(i.result);
874 });
875 codeElement.textContent = JSON.stringify(data, null, 2);
876 const toolNames = data.map(i => i.displayName || i.name);
877 summaryElement.textContent = `Tool calls: ${this.#groupToolNames(toolNames)}`;
878 preElement.append(codeElement);
879 detailsElement.append(summaryElement, preElement);
880 return detailsElement.outerHTML;
881 }
882
883 /**
884 * Saves function tool invocations to the last user chat message extra metadata.
885 * @param {ToolInvocation[]} invocations Successful tool invocations
886 */
887 static async saveFunctionToolInvocations(invocations) {
888 if (!Array.isArray(invocations) || invocations.length === 0) {
889 return;
890 }
891 const message = {
892 name: systemUserName,
893 force_avatar: system_avatar,
894 is_system: true,
895 is_user: false,
896 mes: ToolManager.#formatToolInvocationMessage(invocations),
897 extra: {
898 isSmallSys: true,
899 tool_invocations: invocations,
900 api: getGeneratingApi(),
901 model: getGeneratingModel(),
902 },
903 };
904 chat.push(message);
905 await eventSource.emit(event_types.TOOL_CALLS_PERFORMED, invocations);
906 addOneMessage(message);
907 await eventSource.emit(event_types.TOOL_CALLS_RENDERED, invocations);
908 await saveChatConditional();
909 }
910
911 /**
912 * Shows an error message for tool calls.
913 * @param {Error[]} errors Errors that occurred during tool invocation
914 * @returns {void}
915 */
916 static showToolCallError(errors) {
917 toastr.error('An error occurred while invoking function tools. Click here for more details.', 'Tool Calling', {
918 onclick: () => Popup.show.text('Tool Calling Errors', DOMPurify.sanitize(errors.map(e => `${e.cause}: ${e.message}`).join('<br>'))),
919 timeOut: 5000,
920 });
921 }
922
923 static initToolSlashCommands() {
924 const toolsEnumProvider = () => ToolManager.tools.map(tool => {
925 const toolOpenAI = tool.toFunctionOpenAI();
926 return new SlashCommandEnumValue(toolOpenAI.function.name, toolOpenAI.function.description, enumTypes.enum, enumIcons.closure);
927 });
928
929 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
930 name: 'tools-list',
931 aliases: ['tool-list'],
932 helpString: 'Gets a list of all registered tools in the OpenAI function JSON format. Use the <code>return</code> argument to specify the return value type.',
933 returns: 'A list of all registered tools.',
934 namedArgumentList: [
935 SlashCommandNamedArgument.fromProps({
936 name: 'return',
937 description: 'The way how you want the return value to be provided',
938 typeList: [ARGUMENT_TYPE.STRING],
939 defaultValue: 'none',
940 enumList: slashCommandReturnHelper.enumList({ allowObject: true }),
941 forceEnum: true,
942 }),
943 ],
944 callback: async (args) => {
945 /** @type {any} */
946 const returnType = String(args?.return ?? 'popup-html').trim().toLowerCase();
947 const objectToStringFunc = (tools) => Array.isArray(tools) ? tools.map(x => x.toString()).join('\n\n') : tools.toString();
948 const tools = ToolManager.tools.map(tool => tool.toFunctionOpenAI());
949 return await slashCommandReturnHelper.doReturn(returnType ?? 'popup-html', tools ?? [], { objectToStringFunc });
950 },
951 }));
952
953 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
954 name: 'tools-invoke',
955 aliases: ['tool-invoke'],
956 helpString: 'Invokes a registered tool by name. The <code>parameters</code> argument MUST be a JSON-serialized object.',
957 namedArgumentList: [
958 SlashCommandNamedArgument.fromProps({
959 name: 'parameters',
960 description: 'The parameters to pass to the tool.',
961 typeList: [ARGUMENT_TYPE.DICTIONARY],
962 isRequired: true,
963 acceptsMultiple: false,
964 }),
965 ],
966 unnamedArgumentList: [
967 SlashCommandArgument.fromProps({
968 description: 'The name of the tool to invoke.',
969 typeList: [ARGUMENT_TYPE.STRING],
970 isRequired: true,
971 acceptsMultiple: false,
972 forceEnum: true,
973 enumProvider: toolsEnumProvider,
974 }),
975 ],
976 callback: async (args, name) => {
977 const { parameters } = args;
978
979 const result = await ToolManager.invokeFunctionTool(String(name), parameters);
980 if (result instanceof Error) {
981 throw result;
982 }
983
984 return result;
985 },
986 }));
987
988 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
989 name: 'tools-register',
990 aliases: ['tool-register'],
991 helpString: `<div>Registers a new tool with the tool registry.</div>
992 <ul>
993 <li>The <code>parameters</code> argument MUST be a JSON-serialized object with a valid JSON schema.</li>
994 <li>The unnamed argument MUST be a closure that accepts the function parameters as local script variables.</li>
995 </ul>
996 <div>See <a target="_blank" href="https://json-schema.org/learn/">json-schema.org</a> and <a target="_blank" href="https://platform.openai.com/docs/guides/function-calling">OpenAI Function Calling</a> for more information.</div>
997 <div>Example:</div>
998 <pre><code>/let key=echoSchema
999{
1000 "$schema": "http://json-schema.org/draft-04/schema#",
1001 "type": "object",
1002 "properties": {
1003 "message": {
1004 "type": "string",
1005 "description": "The message to echo."
1006 }
1007 },
1008 "required": [
1009 "message"
1010 ]
1011}
1012||
1013/tools-register name=Echo description="Echoes a message. Call when the user is asking to repeat something" parameters={{var::echoSchema}} {: /echo {{var::arg.message}} :}</code></pre>`,
1014 namedArgumentList: [
1015 SlashCommandNamedArgument.fromProps({
1016 name: 'name',
1017 description: 'The name of the tool.',
1018 typeList: [ARGUMENT_TYPE.STRING],
1019 isRequired: true,
1020 acceptsMultiple: false,
1021 }),
1022 SlashCommandNamedArgument.fromProps({
1023 name: 'description',
1024 description: 'A description of what the tool does.',
1025 typeList: [ARGUMENT_TYPE.STRING],
1026 isRequired: true,
1027 acceptsMultiple: false,
1028 }),
1029 SlashCommandNamedArgument.fromProps({
1030 name: 'parameters',
1031 description: 'The parameters for the tool.',
1032 typeList: [ARGUMENT_TYPE.DICTIONARY],
1033 isRequired: true,
1034 acceptsMultiple: false,
1035 }),
1036 SlashCommandNamedArgument.fromProps({
1037 name: 'displayName',
1038 description: 'The display name of the tool.',
1039 typeList: [ARGUMENT_TYPE.STRING],
1040 isRequired: false,
1041 acceptsMultiple: false,
1042 }),
1043 SlashCommandNamedArgument.fromProps({
1044 name: 'formatMessage',
1045 description: 'The closure to be executed to format the tool call message. Must return a string.',
1046 typeList: [ARGUMENT_TYPE.CLOSURE],
1047 isRequired: true,
1048 acceptsMultiple: false,
1049 }),
1050 SlashCommandNamedArgument.fromProps({
1051 name: 'shouldRegister',
1052 description: 'The closure to be executed to determine if the tool should be registered. Must return a boolean.',
1053 typeList: [ARGUMENT_TYPE.CLOSURE],
1054 isRequired: false,
1055 acceptsMultiple: false,
1056 }),
1057 SlashCommandNamedArgument.fromProps({
1058 name: 'stealth',
1059 description: 'If true, a tool call result will not be shown in the chat and no follow-up generation will be performed.',
1060 typeList: [ARGUMENT_TYPE.BOOLEAN],
1061 isRequired: false,
1062 acceptsMultiple: false,
1063 defaultValue: String(false),
1064 }),
1065 ],
1066 unnamedArgumentList: [
1067 SlashCommandArgument.fromProps({
1068 description: 'The closure to be executed when the tool is invoked.',
1069 typeList: [ARGUMENT_TYPE.CLOSURE],
1070 isRequired: true,
1071 acceptsMultiple: false,
1072 }),
1073 ],
1074 callback: async (args, action) => {
1075 /**
1076 * Converts a slash command closure to a function.
1077 * @param {SlashCommandClosure} action Closure to convert to a function
1078 * @param {function(any): any} convertResult Function to convert the result
1079 * @returns {function} Function that executes the closure
1080 */
1081 function closureToFunction(action, convertResult) {
1082 return async (args) => {
1083 const localClosure = action.getCopy();
1084 localClosure.onProgress = () => { };
1085 const scope = localClosure.scope;
1086 if (typeof args === 'object' && args !== null) {
1087 assignNestedVariables(scope, args, 'arg');
1088 } else if (typeof args !== 'undefined') {
1089 scope.letVariable('arg', args);
1090 }
1091 const result = await localClosure.execute();
1092 return convertResult(result.pipe);
1093 };
1094 }
1095
1096 const { name, displayName, description, parameters, formatMessage, shouldRegister, stealth } = args;
1097
1098 if (!(action instanceof SlashCommandClosure)) {
1099 throw new Error('The unnamed argument must be a closure.');
1100 }
1101 if (typeof name !== 'string' || !name) {
1102 throw new Error('The "name" argument must be a non-empty string.');
1103 }
1104 if (typeof description !== 'string' || !description) {
1105 throw new Error('The "description" argument must be a non-empty string.');
1106 }
1107 if (typeof parameters !== 'string' || !isJson(parameters)) {
1108 throw new Error('The "parameters" argument must be a JSON-serialized object.');
1109 }
1110 if (displayName && typeof displayName !== 'string') {
1111 throw new Error('The "displayName" argument must be a string.');
1112 }
1113 if (formatMessage && !(formatMessage instanceof SlashCommandClosure)) {
1114 throw new Error('The "formatMessage" argument must be a closure.');
1115 }
1116 if (shouldRegister && !(shouldRegister instanceof SlashCommandClosure)) {
1117 throw new Error('The "shouldRegister" argument must be a closure.');
1118 }
1119
1120 const actionFunc = closureToFunction(action, x => x);
1121 const formatMessageFunc = formatMessage instanceof SlashCommandClosure ? closureToFunction(formatMessage, x => String(x)) : null;
1122 const shouldRegisterFunc = shouldRegister instanceof SlashCommandClosure ? closureToFunction(shouldRegister, x => isTrueBoolean(x)) : null;
1123
1124 ToolManager.registerFunctionTool({
1125 name: String(name ?? ''),
1126 displayName: String(displayName ?? ''),
1127 description: String(description ?? ''),
1128 parameters: JSON.parse(parameters ?? '{}'),
1129 action: actionFunc,
1130 formatMessage: formatMessageFunc,
1131 shouldRegister: shouldRegisterFunc,
1132 stealth: stealth && isTrueBoolean(String(stealth)),
1133 });
1134
1135 return '';
1136 },
1137 }));
1138
1139 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
1140 name: 'tools-unregister',
1141 aliases: ['tool-unregister'],
1142 helpString: 'Unregisters a tool from the tool registry.',
1143 unnamedArgumentList: [
1144 SlashCommandArgument.fromProps({
1145 description: 'The name of the tool to unregister.',
1146 typeList: [ARGUMENT_TYPE.STRING],
1147 isRequired: true,
1148 acceptsMultiple: false,
1149 forceEnum: true,
1150 enumProvider: toolsEnumProvider,
1151 }),
1152 ],
1153 callback: async (_, name) => {
1154 if (typeof name !== 'string' || !name) {
1155 throw new Error('The unnamed argument must be a non-empty string.');
1156 }
1157
1158 ToolManager.unregisterFunctionTool(name);
1159 return '';
1160 },
1161 }));
1162 }
1163}