@objectstack/service-ai 4.0.1 → 4.0.3

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 (44) hide show
  1. package/.turbo/turbo-build.log +11 -11
  2. package/CHANGELOG.md +17 -0
  3. package/dist/index.cjs +1632 -355
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.d.cts +330 -87
  6. package/dist/index.d.ts +330 -87
  7. package/dist/index.js +1623 -352
  8. package/dist/index.js.map +1 -1
  9. package/package.json +27 -5
  10. package/src/__tests__/ai-service.test.ts +260 -27
  11. package/src/__tests__/auth-and-toolcalling.test.ts +81 -29
  12. package/src/__tests__/chatbot-features.test.ts +397 -102
  13. package/src/__tests__/metadata-tools.test.ts +970 -0
  14. package/src/__tests__/objectql-conversation-service.test.ts +34 -16
  15. package/src/__tests__/tool-routes.test.ts +191 -0
  16. package/src/__tests__/vercel-stream-encoder.test.ts +310 -0
  17. package/src/adapters/index.ts +2 -0
  18. package/src/adapters/memory-adapter.ts +17 -9
  19. package/src/adapters/vercel-adapter.ts +148 -0
  20. package/src/agent-runtime.ts +27 -3
  21. package/src/agents/index.ts +1 -0
  22. package/src/agents/metadata-assistant-agent.ts +87 -0
  23. package/src/ai-service.ts +75 -36
  24. package/src/conversation/in-memory-conversation-service.ts +2 -2
  25. package/src/conversation/objectql-conversation-service.ts +67 -18
  26. package/src/index.ts +22 -2
  27. package/src/plugin.ts +237 -30
  28. package/src/routes/agent-routes.ts +68 -12
  29. package/src/routes/ai-routes.ts +93 -14
  30. package/src/routes/index.ts +1 -0
  31. package/src/routes/message-utils.ts +90 -0
  32. package/src/routes/tool-routes.ts +142 -0
  33. package/src/stream/index.ts +3 -0
  34. package/src/stream/vercel-stream-encoder.ts +153 -0
  35. package/src/tools/add-field.tool.ts +70 -0
  36. package/src/tools/create-object.tool.ts +66 -0
  37. package/src/tools/data-tools.ts +4 -101
  38. package/src/tools/delete-field.tool.ts +38 -0
  39. package/src/tools/describe-object.tool.ts +31 -0
  40. package/src/tools/index.ts +12 -1
  41. package/src/tools/list-objects.tool.ts +34 -0
  42. package/src/tools/metadata-tools.ts +430 -0
  43. package/src/tools/modify-field.tool.ts +44 -0
  44. package/src/tools/tool-registry.ts +32 -9
package/src/index.ts CHANGED
@@ -10,26 +10,45 @@ export type { AIServicePluginOptions } from './plugin.js';
10
10
 
11
11
  // Adapters
12
12
  export { MemoryLLMAdapter } from './adapters/memory-adapter.js';
13
+ export { VercelLLMAdapter } from './adapters/vercel-adapter.js';
14
+ export type { VercelLLMAdapterConfig } from './adapters/vercel-adapter.js';
13
15
  export type { LLMAdapter } from '@objectstack/spec/contracts';
14
16
 
17
+ // Vercel Data Stream encoder
18
+ export { encodeStreamPart, encodeVercelDataStream } from './stream/vercel-stream-encoder.js';
19
+
15
20
  // Conversation
16
21
  export { InMemoryConversationService } from './conversation/in-memory-conversation-service.js';
17
22
  export { ObjectQLConversationService } from './conversation/objectql-conversation-service.js';
18
23
 
19
24
  // Tool registry
20
25
  export { ToolRegistry } from './tools/tool-registry.js';
21
- export type { ToolHandler } from './tools/tool-registry.js';
26
+ export type { ToolHandler, ToolExecutionResult } from './tools/tool-registry.js';
22
27
 
23
28
  // Data tools
24
29
  export { registerDataTools, DATA_TOOL_DEFINITIONS } from './tools/data-tools.js';
25
30
  export type { DataToolContext } from './tools/data-tools.js';
26
31
 
32
+ // Metadata tools
33
+ export { registerMetadataTools, METADATA_TOOL_DEFINITIONS } from './tools/metadata-tools.js';
34
+ export type { MetadataToolContext } from './tools/metadata-tools.js';
35
+
36
+ // Individual tool metadata (first-class Tool definitions via defineTool)
37
+ export {
38
+ createObjectTool,
39
+ addFieldTool,
40
+ modifyFieldTool,
41
+ deleteFieldTool,
42
+ listObjectsTool,
43
+ describeObjectTool,
44
+ } from './tools/metadata-tools.js';
45
+
27
46
  // Agent runtime
28
47
  export { AgentRuntime } from './agent-runtime.js';
29
48
  export type { AgentChatContext } from './agent-runtime.js';
30
49
 
31
50
  // Built-in agents
32
- export { DATA_CHAT_AGENT } from './agents/index.js';
51
+ export { DATA_CHAT_AGENT, METADATA_ASSISTANT_AGENT } from './agents/index.js';
33
52
 
34
53
  // Object definitions
35
54
  export { AiConversationObject, AiMessageObject } from './objects/index.js';
@@ -37,4 +56,5 @@ export { AiConversationObject, AiMessageObject } from './objects/index.js';
37
56
  // Routes
38
57
  export { buildAIRoutes } from './routes/ai-routes.js';
39
58
  export { buildAgentRoutes } from './routes/agent-routes.js';
59
+ export { buildToolRoutes } from './routes/tool-routes.js';
40
60
  export type { RouteDefinition, RouteRequest, RouteResponse, RouteUserContext } from './routes/ai-routes.js';
package/src/plugin.ts CHANGED
@@ -6,11 +6,15 @@ import { AIService } from './ai-service.js';
6
6
  import type { AIServiceConfig } from './ai-service.js';
7
7
  import { buildAIRoutes } from './routes/ai-routes.js';
8
8
  import { buildAgentRoutes } from './routes/agent-routes.js';
9
+ import { buildToolRoutes } from './routes/tool-routes.js';
9
10
  import { ObjectQLConversationService } from './conversation/objectql-conversation-service.js';
10
11
  import { AiConversationObject, AiMessageObject } from './objects/index.js';
11
12
  import { registerDataTools } from './tools/data-tools.js';
13
+ import { registerMetadataTools } from './tools/metadata-tools.js';
12
14
  import { AgentRuntime } from './agent-runtime.js';
13
- import { DATA_CHAT_AGENT } from './agents/index.js';
15
+ import { DATA_CHAT_AGENT, METADATA_ASSISTANT_AGENT } from './agents/index.js';
16
+ import { VercelLLMAdapter } from './adapters/vercel-adapter.js';
17
+ import { MemoryLLMAdapter } from './adapters/memory-adapter.js';
14
18
 
15
19
  /**
16
20
  * Configuration options for the AIServicePlugin.
@@ -51,7 +55,7 @@ export class AIServicePlugin implements Plugin {
51
55
  name = 'com.objectstack.service-ai';
52
56
  version = '1.0.0';
53
57
  type = 'standard' as const;
54
- dependencies: string[] = [];
58
+ dependencies: string[] = ['com.objectstack.engine.objectql']; // manifest service required
55
59
 
56
60
  private service?: AIService;
57
61
  private readonly options: AIServicePluginOptions;
@@ -60,6 +64,90 @@ export class AIServicePlugin implements Plugin {
60
64
  this.options = options;
61
65
  }
62
66
 
67
+ /**
68
+ * Auto-detect LLM provider from environment variables.
69
+ *
70
+ * Priority order:
71
+ * 1. AI_GATEWAY_MODEL → Vercel AI Gateway
72
+ * 2. OPENAI_API_KEY → OpenAI
73
+ * 3. ANTHROPIC_API_KEY → Anthropic
74
+ * 4. GOOGLE_GENERATIVE_AI_API_KEY → Google
75
+ * 5. Fallback → MemoryLLMAdapter
76
+ *
77
+ * Returns the adapter and a description for logging.
78
+ */
79
+ private async detectAdapter(ctx: PluginContext): Promise<{ adapter: LLMAdapter; description: string }> {
80
+ // 1. Vercel AI Gateway — works with any provider via gateway('provider/model')
81
+ const gatewayModel = process.env.AI_GATEWAY_MODEL;
82
+ if (gatewayModel) {
83
+ try {
84
+ const gatewayPkg = '@ai-sdk/gateway';
85
+ const { gateway } = await import(/* webpackIgnore: true */ gatewayPkg);
86
+ const adapter = new VercelLLMAdapter({ model: gateway(gatewayModel) });
87
+ return { adapter, description: `Vercel AI Gateway (model: ${gatewayModel})` };
88
+ } catch (err) {
89
+ ctx.logger.warn(
90
+ `[AI] Failed to load @ai-sdk/gateway for AI_GATEWAY_MODEL=${gatewayModel}, trying next provider`,
91
+ err instanceof Error ? { error: err.message } : undefined
92
+ );
93
+ }
94
+ }
95
+
96
+ // 2. Direct provider SDKs
97
+ const providerConfigs: Array<{
98
+ envKey: string;
99
+ pkg: string;
100
+ factory: string;
101
+ defaultModel: string;
102
+ displayName: string;
103
+ }> = [
104
+ {
105
+ envKey: 'OPENAI_API_KEY',
106
+ pkg: '@ai-sdk/openai',
107
+ factory: 'openai',
108
+ defaultModel: 'gpt-4o',
109
+ displayName: 'OpenAI'
110
+ },
111
+ {
112
+ envKey: 'ANTHROPIC_API_KEY',
113
+ pkg: '@ai-sdk/anthropic',
114
+ factory: 'anthropic',
115
+ defaultModel: 'claude-sonnet-4-20250514',
116
+ displayName: 'Anthropic'
117
+ },
118
+ {
119
+ envKey: 'GOOGLE_GENERATIVE_AI_API_KEY',
120
+ pkg: '@ai-sdk/google',
121
+ factory: 'google',
122
+ defaultModel: 'gemini-2.0-flash',
123
+ displayName: 'Google'
124
+ },
125
+ ];
126
+
127
+ for (const { envKey, pkg, factory, defaultModel, displayName } of providerConfigs) {
128
+ if (process.env[envKey]) {
129
+ try {
130
+ const mod = await import(/* webpackIgnore: true */ pkg);
131
+ const createModel = mod[factory] ?? mod.default;
132
+ if (typeof createModel === 'function') {
133
+ const modelId = process.env.AI_MODEL ?? defaultModel;
134
+ const adapter = new VercelLLMAdapter({ model: createModel(modelId) });
135
+ return { adapter, description: `${displayName} (model: ${modelId})` };
136
+ }
137
+ } catch (err) {
138
+ ctx.logger.warn(
139
+ `[AI] Failed to load ${pkg} for ${envKey}, trying next provider`,
140
+ err instanceof Error ? { error: err.message } : undefined
141
+ );
142
+ }
143
+ }
144
+ }
145
+
146
+ // 3. Fallback to MemoryLLMAdapter
147
+ ctx.logger.warn('[AI] No LLM provider configured via environment variables. Falling back to MemoryLLMAdapter (echo mode). Set AI_GATEWAY_MODEL, OPENAI_API_KEY, ANTHROPIC_API_KEY, or GOOGLE_GENERATIVE_AI_API_KEY to use a real LLM.');
148
+ return { adapter: new MemoryLLMAdapter(), description: 'MemoryLLMAdapter (echo mode - for testing only)' };
149
+ }
150
+
63
151
  async init(ctx: PluginContext): Promise<void> {
64
152
  // Check if there is an existing AI service (e.g. from dev-plugin)
65
153
  let hasExisting = false;
@@ -87,8 +175,26 @@ export class AIServicePlugin implements Plugin {
87
175
  }
88
176
  }
89
177
 
178
+ // Determine LLM adapter: explicit > auto-detect from env > MemoryLLMAdapter fallback
179
+ let adapter: LLMAdapter;
180
+ let adapterDescription: string;
181
+
182
+ if (this.options.adapter) {
183
+ // User provided an explicit adapter
184
+ adapter = this.options.adapter;
185
+ adapterDescription = `${adapter.name} (explicitly configured)`;
186
+ } else {
187
+ // Auto-detect from environment variables
188
+ const detected = await this.detectAdapter(ctx);
189
+ adapter = detected.adapter;
190
+ adapterDescription = detected.description;
191
+ }
192
+
193
+ // Log the selected adapter
194
+ ctx.logger.info(`[AI] Using LLM adapter: ${adapterDescription}`);
195
+
90
196
  const config: AIServiceConfig = {
91
- adapter: this.options.adapter,
197
+ adapter,
92
198
  logger: ctx.logger,
93
199
  conversationService,
94
200
  };
@@ -102,8 +208,8 @@ export class AIServicePlugin implements Plugin {
102
208
  ctx.registerService('ai', this.service);
103
209
  }
104
210
 
105
- // Register AI system objects so ObjectQLPlugin auto-discovers them
106
- ctx.registerService('app.com.objectstack.service-ai', {
211
+ // Register AI system objects via the manifest service.
212
+ ctx.getService<{ register(m: any): void }>('manifest').register({
107
213
  id: 'com.objectstack.service-ai',
108
214
  name: 'AI Service',
109
215
  version: '1.0.0',
@@ -118,36 +224,128 @@ export class AIServicePlugin implements Plugin {
118
224
  });
119
225
  }
120
226
 
227
+ // Contribute navigation items to the Setup App (if SetupPlugin is loaded).
228
+ try {
229
+ const setupNav = ctx.getService<{ contribute(c: any): void }>('setupNav');
230
+ if (setupNav) {
231
+ setupNav.contribute({
232
+ areaId: 'area_ai',
233
+ items: [
234
+ { id: 'nav_ai_conversations', type: 'object', label: { key: 'setup.nav.ai_conversations', defaultValue: 'Conversations' }, objectName: 'conversations', icon: 'message-square', order: 10 },
235
+ { id: 'nav_ai_messages', type: 'object', label: { key: 'setup.nav.ai_messages', defaultValue: 'Messages' }, objectName: 'messages', icon: 'messages-square', order: 20 },
236
+ ],
237
+ });
238
+ ctx.logger.info('[AI] Navigation items contributed to Setup App');
239
+ }
240
+ } catch {
241
+ // SetupPlugin not loaded — skip silently
242
+ }
243
+
121
244
  ctx.logger.info('[AI] Service initialized');
122
245
  }
123
246
 
124
247
  async start(ctx: PluginContext): Promise<void> {
125
248
  if (!this.service) return;
126
249
 
127
- // ── Auto-register built-in data tools if data engine + metadata are available ──
250
+ // ── Auto-register built-in tools & agents when services are available ──
251
+ let metadataService: IMetadataService | undefined;
252
+ try {
253
+ metadataService = ctx.getService<IMetadataService>('metadata');
254
+ console.log('[AI Plugin] Retrieved metadata service:', !!metadataService, 'has getRegisteredTypes:', typeof (metadataService as any)?.getRegisteredTypes);
255
+ } catch (e: any) {
256
+ console.log('[AI] Metadata service not available:', e.message);
257
+ ctx.logger.debug('[AI] Metadata service not available');
258
+ }
259
+
260
+ // Data tools require only the data engine
128
261
  try {
129
262
  const dataEngine = ctx.getService<IDataEngine>('data');
130
- const metadataService = ctx.getService<IMetadataService>('metadata');
131
- if (dataEngine && metadataService) {
132
- registerDataTools(this.service.toolRegistry, { dataEngine, metadataService });
263
+ if (dataEngine) {
264
+ registerDataTools(this.service.toolRegistry, { dataEngine });
133
265
  ctx.logger.info('[AI] Built-in data tools registered');
134
266
 
135
- // Register the built-in data_chat agent only if it does not already exist
136
- const agentExists =
137
- typeof metadataService.exists === 'function'
138
- ? await metadataService.exists('agent', DATA_CHAT_AGENT.name)
139
- : false;
140
-
141
- if (!agentExists) {
142
- await metadataService.register('agent', DATA_CHAT_AGENT.name, DATA_CHAT_AGENT);
143
- ctx.logger.info('[AI] data_chat agent registered');
144
- } else {
145
- ctx.logger.debug('[AI] data_chat agent already exists, skipping auto-registration');
267
+ // Register data tools as metadata (for Studio visibility)
268
+ if (metadataService) {
269
+ const { DATA_TOOL_DEFINITIONS } = await import('./tools/data-tools.js');
270
+ for (const toolDef of DATA_TOOL_DEFINITIONS) {
271
+ const toolExists =
272
+ typeof metadataService.exists === 'function'
273
+ ? await metadataService.exists('tool', toolDef.name)
274
+ : false;
275
+
276
+ if (!toolExists) {
277
+ await metadataService.register('tool', toolDef.name, toolDef);
278
+ }
279
+ }
280
+ ctx.logger.info(`[AI] ${DATA_TOOL_DEFINITIONS.length} data tools registered as metadata`);
281
+ }
282
+
283
+ // Register the built-in data_chat agent (requires metadata service)
284
+ if (metadataService) {
285
+ try {
286
+ const agentExists =
287
+ typeof metadataService.exists === 'function'
288
+ ? await metadataService.exists('agent', DATA_CHAT_AGENT.name)
289
+ : false;
290
+
291
+ if (!agentExists) {
292
+ await metadataService.register('agent', DATA_CHAT_AGENT.name, DATA_CHAT_AGENT);
293
+ console.log('[AI] Registered data_chat agent to metadataService');
294
+ ctx.logger.info('[AI] data_chat agent registered');
295
+ } else {
296
+ console.log('[AI] data_chat agent already exists, skipping');
297
+ ctx.logger.debug('[AI] data_chat agent already exists, skipping auto-registration');
298
+ }
299
+ } catch (err) {
300
+ ctx.logger.warn('[AI] Failed to register data_chat agent', err instanceof Error ? { error: err.message, stack: err.stack } : { error: String(err) });
301
+ }
146
302
  }
147
303
  }
148
304
  } catch {
149
- // Data engine or metadata service not available skip data tools
150
- ctx.logger.debug('[AI] Data engine or metadata service not available, skipping data tools');
305
+ ctx.logger.debug('[AI] Data engine not available, skipping data tools');
306
+ }
307
+
308
+ // Metadata tools require only the metadata service
309
+ if (metadataService) {
310
+ try {
311
+ registerMetadataTools(this.service.toolRegistry, { metadataService });
312
+ ctx.logger.info('[AI] Built-in metadata tools registered');
313
+
314
+ // Register metadata tools as metadata (for Studio visibility)
315
+ const { METADATA_TOOL_DEFINITIONS } = await import('./tools/metadata-tools.js');
316
+ for (const toolDef of METADATA_TOOL_DEFINITIONS) {
317
+ const toolExists =
318
+ typeof metadataService.exists === 'function'
319
+ ? await metadataService.exists('tool', toolDef.name)
320
+ : false;
321
+
322
+ if (!toolExists) {
323
+ await metadataService.register('tool', toolDef.name, toolDef);
324
+ }
325
+ }
326
+ ctx.logger.info(`[AI] ${METADATA_TOOL_DEFINITIONS.length} metadata tools registered as metadata`);
327
+
328
+ // Register the built-in metadata_assistant agent
329
+ try {
330
+ const agentExists =
331
+ typeof metadataService.exists === 'function'
332
+ ? await metadataService.exists('agent', METADATA_ASSISTANT_AGENT.name)
333
+ : false;
334
+
335
+ if (!agentExists) {
336
+ await metadataService.register('agent', METADATA_ASSISTANT_AGENT.name, METADATA_ASSISTANT_AGENT);
337
+ console.log('[AI] Registered metadata_assistant agent to metadataService');
338
+ ctx.logger.info('[AI] metadata_assistant agent registered');
339
+ } else {
340
+ console.log('[AI] metadata_assistant agent already exists, skipping');
341
+ ctx.logger.debug('[AI] metadata_assistant agent already exists, skipping auto-registration');
342
+ }
343
+ } catch (err) {
344
+ ctx.logger.warn('[AI] Failed to register metadata_assistant agent', err instanceof Error ? { error: err.message, stack: err.stack } : { error: String(err) });
345
+ }
346
+ } catch (err) {
347
+ ctx.logger.debug('[AI] Failed to register metadata tools', err instanceof Error ? err : undefined);
348
+ }
151
349
  }
152
350
 
153
351
  // Trigger hook to notify AI service is ready — other plugins can register tools
@@ -156,21 +354,30 @@ export class AIServicePlugin implements Plugin {
156
354
  // Build and expose route definitions
157
355
  const routes = buildAIRoutes(this.service, this.service.conversationService, ctx.logger);
158
356
 
357
+ // Build tool routes
358
+ const toolRoutes = buildToolRoutes(this.service, ctx.logger);
359
+ routes.push(...toolRoutes);
360
+ ctx.logger.info(`[AI] Tool routes registered (${toolRoutes.length} routes)`);
361
+
159
362
  // Build agent routes if metadata service is available
160
- try {
161
- const metadataService = ctx.getService<IMetadataService>('metadata');
162
- if (metadataService) {
163
- const agentRuntime = new AgentRuntime(metadataService);
164
- const agentRoutes = buildAgentRoutes(this.service, agentRuntime, ctx.logger);
165
- routes.push(...agentRoutes);
166
- }
167
- } catch {
363
+ if (metadataService) {
364
+ const agentRuntime = new AgentRuntime(metadataService);
365
+ const agentRoutes = buildAgentRoutes(this.service, agentRuntime, ctx.logger);
366
+ routes.push(...agentRoutes);
367
+ ctx.logger.info(`[AI] Agent routes registered (${agentRoutes.length} routes)`);
368
+ } else {
168
369
  ctx.logger.debug('[AI] Metadata service not available, skipping agent routes');
169
370
  }
170
371
 
171
372
  // Trigger hook so HTTP server plugins can mount these routes
172
373
  await ctx.trigger('ai:routes', routes);
173
374
 
375
+ // Cache routes on the kernel so HttpDispatcher can find them
376
+ const kernel = ctx.getKernel();
377
+ if (kernel) {
378
+ (kernel as any).__aiRoutes = routes;
379
+ }
380
+
174
381
  ctx.logger.info(
175
382
  `[AI] Service started — adapter="${this.service.adapterName}", ` +
176
383
  `tools=${this.service.toolRegistry.size}, ` +
@@ -1,10 +1,12 @@
1
1
  // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
2
 
3
- import type { AIMessage } from '@objectstack/spec/contracts';
3
+ import type { ModelMessage } from '@objectstack/spec/contracts';
4
4
  import type { Logger } from '@objectstack/spec/contracts';
5
5
  import type { AIService } from '../ai-service.js';
6
6
  import type { AgentRuntime, AgentChatContext } from '../agent-runtime.js';
7
7
  import type { RouteDefinition } from './ai-routes.js';
8
+ import { normalizeMessage, validateMessageContent } from './message-utils.js';
9
+ import { encodeVercelDataStream } from '../stream/vercel-stream-encoder.js';
8
10
 
9
11
  /**
10
12
  * Allowed message roles for the agent chat endpoint.
@@ -25,10 +27,10 @@ function validateAgentMessage(raw: unknown): string | null {
25
27
  if (typeof msg.role !== 'string' || !ALLOWED_AGENT_ROLES.has(msg.role)) {
26
28
  return `message.role must be one of ${[...ALLOWED_AGENT_ROLES].map(r => `"${r}"`).join(', ')} for agent chat`;
27
29
  }
28
- if (typeof msg.content !== 'string') {
29
- return 'message.content must be a string';
30
- }
31
- return null;
30
+
31
+ // Assistant messages may legitimately have empty content (e.g. tool-call-only)
32
+ const allowEmpty = msg.role === 'assistant';
33
+ return validateMessageContent(msg, { allowEmptyContent: allowEmpty });
32
34
  }
33
35
 
34
36
  /**
@@ -36,6 +38,7 @@ function validateAgentMessage(raw: unknown): string | null {
36
38
  *
37
39
  * | Method | Path | Description |
38
40
  * |:---|:---|:---|
41
+ * | GET | /api/v1/ai/agents | List all active agents |
39
42
  * | POST | /api/v1/ai/agents/:agentName/chat | Chat with a specific agent |
40
43
  */
41
44
  export function buildAgentRoutes(
@@ -44,10 +47,37 @@ export function buildAgentRoutes(
44
47
  logger: Logger,
45
48
  ): RouteDefinition[] {
46
49
  return [
50
+ // ── List active agents ──────────────────────────────────────
51
+ {
52
+ method: 'GET',
53
+ path: '/api/v1/ai/agents',
54
+ description: 'List all active AI agents',
55
+ auth: true,
56
+ permissions: ['ai:chat'],
57
+ handler: async () => {
58
+ try {
59
+ const agents = await agentRuntime.listAgents();
60
+ return { status: 200, body: { agents } };
61
+ } catch (err) {
62
+ logger.error(
63
+ '[AI Route] /agents list error',
64
+ err instanceof Error ? err : undefined,
65
+ );
66
+ return { status: 500, body: { error: 'Internal AI service error' } };
67
+ }
68
+ },
69
+ },
70
+
71
+ // ── Chat with a specific agent ──────────────────────────────
72
+ //
73
+ // Dual-mode endpoint matching the general chat route behaviour:
74
+ // • `stream !== false` → Vercel Data Stream Protocol (SSE)
75
+ // • `stream === false` → JSON response (legacy)
76
+ //
47
77
  {
48
78
  method: 'POST',
49
79
  path: '/api/v1/ai/agents/:agentName/chat',
50
- description: 'Chat with a specific AI agent',
80
+ description: 'Chat with a specific AI agent (supports Vercel AI Data Stream Protocol)',
51
81
  auth: true,
52
82
  permissions: ['ai:chat', 'ai:agents'],
53
83
  handler: async (req) => {
@@ -57,11 +87,12 @@ export function buildAgentRoutes(
57
87
  }
58
88
 
59
89
  // Parse request body
90
+ const body = (req.body ?? {}) as Record<string, unknown>;
60
91
  const {
61
92
  messages: rawMessages,
62
93
  context: chatContext,
63
94
  options: extraOptions,
64
- } = (req.body ?? {}) as {
95
+ } = body as {
65
96
  messages?: unknown[];
66
97
  context?: AgentChatContext;
67
98
  options?: Record<string, unknown>;
@@ -109,17 +140,42 @@ export function buildAgentRoutes(
109
140
  const mergedOptions = { ...agentOptions, ...safeOverrides };
110
141
 
111
142
  // Prepend system messages then user conversation
112
- const fullMessages: AIMessage[] = [
143
+ const fullMessages: ModelMessage[] = [
113
144
  ...systemMessages,
114
- ...(rawMessages as AIMessage[]),
145
+ ...rawMessages.map(m => normalizeMessage(m as Record<string, unknown>)),
115
146
  ];
116
147
 
117
- // Use chatWithTools for automatic tool resolution
118
- const result = await aiService.chatWithTools(fullMessages, {
148
+ const chatWithToolsOptions = {
119
149
  ...mergedOptions,
120
150
  maxIterations: agent.planning?.maxIterations,
121
- });
151
+ };
152
+
153
+ // ── Choose response mode ─────────────────────────────
154
+ const wantStream = body.stream !== false;
155
+
156
+ if (wantStream) {
157
+ // Vercel Data Stream Protocol (SSE) — matches general chat behaviour
158
+ if (!aiService.streamChatWithTools) {
159
+ return { status: 501, body: { error: 'Streaming is not supported by the configured AI service' } };
160
+ }
161
+ const events = aiService.streamChatWithTools(fullMessages, chatWithToolsOptions);
162
+ return {
163
+ status: 200,
164
+ stream: true,
165
+ vercelDataStream: true,
166
+ contentType: 'text/event-stream',
167
+ headers: {
168
+ 'Content-Type': 'text/event-stream',
169
+ 'Cache-Control': 'no-cache',
170
+ 'Connection': 'keep-alive',
171
+ 'x-vercel-ai-ui-message-stream': 'v1',
172
+ },
173
+ events: encodeVercelDataStream(events),
174
+ };
175
+ }
122
176
 
177
+ // JSON response (non-streaming / legacy)
178
+ const result = await aiService.chatWithTools(fullMessages, chatWithToolsOptions);
123
179
  return { status: 200, body: result };
124
180
  } catch (err) {
125
181
  logger.error(