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/client.py ADDED
@@ -0,0 +1,1057 @@
1
+ from __future__ import annotations
2
+
3
+ """Main client classes for AdCP."""
4
+
5
+ import hashlib
6
+ import hmac
7
+ import json
8
+ import logging
9
+ import os
10
+ from collections.abc import Callable
11
+ from datetime import datetime, timezone
12
+ from typing import Any
13
+
14
+ from pydantic import BaseModel
15
+
16
+ from adcp.exceptions import ADCPWebhookSignatureError
17
+ from adcp.protocols.a2a import A2AAdapter
18
+ from adcp.protocols.base import ProtocolAdapter
19
+ from adcp.protocols.mcp import MCPAdapter
20
+ from adcp.types import (
21
+ ActivateSignalRequest,
22
+ ActivateSignalResponse,
23
+ BuildCreativeRequest,
24
+ BuildCreativeResponse,
25
+ CreateMediaBuyRequest,
26
+ CreateMediaBuyResponse,
27
+ GeneratedTaskStatus,
28
+ GetMediaBuyDeliveryRequest,
29
+ GetMediaBuyDeliveryResponse,
30
+ GetProductsRequest,
31
+ GetProductsResponse,
32
+ GetSignalsRequest,
33
+ GetSignalsResponse,
34
+ ListAuthorizedPropertiesRequest,
35
+ ListAuthorizedPropertiesResponse,
36
+ ListCreativeFormatsRequest,
37
+ ListCreativeFormatsResponse,
38
+ ListCreativesRequest,
39
+ ListCreativesResponse,
40
+ PreviewCreativeRequest,
41
+ PreviewCreativeResponse,
42
+ ProvidePerformanceFeedbackRequest,
43
+ ProvidePerformanceFeedbackResponse,
44
+ SyncCreativesRequest,
45
+ SyncCreativesResponse,
46
+ UpdateMediaBuyRequest,
47
+ UpdateMediaBuyResponse,
48
+ WebhookPayload,
49
+ )
50
+ from adcp.types.core import (
51
+ Activity,
52
+ ActivityType,
53
+ AgentConfig,
54
+ Protocol,
55
+ TaskResult,
56
+ TaskStatus,
57
+ )
58
+ from adcp.utils.operation_id import create_operation_id
59
+
60
+ logger = logging.getLogger(__name__)
61
+
62
+
63
+ class ADCPClient:
64
+ """Client for interacting with a single AdCP agent."""
65
+
66
+ def __init__(
67
+ self,
68
+ agent_config: AgentConfig,
69
+ webhook_url_template: str | None = None,
70
+ webhook_secret: str | None = None,
71
+ on_activity: Callable[[Activity], None] | None = None,
72
+ ):
73
+ """
74
+ Initialize ADCP client for a single agent.
75
+
76
+ Args:
77
+ agent_config: Agent configuration
78
+ webhook_url_template: Template for webhook URLs with {agent_id},
79
+ {task_type}, {operation_id}
80
+ webhook_secret: Secret for webhook signature verification
81
+ on_activity: Callback for activity events
82
+ """
83
+ self.agent_config = agent_config
84
+ self.webhook_url_template = webhook_url_template
85
+ self.webhook_secret = webhook_secret
86
+ self.on_activity = on_activity
87
+
88
+ # Initialize protocol adapter
89
+ self.adapter: ProtocolAdapter
90
+ if agent_config.protocol == Protocol.A2A:
91
+ self.adapter = A2AAdapter(agent_config)
92
+ elif agent_config.protocol == Protocol.MCP:
93
+ self.adapter = MCPAdapter(agent_config)
94
+ else:
95
+ raise ValueError(f"Unsupported protocol: {agent_config.protocol}")
96
+
97
+ # Initialize simple API accessor (lazy import to avoid circular dependency)
98
+ from adcp.simple import SimpleAPI
99
+
100
+ self.simple = SimpleAPI(self)
101
+
102
+ def get_webhook_url(self, task_type: str, operation_id: str) -> str:
103
+ """Generate webhook URL for a task."""
104
+ if not self.webhook_url_template:
105
+ raise ValueError("webhook_url_template not configured")
106
+
107
+ return self.webhook_url_template.format(
108
+ agent_id=self.agent_config.id,
109
+ task_type=task_type,
110
+ operation_id=operation_id,
111
+ )
112
+
113
+ def _emit_activity(self, activity: Activity) -> None:
114
+ """Emit activity event."""
115
+ if self.on_activity:
116
+ self.on_activity(activity)
117
+
118
+ async def get_products(
119
+ self,
120
+ request: GetProductsRequest,
121
+ fetch_previews: bool = False,
122
+ preview_output_format: str = "url",
123
+ creative_agent_client: ADCPClient | None = None,
124
+ ) -> TaskResult[GetProductsResponse]:
125
+ """
126
+ Get advertising products.
127
+
128
+ Args:
129
+ request: Request parameters
130
+ fetch_previews: If True, generate preview URLs for each product's formats
131
+ (uses batch API for 5-10x performance improvement)
132
+ preview_output_format: "url" for iframe URLs (default), "html" for direct
133
+ embedding (2-3x faster, no iframe overhead)
134
+ creative_agent_client: Client for creative agent (required if
135
+ fetch_previews=True)
136
+
137
+ Returns:
138
+ TaskResult containing GetProductsResponse with optional preview URLs in metadata
139
+
140
+ Raises:
141
+ ValueError: If fetch_previews=True but creative_agent_client is not provided
142
+ """
143
+ if fetch_previews and not creative_agent_client:
144
+ raise ValueError("creative_agent_client is required when fetch_previews=True")
145
+
146
+ operation_id = create_operation_id()
147
+ params = request.model_dump(exclude_none=True)
148
+
149
+ self._emit_activity(
150
+ Activity(
151
+ type=ActivityType.PROTOCOL_REQUEST,
152
+ operation_id=operation_id,
153
+ agent_id=self.agent_config.id,
154
+ task_type="get_products",
155
+ timestamp=datetime.now(timezone.utc).isoformat(),
156
+ )
157
+ )
158
+
159
+ raw_result = await self.adapter.get_products(params)
160
+
161
+ self._emit_activity(
162
+ Activity(
163
+ type=ActivityType.PROTOCOL_RESPONSE,
164
+ operation_id=operation_id,
165
+ agent_id=self.agent_config.id,
166
+ task_type="get_products",
167
+ status=raw_result.status,
168
+ timestamp=datetime.now(timezone.utc).isoformat(),
169
+ )
170
+ )
171
+
172
+ result: TaskResult[GetProductsResponse] = self.adapter._parse_response(
173
+ raw_result, GetProductsResponse
174
+ )
175
+
176
+ if fetch_previews and result.success and result.data and creative_agent_client:
177
+ from adcp.utils.preview_cache import add_preview_urls_to_products
178
+
179
+ products_with_previews = await add_preview_urls_to_products(
180
+ result.data.products,
181
+ creative_agent_client,
182
+ use_batch=True,
183
+ output_format=preview_output_format,
184
+ )
185
+ result.metadata = result.metadata or {}
186
+ result.metadata["products_with_previews"] = products_with_previews
187
+
188
+ return result
189
+
190
+ async def list_creative_formats(
191
+ self,
192
+ request: ListCreativeFormatsRequest,
193
+ fetch_previews: bool = False,
194
+ preview_output_format: str = "url",
195
+ ) -> TaskResult[ListCreativeFormatsResponse]:
196
+ """
197
+ List supported creative formats.
198
+
199
+ Args:
200
+ request: Request parameters
201
+ fetch_previews: If True, generate preview URLs for each format using
202
+ sample manifests (uses batch API for 5-10x performance improvement)
203
+ preview_output_format: "url" for iframe URLs (default), "html" for direct
204
+ embedding (2-3x faster, no iframe overhead)
205
+
206
+ Returns:
207
+ TaskResult containing ListCreativeFormatsResponse with optional preview URLs in metadata
208
+ """
209
+ operation_id = create_operation_id()
210
+ params = request.model_dump(exclude_none=True)
211
+
212
+ self._emit_activity(
213
+ Activity(
214
+ type=ActivityType.PROTOCOL_REQUEST,
215
+ operation_id=operation_id,
216
+ agent_id=self.agent_config.id,
217
+ task_type="list_creative_formats",
218
+ timestamp=datetime.now(timezone.utc).isoformat(),
219
+ )
220
+ )
221
+
222
+ raw_result = await self.adapter.list_creative_formats(params)
223
+
224
+ self._emit_activity(
225
+ Activity(
226
+ type=ActivityType.PROTOCOL_RESPONSE,
227
+ operation_id=operation_id,
228
+ agent_id=self.agent_config.id,
229
+ task_type="list_creative_formats",
230
+ status=raw_result.status,
231
+ timestamp=datetime.now(timezone.utc).isoformat(),
232
+ )
233
+ )
234
+
235
+ result: TaskResult[ListCreativeFormatsResponse] = self.adapter._parse_response(
236
+ raw_result, ListCreativeFormatsResponse
237
+ )
238
+
239
+ if fetch_previews and result.success and result.data:
240
+ from adcp.utils.preview_cache import add_preview_urls_to_formats
241
+
242
+ formats_with_previews = await add_preview_urls_to_formats(
243
+ result.data.formats,
244
+ self,
245
+ use_batch=True,
246
+ output_format=preview_output_format,
247
+ )
248
+ result.metadata = result.metadata or {}
249
+ result.metadata["formats_with_previews"] = formats_with_previews
250
+
251
+ return result
252
+
253
+ async def preview_creative(
254
+ self,
255
+ request: PreviewCreativeRequest,
256
+ ) -> TaskResult[PreviewCreativeResponse]:
257
+ """
258
+ Generate preview of a creative manifest.
259
+
260
+ Args:
261
+ request: Request parameters
262
+
263
+ Returns:
264
+ TaskResult containing PreviewCreativeResponse with preview URLs
265
+ """
266
+ operation_id = create_operation_id()
267
+ params = request.model_dump(exclude_none=True)
268
+
269
+ self._emit_activity(
270
+ Activity(
271
+ type=ActivityType.PROTOCOL_REQUEST,
272
+ operation_id=operation_id,
273
+ agent_id=self.agent_config.id,
274
+ task_type="preview_creative",
275
+ timestamp=datetime.now(timezone.utc).isoformat(),
276
+ )
277
+ )
278
+
279
+ raw_result = await self.adapter.preview_creative(params) # type: ignore[attr-defined]
280
+
281
+ self._emit_activity(
282
+ Activity(
283
+ type=ActivityType.PROTOCOL_RESPONSE,
284
+ operation_id=operation_id,
285
+ agent_id=self.agent_config.id,
286
+ task_type="preview_creative",
287
+ status=raw_result.status,
288
+ timestamp=datetime.now(timezone.utc).isoformat(),
289
+ )
290
+ )
291
+
292
+ return self.adapter._parse_response(raw_result, PreviewCreativeResponse)
293
+
294
+ async def sync_creatives(
295
+ self,
296
+ request: SyncCreativesRequest,
297
+ ) -> TaskResult[SyncCreativesResponse]:
298
+ """
299
+ Sync Creatives.
300
+
301
+ Args:
302
+ request: Request parameters
303
+
304
+ Returns:
305
+ TaskResult containing SyncCreativesResponse
306
+ """
307
+ operation_id = create_operation_id()
308
+ params = request.model_dump(exclude_none=True)
309
+
310
+ self._emit_activity(
311
+ Activity(
312
+ type=ActivityType.PROTOCOL_REQUEST,
313
+ operation_id=operation_id,
314
+ agent_id=self.agent_config.id,
315
+ task_type="sync_creatives",
316
+ timestamp=datetime.now(timezone.utc).isoformat(),
317
+ )
318
+ )
319
+
320
+ raw_result = await self.adapter.sync_creatives(params)
321
+
322
+ self._emit_activity(
323
+ Activity(
324
+ type=ActivityType.PROTOCOL_RESPONSE,
325
+ operation_id=operation_id,
326
+ agent_id=self.agent_config.id,
327
+ task_type="sync_creatives",
328
+ status=raw_result.status,
329
+ timestamp=datetime.now(timezone.utc).isoformat(),
330
+ )
331
+ )
332
+
333
+ return self.adapter._parse_response(raw_result, SyncCreativesResponse)
334
+
335
+ async def list_creatives(
336
+ self,
337
+ request: ListCreativesRequest,
338
+ ) -> TaskResult[ListCreativesResponse]:
339
+ """
340
+ List Creatives.
341
+
342
+ Args:
343
+ request: Request parameters
344
+
345
+ Returns:
346
+ TaskResult containing ListCreativesResponse
347
+ """
348
+ operation_id = create_operation_id()
349
+ params = request.model_dump(exclude_none=True)
350
+
351
+ self._emit_activity(
352
+ Activity(
353
+ type=ActivityType.PROTOCOL_REQUEST,
354
+ operation_id=operation_id,
355
+ agent_id=self.agent_config.id,
356
+ task_type="list_creatives",
357
+ timestamp=datetime.now(timezone.utc).isoformat(),
358
+ )
359
+ )
360
+
361
+ raw_result = await self.adapter.list_creatives(params)
362
+
363
+ self._emit_activity(
364
+ Activity(
365
+ type=ActivityType.PROTOCOL_RESPONSE,
366
+ operation_id=operation_id,
367
+ agent_id=self.agent_config.id,
368
+ task_type="list_creatives",
369
+ status=raw_result.status,
370
+ timestamp=datetime.now(timezone.utc).isoformat(),
371
+ )
372
+ )
373
+
374
+ return self.adapter._parse_response(raw_result, ListCreativesResponse)
375
+
376
+ async def get_media_buy_delivery(
377
+ self,
378
+ request: GetMediaBuyDeliveryRequest,
379
+ ) -> TaskResult[GetMediaBuyDeliveryResponse]:
380
+ """
381
+ Get Media Buy Delivery.
382
+
383
+ Args:
384
+ request: Request parameters
385
+
386
+ Returns:
387
+ TaskResult containing GetMediaBuyDeliveryResponse
388
+ """
389
+ operation_id = create_operation_id()
390
+ params = request.model_dump(exclude_none=True)
391
+
392
+ self._emit_activity(
393
+ Activity(
394
+ type=ActivityType.PROTOCOL_REQUEST,
395
+ operation_id=operation_id,
396
+ agent_id=self.agent_config.id,
397
+ task_type="get_media_buy_delivery",
398
+ timestamp=datetime.now(timezone.utc).isoformat(),
399
+ )
400
+ )
401
+
402
+ raw_result = await self.adapter.get_media_buy_delivery(params)
403
+
404
+ self._emit_activity(
405
+ Activity(
406
+ type=ActivityType.PROTOCOL_RESPONSE,
407
+ operation_id=operation_id,
408
+ agent_id=self.agent_config.id,
409
+ task_type="get_media_buy_delivery",
410
+ status=raw_result.status,
411
+ timestamp=datetime.now(timezone.utc).isoformat(),
412
+ )
413
+ )
414
+
415
+ return self.adapter._parse_response(raw_result, GetMediaBuyDeliveryResponse)
416
+
417
+ async def list_authorized_properties(
418
+ self,
419
+ request: ListAuthorizedPropertiesRequest,
420
+ ) -> TaskResult[ListAuthorizedPropertiesResponse]:
421
+ """
422
+ List Authorized Properties.
423
+
424
+ Args:
425
+ request: Request parameters
426
+
427
+ Returns:
428
+ TaskResult containing ListAuthorizedPropertiesResponse
429
+ """
430
+ operation_id = create_operation_id()
431
+ params = request.model_dump(exclude_none=True)
432
+
433
+ self._emit_activity(
434
+ Activity(
435
+ type=ActivityType.PROTOCOL_REQUEST,
436
+ operation_id=operation_id,
437
+ agent_id=self.agent_config.id,
438
+ task_type="list_authorized_properties",
439
+ timestamp=datetime.now(timezone.utc).isoformat(),
440
+ )
441
+ )
442
+
443
+ raw_result = await self.adapter.list_authorized_properties(params)
444
+
445
+ self._emit_activity(
446
+ Activity(
447
+ type=ActivityType.PROTOCOL_RESPONSE,
448
+ operation_id=operation_id,
449
+ agent_id=self.agent_config.id,
450
+ task_type="list_authorized_properties",
451
+ status=raw_result.status,
452
+ timestamp=datetime.now(timezone.utc).isoformat(),
453
+ )
454
+ )
455
+
456
+ return self.adapter._parse_response(raw_result, ListAuthorizedPropertiesResponse)
457
+
458
+ async def get_signals(
459
+ self,
460
+ request: GetSignalsRequest,
461
+ ) -> TaskResult[GetSignalsResponse]:
462
+ """
463
+ Get Signals.
464
+
465
+ Args:
466
+ request: Request parameters
467
+
468
+ Returns:
469
+ TaskResult containing GetSignalsResponse
470
+ """
471
+ operation_id = create_operation_id()
472
+ params = request.model_dump(exclude_none=True)
473
+
474
+ self._emit_activity(
475
+ Activity(
476
+ type=ActivityType.PROTOCOL_REQUEST,
477
+ operation_id=operation_id,
478
+ agent_id=self.agent_config.id,
479
+ task_type="get_signals",
480
+ timestamp=datetime.now(timezone.utc).isoformat(),
481
+ )
482
+ )
483
+
484
+ raw_result = await self.adapter.get_signals(params)
485
+
486
+ self._emit_activity(
487
+ Activity(
488
+ type=ActivityType.PROTOCOL_RESPONSE,
489
+ operation_id=operation_id,
490
+ agent_id=self.agent_config.id,
491
+ task_type="get_signals",
492
+ status=raw_result.status,
493
+ timestamp=datetime.now(timezone.utc).isoformat(),
494
+ )
495
+ )
496
+
497
+ return self.adapter._parse_response(raw_result, GetSignalsResponse)
498
+
499
+ async def activate_signal(
500
+ self,
501
+ request: ActivateSignalRequest,
502
+ ) -> TaskResult[ActivateSignalResponse]:
503
+ """
504
+ Activate Signal.
505
+
506
+ Args:
507
+ request: Request parameters
508
+
509
+ Returns:
510
+ TaskResult containing ActivateSignalResponse
511
+ """
512
+ operation_id = create_operation_id()
513
+ params = request.model_dump(exclude_none=True)
514
+
515
+ self._emit_activity(
516
+ Activity(
517
+ type=ActivityType.PROTOCOL_REQUEST,
518
+ operation_id=operation_id,
519
+ agent_id=self.agent_config.id,
520
+ task_type="activate_signal",
521
+ timestamp=datetime.now(timezone.utc).isoformat(),
522
+ )
523
+ )
524
+
525
+ raw_result = await self.adapter.activate_signal(params)
526
+
527
+ self._emit_activity(
528
+ Activity(
529
+ type=ActivityType.PROTOCOL_RESPONSE,
530
+ operation_id=operation_id,
531
+ agent_id=self.agent_config.id,
532
+ task_type="activate_signal",
533
+ status=raw_result.status,
534
+ timestamp=datetime.now(timezone.utc).isoformat(),
535
+ )
536
+ )
537
+
538
+ return self.adapter._parse_response(raw_result, ActivateSignalResponse)
539
+
540
+ async def provide_performance_feedback(
541
+ self,
542
+ request: ProvidePerformanceFeedbackRequest,
543
+ ) -> TaskResult[ProvidePerformanceFeedbackResponse]:
544
+ """
545
+ Provide Performance Feedback.
546
+
547
+ Args:
548
+ request: Request parameters
549
+
550
+ Returns:
551
+ TaskResult containing ProvidePerformanceFeedbackResponse
552
+ """
553
+ operation_id = create_operation_id()
554
+ params = request.model_dump(exclude_none=True)
555
+
556
+ self._emit_activity(
557
+ Activity(
558
+ type=ActivityType.PROTOCOL_REQUEST,
559
+ operation_id=operation_id,
560
+ agent_id=self.agent_config.id,
561
+ task_type="provide_performance_feedback",
562
+ timestamp=datetime.now(timezone.utc).isoformat(),
563
+ )
564
+ )
565
+
566
+ raw_result = await self.adapter.provide_performance_feedback(params)
567
+
568
+ self._emit_activity(
569
+ Activity(
570
+ type=ActivityType.PROTOCOL_RESPONSE,
571
+ operation_id=operation_id,
572
+ agent_id=self.agent_config.id,
573
+ task_type="provide_performance_feedback",
574
+ status=raw_result.status,
575
+ timestamp=datetime.now(timezone.utc).isoformat(),
576
+ )
577
+ )
578
+
579
+ return self.adapter._parse_response(raw_result, ProvidePerformanceFeedbackResponse)
580
+
581
+ async def create_media_buy(
582
+ self,
583
+ request: CreateMediaBuyRequest,
584
+ ) -> TaskResult[CreateMediaBuyResponse]:
585
+ """
586
+ Create a new media buy reservation.
587
+
588
+ Requests the agent to reserve inventory for a campaign. The agent returns a
589
+ media_buy_id that tracks this reservation and can be used for updates.
590
+
591
+ Args:
592
+ request: Media buy creation parameters including:
593
+ - brand_manifest: Advertiser brand information and creative assets
594
+ - packages: List of package requests specifying desired inventory
595
+ - publisher_properties: Target properties for ad placement
596
+ - budget: Optional budget constraints
597
+ - start_date/end_date: Campaign flight dates
598
+
599
+ Returns:
600
+ TaskResult containing CreateMediaBuyResponse with:
601
+ - media_buy_id: Unique identifier for this reservation
602
+ - status: Current state of the media buy
603
+ - packages: Confirmed package details
604
+ - Additional platform-specific metadata
605
+
606
+ Example:
607
+ >>> from adcp import ADCPClient, CreateMediaBuyRequest
608
+ >>> client = ADCPClient(agent_config)
609
+ >>> request = CreateMediaBuyRequest(
610
+ ... brand_manifest=brand,
611
+ ... packages=[package_request],
612
+ ... publisher_properties=properties
613
+ ... )
614
+ >>> result = await client.create_media_buy(request)
615
+ >>> if result.success:
616
+ ... media_buy_id = result.data.media_buy_id
617
+ """
618
+ operation_id = create_operation_id()
619
+ params = request.model_dump(exclude_none=True)
620
+
621
+ self._emit_activity(
622
+ Activity(
623
+ type=ActivityType.PROTOCOL_REQUEST,
624
+ operation_id=operation_id,
625
+ agent_id=self.agent_config.id,
626
+ task_type="create_media_buy",
627
+ timestamp=datetime.now(timezone.utc).isoformat(),
628
+ )
629
+ )
630
+
631
+ raw_result = await self.adapter.create_media_buy(params)
632
+
633
+ self._emit_activity(
634
+ Activity(
635
+ type=ActivityType.PROTOCOL_RESPONSE,
636
+ operation_id=operation_id,
637
+ agent_id=self.agent_config.id,
638
+ task_type="create_media_buy",
639
+ status=raw_result.status,
640
+ timestamp=datetime.now(timezone.utc).isoformat(),
641
+ )
642
+ )
643
+
644
+ return self.adapter._parse_response(raw_result, CreateMediaBuyResponse)
645
+
646
+ async def update_media_buy(
647
+ self,
648
+ request: UpdateMediaBuyRequest,
649
+ ) -> TaskResult[UpdateMediaBuyResponse]:
650
+ """
651
+ Update an existing media buy reservation.
652
+
653
+ Modifies a previously created media buy by updating packages or publisher
654
+ properties. The update operation uses discriminated unions to specify what
655
+ to change - either package details or targeting properties.
656
+
657
+ Args:
658
+ request: Media buy update parameters including:
659
+ - media_buy_id: Identifier from create_media_buy response
660
+ - updates: Discriminated union specifying update type:
661
+ * UpdateMediaBuyPackagesRequest: Modify package selections
662
+ * UpdateMediaBuyPropertiesRequest: Change targeting properties
663
+
664
+ Returns:
665
+ TaskResult containing UpdateMediaBuyResponse with:
666
+ - media_buy_id: The updated media buy identifier
667
+ - status: Updated state of the media buy
668
+ - packages: Updated package configurations
669
+ - Additional platform-specific metadata
670
+
671
+ Example:
672
+ >>> from adcp import ADCPClient, UpdateMediaBuyPackagesRequest
673
+ >>> client = ADCPClient(agent_config)
674
+ >>> request = UpdateMediaBuyPackagesRequest(
675
+ ... media_buy_id="mb_123",
676
+ ... packages=[updated_package]
677
+ ... )
678
+ >>> result = await client.update_media_buy(request)
679
+ >>> if result.success:
680
+ ... updated_packages = result.data.packages
681
+ """
682
+ operation_id = create_operation_id()
683
+ params = request.model_dump(exclude_none=True)
684
+
685
+ self._emit_activity(
686
+ Activity(
687
+ type=ActivityType.PROTOCOL_REQUEST,
688
+ operation_id=operation_id,
689
+ agent_id=self.agent_config.id,
690
+ task_type="update_media_buy",
691
+ timestamp=datetime.now(timezone.utc).isoformat(),
692
+ )
693
+ )
694
+
695
+ raw_result = await self.adapter.update_media_buy(params)
696
+
697
+ self._emit_activity(
698
+ Activity(
699
+ type=ActivityType.PROTOCOL_RESPONSE,
700
+ operation_id=operation_id,
701
+ agent_id=self.agent_config.id,
702
+ task_type="update_media_buy",
703
+ status=raw_result.status,
704
+ timestamp=datetime.now(timezone.utc).isoformat(),
705
+ )
706
+ )
707
+
708
+ return self.adapter._parse_response(raw_result, UpdateMediaBuyResponse)
709
+
710
+ async def build_creative(
711
+ self,
712
+ request: BuildCreativeRequest,
713
+ ) -> TaskResult[BuildCreativeResponse]:
714
+ """
715
+ Generate production-ready creative assets.
716
+
717
+ Requests the creative agent to build final deliverable assets in the target
718
+ format (e.g., VAST, DAAST, HTML5). This is typically called after previewing
719
+ and approving a creative manifest.
720
+
721
+ Args:
722
+ request: Creative build parameters including:
723
+ - manifest: Creative manifest with brand info and content
724
+ - target_format_id: Desired output format identifier
725
+ - inputs: Optional user-provided inputs for template variables
726
+ - deployment: Platform or agent deployment configuration
727
+
728
+ Returns:
729
+ TaskResult containing BuildCreativeResponse with:
730
+ - assets: Production-ready creative files (URLs or inline content)
731
+ - format_id: The generated format identifier
732
+ - manifest: The creative manifest used for generation
733
+ - metadata: Additional platform-specific details
734
+
735
+ Example:
736
+ >>> from adcp import ADCPClient, BuildCreativeRequest
737
+ >>> client = ADCPClient(agent_config)
738
+ >>> request = BuildCreativeRequest(
739
+ ... manifest=creative_manifest,
740
+ ... target_format_id="vast_2.0",
741
+ ... inputs={"duration": 30}
742
+ ... )
743
+ >>> result = await client.build_creative(request)
744
+ >>> if result.success:
745
+ ... vast_url = result.data.assets[0].url
746
+ """
747
+ operation_id = create_operation_id()
748
+ params = request.model_dump(exclude_none=True)
749
+
750
+ self._emit_activity(
751
+ Activity(
752
+ type=ActivityType.PROTOCOL_REQUEST,
753
+ operation_id=operation_id,
754
+ agent_id=self.agent_config.id,
755
+ task_type="build_creative",
756
+ timestamp=datetime.now(timezone.utc).isoformat(),
757
+ )
758
+ )
759
+
760
+ raw_result = await self.adapter.build_creative(params)
761
+
762
+ self._emit_activity(
763
+ Activity(
764
+ type=ActivityType.PROTOCOL_RESPONSE,
765
+ operation_id=operation_id,
766
+ agent_id=self.agent_config.id,
767
+ task_type="build_creative",
768
+ status=raw_result.status,
769
+ timestamp=datetime.now(timezone.utc).isoformat(),
770
+ )
771
+ )
772
+
773
+ return self.adapter._parse_response(raw_result, BuildCreativeResponse)
774
+
775
+ async def list_tools(self) -> list[str]:
776
+ """
777
+ List available tools from the agent.
778
+
779
+ Returns:
780
+ List of tool names
781
+ """
782
+ return await self.adapter.list_tools()
783
+
784
+ async def get_info(self) -> dict[str, Any]:
785
+ """
786
+ Get agent information including AdCP extension metadata.
787
+
788
+ Returns agent card information including:
789
+ - Agent name, description, version
790
+ - Protocol type (mcp or a2a)
791
+ - AdCP version (from extensions.adcp.adcp_version)
792
+ - Supported protocols (from extensions.adcp.protocols_supported)
793
+ - Available tools/skills
794
+
795
+ Returns:
796
+ Dictionary with agent metadata
797
+ """
798
+ return await self.adapter.get_agent_info()
799
+
800
+ async def close(self) -> None:
801
+ """Close the adapter and clean up resources."""
802
+ if hasattr(self.adapter, "close"):
803
+ logger.debug(f"Closing adapter for agent {self.agent_config.id}")
804
+ await self.adapter.close()
805
+
806
+ async def __aenter__(self) -> ADCPClient:
807
+ """Async context manager entry."""
808
+ return self
809
+
810
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
811
+ """Async context manager exit."""
812
+ await self.close()
813
+
814
+ def _verify_webhook_signature(self, payload: dict[str, Any], signature: str) -> bool:
815
+ """
816
+ Verify HMAC-SHA256 signature of webhook payload.
817
+
818
+ Args:
819
+ payload: Webhook payload dict
820
+ signature: Signature to verify
821
+
822
+ Returns:
823
+ True if signature is valid, False otherwise
824
+ """
825
+ if not self.webhook_secret:
826
+ return True
827
+
828
+ payload_bytes = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
829
+ expected_signature = hmac.new(
830
+ self.webhook_secret.encode("utf-8"), payload_bytes, hashlib.sha256
831
+ ).hexdigest()
832
+
833
+ return hmac.compare_digest(signature, expected_signature)
834
+
835
+ def _parse_webhook_result(self, webhook: WebhookPayload) -> TaskResult[Any]:
836
+ """
837
+ Parse webhook payload into typed TaskResult based on task_type.
838
+
839
+ Args:
840
+ webhook: Validated webhook payload
841
+
842
+ Returns:
843
+ TaskResult with task-specific typed response data
844
+ """
845
+ from adcp.utils.response_parser import parse_json_or_text
846
+
847
+ # Map task types to their response types (using string literals, not enum)
848
+ # Note: Some response types are Union types (e.g., ActivateSignalResponse = Success | Error)
849
+ response_type_map: dict[str, type[BaseModel] | Any] = {
850
+ "get_products": GetProductsResponse,
851
+ "list_creative_formats": ListCreativeFormatsResponse,
852
+ "sync_creatives": SyncCreativesResponse, # Union type
853
+ "list_creatives": ListCreativesResponse,
854
+ "get_media_buy_delivery": GetMediaBuyDeliveryResponse,
855
+ "list_authorized_properties": ListAuthorizedPropertiesResponse,
856
+ "get_signals": GetSignalsResponse,
857
+ "activate_signal": ActivateSignalResponse, # Union type
858
+ "provide_performance_feedback": ProvidePerformanceFeedbackResponse,
859
+ }
860
+
861
+ # Handle completed tasks with result parsing
862
+
863
+ if webhook.status == GeneratedTaskStatus.completed and webhook.result is not None:
864
+ response_type = response_type_map.get(webhook.task_type.value)
865
+ if response_type:
866
+ try:
867
+ parsed_result: Any = parse_json_or_text(webhook.result, response_type)
868
+ return TaskResult[Any](
869
+ status=TaskStatus.COMPLETED,
870
+ data=parsed_result,
871
+ success=True,
872
+ metadata={
873
+ "task_id": webhook.task_id,
874
+ "operation_id": webhook.operation_id,
875
+ "timestamp": webhook.timestamp,
876
+ "message": webhook.message,
877
+ },
878
+ )
879
+ except ValueError as e:
880
+ logger.warning(f"Failed to parse webhook result: {e}")
881
+ # Fall through to untyped result
882
+
883
+ # Handle failed, input-required, or unparseable results
884
+ # Convert webhook status to core TaskStatus enum
885
+ # Map generated enum values to core enum values
886
+ status_map = {
887
+ GeneratedTaskStatus.completed: TaskStatus.COMPLETED,
888
+ GeneratedTaskStatus.submitted: TaskStatus.SUBMITTED,
889
+ GeneratedTaskStatus.working: TaskStatus.WORKING,
890
+ GeneratedTaskStatus.failed: TaskStatus.FAILED,
891
+ GeneratedTaskStatus.input_required: TaskStatus.NEEDS_INPUT,
892
+ }
893
+ task_status = status_map.get(webhook.status, TaskStatus.FAILED)
894
+
895
+ return TaskResult[Any](
896
+ status=task_status,
897
+ data=webhook.result,
898
+ success=webhook.status == GeneratedTaskStatus.completed,
899
+ error=webhook.error if isinstance(webhook.error, str) else None,
900
+ metadata={
901
+ "task_id": webhook.task_id,
902
+ "operation_id": webhook.operation_id,
903
+ "timestamp": webhook.timestamp,
904
+ "message": webhook.message,
905
+ "context_id": webhook.context_id,
906
+ "progress": webhook.progress,
907
+ },
908
+ )
909
+
910
+ async def handle_webhook(
911
+ self,
912
+ payload: dict[str, Any],
913
+ signature: str | None = None,
914
+ ) -> TaskResult[Any]:
915
+ """
916
+ Handle incoming webhook and return typed result.
917
+
918
+ This method:
919
+ 1. Verifies webhook signature (if provided)
920
+ 2. Validates payload against WebhookPayload schema
921
+ 3. Parses task-specific result data into typed response
922
+ 4. Emits activity for monitoring
923
+
924
+ Args:
925
+ payload: Webhook payload dict
926
+ signature: Optional HMAC-SHA256 signature for verification
927
+
928
+ Returns:
929
+ TaskResult with parsed task-specific response data
930
+
931
+ Raises:
932
+ ADCPWebhookSignatureError: If signature verification fails
933
+ ValidationError: If payload doesn't match WebhookPayload schema
934
+
935
+ Example:
936
+ >>> result = await client.handle_webhook(payload, signature)
937
+ >>> if result.success and isinstance(result.data, GetProductsResponse):
938
+ >>> print(f"Found {len(result.data.products)} products")
939
+ """
940
+ # Verify signature before processing
941
+ if signature and not self._verify_webhook_signature(payload, signature):
942
+ logger.warning(
943
+ f"Webhook signature verification failed for agent {self.agent_config.id}"
944
+ )
945
+ raise ADCPWebhookSignatureError("Invalid webhook signature")
946
+
947
+ # Validate and parse webhook payload
948
+ webhook = WebhookPayload.model_validate(payload)
949
+
950
+ # Emit activity for monitoring
951
+ self._emit_activity(
952
+ Activity(
953
+ type=ActivityType.WEBHOOK_RECEIVED,
954
+ operation_id=webhook.operation_id or "unknown",
955
+ agent_id=self.agent_config.id,
956
+ task_type=webhook.task_type.value,
957
+ timestamp=datetime.now(timezone.utc).isoformat(),
958
+ metadata={"payload": payload},
959
+ )
960
+ )
961
+
962
+ # Parse and return typed result
963
+ return self._parse_webhook_result(webhook)
964
+
965
+
966
+ class ADCPMultiAgentClient:
967
+ """Client for managing multiple AdCP agents."""
968
+
969
+ def __init__(
970
+ self,
971
+ agents: list[AgentConfig],
972
+ webhook_url_template: str | None = None,
973
+ webhook_secret: str | None = None,
974
+ on_activity: Callable[[Activity], None] | None = None,
975
+ handlers: dict[str, Callable[..., Any]] | None = None,
976
+ ):
977
+ """
978
+ Initialize multi-agent client.
979
+
980
+ Args:
981
+ agents: List of agent configurations
982
+ webhook_url_template: Template for webhook URLs
983
+ webhook_secret: Secret for webhook verification
984
+ on_activity: Callback for activity events
985
+ handlers: Task completion handlers
986
+ """
987
+ self.agents = {
988
+ agent.id: ADCPClient(
989
+ agent,
990
+ webhook_url_template=webhook_url_template,
991
+ webhook_secret=webhook_secret,
992
+ on_activity=on_activity,
993
+ )
994
+ for agent in agents
995
+ }
996
+ self.handlers = handlers or {}
997
+
998
+ def agent(self, agent_id: str) -> ADCPClient:
999
+ """Get client for specific agent."""
1000
+ if agent_id not in self.agents:
1001
+ raise ValueError(f"Agent not found: {agent_id}")
1002
+ return self.agents[agent_id]
1003
+
1004
+ @property
1005
+ def agent_ids(self) -> list[str]:
1006
+ """Get list of agent IDs."""
1007
+ return list(self.agents.keys())
1008
+
1009
+ async def close(self) -> None:
1010
+ """Close all agent clients and clean up resources."""
1011
+ import asyncio
1012
+
1013
+ logger.debug("Closing all agent clients in multi-agent client")
1014
+ close_tasks = [client.close() for client in self.agents.values()]
1015
+ await asyncio.gather(*close_tasks, return_exceptions=True)
1016
+
1017
+ async def __aenter__(self) -> ADCPMultiAgentClient:
1018
+ """Async context manager entry."""
1019
+ return self
1020
+
1021
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
1022
+ """Async context manager exit."""
1023
+ await self.close()
1024
+
1025
+ async def get_products(
1026
+ self,
1027
+ request: GetProductsRequest,
1028
+ ) -> list[TaskResult[GetProductsResponse]]:
1029
+ """
1030
+ Execute get_products across all agents in parallel.
1031
+
1032
+ Args:
1033
+ request: Request parameters
1034
+
1035
+ Returns:
1036
+ List of TaskResults containing GetProductsResponse for each agent
1037
+ """
1038
+ import asyncio
1039
+
1040
+ tasks = [agent.get_products(request) for agent in self.agents.values()]
1041
+ return await asyncio.gather(*tasks)
1042
+
1043
+ @classmethod
1044
+ def from_env(cls) -> ADCPMultiAgentClient:
1045
+ """Create client from environment variables."""
1046
+ agents_json = os.getenv("ADCP_AGENTS")
1047
+ if not agents_json:
1048
+ raise ValueError("ADCP_AGENTS environment variable not set")
1049
+
1050
+ agents_data = json.loads(agents_json)
1051
+ agents = [AgentConfig(**agent) for agent in agents_data]
1052
+
1053
+ return cls(
1054
+ agents=agents,
1055
+ webhook_url_template=os.getenv("WEBHOOK_URL_TEMPLATE"),
1056
+ webhook_secret=os.getenv("WEBHOOK_SECRET"),
1057
+ )