wingbot 3.76.3 → 3.76.4-alpha.10

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wingbot",
3
- "version": "3.76.3",
3
+ "version": "3.76.4-alpha.10",
4
4
  "description": "Enterprise Messaging Bot Conversation Engine",
5
5
  "main": "index.js",
6
6
  "type": "commonjs",
@@ -76,4 +76,4 @@
76
76
  "axios": "^1.6.4",
77
77
  "handlebars": "^4.0.0"
78
78
  }
79
- }
79
+ }
package/src/ChatGpt.js CHANGED
@@ -441,6 +441,7 @@ class ChatGpt {
441
441
 
442
442
  this._log('#GPT request', body);
443
443
 
444
+ const startTime = Date.now();
444
445
  const response = await this._fetch(apiUrl, {
445
446
  method: 'POST',
446
447
  headers: {
@@ -470,7 +471,9 @@ class ChatGpt {
470
471
 
471
472
  const [choice] = responseData.choices;
472
473
 
473
- this._log('#GPT response', { choice, responseData });
474
+ const durationMs = Date.now() - startTime;
475
+
476
+ this._log('#GPT response', { choice, responseData, durationMs });
474
477
 
475
478
  return choice;
476
479
  } catch (e) {
package/src/LLM.js CHANGED
@@ -107,11 +107,13 @@ const LLMSession = require('./LLMSession');
107
107
  * @prop {'low'|'medium'|'high'|string} [verbosity]
108
108
  * @prop {'text'|SimpleJsonSchema} [responseFormat]
109
109
  * @prop {number} [temperature]
110
+ * @prop {number} [maxToolCallRounds]
110
111
  */
111
112
 
112
113
  /**
113
114
  * @typedef {object} LLMLogOptions
114
115
  * @prop {VectorSearchResult} [vectorSearchResult]
116
+ * @prop {ToolFunction[]} [tools]
115
117
  */
116
118
 
117
119
  /**
@@ -158,6 +160,9 @@ const LLMSession = require('./LLMSession');
158
160
  * @prop {LLMMessage[]} prompt
159
161
  * @prop {LLMMessage} result
160
162
  * @prop {VectorSearchResult} [vectorSearchResult]
163
+ * @prop {ToolFunction[]} [tools]
164
+ * @prop {number} [durationMs]
165
+ * @prop {string} [toolChoice]
161
166
  */
162
167
 
163
168
  /**
@@ -423,8 +428,17 @@ class LLM {
423
428
  async generate (session, preset = {}, logOptions = {}) {
424
429
  const opts = this.llmOptions(preset);
425
430
  const prompt = await session.toArray(true);
431
+ const startTime = Date.now();
426
432
  const result = await this._provider.requestChat(prompt, opts, session.tools);
427
- this.logPrompt(prompt, result, logOptions.vectorSearchResult);
433
+ const durationMs = Date.now() - startTime;
434
+ this.logPrompt(
435
+ prompt,
436
+ result,
437
+ logOptions.vectorSearchResult,
438
+ session.tools,
439
+ durationMs,
440
+ opts.toolChoice
441
+ );
428
442
  return result;
429
443
  }
430
444
 
@@ -433,11 +447,19 @@ class LLM {
433
447
  * @param {LLMMessage[]} prompt
434
448
  * @param {LLMMessage} result
435
449
  * @param {VectorSearchResult} [vectorSearchResult]
450
+ * @param {ToolFunction[]} [tools]
451
+ * @param {number} [durationMs]
452
+ * @param {string} [toolChoice]
436
453
  */
437
- logPrompt (prompt, result, vectorSearchResult) {
454
+ logPrompt (prompt, result, vectorSearchResult, tools, durationMs, toolChoice) {
438
455
  this._lastResult = result;
439
456
  this._configuration.logger.logPrompt({
440
- prompt, result, vectorSearchResult
457
+ prompt,
458
+ result,
459
+ vectorSearchResult,
460
+ tools,
461
+ durationMs,
462
+ ...(toolChoice != null && { toolChoice })
441
463
  });
442
464
  }
443
465
 
@@ -260,6 +260,7 @@ class LLMDispatcher {
260
260
  ...routing,
261
261
  actionList: [],
262
262
  actionListString: '',
263
+ structuredOutput: LLMRouter.structuredOutput(),
263
264
  byAction: new Map(),
264
265
  router
265
266
  };
package/src/LLMRouter.js CHANGED
@@ -66,7 +66,8 @@ const LLMType = require('./LLMType');
66
66
  */
67
67
 
68
68
  /**
69
- * @typedef {Pick<LLMRoutingCache, 'actionList'|'actionListString'|'byAction'>} LLMRoutingInput
69
+ * @typedef {Pick<LLMRoutingCache,
70
+ * 'actionList'|'actionListString'|'byAction'|'structuredOutput'>} LLMRoutingInput
70
71
  */
71
72
 
72
73
  /**
@@ -74,6 +75,7 @@ const LLMType = require('./LLMType');
74
75
  * @prop {boolean} [keepUserInInteractionsWithBounceAllowed]
75
76
  * @prop {string} actionListString
76
77
  * @prop {LLMAction[]} actionList
78
+ * @prop {object} structuredOutput - JSON schema for the routing response
77
79
  * @prop {ILLMRouter|null} router
78
80
  * @prop {Map<string, GlobalIntentResolved>} byAction
79
81
  * @prop {GlobalIntentResolved[]} routes
@@ -142,24 +144,31 @@ class LLMRouter {
142
144
  }
143
145
 
144
146
  /**
145
- * @type {object}
147
+ * Sentinel action returned when the user did not change the conversation
148
+ * context (or when the model can't decide). Not present in `byAction`.
149
+ *
150
+ * @type {string}
146
151
  */
147
- static _cachedStructuredOutput = null;
152
+ static NOT_CHANGED = '-not-changed-';
148
153
 
149
154
  /**
155
+ * Builds the JSON schema for the routing response. The `action` field is
156
+ * constrained to an enum of the routable actions plus the `-not-changed-`
157
+ * sentinel, so the model can only return a valid routing target.
158
+ *
159
+ * Called once per router config from `_prepareRoutingInput`; the result is
160
+ * stored on the routing cache, so it is not rebuilt per request.
150
161
  *
162
+ * @param {string[]} [actions] - routable action values (e.g. `/btm/dodavatel`)
151
163
  * @returns {object}
152
164
  */
153
- static structuredOutput () {
154
- if (LLMRouter._cachedStructuredOutput === null) {
155
- LLMRouter._cachedStructuredOutput = LLMType
156
- .object({
157
- action: LLMType.string()
158
- .description('recommended action')
159
- }, 'conversation_routing_information')
160
- .toJSON();
161
- }
162
- return LLMRouter._cachedStructuredOutput;
165
+ static structuredOutput (actions = []) {
166
+ return LLMType
167
+ .object({
168
+ action: LLMType.enum([LLMRouter.NOT_CHANGED, ...actions])
169
+ .description('recommended action')
170
+ }, 'conversation_routing_information')
171
+ .toJSON();
163
172
  }
164
173
 
165
174
  /**
@@ -183,7 +192,7 @@ class LLMRouter {
183
192
 
184
193
  let res;
185
194
  try {
186
- res = await session.generateStructured(LLMRouter.structuredOutput());
195
+ res = await session.generateStructured(routing.structuredOutput);
187
196
  } catch (e) {
188
197
  // Fail closed: any error in routing classification is treated
189
198
  // as "no context change" so the user's current flow continues.
@@ -315,6 +324,7 @@ class LLMRouter {
315
324
  : {}),
316
325
  actionListString,
317
326
  actionList,
327
+ structuredOutput: LLMRouter.structuredOutput(actionList.map((a) => a.action)),
318
328
  byAction,
319
329
  router,
320
330
  routes: resolvedLlm
@@ -367,6 +377,7 @@ class LLMRouter {
367
377
  : {
368
378
  actionListString: '',
369
379
  actionList: [],
380
+ structuredOutput: LLMRouter.structuredOutput(),
370
381
  byAction: new Map(),
371
382
  router: null,
372
383
  routes: []
package/src/LLMSession.js CHANGED
@@ -93,6 +93,7 @@ const stateData = require('./utils/stateData');
93
93
  * @prop {string[]} [enum] - Allowed values for this parameter
94
94
  * @prop {number} [minimum] - Minimum value for numeric parameters
95
95
  * @prop {number} [maximum] - Maximum value for numeric parameters
96
+ * @prop {string} [format] - JSON Schema string format, e.g. 'date-time', 'email', 'uuid'
96
97
  */
97
98
 
98
99
  /** @typedef {{ [key: string]: JsonSchemaProp }} SimpleJsonSchema */
@@ -170,6 +171,10 @@ const stateData = require('./utils/stateData');
170
171
  * @prop {LLMCallPreset} [preset]
171
172
  */
172
173
 
174
+ // max number of consecutive tool-call rounds resolved within a single generate()
175
+ // before we force a tool-less final answer (guards against tool-call loops)
176
+ const MAX_TOOL_CALL_ROUNDS = 5;
177
+
173
178
  /**
174
179
  * @class LLMSession
175
180
  * @implements {PromiseLike<LLMMessage<any>>}
@@ -224,6 +229,9 @@ class LLMSession {
224
229
  this._res = res || llm?.res;
225
230
 
226
231
  this._preset = preset;
232
+
233
+ /** @type {PossiblyAsyncContent|null} */
234
+ this._fallbackMessage = null;
227
235
  }
228
236
 
229
237
  /**
@@ -850,6 +858,38 @@ class LLMSession {
850
858
  return this;
851
859
  }
852
860
 
861
+ /**
862
+ * Sets a message to send to the user when a subsequent `generate()` call
863
+ * fails (e.g. a provider network timeout). Instead of rejecting - which
864
+ * would surface the raw error to the user - the failure is logged and this
865
+ * message is sent in place of the model's reply, so the chain resolves
866
+ * cleanly. If the message resolves to an empty value, the original error
867
+ * is rethrown (same as when no fallback is set).
868
+ *
869
+ * Only affects `generate()`. `generateStructured()` keeps using delegated
870
+ * errors (see {@link onDelegatedError}).
871
+ *
872
+ * @param {PossiblyAsyncContent} content
873
+ * @returns {this}
874
+ */
875
+ setFallbackMessage (content) {
876
+ this._job(() => {
877
+ this._fallbackMessage = content;
878
+ }, true);
879
+ return this;
880
+ }
881
+
882
+ /**
883
+ * @returns {Promise<string>}
884
+ */
885
+ async _resolveFallbackContent () {
886
+ const fallback = this._fallbackMessage;
887
+ const content = typeof fallback === 'function'
888
+ ? fallback(this._resolveData())
889
+ : fallback;
890
+ return Promise.resolve(content);
891
+ }
892
+
853
893
  /**
854
894
  *
855
895
  * @param {LLMCallPreset} [providerOptions]
@@ -857,7 +897,28 @@ class LLMSession {
857
897
  * @returns {this}
858
898
  */
859
899
  generate (providerOptions = this._preset, logOptions = {}) {
860
- this._job(() => this._generate(providerOptions, logOptions));
900
+ this._job(async () => {
901
+ try {
902
+ return await this._generate(providerOptions, logOptions);
903
+ } catch (e) {
904
+ if (this._fallbackMessage === null) {
905
+ throw e;
906
+ }
907
+
908
+ const content = await this._resolveFallbackContent();
909
+ if (!content) {
910
+ // no usable fallback message - propagate the original error
911
+ throw e;
912
+ }
913
+ this._llm.log.error(`LLMSession.generate failed, sending fallback message: ${e.message}`, e);
914
+
915
+ /** @type {LLMMessage} */
916
+ const result = { role: ROLE_ASSISTANT, content };
917
+ this._generatedIndex = this._chat.length;
918
+ this._chat.push(result);
919
+ return result;
920
+ }
921
+ });
861
922
  return this;
862
923
  }
863
924
 
@@ -912,9 +973,14 @@ class LLMSession {
912
973
  * @returns {Promise<LLMMessage<any>>}
913
974
  */
914
975
  async _generate (providerOptions = this._preset, logOptions = {}) {
976
+ const { maxToolCallRounds = MAX_TOOL_CALL_ROUNDS } = this._llm.llmOptions(providerOptions);
915
977
  let result = await this._llm.generate(this, providerOptions, logOptions);
916
978
 
917
- if (result.toolCalls?.length) {
979
+ // the model may chain several rounds of tool calls before it produces
980
+ // a final text answer - keep resolving them until it stops (bounded)
981
+ let rounds = 0;
982
+ while (result.toolCalls?.length && rounds < maxToolCallRounds) {
983
+ rounds += 1;
918
984
  const toolCalls = [];
919
985
  const results = await Promise.all(
920
986
  result.toolCalls.map(async (tc) => {
@@ -936,27 +1002,54 @@ class LLMSession {
936
1002
  );
937
1003
  result = await this._llm.generate(this, providerOptions, logOptions);
938
1004
  } else {
939
- // everything failed
940
- /** @type {LLMCallPreset} */
941
- const overrideChoice = typeof providerOptions === 'string'
942
- ? {
943
- preset: providerOptions,
944
- toolChoice: 'none'
945
- }
946
- : {
947
- ...providerOptions,
948
- toolChoice: 'none'
949
- };
950
- result = await this._llm.generate(this, overrideChoice, logOptions);
1005
+ // everything failed - force a final text answer without tools
1006
+ this._llm.log.error(
1007
+ `LLMSession: all ${result.toolCalls.length} tool call(s) failed in round ${rounds}, `
1008
+ + 'forcing a tool-less final answer',
1009
+ { toolCalls: result.toolCalls }
1010
+ );
1011
+ result = await this._generateWithoutTools(providerOptions, logOptions);
1012
+ break;
951
1013
  }
952
1014
  }
953
1015
 
1016
+ // safety net: if the model is still requesting tools (e.g. it hit the
1017
+ // round limit), force one final tool-less generation so we never return
1018
+ // a tool-call message (content === null) to the send pipeline
1019
+ if (result.toolCalls?.length) {
1020
+ this._llm.log.error(
1021
+ `LLMSession: reached maxToolCallRounds (${maxToolCallRounds}), `
1022
+ + 'dropping pending tool calls and forcing a tool-less final answer',
1023
+ { toolCalls: result.toolCalls }
1024
+ );
1025
+ result = await this._generateWithoutTools(providerOptions, logOptions);
1026
+ }
1027
+
954
1028
  this._generatedIndex = this._chat.length;
955
1029
  this._chat.push(result);
956
1030
 
957
1031
  return result;
958
1032
  }
959
1033
 
1034
+ /**
1035
+ *
1036
+ * @param {LLMCallPreset} providerOptions
1037
+ * @param {LLMLogOptions} logOptions
1038
+ * @returns {Promise<LLMMessage<any>>}
1039
+ */
1040
+ _generateWithoutTools (providerOptions, logOptions) {
1041
+ const overrideChoice = typeof providerOptions === 'string'
1042
+ ? {
1043
+ preset: providerOptions,
1044
+ toolChoice: 'none'
1045
+ }
1046
+ : {
1047
+ ...providerOptions,
1048
+ toolChoice: 'none'
1049
+ };
1050
+ return this._llm.generate(this, overrideChoice, logOptions);
1051
+ }
1052
+
960
1053
  /**
961
1054
  *
962
1055
  * @param {ToolCall} toolCall
@@ -1089,6 +1182,10 @@ class LLMSession {
1089
1182
  * @returns {LLMMessage[]}
1090
1183
  */
1091
1184
  static toMessages (result) {
1185
+ // tool-call / structured messages carry no text content - nothing to send
1186
+ if (typeof result.content !== 'string') {
1187
+ return [];
1188
+ }
1092
1189
  let filtered = result.content
1093
1190
  .replace(/\n\n\n+/g, '\n\n')
1094
1191
  .split(/\n\n+(?!\s*-)/g)
package/src/LLMType.js CHANGED
@@ -25,6 +25,7 @@ class LLMType {
25
25
  this._name = name;
26
26
 
27
27
  this._description = null;
28
+ this._format = null;
28
29
  this._min = null;
29
30
  this._max = null;
30
31
 
@@ -155,6 +156,24 @@ class LLMType {
155
156
  return this;
156
157
  }
157
158
 
159
+ /**
160
+ * Sets the JSON Schema `format` for a string property.
161
+ *
162
+ * Supported values: `'date-time'`, `'time'`, `'date'`, `'duration'`,
163
+ * `'email'`, `'hostname'`, `'ipv4'`, `'ipv6'`, `'uuid'`.
164
+ *
165
+ * @example
166
+ * const createdAt = LLMType.string().format('date-time').description('Creation timestamp');
167
+ *
168
+ * @param {'date-time'|'time'|'date'|'duration'|'email'|
169
+ * 'hostname'|'ipv4'|'ipv6'|'uuid'|string} value
170
+ * @returns {this}
171
+ */
172
+ format (value) {
173
+ this._format = value;
174
+ return this;
175
+ }
176
+
158
177
  /**
159
178
  * Sets minimum boundary and maps it to a type-specific JSON Schema keyword.
160
179
  *
@@ -282,7 +301,8 @@ class LLMType {
282
301
  _baseSchema () {
283
302
  return /** @type {object} */ ({
284
303
  ...(this._description !== null && { description: this._description }),
285
- ...(this._name !== null && { name: this._name })
304
+ ...(this._name !== null && { name: this._name }),
305
+ ...(this._format !== null && { format: this._format })
286
306
  });
287
307
  }
288
308
 
@@ -123,10 +123,18 @@ type VectorSearchResult {
123
123
  resultDocuments: [VectorSearchDocument!]!
124
124
  }
125
125
 
126
+ type ToolFunction {
127
+ name: String!
128
+ description: String
129
+ parameters: Any
130
+ }
131
+
126
132
  type PromptInfo {
127
133
  prompt: [LLMMessage!]!
128
134
  result: LLMMessage!
129
135
  vectorSearchResult: VectorSearchResult
136
+ tools: [ToolFunction!]
137
+ durationMs: Float
130
138
  }
131
139
 
132
140
  type UserInteraction {