@objectstack/service-ai 4.0.3 → 4.0.5
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/README.md +293 -0
- package/dist/index.cjs +1176 -135
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1225 -430
- package/dist/index.d.ts +1225 -430
- package/dist/index.js +1160 -128
- package/dist/index.js.map +1 -1
- package/package.json +35 -8
- package/.turbo/turbo-build.log +0 -22
- package/CHANGELOG.md +0 -53
- package/src/__tests__/ai-service.test.ts +0 -964
- package/src/__tests__/auth-and-toolcalling.test.ts +0 -677
- package/src/__tests__/chatbot-features.test.ts +0 -1116
- package/src/__tests__/metadata-tools.test.ts +0 -970
- package/src/__tests__/objectql-conversation-service.test.ts +0 -382
- package/src/__tests__/tool-routes.test.ts +0 -191
- package/src/__tests__/vercel-stream-encoder.test.ts +0 -310
- package/src/adapters/index.ts +0 -6
- package/src/adapters/memory-adapter.ts +0 -72
- package/src/adapters/types.ts +0 -3
- package/src/adapters/vercel-adapter.ts +0 -148
- package/src/agent-runtime.ts +0 -154
- package/src/agents/data-chat-agent.ts +0 -79
- package/src/agents/index.ts +0 -4
- package/src/agents/metadata-assistant-agent.ts +0 -87
- package/src/ai-service.ts +0 -364
- package/src/conversation/in-memory-conversation-service.ts +0 -103
- package/src/conversation/index.ts +0 -4
- package/src/conversation/objectql-conversation-service.ts +0 -301
- package/src/index.ts +0 -60
- package/src/objects/ai-conversation.object.ts +0 -86
- package/src/objects/ai-message.object.ts +0 -86
- package/src/objects/index.ts +0 -10
- package/src/plugin.ts +0 -391
- package/src/routes/agent-routes.ts +0 -190
- package/src/routes/ai-routes.ts +0 -439
- package/src/routes/index.ts +0 -5
- package/src/routes/message-utils.ts +0 -90
- package/src/routes/tool-routes.ts +0 -142
- package/src/stream/index.ts +0 -3
- package/src/stream/vercel-stream-encoder.ts +0 -153
- package/src/tools/add-field.tool.ts +0 -70
- package/src/tools/create-object.tool.ts +0 -66
- package/src/tools/data-tools.ts +0 -293
- package/src/tools/delete-field.tool.ts +0 -38
- package/src/tools/describe-object.tool.ts +0 -31
- package/src/tools/index.ts +0 -18
- package/src/tools/list-objects.tool.ts +0 -34
- package/src/tools/metadata-tools.ts +0 -430
- package/src/tools/modify-field.tool.ts +0 -44
- package/src/tools/tool-registry.ts +0 -132
- package/tsconfig.json +0 -17
package/src/plugin.ts
DELETED
|
@@ -1,391 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
-
|
|
3
|
-
import type { Plugin, PluginContext } from '@objectstack/core';
|
|
4
|
-
import type { IAIService, IAIConversationService, IDataEngine, IMetadataService, LLMAdapter } from '@objectstack/spec/contracts';
|
|
5
|
-
import { AIService } from './ai-service.js';
|
|
6
|
-
import type { AIServiceConfig } from './ai-service.js';
|
|
7
|
-
import { buildAIRoutes } from './routes/ai-routes.js';
|
|
8
|
-
import { buildAgentRoutes } from './routes/agent-routes.js';
|
|
9
|
-
import { buildToolRoutes } from './routes/tool-routes.js';
|
|
10
|
-
import { ObjectQLConversationService } from './conversation/objectql-conversation-service.js';
|
|
11
|
-
import { AiConversationObject, AiMessageObject } from './objects/index.js';
|
|
12
|
-
import { registerDataTools } from './tools/data-tools.js';
|
|
13
|
-
import { registerMetadataTools } from './tools/metadata-tools.js';
|
|
14
|
-
import { AgentRuntime } from './agent-runtime.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';
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Configuration options for the AIServicePlugin.
|
|
21
|
-
*/
|
|
22
|
-
export interface AIServicePluginOptions {
|
|
23
|
-
/** LLM adapter to use (defaults to MemoryLLMAdapter). */
|
|
24
|
-
adapter?: LLMAdapter;
|
|
25
|
-
/** Enable debug logging. */
|
|
26
|
-
debug?: boolean;
|
|
27
|
-
/** Explicit conversation service override. When set, auto-detection is skipped. */
|
|
28
|
-
conversationService?: IAIConversationService;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* AIServicePlugin — Kernel plugin for the unified AI capability service.
|
|
33
|
-
*
|
|
34
|
-
* Lifecycle:
|
|
35
|
-
* 1. **init** — Creates {@link AIService}, registers as `'ai'` service.
|
|
36
|
-
* If an existing AI service is already registered, it is replaced.
|
|
37
|
-
* 2. **start** — Triggers `'ai:ready'` hook so other plugins can register
|
|
38
|
-
* tools or extend the service. Registers REST/SSE routes.
|
|
39
|
-
* 3. **destroy** — Cleans up references.
|
|
40
|
-
*
|
|
41
|
-
* @example
|
|
42
|
-
* ```ts
|
|
43
|
-
* import { LiteKernel } from '@objectstack/core';
|
|
44
|
-
* import { AIServicePlugin } from '@objectstack/service-ai';
|
|
45
|
-
*
|
|
46
|
-
* const kernel = new LiteKernel();
|
|
47
|
-
* kernel.use(new AIServicePlugin());
|
|
48
|
-
* await kernel.bootstrap();
|
|
49
|
-
*
|
|
50
|
-
* const ai = kernel.getService<IAIService>('ai');
|
|
51
|
-
* const result = await ai.chat([{ role: 'user', content: 'Hello' }]);
|
|
52
|
-
* ```
|
|
53
|
-
*/
|
|
54
|
-
export class AIServicePlugin implements Plugin {
|
|
55
|
-
name = 'com.objectstack.service-ai';
|
|
56
|
-
version = '1.0.0';
|
|
57
|
-
type = 'standard' as const;
|
|
58
|
-
dependencies: string[] = ['com.objectstack.engine.objectql']; // manifest service required
|
|
59
|
-
|
|
60
|
-
private service?: AIService;
|
|
61
|
-
private readonly options: AIServicePluginOptions;
|
|
62
|
-
|
|
63
|
-
constructor(options: AIServicePluginOptions = {}) {
|
|
64
|
-
this.options = options;
|
|
65
|
-
}
|
|
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
|
-
|
|
151
|
-
async init(ctx: PluginContext): Promise<void> {
|
|
152
|
-
// Check if there is an existing AI service (e.g. from dev-plugin)
|
|
153
|
-
let hasExisting = false;
|
|
154
|
-
try {
|
|
155
|
-
const existing = ctx.getService<IAIService>('ai');
|
|
156
|
-
if (existing && typeof existing.chat === 'function') {
|
|
157
|
-
hasExisting = true;
|
|
158
|
-
ctx.logger.debug('[AI] Found existing AI service, replacing');
|
|
159
|
-
}
|
|
160
|
-
} catch {
|
|
161
|
-
// No existing service — that's fine
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// Determine conversation service: explicit > auto-detect IDataEngine > InMemory fallback
|
|
165
|
-
let conversationService: IAIConversationService | undefined = this.options.conversationService;
|
|
166
|
-
if (!conversationService) {
|
|
167
|
-
try {
|
|
168
|
-
const engine = ctx.getService<IDataEngine>('data');
|
|
169
|
-
if (engine && typeof engine.find === 'function') {
|
|
170
|
-
conversationService = new ObjectQLConversationService(engine);
|
|
171
|
-
ctx.logger.info('[AI] Using ObjectQLConversationService (IDataEngine detected)');
|
|
172
|
-
}
|
|
173
|
-
} catch {
|
|
174
|
-
// No data engine — fall back to InMemory
|
|
175
|
-
}
|
|
176
|
-
}
|
|
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
|
-
|
|
196
|
-
const config: AIServiceConfig = {
|
|
197
|
-
adapter,
|
|
198
|
-
logger: ctx.logger,
|
|
199
|
-
conversationService,
|
|
200
|
-
};
|
|
201
|
-
|
|
202
|
-
this.service = new AIService(config);
|
|
203
|
-
|
|
204
|
-
// Register or replace the AI service
|
|
205
|
-
if (hasExisting) {
|
|
206
|
-
ctx.replaceService('ai', this.service);
|
|
207
|
-
} else {
|
|
208
|
-
ctx.registerService('ai', this.service);
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
// Register AI system objects via the manifest service.
|
|
212
|
-
ctx.getService<{ register(m: any): void }>('manifest').register({
|
|
213
|
-
id: 'com.objectstack.service-ai',
|
|
214
|
-
name: 'AI Service',
|
|
215
|
-
version: '1.0.0',
|
|
216
|
-
type: 'plugin',
|
|
217
|
-
namespace: 'ai',
|
|
218
|
-
objects: [AiConversationObject, AiMessageObject],
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
if (this.options.debug) {
|
|
222
|
-
ctx.hook('ai:beforeChat', async (messages: unknown) => {
|
|
223
|
-
ctx.logger.debug('[AI] Before chat', { messages });
|
|
224
|
-
});
|
|
225
|
-
}
|
|
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
|
-
|
|
244
|
-
ctx.logger.info('[AI] Service initialized');
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
async start(ctx: PluginContext): Promise<void> {
|
|
248
|
-
if (!this.service) return;
|
|
249
|
-
|
|
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
|
|
261
|
-
try {
|
|
262
|
-
const dataEngine = ctx.getService<IDataEngine>('data');
|
|
263
|
-
if (dataEngine) {
|
|
264
|
-
registerDataTools(this.service.toolRegistry, { dataEngine });
|
|
265
|
-
ctx.logger.info('[AI] Built-in data tools registered');
|
|
266
|
-
|
|
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
|
-
}
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
} catch {
|
|
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
|
-
}
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
// Trigger hook to notify AI service is ready — other plugins can register tools
|
|
352
|
-
await ctx.trigger('ai:ready', this.service);
|
|
353
|
-
|
|
354
|
-
// Build and expose route definitions
|
|
355
|
-
const routes = buildAIRoutes(this.service, this.service.conversationService, ctx.logger);
|
|
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
|
-
|
|
362
|
-
// Build agent routes if metadata service is available
|
|
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 {
|
|
369
|
-
ctx.logger.debug('[AI] Metadata service not available, skipping agent routes');
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
// Trigger hook so HTTP server plugins can mount these routes
|
|
373
|
-
await ctx.trigger('ai:routes', routes);
|
|
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
|
-
|
|
381
|
-
ctx.logger.info(
|
|
382
|
-
`[AI] Service started — adapter="${this.service.adapterName}", ` +
|
|
383
|
-
`tools=${this.service.toolRegistry.size}, ` +
|
|
384
|
-
`routes=${routes.length}`,
|
|
385
|
-
);
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
async destroy(): Promise<void> {
|
|
389
|
-
this.service = undefined;
|
|
390
|
-
}
|
|
391
|
-
}
|
|
@@ -1,190 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
-
|
|
3
|
-
import type { ModelMessage } from '@objectstack/spec/contracts';
|
|
4
|
-
import type { Logger } from '@objectstack/spec/contracts';
|
|
5
|
-
import type { AIService } from '../ai-service.js';
|
|
6
|
-
import type { AgentRuntime, AgentChatContext } from '../agent-runtime.js';
|
|
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';
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Allowed message roles for the agent chat endpoint.
|
|
13
|
-
*
|
|
14
|
-
* Only `user` and `assistant` are accepted from clients.
|
|
15
|
-
* `system` messages are injected server-side from agent instructions,
|
|
16
|
-
* and `tool` messages are produced by the tool-call loop — accepting
|
|
17
|
-
* either from the client would allow callers to override agent
|
|
18
|
-
* guardrails or inject fabricated tool results.
|
|
19
|
-
*/
|
|
20
|
-
const ALLOWED_AGENT_ROLES = new Set<string>(['user', 'assistant']);
|
|
21
|
-
|
|
22
|
-
function validateAgentMessage(raw: unknown): string | null {
|
|
23
|
-
if (typeof raw !== 'object' || raw === null) {
|
|
24
|
-
return 'each message must be an object';
|
|
25
|
-
}
|
|
26
|
-
const msg = raw as Record<string, unknown>;
|
|
27
|
-
if (typeof msg.role !== 'string' || !ALLOWED_AGENT_ROLES.has(msg.role)) {
|
|
28
|
-
return `message.role must be one of ${[...ALLOWED_AGENT_ROLES].map(r => `"${r}"`).join(', ')} for agent chat`;
|
|
29
|
-
}
|
|
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 });
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Build agent-specific REST routes.
|
|
38
|
-
*
|
|
39
|
-
* | Method | Path | Description |
|
|
40
|
-
* |:---|:---|:---|
|
|
41
|
-
* | GET | /api/v1/ai/agents | List all active agents |
|
|
42
|
-
* | POST | /api/v1/ai/agents/:agentName/chat | Chat with a specific agent |
|
|
43
|
-
*/
|
|
44
|
-
export function buildAgentRoutes(
|
|
45
|
-
aiService: AIService,
|
|
46
|
-
agentRuntime: AgentRuntime,
|
|
47
|
-
logger: Logger,
|
|
48
|
-
): RouteDefinition[] {
|
|
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
|
-
//
|
|
77
|
-
{
|
|
78
|
-
method: 'POST',
|
|
79
|
-
path: '/api/v1/ai/agents/:agentName/chat',
|
|
80
|
-
description: 'Chat with a specific AI agent (supports Vercel AI Data Stream Protocol)',
|
|
81
|
-
auth: true,
|
|
82
|
-
permissions: ['ai:chat', 'ai:agents'],
|
|
83
|
-
handler: async (req) => {
|
|
84
|
-
const agentName = req.params?.agentName;
|
|
85
|
-
if (!agentName) {
|
|
86
|
-
return { status: 400, body: { error: 'agentName parameter is required' } };
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// Parse request body
|
|
90
|
-
const body = (req.body ?? {}) as Record<string, unknown>;
|
|
91
|
-
const {
|
|
92
|
-
messages: rawMessages,
|
|
93
|
-
context: chatContext,
|
|
94
|
-
options: extraOptions,
|
|
95
|
-
} = body as {
|
|
96
|
-
messages?: unknown[];
|
|
97
|
-
context?: AgentChatContext;
|
|
98
|
-
options?: Record<string, unknown>;
|
|
99
|
-
};
|
|
100
|
-
|
|
101
|
-
if (!Array.isArray(rawMessages) || rawMessages.length === 0) {
|
|
102
|
-
return { status: 400, body: { error: 'messages array is required' } };
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
for (const msg of rawMessages) {
|
|
106
|
-
const err = validateAgentMessage(msg);
|
|
107
|
-
if (err) return { status: 400, body: { error: err } };
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// Load agent definition
|
|
111
|
-
const agent = await agentRuntime.loadAgent(agentName);
|
|
112
|
-
if (!agent) {
|
|
113
|
-
return { status: 404, body: { error: `Agent "${agentName}" not found` } };
|
|
114
|
-
}
|
|
115
|
-
if (!agent.active) {
|
|
116
|
-
return { status: 403, body: { error: `Agent "${agentName}" is not active` } };
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
try {
|
|
120
|
-
// Build system messages from agent instructions + UI context
|
|
121
|
-
const systemMessages = agentRuntime.buildSystemMessages(agent, chatContext);
|
|
122
|
-
|
|
123
|
-
// Resolve agent model/tools → request options
|
|
124
|
-
const agentOptions = agentRuntime.buildRequestOptions(
|
|
125
|
-
agent,
|
|
126
|
-
aiService.toolRegistry.getAll(),
|
|
127
|
-
);
|
|
128
|
-
|
|
129
|
-
// Whitelist only safe caller overrides — block tools/toolChoice/model
|
|
130
|
-
// to prevent tool-definition injection or DoS via unregistered tools.
|
|
131
|
-
const safeOverrides: Record<string, unknown> = {};
|
|
132
|
-
if (extraOptions) {
|
|
133
|
-
const ALLOWED_KEYS = new Set(['temperature', 'maxTokens', 'stop']);
|
|
134
|
-
for (const key of Object.keys(extraOptions)) {
|
|
135
|
-
if (ALLOWED_KEYS.has(key)) {
|
|
136
|
-
safeOverrides[key] = extraOptions[key];
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
const mergedOptions = { ...agentOptions, ...safeOverrides };
|
|
141
|
-
|
|
142
|
-
// Prepend system messages then user conversation
|
|
143
|
-
const fullMessages: ModelMessage[] = [
|
|
144
|
-
...systemMessages,
|
|
145
|
-
...rawMessages.map(m => normalizeMessage(m as Record<string, unknown>)),
|
|
146
|
-
];
|
|
147
|
-
|
|
148
|
-
const chatWithToolsOptions = {
|
|
149
|
-
...mergedOptions,
|
|
150
|
-
maxIterations: agent.planning?.maxIterations,
|
|
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
|
-
}
|
|
176
|
-
|
|
177
|
-
// JSON response (non-streaming / legacy)
|
|
178
|
-
const result = await aiService.chatWithTools(fullMessages, chatWithToolsOptions);
|
|
179
|
-
return { status: 200, body: result };
|
|
180
|
-
} catch (err) {
|
|
181
|
-
logger.error(
|
|
182
|
-
'[AI Route] /agents/:agentName/chat error',
|
|
183
|
-
err instanceof Error ? err : undefined,
|
|
184
|
-
);
|
|
185
|
-
return { status: 500, body: { error: 'Internal AI service error' } };
|
|
186
|
-
}
|
|
187
|
-
},
|
|
188
|
-
},
|
|
189
|
-
];
|
|
190
|
-
}
|