Skip to content

feat(ack-id): Add basic agent discovery service #5

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions packages/ack-id/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
]
104 changes: 104 additions & 0 deletions packages/ack-id/src/discovery/base-discovery-service.ts
Original file line number Diff line number Diff line change
@@ -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<void>

/**
* Update an agent's registration
*/
abstract update(
did: DidUri,
registration: Partial<AgentRegistration>
): Promise<void>

/**
* Deregister an agent from the discovery service
*/
abstract deregister(did: DidUri): Promise<void>

/**
* Get a specific agent's registration
*/
abstract get(did: DidUri): Promise<AgentRegistration | undefined>

/**
* Discover agents matching the given filters
*/
abstract discover(
filter: DiscoveryFilter,
options?: DiscoveryOptions
): Promise<DiscoveryResponse>

/**
* 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 ?? {}
}
}
109 changes: 109 additions & 0 deletions packages/ack-id/src/discovery/memory-discovery-service.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
101 changes: 101 additions & 0 deletions packages/ack-id/src/discovery/memory-discovery-service.ts
Original file line number Diff line number Diff line change
@@ -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<string, AgentRegistration>

constructor() {
super()
this.agents = new Map()
}

/**
* Register an agent with the discovery service
*/
register(registration: AgentRegistration): Promise<void> {
this.validateRegistration(registration)
this.agents.set(registration.did, registration)
return Promise.resolve()
}

/**
* Update an agent's registration
*/
async update(
did: DidUri,
registration: Partial<AgentRegistration>
): Promise<void> {
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<void> {
this.agents.delete(did)
return Promise.resolve()
}

/**
* Get a specific agent's registration
*/
get(did: DidUri): Promise<AgentRegistration | undefined> {
const agent = this.agents.get(did)
return Promise.resolve(agent ?? undefined)
}

/**
* Discover agents matching the given filters
*/
discover(
filter: DiscoveryFilter,
options?: DiscoveryOptions
): Promise<DiscoveryResponse> {
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
})
}
}
57 changes: 57 additions & 0 deletions packages/ack-id/src/discovery/schemas/valibot.ts
Original file line number Diff line number Diff line change
@@ -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()
})
Loading