Problem
adcp.webhooks exports two helpers that adopters use when forwarding lifecycle events to a buyer-registered push_notification_config.url:
| Helper |
Returns |
Serialization API |
create_a2a_webhook_payload |
a2a_pb2.Task / a2a_pb2.TaskStatusUpdateEvent (protobuf, since a2a-sdk 1.0+) |
MessageToDict(...) |
create_mcp_webhook_payload |
dict[str, Any] (today; ideally McpWebhookPayload Pydantic) |
already-dict, but adopters wrap as McpWebhookPayload.model_construct(**dict) to get a typed shape |
A sender that accepts "any AdCP webhook payload" can't uniformly serialize the result. Adopters end up writing per-shape dispatch:
if isinstance(payload, (Task, TaskStatusUpdateEvent)):
payload_dict = MessageToDict(payload, preserving_proto_field_name=False)
elif isinstance(payload, McpWebhookPayload):
payload_dict = payload.model_dump(mode="json", exclude_none=True)
elif isinstance(payload, dict):
payload_dict = payload
Brittle: a future a2a-sdk version that adds a Pydantic façade would silently change which branch runs, and every adopter has to repeat this dispatch in their HTTP send path.
Repro (downstream context)
Before this dispatch was added in salesagent PR #209, the salesagent protocol_webhook_service.send_notification called payload.model_dump(...) unconditionally. With a2a.types.Task resolving to a protobuf message in a2a-sdk 1.0+, this raised:
AttributeError: 'Task' object has no attribute 'model_dump'
…mid-delivery. Webhook silently failed; buyer never received completion notification. salesagent's investigation chain in bokelley/salesagent#64 traced the failure.
Proposal
Add a uniform serialization seam to adcp.webhooks:
from typing import Any
from a2a.types import Task, TaskStatusUpdateEvent
from adcp.types import McpWebhookPayload
def to_wire_dict(
payload: Task | TaskStatusUpdateEvent | McpWebhookPayload | dict[str, Any],
) -> dict[str, Any]:
"""Serialize any webhook payload to a JSON-ready dict.
- protobuf (Task / TaskStatusUpdateEvent) → MessageToDict with
preserving_proto_field_name=False so JSON keys match the A2A wire
spec (camelCase: ``id``, ``taskId``, ``contextId``).
- Pydantic (McpWebhookPayload, future Pydantic façades) → model_dump.
- dict → return as-is (legacy adopter passthrough).
"""
from google.protobuf.json_format import MessageToDict
if isinstance(payload, (Task, TaskStatusUpdateEvent)):
return MessageToDict(payload, preserving_proto_field_name=False)
if isinstance(payload, McpWebhookPayload):
return payload.model_dump(mode="json", exclude_none=True)
if isinstance(payload, dict):
return payload
raise TypeError(
f"Unsupported webhook payload type {type(payload).__name__}: expected "
"Task / TaskStatusUpdateEvent (protobuf), McpWebhookPayload (pydantic), or dict"
)
Adopters then do payload_dict = to_wire_dict(payload) regardless of which builder produced it. Backward-compatible: existing per-shape dispatch keeps working; to_wire_dict is opt-in.
Bonus: align return types
create_mcp_webhook_payload returning McpWebhookPayload (Pydantic) instead of dict[str, Any] would let adopters drop the .model_construct(**dict) ceremony. salesagent's context_manager.py carries a TODO about this:
# TODO: Fix in adcp python client - create_mcp_webhook_payload should return
# McpWebhookPayload instead of dict[str, Any] for proper type safety
mcp_payload_dict = create_mcp_webhook_payload(step.step_id, status_enum, step.response_data)
payload = McpWebhookPayload.model_construct(**mcp_payload_dict)
With consistent return types and to_wire_dict as the single serialization seam, adopters never have to know the underlying shape.
Acceptance criteria
References
Out of scope
Problem
adcp.webhooksexports two helpers that adopters use when forwarding lifecycle events to a buyer-registeredpush_notification_config.url:create_a2a_webhook_payloada2a_pb2.Task/a2a_pb2.TaskStatusUpdateEvent(protobuf, since a2a-sdk 1.0+)MessageToDict(...)create_mcp_webhook_payloaddict[str, Any](today; ideallyMcpWebhookPayloadPydantic)McpWebhookPayload.model_construct(**dict)to get a typed shapeA sender that accepts "any AdCP webhook payload" can't uniformly serialize the result. Adopters end up writing per-shape dispatch:
Brittle: a future a2a-sdk version that adds a Pydantic façade would silently change which branch runs, and every adopter has to repeat this dispatch in their HTTP send path.
Repro (downstream context)
Before this dispatch was added in salesagent PR #209, the salesagent
protocol_webhook_service.send_notificationcalledpayload.model_dump(...)unconditionally. Witha2a.types.Taskresolving to a protobuf message in a2a-sdk 1.0+, this raised:…mid-delivery. Webhook silently failed; buyer never received completion notification. salesagent's investigation chain in bokelley/salesagent#64 traced the failure.
Proposal
Add a uniform serialization seam to
adcp.webhooks:Adopters then do
payload_dict = to_wire_dict(payload)regardless of which builder produced it. Backward-compatible: existing per-shape dispatch keeps working;to_wire_dictis opt-in.Bonus: align return types
create_mcp_webhook_payloadreturningMcpWebhookPayload(Pydantic) instead ofdict[str, Any]would let adopters drop the.model_construct(**dict)ceremony. salesagent'scontext_manager.pycarries a TODO about this:With consistent return types and
to_wire_dictas the single serialization seam, adopters never have to know the underlying shape.Acceptance criteria
adcp.webhooks.to_wire_dictexported fromadcp.webhooksandadcppackage root.to_wire_dict(create_a2a_webhook_payload(...))produces a dict withid/contextIdkeys (camelCase per A2A wire).to_wire_dict(create_mcp_webhook_payload(...))produces a dict withtask_id/task_type(MCP schema).create_mcp_webhook_payloadreturnsMcpWebhookPayloadinstead of dict (with a deprecation shim for one minor release).References
src/services/protocol_webhook_service.py:113-138Out of scope