outlet-orm 7.0.0 → 9.0.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/README.md +130 -2
- package/package.json +1 -1
- package/src/AI/AIPromptEnhancer.js +170 -0
- package/src/AI/AIQueryBuilder.js +234 -0
- package/src/AI/AIQueryOptimizer.js +185 -0
- package/src/AI/AISeeder.js +181 -0
- package/src/AI/AiBridgeManager.js +287 -0
- package/src/AI/Builders/TextBuilder.js +170 -0
- package/src/AI/Contracts/AudioProviderContract.js +29 -0
- package/src/AI/Contracts/ChatProviderContract.js +38 -0
- package/src/AI/Contracts/EmbeddingsProviderContract.js +19 -0
- package/src/AI/Contracts/ImageProviderContract.js +19 -0
- package/src/AI/Contracts/ModelsProviderContract.js +26 -0
- package/src/AI/Contracts/ToolContract.js +25 -0
- package/src/AI/Facades/AiBridge.js +79 -0
- package/src/AI/MCPServer.js +113 -0
- package/src/AI/Providers/ClaudeProvider.js +64 -0
- package/src/AI/Providers/CustomOpenAIProvider.js +238 -0
- package/src/AI/Providers/GeminiProvider.js +68 -0
- package/src/AI/Providers/GrokProvider.js +46 -0
- package/src/AI/Providers/MistralProvider.js +21 -0
- package/src/AI/Providers/OllamaProvider.js +249 -0
- package/src/AI/Providers/OllamaTurboProvider.js +32 -0
- package/src/AI/Providers/OnnProvider.js +46 -0
- package/src/AI/Providers/OpenAIProvider.js +471 -0
- package/src/AI/Support/AudioNormalizer.js +37 -0
- package/src/AI/Support/ChatNormalizer.js +42 -0
- package/src/AI/Support/Document.js +77 -0
- package/src/AI/Support/DocumentAttachmentMapper.js +101 -0
- package/src/AI/Support/EmbeddingsNormalizer.js +30 -0
- package/src/AI/Support/Exceptions/ProviderError.js +22 -0
- package/src/AI/Support/FileSecurity.js +56 -0
- package/src/AI/Support/ImageNormalizer.js +62 -0
- package/src/AI/Support/JsonSchemaValidator.js +73 -0
- package/src/AI/Support/Message.js +40 -0
- package/src/AI/Support/StreamChunk.js +45 -0
- package/src/AI/Support/ToolChatRunner.js +160 -0
- package/src/AI/Support/ToolRegistry.js +62 -0
- package/src/AI/Tools/SystemInfoTool.js +25 -0
- package/src/index.js +67 -1
- package/types/index.d.ts +326 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const Document = require('./Document');
|
|
6
|
+
const FileSecurity = require('./FileSecurity');
|
|
7
|
+
|
|
8
|
+
const IMAGE_PREFIX = 'image/';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* DocumentAttachmentMapper
|
|
12
|
+
* Converts Document attachments to provider-specific formats.
|
|
13
|
+
*/
|
|
14
|
+
class DocumentAttachmentMapper {
|
|
15
|
+
/**
|
|
16
|
+
* Split attachments into files, images, and inline texts for Ollama-compatible providers.
|
|
17
|
+
* @param {Array} attachments
|
|
18
|
+
* @returns {{files: Array, image_files: string[], inlineTexts: string[]}}
|
|
19
|
+
*/
|
|
20
|
+
static toOllamaOptions(attachments) {
|
|
21
|
+
const files = [];
|
|
22
|
+
const images = [];
|
|
23
|
+
const inlineTexts = [];
|
|
24
|
+
const sec = FileSecurity.fromDefaults();
|
|
25
|
+
|
|
26
|
+
for (const att of attachments) {
|
|
27
|
+
if (att instanceof Document) {
|
|
28
|
+
switch (att.kind) {
|
|
29
|
+
case 'text':
|
|
30
|
+
if (att.text != null) inlineTexts.push(att.text);
|
|
31
|
+
break;
|
|
32
|
+
case 'local':
|
|
33
|
+
if (att.path && sec.validateFile(att.path)) {
|
|
34
|
+
const mime = att.mime || 'application/octet-stream';
|
|
35
|
+
const b64 = fs.readFileSync(att.path).toString('base64');
|
|
36
|
+
if (mime.startsWith(IMAGE_PREFIX)) {
|
|
37
|
+
images.push(b64);
|
|
38
|
+
} else {
|
|
39
|
+
files.push({ name: path.basename(att.path), type: mime, content: b64 });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
break;
|
|
43
|
+
case 'base64':
|
|
44
|
+
if (att.base64 && att.mime) {
|
|
45
|
+
if (att.mime.startsWith(IMAGE_PREFIX)) {
|
|
46
|
+
images.push(att.base64);
|
|
47
|
+
} else {
|
|
48
|
+
files.push({ name: att.title || 'document', type: att.mime, content: att.base64 });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
break;
|
|
52
|
+
case 'raw':
|
|
53
|
+
if (att.raw && att.mime) {
|
|
54
|
+
const b64 = Buffer.from(att.raw).toString('base64');
|
|
55
|
+
if (att.mime.startsWith(IMAGE_PREFIX)) {
|
|
56
|
+
images.push(b64);
|
|
57
|
+
} else {
|
|
58
|
+
files.push({ name: att.title || 'document', type: att.mime, content: b64 });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
break;
|
|
62
|
+
case 'chunks':
|
|
63
|
+
for (const c of att.chunks) inlineTexts.push(String(c));
|
|
64
|
+
break;
|
|
65
|
+
case 'url':
|
|
66
|
+
case 'file_id':
|
|
67
|
+
default:
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
} else if (typeof att === 'string' && sec.validateFile(att)) {
|
|
71
|
+
const b64 = fs.readFileSync(att).toString('base64');
|
|
72
|
+
const mime = 'application/octet-stream';
|
|
73
|
+
files.push({ name: path.basename(att), type: mime, content: b64 });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { files, image_files: images, inlineTexts };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Extract inline text content from attachments (for OpenAI-style providers).
|
|
82
|
+
* @param {Array} attachments
|
|
83
|
+
* @returns {string[]}
|
|
84
|
+
*/
|
|
85
|
+
static extractInlineTexts(attachments) {
|
|
86
|
+
const inline = [];
|
|
87
|
+
for (const att of attachments) {
|
|
88
|
+
if (att instanceof Document) {
|
|
89
|
+
if (att.kind === 'text' && att.text != null) inline.push(att.text);
|
|
90
|
+
if (att.kind === 'chunks') {
|
|
91
|
+
for (const c of att.chunks) inline.push(String(c));
|
|
92
|
+
}
|
|
93
|
+
} else if (typeof att === 'string') {
|
|
94
|
+
inline.push(att);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return inline;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
module.exports = DocumentAttachmentMapper;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* EmbeddingsNormalizer
|
|
5
|
+
* Normalizes embeddings responses into a unified shape.
|
|
6
|
+
*/
|
|
7
|
+
class EmbeddingsNormalizer {
|
|
8
|
+
/**
|
|
9
|
+
* @param {Object} raw
|
|
10
|
+
* @returns {{vectors: number[][], usage: Object, raw: Object}}
|
|
11
|
+
*/
|
|
12
|
+
static normalize(raw) {
|
|
13
|
+
let vectors = [];
|
|
14
|
+
let usage = {};
|
|
15
|
+
|
|
16
|
+
if (Array.isArray(raw?.data)) {
|
|
17
|
+
vectors = raw.data.map(d => d.embedding || []);
|
|
18
|
+
usage = raw.usage || {};
|
|
19
|
+
} else if (Array.isArray(raw?.embeddings)) {
|
|
20
|
+
vectors = raw.embeddings;
|
|
21
|
+
} else if (raw?.embedding?.values) {
|
|
22
|
+
// Gemini single embedding format
|
|
23
|
+
vectors = [raw.embedding.values];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return { vectors, usage, raw };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = EmbeddingsNormalizer;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ProviderError
|
|
5
|
+
* Custom error for provider-related issues.
|
|
6
|
+
*/
|
|
7
|
+
class ProviderError extends Error {
|
|
8
|
+
constructor(message) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = 'ProviderError';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
static notFound(name) {
|
|
14
|
+
return new ProviderError(`Provider '${name}' not found`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
static unsupported(name, feature) {
|
|
18
|
+
return new ProviderError(`Provider '${name}' does not support ${feature}`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
module.exports = ProviderError;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* FileSecurity
|
|
7
|
+
* Enforces file size and MIME type constraints for uploads.
|
|
8
|
+
*/
|
|
9
|
+
class FileSecurity {
|
|
10
|
+
/**
|
|
11
|
+
* @param {Object} [config={}]
|
|
12
|
+
* @param {number} [config.max_file_bytes=10485760] - 10MB default
|
|
13
|
+
* @param {string[]} [config.allowed_mime_files]
|
|
14
|
+
* @param {string[]} [config.allowed_mime_images]
|
|
15
|
+
*/
|
|
16
|
+
constructor(config = {}) {
|
|
17
|
+
this.maxBytes = config.max_file_bytes || 10 * 1024 * 1024;
|
|
18
|
+
this.allowedFiles = config.allowed_mime_files || [
|
|
19
|
+
'text/plain', 'application/pdf', 'application/json',
|
|
20
|
+
'text/csv', 'text/html', 'text/markdown',
|
|
21
|
+
'application/xml', 'text/xml'
|
|
22
|
+
];
|
|
23
|
+
this.allowedImages = config.allowed_mime_images || [
|
|
24
|
+
'image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml'
|
|
25
|
+
];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Validate a file against size and type constraints.
|
|
30
|
+
* @param {string} filePath
|
|
31
|
+
* @param {boolean} [image=false]
|
|
32
|
+
* @returns {boolean}
|
|
33
|
+
*/
|
|
34
|
+
validateFile(filePath, image = false) {
|
|
35
|
+
try {
|
|
36
|
+
if (!fs.existsSync(filePath)) return false;
|
|
37
|
+
const stats = fs.statSync(filePath);
|
|
38
|
+
if (stats.size > this.maxBytes) return false;
|
|
39
|
+
// In Node.js we don't have mime_content_type; basic extension check
|
|
40
|
+
return true;
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Create a FileSecurity instance with default configuration.
|
|
48
|
+
* @returns {FileSecurity}
|
|
49
|
+
*/
|
|
50
|
+
static fromDefaults() {
|
|
51
|
+
const maxBytes = parseInt(process.env.AI_MAX_FILE_BYTES, 10) || 10 * 1024 * 1024;
|
|
52
|
+
return new FileSecurity({ max_file_bytes: maxBytes });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = FileSecurity;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_MIME = 'image/png';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* ImageNormalizer
|
|
7
|
+
* Normalizes heterogeneous image responses into a common array of items.
|
|
8
|
+
*/
|
|
9
|
+
class ImageNormalizer {
|
|
10
|
+
/**
|
|
11
|
+
* @param {Object} raw
|
|
12
|
+
* @returns {Array<{type: string, url?: string, data?: string, mime?: string}>}
|
|
13
|
+
*/
|
|
14
|
+
static normalize(raw) {
|
|
15
|
+
let items = ImageNormalizer._fromOpenAI(raw);
|
|
16
|
+
if (items.length > 0) return items;
|
|
17
|
+
items = ImageNormalizer._fromOllama(raw);
|
|
18
|
+
if (items.length > 0) return items;
|
|
19
|
+
return ImageNormalizer._fromDataUrl(raw);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** @private */
|
|
23
|
+
static _fromOpenAI(raw) {
|
|
24
|
+
const out = [];
|
|
25
|
+
if (!Array.isArray(raw?.data)) return out;
|
|
26
|
+
for (const d of raw.data) {
|
|
27
|
+
if (d.url) {
|
|
28
|
+
out.push({ type: 'url', url: d.url });
|
|
29
|
+
} else if (d.b64_json) {
|
|
30
|
+
out.push({ type: 'b64', mime: DEFAULT_MIME, data: d.b64_json });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** @private */
|
|
37
|
+
static _fromOllama(raw) {
|
|
38
|
+
const out = [];
|
|
39
|
+
if (!Array.isArray(raw?.images)) return out;
|
|
40
|
+
for (const img of raw.images) {
|
|
41
|
+
const b64 = img.b64 || img;
|
|
42
|
+
if (typeof b64 === 'string' && b64 !== '') {
|
|
43
|
+
out.push({ type: 'b64', mime: DEFAULT_MIME, data: b64 });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** @private */
|
|
50
|
+
static _fromDataUrl(raw) {
|
|
51
|
+
const out = [];
|
|
52
|
+
const resp = raw?.response;
|
|
53
|
+
if (typeof resp !== 'string' || !resp.startsWith('data:image')) return out;
|
|
54
|
+
const match = resp.match(/^data:([^;]+);base64,(.*)$/);
|
|
55
|
+
if (match) {
|
|
56
|
+
out.push({ type: 'b64', mime: match[1] || DEFAULT_MIME, data: match[2] || '' });
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = ImageNormalizer;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* JsonSchemaValidator
|
|
5
|
+
* Performs recursive JSON Schema validation on structured outputs.
|
|
6
|
+
*/
|
|
7
|
+
class JsonSchemaValidator {
|
|
8
|
+
/**
|
|
9
|
+
* Validate data against a JSON Schema.
|
|
10
|
+
* @param {*} data
|
|
11
|
+
* @param {Object} schema
|
|
12
|
+
* @returns {{valid: boolean, errors: string[]}}
|
|
13
|
+
*/
|
|
14
|
+
static validate(data, schema) {
|
|
15
|
+
const errors = [];
|
|
16
|
+
JsonSchemaValidator._validateNode(data, schema, '$', errors);
|
|
17
|
+
return { valid: errors.length === 0, errors };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** @private */
|
|
21
|
+
static _validateNode(value, schema, path, errors) {
|
|
22
|
+
const type = schema.type;
|
|
23
|
+
if (type) {
|
|
24
|
+
if (type === 'object' && (typeof value !== 'object' || value === null || Array.isArray(value))) {
|
|
25
|
+
errors.push(`${path}: expected object`);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (type === 'array' && !Array.isArray(value)) {
|
|
29
|
+
errors.push(`${path}: expected array`);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (type === 'string' && typeof value !== 'string') {
|
|
33
|
+
errors.push(`${path}: expected string`);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (type === 'number' && typeof value !== 'number') {
|
|
37
|
+
errors.push(`${path}: expected number`);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (type === 'boolean' && typeof value !== 'boolean') {
|
|
41
|
+
errors.push(`${path}: expected boolean`);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (type === 'integer' && (!Number.isInteger(value))) {
|
|
45
|
+
errors.push(`${path}: expected integer`);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (type === 'object' && typeof value === 'object' && value !== null) {
|
|
51
|
+
const props = schema.properties || {};
|
|
52
|
+
const required = schema.required || [];
|
|
53
|
+
for (const req of required) {
|
|
54
|
+
if (!(req in value)) {
|
|
55
|
+
errors.push(`${path}.${req}: required missing`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
for (const [k, v] of Object.entries(value)) {
|
|
59
|
+
if (props[k]) {
|
|
60
|
+
JsonSchemaValidator._validateNode(v, props[k], `${path}.${k}`, errors);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (type === 'array' && Array.isArray(value) && schema.items) {
|
|
66
|
+
for (let i = 0; i < value.length; i++) {
|
|
67
|
+
JsonSchemaValidator._validateNode(value[i], schema.items, `${path}[${i}]`, errors);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = JsonSchemaValidator;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Message
|
|
5
|
+
* Value object for chat messages with optional attachments.
|
|
6
|
+
*/
|
|
7
|
+
class Message {
|
|
8
|
+
/**
|
|
9
|
+
* @param {string} role
|
|
10
|
+
* @param {string} content
|
|
11
|
+
* @param {Array} [attachments=[]]
|
|
12
|
+
*/
|
|
13
|
+
constructor(role, content, attachments = []) {
|
|
14
|
+
this.role = role;
|
|
15
|
+
this.content = content;
|
|
16
|
+
this.attachments = attachments;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
static user(content, attachments = []) {
|
|
20
|
+
return new Message('user', content, attachments);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
static system(content) {
|
|
24
|
+
return new Message('system', content);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
static assistant(content) {
|
|
28
|
+
return new Message('assistant', content);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
toObject() {
|
|
32
|
+
const obj = { role: this.role, content: this.content };
|
|
33
|
+
if (this.attachments.length > 0) {
|
|
34
|
+
obj.attachments = this.attachments;
|
|
35
|
+
}
|
|
36
|
+
return obj;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = Message;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* StreamChunk
|
|
5
|
+
* Structured DTO for streaming responses.
|
|
6
|
+
*/
|
|
7
|
+
class StreamChunk {
|
|
8
|
+
/**
|
|
9
|
+
* @param {string} [text='']
|
|
10
|
+
* @param {Object|null} [usage=null]
|
|
11
|
+
* @param {string|null} [finishReason=null]
|
|
12
|
+
* @param {string} [chunkType='delta'] - 'delta' | 'end' | 'tool_call' | 'tool_result'
|
|
13
|
+
* @param {Array} [toolCalls=[]]
|
|
14
|
+
* @param {Array} [toolResults=[]]
|
|
15
|
+
*/
|
|
16
|
+
constructor(text = '', usage = null, finishReason = null, chunkType = 'delta', toolCalls = [], toolResults = []) {
|
|
17
|
+
this.text = text;
|
|
18
|
+
this.usage = usage;
|
|
19
|
+
this.finishReason = finishReason;
|
|
20
|
+
this.chunkType = chunkType;
|
|
21
|
+
this.toolCalls = toolCalls;
|
|
22
|
+
this.toolResults = toolResults;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Create a delta (text) chunk.
|
|
27
|
+
* @param {string} text
|
|
28
|
+
* @returns {StreamChunk}
|
|
29
|
+
*/
|
|
30
|
+
static delta(text) {
|
|
31
|
+
return new StreamChunk(text, null, null, 'delta');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Create an end-of-stream chunk.
|
|
36
|
+
* @param {string|null} [finishReason='stop']
|
|
37
|
+
* @param {Object|null} [usage=null]
|
|
38
|
+
* @returns {StreamChunk}
|
|
39
|
+
*/
|
|
40
|
+
static end(finishReason = 'stop', usage = null) {
|
|
41
|
+
return new StreamChunk('', usage, finishReason, 'end');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = StreamChunk;
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ToolChatRunner
|
|
5
|
+
* Provider-agnostic tool-calling loop.
|
|
6
|
+
* Injects a system prompt listing available tools, parses model JSON responses,
|
|
7
|
+
* executes tools, and iterates until model gives a normal text reply.
|
|
8
|
+
*/
|
|
9
|
+
class ToolChatRunner {
|
|
10
|
+
/**
|
|
11
|
+
* @param {import('../AiBridgeManager')} manager
|
|
12
|
+
*/
|
|
13
|
+
constructor(manager) {
|
|
14
|
+
this.manager = manager;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Run a tool-aware chat loop.
|
|
19
|
+
* @param {string} provider
|
|
20
|
+
* @param {Array} messages
|
|
21
|
+
* @param {Object} [options={}]
|
|
22
|
+
* @returns {Promise<{final?: Object, tool_calls: Array, error?: string}>}
|
|
23
|
+
*/
|
|
24
|
+
async run(provider, messages, options = {}) {
|
|
25
|
+
const tools = this.manager.tools();
|
|
26
|
+
if (Object.keys(tools).length === 0) {
|
|
27
|
+
const final = await this.manager.chat(provider, messages, options);
|
|
28
|
+
return { final, tool_calls: [] };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
messages = this._injectToolInstructionIfMissing(messages, tools);
|
|
32
|
+
|
|
33
|
+
const state = {
|
|
34
|
+
tool_calls: [],
|
|
35
|
+
messages: [...messages],
|
|
36
|
+
iterations: 0,
|
|
37
|
+
max: options.max_tool_iterations || 5,
|
|
38
|
+
provider,
|
|
39
|
+
options,
|
|
40
|
+
final: null,
|
|
41
|
+
done: false,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
while (!state.done && state.iterations < state.max) {
|
|
45
|
+
await this._iteration(state);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!state.final) {
|
|
49
|
+
return { error: 'tool_iteration_limit_reached', tool_calls: state.tool_calls };
|
|
50
|
+
}
|
|
51
|
+
return { final: state.final, tool_calls: state.tool_calls };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** @private */
|
|
55
|
+
_injectToolInstructionIfMissing(messages, tools) {
|
|
56
|
+
const instruction = this._buildToolInstruction(tools);
|
|
57
|
+
for (const m of messages) {
|
|
58
|
+
if (m.role === 'system' && (m.content || '').includes('Tools:')) {
|
|
59
|
+
return messages;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return [{ role: 'system', content: instruction }, ...messages];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** @private */
|
|
66
|
+
async _iteration(state) {
|
|
67
|
+
state.iterations++;
|
|
68
|
+
const response = await this.manager.chat(state.provider, state.messages, state.options);
|
|
69
|
+
const assistant = this._extractAssistantContent(response);
|
|
70
|
+
|
|
71
|
+
if (assistant === null) {
|
|
72
|
+
state.final = response;
|
|
73
|
+
state.done = true;
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const toolCalls = this._parseToolCalls(assistant);
|
|
78
|
+
if (toolCalls.length === 0) {
|
|
79
|
+
state.final = response;
|
|
80
|
+
state.done = true;
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const executed = await this._executeToolCalls(toolCalls);
|
|
85
|
+
for (const call of executed) {
|
|
86
|
+
state.tool_calls.push(call);
|
|
87
|
+
state.messages.push({ role: 'tool', name: call.name, content: call.result });
|
|
88
|
+
}
|
|
89
|
+
state.messages.push({
|
|
90
|
+
role: 'user',
|
|
91
|
+
content: 'If more tools are needed respond only with JSON tool_calls; otherwise respond normally.'
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** @private */
|
|
96
|
+
async _executeToolCalls(toolCalls) {
|
|
97
|
+
const out = [];
|
|
98
|
+
for (const call of toolCalls) {
|
|
99
|
+
const name = call.name;
|
|
100
|
+
const args = call.arguments || {};
|
|
101
|
+
if (!name) continue;
|
|
102
|
+
const tool = this.manager.tool(name);
|
|
103
|
+
if (!tool) continue;
|
|
104
|
+
let result;
|
|
105
|
+
try {
|
|
106
|
+
result = await tool.execute(typeof args === 'object' ? args : {});
|
|
107
|
+
} catch (e) {
|
|
108
|
+
result = `Tool execution error: ${e.message}`;
|
|
109
|
+
}
|
|
110
|
+
out.push({ name, arguments: args, result });
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** @private */
|
|
116
|
+
_buildToolInstruction(tools) {
|
|
117
|
+
const specs = Object.values(tools).map(t => ({
|
|
118
|
+
name: t.name(),
|
|
119
|
+
description: t.description(),
|
|
120
|
+
schema: t.schema(),
|
|
121
|
+
}));
|
|
122
|
+
return `You have access to the following tools. To request tool execution, respond STRICTLY with JSON of the form {"tool_calls":[{"name":"toolName","arguments":{...}}]} without additional text. Tools: ${JSON.stringify(specs)}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** @private */
|
|
126
|
+
_extractAssistantContent(response) {
|
|
127
|
+
if (response?.choices?.[0]?.message?.content != null) return response.choices[0].message.content;
|
|
128
|
+
if (response?.message?.content != null) return response.message.content;
|
|
129
|
+
if (response?.content?.[0]?.text != null) return response.content[0].text;
|
|
130
|
+
if (response?.output_text != null) return response.output_text;
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** @private */
|
|
135
|
+
_parseToolCalls(content) {
|
|
136
|
+
let candidate = content.trim();
|
|
137
|
+
// Extract from markdown code blocks
|
|
138
|
+
const codeMatch = candidate.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
139
|
+
if (codeMatch) candidate = codeMatch[1].trim();
|
|
140
|
+
|
|
141
|
+
let decoded;
|
|
142
|
+
try {
|
|
143
|
+
decoded = JSON.parse(candidate);
|
|
144
|
+
} catch {
|
|
145
|
+
// Try to extract a JSON object from the content
|
|
146
|
+
const objMatch = candidate.match(/\{[^{}]*\}/s);
|
|
147
|
+
if (objMatch) {
|
|
148
|
+
try { decoded = JSON.parse(objMatch[0]); } catch { return []; }
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (decoded && typeof decoded === 'object') {
|
|
153
|
+
if (Array.isArray(decoded.tool_calls)) return decoded.tool_calls;
|
|
154
|
+
if (Array.isArray(decoded) && decoded[0] && 'name' in decoded[0]) return decoded;
|
|
155
|
+
}
|
|
156
|
+
return [];
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
module.exports = ToolChatRunner;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ToolRegistry
|
|
5
|
+
* Registry of named tools for function calling.
|
|
6
|
+
*/
|
|
7
|
+
class ToolRegistry {
|
|
8
|
+
constructor() {
|
|
9
|
+
/** @type {Map<string, import('../Contracts/ToolContract')>} */
|
|
10
|
+
this._tools = new Map();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Register a tool.
|
|
15
|
+
* @param {import('../Contracts/ToolContract')} tool
|
|
16
|
+
* @returns {this}
|
|
17
|
+
*/
|
|
18
|
+
register(tool) {
|
|
19
|
+
this._tools.set(tool.name(), tool);
|
|
20
|
+
return this;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Get a tool by name.
|
|
25
|
+
* @param {string} name
|
|
26
|
+
* @returns {import('../Contracts/ToolContract')|null}
|
|
27
|
+
*/
|
|
28
|
+
get(name) {
|
|
29
|
+
return this._tools.get(name) || null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Get all registered tools as an object keyed by name.
|
|
34
|
+
* @returns {Object<string, import('../Contracts/ToolContract')>}
|
|
35
|
+
*/
|
|
36
|
+
all() {
|
|
37
|
+
const out = {};
|
|
38
|
+
for (const [k, v] of this._tools) {
|
|
39
|
+
out[k] = v;
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Check if a tool is registered.
|
|
46
|
+
* @param {string} name
|
|
47
|
+
* @returns {boolean}
|
|
48
|
+
*/
|
|
49
|
+
has(name) {
|
|
50
|
+
return this._tools.has(name);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Number of registered tools.
|
|
55
|
+
* @returns {number}
|
|
56
|
+
*/
|
|
57
|
+
get size() {
|
|
58
|
+
return this._tools.size;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = ToolRegistry;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const ToolContract = require('../Contracts/ToolContract');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* SystemInfoTool
|
|
8
|
+
* Built-in example tool returning system information.
|
|
9
|
+
*/
|
|
10
|
+
class SystemInfoTool extends ToolContract {
|
|
11
|
+
name() { return 'system_info'; }
|
|
12
|
+
description() { return 'Returns system information (node_version, platform, arch)'; }
|
|
13
|
+
schema() { return { type: 'object', properties: {}, required: [] }; }
|
|
14
|
+
|
|
15
|
+
execute(_args) {
|
|
16
|
+
return JSON.stringify({
|
|
17
|
+
node_version: process.version,
|
|
18
|
+
platform: os.platform(),
|
|
19
|
+
arch: os.arch(),
|
|
20
|
+
uptime: os.uptime(),
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
module.exports = SystemInfoTool;
|