@objectstack/service-ai 4.0.0 → 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.
Files changed (40) hide show
  1. package/.turbo/turbo-build.log +11 -11
  2. package/CHANGELOG.md +20 -0
  3. package/dist/index.cjs +1245 -54
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.d.cts +344 -77
  6. package/dist/index.d.ts +344 -77
  7. package/dist/index.js +1230 -51
  8. package/dist/index.js.map +1 -1
  9. package/package.json +26 -4
  10. package/src/__tests__/ai-service.test.ts +248 -27
  11. package/src/__tests__/auth-and-toolcalling.test.ts +627 -0
  12. package/src/__tests__/chatbot-features.test.ts +229 -82
  13. package/src/__tests__/metadata-tools.test.ts +964 -0
  14. package/src/__tests__/objectql-conversation-service.test.ts +34 -16
  15. package/src/__tests__/vercel-stream-encoder.test.ts +263 -0
  16. package/src/adapters/index.ts +2 -0
  17. package/src/adapters/memory-adapter.ts +17 -9
  18. package/src/adapters/vercel-adapter.ts +148 -0
  19. package/src/agent-runtime.ts +27 -3
  20. package/src/agents/index.ts +1 -0
  21. package/src/agents/metadata-assistant-agent.ts +87 -0
  22. package/src/ai-service.ts +174 -22
  23. package/src/conversation/in-memory-conversation-service.ts +2 -2
  24. package/src/conversation/objectql-conversation-service.ts +67 -18
  25. package/src/index.ts +22 -3
  26. package/src/plugin.ts +166 -9
  27. package/src/routes/agent-routes.ts +28 -3
  28. package/src/routes/ai-routes.ts +231 -14
  29. package/src/routes/index.ts +1 -1
  30. package/src/stream/index.ts +3 -0
  31. package/src/stream/vercel-stream-encoder.ts +129 -0
  32. package/src/tools/add-field.tool.ts +70 -0
  33. package/src/tools/create-object.tool.ts +66 -0
  34. package/src/tools/delete-field.tool.ts +38 -0
  35. package/src/tools/describe-metadata-object.tool.ts +32 -0
  36. package/src/tools/index.ts +12 -1
  37. package/src/tools/list-metadata-objects.tool.ts +34 -0
  38. package/src/tools/metadata-tools.ts +430 -0
  39. package/src/tools/modify-field.tool.ts +44 -0
  40. 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 { AIMessage } from '@objectstack/spec/contracts';
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: AIMessage = { role: 'user', content: 'Hello AI!' };
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: AIMessage = {
257
- role: 'tool',
258
- content: '{"temp": 22}',
259
- toolCallId: 'call_abc',
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
- expect(updated.messages[0].toolCallId).toBe('call_abc');
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: AIMessage = {
270
- role: 'assistant',
271
- content: '',
272
- toolCalls: [{ id: 'call_1', name: 'get_weather', arguments: '{}' }],
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
- expect(updated.messages[0].toolCalls).toHaveLength(1);
278
- expect(updated.messages[0].toolCalls![0].name).toBe('get_weather');
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: AIMessage = { role: 'user', content: 'Hello' };
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: 'user', content: 'hi' });
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
- expect(fetched!.messages[0].toolCalls).toBeUndefined();
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
+ });
@@ -2,3 +2,5 @@
2
2
 
3
3
  export type { LLMAdapter } from '@objectstack/spec/contracts';
4
4
  export { MemoryLLMAdapter } from './memory-adapter.js';
5
+ export { VercelLLMAdapter } from './vercel-adapter.js';
6
+ export type { VercelLLMAdapterConfig } from './vercel-adapter.js';
@@ -1,10 +1,11 @@
1
1
  // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
2
 
3
3
  import type {
4
- AIMessage,
4
+ ModelMessage,
5
5
  AIRequestOptions,
6
6
  AIResult,
7
- AIStreamEvent,
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: AIMessage[], options?: AIRequestOptions): Promise<AIResult> {
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] ${lastUserMessage.content}`
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: AIMessage[],
45
+ messages: ModelMessage[],
43
46
  _options?: AIRequestOptions,
44
- ): AsyncIterable<AIStreamEvent> {
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 textDelta = i === 0 ? words[i] : ` ${words[i]}`;
50
- yield { type: 'text-delta', textDelta };
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 { type: 'finish', result };
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
+ }
@@ -1,7 +1,7 @@
1
1
  // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
2
 
3
3
  import type {
4
- AIMessage,
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): AIMessage[] {
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
  /**
@@ -1,3 +1,4 @@
1
1
  // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
2
 
3
3
  export { DATA_CHAT_AGENT } from './data-chat-agent.js';
4
+ export { METADATA_ASSISTANT_AGENT } from './metadata-assistant-agent.js';