Files
aiagent/backend/app/services/team_orchestrator.py
renjianbo 495eafcd78 feat: virtual company M2 control + tier-3/4 capabilities + per-dept session fix
- M2-a/b/c: stop/pause/resume/skip dept, plan/rework approval gates,
  budget & timeout circuit breakers, per-dept cancel and on-demand rework
- tier-3: batched event persistence (_EventBuffer), project/dept
  concurrency semaphores, company_ws event streaming
- tier-4: cross-dept deliverable handoff, CEO memory feedback loop,
  per-dept model override, one-click Markdown report export (RFC5987)
- fix: per-department independent SQLAlchemy session in parallel waves
  (shared Session race across asyncio tasks in company_orchestrator)
- fix: rename process_open_fds gauge to app_process_open_fds
  (collides with prometheus_client ProcessCollector on Linux)
- fix: dept role resolution ladder, real token usage & cost tracking,
  deliverable file capture (mtime scan of dept workspace)
- tests: test_company_orchestrator.py 22 cases with mocked LLM
- frontend: CompanyControlRoom, companyExecution store, EChart,
  dept model config dialog, ws proxy, build/typecheck decoupling
- docs: virtual company design/usage docs, M2 control plan,
  deepseek4pro handover docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-26 18:16:39 +08:00

1976 lines
87 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
团队编排引擎 — PM 规划 → 顺序阶段执行 → QA 审查 → 交付物
用法:
orchestrator = TeamOrchestrator(db, team_id, user_id)
result = await orchestrator.execute("做一个个人博客")
async for event in orchestrator.execute_stream("做一个个人博客"):
...
"""
from __future__ import annotations
import asyncio
import json
import re
import difflib
import logging
import time
from pathlib import Path
from typing import Any, AsyncGenerator, Dict, List, Optional
from datetime import datetime
from sqlalchemy.orm import Session
from app.models.team import Team, TeamMember
from app.models.agent import Agent
from app.agent_runtime.core import AgentRuntime
from app.agent_runtime.schemas import (
AgentConfig,
AgentLLMConfig,
AgentToolConfig,
AgentMemoryConfig,
AgentResult,
)
logger = logging.getLogger(__name__)
# 项目扫描器(共享模块,也供 builtin_tools 使用)
from app.services.project_scanner import (
identify_project_type,
scan_source_code,
scan_multiple_dirs,
)
def _extract_model_override(team_config: Any) -> Optional[dict]:
"""四档-③:从 Team.config 解析部门级模型覆盖 {provider?, model?}(无则 None)。
约定存于 `config["model_override"] = {"provider": "...", "model": "..."}`;
provider/model 任一为空即视为未设该项,两者皆空 → None(回归各成员 Agent 自身配置)。
"""
if not isinstance(team_config, dict):
return None
mo = team_config.get("model_override")
if not isinstance(mo, dict):
return None
model = str(mo.get("model") or "").strip()
provider = str(mo.get("provider") or "").strip()
if not model and not provider:
return None
return {"model": model or None, "provider": provider or None}
def _build_agent_config(
agent: Agent,
auto_approve_files: bool = True,
model_override: Optional[dict] = None,
) -> AgentConfig:
"""从 Agent 的 workflow_config 构建 AgentConfig,供 AgentRuntime 使用。
model_override(四档-③ 部门级配模型)不为空时覆盖 provider/model,
让同一部门内所有成员统一走部门指定的模型(成本/质量分层调优)。
"""
wf = agent.workflow_config or {}
nodes = wf.get("nodes", [])
llm_node = None
for n in nodes:
if n.get("type") == "llm":
llm_node = n
break
llm_data = llm_node.get("data", {}) if llm_node else {}
tools = llm_data.get("selected_tools") or llm_data.get("tools") or []
system_prompt = llm_data.get("prompt") or ""
model = llm_data.get("model", "deepseek-v4-pro")
provider = llm_data.get("provider", "deepseek")
temperature = float(llm_data.get("temperature", 0.4))
max_iterations = int(llm_data.get("max_iterations", 15))
# 四档-③:部门级模型覆盖(优先于成员 Agent 自身配置)
if model_override:
if model_override.get("model"):
model = model_override["model"]
if model_override.get("provider"):
provider = model_override["provider"]
perm = "acceptEdits" if auto_approve_files else "default"
logger.info("_build_agent_config: agent=%s tools=%d permission_level=%s model=%s%s",
agent.name, len(tools), perm, model, " (dept-override)" if model_override else "")
return AgentConfig(
name=agent.name,
system_prompt=system_prompt,
user_id=agent.user_id,
memory_scope_id=f"team_{agent.id}",
llm=AgentLLMConfig(
provider=provider,
model=model,
temperature=temperature,
max_iterations=max_iterations,
),
tools=AgentToolConfig(include_tools=tools, permission_level=perm),
memory=AgentMemoryConfig(
enabled=True,
max_history_messages=30,
persist_to_db=True,
learning_enabled=True,
),
)
def _parse_plan_json(raw_output: str) -> Dict[str, Any]:
"""从 LLM 原始输出中提取 JSON 计划(含 Markdown 表格回退)。"""
text = raw_output.strip()
# 尝试直接解析
try:
return json.loads(text)
except json.JSONDecodeError:
pass
# 从 ```json ... ``` 中提取
m = re.search(r'```(?:json)?\s*([\s\S]*?)\s*```', text)
if m:
try:
return json.loads(m.group(1))
except json.JSONDecodeError:
pass
# 从 { ... } 中提取(含 phases 键)
m = re.search(r'\{[\s\S]*"phases"[\s\S]*\}', text)
if m:
try:
return json.loads(m.group())
except json.JSONDecodeError:
pass
# ─── 回退:从 Markdown 表格提取 phases ───
# 匹配形如 | **phase-1** | `functional_tester` | ... | 的行
phases = _parse_phases_from_markdown(text)
if phases:
project_name = ""
m = re.search(r'(?:项目|测试).*?[::]\s*(.+?)(?:\n|$)', text)
if m:
project_name = m.group(1).strip()
return {
"project_name": project_name or "未命名项目",
"analysis": "",
"user_stories": [],
"phases": phases,
"acceptance_criteria": [],
}
logger.warning("无法从输出中解析 JSON 计划,原始输出: %.500s", text)
return {}
def _extract_paths_from_description(text: str) -> List[Path]:
"""从自然语言项目描述中提取存在的目录路径。
支持 Windows (D:\\... C:/...) 和 Unix (/home/...) 路径格式。
返回去重后的存在路径列表(仅保留目录,排除文件路径)。
"""
# 匹配绝对路径模式:盘符路径 / Unix绝对路径
candidates = re.findall(
r'(?:[A-Za-z]:[\\/][^\s,;,;\n]+|/[^\s,;,;\n]+)',
text
)
found: set = set()
for c in candidates:
# 规范化路径
p = Path(c)
try:
resolved = p.resolve()
except (OSError, ValueError):
continue
# 如果路径指向文件,取其父目录
if resolved.is_file():
resolved = resolved.parent
if resolved.is_dir() and resolved not in found:
found.add(resolved)
return list(found)
def _get_default_scan_paths() -> List[Path]:
"""当项目描述中未提取到路径时,返回默认的扫描目录。
默认扫描 workspace 下的 frontend/src 和 backend/app,
若都不存在则回退到 workspace 根目录。
"""
root = _resolve_workspace_root()
candidates = [
root / "frontend" / "src",
root / "backend" / "app",
]
existing = [p for p in candidates if p.is_dir()]
return existing if existing else [root]
def _validate_and_fix_dependencies(phases: List[Dict[str, Any]]) -> int:
"""验证并修复阶段的 depends_on 引用。
- 移除对不存在阶段号的引用
- 移除自身引用(phase 3 depends_on [3])
- 降级 forward 依赖为顺序暗示(warning 但不阻断)
Returns:
修复的次数(0 表示无问题)
"""
valid_numbers = {p["phase"] for p in phases}
fixes = 0
for p in phases:
deps = p.get("depends_on", [])
if not deps:
continue
fixed = []
for d in deps:
if d not in valid_numbers:
logger.warning(
"阶段 %d (%s): depends_on 引用了不存在的阶段 %d,已移除",
p["phase"], p.get("name", ""), d,
)
fixes += 1
elif d == p["phase"]:
logger.warning(
"阶段 %d (%s): depends_on 引用自身,已移除",
p["phase"], p.get("name", ""),
)
fixes += 1
elif d > p["phase"]:
logger.warning(
"阶段 %d (%s): depends_on=[%d] 是 forward 依赖(依赖后续阶段),"
"可能导致死锁,已移除",
p["phase"], p.get("name", ""), d,
)
fixes += 1
else:
fixed.append(d)
if len(fixed) != len(deps):
p["depends_on"] = fixed
return fixes
def _parse_depends_on(raw: str) -> List[int]:
"""解析 depends_on 列的值,支持 []、[1]、[1,2]、[1, 2] 等格式。返回 int 列表。"""
raw = raw.strip()
if not raw or raw == '[]':
return []
try:
parsed = json.loads(raw)
if isinstance(parsed, list):
return [int(x) for x in parsed]
except (json.JSONDecodeError, ValueError, TypeError):
pass
# 兼容非 JSON 写法: "1,2" 或 "1, 2"
nums = re.findall(r'\d+', raw)
if nums:
return [int(n) for n in nums]
return []
def _norm_role(s: str) -> str:
"""角色名归一化:小写、去空白、下划线/空格统一为连字符。"""
return (s or "").strip().lower().replace("_", "-").replace(" ", "-")
# LLM 常见叫法 → 预设 slug(归一化后的键)。其余靠归一化+模糊兜底。
_ROLE_ALIASES: Dict[str, str] = {
"backend": "dev-backend",
"back-end": "dev-backend",
"backend-dev": "dev-backend",
"backend-developer": "dev-backend",
"developer": "dev-backend",
"engineer": "dev-backend",
"程序员": "dev-backend",
"后端": "dev-backend",
"后端工程师": "dev-backend",
"frontend": "dev-frontend",
"front-end": "dev-frontend",
"frontend-dev": "dev-frontend",
"frontend-developer": "dev-frontend",
"前端": "dev-frontend",
"前端工程师": "dev-frontend",
"产品经理": "pm",
"product-manager": "pm",
"pm-lead": "pm",
"设计师": "designer",
"ui": "designer",
"ui-designer": "designer",
"测试": "qa",
"测试工程师": "qa",
"qa-engineer": "qa",
"运维": "devops",
"文档": "tech-writer",
"技术文档": "tech-writer",
"tech-writer": "tech-writer",
"techwriter": "tech-writer",
"首席执行官": "ceo",
"技术总监": "cto",
"首席技术官": "cto",
}
def _parse_phases_from_markdown(text: str) -> List[Dict[str, Any]]:
"""从 Markdown 表格中提取 phase 信息。支持格式:
| **phase-1** | `functional_tester` | 核心任务 | 工时 |
| phase-1 | functional_tester | 描述 |
| phase-1 | functional_tester | 描述 | [1,2] | ← 可选的 depends_on 列
"""
phases = []
# 匹配表格行: | (可选**的) phase-N (可选**) | role | description | [可选 depends_on] |
# role 可能带反引号 `role` 或直接是文本;支持连字符 slug(如 dev-backend)
table_row = re.compile(
r'^\|\s*\*{0,2}(?:phase[-–]?\s*)?(\d+)\*{0,2}\s*\|'
r'\s*(?:`)?([a-z0-9_-]+)(?:`)?\s*\|'
r'\s*([^|]+?)\s*\|'
r'(?:\s*([^|]*?)\s*\|)?',
re.MULTILINE | re.IGNORECASE
)
for m in table_row.finditer(text):
phase_num = int(m.group(1))
role = m.group(2).strip().lower()
desc = m.group(3).strip()
depends_raw = (m.group(4) or '').strip() if m.lastindex and m.lastindex >= 4 else ''
# 接受任何非空 role slug;不存在的角色交由 _resolve_phase_agent 归一化/兜底
if not role:
continue
phases.append({
"phase": phase_num,
"name": desc[:60],
"role": role,
"description": desc,
"expected_output": f"{role} 阶段的产出物",
"depends_on": _parse_depends_on(depends_raw),
})
if phases:
logger.info("从 Markdown 表格提取到 %d 个 phases", len(phases))
return phases
def _resolve_workspace_root() -> Path:
"""获取文件工具的工作区根目录(与 builtin_tools.py 逻辑一致)。"""
from app.core.config import settings
raw = (getattr(settings, "LOCAL_FILE_TOOLS_ROOT", None) or "").strip()
if raw:
return Path(raw).expanduser().resolve()
# fallback: 4 级父目录 (builtin_tools.py → app/services → app → backend → repo root)
return Path(__file__).resolve().parent.parent.parent.parent
def _safe_project_name(description: str) -> str:
"""从项目描述中提取安全的目录名(英文/拼音/数字/连字符)。"""
# 取前 40 个字符,只保留英文、数字、中文转拼音首字母的近似
cleaned = description.strip().replace(" ", "-").replace("/", "-").replace("\\", "-")
# 去掉非 ASCII 和非基本标点
safe = re.sub(r'[^\w\-]', '', cleaned, flags=re.ASCII)
if not safe:
# 如果全是中文,用时间戳
from datetime import datetime
safe = f"project-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
return safe[:40].strip("-")
def _scan_directory_files(directory: Path) -> List[str]:
"""扫描目录下所有文件的绝对路径列表(递归)。"""
files: List[str] = []
if not directory.exists():
return files
for p in directory.rglob("*"):
if p.is_file():
files.append(str(p))
return sorted(files)
def _scan_recent_files(root: Path, since_ts: float) -> List[str]:
"""递归扫描 root,返回本次运行期间(mtime >= since_ts)修改过的文件绝对路径。
用于精确捕获部门本轮真实产物:Agent 有时把产物写到 project_path 以外的
兄弟目录,或用脚本(code_execute)生成文件——这些都不在 file_write 追踪里,
但只要落在部门工作区且是本轮修改的,就应计入交付物。按 mtime 过滤可避免
把历史运行的陈旧文件误纳入。
"""
import os
hits: List[str] = []
if not root.exists():
return hits
for p in root.rglob("*"):
try:
if p.is_file() and p.stat().st_mtime >= since_ts:
hits.append(str(p))
except OSError:
continue
return hits
def _extract_written_files(result: AgentResult) -> List[str]:
"""从 AgentResult.steps 中提取 file_write 成功产出的绝对路径列表。"""
files: List[str] = []
seen = set()
for step in result.steps:
if step.tool_name == "file_write":
# 优先从 tool_result JSON 中取绝对路径(仅当写入成功时)
if step.tool_result:
try:
data = json.loads(step.tool_result)
# 跳过失败的调用(权限不足 / 需确认)
if data.get("error") or data.get("requires_confirmation"):
logger.warning("file_write 未成功(%s),跳过文件追踪", data.get("error", "requires_confirmation"))
continue
fp = data.get("file_path")
if fp and fp not in seen:
files.append(fp)
seen.add(fp)
continue
except (json.JSONDecodeError, TypeError):
pass
# 回退:从 tool_input 取相对路径,再拼接工作区根(仅当 tool_result 为空时)
if step.tool_input:
fp = step.tool_input.get("file_path")
if fp:
abs_path = str(_resolve_workspace_root() / fp)
if abs_path not in seen:
files.append(abs_path)
seen.add(abs_path)
return files
class TeamOrchestrator:
"""虚拟团队编排器 — 按角色分工顺序执行软件项目。"""
def __init__(
self,
db: Session,
team_id: str,
user_id: str,
auto_approve_files: bool = True,
):
self.db = db
self.team_id = team_id
self.user_id = user_id
self.auto_approve_files = auto_approve_files
self._model_override: Optional[dict] = None # 四档-③:部门级模型覆盖,execute[_stream] 载入 team 后设置
def _acfg(self, agent: Agent) -> AgentConfig:
"""构建成员 AgentConfig,自动应用部门级模型覆盖(四档-③)。"""
return _build_agent_config(agent, self.auto_approve_files, self._model_override)
def _load_team(self) -> Team:
team = self.db.query(Team).filter(Team.id == self.team_id).first()
if not team:
raise ValueError(f"团队不存在: {self.team_id}")
return team
def _load_members(self) -> List[TeamMember]:
return (
self.db.query(TeamMember)
.filter(TeamMember.team_id == self.team_id)
.order_by(TeamMember.position)
.all()
)
def _get_agent_by_role(self, members: List[TeamMember], role: str) -> Optional[Agent]:
"""按角色查找 Agent(严格精确匹配)。规划者角色解析依赖其 None 回退,勿放宽。"""
for m in members:
if m.role == role:
return m.agent
return None
def _resolve_phase_agent(
self, members: List[TeamMember], role: str
) -> tuple[Optional[Agent], str, bool]:
"""健壮地把阶段 role 解析到成员 Agent,避免「部门空转」。
解析阶梯:精确 → 归一化相等 → 复合拆分(a/b、a,b) → alias 表 → 模糊 → 兜底到 Leader。
Returns: (agent, matched_role, is_fallback)。members 为空才返回 (None, role, False)。
"""
if not members:
return None, role, False
# a. 精确
for m in members:
if m.role == role:
return m.agent, m.role, False
norm_to_member = {_norm_role(m.role): m for m in members}
nrole = _norm_role(role)
# b. 归一化相等
if nrole in norm_to_member:
m = norm_to_member[nrole]
return m.agent, m.role, False
# c. 复合拆分("ceo/cto"、"a, b"、"a and b"、"a、b")
tokens = [t.strip().strip("-") for t in re.split(r"[/,&|、]|\band\b", nrole)]
tokens = [t for t in tokens if t]
if len(tokens) > 1:
for tok in tokens:
if tok in norm_to_member:
m = norm_to_member[tok]
return m.agent, m.role, False
alias = _ROLE_ALIASES.get(tok)
if alias and _norm_role(alias) in norm_to_member:
m = norm_to_member[_norm_role(alias)]
return m.agent, m.role, False
# d. alias 表
alias = _ROLE_ALIASES.get(nrole)
if alias and _norm_role(alias) in norm_to_member:
m = norm_to_member[_norm_role(alias)]
return m.agent, m.role, False
# e. 模糊
close = difflib.get_close_matches(nrole, list(norm_to_member.keys()), n=1, cutoff=0.6)
if close:
m = norm_to_member[close[0]]
return m.agent, m.role, False
# f. 兜底到 Leader(无则第一个成员)
lead = next((m for m in sorted(members, key=lambda x: x.position) if m.is_lead), None)
m = lead or sorted(members, key=lambda x: x.position)[0]
return m.agent, m.role, True
def _resolve_planner_role(self, members: List[TeamMember], workflow: str) -> str:
"""根据团队 workflow 类型解析规划者角色。
- software_company → pm
- education_training → curriculum_designer
- platform_engineering → product_lead (产品负责人做规划)
- 回退: 第一个 is_lead=True 的成员
- 最终回退: 第一个成员
"""
workflow_planner_map = {
"software_company": "pm",
"education_training": "curriculum_designer",
"platform_engineering": "product_lead",
"tech_doc": "doc_architect",
"health_management": "health_assessor",
"medical_consultation": "triage_specialist",
"user_simulation_test": "test_planner",
}
role = workflow_planner_map.get(workflow)
if role and self._get_agent_by_role(members, role):
return role
# 回退:找第一个 Leader
for m in sorted(members, key=lambda x: x.position):
if m.is_lead:
return m.role
# 最终回退:第一个成员
if members:
return members[0].role
return "pm"
def _build_phase_input(
self,
phase: Dict[str, Any],
context_doc: str,
project_path: Path,
all_files: List[str],
dependency_outputs: Dict[int, str],
pre_scan_result: str = "",
) -> str:
"""为阶段构建结构化上下文输入。
包含:预扫描源码 + 前置阶段摘要 + 直接依赖的完整产出 + 文件清单 + 阶段任务说明。
"""
phase_name = phase.get("name", "")
phase_desc = phase.get("description", "")
expected = phase.get("expected_output", "")
deps = phase.get("depends_on", [])
parts = [context_doc]
# 预扫描源码(真实代码,非设计文档)
if pre_scan_result:
parts.append("\n## 📋 项目源码预扫描(真实代码)\n")
parts.append(pre_scan_result)
parts.append(
"\n> **重要提示**: 以上是项目实际源代码的预扫描结果。"
"请基于这些真实代码进行分析和审查,而非基于设计文档或推测。"
"引用具体文件路径、类名、方法名来支撑你的结论。\n"
)
# 直接依赖阶段的完整产出(非截断)
if deps and dependency_outputs:
parts.append("\n## 📎 直接依赖阶段的完整产出\n")
for dep_num in sorted(deps):
if dep_num in dependency_outputs:
dep_text = dependency_outputs[dep_num]
parts.append(f"### 阶段 {dep_num} 产出\n{dep_text[:8000]}\n")
# 当前工作目录文件清单
if all_files:
recent = sorted(all_files)[-60:]
parts.append(f"\n## 📁 当前项目文件 ({len(all_files)} 个已有文件)\n")
for fp in recent:
parts.append(f"- {fp}")
if len(all_files) > 60:
parts.append(f"- ... 还有 {len(all_files) - 60} 个文件未列出\n")
# 当前阶段任务
parts.append(f"\n## 🎯 当前阶段: {phase_name}\n")
parts.append(f"任务描述: {phase_desc}\n")
parts.append(f"期望产出: {expected}\n")
parts.append(f"项目文件保存目录: {project_path}\n")
parts.append("请将所有产出文件写入此目录,完成此阶段的工作并输出你的交付物。")
return "\n".join(parts)
async def _run_single_phase(
self,
phase: Dict[str, Any],
members: List[TeamMember],
context_doc: str,
project_path: Path,
prev_files: set,
all_files: Optional[List[str]] = None,
dependency_outputs: Optional[Dict[int, str]] = None,
pre_scan_result: str = "",
) -> Dict[str, Any]:
"""执行单个 phase(可被 asyncio.gather 并发调用)。
每个 phase 创建独立的 AgentRuntime,互不干扰。
支持 self_review 自优化:agent 完成初稿后自动复盘改进。
"""
phase_num = phase.get("phase", 0)
role = phase.get("role", "developer")
phase_name = phase.get("name", f"阶段 {phase_num}")
phase_desc = phase.get("description", "")
expected = phase.get("expected_output", "")
self_review = phase.get("self_review", False)
# DB 查询在协程内完成(members 已预加载,此处仅内存迭代)
agent, matched_role, role_fallback = self._resolve_phase_agent(members, role)
if not agent:
logger.warning("团队编排 [%s]: 阶段 %s 无可用成员(角色 '%s'),跳过",
self.team_id, phase_num, role)
return {
"phase": phase_num, "name": phase_name, "role": role,
"agent_name": None,
"output": f"跳过: 无 {role} 角色",
"success": False, "error": f"missing_role:{role}",
"files": [],
}
if role_fallback or matched_role != role:
logger.info("团队编排 [%s]: 阶段 %s 角色 '%s' 解析为 '%s'%s",
self.team_id, phase_num, role, matched_role,
"(兜底 Leader)" if role_fallback else "")
logger.info("团队编排 [%s]: 执行阶段 %s — %s (角色: %s, Agent: %s)",
self.team_id, phase_num, phase_name, matched_role, agent.name)
try:
agent_config = self._acfg(agent)
runtime = AgentRuntime(agent_config)
phase_input = self._build_phase_input(
phase, context_doc, project_path,
all_files or [], dependency_outputs or {},
pre_scan_result=pre_scan_result,
)
phase_result = await runtime.run(phase_input)
# ─── P2-2: Self-review 自优化 ───
if self_review and phase_result.success:
logger.info("团队编排 [%s]: 阶段 %s 进入自优化复盘",
self.team_id, phase_num)
review_input = (
f"你刚完成了以下任务:\n\n{phase_result.content[:3000]}\n\n"
f"现在请以审查者的视角审视你的产出:\n"
f"1. 是否有遗漏的功能或边界情况?\n"
f"2. 代码质量是否可以改进(命名、结构、注释)?\n"
f"3. 是否与现有项目风格一致?\n"
f"4. 是否有未处理的错误场景?\n\n"
f"请优化你的产出并输出最终版本。如果已经很好,直接输出原内容。"
)
review_result = await runtime.run(review_input)
if review_result.success and review_result.content:
phase_result = review_result
logger.info("团队编排 [%s]: 阶段 %s 自优化完成",
self.team_id, phase_num)
output_text = phase_result.content if phase_result.success else (
phase_result.error or "执行失败"
)
# 提取本阶段写入的文件(AgentResult + 目录扫描双保险)
extracted = _extract_written_files(phase_result)
current_files = set(_scan_directory_files(project_path))
phase_files = sorted(current_files - prev_files)
for fp in extracted:
if fp not in phase_files:
phase_files.append(fp)
return {
"phase": phase_num, "name": phase_name, "role": role,
"actual_role": matched_role, "role_fallback": role_fallback,
"agent_name": agent.name,
"output": output_text,
"success": phase_result.success,
"iterations": phase_result.iterations_used,
"tool_calls": phase_result.tool_calls_made,
"error": phase_result.error if not phase_result.success else None,
"files": phase_files,
}
except Exception as e:
logger.error("团队编排 [%s]: 阶段 %s 异常: %s", self.team_id, phase_num, e)
return {
"phase": phase_num, "name": phase_name, "role": role,
"agent_name": agent.name,
"output": f"执行异常: {e}",
"success": False, "error": str(e),
"files": [],
}
async def _run_phase_stream_and_collect(
self,
phase: Dict[str, Any],
members: List[TeamMember],
context_doc: str,
project_path: Path,
prev_files: set,
event_queue: asyncio.Queue,
all_files: Optional[List[str]] = None,
dependency_outputs: Optional[Dict[int, str]] = None,
pre_scan_result: str = "",
) -> Dict[str, Any]:
"""流式执行单个 phase —— 用 run_stream() 替代 run()。
将 Agent 内部事件(think/tool_call/tool_result/final)实时放入 event_queue,
前端可据此渲染每个 Agent 的独立执行面板。
Returns:
最终结果字典(与 _run_single_phase 相同结构)
"""
phase_num = phase.get("phase", 0)
role = phase.get("role", "developer")
phase_name = phase.get("name", f"阶段 {phase_num}")
agent, matched_role, role_fallback = self._resolve_phase_agent(members, role)
base_event = {
"phase": phase_num,
"role": role,
"actual_role": matched_role,
"role_fallback": role_fallback,
"name": phase_name,
"agent": agent.name if agent else None,
}
if not agent:
event_queue.put_nowait({
**base_event,
"type": "phase_done",
"success": False,
"error": f"missing_role:{role}",
"output": f"跳过: 无 {role} 角色",
"files": [],
})
return {
"phase": phase_num, "name": phase_name, "role": role,
"agent_name": None,
"output": f"跳过: 无 {role} 角色",
"success": False, "error": f"missing_role:{role}",
"files": [],
}
if role_fallback or matched_role != role:
logger.info("团队编排 [%s]: 阶段 %s 角色 '%s' 解析为 '%s'%s",
self.team_id, phase_num, role, matched_role,
"(兜底 Leader)" if role_fallback else "")
try:
agent_config = self._acfg(agent)
runtime = AgentRuntime(agent_config)
phase_input = self._build_phase_input(
phase, context_doc, project_path,
all_files or [], dependency_outputs or {},
pre_scan_result=pre_scan_result,
)
output_parts: List[str] = []
last_iteration = 0
tool_count = 0
async for agent_event in runtime.run_stream(phase_input):
event_type = agent_event.get("type", "")
# 转发 Agent 内部事件到前端(带 phase 元数据)
event_queue.put_nowait({
**base_event,
"type": "agent_event",
"data": agent_event,
})
if event_type == "final":
content = agent_event.get("content", "")
if content:
output_parts.append(content)
elif event_type == "tool_result":
tool_count += 1
last_iteration = agent_event.get("iteration", last_iteration)
output_text = "\n\n".join(output_parts) if output_parts else "执行完成"
# ─── P2-2: Self-review 自优化 ───
self_review = phase.get("self_review", False)
if self_review and output_parts:
review_event = {
**base_event,
"type": "agent_event",
"data": {"type": "think", "content": "🔄 进入自优化复盘..."},
}
event_queue.put_nowait(review_event)
review_input = (
f"你刚完成了以下任务:\n\n{output_text[:3000]}\n\n"
f"现在请以审查者的视角审视你的产出:\n"
f"1. 是否有遗漏的功能或边界情况?\n"
f"2. 代码质量是否可以改进(命名、结构、注释)?\n"
f"3. 是否与现有项目风格一致?\n"
f"4. 是否有未处理的错误场景?\n\n"
f"请优化你的产出并输出最终版本。如果已经很好,直接输出原内容。"
)
review_parts: List[str] = []
async for agent_event in runtime.run_stream(review_input):
event_queue.put_nowait({
**base_event,
"type": "agent_event",
"data": agent_event,
})
if agent_event.get("type") == "final":
content = agent_event.get("content", "")
if content:
review_parts.append(content)
if review_parts:
output_text = "\n\n".join(review_parts)
logger.info("团队编排 [%s]: 阶段 %s 流式自优化完成",
self.team_id, phase_num)
# 提取本阶段写入的文件
current_files = set(_scan_directory_files(project_path))
phase_files = sorted(current_files - prev_files)
result = {
"phase": phase_num, "name": phase_name, "role": role,
"agent_name": agent.name,
"output": output_text,
"success": True,
"iterations": last_iteration,
"tool_calls": tool_count,
"error": None,
"files": phase_files,
}
event_queue.put_nowait({
**base_event,
"type": "phase_done",
"output": output_text,
"success": True,
"iterations": last_iteration,
"tool_calls": tool_count,
"files": phase_files,
"error": None,
})
return result
except Exception as e:
logger.error("团队编排 [%s]: 阶段 %s 流式异常: %s", self.team_id, phase_num, e)
result = {
"phase": phase_num, "name": phase_name, "role": role,
"agent_name": agent.name,
"output": f"执行异常: {e}",
"success": False, "error": str(e),
"files": [],
}
event_queue.put_nowait({
**base_event,
"type": "phase_done",
"success": False,
"error": str(e),
"output": f"执行异常: {e}",
"files": [],
})
return result
async def _execute_phases_parallel(
self,
phases: List[Dict[str, Any]],
members: List[TeamMember],
context_doc: str,
project_path: Path,
prev_files: set,
pre_scan_result: str = "",
):
"""DAG 并行执行 phases。按 depends_on 拓扑分批,批内 asyncio.gather 并发。
Returns:
(phase_results, updated_context_doc, updated_prev_files, all_files)
"""
results: List[Dict[str, Any]] = []
completed: Dict[int, str] = {} # phase_num → output_text
pending = {p["phase"]: p for p in phases}
current_prev = prev_files.copy()
current_context = context_doc
all_files: List[str] = []
while pending:
# 找出所有依赖已满足的阶段
ready = [
p for p in pending.values()
if all(d in completed for d in p.get("depends_on", []))
]
if not ready:
raise RuntimeError(
f"阶段循环依赖,无法继续。已完成: {list(completed.keys())}, "
f"待处理: {list(pending.keys())}"
)
logger.info("团队编排 [%s]: 并行批次 — %d 个阶段: %s",
self.team_id, len(ready),
[(p["phase"], p.get("name", "")) for p in ready])
# 并行执行所有就绪阶段
tasks = [
self._run_single_phase(
p, members, current_context, project_path, current_prev,
all_files=list(all_files), dependency_outputs=completed.copy(),
pre_scan_result=pre_scan_result,
)
for p in ready
]
batch_results = await asyncio.gather(*tasks, return_exceptions=True)
for p, r in zip(ready, batch_results):
if isinstance(r, Exception):
r = {
"phase": p["phase"], "name": p.get("name", ""),
"role": p.get("role", ""),
"agent_name": None,
"output": f"并行执行异常: {r}",
"success": False, "error": str(r),
"files": [],
}
# 记录完成
completed[p["phase"]] = r.get("output", "")
# 更新共享上下文(后续批次可见)
current_context += (
f"\n## 阶段 {p['phase']}: {p.get('name', '')}\n"
f"{r.get('output', '')[:2000]}\n"
)
# 更新文件追踪
for fp in r.get("files", []):
if fp not in all_files:
all_files.append(fp)
current_prev.add(fp)
results.append(r)
del pending[p["phase"]]
return results, current_context, current_prev, all_files
async def execute(self, project_description: str, auto_approve_files: bool = True) -> Dict[str, Any]:
"""执行团队项目(非流式)。
Returns:
{
"project_name": str,
"plan": {...},
"phases": [{phase, role, agent_name, output, steps, success, error}],
"qa_review": {...} | None,
"final_deliverable": str,
"success": bool,
}
"""
self.auto_approve_files = auto_approve_files
team = self._load_team()
members = self._load_members()
if not members:
raise ValueError("团队没有成员,请先为角色分配 Agent")
self._model_override = _extract_model_override(team.config) # 四档-③ 部门级配模型
# ─── 创建项目目录 ───
workspace_root = _resolve_workspace_root()
safe_name = _safe_project_name(project_description)
project_path = workspace_root / "team_projects" / self.team_id / safe_name
project_path.mkdir(parents=True, exist_ok=True)
logger.info("团队编排 [%s]: 项目目录 %s", self.team_id, project_path)
run_start_ts = time.time() # 交付物捕获基准:本轮之后修改的文件才算产物
prev_files = set(_scan_directory_files(project_path))
all_files: List[str] = []
results: Dict[str, Any] = {
"team_id": self.team_id,
"team_name": team.name,
"project_description": project_description,
"project_path": str(project_path),
"files": [],
"started_at": datetime.now().isoformat(),
"plan": None,
"phases": [],
"qa_review": None,
"final_deliverable": "",
"success": True,
"error": None,
}
# ─── Phase 0: 架构分析(Architect 扫描现有项目) ───
enhanced_description = project_description
pre_scan_result = "" # 预扫描源码,后续注入到各执行阶段的 prompt
architect_agent = self._get_agent_by_role(members, "architect")
if architect_agent:
target_paths = _extract_paths_from_description(project_description)
if not target_paths:
target_paths = _get_default_scan_paths()
logger.info("团队编排 [%s]: 未从描述中提取路径,使用默认扫描路径: %s",
self.team_id, [str(p) for p in target_paths])
logger.info("团队编排 [%s]: Architect 开始扫描 %d 个目标路径",
self.team_id, len(target_paths))
try:
arch_config = self._acfg(architect_agent)
arch_runtime = AgentRuntime(arch_config)
# ─── 程序化预扫描源码 ───
pre_scan_result = scan_multiple_dirs(target_paths)
if pre_scan_result:
logger.info("团队编排 [%s]: 预扫描完成,已提取源文件内容",
self.team_id)
arch_input = (
f"你是一名系统架构师。请基于以下已预扫描的源代码,"
f"输出完整的架构上下文文档供后续测试团队使用。\n\n"
f"任务背景:{project_description}\n\n"
f"{pre_scan_result}\n\n"
f"## 你的任务\n"
f"上述源码清单中已包含关键源文件的完整内容。"
f"请基于这些真实代码(不是设计文档)输出架构分析文档,包含:\n"
f"1. 技术栈清单(框架/库/版本,从实际配置文件确认)\n"
f"2. 目录结构与模块划分\n"
f"3. 数据流路径(UI→ViewModel→Repository→API/DAO→DB)\n"
f"4. 关键类的职责与依赖关系(引用源码中的实际类名/方法名)\n"
f"5. 从源码中发现的实际代码质量问题\n\n"
f"## 注意\n"
f"- 主要分析基于上述已提供的源码内容\n"
f"- 如需补充阅读特定文件,可以用 file_read 工具\n"
f"- 不要基于设计文档或推测进行分析\n"
f"- 在输出中列出你分析引用了哪些源文件"
)
arch_result = await arch_runtime.run(arch_input)
if arch_result.success and arch_result.content:
arch_analysis = arch_result.content.strip()
enhanced_description = (
f"{project_description}\n\n"
f"## 架构分析(由系统架构师扫描现有项目生成)\n\n"
f"{arch_analysis}"
)
results["architecture_analysis"] = arch_analysis
logger.info("团队编排 [%s]: Architect 分析完成 (%d 字符)",
self.team_id, len(arch_analysis))
except Exception as e:
logger.warning("团队编排 [%s]: Architect 分析异常(继续执行): %s",
self.team_id, e)
# ─── Phase 1: 规划阶段(由 Leader Agent 担任) ───
workflow = (team.config or {}).get("workflow", "software_company")
planner_role = self._resolve_planner_role(members, workflow)
planner_agent = self._get_agent_by_role(members, planner_role)
if not planner_agent:
raise ValueError(
f"团队缺少 Leader 角色(需要 role='{planner_role}'),无法分解项目。"
f"可用角色: {[m.role for m in members]}"
)
available_roles = [m.role for m in members]
# 如果架构师已作为前置步骤执行,从可用角色中移除,避免 PM 再次规划架构师阶段
if enhanced_description != project_description and "architect" in available_roles:
available_roles.remove("architect")
roles_hint = ", ".join(available_roles)
first_role = available_roles[0] if available_roles else "developer"
logger.info("团队编排 [%s]: 规划开始 (workflow=%s, planner=%s, role=%s)",
self.team_id, workflow, planner_agent.name, planner_role)
planner_config = self._acfg(planner_agent)
planner_runtime = AgentRuntime(planner_config)
planner_input = (
f"你是一个团队的项目规划者。请分析以下需求,将工作分解为阶段,并分配给你团队的每个成员。\n\n"
f"需求描述:{enhanced_description}\n"
f"输出文件目录:{project_path}\n\n"
f"你的团队有以下角色 slug(必须逐字使用,禁止合并/翻译/自造):{roles_hint}\n"
f'每个阶段的 role 字段必须精确等于上述其中一个 slug(例如 "{first_role}");'
f'严禁把多个角色写成 "a/b" 这种复合形式。\n\n'
f"请严格按照以下 JSON 格式输出执行计划(不要输出任何其他内容,只输出 JSON):\n"
f'{{"project_name":"...","analysis":"...","phases":[\n'
f' {{"phase":1,"name":"...","role":"{first_role}","description":"...","expected_output":"...","depends_on":[]}},\n'
f' {{"phase":2,"name":"...","role":"...","description":"...","expected_output":"...","depends_on":[]}}\n'
f'],"acceptance_criteria":["..."]}}\n\n'
f"关键要求:\n"
f"1. phases 数组是强制性的——没有它计划无法执行\n"
f"2. 每个 phase 的 role 必须逐字复制上述角色 slug 之一(区分大小写、含连字符),禁止组合/翻译/自造\n"
f"3. depends_on 列出必须先完成的阶段编号,无依赖则用[]\n"
f"4. 4-7 个阶段最佳\n"
f"5. 用中文输出\n"
f"6. 只输出 JSON,不要有任何前言或后记"
)
results["plan_input"] = enhanced_description
planner_result = await planner_runtime.run(planner_input)
plan = _parse_plan_json(planner_result.content) if planner_result.success else {}
results["plan"] = plan
# 回退方案:如果无法从 LLM 回复中解析 JSON,尝试读取规划师写入的 execution_plan.json
if (not plan or not plan.get("phases")) and project_path.exists():
plan_file = project_path / "execution_plan.json"
if plan_file.exists():
try:
plan = json.loads(plan_file.read_text(encoding="utf-8"))
results["plan"] = plan
logger.info("团队编排 [%s]: 从文件回退读取计划 %s", self.team_id, plan_file)
except (json.JSONDecodeError, PermissionError, OSError) as e:
logger.warning("团队编排 [%s]: 回退读取计划文件失败: %s", self.team_id, e)
if not plan or not plan.get("phases"):
# 降级方案:规划师失败时使用默认阶段计划,避免整个流程中断
logger.warning(
"团队编排 [%s]: %s 未能生成有效计划,使用默认5阶段计划降级执行",
self.team_id, planner_role
)
results["plan_error"] = f"{planner_role} 规划失败,使用默认计划"
# 构建默认计划:从可用角色中选5个,跳过 architect/test_planner
exec_roles = [r for r in available_roles if r not in ("architect", "test_planner")]
if not exec_roles:
exec_roles = available_roles[:]
default_phases = []
phase_names = {
"functional_tester": "功能测试与缺陷分析",
"ux_reviewer": "UX体验审查与竞品对标",
"edge_explorer": "边界条件与异常路径探索",
"performance_evaluator": "性能内存审计与优化建议",
}
for i, role in enumerate(exec_roles[:5], start=1):
default_phases.append({
"phase": i,
"name": phase_names.get(role, f"{role} 执行阶段"),
"role": role,
"description": f"基于架构文档执行 {role} 阶段",
"expected_output": f"{role} 分析报告",
"depends_on": [],
})
plan = {
"project_name": "自动化测试项目",
"analysis": "(规划师超时,使用默认阶段计划)",
"phases": default_phases,
"acceptance_criteria": ["所有阶段执行完成"],
}
results["plan"] = plan
logger.info(
"团队编排 [%s]: 降级计划生成 %d 个阶段,角色: %s",
self.team_id, len(default_phases),
[p["role"] for p in default_phases]
)
logger.info("团队编排 [%s]: PM 生成 %d 个阶段", self.team_id, len(plan["phases"]))
# ─── Phase 1..N: DAG 并行执行(含顺序回退) ───
all_outputs: List[str] = []
context_doc = f"# 项目: {plan.get('project_name', '未命名')}\n\n## 需求分析\n{plan.get('analysis', '')}\n\n"
phases = plan["phases"]
# 检测是否有 phase 显式指定了 depends_on 以决定是否启用并行
has_dependencies = any(p.get("depends_on") for p in phases)
if has_dependencies:
# 执行前验证并修复依赖关系
fix_count = _validate_and_fix_dependencies(phases)
if fix_count > 0:
logger.warning("团队编排 [%s]: 自动修复了 %d 个无效依赖", self.team_id, fix_count)
# 重新检测是否有依赖(可能全部被移除)
has_dependencies = any(p.get("depends_on") for p in phases)
if has_dependencies:
logger.info("团队编排 [%s]: 使用 DAG 并行模式执行 %d 个阶段", self.team_id, len(phases))
phase_results, context_doc, prev_files, all_files = await self._execute_phases_parallel(
phases, members, context_doc, project_path, prev_files,
pre_scan_result=pre_scan_result,
)
results["phases"] = phase_results
all_outputs = [
f"## {r['name']} ({r['role']})\n{r.get('output', '')}"
for r in phase_results
]
# 更新 success(任一阶段失败则整体失败)
for r in phase_results:
if not r.get("success"):
results["success"] = False
else:
logger.info("团队编排 [%s]: 依赖修复后回退为顺序执行", self.team_id)
# fall through to sequential execution block below
has_dependencies = False
if not has_dependencies:
# ─── 顺序执行(向后兼容:无 depends_on 的旧版模板) ───
completed_outputs: Dict[int, str] = {}
for i, phase in enumerate(phases):
r = await self._run_single_phase(
phase, members, context_doc, project_path, prev_files,
all_files=list(all_files), dependency_outputs=completed_outputs,
pre_scan_result=pre_scan_result,
)
phase_num = r["phase"]
phase_name = r["name"]
role = r["role"]
output_text = r.get("output", "")
if r["success"]:
context_doc += f"\n## 阶段 {phase_num}: {phase_name}\n{output_text[:8000]}\n"
all_outputs.append(f"## {phase_name} ({role})\n{output_text}")
completed_outputs[phase_num] = output_text
for fp in r.get("files", []):
if fp not in all_files:
all_files.append(fp)
for fp in r.get("files", []):
prev_files.add(fp)
else:
all_outputs.append(f"[跳过] 阶段 {phase_num} ({phase_name}): {r.get('error', '')}")
results["success"] = False
results["phases"].append(r)
# ─── QA 审查 + 反馈闭环 ───
qa_agent = self._get_agent_by_role(members, "qa")
fix_rounds: List[Dict[str, Any]] = []
final_qa_review: Dict[str, Any] = {}
if qa_agent and all_outputs:
logger.info("团队编排 [%s]: QA 审查开始", self.team_id)
async def _run_qa_review(round_label: str = "") -> tuple:
"""运行一次 QA 审查,返回 (qa_review_dict, raw_output)"""
qa_config = self._acfg(qa_agent)
qa_runtime = AgentRuntime(qa_config)
deliverables = "\n\n---\n\n".join(all_outputs)
prefix = f"({round_label})" if round_label else ""
qa_input = (
f"请审查以下项目交付物{prefix}:\n\n"
f"项目: {project_description}\n\n"
f"{deliverables[:8000]}\n\n"
f"请按 JSON 格式输出审查结果,包含 pass/score/issues/overall_assessment。"
)
qa_result = await qa_runtime.run(qa_input)
qa_review = _parse_plan_json(qa_result.content) if qa_result.success else {}
return qa_review, qa_result.content, qa_result.success
try:
qa_review, qa_output, qa_success = await _run_qa_review()
results["qa_review"] = {
"output": qa_output,
"review": qa_review,
"success": qa_success,
}
final_qa_review = qa_review
# ─── QA 反馈闭环:关键/高优先级问题自动修复 ───
# critical 问题强制执行至少一轮修复;high 问题最多 2 轮
MAX_FIX_ROUNDS = 3
critical_fixed_in_previous_round = False
for fix_round in range(1, MAX_FIX_ROUNDS + 1):
if final_qa_review.get("pass", False):
break
issues = final_qa_review.get("issues", [])
fixable = [i for i in issues
if i.get("severity") in ("critical", "high")]
has_critical = any(
i.get("severity") == "critical" for i in issues
)
if not fixable:
logger.info("团队编排 [%s]: QA 无 critical/high 问题,跳过修复循环",
self.team_id)
break
# critical 问题:第1轮强制执行,不可跳过
if has_critical and fix_round == 1:
logger.info("团队编排 [%s]: 检测到 critical 问题,强制执行修复",
self.team_id)
dev_role = "developer"
dev_agent = self._get_agent_by_role(members, dev_role)
if not dev_agent:
dev_role = "fullstack_dev"
dev_agent = self._get_agent_by_role(members, dev_role)
if not dev_agent:
logger.warning("团队编排 [%s]: QA 修复循环需要 developer,但未找到",
self.team_id)
break
logger.info("团队编排 [%s]: QA 修复第 %d 轮,%d 个待修复问题",
self.team_id, fix_round, len(fixable))
issues_text = "\n\n".join(
f"### 问题 {j+1} [{i['severity']}]\n"
f"**文件**: {i.get('file', 'unknown')}\n"
f"**描述**: {i.get('description', '')}\n"
f"**修复建议**: {i.get('suggestion', '')}"
for j, i in enumerate(fixable)
)
fix_phase = {
"phase": 100 + fix_round,
"name": f"QA反馈修复 (第{fix_round}轮)",
"role": dev_role,
"description": (
f"根据 QA 审查结果修复以下 {len(fixable)} 个问题:\n\n{issues_text}"
),
"expected_output": "修复后的代码文件",
"depends_on": [],
}
fix_result = await self._run_single_phase(
fix_phase, members, context_doc, project_path, prev_files,
pre_scan_result=pre_scan_result,
)
results["phases"].append(fix_result)
if fix_result.get("success"):
fix_output = fix_result.get("output", "")
context_doc += f"\n## QA修复 第{fix_round}轮\n{fix_output[:2000]}\n"
all_outputs.append(
f"## QA修复 第{fix_round}轮 ({dev_role})\n{fix_output}"
)
for fp in fix_result.get("files", []):
if fp not in all_files:
all_files.append(fp)
prev_files.add(fp)
# 重新 QA
before_score = final_qa_review.get("score", 0)
new_review, new_output, new_success = await _run_qa_review(
f"第{fix_round}轮修复后复查"
)
fix_rounds.append({
"round": fix_round,
"before_score": before_score,
"after_score": new_review.get("score", 0),
"fixed_issues": len(fixable),
"qa_output": new_output,
})
final_qa_review = new_review
results["qa_review"]["output"] = new_output
results["qa_review"]["review"] = new_review
results["qa_review"]["success"] = new_success
# 检查本轮修复后是否还有 critical 问题
remaining_critical = any(
i.get("severity") == "critical"
for i in new_review.get("issues", [])
)
# 分数无提升且无 critical 残留 → 终止;有 critical 残留则继续
if new_review.get("score", 0) <= before_score and not remaining_critical:
logger.info("团队编排 [%s]: QA 修复第 %d 轮分数无提升 (%d→%d),终止循环",
self.team_id, fix_round, before_score,
new_review.get("score", 0))
break
if remaining_critical:
logger.warning("团队编排 [%s]: 第 %d 轮修复后仍有 %d 个 critical 问题,继续修复",
self.team_id, fix_round,
sum(1 for i in new_review.get("issues", [])
if i.get("severity") == "critical"))
except Exception as e:
logger.error("团队编排 [%s]: QA 审查异常: %s", self.team_id, e)
results["qa_review"] = results.get("qa_review", {})
results["qa_review"]["error"] = str(e)
results["qa_review"]["fix_rounds"] = fix_rounds
results["qa_review"]["final_review"] = final_qa_review
results["qa_review"]["final_pass"] = final_qa_review.get("pass", False)
# ─── 汇总最终交付物 ───
results["final_deliverable"] = "\n\n---\n\n".join(all_outputs)
# 最终文件列表以目录扫描为准,合并从 AgentResult 提取的
final_scan = set(_scan_directory_files(project_path))
for fp in all_files:
final_scan.add(fp)
# 兜底:捕获 Agent 写到 project_path 以外兄弟目录 / 脚本生成的本轮产物
for fp in _scan_recent_files(project_path.parent, run_start_ts):
final_scan.add(fp)
results["files"] = sorted(final_scan)
results["completed_at"] = datetime.now().isoformat()
return results
async def execute_stream(self, project_description: str, auto_approve_files: bool = True) -> AsyncGenerator[Dict[str, Any], None]:
"""流式执行团队项目,yield SSE 事件。
事件类型:
- plan_start / plan / plan_done — PM 规划阶段
- phase_start / phase_output / phase_done — 各执行阶段
- qa_start / qa_review / qa_done — QA 审查
- final — 全部完成
- error — 错误
"""
self.auto_approve_files = auto_approve_files
try:
team = self._load_team()
members = self._load_members()
self._model_override = _extract_model_override(team.config) # 四档-③ 部门级配模型
except Exception as e:
yield {"type": "error", "content": str(e)}
return
if not members:
yield {"type": "error", "content": "团队没有成员"}
return
# ─── 创建项目目录 ───
workspace_root = _resolve_workspace_root()
safe_name = _safe_project_name(project_description)
project_path = workspace_root / "team_projects" / self.team_id / safe_name
project_path.mkdir(parents=True, exist_ok=True)
run_start_ts = time.time() # 交付物捕获基准:本轮之后修改的文件才算产物
prev_files = set(_scan_directory_files(project_path))
all_files: List[str] = []
yield {
"type": "plan_start",
"team_name": team.name,
"member_count": len(members),
"project_path": str(project_path),
}
all_outputs: List[str] = []
# ─── 架构分析(Architect 扫描现有项目) ───
enhanced_description = project_description
pre_scan_result = "" # 预扫描源码,后续注入到各执行阶段的 prompt
architect_agent = self._get_agent_by_role(members, "architect")
if architect_agent:
target_paths = _extract_paths_from_description(project_description)
if not target_paths:
target_paths = _get_default_scan_paths()
logger.info("团队编排 [%s]: 未从描述中提取路径,使用默认扫描路径: %s",
self.team_id, [str(p) for p in target_paths])
logger.info("团队编排 [%s]: Architect 开始扫描 %d 个目标路径",
self.team_id, len(target_paths))
yield {"type": "architect_start", "agent": architect_agent.name,
"paths": [str(p) for p in target_paths]}
try:
arch_config = self._acfg(architect_agent)
arch_runtime = AgentRuntime(arch_config)
# ─── 程序化预扫描源码 ───
pre_scan_result = scan_multiple_dirs(target_paths)
if pre_scan_result:
logger.info("团队编排 [%s]: 预扫描完成,已提取源文件内容",
self.team_id)
arch_input = (
f"你是一名系统架构师。请基于以下已预扫描的源代码,"
f"输出完整的架构上下文文档供后续测试团队使用。\n\n"
f"任务背景:{project_description}\n\n"
f"{pre_scan_result}\n\n"
f"## 你的任务\n"
f"上述源码清单中已包含关键源文件的完整内容。"
f"请基于这些真实代码(不是设计文档)输出架构分析文档,包含:\n"
f"1. 技术栈清单(框架/库/版本,从实际配置文件确认)\n"
f"2. 目录结构与模块划分\n"
f"3. 数据流路径(UI→ViewModel→Repository→API/DAO→DB)\n"
f"4. 关键类的职责与依赖关系(引用源码中的实际类名/方法名)\n"
f"5. 从源码中发现的实际代码质量问题\n\n"
f"## 注意\n"
f"- 主要分析基于上述已提供的源码内容\n"
f"- 如需补充阅读特定文件,可以用 file_read 工具\n"
f"- 不要基于设计文档或推测进行分析\n"
f"- 在输出中列出你分析引用了哪些源文件"
)
arch_result = await arch_runtime.run(arch_input)
if arch_result.success and arch_result.content:
arch_analysis = arch_result.content.strip()
enhanced_description = (
f"{project_description}\n\n"
f"## 架构分析(由系统架构师扫描现有项目生成)\n\n"
f"{arch_analysis}"
)
yield {"type": "architect_done",
"analysis": arch_analysis[:2000],
"full_length": len(arch_analysis)}
logger.info("团队编排 [%s]: Architect 分析完成 (%d 字符)",
self.team_id, len(arch_analysis))
except Exception as e:
logger.warning("团队编排 [%s]: Architect 分析异常(继续执行): %s",
self.team_id, e)
yield {"type": "architect_done", "error": str(e)}
# ─── 规划阶段(由 Leader Agent 担任) ───
workflow = (team.config or {}).get("workflow", "software_company")
planner_role = self._resolve_planner_role(members, workflow)
planner_agent = self._get_agent_by_role(members, planner_role)
if not planner_agent:
yield {"type": "error", "content": f"团队缺少 Leader 角色(需要 role='{planner_role}'),可用角色: {[m.role for m in members]}"}
return
try:
available_roles = [m.role for m in members]
# 如果架构师已作为前置步骤执行,从可用角色中移除,避免 PM 再次规划架构师阶段
if enhanced_description != project_description and "architect" in available_roles:
available_roles.remove("architect")
roles_hint = ", ".join(available_roles)
first_role = available_roles[0] if available_roles else "developer"
planner_config = self._acfg(planner_agent)
planner_runtime = AgentRuntime(planner_config)
planner_input = (
f"你是一个团队的项目规划者。请分析以下需求,将工作分解为阶段,并分配给你团队的每个成员。\n\n"
f"需求描述:{enhanced_description}\n"
f"输出文件目录:{project_path}\n\n"
f"你的团队有以下角色 slug(必须逐字使用,禁止合并/翻译/自造):{roles_hint}\n"
f'每个阶段的 role 字段必须精确等于上述其中一个 slug(例如 "{first_role}");'
f'严禁把多个角色写成 "a/b" 这种复合形式。\n\n'
f"请严格按照以下 JSON 格式输出执行计划(不要输出任何其他内容,只输出 JSON):\n"
f'{{"project_name":"...","analysis":"...","phases":[\n'
f' {{"phase":1,"name":"...","role":"{first_role}","description":"...","expected_output":"...","depends_on":[]}},\n'
f' {{"phase":2,"name":"...","role":"...","description":"...","expected_output":"...","depends_on":[]}}\n'
f'],"acceptance_criteria":["..."]}}\n\n'
f"关键要求:\n"
f"1. phases 数组是强制性的——没有它计划无法执行\n"
f"2. 每个 phase 的 role 必须逐字复制上述角色 slug 之一(区分大小写、含连字符),禁止组合/翻译/自造\n"
f"3. depends_on 列出必须先完成的阶段编号,无依赖则用[]\n"
f"4. 4-7 个阶段最佳\n"
f"5. 用中文输出\n"
f"6. 只输出 JSON,不要有任何前言或后记"
)
yield {"type": "plan", "status": "running", "agent": planner_agent.name}
planner_result = await planner_runtime.run(planner_input)
plan = _parse_plan_json(planner_result.content) if planner_result.success else {}
# 回退方案:尝试读取规划师写入的 execution_plan.json
if (not plan or not plan.get("phases")) and project_path.exists():
plan_file = project_path / "execution_plan.json"
if plan_file.exists():
try:
plan = json.loads(plan_file.read_text(encoding="utf-8"))
logger.info("团队编排(stream) [%s]: 从文件回退读取计划 %s", self.team_id, plan_file)
except (json.JSONDecodeError, PermissionError, OSError) as e:
logger.warning("团队编排(stream) [%s]: 回退读取计划文件失败: %s", self.team_id, e)
yield {
"type": "plan_done",
"plan": plan,
"raw_output": planner_result.content[:2000],
"success": planner_result.success,
}
except Exception as e:
yield {"type": "error", "content": f"{planner_role} 规划失败: {e}"}
return
if not plan or not plan.get("phases"):
# 降级方案:规划师失败时使用默认阶段计划
logger.warning(
"团队编排(stream) [%s]: %s 未能生成有效计划,使用默认5阶段计划降级执行",
self.team_id, planner_role
)
yield {"type": "plan_fallback", "content": f"{planner_role} 规划失败,使用默认计划降级执行"}
exec_roles = [r for r in available_roles if r not in ("architect", "test_planner")]
if not exec_roles:
exec_roles = available_roles[:]
default_phases = []
phase_names = {
"functional_tester": "功能测试与缺陷分析",
"ux_reviewer": "UX体验审查与竞品对标",
"edge_explorer": "边界条件与异常路径探索",
"performance_evaluator": "性能内存审计与优化建议",
}
for i, role in enumerate(exec_roles[:5], start=1):
default_phases.append({
"phase": i,
"name": phase_names.get(role, f"{role} 执行阶段"),
"role": role,
"description": f"基于架构文档执行 {role} 阶段",
"expected_output": f"{role} 分析报告",
"depends_on": [],
})
plan = {
"project_name": "自动化测试项目",
"analysis": "(规划师超时,使用默认阶段计划)",
"phases": default_phases,
"acceptance_criteria": ["所有阶段执行完成"],
}
logger.info(
"团队编排(stream) [%s]: 降级计划生成 %d 个阶段",
self.team_id, len(default_phases)
)
phases = plan["phases"]
context_doc = f"# 项目: {plan.get('project_name', '未命名')}\n\n## 需求分析\n{plan.get('analysis', '')}\n\n"
# 检测是否有 phase 显式指定了 depends_on
has_dependencies = any(p.get("depends_on") for p in phases)
if has_dependencies:
# 执行前验证并修复依赖关系
fix_count = _validate_and_fix_dependencies(phases)
if fix_count > 0:
logger.warning("团队编排 [%s]: 自动修复了 %d 个无效依赖", self.team_id, fix_count)
has_dependencies = any(p.get("depends_on") for p in phases)
if not has_dependencies:
logger.info("团队编排 [%s]: 依赖修复后回退为顺序执行", self.team_id)
if has_dependencies:
# ─── DAG 并行执行(流式 + 每 Agent 独立事件队列) ───
completed: Dict[int, str] = {}
pending = {p["phase"]: p for p in phases}
current_prev = prev_files.copy()
current_context = context_doc
while pending:
ready = [
p for p in pending.values()
if all(d in completed for d in p.get("depends_on", []))
]
if not ready:
yield {"type": "error", "content": "阶段循环依赖,无法继续"}
return
# 预解析 agent 信息
phase_agents = []
for p in ready:
agent = self._get_agent_by_role(members, p.get("role", ""))
phase_agents.append((p, agent))
yield {
"type": "parallel_batch_start",
"phases": [
{"phase": p["phase"], "name": p.get("name"), "role": p.get("role", ""),
"agent": a.name if a else None}
for p, a in phase_agents
],
}
for p, agent in phase_agents:
yield {
"type": "phase_start",
"phase": p["phase"],
"name": p.get("name"),
"role": p.get("role", ""),
"agent": agent.name if agent else None,
}
# 并行执行:每个 phase 通过 event_queue 实时推送 Agent 内部事件
event_queue: asyncio.Queue = asyncio.Queue()
tasks = {
asyncio.create_task(
self._run_phase_stream_and_collect(
p, members, current_context, project_path, current_prev, event_queue,
pre_scan_result=pre_scan_result,
)
): p
for p, _ in phase_agents
}
# 消费事件队列直到所有 phase 完成
done_count = 0
while done_count < len(tasks):
evt = await event_queue.get()
yield evt
if evt.get("type") == "phase_done":
done_count += 1
# 收集最终结果
for task, p in tasks.items():
try:
r = task.result()
except Exception as exc:
r = {
"phase": p["phase"], "name": p.get("name", ""),
"role": p.get("role", ""),
"agent_name": None,
"output": f"并行执行异常: {exc}",
"success": False, "error": str(exc),
"files": [],
}
output_text = r.get("output", "")
completed[p["phase"]] = output_text
current_context += f"\n## 阶段 {p['phase']}: {p.get('name', '')}\n{output_text[:8000]}\n"
all_outputs.append(f"## {r['name']} ({r['role']})\n{output_text}")
for fp in r.get("files", []):
if fp not in all_files:
all_files.append(fp)
current_prev.add(fp)
del pending[p["phase"]]
yield {
"type": "parallel_batch_end",
"completed": [p["phase"] for p, _ in phase_agents],
}
prev_files = current_prev
context_doc = current_context
else:
# ─── 顺序执行(流式:每 phase 实时推送 Agent 内部事件) ───
completed_outputs_s: Dict[int, str] = {}
for i, phase in enumerate(phases):
phase_num = phase.get("phase", i + 1)
role = phase.get("role", "developer")
phase_name = phase.get("name", f"阶段 {phase_num}")
agent, matched_role, role_fallback = self._resolve_phase_agent(members, role)
if not agent:
yield {"type": "phase_done", "phase": phase_num, "name": phase_name,
"role": role, "success": False, "error": f"missing_role:{role}"}
all_outputs.append(f"[跳过] 阶段 {phase_num} ({phase_name}): 无 {role} 角色")
continue
yield {
"type": "phase_start",
"phase": phase_num,
"name": phase_name,
"role": role,
"actual_role": matched_role,
"role_fallback": role_fallback,
"agent": agent.name,
}
event_queue = asyncio.Queue()
task = asyncio.create_task(
self._run_phase_stream_and_collect(
phase, members, context_doc, project_path, prev_files, event_queue,
pre_scan_result=pre_scan_result,
)
)
# 消费事件直到 phase_done
while True:
evt = await event_queue.get()
yield evt
if evt.get("type") == "phase_done":
break
try:
r = task.result()
except Exception as exc:
r = {
"phase": phase_num,
"name": phase_name,
"role": role,
"agent_name": agent.name,
"output": f"执行异常: {exc}",
"success": False, "error": str(exc),
"files": [],
}
output_text = r.get("output", "")
if r.get("success"):
context_doc += f"\n## 阶段 {phase_num}: {phase_name}\n{output_text[:8000]}\n"
all_outputs.append(f"## {phase_name} ({role})\n{output_text}")
completed_outputs_s[phase_num] = output_text
for fp in r.get("files", []):
if fp not in all_files:
all_files.append(fp)
prev_files.add(fp)
# ─── QA 审查 + 反馈闭环 ───
qa_agent = self._get_agent_by_role(members, "qa")
fix_rounds: List[Dict[str, Any]] = []
final_qa_review: Dict[str, Any] = {}
if qa_agent and all_outputs:
yield {"type": "qa_start", "agent": qa_agent.name}
async def _run_qa_review_stream(round_label: str = "") -> tuple:
"""运行一次 QA 审查,返回 (qa_review_dict, raw_output, success)"""
qa_config = self._acfg(qa_agent)
qa_runtime = AgentRuntime(qa_config)
deliverables = "\n\n---\n\n".join(all_outputs)
prefix = f"({round_label})" if round_label else ""
qa_input = (
f"请审查以下项目交付物{prefix}:\n\n"
f"项目: {project_description}\n\n"
f"{deliverables[:8000]}\n\n"
f"请按 JSON 格式输出审查结果,包含 pass/score/issues/overall_assessment。"
)
qa_result = await qa_runtime.run(qa_input)
qa_review_data = _parse_plan_json(qa_result.content) if qa_result.success else {}
return qa_review_data, qa_result.content, qa_result.success
try:
qa_review_data, qa_output, qa_success = await _run_qa_review_stream()
final_qa_review = qa_review_data
yield {
"type": "qa_done",
"output": qa_output,
"review": qa_review_data,
"success": qa_success,
}
# ─── QA 反馈闭环:critical 问题强制执行至少一轮修复 ───
MAX_FIX_ROUNDS = 3
for fix_round in range(1, MAX_FIX_ROUNDS + 1):
if final_qa_review.get("pass", False):
break
issues = final_qa_review.get("issues", [])
fixable = [i for i in issues
if i.get("severity") in ("critical", "high")]
has_critical = any(
i.get("severity") == "critical" for i in issues
)
if not fixable:
break
if has_critical and fix_round == 1:
logger.info("团队编排 [%s]: 检测到 critical 问题,强制执行修复",
self.team_id)
dev_role = "developer"
dev_agent = self._get_agent_by_role(members, dev_role)
if not dev_agent:
dev_role = "fullstack_dev"
dev_agent = self._get_agent_by_role(members, dev_role)
if not dev_agent:
logger.warning("团队编排: QA 修复循环需要 developer,但未找到")
break
logger.info("团队编排 [%s]: QA 修复第 %d 轮(流式),%d 个问题",
self.team_id, fix_round, len(fixable))
yield {
"type": "fix_phase_start",
"round": fix_round,
"issues_count": len(fixable),
"agent": dev_agent.name,
}
issues_text = "\n\n".join(
f"### 问题 {j+1} [{i['severity']}]\n"
f"**文件**: {i.get('file', 'unknown')}\n"
f"**描述**: {i.get('description', '')}\n"
f"**修复建议**: {i.get('suggestion', '')}"
for j, i in enumerate(fixable)
)
fix_phase = {
"phase": 100 + fix_round,
"name": f"QA反馈修复 (第{fix_round}轮)",
"role": dev_role,
"description": (
f"根据 QA 审查结果修复以下 {len(fixable)} 个问题:\n\n{issues_text}"
),
"expected_output": "修复后的代码文件",
"depends_on": [],
}
# 使用流式执行修复阶段
fix_event_queue: asyncio.Queue = asyncio.Queue()
fix_task = asyncio.create_task(
self._run_phase_stream_and_collect(
fix_phase, members, context_doc, project_path,
prev_files, fix_event_queue,
pre_scan_result=pre_scan_result,
)
)
while True:
evt = await fix_event_queue.get()
evt["_fix_round"] = fix_round
yield evt
if evt.get("type") == "phase_done":
break
try:
fix_result = fix_task.result()
except Exception as exc:
fix_result = {
"phase": 100 + fix_round,
"name": f"QA反馈修复 (第{fix_round}轮)",
"role": dev_role,
"agent_name": dev_agent.name,
"output": f"修复异常: {exc}",
"success": False, "error": str(exc),
"files": [],
}
if fix_result.get("success"):
fix_output = fix_result.get("output", "")
context_doc += f"\n## QA修复 第{fix_round}轮\n{fix_output[:2000]}\n"
all_outputs.append(
f"## QA修复 第{fix_round}轮 ({dev_role})\n{fix_output}"
)
for fp in fix_result.get("files", []):
if fp not in all_files:
all_files.append(fp)
prev_files.add(fp)
yield {
"type": "fix_phase_done",
"round": fix_round,
"success": fix_result.get("success", False),
}
# 重新 QA
before_score = final_qa_review.get("score", 0)
yield {"type": "qa_start", "agent": qa_agent.name,
"round": fix_round}
new_review, new_output, new_success = await _run_qa_review_stream(
f"第{fix_round}轮修复后复查"
)
fix_rounds.append({
"round": fix_round,
"before_score": before_score,
"after_score": new_review.get("score", 0),
"fixed_issues": len(fixable),
})
final_qa_review = new_review
yield {
"type": "qa_done",
"output": new_output,
"review": new_review,
"success": new_success,
"round": fix_round,
}
# 检查本轮修复后是否还有 critical 问题
remaining_critical = any(
i.get("severity") == "critical"
for i in new_review.get("issues", [])
)
# 分数无提升且无 critical 残留 → 终止;有 critical 残留则继续
if new_review.get("score", 0) <= before_score and not remaining_critical:
logger.info("团队编排: QA 修复第 %d 轮分数无提升 (%d→%d),终止",
fix_round, before_score,
new_review.get("score", 0))
break
if remaining_critical:
logger.warning("团队编排 [%s]: 第 %d 轮修复后仍有 %d 个 critical 问题,继续修复",
self.team_id, fix_round,
sum(1 for i in new_review.get("issues", [])
if i.get("severity") == "critical"))
except Exception as e:
logger.error("团队编排: QA 审查异常: %s", e)
yield {"type": "qa_done", "error": str(e)}
# ─── 最终交付物 ───
final = "\n\n---\n\n".join(all_outputs)
final_scan = set(_scan_directory_files(project_path))
for fp in all_files:
final_scan.add(fp)
# 兜底:捕获 Agent 写到 project_path 以外兄弟目录 / 脚本生成的本轮产物
for fp in _scan_recent_files(project_path.parent, run_start_ts):
final_scan.add(fp)
yield {
"type": "final",
"deliverable": final,
"phase_count": len(phases),
"team_name": team.name,
"project_path": str(project_path),
"files": sorted(final_scan),
"qa_fix_rounds": fix_rounds,
"qa_final_pass": final_qa_review.get("pass", False),
"qa_final_score": final_qa_review.get("score", 0),
}