代码提交

This commit is contained in:
fengxiangdebaozi committed 2026-06-04 18:50:31 +08:00
1 parent 47217e9158
commit 9d863f63e5
33 files changed
+2878 -163

No files matched your search

@@ -46,6 +46,7 @@ from app.gateway.routers import (
recommended_questions,
roundtable_chains,
roundtable_drafts,
roundtable_jobs,
runs,
scheduled_tasks,
skills,
@@ -863,6 +864,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Roundtable-planning business chains at /api/roundtable-chains
app.include_router(roundtable_chains.router)
# Roundtable-planning background jobs at /api/roundtable-jobs
app.include_router(roundtable_jobs.router)
# Suggestions API is mounted at /api/threads/{thread_id}/suggestions
app.include_router(suggestions.router)
@@ -8,6 +8,7 @@ Initialization is handled directly in ``app.py`` via :class:`AsyncExitStack`.
from __future__ import annotations
import logging
from collections.abc import AsyncGenerator, Callable
from contextlib import AsyncExitStack, asynccontextmanager
from typing import TYPE_CHECKING, TypeVar, cast
@@ -21,6 +22,8 @@ from deerflow.runtime import RunContext, RunManager, StreamBridge
from deerflow.runtime.events.store.base import RunEventStore
from deerflow.runtime.runs.store.base import RunStore
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from app.gateway.auth.local_provider import LocalAuthProvider
from app.gateway.auth.repositories.sqlite import SQLiteUserRepository
@@ -178,6 +181,42 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
# (Phase 2 后台编排图写进度用)。
_register_roundtable_job_default_store(app.state.roundtable_job_store)
# 圆桌后台作业执行器:把编排引擎作为后台任务跑。
# 网关由环境变量 ``ROUNDTABLE_JOB_GATEWAY`` 选择:
# - "inprocess"(Phase 3b):进程内驱动真模型(leader/seat/report);
# - 其它/缺省(Phase 3a):模拟网关,产生真实进度但不调真模型,供前端联调。
# 默认 simulated —— 真网关需在能起完整后端栈 + 真模型的环境验证后再开。
if app.state.roundtable_job_store is not None:
import os
from app.gateway.roundtable_job_executor import RoundtableJobExecutor
gateway_mode = (os.getenv("ROUNDTABLE_JOB_GATEWAY") or "simulated").strip().lower()
if gateway_mode == "inprocess":
from app.gateway.roundtable_inprocess_gateway import InProcessRoundtableGateway
_app = app # 闭包捕获 app,供网关访问 app.state(checkpointer/thread_store)
gateway_factory = lambda p: InProcessRoundtableGateway( # noqa: E731
_app, user_id=p.user_id, agents=p.agents, model=p.model
)
logger.info("[roundtable-jobs] gateway = inprocess (real models)")
else:
from deerflow.agents.roundtable_orchestrator import SimulatedRoundtableGateway
# delay=3s/步:让模拟后台任务有可观察的进行中时长(切页/重连仍在跑)。
gateway_factory = lambda p: SimulatedRoundtableGateway( # noqa: E731
p.agents, mode=p.mode, delay=3.0
)
logger.info("[roundtable-jobs] gateway = simulated")
app.state.roundtable_job_executor = RoundtableJobExecutor(
app.state.roundtable_job_store,
gateway_factory=gateway_factory,
draft_store=getattr(app.state, "roundtable_draft_store", None),
)
else:
app.state.roundtable_job_executor = None
# Run event store (has its own factory with config-driven backend selection)
run_events_config = getattr(config, "run_events", None)
app.state.run_event_store = make_run_event_store(run_events_config)
@@ -0,0 +1,272 @@
"""圆桌真网关(Phase 3b)—— 进程内驱动真实 leader / seat / report agent run。
实现 ``RoundtableGateway`` 协议(定义在 harness 的 roundtable_orchestrator.types),
但**进程内**跑真模型:复用 ``services.start_run``(scheduled task 同款)驱动一轮 agent,
跑完从 checkpointer 读最终 messages,用 ``multi_agent`` 的纯解析函数解析派活 / 澄清。
不走 HTTP、不需要 Request(用 ``SimpleNamespace(app=app)`` 当虚拟 request,
``set_current_user`` 由执行器设好)。
本网关位于 app 层(依赖 app.state + multi_agent helpers),实现 harness 定义的协议——
不违反 harness→app 边界(harness 永不 import app;app import harness 合法)。
⚠️ 需在能起完整后端栈 + 真模型的环境验证(见 scripts/probe_roundtable_job.py)。
"""
from __future__ import annotations
import logging
import uuid
from datetime import UTC, datetime
from types import SimpleNamespace
from typing import Any
from langgraph.checkpoint.base import empty_checkpoint
from deerflow.agents.roundtable_orchestrator.types import (
LeaderTurn,
ReportResult,
SeatRef,
SeatTurn,
)
from deerflow.config.paths import get_paths
from deerflow.runtime.serialization import serialize_channel_values
from app.gateway.routers._roundtable_seed import (
COORDINATOR_AGENT_ID,
REPORT_AGENT_ID,
ensure_roundtable_functional_agents,
)
logger = logging.getLogger(__name__)
_ROUNDTABLE_THREAD_METADATA: dict[str, Any] = {"thread_type": "roundtable", "system": True}
_REPORT_FILE = "方案总览.html"
# 各角色的工具裁剪(镜像 multi_agent 的 _leader_run / _special_run)。
_LEADER_EXCLUDED = ["web_search"]
_LEADER_SKILL_STOP = ["agent_orchestration"]
_SEAT_EXCLUDED = ["ask_clarification", "present_files", "view_image", "agent_orchestration"]
_REPORT_EXCLUDED = [*_SEAT_EXCLUDED, "bash", "str_replace"]
# ── 进程内底层操作 ───────────────────────────────────────────────────────────
async def _create_thread(app) -> str:
"""同进程建一个带圆桌 metadata 的 thread + 空 checkpoint(镜像 _create_thread_direct)。"""
checkpointer = app.state.checkpointer
thread_store = app.state.thread_store
thread_id = str(uuid.uuid4())
await thread_store.create(thread_id, assistant_id=None, metadata=dict(_ROUNDTABLE_THREAD_METADATA))
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
await checkpointer.aput(
config,
empty_checkpoint(),
{"step": -1, "source": "input", "writes": None, "parents": {}, "created_at": datetime.now(UTC).isoformat()},
{},
)
return thread_id
async def _read_thread_messages(app, thread_id: str) -> list[dict[str, Any]]:
"""读 thread 最新 checkpoint 的 messages(已 serialize 成 dict 列表)。"""
checkpointer = app.state.checkpointer
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
ct = await checkpointer.aget_tuple(config)
if ct is None:
return []
checkpoint = getattr(ct, "checkpoint", {}) or {}
values = serialize_channel_values(checkpoint.get("channel_values", {}) or {})
return values.get("messages") or []
def _latest_ai_message(messages: list[dict[str, Any]]) -> dict[str, Any] | None:
for m in reversed(messages):
if not isinstance(m, dict):
continue
if (m.get("type") or m.get("role")) in {"ai", "assistant"}:
return m
return None
def _message_text(message: dict[str, Any] | None) -> str:
if not message:
return ""
content = message.get("content")
if isinstance(content, str):
return content
if isinstance(content, list):
parts = [p.get("text", "") for p in content if isinstance(p, dict) and p.get("type") == "text"]
return "\n".join(parts)
return ""
async def _run_agent_turn(
app,
*,
thread_id: str,
agent_name: str,
message: str,
model: str | None,
excluded_tools: list[str],
skill_stop_names: list[str],
thinking_enabled: bool,
reasoning_effort: str,
) -> list[dict[str, Any]]:
"""进程内驱动 agent_name 在 thread_id 上跑一轮(发 message),阻塞到完成,返回最终 messages。
镜像 multi_agent ``_run_payload`` 的 body 形状:agent 通过 ``context.agent_name`` 选,
assistant_id 固定 lead_agent;关闭 memory recall / subagent / plan mode(临时研讨任务)。
"""
from app.gateway.routers.thread_runs import RunCreateRequest
from app.gateway.services import start_run
body = RunCreateRequest(
assistant_id="lead_agent",
input={"messages": [{"type": "human", "content": [{"type": "text", "text": message}], "additional_kwargs": {}}]},
config={"recursion_limit": 1000},
context={
"agent_name": agent_name,
"model_name": model or "",
"mode": "pro",
"reasoning_effort": reasoning_effort,
"thinking_enabled": thinking_enabled,
"is_plan_mode": False,
"subagent_enabled": False,
"thread_id": thread_id,
"memory_recall_disabled": True,
},
excluded_tools=excluded_tools,
skill_stop_names=skill_stop_names,
on_disconnect="continue",
multitask_strategy="reject",
)
request = SimpleNamespace(app=app)
record = await start_run(body, thread_id, request) # type: ignore[arg-type]
if getattr(record, "task", None) is not None:
await record.task # 阻塞到这轮 agent 跑完,checkpoint 已落盘
return await _read_thread_messages(app, thread_id)
def _read_report_html(app, thread_id: str, user_id: str) -> str | None:
"""从 report thread 的 outputs/方案总览.html 读出报告 HTML。"""
try:
outputs_dir = get_paths().sandbox_outputs_dir(thread_id, user_id=user_id)
path = outputs_dir / _REPORT_FILE
if path.exists():
return path.read_text(encoding="utf-8")
except Exception: # noqa: BLE001
logger.warning("read report html failed for thread %s", thread_id, exc_info=True)
return None
def _build_seat_intro(agents: list[SeatRef]) -> str:
"""镜像 init 注入 coordinator 的「可调度席位清单」。"""
lines = [f"- agent_name: {a.agent_id}({a.name})" for a in agents]
return (
"【圆桌系统初始化】本次研讨可调度的席位清单如下,请严格按 agent_name 通过 "
"agent_orchestration 技能派活,不要派给不在清单内的 agent:\n" + "\n".join(lines)
)
# ── 网关实现 ─────────────────────────────────────────────────────────────────
class InProcessRoundtableGateway:
"""进程内真网关。executor 先调 ``init_threads`` 建好线程,再跑编排引擎。"""
def __init__(self, app, *, user_id: str, agents: list[SeatRef], model: str | None = None) -> None:
self._app = app
self._user_id = user_id
self._agents = list(agents)
self._model = model
self._seat_intro = ""
self._first_leader = True
async def init_threads(self) -> tuple[dict[str, str], str]:
"""建 coordinator + 各席位 thread,返回 (thread_ids, coordinator_name)。
席位清单通过「首轮 leader 消息前缀」注入(coordinator thread 跨轮累积历史,
首轮带上即长期可见),无需单独写 checkpoint。
"""
ensure_roundtable_functional_agents()
thread_ids: dict[str, str] = {}
for a in self._agents:
thread_ids[a.agent_id] = await _create_thread(self._app)
thread_ids[COORDINATOR_AGENT_ID] = await _create_thread(self._app)
self._seat_intro = _build_seat_intro(self._agents)
return thread_ids, COORDINATOR_AGENT_ID
async def run_leader(self, *, thread_ids, coordinator_name, message, model) -> LeaderTurn:
msg = message
if self._first_leader and self._seat_intro:
msg = self._seat_intro + "\n\n" + message
self._first_leader = False
messages = await _run_agent_turn(
self._app,
thread_id=thread_ids[coordinator_name],
agent_name=coordinator_name,
message=msg,
model=model or self._model,
excluded_tools=_LEADER_EXCLUDED,
skill_stop_names=_LEADER_SKILL_STOP,
thinking_enabled=False,
reasoning_effort="low",
)
ai = _latest_ai_message(messages)
final_text = _message_text(ai)
tool_calls = (ai.get("tool_calls") if ai else None) or []
# 澄清优先
clar = next((tc for tc in tool_calls if (tc.get("name") or "").lower() == "ask_clarification"), None)
if clar is not None:
cargs = clar.get("args") or {}
question = str(cargs.get("question") or "").strip() or "总控需要补充信息,请补充后继续。"
return LeaderTurn(final_text=final_text, clarification=question)
# 派活解析(复用 multi_agent 纯函数)
from app.gateway.routers.multi_agent import _extract_dispatch
dispatched: list[tuple[str, str]] = []
for tc in tool_calls:
try:
name, task = _extract_dispatch(tc)
if name and task:
dispatched.append((name, task))
except Exception: # noqa: BLE001
logger.warning("dispatch parse failed for tool_call", exc_info=True)
return LeaderTurn(final_text=final_text, dispatched=dispatched)
async def run_seat(self, *, thread_ids, agent_id, task, model) -> SeatTurn:
messages = await _run_agent_turn(
self._app,
thread_id=thread_ids[agent_id],
agent_name=agent_id,
message=task,
model=model or self._model,
excluded_tools=_SEAT_EXCLUDED,
skill_stop_names=[],
thinking_enabled=True,
reasoning_effort="medium",
)
return SeatTurn(final_text=_message_text(_latest_ai_message(messages)))
async def create_report_thread(self) -> str:
ensure_roundtable_functional_agents()
return await _create_thread(self._app)
async def run_report(self, *, report_thread_id, prompt, model) -> ReportResult:
messages = await _run_agent_turn(
self._app,
thread_id=report_thread_id,
agent_name=REPORT_AGENT_ID,
message=prompt,
model=model or self._model,
excluded_tools=_REPORT_EXCLUDED,
skill_stop_names=[],
thinking_enabled=True,
reasoning_effort="medium",
)
summary = _message_text(_latest_ai_message(messages))
html = _read_report_html(self._app, report_thread_id, self._user_id) or summary
return ReportResult(html=html, summary=summary, model=model or self._model)
@@ -0,0 +1,152 @@
"""圆桌后台作业执行器(Phase 3a)。
把 Phase 2 的编排引擎(``run_orchestration``)作为**后台 asyncio 任务**跑起来,
survive 发起请求的生命周期。每个任务:
1. ``set_current_user``:用作业的 user_id 设进程内用户上下文,让引擎里的进度落库
(``JobProgressWriter`` → ``RoundtableJobRepository.update_progress(user_id=AUTO)``)
正确归属(与 scheduled task 后台执行同一套做法);
2. 用 ``gateway_factory`` 构造一个 ``RoundtableGateway``(Phase 3a 默认模拟网关,
Phase 3b 换成进程内真网关),跑完整编排(循环 → 共识 → 报告)。
任务句柄登记在 ``_tasks`` 里,供 ``cancel`` 用;完成后自动摘除。
"""
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass, field
from typing import Callable
from deerflow.agents.roundtable_orchestrator import (
JobProgressWriter,
SeatRef,
run_orchestration,
)
from deerflow.runtime.user_context import reset_current_user, set_current_user
logger = logging.getLogger(__name__)
class _JobUser:
"""最小 CurrentUser:满足 user_context 的 ``.id`` 结构协议。"""
def __init__(self, user_id: str) -> None:
self.id = user_id
@dataclass
class StartParams:
job_id: str
user_id: str
agents: list[SeatRef]
seed_message: str
model: str | None = None
mode: str = "recommend"
intent_text: str = ""
thread_ids: dict[str, str] | None = None
coordinator_name: str = "roundtable-coordinator"
# 关联草稿:作业完成后把 step3 报告写回该草稿,使「查看报告」可用。
draft_id: str | None = None
# 网关工厂:给定一次启动参数,返回一个 RoundtableGateway 实例。
GatewayFactory = Callable[[StartParams], object]
class RoundtableJobExecutor:
def __init__(self, store, *, gateway_factory: GatewayFactory, draft_store=None) -> None:
self._store = store
self._gateway_factory = gateway_factory
# 草稿仓储(可选):作业完成后把 step3 写回草稿,供前端「查看报告」。
self._draft_store = draft_store
self._tasks: dict[str, asyncio.Task] = {}
def start_job(self, params: StartParams) -> bool:
"""启动一个后台编排任务。同一 job 已在跑则忽略(幂等)。返回是否新启动。"""
if params.job_id in self._tasks and not self._tasks[params.job_id].done():
return False
task = asyncio.create_task(self._run(params))
self._tasks[params.job_id] = task
task.add_done_callback(lambda t, jid=params.job_id: self._tasks.pop(jid, None))
return True
async def _run(self, params: StartParams) -> None:
token = set_current_user(_JobUser(params.user_id))
try:
gateway = self._gateway_factory(params)
thread_ids = params.thread_ids or {}
coordinator_name = params.coordinator_name
# 真网关需要先进程内建好 leader/seat 线程;模拟网关无 init_threads。
# 仅首启建线程(resume 时 params.thread_ids 已带,复用、不重建)。
init = getattr(gateway, "init_threads", None)
if init is not None and not thread_ids:
thread_ids, coordinator_name = await init()
try:
await self._store.update_progress(
params.job_id, thread_ids=thread_ids, coordinator_name=coordinator_name
)
except Exception: # noqa: BLE001
logger.warning("persist thread_ids for job %s failed", params.job_id, exc_info=True)
progress = JobProgressWriter(self._store, job_id=params.job_id, agents=params.agents)
result = await run_orchestration(
gateway=gateway,
progress=progress,
agents=params.agents,
thread_ids=thread_ids,
coordinator_name=coordinator_name,
model=params.model,
seed_message=params.seed_message,
mode=params.mode,
intent_text=params.intent_text,
)
# 完成且有报告:写回关联草稿的 step3 + 推进 furthest_step=3,
# 这样用户从历史「查看报告」加载草稿即可看到最终方案(沿用既有 hydrateStep3 路径)。
if (
result.status == "done"
and result.step3
and params.draft_id
and self._draft_store is not None
):
try:
await self._draft_store.update_draft(
params.draft_id,
params.user_id,
step3=result.step3,
furthest_step=3,
)
except Exception: # noqa: BLE001 — 写报告失败不影响作业本身已 done
logger.warning("persist step3 to draft %s failed", params.draft_id, exc_info=True)
except asyncio.CancelledError:
# 干净停止即可,**不在被取消的任务里写 DB**:取消态下二次 await 会再抛
# CancelledError、并可能打断进行中的 sqlite 连接。``cancelled`` 终态由
# 路由的 cancel 端点(executor.cancel + store.update_progress)权威写入。
logger.info("roundtable job %s cancelled", params.job_id)
raise
except Exception: # noqa: BLE001 — 顶层兜底:run_orchestration 内部已尽量标 error
logger.exception("roundtable job %s crashed", params.job_id)
finally:
reset_current_user(token)
def cancel(self, job_id: str) -> bool:
"""请求取消一个在跑的作业。返回是否命中在跑任务。"""
task = self._tasks.get(job_id)
if task is None or task.done():
return False
task.cancel()
return True
def is_running(self, job_id: str) -> bool:
task = self._tasks.get(job_id)
return task is not None and not task.done()
async def await_job(self, job_id: str) -> None:
"""等待某作业任务结束(测试用;生产不调用)。"""
task = self._tasks.get(job_id)
if task is not None:
try:
await task
except (Exception, asyncio.CancelledError): # noqa: BLE001 — 取消是 BaseException
pass
@@ -0,0 +1,341 @@
"""圆桌后台作业控制 API(Phase 3a)。
把 Step 2「后台挂起」的任务交给后端编排引擎跑,并暴露进度订阅 / 续跑 / 取消。
Routes(prefix ``/api/roundtable-jobs``)::
POST "" 启动一个后台作业(创建 job 行 + 拉起执行器任务)
GET ""?draft_id= 查某草稿最近一个作业
GET "/{job_id}" 作业快照
GET "/{job_id}/stream" SSE 进度流(轮询 job 表,多 worker 安全)
POST "/{job_id}/resume" 回答澄清后续跑
POST "/{job_id}/cancel" 取消
进度数据源是 ``roundtable_jobs`` 表(Phase 1),SSE 走 DB 轮询而非内存 pub/sub —— 这样
刷新 / 换 worker / 重连都能继续收到进度,规避 ai_writing 踩过的「per-worker 内存 dict
丢会话」坑。响应统一 camelCase(对齐前端 §2.5)。
"""
from __future__ import annotations
import asyncio
import json
import logging
import time
from datetime import UTC, datetime, timedelta
from typing import Any
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from deerflow.agents.roundtable_orchestrator import SeatRef
from deerflow.runtime.user_context import get_effective_user_id
from app.gateway.roundtable_job_executor import StartParams
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/roundtable-jobs", tags=["roundtable-jobs"])
_TERMINAL = {"done", "error", "cancelled"}
# SSE 轮询间隔与最长存活(对齐 ai_writing:长连接最多挂 30 分钟)。
_POLL_INTERVAL_S = 0.7
_MAX_STREAM_TICKS = int(30 * 60 / _POLL_INTERVAL_S)
# 续跑澄清时拼给 leader 的 prompt 前缀。
_RESUME_PREFIX = "用户已回复你刚才的澄清问题,回答如下,请据此继续派活或给出结论:\n"
# ── schemas ──────────────────────────────────────────────────────────────────
class JobAgent(BaseModel):
agentId: str
name: str = ""
class StartJobRequest(BaseModel):
draftId: str | None = Field(default=None, max_length=64)
intent: str = ""
agents: list[JobAgent] = Field(default_factory=list)
model: str | None = None
orchestrationMode: str = "recommend"
chain: dict[str, Any] | None = None
# 第一轮 leader prompt;缺省用 intent。
seedMessage: str | None = None
# 预建好的 thread 映射(Phase 3b 真网关用;模拟网关忽略)。
threadIds: dict[str, str] | None = None
coordinatorName: str = "roundtable-coordinator"
class ResumeJobRequest(BaseModel):
answer: str = ""
class CleanupRequest(BaseModel):
retention_days: int = Field(default=7, ge=0, description="保留多少天内的作业;0 = 不保留")
only_finished: bool = Field(default=True, description="仅清终态(done/error/cancelled),保护在跑作业")
batch_size: int = Field(default=500, ge=1, le=5000)
class CleanupResponse(BaseModel):
deleted: int
elapsed_seconds: float
params: dict[str, Any]
class RoundtableJobResponse(BaseModel):
id: str
draftId: str | None = None
status: str = "queued"
phase: str = "initializing"
cycle: int = 0
dispatchChain: list[dict[str, Any]] = Field(default_factory=list)
activeAgentId: str | None = None
consensusPercentage: int = 0
pendingClarification: str | None = None
orchestrationMode: str = "recommend"
chain: dict[str, Any] | None = None
error: str | None = None
createdAt: datetime | str | None = None
updatedAt: datetime | str | None = None
# ── helpers ──────────────────────────────────────────────────────────────────
def _current_user_id(request: Request) -> str:
user = getattr(request.state, "user", None)
if user is not None:
return str(user.id)
return get_effective_user_id()
def _get_store(request: Request):
store = getattr(request.app.state, "roundtable_job_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Roundtable job store not available")
return store
def _get_executor(request: Request):
executor = getattr(request.app.state, "roundtable_job_executor", None)
if executor is None:
raise HTTPException(status_code=503, detail="Roundtable job executor not available")
return executor
def _pending_nodes(agents: list[JobAgent]) -> list[dict[str, Any]]:
"""启动即落一份「全 pending」的派活链路,使 GET / 首帧 SSE 立刻能渲染流程图。"""
return [
{"agentId": a.agentId, "name": a.name or a.agentId, "order": i + 1, "state": "pending"}
for i, a in enumerate(agents)
]
def _to_response(row: dict[str, Any]) -> RoundtableJobResponse:
return RoundtableJobResponse(
id=row["id"],
draftId=row.get("draft_id"),
status=row.get("status") or "queued",
phase=row.get("phase") or "initializing",
cycle=int(row.get("cycle") or 0),
dispatchChain=row.get("dispatch_chain") or [],
activeAgentId=row.get("active_agent_id"),
consensusPercentage=int(row.get("consensus_percentage") or 0),
pendingClarification=row.get("pending_clarification"),
orchestrationMode=row.get("orchestration_mode") or "recommend",
chain=row.get("chain"),
error=row.get("error"),
createdAt=row.get("created_at"),
updatedAt=row.get("updated_at"),
)
def _seat_refs_from_nodes(nodes: list[dict[str, Any]] | None) -> list[SeatRef]:
out: list[SeatRef] = []
for n in nodes or []:
aid = str(n.get("agentId") or "")
if aid:
out.append(SeatRef(aid, str(n.get("name") or aid)))
return out
# ── routes ───────────────────────────────────────────────────────────────────
@router.post("", response_model=RoundtableJobResponse, status_code=201)
async def start_job(request: Request, body: StartJobRequest) -> RoundtableJobResponse:
store = _get_store(request)
executor = _get_executor(request)
user_id = _current_user_id(request)
if not body.agents:
raise HTTPException(status_code=400, detail="agents is required")
agents = [SeatRef(a.agentId, a.name or a.agentId) for a in body.agents]
job_id = uuid4().hex
row = await store.create(
id=job_id,
draft_id=body.draftId,
user_id=user_id,
orchestration_mode=body.orchestrationMode,
coordinator_name=body.coordinatorName,
thread_ids=body.threadIds,
chain=body.chain,
dispatch_chain=_pending_nodes(body.agents),
status="queued",
)
executor.start_job(
StartParams(
job_id=job_id,
user_id=user_id,
agents=agents,
seed_message=(body.seedMessage or body.intent or "").strip(),
model=body.model,
mode=body.orchestrationMode,
intent_text=body.intent,
thread_ids=body.threadIds,
coordinator_name=body.coordinatorName,
draft_id=body.draftId,
)
)
return _to_response(row)
@router.get("", response_model=RoundtableJobResponse | None)
async def get_job_by_draft(request: Request, draft_id: str) -> RoundtableJobResponse | None:
store = _get_store(request)
user_id = _current_user_id(request)
row = await store.get_by_draft(draft_id, user_id=user_id)
return _to_response(row) if row is not None else None
@router.get("/{job_id}", response_model=RoundtableJobResponse)
async def get_job(request: Request, job_id: str) -> RoundtableJobResponse:
store = _get_store(request)
user_id = _current_user_id(request)
row = await store.get(job_id, user_id=user_id)
if row is None:
raise HTTPException(status_code=404, detail="Job not found")
return _to_response(row)
@router.get("/{job_id}/stream")
async def stream_job(request: Request, job_id: str) -> StreamingResponse:
store = _get_store(request)
user_id = _current_user_id(request)
def _frame(payload: dict[str, Any]) -> str:
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
async def gen():
for _ in range(_MAX_STREAM_TICKS):
if await request.is_disconnected():
return
row = await store.get(job_id, user_id=user_id)
if row is None:
yield _frame({"event": "error", "detail": "Job not found"})
return
yield _frame({"event": "progress", **_to_response(row).model_dump(mode="json")})
if (row.get("status") or "") in _TERMINAL:
return
await asyncio.sleep(_POLL_INTERVAL_S)
return StreamingResponse(
gen(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no", "Connection": "keep-alive"},
)
@router.post("/{job_id}/resume", response_model=RoundtableJobResponse)
async def resume_job(request: Request, job_id: str, body: ResumeJobRequest) -> RoundtableJobResponse:
store = _get_store(request)
executor = _get_executor(request)
user_id = _current_user_id(request)
row = await store.get(job_id, user_id=user_id)
if row is None:
raise HTTPException(status_code=404, detail="Job not found")
if (row.get("status") or "") != "awaiting_input":
raise HTTPException(status_code=409, detail="Job is not awaiting input")
answer = body.answer.strip()
if not answer:
raise HTTPException(status_code=400, detail="answer is required")
updated = await store.update_progress(
job_id, user_id=user_id, status="running", pending_clarification=None
)
executor.start_job(
StartParams(
job_id=job_id,
user_id=user_id,
agents=_seat_refs_from_nodes(row.get("dispatch_chain")),
seed_message=_RESUME_PREFIX + answer,
model=None,
mode=row.get("orchestration_mode") or "recommend",
intent_text=answer,
thread_ids=row.get("thread_ids"),
coordinator_name=row.get("coordinator_name") or "roundtable-coordinator",
draft_id=row.get("draft_id"),
)
)
return _to_response(updated or row)
async def _require_admin(request: Request) -> None:
"""放行 admin / auth-disabled,其余 403(与 ai_writing / scheduled_tasks 同套语义)。"""
from app.gateway.deps import get_optional_user_from_request
user = await get_optional_user_from_request(request)
if user is None:
return
if getattr(user, "system_role", None) != "admin":
raise HTTPException(status_code=403, detail="圆桌作业清理管理接口仅限管理员")
@router.post("/admin/cleanup", response_model=CleanupResponse)
async def admin_cleanup(request: Request, body: CleanupRequest) -> CleanupResponse:
"""清理过期作业行(活跃作业受 only_finished 保护,永不误删)。
分批 ``list_older_than`` → ``delete_by_ids``,避免大事务。``retention_days=0``
一刷清空所有终态作业(调试用)。
"""
await _require_admin(request)
store = _get_store(request)
cutoff = datetime.now(UTC) - timedelta(days=body.retention_days)
t0 = time.perf_counter()
deleted = 0
while True:
ids = await store.list_older_than(cutoff, only_finished=body.only_finished, limit=body.batch_size)
if not ids:
break
deleted += await store.delete_by_ids(ids)
if len(ids) < body.batch_size:
break
return CleanupResponse(
deleted=deleted,
elapsed_seconds=round(time.perf_counter() - t0, 3),
params={"retention_days": body.retention_days, "only_finished": body.only_finished, "batch_size": body.batch_size},
)
@router.post("/{job_id}/cancel", response_model=RoundtableJobResponse)
async def cancel_job(request: Request, job_id: str) -> RoundtableJobResponse:
store = _get_store(request)
executor = _get_executor(request)
user_id = _current_user_id(request)
row = await store.get(job_id, user_id=user_id)
if row is None:
raise HTTPException(status_code=404, detail="Job not found")
executor.cancel(job_id)
updated = await store.update_progress(job_id, user_id=user_id, status="cancelled")
return _to_response(updated or row)
@@ -0,0 +1,48 @@
"""圆桌总控编排引擎(Phase 2)。
把前端 useStep2Orchestration 的编排循环移植到后端,作为可后台跑、可单测的纯引擎。
Phase 3 会提供具体的 ``RoundtableGateway`` 实现(复用 multi_agent.py 内部函数)+
执行器 / LangGraph 注册,把引擎接到真实的 LLM / 线程上。
公开入口:
- ``run_orchestration`` —— 跑一次完整编排(循环 → 共识 → 报告绘制)。
- ``JobProgressWriter`` —— 进度落库 + 可选 SSE 外发。
- 协议 / 数据类(``RoundtableGateway`` / ``LeaderTurn`` / ``SeatTurn`` /
``ReportResult`` / ``SeatRef``)。
"""
from __future__ import annotations
from .engine import OrchestrationResult, build_report_prompt, run_orchestration
from .progress import JobProgressWriter
from .simulated_gateway import SimulatedRoundtableGateway
from .types import (
CHAIN_CLOSEOUT_PROMPT,
CONTINUE_PROMPT,
MAX_CYCLES,
LeaderTurn,
ReportResult,
RoundtableGateway,
SeatRef,
SeatTurn,
build_chain_leader_task,
pick_chain_task,
)
__all__ = [
"run_orchestration",
"OrchestrationResult",
"build_report_prompt",
"JobProgressWriter",
"SimulatedRoundtableGateway",
"RoundtableGateway",
"LeaderTurn",
"SeatTurn",
"ReportResult",
"SeatRef",
"MAX_CYCLES",
"CONTINUE_PROMPT",
"CHAIN_CLOSEOUT_PROMPT",
"build_chain_leader_task",
"pick_chain_task",
]
@@ -0,0 +1,299 @@
"""圆桌总控编排引擎 —— 后台跑的编排循环(移植自前端 useStep2Orchestration.ts)。
纯逻辑:通过 ``RoundtableGateway`` 协议驱动「单轮 leader / seat / report」,通过
``JobProgressWriter`` 落库进度。两种模式:
- recommend(``runOrchestration``):总控自由派活循环,leader → 顺序派 special →
leader …,直到 ``dispatched==[]``(共识)或达到 ``MAX_CYCLES``;
- chain(``runChainOrchestration``):总控**按席位顺序**逐位派活,走完整链后由
leader 收口产出最终结论。
共识达成(或 max cycles 强制收口)后进入 Step3 报告绘制,写出 ``方案总览.html``,
返回 ``DraftStep3Snapshot`` 形状的 step3 快照。命中澄清则返回 ``awaiting_input``
(thread 已 checkpoint,resume 时由调用方用用户答案再发起一轮即可续跑)。
**不并行 special**(与前端同一不变量):顺序 await 每个席位,避免 token 级抖动 +
保证派活链路游标严格推进。
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import UTC, datetime
from .progress import JobProgressWriter
from .types import (
CHAIN_CLOSEOUT_PROMPT,
CONTINUE_PROMPT,
MAX_CYCLES,
ReportResult,
RoundtableGateway,
SeatRef,
build_chain_leader_task,
pick_chain_task,
)
logger = logging.getLogger(__name__)
@dataclass
class OrchestrationResult:
"""引擎一次运行的终态。"""
status: str # "done" | "awaiting_input" | "error"
consensus: bool = False
step3: dict | None = None
pending_clarification: str | None = None
error: str | None = None
last_leader_content: str = ""
@dataclass
class _LoopOutcome:
"""内部:编排循环(未进报告阶段)的结果。"""
kind: str # "consensus" | "clarification" | "max_cycles"
question: str | None = None
last_leader_content: str = ""
# {agent_id: 最近一次交付正文}
deliveries: dict[str, str] = field(default_factory=dict)
async def run_orchestration(
*,
gateway: RoundtableGateway,
progress: JobProgressWriter,
agents: list[SeatRef],
thread_ids: dict[str, str],
coordinator_name: str,
model: str | None,
seed_message: str,
mode: str = "recommend",
intent_text: str = "",
) -> OrchestrationResult:
"""运行一次完整编排(循环 → 共识 → 报告绘制)。
Args:
gateway: 单轮执行抽象出口。
progress: 进度写入器(落库 + 可选 SSE 外发)。
agents: 有序席位(recommend=选择顺序 / chain=链条顺序)。
thread_ids: {agent_id / coordinator_name: thread_id},预先建好。
coordinator_name: 总控 agent_id(如 "roundtable-coordinator")。
model: 选定模型(None/"" 走后端默认)。
seed_message: 第一轮 leader 的 prompt(启动=初始意图;resume=用户答案续轮)。
mode: "recommend" | "chain"。
intent_text: 任务意图,用于报告 prompt(缺省退回 seed_message)。
"""
try:
await progress.begin()
if mode == "chain":
outcome = await _run_chain(gateway, progress, agents, thread_ids, coordinator_name, model, seed_message)
else:
outcome = await _run_recommend(gateway, progress, agents, thread_ids, coordinator_name, model, seed_message)
if outcome.kind == "clarification":
question = outcome.question or "总控需要补充信息,请补充后继续。"
await progress.awaiting_input(question)
return OrchestrationResult(
status="awaiting_input",
pending_clarification=question,
last_leader_content=outcome.last_leader_content,
)
# consensus 或 max_cycles:均收口 → 报告绘制
await progress.consensus(100)
step3 = await _run_report(
gateway,
progress,
agents=agents,
model=model,
intent_text=intent_text or seed_message,
consensus_text=outcome.last_leader_content,
deliveries=outcome.deliveries,
)
await progress.done()
return OrchestrationResult(
status="done",
consensus=True,
step3=step3,
last_leader_content=outcome.last_leader_content,
)
except Exception as exc: # noqa: BLE001 — 顶层兜底:标记 error 并返回
logger.exception("roundtable orchestration failed")
await progress.error(str(exc))
return OrchestrationResult(status="error", error=str(exc))
# ── recommend 模式 ───────────────────────────────────────────────────────────
async def _run_recommend(
gateway: RoundtableGateway,
progress: JobProgressWriter,
agents: list[SeatRef],
thread_ids: dict[str, str],
coordinator_name: str,
model: str | None,
seed_message: str,
) -> _LoopOutcome:
deliveries: dict[str, str] = {}
last_leader = ""
message = seed_message
for cycle in range(1, MAX_CYCLES + 1):
await progress.leader_thinking(cycle)
turn = await gateway.run_leader(
thread_ids=thread_ids,
coordinator_name=coordinator_name,
message=message,
model=model,
)
if turn.final_text:
last_leader = turn.final_text
if turn.is_clarification:
return _LoopOutcome("clarification", question=turn.clarification, last_leader_content=last_leader, deliveries=deliveries)
if turn.is_consensus:
return _LoopOutcome("consensus", last_leader_content=last_leader, deliveries=deliveries)
# **顺序**派活 —— 不要并行
await progress.dispatching(cycle)
for agent_id, task in turn.dispatched:
await progress.seat_active(agent_id)
seat = await gateway.run_seat(thread_ids=thread_ids, agent_id=agent_id, task=task, model=model)
deliveries[agent_id] = seat.final_text
await progress.seat_done(agent_id, cycle)
await progress.bump_consensus()
message = CONTINUE_PROMPT
# 达到最大轮次:强制收口(对齐前端 toast「已达到最大研讨轮次」)
logger.info("roundtable recommend loop hit MAX_CYCLES=%d, forcing consensus", MAX_CYCLES)
return _LoopOutcome("max_cycles", last_leader_content=last_leader, deliveries=deliveries)
# ── chain 模式 ───────────────────────────────────────────────────────────────
async def _run_chain(
gateway: RoundtableGateway,
progress: JobProgressWriter,
agents: list[SeatRef],
thread_ids: dict[str, str],
coordinator_name: str,
model: str | None,
seed_message: str,
) -> _LoopOutcome:
deliveries: dict[str, str] = {}
last_leader = ""
total = len(agents)
for i, seat in enumerate(agents):
# (a) 总控按链条顺序只给当前席位派活
await progress.leader_thinking(i + 1)
leader_task = build_chain_leader_task(
seed_message=seed_message,
position=i,
total=total,
seat_agent_id=seat.agent_id,
seat_name=seat.name,
is_first=(i == 0),
)
turn = await gateway.run_leader(
thread_ids=thread_ids,
coordinator_name=coordinator_name,
message=leader_task,
model=model,
)
if turn.final_text:
last_leader = turn.final_text
if turn.is_clarification:
return _LoopOutcome("clarification", question=turn.clarification, last_leader_content=last_leader, deliveries=deliveries)
task = pick_chain_task(turn.dispatched, turn.final_text, seat.agent_id, seat.name)
# (b) 当前席位交付(顺序,不并行)
await progress.seat_active(seat.agent_id)
seat_turn = await gateway.run_seat(thread_ids=thread_ids, agent_id=seat.agent_id, task=task, model=model)
deliveries[seat.agent_id] = seat_turn.final_text
await progress.seat_done(seat.agent_id, i + 1)
if total > 0:
await progress.set_consensus_pct(round((i + 1) / total * 90))
# (c) 总控收口:综合全链交付,产出最终结论
if total > 0:
await progress.leader_thinking(total + 1)
closeout = await gateway.run_leader(
thread_ids=thread_ids,
coordinator_name=coordinator_name,
message=CHAIN_CLOSEOUT_PROMPT,
model=model,
)
if closeout.final_text:
last_leader = closeout.final_text
return _LoopOutcome("consensus", last_leader_content=last_leader, deliveries=deliveries)
# ── 报告阶段 ─────────────────────────────────────────────────────────────────
async def _run_report(
gateway: RoundtableGateway,
progress: JobProgressWriter,
*,
agents: list[SeatRef],
model: str | None,
intent_text: str,
consensus_text: str,
deliveries: dict[str, str],
) -> dict:
await progress.report_drawing()
report_thread_id = await gateway.create_report_thread()
prompt = build_report_prompt(intent_text, agents, deliveries, consensus_text)
report: ReportResult = await gateway.run_report(report_thread_id=report_thread_id, prompt=prompt, model=model)
return {
"html": report.html,
"generatedAt": report.generated_at or datetime.now(UTC).isoformat(),
"model": report.model or (model or ""),
"summary": report.summary,
}
def build_report_prompt(
intent_text: str,
agents: list[SeatRef],
deliveries: dict[str, str],
consensus_text: str,
) -> str:
"""拼装 Step3 报告绘制 prompt:意图 + 各席位交付 + 总控共识。
席位交付按 ``agents`` 顺序列出(缺交付的席位跳过)。报告智能体据此用 write_file
写出自包含的 ``outputs/方案总览.html``(具体绘制规则在 roundtable-report 的 SOUL)。
"""
lines: list[str] = [
"【圆桌最终成果汇总 —— 请据此绘制方案总览 HTML 看板】",
"",
"## 任务意图",
(intent_text or "").strip() or "(未提供)",
"",
"## 各席位交付",
]
name_by_id = {a.agent_id: a.name for a in agents}
has_delivery = False
for a in agents:
text = (deliveries.get(a.agent_id) or "").strip()
if not text:
continue
has_delivery = True
lines.append(f"### {name_by_id.get(a.agent_id, a.agent_id)}")
lines.append(text)
lines.append("")
if not has_delivery:
lines.append("(各席位暂无可用交付,请基于任务意图与总控结论绘制。)")
lines.append("")
lines.append("## 总控最终结论")
lines.append((consensus_text or "").strip() or "(未提供)")
return "\n".join(lines)
@@ -0,0 +1,142 @@
"""编排进度写入器 —— 把引擎的状态流转持久化进 ``roundtable_jobs`` 表。
引擎只调本类的语义方法(leader_thinking / seat_active / seat_done / consensus / …),
由它维护「派活链路各节点状态」并落库(best-effort,失败不打断编排)。落库的
``dispatch_chain`` 即 Phase 7 流程图弹窗 / Phase 6 历史下拉的数据源。
进度同时可通过可选的 ``event_sink`` 回调外发(Phase 3 的 SSE 流用),引擎逻辑不感知。
"""
from __future__ import annotations
import logging
from typing import Any, Awaitable, Callable
from .types import SeatRef
logger = logging.getLogger(__name__)
# 节点状态机:pending → active → done。
_PENDING = "pending"
_ACTIVE = "active"
_DONE = "done"
EventSink = Callable[[dict[str, Any]], Awaitable[None]] | None
class JobProgressWriter:
"""跟踪派活链路节点状态并持久化进度。
Args:
store: ``RoundtableJobRepository``(或任何提供 ``update_progress`` 的对象);
传 None 时所有持久化变 no-op(引擎仍可纯跑,便于干测)。
job_id: 目标作业 id。
agents: 有序席位列表(recommend=选择顺序 / chain=链条顺序)→ 决定节点 order。
event_sink: 可选异步回调,每次进度变更时透传一份归一化事件(Phase 3 SSE)。
"""
def __init__(
self,
store: Any | None,
*,
job_id: str,
agents: list[SeatRef],
event_sink: EventSink = None,
) -> None:
self._store = store
self._job_id = job_id
self._agents = list(agents)
self._event_sink = event_sink
self._state: dict[str, str] = {a.agent_id: _PENDING for a in agents}
self._spoke_cycle: dict[str, int] = {}
self._consensus = 0
# ── 节点快照 ──────────────────────────────────────────────────────────
def _nodes(self) -> list[dict[str, Any]]:
"""构造 dispatch_chain 节点数组(§2.3)。
不含 avatarType —— 颜色由前端 ``getAvatarTypeFor(agentId, order)`` 派生,
避免把前端的 BUILTIN_AGENT_META 配色表重复移植到后端(易漂移)。
"""
out: list[dict[str, Any]] = []
for i, a in enumerate(self._agents):
node: dict[str, Any] = {
"agentId": a.agent_id,
"name": a.name,
"order": i + 1,
"state": self._state.get(a.agent_id, _PENDING),
}
if a.agent_id in self._spoke_cycle:
node["lastSpokeCycle"] = self._spoke_cycle[a.agent_id]
out.append(node)
return out
async def _persist(self, **fields: Any) -> None:
"""落库(始终带最新 dispatch_chain + consensus)+ 外发事件,均 best-effort。"""
fields.setdefault("dispatch_chain", self._nodes())
fields.setdefault("consensus_percentage", self._consensus)
if self._store is not None:
try:
await self._store.update_progress(self._job_id, **fields)
except Exception: # noqa: BLE001 — 进度落库失败不能打断编排
logger.warning("roundtable job progress persist failed", exc_info=True)
if self._event_sink is not None:
try:
await self._event_sink({"job_id": self._job_id, **fields})
except Exception: # noqa: BLE001
logger.warning("roundtable job progress event sink failed", exc_info=True)
# ── 语义方法(引擎调用)─────────────────────────────────────────────────
async def begin(self) -> None:
await self._persist(status="running", phase="initializing", cycle=0, active_agent_id=None)
async def leader_thinking(self, cycle: int) -> None:
await self._persist(phase="leader_thinking", cycle=cycle, active_agent_id=None)
async def dispatching(self, cycle: int) -> None:
await self._persist(phase="dispatching", cycle=cycle)
async def seat_active(self, agent_id: str) -> None:
if agent_id in self._state:
self._state[agent_id] = _ACTIVE
await self._persist(phase="dispatching", active_agent_id=agent_id)
async def seat_done(self, agent_id: str, cycle: int) -> None:
if agent_id in self._state:
self._state[agent_id] = _DONE
self._spoke_cycle[agent_id] = cycle
await self._persist(active_agent_id=None)
async def bump_consensus(self) -> None:
"""recommend 每轮派活后 +8(封顶 95),对齐前端。"""
self._consensus = min(95, self._consensus + 8)
await self._persist()
async def set_consensus_pct(self, pct: int) -> None:
"""chain 每席位后设进度(封顶 95),对齐前端 round((i+1)/total*90)。"""
self._consensus = min(95, int(pct))
await self._persist()
async def consensus(self, pct: int = 100) -> None:
self._consensus = int(pct)
await self._persist(phase="consensus", active_agent_id=None)
async def report_drawing(self) -> None:
await self._persist(phase="report_drawing", active_agent_id=None)
async def done(self) -> None:
self._consensus = 100
await self._persist(status="done", phase="report_done", active_agent_id=None)
async def awaiting_input(self, question: str) -> None:
await self._persist(
status="awaiting_input",
phase="leader_thinking",
active_agent_id=None,
pending_clarification=question,
)
async def error(self, message: str) -> None:
await self._persist(status="error", error=message)
@@ -0,0 +1,64 @@
"""模拟网关 —— 产生真实的编排进度但**不调真模型 / 真 LangGraph server**。
用途:
- Phase 3a:让 ``/api/roundtable-jobs`` 控制面(路由 + 执行器 + SSE)端到端跑起来,
前端 Phase 4-8 可对着真实 API + 真实 job 表 + 真实 SSE 联调,无需真模型;
- 单测:``delay=0`` 时瞬时跑完,验证执行器 / 进度落库 / 路由生命周期。
真网关 ``InProcessRoundtableGateway``(进程内驱动 leader/seat/report run,复用
multi_agent 的纯解析函数 + ``set_current_user`` 进程内调 agent runtime)是 Phase 3b,
需在 live stack 上验证。本模拟器与真网关实现同一个 ``RoundtableGateway`` 协议,可直接替换。
"""
from __future__ import annotations
import asyncio
from .types import LeaderTurn, ReportResult, SeatRef, SeatTurn
class SimulatedRoundtableGateway:
"""脚本化的假网关。
recommend:第 1 轮把所有席位派活一遍 → 第 2 轮共识;
chain:每个 per-seat leader 调用不显式派活(引擎据链条顺序强制跑席位)→ 收口。
"""
def __init__(self, agents: list[SeatRef], *, delay: float = 0.6, mode: str = "recommend") -> None:
self._agents = list(agents)
self._delay = delay
self._mode = mode
self._leader_calls = 0
async def _tick(self) -> None:
if self._delay > 0:
await asyncio.sleep(self._delay)
async def run_leader(self, *, thread_ids, coordinator_name, message, model) -> LeaderTurn:
await self._tick()
self._leader_calls += 1
if self._mode == "chain":
# chain 模式由引擎按链条顺序强制跑席位,leader 只给说明、不派活。
return LeaderTurn(final_text="(模拟)总控:请当前席位按职责给出分析。", dispatched=[])
# recommend:首轮派活全部席位,次轮共识。
if self._leader_calls == 1:
return LeaderTurn(
final_text="(模拟)总控首轮派活:已为各席位分配本轮任务。",
dispatched=[(a.agent_id, f"请「{a.name}」就任务从你的专业职责给出分析与建议。") for a in self._agents],
)
return LeaderTurn(final_text="(模拟)各席位已充分研讨,结论收敛,达成共识。", dispatched=[])
async def run_seat(self, *, thread_ids, agent_id, task, model) -> SeatTurn:
await self._tick()
return SeatTurn(final_text=f"(模拟){agent_id} 的交付:已基于任务背景完成本轮分析。")
async def create_report_thread(self) -> str:
return "sim-report-thread"
async def run_report(self, *, report_thread_id, prompt, model) -> ReportResult:
await self._tick()
html = (
"<!doctype html><html><head><meta charset='utf-8'><title>方案总览(模拟)</title></head>"
"<body><h1>方案总览(模拟)</h1><p>这是模拟网关生成的占位报告,用于前端联调。</p></body></html>"
)
return ReportResult(html=html, summary="(模拟)方案已生成", model=model)
@@ -0,0 +1,191 @@
"""圆桌总控编排引擎 —— 共享类型、常量与纯函数。
把前端 ``useStep2Orchestration.ts`` 的编排循环(``runOrchestration`` /
``runChainOrchestration``)忠实移植到后端。本文件只放:
- ``RoundtableGateway`` 协议:引擎驱动「单轮 leader / seat / report」的抽象出口,
把对 FastAPI ``Request`` / ``multi_agent.py`` 内部函数的依赖隔离到 Phase 3 的
具体实现里(引擎本身纯逻辑、可用假网关单测);
- ``LeaderTurn`` / ``SeatTurn`` / ``ReportResult`` / ``SeatRef`` 数据类;
- 常量(MAX_CYCLES、各轮 prompt)与 chain 模式的 prompt 构造纯函数。
对应 ``frontend-web/docs/multi-agent-background-run-dev.md`` §Phase 2。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Protocol, runtime_checkable
# 编排循环最大轮次,与前端 roundtable-constants.ts MAX_CYCLES 对齐。
MAX_CYCLES = 8
# recommend 模式每轮派活后、回到 leader 的「续轮」prompt(逐字对齐前端)。
CONTINUE_PROMPT = (
"各子智能体已就上一轮派活完成交付,请你结合最新结论判断是否仍需继续派活;"
"若信息已足够,请直接输出最终方案并停止派活。"
)
# chain 模式全链走完后、总控收口的 prompt(逐字对齐前端)。
CHAIN_CLOSEOUT_PROMPT = (
"各链条席位已按顺序完成发言,请你综合所有席位的交付,输出最终方案与结论。"
"不要再派活、不要再向任何席位分配任务。"
)
# ── 数据类 ──────────────────────────────────────────────────────────────────
@dataclass(frozen=True)
class SeatRef:
"""一个研讨席位(智能体)。chain 模式下 ``agents`` 的顺序即链条顺序。"""
agent_id: str
name: str
@dataclass
class LeaderTurn:
"""总控一轮执行的归一化结果(由 gateway.run_leader 产出)。"""
final_text: str = ""
# [(agent_id, task), ...];空 = 共识(不再派活)。
dispatched: list[tuple[str, str]] = field(default_factory=list)
# 命中澄清时的问题文本;非 None 即 clarification 分支。
clarification: str | None = None
@property
def is_clarification(self) -> bool:
return bool(self.clarification)
@property
def is_consensus(self) -> bool:
return not self.dispatched and not self.is_clarification
@dataclass
class SeatTurn:
"""某席位一轮交付的归一化结果(由 gateway.run_seat 产出)。"""
final_text: str = ""
@dataclass
class ReportResult:
"""Step3 报告绘制结果(由 gateway.run_report 产出)。"""
html: str = ""
summary: str | None = None
model: str | None = None
generated_at: str | None = None
# ── 网关协议 ────────────────────────────────────────────────────────────────
@runtime_checkable
class RoundtableGateway(Protocol):
"""引擎驱动「单轮执行」的抽象出口。
Phase 3 提供具体实现(``LoopbackRoundtableGateway``):内部复用
``multi_agent.py`` 的 ``_leader_run`` / ``_special_run`` / ``_create_thread_direct``
等(它们依赖 ``Request``,由 Phase 3 的启动上下文注入)。引擎只面向本协议,
因此可用假网关脱离真模型 / 真 LangGraph server 单测。
线程约定:席位 + 总控 thread 由调用方预先建好并通过 ``thread_ids`` 传入
(key = agent_id / coordinator_name,value = thread_id);report thread 由引擎在
进入报告阶段时通过 ``create_report_thread`` 现建。
"""
async def run_leader(
self,
*,
thread_ids: dict[str, str],
coordinator_name: str,
message: str,
model: str | None,
) -> LeaderTurn: ...
async def run_seat(
self,
*,
thread_ids: dict[str, str],
agent_id: str,
task: str,
model: str | None,
) -> SeatTurn: ...
async def create_report_thread(self) -> str: ...
async def run_report(
self,
*,
report_thread_id: str,
prompt: str,
model: str | None,
) -> ReportResult: ...
# ── chain 模式 prompt 构造(移植自前端 buildChainLeaderTask / pickChainTask)────
def build_chain_leader_task(
*,
seed_message: str,
position: int,
total: int,
seat_agent_id: str,
seat_name: str,
is_first: bool,
) -> str:
"""构造 chain 模式下「让总控只给当前席位派活」的 leader prompt。"""
ordinal = f"{position + 1}/{total}"
if is_first:
return "\n".join(
[
"这是一次【按业务链条顺序】进行的多智能体圆桌研讨。任务背景如下:",
seed_message,
"",
f"本次研讨严格按预设链条顺序进行,共 {total} 个席位,现在是第 {ordinal} 个席位。",
f"请你作为总控,通过 agent_orchestration 技能**仅**向智能体「{seat_name}」"
f"(agent_name: {seat_agent_id})派活,结合任务背景为它分配聚焦其职责的本轮任务。",
"不要派给其他任何智能体,也不要自己直接作答或给出最终结论。",
]
)
return "\n".join(
[
f"链条研讨继续,现在轮到第 {ordinal} 个席位。",
f"请你作为总控,结合此前各席位已完成的交付,通过 agent_orchestration 技能**仅**向智能体"
f"「{seat_name}」(agent_name: {seat_agent_id})派活,为它分配承接前序结论、聚焦其职责的本轮任务。",
"不要派给其他任何智能体,也不要自己直接作答或给出最终结论。",
]
)
def pick_chain_task(
dispatched: list[tuple[str, str]],
leader_content: str,
seat_agent_id: str,
seat_name: str,
) -> str:
"""从 leader 终态里取「派给当前链条席位」的任务文本,带纠偏(移植自前端 pickChainTask)。
1) 命中 agent_id 完全一致的派活 → 用它的 task;
2) 总控派了活但写偏 id → 仍用它给出的 task 文本(强制跑正确席位);
3) 总控只写了说明没派活 → 用 leader 正文,再不行回退到模板任务。
"""
wanted = seat_agent_id.lower()
for name, task in dispatched:
if name.lower() == wanted and (task or "").strip():
return task
for _name, task in dispatched:
if (task or "").strip():
return task
if (leader_content or "").strip():
return (
f"请你作为「{seat_name}」,基于以下总控说明与此前各席位的交付,"
f"从你的专业职责出发给出你的分析与交付:\n{leader_content.strip()}"
)
return (
f"请你作为「{seat_name}」,基于任务背景与此前各席位的交付,"
"从你的专业职责出发给出你的分析与交付。"
)
@@ -0,0 +1,100 @@
"""圆桌后台作业真网关 —— headless 验证脚本(Phase 3b)。
用法(在能起完整后端栈 + 配好真模型的环境):
1) 用真网关启动后端:
ROUNDTABLE_JOB_GATEWAY=inprocess make gateway
(或 docker 里给 gateway 容器加该环境变量)
2) 跑本脚本,它会:POST 启动一个作业 → 轮询 GET 打印进度 → 跑到 done 打印报告长度。
PYTHONPATH=. uv run python scripts/probe_roundtable_job.py
环境变量:
GATEWAY_URL 默认 http://127.0.0.1:8001
AUTH_COOKIE 若后端开启鉴权,传 "session=xxx" 之类的 Cookie 头
PROBE_INTENT 自定义任务意图
PROBE_MODE recommend(默认)| chain
说明:鉴权关闭的 dev 模式无需 AUTH_COOKIE。真网关会进程内驱动 roundtable-coordinator /
各席位 / roundtable-report 真模型,耗时取决于模型,可能数分钟。
"""
from __future__ import annotations
import asyncio
import os
import time
import httpx
GATEWAY_URL = os.getenv("GATEWAY_URL", "http://127.0.0.1:8001").rstrip("/")
AUTH_COOKIE = os.getenv("AUTH_COOKIE", "")
INTENT = os.getenv("PROBE_INTENT", "为一家中型制造企业设计一套数字化转型的整体方案")
MODE = os.getenv("PROBE_MODE", "recommend")
# 用 3 个内置圆桌席位(确保可见、已 seed)。
AGENTS = [
{"agentId": "roundtable-intelligence", "name": "情报收集"},
{"agentId": "roundtable-solution-design", "name": "方案设计"},
{"agentId": "roundtable-summary", "name": "总结陈述"},
]
TERMINAL = {"done", "error", "cancelled"}
def _headers() -> dict[str, str]:
h = {"Content-Type": "application/json"}
if AUTH_COOKIE:
h["Cookie"] = AUTH_COOKIE
return h
async def main() -> None:
async with httpx.AsyncClient(timeout=30.0, trust_env=False) as client:
print(f"[probe] POST {GATEWAY_URL}/api/roundtable-jobs mode={MODE}")
resp = await client.post(
f"{GATEWAY_URL}/api/roundtable-jobs",
headers=_headers(),
json={"intent": INTENT, "agents": AGENTS, "orchestrationMode": MODE},
)
if resp.status_code != 201:
print(f"[probe] start failed: HTTP {resp.status_code}: {resp.text[:500]}")
return
job = resp.json()
job_id = job["id"]
print(f"[probe] started job {job_id}")
last = ""
t0 = time.perf_counter()
while True:
await asyncio.sleep(2.0)
r = await client.get(f"{GATEWAY_URL}/api/roundtable-jobs/{job_id}", headers=_headers())
if r.status_code != 200:
print(f"[probe] get failed: HTTP {r.status_code}: {r.text[:300]}")
return
j = r.json()
line = (
f"status={j['status']} phase={j['phase']} cycle={j['cycle']} "
f"active={j.get('activeAgentId')} consensus={j['consensusPercentage']}%"
)
if line != last:
print(f"[probe] +{time.perf_counter() - t0:5.0f}s {line}")
last = line
if j["status"] in TERMINAL:
print(f"[probe] terminal: {j['status']}")
if j["status"] == "error":
print(f"[probe] error detail: {j.get('error')}")
# 读草稿 step3 验证报告已落库
if j.get("draftId"):
dr = await client.get(
f"{GATEWAY_URL}/api/roundtable-drafts/{j['draftId']}", headers=_headers()
)
if dr.status_code == 200:
step3 = dr.json().get("step3")
html_len = len((step3 or {}).get("html") or "")
print(f"[probe] draft.step3 html length = {html_len}")
return
if __name__ == "__main__":
asyncio.run(main())