diff --git a/docs/reference/a2a.md b/docs/reference/a2a.md index 020057b6f..b86c338f5 100644 --- a/docs/reference/a2a.md +++ b/docs/reference/a2a.md @@ -326,6 +326,7 @@ Domain exceptions are mapped to JSON-RPC 2.0 error codes: | `-32006` | `BudgetExceededError` | Budget exceeded | | `-32007` | `A2aVersionMismatchError` | Version mismatch | | `-32008` | `PlanError` | Plan lifecycle error | +| `-32009` | `ConfigurationError` | Configuration error | ### Example Error Response diff --git a/features/steps/_a2a_code_map.py b/features/steps/_a2a_code_map.py new file mode 100644 index 000000000..5af95b22c --- /dev/null +++ b/features/steps/_a2a_code_map.py @@ -0,0 +1,34 @@ +"""Shared A2A error code mapping for Behave step definitions. + +Provides a single source of truth for the mapping between symbolic error +name strings (used in Gherkin feature files) and their corresponding +JSON-RPC 2.0 integer codes (defined in ``cleveragents.a2a.errors``). + +All step files that need to translate a symbolic name like ``"NOT_FOUND"`` +to its integer code ``-32001`` should import :data:`A2A_CODE_MAP` from +this module rather than defining a local ``_CODE_MAP`` dictionary. + +This eliminates the DRY violation identified in code review where the +same mapping was duplicated across 4 step files (6 definitions total). +""" + +from __future__ import annotations + +from cleveragents.a2a import errors as _a2a_errors + +#: Maps symbolic error name strings (as used in Gherkin feature files) to +#: their JSON-RPC 2.0 integer error codes. Derived directly from the +#: ``cleveragents.a2a.errors`` module constants so that any future change +#: to the integer values is automatically reflected here. +A2A_CODE_MAP: dict[str, int] = { + "NOT_FOUND": _a2a_errors.NOT_FOUND, + "AUTH_ERROR": _a2a_errors.AUTH_ERROR, + "FORBIDDEN": _a2a_errors.FORBIDDEN, + "INVALID_STATE": _a2a_errors.INVALID_STATE, + "PLAN_ERROR": _a2a_errors.PLAN_ERROR, + "CONFIGURATION_ERROR": _a2a_errors.CONFIGURATION_ERROR, + "VALIDATION_ERROR": _a2a_errors.VALIDATION_ERROR, + "INTERNAL_ERROR": _a2a_errors.INTERNAL_ERROR, +} + +__all__ = ["A2A_CODE_MAP"] diff --git a/features/steps/a2a_facade_coverage_boost_steps.py b/features/steps/a2a_facade_coverage_boost_steps.py index 2dd24e76f..e29906c49 100644 --- a/features/steps/a2a_facade_coverage_boost_steps.py +++ b/features/steps/a2a_facade_coverage_boost_steps.py @@ -25,6 +25,7 @@ try: from cleveragents.a2a.models import A2aRequest except ImportError: pass # a2a module not available +from features.steps._a2a_code_map import A2A_CODE_MAP use_step_matcher("re") @@ -117,9 +118,13 @@ def step_cb_response_status(context: Context, status: str) -> None: @then(r'the coverage-boost response error code should be "(?P[^"]+)"') def step_cb_error_code(context: Context, code: str) -> None: + expected_code: int = A2A_CODE_MAP.get( + code, int(code) if code.lstrip("-").isdigit() else -1 + ) assert context.cb_response.error is not None, "No error in response" - assert context.cb_response.error.code == code, ( - f"Expected error code '{code}', got '{context.cb_response.error.code}'" + assert context.cb_response.error.code == expected_code, ( + f"Expected error code {expected_code} ('{code}'), " + f"got '{context.cb_response.error.code}'" ) diff --git a/features/steps/a2a_facade_steps.py b/features/steps/a2a_facade_steps.py index 631b0515c..f07209c7d 100644 --- a/features/steps/a2a_facade_steps.py +++ b/features/steps/a2a_facade_steps.py @@ -29,6 +29,7 @@ try: except ImportError: pass # a2a module not available from cleveragents.core.exceptions import CleverAgentsError +from features.steps._a2a_code_map import A2A_CODE_MAP use_step_matcher("re") @@ -401,10 +402,12 @@ def step_create_response(context: Context, status: str, rid: str) -> None: if status == "ok": context.response = A2aResponse(id=rid, result={}) else: + from cleveragents.a2a import errors as _a2a_errors from cleveragents.a2a.models import A2aErrorDetail + int_code: int = A2A_CODE_MAP.get(status, _a2a_errors.INTERNAL_ERROR) context.response = A2aResponse( - id=rid, error=A2aErrorDetail(code=status, message=status) + id=rid, error=A2aErrorDetail(code=int_code, message=status) ) @@ -433,7 +436,12 @@ def step_create_response_invalid(context: Context, status: str) -> None: r'I create an A2aErrorDetail with code "(?P[^"]+)" and message "(?P[^"]+)"' ) def step_create_error_detail(context: Context, code: str, msg: str) -> None: - context.error_detail = A2aErrorDetail(code=code, message=msg) + from cleveragents.a2a import errors as _a2a_errors + + int_code: int = A2A_CODE_MAP.get( + code, int(code) if code.lstrip("-").isdigit() else _a2a_errors.INTERNAL_ERROR + ) + context.error_detail = A2aErrorDetail(code=int_code, message=msg) @then(r"the error detail should be valid") diff --git a/features/steps/a2a_facade_wiring_steps.py b/features/steps/a2a_facade_wiring_steps.py index 526b2adc5..ad8a8adc4 100644 --- a/features/steps/a2a_facade_wiring_steps.py +++ b/features/steps/a2a_facade_wiring_steps.py @@ -28,6 +28,7 @@ from cleveragents.core.exceptions import ( ResourceNotFoundError, ValidationError, ) +from features.steps._a2a_code_map import A2A_CODE_MAP use_step_matcher("re") @@ -257,9 +258,13 @@ def step_wired_data_key_not_empty(context: Context, key: str) -> None: @then(r'wired response error code should be "(?P[^"]+)"') def step_wired_error_code(context: Context, code: str) -> None: + expected_code: int = A2A_CODE_MAP.get( + code, int(code) if code.lstrip("-").isdigit() else -1 + ) assert context.wired_response.error is not None, "No error in response" - assert context.wired_response.error.code == code, ( - f"Expected error code '{code}', got '{context.wired_response.error.code}'" + assert context.wired_response.error.code == expected_code, ( + f"Expected error code {expected_code} ('{code}'), " + f"got '{context.wired_response.error.code}'" ) diff --git a/features/steps/a2a_jsonrpc_wire_format_steps.py b/features/steps/a2a_jsonrpc_wire_format_steps.py index b254d0873..0a6f73121 100644 --- a/features/steps/a2a_jsonrpc_wire_format_steps.py +++ b/features/steps/a2a_jsonrpc_wire_format_steps.py @@ -13,6 +13,8 @@ from behave import given, then, use_step_matcher, when from behave.runner import Context from pydantic import ValidationError +from features.steps._a2a_code_map import A2A_CODE_MAP + use_step_matcher("re") try: @@ -148,9 +150,18 @@ def step_serialise_response(context: Context) -> None: r'an error A2aResponse with id "(?P[^"]+)" and error code "(?P[^"]+)"' ) def step_create_error_response(context: Context, resp_id: str, code: str) -> None: + from cleveragents.a2a import errors as _a2a_errors + + int_code: int = A2A_CODE_MAP.get( + code, int(code) if code.lstrip("-").isdigit() else _a2a_errors.INTERNAL_ERROR + ) context.response = A2aResponse( id=resp_id, - error=A2aErrorDetail(code=code, message="Resource not found"), + error=A2aErrorDetail(code=int_code, message="Resource not found"), + ) + context.response = A2aResponse( + id=resp_id, + error=A2aErrorDetail(code=int_code, message="Resource not found"), ) @@ -175,7 +186,7 @@ def step_create_response_both_result_and_error(context: Context) -> None: A2aResponse( id="test-id", result={"status": "ok"}, - error=A2aErrorDetail(code="ERR", message="oops"), + error=A2aErrorDetail(code=-32603, message="oops"), ) except (ValidationError, ValueError) as exc: context.caught_error = exc @@ -255,10 +266,15 @@ def step_response_error_none(context: Context) -> None: r'a JSON-RPC 2.0 error response dict with id "(?P[^"]+)" and error code "(?P[^"]+)"' ) def step_jsonrpc_error_response_dict(context: Context, resp_id: str, code: str) -> None: + from cleveragents.a2a import errors as _a2a_errors + + int_code: int = A2A_CODE_MAP.get( + code, int(code) if code.lstrip("-").isdigit() else _a2a_errors.INTERNAL_ERROR + ) context.raw_dict = { "jsonrpc": "2.0", "id": resp_id, - "error": {"code": code, "message": "Resource not found"}, + "error": {"code": int_code, "message": "Resource not found"}, } @@ -295,12 +311,13 @@ def step_wire_dispatch(context: Context, method: str, params_json: str) -> None: except Exception: # For unknown methods, facade raises A2aOperationNotFoundError # which is caught and returned as an error response + from cleveragents.a2a import errors as _a2a_errors from cleveragents.a2a.models import A2aErrorDetail, A2aResponse context.wire_response = A2aResponse( id=request.id, error=A2aErrorDetail( - code="NOT_FOUND", + code=_a2a_errors.NOT_FOUND, message=f"Unknown A2A method: {method}", ), ) diff --git a/robot/helper_a2a_facade_wiring.py b/robot/helper_a2a_facade_wiring.py index 167b7eb91..39c3e0359 100644 --- a/robot/helper_a2a_facade_wiring.py +++ b/robot/helper_a2a_facade_wiring.py @@ -215,11 +215,13 @@ def wired_event_subscribe() -> None: def wired_error_mapping() -> None: """Verify domain-to-A2A error code mapping.""" - cases: list[tuple[Exception, str]] = [ - (ResourceNotFoundError(resource_type="x", resource_id="1"), "NOT_FOUND"), - (ValidationError("bad"), "VALIDATION_ERROR"), - (PlanError("fail"), "PLAN_ERROR"), - (BusinessRuleViolation("invalid"), "INVALID_STATE"), + from cleveragents.a2a import errors as _a2a_errors + cases: list[tuple[Exception, int]] = [ + (ResourceNotFoundError(resource_type="x", resource_id="1"), + _a2a_errors.NOT_FOUND), + (ValidationError("bad"), _a2a_errors.VALIDATION_ERROR), + (PlanError("fail"), _a2a_errors.PLAN_ERROR), + (BusinessRuleViolation("invalid"), _a2a_errors.INVALID_STATE), ] for exc, expected_code in cases: code, _ = map_domain_error(exc) diff --git a/robot/helper_a2a_jsonrpc_wire_format.py b/robot/helper_a2a_jsonrpc_wire_format.py index 440d85f4b..5108a8c60 100644 --- a/robot/helper_a2a_jsonrpc_wire_format.py +++ b/robot/helper_a2a_jsonrpc_wire_format.py @@ -130,7 +130,7 @@ def response_error_wire_format() -> None: """Verify A2aResponse (error) serialises to JSON-RPC 2.0 wire format.""" resp = A2aResponse( id="REQ-002", - error=A2aErrorDetail(code="NOT_FOUND", message="Plan not found"), + error=A2aErrorDetail(code=-32001, message="Plan not found"), ) wire = resp.model_dump(exclude_none=True) @@ -154,7 +154,7 @@ def response_error_wire_format() -> None: # Check error structure error = wire["error"] - if error.get("code") != "NOT_FOUND": + if error.get("code") != -32001: print(f"FAIL: error code mismatch: {error.get('code')}", file=sys.stderr) sys.exit(1) @@ -276,7 +276,7 @@ def response_deserialise() -> None: error_payload = { "jsonrpc": "2.0", "id": "resp-43", - "error": {"code": "NOT_FOUND", "message": "Plan not found"}, + "error": {"code": -32001, "message": "Plan not found"}, } err_resp = A2aResponse.model_validate(error_payload) if err_resp.error is None: diff --git a/src/cleveragents/a2a/errors.py b/src/cleveragents/a2a/errors.py index 3d9aee3df..cfc5aa5f1 100644 --- a/src/cleveragents/a2a/errors.py +++ b/src/cleveragents/a2a/errors.py @@ -26,17 +26,35 @@ from cleveragents.core.exceptions import ( ) # --------------------------------------------------------------------------- -# A2A error code constants +# A2A error code constants — JSON-RPC 2.0 integer codes +# +# Standard JSON-RPC 2.0 codes (Section 5.1): +# -32700 Parse error +# -32600 Invalid request +# -32601 Method not found +# -32602 Invalid params +# -32603 Internal error +# +# Application-defined codes (per docs/reference/a2a.md §Error Code Taxonomy): +# -32001 Resource not found +# -32002 Authentication required +# -32003 Authorization forbidden +# -32004 Invalid state / business rule violation +# -32005 Duplicate entity +# -32006 Budget exceeded +# -32007 Version mismatch +# -32008 Plan error +# -32009 Configuration error # --------------------------------------------------------------------------- -NOT_FOUND: str = "NOT_FOUND" -VALIDATION_ERROR: str = "VALIDATION_ERROR" -INVALID_STATE: str = "INVALID_STATE" -PLAN_ERROR: str = "PLAN_ERROR" -AUTH_ERROR: str = "AUTH_ERROR" -FORBIDDEN: str = "FORBIDDEN" -CONFIGURATION_ERROR: str = "CONFIGURATION_ERROR" -INTERNAL_ERROR: str = "INTERNAL_ERROR" +NOT_FOUND: int = -32001 +AUTH_ERROR: int = -32002 +FORBIDDEN: int = -32003 +INVALID_STATE: int = -32004 +PLAN_ERROR: int = -32008 +CONFIGURATION_ERROR: int = -32009 +VALIDATION_ERROR: int = -32602 +INTERNAL_ERROR: int = -32603 # --------------------------------------------------------------------------- @@ -99,7 +117,7 @@ class A2aOperationNotFoundError(A2aError): # --------------------------------------------------------------------------- -def map_domain_error(exc: Exception) -> tuple[str, str]: +def map_domain_error(exc: Exception) -> tuple[int, str]: """Map a domain exception to an A2A error code and message. Returns a ``(code, message)`` tuple suitable for constructing an @@ -111,11 +129,16 @@ def map_domain_error(exc: Exception) -> tuple[str, str]: generic ``INVALID_STATE`` that would match its ``DomainError`` ancestor. + Per JSON-RPC 2.0 specification (Section 5.1), error codes are integers. + The returned ``code`` is always a negative integer matching the + JSON-RPC 2.0 standard taxonomy defined in ``docs/reference/a2a.md``. + Args: exc: The exception raised by a domain or application service. Returns: - A two-element tuple of ``(error_code, error_message)``. + A two-element tuple of ``(error_code, error_message)`` where + ``error_code`` is a JSON-RPC 2.0 compliant integer error code. """ if not isinstance(exc, Exception): raise TypeError("exc must be an Exception instance") diff --git a/src/cleveragents/a2a/models.py b/src/cleveragents/a2a/models.py index 2d9c26fc7..7ddaa8e86 100644 --- a/src/cleveragents/a2a/models.py +++ b/src/cleveragents/a2a/models.py @@ -62,15 +62,19 @@ class A2aErrorDetail(BaseModel): Matches the JSON-RPC 2.0 error object structure: ``{"code": ..., "message": ..., "data": {...}}`` + + Per JSON-RPC 2.0 specification (Section 5.1), ``code`` must be an integer. + Standard JSON-RPC 2.0 error codes are negative integers in the range + -32768 to -32000. Application-defined codes must be outside this range. """ model_config = ConfigDict(strict=False) - code: str + code: int message: str details: dict[str, Any] = {} - @field_validator("code", "message") + @field_validator("message") @classmethod def _must_be_non_empty(cls, value: str) -> str: if not value: