Skip to content

fix(client): ignore the patch version when preferring a 1.0 interface - #1304

Merged
mykytanetipa merged 6 commits into
a2aproject:mainfrom
kosesena:fix/client-interface-version-patch
Oct 6, 2026
Merged

mykytanetipa merged 6 commits into
a2aproject:mainfrom
kosesena:fix/client-interface-version-patch

Conversation

@kosesena

@kosesena kosesena commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

Description

ClientFactory._find_best_interface prefers a 1.0 interface with an exact string comparison (protocol_version == '1.0'). When an agent card lists a legacy interface and a 1.0 interface written with a patch version, e.g.

supported_interfaces=[
    AgentInterface(url='http://agent/v03', protocol_binding='JSONRPC', protocol_version='0.3.0'),
    AgentInterface(url='http://agent/v1',  protocol_binding='JSONRPC', protocol_version='1.0.0'),
]

the 1.0.0 entry is neither equal to '1.0' nor greater than 1.0, so the loop falls through to the first >= 0.3 candidate and the client is built with CompatJsonRpcTransport against /v03, i.e. it speaks v0.3 to an agent that supports 1.0. ('1.0' and '1.1' are handled correctly.)

The specification's versioning section says patch versions "MUST not be considered when clients and servers negotiate protocol versions". Patch versions do show up in practice; several tests in this repo build cards with protocol_version='1.0.0'.

  • Compare the Major.Minor release (packaging.version.Version(...).release[:2]) instead of the raw string, via a small _major_minor helper (kept out of the loop for ruff's PERF203).
  • Regression test: with 0.3.0 listed first, both 1.0 and 1.0.0 must select the 1.0 interface and JsonRpcTransport. The 1.0.0 case fails on main with CompatJsonRpcTransport.

Checks: scripts/lint.sh clean, uv run pytest 2183 passed (plus tests/integration/cross_version 5 passed), coverage 93%.

`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.
@kosesena
kosesena requested a review from a team as a code owner October 2, 2026 17:14
@github-actions

github-actions Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

🧪 Code Coverage (vs main)

⬇️ Download Full Report

Base PR Delta
src/a2a/client/client_factory.py 88.67% 88.82% 🟢 +0.15%
src/a2a/server/cluster/database_event_stream.py 96.94% 92.86% 🔴 -4.08%
Total 93.04% 93.00% 🔴 -0.04%

Generated by coverage-comment.yml

Comment thread src/a2a/client/client_factory.py Outdated
Comment thread src/a2a/client/client_factory.py Outdated
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.
@kosesena

kosesena commented Oct 6, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review! Both done in a9e912e: _major_minor now returns (v.major, v.minor), and the >1.0 and >=0.3 fallbacks use it too, so the patch version is out of every comparison. One side effect worth knowing: a pre-release like 0.3rc1 now counts as 0.3 in the fallback, which I think matches the intent. Happy to change that if you'd rather keep pre-releases below the release.

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.

@mykytanetipa mykytanetipa left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

design comment:
I don't think we need an additional _major_minor_of helper (understand that it's done to satisfy the type checker).
Let's instead have major_minor return just tuple[int, int] with no try / except in it:

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)

This allows us to do:

v1_0 = major_minor(PROTOCOL_VERSION_1_0)
v0_3 = major_minor(PROTOCOL_VERSION_0_3)

and catch InvalidVersion in a single candidate loop (which also avoids Ruff's PERF203 and only parses each version once):

for i in candidates:
    if not i.protocol_version:
        if best_no_version is None:
            best_no_version = i
        continue
    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

Additional note: let's also rename _major_minor to major_minor.

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.
@kosesena

kosesena commented Oct 6, 2026

Copy link
Copy Markdown
Contributor Author

Done in fdcb6ab: dropped _major_minor_of, _major_minor now returns tuple[int, int] with no try/except, and a single loop parses each version once, returns on the first 1.0 match and skips InvalidVersion. Tests, ruff and ty pass locally.

@mykytanetipa mykytanetipa left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you please also rename _major_minor to major_minor, no need for "_" since this is a private function syntax

per my previous comment "Additional note: let's also rename _major_minor to major_minor."

@kosesena

kosesena commented Oct 6, 2026

Copy link
Copy Markdown
Contributor Author

Renamed to major_minor in 9e5098b, sorry I missed that note earlier.

@mykytanetipa
mykytanetipa merged commit cbb2d84 into a2aproject:main Oct 6, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants