Files
deerflow-code/offline-backend-20260512/backend/app/gateway/routers/parallel_agents.py
T
2026-06-08 22:52:18 +08:00

248 lines
10 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.
"""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 (file writes disabled),
then **merges** their LangGraph streams into ONE SSE tagged per ``agent_id``, so the
client only consumes one stream and never parses LangGraph internals.
It reuses the roundtable single-agent machinery from ``multi_agent``:
``_special_run(..., no_file=True)`` runs one seat under the ``seat_parallel`` run
policy (which excludes ``write_file`` / ``str_replace`` / ``bash`` / ``web_search``
/ ``agent_orchestration`` …), so parallel agents can't write files or dispatch.
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
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import StreamingResponse
from app.gateway.routers.multi_agent import (
_ROUNDTABLE_THREAD_METADATA,
_auth_headers,
_create_thread,
_loopback_base,
_make_loopback_client,
_special_run,
_validate_agent_id,
)
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
# ── 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 _extract_tool_steps(chunk: dict[str, Any]) -> list[dict[str, Any]]:
"""Tool-call steps from a chunk obj — both complete ``tool_calls`` and
incremental ``tool_call_chunks`` (we only need the name to surface a step)."""
steps: list[dict[str, Any]] = []
tool_calls = chunk.get("tool_calls")
if isinstance(tool_calls, list):
for tc in tool_calls:
if not isinstance(tc, dict):
continue
name = tc.get("name")
if not isinstance(name, str) or not name:
continue
ident = tc.get("id") or name
args = tc.get("args") if isinstance(tc.get("args"), dict) else None
steps.append({"key": f"tool:{ident}", "label": f"调用工具 {name}", "args": args})
tcc = chunk.get("tool_call_chunks")
if isinstance(tcc, list):
for c in tcc:
if not isinstance(c, dict):
continue
name = c.get("name")
if not isinstance(name, str) or not name:
continue
idx = c.get("index") if isinstance(c.get("index"), int) else 0
ident = c.get("id") or f"{name}:{idx}"
steps.append({"key": f"tool:{ident}", "label": f"调用工具 {name}", "args": None})
return steps
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(
"请你从你的专业职责出发,针对以上任务给出你的分析与交付。"
"本轮为**并行**研讨:请把你的产出**直接写在回复正文里**,"
"不要写文件、不要执行脚本/命令。"
)
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 = ""
seen_steps: set[str] = set()
buffer: list[str] = []
done_sent = False
try:
thread_id = await _create_thread(client, base, headers, metadata=_ROUNDTABLE_THREAD_METADATA)
gen = _special_run(
client,
base,
headers,
{agent_id: thread_id},
agent_id,
new_message,
[],
model_name,
display_name="",
no_file=True, # seat_parallel policy → 禁 write_file/bash/str_replace…
)
async for chunk in gen:
if isinstance(chunk, dict):
# 终态帧 {status, content, agent_name}
content = str(chunk.get("content") or "")
await queue.put({"type": "agent_done", "agent_id": agent_id, "content": content})
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})
for step in _extract_tool_steps(obj):
key = step["key"]
if key in seen_steps:
continue
seen_steps.add(key)
await queue.put({"type": "step", "agent_id": agent_id, **step})
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",
},
)