438 lines
15 KiB
Python
438 lines
15 KiB
Python
|
|
# -*- 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 的基础 URL(alias 为空时使用)
|
|||
|
|
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)
|