From 2907f522f879661a74e8a4ba987eeaf4c9e00992 Mon Sep 17 00:00:00 2001 From: Sena Date: Fri, 2 Oct 2026 20:13:02 +0300 Subject: [PATCH 1/5] fix(client): ignore the patch version when preferring a 1.0 interface `ClientFactory._find_best_interface` preferred an interface only when its `protocol_version` was exactly '1.0'. An agent card declaring '1.0.0' next to a '0.3.0' interface therefore lost to the legacy one, and the client talked v0.3 to the agent. The specification says patch versions must not be considered when negotiating protocol versions, so compare `Major.Minor` instead. --- src/a2a/client/client_factory.py | 14 ++++++++++++-- tests/client/test_client_factory.py | 30 +++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/a2a/client/client_factory.py b/src/a2a/client/client_factory.py index a59189ade..2dc995388 100644 --- a/src/a2a/client/client_factory.py +++ b/src/a2a/client/client_factory.py @@ -55,6 +55,14 @@ ] +def _major_minor(version: str) -> tuple[int, ...] | None: + """Returns the `Major.Minor` release of a protocol version, if valid.""" + try: + return Version(version).release[:2] + except InvalidVersion: + return None + + class ClientFactory: """Factory for creating clients that communicate with A2A agents. @@ -229,9 +237,11 @@ def _find_best_interface( if not candidates: return None - # Prefer interface with version 1.0 + # Prefer interface with version 1.0. Patch versions must not affect + # negotiation, so '1.0.0' counts as '1.0'. + v1_0 = _major_minor(PROTOCOL_VERSION_1_0) for i in candidates: - if i.protocol_version == PROTOCOL_VERSION_1_0: + if _major_minor(i.protocol_version) == v1_0: return i best_gt_1_0 = None diff --git a/tests/client/test_client_factory.py b/tests/client/test_client_factory.py index d211a7331..869881086 100644 --- a/tests/client/test_client_factory.py +++ b/tests/client/test_client_factory.py @@ -115,6 +115,36 @@ def test_client_factory_server_preference(base_agent_card: AgentCard): assert client._transport.url == 'http://primary-url.com' # type: ignore[attr-defined] +@pytest.mark.parametrize('v1_version', ['1.0', '1.0.0']) +def test_client_factory_prefers_1_0_ignoring_patch_version( + base_agent_card: AgentCard, v1_version: str +): + """A patch version must not affect protocol negotiation (spec: Versioning).""" + del base_agent_card.supported_interfaces[:] + base_agent_card.supported_interfaces.extend( + [ + AgentInterface( + protocol_binding=TransportProtocol.JSONRPC, + url='http://legacy-url.com', + protocol_version='0.3.0', + ), + AgentInterface( + protocol_binding=TransportProtocol.JSONRPC, + url='http://v1-url.com', + protocol_version=v1_version, + ), + ] + ) + config = ClientConfig( + httpx_client=httpx.AsyncClient(), + supported_protocol_bindings=[TransportProtocol.JSONRPC], + ) + client = ClientFactory(config).create(base_agent_card) + + assert isinstance(client._transport, JsonRpcTransport) # type: ignore[attr-defined] + assert client._transport.url == 'http://v1-url.com' # type: ignore[attr-defined] + + def test_client_factory_no_compatible_transport(base_agent_card: AgentCard): """Verify that the factory raises an error if no compatible transport is found.""" config = ClientConfig( From a9e912e1b7db01c04ceb82f2a2c1bbc0efe06c50 Mon Sep 17 00:00:00 2001 From: Sena Date: Tue, 6 Oct 2026 13:31:11 +0300 Subject: [PATCH 2/5] refactor(client): compare Major.Minor for every protocol version check Address review: build the tuple from Version.major and Version.minor, and use _major_minor for the >1.0 and >=0.3 fallbacks too, so the patch version never takes part in the comparison. --- src/a2a/client/client_factory.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/a2a/client/client_factory.py b/src/a2a/client/client_factory.py index 2dc995388..76dee4e36 100644 --- a/src/a2a/client/client_factory.py +++ b/src/a2a/client/client_factory.py @@ -55,12 +55,13 @@ ] -def _major_minor(version: str) -> tuple[int, ...] | None: +def _major_minor(version: str) -> tuple[int, int] | None: """Returns the `Major.Minor` release of a protocol version, if valid.""" try: - return Version(version).release[:2] + v = Version(version) except InvalidVersion: return None + return (v.major, v.minor) class ClientFactory: @@ -240,6 +241,7 @@ def _find_best_interface( # Prefer interface with version 1.0. Patch versions must not affect # negotiation, so '1.0.0' counts as '1.0'. v1_0 = _major_minor(PROTOCOL_VERSION_1_0) + v0_3 = _major_minor(PROTOCOL_VERSION_0_3) for i in candidates: if _major_minor(i.protocol_version) == v1_0: return i @@ -254,14 +256,13 @@ def _find_best_interface( best_no_version = i continue - try: - v = Version(i.protocol_version) - if best_gt_1_0 is None and v > Version(PROTOCOL_VERSION_1_0): - best_gt_1_0 = i - if best_ge_0_3 is None and v >= Version(PROTOCOL_VERSION_0_3): - best_ge_0_3 = i - except InvalidVersion: - pass + v = _major_minor(i.protocol_version) + if v is None: + continue + if best_gt_1_0 is None and v > v1_0: + best_gt_1_0 = i + if best_ge_0_3 is None and v >= v0_3: + best_ge_0_3 = i return best_gt_1_0 or best_ge_0_3 or best_no_version From 64d7dc9e37d37fc2d65cc5e5c9b72b9395f578b0 Mon Sep 17 00:00:00 2001 From: Sena Date: Tue, 6 Oct 2026 13:34:03 +0300 Subject: [PATCH 3/5] fix(client): keep the protocol constants non-optional for ty The fallback comparisons used _major_minor() on the 1.0 and 0.3 constants, whose return type includes None, so ty rejected the > and >= operations. Parse the constants with a helper that always returns a tuple. --- src/a2a/client/client_factory.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/a2a/client/client_factory.py b/src/a2a/client/client_factory.py index 76dee4e36..a8ea76720 100644 --- a/src/a2a/client/client_factory.py +++ b/src/a2a/client/client_factory.py @@ -55,13 +55,17 @@ ] +def _major_minor_of(version: Version) -> tuple[int, int]: + """Returns the `Major.Minor` release of a parsed version.""" + return (version.major, version.minor) + + def _major_minor(version: str) -> tuple[int, int] | None: """Returns the `Major.Minor` release of a protocol version, if valid.""" try: - v = Version(version) + return _major_minor_of(Version(version)) except InvalidVersion: return None - return (v.major, v.minor) class ClientFactory: @@ -240,8 +244,8 @@ def _find_best_interface( # Prefer interface with version 1.0. Patch versions must not affect # negotiation, so '1.0.0' counts as '1.0'. - v1_0 = _major_minor(PROTOCOL_VERSION_1_0) - v0_3 = _major_minor(PROTOCOL_VERSION_0_3) + v1_0 = _major_minor_of(Version(PROTOCOL_VERSION_1_0)) + v0_3 = _major_minor_of(Version(PROTOCOL_VERSION_0_3)) for i in candidates: if _major_minor(i.protocol_version) == v1_0: return i From fdcb6ab06863ce9c578dfe004e683492ccb4a904 Mon Sep 17 00:00:00 2001 From: Sena Date: Tue, 6 Oct 2026 14:21:30 +0300 Subject: [PATCH 4/5] refactor(client): pick the interface in one loop over candidates Address review: _major_minor returns tuple[int, int] without catching InvalidVersion, and a single loop over the candidates parses each version once, returns the first 1.0 match and skips invalid versions. --- src/a2a/client/client_factory.py | 39 +++++++++++++------------------- 1 file changed, 16 insertions(+), 23 deletions(-) diff --git a/src/a2a/client/client_factory.py b/src/a2a/client/client_factory.py index a8ea76720..ba146b1f4 100644 --- a/src/a2a/client/client_factory.py +++ b/src/a2a/client/client_factory.py @@ -55,17 +55,10 @@ ] -def _major_minor_of(version: Version) -> tuple[int, int]: - """Returns the `Major.Minor` release of a parsed version.""" - return (version.major, version.minor) - - -def _major_minor(version: str) -> tuple[int, int] | None: - """Returns the `Major.Minor` release of a protocol version, if valid.""" - try: - return _major_minor_of(Version(version)) - except InvalidVersion: - return None +def _major_minor(version: str) -> tuple[int, int]: + """Returns the `Major.Minor` release of a protocol version.""" + v = Version(version) + return (v.major, v.minor) class ClientFactory: @@ -244,11 +237,8 @@ def _find_best_interface( # Prefer interface with version 1.0. Patch versions must not affect # negotiation, so '1.0.0' counts as '1.0'. - v1_0 = _major_minor_of(Version(PROTOCOL_VERSION_1_0)) - v0_3 = _major_minor_of(Version(PROTOCOL_VERSION_0_3)) - for i in candidates: - if _major_minor(i.protocol_version) == v1_0: - return i + v1_0 = _major_minor(PROTOCOL_VERSION_1_0) + v0_3 = _major_minor(PROTOCOL_VERSION_0_3) best_gt_1_0 = None best_ge_0_3 = None @@ -260,13 +250,16 @@ def _find_best_interface( best_no_version = i continue - v = _major_minor(i.protocol_version) - if v is None: - continue - if best_gt_1_0 is None and v > v1_0: - best_gt_1_0 = i - if best_ge_0_3 is None and v >= v0_3: - best_ge_0_3 = i + try: + v = _major_minor(i.protocol_version) + if v == v1_0: + return i + if best_gt_1_0 is None and v > v1_0: + best_gt_1_0 = i + if best_ge_0_3 is None and v >= v0_3: + best_ge_0_3 = i + except InvalidVersion: + pass return best_gt_1_0 or best_ge_0_3 or best_no_version From 9e5098b15eb16441f03f30573b0371b44aa2e807 Mon Sep 17 00:00:00 2001 From: Sena Date: Tue, 6 Oct 2026 17:44:53 +0300 Subject: [PATCH 5/5] refactor(client): rename _major_minor to major_minor Address review. --- src/a2a/client/client_factory.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/a2a/client/client_factory.py b/src/a2a/client/client_factory.py index ba146b1f4..e43207ca7 100644 --- a/src/a2a/client/client_factory.py +++ b/src/a2a/client/client_factory.py @@ -55,7 +55,7 @@ ] -def _major_minor(version: str) -> tuple[int, int]: +def major_minor(version: str) -> tuple[int, int]: """Returns the `Major.Minor` release of a protocol version.""" v = Version(version) return (v.major, v.minor) @@ -237,8 +237,8 @@ def _find_best_interface( # Prefer interface with version 1.0. Patch versions must not affect # negotiation, so '1.0.0' counts as '1.0'. - v1_0 = _major_minor(PROTOCOL_VERSION_1_0) - v0_3 = _major_minor(PROTOCOL_VERSION_0_3) + v1_0 = major_minor(PROTOCOL_VERSION_1_0) + v0_3 = major_minor(PROTOCOL_VERSION_0_3) best_gt_1_0 = None best_ge_0_3 = None @@ -251,7 +251,7 @@ def _find_best_interface( continue try: - v = _major_minor(i.protocol_version) + v = major_minor(i.protocol_version) if v == v1_0: return i if best_gt_1_0 is None and v > v1_0: