@objectstack/service-ai 4.0.0 → 4.0.1

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/src/ai-service.ts CHANGED
@@ -131,8 +131,8 @@ export class AIService implements IAIService {
131
131
  messages: AIMessage[],
132
132
  options?: ChatWithToolsOptions,
133
133
  ): Promise<AIResult> {
134
- // Destructure maxIterations out so it is never forwarded to the adapter
135
- const { maxIterations: maxIter, ...restOptions } = options ?? {};
134
+ // Destructure loop-specific options so they are never forwarded to the adapter
135
+ const { maxIterations: maxIter, onToolError, ...restOptions } = options ?? {};
136
136
  const maxIterations = maxIter ?? AIService.DEFAULT_MAX_ITERATIONS;
137
137
  const registeredTools = this.toolRegistry.getAll();
138
138
 
@@ -152,12 +152,17 @@ export class AIService implements IAIService {
152
152
  // Working copy of the conversation
153
153
  const conversation = [...messages];
154
154
 
155
+ // Track errors across iterations for diagnostics
156
+ const toolErrors: Array<{ iteration: number; toolName: string; error: string }> = [];
157
+
155
158
  this.logger.debug('[AI] chatWithTools start', {
156
159
  messageCount: conversation.length,
157
160
  toolCount: mergedTools.length,
158
161
  maxIterations,
159
162
  });
160
163
 
164
+ let abortedByCallback = false;
165
+
161
166
  for (let iteration = 0; iteration < maxIterations; iteration++) {
162
167
  const result = await this.adapter.chat(conversation, chatOptions);
163
168
 
@@ -182,19 +187,47 @@ export class AIService implements IAIService {
182
187
  // Execute all tool calls in parallel
183
188
  const toolResults = await this.toolRegistry.executeAll(result.toolCalls);
184
189
 
185
- // Append each tool result as a `role: 'tool'` message
190
+ // Process results: track errors and honour onToolError callback
186
191
  for (const tr of toolResults) {
192
+ if (tr.isError) {
193
+ // Match tool call by toolCallId for robust attribution
194
+ const matchedCall = result.toolCalls!.find(tc => tc.id === tr.toolCallId);
195
+ const toolName = matchedCall?.name ?? 'unknown';
196
+ const errorEntry = { iteration, toolName, error: tr.content };
197
+ toolErrors.push(errorEntry);
198
+ this.logger.warn('[AI] chatWithTools tool error', errorEntry);
199
+
200
+ if (onToolError && matchedCall) {
201
+ const action = onToolError(matchedCall, tr.content);
202
+ if (action === 'abort') {
203
+ abortedByCallback = true;
204
+ }
205
+ }
206
+ }
207
+
208
+ // Append each tool result as a `role: 'tool'` message
187
209
  conversation.push({
188
210
  role: 'tool',
189
211
  content: tr.content,
190
212
  toolCallId: tr.toolCallId,
191
213
  });
192
214
  }
215
+
216
+ if (abortedByCallback) {
217
+ break;
218
+ }
219
+ }
220
+
221
+ // Distinguish user-driven abort from max-iterations exhaustion in logs
222
+ if (abortedByCallback) {
223
+ this.logger.warn('[AI] chatWithTools aborted by onToolError callback', { toolErrors });
224
+ } else {
225
+ this.logger.warn('[AI] chatWithTools max iterations reached, forcing final response', {
226
+ toolErrors: toolErrors.length > 0 ? toolErrors : undefined,
227
+ });
193
228
  }
194
229
 
195
- // If we exhausted the loop without a final response, make one last
196
- // call *without* tools so the model is forced to produce text.
197
- this.logger.warn('[AI] chatWithTools max iterations reached, forcing final response');
230
+ // Make one last call *without* tools so the model is forced to produce text.
198
231
  const finalResult = await this.adapter.chat(conversation, {
199
232
  ...chatOptions,
200
233
  tools: undefined,
@@ -202,4 +235,91 @@ export class AIService implements IAIService {
202
235
  });
203
236
  return finalResult;
204
237
  }
238
+
239
+ /**
240
+ * Stream chat with automatic tool call resolution.
241
+ *
242
+ * Works like {@link chatWithTools} but yields SSE events. When the model
243
+ * requests tool calls during streaming, they are executed and the results
244
+ * fed back until a final text stream is produced.
245
+ */
246
+ async *streamChatWithTools(
247
+ messages: AIMessage[],
248
+ options?: ChatWithToolsOptions,
249
+ ): AsyncIterable<AIStreamEvent> {
250
+ const { maxIterations: maxIter, onToolError, ...restOptions } = options ?? {};
251
+ const maxIterations = maxIter ?? AIService.DEFAULT_MAX_ITERATIONS;
252
+ const registeredTools = this.toolRegistry.getAll();
253
+
254
+ const mergedTools = [
255
+ ...registeredTools,
256
+ ...(restOptions.tools ?? []),
257
+ ];
258
+
259
+ const chatOptions: AIRequestOptions = {
260
+ ...restOptions,
261
+ tools: mergedTools.length > 0 ? mergedTools : undefined,
262
+ toolChoice: mergedTools.length > 0 ? (restOptions.toolChoice ?? 'auto') : undefined,
263
+ };
264
+
265
+ const conversation = [...messages];
266
+ let abortedByCallback = false;
267
+
268
+ for (let iteration = 0; iteration < maxIterations; iteration++) {
269
+ // Use non-streaming chat for intermediate tool-call rounds
270
+ const result = await this.adapter.chat(conversation, chatOptions);
271
+
272
+ if (!result.toolCalls || result.toolCalls.length === 0) {
273
+ // Final round — return the probed result without an extra model call
274
+ yield { type: 'text-delta', textDelta: result.content };
275
+ yield { type: 'finish', result };
276
+ return;
277
+ }
278
+
279
+ // Emit tool-call events so the client can see tool execution progress
280
+ for (const tc of result.toolCalls) {
281
+ yield { type: 'tool-call', toolCall: tc };
282
+ }
283
+
284
+ conversation.push({
285
+ role: 'assistant',
286
+ content: result.content ?? '',
287
+ toolCalls: result.toolCalls,
288
+ });
289
+
290
+ const toolResults = await this.toolRegistry.executeAll(result.toolCalls);
291
+
292
+ for (const tr of toolResults) {
293
+ if (tr.isError && onToolError) {
294
+ const matchedCall = result.toolCalls!.find(tc => tc.id === tr.toolCallId);
295
+ if (matchedCall) {
296
+ const action = onToolError(matchedCall, tr.content);
297
+ if (action === 'abort') {
298
+ abortedByCallback = true;
299
+ }
300
+ }
301
+ }
302
+ conversation.push({
303
+ role: 'tool',
304
+ content: tr.content,
305
+ toolCallId: tr.toolCallId,
306
+ });
307
+ }
308
+
309
+ if (abortedByCallback) {
310
+ break;
311
+ }
312
+ }
313
+
314
+ // Forced final response (no tools) — either aborted or max iterations
315
+ if (abortedByCallback) {
316
+ this.logger.warn('[AI] streamChatWithTools aborted by onToolError callback');
317
+ } else {
318
+ this.logger.warn('[AI] streamChatWithTools max iterations reached');
319
+ }
320
+ const finalOptions = { ...chatOptions, tools: undefined, toolChoice: undefined };
321
+ const result = await this.adapter.chat(conversation, finalOptions);
322
+ yield { type: 'text-delta', textDelta: result.content };
323
+ yield { type: 'finish', result };
324
+ }
205
325
  }
package/src/index.ts CHANGED
@@ -37,4 +37,4 @@ export { AiConversationObject, AiMessageObject } from './objects/index.js';
37
37
  // Routes
38
38
  export { buildAIRoutes } from './routes/ai-routes.js';
39
39
  export { buildAgentRoutes } from './routes/agent-routes.js';
40
- export type { RouteDefinition, RouteRequest, RouteResponse } from './routes/ai-routes.js';
40
+ export type { RouteDefinition, RouteRequest, RouteResponse, RouteUserContext } from './routes/ai-routes.js';
@@ -48,6 +48,8 @@ export function buildAgentRoutes(
48
48
  method: 'POST',
49
49
  path: '/api/v1/ai/agents/:agentName/chat',
50
50
  description: 'Chat with a specific AI agent',
51
+ auth: true,
52
+ permissions: ['ai:chat', 'ai:agents'],
51
53
  handler: async (req) => {
52
54
  const agentName = req.params?.agentName;
53
55
  if (!agentName) {
@@ -16,6 +16,10 @@ export interface RouteDefinition {
16
16
  path: string;
17
17
  /** Human-readable description */
18
18
  description: string;
19
+ /** Whether this route requires authentication (default: true). */
20
+ auth?: boolean;
21
+ /** Required permissions for accessing this route. */
22
+ permissions?: string[];
19
23
  /**
20
24
  * Handler receives a plain request-like object and returns a response-like
21
25
  * object. SSE responses set `stream: true` and provide an async iterable.
@@ -23,6 +27,22 @@ export interface RouteDefinition {
23
27
  handler: (req: RouteRequest) => Promise<RouteResponse>;
24
28
  }
25
29
 
30
+ /**
31
+ * Authenticated user context attached to a route request.
32
+ *
33
+ * Populated by the auth middleware when `RouteDefinition.auth` is `true`.
34
+ */
35
+ export interface RouteUserContext {
36
+ /** Unique user identifier. */
37
+ userId: string;
38
+ /** User display name (optional). */
39
+ displayName?: string;
40
+ /** Roles assigned to the user (e.g. `['admin', 'user']`). */
41
+ roles?: string[];
42
+ /** Fine-grained permissions (e.g. `['ai:chat', 'ai:admin']`). */
43
+ permissions?: string[];
44
+ }
45
+
26
46
  export interface RouteRequest {
27
47
  /** Parsed JSON body (for POST requests) */
28
48
  body?: unknown;
@@ -30,6 +50,8 @@ export interface RouteRequest {
30
50
  params?: Record<string, string>;
31
51
  /** Query string parameters */
32
52
  query?: Record<string, string>;
53
+ /** Authenticated user context (populated by auth middleware). */
54
+ user?: RouteUserContext;
33
55
  }
34
56
 
35
57
  export interface RouteResponse {
@@ -94,6 +116,8 @@ export function buildAIRoutes(
94
116
  method: 'POST',
95
117
  path: '/api/v1/ai/chat',
96
118
  description: 'Synchronous chat completion',
119
+ auth: true,
120
+ permissions: ['ai:chat'],
97
121
  handler: async (req) => {
98
122
  const { messages, options } = (req.body ?? {}) as {
99
123
  messages?: unknown[];
@@ -124,6 +148,8 @@ export function buildAIRoutes(
124
148
  method: 'POST',
125
149
  path: '/api/v1/ai/chat/stream',
126
150
  description: 'SSE streaming chat completion',
151
+ auth: true,
152
+ permissions: ['ai:chat'],
127
153
  handler: async (req) => {
128
154
  const { messages, options } = (req.body ?? {}) as {
129
155
  messages?: unknown[];
@@ -157,6 +183,8 @@ export function buildAIRoutes(
157
183
  method: 'POST',
158
184
  path: '/api/v1/ai/complete',
159
185
  description: 'Text completion',
186
+ auth: true,
187
+ permissions: ['ai:complete'],
160
188
  handler: async (req) => {
161
189
  const { prompt, options } = (req.body ?? {}) as {
162
190
  prompt?: string;
@@ -182,6 +210,8 @@ export function buildAIRoutes(
182
210
  method: 'GET',
183
211
  path: '/api/v1/ai/models',
184
212
  description: 'List available models',
213
+ auth: true,
214
+ permissions: ['ai:read'],
185
215
  handler: async () => {
186
216
  try {
187
217
  const models = aiService.listModels ? await aiService.listModels() : [];
@@ -198,9 +228,20 @@ export function buildAIRoutes(
198
228
  method: 'POST',
199
229
  path: '/api/v1/ai/conversations',
200
230
  description: 'Create a conversation',
231
+ auth: true,
232
+ permissions: ['ai:conversations'],
201
233
  handler: async (req) => {
202
234
  try {
203
- const options = (req.body ?? {}) as Record<string, unknown>;
235
+ // Ensure the request body is a non-null object before mutating it
236
+ if (req.body !== undefined && req.body !== null && (typeof req.body !== 'object' || Array.isArray(req.body))) {
237
+ return { status: 400, body: { error: 'Invalid request payload' } };
238
+ }
239
+
240
+ const options: Record<string, unknown> = { ...((req.body ?? {}) as Record<string, unknown>) };
241
+ // Bind the conversation to the authenticated user
242
+ if (req.user?.userId) {
243
+ options.userId = req.user.userId;
244
+ }
204
245
  const conversation = await conversationService.create(options as any);
205
246
  return { status: 201, body: conversation };
206
247
  } catch (err) {
@@ -213,6 +254,8 @@ export function buildAIRoutes(
213
254
  method: 'GET',
214
255
  path: '/api/v1/ai/conversations',
215
256
  description: 'List conversations',
257
+ auth: true,
258
+ permissions: ['ai:conversations'],
216
259
  handler: async (req) => {
217
260
  try {
218
261
  const rawQuery = req.query ?? {};
@@ -226,6 +269,11 @@ export function buildAIRoutes(
226
269
  options.limit = parsedLimit;
227
270
  }
228
271
 
272
+ // Scope to the authenticated user's conversations
273
+ if (req.user?.userId) {
274
+ options.userId = req.user.userId;
275
+ }
276
+
229
277
  const conversations = await conversationService.list(options as any);
230
278
  return { status: 200, body: { conversations } };
231
279
  } catch (err) {
@@ -238,6 +286,8 @@ export function buildAIRoutes(
238
286
  method: 'POST',
239
287
  path: '/api/v1/ai/conversations/:id/messages',
240
288
  description: 'Add message to a conversation',
289
+ auth: true,
290
+ permissions: ['ai:conversations'],
241
291
  handler: async (req) => {
242
292
  const id = req.params?.id;
243
293
  if (!id) {
@@ -251,6 +301,17 @@ export function buildAIRoutes(
251
301
  }
252
302
 
253
303
  try {
304
+ // Ownership check: verify the conversation belongs to the current user
305
+ if (req.user?.userId) {
306
+ const existing = await conversationService.get(id);
307
+ if (!existing) {
308
+ return { status: 404, body: { error: `Conversation "${id}" not found` } };
309
+ }
310
+ if (existing.userId && existing.userId !== req.user.userId) {
311
+ return { status: 403, body: { error: 'You do not have access to this conversation' } };
312
+ }
313
+ }
314
+
254
315
  const conversation = await conversationService.addMessage(id, message as AIMessage);
255
316
  return { status: 200, body: conversation };
256
317
  } catch (err) {
@@ -267,6 +328,8 @@ export function buildAIRoutes(
267
328
  method: 'DELETE',
268
329
  path: '/api/v1/ai/conversations/:id',
269
330
  description: 'Delete a conversation',
331
+ auth: true,
332
+ permissions: ['ai:conversations'],
270
333
  handler: async (req) => {
271
334
  const id = req.params?.id;
272
335
  if (!id) {
@@ -274,6 +337,17 @@ export function buildAIRoutes(
274
337
  }
275
338
 
276
339
  try {
340
+ // Ownership check: verify the conversation belongs to the current user
341
+ if (req.user?.userId) {
342
+ const existing = await conversationService.get(id);
343
+ if (!existing) {
344
+ return { status: 404, body: { error: `Conversation "${id}" not found` } };
345
+ }
346
+ if (existing.userId && existing.userId !== req.user.userId) {
347
+ return { status: 403, body: { error: 'You do not have access to this conversation' } };
348
+ }
349
+ }
350
+
277
351
  await conversationService.delete(id);
278
352
  return { status: 204 };
279
353
  } catch (err) {
@@ -1,4 +1,4 @@
1
1
  // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
2
 
3
3
  export { buildAIRoutes } from './ai-routes.js';
4
- export type { RouteDefinition, RouteRequest, RouteResponse } from './ai-routes.js';
4
+ export type { RouteDefinition, RouteRequest, RouteResponse, RouteUserContext } from './ai-routes.js';