simbiot-harness/venv/lib/python3.12/site-packages/strands/agent/a2a_agent.py
Simbiot Harness b60439152b Simbiot Harness v0.1: тактовая лестница на Strands + CQRS/ES-спина
- harness/es.py: append-only event store + проекции (образец — кооп-портал)
- harness/levels.py: уровни 1с/1м/1ч/1д, контекст ∝ масштабу
- harness/scheduler.py: уровневые потоки, идемпотентные тики, absorbed-стабы
- harness/isolated_runner.py + _llm_worker.py: LLM-тик в сабпроцессе
  (глобальный asyncio-рантайм Strands не переживает 2+ create_harness)
- cli.py: init/task/tasks/status/events + requeue (написан самим минутным агентом)
- 9router: отдельный ключ sk-simbiot, модель auto (glm-5.3 первым)
2026-09-29 15:28:50 +00:00

312 lines
12 KiB
Python

"""A2A Agent client for Strands Agents.
This module provides the A2AAgent class, which acts as a client wrapper for remote A2A agents,
allowing them to be used standalone or as part of multi-agent patterns.
A2AAgent can be used to get the Agent Card and interact with the agent.
"""
import dataclasses
import logging
import warnings
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Any
import httpx
from a2a.client import A2ACardResolver, ClientConfig, ClientFactory
from a2a.types import AgentCard, Message, TaskArtifactUpdateEvent, TaskStatusUpdateEvent
from .._async import run_async
from ..multiagent.a2a._converters import (
_STATE_TO_STOP_REASON,
convert_input_to_message,
convert_response_to_agent_result,
)
from ..types._events import AgentResultEvent
from ..types.a2a import A2AResponse, A2AStreamEvent
from ..types.agent import AgentInput
from .agent_result import AgentResult
from .base import AgentBase
logger = logging.getLogger(__name__)
_DEFAULT_TIMEOUT = 300
# A2A task states that indicate the response stream is complete.
# Derived from the canonical _STATE_TO_STOP_REASON mapping in _converters.
# Terminal states (end_turn) mean no more events; input states (interrupt) mean execution is paused.
_TERMINAL_STATES = {state for state, reason in _STATE_TO_STOP_REASON.items() if reason == "end_turn"}
_INPUT_STATES = {state for state, reason in _STATE_TO_STOP_REASON.items() if reason == "interrupt"}
_COMPLETE_STATES = _TERMINAL_STATES | _INPUT_STATES
class A2AAgent(AgentBase):
"""Client wrapper for remote A2A agents."""
def __init__(
self,
endpoint: str,
*,
name: str | None = None,
description: str | None = None,
timeout: int = _DEFAULT_TIMEOUT,
client_config: ClientConfig | None = None,
a2a_client_factory: ClientFactory | None = None,
):
"""Initialize A2A agent.
Args:
endpoint: The base URL of the remote A2A agent.
name: Agent name. If not provided, will be populated from agent card.
description: Agent description. If not provided, will be populated from agent card.
timeout: Timeout for HTTP operations in seconds (defaults to 300).
client_config: A2A ``ClientConfig`` for authentication and transport settings.
The ``httpx_client`` configured here is used for both card discovery and
message sending, enabling authenticated endpoints (SigV4, OAuth, bearer tokens).
When providing an ``httpx_client``, you are responsible for configuring its timeout.
a2a_client_factory: Deprecated. Use ``client_config`` instead.
Raises:
ValueError: If both ``client_config`` and ``a2a_client_factory`` are provided.
"""
if client_config is not None and a2a_client_factory is not None:
raise ValueError(
"Cannot provide both client_config and a2a_client_factory. "
"Use client_config (recommended) or a2a_client_factory (deprecated), not both."
)
if a2a_client_factory is not None:
warnings.warn(
"a2a_client_factory is deprecated. Use client_config instead. "
"a2a_client_factory will be removed in a future version.",
DeprecationWarning,
stacklevel=2,
)
self.endpoint = endpoint
self.name = name
self.description = description
self.timeout = timeout
self._client_config: ClientConfig | None = client_config
self._agent_card: AgentCard | None = None
self._a2a_client_factory: ClientFactory | None = a2a_client_factory
def __call__(
self,
prompt: AgentInput = None,
**kwargs: Any,
) -> AgentResult:
"""Synchronously invoke the remote A2A agent.
Args:
prompt: Input to the agent (string, message list, or content blocks).
**kwargs: Additional arguments (ignored).
Returns:
AgentResult containing the agent's response.
Raises:
ValueError: If prompt is None.
RuntimeError: If no response received from agent.
"""
return run_async(lambda: self.invoke_async(prompt, **kwargs))
async def invoke_async(
self,
prompt: AgentInput = None,
**kwargs: Any,
) -> AgentResult:
"""Asynchronously invoke the remote A2A agent.
Args:
prompt: Input to the agent (string, message list, or content blocks).
**kwargs: Additional arguments (ignored).
Returns:
AgentResult containing the agent's response.
Raises:
ValueError: If prompt is None.
RuntimeError: If no response received from agent.
"""
result: AgentResult | None = None
async for event in self.stream_async(prompt, **kwargs):
if "result" in event:
result = event["result"]
if result is None:
raise RuntimeError("No response received from A2A agent")
return result
async def stream_async(
self,
prompt: AgentInput = None,
**kwargs: Any,
) -> AsyncIterator[Any]:
"""Stream remote agent execution asynchronously.
This method provides an asynchronous interface for streaming A2A protocol events.
Unlike Agent.stream_async() which yields text deltas and tool events, this method
yields raw A2A protocol events wrapped in A2AStreamEvent dictionaries.
Args:
prompt: Input to the agent (string, message list, or content blocks).
**kwargs: Additional arguments (ignored).
Yields:
An async iterator that yields events. Each event is a dictionary:
- A2AStreamEvent: {"type": "a2a_stream", "event": <A2A object>}
where the A2A object can be a Message, or a tuple of
(Task, TaskStatusUpdateEvent) or (Task, TaskArtifactUpdateEvent).
- AgentResultEvent: {"result": AgentResult} - always emitted last.
Raises:
ValueError: If prompt is None.
Example:
```python
async for event in a2a_agent.stream_async("Hello"):
if event.get("type") == "a2a_stream":
print(f"A2A event: {event['event']}")
elif "result" in event:
print(f"Final result: {event['result'].message}")
```
"""
last_event = None
last_complete_event = None
async for event in self._send_message(prompt):
last_event = event
if self._is_complete_event(event):
last_complete_event = event
yield A2AStreamEvent(event)
# Use the last complete event if available, otherwise fall back to last event
final_event = last_complete_event or last_event
if final_event is not None:
result = convert_response_to_agent_result(final_event)
yield AgentResultEvent(result)
async def get_agent_card(self) -> AgentCard:
"""Fetch and return the remote agent's card.
Eagerly fetches the agent card from the remote endpoint, populating name and description
if not already set. The card is cached after the first fetch.
When ``client_config`` is provided with an ``httpx_client``, that client is used for
card resolution, enabling authenticated card discovery (e.g., SigV4, OAuth, bearer tokens).
Returns:
The remote agent's AgentCard containing name, description, capabilities, skills, etc.
"""
if self._agent_card is not None:
return self._agent_card
if self._client_config is not None and self._client_config.httpx_client is not None:
resolver = A2ACardResolver(httpx_client=self._client_config.httpx_client, base_url=self.endpoint)
self._agent_card = await resolver.get_agent_card()
else:
async with httpx.AsyncClient(timeout=self.timeout) as client:
resolver = A2ACardResolver(httpx_client=client, base_url=self.endpoint)
self._agent_card = await resolver.get_agent_card()
# Populate name from card if not set
if self.name is None and self._agent_card.name is not None:
self.name = self._agent_card.name
# Populate description from card if not set
if self.description is None and self._agent_card.description is not None:
self.description = self._agent_card.description
logger.debug("agent=<%s>, endpoint=<%s> | discovered agent card", self.name, self.endpoint)
return self._agent_card
@asynccontextmanager
async def _get_a2a_client(self) -> AsyncIterator[Any]:
"""Get A2A client for sending messages.
If a deprecated factory was provided, delegates to it for client creation.
If client_config was provided, uses it directly — ClientFactory handles defaults.
Otherwise creates a managed httpx client with the agent's timeout.
Yields:
Configured A2A client instance.
"""
agent_card = await self.get_agent_card()
if self._a2a_client_factory is not None:
yield self._a2a_client_factory.create(agent_card)
return
if self._client_config is not None:
config = dataclasses.replace(self._client_config, streaming=True)
yield ClientFactory(config).create(agent_card)
return
# No client_config — create a managed httpx client, consistent with get_agent_card() path
async with httpx.AsyncClient(timeout=self.timeout) as httpx_client:
config = ClientConfig(httpx_client=httpx_client, streaming=True)
yield ClientFactory(config).create(agent_card)
async def _send_message(self, prompt: AgentInput) -> AsyncIterator[A2AResponse]:
"""Send message to A2A agent.
Args:
prompt: Input to send to the agent.
Yields:
A2A response events.
Raises:
ValueError: If prompt is None.
"""
if prompt is None:
raise ValueError("prompt is required for A2AAgent")
message = convert_input_to_message(prompt)
logger.debug("agent=<%s>, endpoint=<%s> | sending message", self.name, self.endpoint)
async with self._get_a2a_client() as client:
async for event in client.send_message(message):
yield event
def _is_complete_event(self, event: A2AResponse) -> bool:
"""Check if an A2A event represents a complete response.
Recognizes all terminal states (completed, failed, canceled, rejected)
and pausing states (input_required, auth_required) as complete events.
Args:
event: A2A event.
Returns:
True if the event represents a complete response.
"""
# Direct Message is always complete
if isinstance(event, Message):
return True
# Handle tuple responses (Task, UpdateEvent | None)
if isinstance(event, tuple) and len(event) == 2:
task, update_event = event
# Initial task response (no update event)
if update_event is None:
return True
# Artifact update with last_chunk flag
if isinstance(update_event, TaskArtifactUpdateEvent):
if hasattr(update_event, "last_chunk") and update_event.last_chunk is not None:
return update_event.last_chunk
return False
# Status update - check for terminal or pausing states
if isinstance(update_event, TaskStatusUpdateEvent):
if update_event.status and hasattr(update_event.status, "state"):
state = update_event.status.state
return state in _COMPLETE_STATES
return False