goblin-agents 2.1.1 → 2.2.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.
@@ -12,8 +12,9 @@ const {
12
12
  record,
13
13
  } = require('xcraft-core-stones');
14
14
  const {getMutex} = require('xcraft-core-utils/lib/locks.js');
15
- const LLMProvider = require('./providers.js');
15
+ const LLMProviders = require('./providers.js');
16
16
  const {OCR_SYSTEM_PROMPT, getPDFImages, splitThinkTags} = require('./utils.js');
17
+ const Logger = require('../logger.js');
17
18
 
18
19
  class MessageShape {
19
20
  role = enumeration('assistant', 'user', 'tool');
@@ -352,7 +353,7 @@ class AiAgent extends Elf {
352
353
  return;
353
354
  }
354
355
 
355
- const Instance = LLMProvider.get(this.state.provider);
356
+ const Instance = LLMProviders.get(this.state.provider);
356
357
  this._provider = new Instance(this.state.host, this.state.headers.toJS());
357
358
  this._conf = {provider, host, headersStr};
358
359
  }
@@ -378,7 +379,9 @@ class AiAgent extends Elf {
378
379
  await this.persist();
379
380
  }
380
381
 
381
- async gen(prompt) {
382
+ async gen(prompt, contextId = 'default') {
383
+ const logger = Logger.get(contextId, this);
384
+
382
385
  await this._mutex.lock(this.id);
383
386
  this.quest.defer(() => this._mutex.unlock(this.id));
384
387
 
@@ -402,6 +405,18 @@ class AiAgent extends Elf {
402
405
  options,
403
406
  stream: false,
404
407
  });
408
+
409
+ logger.push('aiAgent.gen', {
410
+ id: this.id,
411
+ model: this.state.model,
412
+ options: Object.entries(options).reduce(
413
+ (out, opt) => `${out} ${opt[0]}=${opt[1]}`,
414
+ ''
415
+ ),
416
+ reasoning: this.state.reasoning,
417
+ format: format || 'N/A',
418
+ });
419
+
405
420
  return cleanText(res);
406
421
  }
407
422
 
@@ -499,6 +514,8 @@ class AiAgent extends Elf {
499
514
  }
500
515
 
501
516
  async chat(contextId, question, userDesktopId) {
517
+ const logger = Logger.get(contextId, this);
518
+
502
519
  await this._mutex.lock(this.id);
503
520
  this.quest.defer(() => this._mutex.unlock(this.id));
504
521
 
@@ -534,6 +551,18 @@ class AiAgent extends Elf {
534
551
 
535
552
  const result = await this._provider.chat(chatCompletionRequest);
536
553
 
554
+ logger.push('aiAgent.chat', {
555
+ id: this.id,
556
+ model: chatCompletionRequest.model,
557
+ options: Object.entries(chatCompletionRequest.options).reduce(
558
+ (out, opt) => `${out} ${opt[0]}=${opt[1]}`,
559
+ ''
560
+ ),
561
+ question,
562
+ reasoning: result.message?.reasoning,
563
+ callTools: !!result.message.tool_calls?.length,
564
+ });
565
+
537
566
  if (this.state.toolServiceId && this.state.tools) {
538
567
  const used = await this.callTools(
539
568
  contextId,
@@ -0,0 +1,97 @@
1
+ // @ts-check
2
+ const {Ollama} = require('ollama');
3
+ const {Semaphore} = require('xcraft-core-utils/lib/locks.js');
4
+ const LLMProvider = require('./provider.js');
5
+
6
+ class OllamaProvider extends LLMProvider {
7
+ #semEmbed = new Semaphore(4);
8
+ #ollama;
9
+
10
+ constructor(host, headers) {
11
+ super();
12
+ this.#ollama = new Ollama({
13
+ host,
14
+ headers,
15
+ });
16
+ }
17
+
18
+ async chat(chatRequest) {
19
+ const {
20
+ model,
21
+ messages,
22
+ options,
23
+ format,
24
+ tools,
25
+ stream,
26
+ reasoning,
27
+ } = chatRequest;
28
+ const useFormat = !!format;
29
+ const chat = await this.#ollama.chat({
30
+ model,
31
+ messages,
32
+ options,
33
+ format,
34
+ tools,
35
+ stream,
36
+ think: reasoning,
37
+ });
38
+ if (useFormat) {
39
+ chat.message.content = JSON.parse(chat.message.content);
40
+ return chat;
41
+ } else {
42
+ return chat;
43
+ }
44
+ }
45
+
46
+ async gen(genRequest) {
47
+ const {
48
+ model,
49
+ system,
50
+ prompt,
51
+ format,
52
+ options,
53
+ stream,
54
+ reasoning,
55
+ } = genRequest;
56
+ const useFormat = !!format;
57
+ const gen = await this.#ollama.generate({
58
+ model,
59
+ prompt,
60
+ system,
61
+ format,
62
+ options,
63
+ stream,
64
+ think: reasoning,
65
+ });
66
+ if (useFormat) {
67
+ return JSON.parse(gen.response);
68
+ } else {
69
+ return gen.response;
70
+ }
71
+ }
72
+
73
+ async embed(embedRequest) {
74
+ try {
75
+ await this.#semEmbed.wait();
76
+ const {model, input} = embedRequest;
77
+ const {embeddings} = await this.#ollama.embed({
78
+ model,
79
+ input,
80
+ });
81
+ return embeddings[0];
82
+ } finally {
83
+ this.#semEmbed.signal();
84
+ }
85
+ }
86
+
87
+ async embedInBatch(embedRequest) {
88
+ const {model, input} = embedRequest;
89
+ const {embeddings} = await this.#ollama.embed({
90
+ model,
91
+ input,
92
+ });
93
+ return embeddings;
94
+ }
95
+ }
96
+
97
+ module.exports = OllamaProvider;
@@ -0,0 +1,101 @@
1
+ // @ts-check
2
+ const {RestAPI} = require('xcraft-core-utils');
3
+ const LLMProvider = require('./provider.js');
4
+
5
+ class OpenAIProvider extends LLMProvider {
6
+ #baseUrl;
7
+ #openAI;
8
+
9
+ constructor(host = 'https://openrouter.ai/api/v1', headers) {
10
+ super();
11
+ this.#baseUrl = host;
12
+ this.#openAI = new RestAPI(300000, headers);
13
+ }
14
+
15
+ async chat(chatRequest) {
16
+ delete chatRequest.reasoning; //not yet supported
17
+ if (chatRequest.options) {
18
+ chatRequest = {...chatRequest, ...chatRequest.options};
19
+ delete chatRequest.options;
20
+ }
21
+ if (!chatRequest.tools) {
22
+ delete chatRequest.tools;
23
+ }
24
+ if (!chatRequest.format) {
25
+ delete chatRequest.format;
26
+ }
27
+ const useFormat = chatRequest?.format !== undefined;
28
+ if (useFormat) {
29
+ chatRequest.response_format = {
30
+ type: 'json_schema',
31
+ json_schema: {
32
+ name: 'default',
33
+ schema: {
34
+ type: 'object',
35
+ properties: {
36
+ results: {...chatRequest.format},
37
+ },
38
+ required: ['results'],
39
+ },
40
+ },
41
+ };
42
+
43
+ delete chatRequest.format;
44
+ }
45
+ const query = `${this.#baseUrl}/chat/completions`;
46
+ const chat = await this.#openAI._post(query, chatRequest, {
47
+ retry: {
48
+ methods: ['POST'],
49
+ },
50
+ });
51
+ const {choices} = chat;
52
+ if (chat.error) {
53
+ const err = new Error(
54
+ chat.error?.metadata
55
+ ? JSON.stringify(chat.error?.metadata)
56
+ : chat.error.message
57
+ );
58
+ err.code = chat.error.code;
59
+ throw err;
60
+ }
61
+ if (useFormat) {
62
+ choices[0].message.content = JSON.parse(
63
+ choices[0].message.content
64
+ ).results;
65
+ return choices[0];
66
+ } else {
67
+ return choices[0];
68
+ }
69
+ }
70
+
71
+ async gen(genRequest) {
72
+ const {model, system, prompt, format, tools, options, stream} = genRequest;
73
+ const chatRequest = {
74
+ model,
75
+ messages: [
76
+ {role: 'system', content: system},
77
+ {role: 'user', content: prompt},
78
+ ],
79
+ options,
80
+ format,
81
+ tools,
82
+ stream,
83
+ };
84
+ const chat = await this.chat(chatRequest);
85
+ return chat.message.content;
86
+ }
87
+
88
+ async embed(embedRequest) {
89
+ const query = `${this.#baseUrl}/v1/embeddings`;
90
+ const {data} = await this.#openAI._post(query, embedRequest);
91
+ return data[0].embedding;
92
+ }
93
+
94
+ async embedInBatch(embedRequest) {
95
+ const query = `${this.#baseUrl}/v1/embeddings`;
96
+ const {data} = await this.#openAI._post(query, embedRequest);
97
+ return data;
98
+ }
99
+ }
100
+
101
+ module.exports = OpenAIProvider;
@@ -0,0 +1,36 @@
1
+ // @ts-check
2
+
3
+ class LLMProvider {
4
+ constructor() {
5
+ if (new.target === LLMProvider) {
6
+ throw new Error('Cannot instantiate an abstract class.');
7
+ }
8
+ }
9
+
10
+ async chatStream() {
11
+ throw new Error('not impl.');
12
+ }
13
+
14
+ /**
15
+ * @returns {Promise<*>}
16
+ */
17
+ async chat() {
18
+ throw new Error('not impl.');
19
+ }
20
+
21
+ /**
22
+ * @returns {Promise<*>}
23
+ */
24
+ async gen() {
25
+ throw new Error('not impl.');
26
+ }
27
+
28
+ /**
29
+ * @returns {Promise<*>}
30
+ */
31
+ async embed() {
32
+ throw new Error('not impl.');
33
+ }
34
+ }
35
+
36
+ module.exports = LLMProvider;
@@ -1,8 +1,8 @@
1
- const {Ollama} = require('ollama');
2
- const {RestAPI} = require('xcraft-core-utils');
3
- const {Semaphore} = require('xcraft-core-utils/lib/locks.js');
1
+ // @ts-check
2
+ const OllamaProvider = require('./providers/ollama.js');
3
+ const OpenAIProvider = require('./providers/opanAi.js');
4
4
 
5
- class LLMProvider {
5
+ class LLMProviders {
6
6
  static get(name) {
7
7
  switch (name) {
8
8
  default:
@@ -12,215 +12,6 @@ class LLMProvider {
12
12
  return OpenAIProvider;
13
13
  }
14
14
  }
15
-
16
- constructor() {
17
- if (new.target === LLMProvider) {
18
- throw new Error('Cannot instantiate an abstract class.');
19
- }
20
- }
21
-
22
- async chatStream() {
23
- throw new Error('not impl.');
24
- }
25
-
26
- async chat() {
27
- throw new Error('not impl.');
28
- }
29
-
30
- async gen() {
31
- throw new Error('not impl.');
32
- }
33
-
34
- async embed() {
35
- throw new Error('not impl.');
36
- }
37
- }
38
-
39
- class OllamaProvider extends LLMProvider {
40
- #semEmbed = new Semaphore(4);
41
- #ollama;
42
-
43
- constructor(host, headers) {
44
- super();
45
- this.#ollama = new Ollama({
46
- host,
47
- headers,
48
- });
49
- }
50
-
51
- async chat(chatRequest) {
52
- const {
53
- model,
54
- messages,
55
- options,
56
- format,
57
- tools,
58
- stream,
59
- reasoning,
60
- } = chatRequest;
61
- const useFormat = !!format;
62
- const chat = await this.#ollama.chat({
63
- model,
64
- messages,
65
- options,
66
- format,
67
- tools,
68
- stream,
69
- think: reasoning,
70
- });
71
- if (useFormat) {
72
- chat.message.content = JSON.parse(chat.message.content);
73
- return chat;
74
- } else {
75
- return chat;
76
- }
77
- }
78
-
79
- async gen(genRequest) {
80
- const {
81
- model,
82
- system,
83
- prompt,
84
- format,
85
- options,
86
- stream,
87
- reasoning,
88
- } = genRequest;
89
- const useFormat = !!format;
90
- const gen = await this.#ollama.generate({
91
- model,
92
- prompt,
93
- system,
94
- format,
95
- options,
96
- stream,
97
- think: reasoning,
98
- });
99
- if (useFormat) {
100
- return JSON.parse(gen.response);
101
- } else {
102
- return gen.response;
103
- }
104
- }
105
-
106
- async embed(embedRequest) {
107
- try {
108
- await this.#semEmbed.wait();
109
- const {model, input} = embedRequest;
110
- const {embeddings} = await this.#ollama.embed({
111
- model,
112
- input,
113
- });
114
- return embeddings[0];
115
- } finally {
116
- this.#semEmbed.signal();
117
- }
118
- }
119
-
120
- async embedInBatch(embedRequest) {
121
- const {model, input} = embedRequest;
122
- const {embeddings} = await this.#ollama.embed({
123
- model,
124
- input,
125
- });
126
- return embeddings;
127
- }
128
- }
129
-
130
- class OpenAIProvider extends LLMProvider {
131
- #baseUrl;
132
- #openAI;
133
-
134
- constructor(host = 'https://openrouter.ai/api/v1', headers) {
135
- super();
136
- this.#baseUrl = host;
137
- this.#openAI = new RestAPI(300000, headers);
138
- }
139
-
140
- async chat(chatRequest) {
141
- delete chatRequest.reasoning; //not yet supported
142
- if (chatRequest.options) {
143
- chatRequest = {...chatRequest, ...chatRequest.options};
144
- delete chatRequest.options;
145
- }
146
- if (!chatRequest.tools) {
147
- delete chatRequest.tools;
148
- }
149
- if (!chatRequest.format) {
150
- delete chatRequest.format;
151
- }
152
- const useFormat = chatRequest?.format !== undefined;
153
- if (useFormat) {
154
- chatRequest.response_format = {
155
- type: 'json_schema',
156
- json_schema: {
157
- name: 'default',
158
- schema: {
159
- type: 'object',
160
- properties: {
161
- results: {...chatRequest.format},
162
- },
163
- required: ['results'],
164
- },
165
- },
166
- };
167
-
168
- delete chatRequest.format;
169
- }
170
- const query = `${this.#baseUrl}/chat/completions`;
171
- const chat = await this.#openAI._post(query, chatRequest, {
172
- retry: {
173
- methods: ['POST'],
174
- },
175
- });
176
- const {choices} = chat;
177
- if (chat.error) {
178
- const err = new Error(
179
- chat.error?.metadata
180
- ? JSON.stringify(chat.error?.metadata)
181
- : chat.error.message
182
- );
183
- err.code = chat.error.code;
184
- throw err;
185
- }
186
- if (useFormat) {
187
- choices[0].message.content = JSON.parse(
188
- choices[0].message.content
189
- ).results;
190
- return choices[0];
191
- } else {
192
- return choices[0];
193
- }
194
- }
195
-
196
- async gen(genRequest) {
197
- const {model, system, prompt, format, tools, options, stream} = genRequest;
198
- const chatRequest = {
199
- model,
200
- messages: [
201
- {role: 'system', content: system},
202
- {role: 'user', content: prompt},
203
- ],
204
- options,
205
- format,
206
- tools,
207
- stream,
208
- };
209
- const chat = await this.chat(chatRequest);
210
- return chat.message.content;
211
- }
212
-
213
- async embed(embedRequest) {
214
- const query = `${this.#baseUrl}/v1/embeddings`;
215
- const {data} = await this.#openAI._post(query, embedRequest);
216
- return data[0].embedding;
217
- }
218
-
219
- async embedInBatch(embedRequest) {
220
- const query = `${this.#baseUrl}/v1/embeddings`;
221
- const {data} = await this.#openAI._post(query, embedRequest);
222
- return data;
223
- }
224
15
  }
225
16
 
226
- module.exports = LLMProvider;
17
+ module.exports = LLMProviders;
package/lib/logger.js ADDED
@@ -0,0 +1,104 @@
1
+ // @ts-check
2
+ const path = require('node:path');
3
+ const fse = require('fs-extra');
4
+ const {string, Sculpt, dateTime, object} = require('xcraft-core-stones');
5
+ const xConfig = require('xcraft-core-etc')().load('xcraft');
6
+
7
+ class MessageShape {
8
+ timestamp = dateTime;
9
+ step = string;
10
+ payload = object;
11
+ }
12
+
13
+ class Message extends Sculpt(MessageShape) {}
14
+
15
+ class Logger {
16
+ #id;
17
+
18
+ /** @type {Set<Message>} */
19
+ #messages = new Set();
20
+
21
+ /** @type {Map<string, Logger>} */
22
+ static #loggers = new Map();
23
+ static #logDir = path.join(xConfig.xcraftRoot, 'var/log/ai');
24
+
25
+ constructor(id) {
26
+ this.#id = id;
27
+ fse.ensureDirSync(Logger.#logDir);
28
+ Logger.#loggers.set(id, this);
29
+ }
30
+
31
+ /**
32
+ * @param {string} step
33
+ * @param {object} payload
34
+ */
35
+ push(step, payload) {
36
+ const timestamp = new Date().toISOString();
37
+ const msg = new Message({timestamp, step, payload});
38
+ this.#messages.add(msg);
39
+ }
40
+
41
+ async #report() {
42
+ let timestamp = new Date().toISOString();
43
+ let messages = '';
44
+
45
+ for (const message of this.#messages) {
46
+ timestamp = message.timestamp;
47
+
48
+ const messageFile = path.join(__dirname, 'report/message.html');
49
+ let reportMessage = await fse.readFile(messageFile, 'utf8');
50
+
51
+ let payload = '';
52
+ for (const [key, value] of Object.entries(message.payload)) {
53
+ const itemFile = path.join(__dirname, 'report/item.html');
54
+ let reportItem = await fse.readFile(itemFile, 'utf8');
55
+ payload += reportItem
56
+ .replace('${KEY}', key)
57
+ .replace('${VALUE}', `${value}`.replaceAll('\n', '<br/>'));
58
+ }
59
+
60
+ messages += reportMessage
61
+ .replace('${TIMESTAMP}', message.timestamp)
62
+ .replace('${STEP}', message.step)
63
+ .replace('${PAYLOAD}', payload);
64
+ }
65
+
66
+ const reportFile = path.join(__dirname, 'report/report.html');
67
+ let report = await fse.readFile(reportFile, 'utf8');
68
+ report = report
69
+ .replace('${MESSAGES}', messages)
70
+ .replace('${TITLE}', `AI reporting for ${this.#id}`);
71
+
72
+ const ts = timestamp.split(':').slice(0, 2).join('').split('-').join('');
73
+ const reportOutput = path.join(Logger.#logDir, `${this.#id}_${ts}Z.html`);
74
+ await fse.writeFile(reportOutput, report, 'utf8');
75
+ }
76
+
77
+ async dispose() {
78
+ if (this.#id && this.#id !== 'default') {
79
+ await this.#report();
80
+ }
81
+ Logger.#loggers.delete(this.#id);
82
+ }
83
+
84
+ static get(id, quest) {
85
+ if (quest.quest) {
86
+ quest = quest.quest;
87
+ }
88
+ if (Logger.#loggers.has(id)) {
89
+ const logger = Logger.#loggers.get(id);
90
+ if (logger) {
91
+ return logger;
92
+ }
93
+ }
94
+ const logger = new Logger(id);
95
+ quest.defer(async () => await logger.dispose());
96
+ return logger;
97
+ }
98
+
99
+ static get logDir() {
100
+ return Logger.#logDir;
101
+ }
102
+ }
103
+
104
+ module.exports = Logger;
@@ -0,0 +1,2 @@
1
+ <dt><strong>${KEY}:</strong></dt>
2
+ <dd>${VALUE}</dd>
@@ -0,0 +1,8 @@
1
+ <h2>${STEP}</h2>
2
+ <p>
3
+ <small><em>${TIMESTAMP}</em></small>
4
+ </p>
5
+ <dl>
6
+ ${PAYLOAD}
7
+ </dl>
8
+ <hr />
@@ -0,0 +1,10 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <title>${TITLE}</title>
6
+ </head>
7
+ <body>
8
+ ${MESSAGES}
9
+ </body>
10
+ </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "goblin-agents",
3
- "version": "2.1.1",
3
+ "version": "2.2.2",
4
4
  "description": "AI Agents framework",
5
5
  "author": "Epsitec SA",
6
6
  "contributors": [