| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
fix(server): let subscriber taps evict on full instead of wedging dis…
…patch (a2aproject#1137) Fixes a2aproject#1136 (the runtime issue split out of a2aproject#1101). ### Problem One undrained sink stalls `EventQueueSource._dispatch_loop` for every sink. The dispatcher's asyncio.gather awaits a blocking put on each sink, so a single full queue (default cap 1024) whose consumer went away blocks the gather forever. The incoming queue then fills, producers wedge in enqueue_event, and the task's event flow never recovers; see the task dumps and repro in a2aproject#1101. ### Change: split the sink semantics The two kinds of sink want different fullness behavior. The default sink is flow control. Its consumer drives task state, so a full queue that back-pressures the dispatcher (and, transitively, the producer) is correct, and it stays exactly as it is. A slow task store slows the pipeline down rather than losing events. Tapped subscriber sinks are broadcast observers. Their consumers are remote and can be abandoned, most simply by a non-blocking message/send whose HTTP response has already returned. So `tap()` gains `evict_on_full: bool = False`. When an evict-on-full sink is full at delivery time, the dispatcher force-closes it (close(immediate=True), warning logged) and it detaches through the existing remove_sink path; dispatch to the remaining sinks continues, and any producer parked on the incoming queue recovers. The consumer of an evicted sink sees `QueueShutDown` on its next dequeue, same as any closed queue. There is no new exception type and no API surface beyond the keyword. The test is fullness at delivery time and nothing else. An abandoned consumer is the case that motivated this, but a consumer that is alive and reading and has simply fallen `max_queue_size` events behind hits the same predicate and is evicted the same way. At the production default that is 1024 events behind, and at max_queue_size=2 a consumer sleeping five milliseconds between reads is evicted after draining one event. The warning text and the `_deliver_to_sink` docstring currently say the consumer "is not draining it" and call it an abandoned subscriber, which claims something the check never tested, and a reword is coming that names the queue bound instead and makes no claim about the consumer. A sink that already closed since the dispatch snapshot was taken is skipped before the fullness check, so a graceful close(immediate=False) whose consumer is still draining is never upgraded to immediate=True and keeps its trailing events. The full() pre-check is race-free: the dispatcher is the only writer to a sink queue and consumers only read, so a queue observed non-full cannot become full before the put. `tap()` defaults to False, so the documented blocking contract is unchanged for existing callers. ActiveTask's subscriber tap opts in explicitly, and that is the one production site that wedges, so the fix reaches existing deployments without changing the default of the public primitive. ### Tests Five tests. test_evict_on_full_sink_is_evicted_and_dispatch_continues puts a full capacity-1 evict sink through dispatch and asserts it is closed and detached while the default sink still receives every event. test_producer_unblocked_after_evict_on_full is the a2aproject#1101 runtime shape in miniature: producers blocked on a full incoming queue recover once the sink is evicted. test_default_tap_keeps_backpressure pins the existing flow-control contract, a default tap still blocking dispatch when full. test_graceful_close_not_upgraded_to_immediate_by_eviction covers the skip above, a gracefully closing full sink keeping its trailing events instead of being force-closed. test_subscriber_taps_are_evict_on_full asserts that ActiveTask.subscribe taps its subscriber sinks with the keyword set. The eviction and producer-unblock tests fail without the fix. Full suite: 1761 passed, 90 skipped, 3 xfailed, 1 xpassed. ### Out of scope The deeper root cause is subscriber-sink lifecycle: a sink tapped for a consumer that has gone away should be closed by its owner at connection teardown, not discovered full later. That needs its own look at the request-handler layer. This PR keeps the dispatch layer from letting any such sink take the task down in the meantime. Edited 2026-08-20 to say what the eviction check actually tests. The description read as though only an abandoned consumer is evicted; the predicate is queue fullness, so a live consumer more than `max_queue_size` events behind is evicted too, and the warning wording that claims otherwise is being reworded. --------- Co-authored-by: mykytanetipa <mykytanetipa@google.com>
fix: owner-scope cancel/subscribe and write terminal state on cancel (a…
…2aproject#1159, a2aproject#1170) (a2aproject#1172) ## Summary Two related fixes to `DefaultRequestHandlerV2`, shipped together because the first is a prerequisite for the second to be safe. **Fixes a2aproject#1159 (owner-scoping, the security base).** `on_cancel_task` and `on_subscribe_to_task` resolved a live task via `ActiveTaskRegistry.get_or_create(task_id)` by `task_id` alone, skipping the owner-aware `task_store.get(task_id, context)` that `on_get_task` and the send path already perform. A caller who knows another tenant's `task_id` could subscribe to its live stream and cancel it (CWE-639). Both handlers now consult the owner-aware store first and fail closed with `TaskNotFoundError` (masking existence, matching `on_get_task`). **Fixes a2aproject#1170 (cancel writes a terminal state).** `ActiveTask.cancel` cancelled the producer before `AgentExecutor.cancel`, so the component that owns the terminal state could not write it; and a task parked non-terminal (e.g. `input-required`) reported cancel-success without transitioning. The executor cancel now runs before the producer cancel (producer still cancelled on the error path), and cancellation that leaves the task non-terminal is closed out as `CANCELED`. ## Why one PR a2aproject#1170 alone makes a2aproject#1159 worse: for a parked task, a cross-tenant cancel via the a2aproject#1159 bypass changes from a harmless no-op into an actual cross-tenant `CANCELED` write. Landing a2aproject#1159's owner check together (as the base commit) removes that window. Merging them together, or a2aproject#1159 before a2aproject#1170, is safe; a2aproject#1170 must not land first. ## Tests - Owner-scope regressions: non-owner cancel and non-owner subscribe are rejected (`TaskNotFoundError`), including a parked task; the owner still succeeds. - Cancel-terminal regressions: mid-run cancel reaches a terminal state; cancel of a parked task does not silently succeed (the maintainer repros from a2aproject#1171, converted from xfail to passing). ## Validation - Owner-scope tests: reverting the a2aproject#1159 handler change fails all three; restore passes. Full request-handler module: 62 passed. - Cancel-terminal tests: reverting the a2aproject#1170 change fails both scenario_19 tests; restore passes. Full integration module: no regression. - Combined: with both applied, a cross-tenant cancel of a parked task is rejected rather than writing `CANCELED`. - `ruff` clean at the pinned version. --------- Signed-off-by: AlgoVoi <chopmob@gmail.com> Co-authored-by: Josh Nichols <joshua.nichols@gmail.com>
fix(server): warn when queue_manager is ignored in DefaultRequestHand…
…lerV2 (a2aproject#1153) ## Summary Fixes a2aproject#1135. `DefaultRequestHandlerV2.__init__` accepts a `queue_manager` argument (kept for signature compatibility with `DefaultRequestHandler`) but never stores or uses it. As @rohityan confirmed on the issue, v2 delegates event streaming to an in-memory `ActiveTaskRegistry`, so any custom or distributed `QueueManager` — e.g. a Redis-backed one used for multi-replica stream reconnection — is silently dropped. After upgrading, multi-replica streaming breaks with no error or warning. ## Fix Emit a `logger.warning` at construction when a non-`None` `queue_manager` is passed, explaining that it is ignored in v2 and pointing at the documented workarounds (switch to `LegacyRequestHandler`, or route `/tasks/{id}:subscribe` to the replica holding the `ActiveTask`). This turns a silent misconfiguration into a visible one without changing v2's architecture. Also corrected the now-inaccurate inline comment on the parameter. ## Evidence - The parameter is dropped: `default_request_handler_v2.py` `__init__` binds `agent_executor`/`task_store`/etc. but has no `self._queue_manager = queue_manager` (contrast `default_request_handler.py:123`, which does store it and uses it at lines 209/313/503/611). - v2 uses the registry instead: `self._active_task_registry = ActiveTaskRegistry(...)`. ## Testing Added two tests to `tests/server/request_handlers/test_default_request_handler_v2.py`: - `test_init_warns_when_queue_manager_passed` — asserts a WARNING mentioning `queue_manager` is logged (fails on `main`, passes with the fix). - `test_init_no_warning_without_queue_manager` — asserts no such warning in the default case. ``` uv run pytest tests/server/request_handlers/ # 195 passed ``` `ruff check`, `ruff format --check`, and `ty check` all clean on the changed files. This change was developed with AI assistance; I verified the root cause and behavior against the code paths cited above, reviewed every line, and ran the tests. --------- Co-authored-by: mykytanetipa <mykytanetipa@google.com>
fix: prevent first-owner write loss in in-memory stores (a2aproject#1194
) ## Summary `_InMemoryTaskStoreImpl.save` and `InMemoryPushNotificationConfigStore.set_info` created owner buckets before acquiring their existing `RLock`. Two threads could both observe a missing owner, create separate buckets, and overwrite the bucket containing the first completed write. This moves owner-bucket creation inside the lock with `setdefault` in both stores. Threaded regression tests cover concurrent first writes and confirm that both records remain available through the public retrieval methods. ## Testing `uv run pytest tests/server/tasks/test_inmemory_task_store.py tests/server/tasks/test_inmemory_push_notifications.py -q` Result: 40 passed. Signed-off-by: Anas Khan <83116240+anxkhn@users.noreply.github.com> Co-authored-by: mykytanetipa <mykytanetipa@google.com>
fix(signing): canonicalize agent cards per RFC 8785 (a2aproject#1193)
Fixes a2aproject#1174. The `_canonicalize_agent_card` function builds the bytes that a signature covers, so whatever it produces is what a verifier in another SDK has to reproduce exactly. Today it ends in one call: ``` json.dumps(cleaned_dict, separators=(',', ':'), sort_keys=True) ``` That does not produce RFC 8785 on any of the three axes the scheme exists to fix, and I ran each one against the code as it stands before changing it rather than reasoning about it. A card named Café Agent serializes its name as `{"name":"Caf\u00e9 Agent"}`, where JCS section 3.2.2.2 asks for the literal UTF-8 bytes `{"name":"Café Agent"}` and escapes only the C0 range, the quote and the backslash. sort_keys=True orders keys by code point, where section 3.2.3 asks for UTF-16 code unit order, so a card whose extension params carry the keys U+1F600 and U+FF01 comes out in the opposite order from every conforming implementation. And repr formats numbers, where section 3.2.2.3 asks for the ECMAScript Number::toString algorithm, so an extension param of 0.000001 is written 1e-06 and an integral param of 1 is written 1.0. Every one of those four canonical forms disagrees with an independent implementation of the RFC; after the change all four agree byte for byte. There is a fourth divergence underneath the three in the issue, and it is the one that would have made a fix on the first three ineffective. The signer parsed the canonical string back into a dict and handed the dict to jwt.encode, which serializes its payload with its own json.dumps and therefore re-applies ensure_ascii=True to bytes that had just been canonicalized. The canonical form would have been correct and the bytes actually signed would still have been escaped. The signer now hands the canonical bytes to the JWS layer directly. The protected header is byte-identical either way, which the tests assert against a jwt.encode reference so that this cannot regress into a silent compatibility break in the header a verifier reads kid and alg out of. On the question you left open, of vendoring a package versus writing the algorithm here: I wrote it here, and I put the package in as a test-only oracle instead, because that is strictly stronger than either option on its own. The `rfc8785` package on PyPI is Trail of Bits' implementation, Apache-2.0, a pure-Python py3-none-any wheel with no runtime dependencies of its own and requires-python >=3.8, so it installs cleanly across the whole 3.10 to 3.14 matrix; its most recent release is 0.1.4 from 2024-09-27, which for a finished implementation of a frozen RFC is a reasonable place to sit rather than a warning sign. The reason not to make it a runtime dependency of `a2a-sdk[signing]` is that the signing extra is the code that decides whether a card is trustworthy, and a dependency there is one more package an attacker has to compromise to change what a signature means. The reason to use it anyway is that if it were the implementation, nothing would be checking it. As the oracle it checks this implementation on every accepted vector, alongside a corpus whose expected values two other implementations already agree on, so the change lands with three-way agreement instead of one-way trust. The expensive half is number formatting and I did not write that from scratch either: it is adapted from the reference implementation at `cyberphone/json-canonicalization`, Apache-2.0, credited in the module docstring, and then checked against the oracle over twenty thousand seeded random doubles and every mantissa-and-exponent pair near the notation thresholds. If you prefer that the SDK depend on rfc8785 at runtime, say so and I will make that swap; it is a one-line change to _canonicalize_agent_card and the entire test suite here stays valid, because the tests are written against the RFC rather than against this implementation. The tests are lifted rather than invented. The 57 vectors in `tests/utils/jcs_vectors.json` come verbatim from the language-neutral `a2a-jcs-v01` corpus proposed in a2aproject/a2a-tck#228, which is 47 accept, 10 reject, and five groups covering signature exclusion, key ordering, string serialization, number serialization, and arrays and nesting. Each vector carries its expected canonical form as a UTF-8 hex string, so a transcription mistake cannot hide inside an editor's encoding, and each expected value was produced by two independent implementations written by neither SDK author which agree byte for byte. I verified the lift programmatically rather than by eye: all 57 ids present, no extras, and zero mismatches on disposition, input and expected hex against the vector files in that PR. On top of the corpus the suite adds the cases this ecosystem has already found implementations disagreeing on: unpaired surrogates in keys and in values, noncharacters, characters above the BMP, U+2028 and U+2029, negative zero, denormals, the 1e21 and 1e-7 notation boundaries, integers past the exact-integer range, and nesting on both sides of the depth bound. That is 165 tests, and the full suite is 1950 passed with ruff check, ruff format --check and ty check all clean. Then I tried to break it, which is where the depth bound comes from. Nesting is attacker-controlled: `AgentExtension.params` is a `google.protobuf.Struct` and nests arbitrarily, and an unbounded recursive serializer turns a hostile card into a stack exhaustion in whoever verifies it rather than a rejected signature. Serialization stops at 128 levels, and the `_clean_empty` helper needed the same bound because it runs first and would otherwise be the crash site while the serializer's bound sat unreachable behind it. A card past the bound now fails as `InvalidSignaturesError` on the verify path rather than as a new exception type at callers. I also checked the mutations: reversing the key order to code point, restoring ensure_ascii, restoring repr for numbers, removing the depth cap, emitting -0, and removing the surrogate rejection each turn the suite red, at 7, 35, 19, 4, 4 and 6 failures respectively. These tests fail for the reasons they claim to exist. Two things worth knowing that are not this change. The compatibility break is real but narrow, and I measured its edges rather than describing them: a pure-ASCII card with no extension params canonicalizes identically before and after, and so does one whose extension params are ASCII strings, so those signatures keep verifying. A card with any non-ASCII text anywhere, or any numeric extension param, produces different bytes and its existing signatures will not verify, and that is the point: those are exactly the cards that do not verify in other implementations today. Separately, while attacking the signing path I found that _clean_empty lets a card carry content the signature does not cover. A card whose extension params include `"policy": ""` canonicalizes to the same bytes as a card with no policy key at all, so a signature made over the second verifies the first. That behaviour predates this change and fixing it would be a second and larger break, so I have deliberately left it alone here; I have a reproducer and I am happy to open it as its own issue if you want it tracked. --------- Signed-off-by: Sankalp Gilda <sankalp.gilda@gmail.com> Co-authored-by: mykytanetipa <mykytanetipa@google.com>
chore(itk): regenerate itk uv lock (a2aproject#1202)
# Description regenerate itk uv lock to add dependency added in a2aproject#1193
feat(itk): register itk-python-v10-agent as a uv workspace member and…
… update dependency version markers (a2aproject#1203) # Description one lock file to avoid a2aproject#1202 problem in the future
fix: make event queue sink removal idempotent (a2aproject#1134)
# Description Thank you for opening a Pull Request! This pull request changed `EventQueueSource.remove_sink()` to use `set.discard()`, so removing an already-absent sink completes without raising `KeyError`. `EventQueueSink.close()` now delegates directly to that idempotent operation instead of suppressing the exception after telemetry has recorded it. A regression test closes the same sink twice and verifies that the mocked OpenTelemetry span records neither an exception nor an error description. Before submitting your PR, there are a few things you can do to make sure it goes smoothly: - [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. - Important Prefixes for [release-please](https://github.com/googleapis/release-please): - `fix:` which represents bug fixes, and correlates to a [SemVer](https://semver.org/) patch. - `feat:` represents a new feature, and correlates to a SemVer minor. - `feat!:`, or `fix!:`, `refactor!:`, etc., which represent a breaking change (indicated by the `!`) and will result in a SemVer major. - [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#1133 🦕 --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: mykytanetipa <mykytanetipa@google.com> Copilot-Session: 42ca73c6-08fb-4954-96f4-b7e32e46613a
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
| Back | FazBrowse Home | New Git URL |