源码快照 @ 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

This commit is contained in:
agent-radar
2026-09-01 20:01:50 +08:00
commit 1a3a4ccde1
4652 changed files with 1891610 additions and 0 deletions
@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
"""CloudPaw plugin modules."""
@@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
"""A2A client module for CloudPaw.
Provides Agent-to-Agent communication capabilities:
- GatewayTokenProvider: AK-SK → Bearer Token
- BearerTokenInterceptor: SDK interceptor for auth injection
- GatewayAdapter: URL /rpc + A2A-Version adaptations
- A2AClientManager: Client lifecycle and connection management
"""
@@ -0,0 +1,37 @@
# -*- coding: utf-8 -*-
"""Auth token injection helpers for A2A requests.
Provides simple functions for building auth headers instead of SDK
interceptors. Used by A2AClientManager to inject Bearer Token into
httpx requests.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .gateway_token import GatewayTokenProvider
async def get_auth_headers(
auth_type: str,
auth_token: str = "",
token_provider: "GatewayTokenProvider | None" = None,
) -> dict[str, str]:
"""Build Authorization headers based on auth type.
Args:
auth_type: "bearer", "api_key", "gateway", or "".
auth_token: Static token for bearer/api_key.
token_provider: Dynamic provider for gateway mode.
Returns:
Dict of HTTP headers to merge into requests.
"""
if auth_type == "gateway" and token_provider:
token = await token_provider.get_token()
return {"Authorization": f"Bearer {token}"}
if auth_type in ("bearer", "api_key") and auth_token:
return {"Authorization": f"Bearer {auth_token}"}
return {}
@@ -0,0 +1,89 @@
# -*- coding: utf-8 -*-
"""In-process SSE stream for a2a_call tool progress.
a2a_call pushes incremental events into start_stream() queue while it
runs. The /a2a/call/stream REST endpoint reads from read_stream_sse()
and forwards them to the frontend as Server-Sent Events, enabling
real-time display without relying on QwenPaw to forward intermediate
ToolResponse yields.
Only one a2a_call runs at a time (the LLM is blocked until it returns),
so a single module-level queue is safe.
"""
from __future__ import annotations
import asyncio
import json
import logging
from collections.abc import AsyncIterator
logger = logging.getLogger("qwenpaw").getChild(
__name__.replace("plugin_cloudpaw.", ""),
)
_queue: asyncio.Queue | None = None
def start_stream() -> asyncio.Queue:
"""Create a fresh queue for a new call. Called by a2a_call at startup."""
global _queue
_queue = asyncio.Queue(maxsize=1000)
return _queue
def get_stream() -> asyncio.Queue | None:
"""Return the current stream queue (if any)."""
return _queue
def finish_stream() -> None:
"""Signal the end of the stream by putting a None sentinel."""
global _queue
if _queue is not None:
try:
_queue.put_nowait(None)
except asyncio.QueueFull:
pass
_queue = None
def push(data: dict) -> None:
"""Push an event dict into the current stream queue."""
global _queue
if _queue is not None:
try:
_queue.put_nowait(data)
except asyncio.QueueFull:
logger.warning("A2A stream queue full, dropping event")
async def read_stream_sse() -> AsyncIterator[str]:
"""Yield SSE-formatted strings until the call finishes.
Waits up to 3 s for the call to start (handles the race where the
frontend connects before a2a_call has initialised the queue).
"""
for _ in range(30):
if _queue is not None:
break
await asyncio.sleep(0.1)
queue = _queue
if queue is None:
msg = json.dumps({"done": True, "error": "no active call"})
yield f"data: {msg}\n\n"
return
while True:
try:
item = await asyncio.wait_for(queue.get(), timeout=60.0)
except asyncio.TimeoutError:
yield ": keepalive\n\n"
continue
if item is None:
yield f"data: {json.dumps({'done': True})}\n\n"
break
yield f"data: {json.dumps(item, ensure_ascii=False)}\n\n"
@@ -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
@@ -0,0 +1,37 @@
# -*- coding: utf-8 -*-
"""Gateway adapter: URL /rpc suffix + A2A-Version header override.
The Alibaba Cloud intelligent gateway requires:
1. JSON-RPC endpoint at {base_url}/rpc (not the card's URL directly)
2. A2A-Version header = "1.0.0" (SDK default is "1.0")
"""
from __future__ import annotations
import logging
logger = logging.getLogger("qwenpaw").getChild(
__name__.replace("plugin_cloudpaw.", ""),
)
GATEWAY_A2A_VERSION = "1.0.0"
def patch_card_url(card: dict) -> dict:
"""Append /rpc suffix to the card's URL field."""
url = card.get("url", "")
if url and not url.endswith("/rpc"):
card = {**card, "url": url.rstrip("/") + "/rpc"}
logger.debug("Patched card URL: %s", card["url"])
return card
def normalize_gateway_card(card_data: dict) -> dict:
"""Fix gateway AgentCard fields that may cause parsing issues.
Known issues: some gateway cards have ``tags: null`` in skills.
"""
for skill in card_data.get("skills", []):
if skill.get("tags") is None:
skill["tags"] = []
return card_data
@@ -0,0 +1,117 @@
# -*- coding: utf-8 -*-
"""Gateway Token Provider: AK-SK → Bearer Token.
Calls aliyun CLI to exchange Alibaba Cloud AK-SK credentials
for a Bearer Token used by the intelligent gateway A2A endpoint.
Supports token caching and automatic refresh.
"""
import asyncio
import json
import logging
import os
import time
logger = logging.getLogger("qwenpaw").getChild(
__name__.replace("plugin_cloudpaw.", ""),
)
_TOKEN_REFRESH_MARGIN_SECONDS = 300 # refresh 5 min before expiry
class GatewayTokenProvider:
"""Exchange AK-SK for a Bearer Token via aliyun CLI.
The token is cached and refreshed automatically when expired.
"""
def __init__(
self,
ak: str = "",
sk: str = "",
client_id: str = "4081417976505782102",
scope: str = "/internal/agenthub",
endpoint: str = "ramoauth.aliyuncs.com",
):
self._ak = ak or os.environ.get("ALIBABA_CLOUD_ACCESS_KEY_ID", "")
self._sk = sk or os.environ.get("ALIBABA_CLOUD_ACCESS_KEY_SECRET", "")
self._client_id = client_id
self._scope = scope
self._endpoint = endpoint
self._cached_token: str = ""
self._token_expire_at: float = 0
self._lock = asyncio.Lock()
async def get_token(self) -> str:
"""Get a valid Bearer Token, refreshing if expired."""
async with self._lock:
if self._cached_token and time.time() < self._token_expire_at:
return self._cached_token
return await self._refresh_token()
def invalidate(self) -> None:
"""Force clear the cached token."""
self._cached_token = ""
self._token_expire_at = 0
async def _refresh_token(self) -> str:
if not self._ak or not self._sk:
raise RuntimeError(
"ALIBABA_CLOUD_ACCESS_KEY_ID / SECRET not configured",
)
cmd = [
"aliyun",
"ramoauth",
"GenerateAccessToken",
"--ClientId",
self._client_id,
"--endpoint",
self._endpoint,
"--version",
"2026-04-21",
"--access-key-id",
self._ak,
"--access-key-secret",
self._sk,
"--method",
"POST",
"--force",
"--Scope",
self._scope,
]
logger.info("Refreshing gateway token via aliyun CLI...")
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
err_msg = (stderr or stdout).decode().strip()
raise RuntimeError(
f"Token refresh failed (exit={proc.returncode}): {err_msg}",
)
data = json.loads(stdout.decode())
d = data.get("Data") or data
token = d.get("AccessToken", d.get("access_token", ""))
if not token:
raise RuntimeError(
f"No AccessToken in response: {stdout.decode()[:500]}",
)
expires_in = int(d.get("ExpiresIn", 3600))
self._cached_token = token
self._token_expire_at = (
time.time() + expires_in - _TOKEN_REFRESH_MARGIN_SECONDS
)
logger.info(
"Gateway token refreshed (length=%d, expires_in=%ds)",
len(token),
expires_in,
)
return token