@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.
@@ -1,5 +1,5 @@
1
1
 
2
- > @objectstack/service-ai@4.0.0 build /home/runner/work/spec/spec/packages/services/service-ai
2
+ > @objectstack/service-ai@4.0.1 build /home/runner/work/spec/spec/packages/services/service-ai
3
3
  > tsup --config ../../../tsup.config.ts
4
4
 
5
5
  CLI Building entry: src/index.ts
@@ -10,13 +10,13 @@
10
10
  CLI Cleaning output folder
11
11
  ESM Build start
12
12
  CJS Build start
13
- CJS dist/index.cjs 45.63 KB
14
- CJS dist/index.cjs.map 97.28 KB
15
- CJS ⚡️ Build success in 153ms
16
- ESM dist/index.js 43.82 KB
17
- ESM dist/index.js.map 95.70 KB
18
- ESM ⚡️ Build success in 153ms
13
+ ESM dist/index.js 49.00 KB
14
+ ESM dist/index.js.map 106.10 KB
15
+ ESM ⚡️ Build success in 168ms
16
+ CJS dist/index.cjs 50.82 KB
17
+ CJS dist/index.cjs.map 107.70 KB
18
+ CJS ⚡️ Build success in 178ms
19
19
  DTS Build start
20
- DTS ⚡️ Build success in 20835ms
21
- DTS dist/index.d.ts 148.67 KB
22
- DTS dist/index.d.cts 148.67 KB
20
+ DTS ⚡️ Build success in 18643ms
21
+ DTS dist/index.d.ts 149.83 KB
22
+ DTS dist/index.d.cts 149.83 KB
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # @objectstack/service-ai
2
2
 
3
+ ## 4.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - **Route auth/permissions metadata**: Every route definition (`RouteDefinition`) now declares `auth` and `permissions` fields, enabling HTTP server adapters to enforce authentication and authorization automatically.
8
+ - **User context on RouteRequest**: `RouteRequest` now carries an optional `user: RouteUserContext` object populated by the auth middleware, providing `userId`, `displayName`, `roles`, and `permissions`.
9
+ - **Conversation ownership enforcement**: Conversation routes (create, list, add message, delete) are scoped to the authenticated user when a user context is present and the conversation has a `userId`. For backward compatibility, requests without user context and conversations created without a `userId` remain accessible under the existing behavior.
10
+ - **Enhanced tool-call loop error handling**: `chatWithTools` now tracks tool execution errors across iterations and supports an `onToolError` callback (`'continue'` | `'abort'`) for fine-grained error control.
11
+ - **`streamChatWithTools`**: New streaming tool-call loop that yields SSE events while automatically resolving intermediate tool calls.
12
+ - **New `RouteUserContext` type**: Exported from the package for use by HTTP adapters and middleware.
13
+
3
14
  ## 4.0.0
4
15
 
5
16
  ### Major Changes
package/dist/index.cjs CHANGED
@@ -288,7 +288,7 @@ var _AIService = class _AIService {
288
288
  * maximum number of iterations (`maxIterations`) is reached.
289
289
  */
290
290
  async chatWithTools(messages, options) {
291
- const { maxIterations: maxIter, ...restOptions } = options ?? {};
291
+ const { maxIterations: maxIter, onToolError, ...restOptions } = options ?? {};
292
292
  const maxIterations = maxIter ?? _AIService.DEFAULT_MAX_ITERATIONS;
293
293
  const registeredTools = this.toolRegistry.getAll();
294
294
  const mergedTools = [
@@ -301,11 +301,13 @@ var _AIService = class _AIService {
301
301
  toolChoice: mergedTools.length > 0 ? restOptions.toolChoice ?? "auto" : void 0
302
302
  };
303
303
  const conversation = [...messages];
304
+ const toolErrors = [];
304
305
  this.logger.debug("[AI] chatWithTools start", {
305
306
  messageCount: conversation.length,
306
307
  toolCount: mergedTools.length,
307
308
  maxIterations
308
309
  });
310
+ let abortedByCallback = false;
309
311
  for (let iteration = 0; iteration < maxIterations; iteration++) {
310
312
  const result = await this.adapter.chat(conversation, chatOptions);
311
313
  if (!result.toolCalls || result.toolCalls.length === 0) {
@@ -323,14 +325,36 @@ var _AIService = class _AIService {
323
325
  });
324
326
  const toolResults = await this.toolRegistry.executeAll(result.toolCalls);
325
327
  for (const tr of toolResults) {
328
+ if (tr.isError) {
329
+ const matchedCall = result.toolCalls.find((tc) => tc.id === tr.toolCallId);
330
+ const toolName = matchedCall?.name ?? "unknown";
331
+ const errorEntry = { iteration, toolName, error: tr.content };
332
+ toolErrors.push(errorEntry);
333
+ this.logger.warn("[AI] chatWithTools tool error", errorEntry);
334
+ if (onToolError && matchedCall) {
335
+ const action = onToolError(matchedCall, tr.content);
336
+ if (action === "abort") {
337
+ abortedByCallback = true;
338
+ }
339
+ }
340
+ }
326
341
  conversation.push({
327
342
  role: "tool",
328
343
  content: tr.content,
329
344
  toolCallId: tr.toolCallId
330
345
  });
331
346
  }
347
+ if (abortedByCallback) {
348
+ break;
349
+ }
350
+ }
351
+ if (abortedByCallback) {
352
+ this.logger.warn("[AI] chatWithTools aborted by onToolError callback", { toolErrors });
353
+ } else {
354
+ this.logger.warn("[AI] chatWithTools max iterations reached, forcing final response", {
355
+ toolErrors: toolErrors.length > 0 ? toolErrors : void 0
356
+ });
332
357
  }
333
- this.logger.warn("[AI] chatWithTools max iterations reached, forcing final response");
334
358
  const finalResult = await this.adapter.chat(conversation, {
335
359
  ...chatOptions,
336
360
  tools: void 0,
@@ -338,6 +362,74 @@ var _AIService = class _AIService {
338
362
  });
339
363
  return finalResult;
340
364
  }
365
+ /**
366
+ * Stream chat with automatic tool call resolution.
367
+ *
368
+ * Works like {@link chatWithTools} but yields SSE events. When the model
369
+ * requests tool calls during streaming, they are executed and the results
370
+ * fed back until a final text stream is produced.
371
+ */
372
+ async *streamChatWithTools(messages, options) {
373
+ const { maxIterations: maxIter, onToolError, ...restOptions } = options ?? {};
374
+ const maxIterations = maxIter ?? _AIService.DEFAULT_MAX_ITERATIONS;
375
+ const registeredTools = this.toolRegistry.getAll();
376
+ const mergedTools = [
377
+ ...registeredTools,
378
+ ...restOptions.tools ?? []
379
+ ];
380
+ const chatOptions = {
381
+ ...restOptions,
382
+ tools: mergedTools.length > 0 ? mergedTools : void 0,
383
+ toolChoice: mergedTools.length > 0 ? restOptions.toolChoice ?? "auto" : void 0
384
+ };
385
+ const conversation = [...messages];
386
+ let abortedByCallback = false;
387
+ for (let iteration = 0; iteration < maxIterations; iteration++) {
388
+ const result2 = await this.adapter.chat(conversation, chatOptions);
389
+ if (!result2.toolCalls || result2.toolCalls.length === 0) {
390
+ yield { type: "text-delta", textDelta: result2.content };
391
+ yield { type: "finish", result: result2 };
392
+ return;
393
+ }
394
+ for (const tc of result2.toolCalls) {
395
+ yield { type: "tool-call", toolCall: tc };
396
+ }
397
+ conversation.push({
398
+ role: "assistant",
399
+ content: result2.content ?? "",
400
+ toolCalls: result2.toolCalls
401
+ });
402
+ const toolResults = await this.toolRegistry.executeAll(result2.toolCalls);
403
+ for (const tr of toolResults) {
404
+ if (tr.isError && onToolError) {
405
+ const matchedCall = result2.toolCalls.find((tc) => tc.id === tr.toolCallId);
406
+ if (matchedCall) {
407
+ const action = onToolError(matchedCall, tr.content);
408
+ if (action === "abort") {
409
+ abortedByCallback = true;
410
+ }
411
+ }
412
+ }
413
+ conversation.push({
414
+ role: "tool",
415
+ content: tr.content,
416
+ toolCallId: tr.toolCallId
417
+ });
418
+ }
419
+ if (abortedByCallback) {
420
+ break;
421
+ }
422
+ }
423
+ if (abortedByCallback) {
424
+ this.logger.warn("[AI] streamChatWithTools aborted by onToolError callback");
425
+ } else {
426
+ this.logger.warn("[AI] streamChatWithTools max iterations reached");
427
+ }
428
+ const finalOptions = { ...chatOptions, tools: void 0, toolChoice: void 0 };
429
+ const result = await this.adapter.chat(conversation, finalOptions);
430
+ yield { type: "text-delta", textDelta: result.content };
431
+ yield { type: "finish", result };
432
+ }
341
433
  };
342
434
  // ── Tool Call Loop ────────────────────────────────────────────
343
435
  /** Default maximum iterations for the tool call loop. */
@@ -366,6 +458,8 @@ function buildAIRoutes(aiService, conversationService, logger) {
366
458
  method: "POST",
367
459
  path: "/api/v1/ai/chat",
368
460
  description: "Synchronous chat completion",
461
+ auth: true,
462
+ permissions: ["ai:chat"],
369
463
  handler: async (req) => {
370
464
  const { messages, options } = req.body ?? {};
371
465
  if (!Array.isArray(messages) || messages.length === 0) {
@@ -389,6 +483,8 @@ function buildAIRoutes(aiService, conversationService, logger) {
389
483
  method: "POST",
390
484
  path: "/api/v1/ai/chat/stream",
391
485
  description: "SSE streaming chat completion",
486
+ auth: true,
487
+ permissions: ["ai:chat"],
392
488
  handler: async (req) => {
393
489
  const { messages, options } = req.body ?? {};
394
490
  if (!Array.isArray(messages) || messages.length === 0) {
@@ -415,6 +511,8 @@ function buildAIRoutes(aiService, conversationService, logger) {
415
511
  method: "POST",
416
512
  path: "/api/v1/ai/complete",
417
513
  description: "Text completion",
514
+ auth: true,
515
+ permissions: ["ai:complete"],
418
516
  handler: async (req) => {
419
517
  const { prompt, options } = req.body ?? {};
420
518
  if (!prompt || typeof prompt !== "string") {
@@ -434,6 +532,8 @@ function buildAIRoutes(aiService, conversationService, logger) {
434
532
  method: "GET",
435
533
  path: "/api/v1/ai/models",
436
534
  description: "List available models",
535
+ auth: true,
536
+ permissions: ["ai:read"],
437
537
  handler: async () => {
438
538
  try {
439
539
  const models = aiService.listModels ? await aiService.listModels() : [];
@@ -449,9 +549,17 @@ function buildAIRoutes(aiService, conversationService, logger) {
449
549
  method: "POST",
450
550
  path: "/api/v1/ai/conversations",
451
551
  description: "Create a conversation",
552
+ auth: true,
553
+ permissions: ["ai:conversations"],
452
554
  handler: async (req) => {
453
555
  try {
454
- const options = req.body ?? {};
556
+ if (req.body !== void 0 && req.body !== null && (typeof req.body !== "object" || Array.isArray(req.body))) {
557
+ return { status: 400, body: { error: "Invalid request payload" } };
558
+ }
559
+ const options = { ...req.body ?? {} };
560
+ if (req.user?.userId) {
561
+ options.userId = req.user.userId;
562
+ }
455
563
  const conversation = await conversationService.create(options);
456
564
  return { status: 201, body: conversation };
457
565
  } catch (err) {
@@ -464,6 +572,8 @@ function buildAIRoutes(aiService, conversationService, logger) {
464
572
  method: "GET",
465
573
  path: "/api/v1/ai/conversations",
466
574
  description: "List conversations",
575
+ auth: true,
576
+ permissions: ["ai:conversations"],
467
577
  handler: async (req) => {
468
578
  try {
469
579
  const rawQuery = req.query ?? {};
@@ -475,6 +585,9 @@ function buildAIRoutes(aiService, conversationService, logger) {
475
585
  }
476
586
  options.limit = parsedLimit;
477
587
  }
588
+ if (req.user?.userId) {
589
+ options.userId = req.user.userId;
590
+ }
478
591
  const conversations = await conversationService.list(options);
479
592
  return { status: 200, body: { conversations } };
480
593
  } catch (err) {
@@ -487,6 +600,8 @@ function buildAIRoutes(aiService, conversationService, logger) {
487
600
  method: "POST",
488
601
  path: "/api/v1/ai/conversations/:id/messages",
489
602
  description: "Add message to a conversation",
603
+ auth: true,
604
+ permissions: ["ai:conversations"],
490
605
  handler: async (req) => {
491
606
  const id = req.params?.id;
492
607
  if (!id) {
@@ -498,6 +613,15 @@ function buildAIRoutes(aiService, conversationService, logger) {
498
613
  return { status: 400, body: { error: validationError } };
499
614
  }
500
615
  try {
616
+ if (req.user?.userId) {
617
+ const existing = await conversationService.get(id);
618
+ if (!existing) {
619
+ return { status: 404, body: { error: `Conversation "${id}" not found` } };
620
+ }
621
+ if (existing.userId && existing.userId !== req.user.userId) {
622
+ return { status: 403, body: { error: "You do not have access to this conversation" } };
623
+ }
624
+ }
501
625
  const conversation = await conversationService.addMessage(id, message);
502
626
  return { status: 200, body: conversation };
503
627
  } catch (err) {
@@ -514,12 +638,23 @@ function buildAIRoutes(aiService, conversationService, logger) {
514
638
  method: "DELETE",
515
639
  path: "/api/v1/ai/conversations/:id",
516
640
  description: "Delete a conversation",
641
+ auth: true,
642
+ permissions: ["ai:conversations"],
517
643
  handler: async (req) => {
518
644
  const id = req.params?.id;
519
645
  if (!id) {
520
646
  return { status: 400, body: { error: "conversation id is required" } };
521
647
  }
522
648
  try {
649
+ if (req.user?.userId) {
650
+ const existing = await conversationService.get(id);
651
+ if (!existing) {
652
+ return { status: 404, body: { error: `Conversation "${id}" not found` } };
653
+ }
654
+ if (existing.userId && existing.userId !== req.user.userId) {
655
+ return { status: 403, body: { error: "You do not have access to this conversation" } };
656
+ }
657
+ }
523
658
  await conversationService.delete(id);
524
659
  return { status: 204 };
525
660
  } catch (err) {
@@ -552,6 +687,8 @@ function buildAgentRoutes(aiService, agentRuntime, logger) {
552
687
  method: "POST",
553
688
  path: "/api/v1/ai/agents/:agentName/chat",
554
689
  description: "Chat with a specific AI agent",
690
+ auth: true,
691
+ permissions: ["ai:chat", "ai:agents"],
555
692
  handler: async (req) => {
556
693
  const agentName = req.params?.agentName;
557
694
  if (!agentName) {