@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.
- package/.turbo/turbo-build.log +11 -11
- package/CHANGELOG.md +17 -0
- package/dist/index.cjs +1632 -355
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +330 -87
- package/dist/index.d.ts +330 -87
- package/dist/index.js +1623 -352
- package/dist/index.js.map +1 -1
- package/package.json +27 -5
- package/src/__tests__/ai-service.test.ts +260 -27
- package/src/__tests__/auth-and-toolcalling.test.ts +81 -29
- package/src/__tests__/chatbot-features.test.ts +397 -102
- package/src/__tests__/metadata-tools.test.ts +970 -0
- package/src/__tests__/objectql-conversation-service.test.ts +34 -16
- package/src/__tests__/tool-routes.test.ts +191 -0
- package/src/__tests__/vercel-stream-encoder.test.ts +310 -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 +75 -36
- package/src/conversation/in-memory-conversation-service.ts +2 -2
- package/src/conversation/objectql-conversation-service.ts +67 -18
- package/src/index.ts +22 -2
- package/src/plugin.ts +237 -30
- package/src/routes/agent-routes.ts +68 -12
- package/src/routes/ai-routes.ts +93 -14
- package/src/routes/index.ts +1 -0
- package/src/routes/message-utils.ts +90 -0
- package/src/routes/tool-routes.ts +142 -0
- package/src/stream/index.ts +3 -0
- package/src/stream/vercel-stream-encoder.ts +153 -0
- package/src/tools/add-field.tool.ts +70 -0
- package/src/tools/create-object.tool.ts +66 -0
- package/src/tools/data-tools.ts +4 -101
- package/src/tools/delete-field.tool.ts +38 -0
- package/src/tools/describe-object.tool.ts +31 -0
- package/src/tools/index.ts +12 -1
- package/src/tools/list-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
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
|
|
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
|
|
106
|
-
ctx.
|
|
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
|
|
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
|
-
|
|
131
|
-
|
|
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
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
150
|
-
|
|
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
|
-
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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 {
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
return
|
|
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
|
-
} =
|
|
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:
|
|
143
|
+
const fullMessages: ModelMessage[] = [
|
|
113
144
|
...systemMessages,
|
|
114
|
-
...(
|
|
145
|
+
...rawMessages.map(m => normalizeMessage(m as Record<string, unknown>)),
|
|
115
146
|
];
|
|
116
147
|
|
|
117
|
-
|
|
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(
|