goblin-agents 1.0.5 → 2.1.0

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/config.js CHANGED
@@ -7,28 +7,4 @@ module.exports = [
7
7
  message: 'Agents version',
8
8
  default: 2,
9
9
  },
10
- {
11
- type: 'input',
12
- name: 'defaultProfile',
13
- message: 'Agents default profile',
14
- default: null,
15
- },
16
- {
17
- type: 'input',
18
- name: 'defaultSettings',
19
- message: 'Agents default setting',
20
- default: null,
21
- },
22
- {
23
- type: 'input',
24
- name: 'profiles',
25
- message: 'Agents profiles for overriding settings',
26
- default: {},
27
- },
28
- {
29
- type: 'input',
30
- name: 'settings',
31
- message: 'Settings by name',
32
- default: {},
33
- },
34
10
  ];
@@ -13,7 +13,7 @@ const {
13
13
  } = require('xcraft-core-stones');
14
14
  const {getMutex} = require('xcraft-core-utils/lib/locks.js');
15
15
  const LLMProvider = require('./providers.js');
16
- const {OCR_SYSTEM_PROMPT, getPDFImages} = require('./utils.js');
16
+ const {OCR_SYSTEM_PROMPT, getPDFImages, splitThinkTags} = require('./utils.js');
17
17
 
18
18
  class MessageShape {
19
19
  role = enumeration('assistant', 'user', 'tool');
@@ -98,6 +98,37 @@ function vectorToSqlLiteral(vec) {
98
98
  return hex;
99
99
  }
100
100
 
101
+ /**
102
+ * Convertit un vecteur float32 normalisé [-1,1]
103
+ * en int8 [-128,127] et retourne une chaîne hex
104
+ * pour stockage en SQL.
105
+ *
106
+ * @param {Array|Float32Array} vec - vecteur normalisé
107
+ * @returns {string} - représentation hex
108
+ */
109
+ function vectorToSqlLiteralInt8(vec) {
110
+ const dimensions = vec.length;
111
+ const qvec = new Int8Array(dimensions);
112
+
113
+ // Quantization uniforme
114
+ const step = 2.0 / 255.0;
115
+ for (let i = 0; i < dimensions; i++) {
116
+ qvec[i] = Math.round((vec[i] + 1.0) / step - 128);
117
+ }
118
+
119
+ // Vue mémoire en octets non signés
120
+ const view = new Uint8Array(qvec.buffer, qvec.byteOffset, qvec.byteLength);
121
+
122
+ // Conversion en hex
123
+ let hex = '';
124
+ for (let i = 0; i < view.length; i++) {
125
+ const byte = view[i];
126
+ hex += byte < 16 ? '0' + byte.toString(16) : byte.toString(16);
127
+ }
128
+
129
+ return hex;
130
+ }
131
+
101
132
  class AiAgentShape {
102
133
  id = id('aiAgent');
103
134
  version = number;
@@ -114,6 +145,8 @@ class AiAgentShape {
114
145
  tools = option(array(object));
115
146
  toolServiceId = option(string);
116
147
  usability = enumeration('disabled', 'experimental', 'stable', 'deprecated');
148
+ reasoning = option(boolean);
149
+ vectorQuality = option(enumeration('f32', 'int8'));
117
150
  meta = MetaShape;
118
151
  }
119
152
 
@@ -123,15 +156,17 @@ class AiAgentState extends Elf.Sculpt(AiAgentShape) {}
123
156
  const defaultValues = {
124
157
  name: 'agent',
125
158
  role: 'assistant',
126
- prompt: '',
127
- provider: 'ollama',
128
- model: 'mistral-small',
159
+ provider: 'open-ai',
160
+ model: '',
129
161
  options: {},
130
162
  host: 'http://127.0.0.1:11434',
131
163
  headers: {},
132
164
  format: null,
165
+ toolServiceId: null,
133
166
  tools: null,
167
+ reasoning: false,
134
168
  usability: 'disabled',
169
+ vectorQuality: 'f32',
135
170
  };
136
171
 
137
172
  class AiAgentLogic extends Elf.Archetype {
@@ -149,8 +184,10 @@ class AiAgentLogic extends Elf.Archetype {
149
184
  host: defaultValues.host,
150
185
  headers: defaultValues.headers,
151
186
  format: defaultValues.format,
187
+ toolServiceId: defaultValues.toolServiceId,
152
188
  tools: defaultValues.tools,
153
189
  usability: defaultValues.usability,
190
+ vectorQuality: 'f32',
154
191
  meta: {
155
192
  status: 'published',
156
193
  },
@@ -240,6 +277,11 @@ class AiAgentLogic extends Elf.Archetype {
240
277
  state._state.set(path, newValue);
241
278
  }
242
279
 
280
+ publish() {
281
+ const {state} = this;
282
+ state.meta.status = 'published';
283
+ }
284
+
243
285
  trash() {
244
286
  const {state} = this;
245
287
  state.meta.status = 'trashed';
@@ -300,7 +342,7 @@ class AiAgent extends Elf {
300
342
  }
301
343
 
302
344
  const Instance = LLMProvider.get(this.state.provider);
303
- this._provider = new Instance(this.state.host, this.state.headers);
345
+ this._provider = new Instance(this.state.host, this.state.headers.toJS());
304
346
  this._conf = {provider, host, headersStr};
305
347
  }
306
348
 
@@ -332,14 +374,20 @@ class AiAgent extends Elf {
332
374
  if (this.state.options) {
333
375
  options = this.state.options.toJS();
334
376
  }
335
- return await this._provider.gen({
377
+ const res = await this._provider.gen({
336
378
  model: this.state.model,
379
+ reasoning: this.state.reasoning,
337
380
  prompt,
338
381
  system: this.state.prompt,
339
382
  format: this.state.format?.toJS(),
340
383
  options,
341
384
  stream: false,
342
385
  });
386
+ const {thinks, cleanText} = splitThinkTags(res);
387
+ for (const think of thinks) {
388
+ this.log.dbg(`🧐 ${think}`);
389
+ }
390
+ return cleanText;
343
391
  }
344
392
 
345
393
  async reset(contextId, save = false) {
@@ -380,6 +428,7 @@ class AiAgent extends Elf {
380
428
  ];
381
429
  const result = await this._provider.chat({
382
430
  model: this.state.model,
431
+ reasoning: this.state.reasoning,
383
432
  messages,
384
433
  });
385
434
  descriptions.push(result.message.content);
@@ -396,7 +445,11 @@ class AiAgent extends Elf {
396
445
  model: this.state.model,
397
446
  input: rawText,
398
447
  });
399
- return vectorToSqlLiteral(vectors);
448
+ let vectorToSql = vectorToSqlLiteral;
449
+ if (this.state.vectorQuality === 'int8') {
450
+ vectorToSql = vectorToSqlLiteralInt8;
451
+ }
452
+ return vectorToSql(vectors);
400
453
  }
401
454
 
402
455
  async embedInBatch(rawTexts = []) {
@@ -412,22 +465,24 @@ class AiAgent extends Elf {
412
465
  model: this.state.model,
413
466
  input: rawTexts,
414
467
  });
415
- return res.map((v) => vectorToSqlLiteral(v));
468
+ let vectorToSql = vectorToSqlLiteral;
469
+ if (this.state.vectorQuality === 'int8') {
470
+ vectorToSql = vectorToSqlLiteralInt8;
471
+ }
472
+ return res.map((v) => vectorToSql(v));
416
473
  }
417
474
 
418
475
  async set(contextId, messages) {
419
476
  this.logic.set(contextId, messages);
420
477
  }
421
478
 
422
- async chat(contextId, question, userDesktopId, sessionId = null) {
479
+ async chat(contextId, question, userDesktopId) {
423
480
  await this._loadProvider();
424
481
  const contextMessages = this.state.messages[contextId] || [];
425
482
  const messages = [
426
483
  {role: 'system', content: this.state.prompt},
427
484
  ...Array.from(contextMessages).map((m) => m._state.toJS()), //history
428
485
  ];
429
- //add new question
430
- messages.push({role: 'user', content: question});
431
486
 
432
487
  let options;
433
488
  if (this.state.options) {
@@ -435,67 +490,88 @@ class AiAgent extends Elf {
435
490
  options = pState?.options;
436
491
  }
437
492
 
438
- if (!sessionId) {
493
+ if (question) {
439
494
  this.logic.chat(contextId, 'user', question);
440
- sessionId = Elf.uuid();
441
- this.log.dbg(`🐈 Chat session started : ${sessionId}`);
495
+ //add new question
496
+ messages.push({role: 'user', content: question});
442
497
  }
443
498
 
444
- const result = await this._provider.chat({
499
+ this.log.dbg(`🐈 Chat session started : ${contextId}`);
500
+
501
+ const chatCompletionRequest = {
445
502
  model: this.state.model,
446
503
  messages,
447
504
  options,
448
505
  format: this.state.format?.toJS(),
449
506
  tools: this.state.tools?.toJS(),
450
- });
507
+ reasoning: this.state.reasoning,
508
+ };
509
+
510
+ const result = await this._provider.chat(chatCompletionRequest);
451
511
 
452
512
  if (this.state.toolServiceId && this.state.tools) {
453
513
  const used = await this.callTools(
454
514
  contextId,
455
515
  result.message,
456
- userDesktopId,
457
- sessionId
516
+ userDesktopId
458
517
  );
459
518
  if (used) {
460
- return await this.chat(contextId, question, userDesktopId, sessionId);
519
+ //leave for a second turn
520
+ this.log.dbg(`🐈 Chat session ended : ${contextId}`);
521
+ return true;
461
522
  }
462
523
  }
463
524
 
464
525
  //keep agent response
465
- this.logic.chat(contextId, 'assistant', result.message.content);
526
+ const {thinks, cleanText} = splitThinkTags(result.message.content);
527
+ this.logic.chat(contextId, 'assistant', cleanText);
528
+ for (const think of thinks) {
529
+ this.log.dbg(`🧐 ${think}`);
530
+ }
531
+ this.log.dbg(`🐈 Chat session ended : ${contextId}`);
466
532
 
467
- return result.message.content;
533
+ return cleanText;
468
534
  }
469
535
 
470
- async callTools(contextId, message, userDesktopId, sessionId) {
471
- const {tool_calls} = message;
536
+ async callTools(contextId, message, userDesktopId) {
537
+ const {tool_calls, content} = message;
472
538
  if (!tool_calls || !userDesktopId) {
473
539
  return false;
474
540
  }
475
- this.logic.callTools(contextId, tool_calls);
541
+ this.logic.callTools(contextId, content, tool_calls);
476
542
  for (const tool of tool_calls) {
477
543
  const {
478
544
  id,
479
545
  function: {name},
480
546
  } = tool;
547
+
481
548
  let result = '';
549
+ let toolArguments = {};
550
+ if (
551
+ tool.function.arguments &&
552
+ typeof tool.function.arguments === 'string'
553
+ ) {
554
+ toolArguments = JSON.parse(tool.function.arguments);
555
+ } else if (
556
+ tool.function.arguments &&
557
+ typeof tool.function.arguments === 'object'
558
+ ) {
559
+ toolArguments = tool.function.arguments;
560
+ }
482
561
  try {
483
562
  const payload = {
484
563
  id: this.state.toolServiceId,
485
564
  desktopId: userDesktopId,
486
- ...JSON.parse(tool.function.arguments),
565
+ contextId,
566
+ ...toolArguments,
487
567
  };
488
568
  const toolNamespace = this.state.toolServiceId?.split('@', 1)[0];
489
569
  const toolCmd = `${toolNamespace}.${name}`;
490
- this.log.dbg(
491
- `🔧 Tool call: ${toolCmd}(${JSON.stringify(
492
- payload
493
- )}) sid: ${sessionId}`
494
- );
570
+ this.log.dbg(`🔧 Tool call: ${toolCmd}(${JSON.stringify(payload)})`);
495
571
  result = await this.quest.cmd(toolCmd, payload);
496
572
  await this.addToolMessage(contextId, id, name, result);
497
573
  } catch (err) {
498
- this.log.dbg(`🔧 Tool error : ${err} sid: ${sessionId}`);
574
+ this.log.dbg(`🔧 Tool error : ${err}`);
499
575
  }
500
576
  }
501
577
  return true;
@@ -527,6 +603,7 @@ class AiAgent extends Elf {
527
603
  const response = await this._provider.chat({
528
604
  model: this.state.model,
529
605
  messages,
606
+ reasoning: this.state.reasoning,
530
607
  options,
531
608
  stream: true,
532
609
  });
@@ -559,6 +636,7 @@ class AiAgent extends Elf {
559
636
  model: this.state.model,
560
637
  prompt,
561
638
  system: resumePrompt,
639
+ reasoning: this.state.reasoning,
562
640
  options,
563
641
  stream: false,
564
642
  });
@@ -584,6 +662,7 @@ class AiAgent extends Elf {
584
662
  model: this.state.model,
585
663
  messages,
586
664
  options,
665
+ reasoning: this.state.reasoning,
587
666
  format: {
588
667
  type: 'object',
589
668
  properties: {
@@ -683,6 +762,19 @@ class AiAgent extends Elf {
683
762
  ];
684
763
  }
685
764
 
765
+ async getToolChatContextHistory(contextId) {
766
+ const contextMessages = this.state.messages[contextId] || [];
767
+ return [
768
+ ...Array.from(contextMessages)
769
+ .map((m) => m._state.toJS())
770
+ .filter((m) => {
771
+ if (m.role === 'tool' && !m.tool_calls) {
772
+ return m;
773
+ }
774
+ }),
775
+ ];
776
+ }
777
+
686
778
  async getBaseSettings() {
687
779
  const {provider, host, headers, model} = this.state.toJS();
688
780
  return {provider, host, headers, model};
@@ -693,6 +785,11 @@ class AiAgent extends Elf {
693
785
  await this.persist();
694
786
  }
695
787
 
788
+ async publish() {
789
+ this.logic.publish();
790
+ await this.persist();
791
+ }
792
+
696
793
  delete() {}
697
794
  }
698
795
 
@@ -49,7 +49,15 @@ class OllamaProvider extends LLMProvider {
49
49
  }
50
50
 
51
51
  async chat(chatRequest) {
52
- const {model, messages, options, format, tools, stream} = chatRequest;
52
+ const {
53
+ model,
54
+ messages,
55
+ options,
56
+ format,
57
+ tools,
58
+ stream,
59
+ reasoning,
60
+ } = chatRequest;
53
61
  const useFormat = !!format;
54
62
  const chat = await this.#ollama.chat({
55
63
  model,
@@ -58,6 +66,7 @@ class OllamaProvider extends LLMProvider {
58
66
  format,
59
67
  tools,
60
68
  stream,
69
+ think: reasoning,
61
70
  });
62
71
  if (useFormat) {
63
72
  chat.message.content = JSON.parse(chat.message.content);
@@ -68,7 +77,15 @@ class OllamaProvider extends LLMProvider {
68
77
  }
69
78
 
70
79
  async gen(genRequest) {
71
- const {model, system, prompt, format, options, stream} = genRequest;
80
+ const {
81
+ model,
82
+ system,
83
+ prompt,
84
+ format,
85
+ options,
86
+ stream,
87
+ reasoning,
88
+ } = genRequest;
72
89
  const useFormat = !!format;
73
90
  const gen = await this.#ollama.generate({
74
91
  model,
@@ -77,6 +94,7 @@ class OllamaProvider extends LLMProvider {
77
94
  format,
78
95
  options,
79
96
  stream,
97
+ think: reasoning,
80
98
  });
81
99
  if (useFormat) {
82
100
  return JSON.parse(gen.response);
@@ -120,6 +138,7 @@ class OpenAIProvider extends LLMProvider {
120
138
  }
121
139
 
122
140
  async chat(chatRequest) {
141
+ delete chatRequest.reasoning; //not yet supported
123
142
  if (chatRequest.options) {
124
143
  chatRequest = {...chatRequest, ...chatRequest.options};
125
144
  delete chatRequest.options;
@@ -145,6 +164,7 @@ class OpenAIProvider extends LLMProvider {
145
164
  },
146
165
  },
147
166
  };
167
+
148
168
  delete chatRequest.format;
149
169
  }
150
170
  const query = `${this.#baseUrl}/chat/completions`;
package/lib/llm/utils.js CHANGED
@@ -90,7 +90,7 @@ const splitLongText = (text, maxLength, baseId) => {
90
90
  return segments;
91
91
  };
92
92
 
93
- const html2chunks = async (html, chunkSize = 300, chunkOverlap = 0) => {
93
+ const html2chunks = async (html, chunkSize = 300, chunkOverlap = 50) => {
94
94
  const textSplitter = RecursiveCharacterTextSplitter.fromLanguage('html', {
95
95
  chunkSize,
96
96
  chunkOverlap,
@@ -237,44 +237,38 @@ function groupSentences(text, maxSize) {
237
237
  }
238
238
 
239
239
  function buildPromptFromArticlesMulti(
240
- initialQuestion,
241
- questionsWithArticles,
242
- maxTotalTokens
240
+ articles,
241
+ maxTotalTokens,
242
+ dbg = () => null
243
243
  ) {
244
- let prompt = '[CONTEXT]\n\n';
245
- let currentTokenCount = estimateTokenCount(prompt);
246
-
247
244
  const buffer = 16000; // Réserve de tokens pour l'instruction + réponse
248
- const maxTokensPerQuestion = Math.floor(
249
- (maxTotalTokens - buffer) / questionsWithArticles.length
250
- );
251
-
252
- for (const {articles} of questionsWithArticles) {
253
- let section = '';
254
- let sectionTokenCount = estimateTokenCount(section);
255
- for (const article of articles) {
256
- const {title, sourceId, description} = article;
257
- const articleBlock = `\n# ${title} \n## Lien: ${sourceId}\n\n${description}\n`;
258
- const tokensForArticle = estimateTokenCount(articleBlock);
259
-
260
- if (sectionTokenCount + tokensForArticle > maxTokensPerQuestion) {
261
- break;
262
- }
263
-
264
- section += articleBlock;
265
- sectionTokenCount += tokensForArticle;
266
- }
245
+ const maxTokens = Math.floor(maxTotalTokens - buffer);
246
+
247
+ let sections = [];
248
+ let sectionTokenCount = 0;
249
+ for (const article of articles) {
250
+ const {kind, title, sourceId, distance, combinedScore, chunk} = article;
251
+ const articleBlock = JSON.stringify({
252
+ kind,
253
+ title,
254
+ url: sourceId.startsWith('http') ? sourceId : null,
255
+ distance,
256
+ combinedScore,
257
+ chunk,
258
+ });
259
+ dbg(articleBlock);
260
+ const tokensForArticle = estimateTokenCount(articleBlock);
267
261
 
268
- if (currentTokenCount + sectionTokenCount > maxTotalTokens - buffer) {
262
+ if (sectionTokenCount + tokensForArticle > maxTokens) {
263
+ dbg('MAX TOKEN REACHED');
269
264
  break;
270
265
  }
271
266
 
272
- prompt += section;
273
- currentTokenCount += sectionTokenCount;
267
+ sections.push(articleBlock);
268
+ sectionTokenCount += tokensForArticle;
274
269
  }
275
- const questionContext = '[QUESTION]\n\n' + initialQuestion;
276
- prompt += questionContext;
277
- return prompt.trim();
270
+
271
+ return `[${sections.join(',')}]`;
278
272
  }
279
273
 
280
274
  /**
@@ -302,12 +296,17 @@ function chunkArray(array, size) {
302
296
  * @returns {Promise<object>} A map of embeddings structured as:
303
297
  * { [contentId]: { [key]: { chunk, embedding } } }
304
298
  */
305
- async function embedChunksInBatch(agent, allChunks, batchSize = 50) {
299
+ async function embedChunksInBatch(
300
+ agent,
301
+ allChunks,
302
+ batchSize = 50,
303
+ prefix = 'passage: '
304
+ ) {
306
305
  const embeddingsByContent = {};
307
306
 
308
307
  for (let start = 0; start < allChunks.length; start += batchSize) {
309
308
  const currentBatch = allChunks.slice(start, start + batchSize);
310
- const inputTexts = currentBatch.map(({chunk}) => chunk);
309
+ const inputTexts = currentBatch.map(({chunk}) => `${prefix}${chunk}`);
311
310
  const embeddedVectors = await agent.embedInBatch(inputTexts);
312
311
 
313
312
  for (let i = 0; i < embeddedVectors.length; i++) {
@@ -328,6 +327,26 @@ async function embedChunksInBatch(agent, allChunks, batchSize = 50) {
328
327
  return embeddingsByContent;
329
328
  }
330
329
 
330
+ function splitThinkTags(input) {
331
+ const thinkRegex = /<think>([\s\S]*?)<\/think>/g;
332
+ let match;
333
+ const thinks = [];
334
+ let cleanText = input;
335
+
336
+ // Extraire toutes les sections <think>...</think>
337
+ while ((match = thinkRegex.exec(input)) !== null) {
338
+ thinks.push(match[1].trim());
339
+ }
340
+
341
+ // Supprimer les balises et leur contenu du texte
342
+ cleanText = cleanText.replace(thinkRegex, '').trim();
343
+
344
+ return {
345
+ thinks, // Tableau avec le contenu des balises <think>
346
+ cleanText, // Texte sans balises <think>
347
+ };
348
+ }
349
+
331
350
  module.exports = {
332
351
  chunkArray,
333
352
  embedChunksInBatch,
@@ -342,4 +361,5 @@ module.exports = {
342
361
  sliceText,
343
362
  groupSentences,
344
363
  buildPromptFromArticlesMulti,
364
+ splitThinkTags,
345
365
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "goblin-agents",
3
- "version": "1.0.5",
3
+ "version": "2.1.0",
4
4
  "description": "AI Agents framework",
5
5
  "author": "Epsitec SA",
6
6
  "contributors": [