wingbot 3.76.18 → 3.77.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,580 @@
1
+ /**
2
+ * @author Ondrej Veres
3
+ */
4
+ 'use strict';
5
+
6
+ const LLMType = require('./LLMType');
7
+ const compileWithState = require('./utils/compileWithState');
8
+ const stateData = require('./utils/stateData');
9
+ const getCondition = require('./utils/getCondition');
10
+ const { compare, ConditionOperators } = require('./utils/customCondition');
11
+ const { getValue } = require('./utils/getUpdate');
12
+
13
+ /** @typedef {import('./Request')} Request */
14
+ /** @typedef {import('./Responder')} Responder */
15
+ /** @typedef {import('./Plugins')} Plugins */
16
+ /** @typedef {import('./LLMSession')} LLMSession */
17
+ /** @typedef {import('./Responder').QuickReply} QuickReply */
18
+
19
+ /**
20
+ * texts in languages as the designer exports them ([{ l, t }], as the multilang inputs),
21
+ * or { [lang]: text }
22
+ *
23
+ * @typedef {{ l: string|null, t: string }[]|{ [lang: string]: string }} ToolTexts
24
+ */
25
+
26
+ /**
27
+ * @typedef {object} ToolParameterDefinition
28
+ * @prop {string} name
29
+ * @prop {'string'|'number'|'boolean'|'enum'|'array'} type
30
+ * @prop {boolean} [optional]
31
+ * @prop {string} [description]
32
+ * @prop {{ value: string, description: string }[]} [values]
33
+ * @prop {ToolParameterDefinition[]} [items] - array: fields of the objects in the array
34
+ */
35
+
36
+ /**
37
+ * A button of a situation - a prefilled call of a tool (or just a text the user sends)
38
+ *
39
+ * @typedef {object} ToolButtonDefinition
40
+ * @prop {ToolTexts} title
41
+ * @prop {string|null} tool - the tool the click calls (null - the title goes to the LLM as a text)
42
+ * @prop {object|null} [args] - arguments of the tool, strings can use the values
43
+ * of the situation ("{{botCounterOffer}}" keeps the number)
44
+ * @prop {boolean} [hasCondition] - the condition (as the conditions of the designer):
45
+ * the variables are the values of the situation and the state
46
+ * @prop {boolean} [hasEditableCondition]
47
+ * @prop {any[][]} [editableCondition]
48
+ * @prop {string} [conditionFn]
49
+ */
50
+
51
+ /**
52
+ * What happens in a situation - the designer sets either the instruction for the LLM
53
+ * (the conversation with the LLM continues), or the target (a postback)
54
+ *
55
+ * @typedef {object} ToolSituationDefinition
56
+ * @prop {ToolTexts} [instruction]
57
+ * @prop {string|null} [action] - target interaction
58
+ * @prop {object} [setState] - set before the postback to the target
59
+ * (strings can use the values of the situation)
60
+ * @prop {ToolButtonDefinition[]} [buttons] - offered with the answer of the LLM
61
+ */
62
+
63
+ /**
64
+ * Tool as it comes from the designer (Tools tab, on the root block of the snapshot)
65
+ *
66
+ * @typedef {object} ToolDefinition
67
+ * @prop {string} id
68
+ * @prop {string} description
69
+ * @prop {ToolParameterDefinition[]} [parameters]
70
+ * @prop {{ [id: string]: ToolSituationDefinition }} [situations]
71
+ */
72
+
73
+ /**
74
+ * What a tool (registered with plugins.registerTool()) returns - the situation, the code
75
+ * found out. What follows (an instruction for the LLM, or a target) is set in the designer.
76
+ *
77
+ * @typedef {object} ToolResult
78
+ * @prop {string} situation - id of the situation
79
+ * @prop {object} [data] - values for the texts, the set state and the buttons
80
+ * @prop {string} [asTool] - the situation is defined on another tool
81
+ * (e.g. a refusal, which turned out to be a repeated offer)
82
+ */
83
+
84
+ /**
85
+ * @typedef {object} LLMToolRunnerOptions
86
+ * @prop {object[]} [blocks] - blocks of the snapshot (the tools are on the root block)
87
+ * @prop {Plugins} [plugins] - code of the tools (plugins.registerTool())
88
+ * @prop {Console} [log]
89
+ * @prop {boolean} [allowForbiddenSnippetWords] - for the code conditions of the buttons
90
+ * (as in the bot)
91
+ * @prop {string} [source] - for the logs and the tracking (e.g. "prompt", "mainLoop")
92
+ */
93
+
94
+ /**
95
+ * @param {ToolTexts} texts
96
+ * @param {string} lang
97
+ * @returns {string}
98
+ */
99
+ function textForLang (texts, lang) {
100
+ if (!texts) {
101
+ return '';
102
+ }
103
+ // a text can have alternatives (["Accept", "Take it"]) - the first filled one is used
104
+ const first = (t) => (Array.isArray(t) ? t.find((a) => `${a || ''}`.trim()) || '' : t || '');
105
+ const byLang = Array.isArray(texts)
106
+ ? Object.fromEntries(texts.map((tr) => [tr.l || '', first(tr.t)]))
107
+ : Object.fromEntries(Object.entries(texts).map(([l, t]) => [l, first(t)]));
108
+ if (lang && `${byLang[lang] || ''}`.trim()) {
109
+ return byLang[lang];
110
+ }
111
+ return byLang.en || Object.values(byLang).find((t) => `${t || ''}`.trim()) || '';
112
+ }
113
+
114
+ /**
115
+ * @param {ToolParameterDefinition} param
116
+ * @returns {LLMType}
117
+ */
118
+ function parameterType (param) {
119
+ let type;
120
+ let { description } = param;
121
+
122
+ switch (param.type) {
123
+ case 'number':
124
+ type = LLMType.number();
125
+ break;
126
+ case 'boolean':
127
+ type = LLMType.boolean();
128
+ break;
129
+ case 'enum':
130
+ type = LLMType.enum(param.values.map((v) => v.value));
131
+ description = [
132
+ description,
133
+ ...param.values
134
+ .filter((v) => v.description)
135
+ .map((v) => `- ${v.value}: ${v.description}`)
136
+ ].filter((d) => !!d).join('\n');
137
+ break;
138
+ case 'array':
139
+ // array of objects - the fields are parameters too
140
+ type = LLMType.array(LLMType.object(Object.fromEntries(
141
+ (param.items || []).map((item) => [item.name, parameterType(item)])
142
+ )));
143
+ break;
144
+ default:
145
+ type = LLMType.string();
146
+ }
147
+
148
+ if (description) {
149
+ type.description(description);
150
+ }
151
+ return param.optional ? type.optional() : type;
152
+ }
153
+
154
+ /**
155
+ * @param {ToolDefinition} def
156
+ * @returns {LLMType}
157
+ */
158
+ function parametersType (def) {
159
+ return LLMType.object(Object.fromEntries(
160
+ (def.parameters || []).map((param) => [param.name, parameterType(param)])
161
+ ));
162
+ }
163
+
164
+ // a template, which is just one value - "{{price}}"
165
+ const SINGLE_VALUE = /^\{\{\s*([a-zA-Z0-9_$]+)\s*\}\}$/;
166
+
167
+ // the click of a button of a situation - in the data of the quick reply
168
+ const BUTTON_KEY = '_llmToolButton';
169
+
170
+ /**
171
+ * @param {string} template
172
+ * @param {(template: string) => string} compile
173
+ * @param {object} values
174
+ * @returns {any}
175
+ */
176
+ function compileValue (template, compile, values) {
177
+ const single = template.trim().match(SINGLE_VALUE);
178
+ if (single && Object.prototype.hasOwnProperty.call(values, single[1])) {
179
+ return values[single[1]];
180
+ }
181
+ return compile(template);
182
+ }
183
+
184
+ /**
185
+ * Set state from the designer - texts are filled with the values of the situation now
186
+ * (the values won't be in the state later), special values (increment, entity, ...)
187
+ * are kept. A template with just one value keeps its type ("{{price}}" stays a number).
188
+ *
189
+ * @param {object} setState
190
+ * @param {(template: string) => string} compile
191
+ * @param {object} [values]
192
+ * @returns {object}
193
+ */
194
+ function quickReplySetState (setState, compile, values = {}) {
195
+ return Object.fromEntries(
196
+ Object.entries(setState || {})
197
+ .map(([key, value]) => [
198
+ key,
199
+ typeof value === 'string' ? compileValue(value, compile, values) : value
200
+ ])
201
+ );
202
+ }
203
+
204
+ /**
205
+ * Arguments of a tool for a button - every string can use the values of the situation,
206
+ * a template with just one value keeps its type
207
+ *
208
+ * @param {any} args
209
+ * @param {(template: string) => string} compile
210
+ * @param {object} [values]
211
+ * @returns {any}
212
+ */
213
+ function compileArgs (args, compile, values = {}) {
214
+ if (typeof args === 'string') {
215
+ return compileValue(args, compile, values);
216
+ }
217
+ if (Array.isArray(args)) {
218
+ return args.map((a) => compileArgs(a, compile, values));
219
+ }
220
+ if (args && typeof args === 'object') {
221
+ return Object.fromEntries(Object.entries(args)
222
+ .map(([key, value]) => [key, compileArgs(value, compile, values)]));
223
+ }
224
+ return args;
225
+ }
226
+
227
+ /**
228
+ * Runs the tools placed in the interactions ("LLM Tool" blocks the request passed)
229
+ * within an LLM session, which talks to the user:
230
+ *
231
+ * - the LLM calls a tool by what the user did, the code of the tool
232
+ * (plugins.registerTool()) returns the situation
233
+ * - what follows is in the designer (Tools tab): the instruction for the LLM
234
+ * with the buttons, or the target interaction (a postback)
235
+ * - a button is a prefilled call of a tool - the click runs the same code
236
+ * as when the user writes it (handleButton())
237
+ */
238
+ class LLMToolRunner {
239
+
240
+ /**
241
+ * @param {Request} req
242
+ * @param {Responder} res
243
+ * @param {LLMToolRunnerOptions} [options]
244
+ */
245
+ constructor (req, res, options = {}) {
246
+ const {
247
+ blocks = [], plugins = null, log = console, source = 'llm',
248
+ allowForbiddenSnippetWords = false
249
+ } = options;
250
+
251
+ this._req = req;
252
+ this._res = res;
253
+ this._plugins = plugins;
254
+ this._log = log;
255
+ this._source = source;
256
+ this._allowForbiddenSnippetWords = allowForbiddenSnippetWords;
257
+
258
+ const rootBlock = (blocks || []).find((b) => b && b.isRoot);
259
+
260
+ /** @type {ToolDefinition[]} */
261
+ this._definitions = (rootBlock && rootBlock.tools) || [];
262
+
263
+ /** @type {Map<string, (args: object, session: LLMSession) => Promise<string>>} */
264
+ this._attached = new Map();
265
+
266
+ /** @type {string|null} */
267
+ this.postBackAction = null;
268
+
269
+ /** @type {QuickReply[]} */
270
+ this.quickReplies = [];
271
+
272
+ /** number of tools attached to the session */
273
+ this.count = 0;
274
+ }
275
+
276
+ /**
277
+ * the current state - with the changes of the tools (e.g. a new language
278
+ * is used for the texts right after the tool switched it)
279
+ *
280
+ * @returns {object}
281
+ */
282
+ _stateData () {
283
+ return stateData(this._req, this._res);
284
+ }
285
+
286
+ /**
287
+ * @param {string} template
288
+ * @param {object} [values]
289
+ * @returns {string}
290
+ */
291
+ _compile (template, values = {}) {
292
+ return compileWithState(this._req, this._res, template, {
293
+ ...this._stateData(),
294
+ ...values
295
+ });
296
+ }
297
+
298
+ /**
299
+ * @param {string} action
300
+ * @param {LLMSession} session
301
+ * @returns {string}
302
+ */
303
+ _makePostBack (action, session) {
304
+ if (this.postBackAction) {
305
+ return 'Ignored - the conversation already continues elsewhere.';
306
+ }
307
+ this.postBackAction = action;
308
+ session.stop();
309
+ return 'OK';
310
+ }
311
+
312
+ /**
313
+ * @param {ToolDefinition} def
314
+ * @param {string} situation
315
+ * @param {object} values
316
+ * @returns {string}
317
+ */
318
+ _instructionText (def, situation, values) {
319
+ const { lang } = this._stateData();
320
+ const sit = (def.situations || {})[situation] || {};
321
+ const template = textForLang(sit.instruction, lang);
322
+
323
+ if (!template) {
324
+ this._log.error(`LLM tool "${def.id}" - situation "${situation}" has no instruction in the designer`);
325
+ return `${situation}: ${JSON.stringify(values)}`;
326
+ }
327
+ return this._compile(template, values);
328
+ }
329
+
330
+ /**
331
+ * The condition of the button - over the values of the situation and the state
332
+ *
333
+ * @param {ToolDefinition} def
334
+ * @param {ToolButtonDefinition} button
335
+ * @param {object} values
336
+ * @returns {boolean}
337
+ */
338
+ _buttonShown (def, button, values) {
339
+ if (!button.hasCondition) {
340
+ return true;
341
+ }
342
+ try {
343
+ if (!button.hasEditableCondition) {
344
+ // a code condition - (req, res, values)
345
+ const fn = getCondition(button, {
346
+ configuration: this._req.configuration,
347
+ allowForbiddenSnippetWords: this._allowForbiddenSnippetWords
348
+ }, `LLM tool "${def.id}" button`);
349
+ const shown = fn(this._req, this._res, values);
350
+ if (shown && typeof shown.then === 'function') {
351
+ // the buttons are made synchronously - an async condition can't be used
352
+ throw new Error('the condition must not be async');
353
+ }
354
+ return !!shown;
355
+ }
356
+ const data = { ...this._stateData(), ...values };
357
+ const raw = [
358
+ ConditionOperators['matches regexp'],
359
+ ConditionOperators['not matches regexp'],
360
+ ConditionOperators['is true'],
361
+ ConditionOperators['is false']
362
+ ];
363
+ return (button.editableCondition || []).some((list) => list.every((cond) => compare(
364
+ getValue(cond.variable, data),
365
+ cond.operator,
366
+ raw.includes(cond.operator) ? cond.value : this._compile(cond.value, values)
367
+ )));
368
+ } catch (e) {
369
+ this._log.error(`LLM tool "${def.id}" - invalid condition of a button: ${e.message}`, e);
370
+ return false;
371
+ }
372
+ }
373
+
374
+ /**
375
+ * The buttons of the situation - with the answer of the LLM
376
+ *
377
+ * @param {ToolDefinition} def
378
+ * @param {string} situation
379
+ * @param {object} values
380
+ */
381
+ _addButtons (def, situation, values) {
382
+ const sit = (def.situations || {})[situation] || {};
383
+ const { lang } = this._stateData();
384
+ const compile = (t) => this._compile(t, values);
385
+ const action = this._res.currentAction();
386
+
387
+ (sit.buttons || [])
388
+ .filter((button) => this._buttonShown(def, button, values))
389
+ .forEach((button) => {
390
+ const title = compile(textForLang(button.title, lang)).trim();
391
+
392
+ if (!title) {
393
+ this._log.error(`LLM tool "${def.id}" - a button of "${situation}" has no title in the designer`);
394
+ return;
395
+ }
396
+ this.quickReplies.push({
397
+ action,
398
+ title,
399
+ ...(button.tool && {
400
+ data: {
401
+ [BUTTON_KEY]: {
402
+ tool: button.tool,
403
+ args: compileArgs(button.args || {}, compile, values)
404
+ }
405
+ }
406
+ })
407
+ });
408
+ });
409
+ }
410
+
411
+ /**
412
+ * Runs the code of the tool and what follows the situation
413
+ *
414
+ * @param {ToolDefinition} def
415
+ * @param {Function} fn
416
+ * @param {object} args
417
+ * @param {LLMSession} session
418
+ * @returns {Promise<string>}
419
+ */
420
+ async _runTool (def, fn, args, session) {
421
+ const { _req: req, _res: res } = this;
422
+
423
+ /** @type {ToolResult} */
424
+ const result = await fn(args, req, res, session);
425
+
426
+ if (!result || !result.situation) {
427
+ // no situation - the result goes to the LLM as it is (as in LLMSession)
428
+ if (result === undefined || result === null) {
429
+ return 'OK';
430
+ }
431
+ return typeof result === 'string' ? result : JSON.stringify(result);
432
+ }
433
+
434
+ const { situation, data: values = {} } = result;
435
+ res.trackEvent('tool', this._source, def.id, situation);
436
+
437
+ const sitDef = result.asTool
438
+ ? this._definitions.find((d) => d.id === result.asTool) || def
439
+ : def;
440
+ const sit = (sitDef.situations || {})[situation];
441
+
442
+ if (!sit) {
443
+ this._log.error(`LLM tool "${def.id}" - situation "${situation}" is not in the designer (tools.json)`);
444
+ }
445
+
446
+ if (sit && sit.action) {
447
+ if (this.postBackAction) {
448
+ // another tool has already moved the conversation - its state stays
449
+ return this._makePostBack(sit.action, session);
450
+ }
451
+ // the conversation continues in the target interaction
452
+ const setState = quickReplySetState(
453
+ sit.setState,
454
+ (t) => this._compile(t, values),
455
+ values
456
+ );
457
+ if (Object.keys(setState).length) {
458
+ res.setState(setState);
459
+ }
460
+ return this._makePostBack(sit.action, session);
461
+ }
462
+
463
+ // only the buttons of the last situation are offered (the earlier ones are outdated)
464
+ this.quickReplies = [];
465
+ this._addButtons(sitDef, situation, values);
466
+ return this._instructionText(sitDef, situation, values);
467
+ }
468
+
469
+ /**
470
+ * Adds the tools placed in the interactions, the request passed, to the session
471
+ *
472
+ * @param {LLMSession} session
473
+ * @returns {this}
474
+ */
475
+ attach (session) {
476
+ this._res.llmTools()
477
+ .map((id) => {
478
+ const def = this._definitions.find((d) => d.id === id);
479
+ if (!def) {
480
+ this._log.error(`LLM tool "${id}" is placed in an interaction, but it's not in the Tools tab`);
481
+ }
482
+ return def;
483
+ })
484
+ .filter((def) => !!def)
485
+ .forEach((def) => {
486
+ const fn = this._plugins && this._plugins.getTool(def.id);
487
+
488
+ if (!fn) {
489
+ this._log.error(`LLM tool "${def.id}" is in the designer, but it's not registered in the code`);
490
+ return;
491
+ }
492
+
493
+ const run = (args, s) => this._runTool(def, fn, args, s);
494
+ this._attached.set(def.id, run);
495
+ this.count += 1;
496
+ session.tool({
497
+ name: def.id,
498
+ description: def.description,
499
+ parameters: parametersType(def),
500
+ fn: run
501
+ });
502
+ });
503
+
504
+ return this;
505
+ }
506
+
507
+ /**
508
+ * The click of a button of a situation - calls its tool the same way, as when
509
+ * the LLM calls it (the call is in the conversation for the LLM).
510
+ * Then generate the answer - unless the situation made a postback (postBackAction).
511
+ *
512
+ * Only the tools placed on the way can be called (the data of the click come
513
+ * from the user - the same as writing it).
514
+ *
515
+ * @param {LLMSession} session
516
+ * @returns {Promise<boolean>} - a button was clicked
517
+ */
518
+ async handleButton (session) {
519
+ const button = this._req.actionData()[BUTTON_KEY];
520
+
521
+ if (!button || typeof button.tool !== 'string') {
522
+ return false;
523
+ }
524
+
525
+ const run = this._attached.get(button.tool);
526
+ if (!run) {
527
+ this._log.error(`LLM tool "${button.tool}" of a button is not placed on the way to the interaction`);
528
+ return false;
529
+ }
530
+
531
+ const args = button.args && typeof button.args === 'object' ? button.args : {};
532
+ const id = `button_${button.tool}`;
533
+ let content;
534
+ try {
535
+ content = await run(args, session);
536
+ } catch (e) {
537
+ // the same as a failed call of the LLM - not in the conversation, the LLM answers
538
+ this._log.error(`LLM tool "${button.tool}" of a button: ${e.message}`, e, { args });
539
+ return false;
540
+ }
541
+
542
+ session.push(
543
+ {
544
+ role: 'assistant',
545
+ toolCalls: [{
546
+ id, type: 'function', name: button.tool, args: JSON.stringify(args)
547
+ }]
548
+ },
549
+ { role: 'tool', content, toolCallId: id }
550
+ );
551
+ return true;
552
+ }
553
+
554
+ /**
555
+ * Provider options for the generation - one tool call at a time, when there are tools
556
+ * (the tools change the state - e.g. two offers in one message would both be recorded)
557
+ *
558
+ * @param {LLMSession} session
559
+ * @returns {string|object}
560
+ */
561
+ generateOptions (session) {
562
+ const { preset } = session;
563
+ if (this.count === 0) {
564
+ return preset;
565
+ }
566
+ return {
567
+ ...(typeof preset === 'string' || !preset ? { preset } : preset),
568
+ parallelToolCalls: false
569
+ };
570
+ }
571
+ }
572
+
573
+ LLMToolRunner.BUTTON_KEY = BUTTON_KEY;
574
+ LLMToolRunner.textForLang = textForLang;
575
+ LLMToolRunner.parameterType = parameterType;
576
+ LLMToolRunner.parametersType = parametersType;
577
+ LLMToolRunner.quickReplySetState = quickReplySetState;
578
+ LLMToolRunner.compileArgs = compileArgs;
579
+
580
+ module.exports = LLMToolRunner;
package/src/Plugins.js CHANGED
@@ -46,6 +46,20 @@ const { compileWithState } = require('./utils');
46
46
  * @prop {string[]} [notLastMessageItems]
47
47
  */
48
48
 
49
+ /**
50
+ * Tool, which can be offered to the LLM - its description and parameters are
51
+ * defined in the designer (tools.json)
52
+ *
53
+ * @template {object} [S=object]
54
+ * @template {BaseConfiguration} [C=object]
55
+ * @callback PluginTool
56
+ * @param {object} args - arguments filled by the LLM
57
+ * @param {Request<S,C>} req
58
+ * @param {Responder} res
59
+ * @param {import('./LLMSession')} session
60
+ * @returns {any|Promise<any>}
61
+ */
62
+
49
63
  /**
50
64
  * Custom code plugins for BuildRouter and wingbot.ai
51
65
  *
@@ -57,6 +71,9 @@ class Plugins {
57
71
 
58
72
  constructor () {
59
73
  this._plugins = new Map();
74
+
75
+ /** @type {Map<string,PluginTool<S,C>>} */
76
+ this._tools = new Map();
60
77
  }
61
78
 
62
79
  /**
@@ -214,6 +231,10 @@ class Plugins {
214
231
  name._plugins.forEach((el, key) => {
215
232
  this._plugins.set(key, el);
216
233
  });
234
+ // a plugins object of an older wingbot has no tools
235
+ (name._tools || new Map()).forEach((el, key) => {
236
+ this._tools.set(key, el);
237
+ });
217
238
  }
218
239
 
219
240
  /**
@@ -231,6 +252,38 @@ class Plugins {
231
252
  this._plugins.set(name, { pluginFactory, options });
232
253
  }
233
254
 
255
+ /**
256
+ * Register tool for the LLM
257
+ *
258
+ * @param {string} id - tool id (has to match the id in tools.json)
259
+ * @param {PluginTool<S,C>} tool
260
+ */
261
+ registerTool (id, tool) {
262
+ if (typeof tool !== 'function') {
263
+ throw new Error(`Tool should be a function, ${typeof tool} given (tool: ${id})`);
264
+ }
265
+ this._tools.set(id, tool);
266
+ }
267
+
268
+ /**
269
+ * Get registered tool
270
+ *
271
+ * @param {string} id
272
+ * @returns {PluginTool<S,C>|null}
273
+ */
274
+ getTool (id) {
275
+ return this._tools.get(id) || null;
276
+ }
277
+
278
+ /**
279
+ * Ids of all registered tools
280
+ *
281
+ * @returns {string[]}
282
+ */
283
+ getToolIds () {
284
+ return Array.from(this._tools.keys());
285
+ }
286
+
234
287
  }
235
288
 
236
289
  module.exports = Plugins;