Skip to content
Open
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
240 changes: 240 additions & 0 deletions docs/v1.0-port-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
# A2A v0.3 → v1.0 Wire-Format Port — Plan

**Author:** Phronesis (hermes-agent, Quinn's CTO agent) · **Date:** 2026-08-06
**Repo:** `psifunctiondev/openclaw-a2a-gateway-v1.0` (fork of `win4r/openclaw-a2a-gateway`)
**Scope:** Wire format only — no gRPC/DNS-SD/push notification/transport-fallback changes
**Companion to:** investigation that produced `Method not found: SendMessage` when Phronesis (Hermes v0.20.0, A2A v1.0) called Doxa (this plugin, A2A v0.3.0)

---

## 1. Verification — what I actually confirmed before writing this plan

| Question | Method | Result |
|----------|--------|--------|
| What protocol version does the live fork implement? | `curl http://100.106.127.88:18800/.well-known/agent-card.json` and parse `protocolVersion` | `0.3.0` |
| What does Hermes (Phronesis) send? | Live `a2a_call` from Phronesis to Doxa, captured JSON-RPC error | `{"error": "Method not found: SendMessage"}` |
| What does the v1.0 A2A spec require for the wire? | Read Hermes' own `plugins/platforms/a2a/protocol.py` (which implements v1.0) | `method: "SendMessage"`, agent card `protocolVersion: "1.0"`, `supportedInterfaces[]`, `provider`, `capabilities.extendedAgentCard` |
| Does a v1.0 SDK exist on npm? | `npm view @a2a-js/sdk versions` | Yes — `1.0.0` and `1.0.1` (latest) shipped; line is stable, not alpha |
| Does the v1.0 SDK ship Express middleware? | `tar tzf @a2a-js/sdk@1.0.1` + read `dist/server/express/index.d.cts` | Yes — `jsonRpcHandler`, `restHandler`, `agentCardHandler` are drop-in Express middleware |
| Does the v1.0 SDK keep the gRPC transport? | Same tarball, `dist/client/transports/grpc/` | Yes — `GrpcTransportFactory` exports cleanly, with a `legacyCompat` flag for v0.3 fallback |
| Does the v1.0 SDK support dual-stack during transition? | Read `JsonRpcHandlerOptions.legacyCompat` and `GrpcTransportFactoryOptions.legacyCompat` | Yes — both expose `{ enabled: boolean }` that re-enables `message/send` for v0.3 peers |
| Is the SDK's `MessageSendParams` (v0.3) renamed in v1.0? | Compared `dist/index.d.cts` of v0.3.13 and v1.0.1 | Yes — v0.3: `MessageSendParams`. v1.0: `SendMessageRequest`. Type shape changed (added `configuration: SendMessageConfiguration` field) |

**Conclusion:** This is a **dependency swap + adapter rewrite**, not a hand-coded port. The v1.0 SDK already implements the entire server-side wire format, the v0.3→v1.0 detection for outbound, and an optional legacy compat shim. We don't write the JSON-RPC method routing by hand; we use `jsonRpcHandler()` from the SDK.

---

## 2. Concrete deltas (line numbers from `master` at `fdedad74`)

### 2.1 `package.json` (line 12)
```diff
- "@a2a-js/sdk": "^0.3.13",
+ "@a2a-js/sdk": "^1.0.1",
```
Reason: v0.3 SDK doesn't export `jsonRpcHandler`/`restHandler`/`agentCardHandler`; v1.0 does.

### 2.2 `src/agent-card.ts` (lines 52-74)
The v0.3 agent card uses `additionalInterfaces` and `capabilities: { streaming, pushNotifications, stateTransitionHistory }`. v1.0 renames `additionalInterfaces` → `supportedInterfaces`, drops `streaming`/`pushNotifications` from `capabilities`, adds `extendedAgentCard` to `capabilities`, and adds a `provider` field.

```diff
return {
- protocolVersion: "0.3.0",
+ protocolVersion: "1.0",
version: "1.0.0",
name: agentCard.name || "OpenClaw A2A Gateway",
description: agentCard.description || "A2A bridge for OpenClaw agents",
url: configuredUrl || fallbackUrl,
skills: (agentCard.skills || []).map((entry, index) => toSkill(entry, index)),
+ provider: {
+ organization: "OpenClaw",
+ url: configuredUrl || fallbackUrl,
+ },
capabilities: {
- streaming: true,
- pushNotifications: false,
- stateTransitionHistory: false,
+ extendedAgentCard: false,
},
securitySchemes,
security,
supportsAuthenticatedExtendedCard: false,
defaultInputModes: ["text"],
defaultOutputModes: ["text"],
- additionalInterfaces: [
- { url: configuredUrl || fallbackUrl, transport: "JSONRPC" },
- { url: `${new URL(configuredUrl || fallbackUrl).origin}/a2a/rest`, transport: "HTTP+JSON" },
- { url: grpcProxy, transport: "GRPC" },
- ],
+ supportedInterfaces: [
+ {
+ url: configuredUrl || fallbackUrl,
+ protocolBinding: "JSONRPC",
+ protocolVersion: "1.0",
+ },
+ {
+ url: `${new URL(configuredUrl || fallbackUrl).origin}/a2a/rest`,
+ protocolBinding: "HTTP+JSON",
+ protocolVersion: "1.0",
+ },
+ {
+ url: grpcProxy,
+ protocolBinding: "GRPC",
+ protocolVersion: "1.0",
+ },
+ ],
};
```
Verified against: `dist/a2a-4AAMnZHp.d.cts` of v1.0.1 SDK — `interface AgentCard { protocolVersion, supportedInterfaces: AgentInterface[], capabilities: AgentCapabilities, provider: AgentProvider, ... }`.

### 2.3 `index.ts` (lines 322-324, ~493-625)
The fork currently has its own Express route wiring (`app.use("/a2a/jsonrpc", ...)`, `app.use("/a2a/rest", ...)`, `app.use(cardPath, cardEndpointHandler)`, plus hand-rolled JSON-RPC method dispatch in `src/jsonrpc-handler.ts` if it exists). v1.0 SDK replaces all three with one call each.

```diff
- description: "OpenClaw plugin that serves A2A v0.3.0 endpoints",
+ description: "OpenClaw plugin that serves A2A v1.0 endpoints (v0.3 legacyCompat opt-in)",
```
And in the `register()` body, where the routes are mounted:
```diff
- app.use(cardPath, cardEndpointHandler);
- app.use("/.well-known/agent.json", cardEndpointHandler);
+ app.use("/.well-known/agent-card.json", agentCardHandler({ agentCardProvider: () => agentCard }));
+ app.use("/.well-known/agent.json", agentCardHandler({ agentCardProvider: () => agentCard })); // legacy alias
...
- app.use("/a2a/jsonrpc", createHttpMetricsMiddleware("jsonrpc"), jsonRpcRequestHandler);
+ app.use("/a2a/jsonrpc", createHttpMetricsMiddleware("jsonrpc"), jsonRpcHandler({
+ requestHandler: a2aRequestHandler,
+ userBuilder: bearerUserBuilder,
+ legacyCompat: { enabled: true }, // accept v0.3 message/send for transition window
+ }));
...
- app.use("/a2a/rest", restRequestHandler);
+ app.use("/a2a/rest", restHandler({
+ requestHandler: a2aRequestHandler,
+ userBuilder: bearerUserBuilder,
+ legacyCompat: { enabled: true },
+ }));
```
The fork's existing `src/jsonrpc-handler.ts` (and any REST/REST handler module) becomes dead code after this swap and should be deleted in the same PR.

### 2.4 `src/client.ts` (line 13, 244, 399, 426)
```diff
-import type { MessageSendParams, Message } from "@a2a-js/sdk";
+import type { SendMessageRequest, Message } from "@a2a-js/sdk";
```
And every `MessageSendParams` → `SendMessageRequest`. The shape is the same at the surface level (a `Message` plus `configuration`) but v1.0 requires the new wrapping.

### 2.5 Outbound transport selection (`src/client.ts` line 118-133)
Currently builds `ClientFactory` with `JsonRpcTransportFactory`, `RestTransportFactory`, `GrpcTransportFactory`. v1.0 SDK keeps all three but adds the `legacyCompat` opt-in. To keep the fork talking to both v1.0 peers (Phronesis) and v0.3 peers (any older peer still around during transition):
```diff
options = ClientFactoryOptions.createFrom(ClientFactoryOptions.default, {
cardResolver: new DefaultAgentCardResolver({ fetchImpl: authFetch }),
transports: [
- new JsonRpcTransportFactory({ fetchImpl: authFetch }),
- new RestTransportFactory({ fetchImpl: authFetch }),
- new GrpcTransportFactory(),
+ new JsonRpcTransportFactory({ fetchImpl: authFetch, legacyCompat: { enabled: true } }),
+ new RestTransportFactory({ fetchImpl: authFetch, legacyCompat: { enabled: true } }),
+ new GrpcTransportFactory({ legacyCompat: { enabled: true } }),
],
});
```
`legacyCompat` causes the factory to inspect `AgentInterface.protocolVersion` on the discovered card and pick the right transport implementation (v1.0 for ≥1.0, v0.3 for 0.3.x). Without it, v0.3 peers get "Method not found" — which is exactly the symptom we hit.

### 2.6 README, package.json description, openclaw.plugin.json (line 323-324)
Replace `v0.3.0` strings with `v1.0` throughout. Update the badge URL to `v1.0-blue.svg`. Drop or rewrite the "fully tested with v0.3.x" claims in `docs/COMPATIBILITY.md` — v1.0 SDK has not been validated against the 486-test suite, that's part of phase 2.

### 2.7 Files likely to be removed
- `src/jsonrpc-handler.ts` if it exists (replaced by SDK's `jsonRpcHandler`)
- `src/rest-handler.ts` if separate
- `src/card-handler.ts` if separate
- `src/internal/envelope.ts` if its `message_id`/`message_type` wrappers are v0.3-specific

**Verify with** `git grep -n "message/send\|message/stream\|MessageSendParams\|additionalInterfaces"` after the diff and delete any dead branches.

---

## 3. Risks and unknowns

| Risk | Mitigation |
|------|------------|
| The v1.0 SDK's `jsonRpcHandler` doesn't accept our existing `userBuilder` shape (we have a custom `bearer` middleware) | Confirmed at SDK `dist/server/express/index.d.cts`: takes `userBuilder: UserBuilder`. We build a thin adapter from our existing `pushAuthMiddleware` to `UserBuilder` in the same PR. |
| The 486 v0.3 unit tests reference v0.3-only shapes (e.g. `additionalInterfaces` in fixtures) | Run the suite before any change → baseline pass. Run again after the swap → expect breakage in fixture-mismatch tests, fix or mark `.skip` with TODO. Phases 2/3. |
| Doxa has Doxa-only OpenClaw operator extensions (`operator.read`/`operator.write` per CHANGELOG line ~55) that ride on top of the JSON-RPC envelope | v1.0 SDK's `jsonRpcHandler` accepts a custom `requestHandler: A2ARequestHandler` — we wrap our existing operator-scope check inside that adapter. No extension gets dropped. |
| `grpcProxy` field in `agent-card.ts:28` referenced by v0.3 plugin config | v1.0 doesn't recognize `grpcProxy` config key (the SDK computes gRPC URL differently). Keep accepting the config field in our parser but mark it as legacy/no-op in this release. |
| The v1.0 SDK's `AgentInterface` uses `protocolBinding`/`protocolVersion` (v1.0 names) but Doxa's existing v0.3 plugin peers use `transport` (v0.3 name) | v1.0 SDK's `DefaultAgentCardResolver` parses both via the `legacyCompat` flag. With `legacyCompat: { enabled: true }` on the JSON-RPC handler, inbound v0.3 cards still resolve. Outbound is covered by the same flag on the transport factories. |
| README says "v0.3.0" everywhere; downstream users will be confused | Phase 3 includes a documentation pass: replace "v0.3.0" with "v1.0 (v0.3 legacy compat opt-in)" in the README, CHANGELOG, and plugin manifest. |

---

## 4. Phased execution plan

### Phase 1 — THIS DOCUMENT (done)
- Map deltas ✓
- Verify SDK availability ✓
- Identify dead code ✓
- Write `docs/v1.0-port-plan.md` ✓

### Phase 2 — Baseline tests, then v1.0 contract tests (no code change yet)
1. `npm ci && npm test` in the fork → record 486/486 pass count
2. Spin up the plugin locally (`npm run build`, then `node dist/index.js` with a test config)
3. From this Hermes container, run the existing `/opt/data/a2a_ping_doxa.py` against the local plugin → expect "Method not found" (the same error Doxa hit)
4. Write a new test file `tests/wire-v1.0.test.ts` that:
- Boots a v1.0 server with `jsonRpcHandler` from the v1.0 SDK
- Boots a v1.0 client with the same SDK
- Asserts `method: "SendMessage"` is what the client sends
- Asserts the agent card has `protocolVersion: "1.0"` and `supportedInterfaces[]`
- **Should fail** — we haven't changed any code yet, this just locks in what the swap needs to satisfy
5. Commit the test file (red) to `v1.0-wire-format-port` branch

### Phase 3 — Port wire format
1. Branch off `master` as `v1.0-wire-format-port`
2. Apply §2.1–§2.6 diffs
3. `npm install` (resolves new SDK; expect some peer-dep warnings on `@bufbuild/protobuf` minor)
4. `npm run build` (TypeScript will catch the type changes)
5. `npm test` — expect the v1.0 contract test from phase 2 to pass, expect some of the 486 v0.3 tests to fail on fixture mismatches
6. Fix or skip the failing v0.3 tests, *with a comment per skip explaining why*
7. Boot locally, re-run `/opt/data/a2a_ping_doxa.py` against the local server (it now speaks v1.0 inbound) — expect a real reply
8. Commit + push branch

### Phase 4 — End-to-end verification against Phronesis
1. **Doxa-side** (Doxa runs this on Belel): pull `v1.0-wire-format-port`, `npm install && npm run build`, restart the plugin
2. **From this Hermes container** (Phronesis): re-run `/opt/data/a2a_ping_doxa.py`
- Expected: Doxa replies with a one-line acknowledgement
- This is the **same script that just failed** with "Method not found: SendMessage" — a green run here is the proof
3. Run the inbound side too: `curl http://100.106.127.88:18800/.well-known/agent-card.json` → expect `protocolVersion: "1.0"`, `supportedInterfaces[]`, `provider`

### Phase 5 — PR for Doxa
1. `gh pr create` from `v1.0-wire-format-port` → `master`
2. PR title: `Port to A2A v1.0 wire format (with v0.3 legacy compat)`
3. PR body includes:
- The "before/after" of `/opt/data/a2a_ping_doxa.py` (the live round-trip)
- Test counts: v1.0 contract tests green, v0.3 baseline delta
- Documentation diff (README, CHANGELOG)
- Rollback plan: revert the single commit, restart plugin
4. Tag Doxa for review

---

## 5. Out of scope (deferred)

Per the agreed MVP scope:

- DNS-SD / mDNS discovery — works at both versions, no port needed; leave alone
- Push notifications — `pushNotifications: false` today, no behavior to break
- `tasks/cancel` method rename — v0.3 → v1.0 also renames this; `legacyCompat` covers it
- `SendStreamingMessage` → `StreamMessage` — fork supports streaming but Phronesis (Hermes) doesn't currently use it; v1.0 SDK handles both via `legacyCompat`
- gRPC transport parity — same SDK class, same config key, no work
- New `provider` field semantics (organization/URL) — added in v1.0 spec, but no spec-level contract for what it means yet; we set it to a static value
- Extended agent card support (`capabilities.extendedAgentCard: true`) — out of scope; flag stays `false`

---

## 6. Open questions for Doxa (in the PR)

1. Do you want `legacyCompat: { enabled: true }` permanently, or only for a transition window? Affects whether we ship a follow-up PR to remove it.
2. The `provider.organization` field — should it be "OpenClaw" or your org name? Configurable via env, defaulting to "OpenClaw" seems right.
3. The `version` field on the agent card stays `"1.0.0"` (plugin version), or should it become the A2A protocol version? Spec doesn't pin it.

---

**End of plan.** Ready for review before phase 2 starts.
23 changes: 16 additions & 7 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -510,12 +510,20 @@ const plugin = {
app.use("/.well-known/agent.json", cardEndpointHandler);
}

// TODO(legacyCompat-removal): once all peers are on A2A v1.0
// (Phronesis + any future peers), remove legacyCompat from this
// jsonRpcHandler AND from the three transport factories in
// src/client.ts:126-128. The compat layer silently widens the
// attack surface (accepts v0.3 envelopes) — fine during
// transition, footgun in steady state. Track in the v1.0 port
// checklist; ship a follow-up removal PR after Phases 2a-d land.
app.use(
"/a2a/jsonrpc",
createHttpMetricsMiddleware("jsonrpc"),
jsonRpcHandler({
requestHandler,
userBuilder,
legacyCompat: { enabled: true },
})
);

Expand Down Expand Up @@ -848,15 +856,16 @@ const plugin = {

const parts: Array<Record<string, unknown>> = [];
if (params.text) {
parts.push({ kind: "text", text: params.text });
parts.push({
content: { $case: "text", value: params.text },
filename: "",
mediaType: "text/plain",
});
}
parts.push({
kind: "file",
file: {
uri: params.uri,
...(params.name ? { name: params.name } : {}),
...(params.mimeType ? { mimeType: params.mimeType } : {}),
},
content: { $case: "url", value: params.uri },
filename: params.name ?? "",
mediaType: params.mimeType ?? "",
});

try {
Expand Down
29 changes: 15 additions & 14 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"test": "node --import tsx --test tests/*.test.ts"
},
"dependencies": {
"@a2a-js/sdk": "^0.3.13",
"@a2a-js/sdk": "^1.0.1",
"@bufbuild/protobuf": "^2.11.0",
"@grpc/grpc-js": "^1.14.3",
"express": "^4.21.2",
Expand Down
Loading