Skip to content

feat: Adopt AgentFailure/send_failure across Python adapters - #612

Open
AlexanderZ-Band wants to merge 54 commits into
mainfrom
int-1385-band-sdk-python-surface-errors-in-adapters
Open

AlexanderZ-Band wants to merge 54 commits into
mainfrom
int-1385-band-sdk-python-surface-errors-in-adapters

Conversation

@AlexanderZ-Band

@AlexanderZ-Band AlexanderZ-Band commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adopts band-sdk-core's shared AgentFailure shape (provider/code/message/detail)
across band-sdk-python adapters, replacing the free-text "error" event and
its ad hoc per-adapter metadata keys.

Changes

  • Bump band-sdk-core to 2.3.0
  • send_failure/to_failure_event on AgentToolsProtocol, AgentTools, FakeAgentTools
  • DeliveryFailedError/deliver_reply for Band-delivery vs. provider-failure separation
  • Migrated adapters: Anthropic, Gemini, Google ADK, Claude SDK, Copilot SDK, LangGraph, Letta, A2A, A2A Gateway, CrewAI, CrewAI Flow, Pydantic AI, Parlant, Strands, Agno, OpenCode, Codex, ACP client
  • ACP client: turn-level timeout (turn_timeout_s, default 300s) around _runtime.prompt
  • A2A Gateway: structured failure channel on PendingA2ATask.fail, credential redaction on both the adapter's own exception path and the peer-forwarded-failure relay path
  • Codex: removed the old remediation/suggested-action policy; turn-timeout, error-notification, and turn/completed failure paths each report exactly once
  • Shared FAILURE_CODE_TIMEOUT constant; MessageType.ERROR used instead of a raw "error" literal
  • to_failure_event blank-content check uses has_visible_content()
  • Centralized reported_failures()/events_of_type() test helpers in band.testing
  • A2A adapter: terminal task-event emission isolated from the try block's exception in finally
  • CrewAI Flow: record_failed caps the room-visible message at 500 chars, preserves the full message in AgentFailure.detail

Breaking Changes

  • Codex: CodexAdapterConfig.structured_errors (previously bool = True, documented in docs/adapters/codex.md) is removed. The model uses extra="forbid", so any existing deployment config, constructor kwarg, or CODEX_STRUCTURED_ERRORS env var still setting this field will now raise pydantic.ValidationError at construction instead of being silently ignored. No deprecation alias/passthrough is added — this repo's own conventions reject backwards-compat shims in favor of a clean break. The eventual merge commit needs a ! on its type (e.g. refactor!:) and a BREAKING CHANGE: footer so release-please's changelog actually reflects this.

Out of scope

  • Pydantic AI's band_respond_contact_request tool handler's own error event (a platform-tool failure, not a provider failure)
  • ACP client's per-room timeout isolation (currently tears down the adapter-wide shared runtime; pre-existing behavior, not introduced by this PR)
  • Transport-health-scoped connection classification, cancel_turn/stop_reason propagation

Test plan

  • uv run ruff check .
  • uv run ruff format --check .
  • uv run pyrefly check
  • uv run pytest tests/ --ignore=tests/integration/ --ignore=tests/e2e/ — 5620 passed, 146 skipped, 0 failed
  • uv run pytest --markdown-docs $(git ls-files '*.md' ':!:examples/*')

Follow-up: cross-SDK review fixes (99b660a)

Fixed in 99b660a, found against band-sdk-typescript#178's parallel implementation: several adapters reported a terminal provider failure via send_failure but then returned/fell through normally instead of failing the turn.

File:location Before → After Reason Reasoning
a2a/adapter.py DeliveryFailedError except Logged + returned normally → raises e.cause bug fix Room-post failure silently swallowed; turn marked processed despite the reply never landing.
a2a/adapter.py generic except Exception send_failure + return → send_failure + raise bug fix Reported but still marked successful — lost retry.
claude_sdk.py _on_turn_complete (2 branches) send_failure + return → raises new TurnResultAlreadyReported bug fix Terminal error / no-reply turn reported but completed "successfully."
claude_sdk.py on_message No handling for already-reported failures → new except TurnResultAlreadyReported: raise clause bug fix (support) Prevents the above fix from double-reporting via the outer catch-all.
letta.py client-not-initialized guard send_failure + return → synthesizes RuntimeError, reports, raises bug fix Dropped message counted as processed.
letta.py session-prep except Exception send_failure + return → send_failure + bare raise bug fix Same swallow-after-report pattern.
letta.py _handle_message missing-room-context send_failure + return → synthesizes RuntimeError, reports, raises bug fix Turn dropped for lack of context looked like a normal no-op.
letta.py _run_turn DeliveryFailedError except Logged only, no raise → raises e.cause (still no send_failure, correctly) bug fix Band-side delivery failure was invisible to retry, though correctly never misattributed to the provider.
letta.py _run_turn timeout / generic except send_failure then fall through → send_failure then raise bug fix Timed-out/errored turn still counted as successfully processed.
parlant.py app-not-initialized guard send_failure + return → synthesizes RuntimeError, reports, raises bug fix Same pattern as Letta.
parlant.py session-init except Exception send_failure + return → send_failure + raise bug fix Same pattern.

Net effect: across four adapters, a reported provider failure now also fails the turn, so the platform's retry mechanism actually engages instead of silently losing the message. TurnResultAlreadyReported prevents this from causing double-reporting in claude_sdk. Band-side delivery failures remain correctly un-attributed to the provider but are now also retryable.

🤖 Generated with Claude Code

https://claude.ai/code/session_0195k7AdghCyZgBPgs4TdxE3

AlexanderZ-Band and others added 11 commits September 6, 2026 14:13
The AgentFailure shared failure shape shipped in this release via
release-please automation, unblocking the send_failure migration.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmsEQaNzmSmBGVsCTuKvbX
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmsEQaNzmSmBGVsCTuKvbX
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmsEQaNzmSmBGVsCTuKvbX
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmsEQaNzmSmBGVsCTuKvbX
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmsEQaNzmSmBGVsCTuKvbX
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmsEQaNzmSmBGVsCTuKvbX
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmsEQaNzmSmBGVsCTuKvbX
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmsEQaNzmSmBGVsCTuKvbX
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmsEQaNzmSmBGVsCTuKvbX
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmsEQaNzmSmBGVsCTuKvbX
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmsEQaNzmSmBGVsCTuKvbX
@linear-code

linear-code Bot commented Sep 6, 2026

Copy link
Copy Markdown

INT-1385

INT-1388

@AlexanderZ-Band AlexanderZ-Band changed the title chore: bump band-sdk-core to 2.3.0 for AgentFailure feat: Adopt AgentFailure/send_failure across Python adapters Sep 6, 2026
@AlexanderZ-Band
AlexanderZ-Band marked this pull request as draft September 6, 2026 11:55
AlexanderZ-Band and others added 11 commits September 6, 2026 14:59
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmsEQaNzmSmBGVsCTuKvbX
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmsEQaNzmSmBGVsCTuKvbX
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmsEQaNzmSmBGVsCTuKvbX
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmsEQaNzmSmBGVsCTuKvbX
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmsEQaNzmSmBGVsCTuKvbX
_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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmsEQaNzmSmBGVsCTuKvbX
_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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmsEQaNzmSmBGVsCTuKvbX
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmsEQaNzmSmBGVsCTuKvbX
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmsEQaNzmSmBGVsCTuKvbX
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmsEQaNzmSmBGVsCTuKvbX
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmsEQaNzmSmBGVsCTuKvbX
@AlexanderZ-Band
AlexanderZ-Band marked this pull request as ready for review September 6, 2026 13:34
AlexanderZ-Band and others added 3 commits September 6, 2026 16:43
- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmsEQaNzmSmBGVsCTuKvbX
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmsEQaNzmSmBGVsCTuKvbX
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmsEQaNzmSmBGVsCTuKvbX
@AlexanderZ-Band
AlexanderZ-Band requested a lite review from Copilot September 7, 2026 04:19
AlexanderZ-Band and others added 6 commits September 9, 2026 22:01
_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 83fb9fe) 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uGMZeouBGz9s3vKCe2uhj
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 83fb9fe
as a shared, general-purpose utility with no test of its own contract.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uGMZeouBGz9s3vKCe2uhj
…ted_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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uGMZeouBGz9s3vKCe2uhj
…d_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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uGMZeouBGz9s3vKCe2uhj
…ed_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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uGMZeouBGz9s3vKCe2uhj
…ering

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uGMZeouBGz9s3vKCe2uhj
@AlexanderZ-Band

Copy link
Copy Markdown
Collaborator Author

Cycle 2 of the personal fixed-criteria review

Re-ran all six angle-groups against the head produced by cycle 1's fixes (commits 86990dc..0cb8119). Simplification and Logical Bugs came back clean (zero new findings — the cycle-1 Logical Bugs fix was independently re-verified and still holds). The other four groups found 12 new findings, all introduced or made newly visible by cycle 1's own fixes rather than pre-existing PR content. 11 fixed, 1 declined; already committed and pushed (b00e015..004b4fc):

  • issue (Runtime Safety) codex.py's _emit_structured_turn_error had zero logging on its failure path, unlike its sibling _handle_error_event. Fixed in b00e015: added a logger.error(...) before send_failure.
  • issue (Design & Reuse) The send_event_safe migration (cycle 1) only covered 5 of 16 matching hand-rolled try/tools.send_event/except Exception/logger.debug sites in codex.py. Fixed in b00e015: migrated the remaining 11.
  • issue (Design & Reuse) send_event_safe's docstring incorrectly implied send_failure is a raising control-signal call, contradicting send_failure's own docstring. Fixed in fc87987.
  • issue (Test Quality) tests/adapters/agno/test_adapter.py's cycle-1 fix missed a third identical hand-extraction site (test_error_status_run_is_raised_and_reported). Fixed in 86c2f39.
  • suggestion (Test Quality) reported_failures()/events_of_type() (now depended on by ~15 test files) had no direct test of their own contract. Fixed in 004b4fc.
  • suggestion (Test Quality) send_event_safe had no test of its failure branch. Fixed in fc87987.
  • issue (Style & Hygiene) 4 test files (test_letta_adapter.py, test_letta_mcp.py ×2, tests/integrations/a2a/test_adapter.py ×2, tests/adapters/agno/test_adapter.py ×2) added a reported_failures() call in cycle 1 alongside the pre-existing hand-rolled error_events/errors filter+content check instead of replacing it, leaving redundant double-checks. Fixed in 0f8ac32, 86c2f39, 58d3a91: consolidated onto reported_failures() alone at all 8 sites.
  • nit (Style & Hygiene) a2a/adapter.py's terminal-failure branch computed state_name(state) 3 times for one value. Fixed in 0f8ac32: bound once.
  • suggestion (Style & Hygiene, declined) codex.py's 5 (now 16) send_event_safe call sites repeat message_type="task", log_level=logging.DEBUG. Not fixed — a wrapper/partial for 2 repeated kwargs would be more machinery than the duplication it removes, and the shared angle-group audit that flagged the underlying helper (send_event_safe itself) explicitly concluded it is not over-engineered; adding a second-order helper on top of it would tip it the other way.

Full verification gate (ruff check/format, pyrefly, full unit suite) green after these fixes — 5651 passed, 135 skipped; same 2 pre-existing unrelated failures as before (tests/example_agents/test_otel_setup.py, tests/integrations/test_crewai_flow_real_sdk.py).

This was cycle 2 of 2 (--cycles 2) — stopping here.

🤖 Generated with Claude Code

https://claude.ai/code/session_011uGMZeouBGz9s3vKCe2uhj

AlexanderZ-Band and others added 2 commits September 9, 2026 22:19
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uGMZeouBGz9s3vKCe2uhj
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011uGMZeouBGz9s3vKCe2uhj
@AlexanderZ-Band

Copy link
Copy Markdown
Collaborator Author

Two additional test-infra fixes (root-caused, not part of the review findings)

The full suite had 2 failures that looked environment-related at first glance; dug into both per request rather than dismissing them. Both are real, pre-existing bugs (neither touched by this PR's diff), now fixed and pushed (956e0f7, c237ec4):

  • tests/example_agents/test_otel_setup.py::test_pipeline_publishes_no_global_provider — crewai's own event bus (crewai.events.event_listener) installs a global OpenTelemetry TracerProvider and a live span-exporter thread at import time, unless CREWAI_DISABLE_TELEMETRY is set. Fixed by setting it in tests/conftest.py before any test can import crewai.
  • tests/integrations/test_crewai_flow_real_sdk.py::test_real_crewai_flow_can_call_adapter_registered_custom_tooltests/docker/launcher/test_exec.py calls launcher_run.main() in-process, which deliberately mutates the real process HOME (a legitimate side effect for the real launcher process) with nothing to revert it, leaking a fake /home/agent HOME into every later test in the session. Fixed with an autouse monkeypatch fixture in that directory's conftest.py, matching its existing env-isolation pattern.

Neither failure was ever visible in CI: CI splits the suite across 3 jobs by dependency extra, and neither of these two tests ever shares a process with the other in any of those jobs. They only surfaced because a local dev venv had every extra installed at once. Checked whether "make the full tree pass in one CI lane" was achievable now: no — the dev-crewai/dev-parlant extras are genuinely missing ~34 test files' worth of other frameworks (agno, langgraph's langchain deps, letta-client, acp, fastapi, etc.), so running the unrestricted tree there hits hard collection errors unrelated to these two bugs. That's a separate, much larger effort; leaving it for now per your call.

Full suite: 5653 passed, 135 skipped, 0 failed.

🤖 Generated with Claude Code

https://claude.ai/code/session_011uGMZeouBGz9s3vKCe2uhj

@amit-gazal-band amit-gazal-band left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review from a plain Claude Code session (no human review yet). Found several gaps in this PR's own error-surfacing mechanism plus a couple of smaller cleanups — see inline comments. Two additional pre-existing issues noticed but out of scope for this PR (not commented inline): src/band/adapters/opencode/adapter.py around the OpencodeToolStatus.PENDING gate silently drops tool calls still pending at timeout/abort, and src/band/integrations/acp/room_emitter.py's send_event calls aren't wrapped in this PR's new send_event_safe helper the way other adapters now are.

Comment thread src/band/adapters/codex.py
Comment thread src/band/adapters/codex.py Outdated
Comment thread src/band/adapters/codex.py
Comment thread src/band/adapters/anthropic.py
Comment thread src/band/adapters/codex.py Outdated
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JjhSR1MngdKxXiSJctQjun
@AlexanderZ-Band

Copy link
Copy Markdown
Collaborator Author

Personal review pass (2026-09-14): assessing @amit-gazal-band's review

Ran the my-here-is-a-review workflow against the latest CHANGES_REQUESTED review (5 inline comments + 2 informational out-of-scope notes) at c237ec4. Checked out that exact commit to verify every claim against real code rather than the review's quoted excerpts.

Finding Verdict Action
codex.py on_message's inner except Exception around _process_turn_events builds a TurnResult and falls through to a plain chat reply, never calling send_failure Confirmed — reproduced with a regression test that failed on the old code (no failure reported, no raise) Fixed in 80146b7: now reports AgentFailure and raises TurnResultAlreadyReported, consistent with every sibling failure path
transport/closed calls send_failure unconditionally, unlike turn/completed's failure_reported guard — double-reports one incident if an "error" notification preceded the transport drop Confirmed — reproduced with a regression test that failed on the old code (2 failures reported instead of 1) Fixed in 80146b7: added the same guard
Removing CodexAdapterConfig.structured_errors while the model uses extra="forbid" is a breaking change, not flagged as such Confirmed Not code-fixed (this repo's conventions reject compat shims for a field intentionally removed) — added a Breaking Changes section to the PR description instead, and flagging that the eventual merge commit needs refactor!:/feat!: + a BREAKING CHANGE: footer for release-please
_to_agent_failure copy-pasted across anthropic.py/gemini.py/acp/client_adapter.py Pushed back Not fixed — each copy dispatches on a different SDK exception type with different attribute names; a shared helper would need string-keyed getattr, which is worse than three small typed functions. This exact tradeoff was already raised and declined earlier in this PR (43692d1: "genuinely adapter-specific")
Bare "codex" string literal at 4+ AgentFailure(...) call sites instead of a constant Confirmed Fixed in 80146b7: added CODEX_PROVIDER in integrations/codex/types.py, matching claude_sdk.py/letta.py's existing _PROVIDER convention from this same PR

The two informational notes (OpenCode's PENDING tool-status gate, ACP room_emitter not using send_event_safe) were already correctly scoped out by the reviewer themselves — no action needed there.

Full verification gate green: ruff check/format --check, pyrefly check (0 errors), full unit suite (5640 passed, 146 skipped, 0 failed). Both new/changed regression tests confirmed to fail against the pre-fix code and pass after.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JjhSR1MngdKxXiSJctQjun

@AlexanderZ-Band
AlexanderZ-Band requested review from a team and amit-gazal-band September 14, 2026 08:23

@AlexanderZ-Band AlexanderZ-Band left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personal fixed-criteria review (six angle-groups)

Ran the six-lens pass (Style & Hygiene, Design & Reuse, Runtime Safety & Observability, Test Quality, Edge-Case Flow Analysis, and a staged Logical Bugs explorer+skeptic) against the full current diff. 11 findings survived verification (2 nits, 9 issues — several overlapping findings from different groups on the same root cause were merged into one comment each). Simplification/over-engineering: none flagged.

One more finding has no diff hunk to anchor an inline comment to (the file isn't part of this diff), noting it here instead:

  • suggestion tests/e2e/baseline/smoke/inspection/test_events.py: this PR changes the wire shape of every adapter's failure reporting (new metadata["failure"] key carrying AgentFailure.to_dict()) across ~18 adapters, but no e2e/baseline smoke forces a real provider failure and asserts the platform round-trips this new shape end-to-end — existing coverage here only exercises the driven band_send_event(message_type="error") platform tool, not the adapter-internal send_failure path this PR introduces. Consider adding a baseline smoke that forces a deterministic provider failure for at least one adapter and asserts the resulting room event's metadata["failure"]["provider"]/code round-trip through the real platform.

Comment thread src/band/adapters/gemini.py Outdated
Comment thread src/band/adapters/gemini.py Outdated
Comment thread src/band/adapters/codex.py
Comment thread src/band/integrations/acp/client_adapter.py Outdated
Comment thread tests/adapters/test_letta_adapter.py Outdated
Comment thread tests/adapters/agno/test_adapter.py Outdated
Comment thread tests/integrations/a2a/gateway/test_adapter.py
Comment thread src/band/adapters/codex.py Outdated
Comment thread src/band/integrations/a2a/gateway/adapter.py
Comment thread src/band/adapters/letta.py Outdated
AlexanderZ-Band and others added 4 commits September 14, 2026 13:30
- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JjhSR1MngdKxXiSJctQjun
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JjhSR1MngdKxXiSJctQjun
…hon-surface-errors-in-adapters

Keep CrewAI's first-call empty-response retry, and report those failures
through send_failure instead of the removed send_event error path.

Co-authored-by: Cursor <cursoragent@cursor.com>

@AlexanderZ-Band AlexanderZ-Band left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One confirmed ACP lifecycle finding; fixed in ad36d9a and covered by a focused regression.

Comment thread src/band/integrations/acp/room_emitter.py

Copy link
Copy Markdown
Collaborator Author

Triaged as out of scope: INT-1385 requires adapter contract coverage, which this PR provides. A live platform test needs a deterministic real-provider failure boundary; none exists here without adding fragile provider/auth plumbing. No product defect remains untested by the ticket's required contract coverage.

Copy link
Copy Markdown
Collaborator Author

Verified on the current head: _publish_band_response applies _redact_credentials_deep() to the complete failure payload before forwarding it, and test_relayed_peer_failure_redacts_nested_credentials_in_detail covers a credential nested in failure.detail. The reported exposure is fixed.

@AlexanderZ-Band AlexanderZ-Band left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two confirmed behavior defects plus one cleanup nit; fixing them now.

Comment thread src/band/adapters/claude_sdk.py Outdated
Comment thread src/band/integrations/acp/client_adapter.py Outdated
Comment thread src/band/integrations/a2a/adapter.py
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants