Ctrl K
ComunidadGuías…

test/a2a/_helpers.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Copyright (c) 2026, AG2ai, Inc., AG2ai open-source projects maintainers and core contributors
#
# SPDX-License-Identifier: Apache-2.0

import asyncio
from collections.abc import Awaitable, Callable, Iterable
from dataclasses import dataclass
from typing import Any
from uuid import uuid4

import grpc.aio
import httpx
from a2a.server.agent_execution import AgentExecutor as A2AAgentExecutorBase
from a2a.server.agent_execution import RequestContext
from a2a.server.events import EventQueue
from a2a.server.tasks import (
    PushNotificationConfigStore,
    TaskStore,
    TaskUpdater,
)
from a2a.types import Part, Task, TaskState, TaskStatus

from ag2 import Agent
from ag2.a2a import A2AConfig, A2AServer, build_card
from ag2.a2a.testing import (
    ToolRoundsConfig,
    make_test_client_factory,
    make_test_rest_client_factory,
    pick_free_port,
)
from ag2.events import (
    BaseEvent,
    ModelMessageChunk,
)
from ag2.testing import TestConfig, TrackingConfig, Turn


def a2a_config(agent: Agent) -> A2AConfig:
    """The agent's ``A2AConfig``.

    ``Agent.config`` is declared ``ModelConfig | None``, but the task and
    push-notification admin helpers take an ``A2AConfig`` — narrow once here
    instead of handing them an unchecked union at every call site.
    """
    config = agent.config
    assert isinstance(config, A2AConfig), f"{agent.name} is not backed by an A2AConfig"
    return config


@dataclass(slots=True)
class A2APair:
    server: A2AServer
    server_agent: Agent
    client: Agent
    tracking: TrackingConfig


@dataclass(slots=True)
class ExecutorPair:
    server: A2AServer
    executor: A2AAgentExecutorBase
    client: Agent


@dataclass(slots=True)
class GrpcPair:
    server: A2AServer
    server_agent: Agent
    client: Agent
    tracking: TrackingConfig
    grpc_url: str
    grpc_server: grpc.aio.Server


class PromptThenAckExecutor(A2AAgentExecutorBase):
    def __init__(self, prompt: str) -> None:
        self._prompt = prompt
        self.received_user_text: str | None = None

    async def execute(self, request_context: RequestContext, event_queue: EventQueue) -> None:
        msg = request_context.message
        if msg is None:
            return
        task_id = msg.task_id or uuid4().hex
        context_id = msg.context_id or uuid4().hex
        updater = TaskUpdater(event_queue, task_id, context_id)

        if request_context.current_task is None:
            await event_queue.enqueue_event(
                Task(
                    id=task_id,
                    context_id=context_id,
                    status=TaskStatus(state=TaskState.TASK_STATE_SUBMITTED),
                ),
            )
            await updater.start_work()
            await updater.requires_input(
                message=updater.new_agent_message(parts=[Part(text=self._prompt)]),
            )
            return

        text = "".join(p.text for p in msg.parts if p.text)
        self.received_user_text = text
        await updater.complete(
            message=updater.new_agent_message(parts=[Part(text=f"echo: {text}")]),
        )

    async def cancel(self, request_context: RequestContext, event_queue: EventQueue) -> None:
        task = request_context.current_task
        if task is None:
            return
        updater = TaskUpdater(event_queue, task.id, task.context_id)
        await updater.cancel()


class Switchboard(httpx.AsyncBaseTransport):
    """Send each request to whichever replica is `active` — a load balancer that changes its mind."""

    def __init__(self, *servers: A2AServer, url: str) -> None:
        self._transports = [httpx.ASGITransport(app=server.build_jsonrpc(url=url)) for server in servers]
        self.active = 0

    async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
        return await self._transports[self.active].handle_async_request(request)


class GatedExecutor(A2AAgentExecutorBase):
    """Starts a task, then holds it `working` until the test opens the gate."""

    def __init__(self) -> None:
        self.gate = asyncio.Event()
        self.working = asyncio.Event()
        self.task_id = ""

    async def execute(self, request_context: RequestContext, event_queue: EventQueue) -> None:
        task_id, context_id = request_context.task_id or uuid4().hex, request_context.context_id or uuid4().hex
        self.task_id = task_id
        updater = TaskUpdater(event_queue, task_id, context_id)
        await event_queue.enqueue_event(
            Task(id=task_id, context_id=context_id, status=TaskStatus(state=TaskState.TASK_STATE_SUBMITTED))
        )
        await updater.start_work()
        self.working.set()
        await self.gate.wait()
        await updater.complete(message=updater.new_agent_message(parts=[Part(text="finished")]))

    async def cancel(self, request_context: RequestContext, event_queue: EventQueue) -> None:
        raise NotImplementedError


def _tool_rounds(initial: Turn, after_tool: "Turn | None") -> ToolRoundsConfig:
    """The server answers with ``initial``, then with ``after_tool`` once the tool results come back."""
    return ToolRoundsConfig(initial, after_tool if after_tool is not None else "")


def make_pair(
    initial: Turn,
    after_tool: "Turn | None" = None,
    *,
    server_tools: Iterable[Callable[..., object]] = (),
    client_tools: Iterable[Callable[..., object]] = (),
    server_url: str = "http://test",
    streaming: bool = True,
    task_store: TaskStore | None = None,
    push_config_store: PushNotificationConfigStore | None = None,
) -> A2APair:
    tracking = TrackingConfig(_tool_rounds(initial, after_tool))
    server_agent = Agent("server-agent", config=tracking)
    for tool in server_tools:
        server_agent.tool(tool)

    server_kwargs: dict[str, Any] = {}
    if task_store is not None:
        server_kwargs["task_store"] = task_store
    if push_config_store is not None:
        server_kwargs["push_config_store"] = push_config_store

    server = A2AServer(server_agent, **server_kwargs)
    factory = make_test_client_factory(server, url=server_url)

    client_config = A2AConfig(
        card_url=server_url,
        httpx_client_factory=factory,
        streaming=streaming,
    )
    client_agent = Agent("client-agent", config=client_config)
    for tool in client_tools:
        client_agent.tool(tool)

    return A2APair(server=server, server_agent=server_agent, client=client_agent, tracking=tracking)


def make_executor_pair(
    executor: A2AAgentExecutorBase,
    *,
    server_url: str = "http://test",
    streaming: bool = False,
    task_store: TaskStore | None = None,
    push_config_store: PushNotificationConfigStore | None = None,
    hitl_hook: Callable[..., Any] | None = None,
) -> ExecutorPair:
    server_agent = Agent("server-stub", config=TestConfig("unused"))

    server_kwargs: dict[str, Any] = {}
    if task_store is not None:
        server_kwargs["task_store"] = task_store
    if push_config_store is not None:
        server_kwargs["push_config_store"] = push_config_store

    server = A2AServer(server_agent, executor=executor, **server_kwargs)
    factory = make_test_client_factory(server, url=server_url)

    client_kwargs: dict[str, Any] = {}
    if hitl_hook is not None:
        client_kwargs["hitl_hook"] = hitl_hook

    client = Agent(
        "client",
        config=A2AConfig(card_url=server_url, httpx_client_factory=factory, streaming=streaming),
        **client_kwargs,
    )
    return ExecutorPair(server=server, executor=executor, client=client)


def make_rest_pair(
    initial: Turn,
    after_tool: "Turn | None" = None,
    *,
    server_url: str = "http://test",
    streaming: bool = False,
) -> A2APair:
    tracking = TrackingConfig(_tool_rounds(initial, after_tool))
    server_agent = Agent("server-agent", config=tracking)
    server = A2AServer(server_agent)
    factory = make_test_rest_client_factory(server, url=server_url)

    client = Agent(
        "client-agent",
        config=A2AConfig(
            card_url=server_url,
            httpx_client_factory=factory,
            prefer="rest",
            streaming=streaming,
        ),
    )
    return A2APair(server=server, server_agent=server_agent, client=client, tracking=tracking)


async def start_grpc_pair(
    initial: Turn,
    after_tool: "Turn | None" = None,
    *,
    host: str = "127.0.0.1",
    streaming: bool = False,
) -> GrpcPair:
    tracking = TrackingConfig(_tool_rounds(initial, after_tool))
    server_agent = Agent("server-agent", config=tracking)
    server = A2AServer(server_agent)

    grpc_url = f"{host}:{pick_free_port(host)}"
    card = build_card(server_agent, url=grpc_url, transports=("grpc",), grpc_url=grpc_url)
    grpc_server = server.build_grpc(bind=grpc_url, grpc_url=grpc_url, card=card)
    await grpc_server.start()

    client = Agent(
        "client-agent",
        config=A2AConfig(
            card_url=grpc_url,
            preset_card=card,
            prefer="grpc",
            streaming=streaming,
        ),
    )
    return GrpcPair(
        server=server,
        server_agent=server_agent,
        client=client,
        tracking=tracking,
        grpc_url=grpc_url,
        grpc_server=grpc_server,
    )


_Script = Callable[[TaskUpdater, asyncio.Event], Awaitable[None]]


class ScriptedExecutor(A2AAgentExecutorBase):
    """Runs a fresh task through ``script``; ``release`` is opened by the test, never by a timer."""

    def __init__(self, script: _Script) -> None:
        self._script = script
        self.release = asyncio.Event()

    async def execute(self, request_context: RequestContext, event_queue: EventQueue) -> None:
        msg = request_context.message
        assert msg is not None
        task_id = msg.task_id or uuid4().hex
        context_id = msg.context_id or uuid4().hex
        await event_queue.enqueue_event(
            Task(id=task_id, context_id=context_id, status=TaskStatus(state=TaskState.TASK_STATE_SUBMITTED)),
        )
        updater = TaskUpdater(event_queue, task_id, context_id)
        await updater.start_work()
        await self._script(updater, self.release)

    async def cancel(self, request_context: RequestContext, event_queue: EventQueue) -> None:
        return None


class EventCollector:
    """Subscriber that records the events it is given; ``on_event`` is handed to ``stream.subscribe``."""

    def __init__(self) -> None:
        self.events: list[BaseEvent] = []

    async def on_event(self, event: BaseEvent) -> None:
        self.events.append(event)

    @property
    def chunks(self) -> list[str]:
        return [e.content for e in self.events if isinstance(e, ModelMessageChunk)]


_Turn = Callable[[TaskUpdater], Awaitable[None]]


async def _complete_with_ok(updater: TaskUpdater) -> None:
    await updater.complete(message=updater.new_agent_message([Part(text="ok")]))


class ToolRoundTripExecutor(A2AAgentExecutorBase):
    """Runs ``first_turn`` for a new task and ``continuation`` once the client answers its request for input."""

    def __init__(self, first_turn: _Turn, continuation: _Turn = _complete_with_ok) -> None:
        self._first_turn = first_turn
        self._continuation = continuation

    async def execute(self, request_context: RequestContext, event_queue: EventQueue) -> None:
        msg = request_context.message
        assert msg is not None
        resumed = request_context.current_task is not None
        task_id = msg.task_id or uuid4().hex
        context_id = msg.context_id or uuid4().hex
        if not resumed:
            await event_queue.enqueue_event(
                Task(id=task_id, context_id=context_id, status=TaskStatus(state=TaskState.TASK_STATE_SUBMITTED)),
            )
        updater = TaskUpdater(event_queue, task_id, context_id)
        await updater.start_work()
        await (self._continuation if resumed else self._first_turn)(updater)

    async def cancel(self, request_context: RequestContext, event_queue: EventQueue) -> None:
        return None