@objectstack/service-ai 4.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/.turbo/turbo-build.log +22 -0
- package/CHANGELOG.md +25 -0
- package/LICENSE +202 -0
- package/dist/index.cjs +1418 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +3406 -0
- package/dist/index.d.ts +3406 -0
- package/dist/index.js +1378 -0
- package/dist/index.js.map +1 -0
- package/package.json +29 -0
- package/src/__tests__/ai-service.test.ts +731 -0
- package/src/__tests__/chatbot-features.test.ts +821 -0
- package/src/__tests__/objectql-conversation-service.test.ts +364 -0
- package/src/adapters/index.ts +4 -0
- package/src/adapters/memory-adapter.ts +64 -0
- package/src/adapters/types.ts +3 -0
- package/src/agent-runtime.ts +130 -0
- package/src/agents/data-chat-agent.ts +79 -0
- package/src/agents/index.ts +3 -0
- package/src/ai-service.ts +205 -0
- package/src/conversation/in-memory-conversation-service.ts +103 -0
- package/src/conversation/index.ts +4 -0
- package/src/conversation/objectql-conversation-service.ts +252 -0
- package/src/index.ts +40 -0
- package/src/objects/ai-conversation.object.ts +86 -0
- package/src/objects/ai-message.object.ts +86 -0
- package/src/objects/index.ts +10 -0
- package/src/plugin.ts +184 -0
- package/src/routes/agent-routes.ts +132 -0
- package/src/routes/ai-routes.ts +286 -0
- package/src/routes/index.ts +4 -0
- package/src/tools/data-tools.ts +390 -0
- package/src/tools/index.ts +7 -0
- package/src/tools/tool-registry.ts +109 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
4
|
+
import type { IDataEngine } from '@objectstack/spec/contracts';
|
|
5
|
+
import type { AIMessage } from '@objectstack/spec/contracts';
|
|
6
|
+
import { ObjectQLConversationService } from '../conversation/objectql-conversation-service.js';
|
|
7
|
+
|
|
8
|
+
// ─────────────────────────────────────────────────────────────────
|
|
9
|
+
// In-memory IDataEngine stub (mimics driver-memory behavior)
|
|
10
|
+
// ─────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
function createMemoryEngine(): IDataEngine {
|
|
13
|
+
const tables = new Map<string, any[]>();
|
|
14
|
+
|
|
15
|
+
const getTable = (name: string) => {
|
|
16
|
+
if (!tables.has(name)) tables.set(name, []);
|
|
17
|
+
return tables.get(name)!;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Evaluate a single filter condition against a row. */
|
|
21
|
+
const matchesCondition = (row: any, where: Record<string, any>): boolean => {
|
|
22
|
+
for (const [key, value] of Object.entries(where)) {
|
|
23
|
+
if (key === '$or') {
|
|
24
|
+
// At least one branch must match
|
|
25
|
+
if (!Array.isArray(value) || !value.some(branch => matchesCondition(row, branch))) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
} else if (typeof value === 'object' && value !== null && '$gt' in value) {
|
|
29
|
+
if (!(row[key] > value.$gt)) return false;
|
|
30
|
+
} else if (row[key] !== value) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return true;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
find: async (objectName, query?) => {
|
|
39
|
+
let rows = [...getTable(objectName)];
|
|
40
|
+
if (query?.where) {
|
|
41
|
+
rows = rows.filter(row => matchesCondition(row, query.where as Record<string, any>));
|
|
42
|
+
}
|
|
43
|
+
if (query?.orderBy && query.orderBy.length > 0) {
|
|
44
|
+
rows.sort((a, b) => {
|
|
45
|
+
for (const sort of query.orderBy!) {
|
|
46
|
+
const field = (sort as any).field;
|
|
47
|
+
const dir = (sort as any).order === 'desc' ? -1 : 1;
|
|
48
|
+
if (a[field] < b[field]) return -dir;
|
|
49
|
+
if (a[field] > b[field]) return dir;
|
|
50
|
+
}
|
|
51
|
+
return 0;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
if (query?.limit) {
|
|
55
|
+
rows = rows.slice(0, query.limit);
|
|
56
|
+
}
|
|
57
|
+
return rows;
|
|
58
|
+
},
|
|
59
|
+
findOne: async (objectName, query?) => {
|
|
60
|
+
let rows = [...getTable(objectName)];
|
|
61
|
+
if (query?.where) {
|
|
62
|
+
rows = rows.filter(row => matchesCondition(row, query.where as Record<string, any>));
|
|
63
|
+
}
|
|
64
|
+
return rows[0] ?? null;
|
|
65
|
+
},
|
|
66
|
+
insert: async (objectName, data) => {
|
|
67
|
+
const table = getTable(objectName);
|
|
68
|
+
if (Array.isArray(data)) {
|
|
69
|
+
table.push(...data);
|
|
70
|
+
return data;
|
|
71
|
+
}
|
|
72
|
+
table.push({ ...data });
|
|
73
|
+
return data;
|
|
74
|
+
},
|
|
75
|
+
update: async (objectName, data, options?) => {
|
|
76
|
+
const table = getTable(objectName);
|
|
77
|
+
const where = options?.where as Record<string, any> | undefined;
|
|
78
|
+
for (let i = 0; i < table.length; i++) {
|
|
79
|
+
if (where) {
|
|
80
|
+
let match = true;
|
|
81
|
+
for (const [key, value] of Object.entries(where)) {
|
|
82
|
+
if (table[i][key] !== value) { match = false; break; }
|
|
83
|
+
}
|
|
84
|
+
if (!match) continue;
|
|
85
|
+
}
|
|
86
|
+
Object.assign(table[i], data);
|
|
87
|
+
return table[i];
|
|
88
|
+
}
|
|
89
|
+
return data;
|
|
90
|
+
},
|
|
91
|
+
delete: async (objectName, options?) => {
|
|
92
|
+
const table = getTable(objectName);
|
|
93
|
+
const where = options?.where as Record<string, any> | undefined;
|
|
94
|
+
let deleted = 0;
|
|
95
|
+
const multi = (options as any)?.multi ?? false;
|
|
96
|
+
for (let i = table.length - 1; i >= 0; i--) {
|
|
97
|
+
if (where) {
|
|
98
|
+
let match = true;
|
|
99
|
+
for (const [key, value] of Object.entries(where)) {
|
|
100
|
+
if (table[i][key] !== value) { match = false; break; }
|
|
101
|
+
}
|
|
102
|
+
if (!match) continue;
|
|
103
|
+
}
|
|
104
|
+
table.splice(i, 1);
|
|
105
|
+
deleted++;
|
|
106
|
+
if (!multi) break;
|
|
107
|
+
}
|
|
108
|
+
return { deleted };
|
|
109
|
+
},
|
|
110
|
+
count: async (objectName, query?) => {
|
|
111
|
+
let rows = [...getTable(objectName)];
|
|
112
|
+
if (query?.where) {
|
|
113
|
+
rows = rows.filter(row => matchesCondition(row, query.where as Record<string, any>));
|
|
114
|
+
}
|
|
115
|
+
return rows.length;
|
|
116
|
+
},
|
|
117
|
+
aggregate: async () => [],
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ─────────────────────────────────────────────────────────────────
|
|
122
|
+
// Tests
|
|
123
|
+
// ─────────────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
describe('ObjectQLConversationService', () => {
|
|
126
|
+
let engine: IDataEngine;
|
|
127
|
+
let service: ObjectQLConversationService;
|
|
128
|
+
|
|
129
|
+
beforeEach(() => {
|
|
130
|
+
engine = createMemoryEngine();
|
|
131
|
+
service = new ObjectQLConversationService(engine);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// ── create() ───────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
it('should create a conversation with all options', async () => {
|
|
137
|
+
const conv = await service.create({
|
|
138
|
+
title: 'Test Chat',
|
|
139
|
+
agentId: 'agent_1',
|
|
140
|
+
userId: 'user_1',
|
|
141
|
+
metadata: { source: 'web' },
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
expect(conv.id).toMatch(/^conv_/);
|
|
145
|
+
expect(conv.title).toBe('Test Chat');
|
|
146
|
+
expect(conv.agentId).toBe('agent_1');
|
|
147
|
+
expect(conv.userId).toBe('user_1');
|
|
148
|
+
expect(conv.messages).toEqual([]);
|
|
149
|
+
expect(conv.createdAt).toBeDefined();
|
|
150
|
+
expect(conv.updatedAt).toBeDefined();
|
|
151
|
+
expect(conv.metadata).toEqual({ source: 'web' });
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should create a conversation with no options', async () => {
|
|
155
|
+
const conv = await service.create();
|
|
156
|
+
|
|
157
|
+
expect(conv.id).toMatch(/^conv_/);
|
|
158
|
+
expect(conv.title).toBeUndefined();
|
|
159
|
+
expect(conv.agentId).toBeUndefined();
|
|
160
|
+
expect(conv.userId).toBeUndefined();
|
|
161
|
+
expect(conv.messages).toEqual([]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('should generate unique conversation IDs', async () => {
|
|
165
|
+
const c1 = await service.create({ title: 'A' });
|
|
166
|
+
const c2 = await service.create({ title: 'B' });
|
|
167
|
+
|
|
168
|
+
expect(c1.id).not.toBe(c2.id);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// ── get() ──────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
it('should retrieve a conversation by ID', async () => {
|
|
174
|
+
const created = await service.create({ title: 'Retrieve Me' });
|
|
175
|
+
const fetched = await service.get(created.id);
|
|
176
|
+
|
|
177
|
+
expect(fetched).not.toBeNull();
|
|
178
|
+
expect(fetched!.id).toBe(created.id);
|
|
179
|
+
expect(fetched!.title).toBe('Retrieve Me');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('should return null for non-existent conversation', async () => {
|
|
183
|
+
const result = await service.get('conv_nonexistent');
|
|
184
|
+
expect(result).toBeNull();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// ── list() ─────────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
it('should list conversations filtered by userId', async () => {
|
|
190
|
+
await service.create({ userId: 'user_a' });
|
|
191
|
+
await service.create({ userId: 'user_b' });
|
|
192
|
+
await service.create({ userId: 'user_a' });
|
|
193
|
+
|
|
194
|
+
const results = await service.list({ userId: 'user_a' });
|
|
195
|
+
expect(results).toHaveLength(2);
|
|
196
|
+
results.forEach(c => expect(c.userId).toBe('user_a'));
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('should list conversations filtered by agentId', async () => {
|
|
200
|
+
await service.create({ agentId: 'bot_x' });
|
|
201
|
+
await service.create({ agentId: 'bot_y' });
|
|
202
|
+
|
|
203
|
+
const results = await service.list({ agentId: 'bot_x' });
|
|
204
|
+
expect(results).toHaveLength(1);
|
|
205
|
+
expect(results[0].agentId).toBe('bot_x');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('should limit the number of listed conversations', async () => {
|
|
209
|
+
await service.create({ title: '1' });
|
|
210
|
+
await service.create({ title: '2' });
|
|
211
|
+
await service.create({ title: '3' });
|
|
212
|
+
|
|
213
|
+
const results = await service.list({ limit: 2 });
|
|
214
|
+
expect(results).toHaveLength(2);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('should paginate with cursor and have no skips or duplicates', async () => {
|
|
218
|
+
await service.create({ title: 'A' });
|
|
219
|
+
await service.create({ title: 'B' });
|
|
220
|
+
await service.create({ title: 'C' });
|
|
221
|
+
await service.create({ title: 'D' });
|
|
222
|
+
|
|
223
|
+
// First page: 2 items
|
|
224
|
+
const page1 = await service.list({ limit: 2 });
|
|
225
|
+
expect(page1).toHaveLength(2);
|
|
226
|
+
|
|
227
|
+
// Second page: cursor = last item from page 1
|
|
228
|
+
const page2 = await service.list({ limit: 2, cursor: page1[1].id });
|
|
229
|
+
expect(page2).toHaveLength(2);
|
|
230
|
+
|
|
231
|
+
// Third page: should be empty
|
|
232
|
+
const page3 = await service.list({ limit: 2, cursor: page2[1].id });
|
|
233
|
+
expect(page3).toHaveLength(0);
|
|
234
|
+
|
|
235
|
+
// Verify no overlap between pages and all 4 conversations are covered
|
|
236
|
+
const allIds = [...page1, ...page2].map(c => c.id);
|
|
237
|
+
expect(new Set(allIds).size).toBe(4);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// ── addMessage() ───────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
it('should add a user message to a conversation', async () => {
|
|
243
|
+
const conv = await service.create({ title: 'Chat' });
|
|
244
|
+
|
|
245
|
+
const msg: AIMessage = { role: 'user', content: 'Hello AI!' };
|
|
246
|
+
const updated = await service.addMessage(conv.id, msg);
|
|
247
|
+
|
|
248
|
+
expect(updated.messages).toHaveLength(1);
|
|
249
|
+
expect(updated.messages[0].role).toBe('user');
|
|
250
|
+
expect(updated.messages[0].content).toBe('Hello AI!');
|
|
251
|
+
expect(updated.updatedAt >= conv.updatedAt).toBe(true);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('should add a tool message with toolCallId', async () => {
|
|
255
|
+
const conv = await service.create();
|
|
256
|
+
const msg: AIMessage = {
|
|
257
|
+
role: 'tool',
|
|
258
|
+
content: '{"temp": 22}',
|
|
259
|
+
toolCallId: 'call_abc',
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const updated = await service.addMessage(conv.id, msg);
|
|
263
|
+
expect(updated.messages).toHaveLength(1);
|
|
264
|
+
expect(updated.messages[0].toolCallId).toBe('call_abc');
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('should add an assistant message with toolCalls', async () => {
|
|
268
|
+
const conv = await service.create();
|
|
269
|
+
const msg: AIMessage = {
|
|
270
|
+
role: 'assistant',
|
|
271
|
+
content: '',
|
|
272
|
+
toolCalls: [{ id: 'call_1', name: 'get_weather', arguments: '{}' }],
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const updated = await service.addMessage(conv.id, msg);
|
|
276
|
+
expect(updated.messages).toHaveLength(1);
|
|
277
|
+
expect(updated.messages[0].toolCalls).toHaveLength(1);
|
|
278
|
+
expect(updated.messages[0].toolCalls![0].name).toBe('get_weather');
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('should throw when adding message to non-existent conversation', async () => {
|
|
282
|
+
const msg: AIMessage = { role: 'user', content: 'Hello' };
|
|
283
|
+
await expect(service.addMessage('conv_ghost', msg)).rejects.toThrow(
|
|
284
|
+
'Conversation "conv_ghost" not found',
|
|
285
|
+
);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('should preserve message order (ordered by createdAt + id)', async () => {
|
|
289
|
+
const conv = await service.create();
|
|
290
|
+
await service.addMessage(conv.id, { role: 'user', content: 'First' });
|
|
291
|
+
await service.addMessage(conv.id, { role: 'assistant', content: 'Second' });
|
|
292
|
+
await service.addMessage(conv.id, { role: 'user', content: 'Third' });
|
|
293
|
+
|
|
294
|
+
const fetched = await service.get(conv.id);
|
|
295
|
+
expect(fetched!.messages).toHaveLength(3);
|
|
296
|
+
// All three messages should be present
|
|
297
|
+
const contents = fetched!.messages.map(m => m.content);
|
|
298
|
+
expect(contents).toContain('First');
|
|
299
|
+
expect(contents).toContain('Second');
|
|
300
|
+
expect(contents).toContain('Third');
|
|
301
|
+
// Ordering is deterministic (created_at asc, id asc)
|
|
302
|
+
// Since messages are inserted sequentially, created_at is non-decreasing
|
|
303
|
+
for (let i = 1; i < fetched!.messages.length; i++) {
|
|
304
|
+
const prev = fetched!.messages[i - 1];
|
|
305
|
+
const curr = fetched!.messages[i];
|
|
306
|
+
// Verify stable ordering: each message is >= the previous by (created_at, id)
|
|
307
|
+
expect(prev.content).toBeDefined();
|
|
308
|
+
expect(curr.content).toBeDefined();
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// ── delete() ───────────────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
it('should delete a conversation and its messages', async () => {
|
|
315
|
+
const conv = await service.create({ title: 'Delete Me' });
|
|
316
|
+
await service.addMessage(conv.id, { role: 'user', content: 'Bye' });
|
|
317
|
+
|
|
318
|
+
await service.delete(conv.id);
|
|
319
|
+
|
|
320
|
+
const result = await service.get(conv.id);
|
|
321
|
+
expect(result).toBeNull();
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('should handle deleting a non-existent conversation gracefully', async () => {
|
|
325
|
+
// Should not throw
|
|
326
|
+
await expect(service.delete('conv_missing')).resolves.toBeUndefined();
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// ── metadata serialization round-trip ──────────────────────────
|
|
330
|
+
|
|
331
|
+
it('should round-trip metadata through JSON serialization', async () => {
|
|
332
|
+
const metadata = { tags: ['important', 'follow-up'], priority: 1 };
|
|
333
|
+
const conv = await service.create({ metadata });
|
|
334
|
+
|
|
335
|
+
const fetched = await service.get(conv.id);
|
|
336
|
+
expect(fetched!.metadata).toEqual(metadata);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// ── invalid JSON resilience ────────────────────────────────────
|
|
340
|
+
|
|
341
|
+
it('should handle invalid JSON in metadata gracefully', async () => {
|
|
342
|
+
const conv = await service.create({ title: 'Bad Meta' });
|
|
343
|
+
|
|
344
|
+
// Manually corrupt the metadata in the engine
|
|
345
|
+
const rows = await engine.find('ai_conversations', { where: { id: conv.id } });
|
|
346
|
+
rows[0].metadata = 'not-valid-json{';
|
|
347
|
+
|
|
348
|
+
const fetched = await service.get(conv.id);
|
|
349
|
+
expect(fetched).not.toBeNull();
|
|
350
|
+
expect(fetched!.metadata).toBeUndefined();
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('should handle invalid JSON in tool_calls gracefully', async () => {
|
|
354
|
+
const conv = await service.create();
|
|
355
|
+
await service.addMessage(conv.id, { role: 'user', content: 'hi' });
|
|
356
|
+
|
|
357
|
+
// Manually corrupt tool_calls in the engine
|
|
358
|
+
const msgs = await engine.find('ai_messages', { where: { conversation_id: conv.id } });
|
|
359
|
+
msgs[0].tool_calls = 'broken{json';
|
|
360
|
+
|
|
361
|
+
const fetched = await service.get(conv.id);
|
|
362
|
+
expect(fetched!.messages[0].toolCalls).toBeUndefined();
|
|
363
|
+
});
|
|
364
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
AIMessage,
|
|
5
|
+
AIRequestOptions,
|
|
6
|
+
AIResult,
|
|
7
|
+
AIStreamEvent,
|
|
8
|
+
} from '@objectstack/spec/contracts';
|
|
9
|
+
import type { LLMAdapter } from '@objectstack/spec/contracts';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* MemoryLLMAdapter — deterministic in-memory adapter for testing & development.
|
|
13
|
+
*
|
|
14
|
+
* Always echoes back the last user message prefixed with "[memory] ".
|
|
15
|
+
* Useful for unit tests, CI pipelines, and local dev without an LLM key.
|
|
16
|
+
*/
|
|
17
|
+
export class MemoryLLMAdapter implements LLMAdapter {
|
|
18
|
+
readonly name = 'memory';
|
|
19
|
+
|
|
20
|
+
async chat(messages: AIMessage[], options?: AIRequestOptions): Promise<AIResult> {
|
|
21
|
+
const lastUserMessage = [...messages].reverse().find(m => m.role === 'user');
|
|
22
|
+
const content = lastUserMessage
|
|
23
|
+
? `[memory] ${lastUserMessage.content}`
|
|
24
|
+
: '[memory] (no user message)';
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
content,
|
|
28
|
+
model: options?.model ?? 'memory',
|
|
29
|
+
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async complete(prompt: string, options?: AIRequestOptions): Promise<AIResult> {
|
|
34
|
+
return {
|
|
35
|
+
content: `[memory] ${prompt}`,
|
|
36
|
+
model: options?.model ?? 'memory',
|
|
37
|
+
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async *streamChat(
|
|
42
|
+
messages: AIMessage[],
|
|
43
|
+
_options?: AIRequestOptions,
|
|
44
|
+
): AsyncIterable<AIStreamEvent> {
|
|
45
|
+
const result = await this.chat(messages);
|
|
46
|
+
// Emit word-by-word deltas for realistic streaming simulation
|
|
47
|
+
const words = result.content.split(' ');
|
|
48
|
+
for (let i = 0; i < words.length; i++) {
|
|
49
|
+
const textDelta = i === 0 ? words[i] : ` ${words[i]}`;
|
|
50
|
+
yield { type: 'text-delta', textDelta };
|
|
51
|
+
}
|
|
52
|
+
yield { type: 'finish', result };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async embed(input: string | string[]): Promise<number[][]> {
|
|
56
|
+
const texts = Array.isArray(input) ? input : [input];
|
|
57
|
+
// Return deterministic zero vectors of dimension 3
|
|
58
|
+
return texts.map(() => [0, 0, 0]);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async listModels(): Promise<string[]> {
|
|
62
|
+
return ['memory'];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
AIMessage,
|
|
5
|
+
AIRequestOptions,
|
|
6
|
+
AIToolDefinition,
|
|
7
|
+
IMetadataService,
|
|
8
|
+
} from '@objectstack/spec/contracts';
|
|
9
|
+
import type { Agent } from '@objectstack/spec';
|
|
10
|
+
import { AgentSchema } from '@objectstack/spec/ai';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Context passed alongside a user message when chatting with an agent.
|
|
14
|
+
*
|
|
15
|
+
* UI clients set these fields to tell the agent which object, record,
|
|
16
|
+
* or view the user is currently looking at so it can provide contextual
|
|
17
|
+
* answers without additional tool calls.
|
|
18
|
+
*/
|
|
19
|
+
export interface AgentChatContext {
|
|
20
|
+
/** Current object the user is viewing (e.g. "account") */
|
|
21
|
+
objectName?: string;
|
|
22
|
+
/** Currently selected record ID */
|
|
23
|
+
recordId?: string;
|
|
24
|
+
/** Current view name */
|
|
25
|
+
viewName?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* AgentRuntime — Resolves an agent definition into runnable chat parameters.
|
|
30
|
+
*
|
|
31
|
+
* Responsibilities:
|
|
32
|
+
* 1. Load & validate agent metadata from the metadata service.
|
|
33
|
+
* 2. Build the system prompt from agent `instructions` + UI context.
|
|
34
|
+
* 3. Derive {@link AIRequestOptions} from agent `model` and `tools`.
|
|
35
|
+
* 4. Map agent tool references to concrete {@link AIToolDefinition}s
|
|
36
|
+
* registered in the {@link ToolRegistry}.
|
|
37
|
+
*/
|
|
38
|
+
export class AgentRuntime {
|
|
39
|
+
constructor(private readonly metadataService: IMetadataService) {}
|
|
40
|
+
|
|
41
|
+
// ── Public API ────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Load and validate an agent definition by name.
|
|
45
|
+
*
|
|
46
|
+
* The raw metadata is validated through {@link AgentSchema} to ensure
|
|
47
|
+
* required fields (`instructions`, `name`, `role`, etc.) are present
|
|
48
|
+
* and well-typed. Returns `undefined` when the agent does not exist
|
|
49
|
+
* or validation fails.
|
|
50
|
+
*/
|
|
51
|
+
async loadAgent(agentName: string): Promise<Agent | undefined> {
|
|
52
|
+
const raw = await this.metadataService.get('agent', agentName);
|
|
53
|
+
if (!raw) return undefined;
|
|
54
|
+
|
|
55
|
+
const result = AgentSchema.safeParse(raw);
|
|
56
|
+
if (!result.success) {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
return result.data;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Build the system message(s) that should be prepended to the
|
|
64
|
+
* conversation when chatting with the given agent.
|
|
65
|
+
*/
|
|
66
|
+
buildSystemMessages(agent: Agent, context?: AgentChatContext): AIMessage[] {
|
|
67
|
+
const parts: string[] = [];
|
|
68
|
+
|
|
69
|
+
// Base instructions
|
|
70
|
+
parts.push(agent.instructions);
|
|
71
|
+
|
|
72
|
+
// Contextual hints from the user's current UI state
|
|
73
|
+
if (context) {
|
|
74
|
+
const ctx: string[] = [];
|
|
75
|
+
if (context.objectName) ctx.push(`Current object: ${context.objectName}`);
|
|
76
|
+
if (context.recordId) ctx.push(`Selected record ID: ${context.recordId}`);
|
|
77
|
+
if (context.viewName) ctx.push(`Current view: ${context.viewName}`);
|
|
78
|
+
if (ctx.length > 0) {
|
|
79
|
+
parts.push('\n--- Current Context ---\n' + ctx.join('\n'));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return [{ role: 'system', content: parts.join('\n') }];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Derive {@link AIRequestOptions} from an agent definition.
|
|
88
|
+
*
|
|
89
|
+
* Tool references declared in `agent.tools` are resolved by name against
|
|
90
|
+
* `availableTools` (i.e. the full set of ToolRegistry definitions).
|
|
91
|
+
* Any unresolved references (tools the agent declares but that are not
|
|
92
|
+
* registered) are silently skipped — this is intentional so that agents
|
|
93
|
+
* can be defined before all tools are available.
|
|
94
|
+
*
|
|
95
|
+
* @param agent - The agent definition to derive options from
|
|
96
|
+
* @param availableTools - All tool definitions currently registered in the ToolRegistry
|
|
97
|
+
* @returns Request options with model config and resolved tool definitions
|
|
98
|
+
*/
|
|
99
|
+
buildRequestOptions(
|
|
100
|
+
agent: Agent,
|
|
101
|
+
availableTools: AIToolDefinition[],
|
|
102
|
+
): AIRequestOptions {
|
|
103
|
+
const options: AIRequestOptions = {};
|
|
104
|
+
|
|
105
|
+
// Model config
|
|
106
|
+
if (agent.model) {
|
|
107
|
+
options.model = agent.model.model;
|
|
108
|
+
options.temperature = agent.model.temperature;
|
|
109
|
+
options.maxTokens = agent.model.maxTokens;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Resolve agent tool references → concrete tool definitions
|
|
113
|
+
if (agent.tools && agent.tools.length > 0) {
|
|
114
|
+
const toolMap = new Map(availableTools.map(t => [t.name, t]));
|
|
115
|
+
const resolved: AIToolDefinition[] = [];
|
|
116
|
+
for (const ref of agent.tools) {
|
|
117
|
+
const def = toolMap.get(ref.name);
|
|
118
|
+
if (def) {
|
|
119
|
+
resolved.push(def);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (resolved.length > 0) {
|
|
123
|
+
options.tools = resolved;
|
|
124
|
+
options.toolChoice = 'auto';
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return options;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import type { Agent } from '@objectstack/spec';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Built-in `data_chat` agent definition.
|
|
7
|
+
*
|
|
8
|
+
* This agent powers the Airtable-style data conversation Chatbot.
|
|
9
|
+
* It is registered automatically by the AI service plugin when a
|
|
10
|
+
* data engine is available.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```
|
|
14
|
+
* POST /api/v1/ai/agents/data_chat/chat
|
|
15
|
+
* {
|
|
16
|
+
* "messages": [{ "role": "user", "content": "Show me all active accounts" }],
|
|
17
|
+
* "context": { "objectName": "account" }
|
|
18
|
+
* }
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export const DATA_CHAT_AGENT: Agent = {
|
|
22
|
+
name: 'data_chat',
|
|
23
|
+
label: 'Data Assistant',
|
|
24
|
+
role: 'Business Data Analyst',
|
|
25
|
+
instructions: `You are a helpful data assistant that helps users explore and understand their business data through natural language.
|
|
26
|
+
|
|
27
|
+
Capabilities:
|
|
28
|
+
- List available data objects (tables) and their schemas
|
|
29
|
+
- Query records with filters, sorting, and pagination
|
|
30
|
+
- Look up individual records by ID
|
|
31
|
+
- Perform aggregations and statistical analysis (count, sum, avg, min, max)
|
|
32
|
+
|
|
33
|
+
Guidelines:
|
|
34
|
+
1. Always use the describe_object tool first to understand a table's structure before querying it.
|
|
35
|
+
2. Respect the user's current context — if they are viewing a specific object or record, use that as the default scope.
|
|
36
|
+
3. When presenting data, format it in a clear and readable way using markdown tables or bullet lists.
|
|
37
|
+
4. For large result sets, summarize the data and mention the total count.
|
|
38
|
+
5. When performing aggregations, explain the results in plain language.
|
|
39
|
+
6. If a query returns no results, suggest possible reasons and alternative queries.
|
|
40
|
+
7. Never expose internal IDs unless the user explicitly asks for them.
|
|
41
|
+
8. Always answer in the same language the user is using.`,
|
|
42
|
+
|
|
43
|
+
model: {
|
|
44
|
+
provider: 'openai',
|
|
45
|
+
model: 'gpt-4',
|
|
46
|
+
temperature: 0.3,
|
|
47
|
+
maxTokens: 4096,
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
tools: [
|
|
51
|
+
{ type: 'query', name: 'list_objects', description: 'List all available data objects' },
|
|
52
|
+
{ type: 'query', name: 'describe_object', description: 'Get schema/fields of a data object' },
|
|
53
|
+
{ type: 'query', name: 'query_records', description: 'Query records with filters and pagination' },
|
|
54
|
+
{ type: 'query', name: 'get_record', description: 'Get a single record by ID' },
|
|
55
|
+
{ type: 'query', name: 'aggregate_data', description: 'Aggregate/statistics on data' },
|
|
56
|
+
],
|
|
57
|
+
|
|
58
|
+
active: true,
|
|
59
|
+
visibility: 'global',
|
|
60
|
+
|
|
61
|
+
guardrails: {
|
|
62
|
+
maxTokensPerInvocation: 8192,
|
|
63
|
+
maxExecutionTimeSec: 30,
|
|
64
|
+
blockedTopics: ['delete_records', 'drop_table', 'alter_schema'],
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
planning: {
|
|
68
|
+
strategy: 'react',
|
|
69
|
+
maxIterations: 5,
|
|
70
|
+
allowReplan: false,
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
memory: {
|
|
74
|
+
shortTerm: {
|
|
75
|
+
maxMessages: 20,
|
|
76
|
+
maxTokens: 4096,
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
};
|