adcp 2.12.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. adcp/__init__.py +364 -0
  2. adcp/__main__.py +440 -0
  3. adcp/adagents.py +642 -0
  4. adcp/client.py +1057 -0
  5. adcp/config.py +82 -0
  6. adcp/exceptions.py +185 -0
  7. adcp/protocols/__init__.py +9 -0
  8. adcp/protocols/a2a.py +484 -0
  9. adcp/protocols/base.py +190 -0
  10. adcp/protocols/mcp.py +440 -0
  11. adcp/py.typed +0 -0
  12. adcp/simple.py +451 -0
  13. adcp/testing/__init__.py +53 -0
  14. adcp/testing/test_helpers.py +311 -0
  15. adcp/types/__init__.py +561 -0
  16. adcp/types/_generated.py +237 -0
  17. adcp/types/aliases.py +748 -0
  18. adcp/types/base.py +26 -0
  19. adcp/types/core.py +174 -0
  20. adcp/types/generated_poc/__init__.py +3 -0
  21. adcp/types/generated_poc/adagents.py +411 -0
  22. adcp/types/generated_poc/core/__init__.py +3 -0
  23. adcp/types/generated_poc/core/activation_key.py +30 -0
  24. adcp/types/generated_poc/core/assets/__init__.py +3 -0
  25. adcp/types/generated_poc/core/assets/audio_asset.py +26 -0
  26. adcp/types/generated_poc/core/assets/css_asset.py +20 -0
  27. adcp/types/generated_poc/core/assets/daast_asset.py +61 -0
  28. adcp/types/generated_poc/core/assets/html_asset.py +18 -0
  29. adcp/types/generated_poc/core/assets/image_asset.py +19 -0
  30. adcp/types/generated_poc/core/assets/javascript_asset.py +23 -0
  31. adcp/types/generated_poc/core/assets/text_asset.py +20 -0
  32. adcp/types/generated_poc/core/assets/url_asset.py +28 -0
  33. adcp/types/generated_poc/core/assets/vast_asset.py +63 -0
  34. adcp/types/generated_poc/core/assets/video_asset.py +24 -0
  35. adcp/types/generated_poc/core/assets/webhook_asset.py +53 -0
  36. adcp/types/generated_poc/core/brand_manifest.py +201 -0
  37. adcp/types/generated_poc/core/context.py +15 -0
  38. adcp/types/generated_poc/core/creative_asset.py +102 -0
  39. adcp/types/generated_poc/core/creative_assignment.py +27 -0
  40. adcp/types/generated_poc/core/creative_filters.py +86 -0
  41. adcp/types/generated_poc/core/creative_manifest.py +68 -0
  42. adcp/types/generated_poc/core/creative_policy.py +28 -0
  43. adcp/types/generated_poc/core/delivery_metrics.py +111 -0
  44. adcp/types/generated_poc/core/deployment.py +78 -0
  45. adcp/types/generated_poc/core/destination.py +43 -0
  46. adcp/types/generated_poc/core/dimensions.py +18 -0
  47. adcp/types/generated_poc/core/error.py +29 -0
  48. adcp/types/generated_poc/core/ext.py +15 -0
  49. adcp/types/generated_poc/core/format.py +260 -0
  50. adcp/types/generated_poc/core/format_id.py +50 -0
  51. adcp/types/generated_poc/core/frequency_cap.py +19 -0
  52. adcp/types/generated_poc/core/measurement.py +40 -0
  53. adcp/types/generated_poc/core/media_buy.py +40 -0
  54. adcp/types/generated_poc/core/package.py +68 -0
  55. adcp/types/generated_poc/core/performance_feedback.py +78 -0
  56. adcp/types/generated_poc/core/placement.py +37 -0
  57. adcp/types/generated_poc/core/product.py +164 -0
  58. adcp/types/generated_poc/core/product_filters.py +97 -0
  59. adcp/types/generated_poc/core/promoted_offerings.py +102 -0
  60. adcp/types/generated_poc/core/promoted_products.py +38 -0
  61. adcp/types/generated_poc/core/property.py +64 -0
  62. adcp/types/generated_poc/core/property_id.py +21 -0
  63. adcp/types/generated_poc/core/property_tag.py +21 -0
  64. adcp/types/generated_poc/core/protocol_envelope.py +61 -0
  65. adcp/types/generated_poc/core/publisher_property_selector.py +75 -0
  66. adcp/types/generated_poc/core/push_notification_config.py +51 -0
  67. adcp/types/generated_poc/core/reporting_capabilities.py +51 -0
  68. adcp/types/generated_poc/core/response.py +24 -0
  69. adcp/types/generated_poc/core/signal_filters.py +29 -0
  70. adcp/types/generated_poc/core/sub_asset.py +55 -0
  71. adcp/types/generated_poc/core/targeting.py +53 -0
  72. adcp/types/generated_poc/core/webhook_payload.py +96 -0
  73. adcp/types/generated_poc/creative/__init__.py +3 -0
  74. adcp/types/generated_poc/creative/list_creative_formats_request.py +88 -0
  75. adcp/types/generated_poc/creative/list_creative_formats_response.py +55 -0
  76. adcp/types/generated_poc/creative/preview_creative_request.py +153 -0
  77. adcp/types/generated_poc/creative/preview_creative_response.py +169 -0
  78. adcp/types/generated_poc/creative/preview_render.py +152 -0
  79. adcp/types/generated_poc/enums/__init__.py +3 -0
  80. adcp/types/generated_poc/enums/adcp_domain.py +12 -0
  81. adcp/types/generated_poc/enums/asset_content_type.py +23 -0
  82. adcp/types/generated_poc/enums/auth_scheme.py +12 -0
  83. adcp/types/generated_poc/enums/available_metric.py +19 -0
  84. adcp/types/generated_poc/enums/channels.py +19 -0
  85. adcp/types/generated_poc/enums/co_branding_requirement.py +13 -0
  86. adcp/types/generated_poc/enums/creative_action.py +15 -0
  87. adcp/types/generated_poc/enums/creative_agent_capability.py +14 -0
  88. adcp/types/generated_poc/enums/creative_sort_field.py +16 -0
  89. adcp/types/generated_poc/enums/creative_status.py +14 -0
  90. adcp/types/generated_poc/enums/daast_tracking_event.py +21 -0
  91. adcp/types/generated_poc/enums/daast_version.py +12 -0
  92. adcp/types/generated_poc/enums/delivery_type.py +12 -0
  93. adcp/types/generated_poc/enums/dimension_unit.py +14 -0
  94. adcp/types/generated_poc/enums/feed_format.py +13 -0
  95. adcp/types/generated_poc/enums/feedback_source.py +14 -0
  96. adcp/types/generated_poc/enums/format_category.py +17 -0
  97. adcp/types/generated_poc/enums/format_id_parameter.py +12 -0
  98. adcp/types/generated_poc/enums/frequency_cap_scope.py +16 -0
  99. adcp/types/generated_poc/enums/history_entry_type.py +12 -0
  100. adcp/types/generated_poc/enums/http_method.py +12 -0
  101. adcp/types/generated_poc/enums/identifier_types.py +29 -0
  102. adcp/types/generated_poc/enums/javascript_module_type.py +13 -0
  103. adcp/types/generated_poc/enums/landing_page_requirement.py +13 -0
  104. adcp/types/generated_poc/enums/markdown_flavor.py +12 -0
  105. adcp/types/generated_poc/enums/media_buy_status.py +14 -0
  106. adcp/types/generated_poc/enums/metric_type.py +18 -0
  107. adcp/types/generated_poc/enums/notification_type.py +14 -0
  108. adcp/types/generated_poc/enums/pacing.py +13 -0
  109. adcp/types/generated_poc/enums/preview_output_format.py +12 -0
  110. adcp/types/generated_poc/enums/pricing_model.py +17 -0
  111. adcp/types/generated_poc/enums/property_type.py +17 -0
  112. adcp/types/generated_poc/enums/publisher_identifier_types.py +15 -0
  113. adcp/types/generated_poc/enums/reporting_frequency.py +13 -0
  114. adcp/types/generated_poc/enums/signal_catalog_type.py +13 -0
  115. adcp/types/generated_poc/enums/sort_direction.py +12 -0
  116. adcp/types/generated_poc/enums/standard_format_ids.py +45 -0
  117. adcp/types/generated_poc/enums/task_status.py +19 -0
  118. adcp/types/generated_poc/enums/task_type.py +15 -0
  119. adcp/types/generated_poc/enums/update_frequency.py +14 -0
  120. adcp/types/generated_poc/enums/url_asset_type.py +13 -0
  121. adcp/types/generated_poc/enums/validation_mode.py +12 -0
  122. adcp/types/generated_poc/enums/vast_tracking_event.py +26 -0
  123. adcp/types/generated_poc/enums/vast_version.py +15 -0
  124. adcp/types/generated_poc/enums/webhook_response_type.py +14 -0
  125. adcp/types/generated_poc/enums/webhook_security_method.py +13 -0
  126. adcp/types/generated_poc/media_buy/__init__.py +3 -0
  127. adcp/types/generated_poc/media_buy/build_creative_request.py +41 -0
  128. adcp/types/generated_poc/media_buy/build_creative_response.py +51 -0
  129. adcp/types/generated_poc/media_buy/create_media_buy_request.py +94 -0
  130. adcp/types/generated_poc/media_buy/create_media_buy_response.py +56 -0
  131. adcp/types/generated_poc/media_buy/get_media_buy_delivery_request.py +47 -0
  132. adcp/types/generated_poc/media_buy/get_media_buy_delivery_response.py +235 -0
  133. adcp/types/generated_poc/media_buy/get_products_request.py +48 -0
  134. adcp/types/generated_poc/media_buy/get_products_response.py +28 -0
  135. adcp/types/generated_poc/media_buy/list_authorized_properties_request.py +38 -0
  136. adcp/types/generated_poc/media_buy/list_authorized_properties_response.py +84 -0
  137. adcp/types/generated_poc/media_buy/list_creative_formats_request.py +74 -0
  138. adcp/types/generated_poc/media_buy/list_creative_formats_response.py +56 -0
  139. adcp/types/generated_poc/media_buy/list_creatives_request.py +76 -0
  140. adcp/types/generated_poc/media_buy/list_creatives_response.py +214 -0
  141. adcp/types/generated_poc/media_buy/package_request.py +63 -0
  142. adcp/types/generated_poc/media_buy/provide_performance_feedback_request.py +125 -0
  143. adcp/types/generated_poc/media_buy/provide_performance_feedback_response.py +53 -0
  144. adcp/types/generated_poc/media_buy/sync_creatives_request.py +63 -0
  145. adcp/types/generated_poc/media_buy/sync_creatives_response.py +105 -0
  146. adcp/types/generated_poc/media_buy/update_media_buy_request.py +195 -0
  147. adcp/types/generated_poc/media_buy/update_media_buy_response.py +55 -0
  148. adcp/types/generated_poc/pricing_options/__init__.py +3 -0
  149. adcp/types/generated_poc/pricing_options/cpc_option.py +43 -0
  150. adcp/types/generated_poc/pricing_options/cpcv_option.py +45 -0
  151. adcp/types/generated_poc/pricing_options/cpm_auction_option.py +58 -0
  152. adcp/types/generated_poc/pricing_options/cpm_fixed_option.py +43 -0
  153. adcp/types/generated_poc/pricing_options/cpp_option.py +64 -0
  154. adcp/types/generated_poc/pricing_options/cpv_option.py +77 -0
  155. adcp/types/generated_poc/pricing_options/flat_rate_option.py +93 -0
  156. adcp/types/generated_poc/pricing_options/vcpm_auction_option.py +61 -0
  157. adcp/types/generated_poc/pricing_options/vcpm_fixed_option.py +47 -0
  158. adcp/types/generated_poc/protocols/__init__.py +3 -0
  159. adcp/types/generated_poc/protocols/adcp_extension.py +37 -0
  160. adcp/types/generated_poc/signals/__init__.py +3 -0
  161. adcp/types/generated_poc/signals/activate_signal_request.py +32 -0
  162. adcp/types/generated_poc/signals/activate_signal_response.py +51 -0
  163. adcp/types/generated_poc/signals/get_signals_request.py +53 -0
  164. adcp/types/generated_poc/signals/get_signals_response.py +59 -0
  165. adcp/utils/__init__.py +7 -0
  166. adcp/utils/operation_id.py +15 -0
  167. adcp/utils/preview_cache.py +491 -0
  168. adcp/utils/response_parser.py +171 -0
  169. adcp/validation.py +172 -0
  170. adcp-2.12.0.data/data/ADCP_VERSION +1 -0
  171. adcp-2.12.0.dist-info/METADATA +992 -0
  172. adcp-2.12.0.dist-info/RECORD +176 -0
  173. adcp-2.12.0.dist-info/WHEEL +5 -0
  174. adcp-2.12.0.dist-info/entry_points.txt +2 -0
  175. adcp-2.12.0.dist-info/licenses/LICENSE +17 -0
  176. adcp-2.12.0.dist-info/top_level.txt +1 -0
adcp/protocols/a2a.py ADDED
@@ -0,0 +1,484 @@
1
+ from __future__ import annotations
2
+
3
+ """A2A protocol adapter using HTTP client.
4
+
5
+ The official a2a-sdk is primarily for building A2A servers. For client functionality,
6
+ we implement the A2A protocol using HTTP requests as per the A2A specification.
7
+ """
8
+
9
+ import logging
10
+ import time
11
+ from typing import Any
12
+ from uuid import uuid4
13
+
14
+ import httpx
15
+
16
+ from adcp.exceptions import (
17
+ ADCPAuthenticationError,
18
+ ADCPConnectionError,
19
+ ADCPTimeoutError,
20
+ )
21
+ from adcp.protocols.base import ProtocolAdapter
22
+ from adcp.types.core import AgentConfig, DebugInfo, TaskResult, TaskStatus
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ class A2AAdapter(ProtocolAdapter):
28
+ """Adapter for A2A protocol following the Agent2Agent specification."""
29
+
30
+ def __init__(self, agent_config: AgentConfig):
31
+ """Initialize A2A adapter with reusable HTTP client."""
32
+ super().__init__(agent_config)
33
+ self._client: httpx.AsyncClient | None = None
34
+
35
+ async def _get_client(self) -> httpx.AsyncClient:
36
+ """Get or create the HTTP client with connection pooling."""
37
+ if self._client is None:
38
+ # Configure connection pooling for better performance
39
+ limits = httpx.Limits(
40
+ max_keepalive_connections=10,
41
+ max_connections=20,
42
+ keepalive_expiry=30.0,
43
+ )
44
+ self._client = httpx.AsyncClient(limits=limits)
45
+ logger.debug(
46
+ f"Created HTTP client with connection pooling for agent {self.agent_config.id}"
47
+ )
48
+ return self._client
49
+
50
+ async def close(self) -> None:
51
+ """Close the HTTP client and clean up resources."""
52
+ if self._client is not None:
53
+ logger.debug(f"Closing A2A adapter client for agent {self.agent_config.id}")
54
+ await self._client.aclose()
55
+ self._client = None
56
+
57
+ async def _call_a2a_tool(self, tool_name: str, params: dict[str, Any]) -> TaskResult[Any]:
58
+ """
59
+ Call a tool using A2A protocol.
60
+
61
+ A2A uses a tasks/send endpoint to initiate tasks. The agent responds with
62
+ task status and may require multiple roundtrips for completion.
63
+ """
64
+ start_time = time.time() if self.agent_config.debug else None
65
+ client = await self._get_client()
66
+
67
+ headers = {"Content-Type": "application/json"}
68
+
69
+ if self.agent_config.auth_token:
70
+ # Support custom auth headers and types
71
+ if self.agent_config.auth_type == "bearer":
72
+ headers[self.agent_config.auth_header] = f"Bearer {self.agent_config.auth_token}"
73
+ else:
74
+ headers[self.agent_config.auth_header] = self.agent_config.auth_token
75
+
76
+ # Construct A2A message
77
+ message = {
78
+ "role": "user",
79
+ "parts": [
80
+ {
81
+ "type": "text",
82
+ "text": self._format_tool_request(tool_name, params),
83
+ }
84
+ ],
85
+ }
86
+
87
+ # A2A uses message/send endpoint
88
+ url = f"{self.agent_config.agent_uri}/message/send"
89
+
90
+ request_data = {
91
+ "message": message,
92
+ "context_id": str(uuid4()),
93
+ }
94
+
95
+ debug_info = None
96
+ if self.agent_config.debug:
97
+ debug_request = {
98
+ "url": url,
99
+ "method": "POST",
100
+ "headers": {
101
+ k: (
102
+ v
103
+ if k.lower() not in ("authorization", self.agent_config.auth_header.lower())
104
+ else "***"
105
+ )
106
+ for k, v in headers.items()
107
+ },
108
+ "body": request_data,
109
+ }
110
+
111
+ try:
112
+ response = await client.post(
113
+ url,
114
+ json=request_data,
115
+ headers=headers,
116
+ timeout=self.agent_config.timeout,
117
+ )
118
+ response.raise_for_status()
119
+
120
+ data = response.json()
121
+
122
+ if self.agent_config.debug and start_time:
123
+ duration_ms = (time.time() - start_time) * 1000
124
+ debug_info = DebugInfo(
125
+ request=debug_request,
126
+ response={"status": response.status_code, "body": data},
127
+ duration_ms=duration_ms,
128
+ )
129
+
130
+ # Parse A2A response format per canonical spec
131
+ # A2A tasks have lifecycle: submitted, working, completed, failed, input-required
132
+ task_status = data.get("status")
133
+
134
+ if task_status == "completed":
135
+ # Extract the result from the artifacts array
136
+ result_data = self._extract_result(data)
137
+
138
+ # Check for task-level errors in the payload
139
+ errors = result_data.get("errors", []) if isinstance(result_data, dict) else []
140
+ has_errors = bool(errors)
141
+
142
+ return TaskResult[Any](
143
+ status=TaskStatus.COMPLETED,
144
+ data=result_data,
145
+ message=self._extract_text_part(data),
146
+ success=not has_errors,
147
+ metadata={
148
+ "task_id": data.get("taskId"),
149
+ "context_id": data.get("contextId"),
150
+ },
151
+ debug_info=debug_info,
152
+ )
153
+ elif task_status == "failed":
154
+ # Protocol-level failure - extract error message from TextPart
155
+ error_msg = self._extract_text_part(data) or "Task failed"
156
+ return TaskResult[Any](
157
+ status=TaskStatus.FAILED,
158
+ error=error_msg,
159
+ success=False,
160
+ debug_info=debug_info,
161
+ )
162
+ else:
163
+ # Handle all interim states (submitted, working, pending, input-required)
164
+ # These don't need to have structured AdCP content - only completed responses do
165
+ return TaskResult[Any](
166
+ status=TaskStatus.SUBMITTED,
167
+ data=None, # Interim responses may not have structured AdCP content
168
+ message=self._extract_text_part(data),
169
+ success=True,
170
+ metadata={
171
+ "task_id": data.get("taskId"),
172
+ "context_id": data.get("contextId"),
173
+ "status": task_status, # submitted, working, pending, input-required, etc.
174
+ },
175
+ debug_info=debug_info,
176
+ )
177
+
178
+ except httpx.HTTPError as e:
179
+ if self.agent_config.debug and start_time:
180
+ duration_ms = (time.time() - start_time) * 1000
181
+ debug_info = DebugInfo(
182
+ request=debug_request,
183
+ response={"error": str(e)},
184
+ duration_ms=duration_ms,
185
+ )
186
+ return TaskResult[Any](
187
+ status=TaskStatus.FAILED,
188
+ error=str(e),
189
+ success=False,
190
+ debug_info=debug_info,
191
+ )
192
+
193
+ def _format_tool_request(self, tool_name: str, params: dict[str, Any]) -> str:
194
+ """Format tool request as natural language for A2A."""
195
+ # For AdCP tools, we format as a structured request
196
+ import json
197
+
198
+ return f"Execute tool: {tool_name}\nParameters: {json.dumps(params, indent=2)}"
199
+
200
+ def _extract_result(self, response_data: dict[str, Any]) -> Any:
201
+ """
202
+ Extract result data from A2A response following canonical format.
203
+
204
+ Per A2A response spec:
205
+ - Responses MUST include at least one DataPart (kind: "data")
206
+ - When multiple DataParts exist in an artifact, the last one is authoritative
207
+ - When multiple artifacts exist, use the last one (most recent in streaming)
208
+ - DataParts contain structured AdCP payload
209
+ """
210
+ artifacts = response_data.get("artifacts", [])
211
+
212
+ if not artifacts:
213
+ logger.warning("A2A response missing required artifacts array")
214
+ return response_data
215
+
216
+ # Use last artifact (most recent in streaming scenarios)
217
+ # A2A spec doesn't define artifact.status, so we simply take the last one
218
+ target_artifact = artifacts[-1]
219
+
220
+ parts = target_artifact.get("parts", [])
221
+
222
+ if not parts:
223
+ logger.warning("A2A response artifact has no parts")
224
+ return response_data
225
+
226
+ # Find all DataParts (kind: "data")
227
+ data_parts = [p for p in parts if p.get("kind") == "data"]
228
+
229
+ if not data_parts:
230
+ logger.warning("A2A response missing required DataPart (kind: 'data')")
231
+ return response_data
232
+
233
+ # Use last DataPart as authoritative (handles streaming scenarios within an artifact)
234
+ last_data_part = data_parts[-1]
235
+ data = last_data_part.get("data", {})
236
+
237
+ # Some A2A implementations (e.g., ADK) wrap the response in {"response": {...}}
238
+ # Unwrap it to get the actual AdCP payload if present
239
+ # ADK is inconsistent - some DataParts have the wrapper, others don't
240
+ if isinstance(data, dict) and "response" in data:
241
+ # If response is the only key, unwrap completely
242
+ if len(data) == 1:
243
+ return data["response"]
244
+ # If there are other keys alongside response, prefer the wrapped content
245
+ # but keep it flexible for edge cases
246
+ return data["response"]
247
+
248
+ return data
249
+
250
+ def _extract_text_part(self, response_data: dict[str, Any]) -> str | None:
251
+ """
252
+ Extract human-readable message from TextPart if present.
253
+
254
+ Uses last artifact (same logic as _extract_result).
255
+ """
256
+ artifacts = response_data.get("artifacts", [])
257
+
258
+ if not artifacts:
259
+ return None
260
+
261
+ # Use last artifact (most recent in streaming scenarios)
262
+ # A2A spec doesn't define artifact.status, so we simply take the last one
263
+ target_artifact = artifacts[-1]
264
+
265
+ parts = target_artifact.get("parts", [])
266
+
267
+ # Find TextPart (kind: "text")
268
+ for part in parts:
269
+ if part.get("kind") == "text":
270
+ text = part.get("text")
271
+ return str(text) if text is not None else None
272
+
273
+ return None
274
+
275
+ # ========================================================================
276
+ # ADCP Protocol Methods
277
+ # ========================================================================
278
+
279
+ async def get_products(self, params: dict[str, Any]) -> TaskResult[Any]:
280
+ """Get advertising products."""
281
+ return await self._call_a2a_tool("get_products", params)
282
+
283
+ async def list_creative_formats(self, params: dict[str, Any]) -> TaskResult[Any]:
284
+ """List supported creative formats."""
285
+ return await self._call_a2a_tool("list_creative_formats", params)
286
+
287
+ async def sync_creatives(self, params: dict[str, Any]) -> TaskResult[Any]:
288
+ """Sync creatives."""
289
+ return await self._call_a2a_tool("sync_creatives", params)
290
+
291
+ async def list_creatives(self, params: dict[str, Any]) -> TaskResult[Any]:
292
+ """List creatives."""
293
+ return await self._call_a2a_tool("list_creatives", params)
294
+
295
+ async def get_media_buy_delivery(self, params: dict[str, Any]) -> TaskResult[Any]:
296
+ """Get media buy delivery."""
297
+ return await self._call_a2a_tool("get_media_buy_delivery", params)
298
+
299
+ async def list_authorized_properties(self, params: dict[str, Any]) -> TaskResult[Any]:
300
+ """List authorized properties."""
301
+ return await self._call_a2a_tool("list_authorized_properties", params)
302
+
303
+ async def get_signals(self, params: dict[str, Any]) -> TaskResult[Any]:
304
+ """Get signals."""
305
+ return await self._call_a2a_tool("get_signals", params)
306
+
307
+ async def activate_signal(self, params: dict[str, Any]) -> TaskResult[Any]:
308
+ """Activate signal."""
309
+ return await self._call_a2a_tool("activate_signal", params)
310
+
311
+ async def provide_performance_feedback(self, params: dict[str, Any]) -> TaskResult[Any]:
312
+ """Provide performance feedback."""
313
+ return await self._call_a2a_tool("provide_performance_feedback", params)
314
+
315
+ async def preview_creative(self, params: dict[str, Any]) -> TaskResult[Any]:
316
+ """Generate preview URLs for a creative manifest."""
317
+ return await self._call_a2a_tool("preview_creative", params)
318
+
319
+ async def create_media_buy(self, params: dict[str, Any]) -> TaskResult[Any]:
320
+ """Create media buy."""
321
+ return await self._call_a2a_tool("create_media_buy", params)
322
+
323
+ async def update_media_buy(self, params: dict[str, Any]) -> TaskResult[Any]:
324
+ """Update media buy."""
325
+ return await self._call_a2a_tool("update_media_buy", params)
326
+
327
+ async def build_creative(self, params: dict[str, Any]) -> TaskResult[Any]:
328
+ """Build creative."""
329
+ return await self._call_a2a_tool("build_creative", params)
330
+
331
+ async def list_tools(self) -> list[str]:
332
+ """
333
+ List available tools from A2A agent.
334
+
335
+ Note: A2A doesn't have a standard tools/list endpoint. Agents expose
336
+ their capabilities through the agent card. For AdCP, we rely on the
337
+ standard AdCP tool set.
338
+ """
339
+ client = await self._get_client()
340
+
341
+ headers = {"Content-Type": "application/json"}
342
+
343
+ if self.agent_config.auth_token:
344
+ # Support custom auth headers and types
345
+ if self.agent_config.auth_type == "bearer":
346
+ headers[self.agent_config.auth_header] = f"Bearer {self.agent_config.auth_token}"
347
+ else:
348
+ headers[self.agent_config.auth_header] = self.agent_config.auth_token
349
+
350
+ # Try to fetch agent card from standard A2A location
351
+ # A2A spec uses /.well-known/agent.json for agent card
352
+ url = f"{self.agent_config.agent_uri}/.well-known/agent.json"
353
+
354
+ logger.debug(f"Fetching A2A agent card for {self.agent_config.id} from {url}")
355
+
356
+ try:
357
+ response = await client.get(url, headers=headers, timeout=self.agent_config.timeout)
358
+ response.raise_for_status()
359
+
360
+ data = response.json()
361
+
362
+ # Extract skills from agent card
363
+ skills = data.get("skills", [])
364
+ tool_names = [skill.get("name", "") for skill in skills if skill.get("name")]
365
+
366
+ logger.info(f"Found {len(tool_names)} tools from A2A agent {self.agent_config.id}")
367
+ return tool_names
368
+
369
+ except httpx.HTTPStatusError as e:
370
+ status_code = e.response.status_code
371
+ if status_code in (401, 403):
372
+ logger.error(f"Authentication failed for A2A agent {self.agent_config.id}")
373
+ raise ADCPAuthenticationError(
374
+ f"Authentication failed: HTTP {status_code}",
375
+ agent_id=self.agent_config.id,
376
+ agent_uri=self.agent_config.agent_uri,
377
+ ) from e
378
+ else:
379
+ logger.error(f"HTTP {status_code} error fetching agent card: {e}")
380
+ raise ADCPConnectionError(
381
+ f"Failed to fetch agent card: HTTP {status_code}",
382
+ agent_id=self.agent_config.id,
383
+ agent_uri=self.agent_config.agent_uri,
384
+ ) from e
385
+ except httpx.TimeoutException as e:
386
+ logger.error(f"Timeout fetching agent card for {self.agent_config.id}")
387
+ raise ADCPTimeoutError(
388
+ f"Timeout fetching agent card: {e}",
389
+ agent_id=self.agent_config.id,
390
+ agent_uri=self.agent_config.agent_uri,
391
+ timeout=self.agent_config.timeout,
392
+ ) from e
393
+ except httpx.HTTPError as e:
394
+ logger.error(f"HTTP error fetching agent card: {e}")
395
+ raise ADCPConnectionError(
396
+ f"Failed to fetch agent card: {e}",
397
+ agent_id=self.agent_config.id,
398
+ agent_uri=self.agent_config.agent_uri,
399
+ ) from e
400
+
401
+ async def get_agent_info(self) -> dict[str, Any]:
402
+ """
403
+ Get agent information including AdCP extension metadata from A2A agent card.
404
+
405
+ Fetches the agent card from /.well-known/agent.json and extracts:
406
+ - Basic agent info (name, description, version)
407
+ - AdCP extension (extensions.adcp.adcp_version, extensions.adcp.protocols_supported)
408
+ - Available skills/tools
409
+
410
+ Returns:
411
+ Dictionary with agent metadata
412
+ """
413
+ client = await self._get_client()
414
+
415
+ headers = {"Content-Type": "application/json"}
416
+
417
+ if self.agent_config.auth_token:
418
+ if self.agent_config.auth_type == "bearer":
419
+ headers[self.agent_config.auth_header] = f"Bearer {self.agent_config.auth_token}"
420
+ else:
421
+ headers[self.agent_config.auth_header] = self.agent_config.auth_token
422
+
423
+ url = f"{self.agent_config.agent_uri}/.well-known/agent.json"
424
+
425
+ logger.debug(f"Fetching A2A agent info for {self.agent_config.id} from {url}")
426
+
427
+ try:
428
+ response = await client.get(url, headers=headers, timeout=self.agent_config.timeout)
429
+ response.raise_for_status()
430
+
431
+ agent_card = response.json()
432
+
433
+ # Extract basic info
434
+ info: dict[str, Any] = {
435
+ "name": agent_card.get("name"),
436
+ "description": agent_card.get("description"),
437
+ "version": agent_card.get("version"),
438
+ "protocol": "a2a",
439
+ }
440
+
441
+ # Extract skills/tools
442
+ skills = agent_card.get("skills", [])
443
+ tool_names = [skill.get("name") for skill in skills if skill.get("name")]
444
+ if tool_names:
445
+ info["tools"] = tool_names
446
+
447
+ # Extract AdCP extension metadata
448
+ extensions = agent_card.get("extensions", {})
449
+ adcp_ext = extensions.get("adcp", {})
450
+
451
+ if adcp_ext:
452
+ info["adcp_version"] = adcp_ext.get("adcp_version")
453
+ info["protocols_supported"] = adcp_ext.get("protocols_supported")
454
+
455
+ logger.info(f"Retrieved agent info for {self.agent_config.id}")
456
+ return info
457
+
458
+ except httpx.HTTPStatusError as e:
459
+ status_code = e.response.status_code
460
+ if status_code in (401, 403):
461
+ raise ADCPAuthenticationError(
462
+ f"Authentication failed: HTTP {status_code}",
463
+ agent_id=self.agent_config.id,
464
+ agent_uri=self.agent_config.agent_uri,
465
+ ) from e
466
+ else:
467
+ raise ADCPConnectionError(
468
+ f"Failed to fetch agent card: HTTP {status_code}",
469
+ agent_id=self.agent_config.id,
470
+ agent_uri=self.agent_config.agent_uri,
471
+ ) from e
472
+ except httpx.TimeoutException as e:
473
+ raise ADCPTimeoutError(
474
+ f"Timeout fetching agent card: {e}",
475
+ agent_id=self.agent_config.id,
476
+ agent_uri=self.agent_config.agent_uri,
477
+ timeout=self.agent_config.timeout,
478
+ ) from e
479
+ except httpx.HTTPError as e:
480
+ raise ADCPConnectionError(
481
+ f"Failed to fetch agent card: {e}",
482
+ agent_id=self.agent_config.id,
483
+ agent_uri=self.agent_config.agent_uri,
484
+ ) from e
adcp/protocols/base.py ADDED
@@ -0,0 +1,190 @@
1
+ from __future__ import annotations
2
+
3
+ """Base protocol adapter interface."""
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import Any, TypeVar
7
+
8
+ from pydantic import BaseModel
9
+
10
+ from adcp.types.core import AgentConfig, TaskResult, TaskStatus
11
+ from adcp.utils.response_parser import parse_json_or_text, parse_mcp_content
12
+
13
+ T = TypeVar("T", bound=BaseModel)
14
+
15
+
16
+ class ProtocolAdapter(ABC):
17
+ """
18
+ Base class for protocol adapters.
19
+
20
+ Each adapter implements the ADCP protocol methods and handles
21
+ protocol-specific translation (MCP/A2A) while returning properly
22
+ typed responses.
23
+ """
24
+
25
+ def __init__(self, agent_config: AgentConfig):
26
+ """Initialize adapter with agent configuration."""
27
+ self.agent_config = agent_config
28
+
29
+ # ========================================================================
30
+ # Helper methods for response parsing
31
+ # ========================================================================
32
+
33
+ def _parse_response(
34
+ self, raw_result: TaskResult[Any], response_type: type[T] | Any
35
+ ) -> TaskResult[T]:
36
+ """
37
+ Parse raw TaskResult into typed TaskResult.
38
+
39
+ Handles both MCP content arrays and A2A dict responses.
40
+ Supports both single types and Union types (for oneOf discriminated unions).
41
+
42
+ Args:
43
+ raw_result: Raw TaskResult from adapter
44
+ response_type: Expected Pydantic response type (can be a Union type)
45
+
46
+ Returns:
47
+ Typed TaskResult
48
+ """
49
+ # Handle failed results or interim states without data
50
+ # For A2A: interim states (submitted/working) have data=None but success=True
51
+ # For MCP: completed tasks always have data, missing data indicates failure
52
+ if not raw_result.success or raw_result.data is None:
53
+ # If already marked as unsuccessful, preserve that
54
+ # If successful but no data (A2A interim state), preserve success=True
55
+ return TaskResult[T](
56
+ status=raw_result.status,
57
+ data=None,
58
+ message=raw_result.message,
59
+ success=raw_result.success, # Preserve original success state
60
+ error=raw_result.error, # Only use error if one was set
61
+ metadata=raw_result.metadata,
62
+ debug_info=raw_result.debug_info,
63
+ )
64
+
65
+ try:
66
+ # Handle MCP content arrays
67
+ if isinstance(raw_result.data, list):
68
+ parsed_data = parse_mcp_content(raw_result.data, response_type)
69
+ else:
70
+ # Handle A2A or direct responses
71
+ parsed_data = parse_json_or_text(raw_result.data, response_type)
72
+
73
+ return TaskResult[T](
74
+ status=raw_result.status,
75
+ data=parsed_data,
76
+ message=raw_result.message, # Preserve human-readable message from protocol
77
+ success=raw_result.success,
78
+ error=raw_result.error,
79
+ metadata=raw_result.metadata,
80
+ debug_info=raw_result.debug_info,
81
+ )
82
+ except ValueError as e:
83
+ # Parsing failed - return error result
84
+ return TaskResult[T](
85
+ status=TaskStatus.FAILED,
86
+ error=f"Failed to parse response: {e}",
87
+ message=raw_result.message,
88
+ success=False,
89
+ debug_info=raw_result.debug_info,
90
+ )
91
+
92
+ # ========================================================================
93
+ # ADCP Protocol Methods - Type-safe, spec-aligned interface
94
+ # Each adapter MUST implement these methods explicitly.
95
+ # ========================================================================
96
+
97
+ @abstractmethod
98
+ async def get_products(self, params: dict[str, Any]) -> TaskResult[Any]:
99
+ """Get advertising products."""
100
+ pass
101
+
102
+ @abstractmethod
103
+ async def list_creative_formats(self, params: dict[str, Any]) -> TaskResult[Any]:
104
+ """List supported creative formats."""
105
+ pass
106
+
107
+ @abstractmethod
108
+ async def sync_creatives(self, params: dict[str, Any]) -> TaskResult[Any]:
109
+ """Sync creatives."""
110
+ pass
111
+
112
+ @abstractmethod
113
+ async def list_creatives(self, params: dict[str, Any]) -> TaskResult[Any]:
114
+ """List creatives."""
115
+ pass
116
+
117
+ @abstractmethod
118
+ async def get_media_buy_delivery(self, params: dict[str, Any]) -> TaskResult[Any]:
119
+ """Get media buy delivery."""
120
+ pass
121
+
122
+ @abstractmethod
123
+ async def list_authorized_properties(self, params: dict[str, Any]) -> TaskResult[Any]:
124
+ """List authorized properties."""
125
+ pass
126
+
127
+ @abstractmethod
128
+ async def get_signals(self, params: dict[str, Any]) -> TaskResult[Any]:
129
+ """Get signals."""
130
+ pass
131
+
132
+ @abstractmethod
133
+ async def activate_signal(self, params: dict[str, Any]) -> TaskResult[Any]:
134
+ """Activate signal."""
135
+ pass
136
+
137
+ @abstractmethod
138
+ async def provide_performance_feedback(self, params: dict[str, Any]) -> TaskResult[Any]:
139
+ """Provide performance feedback."""
140
+ pass
141
+
142
+ @abstractmethod
143
+ async def create_media_buy(self, params: dict[str, Any]) -> TaskResult[Any]:
144
+ """Create media buy."""
145
+ pass
146
+
147
+ @abstractmethod
148
+ async def update_media_buy(self, params: dict[str, Any]) -> TaskResult[Any]:
149
+ """Update media buy."""
150
+ pass
151
+
152
+ @abstractmethod
153
+ async def build_creative(self, params: dict[str, Any]) -> TaskResult[Any]:
154
+ """Build creative."""
155
+ pass
156
+
157
+ @abstractmethod
158
+ async def list_tools(self) -> list[str]:
159
+ """
160
+ List available tools from the agent.
161
+
162
+ Returns:
163
+ List of tool names
164
+ """
165
+ pass
166
+
167
+ @abstractmethod
168
+ async def get_agent_info(self) -> dict[str, Any]:
169
+ """
170
+ Get agent information including AdCP extension metadata.
171
+
172
+ Returns agent card information including:
173
+ - Agent name, description, version
174
+ - AdCP version (from extensions.adcp.adcp_version)
175
+ - Supported protocols (from extensions.adcp.protocols_supported)
176
+ - Available tools/skills
177
+
178
+ Returns:
179
+ Dictionary with agent metadata including AdCP extension fields
180
+ """
181
+ pass
182
+
183
+ @abstractmethod
184
+ async def close(self) -> None:
185
+ """
186
+ Close the adapter and clean up resources.
187
+
188
+ Implementations should close any open connections, clients, or other resources.
189
+ """
190
+ pass