|
| 1 | +# Response to AIP Discussion #1247 |
| 2 | + |
| 3 | +> Re: [Respecting AIP response payloads in HTTP](https://github.com/a2aproject/A2A/discussions/1247) |
| 4 | +
|
| 5 | +Thanks for this detailed explanation of the AIP conventions, @darrelmiller. I've been working on the a2a-python SDK migration from Pydantic to protobuf types ([PR #572](https://github.com/a2aproject/a2a-python/pull/572)) and wanted to share how we've implemented this. |
| 6 | + |
| 7 | +## How we handle `SetTaskPushNotificationConfig` in the SDK |
| 8 | + |
| 9 | +The key insight is that the request and response types serve different purposes: |
| 10 | + |
| 11 | +**Request (`SetTaskPushNotificationConfigRequest`):** |
| 12 | +```protobuf |
| 13 | +message SetTaskPushNotificationConfigRequest { |
| 14 | + string parent = 1; // e.g., "tasks/{task_id}" |
| 15 | + string config_id = 2; // e.g., "my-config-id" |
| 16 | + TaskPushNotificationConfig config = 3; |
| 17 | +} |
| 18 | +``` |
| 19 | + |
| 20 | +**Response (`TaskPushNotificationConfig`):** |
| 21 | +```protobuf |
| 22 | +message TaskPushNotificationConfig { |
| 23 | + string name = 1; // Full resource name: "tasks/{task_id}/pushNotificationConfigs/{config_id}" |
| 24 | + PushNotificationConfig push_notification_config = 2; |
| 25 | +} |
| 26 | +``` |
| 27 | + |
| 28 | +## Implementation in Python |
| 29 | + |
| 30 | +In our `DefaultRequestHandler`, we construct the proper `name` field from the request's `parent` and `config_id`: |
| 31 | + |
| 32 | +```python |
| 33 | +async def on_set_task_push_notification_config( |
| 34 | + self, |
| 35 | + params: SetTaskPushNotificationConfigRequest, |
| 36 | + context: ServerCallContext | None = None, |
| 37 | +) -> TaskPushNotificationConfig: |
| 38 | + task_id = _extract_task_id(params.parent) # Extract from "tasks/{task_id}" |
| 39 | + |
| 40 | + # Store the config |
| 41 | + await self._push_config_store.set_info( |
| 42 | + task_id, |
| 43 | + params.config.push_notification_config, |
| 44 | + ) |
| 45 | + |
| 46 | + # Build response with proper AIP resource name |
| 47 | + return TaskPushNotificationConfig( |
| 48 | + name=f'{params.parent}/pushNotificationConfigs/{params.config_id}', |
| 49 | + push_notification_config=params.config.push_notification_config, |
| 50 | + ) |
| 51 | +``` |
| 52 | + |
| 53 | +## REST Handler Translation |
| 54 | + |
| 55 | +For the HTTP binding, the REST handler extracts path parameters and constructs the request: |
| 56 | + |
| 57 | +```python |
| 58 | +async def set_push_notification(self, request: Request, context: ServerCallContext): |
| 59 | + task_id = request.path_params['id'] |
| 60 | + body = await request.body() |
| 61 | + |
| 62 | + params = SetTaskPushNotificationConfigRequest() |
| 63 | + Parse(body, params) |
| 64 | + params.parent = f'tasks/{task_id}' # Set from URL path |
| 65 | + |
| 66 | + config = await self.request_handler.on_set_task_push_notification_config(params, context) |
| 67 | + return MessageToDict(config) # Returns with proper `name` field |
| 68 | +``` |
| 69 | + |
| 70 | +## JSON-RPC Handler |
| 71 | + |
| 72 | +The JSON-RPC handler passes the full request directly: |
| 73 | + |
| 74 | +```python |
| 75 | +async def set_push_notification_config( |
| 76 | + self, |
| 77 | + request: SetTaskPushNotificationConfigRequest, |
| 78 | + context: ServerCallContext | None = None, |
| 79 | +) -> SetTaskPushNotificationConfigResponse: |
| 80 | + result = await self.request_handler.on_set_task_push_notification_config( |
| 81 | + request, context |
| 82 | + ) |
| 83 | + return prepare_response_object(...) |
| 84 | +``` |
| 85 | + |
| 86 | +## Key Takeaways |
| 87 | + |
| 88 | +1. **The `name` field is constructed, not passed in** - The server builds the full resource name from `parent` + `config_id` |
| 89 | + |
| 90 | +2. **Consistent across bindings** - Both gRPC and HTTP handlers ultimately call the same `on_set_task_push_notification_config` method |
| 91 | + |
| 92 | +3. **AIP compliance** - The response always includes the full `name` field as required by [AIP-122](https://google.aip.dev/122) |
| 93 | + |
| 94 | +4. **Helper functions for resource name parsing**: |
| 95 | + ```python |
| 96 | + def _extract_task_id(resource_name: str) -> str: |
| 97 | + """Extract task ID from a resource name like 'tasks/{task_id}' or 'tasks/{task_id}/...'.""" |
| 98 | + match = re.match(r'^tasks/([^/]+)', resource_name) |
| 99 | + if match: |
| 100 | + return match.group(1) |
| 101 | + return resource_name # Fall back for backwards compatibility |
| 102 | + |
| 103 | + def _extract_config_id(resource_name: str) -> str | None: |
| 104 | + """Extract config ID from 'tasks/{task_id}/pushNotificationConfigs/{config_id}'.""" |
| 105 | + match = re.match(r'^tasks/[^/]+/pushNotificationConfigs/([^/]+)$', resource_name) |
| 106 | + if match: |
| 107 | + return match.group(1) |
| 108 | + return None |
| 109 | + ``` |
| 110 | + |
| 111 | +## E2E Test Example |
| 112 | + |
| 113 | +Here's how a client uses this in practice: |
| 114 | + |
| 115 | +```python |
| 116 | +# Client sets the push notification config |
| 117 | +await a2a_client.set_task_callback( |
| 118 | + SetTaskPushNotificationConfigRequest( |
| 119 | + parent=f'tasks/{task.id}', |
| 120 | + config_id='my-notification-config', |
| 121 | + config=TaskPushNotificationConfig( |
| 122 | + push_notification_config=PushNotificationConfig( |
| 123 | + id='my-notification-config', |
| 124 | + url=f'{notifications_server}/notifications', |
| 125 | + token=token, |
| 126 | + ), |
| 127 | + ), |
| 128 | + ) |
| 129 | +) |
| 130 | +``` |
| 131 | + |
| 132 | +This approach keeps the abstract handler logic clean while ensuring AIP compliance at the protocol binding level. |
| 133 | + |
| 134 | +--- |
| 135 | + |
| 136 | +**Related PRs:** |
| 137 | +- [a2a-python PR #572](https://github.com/a2aproject/a2a-python/pull/572) - Proto migration with these changes |
0 commit comments