wingbot 3.77.0-alpha.0 → 3.77.0-alpha.2

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,11 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(npx mocha:*)",
5
+ "Bash(npx tsc *)",
6
+ "Bash(npm test *)",
7
+ "Bash(node -e \"const p = require\\('./bot/plugins/BTMLLM'\\); console.log\\('factory type:', typeof p\\); console.log\\('factory name:', p.name\\);\")",
8
+ "Bash(grep -n \"prompt\\\\`\\\\|tagged\\\\|render\\\\|compile\\\\|hbs\" /Users/ondrejveres/Wingbot/wingbot-llm/src/prompt.js)"
9
+ ]
10
+ }
11
+ }
package/index.js CHANGED
@@ -70,6 +70,7 @@ const LLM = require('./src/LLM');
70
70
  const LLMSession = require('./src/LLMSession');
71
71
  const LLMTool = require('./src/LLMTool');
72
72
  const LLMType = require('./src/LLMType');
73
+ const LLMToolRunner = require('./src/LLMToolRunner');
73
74
  const LLMRouter = require('./src/LLMRouter');
74
75
  const prompt = require('./src/prompt');
75
76
 
@@ -167,6 +168,7 @@ module.exports = {
167
168
  LLMSession,
168
169
  LLMTool,
169
170
  LLMType,
171
+ LLMToolRunner,
170
172
 
171
173
  LLMRouter,
172
174
  prompt
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wingbot",
3
- "version": "3.77.0-alpha.0",
3
+ "version": "3.77.0-alpha.2",
4
4
  "description": "Enterprise Messaging Bot Conversation Engine",
5
5
  "main": "index.js",
6
6
  "type": "commonjs",
package/src/ChatGpt.js CHANGED
@@ -450,8 +450,10 @@ class ChatGpt {
450
450
  ...(requestTokens ? {
451
451
  max_completion_tokens: requestTokens
452
452
  } : {}),
453
+ // Chat Completions takes "reasoning_effort" ("reasoning: { effort }" is the
454
+ // Responses API - here 400 Unknown parameter)
453
455
  ...(reasoningEffort ? {
454
- reasoning: { effort: reasoningEffort }
456
+ reasoning_effort: reasoningEffort
455
457
  } : {}),
456
458
  ...(typeof parallelToolCalls === 'boolean' ? {
457
459
  parallel_tool_calls: parallelToolCalls
package/src/LLMSession.js CHANGED
@@ -101,6 +101,7 @@ const stateData = require('./utils/stateData');
101
101
  /**
102
102
  * @callback ToolFnCallback
103
103
  * @param {{[key: string]: any}} input
104
+ * @param {LLMSession} [session] - call `session.stop()` to end the generation after the tool
104
105
  * @returns {string|Promise<string>}
105
106
  */
106
107
 
@@ -256,6 +257,8 @@ class LLMSession {
256
257
 
257
258
  /** @type {PossiblyAsyncContent|null} */
258
259
  this._fallbackMessage = null;
260
+
261
+ this._stopped = false;
259
262
  }
260
263
 
261
264
  /**
@@ -374,6 +377,15 @@ class LLMSession {
374
377
  .then(onFulfilled, onRejected);
375
378
  }
376
379
 
380
+ /**
381
+ * the preset (or the provider options), the session generates with by default
382
+ *
383
+ * @returns {string|object}
384
+ */
385
+ get preset () {
386
+ return this._preset;
387
+ }
388
+
377
389
  /**
378
390
  * @returns {ToolFunction[]}
379
391
  */
@@ -903,6 +915,28 @@ class LLMSession {
903
915
  return this;
904
916
  }
905
917
 
918
+ /**
919
+ * Ends the generation right after the current round of tool calls - the model
920
+ * is not asked again and nothing generated in this session is sent.
921
+ *
922
+ * Meant to be called from within a tool, which takes over the conversation
923
+ * (e.g. makes a postback to an interaction). It's applied immediately
924
+ * (not queued), because the tool already runs inside a queued job.
925
+ *
926
+ * @returns {this}
927
+ */
928
+ stop () {
929
+ this._stopped = true;
930
+ return this;
931
+ }
932
+
933
+ /**
934
+ * @returns {boolean}
935
+ */
936
+ isStoppedSync () {
937
+ return this._stopped;
938
+ }
939
+
906
940
  /**
907
941
  * @returns {Promise<string>}
908
942
  */
@@ -1024,6 +1058,16 @@ class LLMSession {
1024
1058
  },
1025
1059
  ...results.filter((r) => !!r)
1026
1060
  );
1061
+ }
1062
+
1063
+ if (this._stopped) {
1064
+ // a tool took over the conversation - drop any text generated
1065
+ // alongside the tool calls and don't ask the model again
1066
+ result = { role: ROLE_ASSISTANT, content: null };
1067
+ break;
1068
+ }
1069
+
1070
+ if (toolCalls.length) {
1027
1071
  result = await this._llm.generate(this, providerOptions, logOptions);
1028
1072
  } else {
1029
1073
  // everything failed - force a final text answer without tools
@@ -1093,7 +1137,7 @@ class LLMSession {
1093
1137
  let args;
1094
1138
  try {
1095
1139
  args = JSON.parse(toolCall.args);
1096
- const fnResult = await Promise.resolve(tool.fn(args));
1140
+ const fnResult = await Promise.resolve(tool.fn(args, this));
1097
1141
 
1098
1142
  /**
1099
1143
  * {
@@ -1193,6 +1237,9 @@ class LLMSession {
1193
1237
  */
1194
1238
  send (quickReplies = undefined) {
1195
1239
  this._job(() => {
1240
+ if (this._stopped) {
1241
+ return;
1242
+ }
1196
1243
  const messages = this.messagesToSendSync();
1197
1244
  this._onSend(messages, quickReplies);
1198
1245
  }, true);
@@ -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;
package/src/Responder.js CHANGED
@@ -253,6 +253,9 @@ class Responder {
253
253
 
254
254
  /** @typedef {PromptSource|Promise<string>} PromptContextItem */
255
255
 
256
+ /** @type {Set<string>} - ids of the tools placed in the interactions, the request passed */
257
+ this._llmTools = new Set();
258
+
256
259
  /** @type {Map<string,PromptContextItem[]>} */
257
260
  this._llmContext = new Map([
258
261
  [this.LLM_CTX_DEFAULT, []],
@@ -301,6 +304,28 @@ class Responder {
301
304
  return this;
302
305
  }
303
306
 
307
+ /**
308
+ * Makes a tool (registered with plugins.registerTool()) available to the LLM,
309
+ * which talks to the user in this request (prompt messages, plugins using LLMToolRunner)
310
+ * - like the instructions, the tools are collected on the way to the interaction
311
+ *
312
+ * @param {string} toolId
313
+ * @returns {this}
314
+ */
315
+ llmAddTool (toolId) {
316
+ this._llmTools.add(toolId);
317
+ return this;
318
+ }
319
+
320
+ /**
321
+ * Ids of the tools added in this request (in the order they were added)
322
+ *
323
+ * @returns {string[]}
324
+ */
325
+ llmTools () {
326
+ return [...this._llmTools];
327
+ }
328
+
304
329
  /**
305
330
  *
306
331
  * @param {LLMFilter|LLMFilterFn} filter
package/src/log/types.js CHANGED
@@ -59,7 +59,70 @@
59
59
  * @returns {void}
60
60
  */
61
61
 
62
- /** @typedef {<T>(name: string, fn: () => T|Promise<T>, attrs?: object) => Promise<T>} SpanFn */
62
+ /**
63
+ * The role of a span (OpenTelemetry SpanKind). App Insights picks the table
64
+ * by it: `server` and `consumer` are AppRequests (the start of an operation:
65
+ * an HTTP request, a worker run taken from a timer or a queue), the others are
66
+ * AppDependencies (`client`: a call to another system, `internal`: a step).
67
+ *
68
+ * @typedef {'internal'|'server'|'client'|'producer'|'consumer'} SpanKindName
69
+ */
70
+
71
+ /**
72
+ * @typedef {object} SpanOptions
73
+ * @prop {SpanKindName} [kind] default `internal`
74
+ */
75
+
76
+ /**
77
+ * @typedef {<T>(
78
+ * name: string, fn: () => T|Promise<T>, attrs?: object, options?: SpanOptions
79
+ * ) => Promise<T>} SpanFn
80
+ */
81
+
82
+ /**
83
+ * A call to another system (SAP, Microsoft Graph, KSRM ...), see `log.httpCall()`.
84
+ *
85
+ * @typedef {object} HttpCall
86
+ * @prop {string} [method] `GET`, `POST` ...
87
+ * @prop {string|null} [url] the full URL (query secrets are redacted)
88
+ * @prop {Record<string, any>} [params] the small values that tell what the
89
+ * call is about (query parameters, ids, type, language)
90
+ * @prop {Record<string, any>|null} [headers] the request headers, when
91
+ * `send()` is not axios (axios: the headers it sent are read from the
92
+ * response); secret headers are redacted
93
+ * @prop {any} [body] the request body (redacted, cut at 8000 characters)
94
+ * @prop {Record<string, any>} [data] more fields of the row (ECS
95
+ * `eventAction` ...)
96
+ */
97
+
98
+ /**
99
+ * What `toResponse` reads; what it leaves out is read like an axios response
100
+ * (`status`, `data`, `headers`, `config.headers`).
101
+ *
102
+ * @typedef {object} HttpCallResponse
103
+ * @prop {number|null} [status] the HTTP status
104
+ * @prop {any} [body] the response body
105
+ * @prop {Record<string, any>|null} [headers] the response headers
106
+ * @prop {Record<string, any>|null} [requestHeaders] the headers that were sent
107
+ */
108
+
109
+ /**
110
+ * @template R
111
+ * @typedef {object} HttpCallOptions
112
+ * @prop {(res: R) => HttpCallResponse} [toResponse] reads the status, the body
113
+ * and the headers of what `send()` returns (and of `error.response`), or
114
+ * changes the body for the log
115
+ */
116
+
117
+ /**
118
+ * `httpCall(name, send, call, options)`, like `span(name, fn, data, options)`.
119
+ * The name is the span name and the message of the row, a constant per
120
+ * operation: `SAP getListVA`, `Graph sendMail` (not the URL, it has ids).
121
+ *
122
+ * @typedef {<R>(
123
+ * name: string, send: () => Promise<R>, call?: HttpCall, options?: HttpCallOptions<R>
124
+ * ) => Promise<R>} HttpCallFn
125
+ */
63
126
 
64
127
  /**
65
128
  * What a library needs from a logger: the five levels. `console`, the shared
@@ -18,11 +18,13 @@ const setState = require('./setState');
18
18
  const expectedInput = require('./expectedInput');
19
19
  const skip = require('./skip');
20
20
  const contextMessage = require('./contextMessage');
21
+ const llmTool = require('./llmTool');
21
22
 
22
23
  module.exports = {
23
24
  path,
24
25
  message,
25
26
  contextMessage,
27
+ llmTool,
26
28
  include,
27
29
  postback,
28
30
  expected,
@@ -0,0 +1,41 @@
1
+ /*
2
+ * @author Ondrej Veres
3
+ */
4
+ 'use strict';
5
+
6
+ const Router = require('../Router');
7
+ const getCondition = require('../utils/getCondition');
8
+
9
+ /** @typedef {import('../BuildRouter').BotContext<any>} BotContext */
10
+ /** @typedef {import('../Router').Resolver<any>} Resolver */
11
+ /** @typedef {import('../utils/getCondition').ConditionDefinition} ConditionDefinition */
12
+
13
+ /**
14
+ * @typedef {object} LlmToolParamsBase
15
+ * @property {string} toolId - id of a tool registered with plugins.registerTool()
16
+ */
17
+
18
+ /** @typedef {LlmToolParamsBase & ConditionDefinition} LlmToolParams */
19
+
20
+ /**
21
+ * Makes a tool available to the LLM, which talks to the user - like the instructions
22
+ * (contextMessage), it's collected on the way to the interaction
23
+ * (in the root - everywhere, in a dialog - only there)
24
+ *
25
+ * @param {LlmToolParams} params
26
+ * @param {BotContext} context
27
+ * @returns {Resolver}
28
+ */
29
+ function llmTool (params, context) {
30
+ const { toolId } = params;
31
+ const condition = getCondition(params, context, `LLM tool "${toolId}"`);
32
+
33
+ return async (req, res) => {
34
+ if (toolId && await condition(req, res)) {
35
+ res.llmAddTool(toolId);
36
+ }
37
+ return Router.CONTINUE;
38
+ };
39
+ }
40
+
41
+ module.exports = llmTool;
@@ -22,6 +22,7 @@ const {
22
22
  const { vars, VAR_TYPES } = require('../utils/stateVariables');
23
23
  const LLM = require('../LLM');
24
24
  const canaryLog = require('../utils/canaryLog');
25
+ const LLMToolRunner = require('../LLMToolRunner');
25
26
 
26
27
  /** @typedef {import('../Responder').VoiceControl} VoiceControl */
27
28
  /** @typedef {import('../BuildRouter').LinksMap} LinksMap */
@@ -317,9 +318,10 @@ function selectTranslation (resolverId, params, texts, data) {
317
318
  * @param {"message" | "prompt"} [params.type]
318
319
  * @param {string} [params.llmContextType]
319
320
  * @param {BotContext} context
321
+ * @param {import('../Plugins')} [plugins] - code of the LLM tools placed in the interactions
320
322
  * @returns {Resolver}
321
323
  */
322
- function message (params, context = {}) {
324
+ function message (params, context = {}, plugins = null) {
323
325
  const {
324
326
  // @ts-ignore
325
327
  isLastIndex, isLastMessage, linksMap, configuration, resolverId
@@ -459,10 +461,36 @@ function message (params, context = {}) {
459
461
  let discard;
460
462
  let llmError;
461
463
 
464
+ // the tools placed on the way to this interaction ("LLM Tool" blocks)
465
+ const tools = new LLMToolRunner(req, res, {
466
+ // @ts-ignore
467
+ blocks: context.blocks,
468
+ plugins,
469
+ log: context.log,
470
+ allowForbiddenSnippetWords: context.allowForbiddenSnippetWords,
471
+ source: 'prompt'
472
+ });
473
+
462
474
  try {
463
475
  session = res.llmSessionWithHistory(params.llmContextType);
464
- await session.systemPrompt(text)
465
- .generate();
476
+ tools.attach(session);
477
+ // a button of a tool - its tool runs the same way, as when the LLM calls it
478
+ await tools.handleButton(session);
479
+ if (!tools.postBackAction && !session.isStoppedSync()) {
480
+ await session.systemPrompt(text)
481
+ .generate(tools.generateOptions(session));
482
+ }
483
+
484
+ if (tools.postBackAction) {
485
+ // a situation with a target - the conversation continues in its interaction
486
+ postBack(tools.postBackAction);
487
+ return Router.END;
488
+ }
489
+
490
+ if (session.isStoppedSync()) {
491
+ // a tool took over the conversation (session.stop()) - nothing more is sent
492
+ return Router.END;
493
+ }
466
494
 
467
495
  evaluation = await res.llmEvaluate(session, params.llmContextType);
468
496
  ({ discard } = evaluation);
@@ -503,6 +531,11 @@ function message (params, context = {}) {
503
531
 
504
532
  res.setFlag(LLM.GPT_FLAG);
505
533
 
534
+ if (tools.quickReplies.length) {
535
+ // quick replies of the tools go with the answer of the LLM
536
+ sendReplies = [...(sendReplies || []), ...tools.quickReplies];
537
+ }
538
+
506
539
  const lastMessageI = messages.length - 1;
507
540
  messages.forEach((m, i) => {
508
541
  if (lastMessageI === i) {