simbiot-harness/venv/lib/python3.12/site-packages/strands/multiagent/a2a/server.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

333 lines
14 KiB
Python

"""A2A-compatible wrapper for Strands Agent.
This module provides the A2AServer class, which adapts a Strands Agent to the A2A protocol,
allowing it to be used in A2A-compatible systems.
"""
import logging
from typing import Any, Literal
from urllib.parse import urlparse
import uvicorn
from a2a.server.apps import A2AFastAPIApplication, A2AStarletteApplication
from a2a.server.events import QueueManager
from a2a.server.request_handlers import DefaultRequestHandler
from a2a.server.tasks import InMemoryTaskStore, PushNotificationConfigStore, PushNotificationSender, TaskStore
from a2a.types import AgentCapabilities, AgentCard, AgentSkill
from fastapi import FastAPI
from starlette.applications import Starlette
from ...agent.agent import Agent as SAAgent
from .executor import AgentFactory, StrandsA2AExecutor
logger = logging.getLogger(__name__)
# Placeholder context id used to build a representative agent for card metadata in factory mode.
_AGENT_CARD_CONTEXT_ID = "__agent_card__"
class A2AServer:
"""A2A-compatible wrapper for Strands Agent."""
def __init__(
self,
agent: SAAgent | None = None,
*,
agent_factory: AgentFactory | None = None,
max_contexts: int = StrandsA2AExecutor.DEFAULT_MAX_CONTEXTS,
# AgentCard
host: str = "127.0.0.1",
port: int = 9000,
http_url: str | None = None,
serve_at_root: bool = False,
version: str = "0.0.1",
skills: list[AgentSkill] | None = None,
# RequestHandler
task_store: TaskStore | None = None,
queue_manager: QueueManager | None = None,
push_config_store: PushNotificationConfigStore | None = None,
push_sender: PushNotificationSender | None = None,
enable_a2a_compliant_streaming: bool = False,
):
"""Initialize an A2A-compatible server from a Strands agent.
Provide exactly one of ``agent`` or ``agent_factory``:
- ``agent_factory`` (recommended): a callable ``(context_id) -> Agent`` that builds a
dedicated agent per A2A context. Contexts run concurrently and the factory is the place
to wire per-context concerns such as a context-scoped ``session_manager``. The factory
is invoked once at construction (with a placeholder context id) solely to derive the
agent card metadata (name, description, skills); that agent is not used for request
handling. An expensive factory therefore pays its cost once at startup.
- ``agent`` (deprecated): a single agent serving one conversation. Not multi-tenant safe —
every A2A context reuses the same instance — so use ``agent_factory`` for multi-caller
deployments.
Args:
agent: A single Strands Agent to wrap. Deprecated; prefer ``agent_factory``.
agent_factory: Callable ``(context_id) -> Agent`` building a fresh agent per context.
max_contexts: Maximum number of per-context agents to retain concurrently (factory
mode); the least-recently-used is evicted beyond this. Must be >= 1.
host: The hostname or IP address to bind the A2A server to. Defaults to "127.0.0.1".
port: The port to bind the A2A server to. Defaults to 9000.
http_url: The public HTTP URL where this agent will be accessible. If provided,
this overrides the generated URL from host/port and enables automatic
path-based mounting for load balancer scenarios.
Example: "http://my-alb.amazonaws.com/agent1"
serve_at_root: If True, forces the server to serve at root path regardless of
http_url path component. Use this when your load balancer strips path prefixes.
Defaults to False.
version: The version of the agent. Defaults to "0.0.1".
skills: The list of capabilities or functions the agent can perform.
task_store: Custom task store implementation for managing agent tasks. If None,
uses InMemoryTaskStore.
queue_manager: Custom queue manager for handling message queues. If None,
no queue management is used.
push_config_store: Custom store for push notification configurations. If None,
no push notification configuration is used.
push_sender: Custom push notification sender implementation. If None,
no push notifications are sent.
enable_a2a_compliant_streaming: If True, uses A2A-compliant streaming with
artifact updates. If False, uses legacy status updates streaming behavior
for backwards compatibility. Defaults to False.
Raises:
ValueError: If neither or both of ``agent``/``agent_factory`` are provided, or if
``max_contexts`` is less than 1.
"""
if (agent is None) == (agent_factory is None):
raise ValueError("Provide exactly one of 'agent' or 'agent_factory'.")
self.host = host
self.port = port
self.version = version
if http_url:
# Parse the provided URL to extract components for mounting
self.public_base_url, self.mount_path = self._parse_public_url(http_url)
self.http_url = http_url.rstrip("/") + "/"
self._http_url_explicit = True
# Override mount path if serve_at_root is requested
if serve_at_root:
self.mount_path = ""
else:
# Fall back to constructing the URL from host and port
self.public_base_url = f"http://{host}:{port}"
self.http_url = f"{self.public_base_url}/"
self.mount_path = ""
self._http_url_explicit = False
# The agent used to derive card metadata (name/description/skills). With a factory, build
# a representative agent once; per-request agents are created lazily by the executor.
self.strands_agent = agent if agent is not None else agent_factory(_AGENT_CARD_CONTEXT_ID) # type: ignore[misc]
self.name = self.strands_agent.name
self.description = self.strands_agent.description
self.capabilities = AgentCapabilities(streaming=True)
self.request_handler = DefaultRequestHandler(
agent_executor=StrandsA2AExecutor(
agent,
agent_factory=agent_factory,
enable_a2a_compliant_streaming=enable_a2a_compliant_streaming,
max_contexts=max_contexts,
),
task_store=task_store or InMemoryTaskStore(),
queue_manager=queue_manager,
push_config_store=push_config_store,
push_sender=push_sender,
)
self._agent_skills = skills
self._agent_card_url: str | None = None
logger.info("Strands' integration with A2A is experimental. Be aware of frequent breaking changes.")
def _parse_public_url(self, url: str) -> tuple[str, str]:
"""Parse the public URL into base URL and mount path components.
Args:
url: The full public URL (e.g., "http://my-alb.amazonaws.com/agent1")
Returns:
tuple: (base_url, mount_path) where base_url is the scheme+netloc
and mount_path is the path component
Example:
_parse_public_url("http://my-alb.amazonaws.com/agent1")
Returns: ("http://my-alb.amazonaws.com", "/agent1")
"""
parsed = urlparse(url.rstrip("/"))
base_url = f"{parsed.scheme}://{parsed.netloc}"
mount_path = parsed.path if parsed.path != "/" else ""
return base_url, mount_path
@property
def public_agent_card(self) -> AgentCard:
"""Get the public AgentCard for this agent.
The AgentCard contains metadata about the agent, including its name,
description, URL, version, skills, and capabilities. This information
is used by other agents and systems to discover and interact with this agent.
Returns:
AgentCard: The public agent card containing metadata about this agent.
Raises:
ValueError: If name or description is None or empty.
"""
if not self.name:
raise ValueError("A2A agent name cannot be None or empty")
if not self.description:
raise ValueError("A2A agent description cannot be None or empty")
return AgentCard(
name=self.name,
description=self.description,
url=self.agent_card_url,
version=self.version,
skills=self.agent_skills,
default_input_modes=["text"],
default_output_modes=["text"],
capabilities=self.capabilities,
)
def _get_skills_from_tools(self) -> list[AgentSkill]:
"""Get the list of skills from Strands agent tools.
Skills represent specific capabilities that the agent can perform.
Strands agent tools are adapted to A2A skills.
Returns:
list[AgentSkill]: A list of skills this agent provides.
"""
return [
AgentSkill(name=config["name"], id=config["name"], description=config["description"], tags=[])
for config in self.strands_agent.tool_registry.get_all_tools_config().values()
]
@property
def agent_card_url(self) -> str:
"""Get the URL advertised in the AgentCard.
Defaults to http_url. Can be overridden to advertise a custom URL
(e.g., without trailing slash or with a different base).
"""
return self._agent_card_url if self._agent_card_url is not None else self.http_url
@agent_card_url.setter
def agent_card_url(self, url: str) -> None:
"""Override the URL advertised in the AgentCard.
Args:
url: The URL to advertise in the AgentCard.
"""
self._agent_card_url = url
@property
def agent_skills(self) -> list[AgentSkill]:
"""Get the list of skills this agent provides."""
return self._agent_skills if self._agent_skills is not None else self._get_skills_from_tools()
@agent_skills.setter
def agent_skills(self, skills: list[AgentSkill]) -> None:
"""Set the list of skills this agent provides.
Args:
skills: A list of AgentSkill objects to set for this agent.
"""
self._agent_skills = skills
def to_starlette_app(self, *, app_kwargs: dict[str, Any] | None = None) -> Starlette:
"""Create a Starlette application for serving this agent via HTTP.
Automatically handles path-based mounting if a mount path was derived
from the http_url parameter.
Args:
app_kwargs: Additional keyword arguments to pass to the Starlette constructor.
Returns:
Starlette: A Starlette application configured to serve this agent.
"""
a2a_app = A2AStarletteApplication(agent_card=self.public_agent_card, http_handler=self.request_handler).build(
**app_kwargs or {}
)
if self.mount_path:
# Create parent app and mount the A2A app at the specified path
parent_app = Starlette()
parent_app.mount(self.mount_path, a2a_app)
logger.info("Mounting A2A server at path: %s", self.mount_path)
return parent_app
return a2a_app
def to_fastapi_app(self, *, app_kwargs: dict[str, Any] | None = None) -> FastAPI:
"""Create a FastAPI application for serving this agent via HTTP.
Automatically handles path-based mounting if a mount path was derived
from the http_url parameter.
Args:
app_kwargs: Additional keyword arguments to pass to the FastAPI constructor.
Returns:
FastAPI: A FastAPI application configured to serve this agent.
"""
a2a_app = A2AFastAPIApplication(agent_card=self.public_agent_card, http_handler=self.request_handler).build(
**app_kwargs or {}
)
if self.mount_path:
# Create parent app and mount the A2A app at the specified path
parent_app = FastAPI()
parent_app.mount(self.mount_path, a2a_app)
logger.info("Mounting A2A server at path: %s", self.mount_path)
return parent_app
return a2a_app
def serve(
self,
app_type: Literal["fastapi", "starlette"] = "starlette",
*,
host: str | None = None,
port: int | None = None,
**kwargs: Any,
) -> None:
"""Start the A2A server with the specified application type.
This method starts an HTTP server that exposes the agent via the A2A protocol.
The server can be implemented using either FastAPI or Starlette, depending on
the specified app_type.
Args:
app_type: The type of application to serve, either "fastapi" or "starlette".
Defaults to "starlette".
host: The host address to bind the server to. Defaults to "0.0.0.0".
port: The port number to bind the server to. Defaults to 9000.
**kwargs: Additional keyword arguments to pass to uvicorn.run.
"""
# Update host/port if overridden, and recalculate URLs if http_url wasn't explicitly set
if host is not None:
self.host = host
if port is not None:
self.port = port
if host is not None or port is not None:
# Only update the URL if it wasn't explicitly set via http_url parameter
# (i.e., if the URL was auto-generated from host/port in __init__)
if not self._http_url_explicit:
self.public_base_url = f"http://{self.host}:{self.port}"
self.http_url = f"{self.public_base_url}/"
try:
logger.info("Starting Strands A2A server...")
if app_type == "fastapi":
uvicorn.run(self.to_fastapi_app(), host=self.host, port=self.port, **kwargs)
else:
uvicorn.run(self.to_starlette_app(), host=self.host, port=self.port, **kwargs)
except KeyboardInterrupt:
logger.warning("Strands A2A server shutdown requested (KeyboardInterrupt).")
except Exception:
logger.exception("Strands A2A server encountered exception.")
finally:
logger.info("Strands A2A server has shutdown.")