源码快照 @ 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,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
|
||||
Reference in New Issue
Block a user