Skip to content

feat(a2a): SSOT typed-variant response parser + auto-fallback for poll-mode peers (#2967) - #2979

Merged
HongmingWang-Rabbit merged 3 commits into
stagingfrom
fix/a2a-poll-mode-response-shape-2967
May 6, 2026
Merged

feat(a2a): SSOT typed-variant response parser + auto-fallback for poll-mode peers (#2967)#2979
HongmingWang-Rabbit merged 3 commits into
stagingfrom
fix/a2a-poll-mode-response-shape-2967

Conversation

@HongmingWang-Rabbit

Copy link
Copy Markdown
Contributor

Summary

Follow-up to the #2972 hotfix. Lands the broader SSOT typed-A2AResponse refactor and the external↔external delegation auto-fallback that the hotfix explicitly deferred.

The hotfix added a third inline branch to send_a2a_message to recognize {status:"queued",delivery_mode:"poll"} and stop misclassifying it as [A2A_ERROR] unexpected response shape. That stops the symptom but leaves wire-shape detection scattered across the function and leaves external molecule-mcp standalone runtimes (which always poll, never push) without a delegation path: delegate_task → send_a2a_message → "queued for poll-mode peer" succeeds, but delegate_task doesn't yet recognize the queued outcome, so the delegating agent gets no synchronous reply.

This PR:

  1. SSOT typed parser (workspace/a2a_response.py) — single source of truth for A2A wire shapes. parse(data) → Result | Error | Queued | Malformed frozen dataclasses. Never raises; unknown shapes route to Malformed so the operator sees a loud warning, not a silent fallthrough.
  2. send_a2a_message dispatches via the typed variants — replaces the chained if "result" / elif "error" / elif "queued" block with isinstance(variant, ...) branches. New _A2A_QUEUED_PREFIX sentinel lets callers branch on the typed outcome without substring sniffing.
  3. delegate_task auto-fallback to /delegate-poll — when the legacy message/send path returns the queued sentinel, tool_delegate_task now transparently retries via _delegate_sync_via_polling (the RFC RFC: Async-first A2A delegation with durable task ledger #2829 PR-5 path), so external↔external delegation Just Works without operators flipping DELEGATION_SYNC_VIA_INBOX.
  4. Comprehensive logging at INFO for queued classification + WARNING for malformed bodies — operators can grep queued for poll-mode peer and malformed response cleanly.
  5. Restart-metadata surfacing — when the platform returns a restarting=true JSON-RPC error, the client renders a softer (restarting, retry_after=Ns) suffix instead of a generic failure.

Tests

  • tests/test_a2a_response.py — 62 tests, 100% line coverage on a2a_response.py (verified independently via coverage run --source=a2a_response). Includes 17-fixture corpus, 4 variant test classes, ~25-input parametrized robustness suite asserting the parser never raises on adversarial input, and a regression gate that explicitly fails on pre-fix source.
  • tests/test_a2a_client.py — 4 new poll-mode tests (TestSendA2AMessagePollMode) on top of the 3 hotfix tests (rebased onto the new sentinel contract). Pins _A2A_QUEUED_PREFIX semantics, restart-metadata surfacing, and the partial-envelope-still-malformed defense.
  • tests/test_delegation_sync_via_polling.py — 3 new tests (TestPollModeAutoFallback) covering positive auto-fallback on queued sentinel, no-fallback on push-mode peer (preserve fast path), and no-fallback on genuine error (don't mask real failures).
  • tests/e2e/test_poll_mode_e2e.sh — Phase 3.5 added: pipes the actual on-the-wire response from a real workspace-server through the wheel's a2a_response.parse() and asserts it classifies as Queued with the correct method + delivery_mode. A regression in either the server emit shape or the client parser fails this E2E.

Pre-fix verification confirmed: stashed the SSOT parser/sentinel/auto-fallback diff and re-ran the new tests against the hotfix-only base; 5 tests fail (including ImportError: cannot import name '_A2A_QUEUED_PREFIX'), confirming each new assertion discriminates. Restored after.

Rollout

  • Lands behind no flag — wire shape detection only, no behavior change for push-mode peers.
  • Auto-fallback path was already gated behind DELEGATION_SYNC_VIA_INBOX env support in _delegate_sync_via_polling since RFC RFC: Async-first A2A delegation with durable task ledger #2829 PR-5; this just reaches it without requiring operator config when the peer is poll-mode.
  • After merge: publish-runtime auto-ships a new wheel; reno-stars (which surfaced this in production) gets it on next redeploy.

Test plan

  • Unit: WORKSPACE_ID=... pytest tests/test_a2a_response.py tests/test_a2a_client.py::TestSendA2AMessage tests/test_delegation_sync_via_polling.py::TestPollModeAutoFallback — 129 pass
  • 100% line coverage on a2a_response.py (verified independently)
  • Regression gate: every new test fails on pre-fix source (verified via git stash)
  • E2E (Phase 3.5): real workspace-server queued envelope round-trips through a2a_response.parse()
  • Post-merge: watch publish-runtime build, redeploy reno-stars, verify zero unexpected response shape rows in subsequent activity feed

Closes #2967.

🤖 Generated with Claude Code

Hongming Wang and others added 2 commits May 5, 2026 17:21
)

Introduce ``workspace/a2a_response.py`` as the single source of truth for
the wire shapes the workspace-server proxy can return at
``/workspaces/<id>/a2a``:

  * ``Result``    — JSON-RPC success
  * ``Error``     — JSON-RPC error or platform-level error (with
                    restart-in-progress metadata when present)
  * ``Queued``    — poll-mode short-circuit envelope: the platform
                    queued the message into the target's inbox, the
                    target will fetch via /activity poll
  * ``Malformed`` — anything the parser can't classify (logged at
                    WARNING so a future server change is loud)

``send_a2a_message`` (in ``a2a_client.py``) now dispatches via
``a2a_response.parse(data)`` instead of inline ``"result" in data`` /
``"error" in data`` sniffing. The Queued variant returns a new
``_A2A_QUEUED_PREFIX`` sentinel so callers can distinguish "delivered
async, no synchronous reply" from both success-with-text and failure.

reno-stars production data caught two intermittent failures that
both reduced to the same root cause:

  1. **File transfer announce silently failed** — when CEO Ryan PC
     (poll-mode external molecule-mcp) sent the harmi.zip
     announcement to Reno Stars Business Intelligent (also poll-mode
     external), ``send_a2a_message`` saw the platform's poll-queued
     envelope ``{"status":"queued","delivery_mode":"poll","method":"..."}``,
     didn't recognize it as the synthetic delivery-acknowledgement
     it is, and returned ``[A2A_ERROR] unexpected response shape``.
     The agent fell back to a chunk-shipping path; receiver did get
     the file but operator-facing logs showed a failure that didn't
     actually fail.

  2. **Duplicated agent comm** — same bug, inverted direction. d76
     delegated to 67d, send_a2a_message returned the unexpected-shape
     error, delegate_task wrapped it as DELEGATION FAILED, the calling
     agent retried with sharper wording, the recipient saw the same
     request twice and self-reported "二次请求 — 我先不执行".

External molecule-mcp standalone runtimes are inherently poll-mode
(they have no public URL), so every external↔external A2A pair was
hitting this on every send. The pre-fix client only handled JSON-RPC
``result``/``error`` keys and treated the queued envelope (which has
neither) as malformed. RFC #2339 PR 2 added the queued envelope on
the server side; the client never caught up.

When ``send_a2a_message`` returns the ``_A2A_QUEUED_PREFIX`` sentinel,
``tool_delegate_task`` now transparently falls back to
``_delegate_sync_via_polling`` (RFC #2829 PR-5's durable
``/delegate`` + ``/delegations`` polling path, which DOES work for
poll-mode peers because the platform's executeDelegation goroutine
writes to the inbox queue and the result row arrives when the target
picks it up + replies). The agent gets a real synchronous reply
instead of the empty queued sentinel.

  * ``test_a2a_response.py`` — 62 tests, **100% line coverage** on
    the parser (verified via ``coverage run --source=a2a_response``).
    Includes adversarial-input fuzzing across ~25 pathological
    payloads — parser must never raise.
  * ``test_a2a_client.py::TestSendA2AMessagePollMode`` — 4 tests for
    the new Queued/Error wiring in ``send_a2a_message``.
  * ``test_delegation_sync_via_polling.py::TestPollModeAutoFallback``
    — 3 tests for the auto-fallback in ``tool_delegate_task``,
    including negative cases (push-mode reply must NOT trigger
    fallback; genuine error must NOT silently retry).
  * **Verified all new tests FAIL on pre-fix source** by stashing
    a2a_client.py + a2a_tools_delegation.py and re-running — 5
    failures including ImportError for the missing
    ``_A2A_QUEUED_PREFIX``.

Per the operator-debuggability directive:

  * INFO at every Queued classification (expected variant; operator
    sees normal poll-mode-peer queueing in log stream).
  * INFO at the auto-fallback decision in ``tool_delegate_task``
    so a future operator can correlate "send returned queued →
    falling back to polling path" without reading the source.
  * WARNING at every Malformed classification (server contract
    drift; operator MUST see this immediately).
  * Existing transient-retry WARNING preserved.

  * Mirror Go-side typed model in workspace-server. The wire shape
    is documented in ``a2a_response.py``'s module docstring with
    file:line pointers to the canonical emitters; a future PR can
    introduce ``models/a2a_response.go`` without changing wire
    behavior. The fixture corpus in ``test_a2a_response.py`` is
    designed so a one-sided edit breaks CI.
  * ``send_message_to_user`` and ``chat_upload_receive`` use a
    different endpoint (``/notify``) and aren't affected by this
    bug; their parsing stays unchanged.

  * 135 tests pass across ``test_a2a_response.py`` +
    ``test_a2a_client.py`` + ``test_delegation_sync_via_polling.py``
    + ``test_a2a_tools_impl.py``.
  * ``coverage run --source=a2a_response -m pytest`` reports 100%
    line coverage with 0 missing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…2967)

Previously Phase 3 only checked the workspace-server's poll-mode short-circuit
emit shape ({"status":"queued","delivery_mode":"poll","method":"..."}); the
matching client-side classification was tested in isolation against fixture
dicts in test_a2a_response.py.

This phase closes the loop by piping the actual on-the-wire response from a
real workspace-server back through the wheel's a2a_response.parse() and
asserting it classifies as the Queued variant with the right method +
delivery_mode. A regression in EITHER the server emit shape OR the client
parser will now fail this E2E, eliminating the gap that allowed the original
"unexpected response shape" production bug to ship despite green unit tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread workspace/tests/test_a2a_response.py
The drift gate caught the new SSOT parser module — without registration
the wheel ships it un-rewritten and runtime imports fail. Same pattern
as inbox_uploads, a2a_tools_delegation, a2a_tools_rbac registrations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented May 6, 2026

Copy link
Copy Markdown
Contributor

🔒 Auto-merge disabled — new commit (e342d0c) pushed after auto-merge was enabled. The merge queue locks SHAs at entry, so subsequent pushes can race. Verify the new commit and re-enable with gh pr merge --auto.

@HongmingWang-Rabbit
HongmingWang-Rabbit added this pull request to the merge queue May 6, 2026
Merged via the queue into staging with commit 9dd2988 May 6, 2026
24 checks passed
@HongmingWang-Rabbit
HongmingWang-Rabbit deleted the fix/a2a-poll-mode-response-shape-2967 branch May 6, 2026 00:48
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.

External-runtime A2A: send_a2a_message can't parse poll-mode "queued" envelope — drops file chunks, triggers duplicate delegations

1 participant