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.
- adcp/__init__.py +364 -0
- adcp/__main__.py +440 -0
- adcp/adagents.py +642 -0
- adcp/client.py +1057 -0
- adcp/config.py +82 -0
- adcp/exceptions.py +185 -0
- adcp/protocols/__init__.py +9 -0
- adcp/protocols/a2a.py +484 -0
- adcp/protocols/base.py +190 -0
- adcp/protocols/mcp.py +440 -0
- adcp/py.typed +0 -0
- adcp/simple.py +451 -0
- adcp/testing/__init__.py +53 -0
- adcp/testing/test_helpers.py +311 -0
- adcp/types/__init__.py +561 -0
- adcp/types/_generated.py +237 -0
- adcp/types/aliases.py +748 -0
- adcp/types/base.py +26 -0
- adcp/types/core.py +174 -0
- adcp/types/generated_poc/__init__.py +3 -0
- adcp/types/generated_poc/adagents.py +411 -0
- adcp/types/generated_poc/core/__init__.py +3 -0
- adcp/types/generated_poc/core/activation_key.py +30 -0
- adcp/types/generated_poc/core/assets/__init__.py +3 -0
- adcp/types/generated_poc/core/assets/audio_asset.py +26 -0
- adcp/types/generated_poc/core/assets/css_asset.py +20 -0
- adcp/types/generated_poc/core/assets/daast_asset.py +61 -0
- adcp/types/generated_poc/core/assets/html_asset.py +18 -0
- adcp/types/generated_poc/core/assets/image_asset.py +19 -0
- adcp/types/generated_poc/core/assets/javascript_asset.py +23 -0
- adcp/types/generated_poc/core/assets/text_asset.py +20 -0
- adcp/types/generated_poc/core/assets/url_asset.py +28 -0
- adcp/types/generated_poc/core/assets/vast_asset.py +63 -0
- adcp/types/generated_poc/core/assets/video_asset.py +24 -0
- adcp/types/generated_poc/core/assets/webhook_asset.py +53 -0
- adcp/types/generated_poc/core/brand_manifest.py +201 -0
- adcp/types/generated_poc/core/context.py +15 -0
- adcp/types/generated_poc/core/creative_asset.py +102 -0
- adcp/types/generated_poc/core/creative_assignment.py +27 -0
- adcp/types/generated_poc/core/creative_filters.py +86 -0
- adcp/types/generated_poc/core/creative_manifest.py +68 -0
- adcp/types/generated_poc/core/creative_policy.py +28 -0
- adcp/types/generated_poc/core/delivery_metrics.py +111 -0
- adcp/types/generated_poc/core/deployment.py +78 -0
- adcp/types/generated_poc/core/destination.py +43 -0
- adcp/types/generated_poc/core/dimensions.py +18 -0
- adcp/types/generated_poc/core/error.py +29 -0
- adcp/types/generated_poc/core/ext.py +15 -0
- adcp/types/generated_poc/core/format.py +260 -0
- adcp/types/generated_poc/core/format_id.py +50 -0
- adcp/types/generated_poc/core/frequency_cap.py +19 -0
- adcp/types/generated_poc/core/measurement.py +40 -0
- adcp/types/generated_poc/core/media_buy.py +40 -0
- adcp/types/generated_poc/core/package.py +68 -0
- adcp/types/generated_poc/core/performance_feedback.py +78 -0
- adcp/types/generated_poc/core/placement.py +37 -0
- adcp/types/generated_poc/core/product.py +164 -0
- adcp/types/generated_poc/core/product_filters.py +97 -0
- adcp/types/generated_poc/core/promoted_offerings.py +102 -0
- adcp/types/generated_poc/core/promoted_products.py +38 -0
- adcp/types/generated_poc/core/property.py +64 -0
- adcp/types/generated_poc/core/property_id.py +21 -0
- adcp/types/generated_poc/core/property_tag.py +21 -0
- adcp/types/generated_poc/core/protocol_envelope.py +61 -0
- adcp/types/generated_poc/core/publisher_property_selector.py +75 -0
- adcp/types/generated_poc/core/push_notification_config.py +51 -0
- adcp/types/generated_poc/core/reporting_capabilities.py +51 -0
- adcp/types/generated_poc/core/response.py +24 -0
- adcp/types/generated_poc/core/signal_filters.py +29 -0
- adcp/types/generated_poc/core/sub_asset.py +55 -0
- adcp/types/generated_poc/core/targeting.py +53 -0
- adcp/types/generated_poc/core/webhook_payload.py +96 -0
- adcp/types/generated_poc/creative/__init__.py +3 -0
- adcp/types/generated_poc/creative/list_creative_formats_request.py +88 -0
- adcp/types/generated_poc/creative/list_creative_formats_response.py +55 -0
- adcp/types/generated_poc/creative/preview_creative_request.py +153 -0
- adcp/types/generated_poc/creative/preview_creative_response.py +169 -0
- adcp/types/generated_poc/creative/preview_render.py +152 -0
- adcp/types/generated_poc/enums/__init__.py +3 -0
- adcp/types/generated_poc/enums/adcp_domain.py +12 -0
- adcp/types/generated_poc/enums/asset_content_type.py +23 -0
- adcp/types/generated_poc/enums/auth_scheme.py +12 -0
- adcp/types/generated_poc/enums/available_metric.py +19 -0
- adcp/types/generated_poc/enums/channels.py +19 -0
- adcp/types/generated_poc/enums/co_branding_requirement.py +13 -0
- adcp/types/generated_poc/enums/creative_action.py +15 -0
- adcp/types/generated_poc/enums/creative_agent_capability.py +14 -0
- adcp/types/generated_poc/enums/creative_sort_field.py +16 -0
- adcp/types/generated_poc/enums/creative_status.py +14 -0
- adcp/types/generated_poc/enums/daast_tracking_event.py +21 -0
- adcp/types/generated_poc/enums/daast_version.py +12 -0
- adcp/types/generated_poc/enums/delivery_type.py +12 -0
- adcp/types/generated_poc/enums/dimension_unit.py +14 -0
- adcp/types/generated_poc/enums/feed_format.py +13 -0
- adcp/types/generated_poc/enums/feedback_source.py +14 -0
- adcp/types/generated_poc/enums/format_category.py +17 -0
- adcp/types/generated_poc/enums/format_id_parameter.py +12 -0
- adcp/types/generated_poc/enums/frequency_cap_scope.py +16 -0
- adcp/types/generated_poc/enums/history_entry_type.py +12 -0
- adcp/types/generated_poc/enums/http_method.py +12 -0
- adcp/types/generated_poc/enums/identifier_types.py +29 -0
- adcp/types/generated_poc/enums/javascript_module_type.py +13 -0
- adcp/types/generated_poc/enums/landing_page_requirement.py +13 -0
- adcp/types/generated_poc/enums/markdown_flavor.py +12 -0
- adcp/types/generated_poc/enums/media_buy_status.py +14 -0
- adcp/types/generated_poc/enums/metric_type.py +18 -0
- adcp/types/generated_poc/enums/notification_type.py +14 -0
- adcp/types/generated_poc/enums/pacing.py +13 -0
- adcp/types/generated_poc/enums/preview_output_format.py +12 -0
- adcp/types/generated_poc/enums/pricing_model.py +17 -0
- adcp/types/generated_poc/enums/property_type.py +17 -0
- adcp/types/generated_poc/enums/publisher_identifier_types.py +15 -0
- adcp/types/generated_poc/enums/reporting_frequency.py +13 -0
- adcp/types/generated_poc/enums/signal_catalog_type.py +13 -0
- adcp/types/generated_poc/enums/sort_direction.py +12 -0
- adcp/types/generated_poc/enums/standard_format_ids.py +45 -0
- adcp/types/generated_poc/enums/task_status.py +19 -0
- adcp/types/generated_poc/enums/task_type.py +15 -0
- adcp/types/generated_poc/enums/update_frequency.py +14 -0
- adcp/types/generated_poc/enums/url_asset_type.py +13 -0
- adcp/types/generated_poc/enums/validation_mode.py +12 -0
- adcp/types/generated_poc/enums/vast_tracking_event.py +26 -0
- adcp/types/generated_poc/enums/vast_version.py +15 -0
- adcp/types/generated_poc/enums/webhook_response_type.py +14 -0
- adcp/types/generated_poc/enums/webhook_security_method.py +13 -0
- adcp/types/generated_poc/media_buy/__init__.py +3 -0
- adcp/types/generated_poc/media_buy/build_creative_request.py +41 -0
- adcp/types/generated_poc/media_buy/build_creative_response.py +51 -0
- adcp/types/generated_poc/media_buy/create_media_buy_request.py +94 -0
- adcp/types/generated_poc/media_buy/create_media_buy_response.py +56 -0
- adcp/types/generated_poc/media_buy/get_media_buy_delivery_request.py +47 -0
- adcp/types/generated_poc/media_buy/get_media_buy_delivery_response.py +235 -0
- adcp/types/generated_poc/media_buy/get_products_request.py +48 -0
- adcp/types/generated_poc/media_buy/get_products_response.py +28 -0
- adcp/types/generated_poc/media_buy/list_authorized_properties_request.py +38 -0
- adcp/types/generated_poc/media_buy/list_authorized_properties_response.py +84 -0
- adcp/types/generated_poc/media_buy/list_creative_formats_request.py +74 -0
- adcp/types/generated_poc/media_buy/list_creative_formats_response.py +56 -0
- adcp/types/generated_poc/media_buy/list_creatives_request.py +76 -0
- adcp/types/generated_poc/media_buy/list_creatives_response.py +214 -0
- adcp/types/generated_poc/media_buy/package_request.py +63 -0
- adcp/types/generated_poc/media_buy/provide_performance_feedback_request.py +125 -0
- adcp/types/generated_poc/media_buy/provide_performance_feedback_response.py +53 -0
- adcp/types/generated_poc/media_buy/sync_creatives_request.py +63 -0
- adcp/types/generated_poc/media_buy/sync_creatives_response.py +105 -0
- adcp/types/generated_poc/media_buy/update_media_buy_request.py +195 -0
- adcp/types/generated_poc/media_buy/update_media_buy_response.py +55 -0
- adcp/types/generated_poc/pricing_options/__init__.py +3 -0
- adcp/types/generated_poc/pricing_options/cpc_option.py +43 -0
- adcp/types/generated_poc/pricing_options/cpcv_option.py +45 -0
- adcp/types/generated_poc/pricing_options/cpm_auction_option.py +58 -0
- adcp/types/generated_poc/pricing_options/cpm_fixed_option.py +43 -0
- adcp/types/generated_poc/pricing_options/cpp_option.py +64 -0
- adcp/types/generated_poc/pricing_options/cpv_option.py +77 -0
- adcp/types/generated_poc/pricing_options/flat_rate_option.py +93 -0
- adcp/types/generated_poc/pricing_options/vcpm_auction_option.py +61 -0
- adcp/types/generated_poc/pricing_options/vcpm_fixed_option.py +47 -0
- adcp/types/generated_poc/protocols/__init__.py +3 -0
- adcp/types/generated_poc/protocols/adcp_extension.py +37 -0
- adcp/types/generated_poc/signals/__init__.py +3 -0
- adcp/types/generated_poc/signals/activate_signal_request.py +32 -0
- adcp/types/generated_poc/signals/activate_signal_response.py +51 -0
- adcp/types/generated_poc/signals/get_signals_request.py +53 -0
- adcp/types/generated_poc/signals/get_signals_response.py +59 -0
- adcp/utils/__init__.py +7 -0
- adcp/utils/operation_id.py +15 -0
- adcp/utils/preview_cache.py +491 -0
- adcp/utils/response_parser.py +171 -0
- adcp/validation.py +172 -0
- adcp-2.12.0.data/data/ADCP_VERSION +1 -0
- adcp-2.12.0.dist-info/METADATA +992 -0
- adcp-2.12.0.dist-info/RECORD +176 -0
- adcp-2.12.0.dist-info/WHEEL +5 -0
- adcp-2.12.0.dist-info/entry_points.txt +2 -0
- adcp-2.12.0.dist-info/licenses/LICENSE +17 -0
- 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
|