@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.
- package/.turbo/turbo-build.log +11 -11
- package/CHANGELOG.md +17 -0
- package/dist/index.cjs +1632 -355
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +330 -87
- package/dist/index.d.ts +330 -87
- package/dist/index.js +1623 -352
- package/dist/index.js.map +1 -1
- package/package.json +27 -5
- package/src/__tests__/ai-service.test.ts +260 -27
- package/src/__tests__/auth-and-toolcalling.test.ts +81 -29
- package/src/__tests__/chatbot-features.test.ts +397 -102
- package/src/__tests__/metadata-tools.test.ts +970 -0
- package/src/__tests__/objectql-conversation-service.test.ts +34 -16
- package/src/__tests__/tool-routes.test.ts +191 -0
- package/src/__tests__/vercel-stream-encoder.test.ts +310 -0
- package/src/adapters/index.ts +2 -0
- package/src/adapters/memory-adapter.ts +17 -9
- package/src/adapters/vercel-adapter.ts +148 -0
- package/src/agent-runtime.ts +27 -3
- package/src/agents/index.ts +1 -0
- package/src/agents/metadata-assistant-agent.ts +87 -0
- package/src/ai-service.ts +75 -36
- package/src/conversation/in-memory-conversation-service.ts +2 -2
- package/src/conversation/objectql-conversation-service.ts +67 -18
- package/src/index.ts +22 -2
- package/src/plugin.ts +237 -30
- package/src/routes/agent-routes.ts +68 -12
- package/src/routes/ai-routes.ts +93 -14
- 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/index.ts +3 -0
- package/src/stream/vercel-stream-encoder.ts +153 -0
- package/src/tools/add-field.tool.ts +70 -0
- package/src/tools/create-object.tool.ts +66 -0
- package/src/tools/data-tools.ts +4 -101
- package/src/tools/delete-field.tool.ts +38 -0
- package/src/tools/describe-object.tool.ts +31 -0
- package/src/tools/index.ts +12 -1
- package/src/tools/list-objects.tool.ts +34 -0
- package/src/tools/metadata-tools.ts +430 -0
- package/src/tools/modify-field.tool.ts +44 -0
- 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
|
-
|
|
5
|
+
ModelMessage,
|
|
6
6
|
AIResult,
|
|
7
7
|
AIRequestOptions,
|
|
8
|
-
|
|
9
|
-
|
|
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: [{
|
|
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({
|
|
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: [{
|
|
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: [{
|
|
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: [{
|
|
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
|
-
{
|
|
434
|
-
{
|
|
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({
|
|
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
|
|
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:
|
|
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].
|
|
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:
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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:
|
|
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
|
|
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].
|
|
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:
|
|
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].
|
|
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: [{
|
|
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,
|
|
572
|
-
yield { type: 'finish' as const,
|
|
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:
|
|
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: [{
|
|
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:
|
|
665
|
+
const events: TextStreamPart<ToolSet>[] = [];
|
|
614
666
|
for await (const event of service.streamChatWithTools(
|
|
615
667
|
[{ role: 'user', content: 'Critical' }],
|
|
616
668
|
{ onToolError: () => 'abort' },
|