diff --git a/packages/ack-id/eslint.config.js b/packages/ack-id/eslint.config.js index 0979926..4299bdb 100644 --- a/packages/ack-id/eslint.config.js +++ b/packages/ack-id/eslint.config.js @@ -2,6 +2,14 @@ import { config } from "@repo/eslint-config/base" -export default config({ - root: import.meta.dirname -}) +export default [ + ...config({ + root: import.meta.dirname + }), + { + files: ["**/*.test.ts"], + rules: { + "@cspell/spellchecker": "off" + } + } +] diff --git a/packages/ack-id/src/discovery/base-discovery-service.ts b/packages/ack-id/src/discovery/base-discovery-service.ts new file mode 100644 index 0000000..579ad25 --- /dev/null +++ b/packages/ack-id/src/discovery/base-discovery-service.ts @@ -0,0 +1,104 @@ +/* eslint-disable @typescript-eslint/no-unnecessary-condition */ +// This rule is disabled because TypeScript's type inference is too strict here. +// The rule incorrectly flags our null checks and array length checks as unnecessary, +// even though they're needed for runtime safety and code clarity. +import type { + AgentDiscoveryService, + AgentRegistration, + DiscoveryFilter, + DiscoveryOptions, + DiscoveryResponse +} from "./types" +import type { DidUri } from "@agentcommercekit/did" + +/** + * Base class for implementing agent discovery services + */ +export abstract class BaseDiscoveryService implements AgentDiscoveryService { + /** + * Register an agent with the discovery service + */ + abstract register(registration: AgentRegistration): Promise + + /** + * Update an agent's registration + */ + abstract update( + did: DidUri, + registration: Partial + ): Promise + + /** + * Deregister an agent from the discovery service + */ + abstract deregister(did: DidUri): Promise + + /** + * Get a specific agent's registration + */ + abstract get(did: DidUri): Promise + + /** + * Discover agents matching the given filters + */ + abstract discover( + filter: DiscoveryFilter, + options?: DiscoveryOptions + ): Promise + + /** + * Check if an agent matches the given filter + */ + protected matchesFilter( + agent: AgentRegistration, + filter: DiscoveryFilter + ): boolean { + // Check protocols + const protocols = filter.protocols ?? [] + if (!protocols.every((p) => agent.capabilities.protocols.includes(p))) { + return false + } + + // Check service types + const serviceTypes = filter.serviceTypes ?? [] + if ( + !serviceTypes.every((s) => agent.capabilities.serviceTypes.includes(s)) + ) { + return false + } + + // Check attributes + const attributes = filter.attributes ?? {} + return Object.entries(attributes).every( + ([key, value]) => agent.capabilities.attributes[key] === value + ) + } + + /** + * Check if an agent registration has expired + */ + protected isExpired(agent: AgentRegistration): boolean { + return Date.now() > (agent.expiresAt ?? Number.MAX_SAFE_INTEGER) + } + + /** + * Validate agent registration data + */ + protected validateRegistration(registration: AgentRegistration): void { + // Ensure timestamp is present + registration.timestamp = registration.timestamp || Date.now() + + // Ensure capabilities are present + if (!registration.capabilities) { + throw new Error("Agent capabilities are required") + } + + // Initialize capabilities if not present + registration.capabilities.protocols = + registration.capabilities.protocols ?? [] + registration.capabilities.serviceTypes = + registration.capabilities.serviceTypes ?? [] + registration.capabilities.attributes = + registration.capabilities.attributes ?? {} + } +} diff --git a/packages/ack-id/src/discovery/memory-discovery-service.test.ts b/packages/ack-id/src/discovery/memory-discovery-service.test.ts new file mode 100644 index 0000000..544fa10 --- /dev/null +++ b/packages/ack-id/src/discovery/memory-discovery-service.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, test } from "vitest" +import { MemoryDiscoveryService } from "./memory-discovery-service" +import type { AgentRegistration } from "./types" + +describe("MemoryDiscoveryService", () => { + const createService = () => new MemoryDiscoveryService() + const createAgent = (did: string): AgentRegistration => ({ + did: `did:web:${did}`, + timestamp: Date.now(), + capabilities: { + protocols: ["test"], + serviceTypes: ["test"], + attributes: { + test: "test" + } + } + }) + + test("registers and retrieves an agent", async () => { + const service = createService() + const agent = createAgent("example.com") + + await service.register(agent) + const result = await service.get(agent.did) + + expect(result).toEqual(agent) + }) + + test("updates an agent registration", async () => { + const service = createService() + const agent = createAgent("example.com") + + await service.register(agent) + await service.update(agent.did, { + capabilities: { + protocols: ["test"], + serviceTypes: ["updated"], + attributes: { + test: "updated" + } + } + }) + + const result = await service.get(agent.did) + expect(result).toBeDefined() + expect(result).toMatchObject({ + capabilities: { + protocols: ["test"], + serviceTypes: ["updated"], + attributes: { test: "updated" } + } + }) + }) + + test("deregisters an agent", async () => { + const service = createService() + const agent = createAgent("example.com") + + await service.register(agent) + await service.deregister(agent.did) + + const result = await service.get(agent.did) + expect(result).toBeUndefined() + }) + + test("discovers agents by protocol", async () => { + const service = createService() + const agent1 = createAgent("example1.com") + const agent2 = createAgent("example2.com") + agent2.capabilities.protocols = ["other"] + + await service.register(agent1) + await service.register(agent2) + + const result = await service.discover({ protocols: ["test"] }) + expect(result.total).toBe(1) + expect(result.agents[0]).toEqual(agent1) + }) + + test("discovers agents by service type", async () => { + const service = createService() + const agent1 = createAgent("example1.com") + const agent2 = createAgent("example2.com") + agent2.capabilities.serviceTypes = ["other"] + + await service.register(agent1) + await service.register(agent2) + + const result = await service.discover({ serviceTypes: ["test"] }) + expect(result.total).toBe(1) + expect(result.agents[0]).toEqual(agent1) + }) + + test("discovers agents by attribute", async () => { + const service = createService() + const agent1 = createAgent("example1.com") + const agent2 = createAgent("example2.com") + agent2.capabilities.attributes = { test: "other" } + + await service.register(agent1) + await service.register(agent2) + + const result = await service.discover({ + attributes: { test: "test" } + }) + expect(result.total).toBe(1) + expect(result.agents[0]).toEqual(agent1) + }) +}) diff --git a/packages/ack-id/src/discovery/memory-discovery-service.ts b/packages/ack-id/src/discovery/memory-discovery-service.ts new file mode 100644 index 0000000..6ad54e7 --- /dev/null +++ b/packages/ack-id/src/discovery/memory-discovery-service.ts @@ -0,0 +1,101 @@ +import { BaseDiscoveryService } from "./base-discovery-service" +import type { + AgentRegistration, + DiscoveryFilter, + DiscoveryOptions, + DiscoveryResponse +} from "./types" +import type { DidUri } from "@agentcommercekit/did" + +/** + * In-memory implementation of the agent discovery service + */ +export class MemoryDiscoveryService extends BaseDiscoveryService { + private agents: Map + + constructor() { + super() + this.agents = new Map() + } + + /** + * Register an agent with the discovery service + */ + register(registration: AgentRegistration): Promise { + this.validateRegistration(registration) + this.agents.set(registration.did, registration) + return Promise.resolve() + } + + /** + * Update an agent's registration + */ + async update( + did: DidUri, + registration: Partial + ): Promise { + const existing = await this.get(did) + if (!existing) { + throw new Error(`Agent ${did} not found`) + } + + const updated: AgentRegistration = { + ...existing, + ...registration, + capabilities: { + protocols: + registration.capabilities?.protocols ?? + existing.capabilities.protocols, + serviceTypes: + registration.capabilities?.serviceTypes ?? + existing.capabilities.serviceTypes, + attributes: { + ...existing.capabilities.attributes, + ...registration.capabilities?.attributes + } + } + } + + this.validateRegistration(updated) + this.agents.set(did, updated) + } + + /** + * Deregister an agent from the discovery service + */ + deregister(did: DidUri): Promise { + this.agents.delete(did) + return Promise.resolve() + } + + /** + * Get a specific agent's registration + */ + get(did: DidUri): Promise { + const agent = this.agents.get(did) + return Promise.resolve(agent ?? undefined) + } + + /** + * Discover agents matching the given filters + */ + discover( + filter: DiscoveryFilter, + options?: DiscoveryOptions + ): Promise { + const { limit = 10, includeExpired = false } = options ?? {} + const agents = Array.from(this.agents.values()) + .filter((agent) => { + if (!includeExpired && this.isExpired(agent)) { + return false + } + return this.matchesFilter(agent, filter) + }) + .slice(0, limit) + + return Promise.resolve({ + agents, + total: agents.length + }) + } +} diff --git a/packages/ack-id/src/discovery/schemas/valibot.ts b/packages/ack-id/src/discovery/schemas/valibot.ts new file mode 100644 index 0000000..633dfdc --- /dev/null +++ b/packages/ack-id/src/discovery/schemas/valibot.ts @@ -0,0 +1,57 @@ +import { didUriSchema } from "@agentcommercekit/did/schemas/valibot" +import { + array, + boolean, + number, + object, + optional, + record, + string, + unknown +} from "valibot" + +/** + * Schema for agent capabilities + */ +export const agentCapabilitiesSchema = object({ + protocols: array(string()), + serviceTypes: array(string()), + attributes: record(string(), string()) +}) + +/** + * Schema for agent registration + */ +export const agentRegistrationSchema = object({ + did: didUriSchema, + capabilities: agentCapabilitiesSchema, + timestamp: number(), + expiresAt: optional(number()) +}) + +/** + * Schema for discovery filter + */ +export const discoveryFilterSchema = object({ + protocols: optional(array(string())), + serviceTypes: optional(array(string())), + attributes: optional(record(string(), string())) +}) + +/** + * Schema for discovery options + */ +export const discoveryOptionsSchema = object({ + limit: optional(number()), + after: optional(string()), + includeExpired: optional(string()) +}) + +/** + * Schema for discovery response + */ +export const discoveryResponseSchema = object({ + agents: array(agentRegistrationSchema), + nextPage: optional(string()), + total: number() +}) diff --git a/packages/ack-id/src/discovery/schemas/zod.ts b/packages/ack-id/src/discovery/schemas/zod.ts new file mode 100644 index 0000000..44c93bf --- /dev/null +++ b/packages/ack-id/src/discovery/schemas/zod.ts @@ -0,0 +1,48 @@ +import { didUriSchema } from "@agentcommercekit/did/schemas/zod" +import { z } from "zod" + +/** + * Schema for agent capabilities + */ +export const agentCapabilitiesSchema = z.object({ + protocols: z.array(z.string()), + serviceTypes: z.array(z.string()), + attributes: z.record(z.string(), z.unknown()) +}) + +/** + * Schema for agent registration + */ +export const agentRegistrationSchema = z.object({ + did: didUriSchema, + capabilities: agentCapabilitiesSchema, + timestamp: z.number(), + expiresAt: z.number().optional() +}) + +/** + * Schema for discovery filter + */ +export const discoveryFilterSchema = z.object({ + protocols: z.array(z.string()).optional(), + serviceTypes: z.array(z.string()).optional(), + attributes: z.record(z.string(), z.unknown()).optional() +}) + +/** + * Schema for discovery options + */ +export const discoveryOptionsSchema = z.object({ + limit: z.number().optional(), + after: z.string().optional(), + includeExpired: z.boolean().optional() +}) + +/** + * Schema for discovery response + */ +export const discoveryResponseSchema = z.object({ + agents: z.array(agentRegistrationSchema), + nextPage: z.string().optional(), + total: z.number() +}) diff --git a/packages/ack-id/src/discovery/types.ts b/packages/ack-id/src/discovery/types.ts new file mode 100644 index 0000000..4cf01d4 --- /dev/null +++ b/packages/ack-id/src/discovery/types.ts @@ -0,0 +1,103 @@ +import type { DidUri } from "@agentcommercekit/did" + +/** + * Agent capabilities and attributes for discovery + */ +export interface AgentCapabilities { + /** List of supported protocols */ + protocols: string[] + /** List of supported service types */ + serviceTypes: string[] + /** Custom attributes for filtering */ + attributes: Record +} + +/** + * Agent registration data + */ +export interface AgentRegistration { + /** Agent's DID */ + did: DidUri + /** Agent capabilities */ + capabilities: AgentCapabilities + /** Registration timestamp */ + timestamp: number + /** Optional expiration timestamp */ + expiresAt?: number +} + +/** + * Query filters for discovering agents + */ +export interface DiscoveryFilter { + /** Required protocols */ + protocols?: string[] + /** Required service types */ + serviceTypes?: string[] + /** Required attributes */ + attributes?: Record +} + +/** + * Discovery query options + */ +export interface DiscoveryOptions { + /** Maximum number of results */ + limit?: number + /** Pagination token */ + after?: string + /** Include expired registrations */ + includeExpired?: boolean +} + +/** + * Discovery query response + */ +export interface DiscoveryResponse { + /** Found agents */ + agents: AgentRegistration[] + /** Next page token */ + nextPage?: string + /** Total number of matching agents */ + total: number +} + +/** + * Interface for agent discovery service + */ +export interface AgentDiscoveryService { + /** + * Register an agent with the discovery service + * @param registration Agent registration data + */ + register(registration: AgentRegistration): Promise + + /** + * Update an agent's registration + * @param did Agent's DID + * @param registration Updated registration data + */ + update(did: DidUri, registration: Partial): Promise + + /** + * Deregister an agent from the discovery service + * @param did Agent's DID + */ + deregister(did: DidUri): Promise + + /** + * Discover agents matching the given filters + * @param filter Discovery filters + * @param options Query options + */ + discover( + filter: DiscoveryFilter, + options?: DiscoveryOptions + ): Promise + + /** + * Get a specific agent's registration + * @param did Agent's DID + */ + get(did: DidUri): Promise +}