diff --git a/.agentworkforce/trajectories/completed/2026-07/traj_etjrrjqq3s0w.trace.json b/.agentworkforce/trajectories/completed/2026-07/traj_etjrrjqq3s0w.trace.json new file mode 100644 index 00000000..cc217616 --- /dev/null +++ b/.agentworkforce/trajectories/completed/2026-07/traj_etjrrjqq3s0w.trace.json @@ -0,0 +1,131 @@ +{ + "version": "1.0.0", + "id": "e681308e-c2ce-4d12-bf9f-0fa2fb38ae41", + "timestamp": "2026-07-21T23:00:02.623Z", + "trajectory": "traj_etjrrjqq3s0w", + "files": [ + { + "path": "README.md", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 664, + "end_line": 671, + "revision": "0dade1338062fa23bbec4beb9223a0523eda12cc" + } + ] + } + ] + }, + { + "path": "packages/engine/src/engine/__tests__/a2a.test.ts", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 502, + "end_line": 573, + "revision": "0dade1338062fa23bbec4beb9223a0523eda12cc" + }, + { + "start_line": 603, + "end_line": 611, + "revision": "0dade1338062fa23bbec4beb9223a0523eda12cc" + }, + { + "start_line": 614, + "end_line": 627, + "revision": "0dade1338062fa23bbec4beb9223a0523eda12cc" + }, + { + "start_line": 629, + "end_line": 647, + "revision": "0dade1338062fa23bbec4beb9223a0523eda12cc" + } + ] + } + ] + }, + { + "path": "packages/engine/src/engine/a2a.ts", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 419, + "end_line": 427, + "revision": "0dade1338062fa23bbec4beb9223a0523eda12cc" + }, + { + "start_line": 495, + "end_line": 506, + "revision": "0dade1338062fa23bbec4beb9223a0523eda12cc" + }, + { + "start_line": 526, + "end_line": 532, + "revision": "0dade1338062fa23bbec4beb9223a0523eda12cc" + } + ] + } + ] + }, + { + "path": "scripts/e2e.ts", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 6, + "end_line": 24, + "revision": "0dade1338062fa23bbec4beb9223a0523eda12cc" + }, + { + "start_line": 28, + "end_line": 89, + "revision": "0dade1338062fa23bbec4beb9223a0523eda12cc" + }, + { + "start_line": 334, + "end_line": 340, + "revision": "0dade1338062fa23bbec4beb9223a0523eda12cc" + }, + { + "start_line": 854, + "end_line": 862, + "revision": "0dade1338062fa23bbec4beb9223a0523eda12cc" + }, + { + "start_line": 887, + "end_line": 903, + "revision": "0dade1338062fa23bbec4beb9223a0523eda12cc" + }, + { + "start_line": 905, + "end_line": 911, + "revision": "0dade1338062fa23bbec4beb9223a0523eda12cc" + }, + { + "start_line": 1726, + "end_line": 1740, + "revision": "0dade1338062fa23bbec4beb9223a0523eda12cc" + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/.agentworkforce/trajectories/completed/2026-07/traj_etjrrjqq3s0w/summary.md b/.agentworkforce/trajectories/completed/2026-07/traj_etjrrjqq3s0w/summary.md new file mode 100644 index 00000000..31c964a5 --- /dev/null +++ b/.agentworkforce/trajectories/completed/2026-07/traj_etjrrjqq3s0w/summary.md @@ -0,0 +1,46 @@ +# Trajectory: Babysit Relaycast PR #2798 for issue 294 to green human review + +> **Status:** ✅ Completed +> **Task:** REL-294 +> **Confidence:** 97% +> **Started:** July 22, 2026 at 12:26 AM +> **Completed:** July 22, 2026 at 01:00 AM + +--- + +## Summary + +Babysat issue 294 to a green Relaycast PR: hardened native identity classification, made the E2E harness self-host the real gateway, strengthened native-skill and certification coverage, resolved review feedback, and verified the discover-to-engage round trip. + +**Approach:** Standard approach + +--- + +## Key Decisions + +### Make npm run e2e self-host the real Node gateway when no base URL is supplied +- **Chose:** Make npm run e2e self-host the real Node gateway when no base URL is supplied +- **Reasoning:** Issue 294 requires a single-command real HTTP round trip; preserving an explicit URL keeps remote and already-running gateway use intact. + +### Restrict native directory results to agent identities +- **Chose:** Restrict native directory results to agent identities +- **Reasoning:** Human and system Relay identities can receive DMs but are not discoverable agents; filtering in SQL prevents their personas and addresses from leaking into the A2A team directory while registered external agents remain represented through a2a_agents. + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- Make npm run e2e self-host the real Node gateway when no base URL is supplied: Make npm run e2e self-host the real Node gateway when no base URL is supplied +- Issue implementation passes targeted and full unit/build/lint gates plus a 109-check self-hosted gateway E2E. The handoff PR number is cross-repo stale (cloud#2798); Relaycast branch is pushed but has no PR, so the remaining path is commit babysitter fixes, open the correct Relaycast PR, and gate its fresh checks. +- Restrict native directory results to agent identities: Restrict native directory results to agent identities +- Issue 294 now has exhaustive local verification and review feedback is resolved: native and A2A merge/filter/addressing work over a booted gateway, non-agent identities are excluded, and package/docs/OpenAPI remain aligned. + +--- + +## Artifacts + +**Commits:** 0dade133, a3a27371 +**Files changed:** 4 diff --git a/.agentworkforce/trajectories/completed/2026-07/traj_etjrrjqq3s0w/trajectory.json b/.agentworkforce/trajectories/completed/2026-07/traj_etjrrjqq3s0w/trajectory.json new file mode 100644 index 00000000..d1dcd2e2 --- /dev/null +++ b/.agentworkforce/trajectories/completed/2026-07/traj_etjrrjqq3s0w/trajectory.json @@ -0,0 +1,111 @@ +{ + "id": "traj_etjrrjqq3s0w", + "version": 1, + "task": { + "title": "Babysit Relaycast PR #2798 for issue 294 to green human review", + "source": { + "system": "plain", + "id": "REL-294" + } + }, + "status": "completed", + "startedAt": "2026-07-21T22:26:44.467Z", + "completedAt": "2026-07-21T23:00:02.536Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-07-21T22:30:58.699Z" + } + ], + "chapters": [ + { + "id": "chap_yauuyxh0jf1i", + "title": "Work", + "agentName": "default", + "startedAt": "2026-07-21T22:30:58.699Z", + "endedAt": "2026-07-21T23:00:02.536Z", + "events": [ + { + "ts": 1784673058700, + "type": "decision", + "content": "Make npm run e2e self-host the real Node gateway when no base URL is supplied: Make npm run e2e self-host the real Node gateway when no base URL is supplied", + "raw": { + "question": "Make npm run e2e self-host the real Node gateway when no base URL is supplied", + "chosen": "Make npm run e2e self-host the real Node gateway when no base URL is supplied", + "alternatives": [], + "reasoning": "Issue 294 requires a single-command real HTTP round trip; preserving an explicit URL keeps remote and already-running gateway use intact." + }, + "significance": "high" + }, + { + "ts": 1784673416287, + "type": "reflection", + "content": "Issue implementation passes targeted and full unit/build/lint gates plus a 109-check self-hosted gateway E2E. The handoff PR number is cross-repo stale (cloud#2798); Relaycast branch is pushed but has no PR, so the remaining path is commit babysitter fixes, open the correct Relaycast PR, and gate its fresh checks.", + "raw": { + "focalPoints": [ + "acceptance", + "e2e", + "pr-identity" + ], + "adjustments": "Made npm run e2e boot an isolated real engine when no URL is supplied; preserved explicit-URL mode.", + "confidence": 0.9 + }, + "significance": "high", + "tags": [ + "focal:acceptance", + "focal:e2e", + "focal:pr-identity", + "confidence:0.9" + ] + }, + { + "ts": 1784674802095, + "type": "decision", + "content": "Restrict native directory results to agent identities: Restrict native directory results to agent identities", + "raw": { + "question": "Restrict native directory results to agent identities", + "chosen": "Restrict native directory results to agent identities", + "alternatives": [], + "reasoning": "Human and system Relay identities can receive DMs but are not discoverable agents; filtering in SQL prevents their personas and addresses from leaking into the A2A team directory while registered external agents remain represented through a2a_agents." + }, + "significance": "high" + }, + { + "ts": 1784674802318, + "type": "reflection", + "content": "Issue 294 now has exhaustive local verification and review feedback is resolved: native and A2A merge/filter/addressing work over a booted gateway, non-agent identities are excluded, and package/docs/OpenAPI remain aligned.", + "raw": { + "confidence": 0.97 + }, + "significance": "high", + "tags": [ + "confidence:0.97" + ] + } + ] + } + ], + "retrospective": { + "summary": "Babysat issue 294 to a green Relaycast PR: hardened native identity classification, made the E2E harness self-host the real gateway, strengthened native-skill and certification coverage, resolved review feedback, and verified the discover-to-engage round trip.", + "approach": "Standard approach", + "confidence": 0.97 + }, + "commits": [ + "0dade133", + "a3a27371" + ], + "filesChanged": [ + "README.md", + "packages/engine/src/engine/__tests__/a2a.test.ts", + "packages/engine/src/engine/a2a.ts", + "scripts/e2e.ts" + ], + "projectId": "AgentWorkforce/relaycast", + "tags": [], + "_trace": { + "startRef": "7c65eacb62429c6011bda9f84ad81dcd59cfeab7", + "endRef": "0dade1338062fa23bbec4beb9223a0523eda12cc", + "traceId": "e681308e-c2ce-4d12-bf9f-0fa2fb38ae41" + } +} \ No newline at end of file diff --git a/.agentworkforce/trajectories/completed/2026-07/traj_q5iazxzqfyop/summary.md b/.agentworkforce/trajectories/completed/2026-07/traj_q5iazxzqfyop/summary.md new file mode 100644 index 00000000..40dabcc9 --- /dev/null +++ b/.agentworkforce/trajectories/completed/2026-07/traj_q5iazxzqfyop/summary.md @@ -0,0 +1,33 @@ +# Trajectory: Implement Relaycast A2A team directory and shared card contract + +> **Status:** ✅ Completed +> **Task:** 294 +> **Confidence:** 95% +> **Started:** July 21, 2026 at 10:52 PM +> **Completed:** July 21, 2026 at 11:08 PM + +--- + +## Summary + +Published @relaycast/a2a package metadata/docs; added unified filterable A2A directory engine route, SDK method, OpenAPI/README/changelogs, unit tests, and booted-gateway discover-to-message E2E. + +**Approach:** Standard approach + +--- + +## Key Decisions + +### Use relay identities as directory names and engagement URLs as url fields +- **Chose:** Use relay identities as directory names and engagement URLs as url fields +- **Reasoning:** The E2E must feed a discovered identity directly into A2A message/send, while native results must remain directly DM-addressable; registered proxy rows are excluded from the native half to avoid duplicates. + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- Use relay identities as directory names and engagement URLs as url fields: Use relay identities as directory names and engagement URLs as url fields +- Implementation, package contract, docs, unit coverage, SDK surface, and real-gateway discovery-to-engagement E2E are aligned and green. diff --git a/.agentworkforce/trajectories/completed/2026-07/traj_q5iazxzqfyop/trajectory.json b/.agentworkforce/trajectories/completed/2026-07/traj_q5iazxzqfyop/trajectory.json new file mode 100644 index 00000000..400f1275 --- /dev/null +++ b/.agentworkforce/trajectories/completed/2026-07/traj_q5iazxzqfyop/trajectory.json @@ -0,0 +1,81 @@ +{ + "id": "traj_q5iazxzqfyop", + "version": 1, + "task": { + "title": "Implement Relaycast A2A team directory and shared card contract", + "source": { + "system": "plain", + "id": "294" + } + }, + "status": "completed", + "startedAt": "2026-07-21T20:52:37.889Z", + "completedAt": "2026-07-21T21:08:21.850Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-07-21T20:58:04.940Z" + } + ], + "chapters": [ + { + "id": "chap_a50waobtiu64", + "title": "Work", + "agentName": "default", + "startedAt": "2026-07-21T20:58:04.940Z", + "endedAt": "2026-07-21T21:08:21.850Z", + "events": [ + { + "ts": 1784667484941, + "type": "decision", + "content": "Use relay identities as directory names and engagement URLs as url fields: Use relay identities as directory names and engagement URLs as url fields", + "raw": { + "question": "Use relay identities as directory names and engagement URLs as url fields", + "chosen": "Use relay identities as directory names and engagement URLs as url fields", + "alternatives": [], + "reasoning": "The E2E must feed a discovered identity directly into A2A message/send, while native results must remain directly DM-addressable; registered proxy rows are excluded from the native half to avoid duplicates." + }, + "significance": "high" + }, + { + "ts": 1784668080304, + "type": "reflection", + "content": "Implementation, package contract, docs, unit coverage, SDK surface, and real-gateway discovery-to-engagement E2E are aligned and green.", + "raw": { + "focalPoints": [ + "directory merge", + "filter semantics", + "addressability", + "publishability", + "e2e" + ], + "confidence": 0.95 + }, + "significance": "high", + "tags": [ + "focal:directory merge", + "focal:filter semantics", + "focal:addressability", + "focal:publishability", + "focal:e2e", + "confidence:0.95" + ] + } + ] + } + ], + "retrospective": { + "summary": "Published @relaycast/a2a package metadata/docs; added unified filterable A2A directory engine route, SDK method, OpenAPI/README/changelogs, unit tests, and booted-gateway discover-to-message E2E.", + "approach": "Standard approach", + "confidence": 0.95 + }, + "commits": [], + "filesChanged": [], + "projectId": "AgentWorkforce/relaycast", + "tags": [], + "_trace": { + "startRef": "6ef105df27ca448bdf7f70b0036e801207c7841f", + "endRef": "6ef105df27ca448bdf7f70b0036e801207c7841f" + } +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index be8ff611..a7e812b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Packages without a separate changelog are covered by the cross-package notes bel ### Added +- Published `@relaycast/a2a` as the shared agent-card contract and added a unified, filterable A2A team directory for discovering and addressing native and registered agents. - Message retention remains opt-in (history is kept forever by default); self-host deployments can now opt in to a deployment-wide message TTL via `RELAYCAST_MESSAGE_TTL_DAYS`. - Durable `agent.exited` event when a node-hosted agent leaves (deregister, missing from an inventory sync, or release), carrying `agent_id`, `agent_name`, `node_id`, the spawn `invocation_id`, and a `reason`; the spawn's caller is notified directly. - Durable `node.status.online` / `node.status.offline` events on node liveness transitions (offline carries a `reason` like `liveness_timeout`). Wildcard webhook subscriptions (`events: ["*"]`) receive all three new events automatically. diff --git a/README.md b/README.md index 36fde072..9bc771e0 100644 --- a/README.md +++ b/README.md @@ -583,6 +583,7 @@ A2A (Agent-to-Agent) gateway endpoints: ```text POST /v1/a2a/register Register an external A2A agent GET /v1/a2a/agents List registered A2A agents +GET /v1/a2a/directory Discover native + A2A agents; filter by skill, tag, or text DELETE /v1/a2a/agents/:name Remove an A2A agent GET /v1/a2a/agents/:name/card Get agent card for a registered agent GET /.well-known/agent-card.json A2A agent card (root-level) @@ -590,6 +591,22 @@ POST /a2a/rpc A2A JSON-RPC gateway (root-level) POST /a2a/webhook/:ws/:name Inbound webhook for relay agents ``` +`@relaycast/a2a` is the published, shared card contract for Relaycast and peer +repositories. Import `A2aAgentCardSchema` plus the inferred `A2aAgentCard` and +`A2aSkill` types from that package instead of defining a local card shape. + +The team directory returns one addressable list across workspace-native and +registered A2A agents. `skill` and `tag` are exact, case-insensitive filters; +`q` searches names, descriptions, skills, and tags. Filters combine with AND +semantics. A result's `name` is its relay target identity: use it as `to` at the +native result's `/v1/dm` URL or as `agent_name` in `message/send` at the A2A +result's `/a2a/rpc` URL. + +```typescript +const infraAgents = await relay.listA2aDirectory({ skill: 'infra-watch' }); +// No match is a successful response with []. +``` + Programmability, directory & observability: ```text @@ -647,7 +664,8 @@ npm run dev E2E smoke test: ```bash -npm run e2e # against the engine dev server (http://localhost:8787) +npm run e2e # boot an isolated local engine and run interactively +npm run e2e -- --ci # boot an isolated local engine and run unattended npm run e2e -- http://localhost:8787 --ci npm run e2e -- https://cast.agentrelay.com --ci ``` diff --git a/openapi.yaml b/openapi.yaml index 740ebed6..b7f7fd74 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -14,6 +14,10 @@ info: Clients may include `X-Relaycast-Harness` on HTTP requests to attribute traffic in server telemetry. WebSocket clients that cannot send custom headers may use the `harness` query parameter instead. + + `@relaycast/a2a` is the published shared contract for A2A agent cards and + JSON-RPC wire types. Consumers should import its schemas and inferred types + instead of maintaining repository-local card definitions. version: 1.0.0 contact: name: Relaycast @@ -1100,6 +1104,53 @@ components: message: type: string + A2aSkill: + type: object + required: [name] + properties: + id: + type: string + name: + type: string + description: + type: string + tags: + type: array + items: + type: string + + A2aDirectoryEntry: + type: object + required: [name, description, skills, tags, url, kind, status, certification] + properties: + name: + type: string + description: Relay target identity; use as a native DM recipient or A2A `agent_name`. + description: + type: string + nullable: true + skills: + type: array + items: + $ref: '#/components/schemas/A2aSkill' + tags: + type: array + items: + type: string + url: + type: string + format: uri + description: Engagement endpoint (`/v1/dm` for native or `/a2a/rpc` for A2A). + kind: + type: string + enum: [native, a2a] + status: + type: string + certification: + type: string + nullable: true + pattern: '^level_[0-9]+$' + SuccessResponse: type: object properties: @@ -4765,6 +4816,50 @@ paths: schema: type: object + /a2a/directory: + get: + summary: Discover addressable native and A2A agents + description: Returns one workspace-scoped list. Skill and tag filters are exact and case-insensitive; q searches agent and skill text. Supplied filters combine with AND semantics, and no matches returns an empty data array. + tags: [A2A] + parameters: + - name: skill + in: query + schema: + type: string + description: Match a skill id or name. + - name: tag + in: query + schema: + type: string + description: Match an agent or skill tag. + - name: q + in: query + schema: + type: string + description: Case-insensitive text search across names, descriptions, skills, and tags. + responses: + '200': + description: Unified agent directory, possibly empty + content: + application/json: + schema: + type: object + required: [ok, data] + properties: + ok: + type: boolean + enum: [true] + data: + type: array + items: + $ref: '#/components/schemas/A2aDirectoryEntry' + '401': + description: Missing or invalid authentication + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /a2a/agents/{name}: delete: summary: Remove an A2A agent diff --git a/packages/a2a/README.md b/packages/a2a/README.md new file mode 100644 index 00000000..ab2dd346 --- /dev/null +++ b/packages/a2a/README.md @@ -0,0 +1,18 @@ +# `@relaycast/a2a` + +Canonical A2A card and JSON-RPC schemas shared by Relaycast, Cloud, Night CTO, +and other agent-to-agent integrations. + +```ts +import { + A2aAgentCardSchema, + type A2aAgentCard, + type A2aSkill, +} from '@relaycast/a2a'; + +const card: A2aAgentCard = A2aAgentCardSchema.parse(candidate); +``` + +The package models HTTP wire data, so multi-word fields use `snake_case`. It +has no dependency on `@relaycast/engine` and can be imported by clients that do +not run a Relaycast gateway. diff --git a/packages/a2a/package.json b/packages/a2a/package.json index 9773f439..ab90a04e 100644 --- a/packages/a2a/package.json +++ b/packages/a2a/package.json @@ -13,9 +13,13 @@ }, "scripts": { "build": "rm -rf dist tsconfig.tsbuildinfo && tsc", + "prepublishOnly": "npm run build", "test": "vitest run --passWithNoTests", "lint": "eslint src/" }, + "publishConfig": { + "access": "public" + }, "repository": { "type": "git", "url": "git+https://github.com/AgentWorkforce/relaycast.git", diff --git a/packages/engine/CHANGELOG.md b/packages/engine/CHANGELOG.md index 14f72fbc..5a0458ee 100644 --- a/packages/engine/CHANGELOG.md +++ b/packages/engine/CHANGELOG.md @@ -10,6 +10,7 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ## [Unreleased - Minor] ### Added +- Added `GET /v1/a2a/directory` to discover native and registered A2A agents together, filter them by skill, tag, or text, and use the returned relay identity for one-hop engagement. - Added durable `agent.exited` events on every node-hosted agent exit (deregistration, missing from an inventory sync, and release), delivered to the durable workspace event log, webhook subscribers, and the spawn caller's mailbox, and carrying `agent_id`, `agent_name`, `node_id`, `invocation_id`, and a `reason`. - Added durable `node.status.online` / `node.status.offline` events on node liveness transitions (offline carries a `reason` such as `liveness_timeout`, `disconnected`, or `deregistered`), delivered to the workspace event log and webhook subscribers. - Message retention remains opt-in (`pruneExpired` still defaults `messageTtlDays` to `null`). Self-host can now opt in to a deployment-wide message TTL: `startServer` accepts `eventQueue` (`DurableEventQueueOptions`, including `retention`), and the `relaycast-engine` CLI exposes `RELAYCAST_MESSAGE_TTL_DAYS` (positive = prune after N days; unset or `0`/negative = keep forever). diff --git a/packages/engine/src/engine/__tests__/a2a.test.ts b/packages/engine/src/engine/__tests__/a2a.test.ts index ec2e5ec5..ac063a4a 100644 --- a/packages/engine/src/engine/__tests__/a2a.test.ts +++ b/packages/engine/src/engine/__tests__/a2a.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { eq } from 'drizzle-orm'; import type { A2aJsonRpcRequest, A2aJsonRpcResponse, A2aTaskState } from '@relaycast/a2a'; import { getWorkspaceAgentCard, @@ -11,6 +12,7 @@ import { type RelayDM, } from '../a2a.js'; import { createWorkspace, makeNodeStack, registerAgent, type TestStack } from '../../__tests__/conformance/harness.js'; +import { a2aAgents, agents, certifications } from '../../db/schema.js'; describe('translateRelayToA2a', () => { it('wraps a plain text DM in a message/send JSON-RPC request', () => { @@ -470,3 +472,182 @@ describe('getWorkspaceAgentCard', () => { ]); }); }); + +describe('GET /v1/a2a/directory', () => { + let stack: TestStack; + let workspaceKey: string; + + beforeEach(async () => { + stack = makeNodeStack(); + const workspace = await createWorkspace(stack.app, 'a2a-directory-ws'); + workspaceKey = workspace.workspaceKey; + + const nativeResponse = await stack.app.request('/v1/agents', { + method: 'POST', + headers: { + authorization: `Bearer ${workspaceKey}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + name: 'ReleaseAgent', + persona: 'Coordinates release pipelines and deployment readiness', + metadata: { tags: ['team', 'delivery'] }, + skills: [{ + id: 'release-coordination', + name: 'release-coordination', + description: 'Coordinate release pipelines', + tags: ['delivery'], + }], + }), + }); + expect(nativeResponse.status).toBe(201); + + for (const identity of [ + { name: 'HumanOperator', type: 'human' }, + { name: 'SystemNotifier', type: 'system' }, + ]) { + const identityResponse = await stack.app.request('/v1/agents', { + method: 'POST', + headers: { + authorization: `Bearer ${workspaceKey}`, + 'content-type': 'application/json', + }, + body: JSON.stringify(identity), + }); + expect(identityResponse.status).toBe(201); + } + + const proxy = await registerAgent(stack.app, workspaceKey, 'ext-infra-watch'); + const healthyProxy = await registerAgent(stack.app, workspaceKey, 'ext-capacity-planner'); + const removedProxy = await registerAgent(stack.app, workspaceKey, 'ext-removed-peer'); + const now = new Date(); + const externalUrl = 'https://infra.example/rpc'; + await stack.runtime.deps.db.insert(a2aAgents).values([ + { + id: 'a2a_directory_test', + workspaceId: workspace.workspaceId, + relayAgentId: proxy.agentId, + agentCard: { + name: 'Infra Watcher', + description: 'Watches infrastructure health and incident signals', + url: externalUrl, + version: '1.0.0', + skills: [{ + id: 'infra-watch', + name: 'infra-watch', + description: 'Monitor infrastructure health', + tags: ['operations', 'monitoring'], + }], + }, + externalUrl, + status: 'active', + lastHealth: now, + createdAt: now, + updatedAt: now, + }, + { + id: 'a2a_directory_healthy_test', + workspaceId: workspace.workspaceId, + relayAgentId: healthyProxy.agentId, + agentCard: { + name: 'Capacity Planner', + description: 'Plans infrastructure capacity', + url: 'https://capacity.example/rpc', + version: '1.0.0', + skills: [{ id: 'capacity-plan', name: 'capacity-plan' }], + }, + externalUrl: 'https://capacity.example/rpc', + status: 'active', + lastHealth: now, + healthFailures: 0, + createdAt: now, + updatedAt: now, + }, + ]); + await stack.runtime.deps.db + .update(agents) + .set({ + type: 'external', + status: 'offline', + metadata: { a2a: true, a2a_active: false }, + }) + .where(eq(agents.id, removedProxy.agentId)); + await stack.runtime.deps.db.insert(certifications).values({ + id: 'cert_directory_test', + workspaceId: workspace.workspaceId, + agentUrl: externalUrl, + level: 1, + source: 'registration', + status: 'completed', + passed: true, + passedTests: 3, + totalTests: 3, + results: [], + createdAt: now, + updatedAt: now, + }); + }); + + afterEach(() => stack.close()); + + async function getDirectory(query = '') { + const response = await stack.app.request(`/v1/a2a/directory${query}`, { + headers: { authorization: `Bearer ${workspaceKey}` }, + }); + expect(response.status).toBe(200); + const body = await response.json() as { ok: boolean; data: Array> }; + expect(body.ok).toBe(true); + return body.data; + } + + it('merges native and registered A2A agents without duplicating the proxy identity', async () => { + const entries = await getDirectory(); + + expect(entries).toHaveLength(3); + expect(entries.find((entry) => entry.name === 'HumanOperator')).toBeUndefined(); + expect(entries.find((entry) => entry.name === 'SystemNotifier')).toBeUndefined(); + expect(entries.find((entry) => entry.name === 'ext-removed-peer')).toBeUndefined(); + expect(entries.find((entry) => entry.kind === 'native')).toMatchObject({ + name: 'ReleaseAgent', + description: 'Coordinates release pipelines and deployment readiness', + url: 'http://localhost/v1/dm', + status: 'active', + certification: null, + tags: ['team', 'delivery'], + skills: [{ + id: 'release-coordination', + name: 'release-coordination', + description: 'Coordinate release pipelines', + tags: ['delivery'], + }], + }); + expect(entries.find((entry) => entry.name === 'ext-infra-watch')).toMatchObject({ + name: 'ext-infra-watch', + description: 'Watches infrastructure health and incident signals', + url: 'http://localhost/a2a/rpc', + status: 'active', + certification: 'level_1', + tags: ['operations', 'monitoring'], + }); + expect(entries.find((entry) => entry.name === 'ext-capacity-planner')).toMatchObject({ + kind: 'a2a', + certification: 'level_1', + }); + }); + + it('filters by skill, tag, and text and returns an empty list for no match', async () => { + await expect(getDirectory('?skill=infra-watch')).resolves.toMatchObject([ + { name: 'ext-infra-watch', kind: 'a2a' }, + ]); + await expect(getDirectory('?skill=RELEASE-COORDINATION')).resolves.toMatchObject([ + { name: 'ReleaseAgent', kind: 'native' }, + ]); + await expect(getDirectory('?tag=operations')).resolves.toMatchObject([ + { name: 'ext-infra-watch', kind: 'a2a' }, + ]); + await expect(getDirectory('?q=release%20pipelines')).resolves.toMatchObject([ + { name: 'ReleaseAgent', kind: 'native' }, + ]); + await expect(getDirectory('?skill=nope')).resolves.toEqual([]); + }); +}); diff --git a/packages/engine/src/engine/a2a.ts b/packages/engine/src/engine/a2a.ts index a7865a58..ab383fc6 100644 --- a/packages/engine/src/engine/a2a.ts +++ b/packages/engine/src/engine/a2a.ts @@ -1,9 +1,10 @@ -import { and, eq, sql } from 'drizzle-orm'; +import { and, desc, eq, sql } from 'drizzle-orm'; import { A2aAgentCardSchema, A2aMessageSchema, A2aPartSchema, A2aResponseSchema, + A2aSkillSchema, A2aTaskSchema, A2aTaskStateSchema, JsonRpcRequestSchema, @@ -14,13 +15,14 @@ import { type A2aMessage, type A2aPart, type A2aResponse, + type A2aSkill, type A2aTask, type A2aTaskState, } from '@relaycast/a2a'; import { z } from 'zod'; import type { FileAttachment } from '@relaycast/types'; import type { getDb } from '../db/index.js'; -import { a2aAgents, agents } from '../db/schema.js'; +import { a2aAgents, agents, certifications } from '../db/schema.js'; import { registerAgent, getAgentByName, updateAgent } from './agent.js'; import { rotateAgentToken } from './tokenRotate.js'; import { createAndRunCertification } from './certify.js'; @@ -109,6 +111,23 @@ export interface A2aAgentRecord { updated_at: string; } +export interface A2aDirectoryEntry { + name: string; + description: string | null; + skills: A2aSkill[]; + tags: string[]; + url: string; + kind: 'native' | 'a2a'; + status: string; + certification: `level_${number}` | null; +} + +export interface A2aDirectoryQuery { + skill?: string; + tag?: string; + q?: string; +} + function ensureString(value: string | undefined, message: string): string { if (!value) { throw codedError(message, 'invalid_request', 400); @@ -389,6 +408,164 @@ export async function listA2aAgents(db: Db, workspaceId: string): Promise).skills; + if (!Array.isArray(skills)) return []; + + return skills.flatMap((skill) => { + const parsed = A2aSkillSchema.safeParse(skill); + return parsed.success ? [parsed.data] : []; + }); +} + +function isRemovedA2aProxy(type: string, metadata: unknown): boolean { + return type === 'external' + && !!metadata + && typeof metadata === 'object' + && !Array.isArray(metadata) + && (metadata as Record).a2a === true; +} + +function nativeAgentTags(metadata: unknown, skills: A2aSkill[]): string[] { + const metadataTags = metadata && typeof metadata === 'object' && !Array.isArray(metadata) + ? (metadata as Record).tags + : undefined; + const tags = Array.isArray(metadataTags) + ? metadataTags.filter((tag): tag is string => typeof tag === 'string') + : []; + + return [...new Set([...tags, ...skills.flatMap((skill) => skill.tags ?? [])])]; +} + +function directoryEntryMatches( + entry: A2aDirectoryEntry, + query: A2aDirectoryQuery, + aliases: string[] = [], +): boolean { + const skill = query.skill?.trim().toLowerCase(); + const tag = query.tag?.trim().toLowerCase(); + const text = query.q?.trim().toLowerCase(); + + if (skill && !entry.skills.some((candidate) => + candidate.name.toLowerCase() === skill || candidate.id?.toLowerCase() === skill)) { + return false; + } + + if (tag && !entry.tags.some((candidate) => candidate.toLowerCase() === tag)) { + return false; + } + + if (text) { + const haystack = [ + entry.name, + entry.description, + ...aliases, + ...entry.tags, + ...entry.skills.flatMap((candidate) => [ + candidate.id, + candidate.name, + candidate.description, + ...(candidate.tags ?? []), + ]), + ] + .filter((value): value is string => typeof value === 'string') + .join('\n') + .toLowerCase(); + if (!haystack.includes(text)) return false; + } + + return true; +} + +/** + * Return every addressable workspace agent through one discovery surface. + * Registered A2A proxy rows are represented only by their A2A entry, avoiding + * duplicate native/proxy results for the same relay identity. + */ +export async function listA2aDirectory( + db: Db, + workspaceId: string, + baseUrl: string, + query: A2aDirectoryQuery = {}, +): Promise { + const [workspaceAgents, registeredAgents, certificationRows] = await Promise.all([ + db + .select({ + id: agents.id, + name: agents.name, + persona: agents.persona, + type: agents.type, + metadata: agents.metadata, + status: agents.status, + }) + .from(agents) + .where(and(eq(agents.workspaceId, workspaceId), eq(agents.type, 'agent'))), + listA2aAgents(db, workspaceId), + db + .select({ + agentUrl: certifications.agentUrl, + level: certifications.level, + passed: certifications.passed, + }) + .from(certifications) + .where(eq(certifications.workspaceId, workspaceId)) + .orderBy(desc(certifications.createdAt)), + ]); + + const registeredRelayAgentIds = new Set(registeredAgents.map((agent) => agent.relay_agent_id)); + const certificationByUrl = new Map(); + for (const certification of certificationRows) { + if (!certificationByUrl.has(certification.agentUrl)) { + certificationByUrl.set( + certification.agentUrl, + certification.passed ? `level_${certification.level}` : 'level_0', + ); + } + } + + const searchableEntries: Array<{ entry: A2aDirectoryEntry; aliases?: string[] }> = []; + for (const agent of workspaceAgents) { + if (registeredRelayAgentIds.has(agent.id) || isRemovedA2aProxy(agent.type, agent.metadata)) continue; + const skills = nativeAgentSkills(agent.metadata); + searchableEntries.push({ + entry: { + name: agent.name, + description: agent.persona, + skills, + tags: nativeAgentTags(agent.metadata, skills), + url: `${baseUrl}/v1/dm`, + kind: 'native', + status: agent.status, + certification: null, + }, + }); + } + + for (const agent of registeredAgents) { + const card = agent.agent_card; + searchableEntries.push({ + entry: { + name: agent.relay_name, + description: card.description ?? agent.relay_persona, + skills: card.skills, + tags: [...new Set(card.skills.flatMap((skill) => skill.tags ?? []))], + url: `${baseUrl}/a2a/rpc`, + kind: 'a2a', + status: agent.status, + certification: certificationByUrl.get(agent.external_url) + ?? (agent.last_health && agent.health_failures === 0 ? 'level_1' : 'level_0'), + }, + aliases: [card.name], + }); + } + + return searchableEntries + .filter(({ entry, aliases }) => directoryEntryMatches(entry, query, aliases)) + .map(({ entry }) => entry) + .sort((left, right) => left.name.localeCompare(right.name)); +} + export async function getA2aAgentByRelayName( db: Db, workspaceId: string, diff --git a/packages/engine/src/routes/a2a.ts b/packages/engine/src/routes/a2a.ts index 2cf9a8fc..78b28f71 100644 --- a/packages/engine/src/routes/a2a.ts +++ b/packages/engine/src/routes/a2a.ts @@ -15,6 +15,7 @@ import { jsonNotFound, jsonOk, parseJsonBody, + parseQueryParams, } from '../lib/httpResponse.js'; export const a2aRoutes = new Hono(); @@ -31,6 +32,11 @@ const registerA2aSchema = z.object({ const rpcRequestSchema = a2aEngine.JsonRpcRequestSchema; const rpcWebhookSchema = z.union([a2aEngine.JsonRpcRequestSchema, a2aEngine.JsonRpcResponseSchema]); +const directoryQuerySchema = z.object({ + skill: z.string().optional(), + tag: z.string().optional(), + q: z.string().optional(), +}); function jsonRpcHttpStatus(response: a2aEngine.A2aJsonRpcResponse): number { return response.error ? 400 : 200; @@ -183,6 +189,26 @@ a2aRoutes.get('/v1/a2a/agents', requireAuth, rateLimit, async (c) => { } }); +// GET /v1/a2a/directory +a2aRoutes.get('/v1/a2a/directory', requireAuth, rateLimit, async (c) => { + try { + const parsed = parseQueryParams(c, directoryQuerySchema, 'Invalid A2A directory query'); + if (!parsed.ok) { + return parsed.response; + } + + const entries = await a2aEngine.listA2aDirectory( + c.get('db'), + c.get('workspace').id, + new URL(c.req.url).origin, + parsed.data, + ); + return jsonOk(c, entries); + } catch (err: unknown) { + return codedJsonError(c, err); + } +}); + // GET /v1/a2a/agents/:name/card a2aRoutes.get('/v1/a2a/agents/:name/card', requireAuth, rateLimit, async (c) => { try { diff --git a/packages/sdk-typescript/CHANGELOG.md b/packages/sdk-typescript/CHANGELOG.md index 85b6109d..2b375f59 100644 --- a/packages/sdk-typescript/CHANGELOG.md +++ b/packages/sdk-typescript/CHANGELOG.md @@ -10,6 +10,7 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ## [Unreleased - Minor] ### Added +- Added `RelayCast.listA2aDirectory({ skill?, tag?, q? })` for unified native and registered A2A agent discovery. - `AgentClient.disconnect()` and `presence.markOffline()` accept an optional `{ deregister?: boolean }`. By default the disconnect is presence-only for node-hosted agents; pass `{ deregister: true }` to tear down the node binding and re-home the agent to its direct node. ### Fixed diff --git a/packages/sdk-typescript/src/__tests__/relay.test.ts b/packages/sdk-typescript/src/__tests__/relay.test.ts index dfa9526e..d5e7b70b 100644 --- a/packages/sdk-typescript/src/__tests__/relay.test.ts +++ b/packages/sdk-typescript/src/__tests__/relay.test.ts @@ -485,6 +485,20 @@ describe('RelayCast', () => { expect(init.method).toBe('GET'); }); + it('listA2aDirectory() calls GET /v1/a2a/directory with discovery filters', async () => { + const { RelayCast } = await import('../relay.js'); + const relay = new RelayCast({ apiKey: 'rk_live_test123' }); + + mockFetch.mockImplementation(() => mockResponse([])); + await relay.listA2aDirectory({ skill: 'infra-watch', tag: 'operations', q: 'infra health' }); + + const [url, init] = mockFetch.mock.calls[0]!; + expect(url).toBe( + 'https://cast.agentrelay.com/v1/a2a/directory?skill=infra-watch&tag=operations&q=infra+health', + ); + expect(init.method).toBe('GET'); + }); + it('removeA2aAgent() calls DELETE /v1/a2a/agents/:name and returns removal payload', async () => { const { RelayCast } = await import('../relay.js'); const relay = new RelayCast({ apiKey: 'rk_live_test123' }); diff --git a/packages/sdk-typescript/src/relay.ts b/packages/sdk-typescript/src/relay.ts index 8360f9c8..19a429d5 100644 --- a/packages/sdk-typescript/src/relay.ts +++ b/packages/sdk-typescript/src/relay.ts @@ -1,5 +1,7 @@ import type { A2aAgentCard, + A2aDirectoryEntry, + A2aDirectoryQuery, A2aAgentRecord, Agent, AgentListQuery, @@ -516,6 +518,14 @@ export class RelayCast { return this.client.get('/v1/a2a/agents'); } + listA2aDirectory(query: A2aDirectoryQuery = {}): Promise { + const params: Record = {}; + if (query.skill) params.skill = query.skill; + if (query.tag) params.tag = query.tag; + if (query.q) params.q = query.q; + return this.client.get('/v1/a2a/directory', params); + } + removeA2aAgent(name: string): Promise { return this.client.request('DELETE', `/v1/a2a/agents/${encodeURIComponent(name)}`); } diff --git a/packages/sdk-typescript/src/types.ts b/packages/sdk-typescript/src/types.ts index bfb94551..9b443814 100644 --- a/packages/sdk-typescript/src/types.ts +++ b/packages/sdk-typescript/src/types.ts @@ -70,6 +70,23 @@ export interface A2aAgentRecord { updatedAt: string; } +export interface A2aDirectoryEntry { + name: string; + description: string | null; + skills: A2aAgentCardSkill[]; + tags: string[]; + url: string; + kind: 'native' | 'a2a'; + status: string; + certification: `level_${number}` | null; +} + +export interface A2aDirectoryQuery { + skill?: string; + tag?: string; + q?: string; +} + export interface RemoveA2aAgentResponse { name: string; removed: true; diff --git a/scripts/e2e.ts b/scripts/e2e.ts index 7b470e58..03a68018 100644 --- a/scripts/e2e.ts +++ b/scripts/e2e.ts @@ -6,15 +6,19 @@ * messages flow in both the terminal and the dashboard. * * Usage: - * npm run e2e # defaults to the engine dev server (http://localhost:8787) - * npm run e2e -- http://localhost:8787 --ci # self-hosted engine in CI + * npm run e2e # boots an isolated local engine and runs in interactive mode + * npm run e2e -- --ci # boots an isolated local engine and runs unattended + * npm run e2e -- http://localhost:8787 --ci # uses an already-running engine * npm run e2e -- --continue-on-failure # keep running after step failures * npm run e2e -- https://cast.agentrelay.com --ci */ import { createServer } from 'node:http'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { createInterface } from 'node:readline'; -import WebSocket from 'ws'; +import { startServer, type RunningServer } from '../packages/engine/src/entrypoints/node.js'; import { RelayCast, AgentClient, RelayError } from '../packages/sdk-typescript/src/index.js'; // --------------------------------------------------------------------------- @@ -24,8 +28,62 @@ const args = process.argv.slice(2).filter((a) => !a.startsWith('--')); const flags = new Set(process.argv.slice(2).filter((a) => a.startsWith('--'))); const CONTINUE_ON_FAILURE = flags.has('--continue-on-failure'); const DEFAULT_BASE_URL = 'http://localhost:8787'; -const BASE_URL = (args[0] ?? DEFAULT_BASE_URL).replace(/\/+$/, ''); +let BASE_URL = (args[0] ?? DEFAULT_BASE_URL).replace(/\/+$/, ''); const CI = flags.has('--ci') || !!process.env.CI; +const BOOT_LOCAL_GATEWAY = args.length === 0; + +let localGateway: RunningServer | null = null; +let localGatewayDir: string | null = null; + +async function reserveLocalPort(): Promise { + const server = createServer(); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(); + throw new Error('Could not reserve a local port for the E2E gateway'); + } + await new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); + return address.port; +} + +async function startLocalGateway(): Promise { + if (!BOOT_LOCAL_GATEWAY) return; + + const port = await reserveLocalPort(); + BASE_URL = `http://127.0.0.1:${port}`; + localGatewayDir = await mkdtemp(join(tmpdir(), 'relaycast-e2e-')); + localGateway = startServer({ + dbPath: ':memory:', + port, + baseUrl: BASE_URL, + fileDir: join(localGatewayDir, 'files'), + config: { environment: 'e2e' }, + }); + + const server = localGateway.server as unknown as { listening?: boolean; once(event: string, listener: (...args: any[]) => void): void }; + if (!server.listening) { + await new Promise((resolve, reject) => { + server.once('listening', resolve); + server.once('error', reject); + }); + } +} + +async function stopLocalGateway(): Promise { + const running = localGateway; + localGateway = null; + if (running) await running.stop(); + + const tempDir = localGatewayDir; + localGatewayDir = null; + if (tempDir) await rm(tempDir, { recursive: true, force: true }); +} function isLocalHost(host: string): boolean { return host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0' || host.endsWith('.localhost'); @@ -106,7 +164,12 @@ async function startMockA2aAgent() { description: 'Local A2A roundtrip test peer', url: `${origin}/rpc`, version: '1.0.0', - skills: [{ id: 'echo', name: 'echo', description: 'Echo a DM back to Relaycast' }], + skills: [{ + id: 'infra-watch', + name: 'infra-watch', + description: 'Watch infrastructure health and echo a DM back to Relaycast', + tags: ['operations', 'monitoring'], + }], })); return; } @@ -120,18 +183,25 @@ async function startMockA2aAgent() { requests.push(payload); const inputText = extractTextPart(payload); - const callbackPayload = { - jsonrpc: '2.0', - id: payload.id, - result: { - message: { - message_id: `mock-${payload.id}`, - role: 'agent', - context_id: payload?.params?.message?.context_id, - parts: [{ kind: 'text', text: `Mock A2A reply: ${inputText}` }], - }, - }, + const callbackMessage = { + message_id: `mock-${payload.id}`, + role: 'agent', + context_id: payload?.params?.message?.context_id, + parts: [{ kind: 'text', text: `Mock A2A reply: ${inputText}` }], }; + const callbackTarget = payload?.params?.callback_target; + const callbackPayload = typeof callbackTarget === 'string' + ? { + jsonrpc: '2.0', + id: payload.id, + method: 'message/send', + params: { target_agent: callbackTarget, message: callbackMessage }, + } + : { + jsonrpc: '2.0', + id: payload.id, + result: { message: callbackMessage }, + }; if (webhookUrl && relayToken) { const callback = fetch(webhookUrl, { @@ -264,6 +334,7 @@ const MARKDOWN_CHANNEL_MESSAGES = CHANNEL_MESSAGES.filter((message) => // Main // --------------------------------------------------------------------------- async function main() { + await startLocalGateway(); console.log(` ${B}${CYAN}╔══════════════════════════════════════════════╗ ║ Relaycast E2E Smoke Test ║ @@ -278,10 +349,6 @@ ${B}${CYAN}╔══════════════════════ let lead!: AgentClient; let infra!: AgentClient; let backend!: AgentClient; - // Raw agent tokens (captured at registration) for the --next-version actions - // section, which calls the /v1/actions HTTP contract directly. - let leadToken = ''; - let backendToken = ''; const passed: string[] = []; const failed: string[] = []; const channelName = 'engineering'; @@ -408,7 +475,6 @@ ${B}${CYAN}╔══════════════════════ metadata: { cli: 'claude' }, }); lead = relay.as(res.token); - leadToken = res.token; log('🤖', `${YELLOW}${B}${LEAD}${R} registered`); }); @@ -418,6 +484,12 @@ ${B}${CYAN}╔══════════════════════ type: 'agent', persona: 'Senior infrastructure engineer. Owns CI/CD pipelines, deploys, health checks, and cloud resources.', metadata: { cli: 'claude' }, + skills: [{ + id: 'deploy-operations', + name: 'deploy-operations', + description: 'Operate deployment pipelines and cloud resources', + tags: ['delivery', 'cloud'], + }], }); infra = relay.as(res.token); log('🤖', `${GREEN}${B}${INFRA}${R} registered`); @@ -431,7 +503,6 @@ ${B}${CYAN}╔══════════════════════ metadata: { cli: 'claude' }, }); backend = relay.as(res.token); - backendToken = res.token; log('🤖', `${BLUE}${B}${BACKEND}${R} registered`); }); @@ -472,7 +543,6 @@ ${B}${CYAN}╔══════════════════════ if (!res.token) throw new Error('Expected token from registerOrRotate'); // Update lead client with rotated token (old token is now invalid) lead = relay.as(res.token); - leadToken = res.token; agentMap[LEAD] = lead; const channels = await lead.channels.list(); log('🔑', `registerOrRotate returned token, verified with list channels (${channels.length} channels)`); @@ -636,7 +706,12 @@ ${B}${CYAN}╔══════════════════════ description: 'Local A2A roundtrip test peer', url: `${mockA2a.baseUrl}/rpc`, version: '1.0.0', - skills: [{ id: 'echo', name: 'echo', description: 'Echo a DM back to Relaycast' }], + skills: [{ + id: 'infra-watch', + name: 'infra-watch', + description: 'Watch infrastructure health and echo a DM back to Relaycast', + tags: ['operations', 'monitoring'], + }], }, }); @@ -737,7 +812,12 @@ ${B}${CYAN}╔══════════════════════ description: 'Local A2A roundtrip test peer', url: `${mockA2a.baseUrl}/rpc`, version: '1.0.0', - skills: [{ id: 'echo', name: 'echo', description: 'Echo a DM back to Relaycast' }], + skills: [{ + id: 'infra-watch', + name: 'infra-watch', + description: 'Watch infrastructure health and echo a DM back to Relaycast', + tags: ['operations', 'monitoring'], + }], }, }); mockA2a.configureWebhook(registered.webhookUrl, registered.relayToken); @@ -745,7 +825,147 @@ ${B}${CYAN}╔══════════════════════ }); await pause(); - // ── 7c. Directory ────────────────────────────────────────────────── + // ── 7c. Unified A2A team directory ───────────────────────────────── + step('A2A team directory'); + let discoveredA2aName = ''; + + await run('Discover native and registered A2A agents', async () => { + if (!mockA2a) { + if (isLocalHost(new URL(BASE_URL).hostname.toLowerCase())) { + throw new Error('Local mock A2A agent was not registered'); + } + log('ℹ️ ', 'Skipping merged A2A directory assertion: remote gateways cannot reach the local mock agent.'); + return; + } + + const res = await fetch(`${BASE_URL}/v1/a2a/directory`, { + headers: { Authorization: `Bearer ${workspaceKey}` }, + }); + const json = await res.json() as any; + if (!res.ok || !json.ok || !Array.isArray(json.data)) { + throw new Error(`A2A directory failed: ${res.status} ${JSON.stringify(json)}`); + } + + const native = json.data.find((entry: any) => entry.name === INFRA && entry.kind === 'native'); + const external = json.data.find((entry: any) => + entry.kind === 'a2a' + && Array.isArray(entry.skills) + && entry.skills.some((skill: any) => skill.name === 'infra-watch')); + if (!native || !external) { + throw new Error(`Expected native ${INFRA} and infra-watch A2A entries: ${JSON.stringify(json.data)}`); + } + if (!native.skills.some((skill: any) => skill.id === 'deploy-operations')) { + throw new Error(`Native directory entry is missing deploy-operations: ${JSON.stringify(native)}`); + } + for (const entry of [native, external]) { + if (!Array.isArray(entry.skills) || typeof entry.url !== 'string' || !entry.url) { + throw new Error(`Directory entry is missing skills/url: ${JSON.stringify(entry)}`); + } + if (typeof entry.status !== 'string' || !entry.status) { + throw new Error(`Directory entry is missing status: ${JSON.stringify(entry)}`); + } + } + + discoveredA2aName = external.name; + log('🧭', `Discovered ${B}${native.name}${R} (${native.kind}) and ${B}${external.name}${R} (${external.kind})`); + }); + + await run('Filter A2A directory by skill and verify empty matches', async () => { + if (!mockA2a) { + log('ℹ️ ', 'Skipping A2A directory filters: no local mock agent.'); + return; + } + + const matchingRes = await fetch(`${BASE_URL}/v1/a2a/directory?skill=infra-watch`, { + headers: { Authorization: `Bearer ${workspaceKey}` }, + }); + const matching = await matchingRes.json() as any; + if (!matchingRes.ok || !matching.ok || !Array.isArray(matching.data)) { + throw new Error(`A2A skill filter failed: ${matchingRes.status} ${JSON.stringify(matching)}`); + } + if (matching.data.length !== 1 || matching.data[0]?.name !== discoveredA2aName) { + throw new Error(`Expected only ${discoveredA2aName}, got ${JSON.stringify(matching.data)}`); + } + + const nativeMatchingRes = await fetch(`${BASE_URL}/v1/a2a/directory?skill=deploy-operations`, { + headers: { Authorization: `Bearer ${workspaceKey}` }, + }); + const nativeMatching = await nativeMatchingRes.json() as any; + if (!nativeMatchingRes.ok || !nativeMatching.ok || !Array.isArray(nativeMatching.data)) { + throw new Error(`Native A2A skill filter failed: ${nativeMatchingRes.status} ${JSON.stringify(nativeMatching)}`); + } + if (nativeMatching.data.length !== 1 || nativeMatching.data[0]?.name !== INFRA) { + throw new Error(`Expected only ${INFRA}, got ${JSON.stringify(nativeMatching.data)}`); + } + + const emptyRes = await fetch(`${BASE_URL}/v1/a2a/directory?skill=nope`, { + headers: { Authorization: `Bearer ${workspaceKey}` }, + }); + const empty = await emptyRes.json() as any; + if (!emptyRes.ok || !empty.ok || !Array.isArray(empty.data) || empty.data.length !== 0) { + throw new Error(`Expected an empty A2A directory match, got ${emptyRes.status} ${JSON.stringify(empty)}`); + } + log('🔍', `Skill filters returned only ${B}${discoveredA2aName}${R} and ${B}${INFRA}${R}; bogus skill returned []`); + }); + + await run('Message the discovered A2A agent and observe the round trip', async () => { + if (!mockA2a) { + log('ℹ️ ', 'Skipping discovered A2A roundtrip: no local mock agent.'); + return; + } + if (!discoveredA2aName) throw new Error('No discovered A2A identity available'); + + const rpcId = `directory-${Date.now()}`; + const text = `Ping discovered agent ${rpcId}`; + const expectedReply = `Mock A2A reply: ${text}`; + const requestsBefore = mockA2a.requests.length; + const res = await fetch(`${BASE_URL}/a2a/rpc`, { + method: 'POST', + headers: { + Authorization: `Bearer ${workspaceKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: rpcId, + method: 'message/send', + params: { + agent_name: discoveredA2aName, + callback_target: LEAD, + message: { + message_id: rpcId, + role: 'user', + context_id: `agent:${LEAD}`, + parts: [{ kind: 'text', text }], + }, + }, + }), + }); + const rpc = await res.json() as any; + if (!res.ok || rpc.error || rpc.result?.task?.status?.state !== 'submitted') { + throw new Error(`Discovered A2A message was not accepted: ${res.status} ${JSON.stringify(rpc)}`); + } + + for (let attempt = 0; attempt < 20; attempt++) { + await sleep(250); + const conversations = await lead.dms.conversations(); + const conversation = conversations.find((candidate) => + candidate.participants.some((participant) => participant.agentName === discoveredA2aName)); + if (conversation) { + const messages = await lead.dms.messages(conversation.id, { limit: 20 }); + if (messages.some((message) => message.text === expectedReply) + && mockA2a.requests.length > requestsBefore) { + log('🔁', `Directory discover → ${B}${discoveredA2aName}${R} → ${LEAD} round trip verified`); + return; + } + } + } + + throw new Error('Timed out waiting for the discovered A2A agent round trip'); + }); + await pause(); + + // ── 7d. Directory ────────────────────────────────────────────────── step('Directory'); let directorySlug = ''; @@ -1132,36 +1352,29 @@ ${B}${CYAN}╔══════════════════════ // ── 15. Actions (agent-to-agent RPC) ────────────────────────────────── step('Actions'); - // Lightweight raw agent WebSocket to verify action.* fanout. - const openWs = (token: string) => { - const sock = new WebSocket(`${BASE_URL.replace(/^http/, 'ws')}/v1/ws?token=${token}`); - const events: any[] = []; - sock.on('message', (d) => { - // Guard against non-JSON frames so a stray payload can't crash the script. - try { events.push(JSON.parse(d.toString())); } catch { /* ignore */ } - }); - const ready = new Promise((resolve, reject) => { - sock.on('open', () => resolve()); - sock.on('error', reject); - }); - const waitFor = (type: string, timeoutMs = 5000) => - new Promise((resolve, reject) => { - const found = events.find((e) => e.type === type); - if (found) return resolve(found); - const start = Date.now(); - const timer = setInterval(() => { - const e = events.find((ev) => ev.type === type); - if (e) { clearInterval(timer); resolve(e); } - else if (Date.now() - start > timeoutMs) { clearInterval(timer); reject(new Error(`timeout waiting for "${type}"`)); } - }, 50); - }); - return { ready, waitFor, close: () => sock.close() }; - }; - - const handlerWs = openWs(leadToken); - const callerWs = openWs(backendToken); - await run('Connect handler + caller WebSockets', async () => { - await Promise.all([handlerWs.ready, callerWs.ready]); + const handlerActionEvents: any[] = []; + const callerActionEvents: any[] = []; + const stopHandlerActionEvents = lead.on.actionInvoked((event) => handlerActionEvents.push(event)); + const stopCallerActionEvents = backend.on.actionCompleted((event) => callerActionEvents.push(event)); + const waitForActionEvent = ( + events: any[], + type: string, + predicate: (event: any) => boolean, + timeoutMs = 5000, + ) => new Promise((resolve, reject) => { + const found = events.find(predicate); + if (found) return resolve(found); + const start = Date.now(); + const timer = setInterval(() => { + const event = events.find(predicate); + if (event) { + clearInterval(timer); + resolve(event); + } else if (Date.now() - start > timeoutMs) { + clearInterval(timer); + reject(new Error(`timeout waiting for "${type}"`)); + } + }, 50); }); let invocationId = ''; @@ -1192,7 +1405,11 @@ ${B}${CYAN}╔══════════════════════ }); await run(`Handler ${LEAD} receives action.invoked over WS`, async () => { - await handlerWs.waitFor('action.invoked'); + await waitForActionEvent( + handlerActionEvents, + 'action.invoked', + (event) => event.invocationId === invocationId, + ); }); await pause(); @@ -1205,7 +1422,11 @@ ${B}${CYAN}╔══════════════════════ }); await run(`Caller ${BACKEND} receives action.completed over WS`, async () => { - await callerWs.waitFor('action.completed'); + await waitForActionEvent( + callerActionEvents, + 'action.completed', + (event) => event.invocationId === invocationId, + ); }); await run('Get invocation shows completed + output', async () => { @@ -1222,8 +1443,8 @@ ${B}${CYAN}╔══════════════════════ log('🗑️ ', `Deleted deploy action`); }); - handlerWs.close(); - callerWs.close(); + stopHandlerActionEvents(); + stopCallerActionEvents(); await pause(); // ── 16. Inbound Webhooks ────────────────────────────────────────────── @@ -1505,10 +1726,15 @@ ${B}${CYAN}╔══════════════════════ `); await sleep(300); + await stopLocalGateway(); process.exit(failed.length > 0 ? 1 : 0); } -main().catch((err) => { +main().catch(async (err) => { console.error(`\n${RED}${B}Fatal:${R}`, err.message ?? err); + await Promise.race([ + stopLocalGateway().catch(() => {}), + sleep(1_000), + ]); process.exit(1); });