Skip to content

Commit b7d4e31

Browse files
bokelleyclaude
andcommitted
feat(server/a2a): structured error parity with MCP — emit field/details/retry_after, catch decisioning AdcpError (closes #530)
Mirrors PR #525's MCP-side fix on the A2A surface. Two parity gaps closed: 1. ``ADCPAgentExecutor.execute()`` now catches both ``adcp.exceptions.ADCPError`` AND ``adcp.decisioning.types.AdcpError``. The two are disjoint hierarchies; pre-fix decisioning errors fell into the generic ``except Exception`` and rendered as plain "Skill execution failed" text — losing the structured shape entirely for adopters using ``DecisioningPlatform``. 2. ``_send_adcp_error`` projects the full envelope: ``code``, ``message``, ``recovery``, ``field``, ``suggestion``, ``retry_after``, ``details``. Pre-fix only ``code`` / ``message`` / ``recovery`` / ``suggestion`` reached the wire; ``field`` / ``details`` / ``retry_after`` were dropped even when the raised error supplied them. Field extraction is shared with the MCP path via ``adcp.server.translate._extract_structured_fields`` (the helper #525 factored out) so both transports project off the same source-of-truth shape — no duplication, no drift. Tests parametrized over the same code set as #525's MCP test (``MEDIA_BUY_NOT_FOUND``, ``PACKAGE_NOT_FOUND``, ``TERMS_REJECTED``, ``BUDGET_TOO_LOW``) plus optional-field gating coverage and an ``ADCPTaskError`` regression case. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8b1b384 commit b7d4e31

2 files changed

Lines changed: 389 additions & 22 deletions

File tree

src/adcp/server/a2a_server.py

Lines changed: 57 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,22 @@
3535
from google.protobuf.struct_pb2 import Value
3636
from starlette.applications import Starlette
3737

38-
from adcp.exceptions import ADCPError, ADCPTaskError
38+
from adcp.exceptions import ADCPError
3939
from adcp.server.base import ADCPHandler, ToolContext
4040

41+
# Decisioning-layer ``AdcpError`` (from ``adcp.decisioning.types``) is the
42+
# wire-shaped structured error platform methods raise. It is NOT a subclass
43+
# of :class:`adcp.exceptions.ADCPError`; the executor must catch both so
44+
# storyboards graded against decisioning adopters see the same structured
45+
# envelope as MCP. Lazy import — ``adcp.decisioning`` pulls in the
46+
# decisioning graph, which the A2A server module shouldn't load at import
47+
# time. When the import fails (decisioning extra not installed), only the
48+
# client-side ``ADCPError`` path is active.
49+
try:
50+
from adcp.decisioning.types import AdcpError as _DecisioningAdcpError
51+
except Exception: # pragma: no cover - decisioning is an optional dep surface
52+
_DecisioningAdcpError = None # type: ignore[assignment,misc]
53+
4154
if TYPE_CHECKING:
4255
from collections.abc import Sequence
4356

@@ -73,7 +86,6 @@
7386
"""
7487

7588

76-
from adcp.server.helpers import STANDARD_ERROR_CODES # noqa: E402
7789
from adcp.server.mcp_tools import create_tool_caller, get_tools_for_handler
7890
from adcp.server.test_controller import TestControllerStore, _handle_test_controller
7991

@@ -203,15 +215,24 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non
203215
return
204216

205217
tool_context = self._build_tool_context(skill_name, context)
218+
# Catch both the client-side :class:`ADCPError` (raised by
219+
# framework helpers like ``IdempotencyConflictError``) AND the
220+
# decisioning-layer :class:`AdcpError` (raised by platform methods
221+
# adopters write against the decisioning graph). They are
222+
# disjoint hierarchies; both project onto the same structured
223+
# ``adcp_error`` envelope per transport-errors.mdx §A2A Binding.
224+
structured_error_types: tuple[type[BaseException], ...] = (ADCPError,)
225+
if _DecisioningAdcpError is not None:
226+
structured_error_types = (ADCPError, _DecisioningAdcpError)
206227
try:
207228
result = await self._dispatch_with_middleware(skill_name, params, tool_context)
208229
await self._send_result(event_queue, context, skill_name, result)
209-
except ADCPError as exc:
210-
# Application-layer AdCP error (IdempotencyConflictError etc.).
211-
# Emit a failed task with the adcp_error in a DataPart per
212-
# transport-errors.mdx §A2A Binding, plus a human-readable text
213-
# part. The JSON-RPC channel is reserved for transport-level
214-
# errors (auth rejected, rate-limited pre-dispatch).
230+
except structured_error_types as exc:
231+
# Application-layer AdCP error. Emit a failed task with the
232+
# adcp_error in a DataPart per transport-errors.mdx §A2A
233+
# Binding, plus a human-readable text part. The JSON-RPC
234+
# channel is reserved for transport-level errors (auth
235+
# rejected, rate-limited pre-dispatch).
215236
logger.info("AdCP application error for skill %s: %s", skill_name, exc)
216237
await self._send_adcp_error(event_queue, context, exc)
217238
except Exception:
@@ -406,37 +427,51 @@ async def _send_adcp_error(
406427
self,
407428
event_queue: EventQueue,
408429
context: RequestContext,
409-
exc: ADCPError,
430+
exc: Any,
410431
) -> None:
411432
"""Publish a failed task carrying an AdCP ``adcp_error`` payload.
412433
413434
Follows transport-errors.mdx §A2A Binding: failed task with artifact
414435
containing a ``DataPart`` keyed under ``adcp_error`` plus a terse
415436
``TextPart`` for human/LLM consumption.
437+
438+
The structured envelope carries the full spec shape — ``code``,
439+
``message``, ``recovery``, ``field``, ``suggestion``,
440+
``retry_after``, ``details`` — populated when the raised
441+
exception supplies them, omitted when ``None``. Field extraction
442+
is shared with the MCP path via
443+
:func:`adcp.server.translate._extract_structured_fields`, so
444+
both transports project off the same source-of-truth shape.
416445
"""
417-
# Derive the spec error code. ADCPTaskError carries a list of codes
418-
# (e.g. IdempotencyConflictError → IDEMPOTENCY_CONFLICT); fall back
419-
# to a generic INTERNAL_ERROR when the exception doesn't supply one.
420-
code = "INTERNAL_ERROR"
421-
if isinstance(exc, ADCPTaskError) and exc.error_codes:
422-
code = str(exc.error_codes[0])
446+
# Lazy import — ``translate.py`` pulls in heavier server deps
447+
# (mcp.types) which the A2A module doesn't otherwise need.
448+
from adcp.server.translate import _extract_structured_fields
449+
450+
code, message, recovery, field, suggestion, details, _errors = _extract_structured_fields(
451+
exc
452+
)
423453

424454
adcp_error: dict[str, Any] = {
425455
"code": code,
426-
"message": exc.message,
456+
"message": message,
457+
"recovery": recovery,
427458
}
428-
recovery = STANDARD_ERROR_CODES.get(code, {}).get("recovery")
429-
if recovery:
430-
adcp_error["recovery"] = recovery
431-
suggestion = getattr(exc, "suggestion", None)
432-
if suggestion:
459+
if field is not None:
460+
adcp_error["field"] = field
461+
if suggestion is not None:
433462
adcp_error["suggestion"] = suggestion
463+
# ``retry_after`` lives on decisioning AdcpError; project when present.
464+
retry_after = getattr(exc, "retry_after", None)
465+
if retry_after is not None:
466+
adcp_error["retry_after"] = retry_after
467+
if details:
468+
adcp_error["details"] = dict(details)
434469

435470
task = _make_task(
436471
context,
437472
state=pb.TaskState.TASK_STATE_FAILED,
438473
data={"adcp_error": adcp_error},
439-
message=exc.message,
474+
message=message,
440475
)
441476
await event_queue.enqueue_event(task)
442477

0 commit comments

Comments
 (0)