源码快照 @ 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 custom tools."""
|
||||
@@ -0,0 +1,437 @@
|
||||
# -*- 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)
|
||||
@@ -0,0 +1,57 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Helpers to read per-agent A2A configuration from workspace.
|
||||
|
||||
Used by ``a2a_list`` and ``a2a_call`` tools to resolve registered
|
||||
A2A agents by alias without requiring the caller to know URLs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger("qwenpaw").getChild(
|
||||
__name__.replace("plugin_cloudpaw.", ""),
|
||||
)
|
||||
|
||||
_A2A_CONFIG_FILENAME = "a2a_config.json"
|
||||
|
||||
|
||||
def _get_workspace_dir() -> Path | None:
|
||||
"""Resolve workspace directory for the current agent via context."""
|
||||
try:
|
||||
from qwenpaw.app.agent_context import get_current_agent_id
|
||||
from qwenpaw.config.utils import load_config
|
||||
|
||||
agent_id = get_current_agent_id()
|
||||
config = load_config()
|
||||
|
||||
ref = config.agents.profiles.get(agent_id)
|
||||
if ref:
|
||||
return Path(ref.workspace_dir).expanduser()
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to resolve workspace dir: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def load_a2a_agents() -> dict[str, dict]:
|
||||
"""Load per-agent A2A config: {alias -> registration_info}."""
|
||||
ws_dir = _get_workspace_dir()
|
||||
if not ws_dir:
|
||||
return {}
|
||||
path = ws_dir / _A2A_CONFIG_FILENAME
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
return data.get("agents", {})
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
logger.warning("Failed to load %s: %s", path, exc)
|
||||
return {}
|
||||
|
||||
|
||||
def resolve_agent_by_alias(alias: str) -> dict | None:
|
||||
"""Look up a registered agent by alias, returning its config dict."""
|
||||
agents = load_a2a_agents()
|
||||
return agents.get(alias)
|
||||
@@ -0,0 +1,83 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""A2A discover tool: discover remote A2A Agent and get its info.
|
||||
|
||||
Resolves Agent Card, establishes authenticated connection,
|
||||
and returns agent capabilities for the calling agent to decide
|
||||
whether/how to interact with the remote agent.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from agentscope.message import TextBlock
|
||||
from agentscope.message import ToolResultState
|
||||
from agentscope.tool import ToolChunk
|
||||
|
||||
logger = logging.getLogger("qwenpaw").getChild(
|
||||
__name__.replace("plugin_cloudpaw.", ""),
|
||||
)
|
||||
|
||||
|
||||
async def a2a_discover(
|
||||
agent_url: str,
|
||||
auth_type: str = "",
|
||||
auth_token: str = "",
|
||||
) -> ToolChunk:
|
||||
"""发现远程 A2A Agent 并获取其信息。
|
||||
|
||||
解析远程 Agent 的 Agent Card,获取其名称、描述、技能列表、
|
||||
支持的传输协议等信息。同时建立认证连接供后续 a2a_call 复用。
|
||||
|
||||
Args:
|
||||
agent_url: 远程 A2A Agent 的基础 URL
|
||||
(例: https://agent.example.com)
|
||||
auth_type: 认证类型,可选值:
|
||||
- "bearer": Bearer Token 认证
|
||||
- "api_key": API Key 认证
|
||||
- "gateway": 智能网关(自动用 AK-SK 换取 Token)
|
||||
- "": 无认证
|
||||
auth_token: 认证凭证(Bearer Token 或 API Key 值)
|
||||
auth_type="gateway" 时无需传入
|
||||
|
||||
Returns:
|
||||
ToolChunk: 包含 Agent Card 信息的 JSON:
|
||||
- name: Agent 名称
|
||||
- description: Agent 描述
|
||||
- skills: 技能列表(名称和描述)
|
||||
- interfaces: 支持的传输协议
|
||||
- capabilities: 是否支持流式/推送通知
|
||||
- status: 连接状态
|
||||
"""
|
||||
from modules.a2a.client_manager import get_a2a_manager
|
||||
|
||||
manager = get_a2a_manager()
|
||||
|
||||
try:
|
||||
info = await manager.connect(
|
||||
agent_url=agent_url,
|
||||
auth_type=auth_type,
|
||||
auth_token=auth_token,
|
||||
)
|
||||
|
||||
result = json.dumps(info, ensure_ascii=False, indent=2)
|
||||
logger.info(
|
||||
"a2a_discover success: %s (%s)",
|
||||
info.get("name"),
|
||||
agent_url,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("a2a_discover failed for %s: %s", agent_url, e)
|
||||
result = json.dumps(
|
||||
{
|
||||
"url": agent_url,
|
||||
"status": "error",
|
||||
"error": str(e),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
return ToolChunk(
|
||||
state=ToolResultState.SUCCESS,
|
||||
content=[TextBlock(type="text", text=result)],
|
||||
)
|
||||
@@ -0,0 +1,83 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""A2A list tool: list registered remote A2A agents for the current agent.
|
||||
|
||||
Reads the per-agent ``a2a_config.json`` and returns a summary of all
|
||||
registered remote agents with their connection info and capabilities.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from agentscope.message import TextBlock
|
||||
from agentscope.message import ToolResultState
|
||||
from agentscope.tool import ToolChunk
|
||||
|
||||
logger = logging.getLogger("qwenpaw").getChild(
|
||||
__name__.replace("plugin_cloudpaw.", ""),
|
||||
)
|
||||
|
||||
|
||||
async def a2a_list() -> ToolChunk:
|
||||
"""列出当前智能体已注册的远程 A2A Agent。
|
||||
|
||||
无需参数。自动读取当前智能体的 A2A 配置,返回所有已注册的
|
||||
远程 Agent 信息(别名、URL、认证类型、连接状态、技能列表等)。
|
||||
|
||||
Returns:
|
||||
ToolChunk: 包含已注册 Agent 列表的 JSON:
|
||||
- agents: Agent 列表,每个包含 alias, url, auth_type, name,
|
||||
description, skills, capabilities, status 等字段
|
||||
"""
|
||||
from .a2a_config_helper import load_a2a_agents
|
||||
from modules.a2a.client_manager import get_a2a_manager
|
||||
|
||||
agents_cfg = load_a2a_agents()
|
||||
manager = get_a2a_manager()
|
||||
|
||||
agents_list = []
|
||||
for alias, reg in agents_cfg.items():
|
||||
url = reg.get("url", "")
|
||||
entry: dict = {
|
||||
"alias": alias,
|
||||
"url": url,
|
||||
"auth_type": reg.get("auth_type", ""),
|
||||
}
|
||||
card_info = await manager.get_card_info(url)
|
||||
if not card_info:
|
||||
try:
|
||||
card_info = await manager.connect(
|
||||
agent_url=url,
|
||||
auth_type=reg.get("auth_type", ""),
|
||||
auth_token=reg.get("auth_token", ""),
|
||||
gateway_config=reg.get("gateway_config"),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("a2a_list: connect to %s failed: %s", url, exc)
|
||||
entry["status"] = "error"
|
||||
entry["error"] = str(exc)
|
||||
agents_list.append(entry)
|
||||
continue
|
||||
|
||||
if card_info:
|
||||
entry["status"] = card_info.get("status", "connected")
|
||||
entry["name"] = card_info.get("name", "")
|
||||
entry["description"] = card_info.get("description", "")
|
||||
entry["skills"] = card_info.get("skills", [])
|
||||
entry["capabilities"] = card_info.get("capabilities", {})
|
||||
else:
|
||||
entry["status"] = "disconnected"
|
||||
|
||||
agents_list.append(entry)
|
||||
|
||||
result = json.dumps(
|
||||
{"agents": agents_list, "count": len(agents_list)},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
|
||||
logger.info("a2a_list: found %d registered agents", len(agents_list))
|
||||
|
||||
return ToolChunk(
|
||||
state=ToolResultState.SUCCESS,
|
||||
content=[TextBlock(type="text", text=result)],
|
||||
)
|
||||
@@ -0,0 +1,479 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Manage PRD tool — CRUD and mark_passed for prd.json stories."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from agentscope.message import TextBlock
|
||||
from agentscope.message import ToolResultState
|
||||
from agentscope.tool import ToolChunk
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_REQUIRED_STORY_FIELDS = frozenset(
|
||||
{
|
||||
"id",
|
||||
"title",
|
||||
"description",
|
||||
"acceptanceCriteria",
|
||||
"priority",
|
||||
"passes",
|
||||
"notes",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _validate_priority(priority: Any) -> tuple[bool, str]:
|
||||
if isinstance(priority, bool):
|
||||
return False, "priority 必须是整数,不能是布尔值"
|
||||
if not isinstance(priority, int):
|
||||
return False, f"priority 必须是整数类型,当前类型: {type(priority).__name__}"
|
||||
if priority < 1:
|
||||
return False, f"priority 必须是正整数(>=1),当前值: {priority}"
|
||||
return True, ""
|
||||
|
||||
|
||||
def _to_kebab_case(text: str) -> str:
|
||||
"""将文本转换为 kebab-case。
|
||||
|
||||
例如:
|
||||
- "AuthSystem" → "auth-system"
|
||||
- "User Authentication" → "user-authentication"
|
||||
- "already-kebab" → "already-kebab"
|
||||
"""
|
||||
import re
|
||||
|
||||
# 添加连字符到驼峰命名
|
||||
s = re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", text)
|
||||
# 替换空格和下滑线为连字符
|
||||
s = re.sub(r"[\s_]+", "-", s)
|
||||
return s.lower().strip("-")
|
||||
|
||||
|
||||
def _validate_story(
|
||||
story: dict,
|
||||
index: int = -1,
|
||||
check_passes_notes: bool = True,
|
||||
) -> tuple[bool, str]:
|
||||
if check_passes_notes:
|
||||
missing = _REQUIRED_STORY_FIELDS - set(story.keys())
|
||||
else:
|
||||
# create 操作不需要 story 中有 passes/notes,会自动填充
|
||||
required_fields = frozenset(
|
||||
{"id", "title", "description", "acceptanceCriteria", "priority"},
|
||||
)
|
||||
missing = required_fields - set(story.keys())
|
||||
|
||||
if missing:
|
||||
idx_label = f"story[{index}]" if index >= 0 else "story"
|
||||
return False, f"{idx_label} 缺少字段: {', '.join(sorted(missing))}"
|
||||
|
||||
valid, err = _validate_priority(story.get("priority"))
|
||||
if not valid:
|
||||
return False, f"story '{story.get('id', '?')}' priority 错误: {err}"
|
||||
|
||||
acceptance = story.get("acceptanceCriteria")
|
||||
if not isinstance(acceptance, list) or len(acceptance) == 0:
|
||||
return (
|
||||
False,
|
||||
f"story '{story.get('id', '?')}' acceptanceCriteria 必须是非空字符串数组",
|
||||
)
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def _save_prd(prd_path: Path, prd: dict) -> None:
|
||||
timestamp = time.strftime("%Y%m%d-%H%M%S")
|
||||
backup_name = f"prd.json.bak.{timestamp}"
|
||||
backup_path = prd_path.parent / backup_name
|
||||
try:
|
||||
if prd_path.exists():
|
||||
backup_path.write_text(
|
||||
prd_path.read_text(encoding="utf-8"),
|
||||
encoding="utf-8",
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to create backup %s: %s", backup_name, exc)
|
||||
prd_path.write_text(
|
||||
json.dumps(prd, indent=2, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
logger.info("Updated prd.json at: %s (backup: %s)", prd_path, backup_name)
|
||||
|
||||
|
||||
def _error_response(message: str) -> ToolChunk:
|
||||
return ToolChunk(
|
||||
state=ToolResultState.SUCCESS,
|
||||
is_last=True,
|
||||
content=[
|
||||
TextBlock(
|
||||
type="text",
|
||||
text=json.dumps(
|
||||
{
|
||||
"status": "error",
|
||||
"message": message,
|
||||
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _ok_response(message: str, data: dict = None) -> ToolChunk:
|
||||
result = {
|
||||
"status": "ok",
|
||||
"message": message,
|
||||
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"_hint": (
|
||||
"Frontend auto-renders PRD. "
|
||||
"Output ONLY a short confirmation, NO tables/lists/summaries."
|
||||
),
|
||||
}
|
||||
if data:
|
||||
result["data"] = data
|
||||
return ToolChunk(
|
||||
state=ToolResultState.SUCCESS,
|
||||
is_last=True,
|
||||
content=[
|
||||
TextBlock(
|
||||
type="text",
|
||||
text=json.dumps(result, indent=2, ensure_ascii=False),
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
async def _handle_create( # pylint: disable=too-many-return-statements
|
||||
_loop_path: Path,
|
||||
prd_path: Path,
|
||||
project: str = None,
|
||||
description: str = None,
|
||||
branch_name: str = None,
|
||||
stories: list[dict] | str = None,
|
||||
) -> ToolChunk:
|
||||
"""处理 create 操作:创建新的 PRD。
|
||||
|
||||
Args:
|
||||
_loop_path: Mission 目录路径(保留供将来使用)
|
||||
prd_path: prd.json 路径
|
||||
project: 项目名称(必填)
|
||||
description: 项目描述(必填)
|
||||
branch_name: 分支名称(可选,自动生成)
|
||||
stories: stories 列表或 JSON 字符串(必填)
|
||||
|
||||
Returns:
|
||||
ToolChunk: 创建结果
|
||||
"""
|
||||
if not project or not project.strip():
|
||||
return _error_response("create 操作需要 project 参数(项目名称)")
|
||||
|
||||
if not description or not description.strip():
|
||||
return _error_response("create 操作需要 description 参数(项目描述)")
|
||||
|
||||
if stories is None:
|
||||
return _error_response("create 操作需要 stories 参数(story 列表)")
|
||||
|
||||
# 解析 stories
|
||||
if isinstance(stories, str):
|
||||
try:
|
||||
stories = json.loads(stories)
|
||||
except json.JSONDecodeError as exc:
|
||||
return _error_response(f"stories JSON 解析失败: {exc}")
|
||||
|
||||
if not isinstance(stories, list) or len(stories) == 0:
|
||||
return _error_response("stories 必须是非空列表")
|
||||
|
||||
# 批量验证 stories
|
||||
existing_ids = set()
|
||||
validated_stories = []
|
||||
|
||||
for i, story in enumerate(stories):
|
||||
if not isinstance(story, dict):
|
||||
return _error_response(f"story[{i}] 必须是字典对象")
|
||||
|
||||
# 验证 story 格式(不要求已有 passes/notes 字段,会自动填充)
|
||||
valid, err = _validate_story(story, index=i, check_passes_notes=False)
|
||||
if not valid:
|
||||
return _error_response(err)
|
||||
|
||||
# 检查 ID 重复
|
||||
story_id = story.get("id")
|
||||
if story_id in existing_ids:
|
||||
return _error_response(f"story ID '{story_id}' 重复(story[{i}])")
|
||||
existing_ids.add(story_id)
|
||||
|
||||
# 自动填充必填字段
|
||||
story_dict = {
|
||||
"id": story_id,
|
||||
"title": story.get("title"),
|
||||
"description": story.get("description"),
|
||||
"acceptanceCriteria": story.get("acceptanceCriteria"),
|
||||
"priority": story.get("priority"),
|
||||
"passes": False,
|
||||
"notes": "",
|
||||
}
|
||||
|
||||
# 保留可选的 notes 字段(如果有)
|
||||
if "notes" in story and story["notes"]:
|
||||
story_dict["notes"] = story["notes"]
|
||||
|
||||
validated_stories.append(story_dict)
|
||||
|
||||
# 生成分支名称(如果未指定)
|
||||
if not branch_name or not branch_name.strip():
|
||||
project_kebab = _to_kebab_case(project)
|
||||
branch_name = f"mission/{project_kebab}"
|
||||
|
||||
# 构建 PRD 对象
|
||||
prd = {
|
||||
"project": project.strip(),
|
||||
"branchName": branch_name.strip(),
|
||||
"description": description.strip(),
|
||||
"userStories": validated_stories,
|
||||
}
|
||||
|
||||
# 保存 PRD
|
||||
_save_prd(prd_path, prd)
|
||||
|
||||
return _ok_response(
|
||||
f"已创建 PRD: {project},包含 {len(validated_stories)} 个 story",
|
||||
data={
|
||||
"project": project,
|
||||
"branch_name": branch_name,
|
||||
"stories_count": len(validated_stories),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def manage_prd( # noqa: E501 # pylint: disable=too-many-return-statements,too-many-branches,too-many-statements
|
||||
loop_dir: str,
|
||||
operation: str,
|
||||
story: dict | str = None,
|
||||
story_id: str = None,
|
||||
story_ids: list[str] = None,
|
||||
fields: dict | str = None,
|
||||
# create 操作参数
|
||||
project: str = None,
|
||||
description: str = None,
|
||||
branch_name: str = None,
|
||||
stories: list[dict] | str = None,
|
||||
) -> ToolChunk:
|
||||
"""Create or modify the PRD (prd.json) for the current mission.
|
||||
|
||||
⚠️ IMPORTANT: This is the ONLY correct way to create or modify prd.json
|
||||
in Mission Mode. Do NOT use write_file or edit_file on prd.json.
|
||||
|
||||
The frontend automatically renders the PRD as an interactive table
|
||||
after a successful create/add/update/delete/mark_passed operation.
|
||||
|
||||
⚠️ AFTER THIS TOOL SUCCEEDS: Output ONLY ONE short confirmation
|
||||
sentence (e.g. "PRD 已创建,包含 N 个 story,请确认。"). Do NOT
|
||||
output story lists, tables, summaries, deployment plans, or any
|
||||
PRD content — the frontend handles all rendering.
|
||||
|
||||
Operations:
|
||||
- create: Create a new PRD with project info and stories (Phase 1)
|
||||
- add: Add a new story to existing PRD
|
||||
- update: Update fields of an existing story
|
||||
- delete: Remove stories by ID
|
||||
- mark_passed: Mark stories as passed (Phase 2 verification)
|
||||
|
||||
Args:
|
||||
loop_dir (`str`):
|
||||
Mission loop directory absolute path.
|
||||
operation (`str`):
|
||||
Operation type: "create" | "add" | "update" | "delete" |
|
||||
"mark_passed".
|
||||
story (`dict | str`, optional):
|
||||
For "add": new story object or JSON string.
|
||||
Required fields: id, title, description, acceptanceCriteria,
|
||||
priority(int), passes(false), notes.
|
||||
story_id (`str`, optional):
|
||||
For "update": the story ID to update.
|
||||
story_ids (`list[str]`, optional):
|
||||
For "delete"/"mark_passed": list of story IDs.
|
||||
fields (`dict | str`, optional):
|
||||
For "update": dict of fields to update.
|
||||
Allowed: title, description, acceptanceCriteria, priority, notes.
|
||||
Forbidden: id, passes.
|
||||
project (`str`, optional):
|
||||
For "create": project name (required).
|
||||
description (`str`, optional):
|
||||
For "create": project description (required).
|
||||
branch_name (`str`, optional):
|
||||
For "create": branch name, format "mission/<kebab-case>".
|
||||
Auto-generated if not specified.
|
||||
stories (`list[dict] | str`, optional):
|
||||
For "create": list of story dicts or JSON string.
|
||||
Each story needs: id (US-XXX), title, description,
|
||||
acceptanceCriteria (non-empty array), priority (positive int).
|
||||
passes/notes are auto-filled.
|
||||
|
||||
Returns:
|
||||
`ToolChunk`: JSON result with status and message.
|
||||
"""
|
||||
loop_path = Path(loop_dir).expanduser().resolve()
|
||||
prd_path = loop_path / "prd.json"
|
||||
|
||||
if not loop_path.is_dir():
|
||||
return _error_response(f"Mission 目录不存在: {loop_dir}")
|
||||
|
||||
if operation.lower().strip() == "create":
|
||||
if prd_path.exists():
|
||||
try:
|
||||
existing = json.loads(prd_path.read_text(encoding="utf-8"))
|
||||
if isinstance(existing, dict) and isinstance(
|
||||
existing.get("userStories"),
|
||||
list,
|
||||
):
|
||||
return _error_response(
|
||||
"prd.json 已存在且格式正确,不允许覆盖。"
|
||||
"如需修改现有 PRD,请使用 add/update/delete 操作。",
|
||||
)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
logger.info(
|
||||
"prd.json exists but has invalid schema; "
|
||||
"overwriting with new PRD",
|
||||
)
|
||||
return await _handle_create(
|
||||
loop_path,
|
||||
prd_path,
|
||||
project,
|
||||
description,
|
||||
branch_name,
|
||||
stories,
|
||||
)
|
||||
|
||||
if not prd_path.exists():
|
||||
return _error_response(f"prd.json 未找到: {prd_path}")
|
||||
|
||||
operation = operation.lower().strip()
|
||||
valid_operations = ["add", "update", "delete", "mark_passed"]
|
||||
if operation not in valid_operations:
|
||||
return _error_response(
|
||||
f"无效操作: '{operation}'。允许: {', '.join(valid_operations)}",
|
||||
)
|
||||
|
||||
try:
|
||||
prd = json.loads(prd_path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
return _error_response(f"prd.json 格式错误: {exc}")
|
||||
|
||||
prd_stories: list[dict[str, Any]] = prd.get("userStories") or prd.get(
|
||||
"stories",
|
||||
[],
|
||||
)
|
||||
|
||||
if operation == "add":
|
||||
if story is None:
|
||||
return _error_response("add 操作需要 story 参数")
|
||||
|
||||
if isinstance(story, str):
|
||||
try:
|
||||
story = json.loads(story)
|
||||
except json.JSONDecodeError as exc:
|
||||
return _error_response(f"story JSON 解析失败: {exc}")
|
||||
|
||||
if not isinstance(story, dict):
|
||||
return _error_response("story 必须是字典对象或 JSON 字符串")
|
||||
|
||||
valid, err = _validate_story(story)
|
||||
if not valid:
|
||||
return _error_response(err)
|
||||
|
||||
if story["id"] in {s.get("id") for s in prd_stories}:
|
||||
return _error_response(f"story ID '{story['id']}' 已存在")
|
||||
|
||||
prd_stories.append(story)
|
||||
_save_prd(prd_path, prd)
|
||||
return _ok_response(f"已添加 story '{story['id']}'")
|
||||
|
||||
elif operation == "update":
|
||||
if story_id is None:
|
||||
return _error_response("update 操作需要 story_id 参数")
|
||||
if fields is None:
|
||||
return _error_response("update 操作需要 fields 参数")
|
||||
|
||||
if isinstance(fields, str):
|
||||
try:
|
||||
fields = json.loads(fields)
|
||||
except json.JSONDecodeError as exc:
|
||||
return _error_response(f"fields JSON 解析失败: {exc}")
|
||||
|
||||
if not isinstance(fields, dict):
|
||||
return _error_response("fields 必须是字典对象或 JSON 字符串")
|
||||
|
||||
forbidden = {"id", "passes"}
|
||||
if any(k in forbidden for k in fields.keys()):
|
||||
return _error_response(f"禁止更新字段: {', '.join(forbidden)}")
|
||||
|
||||
if "priority" in fields:
|
||||
valid, err = _validate_priority(fields["priority"])
|
||||
if not valid:
|
||||
return _error_response(f"priority 错误: {err}")
|
||||
|
||||
for s in prd_stories:
|
||||
if s.get("id") == story_id:
|
||||
s.update(fields)
|
||||
_save_prd(prd_path, prd)
|
||||
return _ok_response(
|
||||
f"已更新 story '{story_id}'",
|
||||
data={"updated_fields": list(fields.keys())},
|
||||
)
|
||||
|
||||
return _error_response(f"未找到 story: '{story_id}'")
|
||||
|
||||
elif operation == "delete":
|
||||
if story_ids is None:
|
||||
return _error_response("delete 操作需要 story_ids 参数")
|
||||
|
||||
existing_ids = {s.get("id") for s in prd_stories}
|
||||
if all(sid not in existing_ids for sid in story_ids):
|
||||
return _error_response(f"未找到任何 story: {', '.join(story_ids)}")
|
||||
|
||||
prd["userStories"] = [
|
||||
s for s in prd_stories if s.get("id") not in story_ids
|
||||
]
|
||||
_save_prd(prd_path, prd)
|
||||
deleted = len([sid for sid in story_ids if sid in existing_ids])
|
||||
return _ok_response(f"已删除 {deleted} 个 story")
|
||||
|
||||
elif operation == "mark_passed":
|
||||
if story_ids is None:
|
||||
return _error_response("mark_passed 操作需要 story_ids 参数")
|
||||
|
||||
target_ids = set(story_ids)
|
||||
updated: list[str] = []
|
||||
for s in prd_stories:
|
||||
sid = s.get("id")
|
||||
if sid in target_ids:
|
||||
s["passes"] = True
|
||||
updated.append(sid)
|
||||
target_ids.discard(sid)
|
||||
|
||||
_save_prd(prd_path, prd)
|
||||
|
||||
total = len(prd_stories)
|
||||
passed = len([s for s in prd_stories if s.get("passes")])
|
||||
|
||||
return _ok_response(
|
||||
f"已将 {len(updated)} 个 story 标记为通过",
|
||||
data={
|
||||
"updated": updated,
|
||||
"not_found": list(target_ids),
|
||||
"progress": {
|
||||
"passed": passed,
|
||||
"total": total,
|
||||
"all_done": passed == total and total > 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
return _error_response(f"未知操作: {operation}")
|
||||
@@ -0,0 +1,372 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Proposal choice tool for interactive resource selection.
|
||||
|
||||
Presents resource proposals to users in table format and waits for
|
||||
their selection. Supports up to 5 proposals with resource details,
|
||||
including multi-strategy proposals from iac-code.
|
||||
|
||||
Auto-corrects common LLM formatting mistakes:
|
||||
- Strips header rows (e.g. ["资源类型", "资源用途", ...])
|
||||
- Splits flat 2D arrays into multiple proposals using "合计" rows
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, List, Optional, Union
|
||||
|
||||
from agentscope.message import TextBlock
|
||||
from agentscope.message import ToolResultState
|
||||
from agentscope.tool import ToolChunk
|
||||
|
||||
# pylint: disable=no-name-in-module
|
||||
from qwenpaw.app.interaction import InteractionManager
|
||||
from qwenpaw.app.agent_context import get_current_session_id
|
||||
|
||||
logger = logging.getLogger("qwenpaw").getChild(
|
||||
__name__.replace("plugin_cloudpaw.", ""),
|
||||
)
|
||||
|
||||
_INTERACTION_TIMEOUT = 3600 # 1 hour
|
||||
|
||||
# Fixed table headers for resource proposals
|
||||
_TABLE_HEADERS = [
|
||||
"资源类型",
|
||||
"资源用途",
|
||||
"规格",
|
||||
"地域",
|
||||
"数量",
|
||||
"计费方式",
|
||||
"时长",
|
||||
"原价",
|
||||
"优惠",
|
||||
"预估算费用",
|
||||
]
|
||||
|
||||
# Fixed proposal names (extended for multi-strategy support)
|
||||
_PROPOSAL_NAMES = ["方案一", "方案二", "方案三", "方案四", "方案五"]
|
||||
|
||||
|
||||
def _validate_cell(cell: Any) -> bool:
|
||||
"""Validate a single cell value.
|
||||
|
||||
Cell can be:
|
||||
- A string
|
||||
- An object with "text" (required) and optional "url" fields
|
||||
"""
|
||||
if isinstance(cell, str):
|
||||
return True
|
||||
if isinstance(cell, dict):
|
||||
return "text" in cell and isinstance(cell["text"], str)
|
||||
return False
|
||||
|
||||
|
||||
def _is_row(item: Any) -> bool:
|
||||
"""Check if item is a valid row (list of 10 cells)."""
|
||||
return isinstance(item, list) and len(item) == 10
|
||||
|
||||
|
||||
_HEADER_KEYWORDS = frozenset(h.lower() for h in _TABLE_HEADERS)
|
||||
|
||||
|
||||
def _is_header_row(row: List[Any]) -> bool:
|
||||
"""Detect if a row is a table header (e.g. ["资源类型", "资源用途", ...])."""
|
||||
if not _is_row(row):
|
||||
return False
|
||||
first = str(row[0]).strip().lower() if row[0] else ""
|
||||
return first in _HEADER_KEYWORDS
|
||||
|
||||
|
||||
def _is_summary_row(row: List[Any]) -> bool:
|
||||
"""Detect summary/total rows (first cell matches 合计/总计/total)."""
|
||||
if not _is_row(row):
|
||||
return False
|
||||
first = str(row[0]).strip() if row[0] else ""
|
||||
return bool(first) and bool(
|
||||
re.match(r"^(合计|总计|total)", first, re.IGNORECASE),
|
||||
)
|
||||
|
||||
|
||||
def _split_flat_rows_into_proposals(
|
||||
rows: List[List[Any]],
|
||||
) -> List[List[List[Any]]]:
|
||||
"""Split a flat 2D array into proposals.
|
||||
|
||||
Uses summary rows as delimiters.
|
||||
|
||||
When the LLM puts two proposals' rows into a single 2D array, we can
|
||||
recover the intended structure by splitting on "合计" rows: each "合计"
|
||||
row marks the end of a proposal.
|
||||
"""
|
||||
proposals: List[List[List[Any]]] = []
|
||||
current: List[List[Any]] = []
|
||||
for row in rows:
|
||||
current.append(row)
|
||||
if _is_summary_row(row):
|
||||
proposals.append(current)
|
||||
current = []
|
||||
if current:
|
||||
if proposals:
|
||||
proposals[-1].extend(current)
|
||||
else:
|
||||
proposals.append(current)
|
||||
return proposals if proposals else [rows]
|
||||
|
||||
|
||||
def _normalize_proposals( # pylint: disable=too-many-return-statements
|
||||
data: Any,
|
||||
expected_count: int = 0,
|
||||
) -> Union[List[List[List[Any]]], str]:
|
||||
"""Normalize proposals data to 3D array format.
|
||||
|
||||
Accepts multiple formats and auto-corrects common LLM mistakes:
|
||||
- 2D array (single proposal): [[row1], [row2], ...].
|
||||
Each row has 10 columns.
|
||||
- 3D array (multiple proposals): [[[row1], [row2]], [[row3]]]
|
||||
- 2D array with mixed proposals: auto-splits on "合计" rows
|
||||
|
||||
Also auto-strips header rows (e.g. ["资源类型", "资源用途", ...]) that the
|
||||
LLM sometimes inserts.
|
||||
|
||||
Args:
|
||||
data: Parsed JSON data
|
||||
expected_count: Expected number of proposals (from strategy_names).
|
||||
Length used as a hint to split flat arrays when summary rows
|
||||
are missing.
|
||||
"""
|
||||
if not isinstance(data, list) or len(data) == 0:
|
||||
return "Error: data must be a non-empty JSON array."
|
||||
|
||||
# Check if it's a 2D array (all items are 10-column rows)
|
||||
if all(_is_row(item) for item in data):
|
||||
cleaned = [r for r in data if not _is_header_row(r)]
|
||||
if not cleaned:
|
||||
return (
|
||||
"Error: data contains only header rows, "
|
||||
"no actual resource data."
|
||||
)
|
||||
summary_count = sum(1 for r in cleaned if _is_summary_row(r))
|
||||
if summary_count >= 2:
|
||||
return _split_flat_rows_into_proposals(cleaned)
|
||||
if expected_count >= 2 and len(cleaned) >= expected_count * 2:
|
||||
import math
|
||||
|
||||
chunk = math.ceil(len(cleaned) / expected_count)
|
||||
return [
|
||||
cleaned[i : i + chunk] for i in range(0, len(cleaned), chunk)
|
||||
]
|
||||
return [cleaned]
|
||||
|
||||
# Check if it's a 3D array (multiple proposals)
|
||||
if all(isinstance(item, list) for item in data):
|
||||
result = []
|
||||
for i, proposal in enumerate(data):
|
||||
if not isinstance(proposal, list) or len(proposal) == 0:
|
||||
return (
|
||||
f"Error: proposal {i + 1} must be a non-empty array "
|
||||
f"of rows. Each row should have 10 columns: "
|
||||
f"{', '.join(_TABLE_HEADERS)}"
|
||||
)
|
||||
cleaned = [
|
||||
r for r in proposal if _is_row(r) and not _is_header_row(r)
|
||||
]
|
||||
if not cleaned:
|
||||
return (
|
||||
f"Error: proposal {i + 1} has no valid data rows after "
|
||||
f"filtering headers."
|
||||
)
|
||||
for j, row in enumerate(cleaned):
|
||||
if not _is_row(row):
|
||||
return (
|
||||
f"Error: row {j + 1} in proposal {i + 1} has "
|
||||
f"{len(row)} columns, expected 10. "
|
||||
f"Columns: {', '.join(_TABLE_HEADERS)}"
|
||||
)
|
||||
result.append(cleaned)
|
||||
return result
|
||||
|
||||
return (
|
||||
"Error: data format not recognized. Expected either:\n"
|
||||
"- 2D array for single proposal: [[row1], [row2], ...] "
|
||||
"where each row has 10 columns\n"
|
||||
"- 3D array for multiple proposals: [[[row1], [row2]], [[row3]]] "
|
||||
"where each proposal contains rows"
|
||||
)
|
||||
|
||||
|
||||
def _validate_proposals(proposals: List[List[List[Any]]]) -> Union[str, None]:
|
||||
"""Validate normalized proposals data structure."""
|
||||
if len(proposals) < 1 or len(proposals) > 5:
|
||||
return (
|
||||
f"Error: must have 1 to 5 proposals (found {len(proposals)}). "
|
||||
f"Each proposal can contain multiple resource rows."
|
||||
)
|
||||
|
||||
for i, proposal in enumerate(proposals):
|
||||
for j, row in enumerate(proposal):
|
||||
for k, cell in enumerate(row):
|
||||
if not _validate_cell(cell):
|
||||
return (
|
||||
f"Error: cell at proposal {i + 1}, row {j + 1}, "
|
||||
f"column '{_TABLE_HEADERS[k]}' is invalid. "
|
||||
f"Cell must be a string or an object with "
|
||||
f"'text' field."
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def proposal_choice(
|
||||
data: str,
|
||||
strategy_names: Optional[str] = None,
|
||||
) -> ToolChunk:
|
||||
"""Display a resource proposal to user and wait for confirmation.
|
||||
|
||||
Presents a resource proposal in table format with fixed headers and
|
||||
waits for user to confirm deployment or request adjustments.
|
||||
|
||||
Args:
|
||||
data (`str`):
|
||||
A JSON-encoded 2D array of resource rows (single proposal) or
|
||||
3D array (multiple proposals). Each row must have exactly 10
|
||||
columns matching the fixed headers:
|
||||
资源类型, 资源用途, 规格, 地域, 数量, 计费方式, 时长, 原价, 优惠, 预估算费用
|
||||
|
||||
Example (single proposal):
|
||||
```json
|
||||
[
|
||||
["ECS", "Web服务器", "2核4G", "华东1", "1", "包年包月", "1年",
|
||||
"¥1200", "8折", "¥960"],
|
||||
["OSS", "存储", "标准", "华东1", "1", "按量付费", "-",
|
||||
"¥100", "-", "¥100"],
|
||||
["合计", "", "", "", "", "", "", "", "", "≈¥1060/月"]
|
||||
]
|
||||
```
|
||||
|
||||
The last row of each proposal should be a summary row with
|
||||
"合计" as the first column and the total cost in the last
|
||||
column. The frontend
|
||||
renders this row separately and does NOT compute totals itself.
|
||||
|
||||
Cell value format:
|
||||
- String: "value"
|
||||
- Object with URL: {"text": "value", "url": "https://..."}
|
||||
|
||||
strategy_names (`str`, optional):
|
||||
JSON-encoded list of strategy display names for each proposal.
|
||||
If provided, these names replace the default proposal names
|
||||
("方案一/方案二/方案三").
|
||||
Example: '["最低价方案", "中等配置", "高性能方案"]'
|
||||
|
||||
Returns:
|
||||
`ToolChunk`:
|
||||
When user confirms deployment: "用户确认部署"
|
||||
When user requests adjustment: "用户要求调整资源:{user input text}"
|
||||
When timeout (1 hour): "用户确认部署"
|
||||
"""
|
||||
# Parse JSON data
|
||||
try:
|
||||
raw_data: Any = json.loads(data)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return ToolChunk(
|
||||
state=ToolResultState.SUCCESS,
|
||||
content=[
|
||||
TextBlock(
|
||||
type="text",
|
||||
text=(
|
||||
"Error: 'data' parameter must be a valid JSON "
|
||||
"string. Expected a 2D array (single proposal) "
|
||||
"or 3D array (multiple proposals). Each row "
|
||||
"must have 10 columns."
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
# Parse strategy names if provided
|
||||
custom_names: list[str] | None = None
|
||||
if strategy_names:
|
||||
try:
|
||||
parsed_names = json.loads(strategy_names)
|
||||
if isinstance(parsed_names, list) and all(
|
||||
isinstance(n, str) for n in parsed_names
|
||||
):
|
||||
custom_names = parsed_names
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# Normalize to 3D array format
|
||||
expected_count = len(custom_names) if custom_names else 0
|
||||
normalized = _normalize_proposals(raw_data, expected_count=expected_count)
|
||||
if isinstance(normalized, str):
|
||||
return ToolChunk(
|
||||
state=ToolResultState.SUCCESS,
|
||||
content=[TextBlock(type="text", text=normalized)],
|
||||
)
|
||||
proposals: List[List[List[Any]]] = normalized
|
||||
|
||||
# Validate proposals structure
|
||||
error = _validate_proposals(proposals)
|
||||
if error:
|
||||
return ToolChunk(
|
||||
state=ToolResultState.SUCCESS,
|
||||
content=[
|
||||
TextBlock(type="text", text=error),
|
||||
],
|
||||
)
|
||||
|
||||
# Determine proposal names
|
||||
proposal_names: list[str]
|
||||
if custom_names and len(custom_names) >= len(proposals):
|
||||
proposal_names = custom_names[: len(proposals)]
|
||||
elif len(proposals) == 1:
|
||||
proposal_names = ["方案"]
|
||||
else:
|
||||
proposal_names = _PROPOSAL_NAMES[: len(proposals)]
|
||||
|
||||
# Get session context
|
||||
session_id = get_current_session_id()
|
||||
logger.info("[proposal_choice] session_id from context: %s", session_id)
|
||||
if session_id is None:
|
||||
payload = json.dumps(
|
||||
{
|
||||
"headers": _TABLE_HEADERS,
|
||||
"proposals": proposals,
|
||||
"proposal_names": proposal_names,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
return ToolChunk(
|
||||
state=ToolResultState.SUCCESS,
|
||||
content=[TextBlock(type="text", text=payload)],
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"[proposal_choice] Creating interaction for session: %s",
|
||||
session_id,
|
||||
)
|
||||
interaction = InteractionManager.create(session_id)
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
interaction.event.wait(),
|
||||
timeout=_INTERACTION_TIMEOUT,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
return ToolChunk(
|
||||
state=ToolResultState.SUCCESS,
|
||||
content=[
|
||||
TextBlock(
|
||||
type="text",
|
||||
text="用户确认部署",
|
||||
),
|
||||
],
|
||||
)
|
||||
finally:
|
||||
InteractionManager.cleanup(session_id)
|
||||
|
||||
result = interaction.result or "用户未做出选择"
|
||||
return ToolChunk(
|
||||
state=ToolResultState.SUCCESS,
|
||||
content=[TextBlock(type="text", text=result)],
|
||||
)
|
||||
Reference in New Issue
Block a user