Repository navigation
Comparing changes
Open a pull request
base repository: coder-abu/a2a-python
base: main
head repository: a2aproject/a2a-python
compare: main
- 14 commits
- 28 files changed
- 14 contributors
Commits on Nov 12, 2026
-
fix(server): fail fast when an executor produces no events (a2aprojec…
…t#1345) ## Description With an `AgentExecutor` that returns normally without enqueueing any event, `message/send` hung forever on both the request coroutine and the ActiveTask producer/consumer tasks: `result` was `None`, and `ActiveTask.get_task()` awaited `_task_created` with no timeout — but `_task_created` is only set when a task event is processed, the task pre-existed at `start()`, or the producer failed. A *silent success* path sets none of them. Root-cause analysis and a runtime reproduction are in a2aproject#1330. Per the approach endorsed on the issue, this fail-fasts with `InternalError` when the subscribe loop finishes without a task event: - New `ActiveTask.task_created_is_set()` accessor (public wrapper over the internal `_task_created` event) so the handler doesn't reach into private state. - `DefaultRequestHandlerV2.on_message_send`: raise `InternalError` when `result is None` and `task_created_is_set()` is False, before awaiting `get_task()`. Follow-up (not in this PR): the legacy handler contains an equivalent `raise InternalError` guard (`default_request_handler.py:456`) that is empirically unreachable in this scenario — the flow blocks earlier in `consume_and_break_on_interrupt`. Making that reachable is a separate piece of work. - [x] Follow the CONTRIBUTING Guide - [x] PR title follows Conventional Commits (`fix:` → SemVer patch) - [x] Tests and linter pass - [x] Appropriate docs were updated (accessor docstring states the guarantee) Fixes a2aproject#1330 🦕 ## Validation - Claim: a zero-event executor now fails fast with `InternalError` instead of hanging; an event-producing executor is unaffected. - Exact head: 1ed3680 - BEFORE: `message/send` never returns (3s+ timeout in repro, issue a2aproject#1330). AFTER: raises `InternalError('Agent execution produced no events')` immediately. - Tests: new `test_on_message_send_raises_when_agent_produces_no_events`; handler + agent_execution suites green. - Environment: Python 3.10.20, macOS. - Limits / not tested: the optional timeout/completion-signaling complement discussed on the issue — the `is_set()` guard fully covers the unreachable-wait case; happy to add on request. Prepared with AI assistance (WorkBuddy); reviewed and verified by Linux2010 --------- Signed-off-by: andy <[email protected]>
Configuration menu - View commit details
-
Copy full SHA for ab7e36f - Browse repository at this point
Copy the full SHA ab7e36fView commit details -
fix(compat): send no v0.3 configuration when the request has none (a2…
…aproject#1429) ## Problem The v0.3 REST and gRPC client transports sent an empty `SendMessageConfiguration` for a request without one, which the server reads as `blocking=false`. ## Fix `ToProto.message_send_configuration` returns `None` when there is no configuration, so the client sends none and the server keeps its blocking default, like the JSON-RPC transport. ## Tests `test_compat_rest_transport_send_message_without_configuration` and `test_compat_grpc_transport_send_message_without_configuration` fail on main. `test_compat_rest_transport_send_message_with_configuration` checks that an explicit configuration still sends `blocking: true`. 2555 passed. Fixes a2aproject#1428 --------- Co-authored-by: Mateusz Iwaniuk <[email protected]>
Configuration menu - View commit details
-
Copy full SHA for 01483cb - Browse repository at this point
Copy the full SHA 01483cbView commit details -
fix(client): percent-encode ids in REST transport paths (a2aproject#1436
) # Description A REST client that names its push notification configs, or talks to a server whose task ids are not plain UUIDs, can read or delete the wrong resource. `RestTransport` puts task ids and push config ids into the URL path as raw text, so an id with `#`, `?` or `%` changes the URL: `delete_task_push_notification_config(id='alerts#ops')` sends `DELETE .../pushNotificationConfigs/alerts` and deletes the config `alerts`. The same ids work over JSON-RPC and gRPC, which send them in the request body. This PR encodes each id with `urllib.parse.quote(value, safe='')` at the 7 path sites (`get_task`, `cancel_task`, `subscribe` and the four push config methods). The server's router decodes the segment back, so ids with `#`, `?`, `%`, spaces, unicode or `:` now reach their own resource. Ids without reserved characters produce the same URL as before. Reproducer: a real `DefaultRequestHandler` behind `create_rest_routes`, served through `httpx.ASGITransport` to a real `RestTransport`. The client creates the configs `alerts` and `alerts#ops` on one task, deletes `alerts#ops`, then calls `get_task('t1?x')`. ``` # main (28f8a62) configs before: ['alerts', 'alerts#ops'] delete('alerts#ops') -> configs after: ['alerts#ops'] get_task('t1?x') -> returned task 't1' # this branch configs before: ['alerts', 'alerts#ops'] delete('alerts#ops') -> configs after: ['alerts'] get_task('t1?x') -> TaskNotFoundError ``` ```mermaid flowchart LR I["id = 'alerts#ops'"] --> P{RestTransport path} P -->|main| R1["f'.../pushNotificationConfigs/{id}'"] R1 --> H1["httpx: '#ops' is a fragment"] H1 --> S1["DELETE .../alerts: wrong config deleted"] P -->|this PR| R2["_quote_id(id) = 'alerts%23ops'"] R2 --> H2["DELETE .../alerts%23ops"] H2 --> S2["router decodes push_id = 'alerts#ops'"] ``` The tests in `tests/client/transports/test_rest_client.py` (`TestRestTransportPathEncoding`) run the reproducer above: a real `DefaultRequestHandler` behind `create_rest_routes`, called through `httpx.ASGITransport`. One deletes `alerts#ops` and asserts that `alerts` is left. The other asserts that `get_task('t1?x')` raises `TaskNotFoundError`. ``` $ pytest tests/client/transports/test_rest_client.py -k PathEncoding # rest.py from main 2 failed, 41 deselected $ pytest tests/client/transports/test_rest_client.py -k PathEncoding # this branch 2 passed, 41 deselected ``` Run on Windows 11, Python 3.10, on b2a6aa3: `ruff check` and `ruff format --check` pass on the changed files; `pytest --ignore=tests/integration/cross_version --ignore=tests/integration/test_scenarios.py` gives 2431 passed, 207 skipped, 4 xfailed. The 4 push notification e2e tests error at setup on Windows (spawn pickling) on `main` as well. Out of scope, noted for review: - An id containing `/` is encoded as `%2F`, which Starlette decodes before routing, so it now gets a 404 instead of reaching another route. Supporting `/` in ids would need a server change. - Ids that are exactly `.` or `..` are still collapsed by httpx dot-segment handling. - The tenant prefix in `_get_path` is left as is: it is configured by the operator and is not an id. - `src/a2a/compat/v0_3/rest_transport.py` builds paths the same way; happy to follow up there. - [x] Follow the [`CONTRIBUTING` Guide](https://github.com/a2aproject/a2a-python/blob/main/CONTRIBUTING.md). - [x] Make your Pull Request title in the <https://www.conventionalcommits.org/> specification. - [x] Ensure the tests and linter pass (Run `bash scripts/format.sh` from the repository root to format) - [ ] Appropriate docs were updated (if necessary) --------- Co-authored-by: Mateusz Iwaniuk <[email protected]>
Configuration menu - View commit details
-
Copy full SHA for 1bbe69c - Browse repository at this point
Copy the full SHA 1bbe69cView commit details -
chore: switch codeowners to a narrower group (a2aproject#1452)
Use google-a2a-python rather than a very wide group google-a2a-eng. google-a2a-eng still can approve via maintain role, however only google-a2a-python will now be notified.
Configuration menu - View commit details
-
Copy full SHA for 2bddd06 - Browse repository at this point
Copy the full SHA 2bddd06View commit details -
fix(server): infer contextId from task when only taskId is provided (a…
…2aproject#1453) ## Issue A message carrying only `taskId` got a freshly generated `contextId` instead of the one from the task it names. Spec 3.4.3 requires inferring it from the task. ## Change Both handlers now take the `contextId` from the stored task when the message doesn't provide one. The existing mismatch rejection is unchanged. ## Tests Added tests for v2 (blocking + streaming) and legacy, asserting the executor and the emitted events get the task's context. Updated two existing terminal-state tests whose mocked task store wasn't returning a real task. closes a2aproject#1411
Configuration menu - View commit details
-
Copy full SHA for 2ba8e7b - Browse repository at this point
Copy the full SHA 2ba8e7bView commit details
Commits on Nov 13, 2026
-
fix: reject decoded values that are not objects in parse_lenient (a2a…
…project#1431) ## Problem `parse_lenient` calls `ParseDict(..., ignore_unknown_fields=True)`. `ParseDict` iterates the decoded value as if it were an object's keys. So when a decoded value is not a `dict`: - `[]` parses as an empty message - `None`, a number or a boolean raises a bare `TypeError` (`'NoneType' object is not iterable`) The REST and JSON-RPC client transports pass decoded response JSON to `parse_lenient`. Here is `RestTransport.get_task` against a server that answers `200` with each body, on main: ``` client get_task, 200 []: returned Task(id='') client get_task, 200 null: TypeError: 'NoneType' object is not iterable ``` The caller gets an empty `Task` with no error. The JSON-RPC transport does the same with a `"result"` of `[]` or `null`. ## Fix `parse_lenient` raises `ParseError` when a decoded value is not a `dict`. Raw text and bytes still go straight to `Parse`, unchanged. With the fix: ``` client get_task, 200 []: ParseError: Expected a JSON object for Task, got list. client get_task, 200 null: ParseError: Expected a JSON object for Task, got NoneType. ``` This matches `test_malformed_known_field_still_raises` from a2aproject#1354: unknown fields are ignored, but a malformed payload raises `ParseError`. ## Tests - `tests/utils/test_proto_utils.py`: decoded values `[]`, `None`, `0`, `True` raise `ParseError`. A separate test checks that a `str` goes to the raw-text branch (`'id'` fails JSON decoding there, not the object check). - `tests/client/transports/test_rest_client.py`: `get_task` with a body of `[]` or `null`. - `tests/client/transports/test_jsonrpc_client.py`: `get_task` with a `result` of `[]` or `null`. The 8 new `ParseError` cases fail on main. Two existing REST tests, `test_send_message_with_timeout_context` and `test_send_message_with_default_extensions`, never set a response body. The mocked `json()` returned a `MagicMock`, which only parsed because of this bug, so they now return `{}`. - `bash scripts/lint.sh`: success. The 3 ruff diagnostics it prints are in `client_factory.py` and `transports/__init__.py`, which this change does not touch. - `uv run pytest` on Windows, Python 3.10, skipping `tests/integration/cross_version` and `tests/integration/push_notifications`, which do not run on Windows (`os.killpg`, spawn-mode multiprocessing): 2557 passed, 207 skipped, 4 xfailed, 1 xpassed 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <[email protected]> Co-authored-by: Mateusz Iwaniuk <[email protected]>
Configuration menu - View commit details
-
Copy full SHA for 257626b - Browse repository at this point
Copy the full SHA 257626bView commit details -
fix(compat): serve v0.3 gRPC agent cards with a device code OAuth flow (
a2aproject#1434) # Description The v0.3 gRPC `GetAgentCard` fails for an agent card that has an OAuth2 scheme with a device code flow. v1.0 added `DeviceCodeOAuthFlow`, and v0.3 has no equivalent, so `to_compat_oauth_flows` leaves the v0.3 `OAuthFlows` with no flow set. `ToProto.oauth2_flows` raises `ValueError('Unknown oauth flow definition')` for that. `CompatGrpcHandler._handle_unary` only catches `A2AError`, so the call fails with `UNKNOWN`: ```python from a2a.compat.v0_3.conversions import to_compat_agent_card from a2a.compat.v0_3.proto_utils import ToProto from a2a.types.a2a_pb2 import ( AgentCard, AgentInterface, DeviceCodeOAuthFlow, OAuth2SecurityScheme, OAuthFlows, SecurityScheme, ) card = AgentCard( name='a', description='d', version='1', supported_interfaces=[AgentInterface( url='http://x', protocol_binding='GRPC', protocol_version='0.3')], security_schemes={'device': SecurityScheme( oauth2_security_scheme=OAuth2SecurityScheme(flows=OAuthFlows( device_code=DeviceCodeOAuthFlow( device_authorization_url='https://a/device', token_url='https://a/token'))))}, ) ToProto.agent_card(to_compat_agent_card(card)) # ValueError: Unknown oauth flow definition ``` The v0.3 JSON-RPC and REST bindings serve the same scheme as `"flows": {}`, and the public agent card does too. ## Fix - `ToProto.oauth2_flows` returns an empty `OAuthFlows` when no flow is set, instead of raising. gRPC clients then get the same scheme the JSON bindings already send. - `FromProto.oauth2_flows` returned a password flow with empty URLs for an `OAuthFlows` with no flow set, because password was its fallback branch. It now checks for `password` and returns an empty `OAuthFlows` otherwise, so the v0.3 gRPC client does not read the empty flows as a password flow. ## Tests - `test_get_agent_card_with_device_code_oauth_flow` (`test_grpc_handler.py`) serves a card with a device code scheme through `CompatGrpcHandler.GetAgentCard`. On main it fails with the `ValueError` above. With the fix the scheme comes back with empty flows. - `test_oauth_flows_conversion` asserted that `ToProto.oauth2_flows(OAuthFlows())` raises `ValueError`. It now checks that the empty flows convert to an empty proto and back. The `FromProto` half fails on main, where it returns a password flow. - `uv run pytest tests/compat/v0_3`: 324 passed - `bash scripts/lint.sh`: success. The 3 ruff diagnostics it prints are in `client_factory.py` and `transports/__init__.py`, which this change does not touch. - `uv run pytest` on Windows, Python 3.11, skipping `tests/integration/cross_version` and `tests/integration/push_notifications` because they need `os.killpg` and fork-based multiprocessing: 2549 passed, 207 skipped, 4 xfailed, 1 xpassed (with `PYTHONUTF8=1`, which `test_samples_smoke.py` needs on a cp1252 console) - [x] Follow the [`CONTRIBUTING` Guide](https://github.com/a2aproject/a2a-python/blob/main/CONTRIBUTING.md). - [x] Make your Pull Request title in the <https://www.conventionalcommits.org/> specification. - [x] Ensure the tests and linter pass (Run `bash scripts/format.sh` from the repository root to format) - [x] Appropriate docs were updated (if necessary) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <[email protected]> Co-authored-by: Mateusz Iwaniuk <[email protected]>
Configuration menu - View commit details
-
Copy full SHA for 2ccef0d - Browse repository at this point
Copy the full SHA 2ccef0dView commit details -
fix(compat): serve DELETE for v0.3 REST push notification configs (a2…
…aproject#1442) # Description `REST03Adapter` mounts `GET`/`POST /v1/tasks/{id}/pushNotificationConfigs` and `GET /v1/tasks/{id}/pushNotificationConfigs/{push_id}`, but no `DELETE`. The v0.3 binding defines `DELETE /v1/tasks/{id}/pushNotificationConfigs/{configId}` (spec section 3.5.6, and the `google.api.http` option on `DeleteTaskPushNotificationConfig` in `a2a_v0_3.proto`), and the v0.3 JSON-RPC and gRPC adapters already serve the call. Without the route the request falls through to the v1.0 `/{tenant}` mount with tenant `v1`, so a v0.3 client gets a 400 with reason `VERSION_NOT_SUPPORTED` ("A2A version '0.3' is not supported by this handler. Expected version '1.0'."). To reproduce on main: serve a `DefaultRequestHandler` through `create_rest_routes(..., enable_v0_3_compat=True)`, create a push config over v0.3 REST, then send `DELETE /v1/tasks/{task_id}/pushNotificationConfigs/{config_id}`. It returns 400. The same delete over v0.3 JSON-RPC (`tasks/pushNotificationConfig/delete`) and gRPC (`DeleteTaskPushNotificationConfig`) succeeds. This change adds `REST03Handler.delete_push_notification`, which builds a v0.3 `DeleteTaskPushNotificationConfigRequest` from the path and calls the v1.0 handler through `RequestHandler03`, and mounts it. It returns `{}`, the JSON form of `google.protobuf.Empty`, as the v1.0 REST dispatcher does. `CompatRestTransport.delete_task_push_notification_config` raised `NotImplementedError`, which a2aproject#1424 left in place because the server had no route. It now sends the same `DELETE`, so the v0.3 REST client can delete a config too (added after review). Tests: `test_delete_push_notification` (handler) and `test_delete_push_notification_config_v03` (route) fail on main and pass with this change. After review: `test_compat_rest_transport_delete_task_push_notification_config` now checks the request the client sends instead of expecting `NotImplementedError`, `test_compat_rest_client_deletes_push_notification_config` sends the delete from the client through the real v0.3 REST routes, and `test_delete_push_notification_config_v03_unknown_task_returns_404` covers `TaskNotFoundError`. The client tests fail with the old client code. - `uv run pytest tests/compat/v0_3/ -k "delete_push_notification or deletes_push_notification"`: 8 failed with the three compat source files from main, 8 passed with this change - `uv run pytest --cov=a2a --cov-fail-under=88` after merging current main: 2576 passed, 207 skipped, 4 xfailed, 1 xpassed; coverage 93.57%. In one of three runs `test_scenario_auth_required_side_channel[blocking-legacy]` failed and then passed on rerun; it does not touch this code. - `tests/integration/cross_version/client_server/test_client_server.py` (against `a2a-sdk` 0.3.24): 4 passed - `uv run ruff check` and `uv run ruff format --check`: clean; `uv run ty check`: no errors (3 warnings, all in files this PR does not touch) Worth checking: v0.3 routes are registered before the `/{tenant}` mount, so a v1.0 tenant literally named `v1` deleting at this path now reaches the v0.3 handler. That is already true for the existing v0.3 GET on the same path. Deleting an unknown config id returns 200 `{}`, as it does on the other transports. - [x] Follow the CONTRIBUTING Guide. - [x] Make your Pull Request title in the conventionalcommits.org specification. - [x] Ensure the tests and linter pass - [ ] Appropriate docs were updated (if necessary) Fixes a2aproject#1441 --------- Co-authored-by: YusefSyed <[email protected]> Co-authored-by: Mateusz Iwaniuk <[email protected]>
Configuration menu - View commit details
-
Copy full SHA for 14e799c - Browse repository at this point
Copy the full SHA 14e799cView commit details -
fix(server): reject out-of-range ListTasks cursor timestamps (a2aproj…
…ect#1451) # Description Fixes a2aproject#1450. `ListTasks` with `DatabaseTaskStore` returns HTTP 500 when a structurally valid page token contains an out-of-range timestamp. Protobuf 7.36.2 raises `ValueError` from `Timestamp.FromNanoseconds()`, while `_after_cursor` only converts `OverflowError` into `InvalidParamsError`. Handle both exceptions so these tokens use the existing HTTP 400 / `INVALID_ARGUMENT` / `Invalid page token` response. The production change is one exception clause. Add four regression cases covering values immediately outside the timestamp range and very large positive/negative integers. ## Verification Tested source `main` at `2cc1dd5`, Python 3.12.16, Windows, Protobuf 7.36.2, and SQLite/aiosqlite. Final checks used the exact dependencies exported from `uv.lock` with hash verification. - Before the fix, all four new SQLite cases failed with `ValueError`; after the fix they pass. PostgreSQL/MySQL variants skip because their DSNs are unset. - Exercised real Starlette REST routes, `DefaultRequestHandlerV2`, and an in-memory SQLite `DatabaseTaskStore` via `httpx.ASGITransport`, without mocking the conversion or store: three invalid timestamps changed from HTTP 500 to HTTP 400; `ts=null` and `ts=0` remained HTTP 200. No provider requests. - `scripts/lint.sh`: passed Ruff check/format and ty. ty reports three existing unused-ignore warnings in untouched client files; no type errors. - The suite with the two Windows-incompatible integration modules below excluded: **2,556 passed, 215 skipped, 4 xfailed, 1 xpassed**. - The same suite with `--cov=src --cov-report=term-missing --cov-fail-under=88`: **93.57%** coverage, same test counts. - `git diff --check`: passed. The passing suite command (with UTF-8 enabled for sample CLI subprocesses): ```sh PYTHONUTF8=1 uv run pytest --timeout=45 -q \ --ignore=tests/integration/cross_version/client_server/test_client_server.py \ --ignore=tests/integration/push_notifications/test_default_push_notification_support.py ``` ### Full-suite limitations I also ran the unfiltered suite; it was **not green on this Windows host**. Its initial result was 2,553 passed, 215 skipped, 4 xfailed, 1 xpassed, 3 failed, and 8 errors. The three sample CLI failures were GBK console-encoding errors and passed with `PYTHONUTF8=1`. The two excluded modules fail on the unchanged production source too (verified by restoring the original exception clause and rerunning them): the cross-version harness uses POSIX `os.killpg` and `:`-joined `PYTHONPATH` and has short process startup/client timeouts; the push-notification harness cannot pickle a locally created FastAPI application under Windows spawn (`FastAPI.setup.<locals>.openapi`). I left these unrelated harnesses unchanged. Linux, PostgreSQL, and MySQL runs have not been performed locally. ## Checklist - [x] Followed `CONTRIBUTING.md` and read the repository's AI workflow documents. - [x] Conventional Commit title and focused bug fix with regression coverage. - [x] Ran the lint script, unfiltered tests, and the passing suite/coverage commands described above. - [x] No documentation update needed: no public API or schema change. AI disclosure: Codex investigated the code, reproduced the issue, prepared and reviewed this patch, and ran the checks under the account holder's instruction. Independent human review or reproduction is not claimed. Co-authored-by: Mateusz Iwaniuk <[email protected]>
Configuration menu - View commit details
-
Copy full SHA for f451ed7 - Browse repository at this point
Copy the full SHA f451ed7View commit details -
fix(compat): send v0.3 REST request bodies with json_name keys (a2apr…
…oject#1455) Fixes a2aproject#1454 Drops `preserving_proto_field_name=True` from the three `MessageToDict` calls (send, stream, push config create), so the body uses `json_name` keys like the spec requires. Against a2a-go's v0.3 server, a send went from `400 "message is required"` to `200` with the agent's reply. The three new tests in `test_rest_transport.py` fail on main. The cross-version tests against a2a-sdk 0.3.24 still pass. Co-authored-by: Mateusz Iwaniuk <[email protected]>
Configuration menu - View commit details
-
Copy full SHA for 2399993 - Browse repository at this point
Copy the full SHA 2399993View commit details -
fix(compat): percent-encode ids in v0.3 REST transport paths (a2aproj…
…ect#1458) # Description `CompatRestTransport` (the v0.3 REST client) put task ids and push notification config ids into URL paths as raw text. An id containing `#`, `?` or `%` then reached a different resource: getting the push config `alerts#ops` returned the config `alerts`, and `get_task('t1?x')` returned task `t1`. a2aproject#1436 fixed this for `RestTransport`; the v0.3 client was not covered. This PR encodes each id with `urllib.parse.quote(value, safe='')`, the same helper `RestTransport` uses, at the six path sites (`get_task`, `cancel_task`, `subscribe`, and push config create, get and list). Ids without reserved characters produce the same URL as before. Tests: `TestCompatRestTransportPathEncoding` in `tests/compat/v0_3/test_rest_transport.py` runs a real `DefaultRequestHandler` behind `create_rest_routes(..., enable_v0_3_compat=True)` through `httpx.ASGITransport`. One test creates `alerts` and `alerts#ops` and checks that getting `alerts#ops` returns that config. The other checks that `get_task('t1?x')` does not return task `t1`. ``` $ pytest tests/compat/v0_3/test_rest_transport.py -k PathEncoding # rest_transport.py from main 2 failed, 35 deselected $ pytest tests/compat/v0_3/test_rest_transport.py -k PathEncoding # this branch 2 passed, 35 deselected $ pytest tests/compat tests/client 595 passed ``` `ruff check`, `ruff format` and `ty check` pass on the changed files. This change was written with Claude Code. - [x] Follow the [`CONTRIBUTING` Guide](https://github.com/a2aproject/a2a-python/blob/main/CONTRIBUTING.md). - [x] Make your Pull Request title in the <https://www.conventionalcommits.org/> specification. - [x] Ensure the tests and linter pass (Run `bash scripts/format.sh` from the repository root to format) - [x] Appropriate docs were updated (if necessary) Fixes a2aproject#1457 🦕 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <[email protected]> Co-authored-by: Mateusz Iwaniuk <[email protected]>
Configuration menu - View commit details
-
Copy full SHA for 1ff86d5 - Browse repository at this point
Copy the full SHA 1ff86d5View commit details -
fix(client): split SSE lines only on CR, LF and CRLF (a2aproject#1456)
# Description `parse_sse_stream` read the stream with `response.aiter_lines()`, which follows `str.splitlines()` and also ends a line at U+2028, U+2029 and U+0085. The server writes these characters raw inside one `data:` line, so the client cut the event short and the payload failed to parse. As suggested in a2aproject#1432, the fix is in `parse_sse_stream`. Lines now come from `aiter_text()` and end only at CRLF, LF or CR, as the [SSE specification](https://html.spec.whatwg.org/multipage/server-sent-events.html#parsing-an-event-stream) requires. A CR at the end of a chunk is held back, so a CRLF split across two chunks still ends one line. Tests: - `test_parse_sse_stream_ends_lines_only_at_cr_or_lf` feeds real `httpx.Response` byte chunks through the parser: - the three separators inside `data:` - a CRLF and a U+2028 byte sequence each split across chunks - an event with no closing blank line, which is still not dispatched - Transport tests that mocked `aiter_lines()` now mock `aiter_text()`. `./scripts/lint.sh` and `uv run pytest` pass. Coverage on `http_helpers.py` is 93%. - [x] Follow the [`CONTRIBUTING` Guide](https://github.com/a2aproject/a2a-python/blob/main/CONTRIBUTING.md). - [x] Make your Pull Request title in the <https://www.conventionalcommits.org/> specification. - [x] Ensure the tests and linter pass (Run `bash scripts/format.sh` from the repository root to format) - [x] Appropriate docs were updated (if necessary) Fixes a2aproject#1432 🦕 --------- Co-authored-by: Claude Opus 5.5 <[email protected]> Co-authored-by: Mateusz Iwaniuk <[email protected]>
Configuration menu - View commit details
-
Copy full SHA for c8ccea9 - Browse repository at this point
Copy the full SHA c8ccea9View commit details -
fix(client): raise stream errors sent as plain SSE data events (a2apr…
…oject#1444) # Description `RestTransport` only treats an SSE event named `error` as an error. A stream error sent as an ordinary `data:` event, with the `google.rpc.Status` body from section 11.6, goes to `parse_lenient`. That drops the unknown `error` key, so the caller gets an empty `StreamResponse` and no exception. Before a2aproject#1354 the strict `Parse` at least raised a `ParseError`. Section 11.7 only says the `data` field carries a `StreamResponse` and does not name an event type for errors, so SDK servers differ: - a2a-js and this SDK send `event: error`. - a2a-go sends a plain `data:` event, in [`handleStreamingRequest`](https://github.com/a2aproject/a2a-go/blob/ea739de3859f0574175bc9876eda39b7120da8e3/a2asrv/rest.go#L332-L347) through `sseWriter.WriteData`. It does this for errors in the middle of a stream, and for early ones too when the request's `Accept` header lists no JSON type. The Python client always sends `Accept: text/event-stream`. a2a-go's own client reads the error from the `error` key of the data object ([`ParseStreamResponse`](https://github.com/a2aproject/a2a-go/blob/ea739de3859f0574175bc9876eda39b7120da8e3/internal/rest/rest.go#L375-L400)). So a Python client streaming from an a2a-go agent reads a `TaskNotFoundError`, or any other mid-stream failure, as an empty event and keeps going. When a data event parses to no payload, `RestTransport` now checks it for an `error` key and raises through the same `_handle_sse_error` that handles `event: error`. `JsonRpcTransport` already raises for in-band errors the same way. Normal events are not decoded a second time. An event with no known payload and no `error` key is still yielded as before, so the forward compatibility from a2aproject#1354 stays. The repro at the end of this description (a mock server sending one plain `data:` error event), on main (2cc1dd5): ``` event: None 0 stream ended without an error ``` On this branch: ``` raised TaskNotFoundError: task not found: t1 ``` ``` $ pytest tests/client/transports/test_rest_client.py # main with the new tests FAILED tests/client/transports/test_rest_client.py::TestRestStreamErrors::test_stream_error_raises_mapped_error[data_event] 1 failed, 43 passed $ pytest --ignore=tests/integration/cross_version # this branch, Python 3.10, locked deps 2558 passed, 207 skipped, 4 xfailed, 1 xpassed $ pytest --cov=src src/a2a/client/transports/rest.py 87% (the new lines are covered) TOTAL 94% ``` The `error_event` case and the unknown-event case pass on main as well. They pin the existing `event: error` path, which had no REST client test, and the lenient handling of unknown events. The cross-version tests are left out because their servers don't start within the 3 s timeout on my machine; the same happens on main. `ruff check`, `ruff format` and `ty check` pass, with only the three existing unused `type: ignore` warnings from `ty`. - [x] Follow the [`CONTRIBUTING` Guide](https://github.com/a2aproject/a2a-python/blob/main/CONTRIBUTING.md). - [x] Make your Pull Request title in the <https://www.conventionalcommits.org/> specification. - [x] Ensure the tests and linter pass (Run `bash scripts/format.sh` from the repository root to format) - [x] Appropriate docs were updated (if necessary) No issue exists, since the new test reproduces it. <details><summary>Repro script</summary> ```python import asyncio import json import httpx from a2a.client.transports.rest import RestTransport from a2a.types.a2a_pb2 import AgentCard, SubscribeToTaskRequest # The google.rpc.Status body a2a-go writes as a plain `data:` event. ERROR = { 'error': { 'code': 404, 'status': 'NOT_FOUND', 'message': 'task not found: t1', 'details': [ { '@type': 'type.googleapis.com/google.rpc.ErrorInfo', 'reason': 'TASK_NOT_FOUND', 'domain': 'a2a-protocol.org', } ], } } def handler(request: httpx.Request) -> httpx.Response: return httpx.Response( 200, headers={'content-type': 'text/event-stream'}, content=f'id: 1\ndata: {json.dumps(ERROR)}\n\n'.encode(), ) async def main() -> None: async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http: transport = RestTransport(http, AgentCard(), 'http://agent.example') try: async for event in transport.subscribe(SubscribeToTaskRequest(id='t1')): print('event:', event.WhichOneof('payload'), event.ByteSize()) print('stream ended without an error') except Exception as e: print(f'raised {type(e).__name__}: {e}') asyncio.run(main()) ``` </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <[email protected]> Co-authored-by: Mykyta Netipa <[email protected]>
Configuration menu - View commit details
-
Copy full SHA for 8c48ad3 - Browse repository at this point
Copy the full SHA 8c48ad3View commit details -
fix(client): accept an empty body when deleting a push notification c…
…onfig (a2aproject#1445) # Description `RestTransport.delete_task_push_notification_config` goes through `_execute_request`, which always calls `response.json()`. The spec leaves the result to the server ([3.1.10](https://a2a-protocol.org/latest/specification/#3110-delete-push-notification-config): "Confirmation of deletion (implementation-specific)"), and the SDKs answer differently: - this SDK's server answers 200 with `{}`; - a2a-js answers 204 No Content ([`rest_handler.ts`](https://github.com/a2aproject/a2a-js/blob/3d3ca8291e1f6b1f53293f61c12ba46c36670000/src/server/express/rest_handler.ts#L548-L559)); - a2a-go answers 200 with no body ([`handleDeleteTaskPushConfig`](https://github.com/a2aproject/a2a-go/blob/ea739de3859f0574175bc9876eda39b7120da8e3/a2asrv/rest.go#L536-L557)). Against a2a-js or a2a-go servers, the config is deleted, but the Python client then raises `A2AClientError: JSON Decode Error`. A caller that retries or reports the failure is acting on a delete that succeeded. The delete now sends its request, checks the status, and does not read the body. Error responses still go through `_handle_http_error`, so a 404 with `TASK_NOT_FOUND` still raises `TaskNotFoundError`. `send_http_request` and the JSON-RPC transport are unchanged. The repro at the end of this description answers the delete the way each server does. On main (2cc1dd5): ``` a2a-python 200 b'{}' -> ok a2a-js 204 b'' -> A2AClientError: JSON Decode Error: Expecting value: line 1 column 1 (char 0) a2a-go 200 b'' -> A2AClientError: JSON Decode Error: Expecting value: line 1 column 1 (char 0) ``` On this branch, all three print `ok`. ``` $ pytest tests/client/transports/test_rest_client.py # src change reverted FAILED ...::TestTaskCallback::test_delete_task_push_notification_config_ignores_body[no_content] FAILED ...::TestTaskCallback::test_delete_task_push_notification_config_ignores_body[empty_200] 2 failed, 44 passed $ pytest --ignore=tests/integration/cross_version # this branch, Python 3.10, locked deps 2560 passed, 207 skipped, 4 xfailed, 1 xpassed $ pytest --cov=src src/a2a/client/transports/rest.py 87% (the new lines are covered) TOTAL 94% ``` The new tests also cover a mapped error and a closed client on this path. I left the cross-version tests out because their servers don't start within the 3 s timeout on my machine; the same happens on main. `ruff check`, `ruff format` and `ty check` pass. `ty` prints only the three existing unused `type: ignore` warnings. a2aproject#1439 touches `send_http_request` and the transport type hints. This change stays inside `delete_task_push_notification_config` to keep out of its way, apart from one added import. a2aproject#1436 percent-encodes the ids in this method's path, so whichever of the two lands second has a one-line conflict there; I'll rebase this one if a2aproject#1436 goes first. - [x] Follow the [`CONTRIBUTING` Guide](https://github.com/a2aproject/a2a-python/blob/main/CONTRIBUTING.md). - [x] Make your Pull Request title in the <https://www.conventionalcommits.org/> specification. - [x] Ensure the tests and linter pass (Run `bash scripts/format.sh` from the repository root to format) - [x] Appropriate docs were updated (if necessary) No issue exists, since the new tests reproduce it. <details><summary>Repro script</summary> ```python import asyncio import httpx from a2a.client.transports.rest import RestTransport from a2a.types.a2a_pb2 import AgentCard, DeleteTaskPushNotificationConfigRequest # How each SDK's server confirms DELETE /tasks/{id}/pushNotificationConfigs/{configId}. SERVERS = {'a2a-python': (200, b'{}'), 'a2a-js': (204, b''), 'a2a-go': (200, b'')} async def main() -> None: for name, (status, body) in SERVERS.items(): transport = httpx.MockTransport(lambda r, s=status, b=body: httpx.Response(s, content=b)) async with httpx.AsyncClient(transport=transport) as http: client = RestTransport(http, AgentCard(), 'http://agent.example') try: await client.delete_task_push_notification_config( DeleteTaskPushNotificationConfigRequest(task_id='t1', id='cfg1') ) print(f'{name:10} {status} {body!r:5} -> ok') except Exception as e: print(f'{name:10} {status} {body!r:5} -> {type(e).__name__}: {e}') asyncio.run(main()) ``` </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <[email protected]> Co-authored-by: mykytanetipa <[email protected]>
Configuration menu - View commit details
-
Copy full SHA for 09c9347 - Browse repository at this point
Copy the full SHA 09c9347View commit details
This comparison is taking too long to generate.
Unfortunately it looks like we can’t render this comparison for you right now. It might be too big, or there might be something weird with your repository.
You can try running this command locally to see the comparison on your machine:
git diff main...main