Repository navigation
fix(client): ignore the patch version when preferring a 1.0 interface - #1304
Conversation
`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.
🧪 Code Coverage (vs
|
| 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
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.
|
Thanks for the review! Both done in a9e912e: |
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.
There was a problem hiding this comment.
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:
passAdditional 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.
|
Done in fdcb6ab: dropped |
Address review.
|
Renamed to |
Description
ClientFactory._find_best_interfaceprefers 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.the
1.0.0entry is neither equal to'1.0'nor greater than1.0, so the loop falls through to the first>= 0.3candidate and the client is built withCompatJsonRpcTransportagainst/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'.Major.Minorrelease (packaging.version.Version(...).release[:2]) instead of the raw string, via a small_major_minorhelper (kept out of the loop for ruff's PERF203).0.3.0listed first, both1.0and1.0.0must select the 1.0 interface andJsonRpcTransport. The1.0.0case fails on main withCompatJsonRpcTransport.Checks:
scripts/lint.shclean,uv run pytest2183 passed (plustests/integration/cross_version5 passed), coverage 93%.CONTRIBUTINGGuide.