Files
agent-desktop/plugins/bundle/cloudpaw/tools/a2a_call.py
T

438 lines
15 KiB
Python
Raw Normal View History

2026-08-23 22:43:52 +08:00
# -*- coding: utf-8 -*-
"""A2A call tool: send a message to a remote A2A Agent.
Supports resolution by alias (reading from per-agent a2a_config.json)
or by direct URL. When using alias, auth config is automatically
applied from the stored registration.
The tool is an ``AsyncGenerator`` that yields intermediate
``ToolChunk(state=RUNNING, is_last=False)`` chunks as SSE events
arrive from the remote agent, so the QwenPaw frontend can render
incremental progress in real time via the tool renderer. The final
chunk carries ``state=SUCCESS, is_last=True``.
"""
import json
import logging
from collections.abc import AsyncGenerator
from agentscope.message import TextBlock, ToolResultState
from agentscope.tool import ToolChunk
logger = logging.getLogger("qwenpaw").getChild(
__name__.replace("plugin_cloudpaw.", ""),
)
async def a2a_call( # pylint: disable=too-many-branches,too-many-statements
message: str,
agent_alias: str = "",
agent_url: str = "",
context_id: str = "",
) -> AsyncGenerator[ToolChunk, None]:
"""向远程 A2A Agent 发送消息并获取响应。
通过 ``agent_alias``(已注册的别名)或 ``agent_url``URL)指定目标 Agent。
使用别名时自动应用已注册的认证配置。
Args:
message: 发送给远程 Agent 的文本消息
agent_alias: 已注册的远程 Agent 别名(优先使用,通过 a2a_list 查看可用别名)
agent_url: 远程 A2A Agent 的基础 URLalias 为空时使用)
context_id: 可选,会话上下文 ID(多轮对话时传入上次返回的 contextId)
Yields:
ToolChunk: 远程 Agent 的流式响应,包含:
- response_text: Agent 回复的文本内容(累积)
- task_id: 任务 ID(如有)
- context_id: 会话上下文 ID(用于多轮对话)
- task_state: 任务最终状态
- event_count: 收到的事件总数
"""
from modules.a2a.client_manager import get_a2a_manager
try:
from modules.a2a.call_stream import (
finish_stream,
get_stream,
start_stream,
)
stream_queue = get_stream()
if stream_queue is None:
stream_queue = start_stream()
_has_call_stream = True
except ImportError:
stream_queue = None
_has_call_stream = False
manager = get_a2a_manager()
resolved_url = agent_url
auth_type = ""
auth_token = ""
def _error_response(error_msg: str) -> ToolChunk:
return ToolChunk(
state=ToolResultState.SUCCESS,
is_last=True,
content=[
TextBlock(
type="text",
text=json.dumps(
{"error": error_msg, "task_state": "error"},
ensure_ascii=False,
),
),
],
)
if agent_alias:
from .a2a_config_helper import resolve_agent_by_alias
reg = resolve_agent_by_alias(agent_alias)
if not reg:
if _has_call_stream:
finish_stream()
yield _error_response(
f"未找到别名为 '{agent_alias}' 的已注册 A2A Agent。"
f"请先通过 a2a_list 查看可用的 Agent。",
)
return
resolved_url = reg["url"]
auth_type = reg.get("auth_type", "")
auth_token = reg.get("auth_token", "")
gateway_config = reg.get("gateway_config")
card_info = await manager.get_card_info(resolved_url)
if not card_info or card_info.get("status") != "connected":
try:
await manager.connect(
agent_url=resolved_url,
auth_type=auth_type,
auth_token=auth_token,
gateway_config=gateway_config,
)
except Exception as e:
if _has_call_stream:
finish_stream()
yield _error_response(
f"连接 '{agent_alias}' ({resolved_url}) 失败: {e}",
)
return
if not resolved_url:
if _has_call_stream:
finish_stream()
yield _error_response("必须提供 agent_alias 或 agent_url 之一。")
return
events: list[dict] = []
try:
logger.info(
"A2A call started: alias=%s, url=%s, message=%s",
agent_alias or "(direct)",
resolved_url,
message[:100],
)
tracker = _StepTracker()
last_snapshot = ""
async for event in manager.send_message(
agent_url=resolved_url,
message=message,
context_id=context_id,
streaming=True,
):
events.append(event)
tracker.process(event)
snapshot = json.dumps(
tracker.snapshot(),
ensure_ascii=False,
)
if snapshot != last_snapshot:
last_snapshot = snapshot
payload = {
"steps": tracker.snapshot(),
"task_state": "working",
"event_count": len(events),
}
if stream_queue is not None:
_push(stream_queue, payload)
yield ToolChunk(
state=ToolResultState.RUNNING,
is_last=False,
content=[
TextBlock(
type="text",
text=json.dumps(payload, ensure_ascii=False),
),
],
)
result = _build_result(events, context_id)
result["steps"] = tracker.snapshot()
logger.info(
"A2A call completed: events=%d, state=%s, text_len=%d",
len(events),
result.get("task_state"),
len(result.get("response_text", "")),
)
if stream_queue is not None:
_push(stream_queue, {**result, "final": True})
except Exception as e:
logger.exception("A2A call failed: %s%s", resolved_url, e)
result = {
"response_text": "",
"error": str(e),
"task_id": "",
"context_id": context_id,
"task_state": "error",
"event_count": len(events),
}
if stream_queue is not None:
_push(stream_queue, {**result, "final": True})
finally:
if _has_call_stream:
finish_stream()
yield ToolChunk(
state=ToolResultState.SUCCESS,
is_last=True,
content=[
TextBlock(
type="text",
text=json.dumps(result, ensure_ascii=False),
),
],
)
def _push(queue, data: dict) -> None:
"""Push data to the stream queue (non-blocking)."""
try:
queue.put_nowait(data)
except Exception:
pass
class _StepTracker:
"""Accumulates A2A SSE events into a structured list of UI steps.
Step types:
- thinking: LLM thinking tokens, accumulated into a single text block.
Finalized (done=True) once a non-thinking event arrives.
- tool_call: Remote agent tool invocation.
status cycles: running → done / error.
- text: Agent response text (artifact / message).
"""
def __init__(self) -> None:
self._steps: list[dict] = []
self._thinking_buf: list[str] = []
self._active_tools: dict[str, int] = {}
def process( # pylint: disable=too-many-branches
self,
event: dict,
) -> None:
ev_type = event.get("type", "")
if ev_type == "status_update":
su = event.get("statusUpdate", {})
meta = su.get("metadata", {})
msg_type = meta.get("message_type", "")
if msg_type == "thinking":
self._thinking_buf.append(meta.get("thinking", ""))
self._ensure_thinking_step()
return
self._finalize_thinking()
if msg_type == "tool_use":
tool_id = meta.get("tool_use_id", "")
name = meta.get("tool_name", "?")
desc = (meta.get("tool_input") or {}).get("description", "")
step = {
"type": "tool_call",
"name": name,
"status": "running",
"desc": desc,
}
self._steps.append(step)
if tool_id:
self._active_tools[tool_id] = len(self._steps) - 1
elif msg_type == "tool_result":
tool_id = meta.get("tool_use_id", "")
is_error = meta.get("is_error", False)
idx = self._active_tools.pop(tool_id, None)
if idx is not None and idx < len(self._steps):
self._steps[idx]["status"] = (
"error" if is_error else "done"
)
else:
name = meta.get("tool_name", "?")
self._steps.append(
{
"type": "tool_call",
"name": name,
"status": "error" if is_error else "done",
},
)
else:
text = _extract_text_from_parts(
su.get("status", {}).get("message", {}).get("parts", []),
)
if text:
self._append_text(text)
elif ev_type == "artifact_update":
self._finalize_thinking()
artifact = event.get("artifactUpdate", {}).get("artifact", {})
text = _extract_text_from_parts(artifact.get("parts", []))
if text:
self._append_text(text)
elif ev_type == "task":
self._finalize_thinking()
task_data = event.get("task", {})
for artifact in task_data.get("artifacts", []):
text = _extract_text_from_parts(artifact.get("parts", []))
if text:
self._append_text(text)
elif ev_type == "message":
self._finalize_thinking()
text = _extract_text_from_parts(
event.get("message", {}).get("parts", []),
)
if text:
self._append_text(text)
def snapshot(self) -> list[dict]:
steps = [s.copy() for s in self._steps]
if self._thinking_buf:
for s in steps:
if s.get("type") == "thinking" and not s.get("done"):
s["text"] = "".join(self._thinking_buf)
break
return steps
def _ensure_thinking_step(self) -> None:
if (
not self._steps
or self._steps[-1].get("type") != "thinking"
or self._steps[-1].get("done")
):
self._steps.append({"type": "thinking", "text": "", "done": False})
def _finalize_thinking(self) -> None:
if not self._thinking_buf:
return
text = "".join(self._thinking_buf)
self._thinking_buf.clear()
for s in reversed(self._steps):
if s.get("type") == "thinking" and not s.get("done"):
s["text"] = text
s["done"] = True
return
def _append_text(self, text: str) -> None:
if self._steps and self._steps[-1].get("type") == "text":
self._steps[-1]["text"] += text
else:
self._steps.append({"type": "text", "text": text})
def _build_result( # pylint: disable=too-many-branches,too-many-statements
events: list[dict],
initial_context_id: str,
) -> dict:
"""Build final result dict from all collected events."""
artifact_texts: list[str] = []
status_texts: list[str] = []
final_task_id = ""
final_context_id = initial_context_id
final_state = ""
for ev in events:
ev_type = ev.get("type", "")
if ev_type == "task":
task_data = ev.get("task", {})
if "id" in task_data:
final_task_id = task_data["id"]
if "contextId" in task_data:
final_context_id = task_data["contextId"]
status = task_data.get("status", {})
if "state" in status:
final_state = status["state"]
msg = status.get("message", {})
text = _extract_text_from_parts(msg.get("parts", []))
if text:
status_texts.append(text)
for artifact in task_data.get("artifacts", []):
text = _extract_text_from_parts(artifact.get("parts", []))
if text:
artifact_texts.append(text)
elif ev_type == "status_update":
su = ev.get("statusUpdate", {})
if "taskId" in su:
final_task_id = su["taskId"]
if "contextId" in su:
final_context_id = su["contextId"]
status = su.get("status", {})
if "state" in status:
final_state = status["state"]
msg = status.get("message", {})
text = _extract_text_from_parts(msg.get("parts", []))
if text:
status_texts.append(text)
elif ev_type == "artifact_update":
au = ev.get("artifactUpdate", {})
if "taskId" in au:
final_task_id = au["taskId"]
if "contextId" in au:
final_context_id = au["contextId"]
artifact = au.get("artifact", {})
text = _extract_text_from_parts(artifact.get("parts", []))
if text:
artifact_texts.append(text)
elif ev_type == "message":
msg = ev.get("message", {})
text = _extract_text_from_parts(msg.get("parts", []))
if text:
artifact_texts.append(text)
response_text = "".join(artifact_texts)
if not response_text and status_texts:
response_text = "\n".join(status_texts)
if not response_text and final_state:
response_text = f"[任务状态: {final_state}]"
return {
"response_text": response_text,
"task_id": final_task_id,
"context_id": final_context_id,
"task_state": final_state,
"event_count": len(events),
}
def _extract_text_from_parts(parts: list) -> str:
"""Extract concatenated text from a list of A2A message parts."""
texts = []
for part in parts or []:
if isinstance(part, dict) and "text" in part:
texts.append(part["text"])
return "".join(texts)