From c584d15cdc31e1ebf3989c426ed05fb2ddb0c9fc Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 23 Aug 2026 19:40:56 -0700 Subject: [PATCH] feat(bots): typed failure reasons reach the sending agent on A2A calls (#93091) message_agent callers previously got provider prose (a raw 401 paragraph, a missing-provider essay) and could not branch on the failure class. Now the #93091 item-1 reason enum rides the whole relay roundtrip: - Desktop relay drain forwards bot_relay.deliver's error.data.reason into bot_relay.reply (and prefers it for the attention badge over free-text re-parsing); - write_reply already persisted reason / classified fallbacks; - the sender-side waiter prints "[reason: ]" ahead of the free text, so the completion notification the sending agent receives is machine-branchable. Additive everywhere: healthy replies unchanged, reasonless errors classify to a code, old consumers keep working. --- .../desktop/src/plugins/hermes-bots/plugin.js | 13 +- .../tests/bot-attention-badge.test.mjs | 5 +- tests/tools/test_a2a_reason_roundtrip.py | 121 ++++++++++++++++++ tools/bot_relay.py | 7 +- website/docs/user-guide/bot-mode.md | 4 + 5 files changed, 146 insertions(+), 4 deletions(-) create mode 100644 tests/tools/test_a2a_reason_roundtrip.py diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index 5404eb67a0..492557db4b 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -1594,8 +1594,17 @@ async function drainRelayOutboxes() { clearBotAttention(attentionKey) await postReply({ reply: String(res?.reply || '') }) } catch (error) { - noteBotAttention(attentionKey, error?.message || error) - await postReply({ error: String(error?.message || error || 'delivery failed') }) + // #93091: bot_relay.deliver classifies the failed turn and ships the + // typed code in the JSON-RPC error's `data.reason`; forward it into + // the sender-side reply file so the waiter (and the sending agent) + // get the machine-readable cause, and prefer it for the badge — + // classified codes beat free-text re-parsing. + const reason = String(error?.data?.reason || '').trim() + noteBotAttention(attentionKey, reason || error?.message || error) + await postReply({ + error: String(error?.message || error || 'delivery failed'), + ...(reason ? { reason } : {}) + }) } } } diff --git a/apps/desktop/src/plugins/hermes-bots/tests/bot-attention-badge.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/bot-attention-badge.test.mjs index 42fc318dea..934eceecff 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/bot-attention-badge.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/bot-attention-badge.test.mjs @@ -127,7 +127,10 @@ test('hooks: relay delivery and group member turns note/clear attention', () => pluginSource.indexOf('function startBotRelay') ) assert.match(drain, /clearBotAttention\(attentionKey\)/) - assert.match(drain, /noteBotAttention\(attentionKey, error\?\.message \|\| error\)/) + // #93091: the drain prefers the typed reason from bot_relay.deliver's + // error.data over free-text re-parsing, and forwards it to the reply. + assert.match(drain, /noteBotAttention\(attentionKey, reason \|\| error\?\.message \|\| error\)/) + assert.match(drain, /\.\.\.\(reason \? \{ reason \} : \{\}\)/) // Group member turn boundary: failure notes under the member key; a real // reply clears it. diff --git a/tests/tools/test_a2a_reason_roundtrip.py b/tests/tools/test_a2a_reason_roundtrip.py new file mode 100644 index 0000000000..0854153ed1 --- /dev/null +++ b/tests/tools/test_a2a_reason_roundtrip.py @@ -0,0 +1,121 @@ +"""Tests: typed reason codes survive the A2A relay roundtrip (#93091). + +The sending agent must receive the machine-readable failure code, not just +provider prose: bot_relay.deliver ships `reason` in JSON-RPC error.data +(pinned in test_bot_retry_policy), the Desktop forwards it to bot_relay.reply, +write_reply persists it, and the waiter script prints it. This file pins the +persist + waiter surfaces. +""" + +from __future__ import annotations + +import json +import subprocess +import sys + +import pytest + +from tools import bot_failure_reasons as bfr +from tools import bot_relay + + +@pytest.fixture +def home(tmp_path, monkeypatch): + h = tmp_path / ".hermes" + h.mkdir() + monkeypatch.setenv("HERMES_HOME", str(h)) + return h + + +def _reply_file(home, envelope_id): + return bot_relay.relay_root(home) / bot_relay.REPLIES_DIR / f"{envelope_id}.json" + + +def test_write_reply_persists_forwarded_reason(home): + """A reason forwarded by the Desktop drain loop lands in the reply file.""" + envelope_id = "a" * 32 + bot_relay.write_reply( + home, + envelope_id, + error="delivery turn failed: Error code: 429", + reason=bfr.PROVIDER_RATE_LIMIT, + ) + data = json.loads(_reply_file(home, envelope_id).read_text(encoding="utf-8")) + assert data["reason"] == bfr.PROVIDER_RATE_LIMIT + + +def test_write_reply_classifies_when_reason_omitted(home): + """Old senders that never forward a reason still get a classified code.""" + envelope_id = "b" * 32 + bot_relay.write_reply( + home, + envelope_id, + error="Error code: 401 - Your API key is invalid, blocked or out of funds", + ) + data = json.loads(_reply_file(home, envelope_id).read_text(encoding="utf-8")) + assert data["reason"] == bfr.PROVIDER_AUTH_OR_ACCESS + + +def _run_waiter(home, envelope): + cmd = bot_relay.waiter_command(home, envelope) + return subprocess.run( + ["bash", "-c", cmd], capture_output=True, text=True, timeout=30 + ) + + +def _envelope(home): + target = { + "profile": "scout", + "handle": "scout", + "connection_id": "cloud-1", + "connection_label": "", + "title": "", + "description": "", + } + return bot_relay.enqueue_envelope( + home, + target=target, + message="ping", + sender_profile="default", + sender_handle="hermes", + ) + + +def test_waiter_surfaces_reason_tag_to_sending_agent(home, monkeypatch): + """The waiter's stdout — the sending agent's completion notification — + carries the typed reason so the agent can branch without parsing prose.""" + monkeypatch.setattr(bot_relay, "_target_liveness", lambda *a, **k: True) + env = _envelope(home) + bot_relay.write_reply( + home, + env["id"], + error="delivery turn failed: rate limit exceeded", + reason=bfr.PROVIDER_RATE_LIMIT, + ) + proc = _run_waiter(home, env) + assert proc.returncode == 1 + assert f"[reason: {bfr.PROVIDER_RATE_LIMIT}]" in proc.stdout + + +def test_waiter_healthy_reply_has_no_reason_tag(home, monkeypatch): + """Success path unchanged: no reason tag noise on good replies.""" + monkeypatch.setattr(bot_relay, "_target_liveness", lambda *a, **k: True) + env = _envelope(home) + bot_relay.write_reply(home, env["id"], reply="pong") + proc = _run_waiter(home, env) + assert proc.returncode == 0 + assert "pong" in proc.stdout + assert "[reason:" not in proc.stdout + + +def test_waiter_reasonless_error_prints_plain(home, monkeypatch): + """A reply file with error text whose classification is unknown still + prints cleanly — the unknown code is a valid tag, never a crash.""" + monkeypatch.setattr(bot_relay, "_target_liveness", lambda *a, **k: True) + env = _envelope(home) + bot_relay.write_reply(home, env["id"], error="something odd happened") + proc = _run_waiter(home, env) + assert proc.returncode == 1 + assert "failed" in proc.stdout + # classify_agent_error("something odd happened") == unknown → tagged + assert f"[reason: {bfr.UNKNOWN}]" in proc.stdout diff --git a/tools/bot_relay.py b/tools/bot_relay.py index e0cc026484..5c8c76f0a1 100644 --- a/tools/bot_relay.py +++ b/tools/bot_relay.py @@ -505,7 +505,12 @@ def waiter_command(root: Path | str, envelope: dict) -> str: " if os.path.exists(p):\n" " d = json.load(open(p, encoding='utf-8'))\n" " if d.get('error'):\n" - " print('Delivery to ' + label + ' failed: ' + d['error'])\n" + # The typed reason code (#93091) rides ahead of the free text so the + # sending agent can branch on it (auth vs rate limit vs offline) + # without parsing provider prose. + " code = str(d.get('reason') or '').strip()\n" + " tag = ' [reason: ' + code + ']' if code else ''\n" + " print('Delivery to ' + label + ' failed' + tag + ': ' + d['error'])\n" " sys.exit(1)\n" " print('Reply from ' + label + ':')\n" " print(d.get('reply') or '(empty reply)')\n" diff --git a/website/docs/user-guide/bot-mode.md b/website/docs/user-guide/bot-mode.md index 1005b4c5b0..35342ec968 100644 --- a/website/docs/user-guide/bot-mode.md +++ b/website/docs/user-guide/bot-mode.md @@ -113,6 +113,10 @@ Bot-to-bot delivery is per-invocation: the receiving Bot picks the message up wh A failed delivery turn is retried at most once, and only when a retry can actually help. Transient failures (target runtime offline, delivery timeout, provider rate limit or server error) re-run the same Bot Chat session unchanged. A context-overflow failure also re-runs the same session — the retried turn compacts the over-threshold transcript via the standard context-compression pass before calling the model, so the retry fits where the original didn't. Auth, quota, and configuration failures never auto-retry: a second attempt cannot fix them and only burns quota, so the failure is surfaced immediately. A retried turn never starts a fresh session — your Bot Chat history and context stay intact. +### When a delivery fails: typed reasons + +A failed bot turn or relay delivery carries a machine-readable `reason` code alongside the human error text, end to end: the target gateway classifies the failure (`provider_auth_or_access`, `provider_quota_limit`, `provider_rate_limit`, `provider_server_error`, `context_overflow`, `missing_config`, `model_unavailable`, `runtime_offline`, `queued_expired`, `delivery_timeout`, `target_busy`, `unknown`), the Desktop forwards it, and the sending agent's completion notification is tagged `[reason: ]` ahead of the error text. A calling agent can branch on the code — "sign in again" vs "retry later" — instead of parsing provider prose. The Desktop's needs-attention badge uses the same codes. + ### Messaging across connected machines (the Desktop relay) Every gateway you register in **Settings → Connections** — local, remote URL, SSH, Hermes Cloud, docker — is a persistent line the Desktop holds open, and Bot Mode uses those lines for messaging automatically. No extra setup: