diff --git a/CHANGELOG.md b/CHANGELOG.md index c460504293..42ca5e3121 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- **AI Chat Protocol Aligned with Vercel AI SDK** — Removed custom AI chat protocol + types and Zod schemas (`AIMessage`, `AIToolCall`, `AIStreamEvent`, + `AiChatRequestSchema`, `AiChatResponseSchema`) from `@objectstack/spec`. The + canonical message, tool-call, and streaming types are now re-exported from the + Vercel AI SDK (`ai` v6): + - `ModelMessage` replaces `AIMessage` + - `ToolCallPart` replaces `AIToolCall` + - `ToolResultPart` replaces `AIToolResult` + - `TextStreamPart` replaces `AIStreamEvent` + - `IAIService` and `LLMAdapter` method signatures now accept `ModelMessage[]` + and return `TextStreamPart` for streaming + - Deprecated type aliases preserved for migration convenience + - NLQ, Suggest, and Insights protocols (ObjectStack-specific) are retained +- **`@objectstack/service-ai` migrated to Vercel AI SDK types** — All source files + and tests now use canonical Vercel types (`ModelMessage`, `ToolCallPart`, + `ToolResultPart`, `TextStreamPart`) instead of deprecated aliases: + - `ToolRegistry.execute()` accepts `ToolCallPart` and returns `ToolExecutionResult` + (extends `ToolResultPart` with `isError?: boolean`) + - Tool call loop in `AIService.chatWithTools()` constructs proper + `AssistantModelMessage` and `ToolModelMessage` with Vercel-format content arrays + - `MemoryLLMAdapter.streamChat()` emits Vercel `TextStreamPart` events + - Conversation services serialize/deserialize `ModelMessage` union to flat DB columns + - All 158 service-ai tests updated and passing + +### Removed +- `AiChatRequestSchema` / `AiChatResponseSchema` Zod schemas from + `@objectstack/spec/api` — the AI chat wire protocol now uses Vercel AI SDK's + data stream format (`toDataStreamResponse()`) +- `aiChat` method from `IObjectStackAPI` and client SDK — consumers should use + `@ai-sdk/react/useChat` directly +- AI `/chat` endpoint from `DEFAULT_AI_ROUTES` plugin REST API definition + +### Added +- `ai` v6 as a dependency of `@objectstack/spec` for type re-exports + ## [4.0.1] — 2026-03-31 ### Fixed diff --git a/ROADMAP.md b/ROADMAP.md index 28dc5bf91c..482985d012 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # ObjectStack Protocol — Road Map -> **Last Updated:** 2026-03-31 +> **Last Updated:** 2026-04-01 > **Current Version:** v4.0.1 > **Status:** Protocol Specification Complete · Runtime Implementation In Progress diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index a275b2001d..7bbc76a656 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -36,39 +36,13 @@ Architecture Alignment: ## TypeScript Usage ```typescript -import { AiChatRequest, AiChatResponse, AiInsightsRequest, AiInsightsResponse, AiNlqRequest, AiNlqResponse, AiSuggestRequest, AiSuggestResponse, AutomationTriggerRequest, AutomationTriggerResponse, BatchDataRequest, BatchDataResponse, CheckPermissionRequest, CheckPermissionResponse, CreateDataRequest, CreateDataResponse, CreateManyDataRequest, CreateManyDataResponse, DeleteDataRequest, DeleteDataResponse, DeleteManyDataRequest, DeleteManyDataResponse, DeleteViewRequest, DeleteViewResponse, FindDataRequest, FindDataResponse, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPresenceRequest, GetPresenceResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetViewRequest, GetWorkflowConfigRequest, GetWorkflowConfigResponse, GetWorkflowStateRequest, GetWorkflowStateResponse, HttpFindQueryParams, ListNotificationsRequest, ListNotificationsResponse, ListViewsRequest, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, NotificationPreferences, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, SaveMetaItemRequest, SaveMetaItemResponse, SetPresenceRequest, SetPresenceResponse, UnregisterDeviceRequest, UnregisterDeviceResponse, UpdateDataRequest, UpdateDataResponse, UpdateManyDataRequest, UpdateManyDataResponse, UpdateNotificationPreferencesRequest, UpdateNotificationPreferencesResponse, WorkflowApproveRequest, WorkflowApproveResponse, WorkflowRejectRequest, WorkflowRejectResponse, WorkflowState, WorkflowTransitionRequest, WorkflowTransitionResponse } from '@objectstack/spec/api'; -import type { AiChatRequest, AiChatResponse, AiInsightsRequest, AiInsightsResponse, AiNlqRequest, AiNlqResponse, AiSuggestRequest, AiSuggestResponse, AutomationTriggerRequest, AutomationTriggerResponse, BatchDataRequest, BatchDataResponse, CheckPermissionRequest, CheckPermissionResponse, CreateDataRequest, CreateDataResponse, CreateManyDataRequest, CreateManyDataResponse, DeleteDataRequest, DeleteDataResponse, DeleteManyDataRequest, DeleteManyDataResponse, DeleteViewRequest, DeleteViewResponse, FindDataRequest, FindDataResponse, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPresenceRequest, GetPresenceResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetViewRequest, GetWorkflowConfigRequest, GetWorkflowConfigResponse, GetWorkflowStateRequest, GetWorkflowStateResponse, HttpFindQueryParams, ListNotificationsRequest, ListNotificationsResponse, ListViewsRequest, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, NotificationPreferences, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, SaveMetaItemRequest, SaveMetaItemResponse, SetPresenceRequest, SetPresenceResponse, UnregisterDeviceRequest, UnregisterDeviceResponse, UpdateDataRequest, UpdateDataResponse, UpdateManyDataRequest, UpdateManyDataResponse, UpdateNotificationPreferencesRequest, UpdateNotificationPreferencesResponse, WorkflowApproveRequest, WorkflowApproveResponse, WorkflowRejectRequest, WorkflowRejectResponse, WorkflowState, WorkflowTransitionRequest, WorkflowTransitionResponse } from '@objectstack/spec/api'; +import { AiInsightsRequest, AiInsightsResponse, AiNlqRequest, AiNlqResponse, AiSuggestRequest, AiSuggestResponse, AutomationTriggerRequest, AutomationTriggerResponse, BatchDataRequest, BatchDataResponse, CheckPermissionRequest, CheckPermissionResponse, CreateDataRequest, CreateDataResponse, CreateManyDataRequest, CreateManyDataResponse, DeleteDataRequest, DeleteDataResponse, DeleteManyDataRequest, DeleteManyDataResponse, DeleteViewRequest, DeleteViewResponse, FindDataRequest, FindDataResponse, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPresenceRequest, GetPresenceResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetViewRequest, GetWorkflowConfigRequest, GetWorkflowConfigResponse, GetWorkflowStateRequest, GetWorkflowStateResponse, HttpFindQueryParams, ListNotificationsRequest, ListNotificationsResponse, ListViewsRequest, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, NotificationPreferences, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, SaveMetaItemRequest, SaveMetaItemResponse, SetPresenceRequest, SetPresenceResponse, UnregisterDeviceRequest, UnregisterDeviceResponse, UpdateDataRequest, UpdateDataResponse, UpdateManyDataRequest, UpdateManyDataResponse, UpdateNotificationPreferencesRequest, UpdateNotificationPreferencesResponse, WorkflowApproveRequest, WorkflowApproveResponse, WorkflowRejectRequest, WorkflowRejectResponse, WorkflowState, WorkflowTransitionRequest, WorkflowTransitionResponse } from '@objectstack/spec/api'; +import type { AiInsightsRequest, AiInsightsResponse, AiNlqRequest, AiNlqResponse, AiSuggestRequest, AiSuggestResponse, AutomationTriggerRequest, AutomationTriggerResponse, BatchDataRequest, BatchDataResponse, CheckPermissionRequest, CheckPermissionResponse, CreateDataRequest, CreateDataResponse, CreateManyDataRequest, CreateManyDataResponse, DeleteDataRequest, DeleteDataResponse, DeleteManyDataRequest, DeleteManyDataResponse, DeleteViewRequest, DeleteViewResponse, FindDataRequest, FindDataResponse, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPresenceRequest, GetPresenceResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetViewRequest, GetWorkflowConfigRequest, GetWorkflowConfigResponse, GetWorkflowStateRequest, GetWorkflowStateResponse, HttpFindQueryParams, ListNotificationsRequest, ListNotificationsResponse, ListViewsRequest, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, NotificationPreferences, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, SaveMetaItemRequest, SaveMetaItemResponse, SetPresenceRequest, SetPresenceResponse, UnregisterDeviceRequest, UnregisterDeviceResponse, UpdateDataRequest, UpdateDataResponse, UpdateManyDataRequest, UpdateManyDataResponse, UpdateNotificationPreferencesRequest, UpdateNotificationPreferencesResponse, WorkflowApproveRequest, WorkflowApproveResponse, WorkflowRejectRequest, WorkflowRejectResponse, WorkflowState, WorkflowTransitionRequest, WorkflowTransitionResponse } from '@objectstack/spec/api'; // Validate data -const result = AiChatRequest.parse(data); +const result = AiNlqRequest.parse(data); ``` ---- - -## AiChatRequest - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **message** | `string` | ✅ | User message | -| **conversationId** | `string` | optional | Conversation ID for context | -| **context** | `Record` | optional | Additional context data | - - ---- - -## AiChatResponse - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **message** | `string` | ✅ | Assistant response message | -| **conversationId** | `string` | ✅ | Conversation ID | -| **actions** | `Object[]` | optional | Suggested actions | - - --- ## AiInsightsRequest diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 27c2c31758..c4694ad651 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -62,8 +62,6 @@ import { MarkAllNotificationsReadResponse, AiNlqRequest, AiNlqResponse, - AiChatRequest, - AiChatResponse, AiSuggestRequest, AiSuggestResponse, AiInsightsRequest, @@ -1209,17 +1207,7 @@ export class ObjectStackClient { return this.unwrapResponse(res); }, - /** - * Multi-turn AI chat - */ - chat: async (request: AiChatRequest): Promise => { - const route = this.getRoute('ai'); - const res = await this.fetch(`${this.baseUrl}${route}/chat`, { - method: 'POST', - body: JSON.stringify(request) - }); - return this.unwrapResponse(res); - }, + // AI chat method removed — use Vercel AI SDK `useChat()` / `@ai-sdk/react` directly. /** * AI-powered field value suggestions @@ -1826,8 +1814,6 @@ export type { ListNotificationsResponse, AiNlqRequest, AiNlqResponse, - AiChatRequest, - AiChatResponse, AiSuggestRequest, AiSuggestResponse, AiInsightsRequest, diff --git a/packages/services/service-ai/src/__tests__/ai-service.test.ts b/packages/services/service-ai/src/__tests__/ai-service.test.ts index 903c4d784c..48f98eec20 100644 --- a/packages/services/service-ai/src/__tests__/ai-service.test.ts +++ b/packages/services/service-ai/src/__tests__/ai-service.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect, vi, beforeEach } from 'vitest'; -import type { AIMessage, IAIService, AIStreamEvent } from '@objectstack/spec/contracts'; +import type { ModelMessage, IAIService, TextStreamPart, ToolSet } from '@objectstack/spec/contracts'; import { AIService } from '../ai-service.js'; import { MemoryLLMAdapter } from '../adapters/memory-adapter.js'; import { ToolRegistry } from '../tools/tool-registry.js'; @@ -35,7 +35,7 @@ describe('MemoryLLMAdapter', () => { }); it('should echo the last user message in chat()', async () => { - const messages: AIMessage[] = [ + const messages: ModelMessage[] = [ { role: 'system', content: 'You are helpful.' }, { role: 'user', content: 'Hello AI' }, ]; @@ -46,7 +46,7 @@ describe('MemoryLLMAdapter', () => { }); it('should handle no user message in chat()', async () => { - const messages: AIMessage[] = [{ role: 'system', content: 'System only' }]; + const messages: ModelMessage[] = [{ role: 'system', content: 'System only' }]; const result = await adapter.chat(messages); expect(result.content).toBe('[memory] (no user message)'); }); @@ -57,8 +57,8 @@ describe('MemoryLLMAdapter', () => { }); it('should stream word-by-word in streamChat()', async () => { - const messages: AIMessage[] = [{ role: 'user', content: 'Hi there' }]; - const events: AIStreamEvent[] = []; + const messages: ModelMessage[] = [{ role: 'user', content: 'Hi there' }]; + const events: TextStreamPart[] = []; for await (const event of adapter.streamChat(messages)) { events.push(event); } @@ -113,24 +113,26 @@ describe('ToolRegistry', () => { ); const result = await registry.execute({ - id: 'call_1', - name: 'add', - arguments: JSON.stringify({ a: 3, b: 4 }), + type: 'tool-call', + toolCallId: 'call_1', + toolName: 'add', + input: { a: 3, b: 4 }, }); expect(result.toolCallId).toBe('call_1'); - expect(result.content).toBe('7'); + expect(result.output).toEqual({ type: 'text', value: '7' }); expect(result.isError).toBeUndefined(); }); it('should return error for unknown tool', async () => { const result = await registry.execute({ - id: 'call_x', - name: 'unknown', - arguments: '{}', + type: 'tool-call', + toolCallId: 'call_x', + toolName: 'unknown', + input: {}, }); expect(result.isError).toBe(true); - expect(result.content).toContain('not registered'); + expect(result.output).toEqual(expect.objectContaining({ type: 'text', value: expect.stringContaining('not registered') })); }); it('should return error on handler failure', async () => { @@ -140,12 +142,13 @@ describe('ToolRegistry', () => { ); const result = await registry.execute({ - id: 'call_f', - name: 'fail_tool', - arguments: '{}', + type: 'tool-call', + toolCallId: 'call_f', + toolName: 'fail_tool', + input: {}, }); expect(result.isError).toBe(true); - expect(result.content).toBe('boom'); + expect(result.output).toEqual({ type: 'text', value: 'boom' }); }); it('should execute multiple tool calls in parallel', async () => { @@ -155,13 +158,13 @@ describe('ToolRegistry', () => { ); const results = await registry.executeAll([ - { id: 'c1', name: 'echo', arguments: '{"msg":"a"}' }, - { id: 'c2', name: 'echo', arguments: '{"msg":"b"}' }, + { type: 'tool-call', toolCallId: 'c1', toolName: 'echo', input: { msg: 'a' } }, + { type: 'tool-call', toolCallId: 'c2', toolName: 'echo', input: { msg: 'b' } }, ]); expect(results).toHaveLength(2); - expect(results[0].content).toBe('a'); - expect(results[1].content).toBe('b'); + expect(results[0].output).toEqual({ type: 'text', value: 'a' }); + expect(results[1].output).toEqual({ type: 'text', value: 'b' }); }); it('should return all definitions', () => { @@ -272,7 +275,7 @@ describe('AIService', () => { it('should stream via adapter.streamChat()', async () => { const service = new AIService({ logger: silentLogger }); - const events: AIStreamEvent[] = []; + const events: TextStreamPart[] = []; for await (const event of service.streamChat([{ role: 'user', content: 'Hi' }])) { events.push(event); } @@ -289,14 +292,14 @@ describe('AIService', () => { }; const service = new AIService({ adapter, logger: silentLogger }); - const events: AIStreamEvent[] = []; + const events: TextStreamPart[] = []; for await (const event of service.streamChat([{ role: 'user', content: 'Hi' }])) { events.push(event); } expect(events).toHaveLength(2); expect(events[0].type).toBe('text-delta'); - expect(events[0].textDelta).toBe('response'); + expect(events[0].type === 'text-delta' && events[0].text).toBe('response'); expect(events[1].type).toBe('finish'); }); diff --git a/packages/services/service-ai/src/__tests__/auth-and-toolcalling.test.ts b/packages/services/service-ai/src/__tests__/auth-and-toolcalling.test.ts index e6f785e380..c39e9e489a 100644 --- a/packages/services/service-ai/src/__tests__/auth-and-toolcalling.test.ts +++ b/packages/services/service-ai/src/__tests__/auth-and-toolcalling.test.ts @@ -2,11 +2,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { - AIMessage, + ModelMessage, AIResult, AIRequestOptions, - AIStreamEvent, - AIToolCall, + TextStreamPart, + ToolSet, + ToolCallPart, LLMAdapter, } from '@objectstack/spec/contracts'; import { AIService } from '../ai-service.js'; @@ -313,7 +314,7 @@ describe('chatWithTools — Enhanced Error Handling', () => { const onToolError = vi.fn().mockReturnValue('continue'); const adapter = createMockAdapter([ - { content: '', toolCalls: [{ id: 'c1', name: 'bad_tool', arguments: '{}' }] }, + { content: '', toolCalls: [{ type: 'tool-call' as const, toolCallId: 'c1', toolName: 'bad_tool', input: {} }] }, { content: 'Recovered' }, ]); @@ -325,7 +326,7 @@ describe('chatWithTools — Enhanced Error Handling', () => { expect(onToolError).toHaveBeenCalledTimes(1); expect(onToolError).toHaveBeenCalledWith( - expect.objectContaining({ name: 'bad_tool' }), + expect.objectContaining({ toolName: 'bad_tool' }), 'boom', ); expect(result.content).toBe('Recovered'); @@ -338,7 +339,7 @@ describe('chatWithTools — Enhanced Error Handling', () => { ); const adapter = createMockAdapter([ - { content: '', toolCalls: [{ id: 'c1', name: 'abort_tool', arguments: '{}' }] }, + { content: '', toolCalls: [{ type: 'tool-call' as const, toolCallId: 'c1', toolName: 'abort_tool', input: {} }] }, // This would be the forced-final call { content: 'Aborted cleanly' }, ]); @@ -381,7 +382,7 @@ describe('chatWithTools — Enhanced Error Handling', () => { ); const adapter = createMockAdapter([ - { content: '', toolCalls: [{ id: 'c1', name: 'fail_tool', arguments: '{}' }] }, + { content: '', toolCalls: [{ type: 'tool-call' as const, toolCallId: 'c1', toolName: 'fail_tool', input: {} }] }, { content: 'Error was fed back to model' }, ]); @@ -400,7 +401,7 @@ describe('chatWithTools — Enhanced Error Handling', () => { const infiniteToolCall: AIResult = { content: '', - toolCalls: [{ id: 'c', name: 'flaky_tool', arguments: '{}' }], + toolCalls: [{ type: 'tool-call' as const, toolCallId: 'c', toolName: 'flaky_tool', input: {} }], }; const adapter = createMockAdapter( Array(2).fill(infiniteToolCall).concat([{ content: 'Forced' }]), @@ -430,8 +431,8 @@ describe('chatWithTools — Enhanced Error Handling', () => { { content: '', toolCalls: [ - { id: 'c1', name: 'get_weather', arguments: '{"city":"NYC"}' }, - { id: 'c2', name: 'bad_tool', arguments: '{}' }, + { type: 'tool-call' as const, toolCallId: 'c1', toolName: 'get_weather', input: { city: 'NYC' } }, + { type: 'tool-call' as const, toolCallId: 'c2', toolName: 'bad_tool', input: {} }, ], }, { content: 'Weather ok, tool failed' }, @@ -447,12 +448,12 @@ describe('chatWithTools — Enhanced Error Handling', () => { // Only called for the failing tool expect(onToolError).toHaveBeenCalledTimes(1); expect(onToolError).toHaveBeenCalledWith( - expect.objectContaining({ name: 'bad_tool' }), + expect.objectContaining({ toolName: 'bad_tool' }), 'fail', ); // Both tool results fed back - const secondCallMessages = (adapter.chat as any).mock.calls[1][0] as AIMessage[]; + const secondCallMessages = (adapter.chat as any).mock.calls[1][0] as ModelMessage[]; const toolMessages = secondCallMessages.filter(m => m.role === 'tool'); expect(toolMessages).toHaveLength(2); expect(result.content).toBe('Weather ok, tool failed'); @@ -478,7 +479,7 @@ describe('streamChatWithTools', () => { }; const service = new AIService({ adapter, logger: silentLogger, toolRegistry: registry }); - const events: AIStreamEvent[] = []; + const events: TextStreamPart[] = []; for await (const event of service.streamChatWithTools([{ role: 'user', content: 'Hi' }])) { events.push(event); } @@ -486,16 +487,17 @@ describe('streamChatWithTools', () => { // Should emit the probed result as text-delta + finish (no double model call) expect(events).toHaveLength(2); expect(events[0].type).toBe('text-delta'); - expect(events[0].textDelta).toBe('Hello!'); + expect((events[0] as any).text).toBe('Hello!'); expect(events[1].type).toBe('finish'); expect(adapter.chat).toHaveBeenCalledTimes(1); }); it('should emit tool-call events during tool resolution', async () => { - const toolCall: AIToolCall = { - id: 'call_1', - name: 'get_weather', - arguments: JSON.stringify({ city: 'Tokyo' }), + const toolCall: ToolCallPart = { + type: 'tool-call', + toolCallId: 'call_1', + toolName: 'get_weather', + input: { city: 'Tokyo' }, }; let chatCallIndex = 0; @@ -512,7 +514,7 @@ describe('streamChatWithTools', () => { }; const service = new AIService({ adapter, logger: silentLogger, toolRegistry: registry }); - const events: AIStreamEvent[] = []; + const events: TextStreamPart[] = []; for await (const event of service.streamChatWithTools( [{ role: 'user', content: 'Weather in Tokyo?' }], )) { @@ -522,7 +524,7 @@ describe('streamChatWithTools', () => { // Should have tool-call event followed by text-delta + finish (no double call) const toolCallEvents = events.filter(e => e.type === 'tool-call'); expect(toolCallEvents).toHaveLength(1); - expect(toolCallEvents[0].toolCall?.name).toBe('get_weather'); + expect((toolCallEvents[0] as any).toolName).toBe('get_weather'); const finishEvent = events.find(e => e.type === 'finish'); expect(finishEvent).toBeDefined(); @@ -539,7 +541,7 @@ describe('streamChatWithTools', () => { const emptyRegistry = new ToolRegistry(); const service = new AIService({ adapter, logger: silentLogger, toolRegistry: emptyRegistry }); - const events: AIStreamEvent[] = []; + const events: TextStreamPart[] = []; for await (const event of service.streamChatWithTools( [{ role: 'user', content: 'Hi' }], )) { @@ -548,14 +550,14 @@ describe('streamChatWithTools', () => { expect(events).toHaveLength(2); expect(events[0].type).toBe('text-delta'); - expect(events[0].textDelta).toBe('Fallback response'); + expect((events[0] as any).text).toBe('Fallback response'); expect(events[1].type).toBe('finish'); }); it('should respect maxIterations in streaming tool loop', async () => { const infiniteToolCall: AIResult = { content: '', - toolCalls: [{ id: 'c', name: 'get_weather', arguments: '{"city":"X"}' }], + toolCalls: [{ type: 'tool-call' as const, toolCallId: 'c', toolName: 'get_weather', input: { city: 'X' } }], }; let callIndex = 0; @@ -568,13 +570,13 @@ describe('streamChatWithTools', () => { }), complete: vi.fn(async () => ({ content: '' })), async *streamChat() { - yield { type: 'text-delta' as const, textDelta: 'Forced stop' }; - yield { type: 'finish' as const, result: { content: 'Forced stop' } }; + yield { type: 'text-delta' as const, id: '1', text: 'Forced stop' } as TextStreamPart; + yield { type: 'finish' as const, finishReason: 'stop' as const, totalUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }, rawFinishReason: 'stop' } as unknown as TextStreamPart; }, }; const service = new AIService({ adapter, logger: silentLogger, toolRegistry: registry }); - const events: AIStreamEvent[] = []; + const events: TextStreamPart[] = []; for await (const event of service.streamChatWithTools( [{ role: 'user', content: 'Loop' }], { maxIterations: 2 }, @@ -601,7 +603,7 @@ describe('streamChatWithTools', () => { if (chatCallIndex === 1) { return { content: '', - toolCalls: [{ id: 'c1', name: 'critical_fail', arguments: '{}' }], + toolCalls: [{ type: 'tool-call' as const, toolCallId: 'c1', toolName: 'critical_fail', input: {} }], }; } return { content: 'Aborted' }; @@ -610,7 +612,7 @@ describe('streamChatWithTools', () => { }; const service = new AIService({ adapter, logger: silentLogger, toolRegistry: registry }); - const events: AIStreamEvent[] = []; + const events: TextStreamPart[] = []; for await (const event of service.streamChatWithTools( [{ role: 'user', content: 'Critical' }], { onToolError: () => 'abort' }, diff --git a/packages/services/service-ai/src/__tests__/chatbot-features.test.ts b/packages/services/service-ai/src/__tests__/chatbot-features.test.ts index 0ea2ff87b6..1dceded3da 100644 --- a/packages/services/service-ai/src/__tests__/chatbot-features.test.ts +++ b/packages/services/service-ai/src/__tests__/chatbot-features.test.ts @@ -2,10 +2,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { - AIMessage, + ModelMessage, AIResult, AIRequestOptions, - AIToolCall, + ToolCallPart, AIToolDefinition, IDataEngine, IMetadataService, @@ -108,10 +108,11 @@ describe('AIService.chatWithTools', () => { }); it('should execute tool calls and loop until final text response', async () => { - const toolCall: AIToolCall = { - id: 'call_1', - name: 'get_weather', - arguments: JSON.stringify({ city: 'Tokyo' }), + const toolCall: ToolCallPart = { + type: 'tool-call' as const, + toolCallId: 'call_1', + toolName: 'get_weather', + input: { city: 'Tokyo' }, }; const adapter = createMockAdapter([ @@ -131,13 +132,16 @@ describe('AIService.chatWithTools', () => { expect(adapter.chat).toHaveBeenCalledTimes(2); // Verify the second call includes the tool result message - const secondCallMessages = (adapter.chat as any).mock.calls[1][0] as AIMessage[]; + const secondCallMessages = (adapter.chat as any).mock.calls[1][0] as ModelMessage[]; expect(secondCallMessages).toHaveLength(3); // user + assistant(tool_call) + tool(result) expect(secondCallMessages[1].role).toBe('assistant'); - expect(secondCallMessages[1].toolCalls).toEqual([toolCall]); + const assistantContent = secondCallMessages[1].content as any[]; + const toolCallParts = assistantContent.filter((p: any) => p.type === 'tool-call'); + expect(toolCallParts).toEqual([toolCall]); expect(secondCallMessages[2].role).toBe('tool'); - expect(secondCallMessages[2].toolCallId).toBe('call_1'); - expect(secondCallMessages[2].content).toContain('"temp":22'); + const toolResultContent = secondCallMessages[2].content as any[]; + expect(toolResultContent[0].toolCallId).toBe('call_1'); + expect(toolResultContent[0].output.value).toContain('"temp":22'); }); it('should handle multiple sequential tool calls', async () => { @@ -148,9 +152,9 @@ describe('AIService.chatWithTools', () => { const adapter = createMockAdapter([ // Round 1: call get_weather - { content: '', toolCalls: [{ id: 'c1', name: 'get_weather', arguments: '{"city":"NYC"}' }] }, + { content: '', toolCalls: [{ type: 'tool-call' as const, toolCallId: 'c1', toolName: 'get_weather', input: { city: 'NYC' } }] }, // Round 2: call get_time - { content: '', toolCalls: [{ id: 'c2', name: 'get_time', arguments: '{}' }] }, + { content: '', toolCalls: [{ type: 'tool-call' as const, toolCallId: 'c2', toolName: 'get_time', input: {} }] }, // Round 3: final response { content: 'NYC: 22°C at 14:30' }, ]); @@ -173,8 +177,8 @@ describe('AIService.chatWithTools', () => { { content: '', toolCalls: [ - { id: 'c1', name: 'get_weather', arguments: '{"city":"London"}' }, - { id: 'c2', name: 'get_population', arguments: '{"city":"London"}' }, + { type: 'tool-call' as const, toolCallId: 'c1', toolName: 'get_weather', input: { city: 'London' } }, + { type: 'tool-call' as const, toolCallId: 'c2', toolName: 'get_population', input: { city: 'London' } }, ], }, // Final response with both results @@ -187,7 +191,7 @@ describe('AIService.chatWithTools', () => { expect(result.content).toBe('London: 22°C, pop 1M'); // Both tool results should be in the conversation - const secondCallMessages = (adapter.chat as any).mock.calls[1][0] as AIMessage[]; + const secondCallMessages = (adapter.chat as any).mock.calls[1][0] as ModelMessage[]; const toolMessages = secondCallMessages.filter(m => m.role === 'tool'); expect(toolMessages).toHaveLength(2); }); @@ -196,7 +200,7 @@ describe('AIService.chatWithTools', () => { // Adapter always returns tool calls — would loop forever const infiniteToolCall: AIResult = { content: '', - toolCalls: [{ id: 'c', name: 'get_weather', arguments: '{"city":"X"}' }], + toolCalls: [{ type: 'tool-call' as const, toolCallId: 'c', toolName: 'get_weather', input: { city: 'X' } }], }; const adapter = createMockAdapter( Array(5).fill(infiniteToolCall).concat([{ content: 'Forced stop' }]), @@ -243,7 +247,7 @@ describe('AIService.chatWithTools', () => { ); const adapter = createMockAdapter([ - { content: '', toolCalls: [{ id: 'c1', name: 'bad_tool', arguments: '{}' }] }, + { content: '', toolCalls: [{ type: 'tool-call' as const, toolCallId: 'c1', toolName: 'bad_tool', input: {} }] }, { content: 'I see the tool failed' }, ]); @@ -253,9 +257,16 @@ describe('AIService.chatWithTools', () => { expect(result.content).toBe('I see the tool failed'); // The error message should be in the tool result - const secondCallMessages = (adapter.chat as any).mock.calls[1][0] as AIMessage[]; + const secondCallMessages = (adapter.chat as any).mock.calls[1][0] as ModelMessage[]; const toolMsg = secondCallMessages.find(m => m.role === 'tool'); - expect(toolMsg?.content).toContain('Tool crashed'); + let toolContent: string | undefined; + if (toolMsg?.role === 'tool' && Array.isArray(toolMsg.content)) { + const firstResult = toolMsg.content[0]; + if ('output' in firstResult && firstResult.output && typeof firstResult.output === 'object' && 'value' in firstResult.output) { + toolContent = String(firstResult.output.value); + } + } + expect(toolContent).toContain('Tool crashed'); }); it('should work with no registered tools', async () => { @@ -343,12 +354,13 @@ describe('Data Tools', () => { ]); const result = await registry.execute({ - id: 'c1', - name: 'list_objects', - arguments: '{}', + type: 'tool-call' as const, + toolCallId: 'c1', + toolName: 'list_objects', + input: {}, }); - const parsed = JSON.parse(result.content); + const parsed = JSON.parse((result.output as any).value); expect(parsed).toHaveLength(2); expect(parsed[0]).toEqual({ name: 'account', label: 'Account' }); }); @@ -364,12 +376,13 @@ describe('Data Tools', () => { }); const result = await registry.execute({ - id: 'c1', - name: 'describe_object', - arguments: JSON.stringify({ objectName: 'account' }), + type: 'tool-call' as const, + toolCallId: 'c1', + toolName: 'describe_object', + input: { objectName: 'account' }, }); - const parsed = JSON.parse(result.content); + const parsed = JSON.parse((result.output as any).value); expect(parsed.name).toBe('account'); expect(parsed.fields.name.type).toBe('text'); expect(parsed.fields.name.required).toBe(true); @@ -378,12 +391,13 @@ describe('Data Tools', () => { it('describe_object should return error for unknown object', async () => { const result = await registry.execute({ - id: 'c1', - name: 'describe_object', - arguments: JSON.stringify({ objectName: 'nonexistent' }), + type: 'tool-call' as const, + toolCallId: 'c1', + toolName: 'describe_object', + input: { objectName: 'nonexistent' }, }); - const parsed = JSON.parse(result.content); + const parsed = JSON.parse((result.output as any).value); expect(parsed.error).toContain('not found'); }); @@ -392,14 +406,15 @@ describe('Data Tools', () => { (dataEngine.find as any).mockResolvedValue(records); const result = await registry.execute({ - id: 'c1', - name: 'query_records', - arguments: JSON.stringify({ + type: 'tool-call' as const, + toolCallId: 'c1', + toolName: 'query_records', + input: { objectName: 'account', where: { status: 'active' }, fields: ['name', 'status'], limit: 10, - }), + }, }); expect(dataEngine.find).toHaveBeenCalledWith('account', { @@ -410,7 +425,7 @@ describe('Data Tools', () => { offset: undefined, }); - const parsed = JSON.parse(result.content); + const parsed = JSON.parse((result.output as any).value); expect(parsed.count).toBe(2); expect(parsed.records).toEqual(records); }); @@ -419,9 +434,10 @@ describe('Data Tools', () => { (dataEngine.find as any).mockResolvedValue([]); await registry.execute({ - id: 'c1', - name: 'query_records', - arguments: JSON.stringify({ objectName: 'account', limit: 999 }), + type: 'tool-call' as const, + toolCallId: 'c1', + toolName: 'query_records', + input: { objectName: 'account', limit: 999 }, }); expect(dataEngine.find).toHaveBeenCalledWith('account', expect.objectContaining({ @@ -433,9 +449,10 @@ describe('Data Tools', () => { (dataEngine.find as any).mockResolvedValue([]); await registry.execute({ - id: 'c1', - name: 'query_records', - arguments: JSON.stringify({ objectName: 'account' }), + type: 'tool-call' as const, + toolCallId: 'c1', + toolName: 'query_records', + input: { objectName: 'account' }, }); expect(dataEngine.find).toHaveBeenCalledWith('account', expect.objectContaining({ @@ -448,9 +465,10 @@ describe('Data Tools', () => { (dataEngine.findOne as any).mockResolvedValue(record); const result = await registry.execute({ - id: 'c1', - name: 'get_record', - arguments: JSON.stringify({ objectName: 'account', recordId: 'rec_123' }), + type: 'tool-call' as const, + toolCallId: 'c1', + toolName: 'get_record', + input: { objectName: 'account', recordId: 'rec_123' }, }); expect(dataEngine.findOne).toHaveBeenCalledWith('account', { @@ -458,18 +476,19 @@ describe('Data Tools', () => { fields: undefined, }); - const parsed = JSON.parse(result.content); + const parsed = JSON.parse((result.output as any).value); expect(parsed.name).toBe('Acme Corp'); }); it('get_record should return error for missing record', async () => { const result = await registry.execute({ - id: 'c1', - name: 'get_record', - arguments: JSON.stringify({ objectName: 'account', recordId: 'not_found' }), + type: 'tool-call' as const, + toolCallId: 'c1', + toolName: 'get_record', + input: { objectName: 'account', recordId: 'not_found' }, }); - const parsed = JSON.parse(result.content); + const parsed = JSON.parse((result.output as any).value); expect(parsed.error).toContain('not found'); }); @@ -478,13 +497,14 @@ describe('Data Tools', () => { (dataEngine.aggregate as any).mockResolvedValue(aggResult); const result = await registry.execute({ - id: 'c1', - name: 'aggregate_data', - arguments: JSON.stringify({ + type: 'tool-call' as const, + toolCallId: 'c1', + toolName: 'aggregate_data', + input: { objectName: 'account', aggregations: [{ function: 'sum', field: 'revenue', alias: 'total_revenue' }], where: { status: 'active' }, - }), + }, }); expect(dataEngine.aggregate).toHaveBeenCalledWith('account', { @@ -493,21 +513,22 @@ describe('Data Tools', () => { aggregations: [{ function: 'sum', field: 'revenue', alias: 'total_revenue' }], }); - const parsed = JSON.parse(result.content); + const parsed = JSON.parse((result.output as any).value); expect(parsed).toEqual(aggResult); }); it('aggregate_data should reject invalid aggregation functions', async () => { const result = await registry.execute({ - id: 'c1', - name: 'aggregate_data', - arguments: JSON.stringify({ + type: 'tool-call' as const, + toolCallId: 'c1', + toolName: 'aggregate_data', + input: { objectName: 'account', aggregations: [{ function: 'drop_table', field: 'id', alias: 'x' }], - }), + }, }); - const parsed = JSON.parse(result.content); + const parsed = JSON.parse((result.output as any).value); expect(parsed.error).toContain('Invalid aggregation function'); expect(parsed.error).toContain('drop_table'); expect(dataEngine.aggregate).not.toHaveBeenCalled(); @@ -517,9 +538,10 @@ describe('Data Tools', () => { (dataEngine.find as any).mockResolvedValue([]); await registry.execute({ - id: 'c1', - name: 'query_records', - arguments: JSON.stringify({ objectName: 'account', limit: -5 }), + type: 'tool-call' as const, + toolCallId: 'c1', + toolName: 'query_records', + input: { objectName: 'account', limit: -5 }, }); expect(dataEngine.find).toHaveBeenCalledWith('account', expect.objectContaining({ @@ -531,9 +553,10 @@ describe('Data Tools', () => { (dataEngine.find as any).mockResolvedValue([]); await registry.execute({ - id: 'c1', - name: 'query_records', - arguments: JSON.stringify({ objectName: 'account', limit: 'not_a_number' }), + type: 'tool-call' as const, + toolCallId: 'c1', + toolName: 'query_records', + input: { objectName: 'account', limit: 'not_a_number' }, }); expect(dataEngine.find).toHaveBeenCalledWith('account', expect.objectContaining({ @@ -545,9 +568,10 @@ describe('Data Tools', () => { (dataEngine.find as any).mockResolvedValue([]); await registry.execute({ - id: 'c1', - name: 'query_records', - arguments: JSON.stringify({ objectName: 'account', offset: -10 }), + type: 'tool-call' as const, + toolCallId: 'c1', + toolName: 'query_records', + input: { objectName: 'account', offset: -10 }, }); expect(dataEngine.find).toHaveBeenCalledWith('account', expect.objectContaining({ diff --git a/packages/services/service-ai/src/__tests__/objectql-conversation-service.test.ts b/packages/services/service-ai/src/__tests__/objectql-conversation-service.test.ts index a0acca147a..4e0b88d7af 100644 --- a/packages/services/service-ai/src/__tests__/objectql-conversation-service.test.ts +++ b/packages/services/service-ai/src/__tests__/objectql-conversation-service.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import type { IDataEngine } from '@objectstack/spec/contracts'; -import type { AIMessage } from '@objectstack/spec/contracts'; +import type { ModelMessage } from '@objectstack/spec/contracts'; import { ObjectQLConversationService } from '../conversation/objectql-conversation-service.js'; // ───────────────────────────────────────────────────────────────── @@ -242,7 +242,7 @@ describe('ObjectQLConversationService', () => { it('should add a user message to a conversation', async () => { const conv = await service.create({ title: 'Chat' }); - const msg: AIMessage = { role: 'user', content: 'Hello AI!' }; + const msg: ModelMessage = { role: 'user', content: 'Hello AI!' }; const updated = await service.addMessage(conv.id, msg); expect(updated.messages).toHaveLength(1); @@ -253,33 +253,49 @@ describe('ObjectQLConversationService', () => { it('should add a tool message with toolCallId', async () => { const conv = await service.create(); - const msg: AIMessage = { - role: 'tool', - content: '{"temp": 22}', - toolCallId: 'call_abc', + const msg: ModelMessage = { + role: 'tool' as const, + content: [{ + type: 'tool-result' as const, + toolCallId: 'call_abc', + toolName: 'get_weather', + output: { type: 'text' as const, value: '{"temp": 22}' }, + }], }; const updated = await service.addMessage(conv.id, msg); expect(updated.messages).toHaveLength(1); - expect(updated.messages[0].toolCallId).toBe('call_abc'); + const firstMsg = updated.messages[0]; + if (firstMsg.role === 'tool' && Array.isArray(firstMsg.content)) { + expect(firstMsg.content[0].toolCallId).toBe('call_abc'); + } else { + throw new Error('Expected tool message with array content'); + } }); it('should add an assistant message with toolCalls', async () => { const conv = await service.create(); - const msg: AIMessage = { - role: 'assistant', - content: '', - toolCalls: [{ id: 'call_1', name: 'get_weather', arguments: '{}' }], + const msg: ModelMessage = { + role: 'assistant' as const, + content: [ + { type: 'tool-call' as const, toolCallId: 'call_1', toolName: 'get_weather', input: {} }, + ], }; const updated = await service.addMessage(conv.id, msg); expect(updated.messages).toHaveLength(1); - expect(updated.messages[0].toolCalls).toHaveLength(1); - expect(updated.messages[0].toolCalls![0].name).toBe('get_weather'); + const firstMsg = updated.messages[0]; + if (firstMsg.role === 'assistant' && Array.isArray(firstMsg.content)) { + const toolCallParts = firstMsg.content.filter((p) => p.type === 'tool-call'); + expect(toolCallParts).toHaveLength(1); + expect(toolCallParts[0].toolName).toBe('get_weather'); + } else { + throw new Error('Expected assistant message with array content'); + } }); it('should throw when adding message to non-existent conversation', async () => { - const msg: AIMessage = { role: 'user', content: 'Hello' }; + const msg: ModelMessage = { role: 'user', content: 'Hello' }; await expect(service.addMessage('conv_ghost', msg)).rejects.toThrow( 'Conversation "conv_ghost" not found', ); @@ -352,13 +368,15 @@ describe('ObjectQLConversationService', () => { it('should handle invalid JSON in tool_calls gracefully', async () => { const conv = await service.create(); - await service.addMessage(conv.id, { role: 'user', content: 'hi' }); + await service.addMessage(conv.id, { role: 'assistant', content: 'checking tools' }); // Manually corrupt tool_calls in the engine const msgs = await engine.find('ai_messages', { where: { conversation_id: conv.id } }); msgs[0].tool_calls = 'broken{json'; const fetched = await service.get(conv.id); - expect(fetched!.messages[0].toolCalls).toBeUndefined(); + // With broken tool_calls, the assistant message should still load with string content + expect(fetched!.messages[0].role).toBe('assistant'); + expect(fetched!.messages[0].content).toBe('checking tools'); }); }); diff --git a/packages/services/service-ai/src/adapters/memory-adapter.ts b/packages/services/service-ai/src/adapters/memory-adapter.ts index b53cf2e948..1ad21cbca3 100644 --- a/packages/services/service-ai/src/adapters/memory-adapter.ts +++ b/packages/services/service-ai/src/adapters/memory-adapter.ts @@ -1,10 +1,11 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type { - AIMessage, + ModelMessage, AIRequestOptions, AIResult, - AIStreamEvent, + TextStreamPart, + ToolSet, } from '@objectstack/spec/contracts'; import type { LLMAdapter } from '@objectstack/spec/contracts'; @@ -17,10 +18,12 @@ import type { LLMAdapter } from '@objectstack/spec/contracts'; export class MemoryLLMAdapter implements LLMAdapter { readonly name = 'memory'; - async chat(messages: AIMessage[], options?: AIRequestOptions): Promise { + async chat(messages: ModelMessage[], options?: AIRequestOptions): Promise { const lastUserMessage = [...messages].reverse().find(m => m.role === 'user'); + const userContent = lastUserMessage?.content; + const text = typeof userContent === 'string' ? userContent : '(complex content)'; const content = lastUserMessage - ? `[memory] ${lastUserMessage.content}` + ? `[memory] ${text}` : '[memory] (no user message)'; return { @@ -39,17 +42,22 @@ export class MemoryLLMAdapter implements LLMAdapter { } async *streamChat( - messages: AIMessage[], + messages: ModelMessage[], _options?: AIRequestOptions, - ): AsyncIterable { + ): AsyncIterable> { const result = await this.chat(messages); // Emit word-by-word deltas for realistic streaming simulation const words = result.content.split(' '); for (let i = 0; i < words.length; i++) { - const textDelta = i === 0 ? words[i] : ` ${words[i]}`; - yield { type: 'text-delta', textDelta }; + const wordText = i === 0 ? words[i] : ` ${words[i]}`; + yield { type: 'text-delta', id: `delta_${i}`, text: wordText } as TextStreamPart; } - yield { type: 'finish', result }; + yield { + type: 'finish', + finishReason: 'stop' as const, + totalUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }, + rawFinishReason: 'stop', + } as unknown as TextStreamPart; } async embed(input: string | string[]): Promise { diff --git a/packages/services/service-ai/src/agent-runtime.ts b/packages/services/service-ai/src/agent-runtime.ts index 3df5f087db..42edd5b29d 100644 --- a/packages/services/service-ai/src/agent-runtime.ts +++ b/packages/services/service-ai/src/agent-runtime.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type { - AIMessage, + ModelMessage, AIRequestOptions, AIToolDefinition, IMetadataService, @@ -63,7 +63,7 @@ export class AgentRuntime { * Build the system message(s) that should be prepended to the * conversation when chatting with the given agent. */ - buildSystemMessages(agent: Agent, context?: AgentChatContext): AIMessage[] { + buildSystemMessages(agent: Agent, context?: AgentChatContext): ModelMessage[] { const parts: string[] = []; // Base instructions @@ -80,7 +80,7 @@ export class AgentRuntime { } } - return [{ role: 'system', content: parts.join('\n') }]; + return [{ role: 'system' as const, content: parts.join('\n') }]; } /** diff --git a/packages/services/service-ai/src/ai-service.ts b/packages/services/service-ai/src/ai-service.ts index 6488a4ea3a..f4a6646094 100644 --- a/packages/services/service-ai/src/ai-service.ts +++ b/packages/services/service-ai/src/ai-service.ts @@ -1,10 +1,12 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type { - AIMessage, + ModelMessage, + ToolCallPart, + TextStreamPart, + ToolSet, AIRequestOptions, AIResult, - AIStreamEvent, IAIService, IAIConversationService, ChatWithToolsOptions, @@ -14,8 +16,28 @@ import type { Logger } from '@objectstack/spec/contracts'; import { createLogger } from '@objectstack/core'; import { MemoryLLMAdapter } from './adapters/memory-adapter.js'; import { ToolRegistry } from './tools/tool-registry.js'; +import type { ToolExecutionResult } from './tools/tool-registry.js'; import { InMemoryConversationService } from './conversation/in-memory-conversation-service.js'; +// ── Stream event helpers ────────────────────────────────────────── +// These helpers construct properly-typed Vercel AI SDK stream parts +// to avoid repeated `as unknown as TextStreamPart` casts. + +/** Create a text-delta stream part. */ +function textDeltaPart(id: string, text: string): TextStreamPart { + return { type: 'text-delta', id, text } as TextStreamPart; +} + +/** Create a finish stream part from an AIResult. */ +function finishPart(result?: AIResult): TextStreamPart { + return { + type: 'finish', + finishReason: 'stop', + totalUsage: result?.usage ?? { promptTokens: 0, completionTokens: 0, totalTokens: 0 }, + rawFinishReason: 'stop', + } as unknown as TextStreamPart; +} + /** * Configuration for AIService. */ @@ -70,7 +92,7 @@ export class AIService implements IAIService { // ── IAIService implementation ────────────────────────────────── - async chat(messages: AIMessage[], options?: AIRequestOptions): Promise { + async chat(messages: ModelMessage[], options?: AIRequestOptions): Promise { this.logger.debug('[AI] chat', { messageCount: messages.length, model: options?.model }); return this.adapter.chat(messages, options); } @@ -81,16 +103,16 @@ export class AIService implements IAIService { } async *streamChat( - messages: AIMessage[], + messages: ModelMessage[], options?: AIRequestOptions, - ): AsyncIterable { + ): AsyncIterable> { this.logger.debug('[AI] streamChat', { messageCount: messages.length, model: options?.model }); if (!this.adapter.streamChat) { // Fallback: emit the entire response as a single text-delta + finish const result = await this.adapter.chat(messages, options); - yield { type: 'text-delta', textDelta: result.content }; - yield { type: 'finish', result }; + yield textDeltaPart('fallback', result.content); + yield finishPart(result); return; } @@ -116,6 +138,12 @@ export class AIService implements IAIService { /** Default maximum iterations for the tool call loop. */ static readonly DEFAULT_MAX_ITERATIONS = 10; + /** Extract the text value from a ToolExecutionResult's output. */ + private static extractOutputText(tr: ToolExecutionResult): string { + return tr.output && typeof tr.output === 'object' && 'value' in tr.output + ? String(tr.output.value) : 'unknown error'; + } + /** * Chat with automatic tool call resolution. * @@ -128,7 +156,7 @@ export class AIService implements IAIService { * maximum number of iterations (`maxIterations`) is reached. */ async chatWithTools( - messages: AIMessage[], + messages: ModelMessage[], options?: ChatWithToolsOptions, ): Promise { // Destructure loop-specific options so they are never forwarded to the adapter @@ -174,31 +202,34 @@ export class AIService implements IAIService { this.logger.debug('[AI] chatWithTools tool calls', { iteration, - calls: result.toolCalls.map(tc => tc.name), + calls: result.toolCalls.map(tc => tc.toolName), }); // Append the assistant's response (with tool call metadata) to the conversation + const assistantContent: Array<{ type: 'text'; text: string } | ToolCallPart> = []; + if (result.content) assistantContent.push({ type: 'text', text: result.content }); + assistantContent.push(...result.toolCalls); conversation.push({ role: 'assistant', - content: result.content ?? '', - toolCalls: result.toolCalls, - }); + content: assistantContent, + } as ModelMessage); // Execute all tool calls in parallel - const toolResults = await this.toolRegistry.executeAll(result.toolCalls); + const toolResults: ToolExecutionResult[] = await this.toolRegistry.executeAll(result.toolCalls); // Process results: track errors and honour onToolError callback for (const tr of toolResults) { if (tr.isError) { // Match tool call by toolCallId for robust attribution - const matchedCall = result.toolCalls!.find(tc => tc.id === tr.toolCallId); - const toolName = matchedCall?.name ?? 'unknown'; - const errorEntry = { iteration, toolName, error: tr.content }; + const matchedCall = result.toolCalls!.find(tc => tc.toolCallId === tr.toolCallId); + const toolName = matchedCall?.toolName ?? 'unknown'; + const errorText = AIService.extractOutputText(tr); + const errorEntry = { iteration, toolName, error: errorText }; toolErrors.push(errorEntry); this.logger.warn('[AI] chatWithTools tool error', errorEntry); if (onToolError && matchedCall) { - const action = onToolError(matchedCall, tr.content); + const action = onToolError(matchedCall, errorText); if (action === 'abort') { abortedByCallback = true; } @@ -208,9 +239,8 @@ export class AIService implements IAIService { // Append each tool result as a `role: 'tool'` message conversation.push({ role: 'tool', - content: tr.content, - toolCallId: tr.toolCallId, - }); + content: [tr], + } as ModelMessage); } if (abortedByCallback) { @@ -244,9 +274,9 @@ export class AIService implements IAIService { * fed back until a final text stream is produced. */ async *streamChatWithTools( - messages: AIMessage[], + messages: ModelMessage[], options?: ChatWithToolsOptions, - ): AsyncIterable { + ): AsyncIterable> { const { maxIterations: maxIter, onToolError, ...restOptions } = options ?? {}; const maxIterations = maxIter ?? AIService.DEFAULT_MAX_ITERATIONS; const registeredTools = this.toolRegistry.getAll(); @@ -271,29 +301,32 @@ export class AIService implements IAIService { if (!result.toolCalls || result.toolCalls.length === 0) { // Final round — return the probed result without an extra model call - yield { type: 'text-delta', textDelta: result.content }; - yield { type: 'finish', result }; + yield textDeltaPart('stream', result.content); + yield finishPart(result); return; } // Emit tool-call events so the client can see tool execution progress for (const tc of result.toolCalls) { - yield { type: 'tool-call', toolCall: tc }; + yield { type: 'tool-call', toolCallId: tc.toolCallId, toolName: tc.toolName, input: tc.input } as TextStreamPart; } + const assistantContent: Array<{ type: 'text'; text: string } | ToolCallPart> = []; + if (result.content) assistantContent.push({ type: 'text', text: result.content }); + assistantContent.push(...result.toolCalls); conversation.push({ role: 'assistant', - content: result.content ?? '', - toolCalls: result.toolCalls, - }); + content: assistantContent, + } as ModelMessage); - const toolResults = await this.toolRegistry.executeAll(result.toolCalls); + const toolResults: ToolExecutionResult[] = await this.toolRegistry.executeAll(result.toolCalls); for (const tr of toolResults) { if (tr.isError && onToolError) { - const matchedCall = result.toolCalls!.find(tc => tc.id === tr.toolCallId); + const matchedCall = result.toolCalls!.find(tc => tc.toolCallId === tr.toolCallId); if (matchedCall) { - const action = onToolError(matchedCall, tr.content); + const errorText = AIService.extractOutputText(tr); + const action = onToolError(matchedCall, errorText); if (action === 'abort') { abortedByCallback = true; } @@ -301,9 +334,8 @@ export class AIService implements IAIService { } conversation.push({ role: 'tool', - content: tr.content, - toolCallId: tr.toolCallId, - }); + content: [tr], + } as ModelMessage); } if (abortedByCallback) { @@ -319,7 +351,7 @@ export class AIService implements IAIService { } const finalOptions = { ...chatOptions, tools: undefined, toolChoice: undefined }; const result = await this.adapter.chat(conversation, finalOptions); - yield { type: 'text-delta', textDelta: result.content }; - yield { type: 'finish', result }; + yield textDeltaPart('stream', result.content); + yield finishPart(result); } } diff --git a/packages/services/service-ai/src/conversation/in-memory-conversation-service.ts b/packages/services/service-ai/src/conversation/in-memory-conversation-service.ts index 2190d848c8..868e216a2f 100644 --- a/packages/services/service-ai/src/conversation/in-memory-conversation-service.ts +++ b/packages/services/service-ai/src/conversation/in-memory-conversation-service.ts @@ -2,7 +2,7 @@ import type { AIConversation, - AIMessage, + ModelMessage, IAIConversationService, } from '@objectstack/spec/contracts'; @@ -75,7 +75,7 @@ export class InMemoryConversationService implements IAIConversationService { return results; } - async addMessage(conversationId: string, message: AIMessage): Promise { + async addMessage(conversationId: string, message: ModelMessage): Promise { const conversation = this.store.get(conversationId); if (!conversation) { throw new Error(`Conversation "${conversationId}" not found`); diff --git a/packages/services/service-ai/src/conversation/objectql-conversation-service.ts b/packages/services/service-ai/src/conversation/objectql-conversation-service.ts index e71f448468..38311c6428 100644 --- a/packages/services/service-ai/src/conversation/objectql-conversation-service.ts +++ b/packages/services/service-ai/src/conversation/objectql-conversation-service.ts @@ -3,7 +3,9 @@ import { randomUUID } from 'node:crypto'; import type { AIConversation, - AIMessage, + ModelMessage, + ToolCallPart, + ToolResultPart, IAIConversationService, IDataEngine, } from '@objectstack/spec/contracts'; @@ -157,7 +159,7 @@ export class ObjectQLConversationService implements IAIConversationService { return conversations; } - async addMessage(conversationId: string, message: AIMessage): Promise { + async addMessage(conversationId: string, message: ModelMessage): Promise { // Verify conversation exists const row: DbConversationRow | null = await this.engine.findOne(CONVERSATIONS_OBJECT, { where: { id: conversationId }, @@ -169,14 +171,39 @@ export class ObjectQLConversationService implements IAIConversationService { const now = new Date().toISOString(); const msgId = `msg_${randomUUID()}`; + // Extract flat fields from the discriminated union + let contentStr: string; + let toolCallsJson: string | null = null; + let toolCallId: string | null = null; + + if (message.role === 'system' || message.role === 'user') { + contentStr = typeof message.content === 'string' ? message.content : JSON.stringify(message.content); + } else if (message.role === 'assistant') { + if (typeof message.content === 'string') { + contentStr = message.content; + } else { + const parts = message.content; + const textParts = parts.filter((p): p is { type: 'text'; text: string } => p.type === 'text').map(p => p.text); + const toolCalls = parts.filter(p => p.type === 'tool-call'); + contentStr = textParts.join(''); + if (toolCalls.length > 0) toolCallsJson = JSON.stringify(toolCalls); + } + } else if (message.role === 'tool') { + contentStr = JSON.stringify(message.content); + const firstResult = Array.isArray(message.content) ? message.content[0] : undefined; + if (firstResult && 'toolCallId' in firstResult) toolCallId = firstResult.toolCallId; + } else { + contentStr = ''; + } + // Insert the message await this.engine.insert(MESSAGES_OBJECT, { id: msgId, conversation_id: conversationId, role: message.role, - content: message.content, - tool_calls: message.toolCalls ? JSON.stringify(message.toolCalls) : null, - tool_call_id: message.toolCallId ?? null, + content: contentStr, + tool_calls: toolCallsJson, + tool_call_id: toolCallId, created_at: now, }); @@ -233,20 +260,42 @@ export class ObjectQLConversationService implements IAIConversationService { } /** - * Map a database row to an AIMessage. + * Map a database row to a ModelMessage. */ - private toMessage(row: DbMessageRow): AIMessage { - const msg: AIMessage = { - role: row.role, - content: row.content, - }; - const toolCalls = this.safeParse(row.tool_calls); - if (toolCalls) { - msg.toolCalls = toolCalls; - } - if (row.tool_call_id) { - msg.toolCallId = row.tool_call_id; + private toMessage(row: DbMessageRow): ModelMessage { + switch (row.role) { + case 'system': + return { role: 'system', content: row.content }; + case 'user': + return { role: 'user', content: row.content }; + case 'assistant': { + const toolCalls = this.safeParse(row.tool_calls); + if (toolCalls && toolCalls.length > 0) { + const content: Array<{ type: 'text'; text: string } | ToolCallPart> = []; + if (row.content) content.push({ type: 'text', text: row.content }); + content.push(...toolCalls); + return { role: 'assistant', content }; + } + return { role: 'assistant', content: row.content }; + } + case 'tool': { + const toolResults = this.safeParse(row.content); + if (toolResults && toolResults.length > 0 && toolResults[0]?.type === 'tool-result') { + return { role: 'tool', content: toolResults }; + } + // Backward compat: old format was a plain string + return { + role: 'tool', + content: [{ + type: 'tool-result' as const, + toolCallId: row.tool_call_id ?? '', + toolName: 'unknown', + output: { type: 'text' as const, value: row.content }, + }], + }; + } + default: + return { role: 'user', content: row.content }; } - return msg; } } diff --git a/packages/services/service-ai/src/index.ts b/packages/services/service-ai/src/index.ts index 72ff8884c1..df68670e7d 100644 --- a/packages/services/service-ai/src/index.ts +++ b/packages/services/service-ai/src/index.ts @@ -18,7 +18,7 @@ export { ObjectQLConversationService } from './conversation/objectql-conversatio // Tool registry export { ToolRegistry } from './tools/tool-registry.js'; -export type { ToolHandler } from './tools/tool-registry.js'; +export type { ToolHandler, ToolExecutionResult } from './tools/tool-registry.js'; // Data tools export { registerDataTools, DATA_TOOL_DEFINITIONS } from './tools/data-tools.js'; diff --git a/packages/services/service-ai/src/routes/agent-routes.ts b/packages/services/service-ai/src/routes/agent-routes.ts index 571c2e6c72..7684abb276 100644 --- a/packages/services/service-ai/src/routes/agent-routes.ts +++ b/packages/services/service-ai/src/routes/agent-routes.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type { AIMessage } from '@objectstack/spec/contracts'; +import type { ModelMessage } from '@objectstack/spec/contracts'; import type { Logger } from '@objectstack/spec/contracts'; import type { AIService } from '../ai-service.js'; import type { AgentRuntime, AgentChatContext } from '../agent-runtime.js'; @@ -109,9 +109,9 @@ export function buildAgentRoutes( const mergedOptions = { ...agentOptions, ...safeOverrides }; // Prepend system messages then user conversation - const fullMessages: AIMessage[] = [ + const fullMessages: ModelMessage[] = [ ...systemMessages, - ...(rawMessages as AIMessage[]), + ...(rawMessages as ModelMessage[]), ]; // Use chatWithTools for automatic tool resolution diff --git a/packages/services/service-ai/src/routes/ai-routes.ts b/packages/services/service-ai/src/routes/ai-routes.ts index 4687cc16d8..f4fd97a546 100644 --- a/packages/services/service-ai/src/routes/ai-routes.ts +++ b/packages/services/service-ai/src/routes/ai-routes.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type { IAIService, IAIConversationService, AIMessage } from '@objectstack/spec/contracts'; +import type { IAIService, IAIConversationService, ModelMessage } from '@objectstack/spec/contracts'; import type { Logger } from '@objectstack/spec/contracts'; /** @@ -69,7 +69,7 @@ export interface RouteResponse { const VALID_ROLES = new Set(['system', 'user', 'assistant', 'tool']); /** - * Validate that `raw` is a well-formed AIMessage. + * Validate that `raw` is a well-formed message. * Returns null on success, or an error string on failure. */ function validateMessage(raw: unknown): string | null { @@ -134,7 +134,7 @@ export function buildAIRoutes( } try { - const result = await aiService.chat(messages as AIMessage[], options as any); + const result = await aiService.chat(messages as ModelMessage[], options as any); return { status: 200, body: result }; } catch (err) { logger.error('[AI Route] /chat error', err instanceof Error ? err : undefined); @@ -169,7 +169,7 @@ export function buildAIRoutes( if (!aiService.streamChat) { return { status: 501, body: { error: 'Streaming is not supported by the configured AI service' } }; } - const events = aiService.streamChat(messages as AIMessage[], options as any); + const events = aiService.streamChat(messages as ModelMessage[], options as any); return { status: 200, stream: true, events }; } catch (err) { logger.error('[AI Route] /chat/stream error', err instanceof Error ? err : undefined); @@ -312,7 +312,7 @@ export function buildAIRoutes( } } - const conversation = await conversationService.addMessage(id, message as AIMessage); + const conversation = await conversationService.addMessage(id, message as ModelMessage); return { status: 200, body: conversation }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); diff --git a/packages/services/service-ai/src/tools/index.ts b/packages/services/service-ai/src/tools/index.ts index 5d7d54eb27..45dfc86058 100644 --- a/packages/services/service-ai/src/tools/index.ts +++ b/packages/services/service-ai/src/tools/index.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. export { ToolRegistry } from './tool-registry.js'; -export type { ToolHandler } from './tool-registry.js'; +export type { ToolHandler, ToolExecutionResult } from './tool-registry.js'; export { registerDataTools, DATA_TOOL_DEFINITIONS } from './data-tools.js'; export type { DataToolContext } from './data-tools.js'; diff --git a/packages/services/service-ai/src/tools/tool-registry.ts b/packages/services/service-ai/src/tools/tool-registry.ts index db26019a58..74686ea4d8 100644 --- a/packages/services/service-ai/src/tools/tool-registry.ts +++ b/packages/services/service-ai/src/tools/tool-registry.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type { AIToolDefinition, AIToolCall, AIToolResult } from '@objectstack/spec/contracts'; +import type { AIToolDefinition, ToolCallPart, ToolResultPart } from '@objectstack/spec/contracts'; /** * Handler function for a registered tool. @@ -9,6 +9,14 @@ import type { AIToolDefinition, AIToolCall, AIToolResult } from '@objectstack/sp */ export type ToolHandler = (args: Record) => Promise | string; +/** + * Extended ToolResultPart that carries an `isError` flag for internal + * error-tracking in the tool-call loop. + */ +export interface ToolExecutionResult extends ToolResultPart { + isError?: boolean; +} + /** * ToolRegistry — Central registry for AI-callable tools. * @@ -72,30 +80,45 @@ export class ToolRegistry { /** * Execute a tool call and return the result. */ - async execute(toolCall: AIToolCall): Promise { - const handler = this.handlers.get(toolCall.name); + async execute(toolCall: ToolCallPart): Promise { + const handler = this.handlers.get(toolCall.toolName); if (!handler) { return { - toolCallId: toolCall.id, - content: `Tool "${toolCall.name}" is not registered`, + type: 'tool-result', + toolCallId: toolCall.toolCallId, + toolName: toolCall.toolName, + output: { type: 'text', value: `Tool "${toolCall.toolName}" is not registered` }, isError: true, }; } try { - const args: Record = JSON.parse(toolCall.arguments); + const args = typeof toolCall.input === 'string' + ? JSON.parse(toolCall.input) + : (toolCall.input as Record) ?? {}; const content = await handler(args); - return { toolCallId: toolCall.id, content }; + return { + type: 'tool-result', + toolCallId: toolCall.toolCallId, + toolName: toolCall.toolName, + output: { type: 'text', value: content }, + }; } catch (err) { const message = err instanceof Error ? err.message : String(err); - return { toolCallId: toolCall.id, content: message, isError: true }; + return { + type: 'tool-result', + toolCallId: toolCall.toolCallId, + toolName: toolCall.toolName, + output: { type: 'text', value: message }, + isError: true, + }; } } /** * Execute multiple tool calls in parallel. */ - async executeAll(toolCalls: AIToolCall[]): Promise { + async executeAll(toolCalls: ToolCallPart[]): Promise { return Promise.all(toolCalls.map(tc => this.execute(tc))); } diff --git a/packages/spec/package.json b/packages/spec/package.json index 1aed395b81..fee1da8310 100644 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -123,6 +123,7 @@ "vitest": "^4.1.2" }, "dependencies": { + "ai": "^6.0.142", "zod": "^4.3.6" }, "engines": { diff --git a/packages/spec/src/api/plugin-rest-api.test.ts b/packages/spec/src/api/plugin-rest-api.test.ts index 446218014b..aed50bea57 100644 --- a/packages/spec/src/api/plugin-rest-api.test.ts +++ b/packages/spec/src/api/plugin-rest-api.test.ts @@ -571,13 +571,10 @@ describe('plugin-rest-api.zod', () => { expect(DEFAULT_AI_ROUTES.service).toBe('ai'); expect(DEFAULT_AI_ROUTES.category).toBe('ai'); expect(DEFAULT_AI_ROUTES.methods).toContain('aiNlq'); - expect(DEFAULT_AI_ROUTES.methods).toContain('aiChat'); + // aiChat removed — wire protocol aligned with Vercel AI SDK expect(DEFAULT_AI_ROUTES.methods).toContain('aiSuggest'); expect(DEFAULT_AI_ROUTES.methods).toContain('aiInsights'); - expect(DEFAULT_AI_ROUTES.endpoints).toHaveLength(4); - // AI endpoints should have extended timeouts - const chatEndpoint = DEFAULT_AI_ROUTES.endpoints?.find(e => e.handler === 'aiChat'); - expect(chatEndpoint?.timeout).toBe(60000); + expect(DEFAULT_AI_ROUTES.endpoints).toHaveLength(3); }); it('should validate DEFAULT_I18N_ROUTES', () => { diff --git a/packages/spec/src/api/plugin-rest-api.zod.ts b/packages/spec/src/api/plugin-rest-api.zod.ts index d80d53cf06..f2c4bca65f 100644 --- a/packages/spec/src/api/plugin-rest-api.zod.ts +++ b/packages/spec/src/api/plugin-rest-api.zod.ts @@ -1398,7 +1398,7 @@ export const DEFAULT_AI_ROUTES: RestApiRouteRegistration = { prefix: '/api/v1/ai', service: 'ai', category: 'ai', - methods: ['aiNlq', 'aiChat', 'aiSuggest', 'aiInsights'], + methods: ['aiNlq', 'aiSuggest', 'aiInsights'], authRequired: true, endpoints: [ { @@ -1415,20 +1415,8 @@ export const DEFAULT_AI_ROUTES: RestApiRouteRegistration = { timeout: 30000, cacheable: false, }, - { - method: 'POST', - path: '/chat', - handler: 'aiChat', - category: 'ai', - public: false, - summary: 'AI chat interaction', - description: 'Sends a message to the AI assistant and receives a response', - tags: ['AI'], - requestSchema: 'AiChatRequestSchema', - responseSchema: 'AiChatResponseSchema', - timeout: 60000, - cacheable: false, - }, + // AI chat route removed — wire protocol aligned with Vercel AI SDK. + // The chat endpoint should use Vercel's `toDataStreamResponse()` directly. { method: 'POST', path: '/suggest', diff --git a/packages/spec/src/api/protocol.test.ts b/packages/spec/src/api/protocol.test.ts index 4b0d38a0f6..193e2e4c26 100644 --- a/packages/spec/src/api/protocol.test.ts +++ b/packages/spec/src/api/protocol.test.ts @@ -54,8 +54,6 @@ import { // AI AiNlqRequestSchema, AiNlqResponseSchema, - AiChatRequestSchema, - AiChatResponseSchema, AiSuggestRequestSchema, AiSuggestResponseSchema, AiInsightsRequestSchema, @@ -285,11 +283,7 @@ describe('ObjectStack Protocol', () => { query: { object: 'task', where: { status: 'open' } }, explanation: 'Find all tasks with open status', confidence: 0.92, }).success).toBe(true); - expect(AiChatRequestSchema.safeParse({ message: 'How many tasks are overdue?', conversationId: 'c1' }).success).toBe(true); - expect(AiChatResponseSchema.safeParse({ - message: 'There are 5 overdue tasks.', conversationId: 'c1', - actions: [{ type: 'navigate', label: 'View overdue tasks' }], - }).success).toBe(true); + // AiChatRequestSchema/AiChatResponseSchema removed — chat protocol aligned with Vercel AI SDK expect(AiSuggestRequestSchema.safeParse({ object: 'task', field: 'priority', partial: 'hi' }).success).toBe(true); expect(AiSuggestResponseSchema.safeParse({ suggestions: [{ value: 'high', label: 'High', confidence: 0.95, reason: 'Matches partial input' }], diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 8730324b48..6aa4db176c 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -851,21 +851,10 @@ export const AiNlqResponseSchema = z.object({ suggestions: z.array(z.string()).optional().describe('Suggested follow-up queries'), }); -export const AiChatRequestSchema = z.object({ - message: z.string().describe('User message'), - conversationId: z.string().optional().describe('Conversation ID for context'), - context: z.record(z.string(), z.unknown()).optional().describe('Additional context data'), -}); - -export const AiChatResponseSchema = z.object({ - message: z.string().describe('Assistant response message'), - conversationId: z.string().describe('Conversation ID'), - actions: z.array(z.object({ - type: z.string().describe('Action type'), - label: z.string().describe('Action display label'), - data: z.record(z.string(), z.unknown()).optional().describe('Action data'), - })).optional().describe('Suggested actions'), -}); +// AiChatRequestSchema and AiChatResponseSchema have been removed. +// The AI chat wire protocol is now fully aligned with the Vercel AI SDK (`ai`). +// Frontend consumers should use `@ai-sdk/react/useChat` directly. +// See: https://ai-sdk.dev/docs export const AiSuggestRequestSchema = z.object({ object: z.string().describe('Object name for context'), @@ -1264,8 +1253,6 @@ export type MarkAllNotificationsReadResponse = z.infer; export type AiNlqResponse = z.infer; -export type AiChatRequest = z.input; -export type AiChatResponse = z.infer; export type AiSuggestRequest = z.input; export type AiSuggestResponse = z.infer; export type AiInsightsRequest = z.input; @@ -1416,9 +1403,8 @@ export interface ObjectStackProtocol { markNotificationsRead?(request: MarkNotificationsReadRequest): Promise; markAllNotificationsRead?(request: MarkAllNotificationsReadRequest): Promise; - // AI (optional) + // AI (optional — chat is now handled by Vercel AI SDK wire protocol) aiNlq?(request: AiNlqRequest): Promise; - aiChat?(request: AiChatRequest): Promise; aiSuggest?(request: AiSuggestRequest): Promise; aiInsights?(request: AiInsightsRequest): Promise; diff --git a/packages/spec/src/contracts/ai-service.test.ts b/packages/spec/src/contracts/ai-service.test.ts index 734034ef33..315538df65 100644 --- a/packages/spec/src/contracts/ai-service.test.ts +++ b/packages/spec/src/contracts/ai-service.test.ts @@ -1,16 +1,19 @@ import { describe, it, expect } from 'vitest'; import type { IAIService, - AIMessage, AIResult, AIToolDefinition, - AIToolCall, - AIToolResult, AIRequestOptions, - AIStreamEvent, AIConversation, IAIConversationService, } from './ai-service'; +import type { + ModelMessage, + ToolCallPart, + ToolResultPart, + TextStreamPart, + ToolSet, +} from 'ai'; describe('AI Service Contract', () => { it('should allow a minimal IAIService implementation with required methods', () => { @@ -35,12 +38,15 @@ describe('AI Service Contract', () => { expect(service.listModels).toBeDefined(); }); - it('should generate a chat completion', async () => { + it('should generate a chat completion with ModelMessage', async () => { const service: IAIService = { chat: async (messages): Promise => { const lastMessage = messages[messages.length - 1]; + const text = typeof lastMessage.content === 'string' + ? lastMessage.content + : 'complex content'; return { - content: `Echo: ${lastMessage.content}`, + content: `Echo: ${text}`, model: 'test-model', usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 }, }; @@ -48,7 +54,7 @@ describe('AI Service Contract', () => { complete: async () => ({ content: '' }), }; - const messages: AIMessage[] = [ + const messages: ModelMessage[] = [ { role: 'system', content: 'You are a helpful assistant.' }, { role: 'user', content: 'Hello' }, ]; @@ -104,75 +110,76 @@ describe('AI Service Contract', () => { }); // ----------------------------------------------------------------------- - // Tool Calling Types + // Vercel AI SDK Type Integration // ----------------------------------------------------------------------- - describe('Tool Calling Types', () => { - it('should construct valid AIToolDefinition values', () => { - const tool: AIToolDefinition = { - name: 'get_weather', - description: 'Get current weather for a location', - parameters: { - type: 'object', - properties: { location: { type: 'string' } }, - required: ['location'], - }, + describe('Vercel AI SDK Type Integration', () => { + it('should accept Vercel ModelMessage types', () => { + const systemMsg: ModelMessage = { + role: 'system', + content: 'You are helpful.', }; + expect(systemMsg.role).toBe('system'); - expect(tool.name).toBe('get_weather'); - expect(tool.description).toBe('Get current weather for a location'); - expect(tool.parameters).toBeDefined(); - }); - - it('should construct valid AIToolCall values', () => { - const call: AIToolCall = { - id: 'call_abc123', - name: 'get_weather', - arguments: JSON.stringify({ location: 'London' }), + const userMsg: ModelMessage = { + role: 'user', + content: 'Hello', }; + expect(userMsg.role).toBe('user'); - expect(call.id).toBe('call_abc123'); - expect(JSON.parse(call.arguments)).toEqual({ location: 'London' }); + const assistantMsg: ModelMessage = { + role: 'assistant', + content: 'Hi there!', + }; + expect(assistantMsg.role).toBe('assistant'); }); - it('should construct valid AIToolResult values', () => { - const result: AIToolResult = { - toolCallId: 'call_abc123', - content: '{"temp": 18, "unit": "celsius"}', + it('should accept assistant messages with tool call parts', () => { + const toolCallPart: ToolCallPart = { + type: 'tool-call', + toolCallId: 'call_1', + toolName: 'get_weather', + input: { location: 'Paris' }, }; - expect(result.toolCallId).toBe('call_abc123'); - expect(result.isError).toBeUndefined(); + expect(toolCallPart.type).toBe('tool-call'); + expect(toolCallPart.toolCallId).toBe('call_1'); + expect(toolCallPart.toolName).toBe('get_weather'); - const errorResult: AIToolResult = { - toolCallId: 'call_xyz', - content: 'Tool not found', - isError: true, + const assistantMsg: ModelMessage = { + role: 'assistant', + content: [toolCallPart], }; - expect(errorResult.isError).toBe(true); + expect(assistantMsg.role).toBe('assistant'); }); - it('should support AIMessageWithTools for tool conversations', () => { - const assistantMsg: AIMessage = { - role: 'assistant', - content: '', - toolCalls: [ - { id: 'call_1', name: 'get_weather', arguments: '{"location":"Paris"}' }, - ], + it('should accept tool result parts', () => { + const toolResult: ToolResultPart = { + type: 'tool-result', + toolCallId: 'call_1', + toolName: 'get_weather', + output: { type: 'text', value: '{"temp": 22}' }, }; - expect(assistantMsg.toolCalls).toHaveLength(1); - expect(assistantMsg.toolCalls![0].name).toBe('get_weather'); + expect(toolResult.type).toBe('tool-result'); + expect(toolResult.toolCallId).toBe('call_1'); + }); - const toolMsg: AIMessage = { - role: 'tool', - content: '{"temp": 22}', - toolCallId: 'call_1', + it('should construct valid AIToolDefinition values', () => { + const tool: AIToolDefinition = { + name: 'get_weather', + description: 'Get current weather for a location', + parameters: { + type: 'object', + properties: { location: { type: 'string' } }, + required: ['location'], + }, }; - expect(toolMsg.role).toBe('tool'); - expect(toolMsg.toolCallId).toBe('call_1'); + expect(tool.name).toBe('get_weather'); + expect(tool.description).toBe('Get current weather for a location'); + expect(tool.parameters).toBeDefined(); }); it('should support tool options on AIRequestOptions', () => { @@ -195,8 +202,7 @@ describe('AI Service Contract', () => { it('should support non-streaming tool calling via chat()', async () => { const service: IAIService = { - chat: async (messages, options?) => { - // Simulate tool call detection + chat: async (_messages, options?) => { if (options?.tools && options.tools.length > 0) { return { content: 'Using tools', model: 'gpt-4' }; } @@ -219,7 +225,7 @@ describe('AI Service Contract', () => { }); // ----------------------------------------------------------------------- - // Streaming – streamChat + // Streaming – streamChat (Vercel TextStreamPart) // ----------------------------------------------------------------------- describe('streamChat', () => { @@ -227,62 +233,32 @@ describe('AI Service Contract', () => { const service: IAIService = { chat: async () => ({ content: '' }), complete: async () => ({ content: '' }), - async *streamChat(_messages, _options?) { - yield { type: 'text-delta', textDelta: 'Hello' } satisfies AIStreamEvent; - yield { type: 'finish', result: { content: 'Hello' } } satisfies AIStreamEvent; + async *streamChat() { + yield { type: 'text-delta' as const, id: '1', text: 'Hello' } as TextStreamPart; + yield { type: 'finish' as const, finishReason: 'stop' as const, usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }, request: {}, response: { id: '', timestamp: new Date(), modelId: '', headers: {} }, providerMetadata: undefined, warnings: undefined, reasoning: undefined, files: undefined, sources: undefined, isContinued: false } as unknown as TextStreamPart; }, }; expect(service.streamChat).toBeDefined(); }); - it('should stream text-delta events', async () => { + it('should stream text-delta events (Vercel format)', async () => { const service: IAIService = { chat: async () => ({ content: '' }), complete: async () => ({ content: '' }), async *streamChat() { - yield { type: 'text-delta' as const, textDelta: 'Hello' }; - yield { type: 'text-delta' as const, textDelta: ' world' }; - yield { type: 'finish' as const, result: { content: 'Hello world' } }; + yield { type: 'text-delta', id: '1', text: 'Hello' } as TextStreamPart; + yield { type: 'text-delta', id: '1', text: ' world' } as TextStreamPart; }, }; - const events: AIStreamEvent[] = []; + const events: TextStreamPart[] = []; for await (const event of service.streamChat!([], {})) { events.push(event); } - expect(events).toHaveLength(3); + expect(events).toHaveLength(2); expect(events[0].type).toBe('text-delta'); - expect(events[0].textDelta).toBe('Hello'); - expect(events[2].type).toBe('finish'); - expect(events[2].result?.content).toBe('Hello world'); - }); - - it('should stream tool-call events', async () => { - const service: IAIService = { - chat: async () => ({ content: '' }), - complete: async () => ({ content: '' }), - async *streamChat() { - yield { - type: 'tool-call-delta' as const, - toolCall: { id: 'call_1', name: 'get_weather' }, - }; - yield { - type: 'tool-call' as const, - toolCall: { id: 'call_1', name: 'get_weather', arguments: '{"location":"NYC"}' }, - }; - yield { type: 'finish' as const, result: { content: '' } }; - }, - }; - - const events: AIStreamEvent[] = []; - for await (const event of service.streamChat!([], {})) { - events.push(event); - } - - expect(events[0].type).toBe('tool-call-delta'); - expect(events[1].toolCall?.arguments).toBe('{"location":"NYC"}'); }); it('should stream error events', async () => { @@ -290,17 +266,16 @@ describe('AI Service Contract', () => { chat: async () => ({ content: '' }), complete: async () => ({ content: '' }), async *streamChat() { - yield { type: 'error' as const, error: 'Rate limit exceeded' }; + yield { type: 'error', error: 'Rate limit exceeded' } as TextStreamPart; }, }; - const events: AIStreamEvent[] = []; + const events: TextStreamPart[] = []; for await (const event of service.streamChat!([], {})) { events.push(event); } expect(events[0].type).toBe('error'); - expect(events[0].error).toBe('Rate limit exceeded'); }); }); @@ -403,7 +378,7 @@ describe('AI Service Contract', () => { expect(limited).toHaveLength(1); }); - it('should add messages to a conversation', async () => { + it('should add Vercel ModelMessage to a conversation', async () => { const svc = createMockConversationService(); const conv = await svc.create({ title: 'Message Test' }); @@ -413,7 +388,6 @@ describe('AI Service Contract', () => { }); expect(updated.messages).toHaveLength(1); - expect(updated.messages[0].content).toBe('Hello!'); const updated2 = await svc.addMessage(conv.id, { role: 'assistant', diff --git a/packages/spec/src/contracts/ai-service.ts b/packages/spec/src/contracts/ai-service.ts index 15455471af..d2763a2260 100644 --- a/packages/spec/src/contracts/ai-service.ts +++ b/packages/spec/src/contracts/ai-service.ts @@ -11,27 +11,90 @@ * not on concrete AI/LLM provider implementations. * * Aligned with CoreServiceName 'ai' in core-services.zod.ts. + * + * ## Vercel AI SDK Alignment + * + * Message, tool-call, and streaming types are re-exported directly from the + * Vercel AI SDK (`ai`) so that ObjectStack's wire protocol is fully aligned + * with the ecosystem used by `@ai-sdk/react/useChat` on the frontend. + * + * - `ModelMessage` replaces the former custom `AIMessage` + * - `ToolCallPart` replaces `AIToolCall` + * - `ToolResultPart` replaces `AIToolResult` + * - `TextStreamPart` replaces `AIStreamEvent` */ +// --------------------------------------------------------------------------- +// Re-exports from Vercel AI SDK (canonical types) +// --------------------------------------------------------------------------- + +export type { + ModelMessage, + SystemModelMessage, + UserModelMessage, + AssistantModelMessage, + ToolModelMessage, + ToolCallPart, + ToolResultPart, + TextStreamPart, + ToolSet, + FinishReason, +} from 'ai'; + +// --------------------------------------------------------------------------- +// Deprecated aliases — kept for backward compatibility +// --------------------------------------------------------------------------- + +import type { + ModelMessage, + ToolCallPart, + ToolResultPart, + TextStreamPart, + ToolSet, +} from 'ai'; + /** - * A chat message in a conversation. + * @deprecated Use `ModelMessage` from `ai` instead. * - * Supports the standard `system`, `user`, and `assistant` roles as well as - * the `tool` role used to return tool execution results to the model. - * Tool-call metadata (`toolCalls`, `toolCallId`) is optional so that plain - * messages remain simple while tool-using conversations can carry the - * necessary context. + * Previously a flat interface with `role`, `content: string`, `toolCalls?`, + * and `toolCallId?`. The Vercel AI SDK uses a discriminated union where each + * role has its own content type. */ -export interface AIMessage { - /** Message role */ - role: 'system' | 'user' | 'assistant' | 'tool'; - /** Message content */ - content: string; - /** Tool calls requested by the assistant (present when role='assistant') */ - toolCalls?: AIToolCall[]; - /** ID of the tool call this message responds to (present when role='tool') */ - toolCallId?: string; -} +export type AIMessage = ModelMessage; + +/** + * @deprecated Use `ToolCallPart` from `ai` instead. + * + * The Vercel type uses `toolCallId` / `toolName` / `input` rather than + * `id` / `name` / `arguments`. + */ +export type AIToolCall = ToolCallPart; + +/** + * @deprecated Use `ToolResultPart` from `ai` instead. + */ +export type AIToolResult = ToolResultPart; + +/** + * @deprecated Use `AIMessage` directly — tool fields are now on the base type. + */ +export type AIMessageWithTools = ModelMessage; + +/** + * @deprecated Use `AIRequestOptions` directly — tool fields are now on the base type. + */ +export type AIRequestOptionsWithTools = AIRequestOptions; + +/** + * @deprecated Use `TextStreamPart` from `ai` instead. + * + * The Vercel AI SDK uses a rich discriminated union for stream parts. + */ +export type AIStreamEvent = TextStreamPart; + +// --------------------------------------------------------------------------- +// ObjectStack-specific types (no Vercel equivalent) +// --------------------------------------------------------------------------- /** * Options for AI completion/chat requests. @@ -63,7 +126,7 @@ export interface AIResult { /** Model used for generation */ model?: string; /** Tool calls requested by the model (present when the model invokes tools) */ - toolCalls?: AIToolCall[]; + toolCalls?: ToolCallPart[]; /** Token usage statistics */ usage?: { promptTokens: number; @@ -77,7 +140,11 @@ export interface AIResult { // --------------------------------------------------------------------------- /** - * Definition of a tool that can be invoked by the AI model + * Definition of a tool that can be invoked by the AI model. + * + * This is an ObjectStack-specific simplified definition used by the + * `IAIService` contract. For the full Vercel AI SDK tool definition, + * use `Tool` from `ai`. */ export interface AIToolDefinition { /** Tool name (snake_case identifier) */ @@ -88,76 +155,21 @@ export interface AIToolDefinition { parameters: Record; } -/** - * A tool call requested by the AI model - */ -export interface AIToolCall { - /** Unique ID for this tool call */ - id: string; - /** Tool name (must match an AIToolDefinition name, snake_case) */ - name: string; - /** JSON-stringified arguments */ - arguments: string; -} - -/** - * Result returned after executing a tool call - */ -export interface AIToolResult { - /** Tool call ID this result corresponds to */ - toolCallId: string; - /** Tool output content */ - content: string; - /** Whether the tool execution errored */ - isError?: boolean; -} - -// --------------------------------------------------------------------------- -// Extended message & request types (backward-compatible aliases) -// --------------------------------------------------------------------------- - -/** - * @deprecated Use {@link AIMessage} directly — tool fields are now on the base type. - */ -export type AIMessageWithTools = AIMessage; - -/** - * @deprecated Use {@link AIRequestOptions} directly — tool fields are now on the base type. - */ -export type AIRequestOptionsWithTools = AIRequestOptions; - -// --------------------------------------------------------------------------- -// Streaming Protocol -// --------------------------------------------------------------------------- - -/** - * A single event emitted during a streaming AI response - */ -export interface AIStreamEvent { - /** Event type */ - type: 'text-delta' | 'tool-call-delta' | 'tool-call' | 'finish' | 'error'; - /** Text content delta (for type='text-delta') */ - textDelta?: string; - /** Tool call info (for type='tool-call-delta' or 'tool-call') */ - toolCall?: Partial; - /** Final result (for type='finish') */ - result?: AIResult; - /** Error message (for type='error') */ - error?: string; -} - // --------------------------------------------------------------------------- // IAIService // --------------------------------------------------------------------------- export interface IAIService { /** - * Generate a chat completion from a conversation - * @param messages - Array of conversation messages + * Generate a chat completion from a conversation. + * + * Accepts Vercel AI SDK `ModelMessage[]` for full ecosystem alignment. + * + * @param messages - Array of conversation messages (Vercel `ModelMessage`) * @param options - Optional request configuration * @returns AI-generated response */ - chat(messages: AIMessage[], options?: AIRequestOptions): Promise; + chat(messages: ModelMessage[], options?: AIRequestOptions): Promise; /** * Generate a text completion from a prompt @@ -182,12 +194,13 @@ export interface IAIService { listModels?(): Promise; /** - * Stream a chat completion as an async iterable of events - * @param messages - Array of conversation messages + * Stream a chat completion as an async iterable of Vercel AI SDK stream parts. + * + * @param messages - Array of conversation messages (Vercel `ModelMessage`) * @param options - Optional request configuration (supports tool definitions) - * @returns Async iterable of stream events + * @returns Async iterable of `TextStreamPart` events */ - streamChat?(messages: AIMessage[], options?: AIRequestOptions): AsyncIterable; + streamChat?(messages: ModelMessage[], options?: AIRequestOptions): AsyncIterable>; /** * Chat with automatic tool call resolution. @@ -197,11 +210,11 @@ export interface IAIService { * repeats until the model returns a final text response or the * maximum number of iterations is reached. * - * @param messages - Conversation messages + * @param messages - Conversation messages (Vercel `ModelMessage`) * @param options - Request options (tools are auto-injected from the registry) * @returns Final AI result after all tool calls have been resolved */ - chatWithTools?(messages: AIMessage[], options?: ChatWithToolsOptions): Promise; + chatWithTools?(messages: ModelMessage[], options?: ChatWithToolsOptions): Promise; } /** @@ -217,7 +230,7 @@ export interface ChatWithToolsOptions extends AIRequestOptions { * Return `'continue'` (default) to feed the error back to the model, * or `'abort'` to immediately stop the tool call loop. */ - onToolError?: (toolCall: AIToolCall, error: string) => 'continue' | 'abort'; + onToolError?: (toolCall: ToolCallPart, error: string) => 'continue' | 'abort'; } // --------------------------------------------------------------------------- @@ -237,7 +250,7 @@ export interface AIConversation { /** User who owns the conversation */ userId?: string; /** Messages in the conversation */ - messages: AIMessage[]; + messages: ModelMessage[]; /** Creation timestamp (ISO 8601) */ createdAt: string; /** Last update timestamp (ISO 8601) */ @@ -286,10 +299,10 @@ export interface IAIConversationService { /** * Add a message to a conversation * @param conversationId - Target conversation ID - * @param message - Message to append + * @param message - Message to append (Vercel `ModelMessage`) * @returns The updated conversation */ - addMessage(conversationId: string, message: AIMessage): Promise; + addMessage(conversationId: string, message: ModelMessage): Promise; /** * Delete a conversation diff --git a/packages/spec/src/contracts/llm-adapter.test.ts b/packages/spec/src/contracts/llm-adapter.test.ts index e2ce3bfda3..005377a655 100644 --- a/packages/spec/src/contracts/llm-adapter.test.ts +++ b/packages/spec/src/contracts/llm-adapter.test.ts @@ -5,17 +5,20 @@ import type { LLMAdapter, } from './llm-adapter'; import type { - AIMessage, AIRequestOptions, AIResult, - AIStreamEvent, } from './ai-service'; +import type { + ModelMessage, + TextStreamPart, + ToolSet, +} from 'ai'; describe('LLM Adapter Contract', () => { it('should allow a minimal LLMAdapter implementation with required methods', () => { const adapter: LLMAdapter = { name: 'test', - chat: async (_messages: AIMessage[], _options?: AIRequestOptions): Promise => ({ + chat: async (_messages: ModelMessage[], _options?: AIRequestOptions): Promise => ({ content: 'hello', }), complete: async (_prompt: string, _options?: AIRequestOptions): Promise => ({ @@ -33,9 +36,8 @@ describe('LLM Adapter Contract', () => { name: 'full', chat: async () => ({ content: '' }), complete: async () => ({ content: '' }), - async *streamChat(_messages: AIMessage[], _options?: AIRequestOptions): AsyncIterable { - yield { type: 'text-delta', textDelta: 'hi' }; - yield { type: 'finish', result: { content: 'hi' } }; + async *streamChat(_messages: ModelMessage[], _options?: AIRequestOptions): AsyncIterable> { + yield { type: 'text-delta', id: '1', text: 'hi' } as TextStreamPart; }, embed: async (input: string | string[]) => { const texts = Array.isArray(input) ? input : [input]; @@ -50,12 +52,13 @@ describe('LLM Adapter Contract', () => { expect(adapter.listModels).toBeDefined(); }); - it('should generate a chat completion', async () => { + it('should generate a chat completion with ModelMessage', async () => { const adapter: LLMAdapter = { name: 'echo', chat: async (messages) => { const last = messages[messages.length - 1]; - return { content: `Echo: ${last.content}`, model: 'echo' }; + const text = typeof last.content === 'string' ? last.content : 'complex'; + return { content: `Echo: ${text}`, model: 'echo' }; }, complete: async () => ({ content: '' }), }; @@ -79,26 +82,24 @@ describe('LLM Adapter Contract', () => { expect(result.content).toBe('Done: Tell me'); }); - it('should stream chat events', async () => { + it('should stream chat events (Vercel TextStreamPart)', async () => { const adapter: LLMAdapter = { name: 'streamer', chat: async () => ({ content: '' }), complete: async () => ({ content: '' }), async *streamChat() { - yield { type: 'text-delta' as const, textDelta: 'Hello' }; - yield { type: 'text-delta' as const, textDelta: ' world' }; - yield { type: 'finish' as const, result: { content: 'Hello world' } }; + yield { type: 'text-delta' as const, id: '1', text: 'Hello' } as TextStreamPart; + yield { type: 'text-delta' as const, id: '1', text: ' world' } as TextStreamPart; }, }; - const events: AIStreamEvent[] = []; + const events: TextStreamPart[] = []; for await (const event of adapter.streamChat!([], {})) { events.push(event); } - expect(events).toHaveLength(3); - expect(events[0].textDelta).toBe('Hello'); - expect(events[2].type).toBe('finish'); + expect(events).toHaveLength(2); + expect(events[0].type).toBe('text-delta'); }); it('should generate embeddings', async () => { diff --git a/packages/spec/src/contracts/llm-adapter.ts b/packages/spec/src/contracts/llm-adapter.ts index dd212105f9..29927f2cfa 100644 --- a/packages/spec/src/contracts/llm-adapter.ts +++ b/packages/spec/src/contracts/llm-adapter.ts @@ -16,10 +16,14 @@ */ import type { - AIMessage, + ModelMessage, + TextStreamPart, + ToolSet, +} from 'ai'; + +import type { AIRequestOptions, AIResult, - AIStreamEvent, } from './ai-service.js'; export interface LLMAdapter { @@ -28,10 +32,10 @@ export interface LLMAdapter { /** * Generate a chat completion. - * @param messages - Conversation messages + * @param messages - Conversation messages (Vercel `ModelMessage`) * @param options - Request configuration (includes tool definitions) */ - chat(messages: AIMessage[], options?: AIRequestOptions): Promise; + chat(messages: ModelMessage[], options?: AIRequestOptions): Promise; /** * Generate a text completion from a single prompt. @@ -41,10 +45,10 @@ export interface LLMAdapter { complete(prompt: string, options?: AIRequestOptions): Promise; /** - * Stream a chat completion as an async iterable of events. + * Stream a chat completion as an async iterable of Vercel AI SDK stream parts. * Implementations that do not support streaming may omit this method. */ - streamChat?(messages: AIMessage[], options?: AIRequestOptions): AsyncIterable; + streamChat?(messages: ModelMessage[], options?: AIRequestOptions): AsyncIterable>; /** * Generate embedding vectors. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 45fe76072f..c9bb30bfb7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1220,6 +1220,9 @@ importers: packages/spec: dependencies: + ai: + specifier: ^6.0.142 + version: 6.0.142(zod@4.3.6) zod: specifier: ^4.3.6 version: 4.3.6 @@ -1264,6 +1267,22 @@ importers: packages: + '@ai-sdk/gateway@3.0.84': + resolution: {integrity: sha512-RnUw6UNvkaw9MEaJU9cIjA+WBP+ZR5+M/9nfbfJHcGKtTbcWXijJuYKx9nYRnm+qU+iiakb0XvQA/vvho6lTsw==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider-utils@4.0.21': + resolution: {integrity: sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider@3.0.8': + resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==} + engines: {node: '>=18'} + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -3431,6 +3450,10 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + '@vercel/oidc@3.1.0': + resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==} + engines: {node: '>= 20'} + '@vitejs/plugin-react@6.0.1': resolution: {integrity: sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3560,6 +3583,12 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + ai@6.0.142: + resolution: {integrity: sha512-ZoxAsnTL/dFg5WdcwC8QNhKVlLtqwwT3I7p/4i8IJJP+6ZwqF1ljuwMsAsPYYvppZ+RzUxjxxFGb1cbEhNH3dg==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: @@ -4413,6 +4442,10 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + eventsource-parser@3.0.6: + resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + engines: {node: '>=18.0.0'} + expand-template@2.0.3: resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} engines: {node: '>=6'} @@ -5076,6 +5109,9 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -7221,6 +7257,24 @@ packages: snapshots: + '@ai-sdk/gateway@3.0.84(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.21(zod@4.3.6) + '@vercel/oidc': 3.1.0 + zod: 4.3.6 + + '@ai-sdk/provider-utils@4.0.21(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.0.6 + zod: 4.3.6 + + '@ai-sdk/provider@3.0.8': + dependencies: + json-schema: 0.4.0 + '@alloc/quick-lru@5.2.0': {} '@azu/format-text@1.0.2': {} @@ -9371,6 +9425,8 @@ snapshots: '@ungap/structured-clone@1.3.0': {} + '@vercel/oidc@3.1.0': {} + '@vitejs/plugin-react@6.0.1(vite@8.0.3(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.7 @@ -9526,6 +9582,14 @@ snapshots: agent-base@7.1.4: {} + ai@6.0.142(zod@4.3.6): + dependencies: + '@ai-sdk/gateway': 3.0.84(zod@4.3.6) + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.21(zod@4.3.6) + '@opentelemetry/api': 1.9.0 + zod: 4.3.6 + ajv-formats@3.0.1(ajv@8.18.0): optionalDependencies: ajv: 8.18.0 @@ -10248,6 +10312,8 @@ snapshots: etag@1.8.1: {} + eventsource-parser@3.0.6: {} + expand-template@2.0.3: {} expect-type@1.3.0: {} @@ -11032,6 +11098,8 @@ snapshots: json-schema-traverse@1.0.0: {} + json-schema@0.4.0: {} + json5@2.2.3: {} jsonc-parser@3.3.1: {}