@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.
Files changed (44) hide show
  1. package/.turbo/turbo-build.log +11 -11
  2. package/CHANGELOG.md +17 -0
  3. package/dist/index.cjs +1632 -355
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.d.cts +330 -87
  6. package/dist/index.d.ts +330 -87
  7. package/dist/index.js +1623 -352
  8. package/dist/index.js.map +1 -1
  9. package/package.json +27 -5
  10. package/src/__tests__/ai-service.test.ts +260 -27
  11. package/src/__tests__/auth-and-toolcalling.test.ts +81 -29
  12. package/src/__tests__/chatbot-features.test.ts +397 -102
  13. package/src/__tests__/metadata-tools.test.ts +970 -0
  14. package/src/__tests__/objectql-conversation-service.test.ts +34 -16
  15. package/src/__tests__/tool-routes.test.ts +191 -0
  16. package/src/__tests__/vercel-stream-encoder.test.ts +310 -0
  17. package/src/adapters/index.ts +2 -0
  18. package/src/adapters/memory-adapter.ts +17 -9
  19. package/src/adapters/vercel-adapter.ts +148 -0
  20. package/src/agent-runtime.ts +27 -3
  21. package/src/agents/index.ts +1 -0
  22. package/src/agents/metadata-assistant-agent.ts +87 -0
  23. package/src/ai-service.ts +75 -36
  24. package/src/conversation/in-memory-conversation-service.ts +2 -2
  25. package/src/conversation/objectql-conversation-service.ts +67 -18
  26. package/src/index.ts +22 -2
  27. package/src/plugin.ts +237 -30
  28. package/src/routes/agent-routes.ts +68 -12
  29. package/src/routes/ai-routes.ts +93 -14
  30. package/src/routes/index.ts +1 -0
  31. package/src/routes/message-utils.ts +90 -0
  32. package/src/routes/tool-routes.ts +142 -0
  33. package/src/stream/index.ts +3 -0
  34. package/src/stream/vercel-stream-encoder.ts +153 -0
  35. package/src/tools/add-field.tool.ts +70 -0
  36. package/src/tools/create-object.tool.ts +66 -0
  37. package/src/tools/data-tools.ts +4 -101
  38. package/src/tools/delete-field.tool.ts +38 -0
  39. package/src/tools/describe-object.tool.ts +31 -0
  40. package/src/tools/index.ts +12 -1
  41. package/src/tools/list-objects.tool.ts +34 -0
  42. package/src/tools/metadata-tools.ts +430 -0
  43. package/src/tools/modify-field.tool.ts +44 -0
  44. package/src/tools/tool-registry.ts +32 -9
@@ -2,11 +2,12 @@
2
2
 
3
3
  import { describe, it, expect, vi, beforeEach } from 'vitest';
4
4
  import type {
5
- AIMessage,
5
+ ModelMessage,
6
6
  AIResult,
7
7
  AIRequestOptions,
8
- AIStreamEvent,
9
- AIToolCall,
8
+ TextStreamPart,
9
+ ToolSet,
10
+ ToolCallPart,
10
11
  LLMAdapter,
11
12
  } from '@objectstack/spec/contracts';
12
13
  import { AIService } from '../ai-service.js';
@@ -313,7 +314,7 @@ describe('chatWithTools — Enhanced Error Handling', () => {
313
314
 
314
315
  const onToolError = vi.fn().mockReturnValue('continue');
315
316
  const adapter = createMockAdapter([
316
- { content: '', toolCalls: [{ id: 'c1', name: 'bad_tool', arguments: '{}' }] },
317
+ { content: '', toolCalls: [{ type: 'tool-call' as const, toolCallId: 'c1', toolName: 'bad_tool', input: {} }] },
317
318
  { content: 'Recovered' },
318
319
  ]);
319
320
 
@@ -325,7 +326,7 @@ describe('chatWithTools — Enhanced Error Handling', () => {
325
326
 
326
327
  expect(onToolError).toHaveBeenCalledTimes(1);
327
328
  expect(onToolError).toHaveBeenCalledWith(
328
- expect.objectContaining({ name: 'bad_tool' }),
329
+ expect.objectContaining({ toolName: 'bad_tool' }),
329
330
  'boom',
330
331
  );
331
332
  expect(result.content).toBe('Recovered');
@@ -338,7 +339,7 @@ describe('chatWithTools — Enhanced Error Handling', () => {
338
339
  );
339
340
 
340
341
  const adapter = createMockAdapter([
341
- { content: '', toolCalls: [{ id: 'c1', name: 'abort_tool', arguments: '{}' }] },
342
+ { content: '', toolCalls: [{ type: 'tool-call' as const, toolCallId: 'c1', toolName: 'abort_tool', input: {} }] },
342
343
  // This would be the forced-final call
343
344
  { content: 'Aborted cleanly' },
344
345
  ]);
@@ -381,7 +382,7 @@ describe('chatWithTools — Enhanced Error Handling', () => {
381
382
  );
382
383
 
383
384
  const adapter = createMockAdapter([
384
- { content: '', toolCalls: [{ id: 'c1', name: 'fail_tool', arguments: '{}' }] },
385
+ { content: '', toolCalls: [{ type: 'tool-call' as const, toolCallId: 'c1', toolName: 'fail_tool', input: {} }] },
385
386
  { content: 'Error was fed back to model' },
386
387
  ]);
387
388
 
@@ -400,7 +401,7 @@ describe('chatWithTools — Enhanced Error Handling', () => {
400
401
 
401
402
  const infiniteToolCall: AIResult = {
402
403
  content: '',
403
- toolCalls: [{ id: 'c', name: 'flaky_tool', arguments: '{}' }],
404
+ toolCalls: [{ type: 'tool-call' as const, toolCallId: 'c', toolName: 'flaky_tool', input: {} }],
404
405
  };
405
406
  const adapter = createMockAdapter(
406
407
  Array(2).fill(infiniteToolCall).concat([{ content: 'Forced' }]),
@@ -430,8 +431,8 @@ describe('chatWithTools — Enhanced Error Handling', () => {
430
431
  {
431
432
  content: '',
432
433
  toolCalls: [
433
- { id: 'c1', name: 'get_weather', arguments: '{"city":"NYC"}' },
434
- { id: 'c2', name: 'bad_tool', arguments: '{}' },
434
+ { type: 'tool-call' as const, toolCallId: 'c1', toolName: 'get_weather', input: { city: 'NYC' } },
435
+ { type: 'tool-call' as const, toolCallId: 'c2', toolName: 'bad_tool', input: {} },
435
436
  ],
436
437
  },
437
438
  { content: 'Weather ok, tool failed' },
@@ -447,12 +448,12 @@ describe('chatWithTools — Enhanced Error Handling', () => {
447
448
  // Only called for the failing tool
448
449
  expect(onToolError).toHaveBeenCalledTimes(1);
449
450
  expect(onToolError).toHaveBeenCalledWith(
450
- expect.objectContaining({ name: 'bad_tool' }),
451
+ expect.objectContaining({ toolName: 'bad_tool' }),
451
452
  'fail',
452
453
  );
453
454
 
454
455
  // Both tool results fed back
455
- const secondCallMessages = (adapter.chat as any).mock.calls[1][0] as AIMessage[];
456
+ const secondCallMessages = (adapter.chat as any).mock.calls[1][0] as ModelMessage[];
456
457
  const toolMessages = secondCallMessages.filter(m => m.role === 'tool');
457
458
  expect(toolMessages).toHaveLength(2);
458
459
  expect(result.content).toBe('Weather ok, tool failed');
@@ -478,7 +479,7 @@ describe('streamChatWithTools', () => {
478
479
  };
479
480
 
480
481
  const service = new AIService({ adapter, logger: silentLogger, toolRegistry: registry });
481
- const events: AIStreamEvent[] = [];
482
+ const events: TextStreamPart<ToolSet>[] = [];
482
483
  for await (const event of service.streamChatWithTools([{ role: 'user', content: 'Hi' }])) {
483
484
  events.push(event);
484
485
  }
@@ -486,16 +487,17 @@ describe('streamChatWithTools', () => {
486
487
  // Should emit the probed result as text-delta + finish (no double model call)
487
488
  expect(events).toHaveLength(2);
488
489
  expect(events[0].type).toBe('text-delta');
489
- expect(events[0].textDelta).toBe('Hello!');
490
+ expect((events[0] as any).text).toBe('Hello!');
490
491
  expect(events[1].type).toBe('finish');
491
492
  expect(adapter.chat).toHaveBeenCalledTimes(1);
492
493
  });
493
494
 
494
495
  it('should emit tool-call events during tool resolution', async () => {
495
- const toolCall: AIToolCall = {
496
- id: 'call_1',
497
- name: 'get_weather',
498
- arguments: JSON.stringify({ city: 'Tokyo' }),
496
+ const toolCall: ToolCallPart = {
497
+ type: 'tool-call',
498
+ toolCallId: 'call_1',
499
+ toolName: 'get_weather',
500
+ input: { city: 'Tokyo' },
499
501
  };
500
502
 
501
503
  let chatCallIndex = 0;
@@ -512,23 +514,73 @@ describe('streamChatWithTools', () => {
512
514
  };
513
515
 
514
516
  const service = new AIService({ adapter, logger: silentLogger, toolRegistry: registry });
515
- const events: AIStreamEvent[] = [];
517
+ const events: TextStreamPart<ToolSet>[] = [];
516
518
  for await (const event of service.streamChatWithTools(
517
519
  [{ role: 'user', content: 'Weather in Tokyo?' }],
518
520
  )) {
519
521
  events.push(event);
520
522
  }
521
523
 
522
- // Should have tool-call event followed by text-delta + finish (no double call)
524
+ // Should have tool-call + tool-result events followed by text-delta + finish
523
525
  const toolCallEvents = events.filter(e => e.type === 'tool-call');
524
526
  expect(toolCallEvents).toHaveLength(1);
525
- expect(toolCallEvents[0].toolCall?.name).toBe('get_weather');
527
+ expect((toolCallEvents[0] as any).toolName).toBe('get_weather');
528
+
529
+ const toolResultEvents = events.filter(e => e.type === 'tool-result');
530
+ expect(toolResultEvents).toHaveLength(1);
531
+ expect((toolResultEvents[0] as any).toolCallId).toBe('call_1');
532
+ expect((toolResultEvents[0] as any).toolName).toBe('get_weather');
526
533
 
527
534
  const finishEvent = events.find(e => e.type === 'finish');
528
535
  expect(finishEvent).toBeDefined();
529
536
  expect(adapter.chat).toHaveBeenCalledTimes(2);
530
537
  });
531
538
 
539
+ it('should yield tool-result events with tool output', async () => {
540
+ const toolCall: ToolCallPart = {
541
+ type: 'tool-call',
542
+ toolCallId: 'call_weather',
543
+ toolName: 'get_weather',
544
+ input: { city: 'Paris' },
545
+ };
546
+
547
+ let chatCallIndex = 0;
548
+ const adapter: LLMAdapter = {
549
+ name: 'mock-stream',
550
+ chat: vi.fn(async () => {
551
+ chatCallIndex++;
552
+ if (chatCallIndex === 1) {
553
+ return { content: '', toolCalls: [toolCall] };
554
+ }
555
+ return { content: 'Paris is 22°C' };
556
+ }),
557
+ complete: vi.fn(async () => ({ content: '' })),
558
+ };
559
+
560
+ const service = new AIService({ adapter, logger: silentLogger, toolRegistry: registry });
561
+ const events: TextStreamPart<ToolSet>[] = [];
562
+ for await (const event of service.streamChatWithTools(
563
+ [{ role: 'user', content: 'Weather in Paris?' }],
564
+ )) {
565
+ events.push(event);
566
+ }
567
+
568
+ // Verify the tool-result contains actual tool output
569
+ const toolResultEvents = events.filter(e => e.type === 'tool-result');
570
+ expect(toolResultEvents).toHaveLength(1);
571
+ const toolResult = toolResultEvents[0] as any;
572
+ expect(toolResult.toolCallId).toBe('call_weather');
573
+ expect(toolResult.toolName).toBe('get_weather');
574
+ expect(toolResult.output).toEqual({ type: 'text', value: JSON.stringify({ temp: 22, city: 'Paris' }) });
575
+
576
+ // Verify order: tool-call comes before tool-result
577
+ const toolCallIdx = events.findIndex(e => e.type === 'tool-call');
578
+ const toolResultIdx = events.findIndex(e => e.type === 'tool-result');
579
+ expect(toolCallIdx).toBeGreaterThanOrEqual(0);
580
+ expect(toolResultIdx).toBeGreaterThanOrEqual(0);
581
+ expect(toolCallIdx).toBeLessThan(toolResultIdx);
582
+ });
583
+
532
584
  it('should fall back to non-streaming when adapter has no streamChat', async () => {
533
585
  const adapter: LLMAdapter = {
534
586
  name: 'no-stream',
@@ -539,7 +591,7 @@ describe('streamChatWithTools', () => {
539
591
 
540
592
  const emptyRegistry = new ToolRegistry();
541
593
  const service = new AIService({ adapter, logger: silentLogger, toolRegistry: emptyRegistry });
542
- const events: AIStreamEvent[] = [];
594
+ const events: TextStreamPart<ToolSet>[] = [];
543
595
  for await (const event of service.streamChatWithTools(
544
596
  [{ role: 'user', content: 'Hi' }],
545
597
  )) {
@@ -548,14 +600,14 @@ describe('streamChatWithTools', () => {
548
600
 
549
601
  expect(events).toHaveLength(2);
550
602
  expect(events[0].type).toBe('text-delta');
551
- expect(events[0].textDelta).toBe('Fallback response');
603
+ expect((events[0] as any).text).toBe('Fallback response');
552
604
  expect(events[1].type).toBe('finish');
553
605
  });
554
606
 
555
607
  it('should respect maxIterations in streaming tool loop', async () => {
556
608
  const infiniteToolCall: AIResult = {
557
609
  content: '',
558
- toolCalls: [{ id: 'c', name: 'get_weather', arguments: '{"city":"X"}' }],
610
+ toolCalls: [{ type: 'tool-call' as const, toolCallId: 'c', toolName: 'get_weather', input: { city: 'X' } }],
559
611
  };
560
612
 
561
613
  let callIndex = 0;
@@ -568,13 +620,13 @@ describe('streamChatWithTools', () => {
568
620
  }),
569
621
  complete: vi.fn(async () => ({ content: '' })),
570
622
  async *streamChat() {
571
- yield { type: 'text-delta' as const, textDelta: 'Forced stop' };
572
- yield { type: 'finish' as const, result: { content: 'Forced stop' } };
623
+ yield { type: 'text-delta' as const, id: '1', text: 'Forced stop' } as TextStreamPart<ToolSet>;
624
+ yield { type: 'finish' as const, finishReason: 'stop' as const, totalUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }, rawFinishReason: 'stop' } as unknown as TextStreamPart<ToolSet>;
573
625
  },
574
626
  };
575
627
 
576
628
  const service = new AIService({ adapter, logger: silentLogger, toolRegistry: registry });
577
- const events: AIStreamEvent[] = [];
629
+ const events: TextStreamPart<ToolSet>[] = [];
578
630
  for await (const event of service.streamChatWithTools(
579
631
  [{ role: 'user', content: 'Loop' }],
580
632
  { maxIterations: 2 },
@@ -601,7 +653,7 @@ describe('streamChatWithTools', () => {
601
653
  if (chatCallIndex === 1) {
602
654
  return {
603
655
  content: '',
604
- toolCalls: [{ id: 'c1', name: 'critical_fail', arguments: '{}' }],
656
+ toolCalls: [{ type: 'tool-call' as const, toolCallId: 'c1', toolName: 'critical_fail', input: {} }],
605
657
  };
606
658
  }
607
659
  return { content: 'Aborted' };
@@ -610,7 +662,7 @@ describe('streamChatWithTools', () => {
610
662
  };
611
663
 
612
664
  const service = new AIService({ adapter, logger: silentLogger, toolRegistry: registry });
613
- const events: AIStreamEvent[] = [];
665
+ const events: TextStreamPart<ToolSet>[] = [];
614
666
  for await (const event of service.streamChatWithTools(
615
667
  [{ role: 'user', content: 'Critical' }],
616
668
  { onToolError: () => 'abort' },