Skip to content
Merged
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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,14 @@ curl http://127.0.0.1:8000/.well-known/agent-card.json
- Request-scoped model selection through `metadata.shared.model`
- OpenCode-oriented JSON-RPC extensions for session and model/provider queries

## A2A Protocol Support

- Default protocol line: `0.3`
- Declared supported protocol lines: `0.3`, `1.0`
- `0.3` is the stable interoperability baseline for the current runtime surface.
- `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.
- The detailed compatibility matrix and machine-readable support boundary are documented in [`docs/guide.md`](docs/guide.md).

## Peering Node / Outbound Access

`opencode-a2a` supports a "Peering Node" architecture where a single process handles both inbound (Server) and outbound (Client) A2A traffic.
Expand Down
2 changes: 2 additions & 0 deletions docs/extension-specifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ URI: `https://github.com/Intelligent-Internet/opencode-a2a/blob/main/docs/extens
URI: `https://github.com/Intelligent-Internet/opencode-a2a/blob/main/docs/extension-specifications.md#a2a-compatibility-profile-v1`

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

- Scope: wire-level contract for supported methods, endpoints, and error semantics
- Includes the same machine-readable protocol compatibility summary published by the compatibility profile
- Public Agent Card: capability declaration only
- Authenticated extended card: full wire contract payload
- Transport: Agent Card extension params
33 changes: 33 additions & 0 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,31 @@ Consumer guidance:
- Discover custom JSON-RPC methods from Agent Card / OpenAPI before calling them.
- Treat `supported_methods` in `error.data` as the runtime truth for the current deployment, especially when a deployment-conditional method is disabled.

## Protocol Version Negotiation

- The runtime accepts `A2A-Version` from either the HTTP header or the query parameter of A2A transport requests.
- If both are omitted, the runtime falls back to the configured default protocol version.
- Current defaults declare `default_protocol_version=0.3` and `supported_protocol_versions=["0.3", "1.0"]`.
- Unsupported or invalid versions are rejected before request routing:
- JSON-RPC returns a unified `VERSION_NOT_SUPPORTED` error envelope.
- REST returns HTTP `400` with the same contract fields.
- Error shaping now follows the negotiated major line:
- `0.3` keeps the existing legacy `error.data={...}` and flat REST error payloads.
- `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[]`.
- 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.

Current compatibility matrix:

| Area | `0.3` | `1.0` | Current note |
| --- | --- | --- | --- |
| Version negotiation | Supported | Supported | The runtime accepts `A2A-Version` and routes requests before handler dispatch. |
| 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`. |
| Transport payloads and enums | Supported | Partial | Request/response payloads, enums, and schema details still follow the SDK-owned `0.3` baseline. |
| 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. |
| Pagination and list semantics | Supported | Partial | Cursor/list behavior is stable, but the declared shape still follows the `0.3` SDK baseline. |
| Push notification surfaces | Supported | Partial | Core task push-notification routes are available, but no extra `1.0`-specific compatibility layer is declared yet. |
| 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. |

## Compatibility Profile

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

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

## Multipart Input Example

Expand Down
24 changes: 19 additions & 5 deletions src/opencode_a2a/client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,10 @@ async def send_message(
async for event in client.send_message(
request,
context=build_call_context(
self._settings.bearer_token, extra_headers, self._settings.basic_auth
self._settings.bearer_token,
extra_headers,
self._settings.basic_auth,
self._settings.protocol_version,
),
request_metadata=request_metadata,
extensions=extensions,
Expand Down Expand Up @@ -203,7 +206,10 @@ async def get_task(
metadata=request_metadata or {},
),
context=build_call_context(
self._settings.bearer_token, extra_headers, self._settings.basic_auth
self._settings.bearer_token,
extra_headers,
self._settings.basic_auth,
self._settings.protocol_version,
),
)
except (
Expand Down Expand Up @@ -231,7 +237,10 @@ async def cancel_task(
return await client.cancel_task(
TaskIdParams(id=task_id, metadata=request_metadata or {}),
context=build_call_context(
self._settings.bearer_token, extra_headers, self._settings.basic_auth
self._settings.bearer_token,
extra_headers,
self._settings.basic_auth,
self._settings.protocol_version,
),
)
except (
Expand Down Expand Up @@ -259,7 +268,10 @@ async def resubscribe_task(
async for event in client.resubscribe(
TaskIdParams(id=task_id, metadata=request_metadata or {}),
context=build_call_context(
self._settings.bearer_token, extra_headers, self._settings.basic_auth
self._settings.bearer_token,
extra_headers,
self._settings.basic_auth,
self._settings.protocol_version,
),
):
yield event
Expand Down Expand Up @@ -293,7 +305,9 @@ async def _build_client(self) -> Client:
client = factory.create(
card,
interceptors=build_client_interceptors(
self._settings.bearer_token, self._settings.basic_auth
self._settings.bearer_token,
self._settings.basic_auth,
self._settings.protocol_version,
),
)
except ValueError as exc:
Expand Down
23 changes: 23 additions & 0 deletions src/opencode_a2a/client/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from dataclasses import dataclass
from typing import Any

from ..protocol_versions import normalize_protocol_version
from .auth import validate_basic_auth
from .polling import PollingFallbackPolicy, validate_polling_fallback_policy

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


def _coerce_optional_protocol_version(name: str, value: Any) -> str | None:
normalized = _coerce_optional_str(name, value)
if normalized is None:
return None
return normalize_protocol_version(normalized)


def _normalize_transport(value: str) -> str:
normalized = value.strip().lower()
if normalized in {"jsonrpc", "json-rpc", "json_rpc"}:
Expand Down Expand Up @@ -110,6 +118,7 @@ class A2AClientSettings:
card_fetch_timeout: float = 5.0
bearer_token: str | None = None
basic_auth: str | None = None
protocol_version: str | None = None
supported_transports: tuple[str, ...] = (
"JSONRPC",
"HTTP+JSON",
Expand Down Expand Up @@ -172,6 +181,19 @@ def load_settings(raw_settings: Any) -> A2AClientSettings:
)
if basic_auth is not None:
validate_basic_auth(basic_auth)
protocol_version = _coerce_optional_protocol_version(
"A2A_CLIENT_PROTOCOL_VERSION",
_read_setting(
raw_settings,
keys=(
"A2A_CLIENT_PROTOCOL_VERSION",
"a2a_client_protocol_version",
"A2A_PROTOCOL_VERSION",
"a2a_protocol_version",
),
default=None,
),
)
supported_transports = _parse_transports(
_read_setting(
raw_settings,
Expand Down Expand Up @@ -260,6 +282,7 @@ def load_settings(raw_settings: Any) -> A2AClientSettings:
card_fetch_timeout=card_fetch_timeout,
bearer_token=bearer_token,
basic_auth=basic_auth,
protocol_version=protocol_version,
supported_transports=supported_transports,
polling_fallback_enabled=polling_fallback_enabled,
polling_fallback_initial_interval_seconds=polling_fallback_initial_interval_seconds,
Expand Down
23 changes: 17 additions & 6 deletions src/opencode_a2a/client/request_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from a2a.client.middleware import ClientCallContext, ClientCallInterceptor

from ..protocol_versions import normalize_protocol_version
from .auth import encode_basic_auth


Expand Down Expand Up @@ -41,12 +42,16 @@ async def intercept(
def build_default_headers(
bearer_token: str | None,
basic_auth: str | None = None,
protocol_version: str | None = None,
) -> dict[str, str]:
headers: dict[str, str] = {}
if bearer_token:
return {"Authorization": f"Bearer {bearer_token}"}
if basic_auth:
return {"Authorization": f"Basic {encode_basic_auth(basic_auth)}"}
return {}
headers["Authorization"] = f"Bearer {bearer_token}"
elif basic_auth:
headers["Authorization"] = f"Basic {encode_basic_auth(basic_auth)}"
if protocol_version:
headers["A2A-Version"] = normalize_protocol_version(protocol_version)
return headers


def split_request_metadata(
Expand All @@ -59,6 +64,10 @@ def split_request_metadata(
if value is not None:
extra_headers["Authorization"] = str(value)
continue
if isinstance(key, str) and key.lower() == "a2a-version":
if value is not None:
extra_headers["A2A-Version"] = normalize_protocol_version(str(value))
continue
request_metadata[key] = value
return request_metadata or None, extra_headers or None

Expand All @@ -67,8 +76,9 @@ def build_call_context(
bearer_token: str | None,
extra_headers: Mapping[str, str] | None,
basic_auth: str | None = None,
protocol_version: str | None = None,
) -> ClientCallContext | None:
merged_headers = build_default_headers(bearer_token, basic_auth)
merged_headers = build_default_headers(bearer_token, basic_auth, protocol_version)
if extra_headers:
merged_headers.update(extra_headers)
if not merged_headers:
Expand All @@ -84,8 +94,9 @@ def build_call_context(
def build_client_interceptors(
bearer_token: str | None,
basic_auth: str | None = None,
protocol_version: str | None = None,
) -> list[ClientCallInterceptor]:
return [HeaderInterceptor(build_default_headers(bearer_token, basic_auth))]
return [HeaderInterceptor(build_default_headers(bearer_token, basic_auth, protocol_version))]


__all__ = [
Expand Down
49 changes: 47 additions & 2 deletions src/opencode_a2a/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,14 @@
import json
from typing import Annotated, Any, Literal

from pydantic import BeforeValidator, Field, model_validator
from pydantic import BeforeValidator, Field, field_validator, model_validator
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict

from opencode_a2a import __version__
from opencode_a2a.protocol_versions import (
normalize_protocol_version,
normalize_protocol_versions,
)
from opencode_a2a.sandbox_policy import SandboxPolicy

SandboxMode = Literal[
Expand Down Expand Up @@ -97,7 +101,11 @@ class Settings(BaseSettings):
a2a_title: str = Field(default="OpenCode A2A", alias="A2A_TITLE")
a2a_description: str = Field(default="OpenCode A2A runtime", alias="A2A_DESCRIPTION")
a2a_version: str = Field(default=__version__, alias="A2A_VERSION")
a2a_protocol_version: str = Field(default="0.3.0", alias="A2A_PROTOCOL_VERSION")
a2a_protocol_version: str = Field(default="0.3", alias="A2A_PROTOCOL_VERSION")
a2a_supported_protocol_versions: DeclaredStringList = Field(
default=("0.3", "1.0"),
alias="A2A_SUPPORTED_PROTOCOL_VERSIONS",
)
a2a_log_level: str = Field(default="WARNING", alias="A2A_LOG_LEVEL")
a2a_log_payloads: bool = Field(default=False, alias="A2A_LOG_PAYLOADS")
a2a_log_body_limit: int = Field(default=0, alias="A2A_LOG_BODY_LIMIT")
Expand Down Expand Up @@ -180,6 +188,10 @@ class Settings(BaseSettings):
)
a2a_client_bearer_token: str | None = Field(default=None, alias="A2A_CLIENT_BEARER_TOKEN")
a2a_client_basic_auth: str | None = Field(default=None, alias="A2A_CLIENT_BASIC_AUTH")
a2a_client_protocol_version: str | None = Field(
default=None,
alias="A2A_CLIENT_PROTOCOL_VERSION",
)
a2a_client_cache_ttl_seconds: float = Field(
default=900.0,
ge=0.0,
Expand Down Expand Up @@ -212,4 +224,37 @@ def _validate_sandbox_policy(self) -> Settings:
raise ValueError(
"A2A_TASK_STORE_DATABASE_URL is required when A2A_TASK_STORE_BACKEND=database"
)
if self.a2a_protocol_version not in self.a2a_supported_protocol_versions:
supported_display = ", ".join(self.a2a_supported_protocol_versions)
raise ValueError(
"A2A_PROTOCOL_VERSION must be present in A2A_SUPPORTED_PROTOCOL_VERSIONS. "
f"Declared supported versions: {supported_display}"
)
return self

@field_validator("a2a_protocol_version", mode="before")
@classmethod
def _normalize_a2a_protocol_version(cls, value: Any) -> str:
if not isinstance(value, str):
raise TypeError("A2A_PROTOCOL_VERSION must be a string.")
return normalize_protocol_version(value)

@field_validator("a2a_client_protocol_version", mode="before")
@classmethod
def _normalize_a2a_client_protocol_version(cls, value: Any) -> str | None:
if value is None:
return None
if not isinstance(value, str):
raise TypeError("A2A_CLIENT_PROTOCOL_VERSION must be a string.")
normalized = value.strip()
if not normalized:
return None
return normalize_protocol_version(normalized)

@field_validator("a2a_supported_protocol_versions")
@classmethod
def _normalize_supported_protocol_versions(
cls,
value: tuple[str, ...],
) -> tuple[str, ...]:
return normalize_protocol_versions(value)
Loading