diff --git a/.gitignore b/.gitignore index 8535963..34831cf 100644 --- a/.gitignore +++ b/.gitignore @@ -144,3 +144,6 @@ apps/docs/api/ # TypeDoc output docs/ + +coverage/ +tsconfig.tsbuildinfo diff --git a/package-lock.json b/package-lock.json index de87443..7bb51e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "symbiont-sdk-js", - "version": "1.13.0", + "version": "1.14.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "symbiont-sdk-js", - "version": "1.13.0", + "version": "1.14.3", "license": "Apache-2.0", "workspaces": [ "packages/*", @@ -12684,7 +12684,7 @@ }, "packages/agent": { "name": "symbi-agent", - "version": "1.13.0", + "version": "1.14.3", "dependencies": { "agentpin": "^0.2.0", "symbi-core": "^1.0.0", @@ -12774,7 +12774,7 @@ }, "packages/core": { "name": "symbi-core", - "version": "1.13.0", + "version": "1.14.3", "dependencies": { "@types/express": "^4.17.21", "@types/jsonwebtoken": "^9.0.10", @@ -12824,7 +12824,7 @@ }, "packages/mcp": { "name": "symbi-mcp", - "version": "1.13.0", + "version": "1.14.3", "dependencies": { "symbi-core": "^1.0.0", "symbi-types": "^1.0.0", @@ -12902,6 +12902,7 @@ "packages/tool-review": { "name": "symbi-tool-review", "version": "1.13.0", + "deprecated": "Targets the hosted Symbiont Tool Review API, which is not part of the open-source Symbiont runtime. Unmaintained for OSS use as of 1.14.3.", "dependencies": { "symbi-core": "^1.0.0", "symbi-types": "^1.0.0", diff --git a/package.json b/package.json index 4ec6bb3..6aa6638 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "symbiont-sdk-js", - "version": "1.13.0", + "version": "1.14.3", "description": "Symbiont JavaScript SDK - Monorepo", "workspaces": [ "packages/*", diff --git a/packages/agent/package.json b/packages/agent/package.json index 2c45bec..4e7eb11 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "symbi-agent", - "version": "1.13.0", + "version": "1.14.3", "description": "Agent management and execution for Symbiont SDK", "main": "dist/index.js", "module": "dist/index.esm.js", diff --git a/packages/agent/src/AgentClient.ts b/packages/agent/src/AgentClient.ts index 7f901b2..e4381d5 100644 --- a/packages/agent/src/AgentClient.ts +++ b/packages/agent/src/AgentClient.ts @@ -10,6 +10,95 @@ import { RequestOptions, SymbiontConfig, } from 'symbi-types'; +import { buildRuntimeUrl } from './urlUtils'; + +/** + * Message payload for sending a message to an agent. + */ +export interface SendMessageRequest { + /** Identifier of the sender (agent or user). */ + sender: string; + /** Arbitrary message payload. */ + payload: unknown; + /** Optional time-to-live in seconds. */ + ttlSeconds?: number; + /** Optional topic/subject for the message. */ + topic?: string; + /** Optional AgentPin JWT for authenticated delivery. */ + agentpinJwt?: string; +} + +/** + * Response returned when a message is accepted for delivery. + */ +export interface SendMessageResponse { + message_id: string; + status: string; +} + +/** + * Response returned when polling an agent's inbox. + */ +export interface ReceiveMessagesResponse { + messages: unknown[]; +} + +/** + * Status of a previously sent message. + */ +export interface MessageStatusResponse { + message_id: string; + status: string; +} + +/** + * Lifecycle state of an agent for heartbeat reporting. + * PascalCase values mirror the runtime's agent state machine. + */ +export type AgentLifecycleState = + | 'Created' + | 'Ready' + | 'Running' + | 'Suspended' + | 'Completed' + | 'Failed' + | 'Terminated' + | (string & {}); + +/** + * Heartbeat payload reporting agent liveness and state. + */ +export interface HeartbeatRequest { + /** Current lifecycle state (PascalCase, e.g. Running/Completed/Failed). */ + state: AgentLifecycleState; + /** Optional arbitrary metadata. */ + metadata?: Record; + /** Optional result of the last run. */ + lastResult?: unknown; + /** Optional AgentPin JWT for authentication. */ + agentpinJwt?: string; +} + +/** + * Event type for run lifecycle events. + */ +export type AgentEventType = + | 'RunStarted' + | 'RunCompleted' + | 'RunFailed' + | (string & {}); + +/** + * Event payload pushed to an agent's event stream. + */ +export interface PushEventRequest { + /** Event type (e.g. RunStarted/RunCompleted/RunFailed). */ + eventType: AgentEventType; + /** Arbitrary event payload. */ + payload: unknown; + /** Optional AgentPin JWT for authentication. */ + agentpinJwt?: string; +} /** * Simple interface to avoid circular dependency with SymbiontClient @@ -128,17 +217,131 @@ export class AgentClient { } /** - * Re-execute a previously completed agent with optional new input - * POST /api/v1/agents/{id}/re-execute + * Send a message to an agent's inbox + * POST /agents/{id}/messages + */ + async sendMessage( + agentId: string, + request: SendMessageRequest + ): Promise { + if (!agentId) { + throw new Error('Agent ID is required'); + } + if (!request) { + throw new Error('Message request is required'); + } + + const body: Record = { + sender: request.sender, + payload: request.payload, + }; + if (request.ttlSeconds !== undefined) { + body.ttl_seconds = request.ttlSeconds; + } + if (request.topic !== undefined) { + body.topic = request.topic; + } + if (request.agentpinJwt !== undefined) { + body.agentpin_jwt = request.agentpinJwt; + } + + return this.makeRequest(`/agents/${agentId}/messages`, { + method: 'POST', + body, + }); + } + + /** + * Receive pending messages for an agent + * GET /agents/{id}/messages + */ + async receiveMessages(agentId: string): Promise { + if (!agentId) { + throw new Error('Agent ID is required'); + } + + return this.makeRequest( + `/agents/${agentId}/messages`, + { + method: 'GET', + } + ); + } + + /** + * Get the delivery status of a previously sent message + * GET /messages/{id}/status + */ + async getMessageStatus(messageId: string): Promise { + if (!messageId) { + throw new Error('Message ID is required'); + } + + return this.makeRequest( + `/messages/${messageId}/status`, + { + method: 'GET', + } + ); + } + + /** + * Report an agent heartbeat + * POST /agents/{id}/heartbeat + */ + async sendHeartbeat( + agentId: string, + request: HeartbeatRequest + ): Promise { + if (!agentId) { + throw new Error('Agent ID is required'); + } + if (!request) { + throw new Error('Heartbeat request is required'); + } + + const body: Record = { + state: request.state, + }; + if (request.metadata !== undefined) { + body.metadata = request.metadata; + } + if (request.lastResult !== undefined) { + body.last_result = request.lastResult; + } + if (request.agentpinJwt !== undefined) { + body.agentpin_jwt = request.agentpinJwt; + } + + await this.makeRequest(`/agents/${agentId}/heartbeat`, { + method: 'POST', + body, + }); + } + + /** + * Push a run lifecycle event for an agent + * POST /agents/{id}/events */ - async reExecuteAgent(agentId: string, input?: unknown): Promise { + async pushEvent(agentId: string, request: PushEventRequest): Promise { if (!agentId) { throw new Error('Agent ID is required'); } + if (!request) { + throw new Error('Event request is required'); + } - return this.makeRequest(`/api/v1/agents/${agentId}/re-execute`, { + const body: Record = { + event_type: request.eventType, + payload: request.payload, + }; + if (request.agentpinJwt !== undefined) { + body.agentpin_jwt = request.agentpinJwt; + } + + await this.makeRequest(`/agents/${agentId}/events`, { method: 'POST', - body: input !== undefined ? { input } : {}, + body, }); } @@ -179,10 +382,10 @@ export class AgentClient { // Get authentication headers from the parent client const authHeaders = await this.client.getAuthHeaders(endpoint); - // Build the full URL + // Build the full URL with exactly one /api/v1 prefix const config = this.client.configuration; const baseUrl = config.runtimeApiUrl; - const fullUrl = `${baseUrl}${endpoint}`; + const fullUrl = buildRuntimeUrl(baseUrl, endpoint); // Prepare headers const headers: Record = { @@ -219,9 +422,12 @@ export class AgentClient { return undefined as T; } - // Parse JSON response - const data = await response.json(); - return data as T; + // Parse JSON response, tolerating empty bodies (e.g. 204 No Content) + const text = await response.text(); + if (!text) { + return undefined as T; + } + return JSON.parse(text) as T; } catch (error) { if (error instanceof Error) { throw new Error(`AgentClient request failed: ${error.message}`); diff --git a/packages/agent/src/ChannelClient.ts b/packages/agent/src/ChannelClient.ts index b553542..f18a980 100644 --- a/packages/agent/src/ChannelClient.ts +++ b/packages/agent/src/ChannelClient.ts @@ -13,6 +13,7 @@ import { AddIdentityMappingRequest, ChannelAuditResponse, } from 'symbi-types'; +import { buildRuntimeUrl } from './urlUtils'; /** * Simple interface to avoid circular dependency with SymbiontClient @@ -178,7 +179,7 @@ export class ChannelClient { const authHeaders = await this.client.getAuthHeaders(endpoint); const config = this.client.configuration; const baseUrl = config.runtimeApiUrl; - const fullUrl = `${baseUrl}${endpoint}`; + const fullUrl = buildRuntimeUrl(baseUrl, endpoint); const headers: Record = { 'Content-Type': 'application/json', diff --git a/packages/agent/src/ScheduleClient.ts b/packages/agent/src/ScheduleClient.ts index d1f22ef..e3164c6 100644 --- a/packages/agent/src/ScheduleClient.ts +++ b/packages/agent/src/ScheduleClient.ts @@ -13,6 +13,7 @@ import { DeleteScheduleResponse, SchedulerHealthResponse, } from 'symbi-types'; +import { buildRuntimeUrl } from './urlUtils'; /** * Simple interface to avoid circular dependency with SymbiontClient @@ -163,7 +164,7 @@ export class ScheduleClient { const authHeaders = await this.client.getAuthHeaders(endpoint); const config = this.client.configuration; const baseUrl = config.runtimeApiUrl; - const fullUrl = `${baseUrl}${endpoint}`; + const fullUrl = buildRuntimeUrl(baseUrl, endpoint); const headers: Record = { 'Content-Type': 'application/json', diff --git a/packages/agent/src/WorkflowClient.ts b/packages/agent/src/WorkflowClient.ts index ff1d6e8..c8a1ef3 100644 --- a/packages/agent/src/WorkflowClient.ts +++ b/packages/agent/src/WorkflowClient.ts @@ -3,6 +3,7 @@ import { SymbiontConfig, WorkflowExecutionRequest, } from 'symbi-types'; +import { buildRuntimeUrl } from './urlUtils'; /** * Simple interface to avoid circular dependency with SymbiontClient @@ -53,7 +54,7 @@ export class WorkflowClient { const authHeaders = await this.client.getAuthHeaders(endpoint); const config = this.client.configuration; const baseUrl = config.runtimeApiUrl; - const fullUrl = `${baseUrl}${endpoint}`; + const fullUrl = buildRuntimeUrl(baseUrl, endpoint); const headers: Record = { 'Content-Type': 'application/json', diff --git a/packages/agent/src/__tests__/AgentClient.integration.test.ts b/packages/agent/src/__tests__/AgentClient.integration.test.ts index 72928c3..6f063db 100644 --- a/packages/agent/src/__tests__/AgentClient.integration.test.ts +++ b/packages/agent/src/__tests__/AgentClient.integration.test.ts @@ -295,4 +295,217 @@ describe('AgentClient Integration Tests', () => { ); }); }); + + describe('/api/v1 normalization', () => { + it('prefixes exactly one /api/v1 onto the request URL', async () => { + const mocks = testEnv.getMocks(); + mocks.fetch.mockResponse('/agents', { + status: 200, + body: [], + }); + + await agentClient.listAgents(); + + const calls = mocks.fetch.getCallsFor('/agents'); + expect(calls).toHaveLength(1); + const url = calls[0].url; + expect(url).toBe('http://localhost:8080/api/v1/agents'); + expect(url.match(/\/api\/v1/g)).toHaveLength(1); + }); + + it('executeAgent targets /api/v1/agents/{id}/execute', async () => { + const mocks = testEnv.getMocks(); + const id = 'agent-xyz'; + mocks.fetch.mockResponse(`/agents/${id}/execute`, { + status: 200, + body: { ok: true }, + }); + + await agentClient.executeAgent(id, { foo: 'bar' }); + + const calls = mocks.fetch.getCallsFor(`/agents/${id}/execute`); + expect(calls).toHaveLength(1); + expect(calls[0].url).toBe( + `http://localhost:8080/api/v1/agents/${id}/execute` + ); + }); + }); + + describe('sendMessage', () => { + const agentId = 'test-agent-id'; + + it('POSTs to /agents/{id}/messages with snake_case body', async () => { + const mocks = testEnv.getMocks(); + mocks.fetch.mockResponse(`/agents/${agentId}/messages`, { + status: 200, + body: { message_id: 'm-1', status: 'queued' }, + }); + + const result = await agentClient.sendMessage(agentId, { + sender: 'agent-a', + payload: { hello: 'world' }, + ttlSeconds: 60, + topic: 'greeting', + agentpinJwt: 'jwt-token', + }); + + expect(result).toEqual({ message_id: 'm-1', status: 'queued' }); + + const calls = mocks.fetch.getCallsFor(`/agents/${agentId}/messages`); + expect(calls).toHaveLength(1); + expect(calls[0].method).toBe('POST'); + expect(calls[0].url).toBe( + `http://localhost:8080/api/v1/agents/${agentId}/messages` + ); + expect(JSON.parse(calls[0].body!)).toEqual({ + sender: 'agent-a', + payload: { hello: 'world' }, + ttl_seconds: 60, + topic: 'greeting', + agentpin_jwt: 'jwt-token', + }); + }); + + it('omits undefined optional fields', async () => { + const mocks = testEnv.getMocks(); + mocks.fetch.mockResponse(`/agents/${agentId}/messages`, { + status: 200, + body: { message_id: 'm-2', status: 'queued' }, + }); + + await agentClient.sendMessage(agentId, { + sender: 'agent-a', + payload: 'plain', + }); + + const calls = mocks.fetch.getCallsFor(`/agents/${agentId}/messages`); + expect(JSON.parse(calls[0].body!)).toEqual({ + sender: 'agent-a', + payload: 'plain', + }); + }); + + it('throws for empty agent ID', async () => { + await expect( + agentClient.sendMessage('', { sender: 's', payload: 'p' }) + ).rejects.toThrow('Agent ID is required'); + }); + }); + + describe('receiveMessages', () => { + const agentId = 'test-agent-id'; + + it('GETs /agents/{id}/messages', async () => { + const mocks = testEnv.getMocks(); + mocks.fetch.mockResponse(`/agents/${agentId}/messages`, { + status: 200, + body: { messages: [{ id: 'm-1' }] }, + }); + + const result = await agentClient.receiveMessages(agentId); + + expect(result).toEqual({ messages: [{ id: 'm-1' }] }); + const calls = mocks.fetch.getCallsFor(`/agents/${agentId}/messages`); + expect(calls[0].method).toBe('GET'); + }); + + it('throws for empty agent ID', async () => { + await expect(agentClient.receiveMessages('')).rejects.toThrow( + 'Agent ID is required' + ); + }); + }); + + describe('getMessageStatus', () => { + const messageId = 'msg-123'; + + it('GETs /messages/{id}/status', async () => { + const mocks = testEnv.getMocks(); + mocks.fetch.mockResponse(`/messages/${messageId}/status`, { + status: 200, + body: { message_id: messageId, status: 'delivered' }, + }); + + const result = await agentClient.getMessageStatus(messageId); + + expect(result).toEqual({ message_id: messageId, status: 'delivered' }); + const calls = mocks.fetch.getCallsFor(`/messages/${messageId}/status`); + expect(calls[0].method).toBe('GET'); + expect(calls[0].url).toBe( + `http://localhost:8080/api/v1/messages/${messageId}/status` + ); + }); + + it('throws for empty message ID', async () => { + await expect(agentClient.getMessageStatus('')).rejects.toThrow( + 'Message ID is required' + ); + }); + }); + + describe('sendHeartbeat', () => { + const agentId = 'test-agent-id'; + + it('POSTs to /agents/{id}/heartbeat with snake_case body', async () => { + const mocks = testEnv.getMocks(); + mocks.fetch.mockResponse(`/agents/${agentId}/heartbeat`, { + status: 204, + body: '', + }); + + await agentClient.sendHeartbeat(agentId, { + state: 'Running', + metadata: { step: 1 }, + lastResult: { ok: true }, + agentpinJwt: 'jwt', + }); + + const calls = mocks.fetch.getCallsFor(`/agents/${agentId}/heartbeat`); + expect(calls[0].method).toBe('POST'); + expect(JSON.parse(calls[0].body!)).toEqual({ + state: 'Running', + metadata: { step: 1 }, + last_result: { ok: true }, + agentpin_jwt: 'jwt', + }); + }); + + it('throws for empty agent ID', async () => { + await expect( + agentClient.sendHeartbeat('', { state: 'Running' }) + ).rejects.toThrow('Agent ID is required'); + }); + }); + + describe('pushEvent', () => { + const agentId = 'test-agent-id'; + + it('POSTs to /agents/{id}/events with snake_case body', async () => { + const mocks = testEnv.getMocks(); + mocks.fetch.mockResponse(`/agents/${agentId}/events`, { + status: 204, + body: '', + }); + + await agentClient.pushEvent(agentId, { + eventType: 'RunCompleted', + payload: { result: 42 }, + agentpinJwt: 'jwt', + }); + + const calls = mocks.fetch.getCallsFor(`/agents/${agentId}/events`); + expect(calls[0].method).toBe('POST'); + expect(JSON.parse(calls[0].body!)).toEqual({ + event_type: 'RunCompleted', + payload: { result: 42 }, + agentpin_jwt: 'jwt', + }); + }); + + it('throws for empty agent ID', async () => { + await expect( + agentClient.pushEvent('', { eventType: 'RunStarted', payload: {} }) + ).rejects.toThrow('Agent ID is required'); + }); + }); }); \ No newline at end of file diff --git a/packages/agent/src/__tests__/urlNormalization.test.ts b/packages/agent/src/__tests__/urlNormalization.test.ts new file mode 100644 index 0000000..da84290 --- /dev/null +++ b/packages/agent/src/__tests__/urlNormalization.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { AgentClient } from '../AgentClient'; +import { buildRuntimeUrl } from '../urlUtils'; +import type { SymbiontConfig } from 'symbi-types'; + +function makeClient(runtimeApiUrl: string) { + const config: SymbiontConfig = { + apiKey: 'test-api-key', + runtimeApiUrl, + validationMode: 'development', + environment: 'development', + }; + return { + getAuthHeaders: async () => ({ Authorization: 'Bearer test' }), + configuration: Object.freeze({ ...config }), + }; +} + +describe('buildRuntimeUrl', () => { + it('adds a single /api/v1 for a bare base URL and bare path', () => { + expect(buildRuntimeUrl('http://localhost:8080', '/agents')).toBe( + 'http://localhost:8080/api/v1/agents' + ); + }); + + it('does not double the prefix when the base URL already ends with /api/v1', () => { + expect(buildRuntimeUrl('http://localhost:8080/api/v1', '/agents')).toBe( + 'http://localhost:8080/api/v1/agents' + ); + }); + + it('does not double the prefix when the endpoint already starts with /api/v1', () => { + expect(buildRuntimeUrl('http://localhost:8080', '/api/v1/agents')).toBe( + 'http://localhost:8080/api/v1/agents' + ); + }); + + it('handles trailing slash on base URL', () => { + expect(buildRuntimeUrl('http://localhost:8080/', '/agents')).toBe( + 'http://localhost:8080/api/v1/agents' + ); + }); + + it('always yields exactly one /api/v1', () => { + const cases: Array<[string, string]> = [ + ['http://localhost:8080', '/agents'], + ['http://localhost:8080/api/v1', '/agents'], + ['http://localhost:8080', '/api/v1/agents'], + ['http://localhost:8080/api/v1/', '/api/v1/agents'], + ]; + for (const [base, endpoint] of cases) { + const url = buildRuntimeUrl(base, endpoint); + expect(url.match(/\/api\/v1/g)).toHaveLength(1); + } + }); +}); + +describe('AgentClient request URL normalization', () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.fn(async () => ({ + ok: true, + status: 200, + statusText: 'OK', + text: async () => JSON.stringify([]), + json: async () => [], + })); + vi.stubGlobal('fetch', fetchSpy); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('listAgents hits /api/v1/agents exactly once', async () => { + const client = new AgentClient(makeClient('http://localhost:8080')); + await client.listAgents(); + + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toBe('http://localhost:8080/api/v1/agents'); + expect(url.match(/\/api\/v1/g)).toHaveLength(1); + }); + + it('executeAgent hits /api/v1/agents/{id}/execute', async () => { + const client = new AgentClient(makeClient('http://localhost:8080')); + await client.executeAgent('a-1', { foo: 'bar' }); + + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toBe('http://localhost:8080/api/v1/agents/a-1/execute'); + }); + + it('does not double-prefix when base URL already includes /api/v1', async () => { + const client = new AgentClient(makeClient('http://localhost:8080/api/v1')); + await client.listAgents(); + + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toBe('http://localhost:8080/api/v1/agents'); + expect(url.match(/\/api\/v1/g)).toHaveLength(1); + }); +}); diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index cda8b5d..c781d42 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -4,6 +4,19 @@ // Export the main AgentClient export { AgentClient } from './AgentClient'; +export type { + SendMessageRequest, + SendMessageResponse, + ReceiveMessagesResponse, + MessageStatusResponse, + AgentLifecycleState, + HeartbeatRequest, + AgentEventType, + PushEventRequest, +} from './AgentClient'; + +// Export URL helper for runtime clients +export { buildRuntimeUrl } from './urlUtils'; // Export the ScheduleClient export { ScheduleClient } from './ScheduleClient'; diff --git a/packages/agent/src/urlUtils.ts b/packages/agent/src/urlUtils.ts new file mode 100644 index 0000000..54d8c7c --- /dev/null +++ b/packages/agent/src/urlUtils.ts @@ -0,0 +1,32 @@ +/** + * URL normalization helpers for Symbiont Runtime API clients. + * + * The runtime serves every route under a single `/api/v1` version segment. + * Base URLs are configured WITHOUT the version segment (e.g. http://localhost:8080), + * and call sites use BARE paths (e.g. `/agents/{id}/execute`). This helper guarantees + * the final URL contains exactly one `/api/v1` prefix regardless of whether the base + * URL already ends with `/api/v1` or the endpoint already starts with `/api/v1`. + */ +export function buildRuntimeUrl( + baseUrl: string | undefined, + endpoint: string +): string { + // Strip trailing slashes from the base URL. + let base = (baseUrl ?? '').replace(/\/+$/, ''); + + // Remove a trailing /api/v1 from the base URL if present. + base = base.replace(/\/api\/v1$/, ''); + + // Ensure the endpoint starts with a single leading slash. + let path = endpoint.startsWith('/') ? endpoint : `/${endpoint}`; + + // Remove a leading /api/v1 from the endpoint if present. + path = path.replace(/^\/api\/v1(?=\/|$)/, ''); + + // Ensure the remaining path still has a leading slash. + if (!path.startsWith('/')) { + path = `/${path}`; + } + + return `${base}/api/v1${path}`; +} diff --git a/packages/core/package.json b/packages/core/package.json index c749503..9ea8ea4 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "symbi-core", - "version": "1.13.0", + "version": "1.14.3", "description": "Core Symbiont SDK functionality and SymbiontClient", "main": "dist/index.js", "module": "dist/index.esm.js", diff --git a/packages/core/src/CommunicationClient.ts b/packages/core/src/CommunicationClient.ts deleted file mode 100644 index a124e39..0000000 --- a/packages/core/src/CommunicationClient.ts +++ /dev/null @@ -1,120 +0,0 @@ -import type { SymbiontConfig } from 'symbi-types'; -import type { RequestOptions } from 'symbi-types'; - -/** - * Minimal interface to avoid circular dependency with SymbiontClient. - */ -interface ClientDependency { - getAuthHeaders(endpoint: string): Promise>; - configuration: Readonly; -} - -export interface CommunicationRule { - id?: string; - fromAgent: string; - toAgent: string; - action: string; - effect: 'allow' | 'deny'; - reason?: string; - priority?: number; - maxDepth?: number; -} - -export interface CommunicationEvaluation { - allowed: boolean; - rule?: CommunicationRule; - reason: string; -} - -/** - * Client for the CommunicationPolicyGate API — managing inter-agent - * communication rules and evaluating message routing decisions. - * - * Accessed via `client.communication`: - * ```ts - * const rules = await client.communication.listRules(); - * const result = await client.communication.evaluate('agentA', 'agentB', 'send_message'); - * ``` - */ -export class CommunicationClient { - private client: ClientDependency; - - constructor(client: ClientDependency) { - this.client = client; - } - - /** List all communication rules. GET /api/v1/communication/rules */ - async listRules(): Promise { - return this.makeRequest( - '/api/v1/communication/rules', - { method: 'GET' }, - ); - } - - /** Add a communication rule. POST /api/v1/communication/rules */ - async addRule(rule: Omit): Promise { - return this.makeRequest( - '/api/v1/communication/rules', - { method: 'POST', body: rule }, - ); - } - - /** Remove a communication rule. DELETE /api/v1/communication/rules/{ruleId} */ - async removeRule(ruleId: string): Promise { - await this.makeRequest( - `/api/v1/communication/rules/${encodeURIComponent(ruleId)}`, - { method: 'DELETE' }, - ); - } - - /** Evaluate whether a message from sender to recipient is allowed. POST /api/v1/communication/evaluate */ - async evaluate(sender: string, recipient: string, action: string): Promise { - return this.makeRequest( - '/api/v1/communication/evaluate', - { method: 'POST', body: { sender, recipient, action } }, - ); - } - - // --------------------------------------------------------------------------- - // Internal - // --------------------------------------------------------------------------- - - private async makeRequest(endpoint: string, options: RequestOptions): Promise { - const authHeaders = await this.client.getAuthHeaders(endpoint); - const config = this.client.configuration; - const baseUrl = config.runtimeApiUrl; - const fullUrl = `${baseUrl}${endpoint}`; - - const headers: Record = { - 'Content-Type': 'application/json', - ...authHeaders, - ...(options.headers || {}), - }; - - const fetchOptions: RequestInit = { - method: options.method || 'GET', - headers, - ...(options.timeout && { signal: AbortSignal.timeout(options.timeout) }), - }; - - if (options.body && options.method !== 'GET') { - fetchOptions.body = JSON.stringify(options.body); - } - - const response = await fetch(fullUrl, fetchOptions); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error( - `Communication API request failed: ${response.status} ${response.statusText}. ${errorText}`, - ); - } - - if (response.status === 204 || options.method === 'DELETE') { - return undefined as unknown as T; - } - - const data = await response.json(); - return data as T; - } -} diff --git a/packages/core/src/MetricsClient.ts b/packages/core/src/MetricsClient.ts index a0a5383..067b2b2 100644 --- a/packages/core/src/MetricsClient.ts +++ b/packages/core/src/MetricsClient.ts @@ -5,6 +5,7 @@ import { RequestOptions, SymbiontConfig, } from 'symbi-types'; +import { buildRuntimeUrl } from './urlUtils'; // Re-export the type export type { MetricsSnapshotType }; @@ -207,7 +208,7 @@ interface ClientDependency { * * Accessed via `client.metricsClient`: * ```ts - * const snapshot = await client.metricsClient.getMetricsSnapshot(); + * const metrics = await client.metricsClient.getMetrics(); * ``` */ export class MetricsApiClient { @@ -217,34 +218,13 @@ export class MetricsApiClient { this.client = client; } - /** Get the current metrics snapshot. GET /metrics/snapshot */ - async getMetricsSnapshot(): Promise> { - return this.makeRequest>('/metrics/snapshot', { + /** Get the current runtime metrics. GET /metrics */ + async getMetrics(): Promise> { + return this.makeRequest>('/metrics', { method: 'GET', }); } - /** Get scheduler-specific metrics. GET /metrics/scheduler */ - async getSchedulerMetrics(): Promise> { - return this.makeRequest>('/metrics/scheduler', { - method: 'GET', - }); - } - - /** Get system resource metrics. GET /metrics/system */ - async getSystemMetrics(): Promise> { - return this.makeRequest>('/metrics/system', { - method: 'GET', - }); - } - - /** Trigger a metrics export. POST /metrics/export */ - async exportMetrics(): Promise> { - return this.makeRequest>('/metrics/export', { - method: 'POST', - }); - } - private async makeRequest( endpoint: string, options: RequestOptions @@ -253,7 +233,7 @@ export class MetricsApiClient { const authHeaders = await this.client.getAuthHeaders(endpoint); const config = this.client.configuration; const baseUrl = config.runtimeApiUrl; - const fullUrl = `${baseUrl}${endpoint}`; + const fullUrl = buildRuntimeUrl(baseUrl, endpoint); const headers: Record = { 'Content-Type': 'application/json', diff --git a/packages/core/src/ReasoningClient.ts b/packages/core/src/ReasoningClient.ts deleted file mode 100644 index 0109589..0000000 --- a/packages/core/src/ReasoningClient.ts +++ /dev/null @@ -1,240 +0,0 @@ -import type { - CedarPolicy, - CircuitBreakerStatus, - JournalEntry, - LoopDecision, - LoopState, - ProposedAction, - RequestOptions, - RunReasoningLoopRequest, - RunReasoningLoopResponse, - SymbiontConfig, -} from 'symbi-types'; - -/** - * Minimal interface to avoid circular dependency with SymbiontClient. - */ -interface ClientDependency { - getAuthHeaders(endpoint: string): Promise>; - configuration: Readonly; -} - -/** - * Client for reasoning loop, journal, Cedar policy, circuit breaker, - * and knowledge bridge operations. - * - * Accessed via `client.reasoning`: - * ```ts - * const response = await client.reasoning.runLoop(agentId, request); - * ``` - */ -export class ReasoningClient { - private client: ClientDependency; - - constructor(client: ClientDependency) { - this.client = client; - } - - // --------------------------------------------------------------------------- - // Reasoning Loop - // --------------------------------------------------------------------------- - - /** Start a reasoning loop. POST /api/v1/agents/{agentId}/reasoning/loop */ - async runLoop(agentId: string, request: RunReasoningLoopRequest): Promise { - return this.makeRequest( - `/api/v1/agents/${agentId}/reasoning/loop`, - { method: 'POST', body: request }, - ); - } - - /** Get loop status. GET /api/v1/agents/{agentId}/reasoning/loop/{loopId} */ - async getLoopStatus(agentId: string, loopId: string): Promise { - return this.makeRequest( - `/api/v1/agents/${agentId}/reasoning/loop/${loopId}`, - { method: 'GET' }, - ); - } - - /** Cancel a running loop. DELETE /api/v1/agents/{agentId}/reasoning/loop/{loopId} */ - async cancelLoop(agentId: string, loopId: string): Promise { - await this.makeRequest( - `/api/v1/agents/${agentId}/reasoning/loop/${loopId}`, - { method: 'DELETE' }, - ); - } - - // --------------------------------------------------------------------------- - // Journal - // --------------------------------------------------------------------------- - - /** Get journal entries. GET /api/v1/agents/{agentId}/reasoning/journal */ - async getJournalEntries( - agentId: string, - opts?: { fromSequence?: number; limit?: number }, - ): Promise { - const params = new URLSearchParams(); - if (opts?.fromSequence !== undefined) params.set('from_sequence', String(opts.fromSequence)); - if (opts?.limit !== undefined) params.set('limit', String(opts.limit)); - const qs = params.toString(); - const endpoint = `/api/v1/agents/${agentId}/reasoning/journal${qs ? `?${qs}` : ''}`; - return this.makeRequest(endpoint, { method: 'GET' }); - } - - /** Compact journal entries. POST /api/v1/agents/{agentId}/reasoning/journal/compact */ - async compactJournal(agentId: string): Promise<{ deleted: number }> { - return this.makeRequest<{ deleted: number }>( - `/api/v1/agents/${agentId}/reasoning/journal/compact`, - { method: 'POST' }, - ); - } - - // --------------------------------------------------------------------------- - // Cedar Policies - // --------------------------------------------------------------------------- - - /** List Cedar policies. GET /api/v1/agents/{agentId}/reasoning/cedar */ - async listCedarPolicies(agentId: string): Promise { - return this.makeRequest( - `/api/v1/agents/${agentId}/reasoning/cedar`, - { method: 'GET' }, - ); - } - - /** Add a Cedar policy. POST /api/v1/agents/{agentId}/reasoning/cedar */ - async addCedarPolicy(agentId: string, policy: CedarPolicy): Promise { - await this.makeRequest( - `/api/v1/agents/${agentId}/reasoning/cedar`, - { method: 'POST', body: policy }, - ); - } - - /** Remove a Cedar policy. DELETE /api/v1/agents/{agentId}/reasoning/cedar/{policyName} */ - async removeCedarPolicy(agentId: string, policyName: string): Promise { - const result = await this.makeRequest<{ removed: boolean }>( - `/api/v1/agents/${agentId}/reasoning/cedar/${encodeURIComponent(policyName)}`, - { method: 'DELETE' }, - ); - return result.removed; - } - - /** Evaluate a Cedar policy. POST /api/v1/agents/{agentId}/reasoning/cedar/evaluate */ - async evaluateCedarPolicy(agentId: string, action: ProposedAction): Promise { - return this.makeRequest( - `/api/v1/agents/${agentId}/reasoning/cedar/evaluate`, - { method: 'POST', body: action }, - ); - } - - // --------------------------------------------------------------------------- - // Circuit Breakers - // --------------------------------------------------------------------------- - - /** Get circuit breaker status. GET /api/v1/agents/{agentId}/reasoning/circuit-breakers */ - async getCircuitBreakerStatus(agentId: string): Promise> { - return this.makeRequest>( - `/api/v1/agents/${agentId}/reasoning/circuit-breakers`, - { method: 'GET' }, - ); - } - - /** Reset a circuit breaker. POST /api/v1/agents/{agentId}/reasoning/circuit-breakers/{toolName}/reset */ - async resetCircuitBreaker(agentId: string, toolName: string): Promise { - await this.makeRequest( - `/api/v1/agents/${agentId}/reasoning/circuit-breakers/${encodeURIComponent(toolName)}/reset`, - { method: 'POST' }, - ); - } - - // --------------------------------------------------------------------------- - // Knowledge Bridge - // --------------------------------------------------------------------------- - - /** Recall knowledge. POST /api/v1/agents/{agentId}/reasoning/knowledge/recall */ - async recallKnowledge(agentId: string, query: string, limit?: number): Promise { - return this.makeRequest( - `/api/v1/agents/${agentId}/reasoning/knowledge/recall`, - { method: 'POST', body: { query, limit: limit ?? 5 } }, - ); - } - - /** Store knowledge. POST /api/v1/agents/{agentId}/reasoning/knowledge/store */ - async storeKnowledge( - agentId: string, - subject: string, - predicate: string, - object: string, - confidence?: number, - ): Promise<{ id: string }> { - return this.makeRequest<{ id: string }>( - `/api/v1/agents/${agentId}/reasoning/knowledge/store`, - { - method: 'POST', - body: { subject, predicate, object, confidence: confidence ?? 0.8 }, - }, - ); - } - - // --------------------------------------------------------------------------- - // ORGA-adaptive - // --------------------------------------------------------------------------- - - /** Get tool profiles for an agent. GET /api/v1/agents/{agentId}/reasoning/tool-profiles */ - async getToolProfiles(agentId: string): Promise[]> { - return this.makeRequest[]>( - `/api/v1/agents/${agentId}/reasoning/tool-profiles`, - { method: 'GET' }, - ); - } - - /** Get loop diagnostics. GET /api/v1/agents/{agentId}/reasoning/{loopId}/diagnostics */ - async getLoopDiagnostics(agentId: string, loopId: string): Promise> { - return this.makeRequest>( - `/api/v1/agents/${agentId}/reasoning/${loopId}/diagnostics`, - { method: 'GET' }, - ); - } - - // --------------------------------------------------------------------------- - // Internal - // --------------------------------------------------------------------------- - - private async makeRequest(endpoint: string, options: RequestOptions): Promise { - const authHeaders = await this.client.getAuthHeaders(endpoint); - const config = this.client.configuration; - const baseUrl = config.runtimeApiUrl; - const fullUrl = `${baseUrl}${endpoint}`; - - const headers: Record = { - 'Content-Type': 'application/json', - ...authHeaders, - ...(options.headers || {}), - }; - - const fetchOptions: RequestInit = { - method: options.method || 'GET', - headers, - ...(options.timeout && { signal: AbortSignal.timeout(options.timeout) }), - }; - - if (options.body && options.method !== 'GET') { - fetchOptions.body = JSON.stringify(options.body); - } - - const response = await fetch(fullUrl, fetchOptions); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error( - `Reasoning API request failed: ${response.status} ${response.statusText}. ${errorText}`, - ); - } - - // Some endpoints return no body (204) - if (response.status === 204) { - return undefined as unknown as T; - } - - const data = await response.json(); - return data as T; - } -} diff --git a/packages/core/src/SystemClient.ts b/packages/core/src/SystemClient.ts index cb64267..35108c6 100644 --- a/packages/core/src/SystemClient.ts +++ b/packages/core/src/SystemClient.ts @@ -3,6 +3,7 @@ import { SymbiontConfig, HealthResponse, } from 'symbi-types'; +import { buildRuntimeUrl } from './urlUtils'; /** * Simple interface to avoid circular dependency with SymbiontClient @@ -53,7 +54,7 @@ export class SystemClient { const authHeaders = await this.client.getAuthHeaders(endpoint); const config = this.client.configuration; const baseUrl = config.runtimeApiUrl; - const fullUrl = `${baseUrl}${endpoint}`; + const fullUrl = buildRuntimeUrl(baseUrl, endpoint); const headers: Record = { 'Content-Type': 'application/json', diff --git a/packages/core/src/ToolCladClient.ts b/packages/core/src/ToolCladClient.ts deleted file mode 100644 index f858e3c..0000000 --- a/packages/core/src/ToolCladClient.ts +++ /dev/null @@ -1,164 +0,0 @@ -import type { SymbiontConfig } from 'symbi-types'; -import type { RequestOptions } from 'symbi-types'; - -/** - * Minimal interface to avoid circular dependency with SymbiontClient. - */ -interface ClientDependency { - getAuthHeaders(endpoint: string): Promise>; - configuration: Readonly; -} - -export interface ToolManifestInfo { - name: string; - version: string; - description: string; - riskTier: string; - humanApproval: boolean; - argCount: number; - backend: string; - sourcePath: string; -} - -export interface ToolValidationResult { - valid: boolean; - errors: string[]; - warnings: string[]; -} - -export interface ToolTestResult { - command: string; - validations: string[]; - cedar?: string; - timeout: number; -} - -export interface ToolExecutionResult { - status: string; - scanId: string; - tool: string; - command: string; - durationMs: number; - timestamp: string; - outputHash?: string; - exitCode: number; - stderr: string; - results: Record; -} - -/** - * Client for the ToolClad API — listing, validating, testing, and executing - * security-scanned tool manifests. - * - * Accessed via `client.toolclad`: - * ```ts - * const tools = await client.toolclad.listTools(); - * const result = await client.toolclad.executeTool('nmap', { target: '10.0.0.1' }); - * ``` - */ -export class ToolCladClient { - private client: ClientDependency; - - constructor(client: ClientDependency) { - this.client = client; - } - - /** List all registered tools. GET /api/v1/tools */ - async listTools(): Promise { - return this.makeRequest( - '/api/v1/tools', - { method: 'GET' }, - ); - } - - /** Validate a tool manifest at the given path. POST /api/v1/tools/validate */ - async validateManifest(path: string): Promise { - return this.makeRequest( - '/api/v1/tools/validate', - { method: 'POST', body: { path } }, - ); - } - - /** Dry-run a tool with the given arguments. POST /api/v1/tools/{toolName}/test */ - async testTool(toolName: string, args: Record): Promise { - return this.makeRequest( - `/api/v1/tools/${encodeURIComponent(toolName)}/test`, - { method: 'POST', body: { args } }, - ); - } - - /** Get the JSON schema for a tool. GET /api/v1/tools/{toolName}/schema */ - async getSchema(toolName: string): Promise> { - return this.makeRequest>( - `/api/v1/tools/${encodeURIComponent(toolName)}/schema`, - { method: 'GET' }, - ); - } - - /** Execute a tool with the given arguments. POST /api/v1/tools/{toolName}/execute */ - async executeTool(toolName: string, args: Record): Promise { - return this.makeRequest( - `/api/v1/tools/${encodeURIComponent(toolName)}/execute`, - { method: 'POST', body: { args } }, - ); - } - - /** Get detailed info for a specific tool. GET /api/v1/tools/{toolName} */ - async getToolInfo(toolName: string): Promise { - return this.makeRequest( - `/api/v1/tools/${encodeURIComponent(toolName)}`, - { method: 'GET' }, - ); - } - - /** Reload tool manifests from disk. POST /api/v1/tools/reload */ - async reloadTools(): Promise { - await this.makeRequest( - '/api/v1/tools/reload', - { method: 'POST' }, - ); - } - - // --------------------------------------------------------------------------- - // Internal - // --------------------------------------------------------------------------- - - private async makeRequest(endpoint: string, options: RequestOptions): Promise { - const authHeaders = await this.client.getAuthHeaders(endpoint); - const config = this.client.configuration; - const baseUrl = config.runtimeApiUrl; - const fullUrl = `${baseUrl}${endpoint}`; - - const headers: Record = { - 'Content-Type': 'application/json', - ...authHeaders, - ...(options.headers || {}), - }; - - const fetchOptions: RequestInit = { - method: options.method || 'GET', - headers, - ...(options.timeout && { signal: AbortSignal.timeout(options.timeout) }), - }; - - if (options.body && options.method !== 'GET') { - fetchOptions.body = JSON.stringify(options.body); - } - - const response = await fetch(fullUrl, fetchOptions); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error( - `ToolClad API request failed: ${response.status} ${response.statusText}. ${errorText}`, - ); - } - - if (response.status === 204) { - return undefined as unknown as T; - } - - const data = await response.json(); - return data as T; - } -} diff --git a/packages/core/src/__tests__/MetricsClient.test.ts b/packages/core/src/__tests__/MetricsClient.test.ts index 26054e6..c8a4606 100644 --- a/packages/core/src/__tests__/MetricsClient.test.ts +++ b/packages/core/src/__tests__/MetricsClient.test.ts @@ -137,13 +137,15 @@ describe('MetricsCollector', () => { }); describe('MetricsApiClient', () => { - it('should call the correct endpoint', async () => { + it('GET /metrics resolves to a single /api/v1 prefix', async () => { + const metricsData = { timestamp: '2026-02-15T12:00:00Z' }; + const calls: string[] = []; const mockResponse = { ok: true, status: 200, statusText: 'OK', - json: async () => ({ timestamp: '2026-02-15T12:00:00Z' }), - text: async () => '', + json: async () => metricsData, + text: async () => JSON.stringify(metricsData), }; const mockClient = { @@ -151,15 +153,18 @@ describe('MetricsApiClient', () => { configuration: { runtimeApiUrl: 'http://localhost:8080' } as any, }; - // We test through the class const client = new MetricsApiClient(mockClient); - // Mock fetch globally const originalFetch = globalThis.fetch; - globalThis.fetch = (async () => mockResponse) as any; + globalThis.fetch = (async (url: string) => { + calls.push(url); + return mockResponse; + }) as any; try { - const result = await client.getMetricsSnapshot(); - expect(result).toEqual({ timestamp: '2026-02-15T12:00:00Z' }); + const result = await client.getMetrics(); + expect(result).toEqual(metricsData); + expect(calls[0]).toBe('http://localhost:8080/api/v1/metrics'); + expect(calls[0].match(/\/api\/v1/g)).toHaveLength(1); } finally { globalThis.fetch = originalFetch; } diff --git a/packages/core/src/__tests__/ReasoningClient.test.ts b/packages/core/src/__tests__/ReasoningClient.test.ts deleted file mode 100644 index dcee567..0000000 --- a/packages/core/src/__tests__/ReasoningClient.test.ts +++ /dev/null @@ -1,485 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - UsageSchema, - ToolDefinitionSchema, - ToolCallRequestSchema, - FinishReasonSchema, - ResponseFormatSchema, - InferenceOptionsSchema, - InferenceResponseSchema, - ObservationSchema, - ProposedActionSchema, - LoopDecisionSchema, - RecoveryStrategySchema, - TerminationReasonSchema, - LoopConfigSchema, - LoopStateSchema, - LoopResultSchema, - LoopEventSchema, - JournalEntrySchema, - CedarPolicySchema, - KnowledgeConfigSchema, - CircuitStateSchema, - CircuitBreakerConfigSchema, - CircuitBreakerStatusSchema, - RunReasoningLoopRequestSchema, - RunReasoningLoopResponseSchema, -} from 'symbi-types'; -import { ReasoningClient } from '../ReasoningClient'; - -// ============================================================================= -// Schema Validation Tests -// ============================================================================= - -describe('Usage schema', () => { - it('should parse valid usage', () => { - const result = UsageSchema.parse({ prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }); - expect(result.total_tokens).toBe(30); - }); - - it('should reject negative tokens', () => { - expect(() => UsageSchema.parse({ prompt_tokens: -1, completion_tokens: 0, total_tokens: 0 })).toThrow(); - }); -}); - -describe('ToolDefinition schema', () => { - it('should parse valid tool definition', () => { - const result = ToolDefinitionSchema.parse({ - name: 'search', - description: 'Search the web', - parameters: { type: 'object', properties: { query: { type: 'string' } } }, - }); - expect(result.name).toBe('search'); - }); -}); - -describe('FinishReason schema', () => { - it('should accept valid values', () => { - expect(FinishReasonSchema.parse('stop')).toBe('stop'); - expect(FinishReasonSchema.parse('tool_calls')).toBe('tool_calls'); - expect(FinishReasonSchema.parse('max_tokens')).toBe('max_tokens'); - expect(FinishReasonSchema.parse('content_filter')).toBe('content_filter'); - }); - - it('should reject invalid values', () => { - expect(() => FinishReasonSchema.parse('invalid')).toThrow(); - }); -}); - -describe('ResponseFormat schema', () => { - it('should parse text format', () => { - const result = ResponseFormatSchema.parse({ type: 'text' }); - expect(result.type).toBe('text'); - }); - - it('should parse json_schema format with schema', () => { - const result = ResponseFormatSchema.parse({ - type: 'json_schema', - schema: { type: 'object' }, - name: 'my_schema', - }); - expect(result.type).toBe('json_schema'); - }); -}); - -describe('ProposedAction schema', () => { - it('should parse tool_call action', () => { - const result = ProposedActionSchema.parse({ - type: 'tool_call', - call_id: 'call-1', - name: 'search', - arguments: '{"query":"test"}', - }); - expect(result.type).toBe('tool_call'); - if (result.type === 'tool_call') { - expect(result.name).toBe('search'); - } - }); - - it('should parse delegate action', () => { - const result = ProposedActionSchema.parse({ - type: 'delegate', - target: 'agent-2', - message: 'please help', - }); - expect(result.type).toBe('delegate'); - }); - - it('should parse respond action', () => { - const result = ProposedActionSchema.parse({ - type: 'respond', - content: 'Here is the answer.', - }); - expect(result.type).toBe('respond'); - }); - - it('should parse terminate action', () => { - const result = ProposedActionSchema.parse({ - type: 'terminate', - reason: 'task complete', - output: 'Done.', - }); - expect(result.type).toBe('terminate'); - }); - - it('should reject unknown action type', () => { - expect(() => ProposedActionSchema.parse({ type: 'fly' })).toThrow(); - }); -}); - -describe('LoopDecision schema', () => { - it('should parse allow decision', () => { - const result = LoopDecisionSchema.parse({ decision: 'allow' }); - expect(result.decision).toBe('allow'); - }); - - it('should parse deny decision', () => { - const result = LoopDecisionSchema.parse({ decision: 'deny', reason: 'not permitted' }); - expect(result.decision).toBe('deny'); - }); - - it('should parse modify decision', () => { - const result = LoopDecisionSchema.parse({ - decision: 'modify', - modified_action: { type: 'respond', content: 'modified' }, - reason: 'sanitized', - }); - expect(result.decision).toBe('modify'); - }); -}); - -describe('RecoveryStrategy schema', () => { - it('should parse retry strategy', () => { - const result = RecoveryStrategySchema.parse({ type: 'retry', max_attempts: 3, base_delay_ms: 1000 }); - expect(result.type).toBe('retry'); - }); - - it('should parse dead_letter strategy', () => { - const result = RecoveryStrategySchema.parse({ type: 'dead_letter' }); - expect(result.type).toBe('dead_letter'); - }); - - it('should parse escalate strategy', () => { - const result = RecoveryStrategySchema.parse({ type: 'escalate', queue: 'ops', context_snapshot: true }); - expect(result.type).toBe('escalate'); - }); -}); - -describe('TerminationReason schema', () => { - it('should parse completed', () => { - expect(TerminationReasonSchema.parse({ type: 'completed' }).type).toBe('completed'); - }); - - it('should parse policy_denial with reason', () => { - const result = TerminationReasonSchema.parse({ type: 'policy_denial', reason: 'blocked' }); - expect(result.type).toBe('policy_denial'); - }); - - it('should parse error with message', () => { - const result = TerminationReasonSchema.parse({ type: 'error', message: 'crash' }); - expect(result.type).toBe('error'); - }); -}); - -describe('LoopConfig schema', () => { - it('should parse with defaults', () => { - const result = LoopConfigSchema.parse({}); - expect(result.max_iterations).toBe(10); - expect(result.max_total_tokens).toBe(100000); - expect(result.default_recovery.type).toBe('dead_letter'); - }); - - it('should parse with overrides', () => { - const result = LoopConfigSchema.parse({ max_iterations: 5, timeout_ms: 60000 }); - expect(result.max_iterations).toBe(5); - expect(result.timeout_ms).toBe(60000); - }); -}); - -describe('LoopState schema', () => { - it('should parse valid state', () => { - const result = LoopStateSchema.parse({ - agent_id: 'agent-1', - iteration: 3, - total_usage: { prompt_tokens: 100, completion_tokens: 50, total_tokens: 150 }, - started_at: '2026-01-01T00:00:00Z', - current_phase: 'reasoning', - }); - expect(result.iteration).toBe(3); - expect(result.pending_observations).toEqual([]); - }); -}); - -describe('LoopResult schema', () => { - it('should parse valid result', () => { - const result = LoopResultSchema.parse({ - output: 'The answer is 42', - iterations: 3, - total_usage: { prompt_tokens: 100, completion_tokens: 50, total_tokens: 150 }, - termination_reason: { type: 'completed' }, - duration_ms: 5000, - }); - expect(result.output).toBe('The answer is 42'); - expect(result.termination_reason.type).toBe('completed'); - }); -}); - -describe('LoopEvent schema', () => { - it('should parse started event', () => { - const result = LoopEventSchema.parse({ - type: 'started', - agent_id: 'agent-1', - config: {}, - }); - expect(result.type).toBe('started'); - }); - - it('should parse reasoning_complete event', () => { - const result = LoopEventSchema.parse({ - type: 'reasoning_complete', - iteration: 1, - actions: [{ type: 'respond', content: 'hello' }], - usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, - }); - expect(result.type).toBe('reasoning_complete'); - }); - - it('should parse terminated event', () => { - const result = LoopEventSchema.parse({ - type: 'terminated', - reason: { type: 'completed' }, - iterations: 5, - total_usage: { prompt_tokens: 100, completion_tokens: 50, total_tokens: 150 }, - duration_ms: 10000, - }); - expect(result.type).toBe('terminated'); - }); - - it('should parse recovery_triggered event', () => { - const result = LoopEventSchema.parse({ - type: 'recovery_triggered', - iteration: 2, - tool_name: 'search', - strategy: { type: 'retry', max_attempts: 3, base_delay_ms: 1000 }, - error: 'timeout', - }); - expect(result.type).toBe('recovery_triggered'); - }); -}); - -describe('JournalEntry schema', () => { - it('should parse valid entry', () => { - const result = JournalEntrySchema.parse({ - sequence: 0, - timestamp: '2026-01-01T00:00:00Z', - agent_id: 'agent-1', - iteration: 0, - event: { type: 'started', agent_id: 'agent-1', config: {} }, - }); - expect(result.sequence).toBe(0); - }); -}); - -describe('CedarPolicy schema', () => { - it('should parse with defaults', () => { - const result = CedarPolicySchema.parse({ name: 'deny-all', source: 'forbid(principal,action,resource);' }); - expect(result.active).toBe(true); - }); - - it('should parse with explicit active flag', () => { - const result = CedarPolicySchema.parse({ name: 'p1', source: 'src', active: false }); - expect(result.active).toBe(false); - }); -}); - -describe('KnowledgeConfig schema', () => { - it('should parse with defaults', () => { - const result = KnowledgeConfigSchema.parse({}); - expect(result.max_context_items).toBe(5); - expect(result.relevance_threshold).toBe(0.7); - expect(result.auto_persist).toBe(false); - }); -}); - -describe('CircuitState schema', () => { - it('should accept valid states', () => { - expect(CircuitStateSchema.parse('closed')).toBe('closed'); - expect(CircuitStateSchema.parse('open')).toBe('open'); - expect(CircuitStateSchema.parse('half_open')).toBe('half_open'); - }); - - it('should reject invalid state', () => { - expect(() => CircuitStateSchema.parse('broken')).toThrow(); - }); -}); - -describe('CircuitBreakerConfig schema', () => { - it('should parse with defaults', () => { - const result = CircuitBreakerConfigSchema.parse({}); - expect(result.failure_threshold).toBe(5); - expect(result.recovery_timeout_ms).toBe(30000); - }); -}); - -describe('CircuitBreakerStatus schema', () => { - it('should parse valid status', () => { - const result = CircuitBreakerStatusSchema.parse({ - state: 'closed', - failure_count: 0, - success_count: 10, - config: {}, - }); - expect(result.state).toBe('closed'); - expect(result.success_count).toBe(10); - }); -}); - -describe('RunReasoningLoopRequest schema', () => { - it('should parse minimal request', () => { - const result = RunReasoningLoopRequestSchema.parse({ - config: {}, - initial_message: 'Hello', - }); - expect(result.initial_message).toBe('Hello'); - expect(result.config.max_iterations).toBe(10); - }); -}); - -describe('RunReasoningLoopResponse schema', () => { - it('should parse valid response', () => { - const result = RunReasoningLoopResponseSchema.parse({ - loop_id: 'loop-1', - result: { - output: 'Done', - iterations: 2, - total_usage: { prompt_tokens: 50, completion_tokens: 25, total_tokens: 75 }, - termination_reason: { type: 'completed' }, - duration_ms: 3000, - }, - }); - expect(result.loop_id).toBe('loop-1'); - expect(result.journal_entries).toEqual([]); - }); -}); - -// ============================================================================= -// ReasoningClient Method Tests -// ============================================================================= - -describe('ReasoningClient', () => { - function makeMockClient(responseData: unknown = {}, status = 200) { - const mockResponse = { - ok: status >= 200 && status < 300, - status, - statusText: status === 200 ? 'OK' : 'Error', - json: async () => responseData, - text: async () => JSON.stringify(responseData), - }; - - const fetchCalls: { url: string; options: RequestInit }[] = []; - - const mockClient = { - getAuthHeaders: async () => ({ Authorization: 'Bearer test' }), - configuration: { runtimeApiUrl: 'http://localhost:8080' } as any, - }; - - const originalFetch = globalThis.fetch; - globalThis.fetch = (async (url: string, opts: RequestInit) => { - fetchCalls.push({ url, options: opts }); - return mockResponse; - }) as any; - - const client = new ReasoningClient(mockClient); - - return { - client, - fetchCalls, - restore: () => { globalThis.fetch = originalFetch; }, - }; - } - - it('runLoop should POST to correct endpoint', async () => { - const responseData = { - loop_id: 'loop-1', - result: { - output: 'Done', - iterations: 1, - total_usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, - termination_reason: { type: 'completed' }, - duration_ms: 1000, - }, - journal_entries: [], - }; - const { client, fetchCalls, restore } = makeMockClient(responseData); - try { - const result = await client.runLoop('agent-1', { config: {}, initial_message: 'hi' } as any); - expect(result.loop_id).toBe('loop-1'); - expect(fetchCalls[0].url).toBe('http://localhost:8080/api/v1/agents/agent-1/reasoning/loop'); - expect(fetchCalls[0].options.method).toBe('POST'); - } finally { - restore(); - } - }); - - it('getLoopStatus should GET correct endpoint', async () => { - const { client, fetchCalls, restore } = makeMockClient({ - agent_id: 'agent-1', - iteration: 2, - total_usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, - started_at: '2026-01-01T00:00:00Z', - current_phase: 'tools', - }); - try { - await client.getLoopStatus('agent-1', 'loop-1'); - expect(fetchCalls[0].url).toBe('http://localhost:8080/api/v1/agents/agent-1/reasoning/loop/loop-1'); - expect(fetchCalls[0].options.method).toBe('GET'); - } finally { - restore(); - } - }); - - it('listCedarPolicies should GET correct endpoint', async () => { - const { client, fetchCalls, restore } = makeMockClient([]); - try { - await client.listCedarPolicies('agent-1'); - expect(fetchCalls[0].url).toBe('http://localhost:8080/api/v1/agents/agent-1/reasoning/cedar'); - } finally { - restore(); - } - }); - - it('getCircuitBreakerStatus should GET correct endpoint', async () => { - const { client, fetchCalls, restore } = makeMockClient({}); - try { - await client.getCircuitBreakerStatus('agent-1'); - expect(fetchCalls[0].url).toBe('http://localhost:8080/api/v1/agents/agent-1/reasoning/circuit-breakers'); - } finally { - restore(); - } - }); - - it('recallKnowledge should POST correct endpoint', async () => { - const { client, fetchCalls, restore } = makeMockClient(['fact-1', 'fact-2']); - try { - const result = await client.recallKnowledge('agent-1', 'what is X?'); - expect(result).toEqual(['fact-1', 'fact-2']); - expect(fetchCalls[0].url).toBe('http://localhost:8080/api/v1/agents/agent-1/reasoning/knowledge/recall'); - } finally { - restore(); - } - }); - - it('storeKnowledge should POST correct endpoint', async () => { - const { client, fetchCalls, restore } = makeMockClient({ id: 'k-1' }); - try { - const result = await client.storeKnowledge('agent-1', 'X', 'is', 'Y'); - expect(result.id).toBe('k-1'); - expect(fetchCalls[0].url).toBe('http://localhost:8080/api/v1/agents/agent-1/reasoning/knowledge/store'); - const body = JSON.parse(fetchCalls[0].options.body as string); - expect(body.subject).toBe('X'); - expect(body.confidence).toBe(0.8); - } finally { - restore(); - } - }); -}); diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index b162c04..8c7f4a0 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -2,8 +2,6 @@ import { SymbiontConfig, EnhancedSymbiontConfig, HealthStatus, HealthResponse } import { AuthenticationManager } from './auth'; import { EnvManager } from './config'; import { SystemClient } from './SystemClient'; -import { CommunicationClient } from './CommunicationClient'; -import { ToolCladClient } from './ToolCladClient'; /** * Main Symbiont SDK client providing unified access to both Runtime and Tool Review APIs @@ -26,9 +24,6 @@ export class SymbiontClient { private _http?: any; // HttpEndpointManager private _agentpin?: any; // AgentPinClient private _metricsClient?: any; // MetricsApiClient - private _reasoning?: any; // ReasoningClient - private _communication?: CommunicationClient; - private _toolclad?: ToolCladClient; /** * Create a new Symbiont SDK client @@ -300,37 +295,6 @@ export class SymbiontClient { return this._agentpin; } - /** - * Lazy-loaded reasoning client for loop, journal, Cedar, circuit breaker, and knowledge operations - */ - get reasoning(): any { - if (!this._reasoning) { - const { ReasoningClient } = require('./ReasoningClient'); - this._reasoning = new ReasoningClient(this); - } - return this._reasoning; - } - - /** - * Lazy-loaded communication policy gate client - */ - get communication(): CommunicationClient { - if (!this._communication) { - this._communication = new CommunicationClient(this); - } - return this._communication; - } - - /** - * Lazy-loaded ToolClad client for tool manifest management and execution - */ - get toolclad(): ToolCladClient { - if (!this._toolclad) { - this._toolclad = new ToolCladClient(this); - } - return this._toolclad; - } - /** * Lazy-loaded metrics API client */ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b296381..36731c9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -20,9 +20,6 @@ export { MemoryManagerConfig } from './memory'; -// Export vector system implementations -export * from './vector'; - // Export HTTP endpoint management export { HttpEndpointManager } from './http'; export { EndpointMetrics as EndpointMetricsClass } from './http'; @@ -47,22 +44,6 @@ export type { SignatureVerifier, WebhookProviderName } from './WebhookVerifier'; // Export skill scanning and loading export { SkillScanner, SkillLoader } from './SkillScanner'; -// Export reasoning client -export { ReasoningClient } from './ReasoningClient'; - -// Export communication policy gate client -export { CommunicationClient } from './CommunicationClient'; -export type { CommunicationRule, CommunicationEvaluation } from './CommunicationClient'; - -// Export ToolClad client -export { ToolCladClient } from './ToolCladClient'; -export type { - ToolManifestInfo, - ToolValidationResult, - ToolTestResult, - ToolExecutionResult, -} from './ToolCladClient'; - // Export metrics export { FileMetricsExporter, diff --git a/packages/core/src/memory/__tests__/InMemoryStore.test.ts b/packages/core/src/memory/__tests__/InMemoryStore.test.ts index b23d2f8..cd7e004 100644 --- a/packages/core/src/memory/__tests__/InMemoryStore.test.ts +++ b/packages/core/src/memory/__tests__/InMemoryStore.test.ts @@ -358,17 +358,35 @@ describe('InMemoryStore', () => { }); it('should update timestamp on update', async () => { - const originalTimestamp = (await store.get(memoryId))?.timestamp; - - // Wait a bit to ensure timestamp difference - await new Promise(resolve => setTimeout(resolve, 1)); - - await store.update(memoryId, { importance: 0.8 }); - - const updatedMemory = await store.get(memoryId); - expect(updatedMemory?.timestamp.getTime()).toBeGreaterThan( - originalTimestamp?.getTime() || 0 - ); + // Both update() and get() (via recordAccess) stamp the record with + // `new Date()`, so this assertion is purely a function of wall-clock + // time. The original `setTimeout(1)` version flaked in CI when the two + // reads landed in the same millisecond (`expected N to be greater than + // N`). Drive a fake clock so the post-update read is deterministically + // later than the original — no reliance on real timing. + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + await store.store({ + id: 'update-timestamp-test', + content: { text: 'Original content' }, + level: MemoryLevel.SHORT_TERM, + timestamp: new Date(), + accessCount: 0, + importance: 0.5, + }); + const originalTimestamp = (await store.get('update-timestamp-test'))?.timestamp; + + vi.setSystemTime(new Date('2026-01-01T00:00:01.000Z')); + await store.update('update-timestamp-test', { importance: 0.8 }); + + const updatedMemory = await store.get('update-timestamp-test'); + expect(updatedMemory?.timestamp.getTime()).toBeGreaterThan( + originalTimestamp?.getTime() || 0 + ); + } finally { + vi.useRealTimers(); + } }); it('should not allow ID changes', async () => { diff --git a/packages/core/src/urlUtils.ts b/packages/core/src/urlUtils.ts new file mode 100644 index 0000000..2ae916a --- /dev/null +++ b/packages/core/src/urlUtils.ts @@ -0,0 +1,32 @@ +/** + * URL normalization helpers for Symbiont Runtime API clients. + * + * The runtime serves every route under a single `/api/v1` version segment. + * Base URLs are configured WITHOUT the version segment (e.g. http://localhost:8080), + * and call sites use BARE paths (e.g. `/metrics`). This helper guarantees the final + * URL contains exactly one `/api/v1` prefix regardless of whether the base URL already + * ends with `/api/v1` or the endpoint already starts with `/api/v1`. + */ +export function buildRuntimeUrl( + baseUrl: string | undefined, + endpoint: string +): string { + // Strip trailing slashes from the base URL. + let base = (baseUrl ?? '').replace(/\/+$/, ''); + + // Remove a trailing /api/v1 from the base URL if present. + base = base.replace(/\/api\/v1$/, ''); + + // Ensure the endpoint starts with a single leading slash. + let path = endpoint.startsWith('/') ? endpoint : `/${endpoint}`; + + // Remove a leading /api/v1 from the endpoint if present. + path = path.replace(/^\/api\/v1(?=\/|$)/, ''); + + // Ensure the remaining path still has a leading slash. + if (!path.startsWith('/')) { + path = `/${path}`; + } + + return `${base}/api/v1${path}`; +} diff --git a/packages/core/src/vector/index.ts b/packages/core/src/vector/index.ts deleted file mode 100644 index fcd429e..0000000 --- a/packages/core/src/vector/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * symbi-core - Vector Database Integration - */ - -// Qdrant Integration -export * from './qdrant'; - -// Re-export vector types from types package -export type { - VectorPoint, - SearchResult, - Collection, - CollectionInfo, - SearchOptions, - BatchOperationResult, - PointInsertRequest, - PointUpdateRequest, - PointDeleteRequest, - ScrollRequest, - ScrollResponse, - EmbeddingProvider, - EmbeddingRequest, - EmbeddingResponse, - CollectionCreateRequest, -} from 'symbi-types'; \ No newline at end of file diff --git a/packages/core/src/vector/qdrant/CollectionManager.ts b/packages/core/src/vector/qdrant/CollectionManager.ts deleted file mode 100644 index 862c653..0000000 --- a/packages/core/src/vector/qdrant/CollectionManager.ts +++ /dev/null @@ -1,292 +0,0 @@ -import { QdrantClient } from '@qdrant/qdrant-js'; -import { VectorConfig } from '../../config/VectorConfig'; -import { - Collection, - CollectionInfo, - CollectionCreateRequest, - BatchOperationResult, -} from 'symbi-types'; - -/** - * Manages collection lifecycle operations in Qdrant - */ -export class CollectionManager { - constructor( - private client: QdrantClient, - private config: VectorConfig - ) {} - - /** - * Creates a new collection - */ - async create(request: CollectionCreateRequest): Promise { - try { - await this.client.createCollection(request.name, { - vectors: request.vectors, - shard_number: request.shard_number, - replication_factor: request.replication_factor, - write_consistency_factor: request.write_consistency_factor, - on_disk_payload: request.on_disk_payload, - hnsw_config: request.hnsw_config, - optimizers_config: request.optimizer_config, - wal_config: request.wal_config, - }); - return true; - } catch (error) { - const errorMessage = this.handleError(error); - const rootMessage = this.extractRootErrorMessage(errorMessage); - throw new Error(`Failed to create collection ${request.name}: ${rootMessage}`); - } - } - - /** - * Lists all collections - */ - async list(): Promise { - try { - const response = await this.client.getCollections(); - return response.collections.map((collection: any) => ({ - name: collection.name, - dimension: collection.config?.params?.vectors?.size || 0, - distance: collection.config?.params?.vectors?.distance || 'Cosine', - config: collection.config, - })); - } catch (error) { - const errorMessage = this.handleError(error); - const rootMessage = this.extractRootErrorMessage(errorMessage); - throw new Error(`Failed to list collections: ${rootMessage}`); - } - } - - /** - * Gets detailed information about a collection - */ - async getInfo(collectionName: string): Promise { - try { - const response = await this.client.getCollection(collectionName); - - // Narrow qdrant-js response types to our stricter CollectionInfo schema: - // - response.status can be "grey" (initializing); surface as "red" - // - response.optimizer_status can be { error } object; surface as "error" - // - response has no "name" field; use the caller-supplied collectionName - const status = - response.status === 'grey' ? 'red' : response.status; - const optimizer_status = - typeof response.optimizer_status === 'object' ? 'error' : response.optimizer_status; - - const params = response.config?.params; - const vectors = params?.vectors; - const vectorConfig = - vectors && typeof vectors === 'object' && 'size' in vectors && 'distance' in vectors - ? { size: vectors.size as number, distance: vectors.distance as 'Cosine' | 'Dot' | 'Euclid' | 'Manhattan' } - : { size: 0, distance: 'Cosine' as const }; - - return { - name: collectionName, - status, - optimizer_status, - vectors_count: response.vectors_count || 0, - indexed_vectors_count: response.indexed_vectors_count || 0, - points_count: response.points_count || 0, - segments_count: response.segments_count || 0, - config: { - params: { - vectors: vectorConfig, - shard_number: (params?.shard_number as number) ?? 1, - replication_factor: (params?.replication_factor as number) ?? 1, - write_consistency_factor: (params?.write_consistency_factor as number) ?? 1, - on_disk_payload: (params?.on_disk_payload as boolean) ?? false, - }, - }, - payload_schema: response.payload_schema as Record | undefined, - }; - } catch (error) { - const errorMessage = this.handleError(error); - const rootMessage = this.extractRootErrorMessage(errorMessage); - throw new Error(`Failed to get collection info for ${collectionName}: ${rootMessage}`); - } - } - - /** - * Checks if a collection exists - */ - async exists(collectionName: string): Promise { - try { - await this.client.getCollection(collectionName); - return true; - } catch (error) { - return false; - } - } - - /** - * Deletes a collection - */ - async delete(collectionName: string): Promise { - try { - await this.client.deleteCollection(collectionName); - return true; - } catch (error) { - const errorMessage = this.handleError(error); - const rootMessage = this.extractRootErrorMessage(errorMessage); - throw new Error(`Failed to delete collection ${collectionName}: ${rootMessage}`); - } - } - - /** - * Updates collection parameters - */ - async updateCollection( - collectionName: string, - updates: { - optimizers_config?: any; - params?: any; - } - ): Promise { - try { - await this.client.updateCollection(collectionName, updates); - return true; - } catch (error) { - const errorMessage = this.handleError(error); - const rootMessage = this.extractRootErrorMessage(errorMessage); - throw new Error(`Failed to update collection ${collectionName}: ${rootMessage}`); - } - } - - /** - * Creates an alias for a collection - */ - async createAlias(aliasName: string, collectionName: string): Promise { - try { - await this.client.updateCollectionAliases({ - actions: [{ create_alias: { alias_name: aliasName, collection_name: collectionName } }], - }); - return true; - } catch (error) { - const errorMessage = this.handleError(error); - const rootMessage = this.extractRootErrorMessage(errorMessage); - throw new Error(`Failed to create alias ${aliasName} for collection ${collectionName}: ${rootMessage}`); - } - } - - /** - * Deletes an alias - */ - async deleteAlias(aliasName: string): Promise { - try { - await this.client.updateCollectionAliases({ - actions: [{ delete_alias: { alias_name: aliasName } }], - }); - return true; - } catch (error) { - const errorMessage = this.handleError(error); - const rootMessage = this.extractRootErrorMessage(errorMessage); - throw new Error(`Failed to delete alias ${aliasName}: ${rootMessage}`); - } - } - - /** - * Lists all aliases - */ - async listAliases(): Promise { - try { - const response = await this.client.getAliases(); - return response.aliases || []; - } catch (error) { - const errorMessage = this.handleError(error); - const rootMessage = this.extractRootErrorMessage(errorMessage); - throw new Error(`Failed to list aliases: ${rootMessage}`); - } - } - - /** - * Gets collection statistics - */ - async getStats(collectionName: string): Promise { - try { - const info = await this.getInfo(collectionName); - return { - vectors_count: info.vectors_count, - indexed_vectors_count: info.indexed_vectors_count, - points_count: info.points_count, - segments_count: info.segments_count, - status: info.status, - optimizer_status: info.optimizer_status, - }; - } catch (error) { - const err = error as Error; - // Extract the root error message to avoid nesting - const rootMessage = this.extractRootErrorMessage(err.message); - throw new Error(`Failed to get collection stats for ${collectionName}: ${rootMessage}`); - } - } - - /** - * Recreates a collection with new configuration - */ - async recreate( - collectionName: string, - newConfig: CollectionCreateRequest - ): Promise { - try { - // Delete existing collection - await this.delete(collectionName); - - // Create new collection with updated config - await this.create({ - ...newConfig, - name: collectionName, - }); - - return true; - } catch (error) { - const err = error as Error; - // Extract the root error message to avoid nesting - const rootMessage = this.extractRootErrorMessage(err.message); - throw new Error(`Failed to recreate collection ${collectionName}: ${rootMessage}`); - } - } - - /** - * Extracts the root error message from potentially nested error messages - */ - private extractRootErrorMessage(message: string): string { - // Handle string errors that aren't Error objects - if (!message || typeof message !== 'string') { - return 'Unknown error'; - } - - // Remove nested "Failed to..." prefixes to get to the root cause - // Example: "Failed to get collection info for test: Stats failed" -> "Stats failed" - const patterns = [ - /Failed to create collection [^:]+: (.+)/, - /Failed to delete collection [^:]+: (.+)/, - /Failed to get collection info for [^:]+: (.+)/, - /Failed to get collection stats for [^:]+: (.+)/, - /Failed to recreate collection [^:]+: (.+)/, - ]; - - for (const pattern of patterns) { - const match = message.match(pattern); - if (match) { - // Recursively extract in case of multiple layers - return this.extractRootErrorMessage(match[1]); - } - } - - return message; - } - - /** - * Handles error casting and message extraction - */ - private handleError(error: unknown): string { - if (error instanceof Error) { - return error.message; - } - if (typeof error === 'string') { - return error; - } - return 'Unknown error'; - } -} \ No newline at end of file diff --git a/packages/core/src/vector/qdrant/EmbeddingManager.ts b/packages/core/src/vector/qdrant/EmbeddingManager.ts deleted file mode 100644 index 19c962e..0000000 --- a/packages/core/src/vector/qdrant/EmbeddingManager.ts +++ /dev/null @@ -1,395 +0,0 @@ -import { VectorConfig } from '../../config/VectorConfig'; -import { - EmbeddingProvider, - EmbeddingRequest, - EmbeddingResponse, -} from 'symbi-types'; - -/** - * Manages embedding generation and storage - */ -export class EmbeddingManager { - private providers: Map = new Map(); - - constructor(private config: VectorConfig) {} - - /** - * Registers an embedding provider - */ - registerProvider(name: string, provider: EmbeddingProvider): void { - this.providers.set(name, provider); - } - - /** - * Gets a registered embedding provider - */ - getProvider(name: string): EmbeddingProvider | undefined { - return this.providers.get(name); - } - - /** - * Lists all registered providers - */ - listProviders(): string[] { - return Array.from(this.providers.keys()); - } - - /** - * Generates embeddings using OpenAI API - */ - async generateWithOpenAI( - text: string, - options?: { - model?: string; - apiKey?: string; - baseUrl?: string; - } - ): Promise { - try { - const model = options?.model || 'text-embedding-ada-002'; - const apiKey = options?.apiKey || process.env.OPENAI_API_KEY; - const baseUrl = options?.baseUrl || 'https://api.openai.com/v1'; - - if (!apiKey) { - throw new Error('OpenAI API key is required'); - } - - const response = await fetch(`${baseUrl}/embeddings`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - input: text, - model, - }), - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(`OpenAI API error: ${error.error?.message || response.statusText}`); - } - - const data = await response.json(); - const embedding = data.data[0]?.embedding; - - if (!embedding) { - throw new Error('No embedding returned from OpenAI API'); - } - - return { - embedding, - model, - usage: data.usage ? { - prompt_tokens: data.usage.prompt_tokens, - total_tokens: data.usage.total_tokens, - } : undefined, - }; - } catch (error) { - const err = error as Error; - throw new Error(`Failed to generate OpenAI embedding: ${err.message}`); - } - } - - /** - * Generates embeddings using Hugging Face API - */ - async generateWithHuggingFace( - text: string, - options?: { - model?: string; - apiKey?: string; - baseUrl?: string; - } - ): Promise { - try { - const model = options?.model || 'sentence-transformers/all-MiniLM-L6-v2'; - const apiKey = options?.apiKey || process.env.HUGGINGFACE_API_KEY; - const baseUrl = options?.baseUrl || 'https://api-inference.huggingface.co'; - - if (!apiKey) { - throw new Error('Hugging Face API key is required'); - } - - const response = await fetch(`${baseUrl}/pipeline/feature-extraction/${model}`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - inputs: text, - }), - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(`Hugging Face API error: ${error.error || response.statusText}`); - } - - const embedding = await response.json(); - - if (!Array.isArray(embedding) || embedding.length === 0) { - throw new Error('Invalid embedding format from Hugging Face API'); - } - - // Hugging Face returns nested arrays, flatten if needed - const flatEmbedding = Array.isArray(embedding[0]) ? embedding[0] : embedding; - - return { - embedding: flatEmbedding, - model, - }; - } catch (error) { - const err = error as Error; - throw new Error(`Failed to generate Hugging Face embedding: ${err.message}`); - } - } - - /** - * Generates embeddings using Cohere API - */ - async generateWithCohere( - text: string, - options?: { - model?: string; - apiKey?: string; - baseUrl?: string; - inputType?: 'search_document' | 'search_query' | 'classification' | 'clustering'; - } - ): Promise { - try { - const model = options?.model || 'embed-english-v3.0'; - const apiKey = options?.apiKey || process.env.COHERE_API_KEY; - const baseUrl = options?.baseUrl || 'https://api.cohere.ai/v1'; - const inputType = options?.inputType || 'search_document'; - - if (!apiKey) { - throw new Error('Cohere API key is required'); - } - - const response = await fetch(`${baseUrl}/embed`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - texts: [text], - model, - input_type: inputType, - }), - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(`Cohere API error: ${error.message || response.statusText}`); - } - - const data = await response.json(); - const embedding = data.embeddings?.[0]; - - if (!embedding) { - throw new Error('No embedding returned from Cohere API'); - } - - return { - embedding, - model, - usage: data.meta?.billed_units ? { - prompt_tokens: data.meta.billed_units.input_tokens || 0, - total_tokens: data.meta.billed_units.input_tokens || 0, - } : undefined, - }; - } catch (error) { - const err = error as Error; - throw new Error(`Failed to generate Cohere embedding: ${err.message}`); - } - } - - /** - * Generates embeddings using a custom provider function - */ - async generateWithCustomProvider( - providerName: string, - text: string, - customFunction: (text: string, provider: EmbeddingProvider) => Promise - ): Promise { - try { - const provider = this.providers.get(providerName); - if (!provider) { - throw new Error(`Provider '${providerName}' not found`); - } - - const embedding = await customFunction(text, provider); - - return { - embedding, - model: provider.model, - }; - } catch (error) { - const err = error as Error; - throw new Error(`Failed to generate embedding with custom provider '${providerName}': ${err.message}`); - } - } - - /** - * Generates embeddings using the default or specified provider - */ - async generate( - request: EmbeddingRequest, - providerName?: string - ): Promise { - try { - // Use specified provider or try to determine from environment - if (providerName) { - const provider = this.providers.get(providerName); - if (!provider) { - throw new Error(`Provider '${providerName}' not found`); - } - return this.generateWithProvider(request.text, provider); - } - - // Try OpenAI first if API key is available - if (process.env.OPENAI_API_KEY) { - return this.generateWithOpenAI(request.text, { model: request.model }); - } - - // Try Cohere if API key is available - if (process.env.COHERE_API_KEY) { - return this.generateWithCohere(request.text, { model: request.model }); - } - - // Try Hugging Face if API key is available - if (process.env.HUGGINGFACE_API_KEY) { - return this.generateWithHuggingFace(request.text, { model: request.model }); - } - - throw new Error('No embedding provider configured. Please set up an API key or register a custom provider.'); - } catch (error) { - const err = error as Error; - throw new Error(`Failed to generate embedding: ${err.message}`); - } - } - - /** - * Generates embeddings in batch - */ - async generateBatch( - texts: string[], - options?: { - providerName?: string; - model?: string; - batchSize?: number; - } - ): Promise { - try { - const batchSize = options?.batchSize || 10; - const results: EmbeddingResponse[] = []; - - // Process in batches to avoid rate limits - for (let i = 0; i < texts.length; i += batchSize) { - const batch = texts.slice(i, i + batchSize); - const batchPromises = batch.map(text => - this.generate({ text, model: options?.model }, options?.providerName) - ); - - const batchResults = await Promise.all(batchPromises); - results.push(...batchResults); - - // Add delay between batches to respect rate limits - if (i + batchSize < texts.length) { - await new Promise(resolve => setTimeout(resolve, 100)); - } - } - - return results; - } catch (error) { - const err = error as Error; - throw new Error(`Failed to generate batch embeddings: ${err.message}`); - } - } - - /** - * Generates embeddings using a specific provider configuration - */ - private async generateWithProvider( - text: string, - provider: EmbeddingProvider - ): Promise { - switch (provider.provider) { - case 'openai': - return this.generateWithOpenAI(text, { - model: provider.model, - apiKey: provider.apiKey, - baseUrl: provider.baseUrl, - }); - - case 'huggingface': - return this.generateWithHuggingFace(text, { - model: provider.model, - apiKey: provider.apiKey, - baseUrl: provider.baseUrl, - }); - - case 'cohere': - return this.generateWithCohere(text, { - model: provider.model, - apiKey: provider.apiKey, - baseUrl: provider.baseUrl, - }); - - case 'custom': - throw new Error('Custom providers must be handled via generateWithCustomProvider method'); - - default: - throw new Error(`Unsupported provider: ${provider.provider}`); - } - } - - /** - * Validates an embedding vector - */ - validateEmbedding(embedding: number[], expectedDimension?: number): boolean { - if (!Array.isArray(embedding) || embedding.length === 0) { - return false; - } - - if (expectedDimension && embedding.length !== expectedDimension) { - return false; - } - - return embedding.every(value => typeof value === 'number' && !isNaN(value)); - } - - /** - * Normalizes an embedding vector to unit length - */ - normalizeEmbedding(embedding: number[]): number[] { - const magnitude = Math.sqrt(embedding.reduce((sum, val) => sum + val * val, 0)); - if (magnitude === 0) { - return embedding; - } - return embedding.map(val => val / magnitude); - } - - /** - * Calculates cosine similarity between two embeddings - */ - cosineSimilarity(embedding1: number[], embedding2: number[]): number { - if (embedding1.length !== embedding2.length) { - throw new Error('Embeddings must have the same dimension'); - } - - const dotProduct = embedding1.reduce((sum, val, i) => sum + val * embedding2[i], 0); - const magnitude1 = Math.sqrt(embedding1.reduce((sum, val) => sum + val * val, 0)); - const magnitude2 = Math.sqrt(embedding2.reduce((sum, val) => sum + val * val, 0)); - - if (magnitude1 === 0 || magnitude2 === 0) { - return 0; - } - - return dotProduct / (magnitude1 * magnitude2); - } -} \ No newline at end of file diff --git a/packages/core/src/vector/qdrant/QdrantManager.ts b/packages/core/src/vector/qdrant/QdrantManager.ts deleted file mode 100644 index 80c2bdb..0000000 --- a/packages/core/src/vector/qdrant/QdrantManager.ts +++ /dev/null @@ -1,233 +0,0 @@ -import { QdrantClient } from '@qdrant/qdrant-js'; -import { VectorConfig } from '../../config/VectorConfig'; -import { CollectionManager } from './CollectionManager'; -import { VectorOperations } from './VectorOperations'; -import { SearchEngine } from './SearchEngine'; -import { EmbeddingManager } from './EmbeddingManager'; - -/** - * Main class for interacting with Qdrant vector database - */ -export class QdrantManager { - private client: QdrantClient; - private config: VectorConfig; - public collections: CollectionManager; - public vectors: VectorOperations; - public search: SearchEngine; - public embeddings: EmbeddingManager; - - constructor(config: VectorConfig) { - this.config = config; - this.client = this.createClient(); - - // Initialize sub-managers - this.collections = new CollectionManager(this.client, config); - this.vectors = new VectorOperations(this.client, config); - this.search = new SearchEngine(this.client, config); - this.embeddings = new EmbeddingManager(config); - } - - /** - * Creates and configures the Qdrant client - */ - private createClient(): QdrantClient { - const qdrantConfig = this.config.qdrant; - - if (!qdrantConfig) { - throw new Error('Qdrant configuration is required'); - } - - const clientConfig: any = { - host: qdrantConfig.host, - port: qdrantConfig.port, - }; - - if (qdrantConfig.apiKey) { - clientConfig.apiKey = qdrantConfig.apiKey; - } - - if (qdrantConfig.https) { - clientConfig.https = true; - } - - if (qdrantConfig.prefix) { - clientConfig.prefix = qdrantConfig.prefix; - } - - // Use gRPC if preferred and grpcPort is available - if (qdrantConfig.preferGrpc && qdrantConfig.grpcPort) { - clientConfig.port = qdrantConfig.grpcPort; - clientConfig.grpc = true; - } - - return new QdrantClient(clientConfig); - } - - /** - * Tests the connection to Qdrant - */ - async testConnection(): Promise { - try { - await this.client.getCollections(); - return true; - } catch (error) { - console.error('Qdrant connection test failed:', error); - return false; - } - } - - /** - * Gets Qdrant cluster information - */ - async getClusterInfo(): Promise { - try { - // Return basic cluster info using collections endpoint - const collections = await this.client.getCollections(); - return { - cluster_status: 'enabled', - peer_id: null, - collections_count: collections.collections.length - }; - } catch (error) { - const err = error as Error; - throw new Error(`Failed to get cluster info: ${err.message}`); - } - } - - /** - * Gets health status of the Qdrant instance - */ - async getHealth(): Promise { - try { - // Test connection by listing collections - await this.client.getCollections(); - return true; - } catch (error) { - console.error('Health check failed:', error); - return false; - } - } - - /** - * Gets metrics from Qdrant instance - */ - async getMetrics(): Promise { - try { - // Get basic metrics using collections info - const collections = await this.client.getCollections(); - return JSON.stringify({ - collections_count: collections.collections.length, - collections: collections.collections.map(c => ({ - name: c.name - })) - }); - } catch (error) { - const err = error as Error; - throw new Error(`Failed to get metrics: ${err.message}`); - } - } - - /** - * Creates a snapshot of a collection - */ - async createSnapshot(collectionName: string): Promise<{ name: string }> { - try { - const result = await this.client.createSnapshot(collectionName); - if (!result) { - throw new Error('Snapshot creation returned null'); - } - return { name: result.name }; - } catch (error) { - const err = error as Error; - throw new Error(`Failed to create snapshot for collection ${collectionName}: ${err.message}`); - } - } - - /** - * Lists all snapshots for a collection - */ - async listSnapshots(collectionName: string): Promise { - try { - const result = await this.client.listSnapshots(collectionName); - return Array.isArray(result) ? result : []; - } catch (error) { - const err = error as Error; - throw new Error(`Failed to list snapshots for collection ${collectionName}: ${err.message}`); - } - } - - /** - * Deletes a snapshot - */ - async deleteSnapshot(collectionName: string, snapshotName: string): Promise { - try { - await this.client.deleteSnapshot(collectionName, snapshotName); - return true; - } catch (error) { - const err = error as Error; - throw new Error(`Failed to delete snapshot ${snapshotName} for collection ${collectionName}: ${err.message}`); - } - } - - /** - * Recovers a collection from a snapshot - */ - async recoverFromSnapshot( - collectionName: string, - snapshotLocation: string, - options?: { - priority?: 'replica' | 'snapshot'; - checksum?: string; - } - ): Promise { - try { - // Recovery from snapshot is not yet supported in this client version - // This would typically require direct API calls to Qdrant - throw new Error(`Snapshot recovery is not yet implemented in the current Qdrant client. Collection: ${collectionName}, Location: ${snapshotLocation}`); - } catch (error) { - const err = error as Error; - throw new Error(`Failed to recover collection ${collectionName} from snapshot: ${err.message}`); - } - } - - /** - * Gets the Qdrant client instance for advanced operations - */ - getClient(): QdrantClient { - return this.client; - } - - /** - * Gets the current configuration - */ - getConfig(): VectorConfig { - return this.config; - } - - /** - * Updates the configuration and recreates the client - */ - updateConfig(newConfig: Partial): void { - this.config = { ...this.config, ...newConfig }; - this.client = this.createClient(); - - // Update sub-managers with new client and config - this.collections = new CollectionManager(this.client, this.config); - this.vectors = new VectorOperations(this.client, this.config); - this.search = new SearchEngine(this.client, this.config); - this.embeddings = new EmbeddingManager(this.config); - } - - /** - * Closes the connection to Qdrant - */ - async close(): Promise { - try { - // The Qdrant client doesn't have an explicit close method - // but we can clean up our references - this.client = null as any; - } catch (error) { - console.error('Error closing Qdrant connection:', error); - } - } -} \ No newline at end of file diff --git a/packages/core/src/vector/qdrant/SearchEngine.ts b/packages/core/src/vector/qdrant/SearchEngine.ts deleted file mode 100644 index 4aab95c..0000000 --- a/packages/core/src/vector/qdrant/SearchEngine.ts +++ /dev/null @@ -1,354 +0,0 @@ -import { QdrantClient } from '@qdrant/qdrant-js'; -import { VectorConfig } from '../../config/VectorConfig'; -import { - SearchOptions, - SearchResult, - VectorPoint, -} from 'symbi-types'; - -/** - * Handles semantic search operations in Qdrant - */ -export class SearchEngine { - constructor( - private client: QdrantClient, - private config: VectorConfig - ) {} - - /** - * Performs similarity search using a vector - */ - async searchByVector( - collectionName: string, - vector: number[], - options?: SearchOptions - ): Promise { - try { - const searchOptions = { - limit: options?.limit ?? this.config.defaultLimit ?? 10, - offset: options?.offset ?? 0, - with_payload: options?.with_payload ?? this.config.defaultWithPayload ?? true, - with_vector: options?.with_vector ?? this.config.defaultWithVectors ?? false, - score_threshold: options?.score_threshold, - filter: options?.filter, - params: options?.params, - }; - - const result = await this.client.search(collectionName, { - vector, - ...searchOptions, - }); - - return result.map((point: any) => ({ - id: point.id as string, - score: point.score, - payload: point.payload, - vector: point.vector, - })); - } catch (error) { - const err = error as Error; - throw new Error(`Failed to search by vector in collection ${collectionName}: ${err.message}`); - } - } - - /** - * Performs similarity search using a point ID - */ - async searchByPointId( - collectionName: string, - pointId: string, - options?: SearchOptions - ): Promise { - try { - const searchOptions = { - limit: options?.limit ?? this.config.defaultLimit ?? 10, - offset: options?.offset ?? 0, - with_payload: options?.with_payload ?? this.config.defaultWithPayload ?? true, - with_vector: options?.with_vector ?? this.config.defaultWithVectors ?? false, - score_threshold: options?.score_threshold, - filter: options?.filter, - params: options?.params, - }; - - // Retrieve the source point's vector, then do a normal vector search. - // The qdrant-js search endpoint no longer accepts { id } as a vector - // reference; recommendPoints is available but has different semantics. - const sourcePoints = await this.client.retrieve(collectionName, { - ids: [pointId], - with_vector: true, - with_payload: false, - }); - const sourceVector = sourcePoints[0]?.vector; - if (!sourceVector || typeof sourceVector !== 'object' || Array.isArray(sourceVector) === false) { - throw new Error(`Point ${pointId} not found or has no retrievable vector`); - } - - const result = await this.client.search(collectionName, { - vector: sourceVector as number[], - ...searchOptions, - }); - - return result.map((point: any) => ({ - id: point.id as string, - score: point.score, - payload: point.payload, - vector: point.vector, - })); - } catch (error) { - const err = error as Error; - throw new Error(`Failed to search by point ID ${pointId} in collection ${collectionName}: ${err.message}`); - } - } - - /** - * Performs batch similarity search with multiple vectors - */ - async searchBatch( - collectionName: string, - vectors: number[][], - options?: SearchOptions - ): Promise { - try { - const searchOptions = { - limit: options?.limit ?? this.config.defaultLimit ?? 10, - offset: options?.offset ?? 0, - with_payload: options?.with_payload ?? this.config.defaultWithPayload ?? true, - with_vector: options?.with_vector ?? this.config.defaultWithVectors ?? false, - score_threshold: options?.score_threshold, - filter: options?.filter, - params: options?.params, - }; - - const searches = vectors.map(vector => ({ - vector, - ...searchOptions, - })); - - const result = await this.client.searchBatch(collectionName, { searches }); - - return result.map((searchResult: any[]) => - searchResult.map((point: any) => ({ - id: point.id as string, - score: point.score, - payload: point.payload, - vector: point.vector, - })) - ); - } catch (error) { - const err = error as Error; - throw new Error(`Failed to perform batch search in collection ${collectionName}: ${err.message}`); - } - } - - /** - * Searches for points with specific payload conditions - */ - async searchByFilter( - collectionName: string, - filter: Record, - options?: Omit - ): Promise { - try { - const searchOptions = { - limit: options?.limit ?? this.config.defaultLimit ?? 10, - offset: options?.offset ?? 0, - with_payload: options?.with_payload ?? this.config.defaultWithPayload ?? true, - with_vector: options?.with_vector ?? this.config.defaultWithVectors ?? false, - }; - - const result = await this.client.scroll(collectionName, { - filter, - ...searchOptions, - }); - - return result.points.map((point: any) => ({ - id: point.id as string, - vector: point.vector || [], - payload: point.payload, - })); - } catch (error) { - const err = error as Error; - throw new Error(`Failed to search by filter in collection ${collectionName}: ${err.message}`); - } - } - - /** - * Performs approximate nearest neighbor search with custom parameters - */ - async searchWithParams( - collectionName: string, - vector: number[], - params: { - hnsw_ef?: number; - exact?: boolean; - }, - options?: SearchOptions - ): Promise { - try { - const searchOptions = { - limit: options?.limit ?? this.config.defaultLimit ?? 10, - offset: options?.offset ?? 0, - with_payload: options?.with_payload ?? this.config.defaultWithPayload ?? true, - with_vector: options?.with_vector ?? this.config.defaultWithVectors ?? false, - score_threshold: options?.score_threshold, - filter: options?.filter, - params, - }; - - const result = await this.client.search(collectionName, { - vector, - ...searchOptions, - }); - - return result.map((point: any) => ({ - id: point.id as string, - score: point.score, - payload: point.payload, - vector: point.vector, - })); - } catch (error) { - const err = error as Error; - throw new Error(`Failed to search with custom params in collection ${collectionName}: ${err.message}`); - } - } - - /** - * Finds recommendations based on positive and negative examples - */ - async recommend( - collectionName: string, - positive: Array, - negative: Array = [], - options?: SearchOptions - ): Promise { - try { - const searchOptions = { - limit: options?.limit ?? this.config.defaultLimit ?? 10, - offset: options?.offset ?? 0, - with_payload: options?.with_payload ?? this.config.defaultWithPayload ?? true, - with_vector: options?.with_vector ?? this.config.defaultWithVectors ?? false, - score_threshold: options?.score_threshold, - filter: options?.filter, - params: options?.params, - }; - - // qdrant-js accepts IDs directly (string | number) or raw vectors — not - // wrapped in { id } objects — in recommend positive/negative arrays. - const result = await this.client.recommend(collectionName, { - positive, - negative, - ...searchOptions, - }); - - return result.map((point: any) => ({ - id: point.id as string, - score: point.score, - payload: point.payload, - vector: point.vector, - })); - } catch (error) { - const err = error as Error; - throw new Error(`Failed to get recommendations in collection ${collectionName}: ${err.message}`); - } - } - - /** - * Discovers similar points to a set of positive examples - */ - async discover( - collectionName: string, - target: string | number[], - context: Array<{ - positive?: Array; - negative?: Array; - }>, - options?: SearchOptions - ): Promise { - try { - const searchOptions = { - limit: options?.limit ?? this.config.defaultLimit ?? 10, - offset: options?.offset ?? 0, - with_payload: options?.with_payload ?? this.config.defaultWithPayload ?? true, - with_vector: options?.with_vector ?? this.config.defaultWithVectors ?? false, - score_threshold: options?.score_threshold, - filter: options?.filter, - params: options?.params, - }; - - // qdrant-js renamed `discover` → `discoverPoints` and accepts - // IDs directly (string | number) instead of `{ id }` wrappers. - // discoverPoints takes flat positive/negative arrays; flatten the - // caller-supplied context pairs accordingly. - const flatPositive = context.flatMap(ctx => ctx.positive ?? []); - const flatNegative = context.flatMap(ctx => ctx.negative ?? []); - - const result = await this.client.discoverPoints(collectionName, { - target, - context: flatPositive.map((p, i) => ({ - positive: p, - negative: flatNegative[i], - })), - ...searchOptions, - } as any); - - return result.map((point: any) => ({ - id: point.id as string, - score: point.score, - payload: point.payload, - vector: point.vector, - })); - } catch (error) { - const err = error as Error; - throw new Error(`Failed to discover points in collection ${collectionName}: ${err.message}`); - } - } - - /** - * Performs hybrid search combining dense and sparse vectors - */ - async hybridSearch( - collectionName: string, - denseVector?: number[], - sparseVector?: { indices: number[]; values: number[] }, - options?: SearchOptions - ): Promise { - try { - if (!denseVector && !sparseVector) { - throw new Error('Either dense or sparse vector must be provided'); - } - - const searchOptions = { - limit: options?.limit ?? this.config.defaultLimit ?? 10, - offset: options?.offset ?? 0, - with_payload: options?.with_payload ?? this.config.defaultWithPayload ?? true, - with_vector: options?.with_vector ?? this.config.defaultWithVectors ?? false, - score_threshold: options?.score_threshold, - filter: options?.filter, - params: options?.params, - }; - - const queryVector: any = {}; - if (denseVector) { - queryVector.dense = denseVector; - } - if (sparseVector) { - queryVector.sparse = sparseVector; - } - - const result = await this.client.search(collectionName, { - vector: queryVector, - ...searchOptions, - }); - - return result.map((point: any) => ({ - id: point.id as string, - score: point.score, - payload: point.payload, - vector: point.vector, - })); - } catch (error) { - const err = error as Error; - throw new Error(`Failed to perform hybrid search in collection ${collectionName}: ${err.message}`); - } - } -} \ No newline at end of file diff --git a/packages/core/src/vector/qdrant/VectorOperations.ts b/packages/core/src/vector/qdrant/VectorOperations.ts deleted file mode 100644 index a7baa82..0000000 --- a/packages/core/src/vector/qdrant/VectorOperations.ts +++ /dev/null @@ -1,363 +0,0 @@ -import { QdrantClient } from '@qdrant/qdrant-js'; -import { VectorConfig } from '../../config/VectorConfig'; -import { - VectorPoint, - BatchOperationResult, - PointInsertRequest, - PointUpdateRequest, - PointDeleteRequest, - ScrollRequest, - ScrollResponse, -} from 'symbi-types'; - -/** - * Handles CRUD operations on vectors in Qdrant - */ -export class VectorOperations { - constructor( - private client: QdrantClient, - private config: VectorConfig - ) {} - - /** - * Inserts a single vector point - */ - async insertPoint( - collectionName: string, - point: VectorPoint, - options?: { - wait?: boolean; - ordering?: 'weak' | 'medium' | 'strong'; - } - ): Promise { - try { - const result = await this.client.upsert(collectionName, { - wait: options?.wait ?? true, - ordering: options?.ordering, - points: [ - { - id: point.id, - vector: point.vector, - payload: point.payload, - }, - ], - }); - - return { - operation_id: result.operation_id || 0, - status: result.status || 'acknowledged', - }; - } catch (error) { - const err = error as Error; - throw new Error(`Failed to insert point ${point.id} in collection ${collectionName}: ${err.message}`); - } - } - - /** - * Inserts multiple vector points in batch - */ - async insertPoints( - collectionName: string, - request: PointInsertRequest - ): Promise { - try { - const batchSize = this.config.batchSize || 100; - const points = request.points; - - // Process in batches if necessary - if (points.length <= batchSize) { - const result = await this.client.upsert(collectionName, { - wait: request.wait, - ordering: request.ordering, - points: points.map(point => ({ - id: point.id, - vector: point.vector, - payload: point.payload, - })), - }); - - return { - operation_id: result.operation_id || 0, - status: result.status || 'acknowledged', - }; - } - - // Handle large batches - let lastResult: any = { operation_id: 0, status: 'acknowledged' }; - for (let i = 0; i < points.length; i += batchSize) { - const batch = points.slice(i, i + batchSize); - lastResult = await this.client.upsert(collectionName, { - wait: request.wait, - ordering: request.ordering, - points: batch.map(point => ({ - id: point.id, - vector: point.vector, - payload: point.payload, - })), - }); - } - - return { - operation_id: lastResult.operation_id || 0, - status: lastResult.status || 'acknowledged', - }; - } catch (error) { - const err = error as Error; - throw new Error(`Failed to insert points in collection ${collectionName}: ${err.message}`); - } - } - - /** - * Updates existing vector points - */ - async updatePoints( - collectionName: string, - request: PointUpdateRequest - ): Promise { - try { - const result = await this.client.upsert(collectionName, { - wait: request.wait, - ordering: request.ordering, - points: request.points.map(point => ({ - id: point.id, - vector: point.vector, - payload: point.payload, - })), - }); - - return { - operation_id: result.operation_id || 0, - status: result.status || 'acknowledged', - }; - } catch (error) { - const err = error as Error; - throw new Error(`Failed to update points in collection ${collectionName}: ${err.message}`); - } - } - - /** - * Deletes vector points by IDs - */ - async deletePoints( - collectionName: string, - request: PointDeleteRequest - ): Promise { - try { - const result = await this.client.delete(collectionName, { - wait: request.wait, - ordering: request.ordering, - points: request.ids, - }); - - return { - operation_id: result.operation_id || 0, - status: result.status || 'acknowledged', - }; - } catch (error) { - const err = error as Error; - throw new Error(`Failed to delete points in collection ${collectionName}: ${err.message}`); - } - } - - /** - * Retrieves a single vector point by ID - */ - async getPoint( - collectionName: string, - pointId: string, - options?: { - with_payload?: boolean; - with_vector?: boolean; - } - ): Promise { - try { - const result = await this.client.retrieve(collectionName, { - ids: [pointId], - with_payload: options?.with_payload ?? true, - with_vector: options?.with_vector ?? false, - }); - - if (result.length === 0) { - return null; - } - - const point = result[0]; - return { - id: String(point.id), - vector: Array.isArray(point.vector) ? (point.vector as number[]) : [], - payload: (point.payload ?? undefined) as Record | undefined, - }; - } catch (error) { - const err = error as Error; - throw new Error(`Failed to get point ${pointId} from collection ${collectionName}: ${err.message}`); - } - } - - /** - * Retrieves multiple vector points by IDs - */ - async getPoints( - collectionName: string, - pointIds: string[], - options?: { - with_payload?: boolean; - with_vector?: boolean; - } - ): Promise { - try { - const result = await this.client.retrieve(collectionName, { - ids: pointIds, - with_payload: options?.with_payload ?? true, - with_vector: options?.with_vector ?? false, - }); - - return result.map((point: any) => ({ - id: point.id as string, - vector: point.vector || [], - payload: point.payload, - })); - } catch (error) { - const err = error as Error; - throw new Error(`Failed to get points from collection ${collectionName}: ${err.message}`); - } - } - - /** - * Scrolls through all points in a collection with pagination - */ - async scrollPoints( - collectionName: string, - request: ScrollRequest - ): Promise { - try { - const result = await this.client.scroll(collectionName, { - offset: request.offset, - limit: request.limit, - with_payload: request.with_payload, - with_vector: request.with_vector, - filter: request.filter, - }); - - return { - points: result.points.map((point: any) => ({ - id: String(point.id), - vector: Array.isArray(point.vector) ? (point.vector as number[]) : [], - payload: (point.payload ?? undefined) as Record | undefined, - })), - next_page_offset: - result.next_page_offset != null ? String(result.next_page_offset) : undefined, - }; - } catch (error) { - const err = error as Error; - throw new Error(`Failed to scroll points in collection ${collectionName}: ${err.message}`); - } - } - - /** - * Updates payload for existing points - */ - async updatePayload( - collectionName: string, - pointIds: string[], - payload: Record, - options?: { - wait?: boolean; - ordering?: 'weak' | 'medium' | 'strong'; - } - ): Promise { - try { - const result = await this.client.setPayload(collectionName, { - wait: options?.wait ?? true, - ordering: options?.ordering, - payload, - points: pointIds, - }); - - return { - operation_id: result.operation_id || 0, - status: result.status || 'acknowledged', - }; - } catch (error) { - const err = error as Error; - throw new Error(`Failed to update payload for points in collection ${collectionName}: ${err.message}`); - } - } - - /** - * Deletes specific payload keys from points - */ - async deletePayload( - collectionName: string, - pointIds: string[], - keys: string[], - options?: { - wait?: boolean; - ordering?: 'weak' | 'medium' | 'strong'; - } - ): Promise { - try { - const result = await this.client.deletePayload(collectionName, { - wait: options?.wait ?? true, - ordering: options?.ordering, - keys, - points: pointIds, - }); - - return { - operation_id: result.operation_id || 0, - status: result.status || 'acknowledged', - }; - } catch (error) { - const err = error as Error; - throw new Error(`Failed to delete payload keys for points in collection ${collectionName}: ${err.message}`); - } - } - - /** - * Clears all payload from points - */ - async clearPayload( - collectionName: string, - pointIds: string[], - options?: { - wait?: boolean; - ordering?: 'weak' | 'medium' | 'strong'; - } - ): Promise { - try { - const result = await this.client.clearPayload(collectionName, { - wait: options?.wait ?? true, - ordering: options?.ordering, - points: pointIds, - }); - - return { - operation_id: result.operation_id || 0, - status: result.status || 'acknowledged', - }; - } catch (error) { - const err = error as Error; - throw new Error(`Failed to clear payload for points in collection ${collectionName}: ${err.message}`); - } - } - - /** - * Counts points in a collection with optional filter - */ - async countPoints( - collectionName: string, - filter?: Record - ): Promise { - try { - const result = await this.client.count(collectionName, { - filter, - exact: true, - }); - - return result.count; - } catch (error) { - const err = error as Error; - throw new Error(`Failed to count points in collection ${collectionName}: ${err.message}`); - } - } -} \ No newline at end of file diff --git a/packages/core/src/vector/qdrant/__tests__/CollectionManager.test.ts b/packages/core/src/vector/qdrant/__tests__/CollectionManager.test.ts deleted file mode 100644 index aa49d7f..0000000 --- a/packages/core/src/vector/qdrant/__tests__/CollectionManager.test.ts +++ /dev/null @@ -1,568 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { CollectionManager } from '../CollectionManager'; -import { VectorConfig } from '../../../config/VectorConfig'; - -// Define the interface locally for testing -interface CollectionCreateRequest { - name: string; - vectors: { - size: number; - distance: 'Cosine' | 'Dot' | 'Euclid' | 'Manhattan'; - hnsw_config?: { - m?: number; - ef_construct?: number; - full_scan_threshold?: number; - max_indexing_threads?: number; - on_disk?: boolean; - payload_m?: number; - }; - quantization_config?: any; - on_disk?: boolean; - }; - shard_number?: number; - replication_factor?: number; - write_consistency_factor?: number; - on_disk_payload?: boolean; - hnsw_config?: any; - optimizer_config?: any; - wal_config?: any; -} - -describe('CollectionManager', () => { - let mockClient: any; - let config: VectorConfig; - let collectionManager: CollectionManager; - - beforeEach(() => { - mockClient = { - createCollection: vi.fn(), - getCollections: vi.fn(), - getCollection: vi.fn(), - deleteCollection: vi.fn(), - updateCollection: vi.fn(), - updateCollectionAliases: vi.fn(), - getAliases: vi.fn(), - }; - - config = { - provider: 'qdrant', - timeout: 60000, - qdrant: { - host: 'localhost', - port: 6333, - grpcPort: 6334, - preferGrpc: false, - https: false, - }, - collections: {}, - batchSize: 100, - maxRetries: 3, - retryDelayMs: 1000, - defaultLimit: 10, - defaultWithPayload: true, - defaultWithVectors: false, - parallelism: 1, - connectionPoolSize: 10, - }; - - collectionManager = new CollectionManager(mockClient, config); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - describe('create', () => { - const mockCreateRequest: CollectionCreateRequest = { - name: 'test-collection', - vectors: { - size: 384, - distance: 'Cosine', - }, - shard_number: 1, - replication_factor: 1, - write_consistency_factor: 1, - on_disk_payload: false, - }; - - it('should create collection successfully', async () => { - mockClient.createCollection.mockResolvedValue({}); - - const result = await collectionManager.create(mockCreateRequest); - - expect(result).toBe(true); - expect(mockClient.createCollection).toHaveBeenCalledWith('test-collection', { - vectors: mockCreateRequest.vectors, - shard_number: mockCreateRequest.shard_number, - replication_factor: mockCreateRequest.replication_factor, - write_consistency_factor: mockCreateRequest.write_consistency_factor, - on_disk_payload: mockCreateRequest.on_disk_payload, - hnsw_config: mockCreateRequest.hnsw_config, - optimizer_config: mockCreateRequest.optimizer_config, - wal_config: mockCreateRequest.wal_config, - }); - }); - - it('should handle collection creation with optional parameters', async () => { - const requestWithOptionals: CollectionCreateRequest = { - ...mockCreateRequest, - hnsw_config: { - m: 16, - ef_construct: 100, - full_scan_threshold: 10000, - max_indexing_threads: 0, - }, - optimizer_config: { - deleted_threshold: 0.2, - vacuum_min_vector_number: 1000, - default_segment_number: 0, - indexing_threshold: 20000, - flush_interval_sec: 5, - }, - wal_config: { - wal_capacity_mb: 32, - wal_segments_ahead: 0, - }, - }; - - mockClient.createCollection.mockResolvedValue({}); - - const result = await collectionManager.create(requestWithOptionals); - - expect(result).toBe(true); - expect(mockClient.createCollection).toHaveBeenCalledWith( - 'test-collection', - expect.objectContaining({ - hnsw_config: requestWithOptionals.hnsw_config, - optimizers_config: requestWithOptionals.optimizer_config, - wal_config: requestWithOptionals.wal_config, - }) - ); - }); - - it('should throw error when collection creation fails', async () => { - mockClient.createCollection.mockRejectedValue(new Error('Creation failed')); - - await expect(collectionManager.create(mockCreateRequest)).rejects.toThrow( - 'Failed to create collection test-collection: Creation failed' - ); - }); - }); - - describe('list', () => { - it('should list collections successfully', async () => { - const mockResponse = { - collections: [ - { - name: 'collection1', - config: { - params: { - vectors: { - size: 384, - distance: 'Cosine', - }, - }, - }, - }, - { - name: 'collection2', - config: { - params: { - vectors: { - size: 512, - distance: 'Dot', - }, - }, - }, - }, - ], - }; - - mockClient.getCollections.mockResolvedValue(mockResponse); - - const result = await collectionManager.list(); - - expect(result).toEqual([ - { - name: 'collection1', - dimension: 384, - distance: 'Cosine', - config: mockResponse.collections[0].config, - }, - { - name: 'collection2', - dimension: 512, - distance: 'Dot', - config: mockResponse.collections[1].config, - }, - ]); - expect(mockClient.getCollections).toHaveBeenCalled(); - }); - - it('should handle collections with missing config data', async () => { - const mockResponse = { - collections: [ - { - name: 'incomplete-collection', - config: {}, - }, - ], - }; - - mockClient.getCollections.mockResolvedValue(mockResponse); - - const result = await collectionManager.list(); - - expect(result).toEqual([ - { - name: 'incomplete-collection', - dimension: 0, - distance: 'Cosine', - config: {}, - }, - ]); - }); - - it('should throw error when listing fails', async () => { - mockClient.getCollections.mockRejectedValue(new Error('List failed')); - - await expect(collectionManager.list()).rejects.toThrow( - 'Failed to list collections: List failed' - ); - }); - }); - - describe('getInfo', () => { - it('should get collection info successfully', async () => { - const mockCollectionInfo = { - name: 'test-collection', - status: 'green', - optimizer_status: 'ok', - vectors_count: 1000, - indexed_vectors_count: 950, - points_count: 1000, - segments_count: 2, - config: { - params: { - vectors: { - size: 384, - distance: 'Cosine', - }, - }, - }, - payload_schema: {}, - }; - - mockClient.getCollection.mockResolvedValue(mockCollectionInfo); - - const result = await collectionManager.getInfo('test-collection'); - - expect(result).toEqual({ - name: 'test-collection', - status: 'green', - optimizer_status: 'ok', - vectors_count: 1000, - indexed_vectors_count: 950, - points_count: 1000, - segments_count: 2, - config: { - params: { - vectors: { size: 384, distance: 'Cosine' }, - shard_number: 1, - replication_factor: 1, - write_consistency_factor: 1, - on_disk_payload: false, - }, - }, - payload_schema: {}, - }); - expect(mockClient.getCollection).toHaveBeenCalledWith('test-collection'); - }); - - it('should handle missing optional fields', async () => { - const mockCollectionInfo = { - name: 'test-collection', - status: 'green', - optimizer_status: 'ok', - config: {}, - }; - - mockClient.getCollection.mockResolvedValue(mockCollectionInfo); - - const result = await collectionManager.getInfo('test-collection'); - - expect(result.vectors_count).toBe(0); - expect(result.indexed_vectors_count).toBe(0); - expect(result.points_count).toBe(0); - expect(result.segments_count).toBe(0); - }); - - it('should throw error when getting collection info fails', async () => { - mockClient.getCollection.mockRejectedValue(new Error('Get failed')); - - await expect(collectionManager.getInfo('test-collection')).rejects.toThrow( - 'Failed to get collection info for test-collection: Get failed' - ); - }); - }); - - describe('exists', () => { - it('should return true when collection exists', async () => { - mockClient.getCollection.mockResolvedValue({ name: 'test-collection' }); - - const result = await collectionManager.exists('test-collection'); - - expect(result).toBe(true); - expect(mockClient.getCollection).toHaveBeenCalledWith('test-collection'); - }); - - it('should return false when collection does not exist', async () => { - mockClient.getCollection.mockRejectedValue(new Error('Not found')); - - const result = await collectionManager.exists('test-collection'); - - expect(result).toBe(false); - }); - }); - - describe('delete', () => { - it('should delete collection successfully', async () => { - mockClient.deleteCollection.mockResolvedValue({}); - - const result = await collectionManager.delete('test-collection'); - - expect(result).toBe(true); - expect(mockClient.deleteCollection).toHaveBeenCalledWith('test-collection'); - }); - - it('should throw error when deletion fails', async () => { - mockClient.deleteCollection.mockRejectedValue(new Error('Delete failed')); - - await expect(collectionManager.delete('test-collection')).rejects.toThrow( - 'Failed to delete collection test-collection: Delete failed' - ); - }); - }); - - describe('updateCollection', () => { - it('should update collection successfully', async () => { - const updates = { - optimizers_config: { deleted_threshold: 0.3 }, - params: { shard_number: 2 }, - }; - - mockClient.updateCollection.mockResolvedValue({}); - - const result = await collectionManager.updateCollection('test-collection', updates); - - expect(result).toBe(true); - expect(mockClient.updateCollection).toHaveBeenCalledWith('test-collection', updates); - }); - - it('should throw error when update fails', async () => { - mockClient.updateCollection.mockRejectedValue(new Error('Update failed')); - - await expect( - collectionManager.updateCollection('test-collection', {}) - ).rejects.toThrow('Failed to update collection test-collection: Update failed'); - }); - }); - - describe('createAlias', () => { - it('should create alias successfully', async () => { - mockClient.updateCollectionAliases.mockResolvedValue({}); - - const result = await collectionManager.createAlias('my-alias', 'test-collection'); - - expect(result).toBe(true); - expect(mockClient.updateCollectionAliases).toHaveBeenCalledWith({ - actions: [{ create_alias: { alias_name: 'my-alias', collection_name: 'test-collection' } }], - }); - }); - - it('should throw error when alias creation fails', async () => { - mockClient.updateCollectionAliases.mockRejectedValue(new Error('Alias failed')); - - await expect( - collectionManager.createAlias('my-alias', 'test-collection') - ).rejects.toThrow( - 'Failed to create alias my-alias for collection test-collection: Alias failed' - ); - }); - }); - - describe('deleteAlias', () => { - it('should delete alias successfully', async () => { - mockClient.updateCollectionAliases.mockResolvedValue({}); - - const result = await collectionManager.deleteAlias('my-alias'); - - expect(result).toBe(true); - expect(mockClient.updateCollectionAliases).toHaveBeenCalledWith({ - actions: [{ delete_alias: { alias_name: 'my-alias' } }], - }); - }); - - it('should throw error when alias deletion fails', async () => { - mockClient.updateCollectionAliases.mockRejectedValue(new Error('Delete alias failed')); - - await expect(collectionManager.deleteAlias('my-alias')).rejects.toThrow( - 'Failed to delete alias my-alias: Delete alias failed' - ); - }); - }); - - describe('listAliases', () => { - it('should list aliases successfully', async () => { - const mockAliases = [ - { alias: 'alias1', collection: 'collection1' }, - { alias: 'alias2', collection: 'collection2' }, - ]; - - mockClient.getAliases.mockResolvedValue({ aliases: mockAliases }); - - const result = await collectionManager.listAliases(); - - expect(result).toEqual(mockAliases); - expect(mockClient.getAliases).toHaveBeenCalled(); - }); - - it('should handle empty aliases response', async () => { - mockClient.getAliases.mockResolvedValue({}); - - const result = await collectionManager.listAliases(); - - expect(result).toEqual([]); - }); - - it('should throw error when listing aliases fails', async () => { - mockClient.getAliases.mockRejectedValue(new Error('List aliases failed')); - - await expect(collectionManager.listAliases()).rejects.toThrow( - 'Failed to list aliases: List aliases failed' - ); - }); - }); - - describe('getStats', () => { - it('should get collection stats successfully', async () => { - const mockInfo = { - name: 'test-collection', - status: 'green', - optimizer_status: 'ok', - vectors_count: 1000, - indexed_vectors_count: 950, - points_count: 1000, - segments_count: 2, - config: {}, - }; - - mockClient.getCollection.mockResolvedValue(mockInfo); - - const result = await collectionManager.getStats('test-collection'); - - expect(result).toEqual({ - vectors_count: 1000, - indexed_vectors_count: 950, - points_count: 1000, - segments_count: 2, - status: 'green', - optimizer_status: 'ok', - }); - }); - - it('should throw error when getting stats fails', async () => { - mockClient.getCollection.mockRejectedValue(new Error('Stats failed')); - - await expect(collectionManager.getStats('test-collection')).rejects.toThrow( - 'Failed to get collection stats for test-collection: Stats failed' - ); - }); - }); - - describe('recreate', () => { - it('should recreate collection successfully', async () => { - const newConfig: CollectionCreateRequest = { - name: 'test-collection', - vectors: { - size: 512, - distance: 'Dot', - }, - shard_number: 2, - replication_factor: 1, - write_consistency_factor: 1, - on_disk_payload: true, - }; - - mockClient.deleteCollection.mockResolvedValue({}); - mockClient.createCollection.mockResolvedValue({}); - - const result = await collectionManager.recreate('test-collection', newConfig); - - expect(result).toBe(true); - expect(mockClient.deleteCollection).toHaveBeenCalledWith('test-collection'); - expect(mockClient.createCollection).toHaveBeenCalledWith( - 'test-collection', - expect.objectContaining({ - vectors: newConfig.vectors, - shard_number: newConfig.shard_number, - on_disk_payload: newConfig.on_disk_payload, - }) - ); - }); - - it('should throw error when recreation fails during deletion', async () => { - mockClient.deleteCollection.mockRejectedValue(new Error('Delete failed')); - - const newConfig: CollectionCreateRequest = { - name: 'test-collection', - vectors: { size: 512, distance: 'Dot' }, - shard_number: 1, - replication_factor: 1, - write_consistency_factor: 1, - on_disk_payload: false, - }; - - await expect( - collectionManager.recreate('test-collection', newConfig) - ).rejects.toThrow('Failed to recreate collection test-collection: Delete failed'); - }); - - it('should throw error when recreation fails during creation', async () => { - mockClient.deleteCollection.mockResolvedValue({}); - mockClient.createCollection.mockRejectedValue(new Error('Create failed')); - - const newConfig: CollectionCreateRequest = { - name: 'test-collection', - vectors: { size: 512, distance: 'Dot' }, - shard_number: 1, - replication_factor: 1, - write_consistency_factor: 1, - on_disk_payload: false, - }; - - await expect( - collectionManager.recreate('test-collection', newConfig) - ).rejects.toThrow('Failed to recreate collection test-collection: Create failed'); - }); - }); - - describe('error handling', () => { - it('should properly cast errors to Error type', async () => { - mockClient.createCollection.mockRejectedValue('String error'); - - const createRequest: CollectionCreateRequest = { - name: 'test-collection', - vectors: { size: 384, distance: 'Cosine' }, - shard_number: 1, - replication_factor: 1, - write_consistency_factor: 1, - on_disk_payload: false, - }; - - await expect(collectionManager.create(createRequest)).rejects.toThrow( - 'Failed to create collection test-collection: String error' - ); - }); - }); -}); \ No newline at end of file diff --git a/packages/core/src/vector/qdrant/__tests__/EmbeddingManager.test.ts b/packages/core/src/vector/qdrant/__tests__/EmbeddingManager.test.ts deleted file mode 100644 index 44e94aa..0000000 --- a/packages/core/src/vector/qdrant/__tests__/EmbeddingManager.test.ts +++ /dev/null @@ -1,774 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { EmbeddingManager } from '../EmbeddingManager'; -import { VectorConfig } from '../../../config/VectorConfig'; - -// Define interfaces locally for testing -interface EmbeddingProvider { - provider: 'openai' | 'huggingface' | 'cohere' | 'custom'; - model: string; - apiKey?: string; - baseUrl?: string; - dimension: number; -} - -interface EmbeddingRequest { - text: string; - model?: string; -} - -interface EmbeddingResponse { - embedding: number[]; - model: string; - usage?: { - prompt_tokens: number; - total_tokens: number; - }; -} - -// Mock fetch globally -const mockFetch = vi.fn(); -global.fetch = mockFetch; - -describe('EmbeddingManager', () => { - let config: VectorConfig; - let embeddingManager: EmbeddingManager; - - beforeEach(() => { - config = { - provider: 'qdrant', - timeout: 60000, - batchSize: 100, - maxRetries: 3, - retryDelayMs: 1000, - defaultLimit: 10, - defaultWithPayload: true, - defaultWithVectors: false, - parallelism: 1, - connectionPoolSize: 10, - collections: {}, - }; - - embeddingManager = new EmbeddingManager(config); - - // Clear environment variables - delete process.env.OPENAI_API_KEY; - delete process.env.COHERE_API_KEY; - delete process.env.HUGGINGFACE_API_KEY; - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - describe('provider management', () => { - it('should register and retrieve providers', () => { - const provider: EmbeddingProvider = { - provider: 'openai', - model: 'text-embedding-ada-002', - apiKey: 'test-key', - dimension: 1536, - }; - - embeddingManager.registerProvider('test-provider', provider); - - const retrieved = embeddingManager.getProvider('test-provider'); - expect(retrieved).toEqual(provider); - }); - - it('should list all registered providers', () => { - const provider1: EmbeddingProvider = { - provider: 'openai', - model: 'text-embedding-ada-002', - dimension: 1536, - }; - - const provider2: EmbeddingProvider = { - provider: 'cohere', - model: 'embed-english-v3.0', - dimension: 1024, - }; - - embeddingManager.registerProvider('openai-provider', provider1); - embeddingManager.registerProvider('cohere-provider', provider2); - - const providers = embeddingManager.listProviders(); - expect(providers).toContain('openai-provider'); - expect(providers).toContain('cohere-provider'); - expect(providers).toHaveLength(2); - }); - - it('should return undefined for non-existent provider', () => { - const provider = embeddingManager.getProvider('non-existent'); - expect(provider).toBeUndefined(); - }); - }); - - describe('generateWithOpenAI', () => { - it('should generate embedding with OpenAI successfully', async () => { - const mockResponse = { - data: [ - { - embedding: [0.1, 0.2, 0.3, 0.4], - }, - ], - usage: { - prompt_tokens: 10, - total_tokens: 10, - }, - }; - - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockResponse), - }); - - const result = await embeddingManager.generateWithOpenAI('test text', { - apiKey: 'test-key', - }); - - expect(result).toEqual({ - embedding: [0.1, 0.2, 0.3, 0.4], - model: 'text-embedding-ada-002', - usage: { - prompt_tokens: 10, - total_tokens: 10, - }, - }); - - expect(mockFetch).toHaveBeenCalledWith('https://api.openai.com/v1/embeddings', { - method: 'POST', - headers: { - 'Authorization': 'Bearer test-key', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - input: 'test text', - model: 'text-embedding-ada-002', - }), - }); - }); - - it('should use custom model and API URL', async () => { - const mockResponse = { - data: [{ embedding: [0.1, 0.2] }], - }; - - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockResponse), - }); - - await embeddingManager.generateWithOpenAI('test text', { - model: 'text-embedding-3-small', - apiKey: 'test-key', - baseUrl: 'https://custom.openai.com/v1', - }); - - expect(mockFetch).toHaveBeenCalledWith('https://custom.openai.com/v1/embeddings', { - method: 'POST', - headers: { - 'Authorization': 'Bearer test-key', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - input: 'test text', - model: 'text-embedding-3-small', - }), - }); - }); - - it('should use environment variable for API key', async () => { - process.env.OPENAI_API_KEY = 'env-key'; - - const mockResponse = { - data: [{ embedding: [0.1, 0.2] }], - }; - - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockResponse), - }); - - await embeddingManager.generateWithOpenAI('test text'); - - expect(mockFetch).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - headers: expect.objectContaining({ - 'Authorization': 'Bearer env-key', - }), - }) - ); - }); - - it('should throw error when API key is missing', async () => { - await expect( - embeddingManager.generateWithOpenAI('test text') - ).rejects.toThrow('OpenAI API key is required'); - }); - - it('should throw error when API response is not ok', async () => { - mockFetch.mockResolvedValue({ - ok: false, - status: 401, - statusText: 'Unauthorized', - json: () => Promise.resolve({ - error: { message: 'Invalid API key' }, - }), - }); - - await expect( - embeddingManager.generateWithOpenAI('test text', { apiKey: 'invalid-key' }) - ).rejects.toThrow('OpenAI API error: Invalid API key'); - }); - - it('should handle response without usage data', async () => { - const mockResponse = { - data: [{ embedding: [0.1, 0.2] }], - }; - - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockResponse), - }); - - const result = await embeddingManager.generateWithOpenAI('test text', { - apiKey: 'test-key', - }); - - expect(result.usage).toBeUndefined(); - }); - - it('should throw error when no embedding returned', async () => { - const mockResponse = { - data: [], - }; - - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockResponse), - }); - - await expect( - embeddingManager.generateWithOpenAI('test text', { apiKey: 'test-key' }) - ).rejects.toThrow('No embedding returned from OpenAI API'); - }); - }); - - describe('generateWithHuggingFace', () => { - it('should generate embedding with Hugging Face successfully', async () => { - const mockEmbedding = [0.1, 0.2, 0.3]; - - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockEmbedding), - }); - - const result = await embeddingManager.generateWithHuggingFace('test text', { - apiKey: 'hf-key', - }); - - expect(result).toEqual({ - embedding: mockEmbedding, - model: 'sentence-transformers/all-MiniLM-L6-v2', - }); - - expect(mockFetch).toHaveBeenCalledWith( - 'https://api-inference.huggingface.co/pipeline/feature-extraction/sentence-transformers/all-MiniLM-L6-v2', - { - method: 'POST', - headers: { - 'Authorization': 'Bearer hf-key', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - inputs: 'test text', - }), - } - ); - }); - - it('should handle nested array response', async () => { - const mockEmbedding = [[0.1, 0.2, 0.3]]; - - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockEmbedding), - }); - - const result = await embeddingManager.generateWithHuggingFace('test text', { - apiKey: 'hf-key', - }); - - expect(result.embedding).toEqual([0.1, 0.2, 0.3]); - }); - - it('should use custom model and base URL', async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve([0.1, 0.2]), - }); - - await embeddingManager.generateWithHuggingFace('test text', { - model: 'custom/model', - apiKey: 'hf-key', - baseUrl: 'https://custom.hf.co', - }); - - expect(mockFetch).toHaveBeenCalledWith( - 'https://custom.hf.co/pipeline/feature-extraction/custom/model', - expect.any(Object) - ); - }); - - it('should throw error when API key is missing', async () => { - await expect( - embeddingManager.generateWithHuggingFace('test text') - ).rejects.toThrow('Hugging Face API key is required'); - }); - - it('should throw error for invalid embedding format', async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve('invalid'), - }); - - await expect( - embeddingManager.generateWithHuggingFace('test text', { apiKey: 'hf-key' }) - ).rejects.toThrow('Invalid embedding format from Hugging Face API'); - }); - }); - - describe('generateWithCohere', () => { - it('should generate embedding with Cohere successfully', async () => { - const mockResponse = { - embeddings: [[0.1, 0.2, 0.3]], - meta: { - billed_units: { - input_tokens: 5, - }, - }, - }; - - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockResponse), - }); - - const result = await embeddingManager.generateWithCohere('test text', { - apiKey: 'cohere-key', - }); - - expect(result).toEqual({ - embedding: [0.1, 0.2, 0.3], - model: 'embed-english-v3.0', - usage: { - prompt_tokens: 5, - total_tokens: 5, - }, - }); - - expect(mockFetch).toHaveBeenCalledWith('https://api.cohere.ai/v1/embed', { - method: 'POST', - headers: { - 'Authorization': 'Bearer cohere-key', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - texts: ['test text'], - model: 'embed-english-v3.0', - input_type: 'search_document', - }), - }); - }); - - it('should use custom input type', async () => { - const mockResponse = { - embeddings: [[0.1, 0.2]], - }; - - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockResponse), - }); - - await embeddingManager.generateWithCohere('test text', { - apiKey: 'cohere-key', - inputType: 'search_query', - }); - - expect(mockFetch).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - body: JSON.stringify({ - texts: ['test text'], - model: 'embed-english-v3.0', - input_type: 'search_query', - }), - }) - ); - }); - - it('should throw error when API key is missing', async () => { - await expect( - embeddingManager.generateWithCohere('test text') - ).rejects.toThrow('Cohere API key is required'); - }); - - it('should throw error when no embedding returned', async () => { - const mockResponse = { - embeddings: [], - }; - - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockResponse), - }); - - await expect( - embeddingManager.generateWithCohere('test text', { apiKey: 'cohere-key' }) - ).rejects.toThrow('No embedding returned from Cohere API'); - }); - }); - - describe('generateWithCustomProvider', () => { - it('should generate embedding with custom provider', async () => { - const provider: EmbeddingProvider = { - provider: 'custom', - model: 'custom-model', - dimension: 128, - }; - - embeddingManager.registerProvider('custom-provider', provider); - - const customFunction = vi.fn().mockResolvedValue([0.1, 0.2, 0.3]); - - const result = await embeddingManager.generateWithCustomProvider( - 'custom-provider', - 'test text', - customFunction - ); - - expect(result).toEqual({ - embedding: [0.1, 0.2, 0.3], - model: 'custom-model', - }); - - expect(customFunction).toHaveBeenCalledWith('test text', provider); - }); - - it('should throw error for non-existent provider', async () => { - const customFunction = vi.fn(); - - await expect( - embeddingManager.generateWithCustomProvider( - 'non-existent', - 'test text', - customFunction - ) - ).rejects.toThrow('Provider \'non-existent\' not found'); - }); - }); - - describe('generate', () => { - it('should use OpenAI when API key is available', async () => { - process.env.OPENAI_API_KEY = 'openai-key'; - - const mockResponse = { - data: [{ embedding: [0.1, 0.2] }], - }; - - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockResponse), - }); - - const request: EmbeddingRequest = { - text: 'test text', - model: 'custom-model', - }; - - const result = await embeddingManager.generate(request); - - expect(result.embedding).toEqual([0.1, 0.2]); - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining('openai'), - expect.objectContaining({ - body: JSON.stringify({ - input: 'test text', - model: 'custom-model', - }), - }) - ); - }); - - it('should use Cohere when OpenAI unavailable but Cohere available', async () => { - process.env.COHERE_API_KEY = 'cohere-key'; - - const mockResponse = { - embeddings: [[0.3, 0.4]], - }; - - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockResponse), - }); - - const request: EmbeddingRequest = { - text: 'test text', - }; - - const result = await embeddingManager.generate(request); - - expect(result.embedding).toEqual([0.3, 0.4]); - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining('cohere'), - expect.any(Object) - ); - }); - - it('should use Hugging Face when others unavailable', async () => { - process.env.HUGGINGFACE_API_KEY = 'hf-key'; - - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve([0.5, 0.6]), - }); - - const request: EmbeddingRequest = { - text: 'test text', - }; - - const result = await embeddingManager.generate(request); - - expect(result.embedding).toEqual([0.5, 0.6]); - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining('huggingface'), - expect.any(Object) - ); - }); - - it('should use specified provider when available', async () => { - const provider: EmbeddingProvider = { - provider: 'openai', - model: 'custom-openai-model', - apiKey: 'custom-key', - dimension: 1536, - }; - - embeddingManager.registerProvider('custom-openai', provider); - - const mockResponse = { - data: [{ embedding: [0.7, 0.8] }], - }; - - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockResponse), - }); - - const request: EmbeddingRequest = { - text: 'test text', - }; - - const result = await embeddingManager.generate(request, 'custom-openai'); - - expect(result.embedding).toEqual([0.7, 0.8]); - }); - - it('should throw error when no providers configured', async () => { - const request: EmbeddingRequest = { - text: 'test text', - }; - - await expect(embeddingManager.generate(request)).rejects.toThrow( - 'No embedding provider configured. Please set up an API key or register a custom provider.' - ); - }); - - it('should throw error for non-existent specified provider', async () => { - const request: EmbeddingRequest = { - text: 'test text', - }; - - await expect( - embeddingManager.generate(request, 'non-existent') - ).rejects.toThrow('Provider \'non-existent\' not found'); - }); - }); - - describe('generateBatch', () => { - it('should generate embeddings in batch', async () => { - process.env.OPENAI_API_KEY = 'openai-key'; - - const mockResponse = { - data: [{ embedding: [0.1, 0.2] }], - }; - - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockResponse), - }); - - const texts = ['text 1', 'text 2', 'text 3']; - - const result = await embeddingManager.generateBatch(texts); - - expect(result).toHaveLength(3); - expect(result[0].embedding).toEqual([0.1, 0.2]); - expect(mockFetch).toHaveBeenCalledTimes(3); - }); - - it('should process in smaller batches', async () => { - process.env.OPENAI_API_KEY = 'openai-key'; - - const mockResponse = { - data: [{ embedding: [0.1, 0.2] }], - }; - - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockResponse), - }); - - const texts = ['text 1', 'text 2', 'text 3', 'text 4', 'text 5']; - - const result = await embeddingManager.generateBatch(texts, { - batchSize: 2, - }); - - expect(result).toHaveLength(5); - // Should process 3 batches: [1,2], [3,4], [5] - expect(mockFetch).toHaveBeenCalledTimes(5); - }); - - it('should add delay between batches', async () => { - process.env.OPENAI_API_KEY = 'openai-key'; - - const mockResponse = { - data: [{ embedding: [0.1, 0.2] }], - }; - - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockResponse), - }); - - const setTimeoutSpy = vi.spyOn(global, 'setTimeout').mockImplementation((fn) => { - fn(); - return {} as any; - }); - - const texts = ['text 1', 'text 2', 'text 3']; - - await embeddingManager.generateBatch(texts, { - batchSize: 1, - }); - - // Should have 2 delays (between 3 batches) - expect(setTimeoutSpy).toHaveBeenCalledTimes(2); - - setTimeoutSpy.mockRestore(); - }); - }); - - describe('utility functions', () => { - describe('validateEmbedding', () => { - it('should validate correct embedding', () => { - const embedding = [0.1, 0.2, 0.3, 0.4]; - const result = embeddingManager.validateEmbedding(embedding); - expect(result).toBe(true); - }); - - it('should validate embedding with expected dimension', () => { - const embedding = [0.1, 0.2, 0.3]; - const result = embeddingManager.validateEmbedding(embedding, 3); - expect(result).toBe(true); - }); - - it('should invalidate embedding with wrong dimension', () => { - const embedding = [0.1, 0.2]; - const result = embeddingManager.validateEmbedding(embedding, 3); - expect(result).toBe(false); - }); - - it('should invalidate empty array', () => { - const result = embeddingManager.validateEmbedding([]); - expect(result).toBe(false); - }); - - it('should invalidate non-array', () => { - const result = embeddingManager.validateEmbedding('not an array' as any); - expect(result).toBe(false); - }); - - it('should invalidate array with non-numbers', () => { - const result = embeddingManager.validateEmbedding([0.1, 'not a number', 0.3] as any); - expect(result).toBe(false); - }); - - it('should invalidate array with NaN', () => { - const result = embeddingManager.validateEmbedding([0.1, NaN, 0.3]); - expect(result).toBe(false); - }); - }); - - describe('normalizeEmbedding', () => { - it('should normalize embedding to unit length', () => { - const embedding = [3, 4]; // magnitude = 5 - const normalized = embeddingManager.normalizeEmbedding(embedding); - expect(normalized).toEqual([0.6, 0.8]); - }); - - it('should handle zero vector', () => { - const embedding = [0, 0, 0]; - const normalized = embeddingManager.normalizeEmbedding(embedding); - expect(normalized).toEqual([0, 0, 0]); - }); - - it('should normalize already unit vector', () => { - const embedding = [1, 0]; - const normalized = embeddingManager.normalizeEmbedding(embedding); - expect(normalized).toEqual([1, 0]); - }); - }); - - describe('cosineSimilarity', () => { - it('should calculate cosine similarity correctly', () => { - const embedding1 = [1, 0, 0]; - const embedding2 = [0, 1, 0]; - const similarity = embeddingManager.cosineSimilarity(embedding1, embedding2); - expect(similarity).toBe(0); - }); - - it('should calculate similarity for identical vectors', () => { - const embedding1 = [1, 2, 3]; - const embedding2 = [1, 2, 3]; - const similarity = embeddingManager.cosineSimilarity(embedding1, embedding2); - expect(similarity).toBeCloseTo(1, 5); - }); - - it('should calculate similarity for opposite vectors', () => { - const embedding1 = [1, 2, 3]; - const embedding2 = [-1, -2, -3]; - const similarity = embeddingManager.cosineSimilarity(embedding1, embedding2); - expect(similarity).toBeCloseTo(-1, 5); - }); - - it('should throw error for different dimensions', () => { - const embedding1 = [1, 2]; - const embedding2 = [1, 2, 3]; - expect(() => { - embeddingManager.cosineSimilarity(embedding1, embedding2); - }).toThrow('Embeddings must have the same dimension'); - }); - - it('should handle zero vectors', () => { - const embedding1 = [0, 0, 0]; - const embedding2 = [1, 2, 3]; - const similarity = embeddingManager.cosineSimilarity(embedding1, embedding2); - expect(similarity).toBe(0); - }); - }); - }); -}); \ No newline at end of file diff --git a/packages/core/src/vector/qdrant/__tests__/QdrantManager.test.ts b/packages/core/src/vector/qdrant/__tests__/QdrantManager.test.ts deleted file mode 100644 index 40b587f..0000000 --- a/packages/core/src/vector/qdrant/__tests__/QdrantManager.test.ts +++ /dev/null @@ -1,484 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { QdrantManager } from '../QdrantManager'; -import { VectorConfig } from '../../../config/VectorConfig'; - -// Mock QdrantClient -vi.mock('@qdrant/qdrant-js', () => ({ - QdrantClient: vi.fn(), -})); - -// Mock sub-managers -vi.mock('../CollectionManager', () => ({ - CollectionManager: vi.fn(), -})); - -vi.mock('../VectorOperations', () => ({ - VectorOperations: vi.fn(), -})); - -vi.mock('../SearchEngine', () => ({ - SearchEngine: vi.fn(), -})); - -vi.mock('../EmbeddingManager', () => ({ - EmbeddingManager: vi.fn(), -})); - -describe('QdrantManager', () => { - let mockClient: any; - let mockQdrantClient: any; - let config: VectorConfig; - let qdrantManager: QdrantManager; - - const createValidConfig = (overrides: any = {}): VectorConfig => ({ - provider: 'qdrant', - timeout: 60000, - qdrant: { - host: 'localhost', - port: 6333, - grpcPort: 6334, - preferGrpc: false, - https: false, - apiKey: 'test-api-key', - prefix: 'test', - }, - collections: {}, - batchSize: 100, - maxRetries: 3, - retryDelayMs: 1000, - defaultLimit: 10, - defaultWithPayload: true, - defaultWithVectors: false, - parallelism: 1, - connectionPoolSize: 10, - ...overrides, - }); - - beforeEach(async () => { - // Get the mocked QdrantClient - const { QdrantClient } = await vi.importMock('@qdrant/qdrant-js'); - mockQdrantClient = QdrantClient as any; - - // Setup mock client - mockClient = { - getCollections: vi.fn(), - getClusterInfo: vi.fn(), - healthCheck: vi.fn(), - getMetrics: vi.fn(), - createSnapshot: vi.fn(), - listSnapshots: vi.fn(), - deleteSnapshot: vi.fn(), - recover: vi.fn(), - }; - - mockQdrantClient.mockImplementation(() => mockClient); - config = createValidConfig(); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - describe('constructor', () => { - it('should create QdrantManager with valid config', async () => { - qdrantManager = new QdrantManager(config); - - expect(qdrantManager).toBeInstanceOf(QdrantManager); - expect(mockQdrantClient).toHaveBeenCalledWith({ - host: 'localhost', - port: 6333, - apiKey: 'test-api-key', - prefix: 'test', - }); - }); - - it('should create client with HTTPS when enabled', async () => { - const httpsConfig = createValidConfig({ - qdrant: { - host: 'localhost', - port: 6333, - grpcPort: 6334, - preferGrpc: false, - https: true, - apiKey: 'test-api-key', - prefix: 'test', - }, - }); - - qdrantManager = new QdrantManager(httpsConfig); - - expect(mockQdrantClient).toHaveBeenCalledWith({ - host: 'localhost', - port: 6333, - apiKey: 'test-api-key', - https: true, - prefix: 'test', - }); - }); - - it('should use gRPC when preferred and grpcPort is available', async () => { - const grpcConfig = createValidConfig({ - qdrant: { - host: 'localhost', - port: 6333, - grpcPort: 6334, - preferGrpc: true, - https: false, - apiKey: 'test-api-key', - prefix: 'test', - }, - }); - - qdrantManager = new QdrantManager(grpcConfig); - - expect(mockQdrantClient).toHaveBeenCalledWith({ - host: 'localhost', - port: 6334, - apiKey: 'test-api-key', - prefix: 'test', - grpc: true, - }); - }); - - it('should throw error when qdrant config is missing', async () => { - const invalidConfig = createValidConfig({ qdrant: undefined }); - - expect(() => new QdrantManager(invalidConfig)).toThrow( - 'Qdrant configuration is required' - ); - }); - - it('should create client without optional fields', async () => { - const minimalConfig = createValidConfig({ - qdrant: { - host: 'localhost', - port: 6333, - grpcPort: 6334, - preferGrpc: false, - https: false, - }, - }); - - qdrantManager = new QdrantManager(minimalConfig); - - expect(mockQdrantClient).toHaveBeenCalledWith({ - host: 'localhost', - port: 6333, - }); - }); - }); - - describe('testConnection', () => { - beforeEach(() => { - qdrantManager = new QdrantManager(config); - }); - - it('should return true when connection test succeeds', async () => { - mockClient.getCollections.mockResolvedValue({ collections: [] }); - - const result = await qdrantManager.testConnection(); - - expect(result).toBe(true); - expect(mockClient.getCollections).toHaveBeenCalled(); - }); - - it('should return false when connection test fails', async () => { - mockClient.getCollections.mockRejectedValue(new Error('Connection failed')); - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - - const result = await qdrantManager.testConnection(); - - expect(result).toBe(false); - expect(consoleSpy).toHaveBeenCalledWith( - 'Qdrant connection test failed:', - expect.any(Error) - ); - - consoleSpy.mockRestore(); - }); - }); - - describe('getClusterInfo', () => { - beforeEach(() => { - qdrantManager = new QdrantManager(config); - }); - - it('should return cluster info when successful', async () => { - mockClient.getCollections.mockResolvedValue({ collections: [{ name: 'col1' }] }); - - const result = await qdrantManager.getClusterInfo(); - - expect(result).toEqual({ - cluster_status: 'enabled', - peer_id: null, - collections_count: 1, - }); - expect(mockClient.getCollections).toHaveBeenCalled(); - }); - - it('should throw error when cluster info request fails', async () => { - mockClient.getCollections.mockRejectedValue(new Error('API error')); - - await expect(qdrantManager.getClusterInfo()).rejects.toThrow( - 'Failed to get cluster info: API error' - ); - }); - }); - - describe('getHealth', () => { - beforeEach(() => { - qdrantManager = new QdrantManager(config); - }); - - it('should return true when health check succeeds', async () => { - mockClient.getCollections.mockResolvedValue({ collections: [] }); - - const result = await qdrantManager.getHealth(); - - expect(result).toBe(true); - expect(mockClient.getCollections).toHaveBeenCalled(); - }); - - it('should return false when health check fails', async () => { - mockClient.getCollections.mockRejectedValue(new Error('Health check failed')); - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - - const result = await qdrantManager.getHealth(); - - expect(result).toBe(false); - expect(consoleSpy).toHaveBeenCalledWith( - 'Health check failed:', - expect.any(Error) - ); - - consoleSpy.mockRestore(); - }); - }); - - describe('getMetrics', () => { - beforeEach(() => { - qdrantManager = new QdrantManager(config); - }); - - it('should return metrics JSON when successful', async () => { - mockClient.getCollections.mockResolvedValue({ collections: [{ name: 'col1' }] }); - - const result = await qdrantManager.getMetrics(); - const parsed = JSON.parse(result); - - expect(parsed.collections_count).toBe(1); - expect(parsed.collections).toEqual([{ name: 'col1' }]); - expect(mockClient.getCollections).toHaveBeenCalled(); - }); - - it('should throw error when metrics request fails', async () => { - mockClient.getCollections.mockRejectedValue(new Error('Metrics error')); - - await expect(qdrantManager.getMetrics()).rejects.toThrow( - 'Failed to get metrics: Metrics error' - ); - }); - }); - - describe('createSnapshot', () => { - beforeEach(() => { - qdrantManager = new QdrantManager(config); - }); - - it('should create snapshot successfully', async () => { - const mockResult = { name: 'snapshot-123' }; - mockClient.createSnapshot.mockResolvedValue(mockResult); - - const result = await qdrantManager.createSnapshot('test-collection'); - - expect(result).toEqual(mockResult); - expect(mockClient.createSnapshot).toHaveBeenCalledWith('test-collection'); - }); - - it('should throw error when snapshot creation fails', async () => { - mockClient.createSnapshot.mockRejectedValue(new Error('Snapshot error')); - - await expect(qdrantManager.createSnapshot('test-collection')).rejects.toThrow( - 'Failed to create snapshot for collection test-collection: Snapshot error' - ); - }); - }); - - describe('listSnapshots', () => { - beforeEach(() => { - qdrantManager = new QdrantManager(config); - }); - - it('should list snapshots successfully', async () => { - const mockSnapshots = [{ name: 'snapshot-1' }, { name: 'snapshot-2' }]; - mockClient.listSnapshots.mockResolvedValue(mockSnapshots); - - const result = await qdrantManager.listSnapshots('test-collection'); - - expect(result).toEqual(mockSnapshots); - expect(mockClient.listSnapshots).toHaveBeenCalledWith('test-collection'); - }); - - it('should throw error when listing snapshots fails', async () => { - mockClient.listSnapshots.mockRejectedValue(new Error('List error')); - - await expect(qdrantManager.listSnapshots('test-collection')).rejects.toThrow( - 'Failed to list snapshots for collection test-collection: List error' - ); - }); - }); - - describe('deleteSnapshot', () => { - beforeEach(() => { - qdrantManager = new QdrantManager(config); - }); - - it('should delete snapshot successfully', async () => { - mockClient.deleteSnapshot.mockResolvedValue(undefined); - - const result = await qdrantManager.deleteSnapshot('test-collection', 'snapshot-1'); - - expect(result).toBe(true); - expect(mockClient.deleteSnapshot).toHaveBeenCalledWith('test-collection', 'snapshot-1'); - }); - - it('should throw error when snapshot deletion fails', async () => { - mockClient.deleteSnapshot.mockRejectedValue(new Error('Delete error')); - - await expect( - qdrantManager.deleteSnapshot('test-collection', 'snapshot-1') - ).rejects.toThrow( - 'Failed to delete snapshot snapshot-1 for collection test-collection: Delete error' - ); - }); - }); - - describe('recoverFromSnapshot', () => { - beforeEach(() => { - qdrantManager = new QdrantManager(config); - }); - - it('should throw not implemented error', async () => { - await expect( - qdrantManager.recoverFromSnapshot('test-collection', '/path/to/snapshot') - ).rejects.toThrow( - 'Failed to recover collection test-collection from snapshot: Snapshot recovery is not yet implemented' - ); - }); - - it('should throw not implemented error with options', async () => { - await expect( - qdrantManager.recoverFromSnapshot('test-collection', '/path/to/snapshot', { - priority: 'replica', - checksum: 'abc123', - }) - ).rejects.toThrow( - 'Failed to recover collection test-collection from snapshot: Snapshot recovery is not yet implemented' - ); - }); - }); - - describe('getClient', () => { - beforeEach(() => { - qdrantManager = new QdrantManager(config); - }); - - it('should return the client instance', () => { - const client = qdrantManager.getClient(); - - expect(client).toBe(mockClient); - }); - }); - - describe('getConfig', () => { - beforeEach(() => { - qdrantManager = new QdrantManager(config); - }); - - it('should return the current configuration', () => { - const currentConfig = qdrantManager.getConfig(); - - expect(currentConfig).toEqual(config); - }); - }); - - describe('updateConfig', () => { - beforeEach(() => { - qdrantManager = new QdrantManager(config); - }); - - it('should update configuration and recreate client', async () => { - const newConfig = { - qdrant: { - host: 'new-host', - port: 6334, - grpcPort: 6335, - preferGrpc: false, - https: false, - }, - }; - - qdrantManager.updateConfig(newConfig); - - const updatedConfig = qdrantManager.getConfig(); - expect(updatedConfig.qdrant?.host).toBe('new-host'); - expect(updatedConfig.qdrant?.port).toBe(6334); - expect(mockQdrantClient).toHaveBeenCalledTimes(2); // Initial + update - }); - - it('should merge partial configuration updates', () => { - const partialUpdate = { - batchSize: 200, - }; - - qdrantManager.updateConfig(partialUpdate); - - const updatedConfig = qdrantManager.getConfig(); - expect(updatedConfig.batchSize).toBe(200); - expect(updatedConfig.qdrant?.host).toBe('localhost'); // Should remain unchanged - }); - }); - - describe('close', () => { - beforeEach(() => { - qdrantManager = new QdrantManager(config); - }); - - it('should close connection successfully', async () => { - await qdrantManager.close(); - - expect(qdrantManager.getClient()).toBeNull(); - }); - - it('should handle close errors gracefully', async () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - // Force an error by making client property non-configurable - Object.defineProperty(qdrantManager, 'client', { - set: () => { - throw new Error('Cannot set client'); - }, - configurable: false, - }); - - await qdrantManager.close(); - - expect(consoleSpy).toHaveBeenCalledWith( - 'Error closing Qdrant connection:', - expect.any(Error) - ); - - consoleSpy.mockRestore(); - }); - }); - - describe('sub-managers initialization', () => { - it('should initialize all sub-managers', () => { - qdrantManager = new QdrantManager(config); - - expect(qdrantManager.collections).toBeDefined(); - expect(qdrantManager.vectors).toBeDefined(); - expect(qdrantManager.search).toBeDefined(); - expect(qdrantManager.embeddings).toBeDefined(); - }); - }); -}); \ No newline at end of file diff --git a/packages/core/src/vector/qdrant/__tests__/SearchEngine.test.ts b/packages/core/src/vector/qdrant/__tests__/SearchEngine.test.ts deleted file mode 100644 index 1f5d172..0000000 --- a/packages/core/src/vector/qdrant/__tests__/SearchEngine.test.ts +++ /dev/null @@ -1,717 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { SearchEngine } from '../SearchEngine'; -import { VectorConfig } from '../../../config/VectorConfig'; - -// Define interfaces locally for testing -interface SearchOptions { - limit?: number; - offset?: number; - with_payload?: boolean; - with_vector?: boolean; - score_threshold?: number; - filter?: Record; - params?: { - hnsw_ef?: number; - exact?: boolean; - }; -} - -interface SearchResult { - id: string; - score: number; - payload?: Record; - vector?: number[]; -} - -interface VectorPoint { - id: string; - vector: number[]; - payload?: Record; -} - -describe('SearchEngine', () => { - let mockClient: any; - let config: VectorConfig; - let searchEngine: SearchEngine; - - beforeEach(() => { - mockClient = { - search: vi.fn(), - searchBatch: vi.fn(), - scroll: vi.fn(), - recommend: vi.fn(), - discoverPoints: vi.fn(), - retrieve: vi.fn(), - }; - - config = { - provider: 'qdrant', - timeout: 60000, - defaultLimit: 10, - defaultWithPayload: true, - defaultWithVectors: false, - batchSize: 100, - maxRetries: 3, - retryDelayMs: 1000, - parallelism: 1, - connectionPoolSize: 10, - collections: {}, - }; - - searchEngine = new SearchEngine(mockClient, config); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - describe('searchByVector', () => { - const testVector = [0.1, 0.2, 0.3, 0.4]; - - it('should search by vector with default options', async () => { - const mockSearchResult = [ - { - id: 'point-1', - score: 0.95, - payload: { category: 'test' }, - vector: [0.1, 0.2, 0.3, 0.4], - }, - { - id: 'point-2', - score: 0.87, - payload: { category: 'demo' }, - vector: [0.2, 0.3, 0.4, 0.5], - }, - ]; - - mockClient.search.mockResolvedValue(mockSearchResult); - - const result = await searchEngine.searchByVector('test-collection', testVector); - - expect(result).toEqual([ - { - id: 'point-1', - score: 0.95, - payload: { category: 'test' }, - vector: [0.1, 0.2, 0.3, 0.4], - }, - { - id: 'point-2', - score: 0.87, - payload: { category: 'demo' }, - vector: [0.2, 0.3, 0.4, 0.5], - }, - ]); - - expect(mockClient.search).toHaveBeenCalledWith('test-collection', { - vector: testVector, - limit: 10, - offset: 0, - with_payload: true, - with_vector: false, - score_threshold: undefined, - filter: undefined, - params: undefined, - }); - }); - - it('should search by vector with custom options', async () => { - const options: SearchOptions = { - limit: 5, - offset: 10, - with_payload: false, - with_vector: true, - score_threshold: 0.8, - filter: { category: 'test' }, - params: { hnsw_ef: 128, exact: false }, - }; - - mockClient.search.mockResolvedValue([]); - - await searchEngine.searchByVector('test-collection', testVector, options); - - expect(mockClient.search).toHaveBeenCalledWith('test-collection', { - vector: testVector, - limit: 5, - offset: 10, - with_payload: false, - with_vector: true, - score_threshold: 0.8, - filter: { category: 'test' }, - params: { hnsw_ef: 128, exact: false }, - }); - }); - - it('should throw error when search fails', async () => { - mockClient.search.mockRejectedValue(new Error('Search failed')); - - await expect( - searchEngine.searchByVector('test-collection', testVector) - ).rejects.toThrow( - 'Failed to search by vector in collection test-collection: Search failed' - ); - }); - }); - - describe('searchByPointId', () => { - it('should search by point ID successfully', async () => { - const refVector = [0.1, 0.2, 0.3]; - mockClient.retrieve.mockResolvedValue([ - { id: 'reference-point', vector: refVector }, - ]); - const mockSearchResult = [ - { - id: 'similar-1', - score: 0.92, - payload: { type: 'similar' }, - }, - ]; - - mockClient.search.mockResolvedValue(mockSearchResult); - - const result = await searchEngine.searchByPointId('test-collection', 'reference-point'); - - expect(result).toEqual([ - { - id: 'similar-1', - score: 0.92, - payload: { type: 'similar' }, - vector: undefined, - }, - ]); - - expect(mockClient.retrieve).toHaveBeenCalledWith('test-collection', { - ids: ['reference-point'], - with_vector: true, - with_payload: false, - }); - expect(mockClient.search).toHaveBeenCalledWith('test-collection', { - vector: refVector, - limit: 10, - offset: 0, - with_payload: true, - with_vector: false, - score_threshold: undefined, - filter: undefined, - params: undefined, - }); - }); - - it('should search by point ID with custom options', async () => { - const refVector = [0.4, 0.5, 0.6]; - mockClient.retrieve.mockResolvedValue([ - { id: 'reference-point', vector: refVector }, - ]); - - const options: SearchOptions = { - limit: 3, - score_threshold: 0.9, - }; - - mockClient.search.mockResolvedValue([]); - - await searchEngine.searchByPointId('test-collection', 'reference-point', options); - - expect(mockClient.search).toHaveBeenCalledWith('test-collection', { - vector: refVector, - limit: 3, - offset: 0, - with_payload: true, - with_vector: false, - score_threshold: 0.9, - filter: undefined, - params: undefined, - }); - }); - - it('should throw error when search by point ID fails', async () => { - mockClient.retrieve.mockResolvedValue([ - { id: 'reference-point', vector: [0.1, 0.2, 0.3] }, - ]); - mockClient.search.mockRejectedValue(new Error('Point search failed')); - - await expect( - searchEngine.searchByPointId('test-collection', 'reference-point') - ).rejects.toThrow( - 'Failed to search by point ID reference-point in collection test-collection: Point search failed' - ); - }); - }); - - describe('searchBatch', () => { - const testVectors = [ - [0.1, 0.2, 0.3], - [0.4, 0.5, 0.6], - [0.7, 0.8, 0.9], - ]; - - it('should perform batch search successfully', async () => { - const mockBatchResult = [ - [ - { id: 'result-1-1', score: 0.95, payload: { batch: 1 } }, - { id: 'result-1-2', score: 0.87, payload: { batch: 1 } }, - ], - [ - { id: 'result-2-1', score: 0.93, payload: { batch: 2 } }, - ], - [ - { id: 'result-3-1', score: 0.89, payload: { batch: 3 } }, - { id: 'result-3-2', score: 0.82, payload: { batch: 3 } }, - { id: 'result-3-3', score: 0.75, payload: { batch: 3 } }, - ], - ]; - - mockClient.searchBatch.mockResolvedValue(mockBatchResult); - - const result = await searchEngine.searchBatch('test-collection', testVectors); - - expect(result).toEqual([ - [ - { id: 'result-1-1', score: 0.95, payload: { batch: 1 }, vector: undefined }, - { id: 'result-1-2', score: 0.87, payload: { batch: 1 }, vector: undefined }, - ], - [ - { id: 'result-2-1', score: 0.93, payload: { batch: 2 }, vector: undefined }, - ], - [ - { id: 'result-3-1', score: 0.89, payload: { batch: 3 }, vector: undefined }, - { id: 'result-3-2', score: 0.82, payload: { batch: 3 }, vector: undefined }, - { id: 'result-3-3', score: 0.75, payload: { batch: 3 }, vector: undefined }, - ], - ]); - - expect(mockClient.searchBatch).toHaveBeenCalledWith('test-collection', { - searches: [ - { - vector: [0.1, 0.2, 0.3], - limit: 10, - offset: 0, - with_payload: true, - with_vector: false, - score_threshold: undefined, - filter: undefined, - params: undefined, - }, - { - vector: [0.4, 0.5, 0.6], - limit: 10, - offset: 0, - with_payload: true, - with_vector: false, - score_threshold: undefined, - filter: undefined, - params: undefined, - }, - { - vector: [0.7, 0.8, 0.9], - limit: 10, - offset: 0, - with_payload: true, - with_vector: false, - score_threshold: undefined, - filter: undefined, - params: undefined, - }, - ], - }); - }); - - it('should perform batch search with custom options', async () => { - const options: SearchOptions = { - limit: 3, - with_vector: true, - filter: { active: true }, - }; - - mockClient.searchBatch.mockResolvedValue([[], [], []]); - - await searchEngine.searchBatch('test-collection', testVectors, options); - - expect(mockClient.searchBatch).toHaveBeenCalledWith('test-collection', { - searches: testVectors.map(vector => ({ - vector, - limit: 3, - offset: 0, - with_payload: true, - with_vector: true, - score_threshold: undefined, - filter: { active: true }, - params: undefined, - })), - }); - }); - - it('should throw error when batch search fails', async () => { - mockClient.searchBatch.mockRejectedValue(new Error('Batch search failed')); - - await expect( - searchEngine.searchBatch('test-collection', testVectors) - ).rejects.toThrow( - 'Failed to perform batch search in collection test-collection: Batch search failed' - ); - }); - }); - - describe('searchByFilter', () => { - it('should search by filter successfully', async () => { - const filter = { category: 'test', active: true }; - const mockScrollResult = { - points: [ - { id: 'point-1', vector: [0.1, 0.2], payload: { category: 'test', active: true } }, - { id: 'point-2', vector: [0.3, 0.4], payload: { category: 'test', active: true } }, - ], - }; - - mockClient.scroll.mockResolvedValue(mockScrollResult); - - const result = await searchEngine.searchByFilter('test-collection', filter); - - expect(result).toEqual([ - { id: 'point-1', vector: [0.1, 0.2], payload: { category: 'test', active: true } }, - { id: 'point-2', vector: [0.3, 0.4], payload: { category: 'test', active: true } }, - ]); - - expect(mockClient.scroll).toHaveBeenCalledWith('test-collection', { - filter, - limit: 10, - offset: 0, - with_payload: true, - with_vector: false, - }); - }); - - it('should handle points without vectors', async () => { - const filter = { type: 'metadata-only' }; - const mockScrollResult = { - points: [ - { id: 'point-1', payload: { type: 'metadata-only' } }, - ], - }; - - mockClient.scroll.mockResolvedValue(mockScrollResult); - - const result = await searchEngine.searchByFilter('test-collection', filter); - - expect(result).toEqual([ - { id: 'point-1', vector: [], payload: { type: 'metadata-only' } }, - ]); - }); - - it('should throw error when filter search fails', async () => { - mockClient.scroll.mockRejectedValue(new Error('Filter search failed')); - - await expect( - searchEngine.searchByFilter('test-collection', { category: 'test' }) - ).rejects.toThrow( - 'Failed to search by filter in collection test-collection: Filter search failed' - ); - }); - }); - - describe('searchWithParams', () => { - const testVector = [0.1, 0.2, 0.3, 0.4]; - - it('should search with custom parameters', async () => { - const params = { hnsw_ef: 256, exact: true }; - const mockSearchResult = [ - { id: 'exact-1', score: 1.0, payload: { method: 'exact' } }, - ]; - - mockClient.search.mockResolvedValue(mockSearchResult); - - const result = await searchEngine.searchWithParams( - 'test-collection', - testVector, - params - ); - - expect(result).toEqual([ - { id: 'exact-1', score: 1.0, payload: { method: 'exact' }, vector: undefined }, - ]); - - expect(mockClient.search).toHaveBeenCalledWith('test-collection', { - vector: testVector, - limit: 10, - offset: 0, - with_payload: true, - with_vector: false, - score_threshold: undefined, - filter: undefined, - params: { hnsw_ef: 256, exact: true }, - }); - }); - - it('should combine custom params with search options', async () => { - const params = { hnsw_ef: 128 }; - const options: SearchOptions = { - limit: 5, - score_threshold: 0.9, - filter: { quality: 'high' }, - }; - - mockClient.search.mockResolvedValue([]); - - await searchEngine.searchWithParams('test-collection', testVector, params, options); - - expect(mockClient.search).toHaveBeenCalledWith('test-collection', { - vector: testVector, - limit: 5, - offset: 0, - with_payload: true, - with_vector: false, - score_threshold: 0.9, - filter: { quality: 'high' }, - params: { hnsw_ef: 128 }, - }); - }); - - it('should throw error when parametric search fails', async () => { - mockClient.search.mockRejectedValue(new Error('Parametric search failed')); - - await expect( - searchEngine.searchWithParams('test-collection', testVector, {}) - ).rejects.toThrow( - 'Failed to search with custom params in collection test-collection: Parametric search failed' - ); - }); - }); - - describe('recommend', () => { - it('should recommend with point IDs', async () => { - const positive = ['good-1', 'good-2']; - const negative = ['bad-1']; - const mockRecommendResult = [ - { id: 'recommendation-1', score: 0.91, payload: { recommended: true } }, - ]; - - mockClient.recommend.mockResolvedValue(mockRecommendResult); - - const result = await searchEngine.recommend('test-collection', positive, negative); - - expect(result).toEqual([ - { id: 'recommendation-1', score: 0.91, payload: { recommended: true }, vector: undefined }, - ]); - - expect(mockClient.recommend).toHaveBeenCalledWith('test-collection', { - positive: ['good-1', 'good-2'], - negative: ['bad-1'], - limit: 10, - offset: 0, - with_payload: true, - with_vector: false, - score_threshold: undefined, - filter: undefined, - params: undefined, - }); - }); - - it('should recommend with vectors', async () => { - const positive = [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]]; - const negative = [[0.7, 0.8, 0.9]]; - - mockClient.recommend.mockResolvedValue([]); - - await searchEngine.recommend('test-collection', positive, negative); - - expect(mockClient.recommend).toHaveBeenCalledWith('test-collection', { - positive: [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]], - negative: [[0.7, 0.8, 0.9]], - limit: 10, - offset: 0, - with_payload: true, - with_vector: false, - score_threshold: undefined, - filter: undefined, - params: undefined, - }); - }); - - it('should recommend with mixed point IDs and vectors', async () => { - const positive = ['point-1', [0.1, 0.2, 0.3]]; - const negative: Array = []; - - mockClient.recommend.mockResolvedValue([]); - - await searchEngine.recommend('test-collection', positive, negative); - - expect(mockClient.recommend).toHaveBeenCalledWith('test-collection', { - positive: ['point-1', [0.1, 0.2, 0.3]], - negative: [], - limit: 10, - offset: 0, - with_payload: true, - with_vector: false, - score_threshold: undefined, - filter: undefined, - params: undefined, - }); - }); - - it('should throw error when recommendation fails', async () => { - mockClient.recommend.mockRejectedValue(new Error('Recommendation failed')); - - await expect( - searchEngine.recommend('test-collection', ['point-1']) - ).rejects.toThrow( - 'Failed to get recommendations in collection test-collection: Recommendation failed' - ); - }); - }); - - describe('discover', () => { - it('should discover with point ID target', async () => { - const target = 'target-point'; - const context = [ - { positive: ['good-1', 'good-2'], negative: ['bad-1'] }, - { positive: [[0.1, 0.2, 0.3]] }, - ]; - - const mockDiscoverResult = [ - { id: 'discovery-1', score: 0.88, payload: { discovered: true } }, - ]; - - mockClient.discoverPoints.mockResolvedValue(mockDiscoverResult); - - const result = await searchEngine.discover('test-collection', target, context); - - expect(result).toEqual([ - { id: 'discovery-1', score: 0.88, payload: { discovered: true }, vector: undefined }, - ]); - - // qdrant-js discoverPoints takes flat positive/negative pair arrays; - // targets/IDs are passed directly (no { id } wrapper). - expect(mockClient.discoverPoints).toHaveBeenCalledWith( - 'test-collection', - expect.objectContaining({ - target: 'target-point', - context: [ - { positive: 'good-1', negative: 'bad-1' }, - { positive: 'good-2', negative: undefined }, - { positive: [0.1, 0.2, 0.3], negative: undefined }, - ], - limit: 10, - offset: 0, - with_payload: true, - with_vector: false, - }) - ); - }); - - it('should discover with vector target', async () => { - const target = [0.5, 0.6, 0.7]; - const context = [{ positive: ['reference-1'] }]; - - mockClient.discoverPoints.mockResolvedValue([]); - - await searchEngine.discover('test-collection', target, context); - - expect(mockClient.discoverPoints).toHaveBeenCalledWith( - 'test-collection', - expect.objectContaining({ - target: [0.5, 0.6, 0.7], - context: [{ positive: 'reference-1', negative: undefined }], - limit: 10, - offset: 0, - with_payload: true, - with_vector: false, - }) - ); - }); - - it('should throw error when discovery fails', async () => { - mockClient.discoverPoints.mockRejectedValue(new Error('Discovery failed')); - - await expect( - searchEngine.discover('test-collection', 'target', []) - ).rejects.toThrow( - 'Failed to discover points in collection test-collection: Discovery failed' - ); - }); - }); - - describe('hybridSearch', () => { - it('should perform hybrid search with dense vector only', async () => { - const denseVector = [0.1, 0.2, 0.3, 0.4]; - const mockSearchResult = [ - { id: 'hybrid-1', score: 0.92, payload: { type: 'dense' } }, - ]; - - mockClient.search.mockResolvedValue(mockSearchResult); - - const result = await searchEngine.hybridSearch('test-collection', denseVector); - - expect(result).toEqual([ - { id: 'hybrid-1', score: 0.92, payload: { type: 'dense' }, vector: undefined }, - ]); - - expect(mockClient.search).toHaveBeenCalledWith('test-collection', { - vector: { dense: denseVector }, - limit: 10, - offset: 0, - with_payload: true, - with_vector: false, - score_threshold: undefined, - filter: undefined, - params: undefined, - }); - }); - - it('should perform hybrid search with sparse vector only', async () => { - const sparseVector = { indices: [1, 5, 10], values: [0.8, 0.6, 0.4] }; - - mockClient.search.mockResolvedValue([]); - - await searchEngine.hybridSearch('test-collection', undefined, sparseVector); - - expect(mockClient.search).toHaveBeenCalledWith('test-collection', { - vector: { sparse: sparseVector }, - limit: 10, - offset: 0, - with_payload: true, - with_vector: false, - score_threshold: undefined, - filter: undefined, - params: undefined, - }); - }); - - it('should perform hybrid search with both dense and sparse vectors', async () => { - const denseVector = [0.1, 0.2, 0.3]; - const sparseVector = { indices: [2, 7], values: [0.9, 0.3] }; - - mockClient.search.mockResolvedValue([]); - - await searchEngine.hybridSearch('test-collection', denseVector, sparseVector); - - expect(mockClient.search).toHaveBeenCalledWith('test-collection', { - vector: { - dense: denseVector, - sparse: sparseVector, - }, - limit: 10, - offset: 0, - with_payload: true, - with_vector: false, - score_threshold: undefined, - filter: undefined, - params: undefined, - }); - }); - - it('should throw error when no vectors provided', async () => { - await expect( - searchEngine.hybridSearch('test-collection') - ).rejects.toThrow('Either dense or sparse vector must be provided'); - }); - - it('should throw error when hybrid search fails', async () => { - mockClient.search.mockRejectedValue(new Error('Hybrid search failed')); - - await expect( - searchEngine.hybridSearch('test-collection', [0.1, 0.2]) - ).rejects.toThrow( - 'Failed to perform hybrid search in collection test-collection: Hybrid search failed' - ); - }); - }); -}); \ No newline at end of file diff --git a/packages/core/src/vector/qdrant/__tests__/VectorOperations.test.ts b/packages/core/src/vector/qdrant/__tests__/VectorOperations.test.ts deleted file mode 100644 index e339753..0000000 --- a/packages/core/src/vector/qdrant/__tests__/VectorOperations.test.ts +++ /dev/null @@ -1,699 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { VectorOperations } from '../VectorOperations'; -import { VectorConfig } from '../../../config/VectorConfig'; - -// Define interfaces locally for testing -interface VectorPoint { - id: string; - vector: number[]; - payload?: Record; -} - -interface BatchOperationResult { - operation_id: number; - status: 'acknowledged' | 'completed'; -} - -interface PointInsertRequest { - points: VectorPoint[]; - wait?: boolean; - ordering?: 'weak' | 'medium' | 'strong'; -} - -interface PointUpdateRequest { - points: VectorPoint[]; - wait?: boolean; - ordering?: 'weak' | 'medium' | 'strong'; -} - -interface PointDeleteRequest { - ids: string[]; - wait?: boolean; - ordering?: 'weak' | 'medium' | 'strong'; -} - -interface ScrollRequest { - offset?: string; - limit?: number; - with_payload?: boolean; - with_vector?: boolean; - filter?: Record; -} - -interface ScrollResponse { - points: VectorPoint[]; - next_page_offset?: string; -} - -describe('VectorOperations', () => { - let mockClient: any; - let config: VectorConfig; - let vectorOperations: VectorOperations; - - beforeEach(() => { - mockClient = { - upsert: vi.fn(), - delete: vi.fn(), - retrieve: vi.fn(), - scroll: vi.fn(), - setPayload: vi.fn(), - deletePayload: vi.fn(), - clearPayload: vi.fn(), - count: vi.fn(), - }; - - config = { - provider: 'qdrant', - timeout: 60000, - batchSize: 100, - maxRetries: 3, - retryDelayMs: 1000, - defaultLimit: 10, - defaultWithPayload: true, - defaultWithVectors: false, - parallelism: 1, - connectionPoolSize: 10, - collections: {}, - }; - - vectorOperations = new VectorOperations(mockClient, config); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - describe('insertPoint', () => { - const mockPoint: VectorPoint = { - id: 'test-point-1', - vector: [0.1, 0.2, 0.3, 0.4], - payload: { category: 'test', value: 42 }, - }; - - it('should insert single point successfully', async () => { - const mockResult = { - operation_id: 123, - status: 'acknowledged', - }; - - mockClient.upsert.mockResolvedValue(mockResult); - - const result = await vectorOperations.insertPoint('test-collection', mockPoint); - - expect(result).toEqual({ - operation_id: 123, - status: 'acknowledged', - }); - - expect(mockClient.upsert).toHaveBeenCalledWith('test-collection', { - wait: true, - ordering: undefined, - points: [ - { - id: 'test-point-1', - vector: [0.1, 0.2, 0.3, 0.4], - payload: { category: 'test', value: 42 }, - }, - ], - }); - }); - - it('should insert point with custom options', async () => { - const mockResult = { - operation_id: 456, - status: 'completed', - }; - - mockClient.upsert.mockResolvedValue(mockResult); - - const result = await vectorOperations.insertPoint( - 'test-collection', - mockPoint, - { - wait: false, - ordering: 'strong', - } - ); - - expect(result).toEqual({ - operation_id: 456, - status: 'completed', - }); - - expect(mockClient.upsert).toHaveBeenCalledWith('test-collection', { - wait: false, - ordering: 'strong', - points: [expect.objectContaining({ id: 'test-point-1' })], - }); - }); - - it('should handle missing result fields', async () => { - mockClient.upsert.mockResolvedValue({}); - - const result = await vectorOperations.insertPoint('test-collection', mockPoint); - - expect(result).toEqual({ - operation_id: 0, - status: 'acknowledged', - }); - }); - - it('should throw error when insertion fails', async () => { - mockClient.upsert.mockRejectedValue(new Error('Insert failed')); - - await expect( - vectorOperations.insertPoint('test-collection', mockPoint) - ).rejects.toThrow( - 'Failed to insert point test-point-1 in collection test-collection: Insert failed' - ); - }); - }); - - describe('insertPoints', () => { - const mockPoints: VectorPoint[] = [ - { id: 'point-1', vector: [0.1, 0.2], payload: { type: 'A' } }, - { id: 'point-2', vector: [0.3, 0.4], payload: { type: 'B' } }, - { id: 'point-3', vector: [0.5, 0.6], payload: { type: 'C' } }, - ]; - - it('should insert multiple points in single batch', async () => { - const mockRequest: PointInsertRequest = { - points: mockPoints, - wait: true, - ordering: 'medium', - }; - - const mockResult = { - operation_id: 789, - status: 'acknowledged', - }; - - mockClient.upsert.mockResolvedValue(mockResult); - - const result = await vectorOperations.insertPoints('test-collection', mockRequest); - - expect(result).toEqual({ - operation_id: 789, - status: 'acknowledged', - }); - - expect(mockClient.upsert).toHaveBeenCalledTimes(1); - expect(mockClient.upsert).toHaveBeenCalledWith('test-collection', { - wait: true, - ordering: 'medium', - points: mockPoints.map(point => ({ - id: point.id, - vector: point.vector, - payload: point.payload, - })), - }); - }); - - it('should handle large batches by splitting them', async () => { - // Create a config with small batch size - const smallBatchConfig = { ...config, batchSize: 2 }; - vectorOperations = new VectorOperations(mockClient, smallBatchConfig); - - const mockRequest: PointInsertRequest = { - points: mockPoints, // 3 points, batch size is 2 - wait: true, - }; - - const mockResult = { - operation_id: 999, - status: 'completed', - }; - - mockClient.upsert.mockResolvedValue(mockResult); - - const result = await vectorOperations.insertPoints('test-collection', mockRequest); - - expect(result).toEqual({ - operation_id: 999, - status: 'completed', - }); - - // Should be called twice: first batch (2 points), second batch (1 point) - expect(mockClient.upsert).toHaveBeenCalledTimes(2); - }); - - it('should throw error when batch insertion fails', async () => { - mockClient.upsert.mockRejectedValue(new Error('Batch insert failed')); - - const mockRequest: PointInsertRequest = { - points: mockPoints, - }; - - await expect( - vectorOperations.insertPoints('test-collection', mockRequest) - ).rejects.toThrow( - 'Failed to insert points in collection test-collection: Batch insert failed' - ); - }); - }); - - describe('updatePoints', () => { - it('should update points successfully', async () => { - const mockRequest: PointUpdateRequest = { - points: [ - { id: 'point-1', vector: [0.7, 0.8], payload: { updated: true } }, - ], - wait: true, - ordering: 'strong', - }; - - const mockResult = { - operation_id: 111, - status: 'acknowledged', - }; - - mockClient.upsert.mockResolvedValue(mockResult); - - const result = await vectorOperations.updatePoints('test-collection', mockRequest); - - expect(result).toEqual({ - operation_id: 111, - status: 'acknowledged', - }); - - expect(mockClient.upsert).toHaveBeenCalledWith('test-collection', { - wait: true, - ordering: 'strong', - points: [ - { - id: 'point-1', - vector: [0.7, 0.8], - payload: { updated: true }, - }, - ], - }); - }); - - it('should throw error when update fails', async () => { - mockClient.upsert.mockRejectedValue(new Error('Update failed')); - - const mockRequest: PointUpdateRequest = { - points: [{ id: 'point-1', vector: [0.1, 0.2] }], - }; - - await expect( - vectorOperations.updatePoints('test-collection', mockRequest) - ).rejects.toThrow( - 'Failed to update points in collection test-collection: Update failed' - ); - }); - }); - - describe('deletePoints', () => { - it('should delete points successfully', async () => { - const mockRequest: PointDeleteRequest = { - ids: ['point-1', 'point-2', 'point-3'], - wait: true, - ordering: 'medium', - }; - - const mockResult = { - operation_id: 222, - status: 'completed', - }; - - mockClient.delete.mockResolvedValue(mockResult); - - const result = await vectorOperations.deletePoints('test-collection', mockRequest); - - expect(result).toEqual({ - operation_id: 222, - status: 'completed', - }); - - expect(mockClient.delete).toHaveBeenCalledWith('test-collection', { - wait: true, - ordering: 'medium', - points: ['point-1', 'point-2', 'point-3'], - }); - }); - - it('should throw error when deletion fails', async () => { - mockClient.delete.mockRejectedValue(new Error('Delete failed')); - - const mockRequest: PointDeleteRequest = { - ids: ['point-1'], - }; - - await expect( - vectorOperations.deletePoints('test-collection', mockRequest) - ).rejects.toThrow( - 'Failed to delete points in collection test-collection: Delete failed' - ); - }); - }); - - describe('getPoint', () => { - it('should retrieve single point successfully', async () => { - const mockRetrieveResult = [ - { - id: 'point-1', - vector: [0.1, 0.2, 0.3], - payload: { category: 'test' }, - }, - ]; - - mockClient.retrieve.mockResolvedValue(mockRetrieveResult); - - const result = await vectorOperations.getPoint('test-collection', 'point-1'); - - expect(result).toEqual({ - id: 'point-1', - vector: [0.1, 0.2, 0.3], - payload: { category: 'test' }, - }); - - expect(mockClient.retrieve).toHaveBeenCalledWith('test-collection', { - ids: ['point-1'], - with_payload: true, - with_vector: false, - }); - }); - - it('should retrieve point with custom options', async () => { - const mockRetrieveResult = [ - { - id: 'point-1', - vector: [0.1, 0.2, 0.3], - payload: { category: 'test' }, - }, - ]; - - mockClient.retrieve.mockResolvedValue(mockRetrieveResult); - - const result = await vectorOperations.getPoint( - 'test-collection', - 'point-1', - { - with_payload: false, - with_vector: true, - } - ); - - expect(result).toEqual({ - id: 'point-1', - vector: [0.1, 0.2, 0.3], - payload: { category: 'test' }, - }); - - expect(mockClient.retrieve).toHaveBeenCalledWith('test-collection', { - ids: ['point-1'], - with_payload: false, - with_vector: true, - }); - }); - - it('should return null when point not found', async () => { - mockClient.retrieve.mockResolvedValue([]); - - const result = await vectorOperations.getPoint('test-collection', 'nonexistent'); - - expect(result).toBeNull(); - }); - - it('should handle points without vectors', async () => { - const mockRetrieveResult = [ - { - id: 'point-1', - payload: { category: 'test' }, - }, - ]; - - mockClient.retrieve.mockResolvedValue(mockRetrieveResult); - - const result = await vectorOperations.getPoint('test-collection', 'point-1'); - - expect(result).toEqual({ - id: 'point-1', - vector: [], - payload: { category: 'test' }, - }); - }); - - it('should throw error when retrieval fails', async () => { - mockClient.retrieve.mockRejectedValue(new Error('Retrieve failed')); - - await expect( - vectorOperations.getPoint('test-collection', 'point-1') - ).rejects.toThrow( - 'Failed to get point point-1 from collection test-collection: Retrieve failed' - ); - }); - }); - - describe('getPoints', () => { - it('should retrieve multiple points successfully', async () => { - const mockRetrieveResult = [ - { id: 'point-1', vector: [0.1, 0.2], payload: { type: 'A' } }, - { id: 'point-2', vector: [0.3, 0.4], payload: { type: 'B' } }, - ]; - - mockClient.retrieve.mockResolvedValue(mockRetrieveResult); - - const result = await vectorOperations.getPoints( - 'test-collection', - ['point-1', 'point-2'] - ); - - expect(result).toEqual([ - { id: 'point-1', vector: [0.1, 0.2], payload: { type: 'A' } }, - { id: 'point-2', vector: [0.3, 0.4], payload: { type: 'B' } }, - ]); - - expect(mockClient.retrieve).toHaveBeenCalledWith('test-collection', { - ids: ['point-1', 'point-2'], - with_payload: true, - with_vector: false, - }); - }); - - it('should throw error when retrieval fails', async () => { - mockClient.retrieve.mockRejectedValue(new Error('Retrieve failed')); - - await expect( - vectorOperations.getPoints('test-collection', ['point-1']) - ).rejects.toThrow( - 'Failed to get points from collection test-collection: Retrieve failed' - ); - }); - }); - - describe('scrollPoints', () => { - it('should scroll points successfully', async () => { - const mockRequest: ScrollRequest = { - offset: 'offset-123', - limit: 50, - with_payload: true, - with_vector: false, - filter: { category: 'test' }, - }; - - const mockScrollResult = { - points: [ - { id: 'point-1', vector: [0.1, 0.2], payload: { type: 'A' } }, - { id: 'point-2', vector: [0.3, 0.4], payload: { type: 'B' } }, - ], - next_page_offset: 'offset-456', - }; - - mockClient.scroll.mockResolvedValue(mockScrollResult); - - const result = await vectorOperations.scrollPoints('test-collection', mockRequest); - - expect(result).toEqual({ - points: [ - { id: 'point-1', vector: [0.1, 0.2], payload: { type: 'A' } }, - { id: 'point-2', vector: [0.3, 0.4], payload: { type: 'B' } }, - ], - next_page_offset: 'offset-456', - }); - - expect(mockClient.scroll).toHaveBeenCalledWith('test-collection', { - offset: 'offset-123', - limit: 50, - with_payload: true, - with_vector: false, - filter: { category: 'test' }, - }); - }); - - it('should throw error when scrolling fails', async () => { - mockClient.scroll.mockRejectedValue(new Error('Scroll failed')); - - await expect( - vectorOperations.scrollPoints('test-collection', { limit: 10 }) - ).rejects.toThrow( - 'Failed to scroll points in collection test-collection: Scroll failed' - ); - }); - }); - - describe('updatePayload', () => { - it('should update payload successfully', async () => { - const mockResult = { - operation_id: 333, - status: 'acknowledged', - }; - - mockClient.setPayload.mockResolvedValue(mockResult); - - const result = await vectorOperations.updatePayload( - 'test-collection', - ['point-1', 'point-2'], - { updated: true, timestamp: '2023-01-01' } - ); - - expect(result).toEqual({ - operation_id: 333, - status: 'acknowledged', - }); - - expect(mockClient.setPayload).toHaveBeenCalledWith('test-collection', { - wait: true, - ordering: undefined, - payload: { updated: true, timestamp: '2023-01-01' }, - points: ['point-1', 'point-2'], - }); - }); - - it('should throw error when payload update fails', async () => { - mockClient.setPayload.mockRejectedValue(new Error('Payload update failed')); - - await expect( - vectorOperations.updatePayload('test-collection', ['point-1'], {}) - ).rejects.toThrow( - 'Failed to update payload for points in collection test-collection: Payload update failed' - ); - }); - }); - - describe('deletePayload', () => { - it('should delete payload keys successfully', async () => { - const mockResult = { - operation_id: 444, - status: 'completed', - }; - - mockClient.deletePayload.mockResolvedValue(mockResult); - - const result = await vectorOperations.deletePayload( - 'test-collection', - ['point-1', 'point-2'], - ['oldKey1', 'oldKey2'], - { wait: false, ordering: 'weak' } - ); - - expect(result).toEqual({ - operation_id: 444, - status: 'completed', - }); - - expect(mockClient.deletePayload).toHaveBeenCalledWith('test-collection', { - wait: false, - ordering: 'weak', - keys: ['oldKey1', 'oldKey2'], - points: ['point-1', 'point-2'], - }); - }); - - it('should throw error when payload deletion fails', async () => { - mockClient.deletePayload.mockRejectedValue(new Error('Payload delete failed')); - - await expect( - vectorOperations.deletePayload('test-collection', ['point-1'], ['key']) - ).rejects.toThrow( - 'Failed to delete payload keys for points in collection test-collection: Payload delete failed' - ); - }); - }); - - describe('clearPayload', () => { - it('should clear payload successfully', async () => { - const mockResult = { - operation_id: 555, - status: 'acknowledged', - }; - - mockClient.clearPayload.mockResolvedValue(mockResult); - - const result = await vectorOperations.clearPayload( - 'test-collection', - ['point-1', 'point-2'] - ); - - expect(result).toEqual({ - operation_id: 555, - status: 'acknowledged', - }); - - expect(mockClient.clearPayload).toHaveBeenCalledWith('test-collection', { - wait: true, - ordering: undefined, - points: ['point-1', 'point-2'], - }); - }); - - it('should throw error when payload clearing fails', async () => { - mockClient.clearPayload.mockRejectedValue(new Error('Clear payload failed')); - - await expect( - vectorOperations.clearPayload('test-collection', ['point-1']) - ).rejects.toThrow( - 'Failed to clear payload for points in collection test-collection: Clear payload failed' - ); - }); - }); - - describe('countPoints', () => { - it('should count points successfully', async () => { - const mockCountResult = { - count: 1500, - }; - - mockClient.count.mockResolvedValue(mockCountResult); - - const result = await vectorOperations.countPoints('test-collection'); - - expect(result).toBe(1500); - - expect(mockClient.count).toHaveBeenCalledWith('test-collection', { - filter: undefined, - exact: true, - }); - }); - - it('should count points with filter', async () => { - const mockCountResult = { - count: 750, - }; - - mockClient.count.mockResolvedValue(mockCountResult); - - const result = await vectorOperations.countPoints('test-collection', { - category: 'test', - }); - - expect(result).toBe(750); - - expect(mockClient.count).toHaveBeenCalledWith('test-collection', { - filter: { category: 'test' }, - exact: true, - }); - }); - - it('should throw error when counting fails', async () => { - mockClient.count.mockRejectedValue(new Error('Count failed')); - - await expect( - vectorOperations.countPoints('test-collection') - ).rejects.toThrow( - 'Failed to count points in collection test-collection: Count failed' - ); - }); - }); -}); \ No newline at end of file diff --git a/packages/core/src/vector/qdrant/index.ts b/packages/core/src/vector/qdrant/index.ts deleted file mode 100644 index 8ecc58e..0000000 --- a/packages/core/src/vector/qdrant/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * symbi-core - Qdrant Vector Database Integration - */ - -export { QdrantManager } from './QdrantManager'; -export { CollectionManager } from './CollectionManager'; -export { VectorOperations } from './VectorOperations'; -export { SearchEngine } from './SearchEngine'; -export { EmbeddingManager } from './EmbeddingManager'; \ No newline at end of file diff --git a/packages/mcp/package.json b/packages/mcp/package.json index ddb7fb3..6d4b961 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "symbi-mcp", - "version": "1.13.0", + "version": "1.14.3", "description": "MCP client integration for Symbiont SDK", "main": "dist/index.js", "module": "dist/index.esm.js", diff --git a/packages/mcp/src/McpClient.ts b/packages/mcp/src/McpClient.ts index feeeed2..c029bc3 100644 --- a/packages/mcp/src/McpClient.ts +++ b/packages/mcp/src/McpClient.ts @@ -1,14 +1,11 @@ import { WorkflowExecutionPayload, WorkflowExecutionResult, - WorkflowListResponse, - McpConnectionStatus, -} from '../../types/src/mcp'; -import { HealthStatus, RequestOptions, SymbiontConfig, } from 'symbi-types'; +import { buildRuntimeUrl } from './urlUtils'; /** * Simple interface to avoid circular dependency with SymbiontClient @@ -19,7 +16,12 @@ interface ClientDependency { } /** - * Client for managing workflows and MCP operations via the Symbiont Runtime API + * Client for executing workflows against the Symbiont Runtime API. + * + * The open-source Symbiont runtime (v1.14.3) exposes `POST /workflows/execute` + * and the health endpoints under a single `/api/v1` version segment. It does NOT + * expose `/workflows` (list), `/workflows/executions/*`, or `/mcp/*` routes, so + * those phantom methods have been removed. */ export class McpClient { private client: ClientDependency; @@ -30,7 +32,7 @@ export class McpClient { /** * Execute a workflow with parameters - * POST /workflows/execute + * POST /api/v1/workflows/execute */ async executeWorkflow( payload: WorkflowExecutionPayload @@ -53,7 +55,7 @@ export class McpClient { /** * Check server health status - * GET /health + * GET /api/v1/health */ async checkServerHealth(): Promise { return this.makeRequest('/health', { @@ -61,59 +63,6 @@ export class McpClient { }); } - /** - * List available workflows - * GET /workflows - */ - async listWorkflows(): Promise { - return this.makeRequest('/workflows', { - method: 'GET', - }); - } - - /** - * Get workflow execution status - * GET /workflows/executions/{executionId} - */ - async getExecutionStatus( - executionId: string - ): Promise> { - if (!executionId) { - throw new Error('Execution ID is required'); - } - - return this.makeRequest>( - `/workflows/executions/${executionId}`, - { - method: 'GET', - } - ); - } - - /** - * Cancel a workflow execution - * DELETE /workflows/executions/{executionId} - */ - async cancelExecution(executionId: string): Promise { - if (!executionId) { - throw new Error('Execution ID is required'); - } - - await this.makeRequest(`/workflows/executions/${executionId}`, { - method: 'DELETE', - }); - } - - /** - * Get MCP connection status - * GET /mcp/status - */ - async getConnectionStatus(): Promise { - return this.makeRequest('/mcp/status', { - method: 'GET', - }); - } - /** * Make an HTTP request using the underlying client */ @@ -124,11 +73,10 @@ export class McpClient { try { // Get authentication headers from the parent client const authHeaders = await this.client.getAuthHeaders(endpoint); - - // Build the full URL + + // Build the full URL, guaranteeing exactly one /api/v1 segment. const config = this.client.configuration; - const baseUrl = config.runtimeApiUrl; - const fullUrl = `${baseUrl}${endpoint}`; + const fullUrl = buildRuntimeUrl(config.runtimeApiUrl, endpoint); // Prepare headers const headers: Record = { @@ -175,4 +123,4 @@ export class McpClient { throw new Error('McpClient request failed: Unknown error'); } } -} \ No newline at end of file +} diff --git a/packages/mcp/src/__tests__/McpClient.integration.test.ts b/packages/mcp/src/__tests__/McpClient.integration.test.ts index dad2baf..fbefe02 100644 --- a/packages/mcp/src/__tests__/McpClient.integration.test.ts +++ b/packages/mcp/src/__tests__/McpClient.integration.test.ts @@ -1,12 +1,11 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { McpClient } from '../McpClient'; +import { buildRuntimeUrl } from '../urlUtils'; import { TestEnvironment } from '../../../testing/src/TestEnvironment'; import { mockErrorResponses } from '../../../testing/src/data/mockData'; import type { WorkflowExecutionPayload, WorkflowExecutionResult, - WorkflowListResponse, - McpConnectionStatus, HealthStatus, } from 'symbi-types'; @@ -29,6 +28,50 @@ describe('McpClient Integration Tests', () => { await testEnv.teardown(); }); + describe('URL normalization', () => { + it('targets the /api/v1-prefixed workflows/execute route', async () => { + const mocks = testEnv.getMocks(); + mocks.fetch.mockResponse('/workflows/execute', { + status: 200, + body: { + executionId: 'exec-url', + workflowId: 'workflow-url', + status: 'completed', + startedAt: '2024-01-01T12:00:00Z', + parameters: {}, + }, + }); + + await mcpClient.executeWorkflow({ + workflowId: 'workflow-url', + parameters: { input: 'x' }, + options: { priority: 'normal' }, + }); + + const calls = mocks.fetch.getCallsFor('/workflows/execute'); + expect(calls).toHaveLength(1); + // The runtime serves every route under a single /api/v1 segment. + expect(calls[0].url).toContain('/api/v1/workflows/execute'); + expect(calls[0].url).toBe( + buildRuntimeUrl('http://localhost:8080', '/workflows/execute') + ); + }); + + it('targets the /api/v1-prefixed health route', async () => { + const mocks = testEnv.getMocks(); + mocks.fetch.mockResponse('/health', { + status: 200, + body: { status: 'healthy', timestamp: '2024-01-01T12:00:00Z' }, + }); + + await mcpClient.checkServerHealth(); + + const calls = mocks.fetch.getCallsFor('/health'); + expect(calls).toHaveLength(1); + expect(calls[0].url).toContain('/api/v1/health'); + }); + }); + describe('executeWorkflow', () => { const mockPayload: WorkflowExecutionPayload = { workflowId: 'workflow-123', @@ -74,10 +117,11 @@ describe('McpClient Integration Tests', () => { const result = await mcpClient.executeWorkflow(mockPayload); expect(result).toEqual(mockResult); - + const calls = mocks.fetch.getCallsFor('/workflows/execute'); expect(calls).toHaveLength(1); expect(calls[0].method).toBe('POST'); + expect(calls[0].url).toContain('/api/v1/workflows/execute'); expect(JSON.parse(calls[0].body!)).toEqual(mockPayload); }); @@ -229,6 +273,66 @@ describe('McpClient Integration Tests', () => { expect(calls[0].headers['Content-Type']).toBe('application/json'); expect(calls[0].headers['Authorization']).toBe('Bearer test-api-key'); }); + + it('should preserve original error messages', async () => { + const mocks = testEnv.getMocks(); + const customError = 'Workflow validation failed: Invalid workflow definition'; + + mocks.fetch.mockResponse('/workflows/execute', { + status: 422, + body: customError, + }); + + await expect(mcpClient.executeWorkflow({ + workflowId: 'test', + parameters: { test: 'value' }, + options: { + priority: 'normal', + }, + })).rejects.toThrow(customError); + }); + + it('should handle workflow execution with complex result types', async () => { + const mocks = testEnv.getMocks(); + const complexResult = { + data: [ + { id: 1, name: 'Item 1', metadata: { tags: ['tag1', 'tag2'] } }, + { id: 2, name: 'Item 2', metadata: { tags: ['tag3'] } }, + ], + summary: { + totalItems: 2, + processingTime: '5.2s', + warnings: [], + }, + }; + + const executionResult: WorkflowExecutionResult = { + executionId: 'exec-complex', + workflowId: 'data-workflow', + status: 'completed', + result: complexResult, + startedAt: '2024-01-01T12:00:00Z', + completedAt: '2024-01-01T12:05:20Z', + duration: 320000, + parameters: { source: 'database', format: 'json' }, + }; + + mocks.fetch.mockResponse('/workflows/execute', { + status: 200, + body: executionResult, + }); + + const result = await mcpClient.executeWorkflow({ + workflowId: 'data-workflow', + parameters: { source: 'database', format: 'json' }, + options: { + priority: 'normal', + }, + }); + + expect(result.result?.data).toHaveLength(2); + expect(result.result?.summary.totalItems).toBe(2); + }); }); describe('checkServerHealth', () => { @@ -304,438 +408,4 @@ describe('McpClient Integration Tests', () => { ); }); }); - - describe('listWorkflows', () => { - it('should successfully list available workflows', async () => { - const mocks = testEnv.getMocks(); - const workflowList: WorkflowListResponse = [ - { - id: 'workflow-1', - name: 'Data Processing Workflow', - description: 'Processes incoming data and generates reports', - version: '1.2.0', - parameters: [ - { - name: 'inputData', - type: 'object', - required: true, - description: 'Input data to process', - }, - { - name: 'outputFormat', - type: 'string', - required: false, - description: 'Output format (json, csv, xml)', - defaultValue: 'json', - }, - ], - tags: ['data', 'processing', 'reports'], - createdAt: '2024-01-01T10:00:00Z', - updatedAt: '2024-01-01T11:00:00Z', - }, - { - id: 'workflow-2', - name: 'Notification Workflow', - description: 'Sends notifications to various channels', - version: '1.0.5', - parameters: [ - { - name: 'message', - type: 'string', - required: true, - description: 'Message to send', - }, - { - name: 'channels', - type: 'array', - required: true, - description: 'Notification channels', - }, - ], - tags: ['notification', 'communication'], - createdAt: '2024-01-02T10:00:00Z', - updatedAt: '2024-01-02T10:00:00Z', - }, - ]; - - mocks.fetch.mockResponse('/workflows', { - status: 200, - body: workflowList, - }); - - const result = await mcpClient.listWorkflows(); - - expect(result).toEqual(workflowList); - expect(result).toHaveLength(2); - expect(result[0].name).toBe('Data Processing Workflow'); - expect(result[1].name).toBe('Notification Workflow'); - expect(mocks.fetch.getCallsFor('/workflows')).toHaveLength(1); - }); - - it('should handle empty workflow list', async () => { - const mocks = testEnv.getMocks(); - mocks.fetch.mockResponse('/workflows', { - status: 200, - body: [], - }); - - const result = await mcpClient.listWorkflows(); - - expect(result).toEqual([]); - expect(result).toHaveLength(0); - }); - - it('should handle permission denied', async () => { - const mocks = testEnv.getMocks(); - mocks.fetch.mockResponse('/workflows', { - status: 403, - body: mockErrorResponses.forbidden, - }); - - await expect(mcpClient.listWorkflows()).rejects.toThrow( - 'MCP API request failed: 403' - ); - }); - - it('should handle server errors', async () => { - const mocks = testEnv.getMocks(); - mocks.fetch.mockResponse('/workflows', { - status: 500, - body: mockErrorResponses.serverError, - }); - - await expect(mcpClient.listWorkflows()).rejects.toThrow( - 'MCP API request failed: 500' - ); - }); - }); - - describe('getExecutionStatus', () => { - const executionId = 'exec-123'; - - it('should successfully get execution status', async () => { - const mocks = testEnv.getMocks(); - const executionStatus: WorkflowExecutionResult = { - executionId, - workflowId: 'workflow-123', - status: 'running', - startedAt: '2024-01-01T12:00:00Z', - parameters: { input: 'test data' }, - metadata: { progress: 50 }, - }; - - mocks.fetch.mockResponse(`/workflows/executions/${executionId}`, { - status: 200, - body: executionStatus, - }); - - const result = await mcpClient.getExecutionStatus(executionId); - - expect(result).toEqual(executionStatus); - expect(result.status).toBe('running'); - expect(mocks.fetch.getCallsFor(`/workflows/executions/${executionId}`)).toHaveLength(1); - }); - - it('should get completed execution status with result', async () => { - const mocks = testEnv.getMocks(); - const completedExecution: WorkflowExecutionResult = { - executionId, - workflowId: 'workflow-123', - status: 'completed', - result: 'Processing completed successfully', - startedAt: '2024-01-01T12:00:00Z', - completedAt: '2024-01-01T12:05:00Z', - duration: 300000, - parameters: { input: 'test data' }, - }; - - mocks.fetch.mockResponse(`/workflows/executions/${executionId}`, { - status: 200, - body: completedExecution, - }); - - const result = await mcpClient.getExecutionStatus(executionId); - - expect(result).toEqual(completedExecution); - expect(result.status).toBe('completed'); - expect(result.result).toBe('Processing completed successfully'); - }); - - it('should throw error for empty execution ID', async () => { - await expect(mcpClient.getExecutionStatus('')).rejects.toThrow( - 'Execution ID is required' - ); - }); - - it('should handle execution not found', async () => { - const mocks = testEnv.getMocks(); - mocks.fetch.mockResponse(`/workflows/executions/${executionId}`, { - status: 404, - body: mockErrorResponses.notFound, - }); - - await expect(mcpClient.getExecutionStatus(executionId)).rejects.toThrow( - 'MCP API request failed: 404' - ); - }); - - it('should handle failed execution status', async () => { - const mocks = testEnv.getMocks(); - const failedExecution: WorkflowExecutionResult = { - executionId, - workflowId: 'workflow-123', - status: 'failed', - error: 'Workflow execution failed: Invalid input format', - startedAt: '2024-01-01T12:00:00Z', - completedAt: '2024-01-01T12:01:00Z', - duration: 60000, - parameters: { input: 'invalid data' }, - }; - - mocks.fetch.mockResponse(`/workflows/executions/${executionId}`, { - status: 200, - body: failedExecution, - }); - - const result = await mcpClient.getExecutionStatus(executionId); - - expect(result.status).toBe('failed'); - expect(result.error).toContain('Invalid input format'); - }); - }); - - describe('cancelExecution', () => { - const executionId = 'exec-123'; - - it('should successfully cancel execution', async () => { - const mocks = testEnv.getMocks(); - mocks.fetch.mockResponse(`/workflows/executions/${executionId}`, { - status: 204, - body: '', - }); - - await expect(mcpClient.cancelExecution(executionId)).resolves.toBeUndefined(); - - const calls = mocks.fetch.getCallsFor(`/workflows/executions/${executionId}`); - expect(calls).toHaveLength(1); - expect(calls[0].method).toBe('DELETE'); - }); - - it('should throw error for empty execution ID', async () => { - await expect(mcpClient.cancelExecution('')).rejects.toThrow( - 'Execution ID is required' - ); - }); - - it('should handle execution not found', async () => { - const mocks = testEnv.getMocks(); - mocks.fetch.mockResponse(`/workflows/executions/${executionId}`, { - status: 404, - body: mockErrorResponses.notFound, - }); - - await expect(mcpClient.cancelExecution(executionId)).rejects.toThrow( - 'MCP API request failed: 404' - ); - }); - - it('should handle execution already completed', async () => { - const mocks = testEnv.getMocks(); - mocks.fetch.mockResponse(`/workflows/executions/${executionId}`, { - status: 409, - body: { error: 'Execution already completed', message: 'Cannot cancel completed execution' }, - }); - - await expect(mcpClient.cancelExecution(executionId)).rejects.toThrow( - 'MCP API request failed: 409' - ); - }); - - it('should handle permission denied', async () => { - const mocks = testEnv.getMocks(); - mocks.fetch.mockResponse(`/workflows/executions/${executionId}`, { - status: 403, - body: mockErrorResponses.forbidden, - }); - - await expect(mcpClient.cancelExecution(executionId)).rejects.toThrow( - 'MCP API request failed: 403' - ); - }); - }); - - describe('getConnectionStatus', () => { - it('should successfully get MCP connection status', async () => { - const mocks = testEnv.getMocks(); - const connectionStatus: McpConnectionStatus = { - connected: true, - serverInfo: { - name: 'Symbiont MCP Server', - version: '2.1.0', - description: 'Main MCP server for workflow orchestration', - capabilities: ['workflows', 'tool-execution', 'health-monitoring'], - endpoints: ['/workflows', '/health', '/mcp/status'], - health: { - status: 'healthy', - timestamp: '2024-01-01T12:00:00Z', - version: '2.1.0', - uptime: 86400000, - }, - }, - lastConnectedAt: '2024-01-01T08:00:00Z', - }; - - mocks.fetch.mockResponse('/mcp/status', { - status: 200, - body: connectionStatus, - }); - - const result = await mcpClient.getConnectionStatus(); - - expect(result).toEqual(connectionStatus); - expect(result.connected).toBe(true); - expect(result.serverInfo?.name).toBe('Symbiont MCP Server'); - expect(result.serverInfo?.capabilities).toContain('workflows'); - expect(mocks.fetch.getCallsFor('/mcp/status')).toHaveLength(1); - }); - - it('should handle disconnected MCP server', async () => { - const mocks = testEnv.getMocks(); - const disconnectedStatus: McpConnectionStatus = { - connected: false, - lastError: 'Connection timeout after 30 seconds', - lastConnectedAt: '2024-01-01T07:00:00Z', - }; - - mocks.fetch.mockResponse('/mcp/status', { - status: 200, - body: disconnectedStatus, - }); - - const result = await mcpClient.getConnectionStatus(); - - expect(result.connected).toBe(false); - expect(result.lastError).toContain('timeout'); - expect(result.serverInfo).toBeUndefined(); - }); - - it('should handle service unavailable', async () => { - const mocks = testEnv.getMocks(); - mocks.fetch.mockResponse('/mcp/status', { - status: 503, - body: { error: 'Service Unavailable', message: 'MCP service is temporarily unavailable' }, - }); - - await expect(mcpClient.getConnectionStatus()).rejects.toThrow( - 'MCP API request failed: 503' - ); - }); - - it('should handle authentication failure', async () => { - const mocks = testEnv.getMocks(); - mocks.fetch.mockResponse('/mcp/status', { - status: 401, - body: mockErrorResponses.unauthorized, - }); - - await expect(mcpClient.getConnectionStatus()).rejects.toThrow( - 'MCP API request failed: 401' - ); - }); - }); - - describe('error handling and edge cases', () => { - it('should handle network timeouts', async () => { - const mocks = testEnv.getMocks(); - - mocks.fetch.mockResponse('/health', { - status: 200, - body: { status: 'healthy', timestamp: '2024-01-01T12:00:00Z' }, - delay: 5000, // 5 second delay - }); - - // This would timeout in a real scenario with proper timeout configuration - await expect(mcpClient.checkServerHealth()).resolves.toBeDefined(); - }); - - it('should handle malformed JSON responses', async () => { - const mocks = testEnv.getMocks(); - mocks.fetch.mockResponse('/workflows', { - status: 200, - body: 'invalid json', - }); - - await expect(mcpClient.listWorkflows()).rejects.toThrow(); - }); - - it('should preserve original error messages', async () => { - const mocks = testEnv.getMocks(); - const customError = 'Workflow validation failed: Invalid workflow definition'; - - mocks.fetch.mockResponse('/workflows/execute', { - status: 422, - body: customError, - }); - - await expect(mcpClient.executeWorkflow({ - workflowId: 'test', - parameters: { test: 'value' }, - options: { - priority: 'normal', - }, - })).rejects.toThrow(customError); - }); - - it('should handle empty response bodies gracefully', async () => { - const mocks = testEnv.getMocks(); - mocks.fetch.mockResponse('/workflows/executions/test-id', { - status: 204, - body: '', - }); - - await expect(mcpClient.cancelExecution('test-id')).resolves.toBeUndefined(); - }); - - it('should handle workflow execution with complex result types', async () => { - const mocks = testEnv.getMocks(); - const complexResult = { - data: [ - { id: 1, name: 'Item 1', metadata: { tags: ['tag1', 'tag2'] } }, - { id: 2, name: 'Item 2', metadata: { tags: ['tag3'] } }, - ], - summary: { - totalItems: 2, - processingTime: '5.2s', - warnings: [], - }, - }; - - const executionResult: WorkflowExecutionResult = { - executionId: 'exec-complex', - workflowId: 'data-workflow', - status: 'completed', - result: complexResult, - startedAt: '2024-01-01T12:00:00Z', - completedAt: '2024-01-01T12:05:20Z', - duration: 320000, - parameters: { source: 'database', format: 'json' }, - }; - - mocks.fetch.mockResponse('/workflows/execute', { - status: 200, - body: executionResult, - }); - - const result = await mcpClient.executeWorkflow({ - workflowId: 'data-workflow', - parameters: { source: 'database', format: 'json' }, - options: { - priority: 'normal', - }, - }); - - expect(result.result?.data).toHaveLength(2); - expect(result.result?.summary.totalItems).toBe(2); - }); - }); -}); \ No newline at end of file +}); diff --git a/packages/mcp/src/urlUtils.ts b/packages/mcp/src/urlUtils.ts new file mode 100644 index 0000000..54d8c7c --- /dev/null +++ b/packages/mcp/src/urlUtils.ts @@ -0,0 +1,32 @@ +/** + * URL normalization helpers for Symbiont Runtime API clients. + * + * The runtime serves every route under a single `/api/v1` version segment. + * Base URLs are configured WITHOUT the version segment (e.g. http://localhost:8080), + * and call sites use BARE paths (e.g. `/agents/{id}/execute`). This helper guarantees + * the final URL contains exactly one `/api/v1` prefix regardless of whether the base + * URL already ends with `/api/v1` or the endpoint already starts with `/api/v1`. + */ +export function buildRuntimeUrl( + baseUrl: string | undefined, + endpoint: string +): string { + // Strip trailing slashes from the base URL. + let base = (baseUrl ?? '').replace(/\/+$/, ''); + + // Remove a trailing /api/v1 from the base URL if present. + base = base.replace(/\/api\/v1$/, ''); + + // Ensure the endpoint starts with a single leading slash. + let path = endpoint.startsWith('/') ? endpoint : `/${endpoint}`; + + // Remove a leading /api/v1 from the endpoint if present. + path = path.replace(/^\/api\/v1(?=\/|$)/, ''); + + // Ensure the remaining path still has a leading slash. + if (!path.startsWith('/')) { + path = `/${path}`; + } + + return `${base}/api/v1${path}`; +} diff --git a/packages/tool-review/README.md b/packages/tool-review/README.md index 8e39fce..0de7d17 100644 --- a/packages/tool-review/README.md +++ b/packages/tool-review/README.md @@ -1,5 +1,7 @@ # symbi-tool-review +> ⚠️ **Deprecated for OSS use.** `@symbiont/tool-review` targets the hosted Symbiont Tool Review API (a separate service), which is not exposed by the open-source Symbiont runtime. It remains functional against that hosted service but is not covered by OSS-runtime parity. + [![npm](https://img.shields.io/npm/v/symbi-tool-review.svg)](https://www.npmjs.com/package/symbi-tool-review) [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](../../LICENSE) diff --git a/packages/tool-review/package.json b/packages/tool-review/package.json index ad0bdf1..13d81dc 100644 --- a/packages/tool-review/package.json +++ b/packages/tool-review/package.json @@ -2,6 +2,7 @@ "name": "symbi-tool-review", "version": "1.13.0", "description": "Tool Review API client for Symbiont SDK", + "deprecated": "Targets the hosted Symbiont Tool Review API, which is not part of the open-source Symbiont runtime. Unmaintained for OSS use as of 1.14.3.", "main": "dist/index.js", "module": "dist/index.esm.js", "types": "dist/index.d.ts", diff --git a/packages/tool-review/src/ToolReviewClient.ts b/packages/tool-review/src/ToolReviewClient.ts index 6895c3e..aafc4f7 100644 --- a/packages/tool-review/src/ToolReviewClient.ts +++ b/packages/tool-review/src/ToolReviewClient.ts @@ -25,6 +25,7 @@ interface ClientDependency { /** * Client for managing tool review workflows via the Tool Review API */ +/** @deprecated Targets the hosted Tool Review API, not the OSS Symbiont runtime. */ export class ToolReviewClient { private client: ClientDependency;