按 juejin 协作聚合模型重构收敛(取代 best-of-N 选优): - 删蜂后选优(queen.py/test-queen.py) - 新增聚合节点 result_aggregator.py:共享池收集→同文件 LLM/AST 整合→沙箱验证→单次落 main - 质量驱动闭环:不达标打回迭代(AGGREGATE_ACCEPTANCE_THRESHOLD + MAX_REVIEW_CYCLES) - agent 停 git 工作分支,产出走 task.result.files 共享池(AGENT_GIT_PUSH_ENABLED 默认 false) - sandbox_runner 改用 pytest(原生支持 pytest 风格 class),修 stdlib runner 收集失败 - 文档同步重写为协作聚合模型 本地验证:产物仓单分支 main + 三函数完整 + pytest 12/12 pass_rate=100 一次达标。 影响:Swarm 收敛/聚合层;Manager/客户端契约不变(artifact字段/sequence/状态机;契约测试全过)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
390 lines
18 KiB
Python
390 lines
18 KiB
Python
"""ResultAggregator — 蜂群的终态聚合节点(对应掘金文章的"报告生成 Agent",mode=wait_all)。
|
|
|
|
设计依据 https://juejin.cn/post/7603575399255949352 的协作聚合模型,取代旧的"蜂后 best-of-N 选优":
|
|
- 中间产物存**共享结果池**(task.result.files,经 collect_generated_files 读取),**不进 git 交付仓**;
|
|
- run 终态(所有子任务完成)→ 从共享池**收集所有 agent 的互补产出**;
|
|
- 同一文件被多个 agent 各写一部分时,**整合成一份完整产物**:优先 LLM(协作合并),无 LLM 时用
|
|
**AST 函数级合并**(提取各版本 def/class/import 拼合,而非"取最长版本"糊弄);
|
|
- 跑测试**验证**(沙箱,fail-closed);
|
|
- 不达标且未到轮次上限 → **打回迭代**(decision=bounce,调用方 reopen 重做);
|
|
- 达标/达上限 → **单次**把整合产物 push 到 main —— 产物仓永远只有 main 一份,无 agent 工作分支。
|
|
|
|
不 import orchestrator.main(避免循环);finalize_run 返回 plain dict,调用方折进 deliverable 并据
|
|
decision 决定是否打回。质量驱动闭环 = 聚合合并 → 验证 → 不过打回 → 达标落库。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
import re
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
try:
|
|
from openai import AsyncOpenAI
|
|
except Exception: # pragma: no cover
|
|
AsyncOpenAI = None
|
|
|
|
|
|
def _aggregator_timeout() -> float:
|
|
try:
|
|
return float(os.getenv("AGGREGATOR_TIMEOUT_SECONDS", "90") or 90)
|
|
except ValueError:
|
|
return 90.0
|
|
|
|
|
|
def _llm():
|
|
"""(client, model) for LLM file integration, or (None, model) when no key — degrades to AST merge."""
|
|
api_key = os.getenv("OPENAI_API_KEY") or os.getenv("MODEL_API_KEY")
|
|
api_base = (os.getenv("OPENAI_API_BASE") or os.getenv("MODEL_API_BASE")
|
|
or "https://api.openai.com/v1")
|
|
model = (os.getenv("OPENAI_MODEL") or os.getenv("MODEL_NAME") or os.getenv("MODEL_ID")
|
|
or "gpt-4o-mini")
|
|
model = os.getenv("MASTER_REVIEW_MODEL", model)
|
|
client = AsyncOpenAI(api_key=api_key, base_url=api_base) if (api_key and AsyncOpenAI) else None
|
|
return client, model
|
|
|
|
|
|
_FENCE = re.compile(r"^```[a-zA-Z0-9_+-]*\n(.*?)\n```$", re.S)
|
|
|
|
|
|
def _strip_fence(text: str) -> str:
|
|
m = _FENCE.match(text.strip())
|
|
return m.group(1) if m else text.strip()
|
|
|
|
|
|
def _ast_merge_python(contents: List[str]) -> Optional[str]:
|
|
"""确定性整合多个 Python 版本:提取各版本的 import 与顶层 def/class,按名字去重合并(同名后者覆盖)。
|
|
比"取最长版本"强 —— 真把各 agent 写的不同函数拼成一份完整文件。解析全失败 → None(回退最长)。"""
|
|
imports: Dict[str, str] = {}
|
|
defs: Dict[str, str] = {}
|
|
parsed_any = False
|
|
for content in contents:
|
|
try:
|
|
tree = ast.parse(content)
|
|
except SyntaxError:
|
|
continue
|
|
parsed_any = True
|
|
for node in tree.body:
|
|
seg = ast.get_source_segment(content, node)
|
|
if not seg:
|
|
continue
|
|
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
imports[seg.strip()] = seg
|
|
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
defs[node.name] = seg
|
|
if not parsed_any or not defs:
|
|
return None
|
|
parts = list(imports.values())
|
|
if parts:
|
|
parts.append("")
|
|
parts.extend(defs.values())
|
|
return "\n".join(parts).strip() + "\n"
|
|
|
|
|
|
def _merge_fallback(path: str, contents: List[str]) -> str:
|
|
"""无 LLM 兜底:Python 走 AST 函数级合并,其他文件取信息量最大版本。"""
|
|
if path.endswith(".py"):
|
|
merged = _ast_merge_python(contents)
|
|
if merged:
|
|
return merged
|
|
return max(contents, key=len)
|
|
|
|
|
|
# ---------- 收集共享池 ----------
|
|
|
|
def _collect_per_path(tasks) -> Dict[str, List[Any]]:
|
|
"""从所有子任务的共享池产出,按文件 path 分组保留**每个版本**(不去重 —— 去重会丢互补内容)。
|
|
返回 {path: [SandboxFile, ...]}。复用 quality.collect_generated_files 逐 task 提取(它已兼容
|
|
task.result.files 与旧 subtasks 两种结构)。"""
|
|
from .quality import collect_generated_files
|
|
by_path: Dict[str, List[Any]] = {}
|
|
for t in tasks:
|
|
cf = collect_generated_files([t])
|
|
for kind in ("impl", "agent_tests"):
|
|
for f in cf.get(kind) or []:
|
|
by_path.setdefault(f.path, []).append(f)
|
|
return by_path
|
|
|
|
|
|
# ---------- 同文件互补整合(协作合并) ----------
|
|
|
|
async def _merge_one_path(path: str, versions: List[Any], objective: str) -> Any:
|
|
"""同一文件多个 agent 版本 → 整合成一份完整产物。
|
|
单版本直接用;多版本优先 LLM 整合(文章的聚合节点),失败/无 LLM 时用 AST 函数级合并兜底。"""
|
|
from .sandbox import SandboxFile
|
|
contents = [v.content for v in versions]
|
|
if len(contents) == 1:
|
|
return versions[0]
|
|
client, model = _llm()
|
|
if not client:
|
|
logger.warning("aggregator: no LLM for %s; AST/longest merge of %d versions", path, len(contents))
|
|
return SandboxFile(path=path, content=_merge_fallback(path, contents))
|
|
try:
|
|
joined = "\n\n".join(f"===VERSION {i}===\n{c}" for i, c in enumerate(contents))
|
|
resp = await client.chat.completions.create(
|
|
model=model,
|
|
messages=[
|
|
{"role": "system", "content": (
|
|
"你是蜂群的结果聚合节点。多个 agent 各自实现了同一个文件的不同部分。"
|
|
"请把它们整合成一个完整、正确、无重复、可直接运行的文件:合并所有互补的函数/类/导入,"
|
|
"去掉重复定义,保持一致风格。只输出该文件的最终完整内容,不要任何解释、不要 markdown 围栏。")},
|
|
{"role": "user", "content": (
|
|
f"目标:{objective}\n文件路径:{path}\n\n"
|
|
f"以下是 {len(contents)} 个 agent 各自的版本:\n\n{joined}")},
|
|
],
|
|
timeout=_aggregator_timeout(),
|
|
)
|
|
out = _strip_fence((resp.choices[0].message.content or "").strip())
|
|
return SandboxFile(path=path, content=out or _merge_fallback(path, contents))
|
|
except Exception as exc:
|
|
logger.warning("aggregator: LLM merge failed for %s (%s); AST/longest fallback", path, exc)
|
|
return SandboxFile(path=path, content=_merge_fallback(path, contents))
|
|
|
|
|
|
async def merge_artifacts(tasks, objective: str) -> List[Any]:
|
|
"""收集共享池 → 对每个文件整合互补产出 → 返回完整产物文件集(SandboxFile 列表)。"""
|
|
by_path = _collect_per_path(tasks)
|
|
merged: List[Any] = []
|
|
for path, versions in by_path.items():
|
|
merged.append(await _merge_one_path(path, versions, objective))
|
|
return merged
|
|
|
|
|
|
# ---------- 验证(沙箱,fail-closed) ----------
|
|
|
|
async def _validate(merged: List[Any]) -> Dict[str, Any]:
|
|
"""跑测试验证整合后的产物。fail-closed:隔离未确认/无测试 → 不评分(诚实 None,非 0)。"""
|
|
from .sandbox import run_tests, isolation_confirmed
|
|
from .quality import _is_test_file
|
|
if not isolation_confirmed():
|
|
return {"validated": False, "reason": "sandbox_not_isolated", "pass_rate": None}
|
|
impl = [f for f in merged if not _is_test_file(f.path)]
|
|
tests = [f for f in merged if _is_test_file(f.path)]
|
|
if not tests:
|
|
return {"validated": False, "reason": "no_tests", "pass_rate": None}
|
|
try:
|
|
res = await asyncio.to_thread(run_tests, impl, tests)
|
|
return {"validated": True, "pass_rate": res.pass_rate,
|
|
"passed": res.passed, "total": res.total}
|
|
except Exception as exc:
|
|
return {"validated": False, "reason": f"error:{exc!r}", "pass_rate": None}
|
|
|
|
|
|
# ---------- 落 main(单次,复用 git helper) ----------
|
|
|
|
def _auth_url(repo_url: str, user: Optional[str], pw: Optional[str]) -> str:
|
|
"""凭据嵌入 http(s) clone URL,URL-encode 密码。结果**绝不记日志**。"""
|
|
import urllib.parse
|
|
if not user or not pw or "://" not in repo_url:
|
|
return repo_url
|
|
scheme, rest = repo_url.split("://", 1)
|
|
return f"{scheme}://{urllib.parse.quote(user, safe='')}:{urllib.parse.quote(pw, safe='')}@{rest}"
|
|
|
|
|
|
def _git_push(env: Dict[str, str], base_branch: str, files: List[Any], swarm_id: str) -> Dict[str, Any]:
|
|
"""同步 git:clone base_branch → 写入整合产物 → commit → push。阻塞,经 to_thread 调用。
|
|
凭据只活在 clone URL,绝不记日志。"""
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
repo_url = env["GIT_REPO_URL"]
|
|
auth_url = _auth_url(repo_url, env.get("GIT_USERNAME"), env.get("GIT_PASSWORD"))
|
|
workdir = tempfile.mkdtemp(prefix="aggregate-promote-")
|
|
repo_dir = os.path.join(workdir, "repo")
|
|
|
|
def git(*args, cwd=None):
|
|
return subprocess.run(["git", *args], cwd=cwd or workdir,
|
|
capture_output=True, text=True, timeout=180)
|
|
|
|
try:
|
|
if git("clone", "--depth", "1", "--branch", base_branch, auth_url, "repo").returncode != 0:
|
|
return {"promoted": False, "reason": "clone_failed"}
|
|
for f in files:
|
|
dest = os.path.join(repo_dir, f.path)
|
|
os.makedirs(os.path.dirname(dest) or repo_dir, exist_ok=True)
|
|
with open(dest, "w", encoding="utf-8") as fh:
|
|
fh.write(f.content)
|
|
git("config", "user.email", "aggregator@heicode.swarm", cwd=repo_dir)
|
|
git("config", "user.name", "HeiCode Aggregator", cwd=repo_dir)
|
|
git("add", "-A", cwd=repo_dir)
|
|
c = git("commit", "-m", f"aggregate: integrated swarm deliverable ({swarm_id})", cwd=repo_dir)
|
|
if c.returncode != 0:
|
|
return {"promoted": False, "reason": "no_changes"}
|
|
if git("push", "origin", base_branch, cwd=repo_dir).returncode != 0:
|
|
return {"promoted": False, "reason": "push_failed"}
|
|
sha = git("rev-parse", "HEAD", cwd=repo_dir).stdout.strip()
|
|
return {"promoted": True, "commit_sha": sha, "branch": base_branch}
|
|
except Exception as exc:
|
|
return {"promoted": False, "reason": f"error:{exc!r}"}
|
|
finally:
|
|
shutil.rmtree(workdir, ignore_errors=True)
|
|
|
|
|
|
def _emit_patch_mode(run) -> bool:
|
|
"""Benchmark 模式(SWE-bench 等):产出 unified diff,不 push 到 main。
|
|
per-run 门控(run.metadata['emit_patch'])或 per-deployment 门控(env SWARM_EMIT_PATCH)。
|
|
默认关 —— 生产路径仍走 _promote,行为不变。"""
|
|
if (run.metadata or {}).get("emit_patch"):
|
|
return True
|
|
return os.getenv("SWARM_EMIT_PATCH", "").strip().lower() in {"1", "true", "yes"}
|
|
|
|
|
|
def _git_diff(env: Dict[str, str], base_ref: str, files: List[Any], swarm_id: str) -> Dict[str, Any]:
|
|
"""_git_push 的 benchmark 变体:clone → checkout base_ref → 写入整合产物 → `git add -A` →
|
|
`git diff --cached`(相对 base 的 unified diff)。**只产 diff,不 commit、不 push**。
|
|
凭据只活在 clone URL,绝不记日志;diff 本身只含代码,无凭据。阻塞,经 to_thread 调用。"""
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
repo_url = env["GIT_REPO_URL"]
|
|
auth_url = _auth_url(repo_url, env.get("GIT_USERNAME"), env.get("GIT_PASSWORD"))
|
|
workdir = tempfile.mkdtemp(prefix="aggregate-patch-")
|
|
repo_dir = os.path.join(workdir, "repo")
|
|
|
|
def git(*args, cwd=None):
|
|
return subprocess.run(["git", *args], cwd=cwd or workdir,
|
|
capture_output=True, text=True, timeout=300)
|
|
|
|
try:
|
|
# 全量 clone(非 --depth 1):base_ref 可能是任意 base_commit,浅 clone 不含其历史。
|
|
if git("clone", auth_url, "repo").returncode != 0:
|
|
return {"emitted": False, "reason": "clone_failed"}
|
|
if base_ref:
|
|
if git("checkout", base_ref, cwd=repo_dir).returncode != 0:
|
|
return {"emitted": False, "reason": "checkout_failed"}
|
|
for f in files:
|
|
dest = os.path.join(repo_dir, f.path)
|
|
os.makedirs(os.path.dirname(dest) or repo_dir, exist_ok=True)
|
|
with open(dest, "w", encoding="utf-8") as fh:
|
|
fh.write(f.content)
|
|
git("add", "-A", cwd=repo_dir)
|
|
d = git("diff", "--cached", cwd=repo_dir)
|
|
if d.returncode != 0:
|
|
return {"emitted": False, "reason": "diff_failed"}
|
|
if not (d.stdout or "").strip():
|
|
return {"emitted": False, "reason": "empty_patch"}
|
|
return {"emitted": True, "patch": d.stdout, "base_ref": base_ref}
|
|
except Exception as exc:
|
|
return {"emitted": False, "reason": f"error:{exc!r}"}
|
|
finally:
|
|
shutil.rmtree(workdir, ignore_errors=True)
|
|
|
|
|
|
async def _emit_patch(run, files: List[Any]) -> Dict[str, Any]:
|
|
"""Benchmark 入口:把整合产物相对 base_commit 产成 unified diff(SWE-bench patch 格式)。
|
|
凭据按需从 grant.secret_ref 解析,绝不存储/记录。base_ref 优先 grant.base_commit(SWE-bench
|
|
精确 commit),回退 base_branch。"""
|
|
try:
|
|
grant = (run.metadata or {}).get("git_grant") or {}
|
|
repo_url = grant.get("repo_url")
|
|
if not repo_url:
|
|
return {"emitted": False, "reason": "no_git_grant"}
|
|
if not files:
|
|
return {"emitted": False, "reason": "no_files"}
|
|
from .agent_launcher import resolve_git_grant
|
|
env = resolve_git_grant({"resource_grants": [{
|
|
"resource_type": "git", "secret_ref": grant.get("secret_ref"),
|
|
"metadata": {"repo_url": repo_url, "base_branch": grant.get("base_branch", "main")},
|
|
}]})
|
|
if not env or "GIT_REPO_URL" not in env:
|
|
return {"emitted": False, "reason": "grant_unresolved"}
|
|
base_ref = grant.get("base_commit") or grant.get("base_branch", "main")
|
|
return await asyncio.to_thread(_git_diff, env, base_ref, files, getattr(run, "swarm_id", "?"))
|
|
except Exception as exc:
|
|
logger.warning("aggregator: emit_patch failed for %s: %s", getattr(run, "swarm_id", "?"), exc)
|
|
return {"emitted": False, "reason": f"error:{exc!r}"}
|
|
|
|
|
|
async def _promote(run, files: List[Any]) -> Dict[str, Any]:
|
|
"""把整合产物 push 到产物仓 base 分支(main)。凭据按需从 grant.secret_ref 解析,绝不存储/记录。"""
|
|
try:
|
|
grant = (run.metadata or {}).get("git_grant") or {}
|
|
repo_url = grant.get("repo_url")
|
|
if not repo_url:
|
|
return {"promoted": False, "reason": "no_git_grant"}
|
|
if not files:
|
|
return {"promoted": False, "reason": "no_files"}
|
|
from .agent_launcher import resolve_git_grant
|
|
env = resolve_git_grant({"resource_grants": [{
|
|
"resource_type": "git", "secret_ref": grant.get("secret_ref"),
|
|
"metadata": {"repo_url": repo_url, "base_branch": grant.get("base_branch", "main")},
|
|
}]})
|
|
if not env or "GIT_REPO_URL" not in env:
|
|
return {"promoted": False, "reason": "grant_unresolved"}
|
|
return await asyncio.to_thread(_git_push, env, grant.get("base_branch", "main"),
|
|
files, getattr(run, "swarm_id", "?"))
|
|
except Exception as exc:
|
|
logger.warning("aggregator: promote failed for %s: %s", getattr(run, "swarm_id", "?"), exc)
|
|
return {"promoted": False, "reason": f"error:{exc!r}"}
|
|
|
|
|
|
# ---------- 质量决策 ----------
|
|
|
|
def _decide(validation: Dict[str, Any], threshold: Optional[float],
|
|
cycles: int, max_cycles: int) -> str:
|
|
"""聚合质量门决策:accept(达标/无门/无法评分/达上限)或 bounce(评分不达标且未到上限)。
|
|
诚实(规则 #9):无法评分(pass_rate=None)不打回 —— 不在"算不出的分"上重做。"""
|
|
if threshold is None:
|
|
return "accept"
|
|
pass_rate = validation.get("pass_rate")
|
|
if pass_rate is None:
|
|
return "accept"
|
|
if pass_rate >= threshold:
|
|
return "accept"
|
|
if cycles >= max_cycles:
|
|
return "accept" # 达上限:接受当前最好的,交收敛标 MAX_ROUNDS_REACHED
|
|
return "bounce"
|
|
|
|
|
|
# ---------- 终态入口 ----------
|
|
|
|
async def finalize_run(run, tasks, objective: str = "", threshold: Optional[float] = None,
|
|
cycles: int = 0, max_cycles: int = 2) -> Dict[str, Any]:
|
|
"""聚合节点入口(run 终态调用,此时子任务已 wait_all 完成)。
|
|
收集共享池 → 整合互补产出 → 验证 → 决策。
|
|
- decision='bounce':不 promote,调用方 reopen 重做(质量驱动迭代);
|
|
- decision='accept':单次落 main。
|
|
Best-effort,绝不让终态路径崩。"""
|
|
try:
|
|
merged = await merge_artifacts(tasks, objective or "")
|
|
if not merged:
|
|
return {"finalized": False, "decision": "accept", "reason": "no_artifacts",
|
|
"merged_file_count": 0}
|
|
validation = await _validate(merged)
|
|
decision = _decide(validation, threshold, cycles, max_cycles)
|
|
if decision == "bounce":
|
|
return {"finalized": False, "decision": "bounce", "validation": validation,
|
|
"merged_file_count": len(merged), "files": [f.path for f in merged]}
|
|
# Benchmark 模式:产 unified diff 不 push(SWE-bench 等需要 patch,产物仓不能被改);
|
|
# 生产模式:单次落 main。两条互斥,由 _emit_patch_mode 门控。
|
|
if _emit_patch_mode(run):
|
|
patch_res = await _emit_patch(run, merged)
|
|
return {
|
|
"finalized": True,
|
|
"decision": "accept",
|
|
"mode": "emit_patch",
|
|
"merged_file_count": len(merged),
|
|
"files": [f.path for f in merged],
|
|
"validation": validation,
|
|
"patch": patch_res.get("patch"),
|
|
"patch_meta": {k: v for k, v in patch_res.items() if k != "patch"},
|
|
}
|
|
promotion = await _promote(run, merged)
|
|
return {
|
|
"finalized": True,
|
|
"decision": "accept",
|
|
"merged_file_count": len(merged),
|
|
"files": [f.path for f in merged],
|
|
"validation": validation,
|
|
"promotion": promotion,
|
|
}
|
|
except Exception as exc:
|
|
logger.warning("aggregator: finalize_run failed for %s: %s",
|
|
getattr(run, "swarm_id", "?"), exc)
|
|
return {"finalized": False, "decision": "accept", "reason": f"error:{exc!r}",
|
|
"merged_file_count": 0}
|