Module adcp.protocols.a2a
Classes
class A2AAdapter (agent_config: AgentConfig, force_a2a_version: str | None = None)-
Expand source code
class A2AAdapter(ProtocolAdapter): """Adapter for A2A protocol using the official a2a-sdk 1.0 client.""" # A2A task states in which the server is still expecting more from # the buyer on the same task (input-required, auth-required, and # in-flight states). While the adapter holds a task_id in one of # these states, the next outbound Message must echo it back so the # server resumes the same task rather than orphaning it and starting # a new one. Everything else — completed/failed/canceled/rejected # (terminal) and the defensive unknown state — clears the retained # task_id so subsequent calls start a fresh task. The frozenset # holds protobuf enum int values so a rename upstream is a load-time # error, not a silent behavior change. _NONTERMINAL_TASK_STATES: frozenset[int] = frozenset( { pb.TaskState.TASK_STATE_SUBMITTED, pb.TaskState.TASK_STATE_WORKING, pb.TaskState.TASK_STATE_INPUT_REQUIRED, pb.TaskState.TASK_STATE_AUTH_REQUIRED, } ) def __init__( self, agent_config: AgentConfig, force_a2a_version: str | None = None, ): """Initialize A2A adapter with official A2A client. ``force_a2a_version`` pins the A2A wire version by filtering the peer's advertised ``supported_interfaces`` to only entries whose ``protocol_version`` matches. Intended for tests or for forcing a 0.3-speaking path against a dual-advertising peer. Raises :class:`ADCPConnectionError` on first use if no advertised interface matches. ``None`` lets the SDK's ``ClientFactory`` pick the most capable transport the peer supports. """ super().__init__(agent_config) self._httpx_client: httpx.AsyncClient | None = None self._a2a_client: Client | None = None self._cached_agent_card: pb.AgentCard | None = None self._force_a2a_version = force_a2a_version # A2A contextId for multi-turn conversations. First request sends # context_id=None → server mints one and returns it on Task.context_id; # we stash it here and echo it back on every subsequent send so the # server can scope state to the same session. Callers can seed this # via ADCPClient(context_id=...) to resume a session across process # restarts, or clear it via ADCPClient.reset_context() to start a # new conversation. self._context_id: str | None = None # A2A task_id retained across turns only while the prior task is # non-terminal (input-required, working, etc). On terminal states # this clears to None so the next call starts a new task under # the same context_id. Without this, resume of an input-required # task orphans the server-side in-flight task. self._active_task_id: str | None = None @property def context_id(self) -> str | None: """Current A2A conversation context_id, or None if not yet established. ``None`` means either (a) a fresh conversation where the server has not yet replied, or (b) the context was cleared via ``set_context_id(None)``. Callers that need to distinguish these must track their own state. Not thread-safe: the adapter mutates this on every response. For concurrent use, serialize calls on one adapter or construct one per conversation. """ return self._context_id @property def active_task_id(self) -> str | None: """A2A task_id the next send must echo to resume the same task. Populated when the last response was non-terminal (e.g. ``input-required``, ``working``). Echoed on the next outbound message so the server continues the same task. Clears to None on terminal states (``completed``/``failed``/``canceled``/ ``rejected``) — and defensively on ``unknown`` — so subsequent calls start a fresh task under the same context. """ return self._active_task_id @property def a2a_protocol_versions(self) -> list[str] | None: """Sorted list of A2A ``protocol_version`` strings the peer advertises. Populated after the first call (or any operation that fetches the ``AgentCard`` — :meth:`list_tools`, :meth:`get_agent_info`, or an ``_call_a2a_tool`` invocation). Returns ``None`` before the card has been fetched so callers can distinguish "not yet known" from "peer advertises nothing" (empty list). Example:: client = ADCPClient(a2a_config) await client.adapter.get_agent_info() print(client.a2a_protocol_versions) # ['0.3', '1.0'] """ if self._cached_agent_card is None: return None return sorted( {iface.protocol_version for iface in self._cached_agent_card.supported_interfaces} ) def set_context_id(self, context_id: str | None) -> None: """Set the A2A context_id for subsequent message sends. Pass ``None`` to clear — the server mints a fresh id on the next call — or a string to seed. Seeding is safe for *resume* (pass back an id the server previously returned). Seeding with a *self-generated* id is server-dependent: per the A2A spec, agents MAY accept or reject client-supplied ids, and some frameworks (notably ADK) rewrite the id into their own session format and return the rewritten value on the next response — at which point this adapter auto-adopts it. Also clears any retained ``active_task_id``: switching context always starts a fresh task under the new context. """ self._context_id = context_id self._active_task_id = None def _restore_active_task_id(self, task_id: str) -> None: """Internal: rehydrate ``active_task_id`` from a persisted checkpoint. Separate from normal in-flight state updates so the checkpoint restore path is an explicit contract — a rename of the storage field fails loudly here instead of silently breaking resume. Intended for ``ADCPClient.from_checkpoint`` only. """ self._active_task_id = task_id async def _get_httpx_client(self) -> httpx.AsyncClient: """Get or create the HTTP client with connection pooling.""" if self._httpx_client is None: limits = httpx.Limits( max_keepalive_connections=10, max_connections=20, keepalive_expiry=30.0, ) headers = {} if self.agent_config.auth_token: if self.agent_config.auth_type == "bearer": headers["Authorization"] = f"Bearer {self.agent_config.auth_token}" else: headers[self.agent_config.auth_header] = self.agent_config.auth_token if self.agent_config.extra_headers: headers.update(self.agent_config.extra_headers) # When ADCPClient installed a signing_request_hook, register it as # an httpx request event hook so RFC 9421 signature headers are # attached transparently to every outgoing request. The hook is # a bound method, so each call reads the owning client's live # state (signing config, cached capabilities) — it is *not* a # snapshot. Out-of-band calls (e.g. agent-card fetch) no-op # inside the hook because the autosign ContextVar isn't set. # # follow_redirects is forced off whenever signing is active: RFC # 9421 binds the signature to the original `@authority`, so a 302 # would forward stale signature bytes to a new host. httpx's # current default is False already, but pinning it matches the # MCP factory's invariant and protects against future upstream # changes or a2a-sdk overrides. event_hooks: dict[str, list[Any]] = {} client_kwargs: dict[str, Any] = { "limits": limits, "headers": headers, "timeout": self.agent_config.timeout, } if self.signing_request_hook is not None: event_hooks["request"] = [self.signing_request_hook] client_kwargs["follow_redirects"] = False if event_hooks: client_kwargs["event_hooks"] = event_hooks self._httpx_client = httpx.AsyncClient(**client_kwargs) logger.debug( f"Created HTTP client with connection pooling for agent {self.agent_config.id}" ) return self._httpx_client async def _get_a2a_client(self) -> Client: """Get or create the A2A client. Uses :class:`~a2a.client.ClientFactory` to build a transport-negotiated :class:`~a2a.client.Client` against the resolved :class:`~a2a.types.AgentCard`. The shared ``httpx.AsyncClient`` is passed into the :class:`~a2a.client.ClientConfig` so the signing request hook and connection pool are reused across every outbound send. """ if self._a2a_client is None: httpx_client = await self._get_httpx_client() # Use A2ACardResolver to fetch the agent card card_resolver = A2ACardResolver( httpx_client=httpx_client, base_url=self.agent_config.agent_uri, ) try: agent_card = await card_resolver.get_agent_card() logger.debug(f"Fetched agent card for {self.agent_config.id}") except httpx.HTTPStatusError as e: status_code = e.response.status_code if status_code in (401, 403): raise ADCPAuthenticationError( f"Authentication failed: HTTP {status_code}", agent_id=self.agent_config.id, agent_uri=self.agent_config.agent_uri, ) from e else: raise ADCPConnectionError( f"Failed to fetch agent card: HTTP {status_code}", agent_id=self.agent_config.id, agent_uri=self.agent_config.agent_uri, ) from e except httpx.TimeoutException as e: raise ADCPTimeoutError( f"Timeout fetching agent card: {e}", agent_id=self.agent_config.id, agent_uri=self.agent_config.agent_uri, timeout=self.agent_config.timeout, ) from e except httpx.HTTPError as e: raise ADCPConnectionError( f"Failed to fetch agent card: {e}", agent_id=self.agent_config.id, agent_uri=self.agent_config.agent_uri, ) from e # Build a non-streaming client that reuses our httpx pool. # Streaming is disabled: the ADCP adapter surface is one # request in, one task out — streaming would require an # async iterator API that does not match the SDK contract. self._cached_agent_card = agent_card client_card = agent_card if self._force_a2a_version is not None: # Filter the advertised interfaces to the pinned version # before handing the card to ClientFactory; the factory # picks a transport from whatever remains. Raising here # is nicer than a cryptic "no transport available" deep # in the SDK. client_card = _filter_card_to_version(agent_card, self._force_a2a_version) if not client_card.supported_interfaces: raise ADCPConnectionError( f"Peer does not advertise A2A protocol_version=" f"{self._force_a2a_version!r}; advertised versions: " f"{sorted({i.protocol_version for i in agent_card.supported_interfaces})}", agent_id=self.agent_config.id, agent_uri=self.agent_config.agent_uri, ) factory = ClientFactory(ClientConfig(httpx_client=httpx_client, streaming=False)) self._a2a_client = factory.create(client_card) logger.debug(f"Created A2A client for agent {self.agent_config.id}") return self._a2a_client async def _send_and_aggregate( self, client: Client, request: pb.SendMessageRequest ) -> pb.StreamResponse: """Send a non-streaming request and return the terminal StreamResponse. The 1.0 :meth:`~a2a.client.Client.send_message` is an async generator that yields :class:`StreamResponse` events — with ``streaming=False`` it yields a single event carrying the final task. Pulls that event out so the ADCP adapter can stay request/response. Raises :class:`RuntimeError` if the generator yields nothing (should not happen: the SDK raises before yielding zero events). """ last: pb.StreamResponse | None = None stream = client.send_message(request) async for event in stream: last = event if last is None: raise RuntimeError("A2A client yielded no response events") return last async def close(self) -> None: """Close the HTTP client and clean up resources.""" if self._httpx_client is not None: logger.debug(f"Closing A2A adapter client for agent {self.agent_config.id}") # Close the A2A client first so it can drain any transport # state (grpc channel, streaming iterator) before we tear # down the shared httpx pool underneath it. if self._a2a_client is not None: try: await self._a2a_client.close() except Exception: # noqa: BLE001 logger.debug("A2A client close raised; ignoring", exc_info=True) await self._httpx_client.aclose() self._httpx_client = None self._a2a_client = None async def _call_a2a_tool( self, tool_name: str, params: dict[str, Any], use_explicit_skill: bool = True ) -> TaskResult[Any]: """ Call a tool using A2A protocol via official a2a-sdk client. Args: tool_name: Name of the skill/tool to invoke params: Parameters to pass to the skill use_explicit_skill: If True, use explicit skill invocation (deterministic). If False, use natural language (flexible). The default is explicit skill invocation for predictable, repeatable behavior. See: https://docs.adcontextprotocol.org/docs/protocols/a2a-guide """ start_time = time.time() if self.agent_config.debug else None if _idempotency.is_mutating(tool_name) and self.idempotency_capability_check: await self.idempotency_capability_check() params, idempotency_key = _idempotency.inject_key( tool_name, params, client_token=self.idempotency_client_token ) # Apply per-instance envelope enrichment (e.g. adcp_version pin). params = self._enrich_outgoing_params(params) # Pre-send schema validation. Matches the MCP adapter: strict mode # surfaces as TaskStatus.FAILED so the SDK's unified failure model # is preserved; warn mode logs and continues; off short-circuits. try: validate_outgoing_request(tool_name, params, self.request_validation_mode) except SchemaValidationError as exc: return TaskResult[Any]( status=TaskStatus.FAILED, error=str(exc), success=False, idempotency_key=idempotency_key, ) a2a_client = await self._get_a2a_client() # Build A2A message message_id = str(uuid4()) if use_explicit_skill: # Explicit skill invocation (deterministic): a single DataPart # carrying ``{"skill": tool_name, "parameters": params}``. parts = [_make_data_part({"skill": tool_name, "parameters": params})] else: # Natural language invocation (flexible): agent interprets # intent from text. parts = [_make_text_part(self._format_tool_request(tool_name, params))] message = pb.Message( message_id=message_id, role=pb.Role.ROLE_USER, parts=parts, context_id=self._context_id or "", task_id=self._active_task_id or "", ) request = pb.SendMessageRequest(message=message) debug_info = None debug_request: dict[str, Any] = {} if self.agent_config.debug: debug_request = { "method": "send_message", "message_id": message_id, "tool": tool_name, "params": _idempotency.redact_params(params), } # Stamp the AdCP operation name so the httpx request event hook # installed by ADCPClient for RFC 9421 auto-signing can look up the # right signing policy. Set only around send_message so out-of-band # httpx calls (the agent-card fetch above, or unrelated work on # sibling tasks) stay outside the signing scope. signing_token = _signing_operation.set(tool_name) try: # Non-streaming send returns a single StreamResponse envelope. stream_event = await self._send_and_aggregate(a2a_client, request) payload_kind = stream_event.WhichOneof("payload") if payload_kind == "task": result_task = stream_event.task if self.agent_config.debug and start_time: duration_ms = (time.time() - start_time) * 1000 debug_info = DebugInfo( request=debug_request, response=_idempotency.deep_redact( {"result": _task_to_redacted_dict(result_task)} ), duration_ms=duration_ms, ) # Compute next-turn state from the response but do NOT # commit yet — _process_task_response and the idempotency # check below can raise, and leaving the adapter advanced # after an exception would orphan the legitimate in-flight # task on the next retry. Commit only after both succeed. next_context_id = result_task.context_id or None if result_task.status.state in self._NONTERMINAL_TASK_STATES: next_active_task_id: str | None = result_task.id else: # Terminal states (completed/failed/canceled/rejected) # clear the retained task_id — subsequent calls start # a new task under the same context. The defensive # unspecified state falls here too; warn so operators # notice if a server starts emitting it. next_active_task_id = None if result_task.status.state == pb.TaskState.TASK_STATE_UNSPECIFIED: logger.warning( "A2A agent %s returned TASK_STATE_UNSPECIFIED for " "task_id=%s; clearing active_task_id and " "starting a fresh task on next call", self.agent_config.id, result_task.id, ) task_result = self._process_task_response(result_task, debug_info) _idempotency.raise_for_idempotency_error( tool_name, task_result.data, self.agent_config.id ) # All raise-sites have passed; commit next-turn state so # the adapter reflects the response the caller is about # to receive. self._context_id = next_context_id self._active_task_id = next_active_task_id # Post-receive schema validation. Only runs when the task # carries data (terminal completion); async interim states # with ``data=None`` skip naturally. Strict mode flips the # TaskResult to FAILED; warn mode logs and passes through. # Runs after the state commit — a payload-schema failure # doesn't invalidate the A2A envelope ids, and the next # call in the same conversation should still target the # right session. if task_result.success and task_result.data is not None: response_outcome = validate_incoming_response( tool_name, task_result.data, self.response_validation_mode ) if not response_outcome.valid and self.response_validation_mode == "strict": task_result = TaskResult[Any]( status=TaskStatus.FAILED, error=( f"Schema validation failed for {tool_name}: " f"{format_issues(response_outcome.issues)}" ), message=task_result.message, success=False, debug_info=task_result.debug_info, idempotency_key=task_result.idempotency_key, ) return _idempotency.annotate_result(task_result, idempotency_key) if payload_kind == "message": # Message response (shouldn't happen for send_message with # skill invocation, but surface a graceful fallback). agent_id = self.agent_config.id logger.warning(f"Received Message instead of Task from A2A agent {agent_id}") return TaskResult[Any]( status=TaskStatus.COMPLETED, data=None, message="Received message response", success=True, debug_info=debug_info, ) # Shouldn't reach here return TaskResult[Any]( status=TaskStatus.FAILED, error=f"Invalid response from A2A client (payload={payload_kind!r})", success=False, debug_info=debug_info, idempotency_key=idempotency_key, ) except httpx.HTTPStatusError as e: status_code = e.response.status_code if self.agent_config.debug and start_time: duration_ms = (time.time() - start_time) * 1000 debug_info = DebugInfo( request=debug_request, response={"error": str(e), "status_code": status_code}, duration_ms=duration_ms, ) if status_code in (401, 403): error_msg = f"Authentication failed: HTTP {status_code}" else: error_msg = f"HTTP {status_code} error: {e}" return TaskResult[Any]( status=TaskStatus.FAILED, error=error_msg, success=False, debug_info=debug_info, idempotency_key=idempotency_key, ) except httpx.TimeoutException as e: if self.agent_config.debug and start_time: duration_ms = (time.time() - start_time) * 1000 debug_info = DebugInfo( request=debug_request, response={"error": str(e)}, duration_ms=duration_ms, ) return TaskResult[Any]( status=TaskStatus.FAILED, error=f"Timeout: {e}", success=False, debug_info=debug_info, idempotency_key=idempotency_key, ) except (IdempotencyConflictError, IdempotencyExpiredError): # Propagate typed idempotency errors — callers MUST handle these # distinctly (mint fresh key / reconcile state). Other ADCPError # subclasses (connection, timeout, auth) continue to be converted # to TaskResult(failed) below, preserving the existing contract. raise except Exception as e: if self.agent_config.debug and start_time: duration_ms = (time.time() - start_time) * 1000 debug_info = DebugInfo( request=debug_request, response={"error": str(e)}, duration_ms=duration_ms, ) return TaskResult[Any]( status=TaskStatus.FAILED, error=str(e), success=False, debug_info=debug_info, idempotency_key=idempotency_key, ) finally: _signing_operation.reset(signing_token) def _process_task_response( self, task: pb.Task, debug_info: DebugInfo | None ) -> TaskResult[Any]: """Process a Task response from A2A into our TaskResult format.""" task_state = task.status.state if task_state == pb.TaskState.TASK_STATE_COMPLETED: # Extract the result from the artifacts array result_data = self._extract_result_from_task(task) # Check for task-level errors in the payload errors = result_data.get("errors", []) if isinstance(result_data, dict) else [] has_errors = bool(errors) return TaskResult[Any]( status=TaskStatus.COMPLETED, data=result_data, message=self._extract_text_from_task(task), success=not has_errors, metadata={ "task_id": task.id, "context_id": task.context_id, }, debug_info=debug_info, ) elif task_state == pb.TaskState.TASK_STATE_FAILED: # Per transport-errors.mdx §A2A Binding: a failed task carries # an ``adcp_error`` DataPart alongside the human-readable # TextPart. The structured envelope lands on # ``TaskResult.adcp_error`` for programmatic branching; the # text stays on ``error`` for humans. When the seller omits # the TextPart, fall back to the structured envelope's # ``message`` / ``code`` so adopters don't see the # ``"Task failed"`` placeholder mask a real diagnostic. text_msg = self._extract_text_from_task(task) adcp_error = self._extract_adcp_error_from_task(task) if text_msg: error_msg: str | None = text_msg elif adcp_error: error_msg = adcp_error.get("message") or adcp_error.get("code") else: error_msg = "Task failed" return TaskResult[Any]( status=TaskStatus.FAILED, error=error_msg, adcp_error=adcp_error, success=False, debug_info=debug_info, ) else: # Handle all interim states (submitted, working, input-required, etc.). # Metadata ``status`` stays in the 0.3-style lowercase spec form # (``working``, ``input-required``) so downstream consumers don't # need to learn the TaskState_ prefix. state_name = pb.TaskState.Name(task_state) if state_name.startswith("TASK_STATE_"): status_str = state_name[len("TASK_STATE_") :].lower().replace("_", "-") else: status_str = state_name.lower() return TaskResult[Any]( status=TaskStatus.SUBMITTED, data=None, # Interim responses may not have structured AdCP content message=self._extract_text_from_task(task), success=True, metadata={ "task_id": task.id, "context_id": task.context_id, "status": status_str, }, debug_info=debug_info, ) def _format_tool_request(self, tool_name: str, params: dict[str, Any]) -> str: """Format tool request as natural language for A2A.""" import json return f"Execute tool: {tool_name}\nParameters: {json.dumps(params, indent=2)}" def _extract_result_from_task(self, task: pb.Task) -> Any: """ Extract result data from A2A Task following canonical format. Per A2A response spec: - Responses MUST include at least one DataPart (``data`` oneof) - When multiple DataParts exist in an artifact, the last one is authoritative - When multiple artifacts exist, use the last one (most recent in streaming) - DataParts contain structured AdCP payload """ if not task.artifacts: logger.warning("A2A Task missing required artifacts array") return {} # Use last artifact (most recent in streaming scenarios) target_artifact = task.artifacts[-1] if not target_artifact.parts: logger.warning("A2A Task artifact has no parts") return {} data_parts = [ d for d in (_part_data_dict(p) for p in target_artifact.parts) if d is not None ] if not data_parts: logger.warning("A2A Task missing required DataPart (data oneof)") return {} # Use last DataPart as authoritative (handles streaming scenarios within an artifact) data = data_parts[-1] # Some A2A implementations (e.g., ADK) wrap the response in {"response": {...}} # Unwrap it to get the actual AdCP payload if present if isinstance(data, dict) and "response" in data: return data["response"] return data def _extract_adcp_error_from_task(self, task: pb.Task) -> dict[str, Any] | None: """Extract a spec-shaped ``adcp_error`` DataPart from a failed task. Per transport-errors.mdx §A2A Binding the failed task's artifact carries a DataPart wrapping ``{"adcp_error": {...}}``. Returns the validated envelope or ``None`` if no spec-shaped payload is present (spec-permitted: graceful sellers MAY omit the structured envelope, in which case adopters fall back to ``TaskResult.error``). """ data = self._extract_result_from_task(task) if not isinstance(data, dict): return None return validate_adcp_error(data.get("adcp_error")) def _extract_text_from_task(self, task: pb.Task) -> str | None: """Extract human-readable message from TextPart if present.""" if not task.artifacts: return None # Use last artifact (most recent in streaming scenarios) target_artifact = task.artifacts[-1] for part in target_artifact.parts: text = _part_text(part) if text is not None: return text return None # ======================================================================== # ADCP Protocol Methods # ======================================================================== async def get_products(self, params: dict[str, Any]) -> TaskResult[Any]: """Get advertising products.""" return await self._call_a2a_tool("get_products", params) async def list_creative_formats(self, params: dict[str, Any]) -> TaskResult[Any]: """List supported creative formats.""" return await self._call_a2a_tool("list_creative_formats", params) async def sync_creatives(self, params: dict[str, Any]) -> TaskResult[Any]: """Sync creatives.""" return await self._call_a2a_tool("sync_creatives", params) async def list_creatives(self, params: dict[str, Any]) -> TaskResult[Any]: """List creatives.""" return await self._call_a2a_tool("list_creatives", params) async def get_media_buy_delivery(self, params: dict[str, Any]) -> TaskResult[Any]: """Get media buy delivery.""" return await self._call_a2a_tool("get_media_buy_delivery", params) async def get_media_buys(self, params: dict[str, Any]) -> TaskResult[Any]: """Get media buys with status, creative approval state, and optional delivery snapshots.""" return await self._call_a2a_tool("get_media_buys", params) async def get_signals(self, params: dict[str, Any]) -> TaskResult[Any]: """Get signals.""" return await self._call_a2a_tool("get_signals", params) async def activate_signal(self, params: dict[str, Any]) -> TaskResult[Any]: """Activate signal.""" return await self._call_a2a_tool("activate_signal", params) async def provide_performance_feedback(self, params: dict[str, Any]) -> TaskResult[Any]: """Provide performance feedback.""" return await self._call_a2a_tool("provide_performance_feedback", params) async def log_event(self, params: dict[str, Any]) -> TaskResult[Any]: """Log event.""" return await self._call_a2a_tool("log_event", params) async def sync_event_sources(self, params: dict[str, Any]) -> TaskResult[Any]: """Sync event sources.""" return await self._call_a2a_tool("sync_event_sources", params) async def sync_audiences(self, params: dict[str, Any]) -> TaskResult[Any]: """Sync audiences.""" return await self._call_a2a_tool("sync_audiences", params) async def sync_catalogs(self, params: dict[str, Any]) -> TaskResult[Any]: """Sync catalogs.""" return await self._call_a2a_tool("sync_catalogs", params) async def preview_creative(self, params: dict[str, Any]) -> TaskResult[Any]: """Generate preview URLs for a creative manifest.""" return await self._call_a2a_tool("preview_creative", params) async def create_media_buy(self, params: dict[str, Any]) -> TaskResult[Any]: """Create media buy.""" return await self._call_a2a_tool("create_media_buy", params) async def update_media_buy(self, params: dict[str, Any]) -> TaskResult[Any]: """Update media buy.""" return await self._call_a2a_tool("update_media_buy", params) async def build_creative(self, params: dict[str, Any]) -> TaskResult[Any]: """Build creative.""" return await self._call_a2a_tool("build_creative", params) async def get_creative_delivery(self, params: dict[str, Any]) -> TaskResult[Any]: """Get creative delivery.""" return await self._call_a2a_tool("get_creative_delivery", params) async def list_transformers(self, params: dict[str, Any]) -> TaskResult[Any]: """List creative transformers.""" return await self._call_a2a_tool("list_transformers", params) async def list_accounts(self, params: dict[str, Any]) -> TaskResult[Any]: """List accounts.""" return await self._call_a2a_tool("list_accounts", params) async def sync_accounts(self, params: dict[str, Any]) -> TaskResult[Any]: """Sync accounts.""" return await self._call_a2a_tool("sync_accounts", params) async def get_account_financials(self, params: dict[str, Any]) -> TaskResult[Any]: """Get account financials.""" return await self._call_a2a_tool("get_account_financials", params) async def report_usage(self, params: dict[str, Any]) -> TaskResult[Any]: """Report account usage.""" return await self._call_a2a_tool("report_usage", params) async def list_tools(self) -> list[str]: """ List available tools from A2A agent. Uses A2A client which already fetched the agent card during initialization. """ # Ensure the A2A client (and cached agent card) is initialized. await self._get_a2a_client() if self._cached_agent_card is None: raise RuntimeError("Agent card cache was not populated by _get_a2a_client") agent_card: pb.AgentCard = self._cached_agent_card tool_names = [skill.name for skill in agent_card.skills if skill.name] logger.info(f"Found {len(tool_names)} tools from A2A agent {self.agent_config.id}") return tool_names async def get_agent_info(self) -> dict[str, Any]: """ Get agent information including AdCP extension metadata from A2A agent card. Fetches the agent card via :class:`~a2a.client.A2ACardResolver` and extracts: - Basic agent info (name, description, version) - AdCP extension (extensions.adcp.adcp_version, extensions.adcp.protocols_supported) - Available skills/tools Returns: Dictionary with agent metadata """ await self._get_a2a_client() logger.debug(f"Fetching A2A agent info for {self.agent_config.id}") if self._cached_agent_card is None: raise RuntimeError("Agent card cache was not populated by _get_a2a_client") agent_card: pb.AgentCard = self._cached_agent_card info: dict[str, Any] = { "name": agent_card.name, "description": agent_card.description, "version": agent_card.version, "protocol": "a2a", # A2A wire versions the peer advertises. Our server emits # both "0.3" and "1.0" so clients of either era interoperate; # this field lets buyers confirm what a given peer speaks. "a2a_protocol_versions": sorted( {iface.protocol_version for iface in agent_card.supported_interfaces} ), } tool_names = [skill.name for skill in agent_card.skills if skill.name] if tool_names: info["tools"] = tool_names # The 1.0 proto :class:`AgentCard` has no ``extensions`` map. # Sellers advertising AdCP capabilities must surface them via # ``skills`` entries or a follow-up # ``get_adcp_capabilities`` call rather than an out-of-band # extensions dict (which the 0.3 Pydantic card accepted). logger.info(f"Retrieved agent info for {self.agent_config.id}") return info # ======================================================================== # V3 Protocol Methods - Protocol Discovery # ======================================================================== async def get_adcp_capabilities(self, params: dict[str, Any]) -> TaskResult[Any]: """Get AdCP capabilities from the agent.""" return await self._call_a2a_tool("get_adcp_capabilities", params) async def get_task_status(self, params: dict[str, Any]) -> TaskResult[Any]: """Get task status from the agent.""" return await self._call_a2a_tool("get_task_status", params) async def list_tasks(self, params: dict[str, Any]) -> TaskResult[Any]: """List tasks from the agent.""" return await self._call_a2a_tool("list_tasks", params) # ======================================================================== # V3 Protocol Methods - Content Standards # ======================================================================== async def create_content_standards(self, params: dict[str, Any]) -> TaskResult[Any]: """Create content standards configuration.""" return await self._call_a2a_tool("create_content_standards", params) async def get_content_standards(self, params: dict[str, Any]) -> TaskResult[Any]: """Get content standards configuration.""" return await self._call_a2a_tool("get_content_standards", params) async def list_content_standards(self, params: dict[str, Any]) -> TaskResult[Any]: """List content standards configurations.""" return await self._call_a2a_tool("list_content_standards", params) async def update_content_standards(self, params: dict[str, Any]) -> TaskResult[Any]: """Update content standards configuration.""" return await self._call_a2a_tool("update_content_standards", params) async def calibrate_content(self, params: dict[str, Any]) -> TaskResult[Any]: """Calibrate content against standards.""" return await self._call_a2a_tool("calibrate_content", params) async def validate_content_delivery(self, params: dict[str, Any]) -> TaskResult[Any]: """Validate content delivery against standards.""" return await self._call_a2a_tool("validate_content_delivery", params) async def get_media_buy_artifacts(self, params: dict[str, Any]) -> TaskResult[Any]: """Get artifacts associated with a media buy.""" return await self._call_a2a_tool("get_media_buy_artifacts", params) # ======================================================================== # V3 Protocol Methods - Governance # ======================================================================== async def get_creative_features(self, params: dict[str, Any]) -> TaskResult[Any]: """Evaluate governance features for a creative.""" return await self._call_a2a_tool("get_creative_features", params) async def sync_plans(self, params: dict[str, Any]) -> TaskResult[Any]: """Sync campaign governance plans.""" return await self._call_a2a_tool("sync_plans", params) async def check_governance(self, params: dict[str, Any]) -> TaskResult[Any]: """Check an action against campaign governance.""" return await self._call_a2a_tool("check_governance", params) async def report_plan_outcome(self, params: dict[str, Any]) -> TaskResult[Any]: """Report the outcome of a governed action.""" return await self._call_a2a_tool("report_plan_outcome", params) async def get_plan_audit_logs(self, params: dict[str, Any]) -> TaskResult[Any]: """Retrieve governance audit logs for plans.""" return await self._call_a2a_tool("get_plan_audit_logs", params) # ======================================================================== # V3 Protocol Methods - Sponsored Intelligence # ======================================================================== async def si_get_offering(self, params: dict[str, Any]) -> TaskResult[Any]: """Get sponsored intelligence offering.""" return await self._call_a2a_tool("si_get_offering", params) async def si_initiate_session(self, params: dict[str, Any]) -> TaskResult[Any]: """Initiate sponsored intelligence session.""" return await self._call_a2a_tool("si_initiate_session", params) async def si_send_message(self, params: dict[str, Any]) -> TaskResult[Any]: """Send message in sponsored intelligence session.""" return await self._call_a2a_tool("si_send_message", params) async def si_terminate_session(self, params: dict[str, Any]) -> TaskResult[Any]: """Terminate sponsored intelligence session.""" return await self._call_a2a_tool("si_terminate_session", params) # ======================================================================== # V3 Protocol Methods - Governance (Property Lists) # ======================================================================== async def create_property_list(self, params: dict[str, Any]) -> TaskResult[Any]: """Create a property list for governance.""" return await self._call_a2a_tool("create_property_list", params) async def get_property_list(self, params: dict[str, Any]) -> TaskResult[Any]: """Get a property list with optional resolution.""" return await self._call_a2a_tool("get_property_list", params) async def list_property_lists(self, params: dict[str, Any]) -> TaskResult[Any]: """List property lists.""" return await self._call_a2a_tool("list_property_lists", params) async def update_property_list(self, params: dict[str, Any]) -> TaskResult[Any]: """Update a property list.""" return await self._call_a2a_tool("update_property_list", params) async def delete_property_list(self, params: dict[str, Any]) -> TaskResult[Any]: """Delete a property list.""" return await self._call_a2a_tool("delete_property_list", params) # ======================================================================== # V3 Protocol Methods - Governance (Collection Lists) # ======================================================================== async def create_collection_list(self, params: dict[str, Any]) -> TaskResult[Any]: """Create a collection list for governance.""" return await self._call_a2a_tool("create_collection_list", params) async def get_collection_list(self, params: dict[str, Any]) -> TaskResult[Any]: """Get a collection list with optional resolution.""" return await self._call_a2a_tool("get_collection_list", params) async def list_collection_lists(self, params: dict[str, Any]) -> TaskResult[Any]: """List collection lists.""" return await self._call_a2a_tool("list_collection_lists", params) async def update_collection_list(self, params: dict[str, Any]) -> TaskResult[Any]: """Update a collection list.""" return await self._call_a2a_tool("update_collection_list", params) async def delete_collection_list(self, params: dict[str, Any]) -> TaskResult[Any]: """Delete a collection list.""" return await self._call_a2a_tool("delete_collection_list", params) # ======================================================================== # V3 Protocol Methods - Governance (Sync Governance) # ======================================================================== async def sync_governance(self, params: dict[str, Any]) -> TaskResult[Any]: """Sync governance agents attached to an account.""" return await self._call_a2a_tool("sync_governance", params) # ======================================================================== # V3 Protocol Methods - TMP # ======================================================================== async def context_match(self, params: dict[str, Any]) -> TaskResult[Any]: """Match ad context to buyer packages.""" return await self._call_a2a_tool("context_match", params) async def identity_match(self, params: dict[str, Any]) -> TaskResult[Any]: """Match user identity for package eligibility.""" return await self._call_a2a_tool("identity_match", params) # ======================================================================== # V3 Protocol Methods - Brand Rights # ======================================================================== async def get_brand_identity(self, params: dict[str, Any]) -> TaskResult[Any]: """Get brand identity information.""" return await self._call_a2a_tool("get_brand_identity", params) async def get_rights(self, params: dict[str, Any]) -> TaskResult[Any]: """Get available rights for licensing.""" return await self._call_a2a_tool("get_rights", params) async def acquire_rights(self, params: dict[str, Any]) -> TaskResult[Any]: """Acquire rights for brand content usage.""" return await self._call_a2a_tool("acquire_rights", params) async def update_rights(self, params: dict[str, Any]) -> TaskResult[Any]: """Update terms of an existing rights acquisition.""" return await self._call_a2a_tool("update_rights", params) async def validate_input(self, params: dict[str, Any]) -> TaskResult[Any]: """Validate creative input.""" return await self._call_a2a_tool("validate_input", params) async def verify_brand_claim(self, params: dict[str, Any]) -> TaskResult[Any]: """Verify a brand claim.""" return await self._call_a2a_tool("verify_brand_claim", params) async def verify_brand_claims(self, params: dict[str, Any]) -> TaskResult[Any]: """Verify brand claims.""" return await self._call_a2a_tool("verify_brand_claims", params) # ======================================================================== # V3 Protocol Methods - Compliance # ======================================================================== async def comply_test_controller(self, params: dict[str, Any]) -> TaskResult[Any]: """Compliance test controller (sandbox only).""" return await self._call_a2a_tool("comply_test_controller", params)Adapter for A2A protocol using the official a2a-sdk 1.0 client.
Initialize A2A adapter with official A2A client.
force_a2a_versionpins the A2A wire version by filtering the peer's advertisedsupported_interfacesto only entries whoseprotocol_versionmatches. Intended for tests or for forcing a 0.3-speaking path against a dual-advertising peer. Raises :class:ADCPConnectionErroron first use if no advertised interface matches.Nonelets the SDK'sClientFactorypick the most capable transport the peer supports.Ancestors
- ProtocolAdapter
- abc.ABC
Instance variables
prop a2a_protocol_versions : list[str] | None-
Expand source code
@property def a2a_protocol_versions(self) -> list[str] | None: """Sorted list of A2A ``protocol_version`` strings the peer advertises. Populated after the first call (or any operation that fetches the ``AgentCard`` — :meth:`list_tools`, :meth:`get_agent_info`, or an ``_call_a2a_tool`` invocation). Returns ``None`` before the card has been fetched so callers can distinguish "not yet known" from "peer advertises nothing" (empty list). Example:: client = ADCPClient(a2a_config) await client.adapter.get_agent_info() print(client.a2a_protocol_versions) # ['0.3', '1.0'] """ if self._cached_agent_card is None: return None return sorted( {iface.protocol_version for iface in self._cached_agent_card.supported_interfaces} )Sorted list of A2A
protocol_versionstrings the peer advertises.Populated after the first call (or any operation that fetches the
AgentCard— :meth:list_tools, :meth:get_agent_info, or an_call_a2a_toolinvocation). ReturnsNonebefore the card has been fetched so callers can distinguish "not yet known" from "peer advertises nothing" (empty list).Example::
client = ADCPClient(a2a_config) await client.adapter.get_agent_info() print(client.a2a_protocol_versions) # ['0.3', '1.0'] prop active_task_id : str | None-
Expand source code
@property def active_task_id(self) -> str | None: """A2A task_id the next send must echo to resume the same task. Populated when the last response was non-terminal (e.g. ``input-required``, ``working``). Echoed on the next outbound message so the server continues the same task. Clears to None on terminal states (``completed``/``failed``/``canceled``/ ``rejected``) — and defensively on ``unknown`` — so subsequent calls start a fresh task under the same context. """ return self._active_task_idA2A task_id the next send must echo to resume the same task.
Populated when the last response was non-terminal (e.g.
input-required,working). Echoed on the next outbound message so the server continues the same task. Clears to None on terminal states (completed/failed/canceled/rejected) — and defensively onunknown— so subsequent calls start a fresh task under the same context. prop context_id : str | None-
Expand source code
@property def context_id(self) -> str | None: """Current A2A conversation context_id, or None if not yet established. ``None`` means either (a) a fresh conversation where the server has not yet replied, or (b) the context was cleared via ``set_context_id(None)``. Callers that need to distinguish these must track their own state. Not thread-safe: the adapter mutates this on every response. For concurrent use, serialize calls on one adapter or construct one per conversation. """ return self._context_idCurrent A2A conversation context_id, or None if not yet established.
Nonemeans either (a) a fresh conversation where the server has not yet replied, or (b) the context was cleared viaset_context_id(None). Callers that need to distinguish these must track their own state.Not thread-safe: the adapter mutates this on every response. For concurrent use, serialize calls on one adapter or construct one per conversation.
Methods
async def close(self) ‑> None-
Expand source code
async def close(self) -> None: """Close the HTTP client and clean up resources.""" if self._httpx_client is not None: logger.debug(f"Closing A2A adapter client for agent {self.agent_config.id}") # Close the A2A client first so it can drain any transport # state (grpc channel, streaming iterator) before we tear # down the shared httpx pool underneath it. if self._a2a_client is not None: try: await self._a2a_client.close() except Exception: # noqa: BLE001 logger.debug("A2A client close raised; ignoring", exc_info=True) await self._httpx_client.aclose() self._httpx_client = None self._a2a_client = NoneClose the HTTP client and clean up resources.
async def get_agent_info(self) ‑> dict[str, typing.Any]-
Expand source code
async def get_agent_info(self) -> dict[str, Any]: """ Get agent information including AdCP extension metadata from A2A agent card. Fetches the agent card via :class:`~a2a.client.A2ACardResolver` and extracts: - Basic agent info (name, description, version) - AdCP extension (extensions.adcp.adcp_version, extensions.adcp.protocols_supported) - Available skills/tools Returns: Dictionary with agent metadata """ await self._get_a2a_client() logger.debug(f"Fetching A2A agent info for {self.agent_config.id}") if self._cached_agent_card is None: raise RuntimeError("Agent card cache was not populated by _get_a2a_client") agent_card: pb.AgentCard = self._cached_agent_card info: dict[str, Any] = { "name": agent_card.name, "description": agent_card.description, "version": agent_card.version, "protocol": "a2a", # A2A wire versions the peer advertises. Our server emits # both "0.3" and "1.0" so clients of either era interoperate; # this field lets buyers confirm what a given peer speaks. "a2a_protocol_versions": sorted( {iface.protocol_version for iface in agent_card.supported_interfaces} ), } tool_names = [skill.name for skill in agent_card.skills if skill.name] if tool_names: info["tools"] = tool_names # The 1.0 proto :class:`AgentCard` has no ``extensions`` map. # Sellers advertising AdCP capabilities must surface them via # ``skills`` entries or a follow-up # ``get_adcp_capabilities`` call rather than an out-of-band # extensions dict (which the 0.3 Pydantic card accepted). logger.info(f"Retrieved agent info for {self.agent_config.id}") return infoGet agent information including AdCP extension metadata from A2A agent card.
Fetches the agent card via :class:
~a2a.client.A2ACardResolverand extracts:- Basic agent info (name, description, version)
- AdCP extension (extensions.adcp.adcp_version, extensions.adcp.protocols_supported)
- Available skills/tools
Returns
Dictionary with agent metadata
async def list_tools(self) ‑> list[str]-
Expand source code
async def list_tools(self) -> list[str]: """ List available tools from A2A agent. Uses A2A client which already fetched the agent card during initialization. """ # Ensure the A2A client (and cached agent card) is initialized. await self._get_a2a_client() if self._cached_agent_card is None: raise RuntimeError("Agent card cache was not populated by _get_a2a_client") agent_card: pb.AgentCard = self._cached_agent_card tool_names = [skill.name for skill in agent_card.skills if skill.name] logger.info(f"Found {len(tool_names)} tools from A2A agent {self.agent_config.id}") return tool_namesList available tools from A2A agent.
Uses A2A client which already fetched the agent card during initialization.
async def preview_creative(self, params: dict[str, Any]) ‑> TaskResult[Any]-
Expand source code
async def preview_creative(self, params: dict[str, Any]) -> TaskResult[Any]: """Generate preview URLs for a creative manifest.""" return await self._call_a2a_tool("preview_creative", params)Generate preview URLs for a creative manifest.
def set_context_id(self, context_id: str | None) ‑> None-
Expand source code
def set_context_id(self, context_id: str | None) -> None: """Set the A2A context_id for subsequent message sends. Pass ``None`` to clear — the server mints a fresh id on the next call — or a string to seed. Seeding is safe for *resume* (pass back an id the server previously returned). Seeding with a *self-generated* id is server-dependent: per the A2A spec, agents MAY accept or reject client-supplied ids, and some frameworks (notably ADK) rewrite the id into their own session format and return the rewritten value on the next response — at which point this adapter auto-adopts it. Also clears any retained ``active_task_id``: switching context always starts a fresh task under the new context. """ self._context_id = context_id self._active_task_id = NoneSet the A2A context_id for subsequent message sends.
Pass
Noneto clear — the server mints a fresh id on the next call — or a string to seed. Seeding is safe for resume (pass back an id the server previously returned). Seeding with a self-generated id is server-dependent: per the A2A spec, agents MAY accept or reject client-supplied ids, and some frameworks (notably ADK) rewrite the id into their own session format and return the rewritten value on the next response — at which point this adapter auto-adopts it.Also clears any retained
active_task_id: switching context always starts a fresh task under the new context. async def validate_input(self, params: dict[str, Any]) ‑> TaskResult[Any]-
Expand source code
async def validate_input(self, params: dict[str, Any]) -> TaskResult[Any]: """Validate creative input.""" return await self._call_a2a_tool("validate_input", params)Validate creative input.
async def verify_brand_claim(self, params: dict[str, Any]) ‑> TaskResult[Any]-
Expand source code
async def verify_brand_claim(self, params: dict[str, Any]) -> TaskResult[Any]: """Verify a brand claim.""" return await self._call_a2a_tool("verify_brand_claim", params)Verify a brand claim.
async def verify_brand_claims(self, params: dict[str, Any]) ‑> TaskResult[Any]-
Expand source code
async def verify_brand_claims(self, params: dict[str, Any]) -> TaskResult[Any]: """Verify brand claims.""" return await self._call_a2a_tool("verify_brand_claims", params)Verify brand claims.
Inherited members
ProtocolAdapter:acquire_rightsactivate_signalbuild_creativecalibrate_contentcheck_governancecomply_test_controllerconfigure_validationcontext_matchcreate_collection_listcreate_content_standardscreate_media_buycreate_property_listdelete_collection_listdelete_property_listget_account_financialsget_adcp_capabilitiesget_brand_identityget_collection_listget_content_standardsget_creative_deliveryget_creative_featuresget_media_buy_artifactsget_media_buy_deliveryget_media_buysget_plan_audit_logsget_productsget_property_listget_rightsget_signalsget_task_statusidentity_matchlist_accountslist_collection_listslist_content_standardslist_creative_formatslist_creativeslist_property_listslist_taskslist_transformerslog_eventprovide_performance_feedbackreport_plan_outcomereport_usagesi_get_offeringsi_initiate_sessionsi_send_messagesi_terminate_sessionsync_accountssync_audiencessync_catalogssync_creativessync_event_sourcessync_governancesync_plansupdate_collection_listupdate_content_standardsupdate_media_buyupdate_property_listupdate_rightsvalidate_content_delivery