@objectstack/service-ai 4.0.1 → 4.0.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.
- package/.turbo/turbo-build.log +11 -11
- package/CHANGELOG.md +9 -0
- package/dist/index.cjs +1120 -66
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +316 -78
- package/dist/index.d.ts +316 -78
- package/dist/index.js +1105 -63
- package/dist/index.js.map +1 -1
- package/package.json +26 -4
- package/src/__tests__/ai-service.test.ts +248 -27
- package/src/__tests__/auth-and-toolcalling.test.ts +30 -28
- package/src/__tests__/chatbot-features.test.ts +229 -82
- package/src/__tests__/metadata-tools.test.ts +964 -0
- package/src/__tests__/objectql-conversation-service.test.ts +34 -16
- package/src/__tests__/vercel-stream-encoder.test.ts +263 -0
- package/src/adapters/index.ts +2 -0
- package/src/adapters/memory-adapter.ts +17 -9
- package/src/adapters/vercel-adapter.ts +148 -0
- package/src/agent-runtime.ts +27 -3
- package/src/agents/index.ts +1 -0
- package/src/agents/metadata-assistant-agent.ts +87 -0
- package/src/ai-service.ts +68 -36
- package/src/conversation/in-memory-conversation-service.ts +2 -2
- package/src/conversation/objectql-conversation-service.ts +67 -18
- package/src/index.ts +21 -2
- package/src/plugin.ts +166 -9
- package/src/routes/agent-routes.ts +26 -3
- package/src/routes/ai-routes.ts +156 -13
- package/src/stream/index.ts +3 -0
- package/src/stream/vercel-stream-encoder.ts +129 -0
- package/src/tools/add-field.tool.ts +70 -0
- package/src/tools/create-object.tool.ts +66 -0
- package/src/tools/delete-field.tool.ts +38 -0
- package/src/tools/describe-metadata-object.tool.ts +32 -0
- package/src/tools/index.ts +12 -1
- package/src/tools/list-metadata-objects.tool.ts +34 -0
- package/src/tools/metadata-tools.ts +430 -0
- package/src/tools/modify-field.tool.ts +44 -0
- package/src/tools/tool-registry.ts +32 -9
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
4
4
|
import type { IDataEngine } from '@objectstack/spec/contracts';
|
|
5
|
-
import type {
|
|
5
|
+
import type { ModelMessage } from '@objectstack/spec/contracts';
|
|
6
6
|
import { ObjectQLConversationService } from '../conversation/objectql-conversation-service.js';
|
|
7
7
|
|
|
8
8
|
// ─────────────────────────────────────────────────────────────────
|
|
@@ -242,7 +242,7 @@ describe('ObjectQLConversationService', () => {
|
|
|
242
242
|
it('should add a user message to a conversation', async () => {
|
|
243
243
|
const conv = await service.create({ title: 'Chat' });
|
|
244
244
|
|
|
245
|
-
const msg:
|
|
245
|
+
const msg: ModelMessage = { role: 'user', content: 'Hello AI!' };
|
|
246
246
|
const updated = await service.addMessage(conv.id, msg);
|
|
247
247
|
|
|
248
248
|
expect(updated.messages).toHaveLength(1);
|
|
@@ -253,33 +253,49 @@ describe('ObjectQLConversationService', () => {
|
|
|
253
253
|
|
|
254
254
|
it('should add a tool message with toolCallId', async () => {
|
|
255
255
|
const conv = await service.create();
|
|
256
|
-
const msg:
|
|
257
|
-
role: 'tool',
|
|
258
|
-
content:
|
|
259
|
-
|
|
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
|
+
}],
|
|
260
264
|
};
|
|
261
265
|
|
|
262
266
|
const updated = await service.addMessage(conv.id, msg);
|
|
263
267
|
expect(updated.messages).toHaveLength(1);
|
|
264
|
-
|
|
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
|
+
}
|
|
265
274
|
});
|
|
266
275
|
|
|
267
276
|
it('should add an assistant message with toolCalls', async () => {
|
|
268
277
|
const conv = await service.create();
|
|
269
|
-
const msg:
|
|
270
|
-
role: 'assistant',
|
|
271
|
-
content:
|
|
272
|
-
|
|
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
|
+
],
|
|
273
283
|
};
|
|
274
284
|
|
|
275
285
|
const updated = await service.addMessage(conv.id, msg);
|
|
276
286
|
expect(updated.messages).toHaveLength(1);
|
|
277
|
-
|
|
278
|
-
|
|
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
|
+
}
|
|
279
295
|
});
|
|
280
296
|
|
|
281
297
|
it('should throw when adding message to non-existent conversation', async () => {
|
|
282
|
-
const msg:
|
|
298
|
+
const msg: ModelMessage = { role: 'user', content: 'Hello' };
|
|
283
299
|
await expect(service.addMessage('conv_ghost', msg)).rejects.toThrow(
|
|
284
300
|
'Conversation "conv_ghost" not found',
|
|
285
301
|
);
|
|
@@ -352,13 +368,15 @@ describe('ObjectQLConversationService', () => {
|
|
|
352
368
|
|
|
353
369
|
it('should handle invalid JSON in tool_calls gracefully', async () => {
|
|
354
370
|
const conv = await service.create();
|
|
355
|
-
await service.addMessage(conv.id, { role: '
|
|
371
|
+
await service.addMessage(conv.id, { role: 'assistant', content: 'checking tools' });
|
|
356
372
|
|
|
357
373
|
// Manually corrupt tool_calls in the engine
|
|
358
374
|
const msgs = await engine.find('ai_messages', { where: { conversation_id: conv.id } });
|
|
359
375
|
msgs[0].tool_calls = 'broken{json';
|
|
360
376
|
|
|
361
377
|
const fetched = await service.get(conv.id);
|
|
362
|
-
|
|
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');
|
|
363
381
|
});
|
|
364
382
|
});
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect } from 'vitest';
|
|
4
|
+
import type { TextStreamPart, ToolSet } from '@objectstack/spec/contracts';
|
|
5
|
+
import { encodeStreamPart, encodeVercelDataStream } from '../stream/vercel-stream-encoder.js';
|
|
6
|
+
|
|
7
|
+
// Helper to parse SSE frame payload
|
|
8
|
+
function parseSSE(frame: string): Record<string, unknown> | null {
|
|
9
|
+
if (!frame.startsWith('data: ') || !frame.endsWith('\n\n')) return null;
|
|
10
|
+
const json = frame.slice(6, -2);
|
|
11
|
+
if (json === '[DONE]') return null;
|
|
12
|
+
return JSON.parse(json);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// ─────────────────────────────────────────────────────────────────
|
|
16
|
+
// encodeStreamPart — individual frame encoding (v6 SSE format)
|
|
17
|
+
// ─────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
describe('encodeStreamPart', () => {
|
|
20
|
+
it('should encode text-delta as SSE frame', () => {
|
|
21
|
+
const part = { type: 'text-delta', text: 'Hello world' } as TextStreamPart<ToolSet>;
|
|
22
|
+
const frame = encodeStreamPart(part);
|
|
23
|
+
const payload = parseSSE(frame);
|
|
24
|
+
expect(payload).toEqual({ type: 'text-delta', id: '0', delta: 'Hello world' });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should JSON-escape text-delta content', () => {
|
|
28
|
+
const part = { type: 'text-delta', text: 'say "hi"\nnewline' } as TextStreamPart<ToolSet>;
|
|
29
|
+
const frame = encodeStreamPart(part);
|
|
30
|
+
expect(frame.startsWith('data: ')).toBe(true);
|
|
31
|
+
expect(frame.endsWith('\n\n')).toBe(true);
|
|
32
|
+
|
|
33
|
+
// Verify round-trip: decode the frame payload back to the original text
|
|
34
|
+
const payload = parseSSE(frame);
|
|
35
|
+
expect(payload).not.toBeNull();
|
|
36
|
+
expect((payload as Record<string, unknown>).delta).toBe('say "hi"\nnewline');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should encode tool-call as tool-input-available SSE frame', () => {
|
|
40
|
+
const part = {
|
|
41
|
+
type: 'tool-call',
|
|
42
|
+
toolCallId: 'call_1',
|
|
43
|
+
toolName: 'get_weather',
|
|
44
|
+
input: { location: 'San Francisco' },
|
|
45
|
+
} as TextStreamPart<ToolSet>;
|
|
46
|
+
|
|
47
|
+
const frame = encodeStreamPart(part);
|
|
48
|
+
const payload = parseSSE(frame);
|
|
49
|
+
expect(payload).toEqual({
|
|
50
|
+
type: 'tool-input-available',
|
|
51
|
+
toolCallId: 'call_1',
|
|
52
|
+
toolName: 'get_weather',
|
|
53
|
+
input: { location: 'San Francisco' },
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should encode tool-input-start as SSE frame', () => {
|
|
58
|
+
const part = {
|
|
59
|
+
type: 'tool-input-start',
|
|
60
|
+
id: 'call_2',
|
|
61
|
+
toolName: 'search',
|
|
62
|
+
} as TextStreamPart<ToolSet>;
|
|
63
|
+
|
|
64
|
+
const frame = encodeStreamPart(part);
|
|
65
|
+
const payload = parseSSE(frame);
|
|
66
|
+
expect(payload).toEqual({
|
|
67
|
+
type: 'tool-input-start',
|
|
68
|
+
toolCallId: 'call_2',
|
|
69
|
+
toolName: 'search',
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should encode tool-input-delta as SSE frame', () => {
|
|
74
|
+
const part = {
|
|
75
|
+
type: 'tool-input-delta',
|
|
76
|
+
id: 'call_2',
|
|
77
|
+
delta: '{"query":',
|
|
78
|
+
} as TextStreamPart<ToolSet>;
|
|
79
|
+
|
|
80
|
+
const frame = encodeStreamPart(part);
|
|
81
|
+
const payload = parseSSE(frame);
|
|
82
|
+
expect(payload).toEqual({
|
|
83
|
+
type: 'tool-input-delta',
|
|
84
|
+
toolCallId: 'call_2',
|
|
85
|
+
inputTextDelta: '{"query":',
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('should encode tool-result as tool-output-available SSE frame', () => {
|
|
90
|
+
const part = {
|
|
91
|
+
type: 'tool-result',
|
|
92
|
+
toolCallId: 'call_1',
|
|
93
|
+
toolName: 'get_weather',
|
|
94
|
+
output: { temperature: 72 },
|
|
95
|
+
} as TextStreamPart<ToolSet>;
|
|
96
|
+
|
|
97
|
+
const frame = encodeStreamPart(part);
|
|
98
|
+
const payload = parseSSE(frame);
|
|
99
|
+
expect(payload).toEqual({
|
|
100
|
+
type: 'tool-output-available',
|
|
101
|
+
toolCallId: 'call_1',
|
|
102
|
+
output: { temperature: 72 },
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should return empty string for finish (handled by generator)', () => {
|
|
107
|
+
const part = {
|
|
108
|
+
type: 'finish',
|
|
109
|
+
finishReason: 'stop',
|
|
110
|
+
totalUsage: { promptTokens: 10, completionTokens: 20, totalTokens: 30 },
|
|
111
|
+
rawFinishReason: 'stop',
|
|
112
|
+
} as unknown as TextStreamPart<ToolSet>;
|
|
113
|
+
|
|
114
|
+
expect(encodeStreamPart(part)).toBe('');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should return empty string for finish-step (handled by generator)', () => {
|
|
118
|
+
const part = {
|
|
119
|
+
type: 'finish-step',
|
|
120
|
+
finishReason: 'tool-calls',
|
|
121
|
+
usage: { promptTokens: 5, completionTokens: 10, totalTokens: 15 },
|
|
122
|
+
} as unknown as TextStreamPart<ToolSet>;
|
|
123
|
+
|
|
124
|
+
expect(encodeStreamPart(part)).toBe('');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('should return empty string for unknown event types', () => {
|
|
128
|
+
const part = { type: 'unknown-internal' } as unknown as TextStreamPart<ToolSet>;
|
|
129
|
+
expect(encodeStreamPart(part)).toBe('');
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// ─────────────────────────────────────────────────────────────────
|
|
134
|
+
// encodeVercelDataStream — async iterable transformation (v6 SSE)
|
|
135
|
+
//
|
|
136
|
+
// Lifecycle: start → start-step → text-start → ...events... → text-end → finish-step → finish → [DONE]
|
|
137
|
+
// ─────────────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
describe('encodeVercelDataStream', () => {
|
|
140
|
+
it('should transform stream events into v6 UI Message Stream frames', async () => {
|
|
141
|
+
async function* source(): AsyncIterable<TextStreamPart<ToolSet>> {
|
|
142
|
+
yield { type: 'text-delta', text: 'Hello' } as TextStreamPart<ToolSet>;
|
|
143
|
+
yield { type: 'text-delta', text: ' world' } as TextStreamPart<ToolSet>;
|
|
144
|
+
yield {
|
|
145
|
+
type: 'finish',
|
|
146
|
+
finishReason: 'stop',
|
|
147
|
+
totalUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
|
148
|
+
rawFinishReason: 'stop',
|
|
149
|
+
} as unknown as TextStreamPart<ToolSet>;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const frames: string[] = [];
|
|
153
|
+
for await (const frame of encodeVercelDataStream(source())) {
|
|
154
|
+
frames.push(frame);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Preamble: start, start-step, text-start
|
|
158
|
+
// Content: 2 text-deltas
|
|
159
|
+
// Postamble: text-end, finish-step, finish, [DONE]
|
|
160
|
+
expect(frames).toHaveLength(9);
|
|
161
|
+
|
|
162
|
+
// Preamble
|
|
163
|
+
expect(parseSSE(frames[0])).toEqual({ type: 'start' });
|
|
164
|
+
expect(parseSSE(frames[1])).toEqual({ type: 'start-step' });
|
|
165
|
+
expect(parseSSE(frames[2])).toEqual({ type: 'text-start', id: '0' });
|
|
166
|
+
|
|
167
|
+
// Content
|
|
168
|
+
expect(parseSSE(frames[3])).toMatchObject({ type: 'text-delta', delta: 'Hello' });
|
|
169
|
+
expect(parseSSE(frames[4])).toMatchObject({ type: 'text-delta', delta: ' world' });
|
|
170
|
+
|
|
171
|
+
// Postamble
|
|
172
|
+
expect(parseSSE(frames[5])).toEqual({ type: 'text-end', id: '0' });
|
|
173
|
+
expect(parseSSE(frames[6])).toEqual({ type: 'finish-step' });
|
|
174
|
+
expect(parseSSE(frames[7])).toMatchObject({ type: 'finish', finishReason: 'stop' });
|
|
175
|
+
expect(frames[8]).toBe('data: [DONE]\n\n');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('should skip events with no wire format mapping', async () => {
|
|
179
|
+
async function* source(): AsyncIterable<TextStreamPart<ToolSet>> {
|
|
180
|
+
yield { type: 'text-delta', text: 'Hi' } as TextStreamPart<ToolSet>;
|
|
181
|
+
yield { type: 'unknown-internal' } as unknown as TextStreamPart<ToolSet>;
|
|
182
|
+
yield {
|
|
183
|
+
type: 'finish',
|
|
184
|
+
finishReason: 'stop',
|
|
185
|
+
totalUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
|
186
|
+
rawFinishReason: 'stop',
|
|
187
|
+
} as unknown as TextStreamPart<ToolSet>;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const frames: string[] = [];
|
|
191
|
+
for await (const frame of encodeVercelDataStream(source())) {
|
|
192
|
+
frames.push(frame);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Preamble(3) + 1 text-delta + Postamble(4) = 8 ('unknown-internal' dropped)
|
|
196
|
+
expect(frames).toHaveLength(8);
|
|
197
|
+
expect(parseSSE(frames[3])).toMatchObject({ type: 'text-delta', delta: 'Hi' });
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('should handle empty stream', async () => {
|
|
201
|
+
async function* source(): AsyncIterable<TextStreamPart<ToolSet>> {
|
|
202
|
+
// empty
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const frames: string[] = [];
|
|
206
|
+
for await (const frame of encodeVercelDataStream(source())) {
|
|
207
|
+
frames.push(frame);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Preamble(3) + text-end + finish-step + finish + [DONE] = 7
|
|
211
|
+
expect(frames).toHaveLength(7);
|
|
212
|
+
expect(parseSSE(frames[0])).toEqual({ type: 'start' });
|
|
213
|
+
expect(parseSSE(frames[3])).toEqual({ type: 'text-end', id: '0' });
|
|
214
|
+
expect(frames[6]).toBe('data: [DONE]\n\n');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('should handle tool-call events in stream', async () => {
|
|
218
|
+
async function* source(): AsyncIterable<TextStreamPart<ToolSet>> {
|
|
219
|
+
yield {
|
|
220
|
+
type: 'tool-call',
|
|
221
|
+
toolCallId: 'call_1',
|
|
222
|
+
toolName: 'search',
|
|
223
|
+
input: { query: 'test' },
|
|
224
|
+
} as TextStreamPart<ToolSet>;
|
|
225
|
+
yield {
|
|
226
|
+
type: 'tool-result',
|
|
227
|
+
toolCallId: 'call_1',
|
|
228
|
+
toolName: 'search',
|
|
229
|
+
output: { hits: 42 },
|
|
230
|
+
} as TextStreamPart<ToolSet>;
|
|
231
|
+
yield {
|
|
232
|
+
type: 'finish',
|
|
233
|
+
finishReason: 'tool-calls',
|
|
234
|
+
totalUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
|
235
|
+
rawFinishReason: 'tool_calls',
|
|
236
|
+
} as unknown as TextStreamPart<ToolSet>;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const frames: string[] = [];
|
|
240
|
+
for await (const frame of encodeVercelDataStream(source())) {
|
|
241
|
+
frames.push(frame);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Preamble(3) + tool-input-available + tool-output-available + Postamble(4) = 9
|
|
245
|
+
expect(frames).toHaveLength(9);
|
|
246
|
+
|
|
247
|
+
// Verify tool-call frame content
|
|
248
|
+
const toolCallPayload = parseSSE(frames[3]);
|
|
249
|
+
expect(toolCallPayload).toMatchObject({
|
|
250
|
+
type: 'tool-input-available',
|
|
251
|
+
toolCallId: 'call_1',
|
|
252
|
+
toolName: 'search',
|
|
253
|
+
input: { query: 'test' },
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
const toolResultPayload = parseSSE(frames[4]);
|
|
257
|
+
expect(toolResultPayload).toMatchObject({
|
|
258
|
+
type: 'tool-output-available',
|
|
259
|
+
toolCallId: 'call_1',
|
|
260
|
+
output: { hits: 42 },
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
});
|
package/src/adapters/index.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
2
|
|
|
3
3
|
import type {
|
|
4
|
-
|
|
4
|
+
ModelMessage,
|
|
5
5
|
AIRequestOptions,
|
|
6
6
|
AIResult,
|
|
7
|
-
|
|
7
|
+
TextStreamPart,
|
|
8
|
+
ToolSet,
|
|
8
9
|
} from '@objectstack/spec/contracts';
|
|
9
10
|
import type { LLMAdapter } from '@objectstack/spec/contracts';
|
|
10
11
|
|
|
@@ -17,10 +18,12 @@ import type { LLMAdapter } from '@objectstack/spec/contracts';
|
|
|
17
18
|
export class MemoryLLMAdapter implements LLMAdapter {
|
|
18
19
|
readonly name = 'memory';
|
|
19
20
|
|
|
20
|
-
async chat(messages:
|
|
21
|
+
async chat(messages: ModelMessage[], options?: AIRequestOptions): Promise<AIResult> {
|
|
21
22
|
const lastUserMessage = [...messages].reverse().find(m => m.role === 'user');
|
|
23
|
+
const userContent = lastUserMessage?.content;
|
|
24
|
+
const text = typeof userContent === 'string' ? userContent : '(complex content)';
|
|
22
25
|
const content = lastUserMessage
|
|
23
|
-
? `[memory] ${
|
|
26
|
+
? `[memory] ${text}`
|
|
24
27
|
: '[memory] (no user message)';
|
|
25
28
|
|
|
26
29
|
return {
|
|
@@ -39,17 +42,22 @@ export class MemoryLLMAdapter implements LLMAdapter {
|
|
|
39
42
|
}
|
|
40
43
|
|
|
41
44
|
async *streamChat(
|
|
42
|
-
messages:
|
|
45
|
+
messages: ModelMessage[],
|
|
43
46
|
_options?: AIRequestOptions,
|
|
44
|
-
): AsyncIterable<
|
|
47
|
+
): AsyncIterable<TextStreamPart<ToolSet>> {
|
|
45
48
|
const result = await this.chat(messages);
|
|
46
49
|
// Emit word-by-word deltas for realistic streaming simulation
|
|
47
50
|
const words = result.content.split(' ');
|
|
48
51
|
for (let i = 0; i < words.length; i++) {
|
|
49
|
-
const
|
|
50
|
-
yield { type: 'text-delta',
|
|
52
|
+
const wordText = i === 0 ? words[i] : ` ${words[i]}`;
|
|
53
|
+
yield { type: 'text-delta', id: `delta_${i}`, text: wordText } as TextStreamPart<ToolSet>;
|
|
51
54
|
}
|
|
52
|
-
yield {
|
|
55
|
+
yield {
|
|
56
|
+
type: 'finish',
|
|
57
|
+
finishReason: 'stop' as const,
|
|
58
|
+
totalUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
|
59
|
+
rawFinishReason: 'stop',
|
|
60
|
+
} as unknown as TextStreamPart<ToolSet>;
|
|
53
61
|
}
|
|
54
62
|
|
|
55
63
|
async embed(input: string | string[]): Promise<number[][]> {
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
ModelMessage,
|
|
5
|
+
AIRequestOptions,
|
|
6
|
+
AIResult,
|
|
7
|
+
TextStreamPart,
|
|
8
|
+
ToolSet,
|
|
9
|
+
} from '@objectstack/spec/contracts';
|
|
10
|
+
import type { LLMAdapter } from '@objectstack/spec/contracts';
|
|
11
|
+
import type { AIToolDefinition } from '@objectstack/spec/contracts';
|
|
12
|
+
import type { LanguageModelV2 } from '@ai-sdk/provider';
|
|
13
|
+
import { generateText, streamText, tool as vercelTool, jsonSchema } from 'ai';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Convert ObjectStack `AIRequestOptions` into the subset of Vercel AI SDK
|
|
17
|
+
* options supported by `generateText` / `streamText`.
|
|
18
|
+
*
|
|
19
|
+
* Forwards: temperature, maxTokens, stop (→ stopSequences), tools, toolChoice.
|
|
20
|
+
*/
|
|
21
|
+
function buildVercelOptions(options?: AIRequestOptions): Record<string, unknown> {
|
|
22
|
+
if (!options) return {};
|
|
23
|
+
|
|
24
|
+
const opts: Record<string, unknown> = {};
|
|
25
|
+
|
|
26
|
+
if (options.temperature != null) opts.temperature = options.temperature;
|
|
27
|
+
if (options.maxTokens != null) opts.maxTokens = options.maxTokens;
|
|
28
|
+
if (options.stop?.length) opts.stopSequences = options.stop;
|
|
29
|
+
|
|
30
|
+
if (options.tools?.length) {
|
|
31
|
+
const tools: Record<string, unknown> = {};
|
|
32
|
+
for (const t of options.tools as AIToolDefinition[]) {
|
|
33
|
+
tools[t.name] = vercelTool({
|
|
34
|
+
description: t.description,
|
|
35
|
+
inputSchema: jsonSchema(t.parameters as any),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
opts.tools = tools;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (options.toolChoice != null) {
|
|
42
|
+
opts.toolChoice = options.toolChoice;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return opts;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* VercelLLMAdapter — Production LLM adapter powered by the Vercel AI SDK.
|
|
50
|
+
*
|
|
51
|
+
* Wraps `generateText` / `streamText` from the `ai` package, delegating to
|
|
52
|
+
* any Vercel AI SDK–compatible model provider (OpenAI, Anthropic, Google,
|
|
53
|
+
* Ollama, etc.).
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* ```typescript
|
|
57
|
+
* import { openai } from '@ai-sdk/openai';
|
|
58
|
+
* import { VercelLLMAdapter } from '@objectstack/service-ai';
|
|
59
|
+
*
|
|
60
|
+
* const adapter = new VercelLLMAdapter({ model: openai('gpt-4o') });
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
63
|
+
export class VercelLLMAdapter implements LLMAdapter {
|
|
64
|
+
readonly name = 'vercel';
|
|
65
|
+
|
|
66
|
+
private readonly model: LanguageModelV2;
|
|
67
|
+
|
|
68
|
+
constructor(config: VercelLLMAdapterConfig) {
|
|
69
|
+
this.model = config.model;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async chat(messages: ModelMessage[], options?: AIRequestOptions): Promise<AIResult> {
|
|
73
|
+
const result = await generateText({
|
|
74
|
+
model: this.model,
|
|
75
|
+
messages,
|
|
76
|
+
...buildVercelOptions(options),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
content: result.text,
|
|
81
|
+
model: result.response?.modelId,
|
|
82
|
+
toolCalls: result.toolCalls?.length ? result.toolCalls : undefined,
|
|
83
|
+
usage: result.usage ? {
|
|
84
|
+
promptTokens: result.usage.inputTokens ?? 0,
|
|
85
|
+
completionTokens: result.usage.outputTokens ?? 0,
|
|
86
|
+
totalTokens: result.usage.totalTokens ?? 0,
|
|
87
|
+
} : undefined,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async complete(prompt: string, options?: AIRequestOptions): Promise<AIResult> {
|
|
92
|
+
const result = await generateText({
|
|
93
|
+
model: this.model,
|
|
94
|
+
prompt,
|
|
95
|
+
...buildVercelOptions(options),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
content: result.text,
|
|
100
|
+
model: result.response?.modelId,
|
|
101
|
+
usage: result.usage ? {
|
|
102
|
+
promptTokens: result.usage.inputTokens ?? 0,
|
|
103
|
+
completionTokens: result.usage.outputTokens ?? 0,
|
|
104
|
+
totalTokens: result.usage.totalTokens ?? 0,
|
|
105
|
+
} : undefined,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async *streamChat(
|
|
110
|
+
messages: ModelMessage[],
|
|
111
|
+
options?: AIRequestOptions,
|
|
112
|
+
): AsyncIterable<TextStreamPart<ToolSet>> {
|
|
113
|
+
const result = streamText({
|
|
114
|
+
model: this.model,
|
|
115
|
+
messages,
|
|
116
|
+
...buildVercelOptions(options),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
for await (const part of result.fullStream) {
|
|
120
|
+
yield part as TextStreamPart<ToolSet>;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async embed(_input: string | string[]): Promise<number[][]> {
|
|
125
|
+
// Vercel AI SDK uses a separate EmbeddingModel — not supported via this adapter.
|
|
126
|
+
throw new Error(
|
|
127
|
+
'[VercelLLMAdapter] Embeddings require a dedicated EmbeddingModel. ' +
|
|
128
|
+
'Configure an embedding adapter instead.',
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async listModels(): Promise<string[]> {
|
|
133
|
+
// Model listing is provider-specific and not available through the base SDK.
|
|
134
|
+
return [];
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Configuration for the Vercel LLM adapter.
|
|
140
|
+
*/
|
|
141
|
+
export interface VercelLLMAdapterConfig {
|
|
142
|
+
/**
|
|
143
|
+
* A Vercel AI SDK–compatible language model instance.
|
|
144
|
+
*
|
|
145
|
+
* @example `openai('gpt-4o')` or `anthropic('claude-sonnet-4-20250514')`
|
|
146
|
+
*/
|
|
147
|
+
model: LanguageModelV2;
|
|
148
|
+
}
|
package/src/agent-runtime.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
2
|
|
|
3
3
|
import type {
|
|
4
|
-
|
|
4
|
+
ModelMessage,
|
|
5
5
|
AIRequestOptions,
|
|
6
6
|
AIToolDefinition,
|
|
7
7
|
IMetadataService,
|
|
@@ -40,6 +40,30 @@ export class AgentRuntime {
|
|
|
40
40
|
|
|
41
41
|
// ── Public API ────────────────────────────────────────────────
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* List all active agents registered in the metadata service.
|
|
45
|
+
*
|
|
46
|
+
* Returns a summary for each agent (name, label, role) suitable
|
|
47
|
+
* for populating an agent selector dropdown in the UI.
|
|
48
|
+
*/
|
|
49
|
+
async listAgents(): Promise<Array<{ name: string; label: string; role: string }>> {
|
|
50
|
+
const rawItems = await this.metadataService.list('agent');
|
|
51
|
+
const agents: Array<{ name: string; label: string; role: string }> = [];
|
|
52
|
+
|
|
53
|
+
for (const raw of rawItems) {
|
|
54
|
+
const result = AgentSchema.safeParse(raw);
|
|
55
|
+
if (result.success && result.data.active) {
|
|
56
|
+
agents.push({
|
|
57
|
+
name: result.data.name,
|
|
58
|
+
label: result.data.label,
|
|
59
|
+
role: result.data.role,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return agents;
|
|
65
|
+
}
|
|
66
|
+
|
|
43
67
|
/**
|
|
44
68
|
* Load and validate an agent definition by name.
|
|
45
69
|
*
|
|
@@ -63,7 +87,7 @@ export class AgentRuntime {
|
|
|
63
87
|
* Build the system message(s) that should be prepended to the
|
|
64
88
|
* conversation when chatting with the given agent.
|
|
65
89
|
*/
|
|
66
|
-
buildSystemMessages(agent: Agent, context?: AgentChatContext):
|
|
90
|
+
buildSystemMessages(agent: Agent, context?: AgentChatContext): ModelMessage[] {
|
|
67
91
|
const parts: string[] = [];
|
|
68
92
|
|
|
69
93
|
// Base instructions
|
|
@@ -80,7 +104,7 @@ export class AgentRuntime {
|
|
|
80
104
|
}
|
|
81
105
|
}
|
|
82
106
|
|
|
83
|
-
return [{ role: 'system', content: parts.join('\n') }];
|
|
107
|
+
return [{ role: 'system' as const, content: parts.join('\n') }];
|
|
84
108
|
}
|
|
85
109
|
|
|
86
110
|
/**
|
package/src/agents/index.ts
CHANGED