源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
Creator App Tests / PR Spam Gate (push) Skipped
Frontend Tests / PR Spam Gate (push) Skipped
NPM Format (website & console) / PR Spam Gate (push) Skipped
Pre-commit Checks / PR Spam Gate (push) Skipped
QwenPaw Creator Plugin Release / check-version (push) Canceled after 0s
QwenPaw Creator Plugin Release / upload-oss (push) Canceled after 0s
Tests / PR Spam Gate (push) Skipped
CodeQL / CodeQL (python) (push) Canceled after 0s
CodeQL / CodeQL (typescript) (push) Canceled after 0s
Creator App Tests / Creator Backend Tests (push) Canceled after 0s
Creator App Tests / Creator UI Build & Tests (push) Canceled after 0s
E2E Smoke Tests / Playwright UI Smoke (push) Canceled after 0s
Frontend Tests / Detect console changes (push) Canceled after 0s
NPM Format (website & console) / website format check (push) Canceled after 0s
NPM Format (website & console) / console format check (push) Canceled after 0s
Pre-commit Checks / Pre-commit (push) Canceled after 0s
QwenPaw Data Plugin Release / check-version (push) Canceled after 0s
Tests / Detect code changes (push) Canceled after 0s
Frontend Tests / Vitest Unit Tests (push) Canceled after 0s
Frontend Tests / Frontend Summary (push) Canceled after 0s
QwenPaw Data Plugin Release / upload-oss (push) Canceled after 0s
Tests / Maintainer Approval (push) Canceled after 0s
Tests / Unit Tests - py3.11 - macos-latest (push) Canceled after 0s
Tests / Unit Tests - py3.11 - ubuntu-latest (push) Canceled after 0s
Tests / Unit Tests - py3.13 - ubuntu-latest (push) Canceled after 0s
Tests / Unit Tests - py3.11 - windows-latest (push) Canceled after 0s
Tests / Contract Tests - py3.11 - macos-latest (push) Canceled after 0s
Tests / Contract Tests - py3.11 - ubuntu-latest (push) Canceled after 0s
Tests / Contract Tests - py3.13 - ubuntu-latest (push) Canceled after 0s
Tests / Contract Tests - py3.11 - windows-latest (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - macos-latest - p0 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - macos-latest - p1 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - macos-latest - p2 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - ubuntu-latest - fallback (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - ubuntu-latest - p0 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - ubuntu-latest - p1 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - ubuntu-latest - p2 (push) Canceled after 0s
Tests / Integrated Tests - py3.13 - ubuntu-latest - p0 (push) Canceled after 0s
Tests / Integrated Tests - py3.13 - ubuntu-latest - p1 (push) Canceled after 0s
Tests / Integrated Tests - py3.13 - ubuntu-latest - p2 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - windows-latest - p0 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - windows-latest - p1 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - windows-latest - p2 (push) Canceled after 0s
Tests / Coverage Report (push) Canceled after 0s
Tests / Test Summary (push) Canceled after 0s
Creator App Tests / PR Spam Gate (push) Skipped
Frontend Tests / PR Spam Gate (push) Skipped
NPM Format (website & console) / PR Spam Gate (push) Skipped
Pre-commit Checks / PR Spam Gate (push) Skipped
QwenPaw Creator Plugin Release / check-version (push) Canceled after 0s
QwenPaw Creator Plugin Release / upload-oss (push) Canceled after 0s
Tests / PR Spam Gate (push) Skipped
CodeQL / CodeQL (python) (push) Canceled after 0s
CodeQL / CodeQL (typescript) (push) Canceled after 0s
Creator App Tests / Creator Backend Tests (push) Canceled after 0s
Creator App Tests / Creator UI Build & Tests (push) Canceled after 0s
E2E Smoke Tests / Playwright UI Smoke (push) Canceled after 0s
Frontend Tests / Detect console changes (push) Canceled after 0s
NPM Format (website & console) / website format check (push) Canceled after 0s
NPM Format (website & console) / console format check (push) Canceled after 0s
Pre-commit Checks / Pre-commit (push) Canceled after 0s
QwenPaw Data Plugin Release / check-version (push) Canceled after 0s
Tests / Detect code changes (push) Canceled after 0s
Frontend Tests / Vitest Unit Tests (push) Canceled after 0s
Frontend Tests / Frontend Summary (push) Canceled after 0s
QwenPaw Data Plugin Release / upload-oss (push) Canceled after 0s
Tests / Maintainer Approval (push) Canceled after 0s
Tests / Unit Tests - py3.11 - macos-latest (push) Canceled after 0s
Tests / Unit Tests - py3.11 - ubuntu-latest (push) Canceled after 0s
Tests / Unit Tests - py3.13 - ubuntu-latest (push) Canceled after 0s
Tests / Unit Tests - py3.11 - windows-latest (push) Canceled after 0s
Tests / Contract Tests - py3.11 - macos-latest (push) Canceled after 0s
Tests / Contract Tests - py3.11 - ubuntu-latest (push) Canceled after 0s
Tests / Contract Tests - py3.13 - ubuntu-latest (push) Canceled after 0s
Tests / Contract Tests - py3.11 - windows-latest (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - macos-latest - p0 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - macos-latest - p1 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - macos-latest - p2 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - ubuntu-latest - fallback (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - ubuntu-latest - p0 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - ubuntu-latest - p1 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - ubuntu-latest - p2 (push) Canceled after 0s
Tests / Integrated Tests - py3.13 - ubuntu-latest - p0 (push) Canceled after 0s
Tests / Integrated Tests - py3.13 - ubuntu-latest - p1 (push) Canceled after 0s
Tests / Integrated Tests - py3.13 - ubuntu-latest - p2 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - windows-latest - p0 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - windows-latest - p1 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - windows-latest - p2 (push) Canceled after 0s
Tests / Coverage Report (push) Canceled after 0s
Tests / Test Summary (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,416 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""A2A Client Manager: manage A2A 1.0 connections without SDK dependency.
|
||||
|
||||
Implements the A2A 1.0 JSON-RPC + SSE protocol directly using httpx
|
||||
and httpx-sse, providing a unified interface for both generic A2A
|
||||
and intelligent gateway scenarios.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass, field
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import httpx_sse
|
||||
|
||||
from .auth_interceptor import get_auth_headers
|
||||
from .gateway_adapter import (
|
||||
normalize_gateway_card,
|
||||
patch_card_url,
|
||||
)
|
||||
from .gateway_token import GatewayTokenProvider
|
||||
|
||||
logger = logging.getLogger("qwenpaw").getChild(
|
||||
__name__.replace("plugin_cloudpaw.", ""),
|
||||
)
|
||||
|
||||
_CARD_CACHE_TTL = 3600 # 1 hour
|
||||
_DEFAULT_TIMEOUT = 60.0
|
||||
_A2A_VERSION = "1.0.0"
|
||||
|
||||
|
||||
@dataclass
|
||||
class _AgentEntry:
|
||||
"""Internal state for a connected agent."""
|
||||
|
||||
url: str
|
||||
auth_type: str
|
||||
auth_token: str = ""
|
||||
card: dict | None = None
|
||||
card_fetched_at: float = 0
|
||||
rpc_url: str = ""
|
||||
token_provider: GatewayTokenProvider | None = None
|
||||
gateway_config: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
class A2AClientManager:
|
||||
"""Manage A2A connections for multiple remote agents.
|
||||
|
||||
Usage::
|
||||
|
||||
manager = A2AClientManager()
|
||||
info = await manager.connect("https://agent.example.com")
|
||||
async for event in manager.send_message(
|
||||
"https://agent.example.com",
|
||||
"hello",
|
||||
):
|
||||
print(event)
|
||||
await manager.close()
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._agents: dict[str, _AgentEntry] = {}
|
||||
self._http: httpx.AsyncClient | None = None
|
||||
|
||||
async def connect(
|
||||
self,
|
||||
agent_url: str,
|
||||
auth_type: str = "",
|
||||
auth_token: str = "",
|
||||
gateway_config: dict | None = None,
|
||||
) -> dict:
|
||||
"""Discover Agent Card and establish authenticated connection.
|
||||
|
||||
Args:
|
||||
agent_url: Remote A2A Agent base URL.
|
||||
auth_type: "bearer", "api_key", "gateway", or "" (none).
|
||||
auth_token: Token/key value (not needed for gateway).
|
||||
gateway_config: Optional gateway-specific config overrides.
|
||||
|
||||
Returns:
|
||||
dict with agent card summary and connection status.
|
||||
"""
|
||||
entry = self._agents.get(agent_url)
|
||||
if entry and entry.card and not self._card_expired(entry):
|
||||
logger.info("Reusing cached connection for %s", agent_url)
|
||||
return self._card_summary(entry)
|
||||
|
||||
entry = _AgentEntry(
|
||||
url=agent_url,
|
||||
auth_type=auth_type,
|
||||
auth_token=auth_token,
|
||||
gateway_config=gateway_config or {},
|
||||
)
|
||||
|
||||
card = await self._fetch_agent_card(agent_url, auth_type)
|
||||
entry.card = card
|
||||
entry.card_fetched_at = time.time()
|
||||
|
||||
if auth_type == "gateway":
|
||||
card = patch_card_url(card)
|
||||
entry.card = card
|
||||
gw_cfg = gateway_config or {}
|
||||
entry.token_provider = GatewayTokenProvider(
|
||||
client_id=gw_cfg.get("client_id", "4081417976505782102"),
|
||||
scope=gw_cfg.get("scope", "/internal/agenthub"),
|
||||
endpoint=gw_cfg.get("endpoint", "ramoauth.aliyuncs.com"),
|
||||
)
|
||||
logger.info("Applied gateway URL patch for %s", agent_url)
|
||||
|
||||
entry.rpc_url = card.get("url", agent_url)
|
||||
|
||||
self._agents[agent_url] = entry
|
||||
logger.info("Connected to agent: %s (auth=%s)", agent_url, auth_type)
|
||||
return self._card_summary(entry)
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
agent_url: str,
|
||||
message: str,
|
||||
context_id: str = "",
|
||||
streaming: bool = True,
|
||||
) -> AsyncIterator[dict]:
|
||||
"""Send a message to a remote agent and yield response events.
|
||||
|
||||
Args:
|
||||
agent_url: Agent URL (must have been connected first).
|
||||
message: Text message to send.
|
||||
context_id: Optional conversation context ID.
|
||||
streaming: Whether to use streaming (SSE) mode.
|
||||
|
||||
Yields:
|
||||
dict with event data (type, task, statusUpdate, etc.)
|
||||
"""
|
||||
entry = await self._ensure_connected(agent_url)
|
||||
|
||||
msg_id = str(uuid4())
|
||||
parts = [{"text": message}]
|
||||
msg_obj: dict = {
|
||||
"messageId": msg_id,
|
||||
"role": "ROLE_USER",
|
||||
"parts": parts,
|
||||
}
|
||||
if context_id:
|
||||
msg_obj["contextId"] = context_id
|
||||
|
||||
method = "SendStreamingMessage" if streaming else "SendMessage"
|
||||
payload = self._build_jsonrpc(method, {"message": msg_obj})
|
||||
|
||||
auth_headers = await get_auth_headers(
|
||||
entry.auth_type,
|
||||
entry.auth_token,
|
||||
entry.token_provider,
|
||||
)
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"A2A-Version": _A2A_VERSION,
|
||||
**auth_headers,
|
||||
}
|
||||
|
||||
http = self._get_http(entry)
|
||||
|
||||
if streaming:
|
||||
async for event_data in self._stream_sse(
|
||||
http,
|
||||
entry.rpc_url,
|
||||
payload,
|
||||
headers,
|
||||
):
|
||||
yield self._classify_event(event_data)
|
||||
else:
|
||||
resp = await http.post(
|
||||
entry.rpc_url,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
body = resp.json()
|
||||
if "error" in body:
|
||||
raise RuntimeError(
|
||||
f"A2A error {body['error'].get('code')}: "
|
||||
f"{body['error'].get('message')}",
|
||||
)
|
||||
result = body.get("result", {})
|
||||
yield self._classify_event(result)
|
||||
|
||||
async def get_card_info(self, agent_url: str) -> dict | None:
|
||||
"""Get cached card info for a connected agent."""
|
||||
entry = self._agents.get(agent_url)
|
||||
if entry and entry.card:
|
||||
return self._card_summary(entry)
|
||||
return None
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close all connections."""
|
||||
if self._http:
|
||||
await self._http.aclose()
|
||||
self._http = None
|
||||
self._agents.clear()
|
||||
logger.info("A2AClientManager closed all connections")
|
||||
|
||||
async def disconnect(self, agent_url: str) -> None:
|
||||
"""Disconnect and remove a specific agent."""
|
||||
self._agents.pop(agent_url, None)
|
||||
|
||||
def list_connected(self) -> list[dict]:
|
||||
"""List all connected agents with their card summaries."""
|
||||
return [
|
||||
self._card_summary(entry)
|
||||
for entry in self._agents.values()
|
||||
if entry.card
|
||||
]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _ensure_connected(self, agent_url: str) -> _AgentEntry:
|
||||
entry = self._agents.get(agent_url)
|
||||
if entry and entry.card and not self._card_expired(entry):
|
||||
return entry
|
||||
await self.connect(agent_url)
|
||||
entry = self._agents.get(agent_url)
|
||||
if not entry or not entry.card:
|
||||
raise RuntimeError(f"Failed to connect to {agent_url}")
|
||||
return entry
|
||||
|
||||
async def _fetch_agent_card(
|
||||
self,
|
||||
agent_url: str,
|
||||
auth_type: str,
|
||||
) -> dict:
|
||||
"""Fetch and parse Agent Card JSON from remote endpoint."""
|
||||
is_gateway = auth_type == "gateway"
|
||||
verify_ssl = not is_gateway
|
||||
|
||||
card_url = f"{agent_url.rstrip('/')}/.well-known/agent-card.json"
|
||||
async with httpx.AsyncClient(
|
||||
verify=verify_ssl,
|
||||
timeout=30.0,
|
||||
) as http:
|
||||
resp = await http.get(
|
||||
card_url,
|
||||
headers={"A2A-Version": _A2A_VERSION},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
card = resp.json()
|
||||
|
||||
if is_gateway:
|
||||
card = normalize_gateway_card(card)
|
||||
|
||||
if not card.get("url"):
|
||||
card["url"] = agent_url
|
||||
|
||||
logger.info(
|
||||
"Agent Card fetched for %s: %s",
|
||||
agent_url,
|
||||
card.get("name"),
|
||||
)
|
||||
return card
|
||||
|
||||
def _get_http(self, entry: _AgentEntry) -> httpx.AsyncClient:
|
||||
"""Get or create a shared httpx client."""
|
||||
if self._http is None or self._http.is_closed:
|
||||
verify = entry.auth_type != "gateway"
|
||||
self._http = httpx.AsyncClient(
|
||||
verify=verify,
|
||||
timeout=_DEFAULT_TIMEOUT,
|
||||
)
|
||||
return self._http
|
||||
|
||||
async def _stream_sse(
|
||||
self,
|
||||
http: httpx.AsyncClient,
|
||||
url: str,
|
||||
payload: dict,
|
||||
headers: dict,
|
||||
) -> AsyncIterator[dict]:
|
||||
"""Send JSON-RPC request and yield parsed SSE event data.
|
||||
|
||||
Some A2A servers (e.g. the Alibaba Cloud gateway) may respond
|
||||
with Content-Type: application/json even for streaming requests.
|
||||
We handle both SSE and plain JSON responses gracefully.
|
||||
"""
|
||||
resp = await http.send(
|
||||
http.build_request("POST", url, json=payload, headers=headers),
|
||||
stream=True,
|
||||
)
|
||||
try:
|
||||
resp.raise_for_status()
|
||||
content_type = resp.headers.get("content-type", "")
|
||||
|
||||
if "text/event-stream" in content_type:
|
||||
event_source = httpx_sse.EventSource(resp)
|
||||
async for sse_event in event_source.aiter_sse():
|
||||
if sse_event.data:
|
||||
try:
|
||||
data = json.loads(sse_event.data)
|
||||
if "error" in data:
|
||||
err = data["error"]
|
||||
logger.warning(
|
||||
"SSE error: %s - %s",
|
||||
err.get("code"),
|
||||
err.get("message"),
|
||||
)
|
||||
result = data.get("result", data)
|
||||
yield result
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(
|
||||
"Non-JSON SSE event: %s",
|
||||
sse_event.data[:200],
|
||||
)
|
||||
else:
|
||||
body = await resp.aread()
|
||||
data = json.loads(body)
|
||||
if "error" in data:
|
||||
err = data["error"]
|
||||
raise RuntimeError(
|
||||
f"A2A error {err.get('code')}: {err.get('message')}",
|
||||
)
|
||||
result = data.get("result", data)
|
||||
yield result
|
||||
finally:
|
||||
await resp.aclose()
|
||||
|
||||
@staticmethod
|
||||
def _build_jsonrpc(method: str, params: dict) -> dict:
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": method,
|
||||
"params": params,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _classify_event(data: dict) -> dict:
|
||||
"""Add a 'type' field to an event dict for downstream processing."""
|
||||
if "task" in data:
|
||||
return {"type": "task", **data}
|
||||
if "statusUpdate" in data:
|
||||
return {"type": "status_update", **data}
|
||||
if "artifactUpdate" in data:
|
||||
return {"type": "artifact_update", **data}
|
||||
if "message" in data:
|
||||
return {"type": "message", **data}
|
||||
return {"type": "unknown", **data}
|
||||
|
||||
@staticmethod
|
||||
def _card_expired(entry: _AgentEntry) -> bool:
|
||||
return (time.time() - entry.card_fetched_at) > _CARD_CACHE_TTL
|
||||
|
||||
@staticmethod
|
||||
def _card_summary(entry: _AgentEntry) -> dict:
|
||||
card = entry.card
|
||||
if not card:
|
||||
return {"url": entry.url, "status": "not_connected"}
|
||||
|
||||
skills = []
|
||||
for s in card.get("skills", []):
|
||||
skill_info = {"name": s.get("name", "")}
|
||||
desc = s.get("description")
|
||||
if desc:
|
||||
skill_info["description"] = desc
|
||||
skills.append(skill_info)
|
||||
|
||||
interfaces = []
|
||||
for iface in card.get("additionalInterfaces", []):
|
||||
interfaces.append(
|
||||
{
|
||||
"url": iface.get("url", ""),
|
||||
"protocol_binding": iface.get("preferredTransport", ""),
|
||||
"protocol_version": iface.get("protocolVersion", ""),
|
||||
},
|
||||
)
|
||||
|
||||
caps = card.get("capabilities", {})
|
||||
return {
|
||||
"url": entry.url,
|
||||
"status": "connected",
|
||||
"auth_type": entry.auth_type,
|
||||
"name": card.get("name", ""),
|
||||
"description": card.get("description", ""),
|
||||
"version": card.get("version", ""),
|
||||
"skills": skills,
|
||||
"interfaces": interfaces,
|
||||
"capabilities": {
|
||||
"streaming": caps.get("streaming", False),
|
||||
"push_notifications": bool(
|
||||
caps.get("pushNotifications")
|
||||
or caps.get("pushNotificationConfig"),
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
_manager_instance: A2AClientManager | None = None
|
||||
|
||||
|
||||
def get_a2a_manager() -> A2AClientManager:
|
||||
"""Get or create the singleton A2AClientManager instance."""
|
||||
global _manager_instance
|
||||
if _manager_instance is None:
|
||||
_manager_instance = A2AClientManager()
|
||||
return _manager_instance
|
||||
|
||||
|
||||
async def shutdown_a2a_manager() -> None:
|
||||
"""Shutdown the singleton manager if it exists."""
|
||||
global _manager_instance
|
||||
if _manager_instance is not None:
|
||||
await _manager_instance.close()
|
||||
_manager_instance = None
|
||||
Reference in New Issue
Block a user