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: <code>]" 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.
This commit is contained in:
@@ -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 } : {})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
121
tests/tools/test_a2a_reason_roundtrip.py
Normal file
121
tests/tools/test_a2a_reason_roundtrip.py
Normal file
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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: <code>]` 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:
|
||||
|
||||
Reference in New Issue
Block a user