From 7a9eeaddc965724cd5e6b2eaa6a3d68aa61a6d7f Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sun, 6 Sep 2026 14:13:12 +0300 Subject: [PATCH 01/53] chore: bump band-sdk-core to 2.3.0 for AgentFailure The AgentFailure shared failure shape shipped in this release via release-please automation, unblocking the send_failure migration. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CmsEQaNzmSmBGVsCTuKvbX --- pyproject.toml | 2 +- uv.lock | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 32f8f3249..b0d863d58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ classifiers = [ dependencies = [ "band-client-rest==0.0.27", - "band-sdk-core==2.0.0", + "band-sdk-core==2.3.0", "phoenix-channels-python-client>=0.2.4", "python-dotenv>=1.2.2", "pydantic>=2.0", diff --git a/uv.lock b/uv.lock index 7c1e5a58d..949781130 100644 --- a/uv.lock +++ b/uv.lock @@ -774,7 +774,7 @@ requires-dist = [ { name = "anthropic", marker = "extra == 'dev-parlant'", specifier = ">=0.75.0" }, { name = "async-lru", specifier = ">=2.3.0" }, { name = "band-client-rest", specifier = "==0.0.27" }, - { name = "band-sdk-core", specifier = "==2.0.0" }, + { name = "band-sdk-core", specifier = "==2.3.0" }, { name = "band-testing-python", marker = "extra == 'dev'", specifier = "==0.1.4" }, { name = "band-testing-python", marker = "extra == 'dev-crewai'", specifier = "==0.1.4" }, { name = "band-testing-python", marker = "extra == 'dev-parlant'", specifier = "==0.1.4" }, @@ -918,17 +918,17 @@ provides-extras = ["logging", "desktop", "codex", "opencode", "letta", "pydantic [[package]] name = "band-sdk-core" -version = "2.0.0" +version = "2.3.0" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/b0/c0450d0df122aa46ffaf11aabacd5dcf27b81e35ff1f5b08639c0cfa1457/band_sdk_core-2.0.0-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:7db8f08f4dc4616ecee2aa5e8a7bd61d846d959a309a6b25da598e649d4ac278", size = 457389, upload-time = "2026-08-29T12:00:47.019Z" }, - { url = "https://files.pythonhosted.org/packages/a6/99/12d8e5a5fb4ef42e684117de2687cbb7bf8df0b706eeb65587caa9867b49/band_sdk_core-2.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:556f420711b7026b0b3dbb9abf0a2dcdd6f7126f2c09ec40ca281c081229851c", size = 458700, upload-time = "2026-08-29T12:00:48.396Z" }, - { url = "https://files.pythonhosted.org/packages/16/c3/a894dd0a873a80184bdf443e770917c2ec85e671a8c72f4c8400584692e9/band_sdk_core-2.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:19e58438cf932bb0324c0358a8c332437eade63bb253e6f7cddbfa02c928051e", size = 509782, upload-time = "2026-08-29T12:00:49.711Z" }, - { url = "https://files.pythonhosted.org/packages/b1/95/18d976f8a12d24819e3788a2c880ec7d07945673c6a34b68fd7653142ee8/band_sdk_core-2.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:9f581c2173967e44a219d86f124a65650fc915c8c99a1f9ddf69c0e98612253f", size = 510429, upload-time = "2026-08-29T12:00:50.849Z" }, - { url = "https://files.pythonhosted.org/packages/67/87/1eef985931ddddf2f2ff9623622f15bab460d4d943aa7dc96d1da8227d37/band_sdk_core-2.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:430fb51e32080fa58a1c84f8d2342c443865c85a05f8ad727288063e5bdb425a", size = 687161, upload-time = "2026-08-29T12:00:51.987Z" }, - { url = "https://files.pythonhosted.org/packages/08/8d/61d49368320438ad8ee7ebfc689cfc62163cdb9a5d7592e8d48a3438aae4/band_sdk_core-2.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3606b58f5a3d9ff006fcd9db425cc4ac47c4482380146a282e23299ca6f0fcab", size = 724455, upload-time = "2026-08-29T12:00:53.376Z" }, - { url = "https://files.pythonhosted.org/packages/ea/17/c795774be4b1b1c96633c68b5a8f2966bfd0752004835920f6f0a676153a/band_sdk_core-2.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:8ac8e95b466e8110f4e3dd99afaa4520f825c294e2b731d04c246efe1edd7685", size = 331961, upload-time = "2026-08-29T12:00:54.47Z" }, - { url = "https://files.pythonhosted.org/packages/ab/93/aa331d33db1d8436dba57df83d4592c6534223ffc11bf94c1eb5090714cc/band_sdk_core-2.0.0-cp311-abi3-win_arm64.whl", hash = "sha256:d802cb5a411afa5b7e6089962cdc3b4573ad6d43048fc2b28a8a9079a247aa0b", size = 314808, upload-time = "2026-08-29T12:00:55.707Z" }, + { url = "https://files.pythonhosted.org/packages/af/f4/25f7be88054b13dbba887eee2dedcbf50c33e46cb4b4d14b71359b7fcb0d/band_sdk_core-2.3.0-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:9a12f9a447a684b50eccf8fb779928938f72edac02b11bc1b0da67597ff1daa1", size = 478061, upload-time = "2026-09-06T05:54:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/5d/0d/7adc5f3cb9b001ead33ea829932958bb0fff8f5d543a891d1338b94f3ea4/band_sdk_core-2.3.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:4b5232055f48da4d788d83502d9e14453c6153d41c4e9750133f960a6fd18f4d", size = 479953, upload-time = "2026-09-06T05:54:18.464Z" }, + { url = "https://files.pythonhosted.org/packages/45/ab/14449d8967d2fcde4d84455070f5e1d011d9b2f76b22383af97971d20134/band_sdk_core-2.3.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ee15965fc503b372adc6965999acb26dc67cac4d92d7fe7d876b6786b8ae73e4", size = 531154, upload-time = "2026-09-06T05:54:19.997Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ff/5de82d4e5d72436039be905de68436ae5689ac44892214fbca652b0b2f30/band_sdk_core-2.3.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:044769078b6e7ed28062f8280c269e4e904e700c4112f791913ba48d0f255449", size = 530174, upload-time = "2026-09-06T05:54:21.272Z" }, + { url = "https://files.pythonhosted.org/packages/22/b4/cfe7d0d8eaad24c975f4bc6e9d29634225caee6537c1be197a35cc3b0d8a/band_sdk_core-2.3.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4d44b363199fb5bb7b3a21d26c70d443ed381c923cd48f0df2f20f690ac79e23", size = 710040, upload-time = "2026-09-06T05:54:22.7Z" }, + { url = "https://files.pythonhosted.org/packages/17/f2/7758410757818756395449a653f4ba44098af104612eca478c585ec36f59/band_sdk_core-2.3.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:eb541d6c12437e4f12cee4b8db2b5674d8414a8ae018e92267ec237bb1302599", size = 746399, upload-time = "2026-09-06T05:54:24.02Z" }, + { url = "https://files.pythonhosted.org/packages/f4/72/6de9b5820723c04f41ea2644cda496857f0e3151418bf090b80e750f7734/band_sdk_core-2.3.0-cp311-abi3-win_amd64.whl", hash = "sha256:ad2df3ff7ab79d06fe17dd970b95fd9f19c38687368d871bd3e3ab7a328de1de", size = 351424, upload-time = "2026-09-06T05:54:25.483Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8e/cdf700c5553dd0ba879f190b02774b9dff64c7d3438f0acfcb5212477509/band_sdk_core-2.3.0-cp311-abi3-win_arm64.whl", hash = "sha256:adbbdb9cf318bd69e369a02f4fafa7323c2202b903acaaae7ef91175ed61faa1", size = 333893, upload-time = "2026-09-06T05:54:26.84Z" }, ] [[package]] From daaf3e9c303d90d1ae2bc6dd7f21e6d1cc48f46e Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sun, 6 Sep 2026 14:19:12 +0300 Subject: [PATCH 02/53] feat: add send_failure and to_failure_event to AgentToolsProtocol Adds the shared, provider-neutral AgentFailure surfacing contract: send_failure on AgentToolsProtocol/AgentTools/FakeAgentTools, and the to_failure_event helper both implementations delegate to. send_failure is best-effort (swallows its own reporting failure); send_event keeps its existing raising behavior unchanged, since stateful callers (e.g. OpenCode's session-persistence retry) depend on it as a control signal. FakeAgentTools gains a send_event_error hook so tests can simulate a REST rejection without touching send_message's own simulation path. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CmsEQaNzmSmBGVsCTuKvbX --- src/band/core/protocols.py | 28 ++++++++++++++++++ src/band/runtime/tools/agent.py | 17 ++++++++++- src/band/testing/fake_tools.py | 18 ++++++++++++ tests/core/test_protocols.py | 50 ++++++++++++++++++++++++++++++++ tests/runtime/test_tools.py | 45 ++++++++++++++++++++++++++++ tests/testing/test_fake_tools.py | 49 +++++++++++++++++++++++++++++++ 6 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 tests/core/test_protocols.py diff --git a/src/band/core/protocols.py b/src/band/core/protocols.py index 9373fb4bd..6d82335d1 100644 --- a/src/band/core/protocols.py +++ b/src/band/core/protocols.py @@ -4,6 +4,8 @@ from typing import TYPE_CHECKING, Any, Literal, Protocol, TypeVar, runtime_checkable +from band_sdk_core import AgentFailure + if TYPE_CHECKING: from anthropic.types import ToolParam @@ -28,6 +30,22 @@ T = TypeVar("T") +def to_failure_event(failure: AgentFailure) -> tuple[str, dict[str, Any]]: + """Shared shape every ``send_failure`` implementation posts as an `error` event. + + A provider message can arrive blank (``Exception()`` and ``str("")`` both + reach here empty). The platform rejects a blank chat event, so an + unguarded blank message would make the failure vanish from the room + entirely. The fallback string is part of the TS/Python parity contract — + it must match ``toFailureEvent``'s exactly. + """ + content = ( + failure.message.strip() + or f"{failure.provider} failed without an error message." + ) + return content, {"failure": failure.to_dict()} + + @runtime_checkable class HistoryConverter(Protocol[T]): """ @@ -79,6 +97,16 @@ async def send_event( """Send an event (tool_call, tool_result, thought, error, task).""" ... + async def send_failure(self, failure: AgentFailure) -> Any: + """Report a provider-originated failure as a structured `error` event. + + Best-effort: swallows its own reporting failure rather than raising, + so a caller reporting a failure never has that report replaced by an + unrelated exception. Unlike ``send_event``, whose raising callers + depend on it as a control signal. + """ + ... + async def add_participant(self, identifier: str, role: str = "member") -> Any: """Add a participant to the current room by handle, name, or ID.""" ... diff --git a/src/band/runtime/tools/agent.py b/src/band/runtime/tools/agent.py index da29337c6..5fc0c38b9 100644 --- a/src/band/runtime/tools/agent.py +++ b/src/band/runtime/tools/agent.py @@ -45,7 +45,7 @@ organization_scope_rejected_message, validate_subject_scope, ) -from band.core.protocols import AgentToolsProtocol +from band.core.protocols import AgentToolsProtocol, to_failure_event from band.core.task_types import ( TaskAssignmentStatus, TaskIncludeOption, @@ -442,6 +442,21 @@ async def send_event( ), ) + async def send_failure(self, failure: band_sdk_core.AgentFailure) -> Any: + """ + Report a provider-originated failure as a structured error event. + + Best-effort, unlike ``send_event``: this runs inside a caller's own + except block, where raising would replace the provider failure the + room is being told about with an unrelated reporting failure. + """ + content, metadata = to_failure_event(failure) + try: + return await self.send_event(content, "error", metadata) + except Exception as exc: + logger.exception("send_failure could not post the failure event") + return {"ok": False, "error": str(exc)} + async def create_chatroom(self, task_id: str | None = None) -> str: """ Create a new chat room. diff --git a/src/band/testing/fake_tools.py b/src/band/testing/fake_tools.py index 5894ebc89..b86eac8fc 100644 --- a/src/band/testing/fake_tools.py +++ b/src/band/testing/fake_tools.py @@ -8,6 +8,8 @@ from datetime import datetime, timezone from typing import Any, Literal +import band_sdk_core + from band.client.rest import ( AgentContact, AgentMemory, @@ -34,6 +36,7 @@ ) from band.core.content import has_visible_content from band.core.exceptions import BandToolError +from band.core.protocols import to_failure_event from band.core.task_types import TaskAssignmentStatus, TaskLifecycleState, TaskListState from band.core.types import Capability from band.runtime.tools import ( @@ -101,6 +104,9 @@ def __init__( self._hub_room_id = hub_room_id self.messages_sent: list[dict[str, Any]] = [] self.events_sent: list[dict[str, Any]] = [] + # Set to simulate a send_event REST rejection (e.g. proving + # send_failure swallows it while send_event itself still raises). + self.send_event_error: Exception | None = None self._participants: list[dict[str, Any]] = participants or [] self._room_context: list[dict[str, Any]] = list(room_context or []) # Seeds are validated and canonicalized at seed time (not list time), @@ -198,6 +204,8 @@ async def send_event( Same fidelity rationale as ``send_message``: the real send returns ``None`` without a request rather than letting the platform 422. """ + if self.send_event_error is not None: + raise self.send_event_error if not has_visible_content(content): return None event = { @@ -209,6 +217,16 @@ async def send_event( self.events_sent.append(event) return event + async def send_failure( + self, failure: band_sdk_core.AgentFailure + ) -> dict[str, Any] | None: + """Same best-effort delegation as ``AgentTools.send_failure``.""" + content, metadata = to_failure_event(failure) + try: + return await self.send_event(content, "error", metadata) + except Exception as exc: + return {"ok": False, "error": str(exc)} + async def add_participant( self, identifier: str, role: str = "member" ) -> dict[str, Any]: diff --git a/tests/core/test_protocols.py b/tests/core/test_protocols.py new file mode 100644 index 000000000..462966cc3 --- /dev/null +++ b/tests/core/test_protocols.py @@ -0,0 +1,50 @@ +"""Tests for shared AgentToolsProtocol helpers.""" + +from __future__ import annotations + +import pytest +from band_sdk_core import AgentFailure + +from band.core.protocols import to_failure_event + + +class TestToFailureEvent: + """``to_failure_event`` is the one place both SDKs put the failure -> + room-event shape, so its parity string and metadata key are load-bearing.""" + + def test_carries_the_message_and_failure_metadata(self) -> None: + failure = AgentFailure("codex", "boom", "timeout", {"http_status": 500}) + + content, metadata = to_failure_event(failure) + + assert content == "boom" + assert metadata == { + "failure": { + "provider": "codex", + "code": "timeout", + "message": "boom", + "detail": {"http_status": 500}, + } + } + + @pytest.mark.parametrize("blank_message", ["", " ", "\n\t"]) + def test_blank_message_falls_back_to_a_generic_one( + self, blank_message: str + ) -> None: + """A provider message can arrive blank -- the platform rejects a blank + chat event, so an unguarded blank message would make the failure + vanish from the room entirely. The fallback string must match TS's + ``toFailureEvent`` exactly for cross-SDK parity.""" + content, _metadata = to_failure_event(AgentFailure("acp", blank_message)) + + assert content == "acp failed without an error message." + + def test_generic_fallback_has_no_code_or_detail(self) -> None: + """A provider/adapter that gives no structured signal at all still + produces a valid failure -- code and detail default to None rather + than an invented value.""" + content, metadata = to_failure_event(AgentFailure("anthropic", "boom")) + + assert content == "boom" + assert metadata["failure"]["code"] is None + assert metadata["failure"]["detail"] is None diff --git a/tests/runtime/test_tools.py b/tests/runtime/test_tools.py index 8f49f8442..badac4e31 100644 --- a/tests/runtime/test_tools.py +++ b/tests/runtime/test_tools.py @@ -14,6 +14,7 @@ GetAgentChatContextResponse, GetAgentChatContextResponseMetadata, ) +from band_sdk_core import AgentFailure from pydantic import BaseModel, ValidationError from band.client.rest import ( @@ -1489,6 +1490,50 @@ async def test_send_event_refuses_content_with_no_visible_characters( mock_rest_client.agent_api_events.create_agent_chat_event.assert_not_called() +class TestAgentToolsSendFailure: + """Test send_failure's best-effort delegation over the real REST boundary.""" + + async def test_send_failure_posts_an_error_event(self, mock_rest_client): + tools = AgentTools("room-123", mock_rest_client) + + await tools.send_failure(AgentFailure("codex", "boom", "timeout")) + + call_args = mock_rest_client.agent_api_events.create_agent_chat_event.call_args + event = call_args.kwargs["event"] + assert event.message_type == "error" + assert event.metadata["failure"] == { + "provider": "codex", + "code": "timeout", + "message": "boom", + "detail": None, + } + + async def test_send_failure_swallows_a_rest_rejection(self, mock_rest_client): + """A failed report must resolve, not raise -- it runs inside a + caller's except block reporting a real provider failure already.""" + mock_rest_client.agent_api_events.create_agent_chat_event.side_effect = ( + RuntimeError("REST rejected the event") + ) + tools = AgentTools("room-123", mock_rest_client) + + result = await tools.send_failure(AgentFailure("codex", "boom")) + + assert result == {"ok": False, "error": "REST rejected the event"} + + async def test_send_event_itself_still_raises_on_the_same_rejection( + self, mock_rest_client + ): + """The other half of the best-effort contract: send_event's own + raising behavior is unchanged by send_failure wrapping it.""" + mock_rest_client.agent_api_events.create_agent_chat_event.side_effect = ( + RuntimeError("REST rejected the event") + ) + tools = AgentTools("room-123", mock_rest_client) + + with pytest.raises(RuntimeError, match="REST rejected the event"): + await tools.send_event("task update", "task") + + class TestMatchesIdentifier: """Tests for the _matches_identifier helper.""" diff --git a/tests/testing/test_fake_tools.py b/tests/testing/test_fake_tools.py index 5361188e2..55755b4d5 100644 --- a/tests/testing/test_fake_tools.py +++ b/tests/testing/test_fake_tools.py @@ -5,6 +5,7 @@ from typing import Any import pytest +from band_sdk_core import AgentFailure from band.core.exceptions import BandToolError from band.core.protocols import AgentToolsProtocol @@ -236,6 +237,54 @@ async def test_refuses_content_with_no_visible_characters(self, content): assert tools.events_sent == [] +class TestSendFailure: + """Tests for send_failure's best-effort delegation to send_event.""" + + async def test_posts_an_error_event_carrying_the_failure(self): + tools = FakeAgentTools() + + result = await tools.send_failure(AgentFailure("codex", "boom", "timeout")) + + assert len(tools.events_sent) == 1 + assert tools.events_sent[0]["message_type"] == "error" + assert tools.events_sent[0]["metadata"]["failure"] == { + "provider": "codex", + "code": "timeout", + "message": "boom", + "detail": None, + } + assert result["message_type"] == "error" + + async def test_blank_message_falls_back_to_a_generic_one(self): + tools = FakeAgentTools() + + await tools.send_failure(AgentFailure("acp", "")) + + assert tools.events_sent[0]["content"] == "acp failed without an error message." + + async def test_swallows_a_send_event_failure_instead_of_raising(self): + """send_failure must resolve even when the underlying report fails -- + raising here would replace the provider failure being reported with + an unrelated one.""" + tools = FakeAgentTools() + tools.send_event_error = RuntimeError("platform rejected the event") + + result = await tools.send_failure(AgentFailure("codex", "boom")) + + assert result == {"ok": False, "error": "platform rejected the event"} + assert tools.events_sent == [] + + async def test_send_event_itself_still_raises_on_the_same_failure(self): + """The other half of the best-effort contract: send_event's raising + callers (e.g. OpenCode's session-persistence retry) must be + unaffected by send_failure's swallowing.""" + tools = FakeAgentTools() + tools.send_event_error = RuntimeError("platform rejected the event") + + with pytest.raises(RuntimeError, match="platform rejected the event"): + await tools.send_event("task update", "task") + + class TestParticipantOperations: """Tests for participant tracking.""" From 2771d9a4d613b0f29c56133ca7fa7d7a8276582e Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sun, 6 Sep 2026 14:19:57 +0300 Subject: [PATCH 03/53] feat: add DeliveryFailedError/deliver_reply misclassification guard A Band-side send_message rejection must never be reported as a provider failure, even when the send sits inside a shared try/except that also handles real provider errors. deliver_reply wraps the cause so a catch site can re-raise it before falling into its provider-failure branch. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CmsEQaNzmSmBGVsCTuKvbX --- src/band/core/delivery.py | 38 +++++++++++++++++++++++++++++++++++++ tests/core/test_delivery.py | 32 +++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 src/band/core/delivery.py create mode 100644 tests/core/test_delivery.py diff --git a/src/band/core/delivery.py b/src/band/core/delivery.py new file mode 100644 index 000000000..74fa0fb0a --- /dev/null +++ b/src/band/core/delivery.py @@ -0,0 +1,38 @@ +"""Delivery-vs-provider-failure misclassification guard. + +An adapter's reply/bookkeeping post to the room (``send_message``) is Band-side +delivery, never a provider failure -- even when the ``send_message`` call sits +inside a try/except that also handles real provider errors. ``deliver_reply`` +wraps the cause in ``DeliveryFailedError`` so that shared except block can tell +the two apart and re-raise the original cause before its provider branch. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from band.core.protocols import AgentToolsProtocol + + +class DeliveryFailedError(Exception): + """Wraps a ``send_message`` failure so it is never mistaken for a + provider failure by a shared except block.""" + + def __init__(self, cause: BaseException) -> None: + super().__init__(str(cause)) + self.cause = cause + + +async def deliver_reply( + tools: "AgentToolsProtocol", + content: str, + mentions: list[str] | list[dict[str, str]] | None = None, +) -> Any: + """Send a reply, raising ``DeliveryFailedError`` on failure instead of + the raw exception, so the caller's except can distinguish a delivery + failure from a provider failure.""" + try: + return await tools.send_message(content, mentions=mentions) + except Exception as exc: + raise DeliveryFailedError(exc) from exc diff --git a/tests/core/test_delivery.py b/tests/core/test_delivery.py new file mode 100644 index 000000000..f863513ff --- /dev/null +++ b/tests/core/test_delivery.py @@ -0,0 +1,32 @@ +"""Tests for the delivery-vs-provider-failure misclassification guard.""" + +from __future__ import annotations + +import pytest + +from band.core.delivery import DeliveryFailedError, deliver_reply +from band.testing.fake_tools import FakeAgentTools + + +class TestDeliverReply: + async def test_forwards_a_successful_send(self) -> None: + tools = FakeAgentTools() + + result = await deliver_reply(tools, "hello", mentions=["@alice"]) + + assert tools.messages_sent[0]["content"] == "hello" + assert result["content"] == "hello" + + async def test_wraps_a_send_message_failure(self) -> None: + """A raised send_message must become DeliveryFailedError, not + propagate as-is -- so a shared except block reading for a provider + failure can tell delivery and provider failures apart.""" + tools = FakeAgentTools() + + with pytest.raises(DeliveryFailedError) as exc_info: + # FakeAgentTools.send_message raises BandToolError for a + # mention-less send, mirroring the real platform requirement. + await deliver_reply(tools, "hello", mentions=None) + + assert exc_info.value.__cause__ is exc_info.value.cause + assert "mention" in str(exc_info.value.cause).lower() From 2aea71de583d39148a124931c35758564bc00a2a Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sun, 6 Sep 2026 14:29:01 +0300 Subject: [PATCH 04/53] fix: surface Anthropic provider failures via send_failure Replaces the ad hoc "error" event/_report_error guard with send_failure(AgentFailure(...)), preserving APIStatusError's status_code/body as code/detail (or falling back to a generic failure for any other exception type). No change to the existing raise-after-report control flow. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CmsEQaNzmSmBGVsCTuKvbX --- src/band/adapters/anthropic.py | 19 +++++----- tests/adapters/test_anthropic_adapter.py | 46 +++++++++++++++++++++++- 2 files changed, 54 insertions(+), 11 deletions(-) diff --git a/src/band/adapters/anthropic.py b/src/band/adapters/anthropic.py index 5560672c7..1a309b17a 100644 --- a/src/band/adapters/anthropic.py +++ b/src/band/adapters/anthropic.py @@ -11,8 +11,9 @@ import warnings from typing import Any, ClassVar, cast -from anthropic import AsyncAnthropic +from anthropic import APIStatusError, AsyncAnthropic from anthropic.types import Message, MessageParam, TextBlock, ToolParam, ToolUseBlock +from band_sdk_core import AgentFailure from typing_extensions import Unpack from band.core.exceptions import BandConfigError @@ -279,7 +280,13 @@ async def on_message( ) except Exception as e: logger.error("Error calling Anthropic: %s", e, exc_info=True) - await self._report_error(tools, str(e)) + if isinstance(e, APIStatusError): + failure = AgentFailure( + "anthropic", str(e), str(e.status_code), e.body + ) + else: + failure = AgentFailure("anthropic", str(e)) + await tools.send_failure(failure) raise # Re-raise so message is marked as failed turn_usage = turn_usage + self._usage_from_response(response) @@ -512,11 +519,3 @@ async def _process_tool_calls( ) return tool_results - - # --- Copied from BaseFrameworkAgent._report_error --- - async def _report_error(self, tools: AgentToolsProtocol, error: str) -> None: - """Send error event (best effort).""" - try: - await tools.send_event(content=f"Error: {error}", message_type="error") - except Exception as e: - logger.warning("Failed to send error event: %s", e) diff --git a/tests/adapters/test_anthropic_adapter.py b/tests/adapters/test_anthropic_adapter.py index dd8d83c3d..68218506e 100644 --- a/tests/adapters/test_anthropic_adapter.py +++ b/tests/adapters/test_anthropic_adapter.py @@ -14,7 +14,9 @@ from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch +import httpx import pytest +from anthropic import APIStatusError from anthropic.types import TextBlock, ToolUseBlock from pydantic import BaseModel, Field @@ -67,6 +69,7 @@ def mock_tools(): tools.get_tool_schemas = MagicMock(return_value=[]) tools.send_message = AsyncMock(return_value={"status": "sent"}) tools.send_event = AsyncMock(return_value={"status": "sent"}) + tools.send_failure = AsyncMock(return_value={"status": "sent"}) tools.execute_tool_call = AsyncMock(return_value={"status": "success"}) return tools @@ -698,6 +701,13 @@ async def test_handles_tool_error(self, mock_tools): assert "Tool failed!" in results[0]["content"] +def make_api_status_error(status_code: int, body: dict) -> APIStatusError: + """A real anthropic.APIStatusError, built the way the SDK itself would.""" + request = httpx.Request("POST", "https://api.anthropic.com/v1/messages") + response = httpx.Response(status_code, request=request, json=body) + return APIStatusError(body["error"]["message"], response=response, body=body) + + class TestErrorHandling: """Tests for error handling.""" @@ -722,7 +732,41 @@ async def test_reports_error_on_api_failure(self, sample_message, mock_tools): ) # Should have tried to report error - mock_tools.send_event.assert_called() + mock_tools.send_failure.assert_called_once() + failure = mock_tools.send_failure.call_args.args[0] + assert failure.provider == "anthropic" + assert failure.message == "API Error" + assert failure.code is None + assert failure.detail is None + + @pytest.mark.asyncio + async def test_preserves_api_status_error_as_code_and_detail( + self, sample_message, mock_tools + ): + """An APIStatusError's status_code/body are real provider data -- + preserve them rather than falling back to the generic shape.""" + adapter = AnthropicAdapter() + await adapter.on_started("TestBot", "Test bot") + body = {"error": {"type": "overloaded_error", "message": "Overloaded"}} + + with patch.object(adapter, "_call_anthropic") as mock_call: + mock_call.side_effect = make_api_status_error(529, body) + + with pytest.raises(APIStatusError): + await adapter.on_message( + msg=sample_message, + tools=mock_tools, + history=[], + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-123", + ) + + failure = mock_tools.send_failure.call_args.args[0] + assert failure.provider == "anthropic" + assert failure.code == "529" + assert failure.detail == body class EchoInput(BaseModel): From 3fb643e77c107f1156c0240450af8475034bb005 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sun, 6 Sep 2026 14:31:04 +0300 Subject: [PATCH 05/53] fix: surface Gemini provider failures via send_failure Replaces the ad hoc "error" event/_report_error guard with send_failure(AgentFailure(...)), preserving ServerError's status/message as code/detail (generic fallback otherwise). Also closes a gap where exceeding max_tool_rounds raised RuntimeError with zero report at all. No change to the existing raise-after-report control flow. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CmsEQaNzmSmBGVsCTuKvbX --- src/band/adapters/gemini.py | 20 +++++---- tests/adapters/test_gemini_adapter.py | 64 +++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 9 deletions(-) diff --git a/src/band/adapters/gemini.py b/src/band/adapters/gemini.py index 842c063e2..2ca0b35ea 100644 --- a/src/band/adapters/gemini.py +++ b/src/band/adapters/gemini.py @@ -9,6 +9,7 @@ from typing import Any, ClassVar, cast import httpx +from band_sdk_core import AgentFailure from pydantic import ValidationError from typing_extensions import Unpack @@ -240,10 +241,14 @@ async def on_message( try: while True: if tool_rounds >= self.max_tool_rounds: - raise RuntimeError( + max_rounds_error = RuntimeError( f"Exceeded max tool rounds ({self.max_tool_rounds}) " f"in room {room_id}" ) + await tools.send_failure( + AgentFailure("gemini", str(max_rounds_error)) + ) + raise max_rounds_error try: response = await self._call_gemini( @@ -251,7 +256,11 @@ async def on_message( ) except Exception as e: logger.exception("Error calling Gemini: %s", e) - await self._report_error(tools, str(e)) + if isinstance(e, ServerError): + failure = AgentFailure("gemini", str(e), e.status, e.message) + else: + failure = AgentFailure("gemini", str(e)) + await tools.send_failure(failure) raise turn_usage = turn_usage + self._usage_from_response(response) @@ -591,10 +600,3 @@ async def _process_function_calls( ) return tool_response_parts - - async def _report_error(self, tools: AgentToolsProtocol, error: str) -> None: - """Send error event (best effort).""" - try: - await tools.send_event(content=f"Error: {error}", message_type="error") - except Exception as e: - logger.warning("Failed to send error event: %s", e) diff --git a/tests/adapters/test_gemini_adapter.py b/tests/adapters/test_gemini_adapter.py index f6e16a189..71adcd22b 100644 --- a/tests/adapters/test_gemini_adapter.py +++ b/tests/adapters/test_gemini_adapter.py @@ -39,6 +39,7 @@ def mock_tools() -> MagicMock: tools.get_openai_tool_schemas = MagicMock(return_value=[]) tools.send_message = AsyncMock(return_value={"status": "sent"}) tools.send_event = AsyncMock(return_value={"status": "sent"}) + tools.send_failure = AsyncMock(return_value={"status": "sent"}) tools.execute_tool_call = AsyncMock(return_value={"status": "success"}) return tools @@ -200,6 +201,63 @@ def test_extract_candidate_content_preserves_function_call_id_in_fallback(self): assert function_call.args == {"page": "1"} +class TestErrorReporting: + @pytest.mark.asyncio + async def test_reports_generic_failure(self, sample_message, mock_tools): + adapter = GeminiAdapter(provider_key="test-key") + await adapter.on_started("TestBot", "Test bot") + + with patch.object( + adapter, "_call_gemini", AsyncMock(side_effect=Exception("boom")) + ): + with pytest.raises(Exception, match="boom"): + await adapter.on_message( + msg=sample_message, + tools=mock_tools, + history=[], + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-123", + ) + + mock_tools.send_failure.assert_called_once() + failure = mock_tools.send_failure.call_args.args[0] + assert failure.provider == "gemini" + assert failure.message == "boom" + assert failure.code is None + assert failure.detail is None + + @pytest.mark.asyncio + async def test_preserves_server_error_status_and_message( + self, sample_message, mock_tools + ): + """ServerError's status/message are real provider data -- preserve + them as code/detail rather than falling back to the generic shape.""" + adapter = GeminiAdapter(provider_key="test-key") + await adapter.on_started("TestBot", "Test bot") + error = ServerError( + 503, {"error": {"status": "UNAVAILABLE", "message": "overloaded"}}, None + ) + + with patch.object(adapter, "_call_gemini", AsyncMock(side_effect=error)): + with pytest.raises(ServerError): + await adapter.on_message( + msg=sample_message, + tools=mock_tools, + history=[], + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-123", + ) + + failure = mock_tools.send_failure.call_args.args[0] + assert failure.provider == "gemini" + assert failure.code == "UNAVAILABLE" + assert failure.detail == "overloaded" + + class TestRetries: @pytest.mark.asyncio async def test_retries_transient_server_errors(self): @@ -582,6 +640,12 @@ async def test_raises_runtime_error_when_max_rounds_exceeded( room_id="room-123", ) + # Previously reported nothing at all -- this is the added report. + mock_tools.send_failure.assert_called_once() + failure = mock_tools.send_failure.call_args.args[0] + assert failure.provider == "gemini" + assert "Exceeded max tool rounds" in failure.message + class TestHttpxRetries: @pytest.mark.asyncio From c990b3fa1bd9e940f5e00cfafc4f61e723b3447f Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sun, 6 Sep 2026 14:33:04 +0300 Subject: [PATCH 06/53] fix: surface Google ADK provider failures via send_failure Replaces the ad hoc "error" event/_report_error guard with send_failure(AgentFailure(...)). Also widens the try to cover per-message runner construction (_create_runner), which previously ran outside the try entirely and could fail with zero report; the runner now starts as None so the existing finally's close() has nothing to do if construction itself is what failed. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CmsEQaNzmSmBGVsCTuKvbX --- src/band/adapters/google_adk.py | 25 ++++++------ tests/adapters/test_google_adk_adapter.py | 47 ++++++++++++++++------- 2 files changed, 45 insertions(+), 27 deletions(-) diff --git a/src/band/adapters/google_adk.py b/src/band/adapters/google_adk.py index eac5aa15a..af1935174 100644 --- a/src/band/adapters/google_adk.py +++ b/src/band/adapters/google_adk.py @@ -15,6 +15,7 @@ import uuid from typing import ClassVar, TYPE_CHECKING, Any, cast +from band_sdk_core import AgentFailure from pydantic import ValidationError from typing_extensions import Unpack @@ -474,14 +475,18 @@ async def on_message( # Safety: ensure history exists even if not first message self._room_history[room_id] = [] - # A fresh runner is created per message because InMemoryRunner - # accumulates session history internally and tool schemas may change - # between calls. History is injected as a text transcript instead. - runner = self._create_runner(tools) # Per-turn usage, summed across the event stream below. Initialized # outside the try so the finally can emit whatever accumulated. turn_usage = TurnUsage() + # None until the try's construction succeeds, so the finally's close() + # has nothing to do if runner construction itself is what failed. + runner: InMemoryRunner | None = None try: + # A fresh runner is created per message because InMemoryRunner + # accumulates session history internally and tool schemas may change + # between calls. History is injected as a text transcript instead. + runner = self._create_runner(tools) + # Always create a new session ID — each runner is fresh, so there # is no in-memory state to resume. The ID is stored for cleanup # tracking. The session must be pre-created in the runner's @@ -578,7 +583,7 @@ async def on_message( ) except Exception as e: logger.exception("Error running ADK agent in room %s", room_id) - await self._report_error(tools, str(e)) + await tools.send_failure(AgentFailure("google_adk", str(e))) raise finally: # Emit before close so a close() failure can't drop the usage, but @@ -587,7 +592,8 @@ async def on_message( # No-op unless Emit.USAGE is on; best-effort, never raises. await self.emit_usage(tools, turn_usage) finally: - await runner.close() + if runner is not None: + await runner.close() # Accumulate message history for future transcript injection self._room_history[room_id].append( @@ -724,10 +730,3 @@ async def _report_event(self, event: Any, tools: AgentToolsProtocol) -> None: ) except Exception as e: logger.warning("Failed to send tool_result event: %s", e) - - async def _report_error(self, tools: AgentToolsProtocol, error: str) -> None: - """Send error event (best effort).""" - try: - await tools.send_event(content=f"Error: {error}", message_type="error") - except Exception as e: - logger.warning("Failed to send error event: %s", e) diff --git a/tests/adapters/test_google_adk_adapter.py b/tests/adapters/test_google_adk_adapter.py index 2746fa0ac..8d3b02781 100644 --- a/tests/adapters/test_google_adk_adapter.py +++ b/tests/adapters/test_google_adk_adapter.py @@ -75,6 +75,7 @@ def mock_tools(): ) tools.send_message = AsyncMock(return_value={"status": "sent"}) tools.send_event = AsyncMock(return_value={"status": "sent"}) + tools.send_failure = AsyncMock(return_value={"status": "sent"}) tools.execute_tool_call = AsyncMock(return_value={"status": "success"}) return tools @@ -957,10 +958,41 @@ async def failing_run(**kwargs): room_id="room-123", ) - mock_tools.send_event.assert_called() + mock_tools.send_failure.assert_called_once() + failure = mock_tools.send_failure.call_args.args[0] + assert failure.provider == "google_adk" + assert failure.message == "Runner Error" # Runner should be closed even on error (via finally) mock_runner.close.assert_called_once() + @pytest.mark.asyncio + async def test_reports_error_when_runner_construction_itself_fails( + self, sample_message, mock_tools + ): + """Previously uncaught entirely: _create_runner ran outside the try, + so a construction failure escaped with zero report.""" + adapter = GoogleADKAdapter() + await adapter.on_started("TestBot", "Test bot") + + with patch.object( + adapter, "_create_runner", side_effect=RuntimeError("bad tool schema") + ): + with pytest.raises(RuntimeError, match="bad tool schema"): + await adapter.on_message( + msg=sample_message, + tools=mock_tools, + history=[], + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-123", + ) + + mock_tools.send_failure.assert_called_once() + failure = mock_tools.send_failure.call_args.args[0] + assert failure.provider == "google_adk" + assert failure.message == "bad tool schema" + class TestHistoryTranscript: """Tests for _format_history_transcript.""" @@ -1409,19 +1441,6 @@ def test_extract_event_text_no_text_parts(self): assert result == "" -class TestReportErrorFailure: - """Tests for _report_error own failure handling.""" - - @pytest.mark.asyncio - async def test_report_error_handles_own_failure(self, mock_tools): - """Should not raise when _report_error itself fails.""" - adapter = GoogleADKAdapter() - mock_tools.send_event = AsyncMock(side_effect=Exception("Network down")) - - # Should not raise - await adapter._report_error(mock_tools, "some error") - - class TestConcurrentMessages: """Tests for concurrent on_message calls.""" From 6a834e8b55eb0f3f8e0e97caef54bd1e56fe6dbb Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sun, 6 Sep 2026 14:42:39 +0300 Subject: [PATCH 07/53] fix: surface Claude SDK provider failures via send_failure Replaces the ad hoc "error" event/_report_error guard with send_failure(AgentFailure(...)), preserving ResultMessage's api_error_status/errors as code/detail. Closes two previously-silent gaps: a bare re-raise in the session resume-retry path when there is no stored session to fall back to, and the fallback session-creation attempt itself failing. DedupingAgentTools needs no change (forwards send_failure via __getattr__ like every other method). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CmsEQaNzmSmBGVsCTuKvbX --- src/band/adapters/claude_sdk.py | 42 ++++--- tests/adapters/test_claude_sdk_adapter.py | 110 +++++++++++++++--- tests/adapters/test_claude_sdk_tool_names.py | 1 + .../claude_sdk/test_dedup_tools.py | 14 +++ 4 files changed, 137 insertions(+), 30 deletions(-) diff --git a/src/band/adapters/claude_sdk.py b/src/band/adapters/claude_sdk.py index 14a1af986..632473c0e 100644 --- a/src/band/adapters/claude_sdk.py +++ b/src/band/adapters/claude_sdk.py @@ -49,6 +49,7 @@ except ImportError: _CLAUDE_SDK_AVAILABLE = False +from band_sdk_core import AgentFailure from typing_extensions import Unpack from band.core.protocols import AgentToolsProtocol @@ -611,10 +612,15 @@ async def on_message( stored_session_id, resume_exc, ) - client = await self._session_manager.get_or_create_session( - room_id, resume_session_id=None - ) + try: + client = await self._session_manager.get_or_create_session( + room_id, resume_session_id=None + ) + except Exception as fresh_exc: + await tools.send_failure(AgentFailure("claude_sdk", str(fresh_exc))) + raise else: + await tools.send_failure(AgentFailure("claude_sdk", str(resume_exc))) raise # Add chat_id context (Claude needs this for tool calls) -- the label @@ -695,12 +701,12 @@ async def on_message( ) await self._invalidate_session(room_id) - await self._report_error(tools, str(e)) + await tools.send_failure(AgentFailure("claude_sdk", str(e))) raise except Exception as e: logger.exception("Error processing message: %s", e) - await self._report_error(tools, str(e)) + await tools.send_failure(AgentFailure("claude_sdk", str(e))) raise logger.debug("Message %s processed successfully", msg.id) @@ -933,11 +939,25 @@ async def _on_turn_complete( # outright) doesn't linger and grow this room's entry unbounded. notified = self._notified_declines.pop(room_id, None) if sdk_message.is_error: - await self._report_error(tools, self._result_error_detail(sdk_message)) + code = ( + str(sdk_message.api_error_status) + if sdk_message.api_error_status is not None + else None + ) + await tools.send_failure( + AgentFailure( + "claude_sdk", + self._result_error_detail(sdk_message), + code, + sdk_message.errors, + ) + ) elif not replied_this_turn and not self._declined_the_reply( sdk_message.permission_denials, notified ): - await self._report_error(tools, missing_reply_error("Claude SDK")) + await tools.send_failure( + AgentFailure("claude_sdk", missing_reply_error("Claude SDK")) + ) def _declined_the_reply( self, permission_denials: list[Any] | None, notified: set[str] | None @@ -1127,14 +1147,6 @@ async def on_cleanup(self, room_id: str) -> None: self._pending_tool_names.pop(room_id, None) logger.debug("Room %s: Cleaned up Claude SDK session", room_id) - # --- Copied from BaseFrameworkAgent._report_error --- - async def _report_error(self, tools: AgentToolsProtocol, error: str) -> None: - """Send error event (best effort).""" - try: - await tools.send_event(content=f"Error: {error}", message_type="error") - except Exception: - logger.debug("Failed to send error event", exc_info=True) - async def cleanup_all(self) -> None: """Cleanup all sessions (call on stop).""" # Decline all pending approvals across rooms diff --git a/tests/adapters/test_claude_sdk_adapter.py b/tests/adapters/test_claude_sdk_adapter.py index fa88d5c3d..52195bc48 100644 --- a/tests/adapters/test_claude_sdk_adapter.py +++ b/tests/adapters/test_claude_sdk_adapter.py @@ -90,12 +90,8 @@ def _tool_turn(mcp_tool_name: str) -> list: def _error_events(mock_tools: MagicMock) -> list[str]: - """Contents of the error events posted through send_event.""" - return [ - call.kwargs["content"] - for call in mock_tools.send_event.call_args_list - if call.kwargs.get("message_type") == "error" - ] + """Room-visible message of each failure reported through send_failure.""" + return [call.args[0].message for call in mock_tools.send_failure.call_args_list] def _narrated_message_types(mock_tools: MagicMock) -> list[str]: @@ -190,6 +186,7 @@ def mock_tools(): tools = MagicMock() tools.send_message = AsyncMock(return_value={"status": "sent"}) tools.send_event = AsyncMock(return_value={"status": "sent"}) + tools.send_failure = AsyncMock(return_value={"status": "sent"}) tools.add_participant = AsyncMock(return_value={"id": "user-1"}) tools.remove_participant = AsyncMock(return_value={"status": "removed"}) tools.lookup_peers = AsyncMock(return_value={"peers": []}) @@ -457,7 +454,7 @@ class TestErrorHandling: @pytest.mark.asyncio async def test_reports_error_on_query_failure(self, sample_message, mock_tools): - """When client.query raises, adapter reports error via send_event and re-raises.""" + """When client.query raises, adapter reports error via send_failure and re-raises.""" adapter = ClaudeSDKAdapter() mock_client = MagicMock() mock_client.query = AsyncMock(side_effect=Exception("API Error")) @@ -483,10 +480,10 @@ async def test_reports_error_on_query_failure(self, sample_message, mock_tools): room_id="room-123", ) - mock_tools.send_event.assert_called() - call_kwargs = mock_tools.send_event.call_args[1] - assert call_kwargs.get("message_type") == "error" - assert "API Error" in call_kwargs.get("content", "") + mock_tools.send_failure.assert_called_once() + failure = mock_tools.send_failure.call_args.args[0] + assert failure.provider == "claude_sdk" + assert "API Error" in failure.message class TestCLIConnectionError: @@ -564,10 +561,10 @@ async def test_cli_connection_error_reports_error_event( ) # Error should be surfaced to the user - mock_tools.send_event.assert_called() - call_kwargs = mock_tools.send_event.call_args[1] - assert call_kwargs.get("message_type") == "error" - assert "Process dead" in call_kwargs.get("content", "") + mock_tools.send_failure.assert_called_once() + failure = mock_tools.send_failure.call_args.args[0] + assert failure.provider == "claude_sdk" + assert "Process dead" in failure.message @pytest.mark.asyncio async def test_clears_session_id_on_cli_connection_error( @@ -1107,6 +1104,80 @@ async def test_falls_back_to_new_session_on_resume_failure( # Second call should be without resume second_call = mock_manager.get_or_create_session.call_args_list[1] assert second_call == (("room-123",), {"resume_session_id": None}) + # A self-healed retry is not a reportable failure. + mock_tools.send_failure.assert_not_called() + + @pytest.mark.asyncio + async def test_reports_error_when_no_stored_session_to_retry( + self, sample_message, mock_tools + ): + """Previously a bare `raise` with zero report: no stored session id + means there is nothing to fall back to, so the failure must surface.""" + adapter = ClaudeSDKAdapter() + mock_manager = AsyncMock() + mock_manager.get_or_create_session = AsyncMock( + side_effect=Exception("Session setup failed") + ) + + with patch( + "band.adapters.claude_sdk.ClaudeSessionManager", + return_value=mock_manager, + ): + await adapter.on_started( + agent_name="TestBot", agent_description="A test bot" + ) + + with pytest.raises(Exception, match="Session setup failed"): + await adapter.on_message( + msg=sample_message, + tools=mock_tools, + history=ClaudeSDKSessionState(text=""), + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-123", + ) + + mock_tools.send_failure.assert_called_once() + failure = mock_tools.send_failure.call_args.args[0] + assert failure.provider == "claude_sdk" + assert "Session setup failed" in failure.message + + @pytest.mark.asyncio + async def test_reports_error_when_fallback_session_also_fails( + self, sample_message, mock_tools + ): + """The fallback session-creation attempt was previously uncaught by + this scope entirely -- a failure there escaped with zero report.""" + adapter = ClaudeSDKAdapter() + mock_manager = AsyncMock() + mock_manager.get_or_create_session = AsyncMock( + side_effect=[Exception("Resume failed"), Exception("Fresh session failed")] + ) + + with patch( + "band.adapters.claude_sdk.ClaudeSessionManager", + return_value=mock_manager, + ): + await adapter.on_started( + agent_name="TestBot", agent_description="A test bot" + ) + + with pytest.raises(Exception, match="Fresh session failed"): + await adapter.on_message( + msg=sample_message, + tools=mock_tools, + history=ClaudeSDKSessionState(text="", session_id="sess-broken"), + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-123", + ) + + mock_tools.send_failure.assert_called_once() + failure = mock_tools.send_failure.call_args.args[0] + assert failure.provider == "claude_sdk" + assert "Fresh session failed" in failure.message @pytest.mark.asyncio async def test_task_event_failure_does_not_break_flow(self, mock_tools): @@ -1177,6 +1248,11 @@ async def test_reports_error_on_is_error_result(self, mock_tools): errors = _error_events(mock_tools) assert len(errors) == 1 assert "Not logged in · Please run /login" in errors[0] + failure = mock_tools.send_failure.call_args.args[0] + assert failure.provider == "claude_sdk" + # No structured api_error_status on this failure -- code stays unset + # rather than inventing one. + assert failure.code is None @pytest.mark.asyncio async def test_error_detail_includes_api_error_status(self, mock_tools): @@ -1186,6 +1262,7 @@ async def test_error_detail_includes_api_error_status(self, mock_tools): is_error=True, result="Failed to authenticate. API Error: 401", api_error_status=401, + errors=["authentication_error: invalid API key"], ) mock_client = self._client_yielding(result_msg) @@ -1194,6 +1271,9 @@ async def test_error_detail_includes_api_error_status(self, mock_tools): errors = _error_events(mock_tools) assert len(errors) == 1 assert "401" in errors[0] + failure = mock_tools.send_failure.call_args.args[0] + assert failure.code == "401" + assert failure.detail == ["authentication_error: invalid API key"] @pytest.mark.asyncio async def test_reports_missing_reply_when_no_terminal_tool_ran(self, mock_tools): diff --git a/tests/adapters/test_claude_sdk_tool_names.py b/tests/adapters/test_claude_sdk_tool_names.py index 65094349d..645f55f20 100644 --- a/tests/adapters/test_claude_sdk_tool_names.py +++ b/tests/adapters/test_claude_sdk_tool_names.py @@ -47,6 +47,7 @@ async def test_tool_call_event_uses_bare_name() -> None: ) tools = MagicMock() tools.send_event = AsyncMock(return_value={"status": "sent"}) + tools.send_failure = AsyncMock(return_value={"status": "sent"}) assistant = AssistantMessage( content=[ diff --git a/tests/integrations/claude_sdk/test_dedup_tools.py b/tests/integrations/claude_sdk/test_dedup_tools.py index 86954f5d4..fcc60e300 100644 --- a/tests/integrations/claude_sdk/test_dedup_tools.py +++ b/tests/integrations/claude_sdk/test_dedup_tools.py @@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest +from band_sdk_core import AgentFailure from band.integrations.claude_sdk.dedup_tools import ( DEFAULT_DEDUP_MAX_ENTRIES, @@ -20,6 +21,7 @@ def _make_inner() -> MagicMock: inner = MagicMock() inner.send_message = AsyncMock(return_value={"id": "msg-1"}) inner.send_event = AsyncMock(return_value={"id": "evt-1"}) + inner.send_failure = AsyncMock(return_value={"id": "evt-2"}) inner.add_participant = AsyncMock(return_value={"id": "u"}) inner.participants = ["p1", "p2"] return inner @@ -292,6 +294,18 @@ async def test_other_methods_forward_unchanged(self): ) inner.add_participant.assert_awaited_once_with("@svc/bot") + @pytest.mark.asyncio + async def test_send_failure_forwards_unchanged(self): + """No dedup special-casing for send_failure -- __getattr__ forwards + it straight through, same as every other AgentToolsProtocol method.""" + inner = _make_inner() + wrapper = DedupingAgentTools(inner) + failure = AgentFailure("codex", "boom") + + await wrapper.send_failure(failure) + + inner.send_failure.assert_awaited_once_with(failure) + def test_attributes_forward_unchanged(self): inner = _make_inner() wrapper = DedupingAgentTools(inner) From 4437cf796c3eaec4647a042ed5bbfdc695ae147e Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sun, 6 Sep 2026 14:42:44 +0300 Subject: [PATCH 08/53] test: pre-wire send_failure into not-yet-migrated adapter test fixtures Every hand-rolled MagicMock() AgentToolsProtocol double that sets send_event as an AsyncMock needs the same for send_failure, or the next adapter migrated onto it fails with "MagicMock can't be awaited" instead of a real assertion. Fixing the remaining fixtures now so each adapter's own migration commit doesn't have to rediscover this. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CmsEQaNzmSmBGVsCTuKvbX --- tests/adapters/langgraph/conftest.py | 1 + tests/adapters/test_crewai_adapter.py | 1 + tests/adapters/test_parlant_adapter.py | 1 + tests/adapters/test_pydantic_ai_adapter.py | 1 + tests/integrations/parlant/test_tools.py | 1 + 5 files changed, 5 insertions(+) diff --git a/tests/adapters/langgraph/conftest.py b/tests/adapters/langgraph/conftest.py index fcc041c65..7544ff435 100644 --- a/tests/adapters/langgraph/conftest.py +++ b/tests/adapters/langgraph/conftest.py @@ -30,6 +30,7 @@ def mock_tools(): tools = MagicMock() tools.send_message = AsyncMock(return_value={"status": "sent"}) tools.send_event = AsyncMock(return_value={"status": "sent"}) + tools.send_failure = AsyncMock(return_value={"status": "sent"}) tools.add_participant = AsyncMock(return_value={"id": "user-1"}) tools.remove_participant = AsyncMock(return_value={"status": "removed"}) tools.lookup_peers = AsyncMock(return_value={"peers": []}) diff --git a/tests/adapters/test_crewai_adapter.py b/tests/adapters/test_crewai_adapter.py index 8ec46f513..2aec1d4b8 100644 --- a/tests/adapters/test_crewai_adapter.py +++ b/tests/adapters/test_crewai_adapter.py @@ -95,6 +95,7 @@ def mock_tools(): tools.get_openai_tool_schemas = MagicMock(return_value=[]) tools.send_message = AsyncMock(return_value={"status": "sent"}) tools.send_event = AsyncMock(return_value={"status": "sent"}) + tools.send_failure = AsyncMock(return_value={"status": "sent"}) tools.execute_tool_call = AsyncMock(return_value={"status": "success"}) tools.add_participant = AsyncMock( return_value={"id": "123", "name": "Test", "status": "added"} diff --git a/tests/adapters/test_parlant_adapter.py b/tests/adapters/test_parlant_adapter.py index 921c4f332..1f370a599 100644 --- a/tests/adapters/test_parlant_adapter.py +++ b/tests/adapters/test_parlant_adapter.py @@ -42,6 +42,7 @@ def mock_tools(): tools.get_openai_tool_schemas = MagicMock(return_value=[]) tools.send_message = AsyncMock(return_value={"status": "sent"}) tools.send_event = AsyncMock(return_value={"status": "sent"}) + tools.send_failure = AsyncMock(return_value={"status": "sent"}) tools.execute_tool_call = AsyncMock(return_value={"status": "success"}) return tools diff --git a/tests/adapters/test_pydantic_ai_adapter.py b/tests/adapters/test_pydantic_ai_adapter.py index 354990cdf..6118cea0b 100644 --- a/tests/adapters/test_pydantic_ai_adapter.py +++ b/tests/adapters/test_pydantic_ai_adapter.py @@ -152,6 +152,7 @@ def mock_tools(): tools = MagicMock() tools.send_message = AsyncMock(return_value={"status": "sent"}) tools.send_event = AsyncMock(return_value={"status": "sent"}) + tools.send_failure = AsyncMock(return_value={"status": "sent"}) tools.add_participant = AsyncMock(return_value={"id": "user-1"}) tools.remove_participant = AsyncMock(return_value={"status": "removed"}) tools.lookup_peers = AsyncMock(return_value={"peers": []}) diff --git a/tests/integrations/parlant/test_tools.py b/tests/integrations/parlant/test_tools.py index 60872a5db..07b1ae846 100644 --- a/tests/integrations/parlant/test_tools.py +++ b/tests/integrations/parlant/test_tools.py @@ -500,6 +500,7 @@ def mock_tools(self): tools = MagicMock() tools.send_message = AsyncMock() tools.send_event = AsyncMock() + tools.send_failure = AsyncMock() tools.add_participant = AsyncMock(return_value={"status": "added"}) tools.remove_participant = AsyncMock() tools.lookup_peers = AsyncMock( From 4833a5265314add7f99a035d125c96b3f651f3d1 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sun, 6 Sep 2026 14:45:07 +0300 Subject: [PATCH 09/53] fix: surface Copilot SDK provider failures via send_failure Replaces the ad hoc "error" event/_report_error guard with send_failure(AgentFailure(...)). Also widens coverage to _obtain_session (create/resume session setup), which previously ran outside on_message's try entirely and could fail with zero report. _send_event_safe stays untouched -- its other callers depend on its boolean return to drive session-persistence retry. Copilot ACP needs no separate change: it's a pure config subclass of ACPClientAdapter and inherits that adapter's fix. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CmsEQaNzmSmBGVsCTuKvbX --- src/band/adapters/copilot_sdk.py | 21 ++++++++++++------- tests/adapters/copilot_sdk/fakes.py | 4 ++++ .../adapters/copilot_sdk/test_turn_failure.py | 16 ++++++++++++++ 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/src/band/adapters/copilot_sdk.py b/src/band/adapters/copilot_sdk.py index 833415985..21a97e93c 100644 --- a/src/band/adapters/copilot_sdk.py +++ b/src/band/adapters/copilot_sdk.py @@ -14,6 +14,7 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, ClassVar, Literal +from band_sdk_core import AgentFailure from pydantic import ValidationError from band.converters.copilot_sdk import ( @@ -403,9 +404,14 @@ async def on_message( # Same-session calls must not interleave; other rooms run concurrently. async with self._session_manager.turn_lock(room_id): - session, inject_text = await self._obtain_session( - room_id, history, tools, is_session_bootstrap=is_session_bootstrap - ) + try: + session, inject_text = await self._obtain_session( + room_id, history, tools, is_session_bootstrap=is_session_bootstrap + ) + except Exception as exc: + logger.exception("Room %s: Copilot session setup failed", room_id) + await tools.send_failure(AgentFailure("copilot_sdk", str(exc))) + raise prompt = self._compose_prompt( msg, participants_msg, @@ -430,7 +436,7 @@ async def on_message( # Abort any work the runtime is still doing for this turn and # drop the session; the next message resumes it fresh by id. await self._session_manager.evict_session(room_id) - await self._report_error(tools, str(exc)) + await tools.send_failure(AgentFailure("copilot_sdk", str(exc))) raise finally: self._turn_state.pop(room_id, None) @@ -444,7 +450,9 @@ async def on_message( # Session errors raise out of send_and_wait, so a None here # with no room output means the model genuinely said nothing. if final_text is None and not turn.replied_in_room: - await self._report_error(tools, "no assistant reply") + await tools.send_failure( + AgentFailure("copilot_sdk", "no assistant reply") + ) raise RuntimeError("Copilot turn produced no reply") # The turn may already have replied into the room; sending its @@ -962,6 +970,3 @@ async def _send_event_safe( logger.warning("Failed to send %s event: %s", message_type, exc) return False return True - - async def _report_error(self, tools: AgentToolsProtocol, error: str) -> None: - await self._send_event_safe(tools, f"Error: {error}", MessageType.ERROR) diff --git a/tests/adapters/copilot_sdk/fakes.py b/tests/adapters/copilot_sdk/fakes.py index cf496cfba..c836df096 100644 --- a/tests/adapters/copilot_sdk/fakes.py +++ b/tests/adapters/copilot_sdk/fakes.py @@ -147,10 +147,12 @@ def __init__( self, *, resume_error: Exception | None = None, + create_error: Exception | None = None, reply_content: str | None = "Hello from Copilot", turn_events: list[Any] | None = None, ): self.resume_error = resume_error + self.create_error = create_error self.reply_content = reply_content self.turn_events = turn_events or [] self.started = False @@ -168,6 +170,8 @@ async def stop(self) -> None: async def create_session( self, *, session_id: str | None = None, **kwargs: Any ) -> Any: + if self.create_error: + raise self.create_error session = FakeCopilotSession( session_id, kwargs, diff --git a/tests/adapters/copilot_sdk/test_turn_failure.py b/tests/adapters/copilot_sdk/test_turn_failure.py index 10dbf9f24..6e36af7c2 100644 --- a/tests/adapters/copilot_sdk/test_turn_failure.py +++ b/tests/adapters/copilot_sdk/test_turn_failure.py @@ -34,6 +34,7 @@ async def test_failed_turn_aborts_and_evicts_session(self): assert dead.disconnected error_events = [e for e in tools.events_sent if e["message_type"] == "error"] assert error_events + assert error_events[0]["metadata"]["failure"]["provider"] == "copilot_sdk" # ...so the next message starts clean, resuming by the stored id. await run_message(adapter, tools, is_session_bootstrap=False) @@ -70,3 +71,18 @@ async def test_empty_final_text_raises_no_reply(self): await run_message(adapter, tools) assert not tools.messages_sent + + @pytest.mark.asyncio + async def test_session_creation_failure_is_reported(self): + """Previously fully uncaught: session setup ran outside on_message's + try entirely, so a create_session failure escaped with zero report.""" + client = FakeCopilotClient(create_error=RuntimeError("Copilot CLI unreachable")) + adapter = await make_started_adapter(client) + tools = ToolSchemaFakeTools() + + with pytest.raises(RuntimeError, match="Copilot CLI unreachable"): + await run_message(adapter, tools) + + error_events = [e for e in tools.events_sent if e["message_type"] == "error"] + assert error_events + assert error_events[0]["metadata"]["failure"]["provider"] == "copilot_sdk" From e85b559cd30e0017480ca15bcefb861b3c5e9283 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sun, 6 Sep 2026 14:47:00 +0300 Subject: [PATCH 10/53] fix: surface LangGraph provider failures via send_failure Replaces the ad hoc "error" event with send_failure(AgentFailure(...)), preserving the existing redaction guarantee: exception text can carry DB strings, paths, and tokens, so message stays a fixed generic string and code/detail stay unset -- never populated from the caught exception. Also widens the try to cover graph-factory construction, which previously ran outside it entirely and could fail with zero report. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CmsEQaNzmSmBGVsCTuKvbX --- src/band/adapters/langgraph.py | 137 +++++++++++---------- tests/adapters/langgraph/test_lifecycle.py | 51 ++++++-- 2 files changed, 114 insertions(+), 74 deletions(-) diff --git a/src/band/adapters/langgraph.py b/src/band/adapters/langgraph.py index 4e3b0e8bc..2204891f5 100644 --- a/src/band/adapters/langgraph.py +++ b/src/band/adapters/langgraph.py @@ -8,6 +8,7 @@ from collections import OrderedDict from typing import ClassVar, TYPE_CHECKING, Any, Callable +from band_sdk_core import AgentFailure from langgraph.checkpoint.memory import InMemorySaver from langgraph.pregel import Pregel from typing_extensions import Unpack @@ -284,62 +285,6 @@ async def on_message( """Handle message with LangGraph.""" logger.info("[HANDLE] Message %s in room %s", msg.id, room_id) - # Get LangChain tools - lc_tools = ( - langchain_tools.agent_tools_to_langchain( - tools, - features=self.features, - ) - + self.additional_tools - ) - - # Build or get graph - if self.graph_factory: - graph = self.graph_factory(lc_tools) - else: - graph = self._static_graph - - if not graph: - raise RuntimeError("No graph available") - - checkpointer = getattr(graph, "checkpointer", None) or self._simple_checkpointer - if checkpointer is not None: - self._room_checkpointers[room_id] = checkpointer - - # Build messages - messages: list[Any] = [] - - # Session bootstrap: prepend the rendered system prompt and hydrate - # platform history exactly once per room. After that, the LangGraph - # checkpointer carries the system message and prior turns forward and - # we just append the new user turn. - should_mark_bootstrapped = False - if is_session_bootstrap and room_id not in self._bootstrapped_rooms: - checkpointer_already_has_messages = ( - checkpointer is not None - and await self._checkpointer_has_messages(checkpointer, room_id) - ) - if not checkpointer_already_has_messages: - if self._inject_system_prompt and self._system_prompt: - messages.append(("system", self._system_prompt)) - if history: - messages.extend(history) # Already converted by history_converter - should_mark_bootstrapped = True - - # Inject metadata updates as user messages with [System]: prefix. - # Many LLM providers (including Anthropic) require a single system - # message at the start; additional system messages scattered through - # the conversation cause errors and kill provider cache savings. - if participants_msg: - messages.append(("user", f"[System]: {participants_msg}")) - - if contacts_msg: - messages.append(("user", f"[System]: {contacts_msg}")) - - messages.append(("user", msg.format_for_llm())) - - graph_input = {"messages": messages} - # Usage is reported per model call on the stream; a turn may make several # (a tool loop), so sum across every on_chat_model_end into one TurnUsage, # emitted on every exit via the finally. Gated outside the loop: the @@ -348,6 +293,66 @@ async def on_message( track_usage = Emit.USAGE in self.features.emit turn_usage = TurnUsage() try: + # Get LangChain tools + lc_tools = ( + langchain_tools.agent_tools_to_langchain( + tools, + features=self.features, + ) + + self.additional_tools + ) + + # Build or get graph + if self.graph_factory: + graph = self.graph_factory(lc_tools) + else: + graph = self._static_graph + + if not graph: + raise RuntimeError("No graph available") + + checkpointer = ( + getattr(graph, "checkpointer", None) or self._simple_checkpointer + ) + if checkpointer is not None: + self._room_checkpointers[room_id] = checkpointer + + # Build messages + messages: list[Any] = [] + + # Session bootstrap: prepend the rendered system prompt and hydrate + # platform history exactly once per room. After that, the LangGraph + # checkpointer carries the system message and prior turns forward and + # we just append the new user turn. + should_mark_bootstrapped = False + if is_session_bootstrap and room_id not in self._bootstrapped_rooms: + checkpointer_already_has_messages = ( + checkpointer is not None + and await self._checkpointer_has_messages(checkpointer, room_id) + ) + if not checkpointer_already_has_messages: + if self._inject_system_prompt and self._system_prompt: + messages.append(("system", self._system_prompt)) + if history: + messages.extend( + history + ) # Already converted by history_converter + should_mark_bootstrapped = True + + # Inject metadata updates as user messages with [System]: prefix. + # Many LLM providers (including Anthropic) require a single system + # message at the start; additional system messages scattered through + # the conversation cause errors and kill provider cache savings. + if participants_msg: + messages.append(("user", f"[System]: {participants_msg}")) + + if contacts_msg: + messages.append(("user", f"[System]: {contacts_msg}")) + + messages.append(("user", msg.format_for_llm())) + + graph_input = {"messages": messages} + async for event in graph.astream_events( graph_input, config={ @@ -376,17 +381,17 @@ async def on_message( except Exception: logger.exception("Error processing message %s", msg.id) - try: - # Keep the user-facing payload generic; the full traceback is - # in the agent log via logger.exception above. Tool/error - # internals can include DB strings, paths, and tokens that - # should not surface in chat. - await tools.send_event( - content="Internal error while processing message; see agent logs.", - message_type="error", + # Keep the user-facing payload generic; the full traceback is in + # the agent log via logger.exception above. Tool/error internals + # can include DB strings, paths, and tokens that should not + # surface in chat -- code/detail stay unset, never populated from + # the caught exception. + await tools.send_failure( + AgentFailure( + "langgraph", + "Internal error while processing message; see agent logs.", ) - except Exception: - logger.exception("Failed to report error event for message %s", msg.id) + ) raise finally: # No-op unless Emit.USAGE is on; best-effort, never raises. diff --git a/tests/adapters/langgraph/test_lifecycle.py b/tests/adapters/langgraph/test_lifecycle.py index dd338ec26..8d8dbb74a 100644 --- a/tests/adapters/langgraph/test_lifecycle.py +++ b/tests/adapters/langgraph/test_lifecycle.py @@ -327,11 +327,46 @@ async def failing_stream(*args, **kwargs): room_id="room-123", ) - # Should have tried to report an error event, AND that event - # must NOT include the raw exception text (it can carry DB - # strings, paths, tokens, etc.). The full traceback only goes - # to the agent log via logger.exception. - mock_tools.send_event.assert_awaited() - call_kwargs = mock_tools.send_event.call_args.kwargs - assert call_kwargs["message_type"] == "error" - assert "Graph error!" not in call_kwargs["content"] + # Should have tried to report a failure, AND that failure must + # NOT include the raw exception text anywhere -- message, code, + # or detail (it can carry DB strings, paths, tokens, etc.). The + # full traceback only goes to the agent log via logger.exception. + mock_tools.send_failure.assert_awaited_once() + failure = mock_tools.send_failure.call_args.args[0] + assert failure.provider == "langgraph" + assert "Graph error!" not in failure.message + assert failure.code is None + assert failure.detail is None + + @pytest.mark.asyncio + async def test_reports_error_when_graph_factory_yields_no_graph( + self, sample_message, mock_tools, mock_llm, mock_checkpointer + ): + """Previously uncaught entirely: graph-factory construction ran + outside the try, so a bad factory's RuntimeError escaped unreported.""" + adapter = LangGraphAdapter( + llm=mock_llm, + checkpointer=mock_checkpointer, + ) + await adapter.on_started("TestBot", "Test bot") + adapter.graph_factory = MagicMock(return_value=None) + + with patch( + "band.integrations.langgraph.langchain_tools.agent_tools_to_langchain" + ) as mock_convert: + mock_convert.return_value = [] + + with pytest.raises(RuntimeError, match="No graph available"): + await adapter.on_message( + msg=sample_message, + tools=mock_tools, + history=[], + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-123", + ) + + mock_tools.send_failure.assert_awaited_once() + failure = mock_tools.send_failure.call_args.args[0] + assert failure.provider == "langgraph" From 6a6db89e6a6d701b82568fc5992aee9ac599a139 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sun, 6 Sep 2026 14:53:10 +0300 Subject: [PATCH 11/53] fix: surface Letta provider failures via send_failure Replaces the ad hoc "error" event/_report_error guard with send_failure(AgentFailure(...)) across all report-and-return call sites, unchanged control flow. Routes the auto-relay reply through deliver_reply so a Band-side send_message rejection surfaces as a delivery failure rather than a misclassified Letta provider failure. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CmsEQaNzmSmBGVsCTuKvbX --- src/band/adapters/letta.py | 50 +++++++++++++++++----------- tests/adapters/test_letta_adapter.py | 39 ++++++++++++++++++++++ 2 files changed, 70 insertions(+), 19 deletions(-) diff --git a/src/band/adapters/letta.py b/src/band/adapters/letta.py index 91c824a32..974eb8bd9 100644 --- a/src/band/adapters/letta.py +++ b/src/band/adapters/letta.py @@ -9,9 +9,11 @@ from datetime import datetime, timezone from typing import ClassVar, Any +from band_sdk_core import AgentFailure from typing_extensions import Unpack from band.converters.letta import LettaHistoryConverter, LettaSessionState +from band.core.delivery import DeliveryFailedError, deliver_reply from band.core.protocols import AgentToolsProtocol from band.core.simple_adapter import SimpleAdapter from band.core.types import ( @@ -267,7 +269,9 @@ async def on_message( """Handle incoming message via Letta API with MCP tools.""" if not self._client: logger.error("Letta client not initialized, dropping message %s", msg.id) - await self._report_error(tools, "Letta adapter not initialized") + await tools.send_failure( + AgentFailure("letta", "Letta adapter not initialized") + ) return # Lock only protects MCP/agent setup, not the full message path. @@ -284,7 +288,7 @@ async def on_message( await self._ensure_agent(room_id, history, tools) except Exception as e: logger.exception("Room %s: Failed to prepare Letta session: %s", room_id, e) - await self._report_error(tools, str(e)) + await tools.send_failure(AgentFailure("letta", str(e))) return await self._handle_message( @@ -311,7 +315,9 @@ async def _handle_message( """Run one Letta turn: resolve the room, compose the message, send.""" if (room_ctx := await self._room_context(room_id, history, tools)) is None: logger.error("Room %s: No Letta agent context, dropping message", room_id) - await self._report_error(tools, "Letta agent context unavailable") + await tools.send_failure( + AgentFailure("letta", "Letta agent context unavailable") + ) return # Point the MCP resolver at this room's current tools for the @@ -418,19 +424,30 @@ async def _run_turn( ), timeout=self.config.turn_timeout_s, ) + except DeliveryFailedError as e: + # The agent answered; posting the reply to the room is what + # failed. Band-side delivery, never a Letta provider failure. + logger.exception( + "Room %s: Failed to deliver Letta agent's reply: %s", + room_id, + e.cause, + ) except asyncio.TimeoutError: logger.error( "Room %s: Letta turn timed out after %ss", room_id, self.config.turn_timeout_s, ) - await self._report_error( - tools, - f"Letta agent response timed out after {self.config.turn_timeout_s}s", + await tools.send_failure( + AgentFailure( + "letta", + f"Letta agent response timed out after {self.config.turn_timeout_s}s", + "timeout", + ) ) except Exception as e: logger.exception("Room %s: Error during Letta turn: %s", room_id, e) - await self._report_error(tools, str(e)) + await tools.send_failure(AgentFailure("letta", str(e))) else: if room_ctx.pending_seed: room_ctx.pending_seed = [] @@ -576,10 +593,12 @@ async def _process_response_messages( room_id, self._mcp.send_message_tool, ) - await self._report_error( - tools, - f"Letta agent did not call {self._mcp.send_message_tool} " - "(auto-relay disabled); its reply was dropped", + await tools.send_failure( + AgentFailure( + "letta", + f"Letta agent did not call {self._mcp.send_message_tool} " + "(auto-relay disabled); its reply was dropped", + ) ) else: final_text = "\n\n".join(final_text_parts) @@ -589,7 +608,7 @@ async def _process_response_messages( room_id, self._mcp.send_message_tool, ) - await tools.send_message(final_text, mentions=mentions) + await deliver_reply(tools, final_text, mentions=mentions) return final_text_parts @@ -1174,10 +1193,3 @@ def _extract_summary(parts: list[str], max_length: int = 150) -> str: if len(text) <= max_length: return text return text[:max_length].rsplit(" ", 1)[0] + "..." - - async def _report_error(self, tools: AgentToolsProtocol, error: str) -> None: - """Send error event (best effort).""" - try: - await tools.send_event(content=f"Error: {error}", message_type="error") - except Exception: - logger.debug("Failed to report error to platform: %s", error) diff --git a/tests/adapters/test_letta_adapter.py b/tests/adapters/test_letta_adapter.py index bcc8cebf2..3f41d264a 100644 --- a/tests/adapters/test_letta_adapter.py +++ b/tests/adapters/test_letta_adapter.py @@ -148,6 +148,42 @@ async def test_auto_relay_when_no_send_message( assert len(tools.messages_sent) == 1 assert tools.messages_sent[0]["content"] == "I'll help you!" + @pytest.mark.asyncio + async def test_send_message_failure_is_not_reported_as_provider_failure( + self, adapter_with_client: tuple[LettaAdapter, AsyncMock] + ) -> None: + """The Letta agent answered fine; the room POST is what failed. That + must not surface as a Letta AgentFailure -- deliver_reply's + DeliveryFailedError must be recognized and left unreported here.""" + adapter, mock_client = adapter_with_client + adapter._rooms["room-1"] = RoomContext(agent_id="agent-1") + + mock_client.agents.messages.create.return_value = make_letta_response( + make_assistant_message("I'll help you!") + ) + + tools = FakeAgentTools() + + async def _raise(*args: Any, **kwargs: Any) -> None: + raise RuntimeError("platform rejected the message") + + tools.send_message = _raise # type: ignore[method-assign] + + msg = make_platform_message() + history = LettaSessionState() + + await adapter.on_message( + msg, + tools, + history, + None, + None, + is_session_bootstrap=False, + room_id="room-1", + ) + + assert not [e for e in tools.events_sent if e["message_type"] == "error"] + @pytest.mark.asyncio async def test_skip_auto_relay_when_send_message_used( self, adapter_with_client: tuple[LettaAdapter, AsyncMock] @@ -211,6 +247,9 @@ async def slow_response(**kwargs: Any) -> MagicMock: error_events = [e for e in tools.events_sent if e["message_type"] == "error"] assert len(error_events) == 1 assert "timed out" in error_events[0]["content"] + failure = error_events[0]["metadata"]["failure"] + assert failure["provider"] == "letta" + assert failure["code"] == "timeout" @pytest.mark.asyncio async def test_participants_and_contacts_injected( From 312ba96025a8dbcc7c67c97b38a2266ee6f62ec3 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sun, 6 Sep 2026 14:59:16 +0300 Subject: [PATCH 12/53] fix: surface A2A provider failures via send_failure Replaces the ad hoc "error" event with send_failure(AgentFailure(...)), preserving the task's state_name(state) as code for a terminal failure state. Routes both room-reply and task-update send_message calls through deliver_reply so a Band-side delivery rejection is never misclassified as an A2A provider failure -- the existing report-and-return control flow is otherwise unchanged. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CmsEQaNzmSmBGVsCTuKvbX --- src/band/integrations/a2a/adapter.py | 27 +++++++++++++------------- tests/integrations/a2a/test_adapter.py | 12 +++++++++--- 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/src/band/integrations/a2a/adapter.py b/src/band/integrations/a2a/adapter.py index 15042ac84..04a7bf42e 100644 --- a/src/band/integrations/a2a/adapter.py +++ b/src/band/integrations/a2a/adapter.py @@ -19,9 +19,11 @@ Task, TaskState, ) +from band_sdk_core import AgentFailure from typing_extensions import Unpack from band.converters.a2a import A2AHistoryConverter +from band.core.delivery import DeliveryFailedError, deliver_reply from band.core.protocols import AgentToolsProtocol from band.core.simple_adapter import SimpleAdapter from band.core.types import Capability, Emit, FeatureKwargs, PlatformMessage @@ -172,13 +174,13 @@ async def on_message( event, tools, room_id, msg.sender_id, msg.sender_name ) + except DeliveryFailedError as e: + # The A2A agent answered; posting its reply to the room is what + # failed. Band-side delivery, never an A2A provider failure. + logger.exception("A2A reply delivery failed: %s", e.cause) except Exception as e: logger.exception("A2A agent error: %s", e) - await tools.send_event( - content=f"A2A agent error: {e}", - message_type="error", - metadata={"a2a_error": str(e)}, - ) + await tools.send_failure(AgentFailure("a2a", str(e))) async def _handle_event( self, @@ -222,8 +224,9 @@ async def _deliver_message( """Forward a direct A2A message to its Band sender.""" text = get_message_text(message) if text: - await tools.send_message( - content=text, + await deliver_reply( + tools, + text, mentions=[{"id": sender_id, "name": sender_name or ""}], ) @@ -259,22 +262,18 @@ async def _deliver_task_update( if state == TaskState.TASK_STATE_INPUT_REQUIRED: text = self._get_status_text(task) or "Please provide more information." - await tools.send_message(content=text, mentions=[sender]) + await deliver_reply(tools, text, mentions=[sender]) return if state == TaskState.TASK_STATE_COMPLETED: response = self._extract_response(task) if response: - await tools.send_message(content=response, mentions=[sender]) + await deliver_reply(tools, response, mentions=[sender]) return if state in TERMINAL_TASK_STATES: error_text = self._get_status_text(task) or f"Task {state_name(state)}" - await tools.send_event( - content=error_text, - message_type="error", - metadata={"a2a_state": state_name(state)}, - ) + await tools.send_failure(AgentFailure("a2a", error_text, state_name(state))) def _finalize_task(self, room_id: str, task_id: str) -> None: """Release a terminal task after its Band output and state are persisted.""" diff --git a/tests/integrations/a2a/test_adapter.py b/tests/integrations/a2a/test_adapter.py index 1d7ed508e..f575e0937 100644 --- a/tests/integrations/a2a/test_adapter.py +++ b/tests/integrations/a2a/test_adapter.py @@ -22,6 +22,7 @@ TaskStatus, ) +from band.core.delivery import DeliveryFailedError from band.core.types import PlatformMessage from band.integrations.a2a import A2AAdapter, A2AAuth, A2ASessionState from band.integrations.a2a.adapter import _SSE_READ_TIMEOUT_S @@ -298,10 +299,11 @@ async def test_terminal_task_is_finalized_even_when_band_delivery_fails( tools.send_message = AsyncMock(side_effect=RuntimeError("Band unavailable")) task = make_task(artifact_text="Final response") - with pytest.raises(RuntimeError, match="Band unavailable"): + with pytest.raises(DeliveryFailedError) as exc_info: await adapter._handle_event( task_event(task), tools, "room-123", "user-456", "Test User" ) + assert "Band unavailable" in str(exc_info.value.cause) assert tools.events_sent[-1]["metadata"]["a2a_task_state"] == ( "TASK_STATE_COMPLETED" @@ -334,7 +336,9 @@ async def test_auth_required_task_is_posted_as_error_event( ] assert error_events, "an auth-required task must produce an error event" assert error_events[-1]["content"] == "Please authenticate" - assert error_events[-1]["metadata"]["a2a_state"] == "TASK_STATE_AUTH_REQUIRED" + failure = error_events[-1]["metadata"]["failure"] + assert failure["provider"] == "a2a" + assert failure["code"] == "TASK_STATE_AUTH_REQUIRED" @pytest.mark.asyncio async def test_input_required_is_forwarded_and_persisted( @@ -403,7 +407,9 @@ async def test_failed_task_is_posted_as_error_event( ] assert error_events, "a failed task must produce an error event" assert error_events[-1]["content"] == "boom" - assert error_events[-1]["metadata"]["a2a_state"] == "TASK_STATE_FAILED" + failure = error_events[-1]["metadata"]["failure"] + assert failure["provider"] == "a2a" + assert failure["code"] == "TASK_STATE_FAILED" @pytest.mark.asyncio async def test_working_status_text_is_narrated_as_thought( From ef6f45ade41951d30cb49aeeb4ccaffffcdc4869 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sun, 6 Sep 2026 15:07:20 +0300 Subject: [PATCH 13/53] fix: surface CrewAI provider failures via send_failure Report AgentFailure for the not-initialized guard, the missing-reply guard, and the generic turn exception, replacing the ad hoc _report_error helper (now dead and removed). Also tidies a stale comment in the Claude SDK test file left over from that adapter's own migration. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CmsEQaNzmSmBGVsCTuKvbX --- src/band/adapters/crewai.py | 32 ++++++++-------- tests/adapters/test_claude_sdk_adapter.py | 5 +-- tests/adapters/test_crewai_adapter.py | 45 ++++++++++++++--------- 3 files changed, 45 insertions(+), 37 deletions(-) diff --git a/src/band/adapters/crewai.py b/src/band/adapters/crewai.py index 95fe74090..2cba6d430 100644 --- a/src/band/adapters/crewai.py +++ b/src/band/adapters/crewai.py @@ -14,6 +14,7 @@ from contextvars import ContextVar from typing import ClassVar, TYPE_CHECKING, Any +from band_sdk_core import AgentFailure from typing_extensions import Unpack from band.core.protocols import AgentToolsProtocol @@ -281,9 +282,11 @@ async def on_message( logger.debug("Handling message %s in room %s", msg.id, room_id) if not self._crewai_agent: - raise RuntimeError( + error = RuntimeError( "CrewAI agent not initialized - ensure on_started() was called" ) + await tools.send_failure(AgentFailure("crewai", str(error))) + raise error # Set context variable for tool access (thread-safe room context). # Wrap in try/finally immediately to ensure cleanup even if code @@ -406,15 +409,17 @@ async def _process_message( ) if not (reply_tracker is not None and reply_tracker.replied): - await self._report_error( - tools, - missing_reply_error( - "CrewAI", - detail=( - "Repeated tool failures may also have exhausted " - f"max_iter={self.max_iter}." + await tools.send_failure( + AgentFailure( + "crewai", + missing_reply_error( + "CrewAI", + detail=( + "Repeated tool failures may also have exhausted " + f"max_iter={self.max_iter}." + ), ), - ), + ) ) logger.info( @@ -448,7 +453,7 @@ async def _process_message( ) return logger.error("Error processing message: %s", e, exc_info=True) - await self._report_error(tools, str(e)) + await tools.send_failure(AgentFailure("crewai", str(e))) raise logger.debug( @@ -462,10 +467,3 @@ async def on_cleanup(self, room_id: str) -> None: if room_id in self._message_history: del self._message_history[room_id] logger.debug("Room %s: Cleaned up CrewAI session", room_id) - - async def _report_error(self, tools: AgentToolsProtocol, error: str) -> None: - """Send error event (best effort).""" - try: - await tools.send_event(content=f"Error: {error}", message_type="error") - except Exception as e: - logger.warning("Failed to send error event: %s", e) diff --git a/tests/adapters/test_claude_sdk_adapter.py b/tests/adapters/test_claude_sdk_adapter.py index 52195bc48..04281c3e5 100644 --- a/tests/adapters/test_claude_sdk_adapter.py +++ b/tests/adapters/test_claude_sdk_adapter.py @@ -67,9 +67,8 @@ # The reply tool as the SDK namespaces it (MCP_TOOL_PREFIX + bare name). _SEND_MESSAGE_MCP_NAME = "mcp__band__band_send_message" _ANY_MODEL = "claude-sonnet-4-6" -# What a turn that ended without a reply going out must say — the "Error: " -# prefix is _report_error's own formatting, asserted by substring below -# rather than re-derived here. +# What a turn that ended without a reply going out must say, asserted by +# substring below rather than re-derived here. _MISSING_REPLY_TEXT = missing_reply_error("Claude SDK") diff --git a/tests/adapters/test_crewai_adapter.py b/tests/adapters/test_crewai_adapter.py index 2aec1d4b8..166e8a843 100644 --- a/tests/adapters/test_crewai_adapter.py +++ b/tests/adapters/test_crewai_adapter.py @@ -460,7 +460,10 @@ async def test_reports_error_on_kickoff_failure( room_id="room-123", ) - mock_tools.send_event.assert_called() + mock_tools.send_failure.assert_awaited_once() + failure = mock_tools.send_failure.call_args.args[0] + assert failure.provider == "crewai" + assert failure.message == "Agent Error" @pytest.mark.asyncio async def test_reports_error_when_crewai_completes_without_reply( @@ -485,11 +488,11 @@ async def test_reports_error_when_crewai_completes_without_reply( room_id="room-123", ) - mock_tools.send_event.assert_awaited_once() - event_kwargs = mock_tools.send_event.await_args.kwargs - assert event_kwargs["message_type"] == "error" - assert "band_send_message" in event_kwargs["content"] - assert "max_iter=20" in event_kwargs["content"] + mock_tools.send_failure.assert_awaited_once() + failure = mock_tools.send_failure.call_args.args[0] + assert failure.provider == "crewai" + assert "band_send_message" in failure.message + assert "max_iter=20" in failure.message @pytest.mark.asyncio async def test_reports_error_when_crewai_returns_none_without_reply( @@ -512,10 +515,10 @@ async def test_reports_error_when_crewai_returns_none_without_reply( room_id="room-123", ) - mock_tools.send_event.assert_awaited_once() - event_kwargs = mock_tools.send_event.await_args.kwargs - assert event_kwargs["message_type"] == "error" - assert "band_send_message" in event_kwargs["content"] + mock_tools.send_failure.assert_awaited_once() + failure = mock_tools.send_failure.call_args.args[0] + assert failure.provider == "crewai" + assert "band_send_message" in failure.message @pytest.mark.asyncio async def test_does_not_report_completion_error_after_reply( @@ -550,7 +553,7 @@ async def _kickoff(_messages): room_id="room-123", ) - mock_tools.send_event.assert_not_called() + mock_tools.send_failure.assert_not_awaited() @pytest.mark.asyncio async def test_suppresses_empty_final_answer_after_reply( @@ -591,8 +594,8 @@ async def _kickoff(_messages): room_id="room-123", ) - # No error event posted to the room. - mock_tools.send_event.assert_not_called() + # No failure reported to the room. + mock_tools.send_failure.assert_not_awaited() @pytest.mark.asyncio async def test_suppresses_empty_final_answer_after_tool_only_turn( @@ -635,8 +638,8 @@ async def _kickoff(_messages): room_id="room-123", ) - # No error event posted to the room. - mock_tools.send_event.assert_not_called() + # No failure reported to the room. + mock_tools.send_failure.assert_not_awaited() @pytest.mark.asyncio @pytest.mark.parametrize( @@ -685,8 +688,11 @@ async def _kickoff(_messages): room_id="room-123", ) - # And it must surface as an error event in the room. - mock_tools.send_event.assert_called() + # And it must surface as a reported failure. + mock_tools.send_failure.assert_awaited_once() + failure = mock_tools.send_failure.call_args.args[0] + assert failure.provider == "crewai" + assert failure.message == str(error) @pytest.mark.asyncio async def test_raises_error_when_agent_not_initialized( @@ -707,6 +713,11 @@ async def test_raises_error_when_agent_not_initialized( room_id="room-123", ) + mock_tools.send_failure.assert_awaited_once() + failure = mock_tools.send_failure.call_args.args[0] + assert failure.provider == "crewai" + assert "not initialized" in failure.message + class TestVerboseMode: @pytest.mark.asyncio From 634ac13638a4416919da8f8ae5bed94f0275914a Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sun, 6 Sep 2026 15:11:18 +0300 Subject: [PATCH 14/53] fix: surface CrewAI Flow provider failures via send_failure record_failed's visibility event now reports an AgentFailure instead of a hand-built send_event error payload, matching every other migrated adapter. The task-status event's own embedded error field (a distinct, flow-internal envelope) is unrelated and untouched. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CmsEQaNzmSmBGVsCTuKvbX --- src/band/adapters/crewai_flow.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/band/adapters/crewai_flow.py b/src/band/adapters/crewai_flow.py index ec574e9c2..a3d7db28c 100644 --- a/src/band/adapters/crewai_flow.py +++ b/src/band/adapters/crewai_flow.py @@ -26,6 +26,7 @@ from typing import Any, Callable, Literal, Protocol, Union, runtime_checkable from uuid import UUID +from band_sdk_core import AgentFailure from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator from typing_extensions import Unpack @@ -937,15 +938,10 @@ async def record_waiting(self, reason: str) -> None: ) async def record_failed(self, error: CrewAIFlowError) -> None: - # Best-effort error event for visibility, then the task event. - try: - await self._tools.send_event( - content=f"flow error: {error.code}: {error.message}"[:500], - message_type="error", - metadata={"error": error.model_dump()}, - ) - except Exception: # noqa: BLE001 - logger.warning("Failed to emit error event", exc_info=True) + # Best-effort failure event for visibility, then the task event. + await self._tools.send_failure( + AgentFailure("crewai_flow", error.message, error.code) + ) await self._send_event( content=f"failed:{error.code}", message_type="task", From 8b422723240a7e373a29bc803450e2ab28706a3f Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sun, 6 Sep 2026 15:17:28 +0300 Subject: [PATCH 15/53] fix: surface Pydantic AI provider failures via send_failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The turn's run loop had no catch-all: only UnexpectedModelBehavior was handled, and its genuine-failure branch (as well as every other exception type) propagated with zero report — the worst gap in the adapter survey. Consolidate into one except Exception, mirroring the CrewAI adapter's swallow-then-report structure, so every exception that isn't the benign post-reply output-retry exhaustion now reports an AgentFailure before propagating. Also migrates the missing-reply guard off the deleted _report_error helper. The band_respond_contact_request tool handler's own error event is left untouched: that failure originates from a Band platform tool call, not the pydantic_ai provider, so it is out of scope for AgentFailure. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CmsEQaNzmSmBGVsCTuKvbX --- src/band/adapters/pydantic_ai.py | 30 ++++---- tests/adapters/test_pydantic_ai_adapter.py | 79 +++++++++++++++++++++- 2 files changed, 88 insertions(+), 21 deletions(-) diff --git a/src/band/adapters/pydantic_ai.py b/src/band/adapters/pydantic_ai.py index b45f3b7c2..5207cfea1 100644 --- a/src/band/adapters/pydantic_ai.py +++ b/src/band/adapters/pydantic_ai.py @@ -12,7 +12,7 @@ from collections.abc import Callable from typing import Any, ClassVar, Literal, cast, get_origin, get_type_hints -import httpx +from band_sdk_core import AgentFailure from pydantic_ai import ( Agent, AgentRunResultEvent, @@ -35,7 +35,6 @@ ) from pydantic_ai.models import ModelRequestContext -from band_rest.core.api_error import ApiError from typing_extensions import Unpack from band.core.protocols import AgentToolsProtocol @@ -1025,7 +1024,7 @@ async def on_message( room_id, dropped, ) - except UnexpectedModelBehavior as e: + except Exception as e: # A turn that already did its work must not fail over the reply the model # owes pydantic-ai. Allowing `None` — and normalizing blank text into it # — ends the ordinary nothing-left-to-say response cleanly, but some @@ -1034,8 +1033,12 @@ async def on_message( # band_send_message reply, a band_store_memory, ...) the work already went # out, so that exhaustion is benign — mirror the crewai adapter and # swallow it. Genuine no-response failures (no terminal tool ran — only - # read-only lookups or failed tools) still propagate. - if tool_executed and _is_output_retries_exhausted(e): + # read-only lookups or failed tools) still surface and propagate. + if ( + tool_executed + and isinstance(e, UnexpectedModelBehavior) + and _is_output_retries_exhausted(e) + ): logger.warning( "Room %s: Pydantic AI exhausted its output retries after " "the agent already did productive work this turn; treating as " @@ -1055,6 +1058,7 @@ async def on_message( ModelRequest(parts=[UserPromptPart(content=user_message)]), ] return + await tools.send_failure(AgentFailure("pydantic_ai", str(e))) raise finally: capture_cm.__exit__(None, None, None) @@ -1074,7 +1078,9 @@ async def on_message( # either answered in plain text or said nothing at all. Surface it as an # error (mirrors the crewai adapter) instead of letting it vanish. if not tool_executed: - await self._report_error(tools, missing_reply_error("Pydantic AI")) + await tools.send_failure( + AgentFailure("pydantic_ai", missing_reply_error("Pydantic AI")) + ) logger.debug( "Room %s: Pydantic AI agent completed (history now has %s messages)", @@ -1150,18 +1156,6 @@ def _usage_from_messages(messages: list[ModelMessage]) -> TurnUsage: total = total + PydanticAIAdapter._usage_from_usage_obj(message.usage) return total - async def _report_error(self, tools: AgentToolsProtocol, error: str) -> None: - """Send an error event to the room (best effort). - - Structurally mirrors the crewai adapter, but narrows the catch to the REST - call's real failure modes (ApiError = HTTP status, httpx = transport) so a - failed error-report never crashes the turn — while a real bug still raises. - """ - try: - await tools.send_event(content=f"Error: {error}", message_type="error") - except (ApiError, httpx.HTTPError) as e: - logger.warning("Failed to send error event: %s", e) - # --- Copied from BandPydanticAgent._cleanup_session --- async def on_cleanup(self, room_id: str) -> None: """Clean up message history when agent leaves a room.""" diff --git a/tests/adapters/test_pydantic_ai_adapter.py b/tests/adapters/test_pydantic_ai_adapter.py index 6118cea0b..fa8e58a09 100644 --- a/tests/adapters/test_pydantic_ai_adapter.py +++ b/tests/adapters/test_pydantic_ai_adapter.py @@ -980,6 +980,36 @@ async def test_creates_agent_lazily_if_not_started( mock_create.assert_called_once() + @pytest.mark.asyncio + async def test_reports_failure_when_no_terminal_tool_ran( + self, sample_message, mock_tools, mock_pydantic_agent + ): + """A clean run that never called a reply/terminal tool is a silently + dropped turn — must still surface as a failure, even without an + exception.""" + adapter = PydanticAIAdapter(model="openai:gpt-5.4") + with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): + await adapter.on_started("TestBot", "Test bot") + + adapter._agent.run_stream_events = MagicMock( + return_value=make_stream_events(result_messages=[]) + ) + + await adapter.on_message( + msg=sample_message, + tools=mock_tools, + history=[], + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-123", + ) + + mock_tools.send_failure.assert_awaited_once() + failure = mock_tools.send_failure.call_args.args[0] + assert failure.provider == "pydantic_ai" + assert "band_send_message" in failure.message + class TestOnCleanup: """Tests for on_cleanup() method.""" @@ -1430,9 +1460,10 @@ async def test_event_failure_does_not_crash_run( with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): await adapter.on_started("TestBot", "Test bot") - # Mock tools where send_event fails with a real transport error (the kind - # _report_error narrowly tolerates); a generic Exception would be a bug and - # is intentionally left to propagate. + # Mock tools where send_event fails with a real transport error — the + # tool_call event's own local guard swallows this and logs a warning; + # a generic Exception would be a bug and is intentionally left to + # propagate. failing_tools = AsyncMock() failing_tools.send_event = AsyncMock( side_effect=httpx.ConnectError("Network error") @@ -1555,6 +1586,7 @@ async def test_empty_output_after_tool_is_benign( isinstance(part, UserPromptPart) and "Hello, agent!" in str(part.content) for part in preserved[-1].parts ) + mock_tools.send_failure.assert_not_awaited() @pytest.mark.asyncio async def test_empty_output_preserves_full_captured_turn( @@ -1628,6 +1660,10 @@ async def test_empty_output_without_tool_propagates( room_id="room-123", ) + mock_tools.send_failure.assert_awaited_once() + failure = mock_tools.send_failure.call_args.args[0] + assert failure.provider == "pydantic_ai" + @pytest.mark.asyncio async def test_failed_run_still_emits_captured_usage( self, sample_message, mock_tools, mock_pydantic_agent @@ -1711,6 +1747,43 @@ async def test_unrelated_model_error_propagates_even_after_tool( room_id="room-123", ) + mock_tools.send_failure.assert_awaited_once() + assert mock_tools.send_failure.call_args.args[0].provider == "pydantic_ai" + + @pytest.mark.asyncio + async def test_generic_provider_error_reports_and_propagates( + self, sample_message, mock_tools, mock_pydantic_agent + ): + """A failure that isn't UnexpectedModelBehavior at all (a raw provider/API + error) must still surface as a failure and propagate — previously this + class of error had no except clause at all and vanished uncaught.""" + adapter = PydanticAIAdapter(model="openai:gpt-5.4") + with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): + await adapter.on_started("TestBot", "Test bot") + + adapter._agent.run_stream_events = MagicMock( + return_value=make_raising_stream( + RuntimeError("provider connection reset"), + tool_result=False, + ) + ) + + with pytest.raises(RuntimeError, match="provider connection reset"): + await adapter.on_message( + msg=sample_message, + tools=mock_tools, + history=[], + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-123", + ) + + mock_tools.send_failure.assert_awaited_once() + failure = mock_tools.send_failure.call_args.args[0] + assert failure.provider == "pydantic_ai" + assert failure.message == "provider connection reset" + @pytest.mark.asyncio async def test_empty_output_after_read_only_tool_propagates( self, sample_message, mock_tools, mock_pydantic_agent From e0781561e3705b0be0bca327468e389f19eec88b Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sun, 6 Sep 2026 15:21:06 +0300 Subject: [PATCH 16/53] fix: surface Parlant provider failures via send_failure Migrates the session-init and generic-turn-error reports off the deleted _report_error helper, and adds a report to the uninitialized-app guard, which previously returned silently with zero report. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CmsEQaNzmSmBGVsCTuKvbX --- src/band/adapters/parlant.py | 17 +++++---- tests/adapters/test_parlant_adapter.py | 48 +++++++++++++++++++++++--- 2 files changed, 51 insertions(+), 14 deletions(-) diff --git a/src/band/adapters/parlant.py b/src/band/adapters/parlant.py index b6490c274..a2eb8be6a 100644 --- a/src/band/adapters/parlant.py +++ b/src/band/adapters/parlant.py @@ -14,6 +14,7 @@ from dataclasses import dataclass, field from typing import ClassVar, TYPE_CHECKING, Any +from band_sdk_core import AgentFailure from typing_extensions import Unpack from band.core.protocols import AgentToolsProtocol @@ -377,6 +378,9 @@ async def on_message( if not self._app: logger.error("Parlant Application not initialized") + await tools.send_failure( + AgentFailure("parlant", "Parlant Application not initialized") + ) return app = self._app @@ -387,7 +391,9 @@ async def on_message( session_id = await self._get_or_create_session(room_id, sender_name) except Exception as e: logger.error("Failed to get/create session for room %s: %s", room_id, e) - await self._report_error(tools, f"Session initialization failed: {e}") + await tools.send_failure( + AgentFailure("parlant", f"Session initialization failed: {e}") + ) return session_id_str = str(session_id) @@ -445,7 +451,7 @@ async def on_message( except Exception as e: logger.error("Error processing message: %s", e, exc_info=True) - await self._report_error(tools, str(e)) + await tools.send_failure(AgentFailure("parlant", str(e))) raise finally: # Clear tools after message processing @@ -854,13 +860,6 @@ async def on_cleanup(self, room_id: str) -> None: logger.debug("Room %s: Cleaned up Parlant session", room_id) - async def _report_error(self, tools: AgentToolsProtocol, error: str) -> None: - """Send error event (best effort).""" - try: - await tools.send_event(content=f"Error: {error}", message_type="error") - except Exception: - logger.exception("Failed to send error event") - async def cleanup_all(self) -> None: """Release all sessions and the owned Parlant server (call on stop).""" self._room_sessions.clear() diff --git a/tests/adapters/test_parlant_adapter.py b/tests/adapters/test_parlant_adapter.py index 1f370a599..f1454de72 100644 --- a/tests/adapters/test_parlant_adapter.py +++ b/tests/adapters/test_parlant_adapter.py @@ -793,8 +793,43 @@ async def test_reports_error_on_failure( room_id="room-123", ) - # Should have tried to report error - mock_tools.send_event.assert_called() + # Should have tried to report the failure + mock_tools.send_failure.assert_awaited_once() + failure = mock_tools.send_failure.call_args.args[0] + assert failure.provider == "parlant" + assert failure.message == "API error" + + @pytest.mark.asyncio + async def test_reports_error_on_session_init_failure( + self, mock_parlant_server, mock_parlant_agent, sample_message, mock_tools + ): + """A session-creation failure reports and returns without raising.""" + adapter = ParlantAdapter( + server=mock_parlant_server, + parlant_agent=mock_parlant_agent, + ) + adapter.agent_name = "TestBot" + + mock_app = MagicMock() + mock_app.sessions = AsyncMock() + mock_app.sessions.create = AsyncMock(side_effect=Exception("db unreachable")) + adapter._app = mock_app + + # Must not raise: session init failures are reported, not propagated. + await adapter.on_message( + msg=sample_message, + tools=mock_tools, + history=[], + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-123", + ) + + mock_tools.send_failure.assert_awaited_once() + failure = mock_tools.send_failure.call_args.args[0] + assert failure.provider == "parlant" + assert "Session initialization failed" in failure.message @pytest.mark.asyncio async def test_clears_tools_on_error( @@ -848,14 +883,13 @@ async def test_clears_tools_on_error( async def test_handles_uninitialized_app( self, mock_parlant_server, mock_parlant_agent, sample_message, mock_tools ): - """Should handle case when app is not initialized.""" + """An uninitialized app returns early, but must still report the failure.""" adapter = ParlantAdapter( server=mock_parlant_server, parlant_agent=mock_parlant_agent, ) # Don't set _app - # Should return early without error await adapter.on_message( msg=sample_message, tools=mock_tools, @@ -866,8 +900,12 @@ async def test_handles_uninitialized_app( room_id="room-123", ) - # No calls should be made + # No reply attempt, but the failure is reported. mock_tools.send_message.assert_not_called() + mock_tools.send_failure.assert_awaited_once() + failure = mock_tools.send_failure.call_args.args[0] + assert failure.provider == "parlant" + assert "not initialized" in failure.message class TestResponseWaitBudget: From fde418ab9f81a20b9ea0ab73796d1234b270cef1 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sun, 6 Sep 2026 15:24:52 +0300 Subject: [PATCH 17/53] fix: surface Strands provider failures via send_failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _run_turn's agent construction + invoke_async had no except clause at all, so any provider failure propagated with zero report — the worst gap in this adapter. Wrap both in one except Exception that reports before reraising, and migrate the missing-reply guard off the deleted _report_error helper. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CmsEQaNzmSmBGVsCTuKvbX --- src/band/adapters/strands.py | 28 +++++++++++--------------- tests/adapters/test_strands_adapter.py | 1 + 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/src/band/adapters/strands.py b/src/band/adapters/strands.py index 95ce084fc..207ab9c9b 100644 --- a/src/band/adapters/strands.py +++ b/src/band/adapters/strands.py @@ -7,7 +7,7 @@ from collections.abc import Awaitable, Callable, Mapping from typing import Any, ClassVar, cast -import httpx +from band_sdk_core import AgentFailure from pydantic import BaseModel try: @@ -31,7 +31,6 @@ "Install with: uv add band-sdk[strands]" ) from error -from band_rest.core.api_error import ApiError from typing_extensions import Unpack from band.core.protocols import AgentToolsProtocol @@ -525,12 +524,17 @@ async def _run_turn( hooks: BandTurnHooks, ) -> None: """Run the framework loop while preserving transcript and usage on failure.""" - agent = self._build_agent(history, tools, hooks) + agent: Agent | None = None try: + agent = self._build_agent(history, tools, hooks) await agent.invoke_async(message) + except Exception as e: + await tools.send_failure(AgentFailure("strands", str(e))) + raise finally: - self._message_history[room_id] = agent.messages - await self.emit_usage(tools, self._usage_from_agent(agent)) + if agent is not None: + self._message_history[room_id] = agent.messages + await self.emit_usage(tools, self._usage_from_agent(agent)) async def on_message( self, @@ -570,7 +574,9 @@ async def on_message( hooks=hooks, ) if not hooks.terminal_fired: - await self._report_error(tools, missing_reply_error("Strands")) + await tools.send_failure( + AgentFailure("strands", missing_reply_error("Strands")) + ) logger.debug( "Room %s: Strands agent completed (history now has %s messages)", room_id, @@ -592,16 +598,6 @@ def _usage_from_agent(agent: Agent) -> TurnUsage: cache_write="cacheWriteInputTokens", ) - async def _report_error(self, tools: AgentToolsProtocol, error: str) -> None: - """Post a best-effort room-visible adapter error.""" - try: - await tools.send_event( - content=f"Error: {error}", - message_type=MessageType.ERROR, - ) - except (ApiError, httpx.HTTPError) as report_error: - logger.warning("Failed to send error event: %s", report_error) - async def on_cleanup(self, room_id: str) -> None: """Discard the transcript when Band removes the adapter from a room.""" if self._message_history.pop(room_id, None) is not None: diff --git a/tests/adapters/test_strands_adapter.py b/tests/adapters/test_strands_adapter.py index 975750efe..5af449e7a 100644 --- a/tests/adapters/test_strands_adapter.py +++ b/tests/adapters/test_strands_adapter.py @@ -635,6 +635,7 @@ async def test_provider_failure_keeps_the_transcript_and_reports_usage( assert usage[0]["metadata"][USAGE_METADATA_KEY]["input_tokens"] == ( _INPUT_TOKENS_PER_CALL ) + assert "provider down" in _errors(tools)[0] class TestUsageMapping: From 0a38f43f330220394df6b2e3cb5375a04d7453b6 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sun, 6 Sep 2026 15:32:38 +0300 Subject: [PATCH 18/53] fix: surface Agno provider failures via send_failure _run_agent's shared except now reports an AgentFailure instead of a raw send_event: message stays a fixed, redacted string (Agno's swallowed run/exception text can carry DB strings, paths, or tokens), and code is set to RunStatus.error's value only when the exception is an AgnoRunError -- never populated from response.content. Also widens the reported boundary to the agent-null guard and _build_run_input, both previously unguarded ahead of _run_agent's try. Investigated but did not build the contextvar-based delivery-failure slot the plan called for: band_send_message failures never reach here as a raised exception. execute_tool_call_structured (agent.py) catches every non-BandToolError exception and returns it as a plain string result, and send_message's own delivery path (post_message) never raises BandToolError -- so a Band-side delivery failure can't reach _run_agent's except at all, let alone get misclassified as an AgnoRunError. Verified against the installed agno package's own run-loop and function-call exception handling. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CmsEQaNzmSmBGVsCTuKvbX --- src/band/adapters/agno.py | 64 ++++++++++++++++------------- tests/adapters/agno/test_adapter.py | 6 +++ 2 files changed, 41 insertions(+), 29 deletions(-) diff --git a/src/band/adapters/agno.py b/src/band/adapters/agno.py index 5a85f0c7f..4db69c569 100644 --- a/src/band/adapters/agno.py +++ b/src/band/adapters/agno.py @@ -12,6 +12,7 @@ from agno.media import Image from agno.tools.function import ToolResult +from band_sdk_core import AgentFailure from typing_extensions import Unpack from band.core.protocols import AgentToolsProtocol @@ -333,14 +334,18 @@ async def on_message( is_session_bootstrap, ) - messages = self._build_run_input( - msg, - history, - participants_msg, - contacts_msg, - is_session_bootstrap=is_session_bootstrap, - room_id=room_id, - ) + try: + messages = self._build_run_input( + msg, + history, + participants_msg, + contacts_msg, + is_session_bootstrap=is_session_bootstrap, + room_id=room_id, + ) + except Exception as e: + await tools.send_failure(AgentFailure("agno", str(e))) + raise response = await self._run_agent( messages, tools, room_id=room_id, msg_id=msg.id ) @@ -465,18 +470,18 @@ async def _run_agent( :meth:`_run_streamed`), matching the other adapters' live reporting. Otherwise it runs non-streaming, exactly as before. """ - agent = self._agent - if agent is None: - raise RuntimeError("AgnoAdapter was used before on_started()") - session_id = self._session_id_factory(room_id) - logger.debug( - "Room %s msg %s: running Agno agent (%d input messages, session_id=%s)", - room_id, - msg_id, - len(messages), - session_id, - ) try: + agent = self._agent + if agent is None: + raise RuntimeError("AgnoAdapter was used before on_started()") + session_id = self._session_id_factory(room_id) + logger.debug( + "Room %s msg %s: running Agno agent (%d input messages, session_id=%s)", + room_id, + msg_id, + len(messages), + session_id, + ) with _bind_room_tools(tools): if Emit.TOOL_CALLS in self.features.emit: response = await self._run_streamed( @@ -494,22 +499,23 @@ async def _run_agent( # the turn as failed rather than as a silent empty reply. if response is not None and response.status == RunStatus.error: raise AgnoRunError(_error_summary(response.content)) - except Exception: + except Exception as e: # Keep the user-facing payload generic; the full traceback is in the # agent log via logger.exception. Exception text can include DB - # strings, paths, and tokens that must not surface in chat. + # strings, paths, and tokens that must not surface in chat. Only + # the coarse RunStatus.error code -- never response.content -- is + # safe to attach. logger.exception( "Room %s msg %s: error running Agno agent", room_id, msg_id ) - try: - await tools.send_event( - content="Internal error while processing message; see agent logs.", - message_type="error", - ) - except Exception: - logger.exception( - "Room %s msg %s: failed to report error event", room_id, msg_id + code = RunStatus.error.value if isinstance(e, AgnoRunError) else None + await tools.send_failure( + AgentFailure( + "agno", + "Internal error while processing message; see agent logs.", + code, ) + ) raise if response is None: diff --git a/tests/adapters/agno/test_adapter.py b/tests/adapters/agno/test_adapter.py index 51d1837e8..0a583baef 100644 --- a/tests/adapters/agno/test_adapter.py +++ b/tests/adapters/agno/test_adapter.py @@ -897,6 +897,10 @@ async def test_emits_generic_error_event_and_reraises( ) # The exception text (which can carry secrets) must not leak to the room. assert "secret-token" not in errors[0]["content"] + failure = errors[0]["metadata"]["failure"] + assert failure["provider"] == "agno" + # A plain RuntimeError isn't a swallowed Agno run status -- no code. + assert failure["code"] is None async def test_error_status_run_is_raised_and_reported( self, make_started_adapter, tools @@ -925,6 +929,7 @@ async def test_error_status_run_is_raised_and_reported( errors = [e for e in tools.events_sent if e["message_type"] == "error"] assert len(errors) == 1 assert "secret-token" not in errors[0]["content"] + assert errors[0]["metadata"]["failure"]["code"] == RunStatus.error.value # A failed turn must not be committed to the room transcript. assert not adapter._message_history.get("room-A") @@ -954,6 +959,7 @@ async def test_streaming_error_event_is_raised_and_reported( errors = [e for e in tools.events_sent if e["message_type"] == "error"] assert len(errors) == 1 assert "secret-token" not in errors[0]["content"] + assert errors[0]["metadata"]["failure"]["code"] == RunStatus.error.value assert not adapter._message_history.get("room-A") async def test_error_event_failure_does_not_mask_original( From 59cd1d4035aea58f198c200da20ee078f123c89c Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sun, 6 Sep 2026 15:39:55 +0300 Subject: [PATCH 19/53] fix: surface OpenCode provider failures via send_failure Migrates 4 of the adapter's 8 "error" sites to AgentFailure: the HTTP error branch (preserving the status code), the generic turn-failure fallback, the turn-timeout report (code="timeout"), and the terminal last_error_message delivered at end of turn. The other 4 stay send_event: the still-processing backpressure guard, the delivery -failure notice, and the two human-approval-timeout notices in approvals.py -- none of these are provider failures. Adds events_of_type/reported_failures test helpers (mirroring the existing per-package convention in the ACP and Codex test suites) and negative-assertion coverage proving the two approval-timeout sites never carry the shared failure metadata shape. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CmsEQaNzmSmBGVsCTuKvbX --- src/band/adapters/opencode/adapter.py | 30 ++++++++++++------- tests/adapters/opencode/helpers.py | 10 +++++++ tests/adapters/opencode/test_approvals.py | 6 ++++ tests/adapters/opencode/test_turns.py | 35 +++++++++++++++++++---- 4 files changed, 64 insertions(+), 17 deletions(-) diff --git a/src/band/adapters/opencode/adapter.py b/src/band/adapters/opencode/adapter.py index e3877c228..e75b03441 100644 --- a/src/band/adapters/opencode/adapter.py +++ b/src/band/adapters/opencode/adapter.py @@ -13,6 +13,7 @@ from typing import ClassVar, Any import httpx +from band_sdk_core import AgentFailure from typing_extensions import Unpack from band.adapters.opencode.approvals import ApprovalPorts, RoomApprovals @@ -493,15 +494,19 @@ async def on_message( raise except httpx.HTTPStatusError as exc: logger.exception("OpenCode request failed for room %s", room_id) - await tools.send_event( - self._format_http_error(exc), - "error", + await tools.send_failure( + AgentFailure( + "opencode", + self._format_http_error(exc), + str(exc.response.status_code), + ) ) except Exception: logger.exception("Unexpected OpenCode adapter failure in room %s", room_id) - await tools.send_event( - "OpenCode failed while processing the message.", - "error", + await tools.send_failure( + AgentFailure( + "opencode", "OpenCode failed while processing the message." + ) ) async def on_cleanup(self, room_id: str) -> None: @@ -921,9 +926,12 @@ async def _watch_turn_completion( ) await self._abort_session(room_state, "timed-out") if room_state.tools: - await room_state.tools.send_event( - "OpenCode timed out before completing the turn.", - "error", + await room_state.tools.send_failure( + AgentFailure( + "opencode", + "OpenCode timed out before completing the turn.", + "timeout", + ) ) # Tokens spent before the timeout were still spent — emit them, same # as the success path (best-effort; no-op if none captured). @@ -1117,8 +1125,8 @@ async def _deliver_fallback_text(self, room_state: RoomState) -> None: text, mentions=room_state.pending_mentions ) elif room_state.last_error_message: - await room_state.tools.send_event( - room_state.last_error_message, "error" + await room_state.tools.send_failure( + AgentFailure("opencode", room_state.last_error_message) ) elif not replied: await room_state.tools.send_message( diff --git a/tests/adapters/opencode/helpers.py b/tests/adapters/opencode/helpers.py index 19e841618..720f0b26a 100644 --- a/tests/adapters/opencode/helpers.py +++ b/tests/adapters/opencode/helpers.py @@ -212,6 +212,16 @@ def tools_protocol(tools: FakeAgentTools) -> AgentToolsProtocol: return cast(AgentToolsProtocol, tools) +def events_of_type(tools: FakeAgentTools, message_type: str) -> list[dict[str, Any]]: + """Events of ``message_type`` captured on ``tools.events_sent``.""" + return [e for e in tools.events_sent if e["message_type"] == message_type] + + +def reported_failures(tools: FakeAgentTools) -> list[dict[str, Any]]: + """Every ``AgentFailure`` reported via ``send_failure``, as its wire dict.""" + return [e["metadata"]["failure"] for e in events_of_type(tools, "error")] + + class RaisingSendTools(FakeAgentTools): """FakeAgentTools whose send_message always fails, to exercise the best-effort ``_notify_room`` path: a room post that raises must be diff --git a/tests/adapters/opencode/test_approvals.py b/tests/adapters/opencode/test_approvals.py index 39755a829..c6f3cecc1 100644 --- a/tests/adapters/opencode/test_approvals.py +++ b/tests/adapters/opencode/test_approvals.py @@ -628,6 +628,9 @@ async def test_permission_timeout_expiry() -> None: assert fake_client.permission_replies[0]["response"] == "reject" error_events = [e for e in tools.events_sent if e["message_type"] == "error"] assert any("timed out" in e["content"].lower() for e in error_events) + # A human-approval timeout is a Band-side procedural notice, never an + # AgentFailure -- it must not carry the shared failure metadata shape. + assert "failure" not in error_events[0]["metadata"] await adapter.on_cleanup("room-1") @@ -684,6 +687,9 @@ async def test_question_timeout_expiry() -> None: assert fake_client.question_rejections == ["q-timeout"] error_events = [e for e in tools.events_sent if e["message_type"] == "error"] assert any("timed out" in e["content"].lower() for e in error_events) + # A human-approval timeout is a Band-side procedural notice, never an + # AgentFailure -- it must not carry the shared failure metadata shape. + assert "failure" not in error_events[0]["metadata"] await adapter.on_cleanup("room-1") diff --git a/tests/adapters/opencode/test_turns.py b/tests/adapters/opencode/test_turns.py index fda9f32d5..e2e0ac69f 100644 --- a/tests/adapters/opencode/test_turns.py +++ b/tests/adapters/opencode/test_turns.py @@ -32,6 +32,7 @@ event_tool_part, event_user_message_updated, make_platform_message, + reported_failures, tools_protocol, wait_for, ) @@ -84,6 +85,25 @@ async def test_prompt_submission_failure_does_not_leave_room_stuck( ) +async def test_http_error_reports_status_code_as_failure_code( + make_adapter, tools +) -> None: + """An HTTP error talking to the OpenCode server preserves its status code + as the failure's ``code``, so a caller can branch on it without parsing + the message text.""" + fake_client = FakeOpencodeClient( + prompt_exceptions=[AnyHTTPStatusError(503, "sess-1")] + ) + adapter = make_adapter(fake_client) + + await run_single_turn(adapter, tools) + + failures = reported_failures(tools) + assert failures + assert failures[0]["provider"] == "opencode" + assert failures[0]["code"] == "503" + + async def test_reports_tool_events_when_enabled() -> None: fake_client = FakeOpencodeClient( prompt_event_sequences=[ @@ -271,9 +291,10 @@ async def test_session_error_emits_error_event(make_adapter, tools) -> None: room_id="room-1", ) - error_events = [e for e in tools.events_sent if e["message_type"] == "error"] - assert error_events - assert "boom" in error_events[0]["content"].lower() + failures = reported_failures(tools) + assert failures + assert failures[0]["provider"] == "opencode" + assert "boom" in failures[0]["message"].lower() async def test_turn_timeout_aborts_session_and_emits_error() -> None: @@ -300,8 +321,9 @@ async def test_turn_timeout_aborts_session_and_emits_error() -> None: ) assert fake_client.aborted_sessions == ["sess-1"] - error_events = [e for e in tools.events_sent if e["message_type"] == "error"] - assert any("timed out" in e["content"].lower() for e in error_events) + failures = reported_failures(tools) + assert any(f["provider"] == "opencode" and f["code"] == "timeout" for f in failures) + assert any("timed out" in f["message"].lower() for f in failures) await adapter.on_cleanup("room-1") @@ -610,7 +632,8 @@ async def test_task_event_post_failure_does_not_drop_the_turn(make_adapter) -> N "Handled despite the event failure." ] assert not any( - "failed while processing" in e["content"].lower() for e in tools.events_sent + "failed while processing" in f["message"].lower() + for f in reported_failures(tools) ) From baf524829d3292c3a3b60ecc93e54037c3cdaa59 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sun, 6 Sep 2026 15:57:57 +0300 Subject: [PATCH 20/53] refactor: surface Codex provider errors as AgentFailure Replace the structured-error/plain-text dual path (gated by the now-deleted CodexAdapterConfig.structured_errors flag) with a single unconditional path that converts Codex's error payloads into the shared AgentFailure shape via build_agent_failure (replacing build_structured_error_metadata) and reports them with send_failure. is_retryable no longer defaults to False when the upstream codexErrorInfo omits it -- absence now stays unknown rather than lying about retryability. Reply/bookkeeping posts (_handle_local_command's slash-command replies, _emit_turn_outcome's fallback text and error text) now go through deliver_reply so a Band-side delivery failure raises DeliveryFailedError instead of being misread as a Codex provider error. on_message's turn-setup through turn-outcome span is wrapped so that boundary: DeliveryFailedError is logged and swallowed, any other exception is reported as an AgentFailure and re-raised, preserving existing raise-vs-report control flow everywhere else. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CmsEQaNzmSmBGVsCTuKvbX --- src/band/adapters/codex.py | 463 +++++++++++++----------- src/band/integrations/codex/__init__.py | 6 +- src/band/integrations/codex/types.py | 102 ++---- tests/adapters/test_codex_adapter.py | 234 ++++++------ tests/framework_configs/adapters.py | 4 +- 5 files changed, 398 insertions(+), 411 deletions(-) diff --git a/src/band/adapters/codex.py b/src/band/adapters/codex.py index 9ad53f155..4adf7a28f 100644 --- a/src/band/adapters/codex.py +++ b/src/band/adapters/codex.py @@ -12,12 +12,14 @@ from datetime import datetime, timezone from typing import ClassVar, Any, Callable, Literal, NamedTuple, Protocol +from band_sdk_core import AgentFailure from pydantic import AliasChoices, BaseModel, Field, ValidationError, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict from typing_extensions import Unpack from band.converters.codex import CodexHistoryConverter from band.converters.helpers import build_replay_messages +from band.core.delivery import DeliveryFailedError, deliver_reply from band.core.protocols import AgentToolsProtocol from band.core.simple_adapter import SimpleAdapter from band.core.types import ( @@ -42,7 +44,7 @@ CodexItemType, CodexSessionState, CodexTokenUsage, - build_structured_error_metadata, + build_agent_failure, parse_plan_steps, ) from band.runtime.custom_tools import ( @@ -356,8 +358,6 @@ class CodexAdapterConfig(BaseSettings): # File-change approvals always key on the sorted set of paths being # modified, independent of this flag. session_approval_granularity: Literal["binary", "full_command"] = "full_command" - # --- Phase 1: Structured errors & enriched approvals --- - structured_errors: bool = True # --- Phase 2: Plan & task lifecycle --- stream_plan_events: bool = False emit_turn_lifecycle_events: bool = False @@ -525,7 +525,7 @@ def _log_startup_config(self, agent_name: str) -> None: "execution_reporting=%s, self_config_tools=%s, " "task_events=%s, turn_markers=%s, thought_events=%s, " "stream_reasoning=%s, stream_plan=%s, stream_commentary=%s, " - "diffs=%s, token_usage=%s, structured_errors=%s", + "diffs=%s, token_usage=%s", agent_name, self.config.transport, self._selected_model or self.config.model or "auto", @@ -541,7 +541,6 @@ def _log_startup_config(self, agent_name: str) -> None: self.config.stream_commentary_events, self.config.emit_diff_events, self.config.emit_token_usage_events, - self.config.structured_errors, ) async def on_event(self, inp: AgentInput) -> None: @@ -582,142 +581,157 @@ async def on_message( return async with self._rpc_lock: - await self._ensure_client_ready() - if self._client is None: - raise RuntimeError( - "Codex client not initialized after _ensure_client_ready" - ) + try: + await self._ensure_client_ready() + if self._client is None: + raise RuntimeError( + "Codex client not initialized after _ensure_client_ready" + ) - if command is not None: - handled = await self._handle_local_command( + if command is not None: + handled = await self._handle_local_command( + tools=tools, + msg=msg, + history=history, + room_id=room_id, + command=command[0], + args=command[1], + ) + if handled: + return + + thread_id = await self._ensure_thread( + room_id=room_id, + history=history, tools=tools, + is_session_bootstrap=is_session_bootstrap, + ) + + turn_input, has_pending_prompt_injection = self._build_turn_input( msg=msg, - history=history, + participants_msg=participants_msg, + contacts_msg=contacts_msg, room_id=room_id, - command=command[0], - args=command[1], ) - if handled: - return - thread_id = await self._ensure_thread( - room_id=room_id, - history=history, - tools=tools, - is_session_bootstrap=is_session_bootstrap, - ) + turn_params: dict[str, Any] = { + "threadId": thread_id, + "input": turn_input, + } + self._apply_turn_overrides(turn_params, room_id=room_id) - turn_input, has_pending_prompt_injection = self._build_turn_input( - msg=msg, - participants_msg=participants_msg, - contacts_msg=contacts_msg, - room_id=room_id, - ) + turn_started = await self._start_turn(turn_params) + if has_pending_prompt_injection: + self._prompt_injected_rooms.add(room_id) + turn = ( + turn_started.get("turn") if isinstance(turn_started, dict) else {} + ) + turn_id = str((turn or {}).get("id") or "") - turn_params: dict[str, Any] = { - "threadId": thread_id, - "input": turn_input, - } - self._apply_turn_overrides(turn_params, room_id=room_id) + if ( + Emit.TASK_EVENTS in self.features.emit + and self.config.emit_turn_task_markers + ): + try: + await tools.send_event( + content=self._build_task_event_content( + task_id=turn_id or None, + task="Codex turn", + status="started", + summary=f"Thread: {thread_id}", + ), + message_type="task", + metadata={ + "codex_thread_id": thread_id, + "codex_turn_id": turn_id or None, + "codex_room_id": room_id, + }, + ) + except Exception: + logger.debug( + "Failed to emit turn started task event", exc_info=True + ) - turn_started = await self._start_turn(turn_params) - if has_pending_prompt_injection: - self._prompt_injected_rooms.add(room_id) - turn = turn_started.get("turn") if isinstance(turn_started, dict) else {} - turn_id = str((turn or {}).get("id") or "") + # Phase 2: Turn STARTED lifecycle event with input summary + if ( + self.config.emit_turn_lifecycle_events + and Emit.TASK_EVENTS in self.features.emit + ): + input_summary = (msg.content or "")[:200] + try: + await tools.send_event( + content=self._build_task_event_content( + task_id=turn_id or None, + task="Codex turn lifecycle", + status="started", + summary=f"Thread: {thread_id}", + ), + message_type="task", + metadata={ + "codex_event_type": "turn_lifecycle", + "codex_room_id": room_id, + "codex_thread_id": thread_id, + "codex_turn_id": turn_id or None, + "codex_turn_status": "started", + "codex_input_summary": input_summary, + }, + ) + except Exception: + logger.debug( + "Failed to emit turn started lifecycle event", + exc_info=True, + ) - if ( - Emit.TASK_EVENTS in self.features.emit - and self.config.emit_turn_task_markers - ): - await tools.send_event( - content=self._build_task_event_content( - task_id=turn_id or None, - task="Codex turn", - status="started", - summary=f"Thread: {thread_id}", - ), - message_type="task", - metadata={ - "codex_thread_id": thread_id, - "codex_turn_id": turn_id or None, - "codex_room_id": room_id, - }, - ) + # Reset per-turn token deltas for the new turn. + usage_obj = self._token_usage.get(thread_id) + if usage_obj is not None: + usage_obj.reset_turn_deltas() - # Phase 2: Turn STARTED lifecycle event with input summary - if ( - self.config.emit_turn_lifecycle_events - and Emit.TASK_EVENTS in self.features.emit - ): - input_summary = (msg.content or "")[:200] + # perf_counter (not monotonic): highest-resolution clock, so a fast + # turn still measures a non-zero duration on Windows, where + # monotonic()'s coarse tick can round an instant turn to 0.0. + _turn_start = _time.perf_counter() try: - await tools.send_event( - content=self._build_task_event_content( - task_id=turn_id or None, - task="Codex turn lifecycle", - status="started", - summary=f"Thread: {thread_id}", - ), - message_type="task", - metadata={ - "codex_event_type": "turn_lifecycle", - "codex_room_id": room_id, - "codex_thread_id": thread_id, - "codex_turn_id": turn_id or None, - "codex_turn_status": "started", - "codex_input_summary": input_summary, - }, + result = await self._process_turn_events( + tools=tools, + msg=msg, + room_id=room_id, + thread_id=thread_id, + turn_id=turn_id or None, + turn_start=_turn_start, ) except Exception: - logger.debug( - "Failed to emit turn started lifecycle event", - exc_info=True, + logger.exception( + "Unexpected error during Codex turn event processing " + "(thread=%s, turn=%s)", + thread_id, + turn_id, + ) + result = TurnResult( + turn_status="failed", + turn_error="Internal error during turn processing", ) - # Reset per-turn token deltas for the new turn. - usage_obj = self._token_usage.get(thread_id) - if usage_obj is not None: - usage_obj.reset_turn_deltas() - - # perf_counter (not monotonic): highest-resolution clock, so a fast - # turn still measures a non-zero duration on Windows, where - # monotonic()'s coarse tick can round an instant turn to 0.0. - _turn_start = _time.perf_counter() - try: - result = await self._process_turn_events( + _turn_duration_s = _time.perf_counter() - _turn_start + await self._emit_turn_outcome( tools=tools, msg=msg, room_id=room_id, thread_id=thread_id, turn_id=turn_id or None, - turn_start=_turn_start, - ) - except Exception: - logger.exception( - "Unexpected error during Codex turn event processing " - "(thread=%s, turn=%s)", - thread_id, - turn_id, + turn_status=result.turn_status, + turn_error=result.turn_error, + final_text=result.final_text, + saw_send_message_tool=result.saw_send_message_tool, + duration_s=_turn_duration_s, ) - result = TurnResult( - turn_status="failed", - turn_error="Internal error during turn processing", - ) - - _turn_duration_s = _time.perf_counter() - _turn_start - await self._emit_turn_outcome( - tools=tools, - msg=msg, - room_id=room_id, - thread_id=thread_id, - turn_id=turn_id or None, - turn_status=result.turn_status, - turn_error=result.turn_error, - final_text=result.final_text, - saw_send_message_tool=result.saw_send_message_tool, - duration_s=_turn_duration_s, - ) + except DeliveryFailedError as e: + # The turn did its work; posting it to the room is what failed. + # Band-side delivery, never a Codex provider failure. + logger.exception("Codex reply delivery failed: %s", e.cause) + except Exception as e: + await tools.send_failure(AgentFailure("codex", str(e))) + raise async def _process_turn_events( self, @@ -985,7 +999,7 @@ async def _process_turn_events( result.turn_status = str(turn_payload.get("status") or "failed") result.turn_error = self._extract_turn_error(turn_payload) # Phase 1: structured error for failed turns - if result.turn_status == "failed" and self.config.structured_errors: + if result.turn_status == "failed": await self._emit_structured_turn_error( tools=tools, turn_payload=turn_payload, @@ -1144,20 +1158,26 @@ async def _ensure_thread( self._room_threads[room_id] = thread_id self._raw_history_by_room.pop(room_id, None) if Emit.TASK_EVENTS in self.features.emit: - await tools.send_event( - content=self._build_task_event_content( - task_id=thread_id, - task="Codex thread", - status="resumed", - summary=f"Room: {room_id}", - ), - message_type="task", - metadata={ - "codex_thread_id": thread_id, - "codex_room_id": room_id, - "codex_resumed": True, - }, - ) + try: + await tools.send_event( + content=self._build_task_event_content( + task_id=thread_id, + task="Codex thread", + status="resumed", + summary=f"Room: {room_id}", + ), + message_type="task", + metadata={ + "codex_thread_id": thread_id, + "codex_room_id": room_id, + "codex_resumed": True, + }, + ) + except Exception: + logger.debug( + "Failed to emit thread resumed task event", + exc_info=True, + ) return thread_id except CodexJsonRpcError as exc: logger.warning( @@ -1191,21 +1211,24 @@ async def _ensure_thread( self._room_threads[room_id] = thread_id if Emit.TASK_EVENTS in self.features.emit: - await tools.send_event( - content=self._build_task_event_content( - task_id=thread_id, - task="Codex thread", - status="mapped", - summary=f"Transport: {self.config.transport}", - ), - message_type="task", - metadata={ - "codex_thread_id": thread_id, - "codex_room_id": room_id, - "codex_created_at": datetime.now(timezone.utc).isoformat(), - "codex_transport": self.config.transport, - }, - ) + try: + await tools.send_event( + content=self._build_task_event_content( + task_id=thread_id, + task="Codex thread", + status="mapped", + summary=f"Transport: {self.config.transport}", + ), + message_type="task", + metadata={ + "codex_thread_id": thread_id, + "codex_room_id": room_id, + "codex_created_at": datetime.now(timezone.utc).isoformat(), + "codex_transport": self.config.transport, + }, + ) + except Exception: + logger.debug("Failed to emit thread mapped task event", exc_info=True) return thread_id @@ -1669,16 +1692,19 @@ async def _emit_turn_outcome( metadata["codex_duration_s"] = round(duration_s, 2) if has_usage: metadata.update(usage.to_metadata()) - await tools.send_event( - content=self._build_task_event_content( - task_id=turn_id, - task="Codex turn", - status=turn_status, - summary=summary, - ), - message_type="task", - metadata=metadata, - ) + try: + await tools.send_event( + content=self._build_task_event_content( + task_id=turn_id, + task="Codex turn", + status=turn_status, + summary=summary, + ), + message_type="task", + metadata=metadata, + ) + except Exception: + logger.debug("Failed to emit turn outcome task event", exc_info=True) # Phase 2: Enriched turn lifecycle events if ( @@ -1724,11 +1750,12 @@ async def _emit_turn_outcome( and final_text.strip() and not saw_send_message_tool ): - await tools.send_message(final_text.strip(), mentions=mention) + await deliver_reply(tools, final_text.strip(), mentions=mention) return if turn_status == "interrupted": - await tools.send_message( + await deliver_reply( + tools, "I stopped before completing this request.", mentions=mention, ) @@ -1739,7 +1766,7 @@ async def _emit_turn_outcome( if not turn_error else f"I couldn't complete this request ({turn_status}): {turn_error}" ) - await tools.send_message(error_text, mentions=mention) + await deliver_reply(tools, error_text, mentions=mention) async def _emit_item_completed_events( self, @@ -2266,26 +2293,11 @@ async def _handle_error_event( return logger.error("Codex error: %s", error_msg) - if self.config.structured_errors: - content, err_meta = build_structured_error_metadata( - error_obj, thread_id=thread_id, turn_id=turn_id - ) - err_meta["codex_room_id"] = room_id - await tools.send_event( - content=content or f"Codex error: {error_msg}", - message_type="error", - metadata=err_meta, - ) - else: - await tools.send_event( - content=f"Codex error: {error_msg}", - message_type="error", - metadata={ - "codex_room_id": room_id, - "codex_thread_id": thread_id, - "codex_turn_id": turn_id, - }, + await tools.send_failure( + build_agent_failure( + error_obj, thread_id=thread_id, turn_id=turn_id, room_id=room_id ) + ) async def _emit_structured_turn_error( self, @@ -2298,20 +2310,15 @@ async def _emit_structured_turn_error( ) -> None: """Emit a structured error event when turn/completed reports failure.""" error = turn_payload.get("error") - if not isinstance(error, dict): + if error is None: return - content, err_meta = build_structured_error_metadata( - error, thread_id=thread_id, turn_id=turn_id - ) - err_meta["codex_room_id"] = room_id - try: - await tools.send_event( - content=content, - message_type="error", - metadata=err_meta, + if not isinstance(error, dict): + error = {"message": str(error)} + await tools.send_failure( + build_agent_failure( + error, thread_id=thread_id, turn_id=turn_id, room_id=room_id ) - except Exception: - logger.debug("Failed to emit structured turn error", exc_info=True) + ) # ------------------------------------------------------------------ # Phase 2: Plan step tracking @@ -2550,7 +2557,8 @@ async def _handle_local_command( mention = [{"id": msg.sender_id, "name": msg.sender_name or msg.sender_type}] if command == "help": - await tools.send_message( + await deliver_reply( + tools, "Codex commands: " "`/status`, `/model`, `/models`, `/model list`, `/models list`, `/model `, " "`/reasoning [none|minimal|low|medium|high|xhigh]`, " @@ -2587,13 +2595,14 @@ async def _handle_local_command( f"- token_usage: {usage_line}\n" f"- turn_task_markers: {self.config.emit_turn_task_markers}" ) - await tools.send_message(status_text, mentions=mention) + await deliver_reply(tools, status_text, mentions=mention) return True if command in {"model", "models"}: model_arg = args.strip() if not model_arg: - await tools.send_message( + await deliver_reply( + tools, "Current model: " f"`{self._selected_model or 'unknown'}` " f"(configured: `{self.config.model or 'auto'}`). " @@ -2611,12 +2620,14 @@ async def _handle_local_command( preview = ", ".join(models[:10]) if len(models) > 10: preview += ", ..." - await tools.send_message( + await deliver_reply( + tools, f"Available models ({len(models)}): {preview}", mentions=mention, ) else: - await tools.send_message( + await deliver_reply( + tools, "No visible models returned by Codex app-server.", mentions=mention, ) @@ -2624,7 +2635,8 @@ async def _handle_local_command( self.config.model = model_arg self._selected_model = model_arg - await tools.send_message( + await deliver_reply( + tools, f"Model override set to `{model_arg}` for subsequent turns.", mentions=mention, ) @@ -2633,7 +2645,8 @@ async def _handle_local_command( if command == "reasoning": effort_arg = args.strip().lower() if not effort_arg: - await tools.send_message( + await deliver_reply( + tools, f"Current reasoning effort: `{self.config.reasoning_effort or 'default'}`. " f"Summary: `{self.config.reasoning_summary or 'default'}`. " f"Use `/reasoning <{'|'.join(sorted(_REASONING_EFFORTS))}>` to override.", @@ -2641,14 +2654,16 @@ async def _handle_local_command( ) return True if effort_arg not in _REASONING_EFFORTS: - await tools.send_message( + await deliver_reply( + tools, f"Invalid reasoning effort `{effort_arg}`. " f"Valid values: {', '.join(sorted(_REASONING_EFFORTS))}.", mentions=mention, ) return True self.config.reasoning_effort = effort_arg # type: ignore[assignment] # Literal narrowed by Pydantic validation - await tools.send_message( + await deliver_reply( + tools, f"Reasoning effort set to `{effort_arg}` for subsequent turns.", mentions=mention, ) @@ -2657,7 +2672,8 @@ async def _handle_local_command( # --- Phase 1: /sandbox and /permissions commands --- if command == "sandbox": if self.config.sandbox_policy is not None: - await tools.send_message( + await deliver_reply( + tools, "Cannot override sandbox: a `sandbox_policy` is configured. " "Remove `sandbox_policy` from config to use per-room `/sandbox` overrides.", mentions=mention, @@ -2666,7 +2682,8 @@ async def _handle_local_command( mode_arg = args.strip() if not mode_arg: effective = self._effective_sandbox(room_id) or "default" - await tools.send_message( + await deliver_reply( + tools, f"Current sandbox: `{effective}`. " "Use `/sandbox ` to change.", mentions=mention, @@ -2678,7 +2695,8 @@ async def _handle_local_command( confirm_flag = "--confirm" in tokens mode_tokens = [tok for tok in tokens if tok != "--confirm"] if len(mode_tokens) != 1: - await tools.send_message( + await deliver_reply( + tools, "Usage: `/sandbox " "[--confirm]`.", mentions=mention, @@ -2687,14 +2705,16 @@ async def _handle_local_command( mode_token = mode_tokens[0] normalized = self._normalize_sandbox_mode(mode_token) if normalized is None: - await tools.send_message( + await deliver_reply( + tools, f"Invalid sandbox mode `{mode_token}`. " "Valid: read-only, workspace-write, danger-full-access.", mentions=mention, ) return True if normalized == "danger-full-access" and not confirm_flag: - await tools.send_message( + await deliver_reply( + tools, "Escalating to `danger-full-access` removes all sandbox " "restrictions. Re-run with `--confirm` to proceed:\n" "`/sandbox danger-full-access --confirm`", @@ -2709,7 +2729,8 @@ async def _handle_local_command( msg.sender_name or msg.sender_type or "unknown", ) self._sandbox_overrides[room_id] = normalized - await tools.send_message( + await deliver_reply( + tools, f"Sandbox mode set to `{normalized}` for subsequent turns in this room.", mentions=mention, ) @@ -2733,7 +2754,7 @@ async def _handle_local_command( f" - [{entry.timestamp}] {entry.method}: " f"{entry.decision} by {entry.decided_by}" ) - await tools.send_message("\n".join(lines), mentions=mention) + await deliver_reply(tools, "\n".join(lines), mentions=mention) return True # --- Phase 2: /threads, /thread info, /thread archive --- @@ -2742,22 +2763,22 @@ async def _handle_local_command( if command == "threads" or not subcommand: # List all room->thread mappings if not self._room_threads: - await tools.send_message( - "No active thread mappings.", mentions=mention + await deliver_reply( + tools, "No active thread mappings.", mentions=mention ) return True lines = ["Active thread mappings:"] for rid, tid in self._room_threads.items(): current = " (current)" if rid == room_id else "" lines.append(f"- room `{rid}` → thread `{tid}`{current}") - await tools.send_message("\n".join(lines), mentions=mention) + await deliver_reply(tools, "\n".join(lines), mentions=mention) return True if subcommand == "info": mapped_thread = self._room_threads.get(room_id) if not mapped_thread: - await tools.send_message( - "No thread mapped for this room.", mentions=mention + await deliver_reply( + tools, "No thread mapped for this room.", mentions=mention ) return True usage = self._token_usage.get(mapped_thread) @@ -2772,7 +2793,7 @@ async def _handle_local_command( f"- room_id: {room_id}\n" f"- token_usage: {usage_line}" ) - await tools.send_message(info_text, mentions=mention) + await deliver_reply(tools, info_text, mentions=mention) return True if subcommand == "archive": @@ -2781,7 +2802,8 @@ async def _handle_local_command( self._token_usage.pop(mapped_thread or "", None) self._raw_history_by_room.pop(room_id, None) self._needs_history_injection.discard(room_id) - await tools.send_message( + await deliver_reply( + tools, f"Thread `{mapped_thread or 'none'}` archived. " "A new thread will be created on next message.", mentions=mention, @@ -2794,19 +2816,22 @@ async def _handle_local_command( if command == "usage": mapped_thread = self._room_threads.get(room_id) if not mapped_thread: - await tools.send_message( + await deliver_reply( + tools, "No thread mapped for this room — no usage data.", mentions=mention, ) return True usage = self._token_usage.get(mapped_thread) if not usage or usage.total_tokens == 0: - await tools.send_message( + await deliver_reply( + tools, "No token usage recorded for this thread.", mentions=mention, ) return True - await tools.send_message( + await deliver_reply( + tools, f"Thread `{mapped_thread}` — {usage.format_summary()}", mentions=mention, ) diff --git a/src/band/integrations/codex/__init__.py b/src/band/integrations/codex/__init__.py index 9ddaa0403..0ef0c6102 100644 --- a/src/band/integrations/codex/__init__.py +++ b/src/band/integrations/codex/__init__.py @@ -10,21 +10,19 @@ from .stdio_client import CodexStdioClient from .types import ( CODEX_APPROVAL_METHODS, - CODEX_ERROR_REMEDIATION, ApprovalAuditEntry, CodexApprovalMethod, CodexItemType, CodexPlanStep, CodexSessionState, CodexTokenUsage, - build_structured_error_metadata, + build_agent_failure, parse_plan_steps, ) from .websocket_client import CodexWebSocketClient __all__ = [ "CODEX_APPROVAL_METHODS", - "CODEX_ERROR_REMEDIATION", "ApprovalAuditEntry", "CodexApprovalMethod", "CodexItemType", @@ -36,6 +34,6 @@ "CodexWebSocketClient", "OverloadRetryPolicy", "RpcEvent", - "build_structured_error_metadata", + "build_agent_failure", "parse_plan_steps", ] diff --git a/src/band/integrations/codex/types.py b/src/band/integrations/codex/types.py index 86913cd02..2936a5aca 100644 --- a/src/band/integrations/codex/types.py +++ b/src/band/integrations/codex/types.py @@ -9,6 +9,8 @@ from enum import StrEnum from typing import Any +from band_sdk_core import AgentFailure + logger = logging.getLogger(__name__) @@ -77,46 +79,15 @@ def has_thread(self) -> bool: # Structured error types # --------------------------------------------------------------------------- -# Mapping from Codex error type to (human description, suggested action). -CODEX_ERROR_REMEDIATION: dict[str, tuple[str, str]] = { - "ContextWindowExceeded": ( - "Context window exceeded — the conversation is too long for the model.", - "compact_context", - ), - "UsageLimitExceeded": ( - "Usage limit exceeded — you have hit your API quota.", - "wait_or_upgrade", - ), - "HttpConnectionFailed": ( - "HTTP connection failed — could not reach the API.", - "check_connectivity", - ), - "SandboxError": ( - "Sandbox error — a sandbox policy violation occurred.", - "review_sandbox_policy", - ), - "Unauthorized": ( - "Unauthorized — authentication failed or expired.", - "re_authenticate", - ), - "BadRequest": ( - "Bad request — the input format is invalid.", - "check_input_format", - ), - "ResponseTooManyFailedAttempts": ( - "Too many failed attempts — the model could not produce a valid response.", - "retry_different_approach", - ), -} - - -def build_structured_error_metadata( + +def build_agent_failure( error_obj: dict[str, Any], *, thread_id: str | None = None, turn_id: str | None = None, -) -> tuple[str, dict[str, Any]]: - """Parse a Codex error dict and return (content, metadata) for a structured error event. + room_id: str | None = None, +) -> AgentFailure: + """Parse a Codex error dict into the shared provider-failure shape. The ``error_obj`` is typically the ``error`` field from a turn payload or an ``error`` notification. It may contain a nested ``codexErrorInfo`` dict with @@ -124,51 +95,46 @@ def build_structured_error_metadata( ``additionalDetails`` echoes upstream strings that may be attacker-controlled (e.g. error messages from a downstream HTTP target) and will be rendered by - downstream UIs. Consumers MUST treat the resulting - ``codex_additional_details`` metadata field as untrusted — escape it before - rendering as HTML/Markdown. This helper caps the length at - ``_MAX_ERROR_DETAIL_CHARS`` (2 KiB) so a hostile payload can't blow up - WebSocket frames or downstream storage. + downstream UIs. Consumers MUST treat the resulting ``codex_additional_details`` + detail field as untrusted — escape it before rendering as HTML/Markdown. This + helper caps the length at ``_MAX_ERROR_DETAIL_CHARS`` (2 KiB) so a hostile + payload can't blow up WebSocket frames or downstream storage. """ codex_info = error_obj.get("codexErrorInfo") or {} if not isinstance(codex_info, dict): codex_info = {} - error_type = codex_info.get("type") or "" - error_code = codex_info.get("code") or "" + error_type = codex_info.get("type") or None + error_code = codex_info.get("code") or None http_status = codex_info.get("httpStatus") - is_retryable = bool(codex_info.get("retryable", False)) + # A genuine passthrough of codexErrorInfo.retryable: absent means unknown, + # never defaulted to False. + is_retryable = codex_info.get("retryable") additional = error_obj.get("additionalDetails") - # Look up remediation - remediation = CODEX_ERROR_REMEDIATION.get(str(error_type)) - if remediation: - content, suggested_action = remediation - else: - raw_message = error_obj.get("message", "") - content = ( - str(raw_message) - if raw_message - else f"Codex error: {error_type or 'unknown'}" - ) - suggested_action = "" - - metadata: dict[str, Any] = { - "codex_error_type": error_type or None, - "codex_error_code": error_code or None, - "codex_http_status": http_status, - "codex_is_retryable": is_retryable, - "codex_suggested_action": suggested_action or None, - } + raw_message = error_obj.get("message", "") + message = ( + str(raw_message) if raw_message else f"Codex error: {error_type or 'unknown'}" + ) + + detail: dict[str, Any] = {} + if error_code: + detail["codex_error_code"] = error_code + if http_status is not None: + detail["codex_http_status"] = http_status + if is_retryable is not None: + detail["codex_is_retryable"] = bool(is_retryable) if thread_id: - metadata["codex_thread_id"] = thread_id + detail["codex_thread_id"] = thread_id if turn_id: - metadata["codex_turn_id"] = turn_id + detail["codex_turn_id"] = turn_id + if room_id: + detail["codex_room_id"] = room_id if additional is not None: capped = _cap_error_detail(additional) if capped is not None: - metadata["codex_additional_details"] = capped + detail["codex_additional_details"] = capped - return content, metadata + return AgentFailure("codex", message, error_type, detail or None) def _cap_error_detail(value: Any) -> Any: diff --git a/tests/adapters/test_codex_adapter.py b/tests/adapters/test_codex_adapter.py index b01b97dd4..565ebe0f8 100644 --- a/tests/adapters/test_codex_adapter.py +++ b/tests/adapters/test_codex_adapter.py @@ -30,7 +30,7 @@ CodexItemType, CodexSessionState, CodexTokenUsage, - build_structured_error_metadata, + build_agent_failure, parse_plan_steps, ) from band.runtime.custom_tools import CustomToolDef @@ -59,6 +59,11 @@ def events_of_type(tools: FakeAgentTools, message_type: str) -> list[dict[str, A return [e for e in tools.events_sent if e["message_type"] == message_type] +def reported_failures(tools: FakeAgentTools) -> list[dict[str, Any]]: + """Every ``AgentFailure`` reported via ``send_failure``, as its wire dict.""" + return [e["metadata"]["failure"] for e in events_of_type(tools, "error")] + + class ToolSchemaFakeTools(FakeAgentTools): def get_openai_tool_schemas(self, **kwargs: Any) -> list[dict[str, Any]]: return [ @@ -3320,6 +3325,11 @@ async def test_explicit_model_error_propagates_without_fallback(self) -> None: model_list_calls = [m for m, _ in fake_client.requests if m == "model/list"] assert len(model_list_calls) == 0 + failures = reported_failures(tools) + assert len(failures) == 1 + assert failures[0]["provider"] == "codex" + assert "not available" in failures[0]["message"] + @pytest.mark.asyncio async def test_model_selection_uses_default_when_model_list_empty(self) -> None: """Auto-selection uses the adapter default when Codex returns no visible models.""" @@ -3581,7 +3591,7 @@ async def test_structured_error_from_error_event(self) -> None: ] fake_client = FakeCodexClient(events=events) adapter = CodexAdapter( - config=CodexAdapterConfig(transport="ws", structured_errors=True), + config=CodexAdapterConfig(transport="ws"), client_factory=lambda _config: fake_client, ) tools = ToolSchemaFakeTools() @@ -3597,13 +3607,12 @@ async def test_structured_error_from_error_event(self) -> None: room_id="room-1", ) - error_events = events_of_type(tools, "error") - assert len(error_events) == 1 - meta = error_events[0]["metadata"] - assert meta["codex_error_type"] == "ContextWindowExceeded" - assert meta["codex_suggested_action"] == "compact_context" - assert meta["codex_is_retryable"] is False - assert "context window" in error_events[0]["content"].lower() + failures = reported_failures(tools) + assert len(failures) == 1 + assert failures[0]["provider"] == "codex" + assert failures[0]["code"] == "ContextWindowExceeded" + assert failures[0]["detail"]["codex_is_retryable"] is False + assert "context window" in failures[0]["message"].lower() @pytest.mark.asyncio async def test_structured_error_from_failed_turn(self) -> None: @@ -3629,48 +3638,7 @@ async def test_structured_error_from_failed_turn(self) -> None: ] fake_client = FakeCodexClient(events=events) adapter = CodexAdapter( - config=CodexAdapterConfig(transport="ws", structured_errors=True), - client_factory=lambda _config: fake_client, - ) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Agent", "A coding agent") - await adapter.on_message( - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - error_events = events_of_type(tools, "error") - assert len(error_events) == 1 - assert error_events[0]["metadata"]["codex_error_type"] == "UsageLimitExceeded" - assert ( - error_events[0]["metadata"]["codex_suggested_action"] == "wait_or_upgrade" - ) - - @pytest.mark.asyncio - async def test_structured_errors_disabled_falls_back_to_plain_text(self) -> None: - """When structured_errors=False, errors use plain text format.""" - events = [ - _event_notification( - "error", - { - "error": { - "message": "Something failed", - "codexErrorInfo": {"type": "ContextWindowExceeded"}, - }, - "willRetry": False, - }, - ), - _turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = CodexAdapter( - config=CodexAdapterConfig(transport="ws", structured_errors=False), + config=CodexAdapterConfig(transport="ws"), client_factory=lambda _config: fake_client, ) tools = ToolSchemaFakeTools() @@ -3686,10 +3654,9 @@ async def test_structured_errors_disabled_falls_back_to_plain_text(self) -> None room_id="room-1", ) - error_events = events_of_type(tools, "error") - assert len(error_events) == 1 - assert error_events[0]["content"] == "Codex error: Something failed" - assert "codex_error_type" not in error_events[0]["metadata"] + failures = reported_failures(tools) + assert len(failures) == 1 + assert failures[0]["code"] == "UsageLimitExceeded" # =========================================================================== @@ -4649,7 +4616,7 @@ async def test_usage_command_shows_token_usage(self) -> None: class TestCodexTypes: - def test_build_structured_error_metadata_known_type(self) -> None: + def test_build_agent_failure_known_type(self) -> None: error_obj = { "message": "Context overflow", @@ -4659,25 +4626,22 @@ def test_build_structured_error_metadata_known_type(self) -> None: "retryable": False, }, } - content, meta = build_structured_error_metadata( - error_obj, thread_id="t1", turn_id="turn-1" - ) - assert "context window" in content.lower() - assert meta["codex_error_type"] == "ContextWindowExceeded" - assert meta["codex_suggested_action"] == "compact_context" - assert meta["codex_thread_id"] == "t1" - assert meta["codex_turn_id"] == "turn-1" + failure = build_agent_failure(error_obj, thread_id="t1", turn_id="turn-1") + assert failure.provider == "codex" + assert "context overflow" in failure.message.lower() + assert failure.code == "ContextWindowExceeded" + assert failure.detail["codex_thread_id"] == "t1" + assert failure.detail["codex_turn_id"] == "turn-1" - def test_build_structured_error_metadata_unknown_type(self) -> None: + def test_build_agent_failure_unknown_type(self) -> None: error_obj = { "message": "Something weird happened", "codexErrorInfo": {"type": "UnknownError"}, } - content, meta = build_structured_error_metadata(error_obj) - assert content == "Something weird happened" - assert meta["codex_error_type"] == "UnknownError" - assert meta["codex_suggested_action"] is None + failure = build_agent_failure(error_obj) + assert failure.message == "Something weird happened" + assert failure.code == "UnknownError" def test_parse_plan_steps(self) -> None: @@ -4758,9 +4722,8 @@ def test_codex_token_usage_update_current_schema(self) -> None: assert usage.total_tokens == 14822 def test_config_new_flags_default_false(self) -> None: - """All new config flags default to False (except structured_errors=True).""" + """All new config flags default to False.""" config = CodexAdapterConfig() - assert config.structured_errors is True assert config.stream_reasoning_events is False assert config.stream_plan_events is False assert config.stream_commentary_events is False @@ -5720,7 +5683,7 @@ async def test_token_usage_event_skipped_when_total_is_zero(self) -> None: class TestStructuredErrorNormalization: - """build_structured_error_metadata handling of non-standard inputs.""" + """build_agent_failure handling of non-standard inputs.""" def test_structured_error_with_string_error_obj(self) -> None: """_handle_error_event normalizes string error_obj before structuring. @@ -5731,12 +5694,12 @@ def test_structured_error_with_string_error_obj(self) -> None: """ # Simulate the normalization the adapter performs: convert string to - # {"message": } before passing to build_structured_error_metadata. + # {"message": } before passing to build_agent_failure. error_obj: dict[str, Any] = {"message": "raw string error"} - content, meta = build_structured_error_metadata(error_obj) - assert "raw string error" in content + failure = build_agent_failure(error_obj) + assert "raw string error" in failure.message # No codexErrorInfo -> no known error type. - assert meta["codex_error_type"] is None + assert failure.code is None class TestSessionApprovalKeying: @@ -5885,58 +5848,50 @@ def test_record_approval_audit_returns_entry(self) -> None: class TestStructuredErrorMappings: - """Cover every entry in CODEX_ERROR_REMEDIATION plus the fallback path.""" + """build_agent_failure passes codexErrorInfo through verbatim -- no + remediation/suggested-action policy; that belongs to a consumer, not + this shared shape.""" @pytest.mark.parametrize( - ("error_type", "expected_action", "expected_phrase"), + "error_type", [ - ("HttpConnectionFailed", "check_connectivity", "http connection"), - ("SandboxError", "review_sandbox_policy", "sandbox"), - ("Unauthorized", "re_authenticate", "unauthorized"), - ("BadRequest", "check_input_format", "bad request"), - ( - "ResponseTooManyFailedAttempts", - "retry_different_approach", - "failed attempts", - ), + "HttpConnectionFailed", + "SandboxError", + "Unauthorized", + "BadRequest", + "ResponseTooManyFailedAttempts", ], ) - def test_known_error_type_maps_to_remediation( - self, error_type: str, expected_action: str, expected_phrase: str - ) -> None: - - content, meta = build_structured_error_metadata( + def test_error_type_becomes_the_failure_code(self, error_type: str) -> None: + failure = build_agent_failure( {"codexErrorInfo": {"type": error_type, "retryable": True}} ) - assert meta["codex_error_type"] == error_type - assert meta["codex_suggested_action"] == expected_action - assert meta["codex_is_retryable"] is True - assert expected_phrase in content.lower() + assert failure.code == error_type + assert failure.detail["codex_is_retryable"] is True def test_non_dict_codex_error_info_is_tolerated(self) -> None: - content, meta = build_structured_error_metadata( + failure = build_agent_failure( {"message": "boom", "codexErrorInfo": "not-a-dict"} ) - assert meta["codex_error_type"] is None - assert content == "boom" + assert failure.code is None + assert failure.message == "boom" def test_missing_codex_error_info_falls_back_to_message(self) -> None: - content, meta = build_structured_error_metadata({"message": "network down"}) - assert meta["codex_error_type"] is None - assert meta["codex_suggested_action"] is None - assert content == "network down" + failure = build_agent_failure({"message": "network down"}) + assert failure.code is None + assert failure.message == "network down" - def test_additional_details_preserved_in_metadata(self) -> None: + def test_additional_details_preserved_in_detail(self) -> None: - _, meta = build_structured_error_metadata( + failure = build_agent_failure( { "codexErrorInfo": {"type": "Unauthorized"}, "additionalDetails": {"hint": "refresh token"}, } ) - assert meta["codex_additional_details"] == {"hint": "refresh token"} + assert failure.detail["codex_additional_details"] == {"hint": "refresh token"} class TestSlashCommandCoverage: @@ -6050,6 +6005,45 @@ async def test_permissions_reflects_sandbox_override(self) -> None: assert len(perm_msgs) == 1 assert "read-only" in perm_msgs[0]["content"] + @pytest.mark.asyncio + async def test_local_command_reply_delivery_failure_is_not_reported( + self, caplog: pytest.LogCaptureFixture + ) -> None: + """/help's answer failing to post is Band-side delivery, not a Codex + provider failure -- deliver_reply's DeliveryFailedError must be + recognized and left unreported here.""" + + class FailingSendMessageTools(ToolSchemaFakeTools): + async def send_message( + self, content: str, mentions: list[dict[str, str]] | None = None + ) -> Any: + raise RuntimeError("platform rejected the message") + + fake_client = FakeCodexClient() + adapter = CodexAdapter( + config=CodexAdapterConfig(transport="ws"), + client_factory=lambda _config: fake_client, + ) + tools = FailingSendMessageTools() + + await adapter.on_started("Agent", "A coding agent") + with caplog.at_level(logging.ERROR, logger="band.adapters.codex"): + await adapter.on_message( + make_platform_message(content="/help"), + tools, + CodexSessionState(), + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-1", + ) + + assert not tools.messages_sent + assert not reported_failures(tools) + assert any( + "Codex reply delivery failed" in record.message for record in caplog.records + ) + class TestMalformedPayloadTolerance: """Adapter must survive notifications that are missing or misshapen.""" @@ -6063,7 +6057,7 @@ async def test_error_event_with_non_dict_error_field(self) -> None: ] fake_client = FakeCodexClient(events=events) adapter = CodexAdapter( - config=CodexAdapterConfig(transport="ws", structured_errors=True), + config=CodexAdapterConfig(transport="ws"), client_factory=lambda _config: fake_client, ) tools = ToolSchemaFakeTools() @@ -6079,6 +6073,10 @@ async def test_error_event_with_non_dict_error_field(self) -> None: room_id="room-1", ) + failures = reported_failures(tools) + assert len(failures) == 1 + assert failures[0]["message"] == "oops" + @pytest.mark.asyncio async def test_turn_completed_without_items_key(self) -> None: """turn/completed missing `items` is treated as an empty turn, not a crash.""" @@ -6256,13 +6254,13 @@ class TestStructuredErrorDetailCap: def test_long_additional_details_string_is_truncated(self) -> None: long_detail = "x" * (_MAX_ERROR_DETAIL_CHARS + 500) - _, meta = build_structured_error_metadata( + failure = build_agent_failure( { "codexErrorInfo": {"type": "Unauthorized"}, "additionalDetails": long_detail, } ) - detail = meta["codex_additional_details"] + detail = failure.detail["codex_additional_details"] assert isinstance(detail, str) assert len(detail) < len(long_detail) assert "truncated" in detail @@ -6271,24 +6269,24 @@ def test_structured_dict_additional_details_are_preserved(self) -> None: """Only string details are capped; dict/list payloads pass through.""" payload = {"hint": "refresh token", "code": 401} - _, meta = build_structured_error_metadata( + failure = build_agent_failure( { "codexErrorInfo": {"type": "Unauthorized"}, "additionalDetails": payload, } ) - assert meta["codex_additional_details"] == payload + assert failure.detail["codex_additional_details"] == payload def test_empty_additional_details_is_dropped(self) -> None: - """Empty strings are not echoed into metadata.""" + """Empty strings are not echoed into detail.""" - _, meta = build_structured_error_metadata( + failure = build_agent_failure( { "codexErrorInfo": {"type": "Unauthorized"}, "additionalDetails": "", } ) - assert "codex_additional_details" not in meta + assert failure.detail is None def test_oversized_dict_additional_details_is_replaced_with_marker( self, @@ -6305,13 +6303,13 @@ def test_oversized_dict_additional_details_is_replaced_with_marker( oversized_value = "x" * (_MAX_ERROR_DETAIL_CHARS + 500) payload = {"nested": {"blob": oversized_value}} - _, meta = build_structured_error_metadata( + failure = build_agent_failure( { "codexErrorInfo": {"type": "Unauthorized"}, "additionalDetails": payload, } ) - detail = meta["codex_additional_details"] + detail = failure.detail["codex_additional_details"] assert isinstance(detail, str) assert "truncated" in detail assert len(detail) < len(oversized_value) @@ -6325,13 +6323,13 @@ def test_unserializable_additional_details_is_dropped(self) -> None: circular: dict[str, Any] = {} circular["self"] = circular - _, meta = build_structured_error_metadata( + failure = build_agent_failure( { "codexErrorInfo": {"type": "Unauthorized"}, "additionalDetails": circular, } ) - assert "codex_additional_details" not in meta + assert failure.detail is None class TestDiffByteCap: diff --git a/tests/framework_configs/adapters.py b/tests/framework_configs/adapters.py index 952468310..add0c3f7c 100644 --- a/tests/framework_configs/adapters.py +++ b/tests/framework_configs/adapters.py @@ -625,10 +625,10 @@ def _build_codex_config() -> AdapterConfig: "config": CodexAdapterConfig(), }, custom_kwargs={ - "config": CodexAdapterConfig(structured_errors=False), + "config": CodexAdapterConfig(stream_plan_events=True), }, custom_expected={ - "config": CodexAdapterConfig(structured_errors=False), + "config": CodexAdapterConfig(stream_plan_events=True), }, has_custom_tools_attr=True, custom_tools_attr="_custom_tools", From 4baa482511d8bdbb70cdb1e5b7edf7dd04e0f07d Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sun, 6 Sep 2026 16:06:12 +0300 Subject: [PATCH 21/53] feat: surface A2A Gateway failures as structured AgentFailure metadata PendingA2ATask.fail() gains an optional failure dict, attached as TaskStatusUpdateEvent metadata (via TaskUpdater.update_status, which failed() didn't expose) alongside its existing freeform reason text. Two of the five call sites are Band-side room lifecycle (room closed, gateway shut down) and stay untouched -- neither is a provider failure. The other three are provider-originated: - _execute_a2a's broad except and _await_response's timeout synthesize AgentFailure(provider="a2a-gateway", ...) for gateway-relay failures, running the exception text through a redaction/length-cap sanitizer mirroring the TS SDK's sanitizeGatewayErrorMessage before it reaches the external A2A client. - _publish_band_response's "error" message_type case relays the Band peer's own already-built AgentFailure (its adapter's send_failure already stamped metadata["failure"] via to_failure_event) unchanged, rather than re-tagging its provider as "a2a-gateway". Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CmsEQaNzmSmBGVsCTuKvbX --- src/band/integrations/a2a/gateway/adapter.py | 48 ++++++++++- src/band/integrations/a2a/gateway/types.py | 19 ++++- .../integrations/a2a/gateway/test_adapter.py | 85 ++++++++++++++++++- 3 files changed, 143 insertions(+), 9 deletions(-) diff --git a/src/band/integrations/a2a/gateway/adapter.py b/src/band/integrations/a2a/gateway/adapter.py index dbb00f5f2..0b1aac8f7 100644 --- a/src/band/integrations/a2a/gateway/adapter.py +++ b/src/band/integrations/a2a/gateway/adapter.py @@ -15,6 +15,7 @@ from a2a.server.agent_execution import AgentExecutor, RequestContext from a2a.server.events import EventQueue from a2a.types import Task, TaskState, TaskStatus +from band_sdk_core import AgentFailure from typing_extensions import Unpack from band.client.rest import ( @@ -79,6 +80,29 @@ def slugify(name: str) -> str: return slug.strip("-") # Remove leading/trailing dashes +_GATEWAY_ERROR_MAX_CHARS = 240 +_BEARER_TOKEN_RE = re.compile(r"Bearer\s+[^\s,;]+", re.IGNORECASE) +_CREDENTIAL_KV_RE = re.compile( + r"(token|authorization|api[_-]?key)\s*[:=]\s*[^\s,;]+", re.IGNORECASE +) + + +def _sanitize_gateway_error_message(exc: BaseException) -> str: + """Redact bearer tokens/API keys before an internal exception message + reaches an external A2A client, and cap its length. + + Mirrors the TS SDK's ``sanitizeGatewayErrorMessage``. + """ + trimmed = str(exc).strip() + if not trimmed: + return "Unknown error" + redacted = _BEARER_TOKEN_RE.sub("Bearer [REDACTED]", trimmed) + redacted = _CREDENTIAL_KV_RE.sub(r"\1=[REDACTED]", redacted) + if len(redacted) <= _GATEWAY_ERROR_MAX_CHARS: + return redacted + return f"{redacted[: _GATEWAY_ERROR_MAX_CHARS - 3]}..." + + class A2AGatewayAdapter(SimpleAdapter[GatewaySessionState]): """Gateway adapter exposing Band peers as A2A endpoints. @@ -339,14 +363,19 @@ async def _execute_a2a( request.pending.task.id, ) raise - except Exception: + except Exception as exc: logger.exception( "A2A request failed: room=%s context=%s task=%s", request.room_id, request.context_id, request.pending.task.id, ) - await request.pending.fail("A2A request failed") + failure = AgentFailure( + "a2a-gateway", + _sanitize_gateway_error_message(exc), + type(exc).__name__, + ) + await request.pending.fail("A2A request failed", failure=failure.to_dict()) raise else: if completed: @@ -427,7 +456,12 @@ async def _await_response(self, request: GatewayRequest) -> bool: request.pending.task.id, self.config.response_timeout_s, ) - await request.pending.fail("Timed out waiting for a Band response") + failure = AgentFailure( + "a2a-gateway", "Timed out waiting for a Band response", "Timeout" + ) + await request.pending.fail( + "Timed out waiting for a Band response", failure=failure.to_dict() + ) return False return True @@ -554,7 +588,13 @@ async def _publish_band_response( ) -> None: """Translate Band's message category into an A2A task intent.""" if msg.message_type == "error": - await pending.fail(msg.content) + # The peer's own adapter already built this AgentFailure (see + # to_failure_event) -- relay it as-is rather than re-tagging its + # provider as "a2a-gateway". + failure = ( + msg.metadata.get("failure") if isinstance(msg.metadata, dict) else None + ) + await pending.fail(msg.content, failure=failure) elif msg.message_type in ("thought", "tool_call", "tool_result"): await pending.report_progress(msg.content) else: diff --git a/src/band/integrations/a2a/gateway/types.py b/src/band/integrations/a2a/gateway/types.py index 5ed3e848b..3f0f83efe 100644 --- a/src/band/integrations/a2a/gateway/types.py +++ b/src/band/integrations/a2a/gateway/types.py @@ -4,11 +4,12 @@ import asyncio from dataclasses import dataclass, field +from typing import Any from a2a.server.events import EventQueue from a2a.server.tasks import TaskUpdater from a2a.helpers import new_text_message -from a2a.types import Message, Task +from a2a.types import Message, Task, TaskState @dataclass @@ -67,12 +68,22 @@ async def complete_with_message(self, content: str) -> None: await self._updater.complete(self._message(content)) self.done.set() - async def fail(self, reason: str) -> None: - """Publish a terminal failure and release the request.""" + async def fail(self, reason: str, *, failure: dict[str, Any] | None = None) -> None: + """Publish a terminal failure and release the request. + + ``failure`` is the wire-shape ``AgentFailure`` dict (see + ``to_failure_event``), attached as task status metadata alongside + ``reason``'s freeform text so an A2A client can recover structured + provider-failure detail. + """ async with self._lock: if self.done.is_set(): return - await self._updater.failed(self._message(reason)) + await self._updater.update_status( + TaskState.TASK_STATE_FAILED, + message=self._message(reason), + metadata={"failure": failure} if failure else None, + ) self.done.set() async def cancel(self) -> None: diff --git a/tests/integrations/a2a/gateway/test_adapter.py b/tests/integrations/a2a/gateway/test_adapter.py index f7be7d9cf..439e6f765 100644 --- a/tests/integrations/a2a/gateway/test_adapter.py +++ b/tests/integrations/a2a/gateway/test_adapter.py @@ -4,6 +4,7 @@ import asyncio from datetime import datetime +from typing import Any from unittest.mock import AsyncMock, MagicMock, patch from uuid import uuid4 @@ -33,6 +34,7 @@ def make_platform_message( content: str, room_id: str = "room-123", message_type: str = "text", + metadata: dict[str, Any] | None = None, ) -> PlatformMessage: return PlatformMessage( id=str(uuid4()), @@ -42,7 +44,7 @@ def make_platform_message( sender_type="Agent", sender_name="Weather Agent", message_type=message_type, - metadata={}, + metadata=metadata if metadata is not None else {}, created_at=datetime.now(), ) @@ -295,6 +297,8 @@ async def test_timeout_returns_terminal_failure( await queue.dequeue_event() terminal = await queue.dequeue_event() assert terminal.status.state == TaskState.TASK_STATE_FAILED + assert terminal.metadata["failure"]["provider"] == "a2a-gateway" + assert terminal.metadata["failure"]["code"] == "Timeout" assert adapter._pending_tasks == {} assert not any( "A2A request completed" in record.message for record in caplog.records @@ -322,6 +326,35 @@ async def test_send_failure_publishes_terminal_failure(self) -> None: assert terminal.status.message.parts[0].text == "A2A request failed" assert "Band unavailable" not in terminal.status.message.parts[0].text assert adapter._pending_tasks == {} + failure = terminal.metadata["failure"] + assert failure["provider"] == "a2a-gateway" + assert failure["code"] == "RuntimeError" + assert "Band unavailable" in failure["message"] + + @pytest.mark.asyncio + async def test_send_failure_redacts_secrets_from_reported_metadata(self) -> None: + """The sanitized exception text reaches the A2A client's metadata -- + a leaked bearer token or API key must not.""" + adapter = A2AGatewayAdapter(rest_client=MagicMock()) + adapter._peers = {"weather": make_peer("weather", "Weather Agent")} + configure_room_creation(adapter) + adapter._rest.agent_api_messages.create_agent_chat_message = AsyncMock( + side_effect=RuntimeError( + "upstream rejected Bearer abc123.def456 (api_key=sk-live-secret)" + ) + ) + queue = EventQueueLegacy() + + with pytest.raises(RuntimeError): + await BandAgentExecutor(adapter, "weather").execute(make_request(), queue) + + await queue.dequeue_event() + terminal = await queue.dequeue_event() + message = terminal.metadata["failure"]["message"] + assert "abc123.def456" not in message + assert "sk-live-secret" not in message + assert "Bearer [REDACTED]" in message + assert "api_key=[REDACTED]" in message @pytest.mark.asyncio async def test_establish_request_raises_when_peer_missing(self) -> None: @@ -378,6 +411,7 @@ async def test_cleanup_all_fails_inflight_requests(self) -> None: terminal = await queue.dequeue_event() assert terminal.status.state == TaskState.TASK_STATE_FAILED + assert not terminal.metadata, "a gateway shutdown is not a provider failure" assert pending.done.is_set() assert adapter._pending_tasks == {} @@ -409,6 +443,7 @@ async def test_room_cleanup_returns_terminal_failure(self) -> None: terminal = await queue.dequeue_event() assert terminal.status.state == TaskState.TASK_STATE_FAILED + assert not terminal.metadata, "a room closing is not a provider failure" assert pending.done.is_set() assert adapter._pending_tasks == {} @@ -547,3 +582,51 @@ async def test_publishes_band_message_with_matching_task_state( assert event.status.state == state assert event.status.message.parts[0].text == "response" + + @pytest.mark.asyncio + async def test_relays_peers_own_agent_failure_unchanged(self) -> None: + """The peer's adapter already built this AgentFailure (send_failure) -- + the gateway must relay it as-is, not re-tag its provider as + "a2a-gateway".""" + adapter = A2AGatewayAdapter(rest_client=MagicMock()) + queue = EventQueueLegacy() + pending = make_pending(queue) + peer_failure = { + "provider": "codex", + "code": "ContextWindowExceeded", + "message": "context window exceeded", + "detail": None, + } + + await adapter._publish_band_response( + pending, + make_platform_message( + "context window exceeded", + message_type="error", + metadata={"failure": peer_failure}, + ), + ) + event = await queue.dequeue_event() + + assert event.status.state == TaskState.TASK_STATE_FAILED + assert event.metadata["failure"]["provider"] == "codex" + assert event.metadata["failure"]["code"] == "ContextWindowExceeded" + + @pytest.mark.asyncio + async def test_plain_error_message_without_failure_metadata_still_fails( + self, + ) -> None: + """A peer that never migrated to send_failure still fails the task -- + it just carries no structured metadata.""" + adapter = A2AGatewayAdapter(rest_client=MagicMock()) + queue = EventQueueLegacy() + pending = make_pending(queue) + + await adapter._publish_band_response( + pending, + make_platform_message("something broke", message_type="error"), + ) + event = await queue.dequeue_event() + + assert event.status.state == TaskState.TASK_STATE_FAILED + assert not event.metadata From 55c90db96c40629c36d977bd7c99c932f53b3f13 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sun, 6 Sep 2026 16:12:23 +0300 Subject: [PATCH 22/53] refactor: surface ACP client provider errors as AgentFailure on_message's single free-text "error" event becomes send_failure with a new _to_agent_failure(exc) converter, which unwraps acp.exceptions. RequestError's numeric code/data instead of collapsing a JSON-RPC error into one generic string. RoomTurnEmitter.__aexit__ relays a turn's held text through deliver_reply instead of a bare send_message call, and on_message's except now splits DeliveryFailedError from a real provider failure: a Band-side post failure is logged and left alone (the connection stays up, nothing is reported), where previously it fell into the generic branch and both tore down/respawned the ACP connection and misreported a healthy agent turn as an "ACP agent error". Left out of scope (verified against source, not present today): a turn timeout, transport-health-scoped classification of which failures should respawn the connection, and cancel_turn/stop_reason propagation. Each is a new resilience feature or a recovery-action policy change, not error- shape surfacing, and none corresponds to an existing bug in this file. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CmsEQaNzmSmBGVsCTuKvbX --- src/band/integrations/acp/client_adapter.py | 25 ++++- src/band/integrations/acp/room_emitter.py | 3 +- tests/integrations/acp/test_client_adapter.py | 95 +++++++++++++++++-- 3 files changed, 110 insertions(+), 13 deletions(-) diff --git a/src/band/integrations/acp/client_adapter.py b/src/band/integrations/acp/client_adapter.py index 57666a0e3..553d22b86 100644 --- a/src/band/integrations/acp/client_adapter.py +++ b/src/band/integrations/acp/client_adapter.py @@ -11,11 +11,14 @@ from uuid import uuid4 from acp import spawn_agent_process +from acp.exceptions import RequestError from acp.schema import HttpMcpServer, SseMcpServer +from band_sdk_core import AgentFailure from typing_extensions import Unpack from band.converters.acp_client import ACPClientHistoryConverter from band.converters.helpers import build_replay_messages +from band.core.delivery import DeliveryFailedError from band.core.protocols import AgentToolsProtocol from band.core.simple_adapter import SimpleAdapter from band.core.types import ( @@ -125,6 +128,18 @@ def _resolve_launcher(command: list[str]) -> list[str]: return [resolved, *command[1:]] if resolved else list(command) +def _to_agent_failure(exc: Exception) -> AgentFailure: + """Parse a turn-ending exception into the shared provider-failure shape. + + ``RequestError`` is raised for a JSON-RPC error the remote agent + returned; its numeric ``code``/``data`` carry more than the generic + message alone. + """ + if isinstance(exc, RequestError): + return AgentFailure("acp", str(exc), str(exc.code), exc.data) + return AgentFailure("acp", str(exc)) + + class ACPClientAdapter(SimpleAdapter[ACPClientSessionState]): """Adapter that forwards Band messages to a remote ACP agent. @@ -357,14 +372,14 @@ async def on_message( prompt_text=prompt_text, on_chunk=emitter.emit, ) + except DeliveryFailedError as e: + # The turn's reply is what failed to post -- Band-side delivery, + # never an ACP provider failure, so the connection stays up. + logger.exception("ACP reply delivery failed: %s", e.cause) except Exception as e: logger.exception("ACP agent error: %s", e) await self.stop() - await tools.send_event( - content=f"ACP agent error: {e}", - message_type="error", - metadata={"acp_error": str(e)}, - ) + await tools.send_failure(_to_agent_failure(e)) def _make_permission_handler( self, diff --git a/src/band/integrations/acp/room_emitter.py b/src/band/integrations/acp/room_emitter.py index 9806a0915..59c9158f8 100644 --- a/src/band/integrations/acp/room_emitter.py +++ b/src/band/integrations/acp/room_emitter.py @@ -4,6 +4,7 @@ import logging +from band.core.delivery import deliver_reply from band.core.protocols import AgentToolsProtocol from band.integrations.acp.types import ( ACPToolCall, @@ -173,7 +174,7 @@ async def __aexit__(self, exc_type: object, exc: object, tb: object) -> bool: # reply (and leak the agent's narration of the call). if not turn_replied_in_room(self._chunks): for text in self._pending_text: - await self._tools.send_message(content=text, mentions=self._mentions) + await deliver_reply(self._tools, text, mentions=self._mentions) await self._tools.send_event( content="ACP client session", message_type="task", diff --git a/tests/integrations/acp/test_client_adapter.py b/tests/integrations/acp/test_client_adapter.py index 8e912665e..720bd4605 100644 --- a/tests/integrations/acp/test_client_adapter.py +++ b/tests/integrations/acp/test_client_adapter.py @@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from acp.exceptions import RequestError from acp.helpers import update_agent_message_text from band.converters.parsing import parse_tool_call, parse_tool_result @@ -44,6 +45,11 @@ def events_of_type(tools: FakeAgentTools, message_type: str) -> list[dict[str, o return [e for e in tools.events_sent if e.get("message_type") == message_type] +def reported_failures(tools: FakeAgentTools) -> list[dict[str, object]]: + """Every ``AgentFailure`` reported via ``send_failure``, as its wire dict.""" + return [e["metadata"]["failure"] for e in events_of_type(tools, "error")] + + def metadata_values(events: list[dict[str, object]], key: str) -> list[object]: """The ordered value of one metadata field across a set of events.""" return [event["metadata"][key] for event in events] @@ -745,7 +751,7 @@ async def prompt_new_session(**kwargs): async def test_on_message_error_sends_error_event( self, adapter_with_mocks: ACPClientAdapter ) -> None: - """Should send error event when ACP agent fails.""" + """Should report an AgentFailure when the ACP agent fails.""" adapter_with_mocks._runtime._conn.prompt = AsyncMock( side_effect=RuntimeError("Agent crashed") ) @@ -763,9 +769,38 @@ async def test_on_message_error_sends_error_event( room_id="room-123", ) - error_events = events_of_type(tools, "error") - assert len(error_events) == 1 - assert "Agent crashed" in error_events[0]["content"] + failures = reported_failures(tools) + assert len(failures) == 1 + assert failures[0]["provider"] == "acp" + assert "Agent crashed" in failures[0]["message"] + + @pytest.mark.asyncio + async def test_on_message_request_error_captures_code_and_data( + self, adapter_with_mocks: ACPClientAdapter + ) -> None: + """A JSON-RPC RequestError's code/data survive into the AgentFailure.""" + adapter_with_mocks._runtime._conn.prompt = AsyncMock( + side_effect=RequestError(-32603, "Internal error", {"detail": "oom"}) + ) + + tools = FakeAgentTools() + msg = make_platform_message("Hello", room_id="room-123") + + await adapter_with_mocks.on_message( + msg, + tools, + ACPClientSessionState(), + None, + None, + is_session_bootstrap=False, + room_id="room-123", + ) + + failures = reported_failures(tools) + assert len(failures) == 1 + assert failures[0]["provider"] == "acp" + assert failures[0]["code"] == "-32603" + assert failures[0]["detail"] == {"detail": "oom"} @pytest.mark.asyncio async def test_on_message_not_initialized_raises(self) -> None: @@ -1325,9 +1360,55 @@ async def test_prompt_error_clears_connection(self) -> None: assert adapter._runtime._conn is None assert adapter._runtime._ctx is None - # Error event should be sent - error_events = events_of_type(tools, "error") - assert len(error_events) == 1 + # AgentFailure should be reported + assert len(reported_failures(tools)) == 1 + + @pytest.mark.asyncio + async def test_reply_delivery_failure_leaves_connection_up(self) -> None: + """The agent answered fine; posting its reply to the room is what + failed. That must not tear down and respawn a healthy connection, + nor be reported as an ACP provider failure.""" + adapter = ACPClientAdapter(command="codex", inject_band_tools=False) + adapter._runtime._conn = AsyncMock() + mock_session = MagicMock() + mock_session.session_id = "sess-1" + adapter._runtime._conn.new_session = AsyncMock(return_value=mock_session) + adapter._runtime._client = BandACPClient() + + mock_ctx = MagicMock() + mock_ctx.__aexit__ = AsyncMock(return_value=None) + adapter._runtime._ctx = mock_ctx + + async def prompt_with_reply(**kwargs): + session_id = kwargs["session_id"] + await adapter._runtime._client.session_update( + session_id, update_agent_message_text("Here's the answer") + ) + + adapter._runtime._conn.prompt = AsyncMock(side_effect=prompt_with_reply) + + tools = FakeAgentTools() + + async def _raise(*args: object, **kwargs: object) -> None: + raise RuntimeError("platform rejected the message") + + tools.send_message = _raise # type: ignore[method-assign] + + msg = make_platform_message("Hello", room_id="room-1") + + await adapter.on_message( + msg, + tools, + ACPClientSessionState(), + None, + None, + is_session_bootstrap=False, + room_id="room-1", + ) + + assert adapter._runtime._conn is not None + assert adapter._runtime._ctx is not None + assert not reported_failures(tools) class TestACPClientAdapterInjectToolsConfig: From 355ddab7610ef132ec75fbefdbddce5a63eb9741 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sun, 6 Sep 2026 16:43:43 +0300 Subject: [PATCH 23/53] fix: address code-review findings on the AgentFailure migration - codex.py: on_message's except DeliveryFailedError now re-raises the original cause instead of swallowing it. This call path had no try/except at all before this PR, so any exception (delivery or provider) used to propagate and durably mark_failed the message; swallowing it silently turned a failed reply-delivery into a successfully processed message with no observable trace. - acp/client_adapter.py: adds a turn_timeout_s config (default 300s, mirroring Letta's own field) wrapping _runtime.prompt via asyncio.wait_for, reporting a "timeout"-coded AgentFailure and tearing the connection down for the next turn to respawn. This is an explicit Requirement in the INT-1385 ticket text itself ("so a silent/stuck agent becomes an observable failure instead of hanging indefinitely") and was mistakenly bundled into this PR's earlier note about descoped ACP resilience features -- the ticket treats the timeout as the minimum required surfacing fix, not an elective one. - crewai_flow.py: record_failed's message is capped at 500 chars again, matching every other room post in the file (record_waiting, reply_ambiguous) and the cap the old hand-built send_event path had. The ambiguous-participant-identity error embeds every colliding participant id and can exceed that length in a large room. - a2a/gateway/adapter.py: the timeout AgentFailure's code is now lowercase "timeout", matching Letta's and OpenCode's existing convention instead of introducing a second casing for the same closed vocabulary. - codex.py: _handle_approval_command's remaining send_message calls now go through deliver_reply too, for consistency with every other slash-command handler in the same file (this method sits outside any try/except either way, so behavior is unchanged; this only future- proofs it if that ever changes). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CmsEQaNzmSmBGVsCTuKvbX --- src/band/adapters/codex.py | 28 ++++++++---- src/band/adapters/crewai_flow.py | 8 +++- src/band/integrations/a2a/gateway/adapter.py | 2 +- src/band/integrations/acp/client_adapter.py | 31 +++++++++++-- tests/adapters/test_codex_adapter.py | 10 ++++- tests/adapters/test_crewai_flow_phase4.py | 41 ++++++++++++++++++ .../integrations/a2a/gateway/test_adapter.py | 2 +- tests/integrations/acp/test_client_adapter.py | 43 +++++++++++++++++++ 8 files changed, 146 insertions(+), 19 deletions(-) diff --git a/src/band/adapters/codex.py b/src/band/adapters/codex.py index 4adf7a28f..7b387859a 100644 --- a/src/band/adapters/codex.py +++ b/src/band/adapters/codex.py @@ -727,8 +727,12 @@ async def on_message( ) except DeliveryFailedError as e: # The turn did its work; posting it to the room is what failed. - # Band-side delivery, never a Codex provider failure. + # Band-side delivery, never a Codex provider failure -- but + # this call path had no try/except before deliver_reply + # existed, so any exception here must keep propagating + # exactly as it always did (durable mark_failed/retry). logger.exception("Codex reply delivery failed: %s", e.cause) + raise e.cause from None except Exception as e: await tools.send_failure(AgentFailure("codex", str(e))) raise @@ -2853,21 +2857,22 @@ async def _handle_approval_command( if command == "approvals": if not pending: - await tools.send_message("No pending approvals.", mentions=mention) + await deliver_reply(tools, "No pending approvals.", mentions=mention) return True lines = ["Pending approvals:"] now = datetime.now(timezone.utc) for token, item in list(pending.items()): age_s = int((now - item.created_at).total_seconds()) lines.append(f"- {token}: {item.summary} ({age_s}s)") - await tools.send_message("\n".join(lines), mentions=mention) + await deliver_reply(tools, "\n".join(lines), mentions=mention) return True if command not in {"approve", "decline", "approve-session"}: return False if not pending: - await tools.send_message( + await deliver_reply( + tools, "No pending approvals to resolve.", mentions=mention, ) @@ -2878,7 +2883,8 @@ async def _handle_approval_command( selected = pending.get(token) if selected is None: available = ", ".join(sorted(pending.keys())) - await tools.send_message( + await deliver_reply( + tools, f"Unknown approval id `{token}`. Pending: {available}", mentions=mention, ) @@ -2887,7 +2893,8 @@ async def _handle_approval_command( token, selected = next(iter(pending.items())) else: available = ", ".join(sorted(pending.keys())) - await tools.send_message( + await deliver_reply( + tools, "Multiple approvals pending. " f"Use `/{command} `. Pending: {available}", mentions=mention, @@ -2903,7 +2910,8 @@ async def _handle_approval_command( # an empty string in _session_approved or report a misleading # "Future `` requests will be auto-approved" message to the user. if is_session and not selected.session_key: - await tools.send_message( + await deliver_reply( + tools, f"Approval `{token}` cannot be resolved as session-level: " "this request has no command signature to match against. " f"Use `/approve {token}` for a one-shot approval instead.", @@ -2924,13 +2932,15 @@ async def _handle_approval_command( # Session-level: register the session key for auto-approval if is_session: self._record_session_approval(room_id, selected.session_key) - await tools.send_message( + await deliver_reply( + tools, f"Approval `{token}` resolved as `acceptForSession` (session-level). " f"Future `{selected.session_key}` requests will be auto-approved.", mentions=mention, ) else: - await tools.send_message( + await deliver_reply( + tools, f"Approval `{token}` resolved as `{decision_value}`.", mentions=mention, ) diff --git a/src/band/adapters/crewai_flow.py b/src/band/adapters/crewai_flow.py index a3d7db28c..ecbdf5ecb 100644 --- a/src/band/adapters/crewai_flow.py +++ b/src/band/adapters/crewai_flow.py @@ -938,9 +938,13 @@ async def record_waiting(self, reason: str) -> None: ) async def record_failed(self, error: CrewAIFlowError) -> None: - # Best-effort failure event for visibility, then the task event. + # Best-effort failure event for visibility, then the task event. The + # room-visible message is capped like every other room post in this + # file (e.g. record_waiting) -- error.message can embed an unbounded + # value (e.g. a full participant-id list from an ambiguous-identity + # error). await self._tools.send_failure( - AgentFailure("crewai_flow", error.message, error.code) + AgentFailure("crewai_flow", error.message[:500], error.code) ) await self._send_event( content=f"failed:{error.code}", diff --git a/src/band/integrations/a2a/gateway/adapter.py b/src/band/integrations/a2a/gateway/adapter.py index 0b1aac8f7..78ee79202 100644 --- a/src/band/integrations/a2a/gateway/adapter.py +++ b/src/band/integrations/a2a/gateway/adapter.py @@ -457,7 +457,7 @@ async def _await_response(self, request: GatewayRequest) -> bool: self.config.response_timeout_s, ) failure = AgentFailure( - "a2a-gateway", "Timed out waiting for a Band response", "Timeout" + "a2a-gateway", "Timed out waiting for a Band response", "timeout" ) await request.pending.fail( "Timed out waiting for a Band response", failure=failure.to_dict() diff --git a/src/band/integrations/acp/client_adapter.py b/src/band/integrations/acp/client_adapter.py index 553d22b86..a564098de 100644 --- a/src/band/integrations/acp/client_adapter.py +++ b/src/band/integrations/acp/client_adapter.py @@ -172,6 +172,7 @@ def __init__( port: int | None = None, custom_section: str = "", spawn_process: SpawnProcess | None = None, + turn_timeout_s: float = 300.0, **features: Unpack[FeatureKwargs], ) -> None: super().__init__( @@ -189,6 +190,7 @@ def __init__( self._auth_method = auth_method self._profile = profile self._custom_section = custom_section + self._turn_timeout_s = turn_timeout_s self._runtime = self._build_runtime(spawn_process) self._room_to_session: dict[str, str] = {} @@ -367,15 +369,36 @@ async def on_message( session_id, self._make_permission_handler(emitter, room_id), ) - await self._runtime.prompt( - session_id=session_id, - prompt_text=prompt_text, - on_chunk=emitter.emit, + await asyncio.wait_for( + self._runtime.prompt( + session_id=session_id, + prompt_text=prompt_text, + on_chunk=emitter.emit, + ), + timeout=self._turn_timeout_s, ) except DeliveryFailedError as e: # The turn's reply is what failed to post -- Band-side delivery, # never an ACP provider failure, so the connection stays up. logger.exception("ACP reply delivery failed: %s", e.cause) + except asyncio.TimeoutError: + # A silent/stuck agent must become an observable failure instead + # of hanging the turn indefinitely -- the connection is presumed + # wedged, so it's torn down for the next turn to respawn. + logger.error( + "ACP turn timed out after %ss (room=%s, session=%s)", + self._turn_timeout_s, + room_id, + session_id, + ) + await self.stop() + await tools.send_failure( + AgentFailure( + "acp", + f"ACP agent response timed out after {self._turn_timeout_s}s", + "timeout", + ) + ) except Exception as e: logger.exception("ACP agent error: %s", e) await self.stop() diff --git a/tests/adapters/test_codex_adapter.py b/tests/adapters/test_codex_adapter.py index 565ebe0f8..a697ff5ae 100644 --- a/tests/adapters/test_codex_adapter.py +++ b/tests/adapters/test_codex_adapter.py @@ -6011,7 +6011,10 @@ async def test_local_command_reply_delivery_failure_is_not_reported( ) -> None: """/help's answer failing to post is Band-side delivery, not a Codex provider failure -- deliver_reply's DeliveryFailedError must be - recognized and left unreported here.""" + recognized and left unreported here. codex.py's on_message had no + try/except at all before this PR, so the original cause must still + propagate (the message still fails/retries at the platform level), + just never misreported as a Codex AgentFailure.""" class FailingSendMessageTools(ToolSchemaFakeTools): async def send_message( @@ -6027,7 +6030,10 @@ async def send_message( tools = FailingSendMessageTools() await adapter.on_started("Agent", "A coding agent") - with caplog.at_level(logging.ERROR, logger="band.adapters.codex"): + with ( + caplog.at_level(logging.ERROR, logger="band.adapters.codex"), + pytest.raises(RuntimeError, match="platform rejected the message"), + ): await adapter.on_message( make_platform_message(content="/help"), tools, diff --git a/tests/adapters/test_crewai_flow_phase4.py b/tests/adapters/test_crewai_flow_phase4.py index 99b2f4702..436d6a5ea 100644 --- a/tests/adapters/test_crewai_flow_phase4.py +++ b/tests/adapters/test_crewai_flow_phase4.py @@ -890,6 +890,47 @@ async def test_ambiguous_delegation_target_records_failed_without_sending( assert payloads[-1]["status"] == "failed" assert payloads[-1]["error"]["code"] == "ambiguous_participant" + @pytest.mark.asyncio + async def test_ambiguous_delegation_failure_message_is_capped(self) -> None: + """The ambiguous-identity error embeds every colliding participant id + -- room-visible content stays capped like every other post in this + adapter (record_waiting, reply_ambiguous), even with a large room.""" + colliding_ids = [f"participant-id-{i:040d}" for i in range(30)] + flow = _flow( + { + "decision": "delegate", + "delegations": [ + { + "delegation_id": "d-A", + "target": "peer-a", + "content": "do A", + "mentions": ["@example/peer-a"], + } + ], + } + ) + adapter = CrewAIFlowAdapter( + flow_factory=lambda: flow, + state_source=HistoryCrewAIFlowStateSource(acknowledge_test_only=True), + ) + tools = FakeAgentTools( + participants=[ + {"id": pid, "handle": "@example/peer-a", "name": "Peer A"} + for pid in colliding_ids + ] + ) + await _start(adapter, "router") + await _turn(adapter, tools, _msg(id="msg-1"), is_session_bootstrap=True) + + failures = [ + e["metadata"]["failure"] + for e in tools.events_sent + if e["message_type"] == "error" + ] + assert len(failures) == 1 + assert failures[0]["code"] == "ambiguous_participant" + assert len(failures[0]["message"]) <= 500 + @pytest.mark.asyncio async def test_delegation_send_failure_stops_later_delegations( self, diff --git a/tests/integrations/a2a/gateway/test_adapter.py b/tests/integrations/a2a/gateway/test_adapter.py index 439e6f765..edb6cb754 100644 --- a/tests/integrations/a2a/gateway/test_adapter.py +++ b/tests/integrations/a2a/gateway/test_adapter.py @@ -298,7 +298,7 @@ async def test_timeout_returns_terminal_failure( terminal = await queue.dequeue_event() assert terminal.status.state == TaskState.TASK_STATE_FAILED assert terminal.metadata["failure"]["provider"] == "a2a-gateway" - assert terminal.metadata["failure"]["code"] == "Timeout" + assert terminal.metadata["failure"]["code"] == "timeout" assert adapter._pending_tasks == {} assert not any( "A2A request completed" in record.message for record in caplog.records diff --git a/tests/integrations/acp/test_client_adapter.py b/tests/integrations/acp/test_client_adapter.py index 720bd4605..6ab6f4e47 100644 --- a/tests/integrations/acp/test_client_adapter.py +++ b/tests/integrations/acp/test_client_adapter.py @@ -1410,6 +1410,49 @@ async def _raise(*args: object, **kwargs: object) -> None: assert adapter._runtime._ctx is not None assert not reported_failures(tools) + @pytest.mark.asyncio + async def test_turn_timeout_reports_failure_and_clears_connection(self) -> None: + """A silent/stuck agent must become an observable failure instead of + hanging the turn indefinitely, and the presumed-wedged connection is + torn down so the next turn respawns it.""" + adapter = ACPClientAdapter( + command="codex", inject_band_tools=False, turn_timeout_s=0.01 + ) + adapter._runtime._conn = AsyncMock() + mock_session = MagicMock() + mock_session.session_id = "sess-1" + adapter._runtime._conn.new_session = AsyncMock(return_value=mock_session) + adapter._runtime._client = BandACPClient() + + mock_ctx = MagicMock() + mock_ctx.__aexit__ = AsyncMock(return_value=None) + adapter._runtime._ctx = mock_ctx + + async def hang(**kwargs: object) -> None: + await asyncio.sleep(10) + + adapter._runtime._conn.prompt = AsyncMock(side_effect=hang) + + tools = FakeAgentTools() + msg = make_platform_message("Hello", room_id="room-1") + + await adapter.on_message( + msg, + tools, + ACPClientSessionState(), + None, + None, + is_session_bootstrap=False, + room_id="room-1", + ) + + assert adapter._runtime._conn is None + assert adapter._runtime._ctx is None + failures = reported_failures(tools) + assert len(failures) == 1 + assert failures[0]["provider"] == "acp" + assert failures[0]["code"] == "timeout" + class TestACPClientAdapterInjectToolsConfig: """Tests for inject_band_tools configuration.""" From 43692d1491a667389f298ca9531eaa076eb725d2 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sun, 6 Sep 2026 20:17:35 +0300 Subject: [PATCH 24/53] refactor: simplify error-surfacing migration per /simplify review Four parallel review agents (reuse, simplification, efficiency, altitude) checked the AgentFailure migration diff for cleanup opportunities. Applied the verified, in-scope findings: - to_failure_event now uses has_visible_content() instead of a bare .strip(), matching the platform's actual blank-content rule (a zero-width-only message previously slipped past the blank check) - centralized the reported_failures() test helper (band.testing) instead of three duplicated copies plus one inlined reimplementation - send_failure implementations pass MessageType.ERROR instead of the raw "error" literal - added a shared FAILURE_CODE_TIMEOUT constant instead of four independent "timeout" literals across letta/acp/opencode/a2a-gateway - a2a-gateway's timeout branch reuses failure.message instead of retyping the same string twice - codex.py's 5 new task-event send_event calls go through one _emit_event_safe helper instead of duplicating the try/except-log shape (mirrors copilot_sdk's existing _send_event_safe) - removed a docs/adapters/codex.md row describing the now-deleted structured_errors config field Skipped: a shared SimpleAdapter provider-slug ClassVar and a shared report_failure(tools, provider, exc) helper (both touch adapter class-shape/control-flow well beyond this migration's own new code), and per-adapter exception-to-AgentFailure enrichment unification (genuinely adapter-specific, tied to each SDK's own exception types). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CmsEQaNzmSmBGVsCTuKvbX --- docs/adapters/codex.md | 1 - src/band/adapters/codex.py | 190 +++++++++--------- src/band/adapters/letta.py | 4 +- src/band/adapters/opencode/adapter.py | 4 +- src/band/core/protocols.py | 10 +- src/band/integrations/a2a/gateway/adapter.py | 10 +- src/band/integrations/acp/client_adapter.py | 4 +- src/band/runtime/tools/agent.py | 4 +- src/band/testing/__init__.py | 7 +- src/band/testing/fake_tools.py | 13 +- tests/adapters/opencode/helpers.py | 5 - tests/adapters/opencode/test_turns.py | 3 +- tests/adapters/test_codex_adapter.py | 7 +- tests/adapters/test_crewai_flow_phase4.py | 8 +- tests/integrations/acp/test_client_adapter.py | 7 +- 15 files changed, 140 insertions(+), 137 deletions(-) diff --git a/docs/adapters/codex.md b/docs/adapters/codex.md index 0b02d57ab..92af1f8fe 100644 --- a/docs/adapters/codex.md +++ b/docs/adapters/codex.md @@ -249,7 +249,6 @@ These `CodexAdapterConfig(...)` flags add more telemetry detail: | `emit_turn_lifecycle_events` | `bool` | `False` | Emit enriched turn lifecycle events at turn start and completion. | | `emit_diff_events` | `bool` | `False` | Include file diffs in event metadata, capped at 64 KB. | | `emit_token_usage_events` | `bool` | `False` | Track and emit token usage per session. | -| `structured_errors` | `bool` | `True` | Emit structured error events instead of plain text errors. | Enabling both `emit_turn_task_markers` and `emit_turn_lifecycle_events` produces two task events per completed turn. Pick one; lifecycle events contain richer metadata. diff --git a/src/band/adapters/codex.py b/src/band/adapters/codex.py index 7b387859a..07ca00e4c 100644 --- a/src/band/adapters/codex.py +++ b/src/band/adapters/codex.py @@ -632,25 +632,22 @@ async def on_message( Emit.TASK_EVENTS in self.features.emit and self.config.emit_turn_task_markers ): - try: - await tools.send_event( - content=self._build_task_event_content( - task_id=turn_id or None, - task="Codex turn", - status="started", - summary=f"Thread: {thread_id}", - ), - message_type="task", - metadata={ - "codex_thread_id": thread_id, - "codex_turn_id": turn_id or None, - "codex_room_id": room_id, - }, - ) - except Exception: - logger.debug( - "Failed to emit turn started task event", exc_info=True - ) + await self._emit_event_safe( + tools, + content=self._build_task_event_content( + task_id=turn_id or None, + task="Codex turn", + status="started", + summary=f"Thread: {thread_id}", + ), + message_type="task", + metadata={ + "codex_thread_id": thread_id, + "codex_turn_id": turn_id or None, + "codex_room_id": room_id, + }, + log_label="turn started task event", + ) # Phase 2: Turn STARTED lifecycle event with input summary if ( @@ -658,29 +655,25 @@ async def on_message( and Emit.TASK_EVENTS in self.features.emit ): input_summary = (msg.content or "")[:200] - try: - await tools.send_event( - content=self._build_task_event_content( - task_id=turn_id or None, - task="Codex turn lifecycle", - status="started", - summary=f"Thread: {thread_id}", - ), - message_type="task", - metadata={ - "codex_event_type": "turn_lifecycle", - "codex_room_id": room_id, - "codex_thread_id": thread_id, - "codex_turn_id": turn_id or None, - "codex_turn_status": "started", - "codex_input_summary": input_summary, - }, - ) - except Exception: - logger.debug( - "Failed to emit turn started lifecycle event", - exc_info=True, - ) + await self._emit_event_safe( + tools, + content=self._build_task_event_content( + task_id=turn_id or None, + task="Codex turn lifecycle", + status="started", + summary=f"Thread: {thread_id}", + ), + message_type="task", + metadata={ + "codex_event_type": "turn_lifecycle", + "codex_room_id": room_id, + "codex_thread_id": thread_id, + "codex_turn_id": turn_id or None, + "codex_turn_status": "started", + "codex_input_summary": input_summary, + }, + log_label="turn started lifecycle event", + ) # Reset per-turn token deltas for the new turn. usage_obj = self._token_usage.get(thread_id) @@ -1162,26 +1155,22 @@ async def _ensure_thread( self._room_threads[room_id] = thread_id self._raw_history_by_room.pop(room_id, None) if Emit.TASK_EVENTS in self.features.emit: - try: - await tools.send_event( - content=self._build_task_event_content( - task_id=thread_id, - task="Codex thread", - status="resumed", - summary=f"Room: {room_id}", - ), - message_type="task", - metadata={ - "codex_thread_id": thread_id, - "codex_room_id": room_id, - "codex_resumed": True, - }, - ) - except Exception: - logger.debug( - "Failed to emit thread resumed task event", - exc_info=True, - ) + await self._emit_event_safe( + tools, + content=self._build_task_event_content( + task_id=thread_id, + task="Codex thread", + status="resumed", + summary=f"Room: {room_id}", + ), + message_type="task", + metadata={ + "codex_thread_id": thread_id, + "codex_room_id": room_id, + "codex_resumed": True, + }, + log_label="thread resumed task event", + ) return thread_id except CodexJsonRpcError as exc: logger.warning( @@ -1215,24 +1204,23 @@ async def _ensure_thread( self._room_threads[room_id] = thread_id if Emit.TASK_EVENTS in self.features.emit: - try: - await tools.send_event( - content=self._build_task_event_content( - task_id=thread_id, - task="Codex thread", - status="mapped", - summary=f"Transport: {self.config.transport}", - ), - message_type="task", - metadata={ - "codex_thread_id": thread_id, - "codex_room_id": room_id, - "codex_created_at": datetime.now(timezone.utc).isoformat(), - "codex_transport": self.config.transport, - }, - ) - except Exception: - logger.debug("Failed to emit thread mapped task event", exc_info=True) + await self._emit_event_safe( + tools, + content=self._build_task_event_content( + task_id=thread_id, + task="Codex thread", + status="mapped", + summary=f"Transport: {self.config.transport}", + ), + message_type="task", + metadata={ + "codex_thread_id": thread_id, + "codex_room_id": room_id, + "codex_created_at": datetime.now(timezone.utc).isoformat(), + "codex_transport": self.config.transport, + }, + log_label="thread mapped task event", + ) return thread_id @@ -1696,19 +1684,18 @@ async def _emit_turn_outcome( metadata["codex_duration_s"] = round(duration_s, 2) if has_usage: metadata.update(usage.to_metadata()) - try: - await tools.send_event( - content=self._build_task_event_content( - task_id=turn_id, - task="Codex turn", - status=turn_status, - summary=summary, - ), - message_type="task", - metadata=metadata, - ) - except Exception: - logger.debug("Failed to emit turn outcome task event", exc_info=True) + await self._emit_event_safe( + tools, + content=self._build_task_event_content( + task_id=turn_id, + task="Codex turn", + status=turn_status, + summary=summary, + ), + message_type="task", + metadata=metadata, + log_label="turn outcome task event", + ) # Phase 2: Enriched turn lifecycle events if ( @@ -3273,6 +3260,23 @@ def _build_task_event_content( lines.append(f"Summary: {summary}") return "\n".join(lines) + async def _emit_event_safe( + self, + tools: AgentToolsProtocol, + *, + content: str, + message_type: str, + metadata: dict[str, Any], + log_label: str, + ) -> None: + """Send a best-effort platform event, downgrading a failure to a debug log.""" + try: + await tools.send_event( + content=content, message_type=message_type, metadata=metadata + ) + except Exception: + logger.debug("Failed to emit %s", log_label, exc_info=True) + @staticmethod def _extract_local_command(content: str) -> tuple[str, str] | None: """Return ``(command, args)`` when ``content`` opens with a slash command. diff --git a/src/band/adapters/letta.py b/src/band/adapters/letta.py index 974eb8bd9..2a9743ee5 100644 --- a/src/band/adapters/letta.py +++ b/src/band/adapters/letta.py @@ -14,7 +14,7 @@ from band.converters.letta import LettaHistoryConverter, LettaSessionState from band.core.delivery import DeliveryFailedError, deliver_reply -from band.core.protocols import AgentToolsProtocol +from band.core.protocols import FAILURE_CODE_TIMEOUT, AgentToolsProtocol from band.core.simple_adapter import SimpleAdapter from band.core.types import ( AdapterFeatures, @@ -442,7 +442,7 @@ async def _run_turn( AgentFailure( "letta", f"Letta agent response timed out after {self.config.turn_timeout_s}s", - "timeout", + FAILURE_CODE_TIMEOUT, ) ) except Exception as e: diff --git a/src/band/adapters/opencode/adapter.py b/src/band/adapters/opencode/adapter.py index e75b03441..cf89e11a9 100644 --- a/src/band/adapters/opencode/adapter.py +++ b/src/band/adapters/opencode/adapter.py @@ -20,7 +20,7 @@ from band.adapters.opencode.config import OpencodeAdapterConfig from band.converters.opencode import OpencodeHistoryConverter from band.core.exceptions import BandConnectionError -from band.core.protocols import AgentToolsProtocol +from band.core.protocols import FAILURE_CODE_TIMEOUT, AgentToolsProtocol from band.core.simple_adapter import SimpleAdapter from band.core.types import ( AdapterFeatures, @@ -930,7 +930,7 @@ async def _watch_turn_completion( AgentFailure( "opencode", "OpenCode timed out before completing the turn.", - "timeout", + FAILURE_CODE_TIMEOUT, ) ) # Tokens spent before the timeout were still spent — emit them, same diff --git a/src/band/core/protocols.py b/src/band/core/protocols.py index 6d82335d1..dbed1e798 100644 --- a/src/band/core/protocols.py +++ b/src/band/core/protocols.py @@ -6,6 +6,8 @@ from band_sdk_core import AgentFailure +from band.core.content import has_visible_content + if TYPE_CHECKING: from anthropic.types import ToolParam @@ -29,6 +31,11 @@ T = TypeVar("T") +# Shared ``AgentFailure.code`` value for a stalled/unresponsive provider turn, +# so every adapter's timeout branch reports the same code instead of each +# retyping the literal. +FAILURE_CODE_TIMEOUT = "timeout" + def to_failure_event(failure: AgentFailure) -> tuple[str, dict[str, Any]]: """Shared shape every ``send_failure`` implementation posts as an `error` event. @@ -41,7 +48,8 @@ def to_failure_event(failure: AgentFailure) -> tuple[str, dict[str, Any]]: """ content = ( failure.message.strip() - or f"{failure.provider} failed without an error message." + if has_visible_content(failure.message) + else f"{failure.provider} failed without an error message." ) return content, {"failure": failure.to_dict()} diff --git a/src/band/integrations/a2a/gateway/adapter.py b/src/band/integrations/a2a/gateway/adapter.py index 78ee79202..8699cb4ee 100644 --- a/src/band/integrations/a2a/gateway/adapter.py +++ b/src/band/integrations/a2a/gateway/adapter.py @@ -29,7 +29,7 @@ ) from band.converters.a2a_gateway import GatewayHistoryConverter from band.core.content import BLANK_CONTENT_ERROR -from band.core.protocols import AgentToolsProtocol +from band.core.protocols import FAILURE_CODE_TIMEOUT, AgentToolsProtocol from band.core.simple_adapter import SimpleAdapter from band.core.types import Capability, Emit, FeatureKwargs, PlatformMessage from band.platform.posting import post_event, post_message @@ -457,11 +457,11 @@ async def _await_response(self, request: GatewayRequest) -> bool: self.config.response_timeout_s, ) failure = AgentFailure( - "a2a-gateway", "Timed out waiting for a Band response", "timeout" - ) - await request.pending.fail( - "Timed out waiting for a Band response", failure=failure.to_dict() + "a2a-gateway", + "Timed out waiting for a Band response", + FAILURE_CODE_TIMEOUT, ) + await request.pending.fail(failure.message, failure=failure.to_dict()) return False return True diff --git a/src/band/integrations/acp/client_adapter.py b/src/band/integrations/acp/client_adapter.py index a564098de..59a46bf21 100644 --- a/src/band/integrations/acp/client_adapter.py +++ b/src/band/integrations/acp/client_adapter.py @@ -19,7 +19,7 @@ from band.converters.acp_client import ACPClientHistoryConverter from band.converters.helpers import build_replay_messages from band.core.delivery import DeliveryFailedError -from band.core.protocols import AgentToolsProtocol +from band.core.protocols import FAILURE_CODE_TIMEOUT, AgentToolsProtocol from band.core.simple_adapter import SimpleAdapter from band.core.types import ( AdapterFeatures, @@ -396,7 +396,7 @@ async def on_message( AgentFailure( "acp", f"ACP agent response timed out after {self._turn_timeout_s}s", - "timeout", + FAILURE_CODE_TIMEOUT, ) ) except Exception as e: diff --git a/src/band/runtime/tools/agent.py b/src/band/runtime/tools/agent.py index 5fc0c38b9..82465cec3 100644 --- a/src/band/runtime/tools/agent.py +++ b/src/band/runtime/tools/agent.py @@ -54,7 +54,7 @@ validate_include, ) from band.core.tool_filter import sanitize_tool_schema -from band.core.types import Capability +from band.core.types import Capability, MessageType from band.core.validation import at_least_one_of from band.runtime.tools.registry import ( TOOL_DEFINITIONS, @@ -452,7 +452,7 @@ async def send_failure(self, failure: band_sdk_core.AgentFailure) -> Any: """ content, metadata = to_failure_event(failure) try: - return await self.send_event(content, "error", metadata) + return await self.send_event(content, MessageType.ERROR, metadata) except Exception as exc: logger.exception("send_failure could not post the failure event") return {"ok": False, "error": str(exc)} diff --git a/src/band/testing/__init__.py b/src/band/testing/__init__.py index 911ce66df..a930c4e8d 100644 --- a/src/band/testing/__init__.py +++ b/src/band/testing/__init__.py @@ -12,7 +12,10 @@ # Type-only imports for static analysis (pyrefly, mypy, etc.) if TYPE_CHECKING: - from band.testing.fake_tools import FakeAgentTools as FakeAgentTools + from band.testing.fake_tools import ( + FakeAgentTools as FakeAgentTools, + reported_failures as reported_failures, + ) from band.testing.features import feature_kwargs as feature_kwargs from band.testing.phoenix_server import ( FakePhoenixServer as FakePhoenixServer, @@ -35,7 +38,7 @@ __all__, __getattr__ = lazy_exports( __name__, - fake_tools=["FakeAgentTools"], + fake_tools=["FakeAgentTools", "reported_failures"], features=["feature_kwargs"], phoenix_server=["FakePhoenixServer", "JoinOutcome", "fake_phoenix_server"], platform=["platform_connection_stub"], diff --git a/src/band/testing/fake_tools.py b/src/band/testing/fake_tools.py index b86eac8fc..23fa83ef0 100644 --- a/src/band/testing/fake_tools.py +++ b/src/band/testing/fake_tools.py @@ -38,7 +38,7 @@ from band.core.exceptions import BandToolError from band.core.protocols import to_failure_event from band.core.task_types import TaskAssignmentStatus, TaskLifecycleState, TaskListState -from band.core.types import Capability +from band.core.types import Capability, MessageType from band.runtime.tools import ( DEFAULT_FILE_CAPTION, FILE_UNAVAILABLE_MESSAGE, @@ -223,7 +223,7 @@ async def send_failure( """Same best-effort delegation as ``AgentTools.send_failure``.""" content, metadata = to_failure_event(failure) try: - return await self.send_event(content, "error", metadata) + return await self.send_event(content, MessageType.ERROR, metadata) except Exception as exc: return {"ok": False, "error": str(exc)} @@ -729,3 +729,12 @@ def assert_no_messages_sent(self) -> None: assert not self.messages_sent, ( f"Expected no messages, but {len(self.messages_sent)} were sent" ) + + +def reported_failures(tools: FakeAgentTools) -> list[dict[str, Any]]: + """Every ``AgentFailure`` reported via ``send_failure``, as its wire dict.""" + return [ + e["metadata"]["failure"] + for e in tools.events_sent + if e["message_type"] == MessageType.ERROR + ] diff --git a/tests/adapters/opencode/helpers.py b/tests/adapters/opencode/helpers.py index 720f0b26a..ef06181e0 100644 --- a/tests/adapters/opencode/helpers.py +++ b/tests/adapters/opencode/helpers.py @@ -217,11 +217,6 @@ def events_of_type(tools: FakeAgentTools, message_type: str) -> list[dict[str, A return [e for e in tools.events_sent if e["message_type"] == message_type] -def reported_failures(tools: FakeAgentTools) -> list[dict[str, Any]]: - """Every ``AgentFailure`` reported via ``send_failure``, as its wire dict.""" - return [e["metadata"]["failure"] for e in events_of_type(tools, "error")] - - class RaisingSendTools(FakeAgentTools): """FakeAgentTools whose send_message always fails, to exercise the best-effort ``_notify_room`` path: a room post that raises must be diff --git a/tests/adapters/opencode/test_turns.py b/tests/adapters/opencode/test_turns.py index e2e0ac69f..1fab36196 100644 --- a/tests/adapters/opencode/test_turns.py +++ b/tests/adapters/opencode/test_turns.py @@ -12,7 +12,7 @@ Emit, ) from band.integrations.opencode.types import OpencodeSessionState -from band.testing import FakeAgentTools +from band.testing import FakeAgentTools, reported_failures from tests.adapters.usage_events import recorded_usage_payloads @@ -32,7 +32,6 @@ event_tool_part, event_user_message_updated, make_platform_message, - reported_failures, tools_protocol, wait_for, ) diff --git a/tests/adapters/test_codex_adapter.py b/tests/adapters/test_codex_adapter.py index a697ff5ae..82652f2b7 100644 --- a/tests/adapters/test_codex_adapter.py +++ b/tests/adapters/test_codex_adapter.py @@ -35,7 +35,7 @@ ) from band.runtime.custom_tools import CustomToolDef from band.runtime.tools import ToolCallOutcome -from band.testing import FakeAgentTools +from band.testing import FakeAgentTools, reported_failures def make_platform_message( @@ -59,11 +59,6 @@ def events_of_type(tools: FakeAgentTools, message_type: str) -> list[dict[str, A return [e for e in tools.events_sent if e["message_type"] == message_type] -def reported_failures(tools: FakeAgentTools) -> list[dict[str, Any]]: - """Every ``AgentFailure`` reported via ``send_failure``, as its wire dict.""" - return [e["metadata"]["failure"] for e in events_of_type(tools, "error")] - - class ToolSchemaFakeTools(FakeAgentTools): def get_openai_tool_schemas(self, **kwargs: Any) -> list[dict[str, Any]]: return [ diff --git a/tests/adapters/test_crewai_flow_phase4.py b/tests/adapters/test_crewai_flow_phase4.py index 436d6a5ea..6336681ed 100644 --- a/tests/adapters/test_crewai_flow_phase4.py +++ b/tests/adapters/test_crewai_flow_phase4.py @@ -29,7 +29,7 @@ def _mock_crewai(monkeypatch: pytest.MonkeyPatch): RestCrewAIFlowStateSource, ) from band.core.types import PlatformMessage # noqa: E402 -from band.testing.fake_tools import FakeAgentTools # noqa: E402 +from band.testing.fake_tools import FakeAgentTools, reported_failures # noqa: E402 NS_PREFIX = "crewai_flow:" @@ -922,11 +922,7 @@ async def test_ambiguous_delegation_failure_message_is_capped(self) -> None: await _start(adapter, "router") await _turn(adapter, tools, _msg(id="msg-1"), is_session_bootstrap=True) - failures = [ - e["metadata"]["failure"] - for e in tools.events_sent - if e["message_type"] == "error" - ] + failures = reported_failures(tools) assert len(failures) == 1 assert failures[0]["code"] == "ambiguous_participant" assert len(failures[0]["message"]) <= 500 diff --git a/tests/integrations/acp/test_client_adapter.py b/tests/integrations/acp/test_client_adapter.py index 6ab6f4e47..16cb7f340 100644 --- a/tests/integrations/acp/test_client_adapter.py +++ b/tests/integrations/acp/test_client_adapter.py @@ -21,7 +21,7 @@ ) from band.integrations.acp.room_emitter import turn_replied_in_room from band.integrations.acp.types import ACPToolCall, ACPToolResult, CollectedChunk -from band.testing import FakeAgentTools +from band.testing import FakeAgentTools, reported_failures from tests.integrations.acp.conftest import make_platform_message @@ -45,11 +45,6 @@ def events_of_type(tools: FakeAgentTools, message_type: str) -> list[dict[str, o return [e for e in tools.events_sent if e.get("message_type") == message_type] -def reported_failures(tools: FakeAgentTools) -> list[dict[str, object]]: - """Every ``AgentFailure`` reported via ``send_failure``, as its wire dict.""" - return [e["metadata"]["failure"] for e in events_of_type(tools, "error")] - - def metadata_values(events: list[dict[str, object]], key: str) -> list[object]: """The ordered value of one metadata field across a set of events.""" return [event["metadata"][key] for event in events] From eb28bd8d2f117926b820ff90edcc36333f02e3fc Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Sun, 6 Sep 2026 21:11:16 +0300 Subject: [PATCH 25/53] fix: correctness bugs surfaced by /code-review max --fix Verified each finding directly against source/tests before accepting. Credential redaction (a2a-gateway): the KV regex stopped its value group at the first whitespace, so a scheme-prefixed credential ("Authorization: ApiKey sk-...") only redacted up to the space and leaked the real secret; widened it, and applied the same redaction to the peer-forwarded-failure relay path (on_event's "error" branch), which previously reached an external A2A client with zero redaction, unlike this adapter's own exception path. A2A adapter: the finally block's terminal task-event emission could replace a DeliveryFailedError already propagating from the try block (Python try/finally semantics), turning a Band delivery outage into a fabricated "a2a" provider failure once it reached on_message. Now isolated in its own try/except. CrewAI Flow: record_failed's 500-char cap now preserves the untruncated message in AgentFailure.detail instead of discarding it. fake_tools.reported_failures(): now ignores "error" events with no failure metadata, so pydantic_ai's still-unmigrated bare send_event(..., "error") call doesn't crash it with a KeyError. Codex: the turn-timeout path never called send_failure, unlike every sibling adapter's timeout handling; added it. Deduped the case where both an "error" notification and a failed turn/completed fire for the same incident (was reporting twice). Wrapped _handle_approval_command's reply delivery in the same DeliveryFailedError handling as every other reply path in the file (it ran outside it). Tightened _emit_structured_turn_error's non-dict-error guard so a falsy scalar (e.g. False) can't become the literal string "False" in a room-visible message. Also centralized events_of_type in band.testing (removing a dead, byte-identical copy in tests/adapters/opencode/helpers.py and several inline reimplementations), and fixed two comments that narrated the diff's own history instead of stating a present-tense fact. Added regression tests for every fix above (the review's own pass hadn't added coverage for the codex.py, a2a/adapter.py, or a2a/gateway fixes) -- confirmed each new a2a test actually fails against the pre-fix code. Not fixed: ACPClientAdapter's turn-timeout calls self.stop(), which tears down the adapter-wide shared runtime, evicting every other room's in-flight session on one room's timeout. Real, but pre-existing (the adapter's generic except-Exception branch already did this before this PR) and needs a per-room isolation design, not a bolt-on patch -- flagging for a separate discussion rather than guessing at a fix. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CmsEQaNzmSmBGVsCTuKvbX --- src/band/adapters/codex.py | 66 ++++++--- src/band/adapters/crewai_flow.py | 7 +- src/band/integrations/a2a/adapter.py | 13 +- src/band/integrations/a2a/gateway/adapter.py | 27 +++- src/band/testing/__init__.py | 3 +- src/band/testing/fake_tools.py | 16 ++- .../adapters/copilot_sdk/test_turn_failure.py | 17 +-- tests/adapters/opencode/helpers.py | 7 +- tests/adapters/opencode/test_approvals.py | 5 +- tests/adapters/opencode/test_lifecycle.py | 3 +- tests/adapters/opencode/test_setup.py | 3 +- tests/adapters/test_codex_adapter.py | 135 +++++++++++++++++- tests/adapters/test_crewai_flow_phase4.py | 4 + tests/adapters/test_gemini_adapter.py | 2 +- tests/adapters/test_letta_adapter.py | 6 +- .../integrations/a2a/gateway/test_adapter.py | 43 +++++- tests/integrations/a2a/test_adapter.py | 20 +++ 17 files changed, 315 insertions(+), 62 deletions(-) diff --git a/src/band/adapters/codex.py b/src/band/adapters/codex.py index 07ca00e4c..fed88f373 100644 --- a/src/band/adapters/codex.py +++ b/src/band/adapters/codex.py @@ -20,7 +20,7 @@ from band.converters.codex import CodexHistoryConverter from band.converters.helpers import build_replay_messages from band.core.delivery import DeliveryFailedError, deliver_reply -from band.core.protocols import AgentToolsProtocol +from band.core.protocols import FAILURE_CODE_TIMEOUT, AgentToolsProtocol from band.core.simple_adapter import SimpleAdapter from band.core.types import ( AgentInput, @@ -570,13 +570,20 @@ async def on_message( "decline", "approvals", }: - handled = await self._handle_approval_command( - tools=tools, - msg=msg, - room_id=room_id, - command=command[0], - args=command[1], - ) + try: + handled = await self._handle_approval_command( + tools=tools, + msg=msg, + room_id=room_id, + command=command[0], + args=command[1], + ) + except DeliveryFailedError as e: + # Same Band-delivery-vs-provider-failure split as the + # turn-processing path below: re-raise the cause so + # mark_failed/retry bookkeeping keys off the real exception. + logger.exception("Codex reply delivery failed: %s", e.cause) + raise e.cause from None if handled: return @@ -719,11 +726,10 @@ async def on_message( duration_s=_turn_duration_s, ) except DeliveryFailedError as e: - # The turn did its work; posting it to the room is what failed. - # Band-side delivery, never a Codex provider failure -- but - # this call path had no try/except before deliver_reply - # existed, so any exception here must keep propagating - # exactly as it always did (durable mark_failed/retry). + # The turn did its work; posting it to the room is what + # failed -- Band-side delivery, never a Codex provider + # failure. Re-raise the cause (not this wrapper) so + # mark_failed/retry bookkeeping keys off the real exception. logger.exception("Codex reply delivery failed: %s", e.cause) raise e.cause from None except Exception as e: @@ -745,6 +751,7 @@ async def _process_turn_events( raise RuntimeError("CodexAdapter client is None during turn event loop") result = TurnResult() + failure_reported = False try: while True: _remaining = max( @@ -781,13 +788,14 @@ async def _process_turn_events( continue if event.method == "error": - await self._handle_error_event( + reported = await self._handle_error_event( tools=tools, params=params, room_id=room_id, thread_id=thread_id, turn_id=turn_id, ) + failure_reported = failure_reported or reported continue # --- Phase 3: Real-time streaming --- @@ -995,8 +1003,10 @@ async def _process_turn_events( continue result.turn_status = str(turn_payload.get("status") or "failed") result.turn_error = self._extract_turn_error(turn_payload) - # Phase 1: structured error for failed turns - if result.turn_status == "failed": + # Phase 1: structured error for failed turns. Skipped when + # an earlier "error" notification in this same turn + # already reported one, so one incident isn't posted twice. + if result.turn_status == "failed" and not failure_reported: await self._emit_structured_turn_error( tools=tools, turn_payload=turn_payload, @@ -1023,6 +1033,13 @@ async def _process_turn_events( "Failed to send turn/interrupt after timeout", exc_info=True, ) + await tools.send_failure( + AgentFailure( + "codex", + f"Codex turn timed out after {self.config.turn_timeout_s}s", + FAILURE_CODE_TIMEOUT, + ) + ) result.turn_status = "interrupted" result.turn_error = "Turn timed out" return result @@ -2258,8 +2275,13 @@ async def _handle_error_event( room_id: str, thread_id: str, turn_id: str | None, - ) -> None: - """Handle an ``error`` notification from Codex.""" + ) -> bool: + """Handle an ``error`` notification from Codex. + + Returns whether a failure was actually reported, so the turn loop can + skip a redundant second report if ``turn/completed`` also arrives with + a failed status for the same incident. + """ error_obj = params.get("error") or {} if isinstance(error_obj, dict): error_msg = error_obj.get("message", "") @@ -2281,7 +2303,7 @@ async def _handle_error_event( turn_id, error_msg, ) - return + return False logger.error("Codex error: %s", error_msg) await tools.send_failure( @@ -2289,6 +2311,7 @@ async def _handle_error_event( error_obj, thread_id=thread_id, turn_id=turn_id, room_id=room_id ) ) + return True async def _emit_structured_turn_error( self, @@ -2304,7 +2327,10 @@ async def _emit_structured_turn_error( if error is None: return if not isinstance(error, dict): - error = {"message": str(error)} + # A falsy scalar (``""``, ``0``) has no useful message to carry; + # build_agent_failure's own fallback covers it uniformly instead + # of shipping a degenerate literal string like "False". + error = {"message": str(error)} if error else {} await tools.send_failure( build_agent_failure( error, thread_id=thread_id, turn_id=turn_id, room_id=room_id diff --git a/src/band/adapters/crewai_flow.py b/src/band/adapters/crewai_flow.py index ecbdf5ecb..48a83fe20 100644 --- a/src/band/adapters/crewai_flow.py +++ b/src/band/adapters/crewai_flow.py @@ -942,9 +942,12 @@ async def record_failed(self, error: CrewAIFlowError) -> None: # room-visible message is capped like every other room post in this # file (e.g. record_waiting) -- error.message can embed an unbounded # value (e.g. a full participant-id list from an ambiguous-identity - # error). + # error) -- so the untruncated text is preserved in detail for + # structured consumers reading the "error"-typed event. + message = error.message[:500] + detail = error.message if len(error.message) > 500 else None await self._tools.send_failure( - AgentFailure("crewai_flow", error.message[:500], error.code) + AgentFailure("crewai_flow", message, error.code, detail) ) await self._send_event( content=f"failed:{error.code}", diff --git a/src/band/integrations/a2a/adapter.py b/src/band/integrations/a2a/adapter.py index 04a7bf42e..d5ba3f8bb 100644 --- a/src/band/integrations/a2a/adapter.py +++ b/src/band/integrations/a2a/adapter.py @@ -210,8 +210,19 @@ async def _handle_event( finally: # A terminal task must be persisted and released even when Band # delivery fails, or the room keeps addressing a finished task. + # Best-effort: a failure here must never replace an exception + # already propagating from the try block above, or a Band + # delivery outage gets misreported as a fabricated provider + # failure once it reaches on_message's except clauses. if state in TERMINAL_TASK_STATES: - await self._emit_task_event(tools, task, state) + try: + await self._emit_task_event(tools, task, state) + except Exception: + logger.exception( + "Failed to emit terminal task event (room=%s, task=%s)", + room_id, + task.id, + ) self._finalize_task(room_id, task.id) async def _deliver_message( diff --git a/src/band/integrations/a2a/gateway/adapter.py b/src/band/integrations/a2a/gateway/adapter.py index 8699cb4ee..4c408623d 100644 --- a/src/band/integrations/a2a/gateway/adapter.py +++ b/src/band/integrations/a2a/gateway/adapter.py @@ -82,11 +82,20 @@ def slugify(name: str) -> str: _GATEWAY_ERROR_MAX_CHARS = 240 _BEARER_TOKEN_RE = re.compile(r"Bearer\s+[^\s,;]+", re.IGNORECASE) +# The value group excludes only "," and ";" (not whitespace) so a +# scheme-prefixed credential (e.g. "Authorization: ApiKey sk-...") gets +# redacted in full instead of leaking everything past the first space. _CREDENTIAL_KV_RE = re.compile( - r"(token|authorization|api[_-]?key)\s*[:=]\s*[^\s,;]+", re.IGNORECASE + r"(token|authorization|api[_-]?key)\s*[:=]\s*[^,;]+", re.IGNORECASE ) +def _redact_credentials(text: str) -> str: + """Redact bearer tokens/API keys a message may embed.""" + redacted = _BEARER_TOKEN_RE.sub("Bearer [REDACTED]", text) + return _CREDENTIAL_KV_RE.sub(r"\1=[REDACTED]", redacted) + + def _sanitize_gateway_error_message(exc: BaseException) -> str: """Redact bearer tokens/API keys before an internal exception message reaches an external A2A client, and cap its length. @@ -96,8 +105,7 @@ def _sanitize_gateway_error_message(exc: BaseException) -> str: trimmed = str(exc).strip() if not trimmed: return "Unknown error" - redacted = _BEARER_TOKEN_RE.sub("Bearer [REDACTED]", trimmed) - redacted = _CREDENTIAL_KV_RE.sub(r"\1=[REDACTED]", redacted) + redacted = _redact_credentials(trimmed) if len(redacted) <= _GATEWAY_ERROR_MAX_CHARS: return redacted return f"{redacted[: _GATEWAY_ERROR_MAX_CHARS - 3]}..." @@ -589,12 +597,19 @@ async def _publish_band_response( """Translate Band's message category into an A2A task intent.""" if msg.message_type == "error": # The peer's own adapter already built this AgentFailure (see - # to_failure_event) -- relay it as-is rather than re-tagging its - # provider as "a2a-gateway". + # to_failure_event) -- relay it rather than re-tagging its + # provider as "a2a-gateway", but still redact credentials the + # peer's own message may embed before it reaches an external + # A2A client, same as this gateway's own exception path. failure = ( msg.metadata.get("failure") if isinstance(msg.metadata, dict) else None ) - await pending.fail(msg.content, failure=failure) + if isinstance(failure, dict) and isinstance(failure.get("message"), str): + failure = { + **failure, + "message": _redact_credentials(failure["message"]), + } + await pending.fail(_redact_credentials(msg.content), failure=failure) elif msg.message_type in ("thought", "tool_call", "tool_result"): await pending.report_progress(msg.content) else: diff --git a/src/band/testing/__init__.py b/src/band/testing/__init__.py index a930c4e8d..a22ddbc1c 100644 --- a/src/band/testing/__init__.py +++ b/src/band/testing/__init__.py @@ -14,6 +14,7 @@ if TYPE_CHECKING: from band.testing.fake_tools import ( FakeAgentTools as FakeAgentTools, + events_of_type as events_of_type, reported_failures as reported_failures, ) from band.testing.features import feature_kwargs as feature_kwargs @@ -38,7 +39,7 @@ __all__, __getattr__ = lazy_exports( __name__, - fake_tools=["FakeAgentTools", "reported_failures"], + fake_tools=["FakeAgentTools", "events_of_type", "reported_failures"], features=["feature_kwargs"], phoenix_server=["FakePhoenixServer", "JoinOutcome", "fake_phoenix_server"], platform=["platform_connection_stub"], diff --git a/src/band/testing/fake_tools.py b/src/band/testing/fake_tools.py index 23fa83ef0..83c3f39fe 100644 --- a/src/band/testing/fake_tools.py +++ b/src/band/testing/fake_tools.py @@ -731,10 +731,20 @@ def assert_no_messages_sent(self) -> None: ) +def events_of_type(tools: FakeAgentTools, message_type: str) -> list[dict[str, Any]]: + """Events of ``message_type`` captured on ``tools.events_sent``.""" + return [e for e in tools.events_sent if e["message_type"] == message_type] + + def reported_failures(tools: FakeAgentTools) -> list[dict[str, Any]]: - """Every ``AgentFailure`` reported via ``send_failure``, as its wire dict.""" + """Every ``AgentFailure`` reported via ``send_failure``, as its wire dict. + + Ignores an "error" event with no ``failure`` metadata -- a pre-existing, + not-yet-migrated ``send_event(..., "error")`` call site posts one without + the ``send_failure`` shape, and that isn't what this helper reports on. + """ return [ e["metadata"]["failure"] - for e in tools.events_sent - if e["message_type"] == MessageType.ERROR + for e in events_of_type(tools, MessageType.ERROR) + if "failure" in e["metadata"] ] diff --git a/tests/adapters/copilot_sdk/test_turn_failure.py b/tests/adapters/copilot_sdk/test_turn_failure.py index 6e36af7c2..dd452b560 100644 --- a/tests/adapters/copilot_sdk/test_turn_failure.py +++ b/tests/adapters/copilot_sdk/test_turn_failure.py @@ -4,6 +4,7 @@ import pytest +from band.testing import reported_failures from tests.adapters.copilot_sdk.fakes import ( FakeCopilotClient, ToolSchemaFakeTools, @@ -32,9 +33,9 @@ async def test_failed_turn_aborts_and_evicts_session(self): # The stale turn is aborted on the runtime and the session dropped... assert dead.aborted assert dead.disconnected - error_events = [e for e in tools.events_sent if e["message_type"] == "error"] - assert error_events - assert error_events[0]["metadata"]["failure"]["provider"] == "copilot_sdk" + failures = reported_failures(tools) + assert failures + assert failures[0]["provider"] == "copilot_sdk" # ...so the next message starts clean, resuming by the stored id. await run_message(adapter, tools, is_session_bootstrap=False) @@ -74,8 +75,8 @@ async def test_empty_final_text_raises_no_reply(self): @pytest.mark.asyncio async def test_session_creation_failure_is_reported(self): - """Previously fully uncaught: session setup ran outside on_message's - try entirely, so a create_session failure escaped with zero report.""" + """A create_session failure, raised before on_message's own turn + processing begins, must still be reported.""" client = FakeCopilotClient(create_error=RuntimeError("Copilot CLI unreachable")) adapter = await make_started_adapter(client) tools = ToolSchemaFakeTools() @@ -83,6 +84,6 @@ async def test_session_creation_failure_is_reported(self): with pytest.raises(RuntimeError, match="Copilot CLI unreachable"): await run_message(adapter, tools) - error_events = [e for e in tools.events_sent if e["message_type"] == "error"] - assert error_events - assert error_events[0]["metadata"]["failure"]["provider"] == "copilot_sdk" + failures = reported_failures(tools) + assert failures + assert failures[0]["provider"] == "copilot_sdk" diff --git a/tests/adapters/opencode/helpers.py b/tests/adapters/opencode/helpers.py index ef06181e0..4dd14975e 100644 --- a/tests/adapters/opencode/helpers.py +++ b/tests/adapters/opencode/helpers.py @@ -19,7 +19,7 @@ PlatformMessage, ) from band.integrations.opencode.types import OpencodeSessionState -from band.testing import FakeAgentTools +from band.testing import FakeAgentTools, events_of_type as events_of_type RawOpencodeEvent: TypeAlias = dict[str, Any] @@ -212,11 +212,6 @@ def tools_protocol(tools: FakeAgentTools) -> AgentToolsProtocol: return cast(AgentToolsProtocol, tools) -def events_of_type(tools: FakeAgentTools, message_type: str) -> list[dict[str, Any]]: - """Events of ``message_type`` captured on ``tools.events_sent``.""" - return [e for e in tools.events_sent if e["message_type"] == message_type] - - class RaisingSendTools(FakeAgentTools): """FakeAgentTools whose send_message always fails, to exercise the best-effort ``_notify_room`` path: a room post that raises must be diff --git a/tests/adapters/opencode/test_approvals.py b/tests/adapters/opencode/test_approvals.py index c6f3cecc1..325877a97 100644 --- a/tests/adapters/opencode/test_approvals.py +++ b/tests/adapters/opencode/test_approvals.py @@ -32,6 +32,7 @@ event_question, event_session_idle, event_text_part, + events_of_type, make_platform_message, tools_protocol, wait_for, @@ -626,7 +627,7 @@ async def test_permission_timeout_expiry() -> None: await wait_for(lambda: len(fake_client.permission_replies) > 0, timeout_s=3.0) assert fake_client.permission_replies[0]["response"] == "reject" - error_events = [e for e in tools.events_sent if e["message_type"] == "error"] + error_events = events_of_type(tools, "error") assert any("timed out" in e["content"].lower() for e in error_events) # A human-approval timeout is a Band-side procedural notice, never an # AgentFailure -- it must not carry the shared failure metadata shape. @@ -685,7 +686,7 @@ async def test_question_timeout_expiry() -> None: await wait_for(lambda: len(fake_client.question_rejections) > 0, timeout_s=3.0) assert fake_client.question_rejections == ["q-timeout"] - error_events = [e for e in tools.events_sent if e["message_type"] == "error"] + error_events = events_of_type(tools, "error") assert any("timed out" in e["content"].lower() for e in error_events) # A human-approval timeout is a Band-side procedural notice, never an # AgentFailure -- it must not carry the shared failure metadata shape. diff --git a/tests/adapters/opencode/test_lifecycle.py b/tests/adapters/opencode/test_lifecycle.py index a3b7f700a..f002f0964 100644 --- a/tests/adapters/opencode/test_lifecycle.py +++ b/tests/adapters/opencode/test_lifecycle.py @@ -25,6 +25,7 @@ event_message_updated, event_session_idle, event_text_part, + events_of_type, make_platform_message, run_single_turn, tools_protocol, @@ -259,7 +260,7 @@ async def test_concurrent_message_rejected(make_adapter, tools) -> None: ) # Second message should get rejected with "still processing" error - error_events = [e for e in tools.events_sent if e["message_type"] == "error"] + error_events = events_of_type(tools, "error") assert any("still processing" in e["content"].lower() for e in error_events) assert len(fake_client.prompt_calls) == 1 diff --git a/tests/adapters/opencode/test_setup.py b/tests/adapters/opencode/test_setup.py index d7a457b57..3e94a9fc1 100644 --- a/tests/adapters/opencode/test_setup.py +++ b/tests/adapters/opencode/test_setup.py @@ -24,6 +24,7 @@ event_message_updated, event_session_idle, event_text_part, + events_of_type, make_platform_message, tools_protocol, ) @@ -249,7 +250,7 @@ async def test_bootstrap_creates_session_relays_text_and_persists_task( assert fake_client.created_sessions[0]["id"] == "sess-1" assert tools.messages_sent[0]["content"] == "OpenCode says hi" assert tools.messages_sent[0]["mentions"] == [{"id": "user-1"}] - task_events = [e for e in tools.events_sent if e["message_type"] == "task"] + task_events = events_of_type(tools, "task") assert task_events assert task_events[0]["metadata"]["opencode_session_id"] == "sess-1" assert ( diff --git a/tests/adapters/test_codex_adapter.py b/tests/adapters/test_codex_adapter.py index 82652f2b7..79f35d593 100644 --- a/tests/adapters/test_codex_adapter.py +++ b/tests/adapters/test_codex_adapter.py @@ -35,7 +35,7 @@ ) from band.runtime.custom_tools import CustomToolDef from band.runtime.tools import ToolCallOutcome -from band.testing import FakeAgentTools, reported_failures +from band.testing import FakeAgentTools, events_of_type, reported_failures def make_platform_message( @@ -54,11 +54,6 @@ def make_platform_message( ) -def events_of_type(tools: FakeAgentTools, message_type: str) -> list[dict[str, Any]]: - """Events of ``message_type`` captured on ``tools.events_sent``.""" - return [e for e in tools.events_sent if e["message_type"] == message_type] - - class ToolSchemaFakeTools(FakeAgentTools): def get_openai_tool_schemas(self, **kwargs: Any) -> list[dict[str, Any]]: return [ @@ -1560,6 +1555,13 @@ async def test_turn_timeout_sends_interrupt_and_clean_error(self) -> None: # Adapter should send a user-facing message about stopping. assert any("stopped" in msg["content"].lower() for msg in tools.messages_sent) + # A timed-out turn is a reportable Codex failure, same as every + # sibling adapter's own turn-timeout handling. + failures = reported_failures(tools) + assert len(failures) == 1 + assert failures[0]["provider"] == "codex" + assert failures[0]["code"] == "timeout" + @pytest.mark.asyncio async def test_item_completed_text_overrides_accumulated_deltas(self) -> None: """item/completed text is authoritative and should replace any accumulated deltas.""" @@ -6045,6 +6047,48 @@ async def send_message( "Codex reply delivery failed" in record.message for record in caplog.records ) + @pytest.mark.asyncio + async def test_approval_command_reply_delivery_failure_is_not_reported( + self, caplog: pytest.LogCaptureFixture + ) -> None: + """Same delivery-vs-provider-failure split as slash commands, but for + the approval-command path, which runs outside on_message's main + try/except and needs its own DeliveryFailedError handling.""" + + class FailingSendMessageTools(ToolSchemaFakeTools): + async def send_message( + self, content: str, mentions: list[dict[str, str]] | None = None + ) -> Any: + raise RuntimeError("platform rejected the message") + + fake_client = FakeCodexClient() + adapter = CodexAdapter( + config=CodexAdapterConfig(transport="ws"), + client_factory=lambda _config: fake_client, + ) + tools = FailingSendMessageTools() + + await adapter.on_started("Agent", "A coding agent") + with ( + caplog.at_level(logging.ERROR, logger="band.adapters.codex"), + pytest.raises(RuntimeError, match="platform rejected the message"), + ): + await adapter.on_message( + make_platform_message(content="/approvals"), + tools, + CodexSessionState(), + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-1", + ) + + assert not tools.messages_sent + assert not reported_failures(tools) + assert any( + "Codex reply delivery failed" in record.message for record in caplog.records + ) + class TestMalformedPayloadTolerance: """Adapter must survive notifications that are missing or misshapen.""" @@ -6078,6 +6122,85 @@ async def test_error_event_with_non_dict_error_field(self) -> None: assert len(failures) == 1 assert failures[0]["message"] == "oops" + @pytest.mark.asyncio + async def test_failed_turn_after_error_notification_reports_once(self) -> None: + """An `error` notification followed by a `turn/completed` with + status=failed for the same incident must report only one failure.""" + events = [ + _event_notification("error", {"error": {"message": "boom"}}), + _event_notification( + "turn/completed", + { + "turn": { + "id": "turn-1", + "status": "failed", + "items": [], + "error": {"message": "boom"}, + } + }, + ), + ] + fake_client = FakeCodexClient(events=events) + adapter = CodexAdapter( + config=CodexAdapterConfig(transport="ws"), + client_factory=lambda _config: fake_client, + ) + tools = ToolSchemaFakeTools() + await adapter.on_started("Agent", "A coding agent") + + await adapter.on_message( + make_platform_message(), + tools, + CodexSessionState(), + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-1", + ) + + assert len(reported_failures(tools)) == 1 + + @pytest.mark.asyncio + async def test_failed_turn_with_falsy_scalar_error_uses_clean_fallback( + self, + ) -> None: + """A falsy, non-dict `error` (e.g. ``False``) must not become the + literal string "False" in the reported failure message.""" + events = [ + _event_notification( + "turn/completed", + { + "turn": { + "id": "turn-1", + "status": "failed", + "items": [], + "error": False, + } + }, + ), + ] + fake_client = FakeCodexClient(events=events) + adapter = CodexAdapter( + config=CodexAdapterConfig(transport="ws"), + client_factory=lambda _config: fake_client, + ) + tools = ToolSchemaFakeTools() + await adapter.on_started("Agent", "A coding agent") + + await adapter.on_message( + make_platform_message(), + tools, + CodexSessionState(), + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-1", + ) + + failures = reported_failures(tools) + assert len(failures) == 1 + assert failures[0]["message"] == "Codex error: unknown" + @pytest.mark.asyncio async def test_turn_completed_without_items_key(self) -> None: """turn/completed missing `items` is treated as an empty turn, not a crash.""" diff --git a/tests/adapters/test_crewai_flow_phase4.py b/tests/adapters/test_crewai_flow_phase4.py index 6336681ed..543f42481 100644 --- a/tests/adapters/test_crewai_flow_phase4.py +++ b/tests/adapters/test_crewai_flow_phase4.py @@ -926,6 +926,10 @@ async def test_ambiguous_delegation_failure_message_is_capped(self) -> None: assert len(failures) == 1 assert failures[0]["code"] == "ambiguous_participant" assert len(failures[0]["message"]) <= 500 + # The full, untruncated list survives in detail for a structured + # consumer, even though the room-visible message is capped. + assert failures[0]["detail"].startswith(failures[0]["message"]) + assert len(failures[0]["detail"]) > 500 @pytest.mark.asyncio async def test_delegation_send_failure_stops_later_delegations( diff --git a/tests/adapters/test_gemini_adapter.py b/tests/adapters/test_gemini_adapter.py index 71adcd22b..e6118edee 100644 --- a/tests/adapters/test_gemini_adapter.py +++ b/tests/adapters/test_gemini_adapter.py @@ -640,7 +640,7 @@ async def test_raises_runtime_error_when_max_rounds_exceeded( room_id="room-123", ) - # Previously reported nothing at all -- this is the added report. + # Exceeding max tool rounds is a reportable provider failure. mock_tools.send_failure.assert_called_once() failure = mock_tools.send_failure.call_args.args[0] assert failure.provider == "gemini" diff --git a/tests/adapters/test_letta_adapter.py b/tests/adapters/test_letta_adapter.py index 3f41d264a..b499ce038 100644 --- a/tests/adapters/test_letta_adapter.py +++ b/tests/adapters/test_letta_adapter.py @@ -22,7 +22,7 @@ ) from band.converters.letta import LettaSessionState from band.core.types import Emit -from band.testing import FakeAgentTools +from band.testing import FakeAgentTools, reported_failures from tests.adapters.lettakit import ( default_enforcement, make_assistant_message, @@ -182,7 +182,7 @@ async def _raise(*args: Any, **kwargs: Any) -> None: room_id="room-1", ) - assert not [e for e in tools.events_sent if e["message_type"] == "error"] + assert not reported_failures(tools) @pytest.mark.asyncio async def test_skip_auto_relay_when_send_message_used( @@ -1312,7 +1312,7 @@ async def test_disabled_relay_quiet_when_send_tool_used(self) -> None: ) assert len(tools.messages_sent) == 0 - assert not [e for e in tools.events_sent if e["message_type"] == "error"] + assert not reported_failures(tools) # ────────────────────────────────────────────────────────────────────── diff --git a/tests/integrations/a2a/gateway/test_adapter.py b/tests/integrations/a2a/gateway/test_adapter.py index edb6cb754..f4a89616d 100644 --- a/tests/integrations/a2a/gateway/test_adapter.py +++ b/tests/integrations/a2a/gateway/test_adapter.py @@ -24,7 +24,11 @@ from band.core.types import PlatformMessage from band.client.rest import DEFAULT_REQUEST_OPTIONS from band.integrations.a2a.gateway import A2AGatewayAdapter, A2AGatewayAdapterConfig -from band.integrations.a2a.gateway.adapter import BandAgentExecutor, GatewayRequest +from band.integrations.a2a.gateway.adapter import ( + BandAgentExecutor, + GatewayRequest, + _redact_credentials, +) from band.integrations.a2a.gateway.types import GatewaySessionState, PendingA2ATask from band.testing import FakeAgentTools from tests.integrations.a2a.gateway.helpers import make_peer, peers_page @@ -356,6 +360,13 @@ async def test_send_failure_redacts_secrets_from_reported_metadata(self) -> None assert "Bearer [REDACTED]" in message assert "api_key=[REDACTED]" in message + def test_redact_credentials_full_value_scheme_prefixed(self) -> None: + """A scheme-prefixed credential value (a space between the key and + the secret) must be redacted in full, not just up to that space.""" + redacted = _redact_credentials("Authorization: ApiKey sk-live-abcdef123456") + assert "sk-live-abcdef123456" not in redacted + assert redacted == "Authorization=[REDACTED]" + @pytest.mark.asyncio async def test_establish_request_raises_when_peer_missing(self) -> None: adapter = A2AGatewayAdapter(rest_client=MagicMock()) @@ -612,6 +623,36 @@ async def test_relays_peers_own_agent_failure_unchanged(self) -> None: assert event.metadata["failure"]["provider"] == "codex" assert event.metadata["failure"]["code"] == "ContextWindowExceeded" + @pytest.mark.asyncio + async def test_relayed_peer_failure_redacts_embedded_credentials(self) -> None: + """A peer's own AgentFailure can embed a raw provider exception + message -- redact it the same as this gateway's own exception path + before it reaches an external A2A client.""" + adapter = A2AGatewayAdapter(rest_client=MagicMock()) + queue = EventQueueLegacy() + pending = make_pending(queue) + secret_message = "upstream rejected token=sk-live-secret" + peer_failure = { + "provider": "codex", + "code": "Unauthorized", + "message": secret_message, + "detail": None, + } + + await adapter._publish_band_response( + pending, + make_platform_message( + secret_message, + message_type="error", + metadata={"failure": peer_failure}, + ), + ) + event = await queue.dequeue_event() + + assert event.status.state == TaskState.TASK_STATE_FAILED + assert "sk-live-secret" not in event.metadata["failure"]["message"] + assert "sk-live-secret" not in event.status.message.parts[0].text + @pytest.mark.asyncio async def test_plain_error_message_without_failure_metadata_still_fails( self, diff --git a/tests/integrations/a2a/test_adapter.py b/tests/integrations/a2a/test_adapter.py index f575e0937..d8d527475 100644 --- a/tests/integrations/a2a/test_adapter.py +++ b/tests/integrations/a2a/test_adapter.py @@ -312,6 +312,26 @@ async def test_terminal_task_is_finalized_even_when_band_delivery_fails( assert adapter._task_cache == {} assert adapter._task_senders == {} + @pytest.mark.asyncio + async def test_finally_block_failure_does_not_replace_try_blocks_exception( + self, adapter: A2AAdapter + ) -> None: + """The terminal task-event emission in ``finally`` must never clobber + a ``DeliveryFailedError`` already propagating from the try block -- + Python's try/finally semantics otherwise let the finally's own + exception silently replace it.""" + tools = FakeAgentTools() + tools.send_message = AsyncMock(side_effect=RuntimeError("Band unavailable")) + tools.send_event_error = RuntimeError("task event post also failed") + task = make_task(artifact_text="Final response") + + with pytest.raises(DeliveryFailedError) as exc_info: + await adapter._handle_event( + task_event(task), tools, "room-123", "user-456", "Test User" + ) + assert "Band unavailable" in str(exc_info.value.cause) + assert adapter._tasks == {}, "next turn must start a fresh task" + @pytest.mark.asyncio async def test_auth_required_task_is_posted_as_error_event( self, adapter: A2AAdapter From 99b660a15ee0c671214b142b24af4a2ebb5ae93a Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Mon, 7 Sep 2026 09:46:05 +0300 Subject: [PATCH 26/53] fix: fail the turn on a terminal provider failure instead of swallowing it Several adapters reported a terminal failure via send_failure and then returned normally, which the runtime treats as a successfully processed message -- losing the retry mechanism for a turn that actually failed. Found via cross-SDK comparison against the TS SDK's equivalent ProviderTurnFailedError fix for the same adapters. - letta: on_message's client-not-initialized guard, its session-prep except, and _handle_message's missing-room-context guard now raise after reporting; _run_turn's DeliveryFailedError/TimeoutError/generic except now re-raise instead of swallowing. - parlant: on_message's app-not-initialized guard and session-init except now raise after reporting. - a2a: on_message's DeliveryFailedError and generic except now re-raise instead of swallowing. - claude_sdk: _on_turn_complete's is_error and missing-reply branches now raise TurnResultAlreadyReported after reporting; on_message's outer except recognizes it and does not report the same failure again. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0195k7AdghCyZgBPgs4TdxE3 --- src/band/adapters/claude_sdk.py | 26 ++++--- src/band/adapters/letta.py | 23 +++--- src/band/adapters/parlant.py | 9 ++- src/band/integrations/a2a/adapter.py | 6 +- tests/adapters/test_claude_sdk_adapter.py | 34 ++++++--- tests/adapters/test_claude_sdk_tool_names.py | 21 +++--- tests/adapters/test_letta_adapter.py | 76 ++++++++++---------- tests/adapters/test_letta_mcp.py | 38 +++++----- tests/adapters/test_parlant_adapter.py | 43 +++++------ tests/integrations/a2a/test_adapter.py | 49 ++++++++++--- 10 files changed, 194 insertions(+), 131 deletions(-) diff --git a/src/band/adapters/claude_sdk.py b/src/band/adapters/claude_sdk.py index 632473c0e..a74b4830f 100644 --- a/src/band/adapters/claude_sdk.py +++ b/src/band/adapters/claude_sdk.py @@ -101,6 +101,12 @@ logger = logging.getLogger(__name__) +class TurnResultAlreadyReported(Exception): + """A terminal `ResultMessage` failure `_on_turn_complete` already + reported via `send_failure`. `on_message`'s outer except re-raises this + without reporting the same failure a second time.""" + + # Tool names as constants (MCP naming convention: mcp__{server}__{tool}) # Derived from TOOL_MODELS — single source of truth BAND_BASE_TOOLS: list[str] = mcp_tool_names(BASE_TOOL_NAMES) @@ -691,6 +697,11 @@ async def on_message( # Process streaming response (MCP tools handle execution) await self._process_response(client, room_id, tools) + except TurnResultAlreadyReported: + # _on_turn_complete already reported this failure via + # send_failure; propagate without reporting it a second time. + raise + except CLIConnectionError as e: # CLI process is dead — evict the cached session so the next # message creates a fresh one instead of reusing the corpse. @@ -944,20 +955,17 @@ async def _on_turn_complete( if sdk_message.api_error_status is not None else None ) + detail = self._result_error_detail(sdk_message) await tools.send_failure( - AgentFailure( - "claude_sdk", - self._result_error_detail(sdk_message), - code, - sdk_message.errors, - ) + AgentFailure("claude_sdk", detail, code, sdk_message.errors) ) + raise TurnResultAlreadyReported(detail) elif not replied_this_turn and not self._declined_the_reply( sdk_message.permission_denials, notified ): - await tools.send_failure( - AgentFailure("claude_sdk", missing_reply_error("Claude SDK")) - ) + detail = missing_reply_error("Claude SDK") + await tools.send_failure(AgentFailure("claude_sdk", detail)) + raise TurnResultAlreadyReported(detail) def _declined_the_reply( self, permission_denials: list[Any] | None, notified: set[str] | None diff --git a/src/band/adapters/letta.py b/src/band/adapters/letta.py index 2a9743ee5..3b0f91d23 100644 --- a/src/band/adapters/letta.py +++ b/src/band/adapters/letta.py @@ -269,10 +269,9 @@ async def on_message( """Handle incoming message via Letta API with MCP tools.""" if not self._client: logger.error("Letta client not initialized, dropping message %s", msg.id) - await tools.send_failure( - AgentFailure("letta", "Letta adapter not initialized") - ) - return + error = RuntimeError("Letta adapter not initialized") + await tools.send_failure(AgentFailure("letta", str(error))) + raise error # Lock only protects MCP/agent setup, not the full message path. # This allows concurrent rooms to process messages in parallel. Both @@ -289,7 +288,7 @@ async def on_message( except Exception as e: logger.exception("Room %s: Failed to prepare Letta session: %s", room_id, e) await tools.send_failure(AgentFailure("letta", str(e))) - return + raise await self._handle_message( msg=msg, @@ -315,10 +314,9 @@ async def _handle_message( """Run one Letta turn: resolve the room, compose the message, send.""" if (room_ctx := await self._room_context(room_id, history, tools)) is None: logger.error("Room %s: No Letta agent context, dropping message", room_id) - await tools.send_failure( - AgentFailure("letta", "Letta agent context unavailable") - ) - return + error = RuntimeError("Letta agent context unavailable") + await tools.send_failure(AgentFailure("letta", str(error))) + raise error # Point the MCP resolver at this room's current tools for the # server-side tool calls this turn will make. @@ -426,12 +424,15 @@ async def _run_turn( ) except DeliveryFailedError as e: # The agent answered; posting the reply to the room is what - # failed. Band-side delivery, never a Letta provider failure. + # failed. Band-side delivery, never a Letta provider failure -- + # re-raise the cause so mark_failed/retry bookkeeping keys off + # the real exception. logger.exception( "Room %s: Failed to deliver Letta agent's reply: %s", room_id, e.cause, ) + raise e.cause from None except asyncio.TimeoutError: logger.error( "Room %s: Letta turn timed out after %ss", @@ -445,9 +446,11 @@ async def _run_turn( FAILURE_CODE_TIMEOUT, ) ) + raise except Exception as e: logger.exception("Room %s: Error during Letta turn: %s", room_id, e) await tools.send_failure(AgentFailure("letta", str(e))) + raise else: if room_ctx.pending_seed: room_ctx.pending_seed = [] diff --git a/src/band/adapters/parlant.py b/src/band/adapters/parlant.py index a2eb8be6a..832d3c997 100644 --- a/src/band/adapters/parlant.py +++ b/src/band/adapters/parlant.py @@ -378,10 +378,9 @@ async def on_message( if not self._app: logger.error("Parlant Application not initialized") - await tools.send_failure( - AgentFailure("parlant", "Parlant Application not initialized") - ) - return + error = RuntimeError("Parlant Application not initialized") + await tools.send_failure(AgentFailure("parlant", str(error))) + raise error app = self._app sender_name = msg.sender_name or msg.sender_id or "User" @@ -394,7 +393,7 @@ async def on_message( await tools.send_failure( AgentFailure("parlant", f"Session initialization failed: {e}") ) - return + raise session_id_str = str(session_id) # Set tools for this session (keyed by session_id for cross-task access) diff --git a/src/band/integrations/a2a/adapter.py b/src/band/integrations/a2a/adapter.py index d5ba3f8bb..0e80c2c80 100644 --- a/src/band/integrations/a2a/adapter.py +++ b/src/band/integrations/a2a/adapter.py @@ -176,11 +176,15 @@ async def on_message( except DeliveryFailedError as e: # The A2A agent answered; posting its reply to the room is what - # failed. Band-side delivery, never an A2A provider failure. + # failed. Band-side delivery, never an A2A provider failure -- + # re-raise the cause so mark_failed/retry bookkeeping keys off + # the real exception. logger.exception("A2A reply delivery failed: %s", e.cause) + raise e.cause from None except Exception as e: logger.exception("A2A agent error: %s", e) await tools.send_failure(AgentFailure("a2a", str(e))) + raise async def _handle_event( self, diff --git a/tests/adapters/test_claude_sdk_adapter.py b/tests/adapters/test_claude_sdk_adapter.py index 04281c3e5..9f782ad57 100644 --- a/tests/adapters/test_claude_sdk_adapter.py +++ b/tests/adapters/test_claude_sdk_adapter.py @@ -27,6 +27,7 @@ _FORCED_DECLINE, PendingApproval, _pre_tool_use_continue_hook, + TurnResultAlreadyReported, BAND_ALL_TOOLS, BAND_BASE_TOOLS, BAND_MEMORY_TOOLS, @@ -1242,7 +1243,8 @@ async def test_reports_error_on_is_error_result(self, mock_tools): ) mock_client = self._client_yielding(result_msg) - await adapter._process_response(mock_client, "room-123", mock_tools) + with pytest.raises(TurnResultAlreadyReported): + await adapter._process_response(mock_client, "room-123", mock_tools) errors = _error_events(mock_tools) assert len(errors) == 1 @@ -1265,7 +1267,8 @@ async def test_error_detail_includes_api_error_status(self, mock_tools): ) mock_client = self._client_yielding(result_msg) - await adapter._process_response(mock_client, "room-123", mock_tools) + with pytest.raises(TurnResultAlreadyReported): + await adapter._process_response(mock_client, "room-123", mock_tools) errors = _error_events(mock_tools) assert len(errors) == 1 @@ -1281,7 +1284,8 @@ async def test_reports_missing_reply_when_no_terminal_tool_ran(self, mock_tools) result_msg = _result_message(is_error=False) mock_client = self._client_yielding(result_msg) - await adapter._process_response(mock_client, "room-123", mock_tools) + with pytest.raises(TurnResultAlreadyReported): + await adapter._process_response(mock_client, "room-123", mock_tools) errors = _error_events(mock_tools) assert len(errors) == 1 @@ -1350,7 +1354,8 @@ async def test_tool_result_payload_includes_name_and_is_error(self, mock_tools): ) mock_client = self._client_yielding(assistant_msg, user_msg, _result_message()) - await adapter._process_response(mock_client, "room-123", mock_tools) + with pytest.raises(TurnResultAlreadyReported): + await adapter._process_response(mock_client, "room-123", mock_tools) payload = _tool_result_payload(mock_tools) assert payload[ToolEventKey.NAME] == "band_send_message" @@ -1364,7 +1369,8 @@ async def test_no_error_reported_when_only_read_only_tool_ran(self, mock_tools): result_msg = _result_message(is_error=False) mock_client = self._client_yielding(*turn, result_msg) - await adapter._process_response(mock_client, "room-123", mock_tools) + with pytest.raises(TurnResultAlreadyReported): + await adapter._process_response(mock_client, "room-123", mock_tools) errors = _error_events(mock_tools) assert len(errors) == 1 @@ -1489,7 +1495,8 @@ async def test_declined_side_tool_still_reports_missing_reply(self, mock_tools): ) mock_client = self._client_yielding(assistant_msg, user_msg, result_msg) - await adapter._process_response(mock_client, "room-123", mock_tools) + with pytest.raises(TurnResultAlreadyReported): + await adapter._process_response(mock_client, "room-123", mock_tools) errors = _error_events(mock_tools) assert len(errors) == 1 @@ -1627,7 +1634,8 @@ async def test_band_tool_error_string_is_not_terminal_work(self, mock_tools): assistant_msg, failed_result, _result_message(is_error=False) ) - await adapter._process_response(mock_client, "room-123", mock_tools) + with pytest.raises(TurnResultAlreadyReported): + await adapter._process_response(mock_client, "room-123", mock_tools) errors = _error_events(mock_tools) assert len(errors) == 1 @@ -1686,7 +1694,8 @@ async def test_silent_auto_decline_still_reports_missing_reply(self, mock_tools) ) mock_client = self._client_yielding(assistant_msg, user_msg, result_msg) - await adapter._process_response(mock_client, "room-123", mock_tools) + with pytest.raises(TurnResultAlreadyReported): + await adapter._process_response(mock_client, "room-123", mock_tools) errors = _error_events(mock_tools) assert len(errors) == 1 @@ -1714,7 +1723,8 @@ async def test_declined_marker_does_not_leak_into_next_turn(self, mock_tools): # A fresh, unrelated turn: no tool activity, no permission_denials. next_turn_client = self._client_yielding(_result_message(is_error=False)) - await adapter._process_response(next_turn_client, "room-123", mock_tools) + with pytest.raises(TurnResultAlreadyReported): + await adapter._process_response(next_turn_client, "room-123", mock_tools) errors = _error_events(mock_tools) assert len(errors) == 1 @@ -1740,7 +1750,8 @@ async def test_undelivered_approval_prompt_still_reports_missing_reply( result_msg = _result_message(is_error=False) mock_client = self._client_yielding(result_msg) - await adapter._process_response(mock_client, "room-123", mock_tools) + with pytest.raises(TurnResultAlreadyReported): + await adapter._process_response(mock_client, "room-123", mock_tools) errors = _error_events(mock_tools) assert len(errors) == 1 @@ -1773,7 +1784,8 @@ async def test_undelivered_timeout_notice_still_reports_missing_reply( result_msg = _result_message(is_error=False) mock_client = self._client_yielding(result_msg) - await adapter._process_response(mock_client, "room-123", mock_tools) + with pytest.raises(TurnResultAlreadyReported): + await adapter._process_response(mock_client, "room-123", mock_tools) errors = _error_events(mock_tools) assert len(errors) == 1 diff --git a/tests/adapters/test_claude_sdk_tool_names.py b/tests/adapters/test_claude_sdk_tool_names.py index 645f55f20..9889aef7b 100644 --- a/tests/adapters/test_claude_sdk_tool_names.py +++ b/tests/adapters/test_claude_sdk_tool_names.py @@ -15,7 +15,7 @@ import pytest from claude_agent_sdk import AssistantMessage, ResultMessage, ToolUseBlock -from band.adapters.claude_sdk import ClaudeSDKAdapter +from band.adapters.claude_sdk import ClaudeSDKAdapter, TurnResultAlreadyReported from band.converters.claude_sdk import ClaudeSDKSessionState from band.core.types import Emit, PlatformMessage @@ -79,15 +79,16 @@ async def receive(): with patch("band.adapters.claude_sdk.ClaudeSessionManager", return_value=manager): await adapter.on_started(agent_name="Bot", agent_description="d") - await adapter.on_message( - msg=message, - tools=tools, - history=ClaudeSDKSessionState(text=""), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) + with pytest.raises(TurnResultAlreadyReported): + await adapter.on_message( + msg=message, + tools=tools, + history=ClaudeSDKSessionState(text=""), + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-1", + ) tool_calls = [ call diff --git a/tests/adapters/test_letta_adapter.py b/tests/adapters/test_letta_adapter.py index b499ce038..e1ce652dc 100644 --- a/tests/adapters/test_letta_adapter.py +++ b/tests/adapters/test_letta_adapter.py @@ -172,15 +172,16 @@ async def _raise(*args: Any, **kwargs: Any) -> None: msg = make_platform_message() history = LettaSessionState() - await adapter.on_message( - msg, - tools, - history, - None, - None, - is_session_bootstrap=False, - room_id="room-1", - ) + with pytest.raises(RuntimeError, match="platform rejected the message"): + await adapter.on_message( + msg, + tools, + history, + None, + None, + is_session_bootstrap=False, + room_id="room-1", + ) assert not reported_failures(tools) @@ -234,15 +235,16 @@ async def slow_response(**kwargs: Any) -> MagicMock: msg = make_platform_message() history = LettaSessionState() - await adapter.on_message( - msg, - tools, - history, - None, - None, - is_session_bootstrap=False, - room_id="room-1", - ) + with pytest.raises(TimeoutError): + await adapter.on_message( + msg, + tools, + history, + None, + None, + is_session_bootstrap=False, + room_id="room-1", + ) error_events = [e for e in tools.events_sent if e["message_type"] == "error"] assert len(error_events) == 1 @@ -321,15 +323,16 @@ async def test_uninitialized_client_reports_error(self) -> None: msg = make_platform_message() history = LettaSessionState() - await adapter.on_message( - msg, - tools, - history, - None, - None, - is_session_bootstrap=True, - room_id="room-1", - ) + with pytest.raises(RuntimeError, match="not initialized"): + await adapter.on_message( + msg, + tools, + history, + None, + None, + is_session_bootstrap=True, + room_id="room-1", + ) error_events = [e for e in tools.events_sent if e["message_type"] == "error"] assert len(error_events) == 1 @@ -1378,15 +1381,16 @@ async def test_failed_first_turn_preserves_pending_seed( replay_messages=["[Alice]: The secret word is kumquat."] ) tools = FakeAgentTools() - await adapter.on_message( - make_platform_message(content="what was the secret word?"), - tools, - history, - None, - None, - is_session_bootstrap=True, - room_id="room-1", - ) + with pytest.raises(TimeoutError): + await adapter.on_message( + make_platform_message(content="what was the secret word?"), + tools, + history, + None, + None, + is_session_bootstrap=True, + room_id="room-1", + ) assert adapter._rooms["room-1"].pending_seed == [ "[Alice]: The secret word is kumquat." diff --git a/tests/adapters/test_letta_mcp.py b/tests/adapters/test_letta_mcp.py index 9f1f02782..cd2bb6cb2 100644 --- a/tests/adapters/test_letta_mcp.py +++ b/tests/adapters/test_letta_mcp.py @@ -519,15 +519,16 @@ async def test_failed_tool_resync_skips_turn(self) -> None: mock_client.agents.tools.list.side_effect = ConnectionError("letta hiccup") tools = FakeAgentTools() - await adapter.on_message( - make_platform_message(), - tools, - LettaSessionState(), - None, - None, - is_session_bootstrap=False, - room_id="room-1", - ) + with pytest.raises(RuntimeError, match="not attached"): + await adapter.on_message( + make_platform_message(), + tools, + LettaSessionState(), + None, + None, + is_session_bootstrap=False, + room_id="room-1", + ) error_events = [e for e in tools.events_sent if e["message_type"] == "error"] assert len(error_events) == 1 @@ -765,15 +766,16 @@ async def test_prepare_failure_reports_error_and_skips_turn(self) -> None: mock_client.mcp_servers.list.side_effect = ConnectionError("letta down") tools = FakeAgentTools() - await adapter.on_message( - make_platform_message(), - tools, - LettaSessionState(), - None, - None, - is_session_bootstrap=True, - room_id="room-1", - ) + with pytest.raises(RuntimeError, match="MCP server registration failed"): + await adapter.on_message( + make_platform_message(), + tools, + LettaSessionState(), + None, + None, + is_session_bootstrap=True, + room_id="room-1", + ) error_events = [e for e in tools.events_sent if e["message_type"] == "error"] assert len(error_events) == 1 diff --git a/tests/adapters/test_parlant_adapter.py b/tests/adapters/test_parlant_adapter.py index f1454de72..ff480d97f 100644 --- a/tests/adapters/test_parlant_adapter.py +++ b/tests/adapters/test_parlant_adapter.py @@ -803,7 +803,7 @@ async def test_reports_error_on_failure( async def test_reports_error_on_session_init_failure( self, mock_parlant_server, mock_parlant_agent, sample_message, mock_tools ): - """A session-creation failure reports and returns without raising.""" + """A session-creation failure is reported, then fails the turn.""" adapter = ParlantAdapter( server=mock_parlant_server, parlant_agent=mock_parlant_agent, @@ -815,16 +815,16 @@ async def test_reports_error_on_session_init_failure( mock_app.sessions.create = AsyncMock(side_effect=Exception("db unreachable")) adapter._app = mock_app - # Must not raise: session init failures are reported, not propagated. - await adapter.on_message( - msg=sample_message, - tools=mock_tools, - history=[], - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-123", - ) + with pytest.raises(Exception, match="db unreachable"): + await adapter.on_message( + msg=sample_message, + tools=mock_tools, + history=[], + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-123", + ) mock_tools.send_failure.assert_awaited_once() failure = mock_tools.send_failure.call_args.args[0] @@ -883,22 +883,23 @@ async def test_clears_tools_on_error( async def test_handles_uninitialized_app( self, mock_parlant_server, mock_parlant_agent, sample_message, mock_tools ): - """An uninitialized app returns early, but must still report the failure.""" + """An uninitialized app reports the failure, then fails the turn.""" adapter = ParlantAdapter( server=mock_parlant_server, parlant_agent=mock_parlant_agent, ) # Don't set _app - await adapter.on_message( - msg=sample_message, - tools=mock_tools, - history=[], - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-123", - ) + with pytest.raises(RuntimeError, match="not initialized"): + await adapter.on_message( + msg=sample_message, + tools=mock_tools, + history=[], + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-123", + ) # No reply attempt, but the failure is reported. mock_tools.send_message.assert_not_called() diff --git a/tests/integrations/a2a/test_adapter.py b/tests/integrations/a2a/test_adapter.py index d8d527475..8531c0b3a 100644 --- a/tests/integrations/a2a/test_adapter.py +++ b/tests/integrations/a2a/test_adapter.py @@ -388,26 +388,55 @@ async def test_input_required_is_forwarded_and_persisted( async def test_remote_error_is_posted_as_error_event( self, adapter: A2AAdapter ) -> None: - """A remote A2A outage must surface in the room, not crash the turn.""" + """A remote A2A outage must surface in the room and fail the turn.""" adapter._client = MagicMock() adapter._client.send_message = MagicMock( side_effect=RuntimeError("remote down") ) tools = FakeAgentTools() - await adapter.on_message( - make_platform_message(), - tools, - A2ASessionState(), - None, - None, - is_session_bootstrap=False, - room_id="room-123", - ) + with pytest.raises(RuntimeError, match="remote down"): + await adapter.on_message( + make_platform_message(), + tools, + A2ASessionState(), + None, + None, + is_session_bootstrap=False, + room_id="room-123", + ) assert tools.events_sent[-1]["message_type"] == "error" assert "remote down" in tools.events_sent[-1]["content"] + @pytest.mark.asyncio + async def test_on_message_reraises_delivery_failure_without_reporting_it( + self, adapter: A2AAdapter + ) -> None: + """A Band-side post failure must fail the turn for retry, without + being reported as an A2A provider failure.""" + adapter._client = MagicMock() + + async def _events() -> AsyncIterator[StreamResponse]: + yield task_event(make_task(artifact_text="Final response")) + + adapter._client.send_message = MagicMock(return_value=_events()) + tools = FakeAgentTools() + tools.send_message = AsyncMock(side_effect=RuntimeError("Band unavailable")) + + with pytest.raises(RuntimeError, match="Band unavailable"): + await adapter.on_message( + make_platform_message(), + tools, + A2ASessionState(), + None, + None, + is_session_bootstrap=False, + room_id="room-123", + ) + + assert not [e for e in tools.events_sent if e["message_type"] == "error"] + @pytest.mark.asyncio async def test_failed_task_is_posted_as_error_event( self, adapter: A2AAdapter From ad677a0062c80800278c859b1de678a4ff1940d5 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Mon, 7 Sep 2026 10:26:25 +0300 Subject: [PATCH 27/53] refactor: dedup and simplify error-surfacing code per /simplify review Consolidates repeated patterns introduced by the AgentFailure migration: shared GENERIC_PROVIDER_FAILURE_MESSAGE constant instead of retyping it per adapter, small _to_agent_failure helpers for anthropic/gemini's isinstance-dispatch branches, a deduped _reraise_delivery_cause helper in codex.py, and dropped a try/except in agno.py around code that can't raise. Also parallelizes independent stop()/send_failure() calls in the ACP client adapter's error paths. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LDnFqKrWa9q6B6TJprQeTN --- src/band/adapters/agno.py | 28 +++++++------------ src/band/adapters/anthropic.py | 19 ++++++++----- src/band/adapters/codex.py | 25 +++++++++-------- src/band/adapters/crewai.py | 8 ++---- src/band/adapters/crewai_flow.py | 2 +- src/band/adapters/gemini.py | 25 ++++++++++------- src/band/adapters/langgraph.py | 7 ++--- src/band/adapters/letta.py | 12 ++++---- src/band/adapters/parlant.py | 8 +++--- src/band/core/protocols.py | 7 +++++ src/band/integrations/acp/client_adapter.py | 19 +++++++------ tests/integrations/acp/test_client_adapter.py | 7 +---- 12 files changed, 84 insertions(+), 83 deletions(-) diff --git a/src/band/adapters/agno.py b/src/band/adapters/agno.py index 4db69c569..0f1bc531a 100644 --- a/src/band/adapters/agno.py +++ b/src/band/adapters/agno.py @@ -15,7 +15,7 @@ from band_sdk_core import AgentFailure from typing_extensions import Unpack -from band.core.protocols import AgentToolsProtocol +from band.core.protocols import GENERIC_PROVIDER_FAILURE_MESSAGE, AgentToolsProtocol from band.core.simple_adapter import SimpleAdapter from band.core.tool_filter import filter_tool_schemas from band.core.types import ( @@ -334,18 +334,14 @@ async def on_message( is_session_bootstrap, ) - try: - messages = self._build_run_input( - msg, - history, - participants_msg, - contacts_msg, - is_session_bootstrap=is_session_bootstrap, - room_id=room_id, - ) - except Exception as e: - await tools.send_failure(AgentFailure("agno", str(e))) - raise + messages = self._build_run_input( + msg, + history, + participants_msg, + contacts_msg, + is_session_bootstrap=is_session_bootstrap, + room_id=room_id, + ) response = await self._run_agent( messages, tools, room_id=room_id, msg_id=msg.id ) @@ -510,11 +506,7 @@ async def _run_agent( ) code = RunStatus.error.value if isinstance(e, AgnoRunError) else None await tools.send_failure( - AgentFailure( - "agno", - "Internal error while processing message; see agent logs.", - code, - ) + AgentFailure("agno", GENERIC_PROVIDER_FAILURE_MESSAGE, code) ) raise diff --git a/src/band/adapters/anthropic.py b/src/band/adapters/anthropic.py index 1a309b17a..91679a5f5 100644 --- a/src/band/adapters/anthropic.py +++ b/src/band/adapters/anthropic.py @@ -63,6 +63,17 @@ def _image_tool_result_content(result: dict[str, Any]) -> list[dict[str, Any]]: ] +def _to_agent_failure(e: Exception) -> AgentFailure: + """Parse a turn-ending exception into the shared provider-failure shape. + + ``APIStatusError`` carries an HTTP status and response body that a plain + exception's message alone does not. + """ + if isinstance(e, APIStatusError): + return AgentFailure("anthropic", str(e), str(e.status_code), e.body) + return AgentFailure("anthropic", str(e)) + + class AnthropicAdapter(SimpleAdapter[AnthropicMessages]): """ Anthropic SDK adapter using SimpleAdapter pattern. @@ -280,13 +291,7 @@ async def on_message( ) except Exception as e: logger.error("Error calling Anthropic: %s", e, exc_info=True) - if isinstance(e, APIStatusError): - failure = AgentFailure( - "anthropic", str(e), str(e.status_code), e.body - ) - else: - failure = AgentFailure("anthropic", str(e)) - await tools.send_failure(failure) + await tools.send_failure(_to_agent_failure(e)) raise # Re-raise so message is marked as failed turn_usage = turn_usage + self._usage_from_response(response) diff --git a/src/band/adapters/codex.py b/src/band/adapters/codex.py index fed88f373..251482e37 100644 --- a/src/band/adapters/codex.py +++ b/src/band/adapters/codex.py @@ -10,7 +10,7 @@ from collections import OrderedDict from dataclasses import dataclass from datetime import datetime, timezone -from typing import ClassVar, Any, Callable, Literal, NamedTuple, Protocol +from typing import ClassVar, Any, Callable, Literal, NamedTuple, NoReturn, Protocol from band_sdk_core import AgentFailure from pydantic import AliasChoices, BaseModel, Field, ValidationError, field_validator @@ -81,6 +81,16 @@ def _image_content_items(result: dict[str, Any]) -> list[dict[str, Any]]: ] +def _reraise_delivery_cause(e: DeliveryFailedError) -> NoReturn: + """Band-side reply delivery failed, never a Codex provider failure. + + Re-raises the cause (not this wrapper) so mark_failed/retry bookkeeping + keys off the real exception. + """ + logger.exception("Codex reply delivery failed: %s", e.cause) + raise e.cause from None + + TransportKind = Literal["stdio", "ws"] ApprovalMode = Literal["auto_accept", "auto_decline", "manual"] ApprovalDecision = Literal["accept", "acceptForSession", "decline"] @@ -579,11 +589,7 @@ async def on_message( args=command[1], ) except DeliveryFailedError as e: - # Same Band-delivery-vs-provider-failure split as the - # turn-processing path below: re-raise the cause so - # mark_failed/retry bookkeeping keys off the real exception. - logger.exception("Codex reply delivery failed: %s", e.cause) - raise e.cause from None + _reraise_delivery_cause(e) if handled: return @@ -726,12 +732,7 @@ async def on_message( duration_s=_turn_duration_s, ) except DeliveryFailedError as e: - # The turn did its work; posting it to the room is what - # failed -- Band-side delivery, never a Codex provider - # failure. Re-raise the cause (not this wrapper) so - # mark_failed/retry bookkeeping keys off the real exception. - logger.exception("Codex reply delivery failed: %s", e.cause) - raise e.cause from None + _reraise_delivery_cause(e) except Exception as e: await tools.send_failure(AgentFailure("codex", str(e))) raise diff --git a/src/band/adapters/crewai.py b/src/band/adapters/crewai.py index 2cba6d430..e03706ecd 100644 --- a/src/band/adapters/crewai.py +++ b/src/band/adapters/crewai.py @@ -282,11 +282,9 @@ async def on_message( logger.debug("Handling message %s in room %s", msg.id, room_id) if not self._crewai_agent: - error = RuntimeError( - "CrewAI agent not initialized - ensure on_started() was called" - ) - await tools.send_failure(AgentFailure("crewai", str(error))) - raise error + message = "CrewAI agent not initialized - ensure on_started() was called" + await tools.send_failure(AgentFailure("crewai", message)) + raise RuntimeError(message) # Set context variable for tool access (thread-safe room context). # Wrap in try/finally immediately to ensure cleanup even if code diff --git a/src/band/adapters/crewai_flow.py b/src/band/adapters/crewai_flow.py index 48a83fe20..a020d415a 100644 --- a/src/band/adapters/crewai_flow.py +++ b/src/band/adapters/crewai_flow.py @@ -945,7 +945,7 @@ async def record_failed(self, error: CrewAIFlowError) -> None: # error) -- so the untruncated text is preserved in detail for # structured consumers reading the "error"-typed event. message = error.message[:500] - detail = error.message if len(error.message) > 500 else None + detail = error.message if message != error.message else None await self._tools.send_failure( AgentFailure("crewai_flow", message, error.code, detail) ) diff --git a/src/band/adapters/gemini.py b/src/band/adapters/gemini.py index 2ca0b35ea..a2ff550e8 100644 --- a/src/band/adapters/gemini.py +++ b/src/band/adapters/gemini.py @@ -72,6 +72,17 @@ def _image_function_response_parts( return parts +def _to_agent_failure(e: Exception) -> AgentFailure: + """Parse a turn-ending exception into the shared provider-failure shape. + + ``ServerError`` carries an HTTP status and message that a plain + exception's text alone does not. + """ + if isinstance(e, ServerError): + return AgentFailure("gemini", str(e), e.status, e.message) + return AgentFailure("gemini", str(e)) + + class GeminiAdapter(SimpleAdapter[GeminiMessages]): """ Gemini SDK adapter using SimpleAdapter pattern. @@ -241,14 +252,12 @@ async def on_message( try: while True: if tool_rounds >= self.max_tool_rounds: - max_rounds_error = RuntimeError( + message = ( f"Exceeded max tool rounds ({self.max_tool_rounds}) " f"in room {room_id}" ) - await tools.send_failure( - AgentFailure("gemini", str(max_rounds_error)) - ) - raise max_rounds_error + await tools.send_failure(AgentFailure("gemini", message)) + raise RuntimeError(message) try: response = await self._call_gemini( @@ -256,11 +265,7 @@ async def on_message( ) except Exception as e: logger.exception("Error calling Gemini: %s", e) - if isinstance(e, ServerError): - failure = AgentFailure("gemini", str(e), e.status, e.message) - else: - failure = AgentFailure("gemini", str(e)) - await tools.send_failure(failure) + await tools.send_failure(_to_agent_failure(e)) raise turn_usage = turn_usage + self._usage_from_response(response) diff --git a/src/band/adapters/langgraph.py b/src/band/adapters/langgraph.py index 2204891f5..027077de3 100644 --- a/src/band/adapters/langgraph.py +++ b/src/band/adapters/langgraph.py @@ -13,7 +13,7 @@ from langgraph.pregel import Pregel from typing_extensions import Unpack -from band.core.protocols import AgentToolsProtocol +from band.core.protocols import GENERIC_PROVIDER_FAILURE_MESSAGE, AgentToolsProtocol from band.core.simple_adapter import SimpleAdapter from band.core.types import ( Capability, @@ -387,10 +387,7 @@ async def on_message( # surface in chat -- code/detail stay unset, never populated from # the caught exception. await tools.send_failure( - AgentFailure( - "langgraph", - "Internal error while processing message; see agent logs.", - ) + AgentFailure("langgraph", GENERIC_PROVIDER_FAILURE_MESSAGE) ) raise finally: diff --git a/src/band/adapters/letta.py b/src/band/adapters/letta.py index 3b0f91d23..ecda76633 100644 --- a/src/band/adapters/letta.py +++ b/src/band/adapters/letta.py @@ -269,9 +269,9 @@ async def on_message( """Handle incoming message via Letta API with MCP tools.""" if not self._client: logger.error("Letta client not initialized, dropping message %s", msg.id) - error = RuntimeError("Letta adapter not initialized") - await tools.send_failure(AgentFailure("letta", str(error))) - raise error + message = "Letta adapter not initialized" + await tools.send_failure(AgentFailure("letta", message)) + raise RuntimeError(message) # Lock only protects MCP/agent setup, not the full message path. # This allows concurrent rooms to process messages in parallel. Both @@ -314,9 +314,9 @@ async def _handle_message( """Run one Letta turn: resolve the room, compose the message, send.""" if (room_ctx := await self._room_context(room_id, history, tools)) is None: logger.error("Room %s: No Letta agent context, dropping message", room_id) - error = RuntimeError("Letta agent context unavailable") - await tools.send_failure(AgentFailure("letta", str(error))) - raise error + message = "Letta agent context unavailable" + await tools.send_failure(AgentFailure("letta", message)) + raise RuntimeError(message) # Point the MCP resolver at this room's current tools for the # server-side tool calls this turn will make. diff --git a/src/band/adapters/parlant.py b/src/band/adapters/parlant.py index 832d3c997..4e803bf07 100644 --- a/src/band/adapters/parlant.py +++ b/src/band/adapters/parlant.py @@ -377,10 +377,10 @@ async def on_message( logger.debug("Handling message %s in room %s", msg.id, room_id) if not self._app: - logger.error("Parlant Application not initialized") - error = RuntimeError("Parlant Application not initialized") - await tools.send_failure(AgentFailure("parlant", str(error))) - raise error + message = "Parlant Application not initialized" + logger.error(message) + await tools.send_failure(AgentFailure("parlant", message)) + raise RuntimeError(message) app = self._app sender_name = msg.sender_name or msg.sender_id or "User" diff --git a/src/band/core/protocols.py b/src/band/core/protocols.py index dbed1e798..94ab459c6 100644 --- a/src/band/core/protocols.py +++ b/src/band/core/protocols.py @@ -36,6 +36,13 @@ # retyping the literal. FAILURE_CODE_TIMEOUT = "timeout" +# Shared generic message for a caught provider exception whose text must not +# reach the room (it can embed DB strings, paths, or tokens) -- the full +# detail goes to the agent log via logger.exception instead. +GENERIC_PROVIDER_FAILURE_MESSAGE = ( + "Internal error while processing message; see agent logs." +) + def to_failure_event(failure: AgentFailure) -> tuple[str, dict[str, Any]]: """Shared shape every ``send_failure`` implementation posts as an `error` event. diff --git a/src/band/integrations/acp/client_adapter.py b/src/band/integrations/acp/client_adapter.py index 59a46bf21..ddd4559a6 100644 --- a/src/band/integrations/acp/client_adapter.py +++ b/src/band/integrations/acp/client_adapter.py @@ -391,18 +391,19 @@ async def on_message( room_id, session_id, ) - await self.stop() - await tools.send_failure( - AgentFailure( - "acp", - f"ACP agent response timed out after {self._turn_timeout_s}s", - FAILURE_CODE_TIMEOUT, - ) + await asyncio.gather( + self.stop(), + tools.send_failure( + AgentFailure( + "acp", + f"ACP agent response timed out after {self._turn_timeout_s}s", + FAILURE_CODE_TIMEOUT, + ) + ), ) except Exception as e: logger.exception("ACP agent error: %s", e) - await self.stop() - await tools.send_failure(_to_agent_failure(e)) + await asyncio.gather(self.stop(), tools.send_failure(_to_agent_failure(e))) def _make_permission_handler( self, diff --git a/tests/integrations/acp/test_client_adapter.py b/tests/integrations/acp/test_client_adapter.py index 16cb7f340..3375d09cb 100644 --- a/tests/integrations/acp/test_client_adapter.py +++ b/tests/integrations/acp/test_client_adapter.py @@ -21,7 +21,7 @@ ) from band.integrations.acp.room_emitter import turn_replied_in_room from band.integrations.acp.types import ACPToolCall, ACPToolResult, CollectedChunk -from band.testing import FakeAgentTools, reported_failures +from band.testing import FakeAgentTools, events_of_type, reported_failures from tests.integrations.acp.conftest import make_platform_message @@ -40,11 +40,6 @@ def event_types(events: list[dict[str, object]]) -> list[object]: return [event["message_type"] for event in events] -def events_of_type(tools: FakeAgentTools, message_type: str) -> list[dict[str, object]]: - """Events the handler sent, filtered to one message_type.""" - return [e for e in tools.events_sent if e.get("message_type") == message_type] - - def metadata_values(events: list[dict[str, object]], key: str) -> list[object]: """The ordered value of one metadata field across a set of events.""" return [event["metadata"][key] for event in events] From a5d31420fca7dd8eb7918e8f2783161034102912 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Mon, 7 Sep 2026 12:34:15 +0300 Subject: [PATCH 28/53] fix: fail the turn on every remaining reported-but-unraised provider failure Extends 99b660a1's fix to the call sites the code review's follow-up scan found still swallowing a reported failure: ACP client on_message, OpenCode's HTTP/generic exception handlers, A2A's terminal-task-state delivery, CrewAI/ Pydantic AI/Strands/Letta's missing-reply branches, and Codex's turn-timeout, transport/closed, and turn/completed(status=failed) paths -- the latter two were not in the review's finding list but share the exact same bug, found while fixing the turn-timeout sibling in the same function. Promotes TurnResultAlreadyReported to band.core.protocols as the one shared mechanism every adapter uses to avoid double-reporting a failure an inner handler already sent, replacing Codex's adapter-local failure_reported bool for its two now-migrated call sites. Also: - codex/types.py: str()-coerce codexErrorInfo.type before it reaches AgentFailure's strict `code: str | None`, matching every sibling adapter. - a2a/gateway/adapter.py: redact credentials from a relayed peer failure's entire dict (detail/code included), not just its message field. - copilot_sdk.py: wrap the final unguarded send_message in deliver_reply so a delivery failure is reported instead of propagating unhandled. - parlant.py: route the polling-fallback reply through deliver_reply instead of a local log-and-continue try/except (pre-existing, out of this PR's diff, fixed anyway as a small out-of-scope extra). crewai_flow.py's record_failed call sites are deliberately left as return- after-report: that adapter reconstructs state from durable task events each turn rather than a synchronous retry model, so raising there would fight its own design rather than fix a bug. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01YNvve8nDhUac7mf86hHaHp --- src/band/adapters/anthropic.py | 4 +- src/band/adapters/claude_sdk.py | 20 +- src/band/adapters/codex.py | 67 +++-- src/band/adapters/copilot_sdk.py | 25 +- src/band/adapters/crewai.py | 31 ++- src/band/adapters/gemini.py | 4 +- src/band/adapters/google_adk.py | 8 +- src/band/adapters/letta.py | 45 +-- src/band/adapters/opencode/adapter.py | 2 + src/band/adapters/parlant.py | 31 ++- src/band/adapters/pydantic_ai.py | 16 +- src/band/adapters/strands.py | 18 +- src/band/core/delivery.py | 16 +- src/band/core/protocols.py | 6 + src/band/integrations/a2a/adapter.py | 26 +- src/band/integrations/a2a/gateway/adapter.py | 26 +- src/band/integrations/acp/client_adapter.py | 14 +- src/band/integrations/codex/types.py | 3 +- tests/adapters/copilot_sdk/test_reply.py | 5 +- tests/adapters/opencode/test_turns.py | 24 +- tests/adapters/test_anthropic_adapter.py | 3 +- tests/adapters/test_claude_sdk_adapter.py | 7 +- tests/adapters/test_codex_adapter.py | 259 ++++++++---------- tests/adapters/test_crewai_adapter.py | 100 ++++--- tests/adapters/test_crewai_adapter_soak.py | 27 +- tests/adapters/test_gemini_adapter.py | 3 +- tests/adapters/test_google_adk_adapter.py | 5 +- tests/adapters/test_letta_adapter.py | 20 +- tests/adapters/test_parlant_adapter.py | 5 +- tests/adapters/test_pydantic_ai_adapter.py | 75 +++-- tests/adapters/test_strands_adapter.py | 35 ++- .../test_strands_injection_spike.py | 8 +- tests/integrations/a2a/test_adapter.py | 48 ++-- tests/integrations/acp/acp_toolkit/harness.py | 18 ++ tests/integrations/acp/test_client_adapter.py | 98 +++---- .../acp/test_client_adapter_behavior.py | 4 +- 36 files changed, 656 insertions(+), 450 deletions(-) diff --git a/src/band/adapters/anthropic.py b/src/band/adapters/anthropic.py index 91679a5f5..25dc557a6 100644 --- a/src/band/adapters/anthropic.py +++ b/src/band/adapters/anthropic.py @@ -17,7 +17,7 @@ from typing_extensions import Unpack from band.core.exceptions import BandConfigError -from band.core.protocols import AgentToolsProtocol +from band.core.protocols import GENERIC_PROVIDER_FAILURE_MESSAGE, AgentToolsProtocol from band.core.simple_adapter import SimpleAdapter from band.core.types import ( Capability, @@ -71,7 +71,7 @@ def _to_agent_failure(e: Exception) -> AgentFailure: """ if isinstance(e, APIStatusError): return AgentFailure("anthropic", str(e), str(e.status_code), e.body) - return AgentFailure("anthropic", str(e)) + return AgentFailure("anthropic", GENERIC_PROVIDER_FAILURE_MESSAGE) class AnthropicAdapter(SimpleAdapter[AnthropicMessages]): diff --git a/src/band/adapters/claude_sdk.py b/src/band/adapters/claude_sdk.py index a74b4830f..467238842 100644 --- a/src/band/adapters/claude_sdk.py +++ b/src/band/adapters/claude_sdk.py @@ -52,7 +52,11 @@ from band_sdk_core import AgentFailure from typing_extensions import Unpack -from band.core.protocols import AgentToolsProtocol +from band.core.protocols import ( + GENERIC_PROVIDER_FAILURE_MESSAGE, + AgentToolsProtocol, + TurnResultAlreadyReported, +) from band.core.simple_adapter import SimpleAdapter from band.core.types import ( Capability, @@ -101,12 +105,6 @@ logger = logging.getLogger(__name__) -class TurnResultAlreadyReported(Exception): - """A terminal `ResultMessage` failure `_on_turn_complete` already - reported via `send_failure`. `on_message`'s outer except re-raises this - without reporting the same failure a second time.""" - - # Tool names as constants (MCP naming convention: mcp__{server}__{tool}) # Derived from TOOL_MODELS — single source of truth BAND_BASE_TOOLS: list[str] = mcp_tool_names(BASE_TOOL_NAMES) @@ -712,12 +710,16 @@ async def on_message( ) await self._invalidate_session(room_id) - await tools.send_failure(AgentFailure("claude_sdk", str(e))) + await tools.send_failure( + AgentFailure("claude_sdk", GENERIC_PROVIDER_FAILURE_MESSAGE) + ) raise except Exception as e: logger.exception("Error processing message: %s", e) - await tools.send_failure(AgentFailure("claude_sdk", str(e))) + await tools.send_failure( + AgentFailure("claude_sdk", GENERIC_PROVIDER_FAILURE_MESSAGE) + ) raise logger.debug("Message %s processed successfully", msg.id) diff --git a/src/band/adapters/codex.py b/src/band/adapters/codex.py index 251482e37..174fc9991 100644 --- a/src/band/adapters/codex.py +++ b/src/band/adapters/codex.py @@ -10,7 +10,7 @@ from collections import OrderedDict from dataclasses import dataclass from datetime import datetime, timezone -from typing import ClassVar, Any, Callable, Literal, NamedTuple, NoReturn, Protocol +from typing import ClassVar, Any, Callable, Literal, NamedTuple, Protocol from band_sdk_core import AgentFailure from pydantic import AliasChoices, BaseModel, Field, ValidationError, field_validator @@ -19,8 +19,17 @@ from band.converters.codex import CodexHistoryConverter from band.converters.helpers import build_replay_messages -from band.core.delivery import DeliveryFailedError, deliver_reply -from band.core.protocols import FAILURE_CODE_TIMEOUT, AgentToolsProtocol +from band.core.delivery import ( + DeliveryFailedError, + deliver_reply, + reraise_delivery_cause, +) +from band.core.protocols import ( + FAILURE_CODE_TIMEOUT, + GENERIC_PROVIDER_FAILURE_MESSAGE, + AgentToolsProtocol, + TurnResultAlreadyReported, +) from band.core.simple_adapter import SimpleAdapter from band.core.types import ( AgentInput, @@ -81,16 +90,6 @@ def _image_content_items(result: dict[str, Any]) -> list[dict[str, Any]]: ] -def _reraise_delivery_cause(e: DeliveryFailedError) -> NoReturn: - """Band-side reply delivery failed, never a Codex provider failure. - - Re-raises the cause (not this wrapper) so mark_failed/retry bookkeeping - keys off the real exception. - """ - logger.exception("Codex reply delivery failed: %s", e.cause) - raise e.cause from None - - TransportKind = Literal["stdio", "ws"] ApprovalMode = Literal["auto_accept", "auto_decline", "manual"] ApprovalDecision = Literal["accept", "acceptForSession", "decline"] @@ -589,7 +588,7 @@ async def on_message( args=command[1], ) except DeliveryFailedError as e: - _reraise_delivery_cause(e) + reraise_delivery_cause(e) if handled: return @@ -706,6 +705,12 @@ async def on_message( turn_id=turn_id or None, turn_start=_turn_start, ) + except TurnResultAlreadyReported: + # A nested handler (e.g. the turn-timeout branch) already + # reported this failure via send_failure; propagate it + # without emitting a friendly _emit_turn_outcome reply or + # a generic "Internal error" fallback. + raise except Exception: logger.exception( "Unexpected error during Codex turn event processing " @@ -732,10 +737,20 @@ async def on_message( duration_s=_turn_duration_s, ) except DeliveryFailedError as e: - _reraise_delivery_cause(e) - except Exception as e: + reraise_delivery_cause(e) + except TurnResultAlreadyReported: + raise + except CodexJsonRpcError as e: + # A structured RPC error from the app-server (e.g. "model not + # available") is safe, curated text -- unlike an arbitrary + # caught exception, it's worth showing verbatim. await tools.send_failure(AgentFailure("codex", str(e))) raise + except Exception: + await tools.send_failure( + AgentFailure("codex", GENERIC_PROVIDER_FAILURE_MESSAGE) + ) + raise async def _process_turn_events( self, @@ -991,7 +1006,10 @@ async def _process_turn_events( self._token_usage.pop(stale_thread, None) for stale_room in stale_rooms: self._clear_pending_approvals_for_room(stale_room) - break + await tools.send_failure( + AgentFailure("codex", result.turn_error, "transport_closed") + ) + raise TurnResultAlreadyReported(result.turn_error) if event.method == "turn/completed": turn_payload = ( @@ -1004,10 +1022,12 @@ async def _process_turn_events( continue result.turn_status = str(turn_payload.get("status") or "failed") result.turn_error = self._extract_turn_error(turn_payload) - # Phase 1: structured error for failed turns. Skipped when - # an earlier "error" notification in this same turn - # already reported one, so one incident isn't posted twice. - if result.turn_status == "failed" and not failure_reported: + if result.turn_status != "failed": + break + # Skipped when an earlier "error" notification in this same + # turn already reported one, so one incident isn't posted + # twice -- but the turn still fails either way. + if not failure_reported: await self._emit_structured_turn_error( tools=tools, turn_payload=turn_payload, @@ -1015,7 +1035,7 @@ async def _process_turn_events( thread_id=thread_id, turn_id=turn_id, ) - break + raise TurnResultAlreadyReported(result.turn_error or "Turn failed") except asyncio.TimeoutError: logger.error( "Codex turn timed out after %ss (thread=%s, turn=%s)", @@ -1041,8 +1061,7 @@ async def _process_turn_events( FAILURE_CODE_TIMEOUT, ) ) - result.turn_status = "interrupted" - result.turn_error = "Turn timed out" + raise TurnResultAlreadyReported("Turn timed out") return result async def on_cleanup(self, room_id: str) -> None: diff --git a/src/band/adapters/copilot_sdk.py b/src/band/adapters/copilot_sdk.py index 21a97e93c..e846a9fed 100644 --- a/src/band/adapters/copilot_sdk.py +++ b/src/band/adapters/copilot_sdk.py @@ -22,7 +22,13 @@ CopilotSDKHistoryConverter, CopilotSDKSessionState, ) +from band.core.delivery import ( + DeliveryFailedError, + deliver_reply, + reraise_delivery_cause, +) from band.core.exceptions import BandConfigError +from band.core.protocols import GENERIC_PROVIDER_FAILURE_MESSAGE from band.core.simple_adapter import SimpleAdapter from band.core.tool_filter import filter_tool_schemas from band.core.types import Capability, Emit, MessageType, ToolEventKey, TurnUsage @@ -408,9 +414,11 @@ async def on_message( session, inject_text = await self._obtain_session( room_id, history, tools, is_session_bootstrap=is_session_bootstrap ) - except Exception as exc: + except Exception: logger.exception("Room %s: Copilot session setup failed", room_id) - await tools.send_failure(AgentFailure("copilot_sdk", str(exc))) + await tools.send_failure( + AgentFailure("copilot_sdk", GENERIC_PROVIDER_FAILURE_MESSAGE) + ) raise prompt = self._compose_prompt( msg, @@ -431,12 +439,14 @@ async def on_message( self._turn_state[room_id] = turn try: final_text = await self._run_turn(session, prompt, turn) - except Exception as exc: + except Exception: logger.exception("Room %s: Copilot turn failed", room_id) # Abort any work the runtime is still doing for this turn and # drop the session; the next message resumes it fresh by id. await self._session_manager.evict_session(room_id) - await tools.send_failure(AgentFailure("copilot_sdk", str(exc))) + await tools.send_failure( + AgentFailure("copilot_sdk", GENERIC_PROVIDER_FAILURE_MESSAGE) + ) raise finally: self._turn_state.pop(room_id, None) @@ -458,7 +468,12 @@ async def on_message( # The turn may already have replied into the room; sending its # final text too would duplicate the reply. if final_text and not turn.replied_in_room: - await tools.send_message(final_text, mentions=[turn.sender_mention]) + try: + await deliver_reply( + tools, final_text, mentions=[turn.sender_mention] + ) + except DeliveryFailedError as e: + reraise_delivery_cause(e) await self._persist_session_id(room_id, tools) diff --git a/src/band/adapters/crewai.py b/src/band/adapters/crewai.py index e03706ecd..16c017954 100644 --- a/src/band/adapters/crewai.py +++ b/src/band/adapters/crewai.py @@ -17,7 +17,11 @@ from band_sdk_core import AgentFailure from typing_extensions import Unpack -from band.core.protocols import AgentToolsProtocol +from band.core.protocols import ( + GENERIC_PROVIDER_FAILURE_MESSAGE, + AgentToolsProtocol, + TurnResultAlreadyReported, +) from band.core.simple_adapter import SimpleAdapter from band.core.types import Capability, Emit, FeatureKwargs, PlatformMessage from band.converters.crewai import CrewAIHistoryConverter, CrewAIMessages @@ -407,18 +411,15 @@ async def _process_message( ) if not (reply_tracker is not None and reply_tracker.replied): - await tools.send_failure( - AgentFailure( - "crewai", - missing_reply_error( - "CrewAI", - detail=( - "Repeated tool failures may also have exhausted " - f"max_iter={self.max_iter}." - ), - ), - ) + detail = missing_reply_error( + "CrewAI", + detail=( + "Repeated tool failures may also have exhausted " + f"max_iter={self.max_iter}." + ), ) + await tools.send_failure(AgentFailure("crewai", detail)) + raise TurnResultAlreadyReported(detail) logger.info( "Room %s: CrewAI agent completed (output_length=%s)", @@ -426,6 +427,8 @@ async def _process_message( len(result.raw) if result and result.raw else 0, ) + except TurnResultAlreadyReported: + raise except Exception as e: # CrewAI raises ValueError("Invalid response from LLM call - None or # empty.") when its ReAct loop yields an empty final answer. In this @@ -451,7 +454,9 @@ async def _process_message( ) return logger.error("Error processing message: %s", e, exc_info=True) - await tools.send_failure(AgentFailure("crewai", str(e))) + await tools.send_failure( + AgentFailure("crewai", GENERIC_PROVIDER_FAILURE_MESSAGE) + ) raise logger.debug( diff --git a/src/band/adapters/gemini.py b/src/band/adapters/gemini.py index a2ff550e8..3bac6e264 100644 --- a/src/band/adapters/gemini.py +++ b/src/band/adapters/gemini.py @@ -25,7 +25,7 @@ ) from e from band.core.exceptions import BandConfigError -from band.core.protocols import AgentToolsProtocol +from band.core.protocols import GENERIC_PROVIDER_FAILURE_MESSAGE, AgentToolsProtocol from band.core.simple_adapter import SimpleAdapter from band.core.tool_filter import sanitize_tool_schema from band.core.types import ( @@ -80,7 +80,7 @@ def _to_agent_failure(e: Exception) -> AgentFailure: """ if isinstance(e, ServerError): return AgentFailure("gemini", str(e), e.status, e.message) - return AgentFailure("gemini", str(e)) + return AgentFailure("gemini", GENERIC_PROVIDER_FAILURE_MESSAGE) class GeminiAdapter(SimpleAdapter[GeminiMessages]): diff --git a/src/band/adapters/google_adk.py b/src/band/adapters/google_adk.py index af1935174..7cbd79504 100644 --- a/src/band/adapters/google_adk.py +++ b/src/band/adapters/google_adk.py @@ -19,7 +19,7 @@ from pydantic import ValidationError from typing_extensions import Unpack -from band.core.protocols import AgentToolsProtocol +from band.core.protocols import GENERIC_PROVIDER_FAILURE_MESSAGE, AgentToolsProtocol from band.core.simple_adapter import SimpleAdapter from band.core.tool_filter import sanitize_tool_schema from band.core.types import ( @@ -581,9 +581,11 @@ async def on_message( "Room %s: ADK agent completed with final response", room_id, ) - except Exception as e: + except Exception: logger.exception("Error running ADK agent in room %s", room_id) - await tools.send_failure(AgentFailure("google_adk", str(e))) + await tools.send_failure( + AgentFailure("google_adk", GENERIC_PROVIDER_FAILURE_MESSAGE) + ) raise finally: # Emit before close so a close() failure can't drop the usage, but diff --git a/src/band/adapters/letta.py b/src/band/adapters/letta.py index ecda76633..814c32346 100644 --- a/src/band/adapters/letta.py +++ b/src/band/adapters/letta.py @@ -13,8 +13,17 @@ from typing_extensions import Unpack from band.converters.letta import LettaHistoryConverter, LettaSessionState -from band.core.delivery import DeliveryFailedError, deliver_reply -from band.core.protocols import FAILURE_CODE_TIMEOUT, AgentToolsProtocol +from band.core.delivery import ( + DeliveryFailedError, + deliver_reply, + reraise_delivery_cause, +) +from band.core.protocols import ( + FAILURE_CODE_TIMEOUT, + GENERIC_PROVIDER_FAILURE_MESSAGE, + AgentToolsProtocol, + TurnResultAlreadyReported, +) from band.core.simple_adapter import SimpleAdapter from band.core.types import ( AdapterFeatures, @@ -287,7 +296,9 @@ async def on_message( await self._ensure_agent(room_id, history, tools) except Exception as e: logger.exception("Room %s: Failed to prepare Letta session: %s", room_id, e) - await tools.send_failure(AgentFailure("letta", str(e))) + await tools.send_failure( + AgentFailure("letta", GENERIC_PROVIDER_FAILURE_MESSAGE) + ) raise await self._handle_message( @@ -423,16 +434,9 @@ async def _run_turn( timeout=self.config.turn_timeout_s, ) except DeliveryFailedError as e: - # The agent answered; posting the reply to the room is what - # failed. Band-side delivery, never a Letta provider failure -- - # re-raise the cause so mark_failed/retry bookkeeping keys off - # the real exception. - logger.exception( - "Room %s: Failed to deliver Letta agent's reply: %s", - room_id, - e.cause, - ) - raise e.cause from None + reraise_delivery_cause(e) + except TurnResultAlreadyReported: + raise except asyncio.TimeoutError: logger.error( "Room %s: Letta turn timed out after %ss", @@ -449,7 +453,9 @@ async def _run_turn( raise except Exception as e: logger.exception("Room %s: Error during Letta turn: %s", room_id, e) - await tools.send_failure(AgentFailure("letta", str(e))) + await tools.send_failure( + AgentFailure("letta", GENERIC_PROVIDER_FAILURE_MESSAGE) + ) raise else: if room_ctx.pending_seed: @@ -596,13 +602,12 @@ async def _process_response_messages( room_id, self._mcp.send_message_tool, ) - await tools.send_failure( - AgentFailure( - "letta", - f"Letta agent did not call {self._mcp.send_message_tool} " - "(auto-relay disabled); its reply was dropped", - ) + detail = ( + f"Letta agent did not call {self._mcp.send_message_tool} " + "(auto-relay disabled); its reply was dropped" ) + await tools.send_failure(AgentFailure("letta", detail)) + raise TurnResultAlreadyReported(detail) else: final_text = "\n\n".join(final_text_parts) mentions = [reply_to_sender_id] if reply_to_sender_id else None diff --git a/src/band/adapters/opencode/adapter.py b/src/band/adapters/opencode/adapter.py index cf89e11a9..1dd046303 100644 --- a/src/band/adapters/opencode/adapter.py +++ b/src/band/adapters/opencode/adapter.py @@ -501,6 +501,7 @@ async def on_message( str(exc.response.status_code), ) ) + raise except Exception: logger.exception("Unexpected OpenCode adapter failure in room %s", room_id) await tools.send_failure( @@ -508,6 +509,7 @@ async def on_message( "opencode", "OpenCode failed while processing the message." ) ) + raise async def on_cleanup(self, room_id: str) -> None: room_state: RoomState | None = None diff --git a/src/band/adapters/parlant.py b/src/band/adapters/parlant.py index 4e803bf07..dbc4c69ef 100644 --- a/src/band/adapters/parlant.py +++ b/src/band/adapters/parlant.py @@ -17,7 +17,12 @@ from band_sdk_core import AgentFailure from typing_extensions import Unpack -from band.core.protocols import AgentToolsProtocol +from band.core.delivery import ( + DeliveryFailedError, + deliver_reply, + reraise_delivery_cause, +) +from band.core.protocols import GENERIC_PROVIDER_FAILURE_MESSAGE, AgentToolsProtocol from band.core.simple_adapter import SimpleAdapter from band.core.types import Capability, Emit, FeatureKwargs, PlatformMessage from band.integrations.parlant.server import running_parlant_server @@ -391,7 +396,7 @@ async def on_message( except Exception as e: logger.error("Failed to get/create session for room %s: %s", room_id, e) await tools.send_failure( - AgentFailure("parlant", f"Session initialization failed: {e}") + AgentFailure("parlant", GENERIC_PROVIDER_FAILURE_MESSAGE) ) raise session_id_str = str(session_id) @@ -448,9 +453,13 @@ async def on_message( sender_name=sender_name, ) + except DeliveryFailedError as e: + reraise_delivery_cause(e) except Exception as e: logger.error("Error processing message: %s", e, exc_info=True) - await tools.send_failure(AgentFailure("parlant", str(e))) + await tools.send_failure( + AgentFailure("parlant", GENERIC_PROVIDER_FAILURE_MESSAGE) + ) raise finally: # Clear tools after message processing @@ -796,18 +805,10 @@ async def _process_agent_response( room_id, message_content[:100], ) - try: - await tools.send_message( - message_content, mentions=[sender_name] - ) - logger.info("Room %s: Message sent successfully", room_id) - except Exception as e: - logger.error( - "Room %s: Error sending message: %s", - room_id, - e, - exc_info=True, - ) + await deliver_reply( + tools, message_content, mentions=[sender_name] + ) + logger.info("Room %s: Message sent successfully", room_id) else: logger.warning( "Room %s: Empty message content in event", diff --git a/src/band/adapters/pydantic_ai.py b/src/band/adapters/pydantic_ai.py index 5207cfea1..28816f066 100644 --- a/src/band/adapters/pydantic_ai.py +++ b/src/band/adapters/pydantic_ai.py @@ -37,7 +37,11 @@ from typing_extensions import Unpack -from band.core.protocols import AgentToolsProtocol +from band.core.protocols import ( + GENERIC_PROVIDER_FAILURE_MESSAGE, + AgentToolsProtocol, + TurnResultAlreadyReported, +) from band.core.simple_adapter import SimpleAdapter from band.core.task_types import TaskAssignmentStatus, TaskLifecycleState, TaskListState from band.core.types import ( @@ -1058,7 +1062,9 @@ async def on_message( ModelRequest(parts=[UserPromptPart(content=user_message)]), ] return - await tools.send_failure(AgentFailure("pydantic_ai", str(e))) + await tools.send_failure( + AgentFailure("pydantic_ai", GENERIC_PROVIDER_FAILURE_MESSAGE) + ) raise finally: capture_cm.__exit__(None, None, None) @@ -1078,9 +1084,9 @@ async def on_message( # either answered in plain text or said nothing at all. Surface it as an # error (mirrors the crewai adapter) instead of letting it vanish. if not tool_executed: - await tools.send_failure( - AgentFailure("pydantic_ai", missing_reply_error("Pydantic AI")) - ) + detail = missing_reply_error("Pydantic AI") + await tools.send_failure(AgentFailure("pydantic_ai", detail)) + raise TurnResultAlreadyReported(detail) logger.debug( "Room %s: Pydantic AI agent completed (history now has %s messages)", diff --git a/src/band/adapters/strands.py b/src/band/adapters/strands.py index 207ab9c9b..660fb2b3b 100644 --- a/src/band/adapters/strands.py +++ b/src/band/adapters/strands.py @@ -33,7 +33,11 @@ from typing_extensions import Unpack -from band.core.protocols import AgentToolsProtocol +from band.core.protocols import ( + GENERIC_PROVIDER_FAILURE_MESSAGE, + AgentToolsProtocol, + TurnResultAlreadyReported, +) from band.core.simple_adapter import SimpleAdapter from band.core.tool_filter import filter_tool_schemas from band.core.types import ( @@ -528,8 +532,10 @@ async def _run_turn( try: agent = self._build_agent(history, tools, hooks) await agent.invoke_async(message) - except Exception as e: - await tools.send_failure(AgentFailure("strands", str(e))) + except Exception: + await tools.send_failure( + AgentFailure("strands", GENERIC_PROVIDER_FAILURE_MESSAGE) + ) raise finally: if agent is not None: @@ -574,9 +580,9 @@ async def on_message( hooks=hooks, ) if not hooks.terminal_fired: - await tools.send_failure( - AgentFailure("strands", missing_reply_error("Strands")) - ) + detail = missing_reply_error("Strands") + await tools.send_failure(AgentFailure("strands", detail)) + raise TurnResultAlreadyReported(detail) logger.debug( "Room %s: Strands agent completed (history now has %s messages)", room_id, diff --git a/src/band/core/delivery.py b/src/band/core/delivery.py index 74fa0fb0a..6a8427869 100644 --- a/src/band/core/delivery.py +++ b/src/band/core/delivery.py @@ -9,11 +9,14 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +import logging +from typing import TYPE_CHECKING, Any, NoReturn if TYPE_CHECKING: from band.core.protocols import AgentToolsProtocol +logger = logging.getLogger(__name__) + class DeliveryFailedError(Exception): """Wraps a ``send_message`` failure so it is never mistaken for a @@ -24,6 +27,17 @@ def __init__(self, cause: BaseException) -> None: self.cause = cause +def reraise_delivery_cause(e: DeliveryFailedError) -> NoReturn: + """Log then re-raise a ``DeliveryFailedError``'s cause. + + Band-side reply delivery failed, never a provider failure -- re-raises + the cause (not this wrapper) so mark_failed/retry bookkeeping keys off + the real exception. + """ + logger.exception("Reply delivery failed: %s", e.cause) + raise e.cause from None + + async def deliver_reply( tools: "AgentToolsProtocol", content: str, diff --git a/src/band/core/protocols.py b/src/band/core/protocols.py index 94ab459c6..9d1e35c5f 100644 --- a/src/band/core/protocols.py +++ b/src/band/core/protocols.py @@ -44,6 +44,12 @@ ) +class TurnResultAlreadyReported(Exception): + """A terminal turn failure that a nested handler already reported via + ``send_failure``. An adapter's outer ``except`` re-raises this without + reporting the same failure a second time.""" + + def to_failure_event(failure: AgentFailure) -> tuple[str, dict[str, Any]]: """Shared shape every ``send_failure`` implementation posts as an `error` event. diff --git a/src/band/integrations/a2a/adapter.py b/src/band/integrations/a2a/adapter.py index 0e80c2c80..7659033c1 100644 --- a/src/band/integrations/a2a/adapter.py +++ b/src/band/integrations/a2a/adapter.py @@ -23,8 +23,16 @@ from typing_extensions import Unpack from band.converters.a2a import A2AHistoryConverter -from band.core.delivery import DeliveryFailedError, deliver_reply -from band.core.protocols import AgentToolsProtocol +from band.core.delivery import ( + DeliveryFailedError, + deliver_reply, + reraise_delivery_cause, +) +from band.core.protocols import ( + GENERIC_PROVIDER_FAILURE_MESSAGE, + AgentToolsProtocol, + TurnResultAlreadyReported, +) from band.core.simple_adapter import SimpleAdapter from band.core.types import Capability, Emit, FeatureKwargs, PlatformMessage from band.integrations.a2a.protocol import ( @@ -175,15 +183,14 @@ async def on_message( ) except DeliveryFailedError as e: - # The A2A agent answered; posting its reply to the room is what - # failed. Band-side delivery, never an A2A provider failure -- - # re-raise the cause so mark_failed/retry bookkeeping keys off - # the real exception. - logger.exception("A2A reply delivery failed: %s", e.cause) - raise e.cause from None + reraise_delivery_cause(e) + except TurnResultAlreadyReported: + raise except Exception as e: logger.exception("A2A agent error: %s", e) - await tools.send_failure(AgentFailure("a2a", str(e))) + await tools.send_failure( + AgentFailure("a2a", GENERIC_PROVIDER_FAILURE_MESSAGE) + ) raise async def _handle_event( @@ -289,6 +296,7 @@ async def _deliver_task_update( if state in TERMINAL_TASK_STATES: error_text = self._get_status_text(task) or f"Task {state_name(state)}" await tools.send_failure(AgentFailure("a2a", error_text, state_name(state))) + raise TurnResultAlreadyReported(error_text) def _finalize_task(self, room_id: str, task_id: str) -> None: """Release a terminal task after its Band output and state are persisted.""" diff --git a/src/band/integrations/a2a/gateway/adapter.py b/src/band/integrations/a2a/gateway/adapter.py index 4c408623d..64e1ecd6f 100644 --- a/src/band/integrations/a2a/gateway/adapter.py +++ b/src/band/integrations/a2a/gateway/adapter.py @@ -9,7 +9,7 @@ import re from contextlib import asynccontextmanager from collections.abc import AsyncIterator -from typing import ClassVar +from typing import Any, ClassVar from uuid import uuid4 from a2a.server.agent_execution import AgentExecutor, RequestContext @@ -96,6 +96,23 @@ def _redact_credentials(text: str) -> str: return _CREDENTIAL_KV_RE.sub(r"\1=[REDACTED]", redacted) +def _redact_credentials_deep(value: Any) -> Any: + """Recursively redact credentials from a peer's ``AgentFailure.detail``. + + ``detail`` is untrusted, adapter-defined structure (e.g. Codex's own + ``codex_additional_details`` echoes upstream error text) that can nest + a credential-bearing string at any depth before it reaches an external + A2A client. + """ + if isinstance(value, str): + return _redact_credentials(value) + if isinstance(value, dict): + return {key: _redact_credentials_deep(item) for key, item in value.items()} + if isinstance(value, list): + return [_redact_credentials_deep(item) for item in value] + return value + + def _sanitize_gateway_error_message(exc: BaseException) -> str: """Redact bearer tokens/API keys before an internal exception message reaches an external A2A client, and cap its length. @@ -604,11 +621,8 @@ async def _publish_band_response( failure = ( msg.metadata.get("failure") if isinstance(msg.metadata, dict) else None ) - if isinstance(failure, dict) and isinstance(failure.get("message"), str): - failure = { - **failure, - "message": _redact_credentials(failure["message"]), - } + if isinstance(failure, dict): + failure = _redact_credentials_deep(failure) await pending.fail(_redact_credentials(msg.content), failure=failure) elif msg.message_type in ("thought", "tool_call", "tool_result"): await pending.report_progress(msg.content) diff --git a/src/band/integrations/acp/client_adapter.py b/src/band/integrations/acp/client_adapter.py index ddd4559a6..fe8f98dea 100644 --- a/src/band/integrations/acp/client_adapter.py +++ b/src/band/integrations/acp/client_adapter.py @@ -18,8 +18,12 @@ from band.converters.acp_client import ACPClientHistoryConverter from band.converters.helpers import build_replay_messages -from band.core.delivery import DeliveryFailedError -from band.core.protocols import FAILURE_CODE_TIMEOUT, AgentToolsProtocol +from band.core.delivery import DeliveryFailedError, reraise_delivery_cause +from band.core.protocols import ( + FAILURE_CODE_TIMEOUT, + GENERIC_PROVIDER_FAILURE_MESSAGE, + AgentToolsProtocol, +) from band.core.simple_adapter import SimpleAdapter from band.core.types import ( AdapterFeatures, @@ -137,7 +141,7 @@ def _to_agent_failure(exc: Exception) -> AgentFailure: """ if isinstance(exc, RequestError): return AgentFailure("acp", str(exc), str(exc.code), exc.data) - return AgentFailure("acp", str(exc)) + return AgentFailure("acp", GENERIC_PROVIDER_FAILURE_MESSAGE) class ACPClientAdapter(SimpleAdapter[ACPClientSessionState]): @@ -380,7 +384,7 @@ async def on_message( except DeliveryFailedError as e: # The turn's reply is what failed to post -- Band-side delivery, # never an ACP provider failure, so the connection stays up. - logger.exception("ACP reply delivery failed: %s", e.cause) + reraise_delivery_cause(e) except asyncio.TimeoutError: # A silent/stuck agent must become an observable failure instead # of hanging the turn indefinitely -- the connection is presumed @@ -401,9 +405,11 @@ async def on_message( ) ), ) + raise except Exception as e: logger.exception("ACP agent error: %s", e) await asyncio.gather(self.stop(), tools.send_failure(_to_agent_failure(e))) + raise def _make_permission_handler( self, diff --git a/src/band/integrations/codex/types.py b/src/band/integrations/codex/types.py index 2936a5aca..5014abf04 100644 --- a/src/band/integrations/codex/types.py +++ b/src/band/integrations/codex/types.py @@ -103,7 +103,8 @@ def build_agent_failure( codex_info = error_obj.get("codexErrorInfo") or {} if not isinstance(codex_info, dict): codex_info = {} - error_type = codex_info.get("type") or None + raw_error_type = codex_info.get("type") + error_type = str(raw_error_type) if raw_error_type else None error_code = codex_info.get("code") or None http_status = codex_info.get("httpStatus") # A genuine passthrough of codexErrorInfo.retryable: absent means unknown, diff --git a/tests/adapters/copilot_sdk/test_reply.py b/tests/adapters/copilot_sdk/test_reply.py index 76f4c03c8..b1bc1225c 100644 --- a/tests/adapters/copilot_sdk/test_reply.py +++ b/tests/adapters/copilot_sdk/test_reply.py @@ -5,6 +5,7 @@ import pytest from band.adapters.copilot_sdk import _COPILOT_SDK_AVAILABLE +from band.core.protocols import GENERIC_PROVIDER_FAILURE_MESSAGE from band.runtime.tools import CHAT_ID_FIELD_NAME, ToolCallOutcome from tests.adapters.copilot_sdk.fakes import ( FakeCopilotClient, @@ -78,7 +79,9 @@ async def test_session_error_raises_reports_and_evicts(self): session = client.sessions[0] assert session.aborted and session.disconnected error_events = [e for e in tools.events_sent if e["message_type"] == "error"] - assert error_events and "boom" in error_events[0]["content"] + assert error_events and error_events[0]["content"] == ( + GENERIC_PROVIDER_FAILURE_MESSAGE + ) @pytest.mark.asyncio async def test_fallback_send_suppressed_when_band_send_message_fired(self): diff --git a/tests/adapters/opencode/test_turns.py b/tests/adapters/opencode/test_turns.py index 1fab36196..e45888add 100644 --- a/tests/adapters/opencode/test_turns.py +++ b/tests/adapters/opencode/test_turns.py @@ -5,6 +5,8 @@ import asyncio import json +import httpx +import pytest from band.adapters.opencode import OpencodeAdapter, OpencodeAdapterConfig from band.core.types import ( @@ -53,15 +55,16 @@ async def test_prompt_submission_failure_does_not_leave_room_stuck( adapter = make_adapter(fake_client) await adapter.on_started("OpenCode Agent", "A coding agent") - await adapter.on_message( - make_platform_message(content="first try"), - tools_protocol(tools), - OpencodeSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) + with pytest.raises(httpx.HTTPStatusError): + await adapter.on_message( + make_platform_message(content="first try"), + tools_protocol(tools), + OpencodeSessionState(), + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-1", + ) await adapter.on_message( make_platform_message(content="second try"), @@ -95,7 +98,8 @@ async def test_http_error_reports_status_code_as_failure_code( ) adapter = make_adapter(fake_client) - await run_single_turn(adapter, tools) + with pytest.raises(httpx.HTTPStatusError): + await run_single_turn(adapter, tools) failures = reported_failures(tools) assert failures diff --git a/tests/adapters/test_anthropic_adapter.py b/tests/adapters/test_anthropic_adapter.py index 68218506e..6011602d6 100644 --- a/tests/adapters/test_anthropic_adapter.py +++ b/tests/adapters/test_anthropic_adapter.py @@ -21,6 +21,7 @@ from pydantic import BaseModel, Field from band.adapters.anthropic import AnthropicAdapter +from band.core.protocols import GENERIC_PROVIDER_FAILURE_MESSAGE from band.core.types import ( USAGE_EVENT_TYPE, USAGE_METADATA_KEY, @@ -735,7 +736,7 @@ async def test_reports_error_on_api_failure(self, sample_message, mock_tools): mock_tools.send_failure.assert_called_once() failure = mock_tools.send_failure.call_args.args[0] assert failure.provider == "anthropic" - assert failure.message == "API Error" + assert failure.message == GENERIC_PROVIDER_FAILURE_MESSAGE assert failure.code is None assert failure.detail is None diff --git a/tests/adapters/test_claude_sdk_adapter.py b/tests/adapters/test_claude_sdk_adapter.py index 9f782ad57..dca799bc1 100644 --- a/tests/adapters/test_claude_sdk_adapter.py +++ b/tests/adapters/test_claude_sdk_adapter.py @@ -42,6 +42,7 @@ missing_reply_error, mcp_tool_names, ) +from band.core.protocols import GENERIC_PROVIDER_FAILURE_MESSAGE from band.core.types import Capability, Emit, PlatformMessage, ToolEventKey from claude_agent_sdk._errors import CLIConnectionError from claude_agent_sdk.types import PermissionResultAllow, ToolPermissionContext @@ -483,7 +484,7 @@ async def test_reports_error_on_query_failure(self, sample_message, mock_tools): mock_tools.send_failure.assert_called_once() failure = mock_tools.send_failure.call_args.args[0] assert failure.provider == "claude_sdk" - assert "API Error" in failure.message + assert failure.message == GENERIC_PROVIDER_FAILURE_MESSAGE class TestCLIConnectionError: @@ -564,7 +565,7 @@ async def test_cli_connection_error_reports_error_event( mock_tools.send_failure.assert_called_once() failure = mock_tools.send_failure.call_args.args[0] assert failure.provider == "claude_sdk" - assert "Process dead" in failure.message + assert failure.message == GENERIC_PROVIDER_FAILURE_MESSAGE @pytest.mark.asyncio async def test_clears_session_id_on_cli_connection_error( @@ -645,7 +646,7 @@ async def receive(): assert "room-123" not in adapter._session_ids errors = _error_events(mock_tools) assert len(errors) == 1 - assert "ended without a result" in errors[0] + assert errors[0] == GENERIC_PROVIDER_FAILURE_MESSAGE class TestRoomToolsStorage: diff --git a/tests/adapters/test_codex_adapter.py b/tests/adapters/test_codex_adapter.py index 79f35d593..8c43cdcba 100644 --- a/tests/adapters/test_codex_adapter.py +++ b/tests/adapters/test_codex_adapter.py @@ -23,6 +23,7 @@ CodexAdapterConfig, PendingApproval, ) +from band.core.protocols import TurnResultAlreadyReported from band.core.types import AgentInput, Emit, HistoryProvider, PlatformMessage from band.integrations.codex import CodexJsonRpcError, RpcEvent from band.integrations.codex.types import ( @@ -982,20 +983,7 @@ async def test_models_alias_lists_models_without_starting_turn(self) -> None: @pytest.mark.asyncio async def test_reasoning_effort_passed_in_turn_overrides(self) -> None: - events = [ - _event_notification( - "turn/completed", - { - "turn": { - "id": "t1", - "threadId": "th1", - "status": "completed", - }, - "text": "Done", - }, - ), - ] - fake_client = FakeCodexClient(events=events) + fake_client = FakeCodexClient(events=[_turn_completed()]) adapter = CodexAdapter( config=CodexAdapterConfig( transport="ws", @@ -1023,20 +1011,7 @@ async def test_reasoning_effort_passed_in_turn_overrides(self) -> None: @pytest.mark.asyncio async def test_reasoning_effort_omitted_when_none(self) -> None: - events = [ - _event_notification( - "turn/completed", - { - "turn": { - "id": "t1", - "threadId": "th1", - "status": "completed", - }, - "text": "Done", - }, - ), - ] - fake_client = FakeCodexClient(events=events) + fake_client = FakeCodexClient(events=[_turn_completed()]) adapter = CodexAdapter( config=CodexAdapterConfig(transport="ws"), client_factory=lambda _config: fake_client, @@ -1104,20 +1079,7 @@ async def test_reasoning_command_rejects_invalid_effort(self) -> None: @pytest.mark.asyncio async def test_self_config_tools_registered_when_enabled(self) -> None: - events = [ - _event_notification( - "turn/completed", - { - "turn": { - "id": "t1", - "threadId": "th1", - "status": "completed", - }, - "text": "Done", - }, - ), - ] - fake_client = FakeCodexClient(events=events) + fake_client = FakeCodexClient(events=[_turn_completed()]) adapter = CodexAdapter( config=CodexAdapterConfig(transport="ws", enable_self_config_tools=True), client_factory=lambda _config: fake_client, @@ -1145,20 +1107,7 @@ async def test_self_config_tools_registered_when_enabled(self) -> None: @pytest.mark.asyncio async def test_self_config_tools_not_registered_when_disabled(self) -> None: - events = [ - _event_notification( - "turn/completed", - { - "turn": { - "id": "t1", - "threadId": "th1", - "status": "completed", - }, - "text": "Done", - }, - ), - ] - fake_client = FakeCodexClient(events=events) + fake_client = FakeCodexClient(events=[_turn_completed()]) adapter = CodexAdapter( config=CodexAdapterConfig(transport="ws", enable_self_config_tools=False), client_factory=lambda _config: fake_client, @@ -1393,20 +1342,20 @@ async def test_transport_closed_event_aborts_turn(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Codex Agent", "A coding agent") - await adapter.on_message( - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) + with pytest.raises(TurnResultAlreadyReported): + await adapter.on_message( + make_platform_message(), + tools, + CodexSessionState(), + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-1", + ) - # Adapter should send a failure message mentioning the disconnect. - assert any( - "transport closed" in msg["content"].lower() for msg in tools.messages_sent - ) + # Adapter should report a failure mentioning the disconnect. + failures = reported_failures(tools) + assert any("transport closed" in f["message"].lower() for f in failures) @pytest.mark.asyncio async def test_transport_closed_resets_client_state(self) -> None: @@ -1426,15 +1375,16 @@ async def test_transport_closed_resets_client_state(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Codex Agent", "A coding agent") - await adapter.on_message( - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) + with pytest.raises(TurnResultAlreadyReported): + await adapter.on_message( + make_platform_message(), + tools, + CodexSessionState(), + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-1", + ) # After transport/closed, client state should be reset assert adapter._client is None @@ -1464,15 +1414,16 @@ async def test_transport_closed_clears_per_room_state(self) -> None: adapter._room_threads["room-1"] = "old-thread-id" adapter._raw_history_by_room["room-1"] = [{"role": "user", "content": "hi"}] - await adapter.on_message( - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=False, - room_id="room-1", - ) + with pytest.raises(TurnResultAlreadyReported): + await adapter.on_message( + make_platform_message(), + tools, + CodexSessionState(), + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=False, + room_id="room-1", + ) # Per-room state should be cleared so next turn starts fresh. assert "room-1" not in adapter._room_threads @@ -1508,15 +1459,16 @@ async def test_transport_closed_drains_token_usage_for_dead_threads( input_tokens=100, total_tokens=150 ) - await adapter.on_message( - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=False, - room_id="room-1", - ) + with pytest.raises(TurnResultAlreadyReported): + await adapter.on_message( + make_platform_message(), + tools, + CodexSessionState(), + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=False, + room_id="room-1", + ) # Dead thread's usage entry must be gone even without a matching # on_cleanup (the room id can no longer look up the thread id). @@ -1524,7 +1476,9 @@ async def test_transport_closed_drains_token_usage_for_dead_threads( @pytest.mark.asyncio async def test_turn_timeout_sends_interrupt_and_clean_error(self) -> None: - """When recv_event times out, the adapter sends turn/interrupt and reports cleanly.""" + """When recv_event times out, the adapter sends turn/interrupt, reports + the failure, and fails the turn so the platform retries -- same as + every sibling adapter's own turn-timeout handling.""" # No events means FakeCodexClient raises asyncio.TimeoutError immediately. fake_client = FakeCodexClient(events=[]) adapter = CodexAdapter( @@ -1534,15 +1488,16 @@ async def test_turn_timeout_sends_interrupt_and_clean_error(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Codex Agent", "A coding agent") - await adapter.on_message( - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) + with pytest.raises(TurnResultAlreadyReported): + await adapter.on_message( + make_platform_message(), + tools, + CodexSessionState(), + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-1", + ) # Adapter should have sent turn/interrupt with both identifiers. interrupt_requests = [ @@ -1552,11 +1507,10 @@ async def test_turn_timeout_sends_interrupt_and_clean_error(self) -> None: ("turn/interrupt", {"threadId": "thr-1", "turnId": "turn-1"}) ] - # Adapter should send a user-facing message about stopping. - assert any("stopped" in msg["content"].lower() for msg in tools.messages_sent) + # The turn fails the platform's turn, so no separate "I stopped..." + # chat reply goes out alongside the structured failure event. + assert not tools.messages_sent - # A timed-out turn is a reportable Codex failure, same as every - # sibling adapter's own turn-timeout handling. failures = reported_failures(tools) assert len(failures) == 1 assert failures[0]["provider"] == "codex" @@ -3447,15 +3401,16 @@ async def test_codex_error_emits_event_unconditionally(self) -> None: await adapter.on_started("Agent", "An agent") msg = make_platform_message(room_id="room-1", content="do something") - await adapter.on_message( - msg, - tools, - CodexSessionState(), - None, - None, - is_session_bootstrap=False, - room_id="room-1", - ) + with pytest.raises(TurnResultAlreadyReported): + await adapter.on_message( + msg, + tools, + CodexSessionState(), + None, + None, + is_session_bootstrap=False, + room_id="room-1", + ) error_events = events_of_type(tools, "error") assert len(error_events) == 1 @@ -3641,15 +3596,16 @@ async def test_structured_error_from_failed_turn(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Agent", "A coding agent") - await adapter.on_message( - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) + with pytest.raises(TurnResultAlreadyReported): + await adapter.on_message( + make_platform_message(), + tools, + CodexSessionState(), + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-1", + ) failures = reported_failures(tools) assert len(failures) == 1 @@ -5749,6 +5705,7 @@ async def test_approve_session_refused_for_fileChange_without_paths(self) -> Non "item/fileChange/requestApproval", {"reason": "write something"}, ), + _turn_completed(), ] fake_client = FakeCodexClient(events=events) adapter = CodexAdapter( @@ -6028,7 +5985,7 @@ async def send_message( await adapter.on_started("Agent", "A coding agent") with ( - caplog.at_level(logging.ERROR, logger="band.adapters.codex"), + caplog.at_level(logging.ERROR, logger="band.core.delivery"), pytest.raises(RuntimeError, match="platform rejected the message"), ): await adapter.on_message( @@ -6044,7 +6001,7 @@ async def send_message( assert not tools.messages_sent assert not reported_failures(tools) assert any( - "Codex reply delivery failed" in record.message for record in caplog.records + "Reply delivery failed" in record.message for record in caplog.records ) @pytest.mark.asyncio @@ -6070,7 +6027,7 @@ async def send_message( await adapter.on_started("Agent", "A coding agent") with ( - caplog.at_level(logging.ERROR, logger="band.adapters.codex"), + caplog.at_level(logging.ERROR, logger="band.core.delivery"), pytest.raises(RuntimeError, match="platform rejected the message"), ): await adapter.on_message( @@ -6086,7 +6043,7 @@ async def send_message( assert not tools.messages_sent assert not reported_failures(tools) assert any( - "Codex reply delivery failed" in record.message for record in caplog.records + "Reply delivery failed" in record.message for record in caplog.records ) @@ -6148,15 +6105,16 @@ async def test_failed_turn_after_error_notification_reports_once(self) -> None: tools = ToolSchemaFakeTools() await adapter.on_started("Agent", "A coding agent") - await adapter.on_message( - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) + with pytest.raises(TurnResultAlreadyReported): + await adapter.on_message( + make_platform_message(), + tools, + CodexSessionState(), + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-1", + ) assert len(reported_failures(tools)) == 1 @@ -6187,15 +6145,16 @@ async def test_failed_turn_with_falsy_scalar_error_uses_clean_fallback( tools = ToolSchemaFakeTools() await adapter.on_started("Agent", "A coding agent") - await adapter.on_message( - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) + with pytest.raises(TurnResultAlreadyReported): + await adapter.on_message( + make_platform_message(), + tools, + CodexSessionState(), + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-1", + ) failures = reported_failures(tools) assert len(failures) == 1 diff --git a/tests/adapters/test_crewai_adapter.py b/tests/adapters/test_crewai_adapter.py index 166e8a843..09278acab 100644 --- a/tests/adapters/test_crewai_adapter.py +++ b/tests/adapters/test_crewai_adapter.py @@ -19,11 +19,15 @@ import json from datetime import datetime, timezone from typing import TYPE_CHECKING, Any -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import DEFAULT, AsyncMock, MagicMock import pytest from pydantic import BaseModel, Field +from band.core.protocols import ( + GENERIC_PROVIDER_FAILURE_MESSAGE, + TurnResultAlreadyReported, +) from band.core.types import Capability, Emit, PlatformMessage from band.runtime.prompts import render_system_prompt @@ -171,6 +175,24 @@ def mock_crewai_agent(): return mock_agent +@pytest.fixture +def mock_crewai_agent_replied(mock_crewai_agent): + """``mock_crewai_agent`` whose ``kickoff_async`` also marks the turn as + replied, for tests that only care about kickoff/history/message + plumbing rather than the reply-tracking behavior itself (which has its + own dedicated tests under ``TestErrorHandling``).""" + module = importlib.import_module("band.adapters.crewai") + + def _mark_replied(*args, **kwargs): + tracker = module._reply_tracker_var.get() + if tracker is not None: + tracker.replied = True + return DEFAULT + + mock_crewai_agent.kickoff_async.side_effect = _mark_replied + return mock_crewai_agent + + @pytest.fixture def room_context(crewai_mocks, mock_tools): """Context manager fixture for setting up room context in tests. @@ -304,11 +326,11 @@ async def test_includes_platform_instructions_in_backstory( class TestOnMessage: @pytest.mark.asyncio async def test_initializes_history_on_bootstrap( - self, CrewAIAdapter, sample_message, mock_tools, mock_crewai_agent + self, CrewAIAdapter, sample_message, mock_tools, mock_crewai_agent_replied ): adapter = CrewAIAdapter() await adapter.on_started("TestBot", "Test bot") - adapter._crewai_agent = mock_crewai_agent + adapter._crewai_agent = mock_crewai_agent_replied await adapter.on_message( msg=sample_message, @@ -324,11 +346,11 @@ async def test_initializes_history_on_bootstrap( @pytest.mark.asyncio async def test_loads_existing_history( - self, CrewAIAdapter, sample_message, mock_tools, mock_crewai_agent + self, CrewAIAdapter, sample_message, mock_tools, mock_crewai_agent_replied ): adapter = CrewAIAdapter() await adapter.on_started("TestBot", "Test bot") - adapter._crewai_agent = mock_crewai_agent + adapter._crewai_agent = mock_crewai_agent_replied existing_history = [ {"role": "user", "content": "[Bob]: Previous message"}, @@ -349,11 +371,11 @@ async def test_loads_existing_history( @pytest.mark.asyncio async def test_calls_kickoff_async( - self, CrewAIAdapter, sample_message, mock_tools, mock_crewai_agent + self, CrewAIAdapter, sample_message, mock_tools, mock_crewai_agent_replied ): adapter = CrewAIAdapter() await adapter.on_started("TestBot", "Test bot") - adapter._crewai_agent = mock_crewai_agent + adapter._crewai_agent = mock_crewai_agent_replied await adapter.on_message( msg=sample_message, @@ -365,11 +387,11 @@ async def test_calls_kickoff_async( room_id="room-123", ) - mock_crewai_agent.kickoff_async.assert_called_once() + mock_crewai_agent_replied.kickoff_async.assert_called_once() @pytest.mark.asyncio async def test_replays_history_on_followup_turn( - self, CrewAIAdapter, sample_message, mock_tools, mock_crewai_agent + self, CrewAIAdapter, sample_message, mock_tools, mock_crewai_agent_replied ): """A non-bootstrap turn must replay accumulated in-session history. @@ -381,7 +403,7 @@ async def test_replays_history_on_followup_turn( """ adapter = CrewAIAdapter() await adapter.on_started("TestBot", "Test bot") - adapter._crewai_agent = mock_crewai_agent + adapter._crewai_agent = mock_crewai_agent_replied # Turn 1 (bootstrap): states something the agent must recall later. await adapter.on_message( @@ -418,7 +440,9 @@ async def test_replays_history_on_followup_turn( # The second kickoff must carry the prior turn as replayed context, not # just the current message. - second_call_messages = mock_crewai_agent.kickoff_async.call_args_list[1][0][0] + second_call_messages = mock_crewai_agent_replied.kickoff_async.call_args_list[ + 1 + ][0][0] blob = "\n".join(m["content"] for m in second_call_messages) assert "[Previous conversation:]" in blob assert "Hello, agent!" in blob # turn-1 content replayed to the model @@ -463,7 +487,7 @@ async def test_reports_error_on_kickoff_failure( mock_tools.send_failure.assert_awaited_once() failure = mock_tools.send_failure.call_args.args[0] assert failure.provider == "crewai" - assert failure.message == "Agent Error" + assert failure.message == GENERIC_PROVIDER_FAILURE_MESSAGE @pytest.mark.asyncio async def test_reports_error_when_crewai_completes_without_reply( @@ -478,15 +502,16 @@ async def test_reports_error_when_crewai_completes_without_reply( await adapter.on_started("TestBot", "Test bot") adapter._crewai_agent = mock_crewai_agent - await adapter.on_message( - msg=sample_message, - tools=mock_tools, - history=[], - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-123", - ) + with pytest.raises(TurnResultAlreadyReported): + await adapter.on_message( + msg=sample_message, + tools=mock_tools, + history=[], + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-123", + ) mock_tools.send_failure.assert_awaited_once() failure = mock_tools.send_failure.call_args.args[0] @@ -505,15 +530,16 @@ async def test_reports_error_when_crewai_returns_none_without_reply( await adapter.on_started("TestBot", "Test bot") adapter._crewai_agent = mock_crewai_agent - await adapter.on_message( - msg=sample_message, - tools=mock_tools, - history=[], - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-123", - ) + with pytest.raises(TurnResultAlreadyReported): + await adapter.on_message( + msg=sample_message, + tools=mock_tools, + history=[], + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-123", + ) mock_tools.send_failure.assert_awaited_once() failure = mock_tools.send_failure.call_args.args[0] @@ -692,7 +718,7 @@ async def _kickoff(_messages): mock_tools.send_failure.assert_awaited_once() failure = mock_tools.send_failure.call_args.args[0] assert failure.provider == "crewai" - assert failure.message == str(error) + assert failure.message == GENERIC_PROVIDER_FAILURE_MESSAGE @pytest.mark.asyncio async def test_raises_error_when_agent_not_initialized( @@ -794,11 +820,11 @@ def test_allow_delegation_stored_on_adapter(self, CrewAIAdapter): class TestParticipantsUpdate: @pytest.mark.asyncio async def test_includes_participants_update_in_message( - self, CrewAIAdapter, sample_message, mock_tools, mock_crewai_agent + self, CrewAIAdapter, sample_message, mock_tools, mock_crewai_agent_replied ): adapter = CrewAIAdapter() await adapter.on_started("TestBot", "Test bot") - adapter._crewai_agent = mock_crewai_agent + adapter._crewai_agent = mock_crewai_agent_replied await adapter.on_message( msg=sample_message, @@ -810,7 +836,7 @@ async def test_includes_participants_update_in_message( room_id="room-123", ) - call_args = mock_crewai_agent.kickoff_async.call_args + call_args = mock_crewai_agent_replied.kickoff_async.call_args messages = call_args[0][0] found = any("Alice joined" in str(m.get("content", "")) for m in messages) @@ -820,11 +846,11 @@ async def test_includes_participants_update_in_message( class TestContactsUpdate: @pytest.mark.asyncio async def test_includes_contacts_update_in_message( - self, CrewAIAdapter, sample_message, mock_tools, mock_crewai_agent + self, CrewAIAdapter, sample_message, mock_tools, mock_crewai_agent_replied ): adapter = CrewAIAdapter() await adapter.on_started("TestBot", "Test bot") - adapter._crewai_agent = mock_crewai_agent + adapter._crewai_agent = mock_crewai_agent_replied await adapter.on_message( msg=sample_message, @@ -836,7 +862,7 @@ async def test_includes_contacts_update_in_message( room_id="room-123", ) - call_args = mock_crewai_agent.kickoff_async.call_args + call_args = mock_crewai_agent_replied.kickoff_async.call_args messages = call_args[0][0] found = any( diff --git a/tests/adapters/test_crewai_adapter_soak.py b/tests/adapters/test_crewai_adapter_soak.py index 41343e06a..0a5c54cc9 100644 --- a/tests/adapters/test_crewai_adapter_soak.py +++ b/tests/adapters/test_crewai_adapter_soak.py @@ -1,13 +1,18 @@ """Soak test: 100 sequential on_message calls across 3 mocked rooms. Asserts: -- No exceptions across the run - nest_asyncio.apply is invoked at most once (lazy patch idempotency) - Per-room state in `_message_history` does not leak between rooms + +The mocked ``crewai_agent`` never drives a real ``band_send_message`` tool +call, so every turn is a genuine "missing reply" turn and raises +``TurnResultAlreadyReported`` (expected, not a soak failure) — history +bookkeeping still runs before that raise, which is what this test checks. """ from __future__ import annotations +import contextlib import importlib import sys from datetime import datetime, timezone @@ -15,6 +20,7 @@ import pytest +from band.core.protocols import TurnResultAlreadyReported from band.core.types import PlatformMessage @@ -85,15 +91,16 @@ async def test_soak_100_turns_3_rooms(crewai_mocks): for i in range(100): room_id = rooms[i % 3] msg = _make_msg(i, room_id) - await adapter.on_message( - msg=msg, - tools=tools_per_room[room_id], - history=[], - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=(i < 3), - room_id=room_id, - ) + with contextlib.suppress(TurnResultAlreadyReported): + await adapter.on_message( + msg=msg, + tools=tools_per_room[room_id], + history=[], + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=(i < 3), + room_id=room_id, + ) # Per-room state present, no cross-room leakage: each room has only its own # message ids in its history. diff --git a/tests/adapters/test_gemini_adapter.py b/tests/adapters/test_gemini_adapter.py index e6118edee..80b3b789c 100644 --- a/tests/adapters/test_gemini_adapter.py +++ b/tests/adapters/test_gemini_adapter.py @@ -13,6 +13,7 @@ from pydantic import BaseModel, Field, ValidationError from band.adapters.gemini import GeminiAdapter +from band.core.protocols import GENERIC_PROVIDER_FAILURE_MESSAGE from band.core.types import Emit, PlatformMessage, ToolEventKey @@ -224,7 +225,7 @@ async def test_reports_generic_failure(self, sample_message, mock_tools): mock_tools.send_failure.assert_called_once() failure = mock_tools.send_failure.call_args.args[0] assert failure.provider == "gemini" - assert failure.message == "boom" + assert failure.message == GENERIC_PROVIDER_FAILURE_MESSAGE assert failure.code is None assert failure.detail is None diff --git a/tests/adapters/test_google_adk_adapter.py b/tests/adapters/test_google_adk_adapter.py index 8d3b02781..3e30fd43c 100644 --- a/tests/adapters/test_google_adk_adapter.py +++ b/tests/adapters/test_google_adk_adapter.py @@ -17,6 +17,7 @@ import pytest from pydantic import BaseModel, Field +from band.core.protocols import GENERIC_PROVIDER_FAILURE_MESSAGE from band.core.types import ALL_CAPABILITIES, Capability, Emit, PlatformMessage from band.runtime.tools import AgentTools, BandTool @@ -961,7 +962,7 @@ async def failing_run(**kwargs): mock_tools.send_failure.assert_called_once() failure = mock_tools.send_failure.call_args.args[0] assert failure.provider == "google_adk" - assert failure.message == "Runner Error" + assert failure.message == GENERIC_PROVIDER_FAILURE_MESSAGE # Runner should be closed even on error (via finally) mock_runner.close.assert_called_once() @@ -991,7 +992,7 @@ async def test_reports_error_when_runner_construction_itself_fails( mock_tools.send_failure.assert_called_once() failure = mock_tools.send_failure.call_args.args[0] assert failure.provider == "google_adk" - assert failure.message == "bad tool schema" + assert failure.message == GENERIC_PROVIDER_FAILURE_MESSAGE class TestHistoryTranscript: diff --git a/tests/adapters/test_letta_adapter.py b/tests/adapters/test_letta_adapter.py index e1ce652dc..0f55315ae 100644 --- a/tests/adapters/test_letta_adapter.py +++ b/tests/adapters/test_letta_adapter.py @@ -21,6 +21,7 @@ RoomContext, ) from band.converters.letta import LettaSessionState +from band.core.protocols import TurnResultAlreadyReported from band.core.types import Emit from band.testing import FakeAgentTools, reported_failures from tests.adapters.lettakit import ( @@ -1274,15 +1275,16 @@ async def test_disabled_relay_fails_loud_instead_of_sending(self) -> None: ) tools = FakeAgentTools() - await adapter.on_message( - make_platform_message(), - tools, - LettaSessionState(), - None, - None, - is_session_bootstrap=False, - room_id="room-1", - ) + with pytest.raises(TurnResultAlreadyReported): + await adapter.on_message( + make_platform_message(), + tools, + LettaSessionState(), + None, + None, + is_session_bootstrap=False, + room_id="room-1", + ) assert len(tools.messages_sent) == 0 error_events = [e for e in tools.events_sent if e["message_type"] == "error"] diff --git a/tests/adapters/test_parlant_adapter.py b/tests/adapters/test_parlant_adapter.py index ff480d97f..cb5c90d4d 100644 --- a/tests/adapters/test_parlant_adapter.py +++ b/tests/adapters/test_parlant_adapter.py @@ -15,6 +15,7 @@ import pytest from band.adapters.parlant import PARLANT_PREAMBLE_TAG, ParlantAdapter +from band.core.protocols import GENERIC_PROVIDER_FAILURE_MESSAGE from band.core.types import PlatformMessage @@ -797,7 +798,7 @@ async def test_reports_error_on_failure( mock_tools.send_failure.assert_awaited_once() failure = mock_tools.send_failure.call_args.args[0] assert failure.provider == "parlant" - assert failure.message == "API error" + assert failure.message == GENERIC_PROVIDER_FAILURE_MESSAGE @pytest.mark.asyncio async def test_reports_error_on_session_init_failure( @@ -829,7 +830,7 @@ async def test_reports_error_on_session_init_failure( mock_tools.send_failure.assert_awaited_once() failure = mock_tools.send_failure.call_args.args[0] assert failure.provider == "parlant" - assert "Session initialization failed" in failure.message + assert failure.message == GENERIC_PROVIDER_FAILURE_MESSAGE @pytest.mark.asyncio async def test_clears_tools_on_error( diff --git a/tests/adapters/test_pydantic_ai_adapter.py b/tests/adapters/test_pydantic_ai_adapter.py index fa8e58a09..633947b13 100644 --- a/tests/adapters/test_pydantic_ai_adapter.py +++ b/tests/adapters/test_pydantic_ai_adapter.py @@ -55,7 +55,11 @@ _is_output_retries_exhausted, _is_replayable_history_message, ) -from band.core.protocols import AgentToolsProtocol +from band.core.protocols import ( + GENERIC_PROVIDER_FAILURE_MESSAGE, + AgentToolsProtocol, + TurnResultAlreadyReported, +) from band.core.types import Capability, Emit, PlatformMessage, TurnUsage from band.runtime.custom_tools import get_custom_tool_name from tests.adapters.usage_events import sent_usage_payloads @@ -864,7 +868,10 @@ async def test_initializes_history_on_bootstrap( result_messages = [ModelRequest(parts=[UserPromptPart(content="test")])] adapter._agent.run_stream_events = MagicMock( - return_value=make_stream_events(result_messages=result_messages) + return_value=make_stream_events( + result_messages=result_messages, + tool_results=[("band_send_message", "Message sent", "call-1")], + ) ) await adapter.on_message( @@ -898,7 +905,10 @@ async def test_loads_existing_history( ModelRequest(parts=[UserPromptPart(content="new")]) ] adapter._agent.run_stream_events = MagicMock( - return_value=make_stream_events(result_messages=result_messages) + return_value=make_stream_events( + result_messages=result_messages, + tool_results=[("band_send_message", "Message sent", "call-1")], + ) ) await adapter.on_message( @@ -927,7 +937,10 @@ async def test_injects_participants_message( await adapter.on_started("TestBot", "Test bot") adapter._agent.run_stream_events = MagicMock( - return_value=make_stream_events(result_messages=[]) + return_value=make_stream_events( + result_messages=[], + tool_results=[("band_send_message", "Message sent", "call-1")], + ) ) await adapter.on_message( @@ -964,7 +977,10 @@ async def test_creates_agent_lazily_if_not_started( with patch.object(adapter, "_create_agent") as mock_create: mock_agent = MagicMock() mock_agent.run_stream_events = MagicMock( - return_value=make_stream_events(result_messages=[]) + return_value=make_stream_events( + result_messages=[], + tool_results=[("band_send_message", "Message sent", "call-1")], + ) ) mock_create.return_value = mock_agent @@ -995,15 +1011,16 @@ async def test_reports_failure_when_no_terminal_tool_ran( return_value=make_stream_events(result_messages=[]) ) - await adapter.on_message( - msg=sample_message, - tools=mock_tools, - history=[], - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-123", - ) + with pytest.raises(TurnResultAlreadyReported): + await adapter.on_message( + msg=sample_message, + tools=mock_tools, + history=[], + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-123", + ) mock_tools.send_failure.assert_awaited_once() failure = mock_tools.send_failure.call_args.args[0] @@ -1049,7 +1066,10 @@ async def test_updates_history_after_run( ] adapter._agent.run_stream_events = MagicMock( - return_value=make_stream_events(result_messages=new_messages) + return_value=make_stream_events( + result_messages=new_messages, + tool_results=[("band_send_message", "Message sent", "call-1")], + ) ) await adapter.on_message( @@ -1103,7 +1123,10 @@ async def test_keeps_native_history_and_drops_content_null_responses( text_response, ] adapter._agent.run_stream_events = MagicMock( - return_value=make_stream_events(result_messages=result_messages) + return_value=make_stream_events( + result_messages=result_messages, + tool_results=[("band_send_message", {"id": "msg_1"}, "call_1")], + ) ) await adapter.on_message( @@ -1195,7 +1218,10 @@ async def test_ensures_history_exists_for_non_bootstrap( await adapter.on_started("TestBot", "Test bot") adapter._agent.run_stream_events = MagicMock( - return_value=make_stream_events(result_messages=[]) + return_value=make_stream_events( + result_messages=[], + tool_results=[("band_send_message", "Message sent", "call-1")], + ) ) await adapter.on_message( @@ -1232,6 +1258,7 @@ async def test_emits_tool_call_events_when_enabled( return_value=make_stream_events( result_messages=[], tool_calls=[("band_send_message", {"content": "Hello"}, "call-123")], + tool_results=[("band_send_message", "Message sent", "call-123")], ) ) @@ -1276,6 +1303,7 @@ async def test_tool_call_event_redacts_send_room_file_content( "call-123", ) ], + tool_results=[("band_send_message", "Message sent", "call-2")], ) ) @@ -1353,7 +1381,10 @@ async def test_tool_result_event_redacts_binary_content( adapter._agent.run_stream_events = MagicMock( return_value=make_stream_events( result_messages=[], - tool_results=[("band_read_room_file", [image], "call-1")], + tool_results=[ + ("band_read_room_file", [image], "call-1"), + ("band_send_message", "Message sent", "call-2"), + ], ) ) @@ -1473,6 +1504,7 @@ async def test_event_failure_does_not_crash_run( return_value=make_stream_events( result_messages=[ModelRequest(parts=[UserPromptPart(content="test")])], tool_calls=[("band_send_message", {"content": "Hello"}, "call-123")], + tool_results=[("band_send_message", "Message sent", "call-123")], ) ) @@ -1782,7 +1814,7 @@ class of error had no except clause at all and vanished uncaught.""" mock_tools.send_failure.assert_awaited_once() failure = mock_tools.send_failure.call_args.args[0] assert failure.provider == "pydantic_ai" - assert failure.message == "provider connection reset" + assert failure.message == GENERIC_PROVIDER_FAILURE_MESSAGE @pytest.mark.asyncio async def test_empty_output_after_read_only_tool_propagates( @@ -1968,7 +2000,10 @@ async def my_helper(ctx: RunContext[AgentToolsProtocol], value: str) -> str: result_messages = [ModelRequest(parts=[UserPromptPart(content="test")])] adapter._agent.run_stream_events = MagicMock( - return_value=make_stream_events(result_messages=result_messages) + return_value=make_stream_events( + result_messages=result_messages, + tool_results=[("band_send_message", "Message sent", "call-1")], + ) ) # Should not raise diff --git a/tests/adapters/test_strands_adapter.py b/tests/adapters/test_strands_adapter.py index 5af449e7a..e25f40ab4 100644 --- a/tests/adapters/test_strands_adapter.py +++ b/tests/adapters/test_strands_adapter.py @@ -35,7 +35,11 @@ _tool_result, ) from band.converters.strands import StrandsHistoryConverter # noqa: E402 -from band.core.protocols import AgentToolsProtocol # noqa: E402 +from band.core.protocols import ( # noqa: E402 + GENERIC_PROVIDER_FAILURE_MESSAGE, + AgentToolsProtocol, + TurnResultAlreadyReported, +) from band.core.types import ( # noqa: E402 USAGE_METADATA_KEY, AgentInput, @@ -447,11 +451,16 @@ async def test_later_turns_keep_the_transcript_the_adapter_owns( A later turn that reseeded would replay the room's own transcript on top of the one the adapter is already holding. """ - adapter = await scripted(SEND_TURN, SEND_TURN) + adapter = await scripted(SEND_TURN) await _run_message(adapter, tools, history=[]) after_first = list(adapter._message_history[ROOM]) - await _run_message(adapter, tools, history=[], is_session_bootstrap=False) + # The scripted model has no turn left for a second reply, so this + # turn ends without calling band_send_message -- irrelevant to what + # this test checks (the transcript isn't re-seeded), so only the + # failure is asserted here, not suppressed. + with pytest.raises(TurnResultAlreadyReported): + await _run_message(adapter, tools, history=[], is_session_bootstrap=False) assert adapter._message_history[ROOM][: len(after_first)] == after_first @@ -526,7 +535,8 @@ async def send_message(self, content, mentions=None): tools = FailingTools(room_id=ROOM) adapter = await scripted(SEND_TURN) - await _run_message(adapter, tools) + with pytest.raises(TurnResultAlreadyReported): + await _run_message(adapter, tools) assert tools.messages_sent == [] assert len(_errors(tools)) == 1 @@ -548,7 +558,8 @@ async def send_message(self, content, mentions=None): tools = FailingTools(room_id=ROOM) adapter = await scripted(SEND_TURN, emit=Emit.TOOL_CALLS) - await _run_message(adapter, tools) + with pytest.raises(TurnResultAlreadyReported): + await _run_message(adapter, tools) rehydrated = StrandsHistoryConverter(agent_name="Bot").convert( [ @@ -570,7 +581,8 @@ async def test_read_only_tool_alone_does_not_end_the_turn(self, tools, scripted) """Looking peers up succeeds but posts nothing, so the reply is still missing.""" adapter = await scripted(ToolTurn("band_lookup_peers", {})) - await _run_message(adapter, tools) + with pytest.raises(TurnResultAlreadyReported): + await _run_message(adapter, tools) assert _tool_results(adapter) # the lookup did run and succeed assert tools.messages_sent == [] @@ -583,7 +595,8 @@ async def test_invalid_tool_arguments_are_answered_not_raised( """A malformed call is the model's mistake to correct, not a turn-ending crash.""" adapter = await scripted(ToolTurn("band_send_message", {"mentions": ["@x"]})) - await _run_message(adapter, tools) + with pytest.raises(TurnResultAlreadyReported): + await _run_message(adapter, tools) assert tools.messages_sent == [] assert _tool_results(adapter) == [ @@ -604,7 +617,8 @@ async def boom(args: BoomInput) -> str: ToolTurn("boom", {"note": "go"}), additional_tools=[(BoomInput, boom)] ) - await _run_message(adapter, tools) + with pytest.raises(TurnResultAlreadyReported): + await _run_message(adapter, tools) assert _tool_results(adapter) == ["Error executing tool 'boom': no network"] @@ -635,7 +649,7 @@ async def test_provider_failure_keeps_the_transcript_and_reports_usage( assert usage[0]["metadata"][USAGE_METADATA_KEY]["input_tokens"] == ( _INPUT_TOKENS_PER_CALL ) - assert "provider down" in _errors(tools)[0] + assert GENERIC_PROVIDER_FAILURE_MESSAGE in _errors(tools)[0] class TestUsageMapping: @@ -780,7 +794,8 @@ async def test_tool_call_event_redacts_content_not_raw_bytes(self, tools): ) await adapter.on_started("Bot", "A bot") - await _run_message(adapter, tools) + with pytest.raises(TurnResultAlreadyReported): + await _run_message(adapter, tools) tool_calls = [ json.loads(e["content"]) diff --git a/tests/framework_conformance/test_strands_injection_spike.py b/tests/framework_conformance/test_strands_injection_spike.py index 9ef8ed54a..1013464e0 100644 --- a/tests/framework_conformance/test_strands_injection_spike.py +++ b/tests/framework_conformance/test_strands_injection_spike.py @@ -66,7 +66,10 @@ pytest.importorskip("strands", reason="strands extra not installed") from band.adapters.strands import StrandsAdapter # noqa: E402 -from band.core.protocols import AgentToolsProtocol # noqa: E402 +from band.core.protocols import ( # noqa: E402 + AgentToolsProtocol, + TurnResultAlreadyReported, +) from band.core.types import Emit, PlatformMessage # noqa: E402 from band.testing import ( # noqa: E402 FakeAgentTools, @@ -205,7 +208,8 @@ async def test_negative_control_text_only_sends_no_message() -> None: adapter = StrandsAdapter( model=ScriptedStrandsModel([TextTurn("just a reply, no tools")]) ) - await _run(adapter, tools, room_id) + with pytest.raises(TurnResultAlreadyReported): + await _run(adapter, tools, room_id) assert tools.messages_sent == [], ( f"expected no send for a text-only decision, got: {tools.messages_sent}" diff --git a/tests/integrations/a2a/test_adapter.py b/tests/integrations/a2a/test_adapter.py index 8531c0b3a..8be96c03b 100644 --- a/tests/integrations/a2a/test_adapter.py +++ b/tests/integrations/a2a/test_adapter.py @@ -23,6 +23,10 @@ ) from band.core.delivery import DeliveryFailedError +from band.core.protocols import ( + GENERIC_PROVIDER_FAILURE_MESSAGE, + TurnResultAlreadyReported, +) from band.core.types import PlatformMessage from band.integrations.a2a import A2AAdapter, A2AAuth, A2ASessionState from band.integrations.a2a.adapter import _SSE_READ_TIMEOUT_S @@ -338,18 +342,19 @@ async def test_auth_required_task_is_posted_as_error_event( ) -> None: tools = FakeAgentTools() - await adapter._handle_event( - task_event( - make_task( - TaskState.TASK_STATE_AUTH_REQUIRED, - status_message="Please authenticate", - ) - ), - tools, - "room-123", - "user-456", - "Test User", - ) + with pytest.raises(TurnResultAlreadyReported): + await adapter._handle_event( + task_event( + make_task( + TaskState.TASK_STATE_AUTH_REQUIRED, + status_message="Please authenticate", + ) + ), + tools, + "room-123", + "user-456", + "Test User", + ) error_events = [ event for event in tools.events_sent if event["message_type"] == "error" @@ -407,7 +412,7 @@ async def test_remote_error_is_posted_as_error_event( ) assert tools.events_sent[-1]["message_type"] == "error" - assert "remote down" in tools.events_sent[-1]["content"] + assert tools.events_sent[-1]["content"] == GENERIC_PROVIDER_FAILURE_MESSAGE @pytest.mark.asyncio async def test_on_message_reraises_delivery_failure_without_reporting_it( @@ -443,13 +448,16 @@ async def test_failed_task_is_posted_as_error_event( ) -> None: tools = FakeAgentTools() - await adapter._handle_event( - task_event(make_task(TaskState.TASK_STATE_FAILED, status_message="boom")), - tools, - "room-123", - "user-456", - "Test User", - ) + with pytest.raises(TurnResultAlreadyReported): + await adapter._handle_event( + task_event( + make_task(TaskState.TASK_STATE_FAILED, status_message="boom") + ), + tools, + "room-123", + "user-456", + "Test User", + ) error_events = [ event for event in tools.events_sent if event["message_type"] == "error" diff --git a/tests/integrations/acp/acp_toolkit/harness.py b/tests/integrations/acp/acp_toolkit/harness.py index 1b914eda0..c727aa8bc 100644 --- a/tests/integrations/acp/acp_toolkit/harness.py +++ b/tests/integrations/acp/acp_toolkit/harness.py @@ -214,6 +214,23 @@ class AcpSession: def __init__(self, adapter: ACPClientAdapter, agent: FakeACPAgent) -> None: self.adapter = adapter self.agent = agent + self._last_tools: TranscriptTools | None = None + + @property + def last_reply(self) -> Reply: + """What the most recent ``send`` posted, even if it raised. + + A genuine provider failure now fails the turn (raises out of + ``on_message``) instead of returning normally, so a test covering + that path can't get the posted error event from ``send``'s return + value — it reads this instead. + """ + assert self._last_tools is not None, "send() has not been called yet" + return Reply( + messages=self._last_tools.messages_sent, + events=self._last_tools.events_sent, + transcript=self._last_tools.transcript, + ) async def send( self, @@ -238,6 +255,7 @@ async def send( tools = TranscriptTools() if room_context is not None: tools.set_room_context(room_context) + self._last_tools = tools await self.adapter.on_message( _message(content, room), tools, diff --git a/tests/integrations/acp/test_client_adapter.py b/tests/integrations/acp/test_client_adapter.py index 3375d09cb..4c4e4029e 100644 --- a/tests/integrations/acp/test_client_adapter.py +++ b/tests/integrations/acp/test_client_adapter.py @@ -11,6 +11,7 @@ from acp.helpers import update_agent_message_text from band.converters.parsing import parse_tool_call, parse_tool_result +from band.core.protocols import GENERIC_PROVIDER_FAILURE_MESSAGE from band.core.types import Capability from band.integrations.acp.client_adapter import ACPClientAdapter, _resolve_launcher from band.integrations.acp.client_profiles import CursorACPClientProfile @@ -749,20 +750,21 @@ async def test_on_message_error_sends_error_event( tools = FakeAgentTools() msg = make_platform_message("Hello", room_id="room-123") - await adapter_with_mocks.on_message( - msg, - tools, - ACPClientSessionState(), - None, - None, - is_session_bootstrap=False, - room_id="room-123", - ) + with pytest.raises(RuntimeError, match="Agent crashed"): + await adapter_with_mocks.on_message( + msg, + tools, + ACPClientSessionState(), + None, + None, + is_session_bootstrap=False, + room_id="room-123", + ) failures = reported_failures(tools) assert len(failures) == 1 assert failures[0]["provider"] == "acp" - assert "Agent crashed" in failures[0]["message"] + assert failures[0]["message"] == GENERIC_PROVIDER_FAILURE_MESSAGE @pytest.mark.asyncio async def test_on_message_request_error_captures_code_and_data( @@ -776,15 +778,16 @@ async def test_on_message_request_error_captures_code_and_data( tools = FakeAgentTools() msg = make_platform_message("Hello", room_id="room-123") - await adapter_with_mocks.on_message( - msg, - tools, - ACPClientSessionState(), - None, - None, - is_session_bootstrap=False, - room_id="room-123", - ) + with pytest.raises(RequestError): + await adapter_with_mocks.on_message( + msg, + tools, + ACPClientSessionState(), + None, + None, + is_session_bootstrap=False, + room_id="room-123", + ) failures = reported_failures(tools) assert len(failures) == 1 @@ -1336,15 +1339,16 @@ async def test_prompt_error_clears_connection(self) -> None: tools = FakeAgentTools() msg = make_platform_message("Hello", room_id="room-1") - await adapter.on_message( - msg, - tools, - ACPClientSessionState(), - None, - None, - is_session_bootstrap=False, - room_id="room-1", - ) + with pytest.raises(RuntimeError, match="Process died"): + await adapter.on_message( + msg, + tools, + ACPClientSessionState(), + None, + None, + is_session_bootstrap=False, + room_id="room-1", + ) # Connection should be cleared after error assert adapter._runtime._conn is None @@ -1386,15 +1390,16 @@ async def _raise(*args: object, **kwargs: object) -> None: msg = make_platform_message("Hello", room_id="room-1") - await adapter.on_message( - msg, - tools, - ACPClientSessionState(), - None, - None, - is_session_bootstrap=False, - room_id="room-1", - ) + with pytest.raises(RuntimeError, match="platform rejected the message"): + await adapter.on_message( + msg, + tools, + ACPClientSessionState(), + None, + None, + is_session_bootstrap=False, + room_id="room-1", + ) assert adapter._runtime._conn is not None assert adapter._runtime._ctx is not None @@ -1426,15 +1431,16 @@ async def hang(**kwargs: object) -> None: tools = FakeAgentTools() msg = make_platform_message("Hello", room_id="room-1") - await adapter.on_message( - msg, - tools, - ACPClientSessionState(), - None, - None, - is_session_bootstrap=False, - room_id="room-1", - ) + with pytest.raises(TimeoutError): + await adapter.on_message( + msg, + tools, + ACPClientSessionState(), + None, + None, + is_session_bootstrap=False, + room_id="room-1", + ) assert adapter._runtime._conn is None assert adapter._runtime._ctx is None diff --git a/tests/integrations/acp/test_client_adapter_behavior.py b/tests/integrations/acp/test_client_adapter_behavior.py index 5004a71a2..182a59e50 100644 --- a/tests/integrations/acp/test_client_adapter_behavior.py +++ b/tests/integrations/acp/test_client_adapter_behavior.py @@ -813,7 +813,9 @@ async def _script(a: FakeACPAgent, sid: str) -> None: async with acp_adapter(agent) as session: await session.send("My favorite color is blue.", bootstrap=True) - crashed = await session.send("anything") # prompt raises -> adapter stop() + with pytest.raises(RequestError): + await session.send("anything") # prompt raises -> adapter stop() + crashed = session.last_reply reply = await session.send( "What is my favorite color?", room_context=transcript ) From 86990dc3b83d27423ab3864ad3001c98981b7152 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 9 Sep 2026 21:33:19 +0300 Subject: [PATCH 29/53] fix(claude_sdk): stop leaking raw exception text in session-resume failures get_or_create_session's fallback branches reported str(fresh_exc)/ str(resume_exc) straight to the room -- CLI-launch and filesystem errors here can carry local paths, joining the generic-message convention every other failure path in this file already follows. Both branches now log the real exception and report GENERIC_PROVIDER_FAILURE_MESSAGE instead. Also dedupes the "claude_sdk" provider-tag literal (6 call sites) behind one module-level _PROVIDER constant. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011uGMZeouBGz9s3vKCe2uhj --- src/band/adapters/claude_sdk.py | 27 ++++++++++++++++++----- tests/adapters/test_claude_sdk_adapter.py | 14 +++++++----- 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/src/band/adapters/claude_sdk.py b/src/band/adapters/claude_sdk.py index 467238842..5da02bc9a 100644 --- a/src/band/adapters/claude_sdk.py +++ b/src/band/adapters/claude_sdk.py @@ -133,6 +133,9 @@ # same constant instead of a second, driftable number. _CLAUDE_SDK_MAX_BUFFER_BYTES = MAX_INLINE_IMAGE_BYTES * 2 +# AgentFailure.provider tag for every failure this adapter reports. +_PROVIDER = "claude_sdk" + # Approval flow types (mirrors Codex adapter patterns) ApprovalMode = Literal["auto_accept", "auto_decline", "manual"] ApprovalDecision = Literal["accept", "decline"] @@ -621,10 +624,22 @@ async def on_message( room_id, resume_session_id=None ) except Exception as fresh_exc: - await tools.send_failure(AgentFailure("claude_sdk", str(fresh_exc))) + logger.exception( + "Room %s: Fresh session creation also failed: %s", + room_id, + fresh_exc, + ) + await tools.send_failure( + AgentFailure(_PROVIDER, GENERIC_PROVIDER_FAILURE_MESSAGE) + ) raise else: - await tools.send_failure(AgentFailure("claude_sdk", str(resume_exc))) + logger.exception( + "Room %s: Session creation failed: %s", room_id, resume_exc + ) + await tools.send_failure( + AgentFailure(_PROVIDER, GENERIC_PROVIDER_FAILURE_MESSAGE) + ) raise # Add chat_id context (Claude needs this for tool calls) -- the label @@ -711,14 +726,14 @@ async def on_message( await self._invalidate_session(room_id) await tools.send_failure( - AgentFailure("claude_sdk", GENERIC_PROVIDER_FAILURE_MESSAGE) + AgentFailure(_PROVIDER, GENERIC_PROVIDER_FAILURE_MESSAGE) ) raise except Exception as e: logger.exception("Error processing message: %s", e) await tools.send_failure( - AgentFailure("claude_sdk", GENERIC_PROVIDER_FAILURE_MESSAGE) + AgentFailure(_PROVIDER, GENERIC_PROVIDER_FAILURE_MESSAGE) ) raise @@ -959,14 +974,14 @@ async def _on_turn_complete( ) detail = self._result_error_detail(sdk_message) await tools.send_failure( - AgentFailure("claude_sdk", detail, code, sdk_message.errors) + AgentFailure(_PROVIDER, detail, code, sdk_message.errors) ) raise TurnResultAlreadyReported(detail) elif not replied_this_turn and not self._declined_the_reply( sdk_message.permission_denials, notified ): detail = missing_reply_error("Claude SDK") - await tools.send_failure(AgentFailure("claude_sdk", detail)) + await tools.send_failure(AgentFailure(_PROVIDER, detail)) raise TurnResultAlreadyReported(detail) def _declined_the_reply( diff --git a/tests/adapters/test_claude_sdk_adapter.py b/tests/adapters/test_claude_sdk_adapter.py index dca799bc1..fb40cd462 100644 --- a/tests/adapters/test_claude_sdk_adapter.py +++ b/tests/adapters/test_claude_sdk_adapter.py @@ -1112,8 +1112,8 @@ async def test_falls_back_to_new_session_on_resume_failure( async def test_reports_error_when_no_stored_session_to_retry( self, sample_message, mock_tools ): - """Previously a bare `raise` with zero report: no stored session id - means there is nothing to fall back to, so the failure must surface.""" + """No stored session id means there is nothing to fall back to, so + the failure must surface without leaking the raw exception text.""" adapter = ClaudeSDKAdapter() mock_manager = AsyncMock() mock_manager.get_or_create_session = AsyncMock( @@ -1142,14 +1142,15 @@ async def test_reports_error_when_no_stored_session_to_retry( mock_tools.send_failure.assert_called_once() failure = mock_tools.send_failure.call_args.args[0] assert failure.provider == "claude_sdk" - assert "Session setup failed" in failure.message + assert failure.message == GENERIC_PROVIDER_FAILURE_MESSAGE + assert "Session setup failed" not in failure.message @pytest.mark.asyncio async def test_reports_error_when_fallback_session_also_fails( self, sample_message, mock_tools ): - """The fallback session-creation attempt was previously uncaught by - this scope entirely -- a failure there escaped with zero report.""" + """A failure in the fallback session-creation attempt must surface + without leaking the raw exception text.""" adapter = ClaudeSDKAdapter() mock_manager = AsyncMock() mock_manager.get_or_create_session = AsyncMock( @@ -1178,7 +1179,8 @@ async def test_reports_error_when_fallback_session_also_fails( mock_tools.send_failure.assert_called_once() failure = mock_tools.send_failure.call_args.args[0] assert failure.provider == "claude_sdk" - assert "Fresh session failed" in failure.message + assert failure.message == GENERIC_PROVIDER_FAILURE_MESSAGE + assert "Fresh session failed" not in failure.message @pytest.mark.asyncio async def test_task_event_failure_does_not_break_flow(self, mock_tools): From 181edfb19f97c41f120ebc8e5df5b3331d6c3318 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 9 Sep 2026 21:33:23 +0300 Subject: [PATCH 30/53] fix(opencode): use the shared generic provider-failure message The generic except Exception branch reported a hand-rolled string instead of GENERIC_PROVIDER_FAILURE_MESSAGE, the convention every other adapter's send_failure migration in this PR follows. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011uGMZeouBGz9s3vKCe2uhj --- src/band/adapters/opencode/adapter.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/band/adapters/opencode/adapter.py b/src/band/adapters/opencode/adapter.py index 9b7521e8d..a9279d0e8 100644 --- a/src/band/adapters/opencode/adapter.py +++ b/src/band/adapters/opencode/adapter.py @@ -20,7 +20,11 @@ from band.adapters.opencode.config import OpencodeAdapterConfig from band.converters.opencode import OpencodeHistoryConverter from band.core.exceptions import BandConnectionError -from band.core.protocols import FAILURE_CODE_TIMEOUT, AgentToolsProtocol +from band.core.protocols import ( + FAILURE_CODE_TIMEOUT, + GENERIC_PROVIDER_FAILURE_MESSAGE, + AgentToolsProtocol, +) from band.core.simple_adapter import SimpleAdapter from band.core.types import ( AdapterFeatures, @@ -505,9 +509,7 @@ async def on_message( except Exception: logger.exception("Unexpected OpenCode adapter failure in room %s", room_id) await tools.send_failure( - AgentFailure( - "opencode", "OpenCode failed while processing the message." - ) + AgentFailure("opencode", GENERIC_PROVIDER_FAILURE_MESSAGE) ) raise From 83fb9fe3d93c5d2391ae46dc8ae8da265e49a4f1 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 9 Sep 2026 21:33:34 +0300 Subject: [PATCH 31/53] refactor(codex,copilot_sdk): share one send_event_safe helper CodexAdapter._emit_event_safe and CopilotSDKAdapter._send_event_safe were near-identical best-effort event senders with no shared helper. Extracted send_event_safe onto band.core.protocols, parameterized by log_level so each adapter keeps its prior logging behavior (codex logged at DEBUG, copilot_sdk at WARNING). Also fixes _emit_structured_turn_error: a turn/completed event with status="failed" and no "error" key at all returned early without ever calling send_failure, while the caller unconditionally raised TurnResultAlreadyReported right after -- a raise claiming an already-reported failure that was never actually reported. Falls through to the same falsy-scalar handling as every other non-dict error value instead. Adds a regression test for the missing-key case and a test for the outermost generic-exception fallback in on_message, and drops history-narration from one docstring. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011uGMZeouBGz9s3vKCe2uhj --- src/band/adapters/codex.py | 41 +++++---------- src/band/adapters/copilot_sdk.py | 31 +++-------- src/band/core/protocols.py | 35 +++++++++++++ tests/adapters/test_codex_adapter.py | 77 ++++++++++++++++++++++++++-- 4 files changed, 128 insertions(+), 56 deletions(-) diff --git a/src/band/adapters/codex.py b/src/band/adapters/codex.py index 174fc9991..dfdda6f27 100644 --- a/src/band/adapters/codex.py +++ b/src/band/adapters/codex.py @@ -29,6 +29,7 @@ GENERIC_PROVIDER_FAILURE_MESSAGE, AgentToolsProtocol, TurnResultAlreadyReported, + send_event_safe, ) from band.core.simple_adapter import SimpleAdapter from band.core.types import ( @@ -644,7 +645,7 @@ async def on_message( Emit.TASK_EVENTS in self.features.emit and self.config.emit_turn_task_markers ): - await self._emit_event_safe( + await send_event_safe( tools, content=self._build_task_event_content( task_id=turn_id or None, @@ -659,6 +660,7 @@ async def on_message( "codex_room_id": room_id, }, log_label="turn started task event", + log_level=logging.DEBUG, ) # Phase 2: Turn STARTED lifecycle event with input summary @@ -667,7 +669,7 @@ async def on_message( and Emit.TASK_EVENTS in self.features.emit ): input_summary = (msg.content or "")[:200] - await self._emit_event_safe( + await send_event_safe( tools, content=self._build_task_event_content( task_id=turn_id or None, @@ -685,6 +687,7 @@ async def on_message( "codex_input_summary": input_summary, }, log_label="turn started lifecycle event", + log_level=logging.DEBUG, ) # Reset per-turn token deltas for the new turn. @@ -1192,7 +1195,7 @@ async def _ensure_thread( self._room_threads[room_id] = thread_id self._raw_history_by_room.pop(room_id, None) if Emit.TASK_EVENTS in self.features.emit: - await self._emit_event_safe( + await send_event_safe( tools, content=self._build_task_event_content( task_id=thread_id, @@ -1207,6 +1210,7 @@ async def _ensure_thread( "codex_resumed": True, }, log_label="thread resumed task event", + log_level=logging.DEBUG, ) return thread_id except CodexJsonRpcError as exc: @@ -1241,7 +1245,7 @@ async def _ensure_thread( self._room_threads[room_id] = thread_id if Emit.TASK_EVENTS in self.features.emit: - await self._emit_event_safe( + await send_event_safe( tools, content=self._build_task_event_content( task_id=thread_id, @@ -1257,6 +1261,7 @@ async def _ensure_thread( "codex_transport": self.config.transport, }, log_label="thread mapped task event", + log_level=logging.DEBUG, ) return thread_id @@ -1721,7 +1726,7 @@ async def _emit_turn_outcome( metadata["codex_duration_s"] = round(duration_s, 2) if has_usage: metadata.update(usage.to_metadata()) - await self._emit_event_safe( + await send_event_safe( tools, content=self._build_task_event_content( task_id=turn_id, @@ -1732,6 +1737,7 @@ async def _emit_turn_outcome( message_type="task", metadata=metadata, log_label="turn outcome task event", + log_level=logging.DEBUG, ) # Phase 2: Enriched turn lifecycle events @@ -2344,12 +2350,10 @@ async def _emit_structured_turn_error( ) -> None: """Emit a structured error event when turn/completed reports failure.""" error = turn_payload.get("error") - if error is None: - return if not isinstance(error, dict): - # A falsy scalar (``""``, ``0``) has no useful message to carry; - # build_agent_failure's own fallback covers it uniformly instead - # of shipping a degenerate literal string like "False". + # A falsy scalar (``""``, ``0``, ``None``) has no useful message to + # carry; build_agent_failure's own fallback covers it uniformly + # instead of shipping a degenerate literal string like "None". error = {"message": str(error)} if error else {} await tools.send_failure( build_agent_failure( @@ -3306,23 +3310,6 @@ def _build_task_event_content( lines.append(f"Summary: {summary}") return "\n".join(lines) - async def _emit_event_safe( - self, - tools: AgentToolsProtocol, - *, - content: str, - message_type: str, - metadata: dict[str, Any], - log_label: str, - ) -> None: - """Send a best-effort platform event, downgrading a failure to a debug log.""" - try: - await tools.send_event( - content=content, message_type=message_type, metadata=metadata - ) - except Exception: - logger.debug("Failed to emit %s", log_label, exc_info=True) - @staticmethod def _extract_local_command(content: str) -> tuple[str, str] | None: """Return ``(command, args)`` when ``content`` opens with a slash command. diff --git a/src/band/adapters/copilot_sdk.py b/src/band/adapters/copilot_sdk.py index e846a9fed..ae7b5564a 100644 --- a/src/band/adapters/copilot_sdk.py +++ b/src/band/adapters/copilot_sdk.py @@ -28,7 +28,7 @@ reraise_delivery_cause, ) from band.core.exceptions import BandConfigError -from band.core.protocols import GENERIC_PROVIDER_FAILURE_MESSAGE +from band.core.protocols import GENERIC_PROVIDER_FAILURE_MESSAGE, send_event_safe from band.core.simple_adapter import SimpleAdapter from band.core.tool_filter import filter_tool_schemas from band.core.types import Capability, Emit, MessageType, ToolEventKey, TurnUsage @@ -460,6 +460,7 @@ async def on_message( # Session errors raise out of send_and_wait, so a None here # with no room output means the model genuinely said nothing. if final_text is None and not turn.replied_in_room: + logger.warning("Room %s: Copilot turn produced no reply", room_id) await tools.send_failure( AgentFailure("copilot_sdk", "no assistant reply") ) @@ -831,7 +832,7 @@ async def _report_tool_call( invocation: ToolInvocation, arguments: dict[str, Any], ) -> None: - await self._send_event_safe( + await send_event_safe( room_tools, json.dumps( { @@ -851,7 +852,7 @@ async def _report_tool_result( invocation: ToolInvocation, output: str, ) -> None: - await self._send_event_safe( + await send_event_safe( room_tools, json.dumps( { @@ -943,7 +944,7 @@ def _usage_from_event(data: AssistantUsageData) -> TurnUsage: async def _emit_thoughts(self, turn: TurnState, tools: AgentToolsProtocol) -> None: if Emit.THOUGHTS in self.features.emit: for reasoning in turn.reasonings.values(): - await self._send_event_safe(tools, reasoning, MessageType.THOUGHT) + await send_event_safe(tools, reasoning, MessageType.THOUGHT) async def _persist_session_id( self, room_id: str, tools: AgentToolsProtocol @@ -955,7 +956,7 @@ async def _persist_session_id( """ ids = self._ids(room_id) if ids.current and ids.persisted != ids.current: - sent = await self._send_event_safe( + sent = await send_event_safe( tools, "Copilot SDK session", MessageType.TASK, @@ -965,23 +966,3 @@ async def _persist_session_id( # Only mark persisted on success so a transient send failure # is retried next turn instead of silently losing resume. ids.persisted = ids.current - - async def _send_event_safe( - self, - tools: AgentToolsProtocol, - content: str, - message_type: MessageType, - metadata: dict[str, Any] | None = None, - ) -> bool: - """Send a platform event, downgrading failures to a warning. - - Returns True when the event was accepted by the platform. - """ - try: - await tools.send_event( - content=content, message_type=message_type, metadata=metadata - ) - except Exception as exc: - logger.warning("Failed to send %s event: %s", message_type, exc) - return False - return True diff --git a/src/band/core/protocols.py b/src/band/core/protocols.py index 9d1e35c5f..ded1a4882 100644 --- a/src/band/core/protocols.py +++ b/src/band/core/protocols.py @@ -2,12 +2,15 @@ from __future__ import annotations +import logging from typing import TYPE_CHECKING, Any, Literal, Protocol, TypeVar, runtime_checkable from band_sdk_core import AgentFailure from band.core.content import has_visible_content +logger = logging.getLogger(__name__) + if TYPE_CHECKING: from anthropic.types import ToolParam @@ -67,6 +70,38 @@ def to_failure_event(failure: AgentFailure) -> tuple[str, dict[str, Any]]: return content, {"failure": failure.to_dict()} +async def send_event_safe( + tools: "AgentToolsProtocol", + content: str, + message_type: str, + metadata: dict[str, Any] | None = None, + *, + log_label: str | None = None, + log_level: int = logging.WARNING, +) -> bool: + """Send a best-effort platform event, logging instead of raising on failure. + + For events whose loss is tolerable (a thought, a lifecycle/task marker), + unlike ``send_message``/``send_failure`` calls a caller depends on as a + control signal. Returns whether the event was actually accepted, so a + caller that only wants to update its own bookkeeping once delivery is + confirmed (e.g. marking a session id persisted) can act on it. + """ + try: + await tools.send_event( + content=content, message_type=message_type, metadata=metadata + ) + except Exception: + logger.log( + log_level, + "Failed to send %s event", + log_label or message_type, + exc_info=True, + ) + return False + return True + + @runtime_checkable class HistoryConverter(Protocol[T]): """ diff --git a/tests/adapters/test_codex_adapter.py b/tests/adapters/test_codex_adapter.py index 8c43cdcba..460b045cc 100644 --- a/tests/adapters/test_codex_adapter.py +++ b/tests/adapters/test_codex_adapter.py @@ -23,7 +23,10 @@ CodexAdapterConfig, PendingApproval, ) -from band.core.protocols import TurnResultAlreadyReported +from band.core.protocols import ( + GENERIC_PROVIDER_FAILURE_MESSAGE, + TurnResultAlreadyReported, +) from band.core.types import AgentInput, Emit, HistoryProvider, PlatformMessage from band.integrations.codex import CodexJsonRpcError, RpcEvent from band.integrations.codex.types import ( @@ -3611,6 +3614,73 @@ async def test_structured_error_from_failed_turn(self) -> None: assert len(failures) == 1 assert failures[0]["code"] == "UsageLimitExceeded" + @pytest.mark.asyncio + async def test_structured_error_from_failed_turn_with_no_error_key(self) -> None: + """turn/completed with status=failed but no "error" key at all must + still report a failure before raising, not just claim it did.""" + events = [ + _event_notification( + "turn/completed", + {"turn": {"id": "turn-1", "status": "failed", "items": []}}, + ), + ] + fake_client = FakeCodexClient(events=events) + adapter = CodexAdapter( + config=CodexAdapterConfig(transport="ws"), + client_factory=lambda _config: fake_client, + ) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Agent", "A coding agent") + with pytest.raises(TurnResultAlreadyReported): + await adapter.on_message( + make_platform_message(), + tools, + CodexSessionState(), + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-1", + ) + + failures = reported_failures(tools) + assert len(failures) == 1 + assert failures[0]["provider"] == "codex" + + @pytest.mark.asyncio + async def test_generic_exception_reports_and_propagates(self) -> None: + """A bare exception outside Codex's structured-error paths (not a + CodexJsonRpcError, not a delivery/already-reported failure) must + still surface via the generic fallback and propagate.""" + fake_client = FakeCodexClient( + events=[], + turn_start_error=RuntimeError("transport hiccup"), + turn_start_error_once=False, + ) + adapter = CodexAdapter( + config=CodexAdapterConfig(transport="ws"), + client_factory=lambda _config: fake_client, + ) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Agent", "A coding agent") + with pytest.raises(RuntimeError, match="transport hiccup"): + await adapter.on_message( + make_platform_message(), + tools, + CodexSessionState(), + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-1", + ) + + failures = reported_failures(tools) + assert len(failures) == 1 + assert failures[0]["provider"] == "codex" + assert failures[0]["message"] == GENERIC_PROVIDER_FAILURE_MESSAGE + assert "transport hiccup" not in failures[0]["message"] + # =========================================================================== # Phase 1: Enriched approvals & session-level acceptance @@ -5965,9 +6035,8 @@ async def test_local_command_reply_delivery_failure_is_not_reported( ) -> None: """/help's answer failing to post is Band-side delivery, not a Codex provider failure -- deliver_reply's DeliveryFailedError must be - recognized and left unreported here. codex.py's on_message had no - try/except at all before this PR, so the original cause must still - propagate (the message still fails/retries at the platform level), + recognized and left unreported here. The original cause still + propagates (the message still fails/retries at the platform level), just never misreported as a Codex AgentFailure.""" class FailingSendMessageTools(ToolSchemaFakeTools): From 6e8278228e7522a046b4b8df51f73f8e25e69f46 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 9 Sep 2026 21:33:52 +0300 Subject: [PATCH 32/53] test(letta): assert on the structured AgentFailure, not just event count test_failed_tool_resync_skips_turn and test_prepare_failure_reports_error_and_skips_turn only asserted len(error_events) == 1, so either would still pass with a leaked raw exception message or the wrong provider tag. Both now assert provider/message via reported_failures(). Also adds coverage for _run_turn's generic-exception fallback (letta.py:454-459), the one migrated adapter that had no test for that path, and dedupes the "letta" provider-tag literal (6 call sites) behind one module-level _PROVIDER constant, same as claude_sdk.py. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011uGMZeouBGz9s3vKCe2uhj --- src/band/adapters/letta.py | 15 ++++++----- tests/adapters/test_letta_adapter.py | 39 ++++++++++++++++++++++++++-- tests/adapters/test_letta_mcp.py | 9 ++++++- 3 files changed, 54 insertions(+), 9 deletions(-) diff --git a/src/band/adapters/letta.py b/src/band/adapters/letta.py index 814c32346..540bc932d 100644 --- a/src/band/adapters/letta.py +++ b/src/band/adapters/letta.py @@ -59,6 +59,9 @@ logger = logging.getLogger(__name__) +# AgentFailure.provider tag for every failure this adapter reports. +_PROVIDER = "letta" + @dataclass class RoomContext: @@ -279,7 +282,7 @@ async def on_message( if not self._client: logger.error("Letta client not initialized, dropping message %s", msg.id) message = "Letta adapter not initialized" - await tools.send_failure(AgentFailure("letta", message)) + await tools.send_failure(AgentFailure(_PROVIDER, message)) raise RuntimeError(message) # Lock only protects MCP/agent setup, not the full message path. @@ -297,7 +300,7 @@ async def on_message( except Exception as e: logger.exception("Room %s: Failed to prepare Letta session: %s", room_id, e) await tools.send_failure( - AgentFailure("letta", GENERIC_PROVIDER_FAILURE_MESSAGE) + AgentFailure(_PROVIDER, GENERIC_PROVIDER_FAILURE_MESSAGE) ) raise @@ -326,7 +329,7 @@ async def _handle_message( if (room_ctx := await self._room_context(room_id, history, tools)) is None: logger.error("Room %s: No Letta agent context, dropping message", room_id) message = "Letta agent context unavailable" - await tools.send_failure(AgentFailure("letta", message)) + await tools.send_failure(AgentFailure(_PROVIDER, message)) raise RuntimeError(message) # Point the MCP resolver at this room's current tools for the @@ -445,7 +448,7 @@ async def _run_turn( ) await tools.send_failure( AgentFailure( - "letta", + _PROVIDER, f"Letta agent response timed out after {self.config.turn_timeout_s}s", FAILURE_CODE_TIMEOUT, ) @@ -454,7 +457,7 @@ async def _run_turn( except Exception as e: logger.exception("Room %s: Error during Letta turn: %s", room_id, e) await tools.send_failure( - AgentFailure("letta", GENERIC_PROVIDER_FAILURE_MESSAGE) + AgentFailure(_PROVIDER, GENERIC_PROVIDER_FAILURE_MESSAGE) ) raise else: @@ -606,7 +609,7 @@ async def _process_response_messages( f"Letta agent did not call {self._mcp.send_message_tool} " "(auto-relay disabled); its reply was dropped" ) - await tools.send_failure(AgentFailure("letta", detail)) + await tools.send_failure(AgentFailure(_PROVIDER, detail)) raise TurnResultAlreadyReported(detail) else: final_text = "\n\n".join(final_text_parts) diff --git a/tests/adapters/test_letta_adapter.py b/tests/adapters/test_letta_adapter.py index 0f55315ae..85a19280d 100644 --- a/tests/adapters/test_letta_adapter.py +++ b/tests/adapters/test_letta_adapter.py @@ -21,7 +21,10 @@ RoomContext, ) from band.converters.letta import LettaSessionState -from band.core.protocols import TurnResultAlreadyReported +from band.core.protocols import ( + GENERIC_PROVIDER_FAILURE_MESSAGE, + TurnResultAlreadyReported, +) from band.core.types import Emit from band.testing import FakeAgentTools, reported_failures from tests.adapters.lettakit import ( @@ -250,10 +253,42 @@ async def slow_response(**kwargs: Any) -> MagicMock: error_events = [e for e in tools.events_sent if e["message_type"] == "error"] assert len(error_events) == 1 assert "timed out" in error_events[0]["content"] - failure = error_events[0]["metadata"]["failure"] + failure = reported_failures(tools)[0] assert failure["provider"] == "letta" assert failure["code"] == "timeout" + @pytest.mark.asyncio + async def test_generic_exception_reports_and_propagates( + self, adapter_with_client: tuple[LettaAdapter, AsyncMock] + ) -> None: + """A bare exception from the Letta client (not a timeout, not a + delivery failure) must still surface via the generic fallback.""" + adapter, mock_client = adapter_with_client + adapter._rooms["room-1"] = RoomContext(agent_id="agent-1") + mock_client.agents.messages.create.side_effect = ConnectionError( + "letta connection reset" + ) + + tools = FakeAgentTools() + msg = make_platform_message() + history = LettaSessionState() + + with pytest.raises(ConnectionError, match="letta connection reset"): + await adapter.on_message( + msg, + tools, + history, + None, + None, + is_session_bootstrap=False, + room_id="room-1", + ) + + failure = reported_failures(tools)[0] + assert failure["provider"] == "letta" + assert failure["message"] == GENERIC_PROVIDER_FAILURE_MESSAGE + assert "letta connection reset" not in failure["message"] + @pytest.mark.asyncio async def test_participants_and_contacts_injected( self, adapter_with_client: tuple[LettaAdapter, AsyncMock] diff --git a/tests/adapters/test_letta_mcp.py b/tests/adapters/test_letta_mcp.py index cd2bb6cb2..f1f21329c 100644 --- a/tests/adapters/test_letta_mcp.py +++ b/tests/adapters/test_letta_mcp.py @@ -22,12 +22,13 @@ RoomContext, ) from band.converters.letta import LettaSessionState +from band.core.protocols import GENERIC_PROVIDER_FAILURE_MESSAGE from band.integrations.letta.prompts import ( SEND_EVENT_TOOL_NAMES, SEND_MESSAGE_TOOL_NAMES, ) from band.runtime.tools import BandTool -from band.testing import FakeAgentTools +from band.testing import FakeAgentTools, reported_failures from tests.adapters.lettakit import ( make_assistant_message, make_fake_mcp_backend, @@ -532,6 +533,9 @@ async def test_failed_tool_resync_skips_turn(self) -> None: error_events = [e for e in tools.events_sent if e["message_type"] == "error"] assert len(error_events) == 1 + failure = reported_failures(tools)[0] + assert failure["provider"] == "letta" + assert failure["message"] == GENERIC_PROVIDER_FAILURE_MESSAGE mock_client.agents.messages.create.assert_not_called() @pytest.mark.asyncio @@ -779,6 +783,9 @@ async def test_prepare_failure_reports_error_and_skips_turn(self) -> None: error_events = [e for e in tools.events_sent if e["message_type"] == "error"] assert len(error_events) == 1 + failure = reported_failures(tools)[0] + assert failure["provider"] == "letta" + assert failure["message"] == GENERIC_PROVIDER_FAILURE_MESSAGE mock_client.agents.messages.create.assert_not_called() @pytest.mark.asyncio From 61885df5d199c1e58ceaab09e159daf5013d9c39 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 9 Sep 2026 21:33:57 +0300 Subject: [PATCH 33/53] fix(crewai): restore the missing-reply log line dropped by the send_failure migration The pre-migration code logged a warning before reporting a turn that produced nothing for the room; restructuring into send_failure + TurnResultAlreadyReported dropped that log line, so the room got the failure but nothing was left in the local log. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011uGMZeouBGz9s3vKCe2uhj --- src/band/adapters/crewai.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/band/adapters/crewai.py b/src/band/adapters/crewai.py index bd98bd633..aef8ebd5f 100644 --- a/src/band/adapters/crewai.py +++ b/src/band/adapters/crewai.py @@ -438,6 +438,9 @@ async def _process_message( ) if not reply_tracker.did_productive_work: + logger.warning( + "Room %s: CrewAI turn produced nothing for the room", room_id + ) detail = missing_reply_error( "CrewAI", detail=( From dde72e87e1649b5b901f2166a3bfd453733310ca Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 9 Sep 2026 21:34:01 +0300 Subject: [PATCH 34/53] fix(strands): log before reporting a silent turn; assert on structured failures The "no terminal action fired" branch called send_failure and raised TurnResultAlreadyReported with no logger call at all, unlike letta.py's equivalent branch. Also replaces the test helper that only exposed raw event content with band.testing.reported_failures(), so failure tests assert on .provider/.message instead of message-substring text. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011uGMZeouBGz9s3vKCe2uhj --- src/band/adapters/strands.py | 3 +++ tests/adapters/test_strands_adapter.py | 17 ++++++++++------- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/band/adapters/strands.py b/src/band/adapters/strands.py index 660fb2b3b..1c33e00cb 100644 --- a/src/band/adapters/strands.py +++ b/src/band/adapters/strands.py @@ -580,6 +580,9 @@ async def on_message( hooks=hooks, ) if not hooks.terminal_fired: + logger.warning( + "Room %s: Strands turn produced nothing for the room", room_id + ) detail = missing_reply_error("Strands") await tools.send_failure(AgentFailure("strands", detail)) raise TurnResultAlreadyReported(detail) diff --git a/tests/adapters/test_strands_adapter.py b/tests/adapters/test_strands_adapter.py index e25f40ab4..130563408 100644 --- a/tests/adapters/test_strands_adapter.py +++ b/tests/adapters/test_strands_adapter.py @@ -57,6 +57,7 @@ ScriptedStrandsModel, ScriptedTurn, ToolTurn, + reported_failures, ) _INPUT_TOKENS_PER_CALL = 7 @@ -146,10 +147,6 @@ def _alternates(history: list) -> bool: return all(first != second for first, second in zip(roles, roles[1:])) -def _errors(tools: FakeAgentTools) -> list[str]: - return [e["content"] for e in tools.events_sent if e["message_type"] == "error"] - - class TestCustomToolWiring: def test_custom_tool_def_converted_to_bridge(self): class WeatherInput(BaseModel): @@ -539,7 +536,9 @@ async def send_message(self, content, mentions=None): await _run_message(adapter, tools) assert tools.messages_sent == [] - assert len(_errors(tools)) == 1 + failures = reported_failures(tools) + assert len(failures) == 1 + assert failures[0]["provider"] == "strands" # The shared bridge returns a normalized, model-visible tool failure. assert any( text.startswith("Error executing band_send_message:") @@ -586,7 +585,9 @@ async def test_read_only_tool_alone_does_not_end_the_turn(self, tools, scripted) assert _tool_results(adapter) # the lookup did run and succeed assert tools.messages_sent == [] - assert "band_send_message" in _errors(tools)[0] + failure = reported_failures(tools)[0] + assert failure["provider"] == "strands" + assert "band_send_message" in failure["message"] @pytest.mark.asyncio async def test_invalid_tool_arguments_are_answered_not_raised( @@ -649,7 +650,9 @@ async def test_provider_failure_keeps_the_transcript_and_reports_usage( assert usage[0]["metadata"][USAGE_METADATA_KEY]["input_tokens"] == ( _INPUT_TOKENS_PER_CALL ) - assert GENERIC_PROVIDER_FAILURE_MESSAGE in _errors(tools)[0] + failure = reported_failures(tools)[0] + assert failure["provider"] == "strands" + assert failure["message"] == GENERIC_PROVIDER_FAILURE_MESSAGE class TestUsageMapping: From b5a2da4d9684a733f3368557c0e64efd39eb0029 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 9 Sep 2026 21:34:05 +0300 Subject: [PATCH 35/53] fix(pydantic_ai): log before reporting a silent turn The "no tool executed" branch called send_failure and raised TurnResultAlreadyReported with no logger call, the same gap as strands.py's equivalent branch. Also drops history-narration from a test docstring per the repo's comment-style convention. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011uGMZeouBGz9s3vKCe2uhj --- src/band/adapters/pydantic_ai.py | 3 +++ tests/adapters/test_pydantic_ai_adapter.py | 3 +-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/band/adapters/pydantic_ai.py b/src/band/adapters/pydantic_ai.py index cb6138966..c0e7f2593 100644 --- a/src/band/adapters/pydantic_ai.py +++ b/src/band/adapters/pydantic_ai.py @@ -1088,6 +1088,9 @@ async def on_message( # either answered in plain text or said nothing at all. Surface it as an # error (mirrors the crewai adapter) instead of letting it vanish. if not tool_executed: + logger.warning( + "Room %s: Pydantic AI turn produced nothing for the room", room_id + ) detail = missing_reply_error("Pydantic AI") await tools.send_failure(AgentFailure("pydantic_ai", detail)) raise TurnResultAlreadyReported(detail) diff --git a/tests/adapters/test_pydantic_ai_adapter.py b/tests/adapters/test_pydantic_ai_adapter.py index 633947b13..4fa3cdfd9 100644 --- a/tests/adapters/test_pydantic_ai_adapter.py +++ b/tests/adapters/test_pydantic_ai_adapter.py @@ -1787,8 +1787,7 @@ async def test_generic_provider_error_reports_and_propagates( self, sample_message, mock_tools, mock_pydantic_agent ): """A failure that isn't UnexpectedModelBehavior at all (a raw provider/API - error) must still surface as a failure and propagate — previously this - class of error had no except clause at all and vanished uncaught.""" + error) must still surface as a failure and propagate, not vanish uncaught.""" adapter = PydanticAIAdapter(model="openai:gpt-5.4") with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): await adapter.on_started("TestBot", "Test bot") From b21ed1d65f38c77078f739b176c5da8d7ed453b8 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 9 Sep 2026 21:34:09 +0300 Subject: [PATCH 36/53] fix(a2a): log a peer task's terminal failure before raising _deliver_task_update's terminal-failure branch calls send_failure and raises with no local log signal at all -- a genuinely new failure mode (previously this just posted an event, never failed the turn). Also replaces hand-extracted error_events[...]["metadata"]["failure"] lookups with band.testing.reported_failures() in two tests. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011uGMZeouBGz9s3vKCe2uhj --- src/band/integrations/a2a/adapter.py | 3 +++ tests/integrations/a2a/test_adapter.py | 6 +++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/band/integrations/a2a/adapter.py b/src/band/integrations/a2a/adapter.py index 7659033c1..86646deba 100644 --- a/src/band/integrations/a2a/adapter.py +++ b/src/band/integrations/a2a/adapter.py @@ -295,6 +295,9 @@ async def _deliver_task_update( if state in TERMINAL_TASK_STATES: error_text = self._get_status_text(task) or f"Task {state_name(state)}" + logger.warning( + "Task %s: peer A2A task ended in state %s", task.id, state_name(state) + ) await tools.send_failure(AgentFailure("a2a", error_text, state_name(state))) raise TurnResultAlreadyReported(error_text) diff --git a/tests/integrations/a2a/test_adapter.py b/tests/integrations/a2a/test_adapter.py index 8be96c03b..6a0745c87 100644 --- a/tests/integrations/a2a/test_adapter.py +++ b/tests/integrations/a2a/test_adapter.py @@ -30,7 +30,7 @@ from band.core.types import PlatformMessage from band.integrations.a2a import A2AAdapter, A2AAuth, A2ASessionState from band.integrations.a2a.adapter import _SSE_READ_TIMEOUT_S -from band.testing import FakeAgentTools +from band.testing import FakeAgentTools, reported_failures def make_platform_message(content: str = "Hello") -> PlatformMessage: @@ -361,7 +361,7 @@ async def test_auth_required_task_is_posted_as_error_event( ] assert error_events, "an auth-required task must produce an error event" assert error_events[-1]["content"] == "Please authenticate" - failure = error_events[-1]["metadata"]["failure"] + failure = reported_failures(tools)[-1] assert failure["provider"] == "a2a" assert failure["code"] == "TASK_STATE_AUTH_REQUIRED" @@ -464,7 +464,7 @@ async def test_failed_task_is_posted_as_error_event( ] assert error_events, "a failed task must produce an error event" assert error_events[-1]["content"] == "boom" - failure = error_events[-1]["metadata"]["failure"] + failure = reported_failures(tools)[-1] assert failure["provider"] == "a2a" assert failure["code"] == "TASK_STATE_FAILED" From 913723a9840f424dcc8d0b37e08523904cd3f4d4 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 9 Sep 2026 21:34:12 +0300 Subject: [PATCH 37/53] test(agno): assert on the structured AgentFailure via reported_failures() Replaces hand-extracted errors[0]["metadata"]["failure"] lookups with band.testing.reported_failures(), the helper this PR introduces and that sibling suites (codex, opencode, a2a, copilot_sdk) already use. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011uGMZeouBGz9s3vKCe2uhj --- tests/adapters/agno/test_adapter.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/adapters/agno/test_adapter.py b/tests/adapters/agno/test_adapter.py index 0a583baef..9fe138a68 100644 --- a/tests/adapters/agno/test_adapter.py +++ b/tests/adapters/agno/test_adapter.py @@ -30,7 +30,7 @@ _make_band_entrypoint, ) from band.core.types import Capability, Emit, PlatformMessage -from band.testing import FakeAgentTools +from band.testing import FakeAgentTools, reported_failures from tests.adapters.agno.helpers import ( CapturingModel, @@ -897,7 +897,7 @@ async def test_emits_generic_error_event_and_reraises( ) # The exception text (which can carry secrets) must not leak to the room. assert "secret-token" not in errors[0]["content"] - failure = errors[0]["metadata"]["failure"] + failure = reported_failures(tools)[0] assert failure["provider"] == "agno" # A plain RuntimeError isn't a swallowed Agno run status -- no code. assert failure["code"] is None @@ -959,7 +959,7 @@ async def test_streaming_error_event_is_raised_and_reported( errors = [e for e in tools.events_sent if e["message_type"] == "error"] assert len(errors) == 1 assert "secret-token" not in errors[0]["content"] - assert errors[0]["metadata"]["failure"]["code"] == RunStatus.error.value + assert reported_failures(tools)[0]["code"] == RunStatus.error.value assert not adapter._message_history.get("room-A") async def test_error_event_failure_does_not_mask_original( From 3b01fcbdcfc1887785386584603ec73f5df9a975 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 9 Sep 2026 21:34:16 +0300 Subject: [PATCH 38/53] test(parlant): cover the DeliveryFailedError misreporting guard No test proved that a Band-side send_message failure while delivering the reply propagates as itself rather than getting misreported as a Parlant provider AgentFailure via the generic except Exception branch. Adds a regression test that fails without the DeliveryFailedError/reraise_delivery_cause guard and passes with it. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011uGMZeouBGz9s3vKCe2uhj --- tests/adapters/test_parlant_adapter.py | 67 ++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/tests/adapters/test_parlant_adapter.py b/tests/adapters/test_parlant_adapter.py index cb5c90d4d..aeaac635a 100644 --- a/tests/adapters/test_parlant_adapter.py +++ b/tests/adapters/test_parlant_adapter.py @@ -832,6 +832,73 @@ async def test_reports_error_on_session_init_failure( assert failure.provider == "parlant" assert failure.message == GENERIC_PROVIDER_FAILURE_MESSAGE + @pytest.mark.asyncio + async def test_send_message_failure_is_not_reported_as_provider_failure( + self, mock_parlant_server, mock_parlant_agent, sample_message, mock_tools + ): + """A Band-side send_message failure while delivering the reply must + propagate as itself, not get misreported as a Parlant provider failure. + + ``deliver_reply`` wraps the ``send_message`` error in + ``DeliveryFailedError``; ``on_message``'s dedicated except branch must + re-raise the original cause before its generic ``except Exception`` + (which reports ``send_failure``) ever sees it. + """ + adapter = ParlantAdapter( + server=mock_parlant_server, + parlant_agent=mock_parlant_agent, + response_timeout=0.2, + response_poll=0.01, + ) + adapter.agent_name = "TestBot" + + agent_event = MagicMock() + agent_event.kind = "message" + agent_event.source = "ai_agent" + agent_event.offset = 2 + agent_event.data = {"message": "Hello there!", "tags": []} + + mock_app = MagicMock() + mock_app.sessions = AsyncMock() + mock_app.sessions.create = AsyncMock(return_value=MagicMock(id="session-123")) + mock_app.sessions.create_customer_message = AsyncMock( + return_value=MagicMock(offset=1) + ) + mock_app.sessions.wait_for_more_events = AsyncMock(return_value=True) + mock_app.sessions.find_events = AsyncMock(return_value=[agent_event]) + adapter._app = mock_app + + mock_tools.send_message.side_effect = ConnectionError("band down") + + mock_moderation = MagicMock() + mock_moderation.NONE = "none" + + with patch.dict( + sys.modules, + { + "parlant.core.app_modules.sessions": MagicMock( + Moderation=mock_moderation + ), + "parlant.core.sessions": MagicMock( + EventSource=MagicMock(CUSTOMER="customer", AI_AGENT="ai_agent"), + EventKind=MagicMock(MESSAGE="message"), + ), + "parlant.core.async_utils": MagicMock(Timeout=lambda x: x), + }, + ): + with pytest.raises(ConnectionError, match="band down"): + await adapter.on_message( + msg=sample_message, + tools=mock_tools, + history=[], + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-123", + ) + + mock_tools.send_failure.assert_not_awaited() + @pytest.mark.asyncio async def test_clears_tools_on_error( self, mock_parlant_server, mock_parlant_agent, sample_message, mock_tools From 0cb8119351d3a54dd1b396dfdc77485a6bdfc4d1 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 9 Sep 2026 21:34:20 +0300 Subject: [PATCH 39/53] test: drop history-narration from two docstrings "Previously uncaught entirely: ... ran outside the try" narrates how the code used to behave instead of stating the current invariant, which the repo's comment-style convention forbids. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011uGMZeouBGz9s3vKCe2uhj --- tests/adapters/langgraph/test_lifecycle.py | 3 +-- tests/adapters/test_google_adk_adapter.py | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/adapters/langgraph/test_lifecycle.py b/tests/adapters/langgraph/test_lifecycle.py index 8d8dbb74a..27827ccf7 100644 --- a/tests/adapters/langgraph/test_lifecycle.py +++ b/tests/adapters/langgraph/test_lifecycle.py @@ -342,8 +342,7 @@ async def failing_stream(*args, **kwargs): async def test_reports_error_when_graph_factory_yields_no_graph( self, sample_message, mock_tools, mock_llm, mock_checkpointer ): - """Previously uncaught entirely: graph-factory construction ran - outside the try, so a bad factory's RuntimeError escaped unreported.""" + """A bad graph factory's RuntimeError must be reported, not escape unreported.""" adapter = LangGraphAdapter( llm=mock_llm, checkpointer=mock_checkpointer, diff --git a/tests/adapters/test_google_adk_adapter.py b/tests/adapters/test_google_adk_adapter.py index 3e30fd43c..89fa34295 100644 --- a/tests/adapters/test_google_adk_adapter.py +++ b/tests/adapters/test_google_adk_adapter.py @@ -970,8 +970,7 @@ async def failing_run(**kwargs): async def test_reports_error_when_runner_construction_itself_fails( self, sample_message, mock_tools ): - """Previously uncaught entirely: _create_runner ran outside the try, - so a construction failure escaped with zero report.""" + """A runner-construction failure must be reported, not escape uncaught.""" adapter = GoogleADKAdapter() await adapter.on_started("TestBot", "Test bot") From b00e015905658b10696d650a84daf79a4b1655b7 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 9 Sep 2026 22:01:59 +0300 Subject: [PATCH 40/53] fix(codex): log a turn/completed structured failure before reporting it _emit_structured_turn_error had no logging on its path at all, unlike its sibling _handle_error_event (which logs "Codex error: %s" before its own send_failure call) -- a Codex turn failing via turn/completed produced a room-visible AgentFailure with zero corresponding server-side log line. Also finishes the send_event_safe migration (commit 83fb9fe3) for the 11 remaining hand-rolled try/send_event/except-Exception/logger.debug call sites in this file (reasoning/plan/commentary streaming deltas, context compaction, approval thought/request/audit events, turn lifecycle, plan steps, token usage, diff events) -- the shared helper was introduced to replace exactly this pattern but only covered 5 of 16 matching sites. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011uGMZeouBGz9s3vKCe2uhj --- src/band/adapters/codex.py | 359 ++++++++++++++++++------------------- 1 file changed, 173 insertions(+), 186 deletions(-) diff --git a/src/band/adapters/codex.py b/src/band/adapters/codex.py index dfdda6f27..8e8ebcbb1 100644 --- a/src/band/adapters/codex.py +++ b/src/band/adapters/codex.py @@ -825,45 +825,42 @@ async def _process_turn_events( if self.config.stream_reasoning_events: delta = params.get("delta", "") item_id = str(params.get("itemId") or "") - try: - await tools.send_event( - content=str(delta), - message_type="thought", - metadata={ - "streaming": True, - "codex_item_id": item_id, - "codex_event_type": event.method, - "codex_room_id": room_id, - "codex_thread_id": thread_id, - "codex_turn_id": turn_id, - }, - ) - except Exception: - logger.debug( - "Failed to stream reasoning delta", - exc_info=True, - ) + await send_event_safe( + tools, + content=str(delta), + message_type="thought", + metadata={ + "streaming": True, + "codex_item_id": item_id, + "codex_event_type": event.method, + "codex_room_id": room_id, + "codex_thread_id": thread_id, + "codex_turn_id": turn_id, + }, + log_label="reasoning delta", + log_level=logging.DEBUG, + ) continue if event.method == "item/plan/delta": if self.config.stream_plan_events: delta = params.get("delta", "") item_id = str(params.get("itemId") or "") - try: - await tools.send_event( - content=str(delta), - message_type="thought", - metadata={ - "streaming": True, - "subtype": "plan", - "codex_item_id": item_id, - "codex_room_id": room_id, - "codex_thread_id": thread_id, - "codex_turn_id": turn_id, - }, - ) - except Exception: - logger.debug("Failed to stream plan delta", exc_info=True) + await send_event_safe( + tools, + content=str(delta), + message_type="thought", + metadata={ + "streaming": True, + "subtype": "plan", + "codex_item_id": item_id, + "codex_room_id": room_id, + "codex_thread_id": thread_id, + "codex_turn_id": turn_id, + }, + log_label="plan delta", + log_level=logging.DEBUG, + ) continue # --- Phase 2: Plan step tracking --- @@ -897,27 +894,24 @@ async def _process_turn_events( ): compacted_thread = str(params.get("threadId") or thread_id) compacted_turn = str(params.get("turnId") or turn_id or "") - try: - await tools.send_event( - content=self._build_task_event_content( - task_id=compacted_turn or None, - task="Codex context compaction", - status="completed", - summary=f"Thread: {compacted_thread}", - ), - message_type="task", - metadata={ - "codex_event_type": "context_compaction", - "codex_room_id": room_id, - "codex_thread_id": compacted_thread, - "codex_turn_id": compacted_turn or None, - }, - ) - except Exception: - logger.debug( - "Failed to emit context compaction event", - exc_info=True, - ) + await send_event_safe( + tools, + content=self._build_task_event_content( + task_id=compacted_turn or None, + task="Codex context compaction", + status="completed", + summary=f"Thread: {compacted_thread}", + ), + message_type="task", + metadata={ + "codex_event_type": "context_compaction", + "codex_room_id": room_id, + "codex_thread_id": compacted_thread, + "codex_turn_id": compacted_turn or None, + }, + log_label="context compaction event", + log_level=logging.DEBUG, + ) continue # --- Phase 4: Aggregated diffs --- @@ -944,26 +938,21 @@ async def _process_turn_events( and self.config.stream_commentary_events ): # Stream as thought; exclude from final_text. - try: - await tools.send_event( - content=delta, - message_type="thought", - metadata={ - "streaming": True, - "subtype": "commentary", - "codex_item_id": str( - params.get("itemId") or "" - ), - "codex_room_id": room_id, - "codex_thread_id": thread_id, - "codex_turn_id": turn_id, - }, - ) - except Exception: - logger.debug( - "Failed to stream commentary delta", - exc_info=True, - ) + await send_event_safe( + tools, + content=delta, + message_type="thought", + metadata={ + "streaming": True, + "subtype": "commentary", + "codex_item_id": str(params.get("itemId") or ""), + "codex_room_id": room_id, + "codex_thread_id": thread_id, + "codex_turn_id": turn_id, + }, + log_label="commentary delta", + log_level=logging.DEBUG, + ) else: # When streaming is disabled, commentary accumulates # into final_text for backward compatibility. @@ -1638,29 +1627,24 @@ async def _handle_approval_request( logger.exception("Failed to send approval policy notification") if Emit.THOUGHTS in self.features.emit: - try: - await tools.send_event( - content=( - f"Codex approval request handled automatically ({decision})." - ), - message_type="thought", - metadata={ - "codex_approval_method": event.method, - "codex_approval_type": self._approval_type(event.method), - "codex_approval_options": [ - "accept", - "acceptForSession", - "decline", - ], - }, - ) - except Exception: - # Best-effort telemetry — never fail the turn on thought - # emission failures. - logger.debug( - "Failed to emit approval thought event", - exc_info=True, - ) + # Best-effort telemetry — never fail the turn on thought emission + # failures. + await send_event_safe( + tools, + content=(f"Codex approval request handled automatically ({decision})."), + message_type="thought", + metadata={ + "codex_approval_method": event.method, + "codex_approval_type": self._approval_type(event.method), + "codex_approval_options": [ + "accept", + "acceptForSession", + "decline", + ], + }, + log_label="approval thought event", + log_level=logging.DEBUG, + ) @staticmethod def _turn_usage(usage: CodexTokenUsage | None) -> TurnUsage: @@ -1762,19 +1746,19 @@ async def _emit_turn_outcome( lifecycle_metadata["codex_error"] = turn_error if has_usage: lifecycle_metadata.update(usage.to_metadata()) - try: - await tools.send_event( - content=self._build_task_event_content( - task_id=turn_id, - task="Codex turn lifecycle", - status=turn_status, - summary=f"Duration: {duration_s:.1f}s | Thread: {thread_id}", - ), - message_type="task", - metadata=lifecycle_metadata, - ) - except Exception: - logger.debug("Failed to emit turn lifecycle event", exc_info=True) + await send_event_safe( + tools, + content=self._build_task_event_content( + task_id=turn_id, + task="Codex turn lifecycle", + status=turn_status, + summary=f"Duration: {duration_s:.1f}s | Thread: {thread_id}", + ), + message_type="task", + metadata=lifecycle_metadata, + log_label="turn lifecycle event", + log_level=logging.DEBUG, + ) mention = [{"id": msg.sender_id, "name": msg.sender_name or msg.sender_type}] @@ -2197,22 +2181,19 @@ async def _resolve_manual_approval( net_ctx = params.get("networkContext") or params.get("network_context") if net_ctx: approval_metadata["codex_network_context"] = net_ctx - try: - await tools.send_event( - content=self._build_task_event_content( - task_id=token, - task="Codex approval request", - status="pending", - summary=summary, - ), - message_type="task", - metadata=approval_metadata, - ) - except Exception: - logger.debug( - "Failed to emit approval request task event", - exc_info=True, - ) + await send_event_safe( + tools, + content=self._build_task_event_content( + task_id=token, + task="Codex approval request", + status="pending", + summary=summary, + ), + message_type="task", + metadata=approval_metadata, + log_label="approval request task event", + log_level=logging.DEBUG, + ) await tools.send_message(approval_msg, mentions=mention) decision_raw = await asyncio.wait_for( pending.future, @@ -2355,6 +2336,12 @@ async def _emit_structured_turn_error( # carry; build_agent_failure's own fallback covers it uniformly # instead of shipping a degenerate literal string like "None". error = {"message": str(error)} if error else {} + logger.error( + "Codex turn failed (thread=%s, turn=%s): %s", + thread_id, + turn_id, + error.get("message", ""), + ) await tools.send_failure( build_agent_failure( error, thread_id=thread_id, turn_id=turn_id, room_id=room_id @@ -2379,24 +2366,24 @@ async def _forward_plan_steps( if not steps: return step_dicts = [{"step": s.step, "status": s.status} for s in steps] - try: - await tools.send_event( - content=self._build_task_event_content( - task_id=turn_id, - task="Codex plan", - status="updated", - summary=f"{len(steps)} steps", - ), - message_type="task", - metadata={ - "codex_plan_steps": step_dicts, - "codex_room_id": room_id, - "codex_thread_id": thread_id, - "codex_turn_id": turn_id, - }, - ) - except Exception: - logger.debug("Failed to forward plan steps", exc_info=True) + await send_event_safe( + tools, + content=self._build_task_event_content( + task_id=turn_id, + task="Codex plan", + status="updated", + summary=f"{len(steps)} steps", + ), + message_type="task", + metadata={ + "codex_plan_steps": step_dicts, + "codex_room_id": room_id, + "codex_thread_id": thread_id, + "codex_turn_id": turn_id, + }, + log_label="plan steps", + log_level=logging.DEBUG, + ) # ------------------------------------------------------------------ # Phase 4: Token usage & diffs @@ -2427,14 +2414,14 @@ async def _emit_token_usage_event( metadata = usage.to_metadata() metadata["codex_thread_id"] = thread_id metadata["codex_room_id"] = room_id - try: - await tools.send_event( - content=usage.format_summary(), - message_type="task", - metadata=metadata, - ) - except Exception: - logger.debug("Failed to emit token usage event", exc_info=True) + await send_event_safe( + tools, + content=usage.format_summary(), + message_type="task", + metadata=metadata, + log_label="token usage event", + log_level=logging.DEBUG, + ) async def _forward_diff_event( self, @@ -2490,19 +2477,19 @@ async def _forward_diff_event( metadata["codex_diff_truncated"] = True metadata["codex_diff_original_length"] = original_length metadata["codex_diff_original_bytes"] = original_byte_length - try: - await tools.send_event( - content=self._build_task_event_content( - task_id=turn_id, - task="Codex diff", - status="updated", - summary=summary, - ), - message_type="task", - metadata=metadata, - ) - except Exception: - logger.debug("Failed to forward diff event", exc_info=True) + await send_event_safe( + tools, + content=self._build_task_event_content( + task_id=turn_id, + task="Codex diff", + status="updated", + summary=summary, + ), + message_type="task", + metadata=metadata, + log_label="diff event", + log_level=logging.DEBUG, + ) # ------------------------------------------------------------------ # Phase 1: Approval audit trail @@ -2563,27 +2550,27 @@ async def _emit_approval_audit_event( """Emit a task event for an approval decision.""" if Emit.TASK_EVENTS not in self.features.emit: return - try: - await tools.send_event( - content=self._build_task_event_content( - task_id=str(entry.request_id), - task="Codex approval", - status=entry.decision, - summary=entry.summary, - ), - message_type="task", - metadata={ - "codex_event_type": "approval_resolution", - "codex_approval_method": entry.method, - "codex_approval_decision": entry.decision, - "codex_decided_by": entry.decided_by, - "codex_session_level": entry.session_level, - "codex_room_id": room_id, - "codex_timestamp": entry.timestamp, - }, - ) - except Exception: - logger.debug("Failed to emit approval audit event", exc_info=True) + await send_event_safe( + tools, + content=self._build_task_event_content( + task_id=str(entry.request_id), + task="Codex approval", + status=entry.decision, + summary=entry.summary, + ), + message_type="task", + metadata={ + "codex_event_type": "approval_resolution", + "codex_approval_method": entry.method, + "codex_approval_decision": entry.decision, + "codex_decided_by": entry.decided_by, + "codex_session_level": entry.session_level, + "codex_room_id": room_id, + "codex_timestamp": entry.timestamp, + }, + log_label="approval audit event", + log_level=logging.DEBUG, + ) async def _handle_local_command( self, From fc87987399972e2665b59655e4d7f7d44a97f4c4 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 9 Sep 2026 22:02:04 +0300 Subject: [PATCH 41/53] fix(protocols): correct send_event_safe's docstring; add contract tests The docstring claimed send_event_safe is for events "unlike send_message/ send_failure calls a caller depends on as a control signal" -- but send_failure never raises (its own docstring says so, and AgentTools. send_failure swallows its own reporting failure), so lumping it in there was backwards. Drops send_failure from that clause. Also adds direct tests for send_event_safe's failure branch (swallow, log at the given level, return False) -- introduced in commit 83fb9fe3 as a shared, general-purpose utility with no test of its own contract. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011uGMZeouBGz9s3vKCe2uhj --- src/band/core/protocols.py | 8 +++--- tests/core/test_protocols.py | 53 +++++++++++++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/src/band/core/protocols.py b/src/band/core/protocols.py index ded1a4882..66f1634fb 100644 --- a/src/band/core/protocols.py +++ b/src/band/core/protocols.py @@ -82,10 +82,10 @@ async def send_event_safe( """Send a best-effort platform event, logging instead of raising on failure. For events whose loss is tolerable (a thought, a lifecycle/task marker), - unlike ``send_message``/``send_failure`` calls a caller depends on as a - control signal. Returns whether the event was actually accepted, so a - caller that only wants to update its own bookkeeping once delivery is - confirmed (e.g. marking a session id persisted) can act on it. + unlike a ``send_message`` call a caller depends on as a control signal. + Returns whether the event was actually accepted, so a caller that only + wants to update its own bookkeeping once delivery is confirmed (e.g. + marking a session id persisted) can act on it. """ try: await tools.send_event( diff --git a/tests/core/test_protocols.py b/tests/core/test_protocols.py index 462966cc3..0e4bf2828 100644 --- a/tests/core/test_protocols.py +++ b/tests/core/test_protocols.py @@ -2,10 +2,13 @@ from __future__ import annotations +import logging + import pytest from band_sdk_core import AgentFailure -from band.core.protocols import to_failure_event +from band.core.protocols import send_event_safe, to_failure_event +from band.testing.fake_tools import FakeAgentTools class TestToFailureEvent: @@ -48,3 +51,51 @@ def test_generic_fallback_has_no_code_or_detail(self) -> None: assert content == "boom" assert metadata["failure"]["code"] is None assert metadata["failure"]["detail"] is None + + +class TestSendEventSafe: + """send_event_safe is the shared best-effort event sender every migrated + adapter's non-critical telemetry (thoughts, task/lifecycle markers) goes + through -- a regression here silently drops events across every one of + them.""" + + async def test_forwards_a_successful_send_and_returns_true(self) -> None: + tools = FakeAgentTools() + + sent = await send_event_safe(tools, "hello", "thought") + + assert sent is True + assert tools.events_sent[0]["content"] == "hello" + + async def test_swallows_a_send_event_failure_and_logs_at_the_given_level( + self, caplog: pytest.LogCaptureFixture + ) -> None: + tools = FakeAgentTools() + tools.send_event_error = RuntimeError("platform rejected the event") + + with caplog.at_level(logging.DEBUG, logger="band.core.protocols"): + sent = await send_event_safe( + tools, + "hello", + "task", + log_label="widget event", + log_level=logging.DEBUG, + ) + + assert sent is False + assert tools.events_sent == [] + record = next(r for r in caplog.records if r.name == "band.core.protocols") + assert record.levelno == logging.DEBUG + assert "widget event" in record.message + + async def test_default_log_level_is_warning( + self, caplog: pytest.LogCaptureFixture + ) -> None: + tools = FakeAgentTools() + tools.send_event_error = RuntimeError("boom") + + with caplog.at_level(logging.WARNING, logger="band.core.protocols"): + await send_event_safe(tools, "hello", "task") + + record = next(r for r in caplog.records if r.name == "band.core.protocols") + assert record.levelno == logging.WARNING From 0f8ac322d15bdc100dc49f641f1ac00921692e9d Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 9 Sep 2026 22:02:09 +0300 Subject: [PATCH 42/53] refactor(a2a): dedupe state_name(state); consolidate tests onto reported_failures() _deliver_task_update's terminal-failure branch called state_name(state) three times for one value across four lines; binds it once. Also consolidates two tests that hand-rolled an error_events filter alongside a separate reported_failures(tools) call for the same data -- both checks now go through reported_failures() alone. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011uGMZeouBGz9s3vKCe2uhj --- src/band/integrations/a2a/adapter.py | 7 ++++--- tests/integrations/a2a/test_adapter.py | 26 ++++++++++---------------- 2 files changed, 14 insertions(+), 19 deletions(-) diff --git a/src/band/integrations/a2a/adapter.py b/src/band/integrations/a2a/adapter.py index 86646deba..ffb903946 100644 --- a/src/band/integrations/a2a/adapter.py +++ b/src/band/integrations/a2a/adapter.py @@ -294,11 +294,12 @@ async def _deliver_task_update( return if state in TERMINAL_TASK_STATES: - error_text = self._get_status_text(task) or f"Task {state_name(state)}" + state_str = state_name(state) + error_text = self._get_status_text(task) or f"Task {state_str}" logger.warning( - "Task %s: peer A2A task ended in state %s", task.id, state_name(state) + "Task %s: peer A2A task ended in state %s", task.id, state_str ) - await tools.send_failure(AgentFailure("a2a", error_text, state_name(state))) + await tools.send_failure(AgentFailure("a2a", error_text, state_str)) raise TurnResultAlreadyReported(error_text) def _finalize_task(self, room_id: str, task_id: str) -> None: diff --git a/tests/integrations/a2a/test_adapter.py b/tests/integrations/a2a/test_adapter.py index 6a0745c87..94f940e1e 100644 --- a/tests/integrations/a2a/test_adapter.py +++ b/tests/integrations/a2a/test_adapter.py @@ -356,14 +356,11 @@ async def test_auth_required_task_is_posted_as_error_event( "Test User", ) - error_events = [ - event for event in tools.events_sent if event["message_type"] == "error" - ] - assert error_events, "an auth-required task must produce an error event" - assert error_events[-1]["content"] == "Please authenticate" - failure = reported_failures(tools)[-1] - assert failure["provider"] == "a2a" - assert failure["code"] == "TASK_STATE_AUTH_REQUIRED" + failures = reported_failures(tools) + assert failures, "an auth-required task must produce an error event" + assert failures[-1]["message"] == "Please authenticate" + assert failures[-1]["provider"] == "a2a" + assert failures[-1]["code"] == "TASK_STATE_AUTH_REQUIRED" @pytest.mark.asyncio async def test_input_required_is_forwarded_and_persisted( @@ -459,14 +456,11 @@ async def test_failed_task_is_posted_as_error_event( "Test User", ) - error_events = [ - event for event in tools.events_sent if event["message_type"] == "error" - ] - assert error_events, "a failed task must produce an error event" - assert error_events[-1]["content"] == "boom" - failure = reported_failures(tools)[-1] - assert failure["provider"] == "a2a" - assert failure["code"] == "TASK_STATE_FAILED" + failures = reported_failures(tools) + assert failures, "a failed task must produce an error event" + assert failures[-1]["message"] == "boom" + assert failures[-1]["provider"] == "a2a" + assert failures[-1]["code"] == "TASK_STATE_FAILED" @pytest.mark.asyncio async def test_working_status_text_is_narrated_as_thought( From 86c2f3953cb7a43654dd86d8bd586a87141b18bf Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 9 Sep 2026 22:02:13 +0300 Subject: [PATCH 43/53] test(agno): consolidate redundant error-event assertions onto reported_failures() Three tests hand-rolled an errors/error_events filter over tools.events_sent alongside a separate reported_failures(tools) call checking the same data (one of the three still had the pre-migration hand-rolled check as its only check, with no reported_failures() call at all). All three now assert through reported_failures() alone. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011uGMZeouBGz9s3vKCe2uhj --- tests/adapters/agno/test_adapter.py | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/tests/adapters/agno/test_adapter.py b/tests/adapters/agno/test_adapter.py index 9fe138a68..019a9aa70 100644 --- a/tests/adapters/agno/test_adapter.py +++ b/tests/adapters/agno/test_adapter.py @@ -889,18 +889,17 @@ async def test_emits_generic_error_event_and_reraises( room_id="room-A", ) - errors = [e for e in tools.events_sent if e["message_type"] == "error"] - assert len(errors) == 1 + failures = reported_failures(tools) + assert len(failures) == 1 assert ( - errors[0]["content"] + failures[0]["message"] == "Internal error while processing message; see agent logs." ) # The exception text (which can carry secrets) must not leak to the room. - assert "secret-token" not in errors[0]["content"] - failure = reported_failures(tools)[0] - assert failure["provider"] == "agno" + assert "secret-token" not in failures[0]["message"] + assert failures[0]["provider"] == "agno" # A plain RuntimeError isn't a swallowed Agno run status -- no code. - assert failure["code"] is None + assert failures[0]["code"] is None async def test_error_status_run_is_raised_and_reported( self, make_started_adapter, tools @@ -926,10 +925,10 @@ async def test_error_status_run_is_raised_and_reported( room_id="room-A", ) - errors = [e for e in tools.events_sent if e["message_type"] == "error"] - assert len(errors) == 1 - assert "secret-token" not in errors[0]["content"] - assert errors[0]["metadata"]["failure"]["code"] == RunStatus.error.value + failures = reported_failures(tools) + assert len(failures) == 1 + assert "secret-token" not in failures[0]["message"] + assert failures[0]["code"] == RunStatus.error.value # A failed turn must not be committed to the room transcript. assert not adapter._message_history.get("room-A") @@ -956,10 +955,10 @@ async def test_streaming_error_event_is_raised_and_reported( room_id="room-A", ) - errors = [e for e in tools.events_sent if e["message_type"] == "error"] - assert len(errors) == 1 - assert "secret-token" not in errors[0]["content"] - assert reported_failures(tools)[0]["code"] == RunStatus.error.value + failures = reported_failures(tools) + assert len(failures) == 1 + assert "secret-token" not in failures[0]["message"] + assert failures[0]["code"] == RunStatus.error.value assert not adapter._message_history.get("room-A") async def test_error_event_failure_does_not_mask_original( From 58d3a910ab96fb7002c20c5d5c75920b1a8fd4cb Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 9 Sep 2026 22:02:17 +0300 Subject: [PATCH 44/53] test(letta): consolidate redundant error-event assertions onto reported_failures() Three tests (test_timeout_reports_error, test_failed_tool_resync_skips_turn, test_prepare_failure_reports_error_and_skips_turn) hand-rolled an error_events filter over tools.events_sent alongside a separate reported_failures(tools) call checking the same data. All three now assert through reported_failures() alone. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011uGMZeouBGz9s3vKCe2uhj --- tests/adapters/test_letta_adapter.py | 11 +++++------ tests/adapters/test_letta_mcp.py | 18 ++++++++---------- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/tests/adapters/test_letta_adapter.py b/tests/adapters/test_letta_adapter.py index 85a19280d..267bd68bf 100644 --- a/tests/adapters/test_letta_adapter.py +++ b/tests/adapters/test_letta_adapter.py @@ -250,12 +250,11 @@ async def slow_response(**kwargs: Any) -> MagicMock: room_id="room-1", ) - error_events = [e for e in tools.events_sent if e["message_type"] == "error"] - assert len(error_events) == 1 - assert "timed out" in error_events[0]["content"] - failure = reported_failures(tools)[0] - assert failure["provider"] == "letta" - assert failure["code"] == "timeout" + failures = reported_failures(tools) + assert len(failures) == 1 + assert "timed out" in failures[0]["message"] + assert failures[0]["provider"] == "letta" + assert failures[0]["code"] == "timeout" @pytest.mark.asyncio async def test_generic_exception_reports_and_propagates( diff --git a/tests/adapters/test_letta_mcp.py b/tests/adapters/test_letta_mcp.py index f1f21329c..aa44d9f94 100644 --- a/tests/adapters/test_letta_mcp.py +++ b/tests/adapters/test_letta_mcp.py @@ -531,11 +531,10 @@ async def test_failed_tool_resync_skips_turn(self) -> None: room_id="room-1", ) - error_events = [e for e in tools.events_sent if e["message_type"] == "error"] - assert len(error_events) == 1 - failure = reported_failures(tools)[0] - assert failure["provider"] == "letta" - assert failure["message"] == GENERIC_PROVIDER_FAILURE_MESSAGE + failures = reported_failures(tools) + assert len(failures) == 1 + assert failures[0]["provider"] == "letta" + assert failures[0]["message"] == GENERIC_PROVIDER_FAILURE_MESSAGE mock_client.agents.messages.create.assert_not_called() @pytest.mark.asyncio @@ -781,11 +780,10 @@ async def test_prepare_failure_reports_error_and_skips_turn(self) -> None: room_id="room-1", ) - error_events = [e for e in tools.events_sent if e["message_type"] == "error"] - assert len(error_events) == 1 - failure = reported_failures(tools)[0] - assert failure["provider"] == "letta" - assert failure["message"] == GENERIC_PROVIDER_FAILURE_MESSAGE + failures = reported_failures(tools) + assert len(failures) == 1 + assert failures[0]["provider"] == "letta" + assert failures[0]["message"] == GENERIC_PROVIDER_FAILURE_MESSAGE mock_client.agents.messages.create.assert_not_called() @pytest.mark.asyncio From 004b4fc3c90c8b818c2f3ca6b026e877cb3a8c4e Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 9 Sep 2026 22:02:21 +0300 Subject: [PATCH 45/53] test(testing): cover reported_failures()'s ordering and metadata filtering reported_failures()/events_of_type() are now depended on by roughly 15 adapter test files as the sole way to assert on a reported AgentFailure, but had no direct test of their own -- in particular the documented "ignores an error event with no failure metadata" behavior was never exercised. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011uGMZeouBGz9s3vKCe2uhj --- tests/testing/test_fake_tools.py | 33 +++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/tests/testing/test_fake_tools.py b/tests/testing/test_fake_tools.py index 55755b4d5..86918b048 100644 --- a/tests/testing/test_fake_tools.py +++ b/tests/testing/test_fake_tools.py @@ -10,7 +10,7 @@ from band.core.exceptions import BandToolError from band.core.protocols import AgentToolsProtocol from band.runtime.tools import DEFAULT_FILE_CAPTION, serialize_tool_result -from band.testing import FakeAgentTools +from band.testing import FakeAgentTools, reported_failures from tests.content import BLANK_CONTENT_CASES @@ -285,6 +285,37 @@ async def test_send_event_itself_still_raises_on_the_same_failure(self): await tools.send_event("task update", "task") +class TestReportedFailures: + """reported_failures() is the shared projection every migrated adapter's + test suite uses to assert on a reported AgentFailure -- a regression here + would silently weaken failure assertions across the whole test suite.""" + + async def test_returns_each_reported_failure_in_order(self): + tools = FakeAgentTools() + + await tools.send_failure(AgentFailure("codex", "first")) + await tools.send_failure(AgentFailure("letta", "second")) + + failures = reported_failures(tools) + + assert [f["message"] for f in failures] == ["first", "second"] + assert [f["provider"] for f in failures] == ["codex", "letta"] + + async def test_ignores_a_plain_error_event_with_no_failure_metadata(self): + """A pre-migration send_event(..., "error") call carries no + ``failure`` metadata -- it must not be mistaken for a reported + AgentFailure.""" + tools = FakeAgentTools() + + await tools.send_event("legacy error text", "error") + await tools.send_failure(AgentFailure("codex", "structured failure")) + + failures = reported_failures(tools) + + assert len(failures) == 1 + assert failures[0]["message"] == "structured failure" + + class TestParticipantOperations: """Tests for participant tracking.""" From 956e0f77247f11e75ec7a3e97ef76690059448dd Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 9 Sep 2026 22:19:11 +0300 Subject: [PATCH 46/53] fix(tests): disable crewai's own telemetry for the test session crewai's event bus installs a global OpenTelemetry TracerProvider and a live span-exporter thread at import time (crewai.events.event_listener's module-level singleton). In a venv where crewai is installed alongside everything else, this silently breaks tests/example_agents/test_otel_setup.py's "nothing is a global" assertion for any test collected in the same session, and leaves a live network connection open that delays process exit. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011uGMZeouBGz9s3vKCe2uhj --- tests/conftest.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 5c9f68553..f6c6877a9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,8 +15,13 @@ from __future__ import annotations -import asyncio import os + +# Must be set before crewai is first imported: its event bus installs a +# global OpenTelemetry provider and a live exporter thread at import time. +os.environ.setdefault("CREWAI_DISABLE_TELEMETRY", "true") + +import asyncio from datetime import datetime, timezone from functools import cache from itertools import count From c237ec4d20f1e6d4787b521878d4ca15b3a0ec8c Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Wed, 9 Sep 2026 22:19:16 +0300 Subject: [PATCH 47/53] fix(tests): restore HOME after launcher tests call main() in-process main() deliberately mutates the real process HOME (os.environ["HOME"] = AGENT_HOME) as a production side effect for the real launcher process. Two tests in test_exec.py call main() in-process, so nothing reverted that mutation -- HOME stayed "/home/agent" for the rest of the pytest session, breaking any later test resolving Path.home() on a machine where that path doesn't exist (observed via tests/integrations/test_crewai_flow_real_sdk.py, which lazily resolves it through crewai's own storage-path code). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011uGMZeouBGz9s3vKCe2uhj --- tests/docker/launcher/conftest.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/docker/launcher/conftest.py b/tests/docker/launcher/conftest.py index 5090b83d6..7350acee0 100644 --- a/tests/docker/launcher/conftest.py +++ b/tests/docker/launcher/conftest.py @@ -7,6 +7,7 @@ from __future__ import annotations +import os from pathlib import Path import pytest @@ -30,6 +31,13 @@ def as_agent_uid(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(launcher_run, "current_uid", lambda: AGENT_UID) +@pytest.fixture(autouse=True) +def restore_home(monkeypatch: pytest.MonkeyPatch) -> None: + """main() sets the real process HOME as a deliberate side effect; pin it + through monkeypatch so a test calling main() in-process doesn't leak it.""" + monkeypatch.setenv("HOME", os.environ.get("HOME", "")) + + @pytest.fixture def workspace(tmp_path: Path) -> Workspace: return make_workspace(tmp_path) From 80146b7bd11e8ca4ef546b4c07a662af52632ae7 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Mon, 14 Sep 2026 11:21:11 +0300 Subject: [PATCH 48/53] fix: address code-review findings on Codex turn-failure reporting An unexpected exception while consuming the turn's event stream fell through to a plain "I couldn't complete this request" chat reply with zero send_failure call, bypassing on_message's own outer AgentFailure handler -- the opposite of this PR's goal. It now reports and raises TurnResultAlreadyReported like every sibling failure path. transport/closed also reported unconditionally, double-posting one incident when an "error" notification already reported it just before the transport dropped; it now respects the same failure_reported guard turn/completed already uses. Also replaces codex.py's four bare "codex" AgentFailure provider literals (plus types.py's build_agent_failure) with one CODEX_PROVIDER constant, matching claude_sdk.py/letta.py's existing _PROVIDER pattern from this same PR -- a typo in one occurrence would otherwise silently break provider filtering for that failure event alone. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JjhSR1MngdKxXiSJctQjun --- src/band/adapters/codex.py | 27 +++++++---- src/band/integrations/codex/__init__.py | 2 + src/band/integrations/codex/types.py | 4 +- tests/adapters/test_codex_adapter.py | 64 ++++++++++++++++++++----- 4 files changed, 74 insertions(+), 23 deletions(-) diff --git a/src/band/adapters/codex.py b/src/band/adapters/codex.py index 8e8ebcbb1..5acfac4bb 100644 --- a/src/band/adapters/codex.py +++ b/src/band/adapters/codex.py @@ -49,6 +49,7 @@ ) from band.integrations.codex.types import ( CODEX_APPROVAL_METHODS, + CODEX_PROVIDER, ApprovalAuditEntry, CodexApprovalMethod, CodexItemType, @@ -721,10 +722,12 @@ async def on_message( thread_id, turn_id, ) - result = TurnResult( - turn_status="failed", - turn_error="Internal error during turn processing", + await tools.send_failure( + AgentFailure(CODEX_PROVIDER, GENERIC_PROVIDER_FAILURE_MESSAGE) ) + raise TurnResultAlreadyReported( + "Internal error during turn processing" + ) from None _turn_duration_s = _time.perf_counter() - _turn_start await self._emit_turn_outcome( @@ -747,11 +750,11 @@ async def on_message( # A structured RPC error from the app-server (e.g. "model not # available") is safe, curated text -- unlike an arbitrary # caught exception, it's worth showing verbatim. - await tools.send_failure(AgentFailure("codex", str(e))) + await tools.send_failure(AgentFailure(CODEX_PROVIDER, str(e))) raise except Exception: await tools.send_failure( - AgentFailure("codex", GENERIC_PROVIDER_FAILURE_MESSAGE) + AgentFailure(CODEX_PROVIDER, GENERIC_PROVIDER_FAILURE_MESSAGE) ) raise @@ -998,9 +1001,15 @@ async def _process_turn_events( self._token_usage.pop(stale_thread, None) for stale_room in stale_rooms: self._clear_pending_approvals_for_room(stale_room) - await tools.send_failure( - AgentFailure("codex", result.turn_error, "transport_closed") - ) + # Skipped when an earlier "error" notification in this same + # turn already reported one, so one incident isn't posted + # twice -- but the turn still fails either way. + if not failure_reported: + await tools.send_failure( + AgentFailure( + CODEX_PROVIDER, result.turn_error, "transport_closed" + ) + ) raise TurnResultAlreadyReported(result.turn_error) if event.method == "turn/completed": @@ -1048,7 +1057,7 @@ async def _process_turn_events( ) await tools.send_failure( AgentFailure( - "codex", + CODEX_PROVIDER, f"Codex turn timed out after {self.config.turn_timeout_s}s", FAILURE_CODE_TIMEOUT, ) diff --git a/src/band/integrations/codex/__init__.py b/src/band/integrations/codex/__init__.py index 0ef0c6102..dc3d96a3a 100644 --- a/src/band/integrations/codex/__init__.py +++ b/src/band/integrations/codex/__init__.py @@ -10,6 +10,7 @@ from .stdio_client import CodexStdioClient from .types import ( CODEX_APPROVAL_METHODS, + CODEX_PROVIDER, ApprovalAuditEntry, CodexApprovalMethod, CodexItemType, @@ -23,6 +24,7 @@ __all__ = [ "CODEX_APPROVAL_METHODS", + "CODEX_PROVIDER", "ApprovalAuditEntry", "CodexApprovalMethod", "CodexItemType", diff --git a/src/band/integrations/codex/types.py b/src/band/integrations/codex/types.py index 5014abf04..347e9206a 100644 --- a/src/band/integrations/codex/types.py +++ b/src/band/integrations/codex/types.py @@ -61,6 +61,8 @@ class CodexApprovalMethod(StrEnum): CODEX_APPROVAL_METHODS: frozenset[CodexApprovalMethod] = frozenset(CodexApprovalMethod) +CODEX_PROVIDER = "codex" + @dataclass class CodexSessionState: @@ -135,7 +137,7 @@ def build_agent_failure( if capped is not None: detail["codex_additional_details"] = capped - return AgentFailure("codex", message, error_type, detail or None) + return AgentFailure(CODEX_PROVIDER, message, error_type, detail or None) def _cap_error_detail(value: Any) -> Any: diff --git a/tests/adapters/test_codex_adapter.py b/tests/adapters/test_codex_adapter.py index 460b045cc..5e6ed47ee 100644 --- a/tests/adapters/test_codex_adapter.py +++ b/tests/adapters/test_codex_adapter.py @@ -1477,6 +1477,42 @@ async def test_transport_closed_drains_token_usage_for_dead_threads( # on_cleanup (the room id can no longer look up the thread id). assert "old-thread-id" not in adapter._token_usage + @pytest.mark.asyncio + async def test_transport_closed_after_error_does_not_double_report(self) -> None: + """An "error" notification immediately followed by transport/closed for + the same incident must report the failure once, not twice -- matching + the turn/completed branch's existing failure_reported guard.""" + events = [ + _event_notification( + "error", + {"error": {"message": "Something went wrong"}, "willRetry": False}, + ), + _event_notification( + "transport/closed", + {"reason": "Codex process exited unexpectedly"}, + ), + ] + fake_client = FakeCodexClient(events=events) + adapter = CodexAdapter( + config=CodexAdapterConfig(transport="ws"), + client_factory=lambda _config: fake_client, + ) + tools = ToolSchemaFakeTools() + await adapter.on_started("Codex Agent", "A coding agent") + + with pytest.raises(TurnResultAlreadyReported): + await adapter.on_message( + make_platform_message(), + tools, + CodexSessionState(), + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-1", + ) + + assert len(reported_failures(tools)) == 1 + @pytest.mark.asyncio async def test_turn_timeout_sends_interrupt_and_clean_error(self) -> None: """When recv_event times out, the adapter sends turn/interrupt, reports @@ -5145,8 +5181,10 @@ def test_session_approval_key_empty_prevents_wildcard_match(self) -> None: assert not (key and key in {"commandExecution:npm"}) @pytest.mark.asyncio - async def test_unexpected_recv_error_still_emits_turn_outcome(self) -> None: - """When recv_event raises a non-timeout exception, _emit_turn_outcome is still called.""" + async def test_unexpected_recv_error_reports_and_fails_turn(self) -> None: + """When recv_event raises a non-timeout exception, the adapter reports + an AgentFailure and fails the turn instead of silently degrading to a + plain chat reply with no structured signal at all.""" class BrokenClient(FakeCodexClient): async def recv_event(self, timeout_s: float | None = None) -> RpcEvent: @@ -5162,17 +5200,17 @@ async def recv_event(self, timeout_s: float | None = None) -> RpcEvent: ) tools = ToolSchemaFakeTools() await adapter.on_started("Agent", "A coding agent") - await adapter.on_message( - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - # Should have sent an error message to the user instead of crashing - assert any("couldn't complete" in m["content"] for m in tools.messages_sent) + with pytest.raises(TurnResultAlreadyReported): + await adapter.on_message( + make_platform_message(), + tools, + CodexSessionState(), + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-1", + ) + assert len(reported_failures(tools)) == 1 # =========================================================================== From 0b718650678bbc55f3165d096f44acea38237e60 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Mon, 14 Sep 2026 13:30:37 +0300 Subject: [PATCH 49/53] fix: address /my-code-review findings across adapter failure reporting - Add a per-adapter `_PROVIDER` constant (gemini, parlant, crewai, copilot_sdk, strands, a2a, a2a gateway, acp client, opencode, pydantic_ai) so every AgentFailure call site references one source of truth instead of a repeated string literal. - Log before every previously-silent generic-exception send_failure path (codex, strands, pydantic_ai) so a turn failure leaves a stack trace, not just a user-facing message. - Fix a real crash: codex.py's outer except could reference thread_id/ turn_id before either was ever assigned if _ensure_client_ready or _ensure_thread itself raised; both are now initialized up front. - Guard codex.py's manual-approval notification: a failed room post while requesting approval now reports a failure and defaults to "decline" instead of leaving the approval unresolved. - Replace acp/client_adapter.py's asyncio.gather(stop(), send_failure()) with sequential awaits so a fast send_failure can't race a slower stop. - Preserve gemini.py ServerError.status's real value (including None) instead of always stringifying it. - Broaden a2a gateway's credential-redaction regex to cover password/secret/access-key keys, not just token/authorization/api-key. - Narrow letta.py's turn_timeout_s to bound only the Letta provider call, not response processing/delivery -- a slow Band-side reply post could previously race the same clock and get misreported as a Letta provider timeout. - Convert several tests (letta, copilot_sdk, a2a, strands conformance) to assert on reported_failures(tools) instead of hand-filtering tools.events_sent, and add regression coverage for each fix above. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JjhSR1MngdKxXiSJctQjun --- src/band/adapters/codex.py | 29 ++++++++- src/band/adapters/copilot_sdk.py | 11 ++-- src/band/adapters/crewai.py | 9 ++- src/band/adapters/gemini.py | 10 ++- src/band/adapters/letta.py | 61 +++++++++++-------- src/band/adapters/opencode/adapter.py | 11 ++-- src/band/adapters/parlant.py | 9 ++- src/band/adapters/pydantic_ai.py | 8 ++- src/band/adapters/strands.py | 8 ++- src/band/integrations/a2a/adapter.py | 7 ++- src/band/integrations/a2a/gateway/adapter.py | 11 +++- src/band/integrations/acp/client_adapter.py | 26 ++++---- tests/adapters/agno/test_adapter.py | 6 +- tests/adapters/copilot_sdk/test_reply.py | 8 +-- tests/adapters/test_codex_adapter.py | 7 +++ tests/adapters/test_gemini_adapter.py | 30 +++++++++ tests/adapters/test_letta_adapter.py | 57 +++++++++++++++-- .../test_strands_injection_spike.py | 10 ++- .../integrations/a2a/gateway/test_adapter.py | 52 ++++++++++++++++ tests/integrations/a2a/test_adapter.py | 7 ++- 20 files changed, 292 insertions(+), 85 deletions(-) diff --git a/src/band/adapters/codex.py b/src/band/adapters/codex.py index 5acfac4bb..63fd080fd 100644 --- a/src/band/adapters/codex.py +++ b/src/band/adapters/codex.py @@ -595,6 +595,8 @@ async def on_message( return async with self._rpc_lock: + thread_id: str | None = None + turn_id: str | None = None try: await self._ensure_client_ready() if self._client is None: @@ -753,6 +755,11 @@ async def on_message( await tools.send_failure(AgentFailure(CODEX_PROVIDER, str(e))) raise except Exception: + logger.exception( + "Unexpected error in Codex on_message (thread=%s, turn=%s)", + thread_id, + turn_id, + ) await tools.send_failure( AgentFailure(CODEX_PROVIDER, GENERIC_PROVIDER_FAILURE_MESSAGE) ) @@ -2203,7 +2210,27 @@ async def _resolve_manual_approval( log_label="approval request task event", log_level=logging.DEBUG, ) - await tools.send_message(approval_msg, mentions=mention) + try: + await tools.send_message(approval_msg, mentions=mention) + except Exception: + # The room was never notified, so waiting out the full + # approval_wait_timeout_s would misreport a Band delivery + # hiccup as a genuine human-decision timeout. Report it now, + # rather than letting it silently decline with no signal at + # all, same as every other failure path in this file. + logger.exception( + "Failed to notify room %s about pending approval %s", + room_id, + token, + ) + await tools.send_failure( + AgentFailure( + CODEX_PROVIDER, + "Failed to notify the room about a pending approval " + "request; defaulting to decline.", + ) + ) + return "decline" decision_raw = await asyncio.wait_for( pending.future, timeout=self.config.approval_wait_timeout_s, diff --git a/src/band/adapters/copilot_sdk.py b/src/band/adapters/copilot_sdk.py index ae7b5564a..8a5b483f7 100644 --- a/src/band/adapters/copilot_sdk.py +++ b/src/band/adapters/copilot_sdk.py @@ -95,6 +95,9 @@ logger = logging.getLogger(__name__) +# AgentFailure.provider tag for every failure this adapter reports. +_PROVIDER = "copilot_sdk" + @dataclass(frozen=True) class CopilotSDKAdapterConfig: @@ -417,7 +420,7 @@ async def on_message( except Exception: logger.exception("Room %s: Copilot session setup failed", room_id) await tools.send_failure( - AgentFailure("copilot_sdk", GENERIC_PROVIDER_FAILURE_MESSAGE) + AgentFailure(_PROVIDER, GENERIC_PROVIDER_FAILURE_MESSAGE) ) raise prompt = self._compose_prompt( @@ -445,7 +448,7 @@ async def on_message( # drop the session; the next message resumes it fresh by id. await self._session_manager.evict_session(room_id) await tools.send_failure( - AgentFailure("copilot_sdk", GENERIC_PROVIDER_FAILURE_MESSAGE) + AgentFailure(_PROVIDER, GENERIC_PROVIDER_FAILURE_MESSAGE) ) raise finally: @@ -461,9 +464,7 @@ async def on_message( # with no room output means the model genuinely said nothing. if final_text is None and not turn.replied_in_room: logger.warning("Room %s: Copilot turn produced no reply", room_id) - await tools.send_failure( - AgentFailure("copilot_sdk", "no assistant reply") - ) + await tools.send_failure(AgentFailure(_PROVIDER, "no assistant reply")) raise RuntimeError("Copilot turn produced no reply") # The turn may already have replied into the room; sending its diff --git a/src/band/adapters/crewai.py b/src/band/adapters/crewai.py index aef8ebd5f..fce43592a 100644 --- a/src/band/adapters/crewai.py +++ b/src/band/adapters/crewai.py @@ -41,6 +41,9 @@ logger = logging.getLogger(__name__) +# AgentFailure.provider tag for every failure this adapter reports. +_PROVIDER = "crewai" + # Context variable for thread-safe room context access. # Set automatically when processing messages, accessed by tools. @@ -301,7 +304,7 @@ async def on_message( if not self._crewai_agent: message = "CrewAI agent not initialized - ensure on_started() was called" - await tools.send_failure(AgentFailure("crewai", message)) + await tools.send_failure(AgentFailure(_PROVIDER, message)) raise RuntimeError(message) # Set context variable for tool access (thread-safe room context). @@ -420,7 +423,7 @@ async def _process_message( if not (_is_empty_llm_response(e) and reply_tracker.any_tool_ran): logger.error("Error processing message: %s", e, exc_info=True) await tools.send_failure( - AgentFailure("crewai", GENERIC_PROVIDER_FAILURE_MESSAGE) + AgentFailure(_PROVIDER, GENERIC_PROVIDER_FAILURE_MESSAGE) ) raise # Keep the exception text: it is the only record that CrewAI raised, @@ -448,7 +451,7 @@ async def _process_message( f"max_iter={self.max_iter}." ), ) - await tools.send_failure(AgentFailure("crewai", detail)) + await tools.send_failure(AgentFailure(_PROVIDER, detail)) if not reply_tracker.any_tool_ran: # Some tool activity (even read-only) means the turn did what it # was asked and correctly had nothing left to say -- report but diff --git a/src/band/adapters/gemini.py b/src/band/adapters/gemini.py index 3bac6e264..b7cfb1dbd 100644 --- a/src/band/adapters/gemini.py +++ b/src/band/adapters/gemini.py @@ -54,6 +54,9 @@ logger = logging.getLogger(__name__) +# AgentFailure.provider tag for every failure this adapter reports. +_PROVIDER = "gemini" + def _image_function_response_parts( result: dict[str, Any], @@ -79,8 +82,9 @@ def _to_agent_failure(e: Exception) -> AgentFailure: exception's text alone does not. """ if isinstance(e, ServerError): - return AgentFailure("gemini", str(e), e.status, e.message) - return AgentFailure("gemini", GENERIC_PROVIDER_FAILURE_MESSAGE) + status = e.status if e.status is None else str(e.status) + return AgentFailure(_PROVIDER, str(e), status, e.message) + return AgentFailure(_PROVIDER, GENERIC_PROVIDER_FAILURE_MESSAGE) class GeminiAdapter(SimpleAdapter[GeminiMessages]): @@ -256,7 +260,7 @@ async def on_message( f"Exceeded max tool rounds ({self.max_tool_rounds}) " f"in room {room_id}" ) - await tools.send_failure(AgentFailure("gemini", message)) + await tools.send_failure(AgentFailure(_PROVIDER, message)) raise RuntimeError(message) try: diff --git a/src/band/adapters/letta.py b/src/band/adapters/letta.py index 540bc932d..3f6d174df 100644 --- a/src/band/adapters/letta.py +++ b/src/band/adapters/letta.py @@ -425,16 +425,13 @@ async def _run_turn( "Room %s: Sending message to Letta agent %s", room_id, room_ctx.agent_id ) try: - final_text_parts = await asyncio.wait_for( - self._send_message( - agent_id=room_ctx.agent_id, - content=content, - tools=tools, - room_ctx=room_ctx, - room_id=room_id, - reply_to_sender_id=msg.sender_id, - ), - timeout=self.config.turn_timeout_s, + final_text_parts = await self._send_message( + agent_id=room_ctx.agent_id, + content=content, + tools=tools, + room_ctx=room_ctx, + room_id=room_id, + reply_to_sender_id=msg.sender_id, ) except DeliveryFailedError as e: reraise_delivery_cause(e) @@ -494,21 +491,13 @@ async def _send_message( turn_usage = TurnUsage() try: - # Use Conversations API in shared mode, direct agent API in per_room mode - if self.config.mode == "shared" and room_ctx.conversation_id: - conversation_stream = await self._client.conversations.messages.create( - conversation_id=room_ctx.conversation_id, - messages=messages, - ) - response_messages = [resp_msg async for resp_msg in conversation_stream] - else: - response = await self._client.agents.messages.create( - agent_id=agent_id, - messages=messages, - ) - response_messages = list(response.messages) - turn_usage = self._usage_from_response(response) - + # turn_timeout_s bounds only the round-trip to Letta -- response + # processing (including deliver_reply, below) runs unbounded so a + # slow Band-side delivery is never mislabeled as a Letta timeout. + response_messages, turn_usage = await asyncio.wait_for( + self._call_provider(agent_id, messages, room_ctx), + timeout=self.config.turn_timeout_s, + ) return await self._process_response_messages( response_messages, tools, @@ -519,6 +508,28 @@ async def _send_message( # No-op unless Emit.USAGE is on; best-effort, never raises. await self.emit_usage(tools, turn_usage) + async def _call_provider( + self, + agent_id: str, + messages: list[dict[str, str]], + room_ctx: RoomContext, + ) -> tuple[list[Any], TurnUsage]: + """Round-trip to the Letta API -- no response processing or delivery.""" + # Use Conversations API in shared mode, direct agent API in per_room mode + if self.config.mode == "shared" and room_ctx.conversation_id: + conversation_stream = await self._client.conversations.messages.create( + conversation_id=room_ctx.conversation_id, + messages=messages, + ) + response_messages = [resp_msg async for resp_msg in conversation_stream] + return response_messages, TurnUsage() + + response = await self._client.agents.messages.create( + agent_id=agent_id, + messages=messages, + ) + return list(response.messages), self._usage_from_response(response) + async def _process_response_messages( self, response_messages: list[Any], diff --git a/src/band/adapters/opencode/adapter.py b/src/band/adapters/opencode/adapter.py index a9279d0e8..b1b2dab54 100644 --- a/src/band/adapters/opencode/adapter.py +++ b/src/band/adapters/opencode/adapter.py @@ -68,6 +68,9 @@ logger = logging.getLogger(__name__) +# AgentFailure.provider tag for every failure this adapter reports. +_PROVIDER = "opencode" + _OPENCODE_SYSTEM_NOTE = """\ Responses are relayed back into the Band room by the adapter. Use the band_ prefixed tools (e.g. band_send_message) for Band platform actions when available. @@ -500,7 +503,7 @@ async def on_message( logger.exception("OpenCode request failed for room %s", room_id) await tools.send_failure( AgentFailure( - "opencode", + _PROVIDER, self._format_http_error(exc), str(exc.response.status_code), ) @@ -509,7 +512,7 @@ async def on_message( except Exception: logger.exception("Unexpected OpenCode adapter failure in room %s", room_id) await tools.send_failure( - AgentFailure("opencode", GENERIC_PROVIDER_FAILURE_MESSAGE) + AgentFailure(_PROVIDER, GENERIC_PROVIDER_FAILURE_MESSAGE) ) raise @@ -940,7 +943,7 @@ async def _watch_turn_completion( if room_state.tools: await room_state.tools.send_failure( AgentFailure( - "opencode", + _PROVIDER, "OpenCode timed out before completing the turn.", FAILURE_CODE_TIMEOUT, ) @@ -1138,7 +1141,7 @@ async def _deliver_fallback_text(self, room_state: RoomState) -> None: ) elif room_state.last_error_message: await room_state.tools.send_failure( - AgentFailure("opencode", room_state.last_error_message) + AgentFailure(_PROVIDER, room_state.last_error_message) ) elif not replied: await room_state.tools.send_message( diff --git a/src/band/adapters/parlant.py b/src/band/adapters/parlant.py index dbc4c69ef..fe1db6cd0 100644 --- a/src/band/adapters/parlant.py +++ b/src/band/adapters/parlant.py @@ -42,6 +42,9 @@ logger = logging.getLogger(__name__) +# AgentFailure.provider tag for every failure this adapter reports. +_PROVIDER = "parlant" + # Parlant preamble message tag - used to identify acknowledgment messages before tool execution PARLANT_PREAMBLE_TAG = "__preamble__" @@ -384,7 +387,7 @@ async def on_message( if not self._app: message = "Parlant Application not initialized" logger.error(message) - await tools.send_failure(AgentFailure("parlant", message)) + await tools.send_failure(AgentFailure(_PROVIDER, message)) raise RuntimeError(message) app = self._app @@ -396,7 +399,7 @@ async def on_message( except Exception as e: logger.error("Failed to get/create session for room %s: %s", room_id, e) await tools.send_failure( - AgentFailure("parlant", GENERIC_PROVIDER_FAILURE_MESSAGE) + AgentFailure(_PROVIDER, GENERIC_PROVIDER_FAILURE_MESSAGE) ) raise session_id_str = str(session_id) @@ -458,7 +461,7 @@ async def on_message( except Exception as e: logger.error("Error processing message: %s", e, exc_info=True) await tools.send_failure( - AgentFailure("parlant", GENERIC_PROVIDER_FAILURE_MESSAGE) + AgentFailure(_PROVIDER, GENERIC_PROVIDER_FAILURE_MESSAGE) ) raise finally: diff --git a/src/band/adapters/pydantic_ai.py b/src/band/adapters/pydantic_ai.py index c0e7f2593..f9310295f 100644 --- a/src/band/adapters/pydantic_ai.py +++ b/src/band/adapters/pydantic_ai.py @@ -77,6 +77,9 @@ logger = logging.getLogger(__name__) +# AgentFailure.provider tag for every failure this adapter reports. +_PROVIDER = "pydantic_ai" + OUTPUT_RETRIES_EXHAUSTED = "exceeded maximum output retries" """pydantic-ai's wording when a run burns its output-retry budget. @@ -1066,8 +1069,9 @@ async def on_message( ModelRequest(parts=[UserPromptPart(content=user_message)]), ] return + logger.exception("Room %s: Pydantic AI turn failed", room_id) await tools.send_failure( - AgentFailure("pydantic_ai", GENERIC_PROVIDER_FAILURE_MESSAGE) + AgentFailure(_PROVIDER, GENERIC_PROVIDER_FAILURE_MESSAGE) ) raise finally: @@ -1092,7 +1096,7 @@ async def on_message( "Room %s: Pydantic AI turn produced nothing for the room", room_id ) detail = missing_reply_error("Pydantic AI") - await tools.send_failure(AgentFailure("pydantic_ai", detail)) + await tools.send_failure(AgentFailure(_PROVIDER, detail)) raise TurnResultAlreadyReported(detail) logger.debug( diff --git a/src/band/adapters/strands.py b/src/band/adapters/strands.py index 1c33e00cb..e5ec6067d 100644 --- a/src/band/adapters/strands.py +++ b/src/band/adapters/strands.py @@ -76,6 +76,9 @@ logger = logging.getLogger(__name__) +# AgentFailure.provider tag for every failure this adapter reports. +_PROVIDER = "strands" + def _format_tool_output(value: object) -> str: """Return a stable text representation accepted by Strands tool results.""" @@ -533,8 +536,9 @@ async def _run_turn( agent = self._build_agent(history, tools, hooks) await agent.invoke_async(message) except Exception: + logger.exception("Room %s: Strands turn failed", room_id) await tools.send_failure( - AgentFailure("strands", GENERIC_PROVIDER_FAILURE_MESSAGE) + AgentFailure(_PROVIDER, GENERIC_PROVIDER_FAILURE_MESSAGE) ) raise finally: @@ -584,7 +588,7 @@ async def on_message( "Room %s: Strands turn produced nothing for the room", room_id ) detail = missing_reply_error("Strands") - await tools.send_failure(AgentFailure("strands", detail)) + await tools.send_failure(AgentFailure(_PROVIDER, detail)) raise TurnResultAlreadyReported(detail) logger.debug( "Room %s: Strands agent completed (history now has %s messages)", diff --git a/src/band/integrations/a2a/adapter.py b/src/band/integrations/a2a/adapter.py index ffb903946..e7e40a159 100644 --- a/src/band/integrations/a2a/adapter.py +++ b/src/band/integrations/a2a/adapter.py @@ -47,6 +47,9 @@ logger = logging.getLogger(__name__) +# AgentFailure.provider tag for every failure this adapter reports. +_PROVIDER = "a2a" + # httpx's read timeout resets on every chunk received, so this bounds the gap # between SSE events, not the turn as a whole. Generous enough for the # multi-second silences of a live LLM call or tool loop; still finite, so a @@ -189,7 +192,7 @@ async def on_message( except Exception as e: logger.exception("A2A agent error: %s", e) await tools.send_failure( - AgentFailure("a2a", GENERIC_PROVIDER_FAILURE_MESSAGE) + AgentFailure(_PROVIDER, GENERIC_PROVIDER_FAILURE_MESSAGE) ) raise @@ -299,7 +302,7 @@ async def _deliver_task_update( logger.warning( "Task %s: peer A2A task ended in state %s", task.id, state_str ) - await tools.send_failure(AgentFailure("a2a", error_text, state_str)) + await tools.send_failure(AgentFailure(_PROVIDER, error_text, state_str)) raise TurnResultAlreadyReported(error_text) def _finalize_task(self, room_id: str, task_id: str) -> None: diff --git a/src/band/integrations/a2a/gateway/adapter.py b/src/band/integrations/a2a/gateway/adapter.py index 64e1ecd6f..edc2b7a06 100644 --- a/src/band/integrations/a2a/gateway/adapter.py +++ b/src/band/integrations/a2a/gateway/adapter.py @@ -41,6 +41,9 @@ logger = logging.getLogger(__name__) +# AgentFailure.provider tag for every failure this adapter reports. +_PROVIDER = "a2a-gateway" + @dataclass class GatewayRequest: @@ -86,7 +89,9 @@ def slugify(name: str) -> str: # scheme-prefixed credential (e.g. "Authorization: ApiKey sk-...") gets # redacted in full instead of leaking everything past the first space. _CREDENTIAL_KV_RE = re.compile( - r"(token|authorization|api[_-]?key)\s*[:=]\s*[^,;]+", re.IGNORECASE + r"(token|authorization|api[_-]?key|access[_-]?key|secret|password)" + r"\s*[:=]\s*[^,;]+", + re.IGNORECASE, ) @@ -396,7 +401,7 @@ async def _execute_a2a( request.pending.task.id, ) failure = AgentFailure( - "a2a-gateway", + _PROVIDER, _sanitize_gateway_error_message(exc), type(exc).__name__, ) @@ -482,7 +487,7 @@ async def _await_response(self, request: GatewayRequest) -> bool: self.config.response_timeout_s, ) failure = AgentFailure( - "a2a-gateway", + _PROVIDER, "Timed out waiting for a Band response", FAILURE_CODE_TIMEOUT, ) diff --git a/src/band/integrations/acp/client_adapter.py b/src/band/integrations/acp/client_adapter.py index fe8f98dea..baf50d6bf 100644 --- a/src/band/integrations/acp/client_adapter.py +++ b/src/band/integrations/acp/client_adapter.py @@ -67,6 +67,9 @@ logger = logging.getLogger(__name__) +# AgentFailure.provider tag for every failure this adapter reports. +_PROVIDER = "acp" + LocalMcpServerConfig = HttpMcpServer | SseMcpServer # Prefixes the change-triggered roster/contacts updates injected into a @@ -140,8 +143,8 @@ def _to_agent_failure(exc: Exception) -> AgentFailure: message alone. """ if isinstance(exc, RequestError): - return AgentFailure("acp", str(exc), str(exc.code), exc.data) - return AgentFailure("acp", GENERIC_PROVIDER_FAILURE_MESSAGE) + return AgentFailure(_PROVIDER, str(exc), str(exc.code), exc.data) + return AgentFailure(_PROVIDER, GENERIC_PROVIDER_FAILURE_MESSAGE) class ACPClientAdapter(SimpleAdapter[ACPClientSessionState]): @@ -395,20 +398,19 @@ async def on_message( room_id, session_id, ) - await asyncio.gather( - self.stop(), - tools.send_failure( - AgentFailure( - "acp", - f"ACP agent response timed out after {self._turn_timeout_s}s", - FAILURE_CODE_TIMEOUT, - ) - ), + await self.stop() + await tools.send_failure( + AgentFailure( + _PROVIDER, + f"ACP agent response timed out after {self._turn_timeout_s}s", + FAILURE_CODE_TIMEOUT, + ) ) raise except Exception as e: logger.exception("ACP agent error: %s", e) - await asyncio.gather(self.stop(), tools.send_failure(_to_agent_failure(e))) + await self.stop() + await tools.send_failure(_to_agent_failure(e)) raise def _make_permission_handler( diff --git a/tests/adapters/agno/test_adapter.py b/tests/adapters/agno/test_adapter.py index 019a9aa70..888112b29 100644 --- a/tests/adapters/agno/test_adapter.py +++ b/tests/adapters/agno/test_adapter.py @@ -29,6 +29,7 @@ _bind_room_tools, _make_band_entrypoint, ) +from band.core.protocols import GENERIC_PROVIDER_FAILURE_MESSAGE from band.core.types import Capability, Emit, PlatformMessage from band.testing import FakeAgentTools, reported_failures @@ -891,10 +892,7 @@ async def test_emits_generic_error_event_and_reraises( failures = reported_failures(tools) assert len(failures) == 1 - assert ( - failures[0]["message"] - == "Internal error while processing message; see agent logs." - ) + assert failures[0]["message"] == GENERIC_PROVIDER_FAILURE_MESSAGE # The exception text (which can carry secrets) must not leak to the room. assert "secret-token" not in failures[0]["message"] assert failures[0]["provider"] == "agno" diff --git a/tests/adapters/copilot_sdk/test_reply.py b/tests/adapters/copilot_sdk/test_reply.py index b1bc1225c..44536b757 100644 --- a/tests/adapters/copilot_sdk/test_reply.py +++ b/tests/adapters/copilot_sdk/test_reply.py @@ -7,6 +7,7 @@ from band.adapters.copilot_sdk import _COPILOT_SDK_AVAILABLE from band.core.protocols import GENERIC_PROVIDER_FAILURE_MESSAGE from band.runtime.tools import CHAT_ID_FIELD_NAME, ToolCallOutcome +from band.testing import reported_failures from tests.adapters.copilot_sdk.fakes import ( FakeCopilotClient, FakeCopilotSession, @@ -78,10 +79,9 @@ async def test_session_error_raises_reports_and_evicts(self): session = client.sessions[0] assert session.aborted and session.disconnected - error_events = [e for e in tools.events_sent if e["message_type"] == "error"] - assert error_events and error_events[0]["content"] == ( - GENERIC_PROVIDER_FAILURE_MESSAGE - ) + failures = reported_failures(tools) + assert failures and failures[0]["provider"] == "copilot_sdk" + assert failures[0]["message"] == GENERIC_PROVIDER_FAILURE_MESSAGE @pytest.mark.asyncio async def test_fallback_send_suppressed_when_band_send_message_fired(self): diff --git a/tests/adapters/test_codex_adapter.py b/tests/adapters/test_codex_adapter.py index 5e6ed47ee..20d1378bf 100644 --- a/tests/adapters/test_codex_adapter.py +++ b/tests/adapters/test_codex_adapter.py @@ -676,6 +676,13 @@ async def send_message( assert payload["decision"] == "decline" assert "room-1" not in adapter._pending_approvals + # The Band-delivery hiccup that caused this auto-decline must itself + # be reported -- otherwise it's indistinguishable from a genuine + # human decision, with no signal at all that anything went wrong. + failures = reported_failures(tools) + assert len(failures) == 1 + assert failures[0]["provider"] == "codex" + @pytest.mark.asyncio async def test_cleanup_closes_client_when_last_room_removed(self) -> None: fake_client = FakeCodexClient(events=[_turn_completed()]) diff --git a/tests/adapters/test_gemini_adapter.py b/tests/adapters/test_gemini_adapter.py index 80b3b789c..e784bbdf1 100644 --- a/tests/adapters/test_gemini_adapter.py +++ b/tests/adapters/test_gemini_adapter.py @@ -258,6 +258,36 @@ async def test_preserves_server_error_status_and_message( assert failure.code == "UNAVAILABLE" assert failure.detail == "overloaded" + @pytest.mark.asyncio + async def test_non_string_server_error_status_is_stringified( + self, sample_message, mock_tools + ): + """A malformed error body's non-string ``status`` field must not + crash failure reporting -- band_sdk_core's AgentFailure requires + code: str | None, but ServerError.status is an unconstrained + Optional[str] at runtime (parsed straight off the response JSON).""" + adapter = GeminiAdapter(provider_key="test-key") + await adapter.on_started("TestBot", "Test bot") + error = ServerError(503, {"status": 503, "message": "backend overloaded"}, None) + + with patch.object(adapter, "_call_gemini", AsyncMock(side_effect=error)): + with pytest.raises(ServerError): + await adapter.on_message( + msg=sample_message, + tools=mock_tools, + history=[], + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-123", + ) + + mock_tools.send_failure.assert_called_once() + failure = mock_tools.send_failure.call_args.args[0] + assert failure.provider == "gemini" + assert failure.code == "503" + assert failure.detail == "backend overloaded" + class TestRetries: @pytest.mark.asyncio diff --git a/tests/adapters/test_letta_adapter.py b/tests/adapters/test_letta_adapter.py index 267bd68bf..dc95235cb 100644 --- a/tests/adapters/test_letta_adapter.py +++ b/tests/adapters/test_letta_adapter.py @@ -256,6 +256,49 @@ async def slow_response(**kwargs: Any) -> MagicMock: assert failures[0]["provider"] == "letta" assert failures[0]["code"] == "timeout" + @pytest.mark.asyncio + async def test_slow_delivery_is_not_misreported_as_provider_timeout( + self, adapter_with_client: tuple[LettaAdapter, AsyncMock] + ) -> None: + """turn_timeout_s bounds only the Letta round-trip. A room POST that + is merely slow (not the Letta call) must not be misreported as a + Letta provider timeout -- it must be given time to complete.""" + adapter, mock_client = adapter_with_client + adapter.config.turn_timeout_s = 0.05 + + adapter._rooms["room-1"] = RoomContext(agent_id="agent-1") + + # The Letta call itself responds instantly, well inside turn_timeout_s. + mock_client.agents.messages.create.return_value = make_letta_response( + make_assistant_message("I'll help you!") + ) + + tools = FakeAgentTools() + real_send_message = tools.send_message + + async def _slow_send_message(*args: Any, **kwargs: Any) -> Any: + # Longer than turn_timeout_s: only the provider call may race it. + await asyncio.sleep(0.15) + return await real_send_message(*args, **kwargs) + + tools.send_message = _slow_send_message # type: ignore[method-assign] + + msg = make_platform_message() + history = LettaSessionState() + + await adapter.on_message( + msg, + tools, + history, + None, + None, + is_session_bootstrap=False, + room_id="room-1", + ) + + assert len(tools.messages_sent) == 1 + assert not reported_failures(tools) + @pytest.mark.asyncio async def test_generic_exception_reports_and_propagates( self, adapter_with_client: tuple[LettaAdapter, AsyncMock] @@ -369,9 +412,10 @@ async def test_uninitialized_client_reports_error(self) -> None: room_id="room-1", ) - error_events = [e for e in tools.events_sent if e["message_type"] == "error"] - assert len(error_events) == 1 - assert "not initialized" in error_events[0]["content"] + failures = reported_failures(tools) + assert len(failures) == 1 + assert failures[0]["provider"] == "letta" + assert "not initialized" in failures[0]["message"] # ────────────────────────────────────────────────────────────────────── @@ -1321,9 +1365,10 @@ async def test_disabled_relay_fails_loud_instead_of_sending(self) -> None: ) assert len(tools.messages_sent) == 0 - error_events = [e for e in tools.events_sent if e["message_type"] == "error"] - assert len(error_events) == 1 - assert "band_send_message" in error_events[0]["content"] + failures = reported_failures(tools) + assert len(failures) == 1 + assert failures[0]["provider"] == "letta" + assert "band_send_message" in failures[0]["message"] @pytest.mark.asyncio async def test_disabled_relay_quiet_when_send_tool_used(self) -> None: diff --git a/tests/framework_conformance/test_strands_injection_spike.py b/tests/framework_conformance/test_strands_injection_spike.py index 1013464e0..69875fabc 100644 --- a/tests/framework_conformance/test_strands_injection_spike.py +++ b/tests/framework_conformance/test_strands_injection_spike.py @@ -76,6 +76,7 @@ ScriptedStrandsModel, TextTurn, ToolTurn, + reported_failures, ) _SEND_CONTENT = "Injected reply: PINEAPPLE" @@ -216,6 +217,9 @@ async def test_negative_control_text_only_sends_no_message() -> None: ) assert tools.tool_calls == [] # The plain-text answer was silently dropped — the adapter must surface it. - errors = [e for e in tools.events_sent if e["message_type"] == "error"] - assert len(errors) == 1, f"expected one error event, got: {tools.events_sent}" - assert "band_send_message" in errors[0]["content"] + failures = reported_failures(tools) + assert len(failures) == 1, ( + f"expected one reported failure, got: {tools.events_sent}" + ) + assert failures[0]["provider"] == "strands" + assert "band_send_message" in failures[0]["message"] diff --git a/tests/integrations/a2a/gateway/test_adapter.py b/tests/integrations/a2a/gateway/test_adapter.py index f4a89616d..f3424cab2 100644 --- a/tests/integrations/a2a/gateway/test_adapter.py +++ b/tests/integrations/a2a/gateway/test_adapter.py @@ -367,6 +367,22 @@ def test_redact_credentials_full_value_scheme_prefixed(self) -> None: assert "sk-live-abcdef123456" not in redacted assert redacted == "Authorization=[REDACTED]" + @pytest.mark.parametrize( + "text", + [ + "password=hunter2", + "client_secret=abc123XYZ", + "AWS_SECRET_ACCESS_KEY=AKIAABCDEFGHIJKLMNOP", + ], + ) + def test_redact_credentials_covers_non_token_keywords(self, text: str) -> None: + """token/authorization/api_key aren't the only credential-shaped + keywords a peer's error text can embed -- password, secret (and its + client_secret compound), and access_key must be redacted too.""" + redacted = _redact_credentials(text) + secret_value = text.split("=", 1)[1] + assert secret_value not in redacted + @pytest.mark.asyncio async def test_establish_request_raises_when_peer_missing(self) -> None: adapter = A2AGatewayAdapter(rest_client=MagicMock()) @@ -653,6 +669,42 @@ async def test_relayed_peer_failure_redacts_embedded_credentials(self) -> None: assert "sk-live-secret" not in event.metadata["failure"]["message"] assert "sk-live-secret" not in event.status.message.parts[0].text + @pytest.mark.asyncio + async def test_relayed_peer_failure_redacts_nested_credentials_in_detail( + self, + ) -> None: + """A peer's AgentFailure.detail can nest a credential-bearing string + inside a dict/list (e.g. Codex's own codex_additional_details) -- + _redact_credentials_deep must recurse into it, not just the flat + message string.""" + adapter = A2AGatewayAdapter(rest_client=MagicMock()) + queue = EventQueueLegacy() + pending = make_pending(queue) + peer_failure = { + "provider": "codex", + "code": "Unauthorized", + "message": "upstream rejected the request", + "detail": { + "codex_additional_details": { + "raw": ["upstream said: token=sk-live-nested-secret"], + }, + }, + } + + await adapter._publish_band_response( + pending, + make_platform_message( + "upstream rejected the request", + message_type="error", + metadata={"failure": peer_failure}, + ), + ) + event = await queue.dequeue_event() + + assert event.status.state == TaskState.TASK_STATE_FAILED + detail = event.metadata["failure"]["detail"] + assert "sk-live-nested-secret" not in str(detail) + @pytest.mark.asyncio async def test_plain_error_message_without_failure_metadata_still_fails( self, diff --git a/tests/integrations/a2a/test_adapter.py b/tests/integrations/a2a/test_adapter.py index 94f940e1e..5533d2e62 100644 --- a/tests/integrations/a2a/test_adapter.py +++ b/tests/integrations/a2a/test_adapter.py @@ -408,8 +408,9 @@ async def test_remote_error_is_posted_as_error_event( room_id="room-123", ) - assert tools.events_sent[-1]["message_type"] == "error" - assert tools.events_sent[-1]["content"] == GENERIC_PROVIDER_FAILURE_MESSAGE + failures = reported_failures(tools) + assert failures[-1]["provider"] == "a2a" + assert failures[-1]["message"] == GENERIC_PROVIDER_FAILURE_MESSAGE @pytest.mark.asyncio async def test_on_message_reraises_delivery_failure_without_reporting_it( @@ -437,7 +438,7 @@ async def _events() -> AsyncIterator[StreamResponse]: room_id="room-123", ) - assert not [e for e in tools.events_sent if e["message_type"] == "error"] + assert not reported_failures(tools) @pytest.mark.asyncio async def test_failed_task_is_posted_as_error_event( From e609f8d9def0105d139037c4fbdaa9f16a252081 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Mon, 14 Sep 2026 13:45:41 +0300 Subject: [PATCH 50/53] fix: close two regressions from the prior fix pass A focused Logical Bugs re-check against the previous commit's fixes found two new defects introduced by those very fixes: - codex.py: the manual-approval notify-failure fix returned "decline" directly instead of re-raising, which made the caller's success path run and credit/blame the human sender (decided_by) for a decision they were never actually notified about. Now re-raises after reporting, so it flows through the same system_fallback attribution as every other forced-decline path in this file. - letta.py: narrowing turn_timeout_s to the provider call fixed the auto-relay race, but _run_turn's except asyncio.TimeoutError: still blanketed the whole (now-unbounded) response-processing phase, so a TimeoutError from tool-event reporting could still be mislabeled as a Letta provider timeout. The timeout is now caught and reported at the exact wait_for call it bounds, converted to TurnResultAlreadyReported so it can never be conflated with an unrelated TimeoutError from elsewhere in the same method. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JjhSR1MngdKxXiSJctQjun --- src/band/adapters/codex.py | 8 +++-- src/band/adapters/letta.py | 52 +++++++++++++++++----------- tests/adapters/test_codex_adapter.py | 11 ++++++ tests/adapters/test_letta_adapter.py | 51 +++++++++++++++++++++++++-- 4 files changed, 97 insertions(+), 25 deletions(-) diff --git a/src/band/adapters/codex.py b/src/band/adapters/codex.py index 63fd080fd..547932d6a 100644 --- a/src/band/adapters/codex.py +++ b/src/band/adapters/codex.py @@ -2217,7 +2217,11 @@ async def _resolve_manual_approval( # approval_wait_timeout_s would misreport a Band delivery # hiccup as a genuine human-decision timeout. Report it now, # rather than letting it silently decline with no signal at - # all, same as every other failure path in this file. + # all, same as every other failure path in this file. Re-raise + # (rather than returning "decline" here) so the caller's own + # except-block attributes this to "system_fallback" instead + # of crediting/blaming the human sender for a decision they + # were never actually notified about. logger.exception( "Failed to notify room %s about pending approval %s", room_id, @@ -2230,7 +2234,7 @@ async def _resolve_manual_approval( "request; defaulting to decline.", ) ) - return "decline" + raise decision_raw = await asyncio.wait_for( pending.future, timeout=self.config.approval_wait_timeout_s, diff --git a/src/band/adapters/letta.py b/src/band/adapters/letta.py index 3f6d174df..cb69b1ede 100644 --- a/src/band/adapters/letta.py +++ b/src/band/adapters/letta.py @@ -437,20 +437,6 @@ async def _run_turn( reraise_delivery_cause(e) except TurnResultAlreadyReported: raise - except asyncio.TimeoutError: - logger.error( - "Room %s: Letta turn timed out after %ss", - room_id, - self.config.turn_timeout_s, - ) - await tools.send_failure( - AgentFailure( - _PROVIDER, - f"Letta agent response timed out after {self.config.turn_timeout_s}s", - FAILURE_CODE_TIMEOUT, - ) - ) - raise except Exception as e: logger.exception("Room %s: Error during Letta turn: %s", room_id, e) await tools.send_failure( @@ -491,13 +477,37 @@ async def _send_message( turn_usage = TurnUsage() try: - # turn_timeout_s bounds only the round-trip to Letta -- response - # processing (including deliver_reply, below) runs unbounded so a - # slow Band-side delivery is never mislabeled as a Letta timeout. - response_messages, turn_usage = await asyncio.wait_for( - self._call_provider(agent_id, messages, room_ctx), - timeout=self.config.turn_timeout_s, - ) + try: + # turn_timeout_s bounds only the round-trip to Letta -- response + # processing (including deliver_reply, below) runs unbounded so a + # slow Band-side delivery is never mislabeled as a Letta timeout. + response_messages, turn_usage = await asyncio.wait_for( + self._call_provider(agent_id, messages, room_ctx), + timeout=self.config.turn_timeout_s, + ) + except asyncio.TimeoutError: + # Caught and reported here, at the exact call this timeout + # bounds -- a TimeoutError surfacing from anywhere else in + # this method (e.g. tool-event reporting below) is a + # genuine unrelated failure, not a Letta provider timeout, + # and must reach _run_turn's generic exception handler + # instead of being conflated with this one. + logger.error( + "Room %s: Letta turn timed out after %ss", + room_id, + self.config.turn_timeout_s, + ) + await tools.send_failure( + AgentFailure( + _PROVIDER, + f"Letta agent response timed out after {self.config.turn_timeout_s}s", + FAILURE_CODE_TIMEOUT, + ) + ) + raise TurnResultAlreadyReported( + "Letta provider call timed out" + ) from None + return await self._process_response_messages( response_messages, tools, diff --git a/tests/adapters/test_codex_adapter.py b/tests/adapters/test_codex_adapter.py index 20d1378bf..439f7953c 100644 --- a/tests/adapters/test_codex_adapter.py +++ b/tests/adapters/test_codex_adapter.py @@ -683,6 +683,17 @@ async def send_message( assert len(failures) == 1 assert failures[0]["provider"] == "codex" + # The human sender was never actually notified, so the audit trail + # must not credit/blame them for this decision -- it was forced by + # the delivery failure, same as every other forced-decline path. + audit_events = [ + e + for e in tools.events_sent + if e["metadata"].get("codex_event_type") == "approval_resolution" + ] + assert len(audit_events) == 1 + assert audit_events[0]["metadata"]["codex_decided_by"] == "system_fallback" + @pytest.mark.asyncio async def test_cleanup_closes_client_when_last_room_removed(self) -> None: fake_client = FakeCodexClient(events=[_turn_completed()]) diff --git a/tests/adapters/test_letta_adapter.py b/tests/adapters/test_letta_adapter.py index dc95235cb..793b2d088 100644 --- a/tests/adapters/test_letta_adapter.py +++ b/tests/adapters/test_letta_adapter.py @@ -8,6 +8,7 @@ import asyncio import json +import logging from datetime import datetime, timedelta, timezone from typing import Any from unittest.mock import AsyncMock, MagicMock @@ -239,7 +240,7 @@ async def slow_response(**kwargs: Any) -> MagicMock: msg = make_platform_message() history = LettaSessionState() - with pytest.raises(TimeoutError): + with pytest.raises(TurnResultAlreadyReported): await adapter.on_message( msg, tools, @@ -299,6 +300,52 @@ async def _slow_send_message(*args: Any, **kwargs: Any) -> Any: assert len(tools.messages_sent) == 1 assert not reported_failures(tools) + @pytest.mark.asyncio + async def test_send_event_timeout_is_not_misreported_as_provider_timeout( + self, caplog: pytest.LogCaptureFixture + ) -> None: + """A TimeoutError from tool-event reporting (send_event) -- not the + Letta round-trip itself -- must fall through to the generic failure + path, never be mislabeled as a Letta provider timeout. (send_failure + itself best-effort-swallows the same broken channel here, same as + production, so the room never receives a failure event either way -- + what this guards is which branch is taken/logged.)""" + config = LettaAdapterConfig() + adapter = LettaAdapter(config=config, emit=Emit.TOOL_CALLS) + mock_client = AsyncMock() + adapter._client = mock_client + adapter._system_prompt = "Test" + adapter._mcp.tool_ids = [] + adapter._mcp.server_id = "mcp-server-1" + adapter._rooms["room-1"] = RoomContext(agent_id="agent-1") + + # The Letta round-trip itself returns instantly -- well inside + # turn_timeout_s -- so any TimeoutError must come from elsewhere. + mock_client.agents.messages.create.return_value = make_letta_response( + make_tool_call_message("band_lookup_peers", "{}"), + make_tool_return_message("band_lookup_peers", '{"peers": []}'), + ) + + tools = FakeAgentTools() + tools.send_event_error = TimeoutError("event POST hiccup") + msg = make_platform_message() + history = LettaSessionState() + + with caplog.at_level(logging.ERROR, logger="band.adapters.letta"): + with pytest.raises(TimeoutError): + await adapter.on_message( + msg, + tools, + history, + None, + None, + is_session_bootstrap=False, + room_id="room-1", + ) + + assert not any("timed out" in r.message for r in caplog.records) + assert any("Error during Letta turn" in r.message for r in caplog.records) + @pytest.mark.asyncio async def test_generic_exception_reports_and_propagates( self, adapter_with_client: tuple[LettaAdapter, AsyncMock] @@ -1462,7 +1509,7 @@ async def test_failed_first_turn_preserves_pending_seed( replay_messages=["[Alice]: The secret word is kumquat."] ) tools = FakeAgentTools() - with pytest.raises(TimeoutError): + with pytest.raises(TurnResultAlreadyReported): await adapter.on_message( make_platform_message(content="what was the secret word?"), tools, From ad36d9aab6cd48c1d603521c792e3b987a4ae7a1 Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Mon, 14 Sep 2026 17:14:29 +0300 Subject: [PATCH 51/53] fix: keep ACP session on bookkeeping failure --- src/band/integrations/acp/room_emitter.py | 6 ++-- tests/integrations/acp/test_client_adapter.py | 33 +++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/band/integrations/acp/room_emitter.py b/src/band/integrations/acp/room_emitter.py index 59c9158f8..540a9e83f 100644 --- a/src/band/integrations/acp/room_emitter.py +++ b/src/band/integrations/acp/room_emitter.py @@ -5,7 +5,7 @@ import logging from band.core.delivery import deliver_reply -from band.core.protocols import AgentToolsProtocol +from band.core.protocols import AgentToolsProtocol, send_event_safe from band.integrations.acp.types import ( ACPToolCall, ACPToolResult, @@ -175,12 +175,14 @@ async def __aexit__(self, exc_type: object, exc: object, tb: object) -> bool: if not turn_replied_in_room(self._chunks): for text in self._pending_text: await deliver_reply(self._tools, text, mentions=self._mentions) - await self._tools.send_event( + await send_event_safe( + self._tools, content="ACP client session", message_type="task", metadata={ "acp_client_session_id": self._session_id, "acp_client_room_id": self._room_id, }, + log_label="ACP client session", ) return False diff --git a/tests/integrations/acp/test_client_adapter.py b/tests/integrations/acp/test_client_adapter.py index 4c4e4029e..78358eadb 100644 --- a/tests/integrations/acp/test_client_adapter.py +++ b/tests/integrations/acp/test_client_adapter.py @@ -1405,6 +1405,39 @@ async def _raise(*args: object, **kwargs: object) -> None: assert adapter._runtime._ctx is not None assert not reported_failures(tools) + @pytest.mark.asyncio + async def test_session_bookkeeping_failure_leaves_connection_up(self) -> None: + """A failed session task event must not turn a completed prompt into an ACP failure.""" + adapter = ACPClientAdapter(command="codex", inject_band_tools=False) + adapter._runtime._conn = AsyncMock() + mock_session = MagicMock() + mock_session.session_id = "sess-1" + adapter._runtime._conn.new_session = AsyncMock(return_value=mock_session) + adapter._runtime._client = BandACPClient() + + mock_ctx = MagicMock() + mock_ctx.__aexit__ = AsyncMock(return_value=None) + adapter._runtime._ctx = mock_ctx + + adapter._runtime._conn.prompt = AsyncMock() + tools = FakeAgentTools() + tools.send_event_error = RuntimeError("platform rejected the task event") + msg = make_platform_message("Hello", room_id="room-1") + + await adapter.on_message( + msg, + tools, + ACPClientSessionState(), + None, + None, + is_session_bootstrap=False, + room_id="room-1", + ) + + assert adapter._runtime._conn is not None + assert adapter._runtime._ctx is not None + assert not reported_failures(tools) + @pytest.mark.asyncio async def test_turn_timeout_reports_failure_and_clears_connection(self) -> None: """A silent/stuck agent must become an observable failure instead of From f530ba8b7e2c8e731acb47a3b51ebf9bfe51c32b Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Tue, 15 Sep 2026 14:49:04 +0300 Subject: [PATCH 52/53] fix: isolate adapter failure recovery by room --- src/band/adapters/claude_sdk.py | 1 - src/band/adapters/copilot_sdk.py | 1 - src/band/adapters/crewai.py | 1 - src/band/adapters/gemini.py | 1 - src/band/adapters/letta.py | 1 - src/band/adapters/opencode/adapter.py | 1 - src/band/adapters/parlant.py | 1 - src/band/adapters/pydantic_ai.py | 1 - src/band/adapters/strands.py | 1 - src/band/integrations/a2a/adapter.py | 4 +- src/band/integrations/a2a/gateway/adapter.py | 1 - src/band/integrations/acp/client_adapter.py | 16 ++++- src/band/integrations/acp/client_runtime.py | 7 ++ tests/integrations/a2a/test_adapter.py | 31 +++++++++ tests/integrations/acp/test_client_adapter.py | 66 +++++++++++++------ 15 files changed, 100 insertions(+), 34 deletions(-) diff --git a/src/band/adapters/claude_sdk.py b/src/band/adapters/claude_sdk.py index 5da02bc9a..e3c0c4d9a 100644 --- a/src/band/adapters/claude_sdk.py +++ b/src/band/adapters/claude_sdk.py @@ -133,7 +133,6 @@ # same constant instead of a second, driftable number. _CLAUDE_SDK_MAX_BUFFER_BYTES = MAX_INLINE_IMAGE_BYTES * 2 -# AgentFailure.provider tag for every failure this adapter reports. _PROVIDER = "claude_sdk" # Approval flow types (mirrors Codex adapter patterns) diff --git a/src/band/adapters/copilot_sdk.py b/src/band/adapters/copilot_sdk.py index 8a5b483f7..a1a60db67 100644 --- a/src/band/adapters/copilot_sdk.py +++ b/src/band/adapters/copilot_sdk.py @@ -95,7 +95,6 @@ logger = logging.getLogger(__name__) -# AgentFailure.provider tag for every failure this adapter reports. _PROVIDER = "copilot_sdk" diff --git a/src/band/adapters/crewai.py b/src/band/adapters/crewai.py index 85f14efb4..8e0f63468 100644 --- a/src/band/adapters/crewai.py +++ b/src/band/adapters/crewai.py @@ -41,7 +41,6 @@ logger = logging.getLogger(__name__) -# AgentFailure.provider tag for every failure this adapter reports. _PROVIDER = "crewai" diff --git a/src/band/adapters/gemini.py b/src/band/adapters/gemini.py index b7cfb1dbd..f9e6e20b2 100644 --- a/src/band/adapters/gemini.py +++ b/src/band/adapters/gemini.py @@ -54,7 +54,6 @@ logger = logging.getLogger(__name__) -# AgentFailure.provider tag for every failure this adapter reports. _PROVIDER = "gemini" diff --git a/src/band/adapters/letta.py b/src/band/adapters/letta.py index cb69b1ede..f565eaee8 100644 --- a/src/band/adapters/letta.py +++ b/src/band/adapters/letta.py @@ -59,7 +59,6 @@ logger = logging.getLogger(__name__) -# AgentFailure.provider tag for every failure this adapter reports. _PROVIDER = "letta" diff --git a/src/band/adapters/opencode/adapter.py b/src/band/adapters/opencode/adapter.py index 59b703066..974b6ce6f 100644 --- a/src/band/adapters/opencode/adapter.py +++ b/src/band/adapters/opencode/adapter.py @@ -68,7 +68,6 @@ logger = logging.getLogger(__name__) -# AgentFailure.provider tag for every failure this adapter reports. _PROVIDER = "opencode" _OPENCODE_SYSTEM_NOTE = """\ diff --git a/src/band/adapters/parlant.py b/src/band/adapters/parlant.py index fe1db6cd0..f56e13793 100644 --- a/src/band/adapters/parlant.py +++ b/src/band/adapters/parlant.py @@ -42,7 +42,6 @@ logger = logging.getLogger(__name__) -# AgentFailure.provider tag for every failure this adapter reports. _PROVIDER = "parlant" diff --git a/src/band/adapters/pydantic_ai.py b/src/band/adapters/pydantic_ai.py index f9310295f..68e1b52b4 100644 --- a/src/band/adapters/pydantic_ai.py +++ b/src/band/adapters/pydantic_ai.py @@ -77,7 +77,6 @@ logger = logging.getLogger(__name__) -# AgentFailure.provider tag for every failure this adapter reports. _PROVIDER = "pydantic_ai" diff --git a/src/band/adapters/strands.py b/src/band/adapters/strands.py index e5ec6067d..f221c1eeb 100644 --- a/src/band/adapters/strands.py +++ b/src/band/adapters/strands.py @@ -76,7 +76,6 @@ logger = logging.getLogger(__name__) -# AgentFailure.provider tag for every failure this adapter reports. _PROVIDER = "strands" diff --git a/src/band/integrations/a2a/adapter.py b/src/band/integrations/a2a/adapter.py index e7e40a159..99d1ce70a 100644 --- a/src/band/integrations/a2a/adapter.py +++ b/src/band/integrations/a2a/adapter.py @@ -32,6 +32,7 @@ GENERIC_PROVIDER_FAILURE_MESSAGE, AgentToolsProtocol, TurnResultAlreadyReported, + send_event_safe, ) from band.core.simple_adapter import SimpleAdapter from band.core.types import Capability, Emit, FeatureKwargs, PlatformMessage @@ -47,7 +48,6 @@ logger = logging.getLogger(__name__) -# AgentFailure.provider tag for every failure this adapter reports. _PROVIDER = "a2a" # httpx's read timeout resets on every chunk received, so this bounds the gap @@ -282,7 +282,7 @@ async def _deliver_task_update( if state == TaskState.TASK_STATE_WORKING: status_text = self._get_status_text(task) if status_text: - await tools.send_event(content=status_text, message_type="thought") + await send_event_safe(tools, status_text, "thought") return if state == TaskState.TASK_STATE_INPUT_REQUIRED: diff --git a/src/band/integrations/a2a/gateway/adapter.py b/src/band/integrations/a2a/gateway/adapter.py index edc2b7a06..c35180c90 100644 --- a/src/band/integrations/a2a/gateway/adapter.py +++ b/src/band/integrations/a2a/gateway/adapter.py @@ -41,7 +41,6 @@ logger = logging.getLogger(__name__) -# AgentFailure.provider tag for every failure this adapter reports. _PROVIDER = "a2a-gateway" diff --git a/src/band/integrations/acp/client_adapter.py b/src/band/integrations/acp/client_adapter.py index baf50d6bf..c9b83c68a 100644 --- a/src/band/integrations/acp/client_adapter.py +++ b/src/band/integrations/acp/client_adapter.py @@ -67,7 +67,6 @@ logger = logging.getLogger(__name__) -# AgentFailure.provider tag for every failure this adapter reports. _PROVIDER = "acp" LocalMcpServerConfig = HttpMcpServer | SseMcpServer @@ -398,7 +397,15 @@ async def on_message( room_id, session_id, ) - await self.stop() + try: + await self._runtime.cancel_turn(session_id) + except ConnectionError: + await self.stop() + except Exception: + logger.exception("ACP turn cancellation failed (room=%s)", room_id) + await self.on_cleanup(room_id) + else: + await self.on_cleanup(room_id) await tools.send_failure( AgentFailure( _PROVIDER, @@ -409,7 +416,10 @@ async def on_message( raise except Exception as e: logger.exception("ACP agent error: %s", e) - await self.stop() + if isinstance(e, RequestError): + await self.on_cleanup(room_id) + else: + await self.stop() await tools.send_failure(_to_agent_failure(e)) raise diff --git a/src/band/integrations/acp/client_runtime.py b/src/band/integrations/acp/client_runtime.py index a689f64d9..65333d7ea 100644 --- a/src/band/integrations/acp/client_runtime.py +++ b/src/band/integrations/acp/client_runtime.py @@ -232,6 +232,8 @@ async def load_session( async def prompt(self, *, session_id: str, prompt: list[object]) -> object: ... + async def cancel(self, session_id: str) -> None: ... + class ACPSpawnContextProtocol(Protocol): """Protocol for the spawn_agent_process async context manager.""" @@ -838,6 +840,11 @@ async def prompt( self._client.set_sink(session_id, None) return self.get_collected_chunks(session_id) + async def cancel_turn(self, session_id: str) -> None: + """Tell the agent to stop a timed-out room's prompt.""" + conn = await self.ensure_connection(can_respawn=False) + await conn.cancel(session_id) + def reset_session(self, session_id: str) -> None: if self._client is not None: self._client.reset_session(session_id) diff --git a/tests/integrations/a2a/test_adapter.py b/tests/integrations/a2a/test_adapter.py index 5533d2e62..2da5b3559 100644 --- a/tests/integrations/a2a/test_adapter.py +++ b/tests/integrations/a2a/test_adapter.py @@ -485,6 +485,37 @@ async def test_working_status_text_is_narrated_as_thought( assert tools.events_sent[-1]["message_type"] == "thought" assert tools.events_sent[-1]["content"] == "Checking sources" + @pytest.mark.asyncio + async def test_working_status_delivery_failure_is_not_a_provider_failure( + self, adapter: A2AAdapter + ) -> None: + """A failed progress post must not blame a healthy A2A peer.""" + adapter._client = MagicMock() + adapter._client.send_message = MagicMock( + return_value=stream( + task_event( + make_task( + TaskState.TASK_STATE_WORKING, + status_message="Checking sources", + ) + ) + ) + ) + tools = FakeAgentTools() + tools.send_event_error = RuntimeError("Band unavailable") + + await adapter.on_message( + make_platform_message(), + tools, + A2ASessionState(), + None, + None, + is_session_bootstrap=False, + room_id="room-123", + ) + + assert not reported_failures(tools) + @pytest.mark.asyncio async def test_second_turn_carries_the_stored_context( self, adapter: A2AAdapter diff --git a/tests/integrations/acp/test_client_adapter.py b/tests/integrations/acp/test_client_adapter.py index 78358eadb..2a3abac2d 100644 --- a/tests/integrations/acp/test_client_adapter.py +++ b/tests/integrations/acp/test_client_adapter.py @@ -1439,49 +1439,77 @@ async def test_session_bookkeeping_failure_leaves_connection_up(self) -> None: assert not reported_failures(tools) @pytest.mark.asyncio - async def test_turn_timeout_reports_failure_and_clears_connection(self) -> None: - """A silent/stuck agent must become an observable failure instead of - hanging the turn indefinitely, and the presumed-wedged connection is - torn down so the next turn respawns it.""" + async def test_turn_timeout_preserves_other_room_connection(self) -> None: + """A timed-out room must not interrupt another room's prompt.""" adapter = ACPClientAdapter( - command="codex", inject_band_tools=False, turn_timeout_s=0.01 + command="codex", inject_band_tools=False, turn_timeout_s=1 ) adapter._runtime._conn = AsyncMock() - mock_session = MagicMock() - mock_session.session_id = "sess-1" - adapter._runtime._conn.new_session = AsyncMock(return_value=mock_session) + session_b = MagicMock(session_id="sess-b") + session_a = MagicMock(session_id="sess-a") + adapter._runtime._conn.new_session = AsyncMock( + side_effect=[session_b, session_a] + ) adapter._runtime._client = BandACPClient() mock_ctx = MagicMock() mock_ctx.__aexit__ = AsyncMock(return_value=None) adapter._runtime._ctx = mock_ctx - async def hang(**kwargs: object) -> None: - await asyncio.sleep(10) + b_started = asyncio.Event() + release_b = asyncio.Event() - adapter._runtime._conn.prompt = AsyncMock(side_effect=hang) + async def prompt(*, session_id: str, **kwargs: object) -> None: + if session_id == "sess-b": + b_started.set() + await release_b.wait() + else: + await asyncio.sleep(10) - tools = FakeAgentTools() - msg = make_platform_message("Hello", room_id="room-1") + adapter._runtime._conn.prompt = AsyncMock(side_effect=prompt) + + tools_b = FakeAgentTools() + b_turn = asyncio.create_task( + adapter.on_message( + make_platform_message("Hello", room_id="room-b"), + tools_b, + ACPClientSessionState(), + None, + None, + is_session_bootstrap=False, + room_id="room-b", + ) + ) + await b_started.wait() + adapter._turn_timeout_s = 0.01 + + tools_a = FakeAgentTools() with pytest.raises(TimeoutError): await adapter.on_message( - msg, - tools, + make_platform_message("Hello", room_id="room-a"), + tools_a, ACPClientSessionState(), None, None, is_session_bootstrap=False, - room_id="room-1", + room_id="room-a", ) - assert adapter._runtime._conn is None - assert adapter._runtime._ctx is None - failures = reported_failures(tools) + assert not b_turn.done() + assert adapter._runtime._conn is not None + assert adapter._runtime._ctx is not None + assert "room-a" not in adapter._room_to_session + assert adapter._room_to_session["room-b"] == "sess-b" + adapter._runtime._conn.cancel.assert_awaited_once_with("sess-a") + failures = reported_failures(tools_a) assert len(failures) == 1 assert failures[0]["provider"] == "acp" assert failures[0]["code"] == "timeout" + release_b.set() + await b_turn + class TestACPClientAdapterInjectToolsConfig: """Tests for inject_band_tools configuration.""" From 255d78f53584e64041288418fe051b5674c008ff Mon Sep 17 00:00:00 2001 From: Alexander Zaikman Date: Thu, 17 Sep 2026 20:59:32 +0300 Subject: [PATCH 53/53] fix: preserve adapter failure boundaries --- .github/workflows/ci.yml | 8 ++ src/band/adapters/codex.py | 75 ++++++++++++++++++ src/band/adapters/letta.py | 5 +- src/band/integrations/a2a/adapter.py | 4 +- src/band/integrations/a2a/gateway/adapter.py | 2 + src/band/integrations/a2a/protocol.py | 7 ++ src/band/integrations/acp/client_adapter.py | 79 ++++++++++++------- tests/adapters/test_codex_adapter.py | 72 +++++++++++++++++ tests/adapters/test_letta_adapter.py | 31 ++++++++ .../integrations/a2a/gateway/test_adapter.py | 22 ++++++ tests/integrations/a2a/test_adapter.py | 39 ++++----- tests/integrations/acp/test_client_adapter.py | 56 +++++++++++++ 12 files changed, 349 insertions(+), 51 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61a76cf4d..dc3dbf62f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -257,6 +257,7 @@ jobs: from band import Agent, BandLink, AgentRuntime from band.config import load_agent_config from band_sdk_core import ( + AgentFailure, ClaimRegistry, ParticipantRoster, RetryTracker, @@ -291,6 +292,13 @@ jobs: "room_id": "room-1", "message_id": "msg-1", } + failure = AgentFailure("wheel-smoke", "failure") + assert failure.to_dict() == { + "provider": "wheel-smoke", + "code": None, + "message": "failure", + "detail": None, + } print('Core imports successful') PYEOF diff --git a/src/band/adapters/codex.py b/src/band/adapters/codex.py index 547932d6a..0072a2c0d 100644 --- a/src/band/adapters/codex.py +++ b/src/band/adapters/codex.py @@ -717,6 +717,21 @@ async def on_message( # without emitting a friendly _emit_turn_outcome reply or # a generic "Internal error" fallback. raise + except CodexJsonRpcError as error: + result = TurnResult( + turn_status="failed", + turn_error=str(error), + ) + await self._emit_failed_turn_outcome( + tools=tools, + msg=msg, + room_id=room_id, + thread_id=thread_id, + turn_id=turn_id or None, + result=result, + turn_start=_turn_start, + ) + raise except Exception: logger.exception( "Unexpected error during Codex turn event processing " @@ -724,9 +739,22 @@ async def on_message( thread_id, turn_id, ) + result = TurnResult( + turn_status="failed", + turn_error="Internal error during turn processing", + ) await tools.send_failure( AgentFailure(CODEX_PROVIDER, GENERIC_PROVIDER_FAILURE_MESSAGE) ) + await self._emit_failed_turn_outcome( + tools=tools, + msg=msg, + room_id=room_id, + thread_id=thread_id, + turn_id=turn_id or None, + result=result, + turn_start=_turn_start, + ) raise TurnResultAlreadyReported( "Internal error during turn processing" ) from None @@ -765,6 +793,32 @@ async def on_message( ) raise + async def _emit_failed_turn_outcome( + self, + *, + tools: AgentToolsProtocol, + msg: PlatformMessage, + room_id: str, + thread_id: str, + turn_id: str | None, + result: TurnResult, + turn_start: float, + ) -> None: + """Emit failure lifecycle events without posting a second reply.""" + await self._emit_turn_outcome( + tools=tools, + msg=msg, + room_id=room_id, + thread_id=thread_id, + turn_id=turn_id, + turn_status=result.turn_status, + turn_error=result.turn_error, + final_text=result.final_text, + saw_send_message_tool=result.saw_send_message_tool, + duration_s=_time.perf_counter() - turn_start, + include_reply=False, + ) + async def _process_turn_events( self, *, @@ -1044,6 +1098,19 @@ async def _process_turn_events( turn_id=turn_id, ) raise TurnResultAlreadyReported(result.turn_error or "Turn failed") + except TurnResultAlreadyReported as error: + result.turn_status = "failed" + result.turn_error = str(error) + await self._emit_failed_turn_outcome( + tools=tools, + msg=msg, + room_id=room_id, + thread_id=thread_id, + turn_id=turn_id, + result=result, + turn_start=turn_start, + ) + raise except asyncio.TimeoutError: logger.error( "Codex turn timed out after %ss (thread=%s, turn=%s)", @@ -1069,6 +1136,10 @@ async def _process_turn_events( FAILURE_CODE_TIMEOUT, ) ) + result.turn_status = "failed" + result.turn_error = ( + f"Codex turn timed out after {self.config.turn_timeout_s}s" + ) raise TurnResultAlreadyReported("Turn timed out") return result @@ -1696,6 +1767,7 @@ async def _emit_turn_outcome( final_text: str, saw_send_message_tool: bool, duration_s: float = 0.0, + include_reply: bool = True, ) -> None: # Look up token usage once for both marker and lifecycle events. usage = self._token_usage.get(thread_id) @@ -1776,6 +1848,9 @@ async def _emit_turn_outcome( log_level=logging.DEBUG, ) + if not include_reply: + return + mention = [{"id": msg.sender_id, "name": msg.sender_name or msg.sender_type}] if turn_status == "completed": diff --git a/src/band/adapters/letta.py b/src/band/adapters/letta.py index f565eaee8..b045d1124 100644 --- a/src/band/adapters/letta.py +++ b/src/band/adapters/letta.py @@ -443,9 +443,6 @@ async def _run_turn( ) raise else: - if room_ctx.pending_seed: - room_ctx.pending_seed = [] - room_ctx.last_interaction = datetime.now(timezone.utc) if final_text_parts: room_ctx.summary = self._extract_summary( final_text_parts, self.config.summary_max_length @@ -484,6 +481,8 @@ async def _send_message( self._call_provider(agent_id, messages, room_ctx), timeout=self.config.turn_timeout_s, ) + room_ctx.pending_seed = [] + room_ctx.last_interaction = datetime.now(timezone.utc) except asyncio.TimeoutError: # Caught and reported here, at the exact call this timeout # bounds -- a TimeoutError surfacing from anywhere else in diff --git a/src/band/integrations/a2a/adapter.py b/src/band/integrations/a2a/adapter.py index 99d1ce70a..f9c257b8e 100644 --- a/src/band/integrations/a2a/adapter.py +++ b/src/band/integrations/a2a/adapter.py @@ -39,6 +39,7 @@ from band.integrations.a2a.protocol import ( TERMINAL_TASK_STATE_NAMES, TERMINAL_TASK_STATES, + RETRYABLE_TASK_FAILURE_STATES, apply_task_stream_event, state_name, task_id_from_stream_event, @@ -303,7 +304,8 @@ async def _deliver_task_update( "Task %s: peer A2A task ended in state %s", task.id, state_str ) await tools.send_failure(AgentFailure(_PROVIDER, error_text, state_str)) - raise TurnResultAlreadyReported(error_text) + if state in RETRYABLE_TASK_FAILURE_STATES: + raise TurnResultAlreadyReported(error_text) def _finalize_task(self, room_id: str, task_id: str) -> None: """Release a terminal task after its Band output and state are persisted.""" diff --git a/src/band/integrations/a2a/gateway/adapter.py b/src/band/integrations/a2a/gateway/adapter.py index c35180c90..2247525ac 100644 --- a/src/band/integrations/a2a/gateway/adapter.py +++ b/src/band/integrations/a2a/gateway/adapter.py @@ -627,6 +627,8 @@ async def _publish_band_response( ) if isinstance(failure, dict): failure = _redact_credentials_deep(failure) + else: + failure = None await pending.fail(_redact_credentials(msg.content), failure=failure) elif msg.message_type in ("thought", "tool_call", "tool_result"): await pending.report_progress(msg.content) diff --git a/src/band/integrations/a2a/protocol.py b/src/band/integrations/a2a/protocol.py index fa191cec7..ede46d5da 100644 --- a/src/band/integrations/a2a/protocol.py +++ b/src/band/integrations/a2a/protocol.py @@ -17,6 +17,13 @@ } ) +RETRYABLE_TASK_FAILURE_STATES = frozenset( + { + TaskState.TASK_STATE_FAILED, + TaskState.TASK_STATE_REJECTED, + } +) + # Terminal states as persisted in task-event metadata. Includes the values # written by the pre-protobuf adapter (a2a-sdk 0.x string enums), so rooms # with history from before the migration still rehydrate as terminal. diff --git a/src/band/integrations/acp/client_adapter.py b/src/band/integrations/acp/client_adapter.py index c9b83c68a..2023dd9e1 100644 --- a/src/band/integrations/acp/client_adapter.py +++ b/src/band/integrations/acp/client_adapter.py @@ -7,6 +7,7 @@ import os import shutil from collections.abc import Callable +from contextlib import suppress from typing import Any, ClassVar from uuid import uuid4 @@ -69,6 +70,11 @@ _PROVIDER = "acp" + +class ACPTurnTimeoutError(TimeoutError): + """The adapter deadline expired before the ACP prompt completed.""" + + LocalMcpServerConfig = HttpMcpServer | SseMcpServer # Prefixes the change-triggered roster/contacts updates injected into a @@ -375,44 +381,32 @@ async def on_message( session_id, self._make_permission_handler(emitter, room_id), ) - await asyncio.wait_for( + prompt_task = asyncio.create_task( self._runtime.prompt( session_id=session_id, prompt_text=prompt_text, on_chunk=emitter.emit, - ), - timeout=self._turn_timeout_s, + ) ) + done, _ = await asyncio.wait( + {prompt_task}, timeout=self._turn_timeout_s + ) + if not done: + prompt_task.cancel() + with suppress(asyncio.CancelledError): + await prompt_task + await self._handle_turn_timeout( + room_id=room_id, session_id=session_id, tools=tools + ) + raise ACPTurnTimeoutError( + f"ACP turn timed out after {self._turn_timeout_s}s" + ) from None + await prompt_task except DeliveryFailedError as e: # The turn's reply is what failed to post -- Band-side delivery, # never an ACP provider failure, so the connection stays up. reraise_delivery_cause(e) - except asyncio.TimeoutError: - # A silent/stuck agent must become an observable failure instead - # of hanging the turn indefinitely -- the connection is presumed - # wedged, so it's torn down for the next turn to respawn. - logger.error( - "ACP turn timed out after %ss (room=%s, session=%s)", - self._turn_timeout_s, - room_id, - session_id, - ) - try: - await self._runtime.cancel_turn(session_id) - except ConnectionError: - await self.stop() - except Exception: - logger.exception("ACP turn cancellation failed (room=%s)", room_id) - await self.on_cleanup(room_id) - else: - await self.on_cleanup(room_id) - await tools.send_failure( - AgentFailure( - _PROVIDER, - f"ACP agent response timed out after {self._turn_timeout_s}s", - FAILURE_CODE_TIMEOUT, - ) - ) + except ACPTurnTimeoutError: raise except Exception as e: logger.exception("ACP agent error: %s", e) @@ -423,6 +417,33 @@ async def on_message( await tools.send_failure(_to_agent_failure(e)) raise + async def _handle_turn_timeout( + self, *, room_id: str, session_id: str, tools: AgentToolsProtocol + ) -> None: + """Cancel and report a prompt that exceeded the adapter timeout.""" + logger.error( + "ACP turn timed out after %ss (room=%s, session=%s)", + self._turn_timeout_s, + room_id, + session_id, + ) + try: + await self._runtime.cancel_turn(session_id) + except ConnectionError: + await self.stop() + except Exception: + logger.exception("ACP turn cancellation failed (room=%s)", room_id) + await self.on_cleanup(room_id) + else: + await self.on_cleanup(room_id) + await tools.send_failure( + AgentFailure( + _PROVIDER, + f"ACP agent response timed out after {self._turn_timeout_s}s", + FAILURE_CODE_TIMEOUT, + ) + ) + def _make_permission_handler( self, emitter: RoomTurnEmitter, diff --git a/tests/adapters/test_codex_adapter.py b/tests/adapters/test_codex_adapter.py index 439f7953c..d13a6941e 100644 --- a/tests/adapters/test_codex_adapter.py +++ b/tests/adapters/test_codex_adapter.py @@ -5230,6 +5230,78 @@ async def recv_event(self, timeout_s: float | None = None) -> RpcEvent: ) assert len(reported_failures(tools)) == 1 + @pytest.mark.asyncio + async def test_rpc_error_from_event_loop_keeps_curated_failure(self) -> None: + """RPC errors raised while receiving events keep their provider message.""" + rpc_error = CodexJsonRpcError(code=-32000, message="model unavailable") + + class RpcErrorClient(FakeCodexClient): + async def recv_event(self, timeout_s: float | None = None) -> RpcEvent: + raise rpc_error + + rpc_error_client = RpcErrorClient() + adapter = CodexAdapter( + config=CodexAdapterConfig(transport="ws"), + client_factory=lambda _config: rpc_error_client, + ) + tools = ToolSchemaFakeTools() + await adapter.on_started("Agent", "A coding agent") + + with pytest.raises(CodexJsonRpcError, match="model unavailable"): + await adapter.on_message( + make_platform_message(), + tools, + CodexSessionState(), + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-1", + ) + + failures = reported_failures(tools) + assert len(failures) == 1 + assert failures[0]["message"] == str(rpc_error) + + @pytest.mark.asyncio + async def test_failed_turn_emits_terminal_lifecycle_event(self) -> None: + """A reported failed turn still closes the lifecycle event pair.""" + events = [ + _event_notification( + "turn/completed", + {"turn": {"id": "turn-1", "status": "failed", "items": []}}, + ) + ] + fake_client = FakeCodexClient(events=events) + adapter = CodexAdapter( + config=CodexAdapterConfig( + transport="ws", + emit_turn_lifecycle_events=True, + ), + client_factory=lambda _config: fake_client, + ) + tools = ToolSchemaFakeTools() + await adapter.on_started("Agent", "A coding agent") + + with pytest.raises(TurnResultAlreadyReported): + await adapter.on_message( + make_platform_message(), + tools, + CodexSessionState(), + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-1", + ) + + lifecycle_events = [ + event + for event in events_of_type(tools, "task") + if event["metadata"].get("codex_event_type") == "turn_lifecycle" + ] + assert [ + event["metadata"]["codex_turn_status"] for event in lifecycle_events + ] == ["started", "failed"] + # =========================================================================== # Gap fixes: acceptForSession, network_context, turn started, compaction, diff --git a/tests/adapters/test_letta_adapter.py b/tests/adapters/test_letta_adapter.py index 793b2d088..6546e2cac 100644 --- a/tests/adapters/test_letta_adapter.py +++ b/tests/adapters/test_letta_adapter.py @@ -1495,6 +1495,37 @@ async def test_new_agent_in_room_with_history_is_seeded( # Seed is delivered exactly once assert adapter._rooms["room-1"].pending_seed == [] + @pytest.mark.asyncio + async def test_reported_turn_consumes_delivered_seed(self) -> None: + """A completed provider call consumes its seed before response handling.""" + adapter = LettaAdapter(config=LettaAdapterConfig(auto_relay=False)) + mock_client = AsyncMock() + adapter._client = mock_client + adapter._system_prompt = "Test prompt" + adapter._mcp.server_id = "mcp-server-1" + adapter._mcp.tool_ids = [] + room_ctx = RoomContext( + agent_id="agent-1", pending_seed=["[Alice]: Earlier context"] + ) + adapter._rooms["room-1"] = room_ctx + mock_client.agents.messages.create.return_value = make_letta_response( + make_assistant_message("The response was not sent through the tool.") + ) + + with pytest.raises(TurnResultAlreadyReported): + await adapter.on_message( + make_platform_message(), + FakeAgentTools(), + LettaSessionState(), + None, + None, + is_session_bootstrap=False, + room_id="room-1", + ) + + assert room_ctx.pending_seed == [] + assert room_ctx.last_interaction is not None + @pytest.mark.asyncio async def test_failed_first_turn_preserves_pending_seed( self, adapter_with_client: tuple[LettaAdapter, AsyncMock] diff --git a/tests/integrations/a2a/gateway/test_adapter.py b/tests/integrations/a2a/gateway/test_adapter.py index f3424cab2..0397ddf6f 100644 --- a/tests/integrations/a2a/gateway/test_adapter.py +++ b/tests/integrations/a2a/gateway/test_adapter.py @@ -705,6 +705,28 @@ async def test_relayed_peer_failure_redacts_nested_credentials_in_detail( detail = event.metadata["failure"]["detail"] assert "sk-live-nested-secret" not in str(detail) + @pytest.mark.asyncio + async def test_drops_non_dict_peer_failure_metadata(self) -> None: + """Malformed peer failure metadata must not cross the A2A boundary.""" + adapter = A2AGatewayAdapter(rest_client=MagicMock()) + queue = EventQueueLegacy() + pending = make_pending(queue) + secret = "password=peer-secret" + + await adapter._publish_band_response( + pending, + make_platform_message( + secret, + message_type="error", + metadata={"failure": secret}, + ), + ) + event = await queue.dequeue_event() + + assert event.status.state == TaskState.TASK_STATE_FAILED + assert "failure" not in event.metadata + assert secret not in event.status.message.parts[0].text + @pytest.mark.asyncio async def test_plain_error_message_without_failure_metadata_still_fails( self, diff --git a/tests/integrations/a2a/test_adapter.py b/tests/integrations/a2a/test_adapter.py index 2da5b3559..b5aa24dde 100644 --- a/tests/integrations/a2a/test_adapter.py +++ b/tests/integrations/a2a/test_adapter.py @@ -337,30 +337,33 @@ async def test_finally_block_failure_does_not_replace_try_blocks_exception( assert adapter._tasks == {}, "next turn must start a fresh task" @pytest.mark.asyncio - async def test_auth_required_task_is_posted_as_error_event( - self, adapter: A2AAdapter + @pytest.mark.parametrize( + "state", + [TaskState.TASK_STATE_CANCELED, TaskState.TASK_STATE_AUTH_REQUIRED], + ) + async def test_non_retryable_terminal_task_is_acked_after_error_event( + self, adapter: A2AAdapter, state: int ) -> None: tools = FakeAgentTools() + status_message = ( + "Please authenticate" + if state == TaskState.TASK_STATE_AUTH_REQUIRED + else "The task was canceled" + ) - with pytest.raises(TurnResultAlreadyReported): - await adapter._handle_event( - task_event( - make_task( - TaskState.TASK_STATE_AUTH_REQUIRED, - status_message="Please authenticate", - ) - ), - tools, - "room-123", - "user-456", - "Test User", - ) + await adapter._handle_event( + task_event(make_task(state, status_message=status_message)), + tools, + "room-123", + "user-456", + "Test User", + ) failures = reported_failures(tools) - assert failures, "an auth-required task must produce an error event" - assert failures[-1]["message"] == "Please authenticate" + assert failures, "a non-retryable terminal task must produce an error event" + assert failures[-1]["message"] == status_message assert failures[-1]["provider"] == "a2a" - assert failures[-1]["code"] == "TASK_STATE_AUTH_REQUIRED" + assert failures[-1]["code"] == TaskState.Name(state) @pytest.mark.asyncio async def test_input_required_is_forwarded_and_persisted( diff --git a/tests/integrations/acp/test_client_adapter.py b/tests/integrations/acp/test_client_adapter.py index 2a3abac2d..749ad48d5 100644 --- a/tests/integrations/acp/test_client_adapter.py +++ b/tests/integrations/acp/test_client_adapter.py @@ -766,6 +766,62 @@ async def test_on_message_error_sends_error_event( assert failures[0]["provider"] == "acp" assert failures[0]["message"] == GENERIC_PROVIDER_FAILURE_MESSAGE + @pytest.mark.asyncio + async def test_prompt_timeout_error_is_not_reported_as_adapter_timeout( + self, adapter_with_mocks: ACPClientAdapter + ) -> None: + """A provider-raised TimeoutError is not the adapter's deadline.""" + adapter_with_mocks._runtime._conn.prompt = AsyncMock( + side_effect=TimeoutError("provider socket timeout") + ) + + tools = FakeAgentTools() + + with pytest.raises(TimeoutError, match="provider socket timeout"): + await adapter_with_mocks.on_message( + make_platform_message("Hello", room_id="room-123"), + tools, + ACPClientSessionState(), + None, + None, + is_session_bootstrap=False, + room_id="room-123", + ) + + failures = reported_failures(tools) + assert len(failures) == 1 + assert failures[0]["message"] == GENERIC_PROVIDER_FAILURE_MESSAGE + assert failures[0]["code"] is None + + @pytest.mark.asyncio + async def test_adapter_deadline_raises_already_reported_failure( + self, adapter_with_mocks: ACPClientAdapter + ) -> None: + """The adapter's own deadline reports once and remains retryable.""" + adapter_with_mocks._turn_timeout_s = 0.01 + + async def slow_prompt(**_: object) -> None: + await asyncio.sleep(1) + + adapter_with_mocks._runtime._conn.prompt = AsyncMock(side_effect=slow_prompt) + + tools = FakeAgentTools() + + with pytest.raises(TimeoutError): + await adapter_with_mocks.on_message( + make_platform_message("Hello", room_id="room-123"), + tools, + ACPClientSessionState(), + None, + None, + is_session_bootstrap=False, + room_id="room-123", + ) + + failures = reported_failures(tools) + assert len(failures) == 1 + assert failures[0]["code"] == "timeout" + @pytest.mark.asyncio async def test_on_message_request_error_captures_code_and_data( self, adapter_with_mocks: ACPClientAdapter