源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
Creator App Tests / PR Spam Gate (push) Skipped
Frontend Tests / PR Spam Gate (push) Skipped
NPM Format (website & console) / PR Spam Gate (push) Skipped
Pre-commit Checks / PR Spam Gate (push) Skipped
QwenPaw Creator Plugin Release / check-version (push) Canceled after 0s
QwenPaw Creator Plugin Release / upload-oss (push) Canceled after 0s
Tests / PR Spam Gate (push) Skipped
CodeQL / CodeQL (python) (push) Canceled after 0s
CodeQL / CodeQL (typescript) (push) Canceled after 0s
Creator App Tests / Creator Backend Tests (push) Canceled after 0s
Creator App Tests / Creator UI Build & Tests (push) Canceled after 0s
E2E Smoke Tests / Playwright UI Smoke (push) Canceled after 0s
Frontend Tests / Detect console changes (push) Canceled after 0s
NPM Format (website & console) / website format check (push) Canceled after 0s
NPM Format (website & console) / console format check (push) Canceled after 0s
Pre-commit Checks / Pre-commit (push) Canceled after 0s
QwenPaw Data Plugin Release / check-version (push) Canceled after 0s
Tests / Detect code changes (push) Canceled after 0s
Frontend Tests / Vitest Unit Tests (push) Canceled after 0s
Frontend Tests / Frontend Summary (push) Canceled after 0s
QwenPaw Data Plugin Release / upload-oss (push) Canceled after 0s
Tests / Maintainer Approval (push) Canceled after 0s
Tests / Unit Tests - py3.11 - macos-latest (push) Canceled after 0s
Tests / Unit Tests - py3.11 - ubuntu-latest (push) Canceled after 0s
Tests / Unit Tests - py3.13 - ubuntu-latest (push) Canceled after 0s
Tests / Unit Tests - py3.11 - windows-latest (push) Canceled after 0s
Tests / Contract Tests - py3.11 - macos-latest (push) Canceled after 0s
Tests / Contract Tests - py3.11 - ubuntu-latest (push) Canceled after 0s
Tests / Contract Tests - py3.13 - ubuntu-latest (push) Canceled after 0s
Tests / Contract Tests - py3.11 - windows-latest (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - macos-latest - p0 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - macos-latest - p1 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - macos-latest - p2 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - ubuntu-latest - fallback (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - ubuntu-latest - p0 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - ubuntu-latest - p1 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - ubuntu-latest - p2 (push) Canceled after 0s
Tests / Integrated Tests - py3.13 - ubuntu-latest - p0 (push) Canceled after 0s
Tests / Integrated Tests - py3.13 - ubuntu-latest - p1 (push) Canceled after 0s
Tests / Integrated Tests - py3.13 - ubuntu-latest - p2 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - windows-latest - p0 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - windows-latest - p1 (push) Canceled after 0s
Tests / Integrated Tests - py3.11 - windows-latest - p2 (push) Canceled after 0s
Tests / Coverage Report (push) Canceled after 0s
Tests / Test Summary (push) Canceled after 0s

This commit is contained in:
agent-radar
2026-09-01 20:01:50 +08:00
commit 1a3a4ccde1
4652 changed files with 1891610 additions and 0 deletions
@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
"""CloudPaw custom tools."""
+437
View File
@@ -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)],
)
+83
View File
@@ -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)],
)
+479
View File
@@ -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)],
)