|
35 | 35 | from google.protobuf.struct_pb2 import Value |
36 | 36 | from starlette.applications import Starlette |
37 | 37 |
|
38 | | -from adcp.exceptions import ADCPError, ADCPTaskError |
| 38 | +from adcp.exceptions import ADCPError |
39 | 39 | from adcp.server.base import ADCPHandler, ToolContext |
40 | 40 |
|
| 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 | + |
41 | 54 | if TYPE_CHECKING: |
42 | 55 | from collections.abc import Sequence |
43 | 56 |
|
|
73 | 86 | """ |
74 | 87 |
|
75 | 88 |
|
76 | | -from adcp.server.helpers import STANDARD_ERROR_CODES # noqa: E402 |
77 | 89 | from adcp.server.mcp_tools import create_tool_caller, get_tools_for_handler |
78 | 90 | from adcp.server.test_controller import TestControllerStore, _handle_test_controller |
79 | 91 |
|
@@ -203,15 +215,24 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non |
203 | 215 | return |
204 | 216 |
|
205 | 217 | 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) |
206 | 227 | try: |
207 | 228 | result = await self._dispatch_with_middleware(skill_name, params, tool_context) |
208 | 229 | 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). |
215 | 236 | logger.info("AdCP application error for skill %s: %s", skill_name, exc) |
216 | 237 | await self._send_adcp_error(event_queue, context, exc) |
217 | 238 | except Exception: |
@@ -406,37 +427,51 @@ async def _send_adcp_error( |
406 | 427 | self, |
407 | 428 | event_queue: EventQueue, |
408 | 429 | context: RequestContext, |
409 | | - exc: ADCPError, |
| 430 | + exc: Any, |
410 | 431 | ) -> None: |
411 | 432 | """Publish a failed task carrying an AdCP ``adcp_error`` payload. |
412 | 433 |
|
413 | 434 | Follows transport-errors.mdx §A2A Binding: failed task with artifact |
414 | 435 | containing a ``DataPart`` keyed under ``adcp_error`` plus a terse |
415 | 436 | ``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. |
416 | 445 | """ |
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 | + ) |
423 | 453 |
|
424 | 454 | adcp_error: dict[str, Any] = { |
425 | 455 | "code": code, |
426 | | - "message": exc.message, |
| 456 | + "message": message, |
| 457 | + "recovery": recovery, |
427 | 458 | } |
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: |
433 | 462 | 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) |
434 | 469 |
|
435 | 470 | task = _make_task( |
436 | 471 | context, |
437 | 472 | state=pb.TaskState.TASK_STATE_FAILED, |
438 | 473 | data={"adcp_error": adcp_error}, |
439 | | - message=exc.message, |
| 474 | + message=message, |
440 | 475 | ) |
441 | 476 | await event_queue.enqueue_event(task) |
442 | 477 |
|
|
0 commit comments