diff --git a/docs/development.md b/docs/development.md index d08f5be..9ef8c78 100644 --- a/docs/development.md +++ b/docs/development.md @@ -124,6 +124,81 @@ Build a single service with `npm run build:` (e.g. `build:orchestrator`, `build:oracle`) — see `package.json` for the full list. Each maps to an esbuild invocation that bundles the service into `packages//dist/`. +## Registry API endpoints + +### GET /agents + +Discover agents with optional filters and pagination support. + +**Query parameters** + +- `capabilities` (optional): Comma-separated list of required capabilities +- `min_reputation` (optional): Minimum reputation score (0-100) +- `payment_model` (optional): Filter by payment model (`x402` or `mpp`) +- `status` (optional): Filter by agent status (e.g., `active`) +- `limit` (optional): Number of results to return per page (default: 20, max: 100, min: 1) +- `offset` (optional): Number of results to skip (default: 0, min: 0) + +**Behavior** + +- Agents are ordered by reputation score (highest first) before pagination +- Pagination is applied after all filters +- Invalid/negative values for `limit` and `offset` are clamped to valid ranges, not rejected +- When `limit` or `offset` are provided, the response includes an envelope with metadata +- When neither `limit` nor `offset` are provided, the response is a bare array (backward compatible) + +**Response without pagination (backward compatible)** + +```json +[ + { + "agent_id": "agent-web-intel", + "name": "Web Intel Agent", + ... + } +] +``` + +**Response with pagination** + +```json +{ + "agents": [ + { + "agent_id": "agent-web-intel", + "name": "Web Intel Agent", + ... + } + ], + "total": 45, + "limit": 20, + "offset": 0 +} +``` + +**Example requests** + +```bash +# Get first 20 agents (paginated, first page) +curl "http://localhost:4000/agents?limit=20" + +# Get agents 21-40 +curl "http://localhost:4000/agents?limit=20&offset=20" + +# Filter by capabilities and paginate +curl http://localhost:4000/agents?capabilities=web-search,news&limit=10 + +# Filter by minimum reputation and paginate +curl http://localhost:4000/agents?min_reputation=70&limit=5&offset=10 +``` + +**Pagination design rationale** + +- Default limit of 20: Balances dashboard rendering performance with API efficiency for typical use cases +- Maximum limit of 100: Applies only when pagination parameters are provided; prevents unbounded responses while allowing bulk operations +- Reputation ordering: Ensures stable, deterministic pagination across requests +- Backward compatibility: Existing clients without pagination params continue to work with bare array responses + ## Orchestrator API endpoints ### POST /api/tasks/preview diff --git a/package-lock.json b/package-lock.json index 6cd1da3..62f3d47 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,12 +16,14 @@ }, "devDependencies": { "@eslint/js": "^9.39.4", + "@types/supertest": "^7.2.1", "@vitejs/plugin-react": "^4.3.0", "concurrently": "^8.2.0", "esbuild": "^0.25.0", "eslint": "^9.39.4", "globals": "^15.15.0", "prettier": "^3.8.4", + "supertest": "^7.2.2", "tsx": "^4.7.0", "typescript": "^5.4.0", "typescript-eslint": "^8.61.0", @@ -2186,6 +2188,16 @@ "node": ">= 8" } }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -4164,6 +4176,13 @@ "@types/node": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -4219,6 +4238,13 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "license": "MIT" }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -4311,6 +4337,30 @@ "@types/node": "*" } }, + "node_modules/@types/superagent": { + "version": "8.1.11", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.11.tgz", + "integrity": "sha512-KA7srSW/HENDtOw9DOqaFLgWuMqN9WgjEw62lh9dpvRaZDkhdOkazASd7X7i2eMUYLHa1U37ZttnePsH5zTDHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.1.tgz", + "integrity": "sha512-4CbBvoYVLHL7+yhbYrZET0vsvuyXTC05aRe7dNQkwMzm56auceoy6Yu3K50uZmwfHna1os3CMSgM/3QVkUtPTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -5609,6 +5659,13 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -6353,6 +6410,16 @@ "node": ">=20" } }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -6451,6 +6518,13 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -6702,6 +6776,17 @@ ], "license": "MIT" }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -7754,6 +7839,13 @@ "node": ">=6" } }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-stable-stringify": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fast-stable-stringify/-/fast-stable-stringify-1.0.0.tgz", @@ -7969,6 +8061,24 @@ "node": ">= 12.20" } }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -11129,6 +11239,65 @@ "node": ">= 6" } }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/superagent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/superstruct": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/superstruct/-/superstruct-2.0.2.tgz", @@ -11138,6 +11307,31 @@ "node": ">=14.0.0" } }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/supports-color": { "version": "8.1.1", "dev": true, @@ -11431,6 +11625,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -11447,6 +11642,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -11463,6 +11659,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -11479,6 +11676,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -11495,6 +11693,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -11511,6 +11710,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -11527,6 +11727,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -11543,6 +11744,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -11559,6 +11761,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -11575,6 +11778,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -11591,6 +11795,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -11607,6 +11812,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -11623,6 +11829,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -11639,6 +11846,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -11655,6 +11863,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -11671,6 +11880,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -11687,6 +11897,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -11703,6 +11914,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -11719,6 +11931,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -11735,6 +11948,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -11751,6 +11965,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -11767,6 +11982,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -11783,6 +11999,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -11799,6 +12016,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -11815,6 +12033,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -11831,6 +12050,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ diff --git a/package.json b/package.json index e9f755a..801456e 100644 --- a/package.json +++ b/package.json @@ -36,12 +36,14 @@ }, "devDependencies": { "@eslint/js": "^9.39.4", + "@types/supertest": "^7.2.1", "@vitejs/plugin-react": "^4.3.0", "concurrently": "^8.2.0", "esbuild": "^0.25.0", "eslint": "^9.39.4", "globals": "^15.15.0", "prettier": "^3.8.4", + "supertest": "^7.2.2", "tsx": "^4.7.0", "typescript": "^5.4.0", "typescript-eslint": "^8.61.0", diff --git a/packages/registry/src/server.test.ts b/packages/registry/src/server.test.ts index 49c667e..8f719fd 100644 --- a/packages/registry/src/server.test.ts +++ b/packages/registry/src/server.test.ts @@ -1,5 +1,9 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; import { validateRegistration } from './validate.js'; +import { loadAgents, upsertAgent, removeAgent } from './store.js'; +import { app } from './server.js'; +import type { AgentRecord } from '@clevercon/common'; const validBody = { agent_id: 'agent-web-intel', @@ -314,3 +318,174 @@ describe('validateRegistration — multiple errors', () => { expect(fields).toContain('pricing.currency'); }); }); + +// --------------------------------------------------------------------------- +// Pagination tests for GET /agents +// --------------------------------------------------------------------------- + +describe('GET /agents pagination', () => { + const now = new Date().toISOString(); + + const mockAgents: AgentRecord[] = [ + { + agent_id: 'agent-1', + name: 'Agent 1', + description: 'Test agent 1', + capabilities: ['test'], + pricing: { model: 'x402', price_per_call: 0.05, currency: 'USDC' }, + endpoint: 'https://example.com/agent1', + stellar_address: 'GABC1', + health_check: 'https://example.com/agent1/health', + registered_by: 'user1', + registered_at: now, + last_seen: now, + status: 'active', + reputation: { + score: 90, + total_jobs: 10, + successful_jobs: 9, + failed_jobs: 1, + avg_quality: 4.5, + avg_latency_ms: 100, + last_updated: now, + }, + }, + { + agent_id: 'agent-2', + name: 'Agent 2', + description: 'Test agent 2', + capabilities: ['test'], + pricing: { model: 'x402', price_per_call: 0.05, currency: 'USDC' }, + endpoint: 'https://example.com/agent2', + stellar_address: 'GABC2', + health_check: 'https://example.com/agent2/health', + registered_by: 'user1', + registered_at: now, + last_seen: now, + status: 'active', + reputation: { + score: 70, + total_jobs: 5, + successful_jobs: 4, + failed_jobs: 1, + avg_quality: 4.0, + avg_latency_ms: 150, + last_updated: now, + }, + }, + { + agent_id: 'agent-3', + name: 'Agent 3', + description: 'Test agent 3', + capabilities: ['test'], + pricing: { model: 'x402', price_per_call: 0.05, currency: 'USDC' }, + endpoint: 'https://example.com/agent3', + stellar_address: 'GABC3', + health_check: 'https://example.com/agent3/health', + registered_by: 'user1', + registered_at: now, + last_seen: now, + status: 'active', + reputation: { + score: 50, + total_jobs: 3, + successful_jobs: 2, + failed_jobs: 1, + avg_quality: 3.5, + avg_latency_ms: 200, + last_updated: now, + }, + }, + ]; + + beforeEach(() => { + // Clear existing agents + const existing = loadAgents(); + existing.forEach((agent) => removeAgent(agent.agent_id)); + // Add mock agents + mockAgents.forEach((agent) => upsertAgent(agent)); + }); + + it('returns bare array when no pagination params (backward compatibility)', async () => { + const res = await request(app).get('/agents'); + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + expect(res.body.length).toBe(3); + }); + + it('applies default limit when only limit param key is provided', async () => { + const res = await request(app).get('/agents?offset=0'); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ total: 3, limit: 20, offset: 0 }); + expect(Array.isArray(res.body.agents)).toBe(true); + expect(res.body.agents.length).toBe(3); // all agents fit within default limit + }); + + it('honors custom limit and returns correct page size', async () => { + const res = await request(app).get('/agents?limit=2'); + expect(res.status).toBe(200); + expect(res.body.agents.length).toBe(2); + expect(res.body.limit).toBe(2); + expect(res.body.total).toBe(3); + }); + + it('honors offset and skips correct number of results', async () => { + const res = await request(app).get('/agents?limit=2&offset=1'); + expect(res.status).toBe(200); + expect(res.body.agents.length).toBe(2); + expect(res.body.agents[0].agent_id).toBe('agent-2'); + expect(res.body.offset).toBe(1); + }); + + it('returns empty array when offset exceeds result set', async () => { + const res = await request(app).get('/agents?limit=10&offset=100'); + expect(res.status).toBe(200); + expect(res.body.agents).toEqual([]); + expect(res.body.total).toBe(3); + }); + + it('clamps limit to maximum (100)', async () => { + const res = await request(app).get('/agents?limit=200'); + expect(res.status).toBe(200); + expect(res.body.limit).toBe(100); + expect(res.body.agents.length).toBe(3); // all 3 fit within clamped limit + }); + + it('clamps limit to minimum (1) when limit is 0', async () => { + // limit=0 is parsed as 0, which is clamped to the minimum value of 1 + const res = await request(app).get('/agents?limit=0'); + expect(res.status).toBe(200); + expect(res.body.limit).toBe(1); // numeric zero is clamped to minimum of 1 + expect(Array.isArray(res.body.agents)).toBe(true); + }); + + it('clamps offset to minimum (0) when offset is negative', async () => { + const res = await request(app).get('/agents?limit=2&offset=-5'); + expect(res.status).toBe(200); + expect(res.body.offset).toBe(0); + expect(res.body.agents.length).toBe(2); + }); + + it('treats non-numeric offset (e.g. "abc") as 0', async () => { + const res = await request(app).get('/agents?limit=2&offset=abc'); + expect(res.status).toBe(200); + expect(res.body.offset).toBe(0); + expect(res.body.agents.length).toBe(2); + }); + + it('orders agents by reputation descending before pagination', async () => { + const res = await request(app).get('/agents?limit=3'); + expect(res.status).toBe(200); + const ids = res.body.agents.map((a: AgentRecord) => a.agent_id); + expect(ids[0]).toBe('agent-1'); // score 90 + expect(ids[1]).toBe('agent-2'); // score 70 + expect(ids[2]).toBe('agent-3'); // score 50 + }); + + it('returns total count in envelope response', async () => { + const res = await request(app).get('/agents?limit=1'); + expect(res.status).toBe(200); + expect(res.body.total).toBe(3); + expect(res.body.agents.length).toBe(1); + }); +}); diff --git a/packages/registry/src/server.ts b/packages/registry/src/server.ts index 819b497..fd0cac2 100644 --- a/packages/registry/src/server.ts +++ b/packages/registry/src/server.ts @@ -70,12 +70,13 @@ app.post('/register', (req, res) => { return res.json(record); }); -// GET /agents — discover agents with optional filters +// GET /agents — discover agents with optional filters and pagination app.get('/agents', (req, res) => { let agents = loadAgents(); - const { capabilities, min_reputation, payment_model, status } = req.query; + const { capabilities, min_reputation, payment_model, status, limit, offset } = req.query; + // Apply filters if (capabilities) { const caps = (capabilities as string).split(',').map((c) => c.trim()); agents = matchCapabilities(agents, caps); @@ -94,6 +95,42 @@ app.get('/agents', (req, res) => { agents = agents.filter((a) => a.status === status); } + // Order by reputation (highest first) for stable pagination + agents.sort((a, b) => b.reputation.score - a.reputation.score); + + const total = agents.length; + + // Check if pagination is requested + const hasPagination = limit !== undefined || offset !== undefined; + + if (hasPagination) { + // Parse and clamp limit (default: 20, max: 100, min: 1) + const DEFAULT_LIMIT = 20; + const MAX_LIMIT = 100; + const parsedLimit = limit ? parseInt(limit as string, 10) : DEFAULT_LIMIT; + // Use default only for NaN or absent, clamp numeric zero to 1 + const clampedLimit = Math.max( + 1, + Math.min(MAX_LIMIT, Number.isNaN(parsedLimit) ? DEFAULT_LIMIT : parsedLimit), + ); + + // Parse and clamp offset (default: 0, min: 0) + // Guard against non-numeric values (e.g. "abc") that would produce NaN + const parsedOffset = offset ? parseInt(offset as string, 10) || 0 : 0; + const clampedOffset = Math.max(0, parsedOffset); + + // Apply pagination + const paginatedAgents = agents.slice(clampedOffset, clampedOffset + clampedLimit); + + return res.json({ + agents: paginatedAgents, + total, + limit: clampedLimit, + offset: clampedOffset, + }); + } + + // Backward compatibility: return bare array when no pagination params return res.json(agents); }); @@ -218,6 +255,12 @@ app.get('/.well-known/x402', (_req, res) => { }); }); -app.listen(PORT, () => { - logger.info(`Service Registry running on http://localhost:${PORT}`); -}); +// Export app for integration tests (supertest imports this without calling listen) +export { app }; + +// Only bind the port when this module is the entry-point, not during tests +if (process.env.NODE_ENV !== 'test') { + app.listen(PORT, () => { + logger.info(`Service Registry running on http://localhost:${PORT}`); + }); +}