@objectstack/service-ai 4.0.2 → 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 +10 -10
- package/CHANGELOG.md +8 -0
- package/dist/index.cjs +1869 -1646
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +32 -27
- package/dist/index.d.ts +32 -27
- package/dist/index.js +2135 -1906
- package/dist/index.js.map +1 -1
- package/package.json +6 -6
- package/src/__tests__/ai-service.test.ts +13 -1
- package/src/__tests__/auth-and-toolcalling.test.ts +51 -1
- package/src/__tests__/chatbot-features.test.ts +172 -24
- package/src/__tests__/metadata-tools.test.ts +35 -29
- package/src/__tests__/tool-routes.test.ts +191 -0
- package/src/__tests__/vercel-stream-encoder.test.ts +47 -0
- package/src/agents/metadata-assistant-agent.ts +4 -4
- package/src/ai-service.ts +7 -0
- package/src/index.ts +3 -2
- package/src/plugin.ts +83 -33
- package/src/routes/agent-routes.ts +43 -10
- package/src/routes/ai-routes.ts +3 -67
- 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/vercel-stream-encoder.ts +24 -0
- package/src/tools/data-tools.ts +4 -101
- package/src/tools/describe-object.tool.ts +31 -0
- package/src/tools/index.ts +2 -2
- package/src/tools/{list-metadata-objects.tool.ts → list-objects.tool.ts} +9 -9
- package/src/tools/metadata-tools.ts +8 -8
- package/src/tools/describe-metadata-object.tool.ts +0 -32
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
4
|
+
import { buildToolRoutes } from '../routes/tool-routes.js';
|
|
5
|
+
import { AIService } from '../ai-service.js';
|
|
6
|
+
import { InMemoryConversationService } from '../conversation/in-memory-conversation-service.js';
|
|
7
|
+
import { ToolRegistry } from '../tools/tool-registry.js';
|
|
8
|
+
import type { Logger } from '@objectstack/spec/contracts';
|
|
9
|
+
|
|
10
|
+
const silentLogger: Logger = {
|
|
11
|
+
debug: vi.fn(),
|
|
12
|
+
info: vi.fn(),
|
|
13
|
+
warn: vi.fn(),
|
|
14
|
+
error: vi.fn(),
|
|
15
|
+
fatal: vi.fn(),
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
describe('Tool Routes', () => {
|
|
19
|
+
let aiService: AIService;
|
|
20
|
+
let routes: ReturnType<typeof buildToolRoutes>;
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
const conversationService = new InMemoryConversationService();
|
|
24
|
+
aiService = new AIService({
|
|
25
|
+
adapter: 'memory',
|
|
26
|
+
conversationService,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Register a test tool
|
|
30
|
+
aiService.toolRegistry.register(
|
|
31
|
+
{
|
|
32
|
+
name: 'test_tool',
|
|
33
|
+
description: 'A test tool for playground',
|
|
34
|
+
parameters: {
|
|
35
|
+
type: 'object',
|
|
36
|
+
properties: {
|
|
37
|
+
message: { type: 'string' },
|
|
38
|
+
},
|
|
39
|
+
required: ['message'],
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
async (params: any) => {
|
|
43
|
+
return JSON.stringify({ echo: params.message });
|
|
44
|
+
}
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
routes = buildToolRoutes(aiService, silentLogger);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('GET /api/v1/ai/tools', () => {
|
|
51
|
+
it('should list all registered tools', async () => {
|
|
52
|
+
const listRoute = routes.find(r => r.method === 'GET' && r.path === '/api/v1/ai/tools');
|
|
53
|
+
expect(listRoute).toBeDefined();
|
|
54
|
+
|
|
55
|
+
const response = await listRoute!.handler({});
|
|
56
|
+
expect(response.status).toBe(200);
|
|
57
|
+
expect(response.body).toHaveProperty('tools');
|
|
58
|
+
expect(Array.isArray((response.body as any).tools)).toBe(true);
|
|
59
|
+
|
|
60
|
+
const tools = (response.body as any).tools;
|
|
61
|
+
expect(tools.length).toBeGreaterThan(0);
|
|
62
|
+
expect(tools.some((t: any) => t.name === 'test_tool')).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should require authentication', () => {
|
|
66
|
+
const listRoute = routes.find(r => r.method === 'GET' && r.path === '/api/v1/ai/tools');
|
|
67
|
+
expect(listRoute?.auth).toBe(true);
|
|
68
|
+
expect(listRoute?.permissions).toContain('ai:tools');
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('POST /api/v1/ai/tools/:toolName/execute', () => {
|
|
73
|
+
it('should execute a tool with parameters', async () => {
|
|
74
|
+
const executeRoute = routes.find(
|
|
75
|
+
r => r.method === 'POST' && r.path === '/api/v1/ai/tools/:toolName/execute'
|
|
76
|
+
);
|
|
77
|
+
expect(executeRoute).toBeDefined();
|
|
78
|
+
|
|
79
|
+
const response = await executeRoute!.handler({
|
|
80
|
+
params: { toolName: 'test_tool' },
|
|
81
|
+
body: {
|
|
82
|
+
parameters: { message: 'Hello, Playground!' },
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(response.status).toBe(200);
|
|
87
|
+
expect(response.body).toHaveProperty('result');
|
|
88
|
+
// Result is a JSON string from the handler
|
|
89
|
+
expect((response.body as any).result).toBe('{"echo":"Hello, Playground!"}');
|
|
90
|
+
expect((response.body as any).toolName).toBe('test_tool');
|
|
91
|
+
expect((response.body as any).duration).toBeTypeOf('number');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('should return 404 for non-existent tool', async () => {
|
|
95
|
+
const executeRoute = routes.find(
|
|
96
|
+
r => r.method === 'POST' && r.path === '/api/v1/ai/tools/:toolName/execute'
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const response = await executeRoute!.handler({
|
|
100
|
+
params: { toolName: 'non_existent_tool' },
|
|
101
|
+
body: {
|
|
102
|
+
parameters: {},
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
expect(response.status).toBe(404);
|
|
107
|
+
expect((response.body as any).error).toContain('not found');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('should return 400 when toolName is missing', async () => {
|
|
111
|
+
const executeRoute = routes.find(
|
|
112
|
+
r => r.method === 'POST' && r.path === '/api/v1/ai/tools/:toolName/execute'
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const response = await executeRoute!.handler({
|
|
116
|
+
body: {
|
|
117
|
+
parameters: {},
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
expect(response.status).toBe(400);
|
|
122
|
+
expect((response.body as any).error).toContain('toolName');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('should return 400 when parameters are missing', async () => {
|
|
126
|
+
const executeRoute = routes.find(
|
|
127
|
+
r => r.method === 'POST' && r.path === '/api/v1/ai/tools/:toolName/execute'
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const response = await executeRoute!.handler({
|
|
131
|
+
params: { toolName: 'test_tool' },
|
|
132
|
+
body: {},
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
expect(response.status).toBe(400);
|
|
136
|
+
expect((response.body as any).error).toContain('parameters');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('should handle tool execution errors', async () => {
|
|
140
|
+
// Register a tool that throws an error
|
|
141
|
+
aiService.toolRegistry.register(
|
|
142
|
+
{
|
|
143
|
+
name: 'error_tool',
|
|
144
|
+
description: 'A tool that throws an error',
|
|
145
|
+
parameters: { type: 'object', properties: {} },
|
|
146
|
+
},
|
|
147
|
+
async () => {
|
|
148
|
+
throw new Error('Tool execution failed');
|
|
149
|
+
}
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const executeRoute = routes.find(
|
|
153
|
+
r => r.method === 'POST' && r.path === '/api/v1/ai/tools/:toolName/execute'
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const response = await executeRoute!.handler({
|
|
157
|
+
params: { toolName: 'error_tool' },
|
|
158
|
+
body: {
|
|
159
|
+
parameters: {},
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
expect(response.status).toBe(500);
|
|
164
|
+
expect((response.body as any).error).toContain('Tool execution failed');
|
|
165
|
+
expect((response.body as any).duration).toBeTypeOf('number');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('should require authentication and permissions', () => {
|
|
169
|
+
const executeRoute = routes.find(
|
|
170
|
+
r => r.method === 'POST' && r.path === '/api/v1/ai/tools/:toolName/execute'
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
expect(executeRoute?.auth).toBe(true);
|
|
174
|
+
expect(executeRoute?.permissions).toContain('ai:tools');
|
|
175
|
+
expect(executeRoute?.permissions).toContain('ai:execute');
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe('Route Configuration', () => {
|
|
180
|
+
it('should register exactly 2 routes', () => {
|
|
181
|
+
expect(routes).toHaveLength(2);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('should have descriptive route descriptions', () => {
|
|
185
|
+
routes.forEach(route => {
|
|
186
|
+
expect(route.description).toBeTruthy();
|
|
187
|
+
expect(route.description.length).toBeGreaterThan(10);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -128,6 +128,53 @@ describe('encodeStreamPart', () => {
|
|
|
128
128
|
const part = { type: 'unknown-internal' } as unknown as TextStreamPart<ToolSet>;
|
|
129
129
|
expect(encodeStreamPart(part)).toBe('');
|
|
130
130
|
});
|
|
131
|
+
|
|
132
|
+
it('should encode reasoning-start part with g: prefix', () => {
|
|
133
|
+
const part = {
|
|
134
|
+
type: 'reasoning-start',
|
|
135
|
+
id: 'r1',
|
|
136
|
+
} as unknown as TextStreamPart<ToolSet>;
|
|
137
|
+
|
|
138
|
+
const frame = encodeStreamPart(part);
|
|
139
|
+
expect(frame).toBe('g:{"text":""}\n');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('should encode reasoning-delta part with g: prefix', () => {
|
|
143
|
+
const part = {
|
|
144
|
+
type: 'reasoning-delta',
|
|
145
|
+
id: 'r1',
|
|
146
|
+
text: 'Let me think through this step by step...',
|
|
147
|
+
} as unknown as TextStreamPart<ToolSet>;
|
|
148
|
+
|
|
149
|
+
const frame = encodeStreamPart(part);
|
|
150
|
+
expect(frame).toBe('g:{"text":"Let me think through this step by step..."}\n');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('should encode reasoning-end part as empty (no specific end marker)', () => {
|
|
154
|
+
const part = {
|
|
155
|
+
type: 'reasoning-end',
|
|
156
|
+
id: 'r1',
|
|
157
|
+
} as unknown as TextStreamPart<ToolSet>;
|
|
158
|
+
|
|
159
|
+
const frame = encodeStreamPart(part);
|
|
160
|
+
expect(frame).toBe('');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('should pass through custom step events', () => {
|
|
164
|
+
const part = {
|
|
165
|
+
type: 'step-start',
|
|
166
|
+
stepId: 'step_1',
|
|
167
|
+
stepName: 'Query database',
|
|
168
|
+
} as unknown as TextStreamPart<ToolSet>;
|
|
169
|
+
|
|
170
|
+
const frame = encodeStreamPart(part);
|
|
171
|
+
const payload = parseSSE(frame);
|
|
172
|
+
expect(payload).toEqual({
|
|
173
|
+
type: 'step-start',
|
|
174
|
+
stepId: 'step_1',
|
|
175
|
+
stepName: 'Query database',
|
|
176
|
+
});
|
|
177
|
+
});
|
|
131
178
|
});
|
|
132
179
|
|
|
133
180
|
// ─────────────────────────────────────────────────────────────────
|
|
@@ -36,8 +36,8 @@ Capabilities:
|
|
|
36
36
|
- Describe the full schema of a specific object
|
|
37
37
|
|
|
38
38
|
Guidelines:
|
|
39
|
-
1. Before creating a new object, use
|
|
40
|
-
2. Before modifying or deleting fields, use
|
|
39
|
+
1. Before creating a new object, use list_objects to check if a similar one already exists.
|
|
40
|
+
2. Before modifying or deleting fields, use describe_object to understand the current schema.
|
|
41
41
|
3. Always use snake_case for object names and field names (e.g. project_task, due_date).
|
|
42
42
|
4. Suggest meaningful field types based on the user's description (e.g. "deadline" → date, "active" → boolean).
|
|
43
43
|
5. When creating objects, propose a reasonable set of initial fields based on the entity type.
|
|
@@ -59,8 +59,8 @@ Guidelines:
|
|
|
59
59
|
{ type: 'action', name: 'add_field', description: 'Add a field to an existing object' },
|
|
60
60
|
{ type: 'action', name: 'modify_field', description: 'Modify an existing field definition' },
|
|
61
61
|
{ type: 'action', name: 'delete_field', description: 'Delete a field from an object' },
|
|
62
|
-
{ type: 'query', name: '
|
|
63
|
-
{ type: 'query', name: '
|
|
62
|
+
{ type: 'query', name: 'list_objects', description: 'List all data objects' },
|
|
63
|
+
{ type: 'query', name: 'describe_object', description: 'Describe an object schema' },
|
|
64
64
|
],
|
|
65
65
|
|
|
66
66
|
active: true,
|
package/src/ai-service.ts
CHANGED
|
@@ -332,6 +332,13 @@ export class AIService implements IAIService {
|
|
|
332
332
|
}
|
|
333
333
|
}
|
|
334
334
|
}
|
|
335
|
+
// Emit tool-result so the client can see tool output via SSE
|
|
336
|
+
yield {
|
|
337
|
+
type: 'tool-result',
|
|
338
|
+
toolCallId: tr.toolCallId,
|
|
339
|
+
toolName: tr.toolName,
|
|
340
|
+
output: tr.output,
|
|
341
|
+
} as TextStreamPart<ToolSet>;
|
|
335
342
|
conversation.push({
|
|
336
343
|
role: 'tool',
|
|
337
344
|
content: [tr],
|
package/src/index.ts
CHANGED
|
@@ -39,8 +39,8 @@ export {
|
|
|
39
39
|
addFieldTool,
|
|
40
40
|
modifyFieldTool,
|
|
41
41
|
deleteFieldTool,
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
listObjectsTool,
|
|
43
|
+
describeObjectTool,
|
|
44
44
|
} from './tools/metadata-tools.js';
|
|
45
45
|
|
|
46
46
|
// Agent runtime
|
|
@@ -56,4 +56,5 @@ export { AiConversationObject, AiMessageObject } from './objects/index.js';
|
|
|
56
56
|
// Routes
|
|
57
57
|
export { buildAIRoutes } from './routes/ai-routes.js';
|
|
58
58
|
export { buildAgentRoutes } from './routes/agent-routes.js';
|
|
59
|
+
export { buildToolRoutes } from './routes/tool-routes.js';
|
|
59
60
|
export type { RouteDefinition, RouteRequest, RouteResponse, RouteUserContext } from './routes/ai-routes.js';
|
package/src/plugin.ts
CHANGED
|
@@ -6,6 +6,7 @@ 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';
|
|
@@ -250,28 +251,54 @@ export class AIServicePlugin implements Plugin {
|
|
|
250
251
|
let metadataService: IMetadataService | undefined;
|
|
251
252
|
try {
|
|
252
253
|
metadataService = ctx.getService<IMetadataService>('metadata');
|
|
253
|
-
|
|
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);
|
|
254
257
|
ctx.logger.debug('[AI] Metadata service not available');
|
|
255
258
|
}
|
|
256
259
|
|
|
257
|
-
// Data tools require
|
|
260
|
+
// Data tools require only the data engine
|
|
258
261
|
try {
|
|
259
262
|
const dataEngine = ctx.getService<IDataEngine>('data');
|
|
260
|
-
if (dataEngine
|
|
261
|
-
registerDataTools(this.service.toolRegistry, { dataEngine
|
|
263
|
+
if (dataEngine) {
|
|
264
|
+
registerDataTools(this.service.toolRegistry, { dataEngine });
|
|
262
265
|
ctx.logger.info('[AI] Built-in data tools registered');
|
|
263
266
|
|
|
264
|
-
// Register
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
+
}
|
|
275
302
|
}
|
|
276
303
|
}
|
|
277
304
|
} catch {
|
|
@@ -284,17 +311,37 @@ export class AIServicePlugin implements Plugin {
|
|
|
284
311
|
registerMetadataTools(this.service.toolRegistry, { metadataService });
|
|
285
312
|
ctx.logger.info('[AI] Built-in metadata tools registered');
|
|
286
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
|
+
|
|
287
328
|
// Register the built-in metadata_assistant agent
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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) });
|
|
298
345
|
}
|
|
299
346
|
} catch (err) {
|
|
300
347
|
ctx.logger.debug('[AI] Failed to register metadata tools', err instanceof Error ? err : undefined);
|
|
@@ -307,15 +354,18 @@ export class AIServicePlugin implements Plugin {
|
|
|
307
354
|
// Build and expose route definitions
|
|
308
355
|
const routes = buildAIRoutes(this.service, this.service.conversationService, ctx.logger);
|
|
309
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
|
+
|
|
310
362
|
// Build agent routes if metadata service is available
|
|
311
|
-
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
}
|
|
318
|
-
} 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 {
|
|
319
369
|
ctx.logger.debug('[AI] Metadata service not available, skipping agent routes');
|
|
320
370
|
}
|
|
321
371
|
|
|
@@ -5,6 +5,8 @@ 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
|
/**
|
|
@@ -67,10 +69,15 @@ export function buildAgentRoutes(
|
|
|
67
69
|
},
|
|
68
70
|
|
|
69
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
|
+
//
|
|
70
77
|
{
|
|
71
78
|
method: 'POST',
|
|
72
79
|
path: '/api/v1/ai/agents/:agentName/chat',
|
|
73
|
-
description: 'Chat with a specific AI agent',
|
|
80
|
+
description: 'Chat with a specific AI agent (supports Vercel AI Data Stream Protocol)',
|
|
74
81
|
auth: true,
|
|
75
82
|
permissions: ['ai:chat', 'ai:agents'],
|
|
76
83
|
handler: async (req) => {
|
|
@@ -80,11 +87,12 @@ export function buildAgentRoutes(
|
|
|
80
87
|
}
|
|
81
88
|
|
|
82
89
|
// Parse request body
|
|
90
|
+
const body = (req.body ?? {}) as Record<string, unknown>;
|
|
83
91
|
const {
|
|
84
92
|
messages: rawMessages,
|
|
85
93
|
context: chatContext,
|
|
86
94
|
options: extraOptions,
|
|
87
|
-
} =
|
|
95
|
+
} = body as {
|
|
88
96
|
messages?: unknown[];
|
|
89
97
|
context?: AgentChatContext;
|
|
90
98
|
options?: Record<string, unknown>;
|
|
@@ -134,15 +142,40 @@ export function buildAgentRoutes(
|
|
|
134
142
|
// Prepend system messages then user conversation
|
|
135
143
|
const fullMessages: ModelMessage[] = [
|
|
136
144
|
...systemMessages,
|
|
137
|
-
...(
|
|
145
|
+
...rawMessages.map(m => normalizeMessage(m as Record<string, unknown>)),
|
|
138
146
|
];
|
|
139
147
|
|
|
140
|
-
|
|
141
|
-
const result = await aiService.chatWithTools(fullMessages, {
|
|
148
|
+
const chatWithToolsOptions = {
|
|
142
149
|
...mergedOptions,
|
|
143
150
|
maxIterations: agent.planning?.maxIterations,
|
|
144
|
-
}
|
|
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
|
+
}
|
|
145
176
|
|
|
177
|
+
// JSON response (non-streaming / legacy)
|
|
178
|
+
const result = await aiService.chatWithTools(fullMessages, chatWithToolsOptions);
|
|
146
179
|
return { status: 200, body: result };
|
|
147
180
|
} catch (err) {
|
|
148
181
|
logger.error(
|
package/src/routes/ai-routes.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import type { IAIService, IAIConversationService, ModelMessage } from '@objectstack/spec/contracts';
|
|
4
4
|
import type { Logger } from '@objectstack/spec/contracts';
|
|
5
5
|
import { encodeVercelDataStream } from '../stream/vercel-stream-encoder.js';
|
|
6
|
+
import { normalizeMessage, validateMessageContent } from './message-utils.js';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Minimal HTTP handler abstraction so routes stay framework-agnostic.
|
|
@@ -77,37 +78,6 @@ export interface RouteResponse {
|
|
|
77
78
|
/** Valid message roles accepted by the AI routes. */
|
|
78
79
|
const VALID_ROLES = new Set<string>(['system', 'user', 'assistant', 'tool']);
|
|
79
80
|
|
|
80
|
-
/**
|
|
81
|
-
* Normalize a Vercel AI SDK v6 message (which may use `parts` instead of
|
|
82
|
-
* `content`) into a plain `{ role, content }` ModelMessage.
|
|
83
|
-
*/
|
|
84
|
-
function normalizeMessage(raw: Record<string, unknown>): ModelMessage {
|
|
85
|
-
const role = raw.role as string;
|
|
86
|
-
|
|
87
|
-
// If content is already a string, use it directly
|
|
88
|
-
if (typeof raw.content === 'string') {
|
|
89
|
-
return { role, content: raw.content } as unknown as ModelMessage;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// If content is an array (multi-part), pass through
|
|
93
|
-
if (Array.isArray(raw.content)) {
|
|
94
|
-
return { role, content: raw.content } as unknown as ModelMessage;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// Vercel AI SDK v6: extract text from `parts` array
|
|
98
|
-
if (Array.isArray(raw.parts)) {
|
|
99
|
-
const textParts = (raw.parts as Array<Record<string, unknown>>)
|
|
100
|
-
.filter(p => p.type === 'text' && typeof p.text === 'string')
|
|
101
|
-
.map(p => p.text as string);
|
|
102
|
-
if (textParts.length > 0) {
|
|
103
|
-
return { role, content: textParts.join('') } as unknown as ModelMessage;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// Fallback: empty content (e.g. tool-only assistant messages)
|
|
108
|
-
return { role, content: '' } as unknown as ModelMessage;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
81
|
/**
|
|
112
82
|
* Validate that `raw` is a well-formed message.
|
|
113
83
|
* Returns null on success, or an error string on failure.
|
|
@@ -125,44 +95,10 @@ function validateMessage(raw: unknown): string | null {
|
|
|
125
95
|
if (typeof msg.role !== 'string' || !VALID_ROLES.has(msg.role)) {
|
|
126
96
|
return `message.role must be one of ${[...VALID_ROLES].map(r => `"${r}"`).join(', ')}`;
|
|
127
97
|
}
|
|
128
|
-
const content = msg.content;
|
|
129
|
-
|
|
130
|
-
// Vercel AI SDK v6 sends `parts` instead of (or alongside) `content`.
|
|
131
|
-
// Accept any message that carries a `parts` array, even when `content` is absent.
|
|
132
|
-
if (Array.isArray(msg.parts)) {
|
|
133
|
-
return null;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// content is a plain string — OK
|
|
137
|
-
if (typeof content === 'string') {
|
|
138
|
-
return null;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// content is an array of typed parts (legacy multi-part format)
|
|
142
|
-
if (Array.isArray(content)) {
|
|
143
|
-
for (const part of content as unknown[]) {
|
|
144
|
-
if (typeof part !== 'object' || part === null) {
|
|
145
|
-
return 'message.content array elements must be non-null objects';
|
|
146
|
-
}
|
|
147
|
-
const partObj = part as Record<string, unknown>;
|
|
148
|
-
if (typeof partObj.type !== 'string') {
|
|
149
|
-
return 'each message.content array element must have a string "type" property';
|
|
150
|
-
}
|
|
151
|
-
if (partObj.type === 'text' && typeof partObj.text !== 'string') {
|
|
152
|
-
return 'message.content elements with type "text" must have a string "text" property';
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
return null;
|
|
156
|
-
}
|
|
157
98
|
|
|
158
99
|
// Assistant / tool messages may legitimately have null or missing content
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
return null;
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
return 'message.content must be a string, an array, or include parts';
|
|
100
|
+
const allowEmpty = msg.role === 'assistant' || msg.role === 'tool';
|
|
101
|
+
return validateMessageContent(msg, { allowEmptyContent: allowEmpty });
|
|
166
102
|
}
|
|
167
103
|
|
|
168
104
|
/**
|
package/src/routes/index.ts
CHANGED