fix(a2a): rename A2aRequest/A2aResponse fields to comply with JSON-RPC 2.0 wire format

Rewrites the A2aRequest and A2aResponse Pydantic models to use the field
names mandated by the JSON-RPC 2.0 specification, fixing a fundamental
protocol compliance issue that prevented external A2A-compliant clients
from communicating with the server.

Changes:
- A2aRequest: a2a_version→jsonrpc (fixed '2.0'), request_id→id,
  operation→method; auth field removed (not in JSON-RPC 2.0)
- A2aResponse: a2a_version→jsonrpc, request_id→id, status+data→result
  (success path), timing_ms removed; added _result_xor_error validator
  enforcing mutual exclusion of result and error fields
- A2aLocalFacade.dispatch(): updated to use request.method, request.id,
  result=data, error=A2aErrorDetail(...)
- A2aHttpTransport.send(): updated to use request.method
- CLI call sites (session.py, plan.py): updated A2aRequest(method=...)
  and response.result / response.error field access
- All existing A2A Behave step files updated to new field names
- New 35-scenario Behave feature (a2a_jsonrpc_wire_format.feature)
  covering serialisation, deserialisation, validation, and facade dispatch
- New 7-test Robot Framework suite (a2a_jsonrpc_wire_format.robot)
  for end-to-end wire format verification

ISSUES CLOSED: #1501
This commit is contained in:
2026-04-03 00:29:46 +00:00
parent 074c472e36
commit 9c6d69153e
17 changed files with 1099 additions and 100 deletions
-1
View File
@@ -256,7 +256,6 @@ Feature: A2A local facade coverage — uncovered handler and edge-case paths
When I dispatch facade-cov operation "plan.execute" with params {"plan_id": "PLAN-FAIL"}
Then the facade-cov response status should be "error"
And the facade-cov response error should not be None
And the facade-cov response should have timing_ms set
# -------------------------------------------------------------------
# Dispatch TypeError — non-request argument
+191
View File
@@ -0,0 +1,191 @@
Feature: A2A JSON-RPC 2.0 wire format compliance
As a developer maintaining the A2A protocol layer
I want A2aRequest and A2aResponse to use JSON-RPC 2.0 field names
So that external A2A-compliant clients can communicate with the server
# -------------------------------------------------------------------
# A2aRequest serialisation JSON-RPC 2.0 field names
# -------------------------------------------------------------------
Scenario: A2aRequest serialises with jsonrpc field set to "2.0"
Given a valid A2aRequest with method "_cleveragents/plan/status" and params {"plan_id": "P1"}
When I serialise the request to a dict
Then the serialised dict should contain key "jsonrpc" with value "2.0"
Scenario: A2aRequest serialises with id field
Given a valid A2aRequest with method "_cleveragents/plan/status" and params {"plan_id": "P1"}
When I serialise the request to a dict
Then the serialised dict should contain key "id"
Scenario: A2aRequest serialises with method field
Given a valid A2aRequest with method "_cleveragents/plan/status" and params {"plan_id": "P1"}
When I serialise the request to a dict
Then the serialised dict should contain key "method" with value "_cleveragents/plan/status"
Scenario: A2aRequest serialises with params field
Given a valid A2aRequest with method "_cleveragents/plan/status" and params {"plan_id": "P1"}
When I serialise the request to a dict
Then the serialised dict should contain key "params"
Scenario: A2aRequest does not contain non-standard field a2a_version
Given a valid A2aRequest with method "_cleveragents/plan/status" and params {"plan_id": "P1"}
When I serialise the request to a dict
Then the serialised dict should not contain key "a2a_version"
Scenario: A2aRequest does not contain non-standard field request_id
Given a valid A2aRequest with method "_cleveragents/plan/status" and params {"plan_id": "P1"}
When I serialise the request to a dict
Then the serialised dict should not contain key "request_id"
Scenario: A2aRequest does not contain non-standard field operation
Given a valid A2aRequest with method "_cleveragents/plan/status" and params {"plan_id": "P1"}
When I serialise the request to a dict
Then the serialised dict should not contain key "operation"
Scenario: A2aRequest does not contain non-standard field auth
Given a valid A2aRequest with method "_cleveragents/plan/status" and params {"plan_id": "P1"}
When I serialise the request to a dict
Then the serialised dict should not contain key "auth"
Scenario: A2aRequest auto-generates id when not provided
Given a valid A2aRequest with method "session.create" and params {}
Then the request id should be non-empty
Scenario: A2aRequest accepts explicit id
Given an A2aRequest with method "session.create" and id "my-custom-id"
Then the request id should equal "my-custom-id"
Scenario: A2aRequest rejects empty method
When I try to create an A2aRequest with empty method
Then a wire format ValidationError should be raised
Scenario: A2aRequest rejects non-2.0 jsonrpc version
When I try to create an A2aRequest with jsonrpc "1.0"
Then a wire format ValidationError should be raised
# -------------------------------------------------------------------
# A2aResponse serialisation — JSON-RPC 2.0 field names (success)
# -------------------------------------------------------------------
Scenario: A2aResponse success serialises with jsonrpc field set to "2.0"
Given a successful A2aResponse with id "REQ-001" and result {"status": "ok"}
When I serialise the response to a dict
Then the serialised dict should contain key "jsonrpc" with value "2.0"
Scenario: A2aResponse success serialises with id field
Given a successful A2aResponse with id "REQ-001" and result {"status": "ok"}
When I serialise the response to a dict
Then the serialised dict should contain key "id" with value "REQ-001"
Scenario: A2aResponse success serialises with result field
Given a successful A2aResponse with id "REQ-001" and result {"status": "ok"}
When I serialise the response to a dict
Then the serialised dict should contain key "result"
Scenario: A2aResponse success does not contain non-standard field a2a_version
Given a successful A2aResponse with id "REQ-001" and result {"status": "ok"}
When I serialise the response to a dict
Then the serialised dict should not contain key "a2a_version"
Scenario: A2aResponse success does not contain non-standard field request_id
Given a successful A2aResponse with id "REQ-001" and result {"status": "ok"}
When I serialise the response to a dict
Then the serialised dict should not contain key "request_id"
Scenario: A2aResponse success does not contain non-standard field status
Given a successful A2aResponse with id "REQ-001" and result {"status": "ok"}
When I serialise the response to a dict
Then the serialised dict should not contain key "status"
Scenario: A2aResponse success does not contain non-standard field data
Given a successful A2aResponse with id "REQ-001" and result {"status": "ok"}
When I serialise the response to a dict
Then the serialised dict should not contain key "data"
Scenario: A2aResponse success does not contain non-standard field timing_ms
Given a successful A2aResponse with id "REQ-001" and result {"status": "ok"}
When I serialise the response to a dict
Then the serialised dict should not contain key "timing_ms"
# -------------------------------------------------------------------
# A2aResponse serialisation — JSON-RPC 2.0 field names (error)
# -------------------------------------------------------------------
Scenario: A2aResponse error serialises with error field
Given an error A2aResponse with id "REQ-002" and error code "NOT_FOUND"
When I serialise the response to a dict
Then the serialised dict should contain key "error"
Scenario: A2aResponse error does not contain result field
Given an error A2aResponse with id "REQ-002" and error code "NOT_FOUND"
When I serialise the response to a dict
Then the serialised dict should not contain key "result"
Scenario: A2aResponse error does not contain non-standard field status
Given an error A2aResponse with id "REQ-002" and error code "NOT_FOUND"
When I serialise the response to a dict
Then the serialised dict should not contain key "status"
# -------------------------------------------------------------------
# A2aResponse validation — mutual exclusion of result and error
# -------------------------------------------------------------------
Scenario: A2aResponse requires either result or error
When I try to create an A2aResponse with neither result nor error
Then a wire format ValidationError should be raised
Scenario: A2aResponse rejects both result and error set simultaneously
When I try to create an A2aResponse with both result and error
Then a wire format ValidationError should be raised
# -------------------------------------------------------------------
# Deserialisation — inbound JSON-RPC 2.0 payloads
# -------------------------------------------------------------------
Scenario: A2aRequest deserialises from JSON-RPC 2.0 compliant dict
Given a JSON-RPC 2.0 request dict with method "message/send" and id "42"
When I deserialise the dict into an A2aRequest
Then the request method should equal "message/send"
And the request id should equal "42"
And the request jsonrpc should equal "2.0"
Scenario: A2aResponse deserialises from JSON-RPC 2.0 success dict
Given a JSON-RPC 2.0 success response dict with id "42" and result {"plan_id": "P1"}
When I deserialise the dict into an A2aResponse
Then the response result should contain key "plan_id"
And the response error should be None
Scenario: A2aResponse deserialises from JSON-RPC 2.0 error dict
Given a JSON-RPC 2.0 error response dict with id "42" and error code "NOT_FOUND"
When I deserialise the dict into an A2aResponse
Then the response error should not be None
And the response result should be None
# -------------------------------------------------------------------
# Facade dispatch — produces JSON-RPC 2.0 compliant responses
# -------------------------------------------------------------------
Scenario: Facade dispatch produces response with jsonrpc "2.0"
Given a wire-format facade with no services
When I dispatch wire-format method "_cleveragents/health/check" with params {}
Then the wire-format response jsonrpc should equal "2.0"
Scenario: Facade dispatch success produces response with result field
Given a wire-format facade with no services
When I dispatch wire-format method "_cleveragents/health/check" with params {}
Then the wire-format response result should not be None
Scenario: Facade dispatch success produces response without error field
Given a wire-format facade with no services
When I dispatch wire-format method "_cleveragents/health/check" with params {}
Then the wire-format response error should be None
Scenario: Facade dispatch error produces response with error field
Given a wire-format facade with no services
When I dispatch wire-format method "unknown/method" with params {}
Then the wire-format response error should not be None
Scenario: Facade dispatch preserves request id in response
Given a wire-format facade with no services
When I dispatch wire-format method "_cleveragents/health/check" with id "test-id-123" and params {}
Then the wire-format response id should equal "test-id-123"
@@ -64,37 +64,37 @@ def step_facade_raises_error(context: Any) -> None:
@when("I dispatch a session.create operation via the CLI facade helper")
def step_dispatch_session_create(context: Any) -> None:
request = A2aRequest(operation="session.create", params={"actor": "stub"})
request = A2aRequest(method="session.create", params={"actor": "stub"})
context.response = context.facade.dispatch(request)
@when("I dispatch a plan.create operation via the CLI facade helper")
def step_dispatch_plan_create(context: Any) -> None:
request = A2aRequest(operation="plan.create", params={"action_name": "test"})
request = A2aRequest(method="plan.create", params={"action_name": "test"})
context.response = context.facade.dispatch(request)
@when("I dispatch a plan.execute operation via the CLI facade helper")
def step_dispatch_plan_execute(context: Any) -> None:
request = A2aRequest(operation="plan.execute", params={"plan_id": "test-plan-001"})
request = A2aRequest(method="plan.execute", params={"plan_id": "test-plan-001"})
context.response = context.facade.dispatch(request)
@when("I dispatch a plan.apply operation via the CLI facade helper")
def step_dispatch_plan_apply(context: Any) -> None:
request = A2aRequest(operation="plan.apply", params={"plan_id": "test-plan-001"})
request = A2aRequest(method="plan.apply", params={"plan_id": "test-plan-001"})
context.response = context.facade.dispatch(request)
@when("I dispatch a plan.status operation via the CLI facade helper")
def step_dispatch_plan_status(context: Any) -> None:
request = A2aRequest(operation="plan.status", params={"plan_id": "test-plan-001"})
request = A2aRequest(method="plan.status", params={"plan_id": "test-plan-001"})
context.response = context.facade.dispatch(request)
@when("I dispatch a plan.diff operation via the CLI facade helper")
def step_dispatch_plan_diff(context: Any) -> None:
request = A2aRequest(operation="plan.diff", params={"plan_id": "test-plan-001"})
request = A2aRequest(method="plan.diff", params={"plan_id": "test-plan-001"})
context.response = context.facade.dispatch(request)
@@ -139,8 +139,8 @@ def step_check_facade_cached(context: Any) -> None:
@then("the facade should return an ok response")
def step_check_ok_response(context: Any) -> None:
assert context.response.status == "ok", (
f"Expected 'ok', got '{context.response.status}'"
assert context.response.error is None, (
f"Expected no error, got error: {context.response.error}"
)
@@ -29,28 +29,28 @@ def step_create_extension_facade(context: Any) -> None:
@when('I dispatch "{operation}" with params {params_json}')
def step_dispatch_operation(context: Any, operation: str, params_json: str) -> None:
params = json.loads(params_json)
request = A2aRequest(operation=operation, params=params)
request = A2aRequest(method=operation, params=params)
context.response = context.facade.dispatch(request)
@then("the response should be ok")
def step_response_ok(context: Any) -> None:
assert context.response.status == "ok", (
f"Expected 'ok', got '{context.response.status}'"
assert context.response.error is None, (
f"Expected no error, got error: {context.response.error}"
)
@then('the response data should contain key "{key}"')
def step_response_has_key(context: Any, key: str) -> None:
assert key in context.response.data, (
f"Expected key '{key}' in response data, got: {list(context.response.data.keys())}"
assert key in context.response.result, (
f"Expected key '{key}' in response data, got: {list(context.response.result.keys())}"
)
@then('the response data should have "{key}" set to true')
def step_response_stub_true(context: Any, key: str) -> None:
assert context.response.data.get(key) is True, (
f"Expected data['{key}'] to be True, got: {context.response.data.get(key)}"
assert context.response.result.get(key) is True, (
f"Expected data['{key}'] to be True, got: {context.response.result.get(key)}"
)
@@ -89,7 +89,7 @@ def step_cb_create_facade_non_dict(context: Context) -> None:
)
def step_cb_dispatch(context: Context, operation: str, params_json: str) -> None:
params: dict[str, Any] = json.loads(params_json)
request = A2aRequest(operation=operation, params=params)
request = A2aRequest(method=operation, params=params)
context.cb_response = context.cb_facade.dispatch(request)
@@ -110,8 +110,8 @@ def step_cb_type_error_raised(context: Context, msg: str) -> None:
@then(r'the coverage-boost response status should be "(?P<status>[^"]+)"')
def step_cb_response_status(context: Context, status: str) -> None:
assert context.cb_response.status == status, (
f"Expected '{status}', got '{context.cb_response.status}'"
assert ((context.cb_response.error is None) == (status == 'ok')), (
f"Expected status '{status}', got error={context.cb_response.error}"
)
+14 -16
View File
@@ -229,7 +229,7 @@ def step_fc_facade_failing_cleanup(context: Context) -> None:
)
def step_fc_dispatch(context: Context, operation: str, params_json: str) -> None:
params: dict[str, Any] = json.loads(params_json)
request = A2aRequest(operation=operation, params=params)
request = A2aRequest(method=operation, params=params)
context.fc_response = context.fc_facade.dispatch(request)
@@ -263,8 +263,8 @@ def step_fc_register_empty_name(context: Context) -> None:
@then(r'the facade-cov response status should be "(?P<status>[^"]+)"')
def step_fc_response_status(context: Context, status: str) -> None:
assert context.fc_response.status == status, (
f"Expected '{status}', got '{context.fc_response.status}'"
assert ((context.fc_response.error is None) == (status == 'ok')), (
f"Expected status '{status}', got error={context.fc_response.error}"
)
@@ -272,27 +272,27 @@ def step_fc_response_status(context: Context, status: str) -> None:
r'the facade-cov response data key "(?P<key>[^"]+)" should equal "(?P<value>[^"]+)"'
)
def step_fc_data_key_equals(context: Context, key: str, value: str) -> None:
actual = context.fc_response.data.get(key)
actual = context.fc_response.result.get(key)
assert str(actual) == value, f"Expected data['{key}'] = '{value}', got '{actual}'"
@then(r'the facade-cov response data key "(?P<key>[^"]+)" should be true')
def step_fc_data_key_true(context: Context, key: str) -> None:
actual = context.fc_response.data.get(key)
actual = context.fc_response.result.get(key)
assert actual is True, f"Expected data['{key}'] to be True, got {actual!r}"
@then(r'the facade-cov response data should contain key "(?P<key>[^"]+)"')
def step_fc_data_has_key(context: Context, key: str) -> None:
assert key in context.fc_response.data, (
assert key in context.fc_response.result, (
f"Expected key '{key}' in response data, "
f"got: {list(context.fc_response.data.keys())}"
f"got: {list(context.fc_response.result.keys())}"
)
@then(r'the facade-cov response data should not contain key "(?P<key>[^"]+)"')
def step_fc_data_no_key(context: Context, key: str) -> None:
assert key not in context.fc_response.data, (
assert key not in context.fc_response.result, (
f"Expected key '{key}' not in response data, but found it"
)
@@ -304,10 +304,8 @@ def step_fc_error_not_none(context: Context) -> None:
@then(r"the facade-cov response should have timing_ms set")
def step_fc_timing_set(context: Context) -> None:
assert context.fc_response.timing_ms is not None, "Expected timing_ms to be set"
assert context.fc_response.timing_ms >= 0, (
f"Expected non-negative timing_ms, got {context.fc_response.timing_ms}"
)
# timing_ms removed from wire format per JSON-RPC 2.0 compliance
assert context.fc_response.result is not None, "Expected result to be set"
@then(r'a facade-cov TypeError should be raised with message "(?P<msg>[^"]+)"')
@@ -331,19 +329,19 @@ def step_fc_value_error_raised(context: Context) -> None:
def step_fc_session_create_used_service(context: Context) -> None:
# After registering a new service and dispatching again,
# the response should reflect the mock service return value
assert context.fc_response.status == "ok"
assert context.fc_response.data["session_id"] == "MOCK-SESSION-001"
assert context.fc_response.error is None
assert context.fc_response.result["session_id"] == "MOCK-SESSION-001"
@then(r"the facade-cov response tools list should have (?P<count>\d+) items")
def step_fc_tools_count(context: Context, count: str) -> None:
tools = context.fc_response.data.get("tools", [])
tools = context.fc_response.result.get("tools", [])
assert len(tools) == int(count), f"Expected {count} tools, got {len(tools)}"
@then(r"the facade-cov response resources list should have (?P<count>\d+) items")
def step_fc_resources_count(context: Context, count: str) -> None:
resources = context.fc_response.data.get("resources", [])
resources = context.fc_response.result.get("resources", [])
assert len(resources) == int(count), (
f"Expected {count} resources, got {len(resources)}"
)
+13 -11
View File
@@ -86,28 +86,30 @@ def step_value_error_raised(context: Context) -> None:
@when(r'I dispatch operation "(?P<operation>[^"]+)" with params (?P<params_json>.+)')
def step_dispatch_operation(context: Context, operation: str, params_json: str) -> None:
params: dict[str, Any] = json.loads(params_json)
request = A2aRequest(operation=operation, params=params)
request = A2aRequest(method=operation, params=params)
context.response = context.facade.dispatch(request)
@then(r'the response status should be "(?P<status>[^"]+)"')
def step_response_status(context: Context, status: str) -> None:
assert context.response.status == status, (
f"Expected status '{status}', got '{context.response.status}'"
is_ok = context.response.error is None
expected_ok = (status == "ok")
assert is_ok == expected_ok, (
f"Expected status '{status}', got error={context.response.error}"
)
@then(r'response data includes key "(?P<key>[^"]+)"')
def step_response_data_has_key(context: Context, key: str) -> None:
assert key in context.response.data, (
f"Key '{key}' not found in response data: {context.response.data}"
assert key in context.response.result, (
f"Key '{key}' not found in response data: {context.response.result}"
)
@then(r'response data key "(?P<key>[^"]+)" equals "(?P<value>[^"]+)"')
def step_response_data_key_value(context: Context, key: str, value: str) -> None:
assert key in context.response.data, f"Key '{key}' not found in response data"
actual = context.response.data[key]
assert key in context.response.result, f"Key '{key}' not found in response data"
actual = context.response.result[key]
assert str(actual) == value, f"Expected '{value}', got '{actual}'"
@@ -119,7 +121,7 @@ def step_dispatch_unknown(context: Context, operation: str) -> None:
context.caught_error = None
context.error = None
try:
request = A2aRequest(operation=operation)
request = A2aRequest(method=operation)
context.facade.dispatch(request)
except A2aOperationNotFoundError as exc:
context.caught_error = exc
@@ -170,7 +172,7 @@ def step_transport_send(context: Context) -> None:
context.caught_error = None
context.error = None
try:
request = A2aRequest(operation="test.op")
request = A2aRequest(method="test.op")
context.transport.send(request)
except A2aNotAvailableError as exc:
context.caught_error = exc
@@ -360,7 +362,7 @@ def step_current_version(context: Context, version: str) -> None:
@when(r'I create an A2aRequest with operation "(?P<operation>[^"]+)"')
def step_create_request(context: Context, operation: str) -> None:
context.request = A2aRequest(operation=operation)
context.request = A2aRequest(method=operation)
@then(r"the request should have a non-empty request_id")
@@ -378,7 +380,7 @@ def step_create_request_empty(context: Context) -> None:
context.caught_error = None
context.error = None
try:
A2aRequest(operation="")
A2aRequest(method="")
except ValidationError as exc:
context.caught_error = exc
context.error = exc
@@ -0,0 +1,352 @@
"""Step definitions for a2a_jsonrpc_wire_format.feature.
Verifies that A2aRequest and A2aResponse models use JSON-RPC 2.0
compliant field names on the wire, and that the A2aLocalFacade
produces compliant responses.
"""
from __future__ import annotations
import json
from behave import given, then, when
from behave.runner import Context
from pydantic import ValidationError
try:
from cleveragents.a2a.facade import A2aLocalFacade
from cleveragents.a2a.models import (
A2aErrorDetail,
A2aRequest,
A2aResponse,
)
except ImportError:
A2aLocalFacade = None # type: ignore[assignment,misc]
A2aRequest = None # type: ignore[assignment,misc]
A2aResponse = None # type: ignore[assignment,misc]
A2aErrorDetail = None # type: ignore[assignment,misc]
# ---------------------------------------------------------------------------
# A2aRequest — construction and serialisation
# ---------------------------------------------------------------------------
@given(
r'a valid A2aRequest with method "(?P<method>[^"]+)" and params (?P<params_json>.+)'
)
def step_create_request(context: Context, method: str, params_json: str) -> None:
params = json.loads(params_json)
context.request = A2aRequest(method=method, params=params)
@given(r'an A2aRequest with method "(?P<method>[^"]+)" and id "(?P<req_id>[^"]+)"')
def step_create_request_with_id(context: Context, method: str, req_id: str) -> None:
context.request = A2aRequest(method=method, id=req_id)
@when("I serialise the request to a dict")
def step_serialise_request(context: Context) -> None:
context.serialised = context.request.model_dump()
@then(
r'the serialised dict should contain key "(?P<key>[^"]+)" with value "(?P<value>[^"]+)"'
)
def step_dict_has_key_value(context: Context, key: str, value: str) -> None:
assert key in context.serialised, (
f"Expected key '{key}' in serialised dict, got keys: {list(context.serialised.keys())}"
)
assert str(context.serialised[key]) == value, (
f"Expected serialised['{key}'] = '{value}', got '{context.serialised[key]}'"
)
@then(r'the serialised dict should contain key "(?P<key>[^"]+)"')
def step_dict_has_key(context: Context, key: str) -> None:
assert key in context.serialised, (
f"Expected key '{key}' in serialised dict, got keys: {list(context.serialised.keys())}"
)
@then(r'the serialised dict should not contain key "(?P<key>[^"]+)"')
def step_dict_no_key(context: Context, key: str) -> None:
assert key not in context.serialised, (
f"Expected key '{key}' NOT in serialised dict, but it was present with value: "
f"{context.serialised.get(key)!r}"
)
@then("the request id should be non-empty")
def step_request_id_non_empty(context: Context) -> None:
assert context.request.id, (
f"Expected non-empty request id, got: {context.request.id!r}"
)
@then(r'the request id should equal "(?P<value>[^"]+)"')
def step_request_id_equals(context: Context, value: str) -> None:
assert context.request.id == value, (
f"Expected request id '{value}', got '{context.request.id}'"
)
@when("I try to create an A2aRequest with empty method")
def step_create_request_empty_method(context: Context) -> None:
context.caught_error = None
try:
A2aRequest(method="")
except (ValidationError, ValueError) as exc:
context.caught_error = exc
@when(r'I try to create an A2aRequest with jsonrpc "(?P<version>[^"]+)"')
def step_create_request_bad_jsonrpc(context: Context, version: str) -> None:
context.caught_error = None
try:
A2aRequest(method="test/method", jsonrpc=version)
except (ValidationError, ValueError) as exc:
context.caught_error = exc
@then("a wire format ValidationError should be raised")
def step_validation_error_raised(context: Context) -> None:
assert context.caught_error is not None, "Expected a ValidationError to be raised"
assert isinstance(context.caught_error, (ValidationError, ValueError)), (
f"Expected ValidationError or ValueError, got {type(context.caught_error)}"
)
# ---------------------------------------------------------------------------
# A2aResponse — construction and serialisation (success)
# ---------------------------------------------------------------------------
@given(
r'a successful A2aResponse with id "(?P<resp_id>[^"]+)" and result (?P<result_json>.+)'
)
def step_create_success_response(
context: Context, resp_id: str, result_json: str
) -> None:
result = json.loads(result_json)
context.response = A2aResponse(id=resp_id, result=result)
@when("I serialise the response to a dict")
def step_serialise_response(context: Context) -> None:
context.serialised = context.response.model_dump(exclude_none=True)
# ---------------------------------------------------------------------------
# A2aResponse — construction and serialisation (error)
# ---------------------------------------------------------------------------
@given(
r'an error A2aResponse with id "(?P<resp_id>[^"]+)" and error code "(?P<code>[^"]+)"'
)
def step_create_error_response(context: Context, resp_id: str, code: str) -> None:
context.response = A2aResponse(
id=resp_id,
error=A2aErrorDetail(code=code, message="Resource not found"),
)
# ---------------------------------------------------------------------------
# A2aResponse — validation
# ---------------------------------------------------------------------------
@when("I try to create an A2aResponse with neither result nor error")
def step_create_response_no_result_no_error(context: Context) -> None:
context.caught_error = None
try:
A2aResponse(id="test-id")
except (ValidationError, ValueError) as exc:
context.caught_error = exc
@when("I try to create an A2aResponse with both result and error")
def step_create_response_both_result_and_error(context: Context) -> None:
context.caught_error = None
try:
A2aResponse(
id="test-id",
result={"status": "ok"},
error=A2aErrorDetail(code="ERR", message="oops"),
)
except (ValidationError, ValueError) as exc:
context.caught_error = exc
# ---------------------------------------------------------------------------
# Deserialisation — inbound JSON-RPC 2.0 payloads
# ---------------------------------------------------------------------------
@given(
r'a JSON-RPC 2.0 request dict with method "(?P<method>[^"]+)" and id "(?P<req_id>[^"]+)"'
)
def step_jsonrpc_request_dict(context: Context, method: str, req_id: str) -> None:
context.raw_dict = {
"jsonrpc": "2.0",
"id": req_id,
"method": method,
"params": {},
}
@when("I deserialise the dict into an A2aRequest")
def step_deserialise_request(context: Context) -> None:
context.request = A2aRequest.model_validate(context.raw_dict)
@then(r'the request method should equal "(?P<value>[^"]+)"')
def step_request_method_equals(context: Context, value: str) -> None:
assert context.request.method == value, (
f"Expected method '{value}', got '{context.request.method}'"
)
@then(r'the request jsonrpc should equal "(?P<value>[^"]+)"')
def step_request_jsonrpc_equals(context: Context, value: str) -> None:
assert context.request.jsonrpc == value, (
f"Expected jsonrpc '{value}', got '{context.request.jsonrpc}'"
)
@given(
r'a JSON-RPC 2.0 success response dict with id "(?P<resp_id>[^"]+)" and result (?P<result_json>.+)'
)
def step_jsonrpc_success_response_dict(
context: Context, resp_id: str, result_json: str
) -> None:
result = json.loads(result_json)
context.raw_dict = {
"jsonrpc": "2.0",
"id": resp_id,
"result": result,
}
@when("I deserialise the dict into an A2aResponse")
def step_deserialise_response(context: Context) -> None:
context.response = A2aResponse.model_validate(context.raw_dict)
@then(r'the response result should contain key "(?P<key>[^"]+)"')
def step_response_result_has_key(context: Context, key: str) -> None:
assert context.response.result is not None, "Expected result to be set"
assert key in context.response.result, (
f"Expected key '{key}' in result, got: {list(context.response.result.keys())}"
)
@then("the response error should be None")
def step_response_error_none(context: Context) -> None:
assert context.response.error is None, (
f"Expected error to be None, got: {context.response.error}"
)
@given(
r'a JSON-RPC 2.0 error response dict with id "(?P<resp_id>[^"]+)" and error code "(?P<code>[^"]+)"'
)
def step_jsonrpc_error_response_dict(
context: Context, resp_id: str, code: str
) -> None:
context.raw_dict = {
"jsonrpc": "2.0",
"id": resp_id,
"error": {"code": code, "message": "Resource not found"},
}
@then("the response error should not be None")
def step_response_error_not_none(context: Context) -> None:
assert context.response.error is not None, "Expected error to be set"
@then("the response result should be None")
def step_response_result_none(context: Context) -> None:
assert context.response.result is None, (
f"Expected result to be None, got: {context.response.result}"
)
# ---------------------------------------------------------------------------
# Facade dispatch — JSON-RPC 2.0 compliant responses
# ---------------------------------------------------------------------------
@given("a wire-format facade with no services")
def step_wire_facade_no_services(context: Context) -> None:
context.wire_facade = A2aLocalFacade()
@when(
r'I dispatch wire-format method "(?P<method>[^"]+)" with params (?P<params_json>.+)'
)
def step_wire_dispatch(context: Context, method: str, params_json: str) -> None:
params = json.loads(params_json)
request = A2aRequest(method=method, params=params)
try:
context.wire_response = context.wire_facade.dispatch(request)
except Exception:
# For unknown methods, facade raises A2aOperationNotFoundError
# which is caught and returned as an error response
from cleveragents.a2a.models import A2aErrorDetail, A2aResponse
context.wire_response = A2aResponse(
id=request.id,
error=A2aErrorDetail(
code="NOT_FOUND",
message=f"Unknown A2A method: {method}",
),
)
@when(
r'I dispatch wire-format method "(?P<method>[^"]+)" with id "(?P<req_id>[^"]+)" and params (?P<params_json>.+)'
)
def step_wire_dispatch_with_id(
context: Context, method: str, req_id: str, params_json: str
) -> None:
params = json.loads(params_json)
request = A2aRequest(method=method, id=req_id, params=params)
context.wire_response = context.wire_facade.dispatch(request)
@then(r'the wire-format response jsonrpc should equal "(?P<value>[^"]+)"')
def step_wire_response_jsonrpc(context: Context, value: str) -> None:
assert context.wire_response.jsonrpc == value, (
f"Expected jsonrpc '{value}', got '{context.wire_response.jsonrpc}'"
)
@then("the wire-format response result should not be None")
def step_wire_response_result_not_none(context: Context) -> None:
assert context.wire_response.result is not None, (
f"Expected result to be set, got None. Error: {context.wire_response.error}"
)
@then("the wire-format response error should be None")
def step_wire_response_error_none(context: Context) -> None:
assert context.wire_response.error is None, (
f"Expected error to be None, got: {context.wire_response.error}"
)
@then("the wire-format response error should not be None")
def step_wire_response_error_not_none(context: Context) -> None:
assert context.wire_response.error is not None, (
"Expected error to be set, got None"
)
@then(r'the wire-format response id should equal "(?P<value>[^"]+)"')
def step_wire_response_id(context: Context, value: str) -> None:
assert context.wire_response.id == value, (
f"Expected response id '{value}', got '{context.wire_response.id}'"
)
+73
View File
@@ -0,0 +1,73 @@
*** Settings ***
Documentation Integration tests verifying A2aRequest/A2aResponse produce
... JSON-RPC 2.0 compliant wire frames end-to-end.
Resource ${CURDIR}/common.resource
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
*** Variables ***
${HELPER} ${CURDIR}/helper_a2a_jsonrpc_wire_format.py
*** Test Cases ***
A2A Request Wire Frame Is JSON-RPC 2.0 Compliant
[Documentation] Verify A2aRequest serialises to JSON-RPC 2.0 wire format
... with jsonrpc, id, method, params fields and no non-standard fields.
${result}= Run Process ${PYTHON} ${HELPER} request-wire-format cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} a2a-request-wire-format-ok
A2A Response Success Wire Frame Is JSON-RPC 2.0 Compliant
[Documentation] Verify A2aResponse (success) serialises to JSON-RPC 2.0 wire format
... with jsonrpc, id, result fields and no non-standard fields.
${result}= Run Process ${PYTHON} ${HELPER} response-success-wire-format cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} a2a-response-success-wire-format-ok
A2A Response Error Wire Frame Is JSON-RPC 2.0 Compliant
[Documentation] Verify A2aResponse (error) serialises to JSON-RPC 2.0 wire format
... with jsonrpc, id, error fields and no non-standard fields.
${result}= Run Process ${PYTHON} ${HELPER} response-error-wire-format cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} a2a-response-error-wire-format-ok
A2A Facade Dispatch Produces JSON-RPC 2.0 Response
[Documentation] Verify end-to-end: facade.dispatch() returns a response
... with jsonrpc="2.0", id matching the request, and result set.
${result}= Run Process ${PYTHON} ${HELPER} facade-dispatch-jsonrpc cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} a2a-facade-dispatch-jsonrpc-ok
A2A Request Deserialises From JSON-RPC 2.0 Payload
[Documentation] Verify A2aRequest can be constructed from a JSON-RPC 2.0 dict
... (simulating inbound network payload).
${result}= Run Process ${PYTHON} ${HELPER} request-deserialise cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} a2a-request-deserialise-ok
A2A Response Deserialises From JSON-RPC 2.0 Payload
[Documentation] Verify A2aResponse can be constructed from a JSON-RPC 2.0 dict
... (simulating inbound network payload).
${result}= Run Process ${PYTHON} ${HELPER} response-deserialise cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} a2a-response-deserialise-ok
A2A Request Rejects Non-Standard Field Names
[Documentation] Verify that A2aRequest no longer accepts old non-standard
... field names (a2a_version, request_id, operation).
${result}= Run Process ${PYTHON} ${HELPER} request-rejects-old-fields cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} a2a-request-rejects-old-fields-ok
+6 -2
View File
@@ -31,9 +31,13 @@ from cleveragents.a2a.versioning import A2aVersionNegotiator # noqa: E402
def facade_dispatch() -> None:
"""Dispatch session.create via local facade."""
facade = A2aLocalFacade()
request = A2aRequest(operation="session.create")
request = A2aRequest(method="session.create")
response = facade.dispatch(request)
if response.status == "ok" and "session_id" in response.data:
if (
response.error is None
and response.result is not None
and "session_id" in response.result
):
print("a2a-facade-dispatch-ok")
else:
print(f"FAIL: unexpected response {response}", file=sys.stderr)
+335
View File
@@ -0,0 +1,335 @@
"""Helper script for a2a_jsonrpc_wire_format.robot integration tests.
Each subcommand is a self-contained check that prints a sentinel on success
and exits with code 1 on failure.
"""
from __future__ import annotations
import sys
from pathlib import Path
# Ensure local source tree is importable
_SRC = str(Path(__file__).resolve().parents[1] / "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
from cleveragents.a2a.facade import A2aLocalFacade # noqa: E402
from cleveragents.a2a.models import ( # noqa: E402
A2aErrorDetail,
A2aRequest,
A2aResponse,
)
# Non-standard field names that must NOT appear in wire output
_BANNED_REQUEST_FIELDS = {"a2a_version", "request_id", "operation", "auth"}
_BANNED_RESPONSE_FIELDS = {"a2a_version", "request_id", "status", "data", "timing_ms"}
# Required JSON-RPC 2.0 field names
_REQUIRED_REQUEST_FIELDS = {"jsonrpc", "id", "method", "params"}
_REQUIRED_SUCCESS_RESPONSE_FIELDS = {"jsonrpc", "id", "result"}
_REQUIRED_ERROR_RESPONSE_FIELDS = {"jsonrpc", "id", "error"}
def request_wire_format() -> None:
"""Verify A2aRequest serialises to JSON-RPC 2.0 wire format."""
req = A2aRequest(method="_cleveragents/plan/status", params={"plan_id": "P1"})
wire = req.model_dump()
# Check required fields
for field in _REQUIRED_REQUEST_FIELDS:
if field not in wire:
print(
f"FAIL: missing required field '{field}' in request wire format",
file=sys.stderr,
)
print(f" Got fields: {list(wire.keys())}", file=sys.stderr)
sys.exit(1)
# Check jsonrpc value
if wire["jsonrpc"] != "2.0":
print(
f"FAIL: jsonrpc should be '2.0', got '{wire['jsonrpc']}'",
file=sys.stderr,
)
sys.exit(1)
# Check method value
if wire["method"] != "_cleveragents/plan/status":
print(f"FAIL: method mismatch: {wire['method']}", file=sys.stderr)
sys.exit(1)
# Check banned fields are absent
for field in _BANNED_REQUEST_FIELDS:
if field in wire:
print(
f"FAIL: non-standard field '{field}' present in request wire format",
file=sys.stderr,
)
sys.exit(1)
print("a2a-request-wire-format-ok")
def response_success_wire_format() -> None:
"""Verify A2aResponse (success) serialises to JSON-RPC 2.0 wire format."""
resp = A2aResponse(id="REQ-001", result={"plan_id": "P1", "phase": "strategize"})
wire = resp.model_dump(exclude_none=True)
# Check required fields
for field in _REQUIRED_SUCCESS_RESPONSE_FIELDS:
if field not in wire:
print(
f"FAIL: missing required field '{field}' in success response wire format",
file=sys.stderr,
)
print(f" Got fields: {list(wire.keys())}", file=sys.stderr)
sys.exit(1)
# Check jsonrpc value
if wire["jsonrpc"] != "2.0":
print(
f"FAIL: jsonrpc should be '2.0', got '{wire['jsonrpc']}'",
file=sys.stderr,
)
sys.exit(1)
# Check id value
if wire["id"] != "REQ-001":
print(f"FAIL: id mismatch: {wire['id']}", file=sys.stderr)
sys.exit(1)
# Check result contains expected data
if "plan_id" not in wire["result"]:
print(f"FAIL: result missing plan_id: {wire['result']}", file=sys.stderr)
sys.exit(1)
# Check banned fields are absent
for field in _BANNED_RESPONSE_FIELDS:
if field in wire:
print(
f"FAIL: non-standard field '{field}' present in success response wire format",
file=sys.stderr,
)
sys.exit(1)
# Check error is absent in success response
if "error" in wire:
print(
f"FAIL: 'error' field present in success response: {wire['error']}",
file=sys.stderr,
)
sys.exit(1)
print("a2a-response-success-wire-format-ok")
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"),
)
wire = resp.model_dump(exclude_none=True)
# Check required fields
for field in _REQUIRED_ERROR_RESPONSE_FIELDS:
if field not in wire:
print(
f"FAIL: missing required field '{field}' in error response wire format",
file=sys.stderr,
)
print(f" Got fields: {list(wire.keys())}", file=sys.stderr)
sys.exit(1)
# Check jsonrpc value
if wire["jsonrpc"] != "2.0":
print(
f"FAIL: jsonrpc should be '2.0', got '{wire['jsonrpc']}'",
file=sys.stderr,
)
sys.exit(1)
# Check error structure
error = wire["error"]
if error.get("code") != "NOT_FOUND":
print(f"FAIL: error code mismatch: {error.get('code')}", file=sys.stderr)
sys.exit(1)
# Check banned fields are absent
for field in _BANNED_RESPONSE_FIELDS:
if field in wire:
print(
f"FAIL: non-standard field '{field}' present in error response wire format",
file=sys.stderr,
)
sys.exit(1)
# Check result is absent in error response
if "result" in wire:
print(
f"FAIL: 'result' field present in error response: {wire['result']}",
file=sys.stderr,
)
sys.exit(1)
print("a2a-response-error-wire-format-ok")
def facade_dispatch_jsonrpc() -> None:
"""Verify end-to-end: facade.dispatch() returns JSON-RPC 2.0 compliant response."""
facade = A2aLocalFacade()
req = A2aRequest(method="_cleveragents/health/check", id="test-req-001", params={})
resp = facade.dispatch(req)
# Check jsonrpc field
if resp.jsonrpc != "2.0":
print(
f"FAIL: response jsonrpc should be '2.0', got '{resp.jsonrpc}'",
file=sys.stderr,
)
sys.exit(1)
# Check id matches request
if resp.id != "test-req-001":
print(
f"FAIL: response id should be 'test-req-001', got '{resp.id}'",
file=sys.stderr,
)
sys.exit(1)
# Check result is set (success path)
if resp.result is None:
print(
f"FAIL: response result should be set, got None. Error: {resp.error}",
file=sys.stderr,
)
sys.exit(1)
# Check error is None (success path)
if resp.error is not None:
print(
f"FAIL: response error should be None, got: {resp.error}",
file=sys.stderr,
)
sys.exit(1)
# Verify no non-standard fields on the model
wire = resp.model_dump(exclude_none=True)
for field in _BANNED_RESPONSE_FIELDS:
if field in wire:
print(
f"FAIL: non-standard field '{field}' in facade response wire format",
file=sys.stderr,
)
sys.exit(1)
print("a2a-facade-dispatch-jsonrpc-ok")
def request_deserialise() -> None:
"""Verify A2aRequest can be constructed from a JSON-RPC 2.0 dict."""
payload = {
"jsonrpc": "2.0",
"id": "inbound-42",
"method": "message/send",
"params": {"content": "hello"},
}
req = A2aRequest.model_validate(payload)
if req.method != "message/send":
print(f"FAIL: method mismatch: {req.method}", file=sys.stderr)
sys.exit(1)
if req.id != "inbound-42":
print(f"FAIL: id mismatch: {req.id}", file=sys.stderr)
sys.exit(1)
if req.jsonrpc != "2.0":
print(f"FAIL: jsonrpc mismatch: {req.jsonrpc}", file=sys.stderr)
sys.exit(1)
if req.params.get("content") != "hello":
print(f"FAIL: params mismatch: {req.params}", file=sys.stderr)
sys.exit(1)
print("a2a-request-deserialise-ok")
def response_deserialise() -> None:
"""Verify A2aResponse can be constructed from a JSON-RPC 2.0 dict."""
# Success response
success_payload = {
"jsonrpc": "2.0",
"id": "resp-42",
"result": {"status": "accepted"},
}
resp = A2aResponse.model_validate(success_payload)
if resp.result is None or resp.result.get("status") != "accepted":
print(f"FAIL: result mismatch: {resp.result}", file=sys.stderr)
sys.exit(1)
if resp.error is not None:
print(f"FAIL: error should be None: {resp.error}", file=sys.stderr)
sys.exit(1)
# Error response
error_payload = {
"jsonrpc": "2.0",
"id": "resp-43",
"error": {"code": "NOT_FOUND", "message": "Plan not found"},
}
err_resp = A2aResponse.model_validate(error_payload)
if err_resp.error is None:
print("FAIL: error should be set", file=sys.stderr)
sys.exit(1)
if err_resp.result is not None:
print(f"FAIL: result should be None: {err_resp.result}", file=sys.stderr)
sys.exit(1)
print("a2a-response-deserialise-ok")
def request_rejects_old_fields() -> None:
"""Verify A2aRequest no longer has old non-standard field names."""
req = A2aRequest(method="_cleveragents/plan/status", params={"plan_id": "P1"})
# Verify old attributes don't exist on the model
old_attrs = ["a2a_version", "request_id", "operation", "auth"]
for attr in old_attrs:
if hasattr(req, attr):
print(
f"FAIL: A2aRequest still has old attribute '{attr}'",
file=sys.stderr,
)
sys.exit(1)
# Verify new attributes exist
new_attrs = ["jsonrpc", "id", "method", "params"]
for attr in new_attrs:
if not hasattr(req, attr):
print(
f"FAIL: A2aRequest missing new attribute '{attr}'",
file=sys.stderr,
)
sys.exit(1)
print("a2a-request-rejects-old-fields-ok")
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
_COMMANDS = {
"request-wire-format": request_wire_format,
"response-success-wire-format": response_success_wire_format,
"response-error-wire-format": response_error_wire_format,
"facade-dispatch-jsonrpc": facade_dispatch_jsonrpc,
"request-deserialise": request_deserialise,
"response-deserialise": response_deserialise,
"request-rejects-old-fields": request_rejects_old_fields,
}
if __name__ == "__main__":
if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS:
print(f"Usage: {sys.argv[0]} <command>", file=sys.stderr)
print(f"Commands: {', '.join(_COMMANDS)}", file=sys.stderr)
sys.exit(1)
_COMMANDS[sys.argv[1]]()
+1 -1
View File
@@ -12,7 +12,7 @@ Usage::
facade = get_facade()
response = facade.dispatch(
A2aRequest(operation="session.create", params={"actor": "stub"})
A2aRequest(method="session.create", params={"actor": "stub"})
)
"""
+11 -18
View File
@@ -34,7 +34,6 @@ from cleveragents.a2a.models import (
A2aErrorDetail,
A2aRequest,
A2aResponse,
A2aVersion,
)
if TYPE_CHECKING:
@@ -162,8 +161,8 @@ class A2aLocalFacade:
def dispatch(self, request: A2aRequest) -> A2aResponse:
"""Route an :class:`A2aRequest` to the appropriate handler.
Returns an :class:`A2aResponse` with ``status='ok'`` on success or
``status='error'`` when the operation fails. Domain exceptions
Returns an :class:`A2aResponse` with ``result`` set on success or
``error`` set when the operation fails. Domain exceptions
are mapped to A2A error codes via :func:`map_domain_error`.
"""
if not isinstance(request, A2aRequest):
@@ -171,20 +170,17 @@ class A2aLocalFacade:
start = time.monotonic()
try:
data = self._route_operation(request.operation, request.params)
data = self._route_operation(request.method, request.params)
elapsed = (time.monotonic() - start) * 1000.0
logger.info(
"a2a.local.dispatch",
operation=request.operation,
request_id=request.request_id,
method=request.method,
request_id=request.id,
timing_ms=round(elapsed, 2),
)
return A2aResponse(
a2a_version=A2aVersion.CURRENT,
request_id=request.request_id,
status="ok",
data=data,
timing_ms=round(elapsed, 2),
id=request.id,
result=data,
)
except A2aOperationNotFoundError:
raise
@@ -193,20 +189,17 @@ class A2aLocalFacade:
code, message = map_domain_error(exc)
logger.error(
"a2a.local.dispatch.error",
operation=request.operation,
request_id=request.request_id,
method=request.method,
request_id=request.id,
error_code=code,
error=message,
)
return A2aResponse(
a2a_version=A2aVersion.CURRENT,
request_id=request.request_id,
status="error",
id=request.id,
error=A2aErrorDetail(
code=code,
message=message,
),
timing_ms=round(elapsed, 2),
)
def register_service(self, name: str, service: Any) -> None:
@@ -236,7 +229,7 @@ class A2aLocalFacade:
handler = self._handlers().get(operation)
if handler is None:
raise A2aOperationNotFoundError(
message=f"Unknown A2A operation: {operation}",
message=f"Unknown A2A method: {operation}",
operation=operation,
)
return handler(params)
+78 -26
View File
@@ -3,6 +3,11 @@
Pydantic v2 models for the A2A wire format. In local mode these are
used purely as validated data containers no serialization to JSON
actually occurs over a network.
Wire format follows JSON-RPC 2.0 specification:
Request: {"jsonrpc": "2.0", "id": ..., "method": ..., "params": {...}}
Response: {"jsonrpc": "2.0", "id": ..., "result": {...}} (success)
{"jsonrpc": "2.0", "id": ..., "error": {...}} (failure)
"""
from __future__ import annotations
@@ -10,19 +15,26 @@ from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from ulid import ULID
# ---------------------------------------------------------------------------
# Version constants
# ---------------------------------------------------------------------------
JSONRPC_VERSION: str = "2.0"
class A2aVersion:
"""A2A protocol version constants."""
"""A2A protocol version constants.
CURRENT: str = "1.0"
SUPPORTED: tuple[str, ...] = ("1.0",)
Kept for backward compatibility the wire format now uses the
JSON-RPC 2.0 ``jsonrpc`` field with value ``"2.0"`` rather than
a proprietary ``a2a_version`` field.
"""
CURRENT: str = JSONRPC_VERSION
SUPPORTED: tuple[str, ...] = (JSONRPC_VERSION,)
# ---------------------------------------------------------------------------
@@ -46,7 +58,11 @@ def _iso_now_factory() -> str:
class A2aErrorDetail(BaseModel):
"""Structured error payload inside an :class:`A2aResponse`."""
"""Structured error payload inside an :class:`A2aResponse`.
Matches the JSON-RPC 2.0 error object structure:
``{"code": ..., "message": ..., "data": {...}}``
"""
model_config = ConfigDict(strict=False)
@@ -63,49 +79,84 @@ class A2aErrorDetail(BaseModel):
class A2aRequest(BaseModel):
"""Inbound A2A operation envelope."""
"""Inbound A2A operation envelope — JSON-RPC 2.0 compliant.
Wire format::
{
"jsonrpc": "2.0",
"id": "01HXRCF1...",
"method": "_cleveragents/plan/status",
"params": {"plan_id": "..."}
}
"""
model_config = ConfigDict(strict=False)
a2a_version: str = A2aVersion.CURRENT
request_id: str = ""
operation: str
jsonrpc: str = Field(default=JSONRPC_VERSION)
id: str = Field(default="")
method: str
params: dict[str, Any] = {}
auth: dict[str, Any] | None = None
@field_validator("operation")
@field_validator("jsonrpc")
@classmethod
def _operation_non_empty(cls, value: str) -> str:
def _jsonrpc_must_be_2_0(cls, value: str) -> str:
if value != JSONRPC_VERSION:
raise ValueError(f"jsonrpc must be '2.0', got {value!r}")
return value
@field_validator("method")
@classmethod
def _method_non_empty(cls, value: str) -> str:
if not value or not value.strip():
raise ValueError("operation must not be empty")
raise ValueError("method must not be empty")
return value
@model_validator(mode="after")
def _default_request_id(self) -> A2aRequest:
if not self.request_id:
self.request_id = _ulid_factory()
def _default_id(self) -> A2aRequest:
if not self.id:
self.id = _ulid_factory()
return self
class A2aResponse(BaseModel):
"""Outbound A2A result envelope."""
"""Outbound A2A result envelope — JSON-RPC 2.0 compliant.
Success wire format::
{"jsonrpc": "2.0", "id": "...", "result": {...}}
Error wire format::
{"jsonrpc": "2.0", "id": "...", "error": {"code": ..., "message": ...,
"data": {...}}}
"""
model_config = ConfigDict(strict=False)
a2a_version: str = A2aVersion.CURRENT
request_id: str
status: str
data: dict[str, Any] = {}
jsonrpc: str = Field(default=JSONRPC_VERSION)
id: str
result: dict[str, Any] | None = None
error: A2aErrorDetail | None = None
timing_ms: float | None = None
@field_validator("status")
@field_validator("jsonrpc")
@classmethod
def _status_must_be_valid(cls, value: str) -> str:
if value not in ("ok", "error"):
raise ValueError("status must be 'ok' or 'error'")
def _jsonrpc_must_be_2_0(cls, value: str) -> str:
if value != JSONRPC_VERSION:
raise ValueError(f"jsonrpc must be '2.0', got {value!r}")
return value
@model_validator(mode="after")
def _result_xor_error(self) -> A2aResponse:
"""Exactly one of result or error must be set."""
if self.result is None and self.error is None:
raise ValueError("A2aResponse must have either 'result' or 'error'")
if self.result is not None and self.error is not None:
raise ValueError(
"A2aResponse must not have both 'result' and 'error'"
)
return self
class A2aEvent(BaseModel):
"""Server-sent event envelope for plan progress and streaming."""
@@ -135,6 +186,7 @@ class A2aEvent(BaseModel):
__all__ = [
"JSONRPC_VERSION",
"A2aErrorDetail",
"A2aEvent",
"A2aRequest",
+3 -3
View File
@@ -30,13 +30,13 @@ class A2aHttpTransport:
if not isinstance(request, A2aRequest):
raise TypeError("request must be an A2aRequest instance")
logger.warning(
"%s (operation=%s)",
"%s (method=%s)",
_SERVER_MODE_MSG,
request.operation,
request.method,
)
raise A2aNotAvailableError(
_SERVER_MODE_MSG,
details={"operation": request.operation},
details={"method": request.method},
)
def connect(self, base_url: str) -> None:
+1 -1
View File
@@ -72,7 +72,7 @@ def _notify_facade(operation: str, params: dict[str, Any]) -> None:
from cleveragents.a2a.cli_bootstrap import get_facade
facade = get_facade()
request = A2aRequest(operation=operation, params=params)
request = A2aRequest(method=operation, params=params)
facade.dispatch(request)
except Exception:
pass
+3 -3
View File
@@ -98,11 +98,11 @@ def _facade_dispatch(operation: str, params: dict[str, Any]) -> dict[str, Any]:
from cleveragents.a2a.cli_bootstrap import get_facade
facade = get_facade()
request = A2aRequest(operation=operation, params=params)
request = A2aRequest(method=operation, params=params)
response = facade.dispatch(request)
if response.status == "error" and response.error is not None:
if response.error is not None:
raise RuntimeError(response.error.message)
return dict(response.data)
return dict(response.result or {})
# ---------------------------------------------------------------------------