From 97c2a0e2ade9043abc71f81883cd525b7f1fbfee Mon Sep 17 00:00:00 2001 From: qbit-mirror-bot Date: Wed, 1 Jul 2026 16:48:40 +0000 Subject: [PATCH] fix(a2a): wait for final replies before resolving RPCs --- .github/workflows/build-windows-installer.yml | 4 +- .github/workflows/contributor-check.yml | 2 +- .github/workflows/deploy-site.yml | 4 +- .github/workflows/docker-lint.yml | 4 +- .github/workflows/docker-publish.yml | 6 +- .github/workflows/docs-site-checks.yml | 2 +- .github/workflows/history-check.yml | 2 +- .github/workflows/lint.yml | 6 +- .github/workflows/nix-lockfile-fix.yml | 4 +- .github/workflows/skills-index-freshness.yml | 2 +- .github/workflows/skills-index.yml | 4 +- .github/workflows/supply-chain-audit.yml | 10 +-- .github/workflows/tests.yml | 6 +- .github/workflows/upload_to_pypi.yml | 6 +- .github/workflows/uv-lockfile-check.yml | 2 +- plugins/platforms/a2a/adapter.py | 20 ++++-- tests/plugins/test_a2a_plugin.py | 65 ++++++++++++++++++- 17 files changed, 112 insertions(+), 37 deletions(-) diff --git a/.github/workflows/build-windows-installer.yml b/.github/workflows/build-windows-installer.yml index 3fc4f2b07464..d0bc6f377efe 100644 --- a/.github/workflows/build-windows-installer.yml +++ b/.github/workflows/build-windows-installer.yml @@ -12,7 +12,7 @@ jobs: # permission via the API and fail fast for anyone below admin. authorize: name: Authorize (admins only) - runs-on: ubuntu-latest + runs-on: tenki-standard-medium-4c-8g timeout-minutes: 5 steps: - name: Check actor is a repo admin @@ -34,7 +34,7 @@ jobs: build: name: Hermes-Setup.exe needs: authorize - runs-on: windows-latest + runs-on: tenki-standard-medium-4c-8g timeout-minutes: 30 permissions: contents: read diff --git a/.github/workflows/contributor-check.yml b/.github/workflows/contributor-check.yml index de38fcaae9a6..17140d5871a6 100644 --- a/.github/workflows/contributor-check.yml +++ b/.github/workflows/contributor-check.yml @@ -12,7 +12,7 @@ permissions: jobs: check-attribution: - runs-on: ubuntu-latest + runs-on: tenki-standard-medium-4c-8g steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index 82acaa6667d7..7c22603c8480 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -28,14 +28,14 @@ jobs: # a skills-index PR that doesn't touch website/** paths and so # doesn't auto-deploy via the deploy-docs path. if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' - runs-on: ubuntu-latest + runs-on: tenki-standard-medium-4c-8g steps: - name: Trigger Vercel Deploy run: curl -X POST "${{ secrets.VERCEL_DEPLOY_HOOK }}" deploy-docs: if: github.repository == 'NousResearch/hermes-agent' - runs-on: ubuntu-latest + runs-on: tenki-standard-medium-4c-8g environment: name: github-pages url: ${{ steps.deploy.outputs.page_url }} diff --git a/.github/workflows/docker-lint.yml b/.github/workflows/docker-lint.yml index f1673813e99b..10213b4037d0 100644 --- a/.github/workflows/docker-lint.yml +++ b/.github/workflows/docker-lint.yml @@ -36,7 +36,7 @@ concurrency: jobs: hadolint: name: Lint Dockerfile (hadolint) - runs-on: ubuntu-latest + runs-on: tenki-standard-medium-4c-8g timeout-minutes: 5 steps: - name: Checkout code @@ -51,7 +51,7 @@ jobs: shellcheck: name: Lint docker/ shell scripts (shellcheck) - runs-on: ubuntu-latest + runs-on: tenki-standard-medium-4c-8g timeout-minutes: 5 steps: - name: Checkout code diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 2e972cb11c31..d43922173bfe 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -51,7 +51,7 @@ jobs: build-amd64: # Only run on the upstream repository, not on forks if: github.repository == 'NousResearch/hermes-agent' - runs-on: ubuntu-latest + runs-on: tenki-standard-medium-4c-8g timeout-minutes: 45 outputs: digest: ${{ steps.push.outputs.digest }} @@ -185,7 +185,7 @@ jobs: # --------------------------------------------------------------------------- build-arm64: if: github.repository == 'NousResearch/hermes-agent' - runs-on: ubuntu-24.04-arm + runs-on: tenki-standard-medium-4c-8g timeout-minutes: 45 outputs: digest: ${{ steps.push.outputs.digest }} @@ -307,7 +307,7 @@ jobs: # --------------------------------------------------------------------------- merge: if: github.repository == 'NousResearch/hermes-agent' && (github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release') - runs-on: ubuntu-latest + runs-on: tenki-standard-medium-4c-8g needs: [build-amd64, build-arm64] timeout-minutes: 10 steps: diff --git a/.github/workflows/docs-site-checks.yml b/.github/workflows/docs-site-checks.yml index 49111b5ac095..be26205087cb 100644 --- a/.github/workflows/docs-site-checks.yml +++ b/.github/workflows/docs-site-checks.yml @@ -12,7 +12,7 @@ permissions: jobs: docs-site-checks: - runs-on: ubuntu-latest + runs-on: tenki-standard-medium-4c-8g steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/.github/workflows/history-check.yml b/.github/workflows/history-check.yml index 46f5368f7903..2d2f53561173 100644 --- a/.github/workflows/history-check.yml +++ b/.github/workflows/history-check.yml @@ -22,7 +22,7 @@ permissions: jobs: check-common-ancestor: - runs-on: ubuntu-latest + runs-on: tenki-standard-medium-4c-8g steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 013d212020df..4431c8d2b0e9 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -33,7 +33,7 @@ concurrency: jobs: lint-diff: name: ruff + ty diff - runs-on: ubuntu-latest + runs-on: tenki-standard-medium-4c-8g timeout-minutes: 10 steps: - name: Checkout code @@ -163,7 +163,7 @@ jobs: # ``lint-diff`` job above runs independently so reviewers still get # the diff comment even when enforcement fails. name: ruff enforcement (blocking) - runs-on: ubuntu-latest + runs-on: tenki-standard-medium-4c-8g timeout-minutes: 5 steps: - name: Checkout code @@ -187,7 +187,7 @@ jobs: # shebang scripts via subprocess, bare open() without encoding=, etc. # See scripts/check-windows-footguns.py for the full rule list. name: Windows footguns (blocking) - runs-on: ubuntu-latest + runs-on: tenki-standard-medium-4c-8g timeout-minutes: 5 steps: - name: Checkout code diff --git a/.github/workflows/nix-lockfile-fix.yml b/.github/workflows/nix-lockfile-fix.yml index ada0b79f23c6..6661c1d505ce 100644 --- a/.github/workflows/nix-lockfile-fix.yml +++ b/.github/workflows/nix-lockfile-fix.yml @@ -43,7 +43,7 @@ jobs: # triggers downstream nix.yml verification. auto-fix-main: if: github.event_name == 'push' - runs-on: ubuntu-latest + runs-on: tenki-standard-medium-4c-8g timeout-minutes: 25 concurrency: group: auto-fix-main @@ -131,7 +131,7 @@ jobs: && github.event.issue.pull_request != null && contains(github.event.comment.body, '[x] **Apply lockfile fix**') && !contains(github.event.changes.body.from, '[x] **Apply lockfile fix**')) - runs-on: ubuntu-latest + runs-on: tenki-standard-medium-4c-8g timeout-minutes: 25 steps: - name: Authorize & resolve PR diff --git a/.github/workflows/skills-index-freshness.yml b/.github/workflows/skills-index-freshness.yml index 856878def5f1..52f19b8d0137 100644 --- a/.github/workflows/skills-index-freshness.yml +++ b/.github/workflows/skills-index-freshness.yml @@ -19,7 +19,7 @@ permissions: jobs: check-freshness: if: github.repository == 'NousResearch/hermes-agent' - runs-on: ubuntu-latest + runs-on: tenki-standard-medium-4c-8g steps: - name: Probe live index id: probe diff --git a/.github/workflows/skills-index.yml b/.github/workflows/skills-index.yml index 72f252b26eb8..b1e843d05c20 100644 --- a/.github/workflows/skills-index.yml +++ b/.github/workflows/skills-index.yml @@ -19,7 +19,7 @@ jobs: build-index: # Only run on the upstream repository, not on forks if: github.repository == 'NousResearch/hermes-agent' - runs-on: ubuntu-latest + runs-on: tenki-standard-medium-4c-8g steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -48,7 +48,7 @@ jobs: trigger-deploy: needs: build-index if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' - runs-on: ubuntu-latest + runs-on: tenki-standard-medium-4c-8g steps: - name: Trigger Deploy Site workflow env: diff --git a/.github/workflows/supply-chain-audit.yml b/.github/workflows/supply-chain-audit.yml index 3309de78daef..bc7f68c8cee2 100644 --- a/.github/workflows/supply-chain-audit.yml +++ b/.github/workflows/supply-chain-audit.yml @@ -23,7 +23,7 @@ permissions: jobs: # ── Path filter (shared by both scan and dep-bounds) ─────────────── changes: - runs-on: ubuntu-latest + runs-on: tenki-standard-medium-4c-8g outputs: # True when any file the scanner cares about changed in this PR scan: ${{ steps.filter.outputs.scan }} @@ -59,7 +59,7 @@ jobs: name: Scan PR for critical supply chain risks needs: changes if: needs.changes.outputs.scan == 'true' - runs-on: ubuntu-latest + runs-on: tenki-standard-medium-4c-8g steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -186,7 +186,7 @@ jobs: # skipped — without it, a failed dependency would leave the required # check unreported (i.e. "pending"), the exact failure mode this fixes. if: always() && needs.changes.outputs.scan != 'true' - runs-on: ubuntu-latest + runs-on: tenki-standard-medium-4c-8g steps: - run: echo "No supply-chain-relevant files changed, skipping scan." @@ -194,7 +194,7 @@ jobs: name: Check PyPI dependency upper bounds needs: changes if: needs.changes.outputs.deps == 'true' - runs-on: ubuntu-latest + runs-on: tenki-standard-medium-4c-8g steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -265,6 +265,6 @@ jobs: # skipped — without it, a failed dependency would leave the required # check unreported (i.e. "pending"), the exact failure mode this fixes. if: always() && needs.changes.outputs.deps != 'true' - runs-on: ubuntu-latest + runs-on: tenki-standard-medium-4c-8g steps: - run: echo "No pyproject.toml changes, skipping dependency bounds check." diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2755641073a1..1db866d7848f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -22,7 +22,7 @@ concurrency: jobs: test: - runs-on: ubuntu-latest + runs-on: tenki-standard-medium-4c-8g timeout-minutes: 30 strategy: fail-fast: false @@ -110,7 +110,7 @@ jobs: save-durations: needs: test if: always() && github.ref == 'refs/heads/main' - runs-on: ubuntu-latest + runs-on: tenki-standard-medium-4c-8g steps: - name: Download all slice durations uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -139,7 +139,7 @@ jobs: key: test-durations e2e: - runs-on: ubuntu-latest + runs-on: tenki-standard-medium-4c-8g timeout-minutes: 15 steps: - name: Checkout code diff --git a/.github/workflows/upload_to_pypi.yml b/.github/workflows/upload_to_pypi.yml index 9d1806d6f72a..e8ad29714ad8 100644 --- a/.github/workflows/upload_to_pypi.yml +++ b/.github/workflows/upload_to_pypi.yml @@ -25,7 +25,7 @@ concurrency: jobs: build: name: Build distribution 📦 - runs-on: ubuntu-latest + runs-on: tenki-standard-medium-4c-8g steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -89,7 +89,7 @@ jobs: publish: name: Publish to PyPI needs: build - runs-on: ubuntu-latest + runs-on: tenki-standard-medium-4c-8g environment: name: pypi url: https://pypi.org/p/hermes-agent @@ -114,7 +114,7 @@ jobs: # and workflow_dispatch won't have a matching release to attach to. if: startsWith(github.ref, 'refs/tags/') needs: publish - runs-on: ubuntu-latest + runs-on: tenki-standard-medium-4c-8g permissions: contents: write # attach assets to the existing release id-token: write # sigstore signing diff --git a/.github/workflows/uv-lockfile-check.yml b/.github/workflows/uv-lockfile-check.yml index 37c31799bea6..cda4696ae0bb 100644 --- a/.github/workflows/uv-lockfile-check.yml +++ b/.github/workflows/uv-lockfile-check.yml @@ -67,7 +67,7 @@ concurrency: jobs: check: name: uv lock --check - runs-on: ubuntu-latest + runs-on: tenki-standard-medium-4c-8g timeout-minutes: 5 steps: - name: Checkout code diff --git a/plugins/platforms/a2a/adapter.py b/plugins/platforms/a2a/adapter.py index 9c5c5751c842..31ab0cc9ce1e 100644 --- a/plugins/platforms/a2a/adapter.py +++ b/plugins/platforms/a2a/adapter.py @@ -85,7 +85,10 @@ def name(self) -> str: # ── Lifecycle ───────────────────────────────────────────────────────── - async def connect(self) -> bool: + async def connect(self, **_kwargs) -> bool: + # Gateway reconnection plumbing passes adapter-agnostic kwargs such as + # ``is_reconnect``. A2A does not need them, but accepting them keeps the + # plugin compatible with the BasePlatformAdapter lifecycle contract. # Capture the running gateway loop so the HTTP thread can marshal # events onto it via run_coroutine_threadsafe. try: @@ -280,12 +283,21 @@ async def send( ``chat_id`` is the A2A context id we set as the source chat_id, so it keys straight back to the blocked HTTP request. + + The gateway marks final user-visible replies with ``metadata['notify']``. + Progress, status, and editable preview sends intentionally lack that + marker; those must not satisfy the JSON-RPC caller, or the caller sees + a banner/status update instead of the agent's actual answer. """ + is_final_reply = bool((metadata or {}).get("notify")) with self._pending_lock: fut = self._pending_replies.get(chat_id) - if fut is not None and not fut.done(): - fut.set_result(content or "") - return SendResult(success=True, message_id=str(int(time.time() * 1000))) + if fut is not None and not fut.done(): + if not is_final_reply: + logger.debug("A2A: ignoring non-final send for context %s", chat_id) + return SendResult(success=True, message_id=str(int(time.time() * 1000))) + fut.set_result(content or "") + return SendResult(success=True, message_id=str(int(time.time() * 1000))) # No waiter (e.g. a late streamed chunk or out-of-band send) — drop it. logger.debug("A2A: send() for context %s had no pending waiter", chat_id) return SendResult(success=True, message_id=str(int(time.time() * 1000))) diff --git a/tests/plugins/test_a2a_plugin.py b/tests/plugins/test_a2a_plugin.py index 6567cb36a783..1e998ba45d87 100644 --- a/tests/plugins/test_a2a_plugin.py +++ b/tests/plugins/test_a2a_plugin.py @@ -8,6 +8,7 @@ from __future__ import annotations import asyncio +from concurrent.futures import Future import json import os import tempfile @@ -310,6 +311,45 @@ def fake_post(url, body, headers, timeout): assert "PONG" in out +# -------------------------------------------------------------------------- +# A2A reply capture +# -------------------------------------------------------------------------- + +class TestReplyCapture: + def test_send_waits_for_notify_marked_final_reply(self): + """Interim/editable sends must not satisfy the blocked A2A RPC future.""" + from plugins.platforms.a2a.adapter import A2AAdapter + from gateway.config import PlatformConfig + + adapter = A2AAdapter(PlatformConfig(enabled=True)) + fut = Future() + with adapter._pending_lock: + adapter._pending_replies["ctx-final"] = fut + + async def run(): + interim = await adapter.send( + "ctx-final", + "⏩ Steered into current run (iteration 1/200).", + metadata={"expect_edits": True}, + ) + assert interim.success is True + assert fut.done() is False + + final = await adapter.send( + "ctx-final", + "FINAL_PROOF_PAYLOAD", + metadata={"notify": True}, + ) + assert final.success is True + assert fut.result(timeout=0) == "FINAL_PROOF_PAYLOAD" + + try: + asyncio.run(run()) + finally: + with adapter._pending_lock: + adapter._pending_replies.pop("ctx-final", None) + + # -------------------------------------------------------------------------- # End-to-end inbound round-trip (real http.server + mocked agent) # -------------------------------------------------------------------------- @@ -340,7 +380,7 @@ def test_live_server_card_and_message_send(self, monkeypatch): # by resolving the pending future via the real send() path. async def fake_handle_message(event): # The reply path the gateway would normally drive. - await adapter.send(event.source.chat_id, "ECHO: " + event.text) + await adapter.send(event.source.chat_id, "ECHO: " + event.text, metadata={"notify": True}) adapter.handle_message = fake_handle_message # type: ignore adapter._message_handler = object() # non-None so dispatch proceeds @@ -387,6 +427,29 @@ def _post(): asyncio.run(run()) + def test_connect_accepts_gateway_reconnect_kwarg(self, monkeypatch): + """Gateway reconnection passes is_reconnect=... to every adapter connect().""" + monkeypatch.setenv("A2A_BEARER_TOKEN", "topsecret") + monkeypatch.setenv("A2A_HOST", "127.0.0.1") + + from plugins.platforms.a2a.adapter import A2AAdapter + from gateway.config import PlatformConfig + import socket + + s = socket.socket() + s.bind(("127.0.0.1", 0)) + port = s.getsockname()[1] + s.close() + monkeypatch.setenv("A2A_PORT", str(port)) + + adapter = A2AAdapter(PlatformConfig(enabled=True)) + + async def run(): + assert await adapter.connect(is_reconnect=True) is True + await adapter.disconnect() + + asyncio.run(run()) + def test_auth_required_when_token_set(self, monkeypatch): monkeypatch.setenv("A2A_BEARER_TOKEN", "topsecret")