deerflow-code/offline-backend-20260512/backend/app/gateway/routers/parallel_agents.py
2026-09-07 18:24:55 +08:00

448 lines
21 KiB
Python
Raw Permalink 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.

"""Server-side parallel fan-out for the standalone parallel multi-agent panel.
The Vue embeddable panel sends a single request — ``{task_name, brief, agent_ids}``
— and this endpoint **fans out** to every agent in parallel, then **merges** their
LangGraph streams into ONE SSE tagged per ``agent_id``, so the client only consumes
one stream and never parses LangGraph internals.
Each fan-out agent runs as a pure **executing seat**: ``excluded_tools`` =
``_EXCLUDED_TOOLS`` (``write_file`` / ``str_replace`` / ``agent_orchestration`` /
``bash``) so a seat can neither write files (parallel writes would clash) nor
dispatch / run scripts (a weak model fed "总控 / 业务链条" framing would otherwise
mimic the controller and "派活"). Read-only tools + ``ask_clarification`` stay on,
so the step bar and clarification cards still work.
Unified event protocol (one JSON object per ``data:`` frame)::
{"type": "agent_start", "agent_id": "..."}
{"type": "delta", "agent_id": "...", "text": "..."} # AI text increment
{"type": "step", "agent_id": "...", "key": "...", "label": "...", "args": {...}}
{"type": "agent_done", "agent_id": "...", "content": "..."}
{"type": "agent_error", "agent_id": "...", "message": "..."}
{"type": "all_done"}
The frame-normalisation (LangGraph ``messages-tuple`` → delta / tool step) mirrors
the frontend parser in ``frontend-web/src/roundtable-planning/api/multi-agent.ts``
(``extractAiTextDelta`` + ``tool_call_chunks`` accumulation).
"""
from __future__ import annotations
import asyncio
import json
import logging
from typing import Any, AsyncIterator
import httpx
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from app.gateway.routers.multi_agent import (
_ROUNDTABLE_THREAD_METADATA,
_auth_headers,
_create_thread,
_flatten_content,
_loopback_base,
_make_loopback_client,
_parse_last_messages,
_run_payload,
_stream_upstream,
_validate_agent_id,
)
from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/parallel-agents", tags=["parallel-agents"])
# Soft cap on parallel degree — protects remote model concurrency + sandbox.
MAX_PARALLEL_AGENTS = 8
# 禁两类工具:①**写文件**(write_file / str_replace)—— 并行席位互相写文件会打架;
# ②**派活 / 脚本逃逸**(agent_orchestration / bash)—— 本面板里每个智能体只做自己那
# 份分析交付,绝不该扮演「总控」去调度别人。弱模型(deepseek 等)拿到带「总控 / 业务
# 链条」语气的任务后,会概率性地用 agent_orchestration 真去派活,或绕道 bash 跑脚本
# 调内部接口模仿总控调度(bug:单步单智能体链条里子智能体输出「总控调度指令…」并用
# glob / bash 翻找派活脚本)。物理禁掉这两类即可根治。其余只读工具(ls / read_file /
# 检索)与 ask_clarification(协作卡片)保留:既不互相打架,又能正常出步骤条 / 要澄清。
_EXCLUDED_TOOLS: list[str] = [
"write_file", # 并行不互相写文件打架
"str_replace",
"agent_orchestration", # 子智能体不派活 / 不调度(防模仿总控)
"bash", # 禁跑脚本(防绕道脚本调内部接口模仿派活)
]
# ── 专用「绕过常规登录」取 token ──────────────────────────────────────────────
# 内网部署对标准 /api/v1/auth/login/username 有额外操作限制(口令门 / 网关裁剪等),
# 嵌入方拿不到 token。这里给并行多智能体面板单开一个**公共**端点:传用户名直接签发 token
# (账号不存在则自动注册,与 username 登录同一套逻辑)。已在 auth_middleware / csrf_middleware
# 的白名单里放行(公共 + CSRF 豁免)。⚠️ 这是有意的鉴权旁路,仅供内网部署的本功能使用。
class _TokenRequest(BaseModel):
username: str = Field(min_length=1, max_length=128)
class _TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
user_id: str
@router.post("/auth/token", response_model=_TokenResponse)
async def issue_token(body: _TokenRequest) -> _TokenResponse:
"""传用户名 → 直接签发 access_token(绕过常规登录,公共端点)。"""
# 懒加载,避免与 auth 路由的模块级循环依赖。
from app.gateway.auth import create_access_token
from app.gateway.routers.auth import _get_or_create_user_by_username
user, _created = await _get_or_create_user_by_username(body.username.strip())
token = create_access_token(str(user.id), token_version=user.token_version)
return _TokenResponse(access_token=token, token_type="bearer", user_id=str(user.id))
# ── frame normalisation (mirrors multi-agent.ts) ─────────────────────────────
def _extract_ai_text(chunk: dict[str, Any]) -> str:
"""AI text delta from a ``messages-tuple`` chunk obj; "" when not AI text."""
type_ = str(chunk.get("type") or "").lower()
if type_ not in ("ai", "aimessagechunk", "aimessage"):
return ""
content = chunk.get("content")
if isinstance(content, str):
return content
if isinstance(content, list):
out: list[str] = []
for item in content:
if isinstance(item, str):
out.append(item)
elif isinstance(item, dict):
t = item.get("text")
if isinstance(t, str):
out.append(t)
return "".join(out)
return ""
def _ingest_tool_chunks(chunks: Any, acc: dict[int, dict[str, Any]]) -> None:
"""累积 ``tool_call_chunks`` 的增量(name + args 字符串),按 index 归并
(mirror multi-agent.ts ``ingestToolCallChunks``)。"""
if not isinstance(chunks, list):
return
for c in chunks:
if not isinstance(c, dict):
continue
idx = c.get("index") if isinstance(c.get("index"), int) else 0
entry = acc.setdefault(idx, {"id": None, "name": "", "argsStr": ""})
cid = c.get("id")
if isinstance(cid, str) and cid:
if entry["id"] and entry["id"] != cid: # 同 index 换了新调用 → 重置
entry["name"] = ""
entry["argsStr"] = ""
entry["id"] = cid
name = c.get("name")
if isinstance(name, str) and name:
entry["name"] = name
a = c.get("args")
if isinstance(a, str):
entry["argsStr"] += a
def _synthetic_tool_calls(acc: dict[int, dict[str, Any]]) -> list[dict[str, Any]]:
"""把累积的 argsStr 尽力 parse 成完整 args,产出 ``{id,name,args}``(未成完整 JSON 则跳过)。"""
out: list[dict[str, Any]] = []
for entry in acc.values():
if not entry["name"]:
continue
s = str(entry["argsStr"]).strip()
if not s:
continue
try:
args = json.loads(s)
except Exception:
continue
if isinstance(args, dict):
out.append({"id": entry["id"], "name": entry["name"], "args": args})
return out
def _clarification_from_messages(messages: list[dict[str, Any]]) -> dict[str, Any] | None:
"""从最终 messages 里找最后一个 ``ask_clarification`` 工具调用,抽出结构化澄清载荷
(字段对齐 multi_agent leader 的 clarification 帧)。"""
for msg in reversed(messages):
if not isinstance(msg, dict):
continue
if (msg.get("type") or "").lower() not in ("ai", "aimessage", "aimessagechunk"):
continue
for tc in msg.get("tool_calls") or []:
if isinstance(tc, dict) and (tc.get("name") or "").lower() == "ask_clarification":
cargs = tc.get("args") if isinstance(tc.get("args"), dict) else {}
return {
"question": str(cargs.get("question") or ""),
"clarification_type": str(cargs.get("clarification_type") or ""),
"clarification_context": str(cargs.get("context") or ""),
"options": cargs.get("options") or [],
"allow_custom": bool(cargs.get("allow_custom", True)),
"allow_multiple": resolve_allow_multiple(cargs),
}
return None
async def _run_one_agent(
client: httpx.AsyncClient,
base: str,
headers: dict[str, str],
agent_id: str,
thread_id: str,
new_message: str,
model_name: str,
) -> AsyncIterator[str | dict[str, Any]]:
"""跑一个智能体一轮(禁写文件 + 禁派活 / 脚本,其余只读工具放开)。
与 ``multi_agent._special_run`` 同形(yield 原始上游 SSE 行 + 末尾一条终态 dict),
但 ``excluded_tools`` = ``_EXCLUDED_TOOLS``(write_file / str_replace /
agent_orchestration / bash),且不做 post-broadcast(独立面板各智能体互不广播)。
"""
body = _run_payload(
agent_name=agent_id,
model_name=model_name,
thread_id=thread_id,
new_message=new_message,
excluded_tools=list(_EXCLUDED_TOOLS),
skill_stop_names=[],
thinking_enabled=True,
reasoning_effort="medium",
multitask_strategy="rollback",
)
captured: list[str] = []
async for line, done in _stream_upstream(client, base, headers, thread_id, body):
if line is not None:
yield line
elif done is not None:
captured = done
messages = _parse_last_messages(captured)
if messages is None:
# 流被抢占/截断,没有终态 values 帧 —— 静默收尾(调用方据无终态帧兜底)。
return
last_ai: dict[str, Any] = {}
for msg in reversed(messages):
if isinstance(msg, dict) and (msg.get("type") or "").lower() in ("ai", "aimessage", "aimessagechunk"):
last_ai = msg
break
content = _flatten_content(last_ai.get("content", ""))
# 收集**所有** AI 消息的 tool_calls(全量 args,按顺序)—— 用于在收口时补发所有步骤
# (含 ls / skill_list 这类无参/中间步骤),保证步骤条不丢步、且 args 完整。
final_tool_calls: list[dict[str, Any]] = []
for msg in messages:
if isinstance(msg, dict) and (msg.get("type") or "").lower() in ("ai", "aimessage", "aimessagechunk"):
for tc in msg.get("tool_calls") or []:
if isinstance(tc, dict):
final_tool_calls.append(tc)
clarification = _clarification_from_messages(messages)
if clarification is not None:
# 智能体在向用户要澄清 —— 交给前端渲染「协作卡片」(对齐圆桌第一步)。
yield {
"type": "clarification",
"content": content,
"clarification": clarification,
"tool_calls": final_tool_calls,
}
else:
yield {
"status": f"子智能体 {agent_id} 完成",
"content": content,
"agent_name": agent_id,
"tool_calls": final_tool_calls,
}
def _build_task_message(task_name: str, brief: str) -> str:
parts: list[str] = []
if task_name.strip():
parts.append(f"任务名称:{task_name.strip()}")
if brief.strip():
parts.append(f"任务简介:\n{brief.strip()}")
parts.append(
"请你从你的专业职责出发,针对以上任务给出你的分析与交付。"
"本轮为**并行**研讨:请把你的产出**直接写在回复正文里**(本轮禁止写文件)。"
)
# 角色边界护栏:本面板里每个智能体都是**执行席位**,不是总控 / 指挥。即便任务里带有
# 「总控 / 业务链条 / 调度 / 派活」字样,也只完成属于自己的那份交付,严禁模仿总控派活。
parts.append(
"【角色边界】你是本轮的**执行席位**,不是总控 / 指挥。即使任务里出现「总控 / 业务链条 / 调度 / "
"派活」等字样,你也只需完成**属于你自己**的那份分析与交付;严禁模仿总控口吻输出「总控调度指令 / "
"调度对象 / 分配给『…』的任务 / agent_name」之类调度内容,严禁罗列或转派其他席位的任务,严禁调用"
"任何派活 / 调度类工具,也不要用脚本(bash / python)去调用内部接口或翻找派活脚本。"
)
# 澄清护栏:要向用户确认时,必须用 ask_clarification 工具发起(前端据此渲染可点击的协作
# 卡片),不能只把问题写在正文里 —— 否则用户看不到卡片、无法回应。能合理假设就先假设并标注。
parts.append(
"【需要向用户确认时】如果确实缺少完成分析所必需的关键信息,请**调用 `ask_clarification` 工具**"
"发起确认(这样用户会看到一张可点击的协作卡片来回答);**不要只把问题写在正文里**,否则用户无法回应。"
"能基于已有信息合理假设的,就先假设并在正文中标注你的假设;只有当不同假设会显著改变结论时,才发起确认。"
)
return "\n\n".join(parts)
# ── endpoint ─────────────────────────────────────────────────────────────────
@router.post("/run/stream")
async def run_parallel_agents_stream(request: Request, payload: dict = Body(...)) -> StreamingResponse:
"""并行扇出 N 个智能体,合并成一条带 ``agent_id`` 标记的 SSE。文件写入固定禁用。"""
task_name: str = str(payload.get("task_name") or "").strip()
brief: str = str(payload.get("brief") or "").strip()
raw_ids = payload.get("agent_ids") or []
model_name: str = str(payload.get("model") or "")
if not isinstance(raw_ids, list) or not raw_ids:
raise HTTPException(status_code=400, detail="agent_ids 不能为空")
agent_ids: list[str] = []
for aid in raw_ids:
sid = str(aid or "").strip().lower()
_validate_agent_id(sid, "agent_ids")
if sid not in agent_ids:
agent_ids.append(sid)
if len(agent_ids) > MAX_PARALLEL_AGENTS:
raise HTTPException(
status_code=400,
detail=f"并行智能体数量上限 {MAX_PARALLEL_AGENTS}(当前 {len(agent_ids)})",
)
if not task_name and not brief:
raise HTTPException(status_code=400, detail="任务名称与简介至少填写其一")
base = _loopback_base(request)
headers = _auth_headers(request)
new_message = _build_task_message(task_name, brief)
async def _consume_agent(
client: Any,
agent_id: str,
queue: asyncio.Queue[dict[str, Any]],
) -> None:
await queue.put({"type": "agent_start", "agent_id": agent_id})
last_event = ""
emitted_tools: set[str] = set() # 已发过 step 的 tool_call id(去重)
tool_acc: dict[int, dict[str, Any]] = {} # tool_call_chunks 累积
buffer: list[str] = []
done_sent = False
async def _emit_tool(tc: Any, allow_empty: bool = False) -> None:
"""一个 tool_call(含完整 args)→ 一条 step 事件;按 id 去重。
``allow_empty=False``(流式期间):args 为空先不发 —— 流式帧里的 tool_calls 常先给
空 ``{}`` 占位、真实 args 走 tool_call_chunks 后到,过早发会把空 args 钉死。等累积出
完整 args(synthetic)或终态全量 tool_calls(``allow_empty=True``)再发。
"""
if not isinstance(tc, dict):
return
name = tc.get("name")
if not isinstance(name, str) or not name:
return
args = tc.get("args") if isinstance(tc.get("args"), dict) else None
if args is None:
return
if not args and not allow_empty: # 空 args 先不发,等 chunks / 终态补全
return
key_id = str(tc.get("id") or name)
if key_id in emitted_tools:
return
emitted_tools.add(key_id)
await queue.put(
{"type": "step", "agent_id": agent_id, "key": f"tool_calling:{name}", "label": name, "args": args}
)
try:
thread_id = await _create_thread(client, base, headers, metadata=_ROUNDTABLE_THREAD_METADATA)
gen = _run_one_agent( # 只禁文件写入,其余工具放开(保留步骤条)
client, base, headers, agent_id, thread_id, new_message, model_name
)
async for chunk in gen:
if isinstance(chunk, dict):
# 终态:先用最终 tool_calls(全量 args)补发漏掉的 step,再发收口/澄清。
for tc in chunk.get("tool_calls") or []:
await _emit_tool(tc, allow_empty=True)
if chunk.get("type") == "clarification":
clar = chunk.get("clarification") or {}
await queue.put(
{
"type": "agent_clarification",
"agent_id": agent_id,
"content": str(chunk.get("content") or ""),
**clar,
}
)
else:
await queue.put(
{"type": "agent_done", "agent_id": agent_id, "content": str(chunk.get("content") or "")}
)
done_sent = True
continue
line = chunk.strip() if isinstance(chunk, str) else ""
if line.startswith("event:"):
last_event = line[6:].strip()
continue
if not line.startswith("data:"):
continue
raw = line[5:].strip()
if not raw:
continue
try:
parsed = json.loads(raw)
except Exception:
continue
if last_event == "messages" and isinstance(parsed, list) and parsed:
obj = parsed[0]
if isinstance(obj, dict):
text = _extract_ai_text(obj)
if text:
buffer.append(text)
await queue.put({"type": "delta", "agent_id": agent_id, "text": text})
# 完整 tool_calls(部分模型直接给)+ 累积 chunks 后的合成调用,都尝试发 step。
for tc in obj.get("tool_calls") or []:
await _emit_tool(tc)
_ingest_tool_chunks(obj.get("tool_call_chunks"), tool_acc)
for tc in _synthetic_tool_calls(tool_acc):
await _emit_tool(tc)
if not done_sent:
# 流被抢占/截断,没收到终态帧 —— 用已累积正文兜底收口本席位。
await queue.put(
{"type": "agent_done", "agent_id": agent_id, "content": "".join(buffer)}
)
except Exception as exc: # noqa: BLE001 — 单个 agent 失败不拖垮其它
logger.warning("[parallel-agents] %s failed: %r", agent_id, exc)
await queue.put({"type": "agent_error", "agent_id": agent_id, "message": str(exc)})
finally:
await queue.put({"type": "__agent_end__", "agent_id": agent_id})
async def stream_generator() -> AsyncIterator[str]:
queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
async with _make_loopback_client(request) as client:
tasks = [asyncio.create_task(_consume_agent(client, aid, queue)) for aid in agent_ids]
ended = 0
try:
while ended < len(agent_ids):
event = await queue.get()
if event.get("type") == "__agent_end__":
ended += 1
continue
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
yield f"data: {json.dumps({'type': 'all_done'}, ensure_ascii=False)}\n\n"
finally:
for t in tasks:
if not t.done():
t.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
return StreamingResponse(
stream_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
},
)