@objectstack/service-ai 4.0.4 → 4.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.
Files changed (52) hide show
  1. package/dist/index.cjs +1176 -134
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +1752 -431
  4. package/dist/index.d.ts +1752 -431
  5. package/dist/index.js +1160 -127
  6. package/dist/index.js.map +1 -1
  7. package/package.json +35 -8
  8. package/.turbo/turbo-build.log +0 -22
  9. package/CHANGELOG.md +0 -61
  10. package/src/__tests__/ai-service.test.ts +0 -981
  11. package/src/__tests__/auth-and-toolcalling.test.ts +0 -677
  12. package/src/__tests__/chatbot-features.test.ts +0 -1116
  13. package/src/__tests__/metadata-tools.test.ts +0 -970
  14. package/src/__tests__/objectql-conversation-service.test.ts +0 -382
  15. package/src/__tests__/tool-routes.test.ts +0 -191
  16. package/src/__tests__/vercel-stream-encoder.test.ts +0 -310
  17. package/src/adapters/index.ts +0 -6
  18. package/src/adapters/memory-adapter.ts +0 -72
  19. package/src/adapters/types.ts +0 -3
  20. package/src/adapters/vercel-adapter.ts +0 -148
  21. package/src/agent-runtime.ts +0 -154
  22. package/src/agents/data-chat-agent.ts +0 -79
  23. package/src/agents/index.ts +0 -4
  24. package/src/agents/metadata-assistant-agent.ts +0 -87
  25. package/src/ai-service.ts +0 -364
  26. package/src/conversation/in-memory-conversation-service.ts +0 -103
  27. package/src/conversation/index.ts +0 -4
  28. package/src/conversation/objectql-conversation-service.ts +0 -301
  29. package/src/index.ts +0 -60
  30. package/src/objects/ai-conversation.object.ts +0 -86
  31. package/src/objects/ai-message.object.ts +0 -86
  32. package/src/objects/index.ts +0 -10
  33. package/src/plugin.ts +0 -391
  34. package/src/routes/agent-routes.ts +0 -190
  35. package/src/routes/ai-routes.ts +0 -439
  36. package/src/routes/index.ts +0 -5
  37. package/src/routes/message-utils.ts +0 -90
  38. package/src/routes/tool-routes.ts +0 -142
  39. package/src/stream/index.ts +0 -3
  40. package/src/stream/vercel-stream-encoder.ts +0 -153
  41. package/src/tools/add-field.tool.ts +0 -70
  42. package/src/tools/create-object.tool.ts +0 -66
  43. package/src/tools/data-tools.ts +0 -293
  44. package/src/tools/delete-field.tool.ts +0 -38
  45. package/src/tools/describe-object.tool.ts +0 -31
  46. package/src/tools/index.ts +0 -18
  47. package/src/tools/list-objects.tool.ts +0 -34
  48. package/src/tools/metadata-tools.ts +0 -430
  49. package/src/tools/modify-field.tool.ts +0 -44
  50. package/src/tools/tool-registry.ts +0 -132
  51. package/tsconfig.json +0 -17
  52. package/vitest.config.ts +0 -23
@@ -1,382 +0,0 @@
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 { ModelMessage } 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: ModelMessage = { 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: ModelMessage = {
257
- role: 'tool' as const,
258
- content: [{
259
- type: 'tool-result' as const,
260
- toolCallId: 'call_abc',
261
- toolName: 'get_weather',
262
- output: { type: 'text' as const, value: '{"temp": 22}' },
263
- }],
264
- };
265
-
266
- const updated = await service.addMessage(conv.id, msg);
267
- expect(updated.messages).toHaveLength(1);
268
- const firstMsg = updated.messages[0];
269
- if (firstMsg.role === 'tool' && Array.isArray(firstMsg.content)) {
270
- expect(firstMsg.content[0].toolCallId).toBe('call_abc');
271
- } else {
272
- throw new Error('Expected tool message with array content');
273
- }
274
- });
275
-
276
- it('should add an assistant message with toolCalls', async () => {
277
- const conv = await service.create();
278
- const msg: ModelMessage = {
279
- role: 'assistant' as const,
280
- content: [
281
- { type: 'tool-call' as const, toolCallId: 'call_1', toolName: 'get_weather', input: {} },
282
- ],
283
- };
284
-
285
- const updated = await service.addMessage(conv.id, msg);
286
- expect(updated.messages).toHaveLength(1);
287
- const firstMsg = updated.messages[0];
288
- if (firstMsg.role === 'assistant' && Array.isArray(firstMsg.content)) {
289
- const toolCallParts = firstMsg.content.filter((p) => p.type === 'tool-call');
290
- expect(toolCallParts).toHaveLength(1);
291
- expect(toolCallParts[0].toolName).toBe('get_weather');
292
- } else {
293
- throw new Error('Expected assistant message with array content');
294
- }
295
- });
296
-
297
- it('should throw when adding message to non-existent conversation', async () => {
298
- const msg: ModelMessage = { role: 'user', content: 'Hello' };
299
- await expect(service.addMessage('conv_ghost', msg)).rejects.toThrow(
300
- 'Conversation "conv_ghost" not found',
301
- );
302
- });
303
-
304
- it('should preserve message order (ordered by createdAt + id)', async () => {
305
- const conv = await service.create();
306
- await service.addMessage(conv.id, { role: 'user', content: 'First' });
307
- await service.addMessage(conv.id, { role: 'assistant', content: 'Second' });
308
- await service.addMessage(conv.id, { role: 'user', content: 'Third' });
309
-
310
- const fetched = await service.get(conv.id);
311
- expect(fetched!.messages).toHaveLength(3);
312
- // All three messages should be present
313
- const contents = fetched!.messages.map(m => m.content);
314
- expect(contents).toContain('First');
315
- expect(contents).toContain('Second');
316
- expect(contents).toContain('Third');
317
- // Ordering is deterministic (created_at asc, id asc)
318
- // Since messages are inserted sequentially, created_at is non-decreasing
319
- for (let i = 1; i < fetched!.messages.length; i++) {
320
- const prev = fetched!.messages[i - 1];
321
- const curr = fetched!.messages[i];
322
- // Verify stable ordering: each message is >= the previous by (created_at, id)
323
- expect(prev.content).toBeDefined();
324
- expect(curr.content).toBeDefined();
325
- }
326
- });
327
-
328
- // ── delete() ───────────────────────────────────────────────────
329
-
330
- it('should delete a conversation and its messages', async () => {
331
- const conv = await service.create({ title: 'Delete Me' });
332
- await service.addMessage(conv.id, { role: 'user', content: 'Bye' });
333
-
334
- await service.delete(conv.id);
335
-
336
- const result = await service.get(conv.id);
337
- expect(result).toBeNull();
338
- });
339
-
340
- it('should handle deleting a non-existent conversation gracefully', async () => {
341
- // Should not throw
342
- await expect(service.delete('conv_missing')).resolves.toBeUndefined();
343
- });
344
-
345
- // ── metadata serialization round-trip ──────────────────────────
346
-
347
- it('should round-trip metadata through JSON serialization', async () => {
348
- const metadata = { tags: ['important', 'follow-up'], priority: 1 };
349
- const conv = await service.create({ metadata });
350
-
351
- const fetched = await service.get(conv.id);
352
- expect(fetched!.metadata).toEqual(metadata);
353
- });
354
-
355
- // ── invalid JSON resilience ────────────────────────────────────
356
-
357
- it('should handle invalid JSON in metadata gracefully', async () => {
358
- const conv = await service.create({ title: 'Bad Meta' });
359
-
360
- // Manually corrupt the metadata in the engine
361
- const rows = await engine.find('ai_conversations', { where: { id: conv.id } });
362
- rows[0].metadata = 'not-valid-json{';
363
-
364
- const fetched = await service.get(conv.id);
365
- expect(fetched).not.toBeNull();
366
- expect(fetched!.metadata).toBeUndefined();
367
- });
368
-
369
- it('should handle invalid JSON in tool_calls gracefully', async () => {
370
- const conv = await service.create();
371
- await service.addMessage(conv.id, { role: 'assistant', content: 'checking tools' });
372
-
373
- // Manually corrupt tool_calls in the engine
374
- const msgs = await engine.find('ai_messages', { where: { conversation_id: conv.id } });
375
- msgs[0].tool_calls = 'broken{json';
376
-
377
- const fetched = await service.get(conv.id);
378
- // With broken tool_calls, the assistant message should still load with string content
379
- expect(fetched!.messages[0].role).toBe('assistant');
380
- expect(fetched!.messages[0].content).toBe('checking tools');
381
- });
382
- });
@@ -1,191 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import { describe, it, expect, beforeEach, vi } from 'vitest';
4
- import { buildToolRoutes } from '../routes/tool-routes.js';
5
- import { AIService } from '../ai-service.js';
6
- import { InMemoryConversationService } from '../conversation/in-memory-conversation-service.js';
7
- import { ToolRegistry } from '../tools/tool-registry.js';
8
- import type { Logger } from '@objectstack/spec/contracts';
9
-
10
- const silentLogger: Logger = {
11
- debug: vi.fn(),
12
- info: vi.fn(),
13
- warn: vi.fn(),
14
- error: vi.fn(),
15
- fatal: vi.fn(),
16
- };
17
-
18
- describe('Tool Routes', () => {
19
- let aiService: AIService;
20
- let routes: ReturnType<typeof buildToolRoutes>;
21
-
22
- beforeEach(() => {
23
- const conversationService = new InMemoryConversationService();
24
- aiService = new AIService({
25
- adapter: 'memory',
26
- conversationService,
27
- });
28
-
29
- // Register a test tool
30
- aiService.toolRegistry.register(
31
- {
32
- name: 'test_tool',
33
- description: 'A test tool for playground',
34
- parameters: {
35
- type: 'object',
36
- properties: {
37
- message: { type: 'string' },
38
- },
39
- required: ['message'],
40
- },
41
- },
42
- async (params: any) => {
43
- return JSON.stringify({ echo: params.message });
44
- }
45
- );
46
-
47
- routes = buildToolRoutes(aiService, silentLogger);
48
- });
49
-
50
- describe('GET /api/v1/ai/tools', () => {
51
- it('should list all registered tools', async () => {
52
- const listRoute = routes.find(r => r.method === 'GET' && r.path === '/api/v1/ai/tools');
53
- expect(listRoute).toBeDefined();
54
-
55
- const response = await listRoute!.handler({});
56
- expect(response.status).toBe(200);
57
- expect(response.body).toHaveProperty('tools');
58
- expect(Array.isArray((response.body as any).tools)).toBe(true);
59
-
60
- const tools = (response.body as any).tools;
61
- expect(tools.length).toBeGreaterThan(0);
62
- expect(tools.some((t: any) => t.name === 'test_tool')).toBe(true);
63
- });
64
-
65
- it('should require authentication', () => {
66
- const listRoute = routes.find(r => r.method === 'GET' && r.path === '/api/v1/ai/tools');
67
- expect(listRoute?.auth).toBe(true);
68
- expect(listRoute?.permissions).toContain('ai:tools');
69
- });
70
- });
71
-
72
- describe('POST /api/v1/ai/tools/:toolName/execute', () => {
73
- it('should execute a tool with parameters', async () => {
74
- const executeRoute = routes.find(
75
- r => r.method === 'POST' && r.path === '/api/v1/ai/tools/:toolName/execute'
76
- );
77
- expect(executeRoute).toBeDefined();
78
-
79
- const response = await executeRoute!.handler({
80
- params: { toolName: 'test_tool' },
81
- body: {
82
- parameters: { message: 'Hello, Playground!' },
83
- },
84
- });
85
-
86
- expect(response.status).toBe(200);
87
- expect(response.body).toHaveProperty('result');
88
- // Result is a JSON string from the handler
89
- expect((response.body as any).result).toBe('{"echo":"Hello, Playground!"}');
90
- expect((response.body as any).toolName).toBe('test_tool');
91
- expect((response.body as any).duration).toBeTypeOf('number');
92
- });
93
-
94
- it('should return 404 for non-existent tool', async () => {
95
- const executeRoute = routes.find(
96
- r => r.method === 'POST' && r.path === '/api/v1/ai/tools/:toolName/execute'
97
- );
98
-
99
- const response = await executeRoute!.handler({
100
- params: { toolName: 'non_existent_tool' },
101
- body: {
102
- parameters: {},
103
- },
104
- });
105
-
106
- expect(response.status).toBe(404);
107
- expect((response.body as any).error).toContain('not found');
108
- });
109
-
110
- it('should return 400 when toolName is missing', async () => {
111
- const executeRoute = routes.find(
112
- r => r.method === 'POST' && r.path === '/api/v1/ai/tools/:toolName/execute'
113
- );
114
-
115
- const response = await executeRoute!.handler({
116
- body: {
117
- parameters: {},
118
- },
119
- });
120
-
121
- expect(response.status).toBe(400);
122
- expect((response.body as any).error).toContain('toolName');
123
- });
124
-
125
- it('should return 400 when parameters are missing', async () => {
126
- const executeRoute = routes.find(
127
- r => r.method === 'POST' && r.path === '/api/v1/ai/tools/:toolName/execute'
128
- );
129
-
130
- const response = await executeRoute!.handler({
131
- params: { toolName: 'test_tool' },
132
- body: {},
133
- });
134
-
135
- expect(response.status).toBe(400);
136
- expect((response.body as any).error).toContain('parameters');
137
- });
138
-
139
- it('should handle tool execution errors', async () => {
140
- // Register a tool that throws an error
141
- aiService.toolRegistry.register(
142
- {
143
- name: 'error_tool',
144
- description: 'A tool that throws an error',
145
- parameters: { type: 'object', properties: {} },
146
- },
147
- async () => {
148
- throw new Error('Tool execution failed');
149
- }
150
- );
151
-
152
- const executeRoute = routes.find(
153
- r => r.method === 'POST' && r.path === '/api/v1/ai/tools/:toolName/execute'
154
- );
155
-
156
- const response = await executeRoute!.handler({
157
- params: { toolName: 'error_tool' },
158
- body: {
159
- parameters: {},
160
- },
161
- });
162
-
163
- expect(response.status).toBe(500);
164
- expect((response.body as any).error).toContain('Tool execution failed');
165
- expect((response.body as any).duration).toBeTypeOf('number');
166
- });
167
-
168
- it('should require authentication and permissions', () => {
169
- const executeRoute = routes.find(
170
- r => r.method === 'POST' && r.path === '/api/v1/ai/tools/:toolName/execute'
171
- );
172
-
173
- expect(executeRoute?.auth).toBe(true);
174
- expect(executeRoute?.permissions).toContain('ai:tools');
175
- expect(executeRoute?.permissions).toContain('ai:execute');
176
- });
177
- });
178
-
179
- describe('Route Configuration', () => {
180
- it('should register exactly 2 routes', () => {
181
- expect(routes).toHaveLength(2);
182
- });
183
-
184
- it('should have descriptive route descriptions', () => {
185
- routes.forEach(route => {
186
- expect(route.description).toBeTruthy();
187
- expect(route.description.length).toBeGreaterThan(10);
188
- });
189
- });
190
- });
191
- });