Files

825 lines
38 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 stay on. ``ask_clarification``
is available only to strategy intent, the sole stage with a reply surface.
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
import time
from collections.abc import AsyncIterator
from typing import Any
import httpx
from fastapi import APIRouter, Body, HTTPException, Query, Request, Response
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from app.gateway.roundtable_model_fallback import is_llm_error_message, reason_text
from app.gateway.routers import agents as agents_router
from app.gateway.routers._strategy_selection_seed import (
STRATEGY_AGENT_ID_PREFIX,
STRATEGY_INTENT_AGENT_ID,
)
from app.gateway.routers.multi_agent import (
_ROUNDTABLE_THREAD_METADATA,
_auth_headers,
_create_thread,
_loopback_base,
_make_loopback_client,
_parse_last_messages,
_run_payload,
_stream_upstream,
_validate_agent_id,
)
from deerflow.agents.roundtable_orchestrator.delivery import visible_seat_text
from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple
from deerflow.utils.stream_text import InlineThinkTagFilter
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
# Strategy roles are expected to return a substantive task/strategy/evaluation,
# not merely punctuation or a broken provider placeholder. Counting Unicode
# alphanumerics keeps the check language-neutral while rejecting outputs such as
# a long run of "!!!", which otherwise look non-empty to the UI.
MIN_MEANINGFUL_CONTENT_CHARS = 20
# Long-running read-only skills can be silent while they wait on an upstream
# service. Emit user-visible progress often enough to keep proxies and the UI
# alive, and fail one seat instead of leaving the whole strategy page pending
# forever when the upstream stream has truly stalled.
PROGRESS_HEARTBEAT_SECONDS = 5.0
AGENT_STREAM_IDLE_TIMEOUT_SECONDS = 180.0
_TOOL_PROGRESS_LABELS: dict[str, str] = {
"search_skills": "正在搜索匹配技能",
"skill_list": "正在查找可用技能",
"skill_view": "正在读取技能说明",
"llmwiki_search": "正在检索知识库",
"web_search": "正在检索外部信息",
"web_fetch": "正在读取外部资料",
"knowledge_search": "正在检索知识资料",
"read_file": "正在读取资料",
"read_multiple_files": "正在读取多份资料",
"ls": "正在查看资料目录",
"glob": "正在查找相关资料",
"grep": "正在筛选相关内容",
}
def _tool_progress_message(name: str, args: dict[str, Any]) -> str:
base = _TOOL_PROGRESS_LABELS.get(name, f"正在执行 {name}" if name else "正在执行策略分析步骤")
detail = next(
(
str(value).strip()
for value in (
args.get("query"),
args.get("skill_name"),
args.get("skill"),
args.get("name"),
args.get("path"),
)
if value is not None and str(value).strip()
),
"",
)
if not detail:
return base
compact = " ".join(detail.split())
if len(compact) > 64:
compact = f"{compact[:64]}…"
return f"{base}:{compact}"
def _has_substantive_content(content: str) -> bool:
visible = visible_seat_text(content)
return sum(character.isalnum() for character in visible) >= MIN_MEANINGFUL_CONTENT_CHARS
# 禁两类工具:①**写文件**(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", # 禁跑脚本(防绕道脚本调内部接口模仿派活)
]
def _strategy_stage_allows_clarification(agent_id: str) -> bool:
"""Return whether a strategy-selection stage has a user reply surface.
The workbench renders and resumes clarification cards only for the intent
stage. Angle generation, judging and final synthesis are one-shot stages;
letting those agents call ``ask_clarification`` produces an interrupt that
the UI cannot answer and therefore turns a recoverable run into a failure.
"""
return agent_id.strip().casefold() == STRATEGY_INTENT_AGENT_ID.casefold()
def _excluded_tools_for_agent(agent_id: str) -> list[str]:
excluded = list(_EXCLUDED_TOOLS)
normalized = agent_id.strip().casefold()
if normalized.startswith(STRATEGY_AGENT_ID_PREFIX.casefold()) and not _strategy_stage_allows_clarification(agent_id):
excluded.append("ask_clarification")
return excluded
# ── 并行面板兼容登录:复用单点开关、密码、限速与封禁校验 ────────────────────
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
def _force_situation_detail_compat_token(username: str) -> _TokenResponse:
"""Return a non-JWT value accepted only by open force-situation detail APIs.
Existing task-detail skills call this legacy endpoint before every detail
GET/PUT and require a non-empty value even though those endpoints are now
credential-free. Keeping the compatibility behavior generic avoids
coupling backend routing to a particular skill name or service account.
"""
normalized = username.casefold()
return _TokenResponse(
# Deliberately not a signed JWT: AuthMiddleware rejects it everywhere
# except the explicitly credential-free force-situation detail routes.
access_token=f"force-situation-detail-compat-{normalized}",
token_type="bearer",
user_id=f"external:{normalized}",
)
@router.post("/auth/token", response_model=_TokenResponse)
async def issue_token(body: _TokenRequest, request: Request, response: Response, password: str | None = Query(default=None)) -> _TokenResponse:
"""Issue a normal login token, or detail-route compatibility value on policy rejection."""
username = body.username.strip()
from app.gateway.routers.auth import login_by_username
try:
result = await login_by_username(
request,
response,
password=password,
)
except HTTPException as exc:
# The caller needs this endpoint only as a preflight before the open
# force-situation detail routes. Do not make individual skill names or
# current password/SSO policy a prerequisite for that workflow.
if exc.status_code in {401, 403}:
return _force_situation_detail_compat_token(username)
raise
return _TokenResponse(access_token=result.access_token, token_type="bearer", user_id=result.user_id)
# ── frame normalisation (mirrors multi-agent.ts) ─────────────────────────────
def _extract_ai_stream_parts(chunk: dict[str, Any]) -> tuple[str, str]:
"""Return ``(answer, reasoning)`` deltas from one AI message chunk.
OpenAI-compatible providers do not agree on where displayable reasoning
lives. Most put it in ``additional_kwargs.reasoning_content`` (or the
legacy ``reasoning`` key), while a few return typed content blocks. Keep
that channel separate from the answer so the strategy workbench can show
progress without mixing internal trace text into the generated strategy.
Literal ``<think>`` blocks in the normal text channel are handled by the
per-agent :class:`InlineThinkTagFilter` in ``_consume_agent`` because tags
may be split across arbitrary SSE chunks.
"""
type_ = str(chunk.get("type") or "").lower()
if type_ not in ("ai", "aimessagechunk", "aimessage"):
return "", ""
content = chunk.get("content")
answer_parts: list[str] = []
reasoning_parts: list[str] = []
if isinstance(content, str):
answer_parts.append(content)
elif isinstance(content, list):
for item in content:
if isinstance(item, str):
answer_parts.append(item)
elif isinstance(item, dict):
block_type = str(item.get("type") or "").lower()
value = item.get("thinking")
if not isinstance(value, str):
value = item.get("text")
if not isinstance(value, str):
value = item.get("content")
if not isinstance(value, str):
continue
if block_type in {
"thinking",
"thinking_delta",
"reasoning",
"reasoning_delta",
}:
reasoning_parts.append(value)
else:
answer_parts.append(value)
additional_kwargs = chunk.get("additional_kwargs")
direct_reasoning = chunk.get("reasoning_content") or chunk.get("reasoning")
nested_reasoning: object = None
if isinstance(additional_kwargs, dict):
nested_reasoning = additional_kwargs.get("reasoning_content") or additional_kwargs.get("reasoning")
raw_reasoning = nested_reasoning or direct_reasoning
if isinstance(raw_reasoning, str):
reasoning_parts.append(raw_reasoning)
return "".join(answer_parts), "".join(reasoning_parts)
def _extract_ai_text(chunk: dict[str, Any]) -> str:
"""Compatibility helper returning only the ordinary AI text delta."""
return _extract_ai_stream_parts(chunk)[0]
def _split_final_inline_reasoning(content: str) -> tuple[str, str]:
"""Split complete inline thinking, including template-injected close tags.
Some reasoning chat templates insert ``<think>`` before generation, so the
provider returns only ``reasoning</think>answer``. The streaming filter
cannot classify that prefix before the closing marker arrives, but the
terminal values frame can. Returning the recovered trace here also lets
the client correct any transient streaming classification.
"""
if not content:
return "", ""
working = content
prefix_reasoning = ""
lowered = working.lower()
first_open = lowered.find("<think>")
first_close = lowered.find("</think>")
if first_close >= 0 and (first_open < 0 or first_close < first_open):
prefix_reasoning = working[:first_close]
working = working[first_close + len("</think>") :]
text_filter = InlineThinkTagFilter()
visible, reasoning = text_filter.push_parts(working)
visible_tail, reasoning_tail = text_filter.finish_parts()
final_reasoning = "".join(part for part in (prefix_reasoning, reasoning, reasoning_tail) if part)
# ``visible_seat_text`` remains the canonical complete-output cleanup for
# malformed/multiple tags; the filter-derived visible value is only a
# fallback for an otherwise empty canonical result.
final_visible = visible_seat_text(content) or f"{visible}{visible_tail}".strip()
return final_visible, final_reasoning.strip()
def _should_probe_implicit_reasoning(
request: Request,
agent_id: str,
requested_model: str,
) -> bool:
"""Enable close-only probing only for a thinking-capable effective model.
Ordinary models must stream untagged answer chunks immediately. The
stricter prelude buffer is reserved for providers that advertise thinking
support and may use templates where ``<think>`` is injected outside the
returned content while ``</think>`` remains in-band.
"""
config = getattr(request.app.state, "config", None)
models = list(getattr(config, "models", None) or [])
effective_name = requested_model.strip()
if not effective_name:
try:
effective_name = str(agents_router._load_config_extras(agent_id).get("model") or "").strip()
except Exception:
effective_name = ""
if not effective_name and models:
effective_name = str(getattr(models[0], "name", "") or "")
selected = next(
(entry for entry in models if str(getattr(entry, "name", "") or "") == effective_name),
None,
)
return bool(selected and getattr(selected, "supports_thinking", False))
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=_excluded_tools_for_agent(agent_id),
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
failure_reason = is_llm_error_message(last_ai)
if failure_reason:
raise RuntimeError(f"模型调用失败:{reason_text(failure_reason)}")
answer_channel, structured_reasoning = _extract_ai_stream_parts(last_ai)
content, inline_reasoning = _split_final_inline_reasoning(answer_channel)
reasoning = "\n\n".join(part.strip() for part in (structured_reasoning, inline_reasoning) if part.strip())
# 收集**所有** 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,
"reasoning": reasoning,
"clarification": clarification,
"tool_calls": final_tool_calls,
}
else:
if not _has_substantive_content(content):
raise RuntimeError("智能体未生成有效正文,请重试或更换全局模型")
yield {
"status": f"子智能体 {agent_id} 完成",
"content": content,
"reasoning": reasoning,
"agent_name": agent_id,
"tool_calls": final_tool_calls,
}
def _build_task_message(task_name: str, brief: str, *, agent_id: 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 会产生前端无法回答的中断并被误报为生成失败。
if agent_id and agent_id.casefold().startswith(STRATEGY_AGENT_ID_PREFIX.casefold()) and not _strategy_stage_allows_clarification(agent_id):
parts.append(
"【本阶段禁止反问】当前是无需用户中途介入的一次性交付阶段。不得调用 `ask_clarification`,"
"不得在正文里向用户提问、要求补充材料或要求点击重试。信息不足时请采用保守的合理假设,"
"明确降低相应结论的置信度,并把缺口列入“数据限制与待核验”,然后直接完成本阶段要求的全部交付;"
"不要重复输出同一正文或多个版本。"
)
else:
# 澄清护栏:要向用户确认时,必须用 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)})",
)
role_store = getattr(request.app.state, "strategy_agent_role_store", None)
agent_store = getattr(request.app.state, "agent_store", None)
strategy_ids = [agent_id for agent_id in agent_ids if agent_id.casefold().startswith(STRATEGY_AGENT_ID_PREFIX.casefold())]
if strategy_ids and role_store is not None and agent_store is not None:
# Every worker owns an independent memory store. Synchronize from
# durable folders/sidecars immediately before execution so a request
# routed to another worker cannot see random 404s or revived angles.
from app.gateway.routers.strategy_agents import _ensure_registry
await _ensure_registry(agent_store, role_store)
for agent_id in agent_ids:
if not agent_id.casefold().startswith(STRATEGY_AGENT_ID_PREFIX.casefold()):
continue
if role_store is None or agent_store is None:
raise HTTPException(
status_code=503,
detail="策略智能体分类或配置服务不可用",
)
role = await role_store.get_role(agent_id)
if role is None:
raise HTTPException(
status_code=400,
detail=f"未注册的策略智能体:{agent_id}",
)
if not bool(role.get("enabled")):
raise HTTPException(
status_code=409,
detail=f"策略智能体已停用:{agent_id}",
)
record = await agent_store.get_any(agent_id)
if record is None or str(record.get("id") or "") != agent_id or record.get("user_id") is not None:
raise HTTPException(
status_code=409,
detail=f"策略智能体必须由系统全局持有:{agent_id}",
)
if not task_name and not brief:
raise HTTPException(status_code=400, detail="任务名称与简介至少填写其一")
base = _loopback_base(request)
headers = _auth_headers(request)
async def _consume_agent(
client: Any,
agent_id: str,
queue: asyncio.Queue[dict[str, Any]],
) -> None:
new_message = _build_task_message(task_name, brief, agent_id=agent_id)
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] = []
raw_answer_buffer: list[str] = []
probe_implicit_close = await asyncio.to_thread(
_should_probe_implicit_reasoning,
request,
agent_id,
model_name,
)
inline_thinking = InlineThinkTagFilter(allow_implicit_close=probe_implicit_close)
reasoning_emitted = False
done_sent = False
started_at = time.monotonic()
progress_message = "智能体正在分析任务"
async def _emit_progress_heartbeats() -> None:
while not done_sent:
await asyncio.sleep(PROGRESS_HEARTBEAT_SECONDS)
# A terminal event may have been queued while this task was
# sleeping. Never let a late heartbeat move a completed card
# back into the running state on the frontend.
if done_sent:
return
await queue.put(
{
"type": "agent_progress",
"agent_id": agent_id,
"message": progress_message,
"elapsed_seconds": max(0, round(time.monotonic() - started_at)),
}
)
async def _emit_text_parts(text: str, provider_reasoning: str = "") -> None:
nonlocal progress_message, reasoning_emitted
if text:
raw_answer_buffer.append(text)
visible_prelude = inline_thinking.confirm_visible_channel() if provider_reasoning else ""
visible, inline_reasoning = inline_thinking.push_parts(text)
reasoning = f"{provider_reasoning}{inline_reasoning}"
if reasoning:
progress_message = "正在分析并组织策略"
reasoning_emitted = True
await queue.put(
{
"type": "reasoning_delta",
"agent_id": agent_id,
"text": reasoning,
}
)
confirmed_visible = f"{visible_prelude}{visible}"
if confirmed_visible:
progress_message = "正在生成策略正文"
buffer.append(confirmed_visible)
await queue.put({"type": "delta", "agent_id": agent_id, "text": confirmed_visible})
async def _flush_text_filter() -> None:
nonlocal reasoning_emitted
visible, reasoning = inline_thinking.finish_parts()
if reasoning:
reasoning_emitted = True
await queue.put(
{
"type": "reasoning_delta",
"agent_id": agent_id,
"text": reasoning,
}
)
if visible:
buffer.append(visible)
await queue.put({"type": "delta", "agent_id": agent_id, "text": visible})
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``)再发。
"""
nonlocal progress_message
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)
progress_message = _tool_progress_message(name, args)
await queue.put({"type": "step", "agent_id": agent_id, "key": f"tool_calling:{name}", "label": name, "args": args})
heartbeat_task = asyncio.create_task(_emit_progress_heartbeats())
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
).__aiter__()
while True:
try:
chunk = await asyncio.wait_for(
anext(gen),
timeout=AGENT_STREAM_IDLE_TIMEOUT_SECONDS,
)
except StopAsyncIteration:
break
except TimeoutError as exc:
await gen.aclose()
raise RuntimeError(f"智能体连续 {int(AGENT_STREAM_IDLE_TIMEOUT_SECONDS)} 秒没有返回进度,已停止本次执行,请重试") from exc
if isinstance(chunk, dict):
# 终态:先用最终 tool_calls(全量 args)补发漏掉的 step,再发收口/澄清。
await _flush_text_filter()
for tc in chunk.get("tool_calls") or []:
await _emit_tool(tc, allow_empty=True)
final_content = visible_seat_text(str(chunk.get("content") or ""))
final_reasoning = str(chunk.get("reasoning") or "")
if final_reasoning and not reasoning_emitted:
reasoning_emitted = True
await queue.put(
{
"type": "reasoning_delta",
"agent_id": agent_id,
"text": final_reasoning,
}
)
if chunk.get("type") == "clarification":
done_sent = True
clar = chunk.get("clarification") or {}
await queue.put(
{
"type": "agent_clarification",
"agent_id": agent_id,
"content": final_content,
**clar,
}
)
else:
done_sent = True
await queue.put({"type": "agent_done", "agent_id": agent_id, "content": final_content})
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, reasoning = _extract_ai_stream_parts(obj)
if text or reasoning:
await _emit_text_parts(text, reasoning)
# 完整 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 _flush_text_filter()
buffered_content, recovered_reasoning = _split_final_inline_reasoning("".join(raw_answer_buffer))
if not buffered_content:
buffered_content = "".join(buffer)
if recovered_reasoning and not reasoning_emitted:
reasoning_emitted = True
await queue.put(
{
"type": "reasoning_delta",
"agent_id": agent_id,
"text": recovered_reasoning,
}
)
failure_reason = is_llm_error_message({"type": "ai", "content": buffered_content})
if failure_reason:
raise RuntimeError(f"模型调用失败:{reason_text(failure_reason)}")
if not _has_substantive_content(buffered_content):
raise RuntimeError("智能体未生成有效正文,请重试或更换全局模型")
done_sent = True
await queue.put(
{
"type": "agent_done",
"agent_id": agent_id,
"content": buffered_content,
}
)
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:
heartbeat_task.cancel()
await asyncio.gather(heartbeat_task, return_exceptions=True)
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",
},
)