Files
Agentswarm/orchestrator/result_aggregator.py
T
gongzhiyongandClaude Opus 4.8 1288fd19d7
CI / tests (push) Failing after 15m3s
CI / guardrails (push) Failing after 15m3s
feat(swarm): 协作聚合收敛取代蜂后选优 + sandbox 用 pytest 验证
按 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>
2026-06-20 22:03:00 +08:00

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}