Skip to content

Commit 8dc313a

Browse files
feat: add A2A dual-stack compatibility foundations (#382)
1 parent b4c0bc7 commit 8dc313a

29 files changed

Lines changed: 1095 additions & 30 deletions

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,14 @@ curl http://127.0.0.1:8000/.well-known/agent-card.json
9191
- Request-scoped model selection through `metadata.shared.model`
9292
- OpenCode-oriented JSON-RPC extensions for session and model/provider queries
9393

94+
## A2A Protocol Support
95+
96+
- Default protocol line: `0.3`
97+
- Declared supported protocol lines: `0.3`, `1.0`
98+
- `0.3` is the stable interoperability baseline for the current runtime surface.
99+
- `1.0` currently covers version negotiation plus protocol-aware JSON-RPC and REST error shaping, while transport payloads, enums, pagination, signatures, and interface-level protocol declarations still follow the shipped SDK baseline.
100+
- The detailed compatibility matrix and machine-readable support boundary are documented in [`docs/guide.md`](docs/guide.md).
101+
94102
## Peering Node / Outbound Access
95103

96104
`opencode-a2a` supports a "Peering Node" architecture where a single process handles both inbound (Server) and outbound (Client) A2A traffic.

docs/extension-specifications.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ URI: `https://github.com/Intelligent-Internet/opencode-a2a/blob/main/docs/extens
8787
URI: `https://github.com/Intelligent-Internet/opencode-a2a/blob/main/docs/extension-specifications.md#a2a-compatibility-profile-v1`
8888

8989
- Scope: compatibility profile describing core baselines, extension retention, and service behaviors
90+
- Includes machine-readable protocol compatibility summary for the currently declared `0.3` / `1.0` support boundary
9091
- Public Agent Card: capability declaration only
9192
- Authenticated extended card: full compatibility profile payload
9293
- Transport: Agent Card extension params
@@ -96,6 +97,7 @@ URI: `https://github.com/Intelligent-Internet/opencode-a2a/blob/main/docs/extens
9697
URI: `https://github.com/Intelligent-Internet/opencode-a2a/blob/main/docs/extension-specifications.md#a2a-wire-contract-v1`
9798

9899
- Scope: wire-level contract for supported methods, endpoints, and error semantics
100+
- Includes the same machine-readable protocol compatibility summary published by the compatibility profile
99101
- Public Agent Card: capability declaration only
100102
- Authenticated extended card: full wire contract payload
101103
- Transport: Agent Card extension params

docs/guide.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,31 @@ Consumer guidance:
257257
- Discover custom JSON-RPC methods from Agent Card / OpenAPI before calling them.
258258
- Treat `supported_methods` in `error.data` as the runtime truth for the current deployment, especially when a deployment-conditional method is disabled.
259259

260+
## Protocol Version Negotiation
261+
262+
- The runtime accepts `A2A-Version` from either the HTTP header or the query parameter of A2A transport requests.
263+
- If both are omitted, the runtime falls back to the configured default protocol version.
264+
- Current defaults declare `default_protocol_version=0.3` and `supported_protocol_versions=["0.3", "1.0"]`.
265+
- Unsupported or invalid versions are rejected before request routing:
266+
- JSON-RPC returns a unified `VERSION_NOT_SUPPORTED` error envelope.
267+
- REST returns HTTP `400` with the same contract fields.
268+
- Error shaping now follows the negotiated major line:
269+
- `0.3` keeps the existing legacy `error.data={...}` and flat REST error payloads.
270+
- `1.0` keeps standard JSON-RPC error codes for standard failures, but moves A2A-specific JSON-RPC errors to `google.rpc.ErrorInfo`-style `error.data[]` details and REST errors to AIP-193 `error.details[]`.
271+
- The current transport payloads still follow the SDK-owned request/response shapes; version negotiation is introduced first so later issues can evolve error and payload compatibility without scattering version checks across handlers.
272+
273+
Current compatibility matrix:
274+
275+
| Area | `0.3` | `1.0` | Current note |
276+
| --- | --- | --- | --- |
277+
| Version negotiation | Supported | Supported | The runtime accepts `A2A-Version` and routes requests before handler dispatch. |
278+
| Agent Card / interface version discovery | Default card protocol only | Partial | The service publishes `default_protocol_version` and `supported_protocol_versions`, but `AgentInterface.protocolVersion` cannot yet be declared with `a2a-sdk==0.3.25`. |
279+
| Transport payloads and enums | Supported | Partial | Request/response payloads, enums, and schema details still follow the SDK-owned `0.3` baseline. |
280+
| Error model | Supported | Partial | `0.3` keeps legacy `error.data={...}` / flat REST payloads; `1.0` uses protocol-aware JSON-RPC details and AIP-193-style REST errors. |
281+
| Pagination and list semantics | Supported | Partial | Cursor/list behavior is stable, but the declared shape still follows the `0.3` SDK baseline. |
282+
| Push notification surfaces | Supported | Partial | Core task push-notification routes are available, but no extra `1.0`-specific compatibility layer is declared yet. |
283+
| Signatures and authenticated data | Supported | Partial | Security schemes and authenticated extended card discovery follow the shipped SDK schema rather than a dedicated `1.0` compatibility layer. |
284+
260285
## Compatibility Profile
261286

262287
The service also publishes a machine-readable compatibility profile through Agent Card and OpenAPI metadata.
@@ -271,6 +296,13 @@ Its purpose is to declare:
271296
Current profile shape:
272297

273298
- `profile_id=opencode-a2a-single-tenant-coding-v1`
299+
- `default_protocol_version`
300+
- `supported_protocol_versions`
301+
- `protocol_compatibility`
302+
- `versions["0.3"].status=supported`
303+
- `versions["1.0"].status=partial`
304+
- `versions[*].supported_features[]`
305+
- `versions[*].known_gaps[]`
274306
- Deployment semantics are declared under `deployment`:
275307
- `id=single_tenant_shared_workspace`
276308
- `single_tenant=true`
@@ -306,6 +338,7 @@ Retention guidance:
306338
- Treat `a2a.interrupt.*` methods as shared extensions.
307339
- Treat `opencode.sessions.*`, `opencode.providers.*`, and `opencode.models.*` as provider-private OpenCode extensions rather than portable A2A baseline capabilities.
308340
- Treat `opencode.sessions.shell` as deployment-conditional and discover it from the declared profile and current wire contract before calling it.
341+
- Treat `protocol_compatibility` as the runtime truth for which protocol line is fully supported versus only partially adapted.
309342

310343
## Multipart Input Example
311344

src/opencode_a2a/client/client.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,10 @@ async def send_message(
131131
async for event in client.send_message(
132132
request,
133133
context=build_call_context(
134-
self._settings.bearer_token, extra_headers, self._settings.basic_auth
134+
self._settings.bearer_token,
135+
extra_headers,
136+
self._settings.basic_auth,
137+
self._settings.protocol_version,
135138
),
136139
request_metadata=request_metadata,
137140
extensions=extensions,
@@ -203,7 +206,10 @@ async def get_task(
203206
metadata=request_metadata or {},
204207
),
205208
context=build_call_context(
206-
self._settings.bearer_token, extra_headers, self._settings.basic_auth
209+
self._settings.bearer_token,
210+
extra_headers,
211+
self._settings.basic_auth,
212+
self._settings.protocol_version,
207213
),
208214
)
209215
except (
@@ -231,7 +237,10 @@ async def cancel_task(
231237
return await client.cancel_task(
232238
TaskIdParams(id=task_id, metadata=request_metadata or {}),
233239
context=build_call_context(
234-
self._settings.bearer_token, extra_headers, self._settings.basic_auth
240+
self._settings.bearer_token,
241+
extra_headers,
242+
self._settings.basic_auth,
243+
self._settings.protocol_version,
235244
),
236245
)
237246
except (
@@ -259,7 +268,10 @@ async def resubscribe_task(
259268
async for event in client.resubscribe(
260269
TaskIdParams(id=task_id, metadata=request_metadata or {}),
261270
context=build_call_context(
262-
self._settings.bearer_token, extra_headers, self._settings.basic_auth
271+
self._settings.bearer_token,
272+
extra_headers,
273+
self._settings.basic_auth,
274+
self._settings.protocol_version,
263275
),
264276
):
265277
yield event
@@ -293,7 +305,9 @@ async def _build_client(self) -> Client:
293305
client = factory.create(
294306
card,
295307
interceptors=build_client_interceptors(
296-
self._settings.bearer_token, self._settings.basic_auth
308+
self._settings.bearer_token,
309+
self._settings.basic_auth,
310+
self._settings.protocol_version,
297311
),
298312
)
299313
except ValueError as exc:

src/opencode_a2a/client/config.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from dataclasses import dataclass
77
from typing import Any
88

9+
from ..protocol_versions import normalize_protocol_version
910
from .auth import validate_basic_auth
1011
from .polling import PollingFallbackPolicy, validate_polling_fallback_policy
1112

@@ -70,6 +71,13 @@ def _coerce_optional_str(name: str, value: Any) -> str | None:
7071
raise ValueError(f"{name} must be a string, got {value!r}")
7172

7273

74+
def _coerce_optional_protocol_version(name: str, value: Any) -> str | None:
75+
normalized = _coerce_optional_str(name, value)
76+
if normalized is None:
77+
return None
78+
return normalize_protocol_version(normalized)
79+
80+
7381
def _normalize_transport(value: str) -> str:
7482
normalized = value.strip().lower()
7583
if normalized in {"jsonrpc", "json-rpc", "json_rpc"}:
@@ -110,6 +118,7 @@ class A2AClientSettings:
110118
card_fetch_timeout: float = 5.0
111119
bearer_token: str | None = None
112120
basic_auth: str | None = None
121+
protocol_version: str | None = None
113122
supported_transports: tuple[str, ...] = (
114123
"JSONRPC",
115124
"HTTP+JSON",
@@ -172,6 +181,19 @@ def load_settings(raw_settings: Any) -> A2AClientSettings:
172181
)
173182
if basic_auth is not None:
174183
validate_basic_auth(basic_auth)
184+
protocol_version = _coerce_optional_protocol_version(
185+
"A2A_CLIENT_PROTOCOL_VERSION",
186+
_read_setting(
187+
raw_settings,
188+
keys=(
189+
"A2A_CLIENT_PROTOCOL_VERSION",
190+
"a2a_client_protocol_version",
191+
"A2A_PROTOCOL_VERSION",
192+
"a2a_protocol_version",
193+
),
194+
default=None,
195+
),
196+
)
175197
supported_transports = _parse_transports(
176198
_read_setting(
177199
raw_settings,
@@ -260,6 +282,7 @@ def load_settings(raw_settings: Any) -> A2AClientSettings:
260282
card_fetch_timeout=card_fetch_timeout,
261283
bearer_token=bearer_token,
262284
basic_auth=basic_auth,
285+
protocol_version=protocol_version,
263286
supported_transports=supported_transports,
264287
polling_fallback_enabled=polling_fallback_enabled,
265288
polling_fallback_initial_interval_seconds=polling_fallback_initial_interval_seconds,

src/opencode_a2a/client/request_context.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from a2a.client.middleware import ClientCallContext, ClientCallInterceptor
99

10+
from ..protocol_versions import normalize_protocol_version
1011
from .auth import encode_basic_auth
1112

1213

@@ -41,12 +42,16 @@ async def intercept(
4142
def build_default_headers(
4243
bearer_token: str | None,
4344
basic_auth: str | None = None,
45+
protocol_version: str | None = None,
4446
) -> dict[str, str]:
47+
headers: dict[str, str] = {}
4548
if bearer_token:
46-
return {"Authorization": f"Bearer {bearer_token}"}
47-
if basic_auth:
48-
return {"Authorization": f"Basic {encode_basic_auth(basic_auth)}"}
49-
return {}
49+
headers["Authorization"] = f"Bearer {bearer_token}"
50+
elif basic_auth:
51+
headers["Authorization"] = f"Basic {encode_basic_auth(basic_auth)}"
52+
if protocol_version:
53+
headers["A2A-Version"] = normalize_protocol_version(protocol_version)
54+
return headers
5055

5156

5257
def split_request_metadata(
@@ -59,6 +64,10 @@ def split_request_metadata(
5964
if value is not None:
6065
extra_headers["Authorization"] = str(value)
6166
continue
67+
if isinstance(key, str) and key.lower() == "a2a-version":
68+
if value is not None:
69+
extra_headers["A2A-Version"] = normalize_protocol_version(str(value))
70+
continue
6271
request_metadata[key] = value
6372
return request_metadata or None, extra_headers or None
6473

@@ -67,8 +76,9 @@ def build_call_context(
6776
bearer_token: str | None,
6877
extra_headers: Mapping[str, str] | None,
6978
basic_auth: str | None = None,
79+
protocol_version: str | None = None,
7080
) -> ClientCallContext | None:
71-
merged_headers = build_default_headers(bearer_token, basic_auth)
81+
merged_headers = build_default_headers(bearer_token, basic_auth, protocol_version)
7282
if extra_headers:
7383
merged_headers.update(extra_headers)
7484
if not merged_headers:
@@ -84,8 +94,9 @@ def build_call_context(
8494
def build_client_interceptors(
8595
bearer_token: str | None,
8696
basic_auth: str | None = None,
97+
protocol_version: str | None = None,
8798
) -> list[ClientCallInterceptor]:
88-
return [HeaderInterceptor(build_default_headers(bearer_token, basic_auth))]
99+
return [HeaderInterceptor(build_default_headers(bearer_token, basic_auth, protocol_version))]
89100

90101

91102
__all__ = [

src/opencode_a2a/config.py

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,14 @@
33
import json
44
from typing import Annotated, Any, Literal
55

6-
from pydantic import BeforeValidator, Field, model_validator
6+
from pydantic import BeforeValidator, Field, field_validator, model_validator
77
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
88

99
from opencode_a2a import __version__
10+
from opencode_a2a.protocol_versions import (
11+
normalize_protocol_version,
12+
normalize_protocol_versions,
13+
)
1014
from opencode_a2a.sandbox_policy import SandboxPolicy
1115

1216
SandboxMode = Literal[
@@ -97,7 +101,11 @@ class Settings(BaseSettings):
97101
a2a_title: str = Field(default="OpenCode A2A", alias="A2A_TITLE")
98102
a2a_description: str = Field(default="OpenCode A2A runtime", alias="A2A_DESCRIPTION")
99103
a2a_version: str = Field(default=__version__, alias="A2A_VERSION")
100-
a2a_protocol_version: str = Field(default="0.3.0", alias="A2A_PROTOCOL_VERSION")
104+
a2a_protocol_version: str = Field(default="0.3", alias="A2A_PROTOCOL_VERSION")
105+
a2a_supported_protocol_versions: DeclaredStringList = Field(
106+
default=("0.3", "1.0"),
107+
alias="A2A_SUPPORTED_PROTOCOL_VERSIONS",
108+
)
101109
a2a_log_level: str = Field(default="WARNING", alias="A2A_LOG_LEVEL")
102110
a2a_log_payloads: bool = Field(default=False, alias="A2A_LOG_PAYLOADS")
103111
a2a_log_body_limit: int = Field(default=0, alias="A2A_LOG_BODY_LIMIT")
@@ -180,6 +188,10 @@ class Settings(BaseSettings):
180188
)
181189
a2a_client_bearer_token: str | None = Field(default=None, alias="A2A_CLIENT_BEARER_TOKEN")
182190
a2a_client_basic_auth: str | None = Field(default=None, alias="A2A_CLIENT_BASIC_AUTH")
191+
a2a_client_protocol_version: str | None = Field(
192+
default=None,
193+
alias="A2A_CLIENT_PROTOCOL_VERSION",
194+
)
183195
a2a_client_cache_ttl_seconds: float = Field(
184196
default=900.0,
185197
ge=0.0,
@@ -212,4 +224,37 @@ def _validate_sandbox_policy(self) -> Settings:
212224
raise ValueError(
213225
"A2A_TASK_STORE_DATABASE_URL is required when A2A_TASK_STORE_BACKEND=database"
214226
)
227+
if self.a2a_protocol_version not in self.a2a_supported_protocol_versions:
228+
supported_display = ", ".join(self.a2a_supported_protocol_versions)
229+
raise ValueError(
230+
"A2A_PROTOCOL_VERSION must be present in A2A_SUPPORTED_PROTOCOL_VERSIONS. "
231+
f"Declared supported versions: {supported_display}"
232+
)
215233
return self
234+
235+
@field_validator("a2a_protocol_version", mode="before")
236+
@classmethod
237+
def _normalize_a2a_protocol_version(cls, value: Any) -> str:
238+
if not isinstance(value, str):
239+
raise TypeError("A2A_PROTOCOL_VERSION must be a string.")
240+
return normalize_protocol_version(value)
241+
242+
@field_validator("a2a_client_protocol_version", mode="before")
243+
@classmethod
244+
def _normalize_a2a_client_protocol_version(cls, value: Any) -> str | None:
245+
if value is None:
246+
return None
247+
if not isinstance(value, str):
248+
raise TypeError("A2A_CLIENT_PROTOCOL_VERSION must be a string.")
249+
normalized = value.strip()
250+
if not normalized:
251+
return None
252+
return normalize_protocol_version(normalized)
253+
254+
@field_validator("a2a_supported_protocol_versions")
255+
@classmethod
256+
def _normalize_supported_protocol_versions(
257+
cls,
258+
value: tuple[str, ...],
259+
) -> tuple[str, ...]:
260+
return normalize_protocol_versions(value)

0 commit comments

Comments
 (0)