diff --git a/frontend-web/docs/multi-agent-backend-dev.md b/frontend-web/docs/multi-agent-backend-dev.md index dcfcc807..e759d19d 100644 --- a/frontend-web/docs/multi-agent-backend-dev.md +++ b/frontend-web/docs/multi-agent-backend-dev.md @@ -10,14 +10,16 @@ ## 0. 速读 -- 三个圆桌 Router(`intent / recommend / multi_agent`)**全部走 HTTP loopback**:在同进程内通过 `httpx.AsyncClient` 调本机 `127.0.0.1` 的 `/api/threads`、`/api/agents`、`/api/threads/{id}/runs/stream`。 -- **所有 loopback `httpx.AsyncClient` 必须 `trust_env=False`**。开发机若设了 `HTTP_PROXY` / `HTTPS_PROXY`,httpx 默认会把 loopback 也走代理 → 不可达 → 单次请求多出 40 秒超时(典型症状日志:`headers_in=42.160s`)。 +- 三个圆桌 Router(`intent / recommend / multi_agent`)**通过 httpx ASGITransport 进程内直调本机 ASGI app**,复用既有 `/api/threads`、`/api/agents`、`/api/threads/{id}/runs/stream` 路由——**不走 TCP / 不经过网络栈**。所有 client 都由 `multi_agent._make_loopback_client(request)` 工厂统一构造。 +- 历史包袱:早期实现走 `httpx.AsyncClient(trust_env=False)` + `127.0.0.1:`,遇到开发机 `HTTP_PROXY` / `HTTPS_PROXY` 时仍可能把回环走代理(症状 `headers_in=42.160s`)。**改用 ASGITransport 后此问题不再存在**,相关 `trust_env=False` workaround 全部移除。 - **席位 agent 持久注册**(磁盘 `.deer-flow/agents/` + `_sync_legacy_agents`),**协调智能体每次新建**(命名 `roundtable-coordinator-{timestamp36}`,DB 累积,需后续 GC)。 +- **`.deer-flow/` 整个目录被 git ignore**(仓库根 `.gitignore` 第 64 / 74 行),`git clone` 后内置 agent 目录在部署机上**不会存在**——任何环境都必须把 `roundtable-intent` + `roundtable-recommender` 两个**功能型 agent** 持久化进发布物,否则 `/api/intent/stream` 报 500。**互联网 demo 还需要 6 个示例研讨席位**(`roundtable-intelligence` 等),**内网部署用客户自有 agent 替代**这些席位,无需打包。详见 §11.7。 - **`init` 是 SSE 而非一次性 JSON**:用 `asyncio.as_completed` 按真实完成顺序推送 `seat_ready`,前端因此能逐个点亮席位。 - **`init_done.thread_ids` 包含协调智能体自身**(`agent_threads[main_agent_name] = coord_thread`),前端 Step 3 列文件时需自行过滤。 - **澄清协议统一**:Step 1 与 Step 2 leader 都借 `ask_clarification` + 终态 `clarification` 帧驱动同一套选项卡片组件;`allow_multiple` 默认 false(单选、点击即发送)。 - **leader 共识 = `status: []` 空数组**;澄清 = `status: "clarification"`(字符串);派活 = `status: [[name, task], ...]`。这三者**互斥不混用**。 - **后端 picks clamp 2–6,前端用户可手动加到 8**。 +- **不写死任何具体模型名**:`intent / recommend / multi_agent` 三个 router 的 `model_name = str(payload.get("model") or "")`,空串会被下游 `lead_agent._resolve_model_name()` 自动回退到 `config.yaml` 的 `models[0]`(与 `/api/ai-writing` 行为一致)。内网部署只需配 `config.yaml`,前后端均无需改硬编码。 --- @@ -57,7 +59,7 @@ Step 3 最终方案展示 | 2 | 2 | `multi_agent.py` | 协调智能体(每次新建)+ 用户选定的 `roundtable-*` 席位 | | 3 | 3 | `artifacts.py`(通用) | 无新 agent | -所有接口需 `Authorization: Bearer `。Loopback 调用统一 `trust_env=False`,避免本机 `HTTP_PROXY` 把 `127.0.0.1` 指到不可达代理(症状:`headers_in=40s+`,前端表现为「Step 1 init 卡顿 / Step 2 init 永远不发 seat_ready」)。 +所有接口需 `Authorization: Bearer `。Loopback 调用一律由 `_make_loopback_client(request)` 构造(`httpx.ASGITransport(app=request.app)`,base_url 占位 `http://loopback`),**不经 TCP**,因此不再受本机 `HTTP_PROXY` 干扰。 --- @@ -497,7 +499,7 @@ async def _broadcast(client, base, headers, message, agent_threads, skip): (None, captured) — 流结束,captured 是所有 data: 行的字符串列表 ``` -日志 `[upstream-timing] headers_in=<>` / `first_line=<>`:排查首 token 慢、代理导致 loopback 超时(见 `trust_env=False`)。 +日志 `[upstream-timing] headers_in=<>` / `first_line=<>`:排查上游首字节延迟;改 ASGITransport 后通常 `headers_in` < 10ms,长尾大概率出在路由 handler 内部(不再是网络/代理问题)。 `_parse_last_messages(captured)` 从倒数往前扫,**跳过 trailing `data: null` end 帧**,找第一条解析为 dict 且含 `messages` key 的 frame;取 `messages` 列表。无可解析帧抛 `502`。 @@ -591,14 +593,21 @@ Step 3 **没有** 单独的 `multi_agent` 路由;使用既有 artifacts 能力 ## 9. 关键设计决策 -### 9.1 HTTP loopback + `trust_env=False` +### 9.1 ASGITransport 进程内 loopback(替代旧的 HTTP loopback) -三个 router 均通过 `httpx` 调本机 Gateway(`127.0.0.1`),复用鉴权与 LangGraph 路由,代价是额外 HTTP 开销;init 已用 `asyncio.as_completed` 并行化席位准备。 +三个 router 通过 `httpx.ASGITransport(app=request.app)` 直调本机 ASGI app,**不走 TCP / 不开 socket / 不经网络栈**,但仍然完整复用 FastAPI 路由分发、鉴权 middleware、Pydantic 校验、SSE 流式响应。代价仅是路由层的少量 dispatch 开销(< 1ms),相对 LLM run 本身的数百 ms~数十秒可忽略。 -**每个 `httpx.AsyncClient(timeout=None, trust_env=False)` 都是必须的**: +统一入口:`multi_agent._make_loopback_client(request)`,返回: -- `timeout=None` — SSE 长连接需要 -- `trust_env=False` — 否则 httpx 会读取 `HTTP_PROXY` / `HTTPS_PROXY` 环境变量,把 loopback `127.0.0.1` 也走代理。开发机常配的「校园网代理 / 公司代理」会把回环地址丢弃,导致单次请求 40+ 秒超时(日志典型症状:`headers_in=42.160s`) +```python +httpx.AsyncClient( + transport=httpx.ASGITransport(app=request.app), + base_url="http://loopback", # 占位 host,ASGITransport 不在意 + timeout=None, # SSE 长连接仍需 None +) +``` + +**已废止的 `trust_env=False` workaround**:旧版本走 `127.0.0.1:` 真实 HTTP,需要 `trust_env=False` 阻止 httpx 读 `HTTP_PROXY` 把回环也走代理(症状日志 `headers_in=42.160s`)。改 ASGITransport 后这个问题在物理上已不存在——根本没走网络栈。 ### 9.2 协调智能体每次新建 @@ -664,12 +673,249 @@ grep "special-timing" .runtime/logs/*.log # payload_built / first_upstream_l `model` 写入 run 的 `context.model_name`;前端可按席位覆盖 special 调用的模型(见前端文档 §5.5 Persona 抽屉)。 +**绝不在路由里硬编码具体模型名**。三个 router 一律 `str(payload.get("model") or "")`: + +- 前端不传 / 传空串 → 下游 `lead_agent._resolve_model_name("")` 命中 falsy 分支,返回 `app_config.models[0].name`(`config.yaml` 配置顺序里的第一项) +- 前端传了一个不在 `config.yaml` 里的 model 名 → 同样回退到首项,并 `logger.warning("Model 'x' not found ... fallback to ...")` +- 前端传了合法 model 名 → 直接使用 + +这是 `/api/ai-writing` 与圆桌三路由的统一行为,让本地 / 内网部署只需要改 `config.yaml`,不需要触前端常量或路由代码。详见 `deerflow/agents/lead_agent/agent.py:_resolve_model_name`。 + ### 11.3 新增席位 1. `.deer-flow/agents/roundtable-xxx/` 增加 `config.yaml` + `SOUL.md` 2. 前端候选池 / 推荐逻辑纳入该 id 3. Step 2 `agent_names` 传入即可 — **multi_agent 路由无需改** +### 11.7 内置 agent 部署持久化(必读,关系到内网部署能否启动) + +#### 11.7.1 问题背景 + +圆桌功能涉及的 agent 都靠**磁盘文件**激活(`.deer-flow/agents//{config.yaml,SOUL.md}`),但仓库根 `.gitignore` 第 64 / 74 行有: + +``` +offline-backend-20260512/backend/.deer-flow/* +``` + +整个 `.deer-flow/` 被 git 排除。新机器 `git clone` 后 `.deer-flow/agents/` 不存在 → 圆桌相关 agent 缺失 → 进入 `/api/intent/init` 后 `_create_thread` 本身能成功,但下一步 `/api/intent/stream` 真正调用 LangGraph 时会因为 agent 解析失败抛 500(典型表现:前端 Step 1 init 一次成功,下一句话发出去就 500)。 + +#### 11.7.1.1 哪些 agent **必须**打包,哪些**不需要** + +圆桌涉及的 agent 分**两类**,部署侧需求完全不同: + +| Agent | 角色 | 是否必须打包发布 | +|-------|------|------------------| +| `roundtable-intent` | **功能型**:Step 1 任务理解,所有部署都用它 | ✅ **必须**(任何环境) | +| `roundtable-recommender` | **功能型**:Step 1→2 之间从候选池里挑推荐,所有部署都用它 | ✅ **必须**(任何环境) | +| `roundtable-intelligence` | 示例研讨席位(情报收集) | ⚠️ **互联网 demo 用**,内网不需要 | +| `roundtable-environment` | 示例研讨席位(环境评估) | ⚠️ 同上 | +| `roundtable-solution-design` | 示例研讨席位(方案设计) | ⚠️ 同上 | +| `roundtable-risk-review` | 示例研讨席位(风险审查) | ⚠️ 同上 | +| `roundtable-execution-plan` | 示例研讨席位(执行规划) | ⚠️ 同上 | +| `roundtable-summary` | 示例研讨席位(总结陈述) | ⚠️ 同上 | +| `roundtable-coordinator-*` | 协调智能体壳,每次会话动态新建 | ❌ **不打包**(运行时生成) | + +**关键区分**: +- `roundtable-intent` / `roundtable-recommender` 是**圆桌功能本身的一部分**——没有它们,整个 Step 1 + 推荐流程跑不起来,跟客户业务无关 +- 6 个 `roundtable-<领域>` 席位只是**互联网 demo 用的示例研讨者**,让访客打开页面就能看到完整三步流程。内网部署面对的是客户自己的业务场景,会把客户自有的领域 agent(行业专家、合规审查员、业务规则引擎……)注册成候选 agent;这时 6 个示例席位反而成噪声 + +**对应的部署组合**: + +| 部署场景 | 打包内容 | +|----------|----------| +| **互联网 demo** | `roundtable-intent` + `roundtable-recommender` + **6 个示例席位** | +| **内网客户部署** | `roundtable-intent` + `roundtable-recommender`(仅此 2 个,**不带** 6 个示例席位) | +| **本地开发** | 全要(方便快速跑通) | + +**内网部署的额外注意**(去掉 6 个示例席位时): + +1. **客户自有的候选 agent 必须先存在**:内网部署落地时,客户的业务 agent 应该已经注册进 `.deer-flow/agents/`(手工建目录或通过 `POST /api/agents` 创建)。否则候选池为空,推荐拿不到任何 picks。 +2. **前端 `FALLBACK_AGENT_IDS` 需要适配**:前端 `RecommendAgentsDialog.tsx` 里硬编码了 6 个示例席位 id 作为 fallback;若推荐失败回退路径会拿这 6 个去 `candidates` 里交集,内网部署该交集恒为空,**fallback 会失败**。建议要么: + - **方案 1**(最稳):内网客户的候选池足够丰富 + recommender SOUL 调好,让推荐成功率 ~100%,不依赖 fallback + - **方案 2**:把 `FALLBACK_AGENT_IDS` 改成从后端 `/api/agents` 取前 6 个 `published=true` 的候选 agent(动态 fallback)。改动点见前端文档 §6.2 `RecommendAgentsDialog` +3. **推荐 SOUL(`roundtable-recommender/SOUL.md`)通常无需改**:SOUL 本身没写死候选 id,只描述「从给的候选池里挑」;候选池由前端传入,模型读到客户自有 agent 后会照样工作。 + +下文给出三种修复方案,**推荐方案 A**(最简单,无需写代码)。 + +#### 11.7.2 方案 A:`.gitignore` 白名单(推荐) + +让 git 只跟踪 **`roundtable-intent` + `roundtable-recommender` 两个功能型 agent** 的 `config.yaml` 与 `SOUL.md`,其它一律忽略。6 个示例席位**不进白名单**——互联网 demo 部署机如果需要它们,按 §11.7.2.A 在该分支单独追加白名单;内网部署机直接享受「干净的 `.deer-flow/`」。 + +**步骤 1:编辑仓库根 `.gitignore`** + +在最后追加白名单段(位置很重要,**必须在 `offline-backend-20260512/backend/.deer-flow/*` 之后**,否则被前面的通配符吃掉): + +```gitignore +# === 圆桌功能型 agent 跟随仓库发布(任何部署都需要) === +# 父目录先 unignore,git 才能往下递归 +!offline-backend-20260512/backend/.deer-flow/ +!offline-backend-20260512/backend/.deer-flow/agents/ +# 仅放行 2 个功能型 agent;6 个示例席位由互联网分支自行放行 +!offline-backend-20260512/backend/.deer-flow/agents/roundtable-intent/ +!offline-backend-20260512/backend/.deer-flow/agents/roundtable-intent/config.yaml +!offline-backend-20260512/backend/.deer-flow/agents/roundtable-intent/SOUL.md +!offline-backend-20260512/backend/.deer-flow/agents/roundtable-recommender/ +!offline-backend-20260512/backend/.deer-flow/agents/roundtable-recommender/config.yaml +!offline-backend-20260512/backend/.deer-flow/agents/roundtable-recommender/SOUL.md +``` + +⚠️ **不要**直接写 `!offline-backend-20260512/backend/.deer-flow/agents/roundtable-intent/**`——`git` 不会进入还在 ignore 的父目录,必须逐层 un-ignore,再精确放行文件。 + +**步骤 2:强制 add 并提交** + +```bash +# 验证白名单生效 +git check-ignore -v offline-backend-20260512/backend/.deer-flow/agents/roundtable-intent/config.yaml +# 期望输出无任何匹配,或最后一条匹配是上面的 ! 行 + +# 加入索引(只加 2 个功能型 agent) +git add offline-backend-20260512/backend/.deer-flow/agents/roundtable-intent +git add offline-backend-20260512/backend/.deer-flow/agents/roundtable-recommender + +git status # 应只看到 2 × 2 = 4 个新文件,绝不出现 users/、threads/、data/、6 个示例席位 + +git commit -m "chore: 把圆桌功能型 agent(intent + recommender)纳入版本管理" +``` + +**步骤 3:部署机验证** + +```bash +# clone 之后立刻 +ls offline-backend-20260512/backend/.deer-flow/agents/ +# 内网部署:应看到 roundtable-intent/ + roundtable-recommender/(+ 客户自有 agent) +# 互联网 demo:还应看到 6 个 roundtable-<领域>/ 示例席位 +``` + +启动后端,前端打开圆桌规划页,Step 1 第一句话发出去能正常拿到 streaming 响应而不是 500,就说明 OK。 + +##### 11.7.2.A 互联网 demo 分支:追加 6 个示例席位 + +互联网 demo 维护一条独立分支(或独立 `.gitignore` 段,用 build flag 切),在白名单后追加: + +```gitignore +# === 仅互联网 demo 需要的 6 个示例研讨席位 === +!offline-backend-20260512/backend/.deer-flow/agents/roundtable-intelligence/ +!offline-backend-20260512/backend/.deer-flow/agents/roundtable-intelligence/config.yaml +!offline-backend-20260512/backend/.deer-flow/agents/roundtable-intelligence/SOUL.md +!offline-backend-20260512/backend/.deer-flow/agents/roundtable-environment/ +!offline-backend-20260512/backend/.deer-flow/agents/roundtable-environment/config.yaml +!offline-backend-20260512/backend/.deer-flow/agents/roundtable-environment/SOUL.md +!offline-backend-20260512/backend/.deer-flow/agents/roundtable-solution-design/ +!offline-backend-20260512/backend/.deer-flow/agents/roundtable-solution-design/config.yaml +!offline-backend-20260512/backend/.deer-flow/agents/roundtable-solution-design/SOUL.md +!offline-backend-20260512/backend/.deer-flow/agents/roundtable-risk-review/ +!offline-backend-20260512/backend/.deer-flow/agents/roundtable-risk-review/config.yaml +!offline-backend-20260512/backend/.deer-flow/agents/roundtable-risk-review/SOUL.md +!offline-backend-20260512/backend/.deer-flow/agents/roundtable-execution-plan/ +!offline-backend-20260512/backend/.deer-flow/agents/roundtable-execution-plan/config.yaml +!offline-backend-20260512/backend/.deer-flow/agents/roundtable-execution-plan/SOUL.md +!offline-backend-20260512/backend/.deer-flow/agents/roundtable-summary/ +!offline-backend-20260512/backend/.deer-flow/agents/roundtable-summary/config.yaml +!offline-backend-20260512/backend/.deer-flow/agents/roundtable-summary/SOUL.md +``` + +`git add` 对应 6 个目录、提交,互联网分支即拿到全部 8 个内置 agent。两条分支共享同一份 `roundtable-intent / roundtable-recommender`,SOUL 调整对两边都生效,避免双维护。 + +**优点**: + +- 零代码改动;新机器 `git clone` 即可,部署文档不用单独说「记得拷一下 `.deer-flow/agents/`」 +- 修改 SOUL 直接走 git 走 review 流程 +- 多人协作时 SOUL 改动可见 + +**注意事项**: + +- `git status` 检查时确保**只**有 16 个内置文件进了索引,**绝对不能**包括 `users/`、`threads/`、`data/*.db`、`roundtable-coordinator-*/` 这些运行时数据 +- `roundtable-coordinator-mp*/` 这类历史会话残留目录会被 ignore 规则自动忽略(白名单没放行它们),无需手动清理 +- 部署机如果之前手动建过 `.deer-flow/agents/roundtable-intent/`,`git pull` 会把仓库版本覆盖上去——这是期望行为 + +#### 11.7.3 方案 B:源码内置 + 启动时种子化 + +如果不想让 `.deer-flow/` 这种「运行时目录」出现在 git 里,可以把 SOUL/config 放进 `packages/harness/` 里作为只读资源,启动时检查 `.deer-flow/agents//` 不存在就从源拷过去。 + +**步骤大纲**: + +1. 在 `packages/harness/deerflow/agents/builtin_personas/` 下建 8 个目录(`roundtable-intent/` 等),把 SOUL.md + config.yaml 搬过去 +2. 在 `app/gateway/app.py` 的 `lifespan` 启动阶段新增 `_seed_builtin_roundtable_agents()`: + ```python + def _seed_builtin_roundtable_agents() -> None: + from importlib.resources import files + src_root = files("deerflow.agents.builtin_personas") + dst_root = get_paths().agents_dir() # .deer-flow/agents/ + for agent_id in BUILTIN_ROUNDTABLE_AGENT_IDS: # 8 个常量 + dst = dst_root / agent_id + if dst.exists(): + continue # 已有(可能是用户改过的),不覆盖 + dst.mkdir(parents=True) + for fname in ("config.yaml", "SOUL.md"): + (dst / fname).write_bytes((src_root / agent_id / fname).read_bytes()) + ``` +3. 紧接着调一次 `_sync_legacy_agents(store)`,把这些刚落地的目录 upsert 进 `agents` 表 +4. 把 `BUILTIN_ROUNDTABLE_AGENT_IDS` 常量也导出给 `recommend.py` / `multi_agent.py` 共享 + +**优点**: + +- `.deer-flow/` 仍然作为「运行时数据目录」语义干净 +- 运维可以手动改 `.deer-flow/agents/roundtable-intent/SOUL.md` 临时调试,重启不被覆盖(`if dst.exists(): continue`) +- 升级时如果想强制刷新,加个 `--force-reseed-builtins` 启动参数即可 + +**缺点**: + +- 多一段 bootstrap 代码 + 一份 `pyproject.toml` 的 `package-data` 声明(确保 SOUL.md 跟着 wheel 一起发布) +- 「现场改 SOUL 不被覆盖」既是优点也是坑——A 改了 SOUL 但忘了同步源码,下次新机器部署又回到旧版本 + +#### 11.7.4 方案 C:纯运维流程(最弱,不推荐) + +不动代码、不动 .gitignore,发布说明里写一行: + +> 部署前请将 `<开发机>:offline-backend-20260512/backend/.deer-flow/agents/` 整个目录 `scp` 到部署机同位置。 + +只在「不允许改仓库结构」或「初期试跑」时用。长期维护几乎一定会有人忘记拷而部署失败。 + +#### 11.7.5 方案选择决策 + +| 场景 | 推荐方案 | +|------|----------| +| 单仓库多人协作、SOUL 经常调整 | **A**(.gitignore 白名单) | +| SOUL 极少改,希望严格区分代码与运行时数据 | **B**(源码 + lifespan 种子化) | +| 一次性 POC / 即将切换到 B | C | + +无论选哪种,部署机第一次启动后都建议跑这条 smoke test 验证: + +```bash +curl -X POST http://127.0.0.1:8001/api/intent/init \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{}' +# 期望: 200 + {"status":"success","agent_id":"roundtable-intent","thread_id":"..."} + +curl -X POST http://127.0.0.1:8001/api/intent/stream \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"thread_id":"<上一步返回的>","message":"测试一下"}' +# 期望: 200 + SSE 长连接,最后一帧 status 为 asking 或 clarification +# 若 500 → 看后端日志,大概率是 SOUL.md 找不到或 LLM 外网不通(看上一条排查项) +``` + +#### 11.7.6 推荐智能体(`roundtable-recommender`)与研讨席位的关系(注:完整定制流程见 §11.8) + +推荐路由 `recommend.py` 用到的 `roundtable-recommender` 与「研讨席位」是**两件事**: + +- **`roundtable-recommender`**:负责从候选池里挑 picks 的「推荐者」本身,是个 LLM agent,**功能型**,所有部署必须有 +- **研讨席位**(无论是 6 个示例还是客户自有 agent):是「被挑选的候选」,前端 `filterRecommendCandidates` 过滤后送进 `/api/recommend/stream` 的 `candidates` 数组;recommender 在 SOUL 里看到候选池后才能输出 `[RECOMMEND_READY]` JSON + +「推荐改成动态」改的是**从池子里挑哪几个**,不是「池子可以没有」。**池子的来源在两类部署里不同**: + +| 部署 | 候选池来源 | +|------|------------| +| 互联网 demo | 仓库自带的 6 个 `roundtable-<领域>` 示例席位(+ 用户自建的) | +| 内网客户 | 客户自有的业务 agent(通过 `POST /api/agents` 或 `.deer-flow/agents//` 注册)| + +`roundtable-recommender/SOUL.md` 本身没写死任何候选 id(只描述「从给的候选池里挑」),所以内网部署即使候选池从「6 个示例」换成「客户自有 agent」,**recommender 的 SOUL 无需改动**,候选池由前端传入。 + +**前端 fallback 行为差异**: + +互联网 demo 因为有这 6 个内置示例席位垫底,推荐失败时 `FALLBACK_AGENT_IDS ∩ candidates` 还能拿到回退选项;**内网部署该交集恒为空**,回退路径失效——所以内网部署应通过「候选池足够丰富 + recommender SOUL 调好」让推荐成功率接近 100%,或改造前端 `FALLBACK_AGENT_IDS` 为动态从 `/api/agents` 取前 N 个(见前端文档 §6.2)。 + ### 11.4 新增 Step 1 工具 若需新工具,须同步修改 `intent.py` 的 `excluded_tools` 黑名单(当前默认禁 15 个工具,仅 `ask_clarification` 实际可用)。 @@ -682,6 +928,181 @@ grep "special-timing" .runtime/logs/*.log # payload_built / first_upstream_l `_append_thread_message` 通过 `GET state → mutate → POST state` 实现,是为了与 LangGraph checkpoint 一致;如果将来 Gateway 提供 `POST /api/threads/{id}/messages/append` 这类原子接口,可换上,降低并发竞争(虽然圆桌当前不会并发广播同一 thread)。 +### 11.8 添加 / 替换任务推荐智能体(`roundtable-recommender`) + +推荐智能体 id 在后端是**硬编码**常量(`recommend.py:63` → `RECOMMENDER_AGENT_ID = "roundtable-recommender"`),路由不接收 `agent_id` 参数。因此「添加推荐智能体」**不是新增一个 id**,而是落地这个固定 id 对应的文件(首次部署)或**就地改 SOUL**(定制行为)。 + +#### 11.8.1 何时需要动它 + +| 场景 | 是否需要动 SOUL | +|------|-----------------| +| 全新部署,目录还不存在 | ✅ 必须先把目录搭起来(§11.8.2) | +| 候选池从 6 个互联网示例换成客户自有 agent | ❌ **不用改**——SOUL 没写死候选 id,候选池由前端传入 | +| 想改变推荐风格(更激进 / 更保守 / 偏好某领域) | ✅ 改 SOUL(§11.8.4) | +| 想让模型偏好你新加的领域 agent | ⚠️ **优先**改新 agent 的 `description`(候选池 prompt 用的就是它),其次才改 recommender SOUL | +| 想改输出 JSON 格式 | ❌ **不要**——后端正则 `_RECOMMEND_READY_PATTERN` 写死了 `[RECOMMEND_READY]` + ```json ``` 块;改 SOUL 输出会让后端解析失败走 fallback | + +#### 11.8.2 标准目录结构 + +``` +.deer-flow/agents/roundtable-recommender/ +├── config.yaml # id / name / description(只是 DB 元数据,不进 prompt) +└── SOUL.md # 行为契约,详见 11.8.4 +``` + +**`config.yaml` 模板**(**id 必须严格等于 `roundtable-recommender`**,否则 `recommend.py` 找不到): + +```yaml +description: 智能体推荐专员,基于用户的任务目标与约束,从候选智能体池中挑选最相关的 2-6 个智能体并给出推荐理由,为多智能体圆桌研讨准备最终参会名单 +id: roundtable-recommender +name: 智能体推荐 +``` + +字段说明: +- `id`:**不可改**,与 `RECOMMENDER_AGENT_ID` 常量绑死 +- `name`:DB 显示名,候选池过滤时不参与(`filterRecommendCandidates` 按 id 排除) +- `description`:DB 元数据,不进 prompt;改了不会影响推荐效果 + +**SOUL.md** 内容见 §11.8.4 模板。新增之后按 §11.7.2 把这两个文件加入 git 白名单 + commit 即可。 + +#### 11.8.3 后端给推荐智能体喂的 prompt 长什么样 + +`recommend.py:_build_prompt()` 把前端传的 intent + candidates 拼成下面这段,作为**唯一一条 user message** 投递: + +``` +任务目标:对生产系统进行安全改造,满足等保 2.0 合规要求 +约束:预算 ≤ 500 万元; 工期 ≤ 6 个月; 必须满足等保 2.0 +关键假设:现有系统具备基础数字化能力; 允许预定窗口停机 + +候选智能体池: +- roundtable-intelligence (情报收集): 检索同类历史案例、行业最佳实践与外部情报 +- roundtable-solution-design (方案设计): 产出主备方案与关键路径 +- my-cost-calc (成本测算): 基于预算和资源单价计算总成本 +- ... + +请按 SOUL 中规定的格式输出推荐结果(自然语言思路 + [RECOMMEND_READY] + json 块)。 +``` + +**关键事实**: +- **候选 agent 的 `name` 与 `description` 在 prompt 里就是它的「介绍」**——这是模型判断「这个 agent 适不适合本任务」的全部信息。客户自有 agent 想被推荐准确,`description` 必须写**任务导向**的能力描述,不要写 "由 XX 团队维护" 这种元信息 +- **候选池由前端过滤后传入**,后端不读 DB;前端 `filterRecommendCandidates` 已剔除 `roundtable-intent / roundtable-recommender / roundtable-coordinator-*` +- SOUL 看不到「intent agent 上一轮说了什么 / 用户原始 message」,只看到结构化的 `objective / constraints / assumptions`——这是设计选择,让推荐稳定 + +#### 11.8.4 SOUL 设计要点(模板 + 注解) + +SOUL **必须**满足三件事,其它都可定制: + +1. **输出格式严格**:自然语言思路 → `[RECOMMEND_READY]` 单独成行 → ```json {"picks": [{"agent_id": "...", "reason": "..."}, ...]} ``` +2. **`agent_id` 严格来自候选池**:模型造词出来会被 `_validate_picks` 过滤掉 +3. **2 ≤ picks ≤ 6**:少于 2 → 整轮判 `asking` 走 fallback;多于 6 → 后端截断 + +下面是一份可直接用的最小 SOUL,重点行带注释(**真正落盘时去掉 ``**): + +```markdown +# 智能体推荐专员 + +## 角色定位 +你是多智能体圆桌研讨的"智能体推荐专员"。任务理解专员已经把用户需求整理成 +「任务目标 + 约束 + 关键假设」交给你,你的工作是从一个**候选智能体池**里, +挑出**最适合**参加这次圆桌研讨的 2-6 个智能体,并为每一位写一句推荐理由。 + +## 核心职责 +- 仔细阅读用户的任务目标与约束 +- 浏览候选池中每个智能体的 `name` 与 `description` +- 选出与本次任务**最相关**的 2-6 个智能体(数量动态,不必凑齐 6 个) +- 为每个推荐的智能体写一句**具体、可验证**的推荐理由 +- 不推荐重复职能 / 不推荐与任务明显无关的智能体(宁缺毋滥) + +## 数量约束 +- **下限**:至少 2 个 / **上限**:最多 6 个 +- 候选池不足 2 个时,有几个推几个 + +## 输出协议(后端用正则识别,严格执行) +1. 先用自然语言简短说明推荐思路(1-3 句话) +2. 输出标记 `[RECOMMEND_READY]` 单独成行 +3. 紧接一个 ```json 代码块: + + ```json + { + "picks": [ + {"agent_id": "<候选池中的 id>", "reason": "<一句推荐理由>"}, + ... + ] + } + ``` + +格式注意: +- `[RECOMMEND_READY]` **单独成行**,前后无其他字符 +- `agent_id` **严格**来自候选池,不要造词 +- `reason` **具体**,不要"很合适"这种空话 + +## 边界 +- 不调用任何工具(已被后端 excluded_tools 屏蔽) +- 不评价候选 agent 优劣,只判断"是否适合本次任务" +- 不发起对话、不反问用户 + +## 示例 + +``` + +##### 定制方向 + +- **偏好新加的 agent**:在「示例」里把它列进候选池并出现在推荐结果中,模型会学到这种 id 也可推荐。比单纯在「核心职责」里加文字约束有效 +- **行业偏向**:在「角色定位」加一句「优先考虑能直接对接 <行业> 合规要求的智能体」 +- **激进 vs 保守**:调「数量约束」——例如「能挑出强相关的 3 个就不要凑 6 个」会让 picks 缩到 3-4 个 +- **不要做的事**: + - ❌ 改 `[RECOMMEND_READY]` 为别的标记 → 解析失败 + - ❌ 把 JSON 结构改成 `{"recommendations": [...]}` 之类 → 解析失败 + - ❌ 让模型只输出 JSON 不带前面的自然语言 → 前端 `parseRecommendStreamContent` 把整段当 `rationale`,弹窗顶部为空白 + +#### 11.8.5 验证流程 + +部署完成后跑 smoke test 确认推荐路径通: + +```bash +# 1. 确认 agent 文件落地 +ls .deer-flow/agents/roundtable-recommender/ +# 期望: SOUL.md config.yaml + +# 2. 确认 DB 已 upsert(任意 /api/agents 请求会触发 _sync_legacy_agents) +curl -H "Authorization: Bearer " http://127.0.0.1:8001/api/agents | \ + python -c "import sys,json; ids=[a['id'] for a in json.load(sys.stdin).get('agents',[])]; print('roundtable-recommender' in ids)" +# 期望: True + +# 3. 触发一次推荐(用最小 intent + 候选池) +curl -X POST http://127.0.0.1:8001/api/recommend/stream \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "intent": { + "objective": "测试推荐链路", + "constraints": ["响应时间 < 30s"], + "assumptions": [] + }, + "candidates": [ + {"agent_id": "agent-a", "name": "A", "description": "做 X"}, + {"agent_id": "agent-b", "name": "B", "description": "做 Y"}, + {"agent_id": "agent-c", "name": "C", "description": "做 Z"} + ] + }' +# 期望(SSE 流):最后一帧 data: {"status":"done","content":"...","picks":[...]} +# 若 picks 是空 → SOUL 没产生合法 [RECOMMEND_READY] / agent_id 不在候选池 +# 若整轮 status=error → 看后端 _diagnose_exception 输出的 hint +``` + +#### 11.8.6 故障排查清单 + +| 现象 | 排查方向 | +|------|----------| +| 推荐永远走 fallback(status=asking) | 后端日志 grep `recommend stream`,看 `last_ai_content` 是不是不含 `[RECOMMEND_READY]`——SOUL 没遵守协议 | +| `picks` 数量被截断到 6 | 后端 `_MAX_PICKS=6` 故意限制,前端用户可再手加到 8 | +| 模型推荐了候选池里没有的 id | `_validate_picks` 会丢掉,但若有效项不足 2 个会整轮走 fallback;改 SOUL 强调「严格来自候选池」 | +| 推荐结果不偏向客户自有 agent | 先检查该 agent 的 `description` 是不是写得「任务导向」;再考虑在 SOUL 示例里加它 | +| 推荐稳定性差(同 intent 重跑结果飘) | 推荐 model 用低 temperature(在 `roundtable-recommender/config.yaml` 加 `model: <某个低 temp 模型>` 或直接前端选 deterministic 模型) | +| 前端弹窗顶部 rationale 区空白 | SOUL 让模型只输出 JSON 没有自然语言;恢复成「先思路 + 后 JSON」 | + --- ## 12. 故障排查 @@ -691,7 +1112,7 @@ grep "special-timing" .runtime/logs/*.log # payload_built / first_upstream_l | Step 1 一直有澄清、无法进入 Step 2 | 未产出 `[INTENT_READY]` 或终态被 `clarification` 覆盖 | 看 SOUL、最后一帧 `status`;确认 intent 先扫 READY(§4.3 顺序) | | 选项全是多选 | 旧前端默认 `allow_multiple !== false` | 确认 `allow_multiple` 字段与 `resolve_allow_multiple`(缺省 false) | | Step 2 init SSE `error` | 协调 agent 创建失败 / agent_orchestration 缺失 / `_validate_agent_id` 命中 | Network + 后端 `multi_agent` 日志 | -| Step 2 init 完全卡住 ~40 秒 | `httpx` 走了 `HTTP_PROXY` | 确认 `trust_env=False` 仍在;grep `headers_in=` 看时长 | +| Step 2 init 完全卡住(历史症状,ASGITransport 改造后已不应再出现) | 旧版 `httpx` 走 `HTTP_PROXY` 拦回环 → 40s 超时 | 自改 ASGITransport 起此现象物理上不可能复现;若仍卡,看后端日志栈、检查路由 handler 是否阻塞 | | leader 立即 `status: []` | 未调用 orchestration 技能 / SkillStopMiddleware 没拦到 / SOUL 没声明派活 | 换模型或加强 SOUL;检查 `skill_stop_names` 是否包含 `agent_orchestration` | | dispatch 失败但 leader 文本看着正常 | 模型把 `roundtable-x(中文)` 整段塞进 `agent_name` | `_strip_display_suffix` 会兜底;若仍报 400,强化 SOUL 的「严格英文 id」 | | 推荐 picks 为空 | 未输出 `[RECOMMEND_READY]` 或 picks 不在候选池 | 看 recommend 终态帧;检查 `candidate_ids` 集合 | @@ -708,7 +1129,7 @@ grep "dispatch failed" .runtime/logs/*.log grep "upstream-timing" .runtime/logs/*.log grep "leader-timing" .runtime/logs/*.log grep "special-timing" .runtime/logs/*.log -grep "headers_in=" .runtime/logs/*.log # 走代理症状 +grep "headers_in=" .runtime/logs/*.log # 上游首字节延迟;ASGITransport 改造后通常 < 10ms ``` --- diff --git a/frontend-web/docs/multi-agent-frontend-dev.md b/frontend-web/docs/multi-agent-frontend-dev.md index 298536ec..fe1c781d 100644 --- a/frontend-web/docs/multi-agent-frontend-dev.md +++ b/frontend-web/docs/multi-agent-frontend-dev.md @@ -10,7 +10,7 @@ - **整页只有一个文件**:`RoundtablePlanningPage.tsx`(≈ 3 621 行)。三步 UI 通过 `currentStep ∈ {1,2,3}` 切换,不使用 React Router 子路由。 - **不使用 LangGraph SDK**:所有 API 都是自研的 `apiFetch` + `ReadableStream` SSE 解析(见 `api/intent.ts`、`api/recommend.ts`、`api/multi-agent.ts`)。 -- **唯一默认模型**:`DEFAULT_MODEL = "deepseek-chat"`(页面常量,写在 `RoundtablePlanningPage.tsx`)。`useModels()` 只用于 Persona 抽屉的下拉选项。 +- **不写死模型名**:与 `/page/canvas/ai-writing` 同样的策略 —— 全局选中模型由 `selectedModel` state 维护,首次 `useModels()` 完成后初始化为 `availableModels[0].name`,顶栏「模型」下拉可切换。所有 API 调用统一传 `model: selectedModel || undefined`;后端缺省由 `lead_agent._resolve_model_name()` 回退到 `config.yaml` 的 `models[0]`。**内网部署只需在 `config.yaml` 配自家模型即可,前后端均无需改动**。 - **席位区间硬约束**:推荐弹窗要求 **2 ≤ 选中数 ≤ 8**(`MIN_SELECTED` / `MAX_SELECTED`)。 - **总控编排循环上限**:`MAX_CYCLES = 8`,超出后强制停止并 toast。 - **席位派活策略**:**顺序**(不是并行)。旧实现并行触发多路 SSE 会让浏览器卡顿,现已改为顺序。 @@ -124,17 +124,17 @@ frontend-web/src/roundtable-planning/ [进入 Step 3] HighFidelityReport - consensusText ← lastLeaderContent - seatDeliveries ← step2RoundtableDialogues.filter(d => d.confidence === "子智能体交付") - Object.entries(threadIds).filter(name !== coordinatorName).map → listSessionArtifacts(threadId) - 文本类文件 → ArtifactPreviewModal;其它 → window.open(downloadUrl) -``` + consensusText ← lastLeaderContent + seatDeliveries ← step2RoundtableDialogues.filter(d => d.confidence === "子智能体交付") + Object.entries(threadIds).filter(name !== coordinatorName).map → listSessionArtifacts(threadId) + 文本类文件 → ArtifactPreviewModal;其它 → window.open(downloadUrl) + ``` ---- + --- -## 4. API 层 + ## 4. API 层 -### 4.1 `api/intent.ts` — Step 1 + ### 4.1 `api/intent.ts` — Step 1 | 函数 | 说明 | |------|------| @@ -364,13 +364,22 @@ isOrchestrating = false - 抽屉控件: - **System Prompt**:仅前端记录(后端覆写未开放) - **Temp**:仅前端记录 - - **推理模型**:选项来自 `useModels()`,默认 `DEFAULT_MODEL` + - **推理模型**:选项来自 `useModels()`;显示值优先级 `agentConfigs[seat]?.model > selectedModel` - 「参数覆盖并重启动算研讨」按钮 → `handleSaveAgentAndRecalculate`: - 把新的 `(seatId, model)` 写入 `agentConfigs` - 追加一条「⚡ [模型偏好已保存] ...」对话气泡 - - **不**立即重启编排;下一轮该席位被派活时 `runOrchestration` 内会读 `agentConfigs[meta.speakerId]?.model` 作为 special 调用的 `model` 参数 + - **不**立即重启编排;下一轮该席位被派活时 `runOrchestration` 内会读 `agentConfigs[meta.speakerId]?.model || selectedModel || undefined` 作为 special 调用的 `model` 参数 -### 5.6 草稿持久化(`utils/drafts.ts`) +### 5.6 全局模型选择器(右侧 sidebar 顶部) + +- **位置**:右侧 sidebar `currentStep !== 3` 时显示的第一个面板 —— 在「当前意图摘要」**正上方**。Step 3 时整个 sidebar 切换成「最终共识结论 / 风险与预算摘要 / 导出与交付」,所以模型选择器仅在 Step 1 / Step 2 可见(此时也是唯一会发起新模型调用的阶段) +- **state**:`const [selectedModel, setSelectedModel] = useState("")` +- **初始化**:`useEffect(() => ...)` 在 `availableModels` 首次返回后把 `selectedModel` 设为 `availableModels[0].name`;后端配置变化导致当前选中 model 不在列表里时也回退到首项 +- **传参规则**:所有 5 个 API 调用点(`initIntent` / `streamIntent` / `streamRecommend` / `initMultiAgent` / `streamMultiAgent` leader / `streamMultiAgent` special)都传 `selectedModel || undefined`;special 还会先看 `agentConfigs[seat]?.model` per-seat 覆盖 +- **禁用态**:`availableModels.length === 0` 或 `modelsLoading` → `disabled`,placeholder 显示「加载中…」或「未配置模型」 +- **可改换为 Radix Select**:当前用原生 ` setKeywordCountInput(e.target.value)} - onBlur={handleKeywordCountBlur} - disabled={disabled} - /> -

- 素材收集专家用几个关键词检索,越多素材越全面但耗时更长。 -

- -
; + if (typeof obj.code !== "string" || typeof obj.message !== "string") return null; + return { + code: obj.code, + message: obj.message, + exception_type: typeof obj.exception_type === "string" ? obj.exception_type : "", + exception_repr: typeof obj.exception_repr === "string" ? obj.exception_repr : "", + root_type: typeof obj.root_type === "string" ? obj.root_type : null, + root_repr: typeof obj.root_repr === "string" ? obj.root_repr : null, + hint: typeof obj.hint === "string" ? obj.hint : null, + trace_tail: Array.isArray(obj.trace_tail) ? obj.trace_tail.filter((l): l is string => typeof l === "string") : [], + context: typeof obj.context === "string" ? obj.context : null, + }; +} + /** * Create a fresh thread for an intent-clarification session. * The backend returns the built-in agent id so the frontend doesn't hardcode it. + * + * On failure throws `IntentInitError` whose `.detail` carries the backend's + * `_diagnose_exception` payload — the page should surface `detail.hint` / + * `detail.trace_tail` to help diagnose intranet deployment issues without + * shell access to the server. */ export async function initIntent(model?: string): Promise { const res = await apiFetch(`${getBackendBaseURL()}/api/intent/init`, { @@ -32,8 +103,18 @@ export async function initIntent(model?: string): Promise { body: JSON.stringify({ model }), }); if (!res.ok) { - const text = await res.text().catch(() => ""); - throw new Error(`init intent failed (${res.status}): ${text}`); + const raw = await res.text().catch(() => ""); + let detail: IntentErrorDetail | null = null; + try { + const parsed = JSON.parse(raw); + // FastAPI HTTPException(detail=dict) → { "detail": {...} } + if (parsed && typeof parsed === "object" && parsed !== null) { + detail = parseIntentErrorDetail((parsed as { detail?: unknown }).detail) || parseIntentErrorDetail(parsed); + } + } catch { + // body wasn't JSON — fall through with raw text only. + } + throw new IntentInitError(res.status, detail, raw); } return (await res.json()) as IntentInitResponse; } @@ -89,6 +170,12 @@ export interface StreamIntentResult { * cards + custom input instead of treating the turn as plain text only. */ clarification: ClarificationPayload | null; + /** + * Populated only when status === "error" and the backend emitted a + * structured diagnostic (new in `_diagnose_exception`). UI uses this to show + * the same rich panel as init failures. + */ + errorDetail: IntentErrorDetail | null; } /** @@ -106,6 +193,7 @@ function isFinalIntentFrame(value: unknown): value is { options?: unknown[]; allow_custom?: boolean; allow_multiple?: boolean; + error_detail?: unknown; } { if (!value || typeof value !== "object") return false; if (!("status" in value) || !("content" in value)) return false; @@ -192,6 +280,7 @@ export async function streamIntent(req: StreamIntentRequest): Promise selectedModel) +// 内网部署只需在 config.yaml 配自家模型,前后端都不需要再硬编码具体模型名。 type RoleEntry = { id: number; @@ -373,16 +387,24 @@ export default function RoundtablePlanningPage() { // for each of them concurrently instead of only the most recently set one. const [activeSpeakerSet, setActiveSpeakerSet] = useState>(new Set()); - // Custom presets for agent model, weight, temperature and prompt editors + // Custom presets for agent model, weight, temperature and prompt editors. + // model 字段留空,运行时由 runOrchestration 解析为 + // agentConfigs[seat]?.model || selectedModel || undefined + // 即「席位覆盖优先,否则跟随全局选中模型,否则让后端回退到 config.yaml models[0]」。 const [agentConfigs, setAgentConfigs] = useState<{ [key: number]: { model: string; weight: number; prompt: string; temp: number } }>({ - 2: { model: DEFAULT_MODEL, weight: 90, temp: 0.2, prompt: "你负责解析核心任务、资源极限和用户目标偏好,监督全局研商收敛。" }, - 3: { model: DEFAULT_MODEL, weight: 85, temp: 0.3, prompt: "你负责搜集电力、通信系统、历史极端部署条件情报,支持备灾架构体系。" }, - 4: { model: DEFAULT_MODEL, weight: 88, temp: 0.1, prompt: "你评估服务器机房热载荷、算力并发承载极限,预报运行瓶颈与风险时钟。" }, - 5: { model: DEFAULT_MODEL, weight: 92, temp: 0.4, prompt: "你负责双全栈主备建模设计,高内聚低耦合,规避不可预知停机崩溃。" }, - 6: { model: DEFAULT_MODEL, weight: 95, temp: 0.1, prompt: "你作为安全主管,严厉把控预算开销与合规通过率防线,识别计划漏洞。" }, - 7: { model: DEFAULT_MODEL, weight: 80, temp: 0.4, prompt: "你负责对72小时里程碑执行路径规划、重排和资源调配演练序列。" }, + 2: { model: "", weight: 90, temp: 0.2, prompt: "你负责解析核心任务、资源极限和用户目标偏好,监督全局研商收敛。" }, + 3: { model: "", weight: 85, temp: 0.3, prompt: "你负责搜集电力、通信系统、历史极端部署条件情报,支持备灾架构体系。" }, + 4: { model: "", weight: 88, temp: 0.1, prompt: "你评估服务器机房热载荷、算力并发承载极限,预报运行瓶颈与风险时钟。" }, + 5: { model: "", weight: 92, temp: 0.4, prompt: "你负责双全栈主备建模设计,高内聚低耦合,规避不可预知停机崩溃。" }, + 6: { model: "", weight: 95, temp: 0.1, prompt: "你作为安全主管,严厉把控预算开销与合规通过率防线,识别计划漏洞。" }, + 7: { model: "", weight: 80, temp: 0.4, prompt: "你负责对72小时里程碑执行路径规划、重排和资源调配演练序列。" }, }); + // 全局选中模型(对应顶栏「模型」下拉)。 + // 首次 useModels 加载完成后由下面的 useEffect 初始化为 availableModels[0].name, + // 用户可以通过下拉框切换;Persona 抽屉的 per-seat 覆盖更高优先级。 + const [selectedModel, setSelectedModel] = useState(""); + // Message lists state representation to mimic image items & enable actual conversations const [interactiveMessages, setInteractiveMessages] = useState([ { @@ -484,6 +506,8 @@ export default function RoundtablePlanningPage() { const [intentThreadId, setIntentThreadId] = useState(null); const [intentReady, setIntentReady] = useState(null); const [intentInitError, setIntentInitError] = useState(null); + // 后端 _diagnose_exception 输出的结构化诊断,有就额外渲染 hint + trace_tail 面板。 + const [intentInitErrorDetail, setIntentInitErrorDetail] = useState(null); const [isIntentSending, setIsIntentSending] = useState(false); const [pendingIntentClarificationMsgId, setPendingIntentClarificationMsgId] = useState(null); const [intentClarificationSelections, setIntentClarificationSelections] = useState< @@ -561,6 +585,17 @@ export default function RoundtablePlanningPage() { } }, []); + // 首次 /api/models 返回后,把 selectedModel 初始化为列表第一个 —— 与 + // AIWritingForm 的行为对齐。若 selectedModel 已经被用户在下拉框里选过,不再覆盖。 + // 若当前选中的 model 已不在 availableModels 里(后端配置变了),也回退到首项。 + useEffect(() => { + if (availableModels.length === 0) return; + const stillExists = availableModels.some((m) => m.name === selectedModel); + if (!selectedModel || !stillExists) { + setSelectedModel(availableModels[0].name); + } + }, [availableModels, selectedModel]); + // Toast auto-dismiss logic useEffect(() => { if (showToast) { @@ -837,7 +872,7 @@ export default function RoundtablePlanningPage() { dAgentThreadId: ids, agentName: coord, newMessage: nextLeaderTask, - model: DEFAULT_MODEL, + model: selectedModel || undefined, signal: leaderAborter.signal, onTextDelta: (text) => appendToDialogue(leaderBubbleId, text), onPhaseChange: (phase, toolName) => { @@ -918,7 +953,8 @@ export default function RoundtablePlanningPage() { dAgentThreadId: ids, agentName: subName, newMessage: task, - model: perSeatModel || DEFAULT_MODEL, + // 优先级: 席位 Persona 覆盖 → 全局选中模型 → 让后端回退到 config.yaml models[0] + model: perSeatModel || selectedModel || undefined, signal: subAborter.signal, onTextDelta: (text) => appendToDialogue(subBubbleId, text), onPhaseChange: (phase, toolName) => { @@ -1026,7 +1062,7 @@ export default function RoundtablePlanningPage() { const initRes = await initMultiAgent({ agentNames: roundtableAgentIds, mainAgentName: coord, - model: DEFAULT_MODEL, + model: selectedModel || undefined, // As each sub-agent's thread comes online, flip every role that // shares the agentId to "在线". Some seats reuse the same agent // (e.g. seat 8 reuses roundtable-summary) so we match on agentId @@ -1148,7 +1184,9 @@ export default function RoundtablePlanningPage() { const role = roles.find(r => r.id === id); const roleName = role?.name || "专家智能体"; const cfg = agentConfigs[id]; - const targetModel = cfg?.model || DEFAULT_MODEL; + // Persona 抽屉里的「保存」反馈文字: 优先显示用户在抽屉里选的 model, + // 没选时退回全局 selectedModel,最后才显示「默认」二字。 + const targetModel = cfg?.model || selectedModel || "默认模型"; const avatarColor = id === 2 ? "blue" @@ -1191,6 +1229,7 @@ export default function RoundtablePlanningPage() { const myGen = ++step1InitGenRef.current; setIntentReady(null); setIntentInitError(null); + setIntentInitErrorDetail(null); setIntentThreadId(null); setPendingIntentClarificationMsgId(null); setIntentClarificationSelections(new Map()); @@ -1206,7 +1245,9 @@ export default function RoundtablePlanningPage() { ]); (async () => { try { - const res = await initIntent(DEFAULT_MODEL); + // initIntent 当前不在 thread 上落盘 model,只为占位与兼容;空时由后端 + // 走 lead_agent._resolve_model_name 回退到 config.yaml models[0] + const res = await initIntent(selectedModel || undefined); if (step1InitGenRef.current !== myGen) return; setIntentThreadId(res.thread_id); setTimelineEntries([ @@ -1220,7 +1261,14 @@ export default function RoundtablePlanningPage() { } catch (err) { if (step1InitGenRef.current !== myGen) return; console.error("init intent failed:", err); - setIntentInitError((err as Error).message); + if (err instanceof IntentInitError) { + // 优先使用后端的可读 message;若 detail 在,渲染诊断面板。 + setIntentInitError(err.detail?.message || err.message); + setIntentInitErrorDetail(err.detail); + } else { + setIntentInitError((err as Error).message); + setIntentInitErrorDetail(null); + } } })(); }, []); @@ -1384,6 +1432,7 @@ export default function RoundtablePlanningPage() { setIntentThreadId(draft.step1.intentThreadId); setIntentReady(draft.step1.intentReady); setIntentInitError(null); + setIntentInitErrorDetail(null); setInteractiveMessages(draft.step1.interactiveMessages as typeof interactiveMessages); setTimelineEntries(draft.step1.timelineEntries as typeof timelineEntries); step1InitRef.current = true; @@ -1540,7 +1589,7 @@ export default function RoundtablePlanningPage() { const res = await streamIntent({ threadId: intentThreadId, message: trimmed, - model: DEFAULT_MODEL, + model: selectedModel || undefined, signal: aborter.signal, onTextDelta: (delta) => { setInteractiveMessages(prev => @@ -1593,6 +1642,15 @@ export default function RoundtablePlanningPage() { return next; }); } else if (res.status === "error") { + // 同时把诊断信息写到顶部面板:toast 一闪而过,面板能稳定展示 hint + trace_tail + // 方便用户截图发给后端排查。 + if (res.errorDetail) { + setIntentInitError(res.errorDetail.message || res.content); + setIntentInitErrorDetail(res.errorDetail); + } else { + setIntentInitError(res.content); + setIntentInitErrorDetail(null); + } triggerToast(`❌ 智能体调用失败: ${res.content}`); } } catch (err) { @@ -1791,7 +1849,7 @@ export default function RoundtablePlanningPage() { setIsRecommendDialogOpen(false)} onConfirm={handleConfirmAgents} /> @@ -2441,9 +2499,40 @@ export default function RoundtablePlanningPage() {
{intentInitError && ( -

- 智能体初始化失败: {intentInitError} -

+
+
+ 智能体初始化失败 + {intentInitErrorDetail?.code && ( + + {intentInitErrorDetail.code} + + )} + {intentInitErrorDetail?.exception_type && ( + + {intentInitErrorDetail.exception_type} + {intentInitErrorDetail.root_type && intentInitErrorDetail.root_type !== intentInitErrorDetail.exception_type + ? ` → ${intentInitErrorDetail.root_type}` + : ""} + + )} +
+

{intentInitError}

+ {intentInitErrorDetail?.hint && ( +

+ 💡 建议: {intentInitErrorDetail.hint} +

+ )} + {intentInitErrorDetail?.trace_tail && intentInitErrorDetail.trace_tail.length > 0 && ( +
+ + 展开 traceback 末段(共 {intentInitErrorDetail.trace_tail.length} 行) + +
+{intentInitErrorDetail.trace_tail.join("\n")}
+                            
+
+ )} +
)} )} @@ -3091,7 +3180,8 @@ export default function RoundtablePlanningPage() {
setSelectedModel(e.target.value)} + disabled={modelsLoading || availableModels.length === 0} + title={ + modelsLoading + ? "正在加载模型列表…" + : availableModels.length === 0 + ? "未在 config.yaml 中配置任何模型" + : "切换全局推理模型;不影响 Persona 抽屉里已设置的 per-seat 覆盖" + } + className="w-full bg-slate-50/50 border border-slate-100 rounded-xl px-3 py-2 text-xs font-bold text-slate-700 cursor-pointer focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-secondary dark:text-foreground dark:border-border" + > + {availableModels.length === 0 && ( + + )} + {availableModels.map((m) => ( + + ))} + +
+ {/* Panel 1: 当前意图摘要 */}
diff --git a/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed.py b/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed.py new file mode 100644 index 00000000..791206e5 --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed.py @@ -0,0 +1,411 @@ +"""圆桌功能型 agent 的「调用前自检 + 缺失自动补建」工具。 + +适用场景 +-------- +``roundtable-intent`` / ``roundtable-recommender`` 是圆桌 Step 1 + 推荐链路的 +**功能型** 内置 agent,任何部署都必须存在。但仓库根 ``.gitignore`` 把整个 +``.deer-flow/`` 目录排除,新机器 ``git clone`` 之后这两个目录**不会存在**, +直接调 ``/api/intent/stream`` 会因为 agent 解析失败抛 500。 + +本模块在三个路由入口(``intent.init``、``intent.stream``、``recommend.stream``) +被调用,检查磁盘文件是否齐全;缺失则用下方嵌入的标准模板写回去。 +``_sync_legacy_agents`` 会在下一次 ``GET /api/agents`` 时把目录 upsert 进 DB, +所以这里只关心磁盘文件,不直接动 DB。 + +为什么嵌入字符串而不是用 importlib.resources 资源文件 +------------------------------------------------------ +SOUL 体量不大(两个加起来 ~200 行),嵌入到 Python 文件里是**单一可信源**——改 +SOUL 走代码 review、commit 走 git 历史,且不会因为 wheel 打包漏配 +``package-data`` 导致部署机拿不到。 +""" + +from __future__ import annotations + +import logging +from typing import Iterable + +import yaml + +from deerflow.config.paths import get_paths + +logger = logging.getLogger(__name__) + + +INTENT_AGENT_ID = "roundtable-intent" +INTENT_AGENT_NAME = "任务理解" +INTENT_AGENT_DESCRIPTION = ( + "任务理解智能体,通过 2-5 轮简短追问把用户模糊需求转化成结构化的" + "「任务目标+约束+关键假设」,为多智能体圆桌研讨准备输入" +) + +# 与 .deer-flow/agents/roundtable-intent/SOUL.md 保持完全一致; +# 改动请同步两处或以本文件为准 + 删除磁盘旧目录让下一次请求重建。 +INTENT_AGENT_SOUL = """# 任务理解智能体 + +你是多智能体圆桌研讨 **Step 1** 的「任务理解专员」。本提示词定义了 Step 1 的完整流程与边界;若管理员为你配置了 Skill,可结合 Skill 补充领域话术或检查清单,但**不得偏离**下文的工作流、`ask_clarification` 用法与 `[INTENT_READY]` 结束协议。 + +--- + +## 1. 你的目标 + +通过 **2–5 轮**简短对话,把用户模糊的需求整理成结构化输入,供 Step 2 圆桌使用: + +| 字段 | 含义 | +|------|------| +| `objective` | 一句话任务目标 | +| `constraints` | 硬约束列表(预算/时间/合规/范围等,至少 1 条) | +| `assumptions` | 你替用户做的合理假设(可空数组) | + +**优先级**:目标 > 硬约束 > 软偏好。已经清楚的信息不要再问。 + +--- + +## 2. 工作流(必须遵守) + +``` +用户描述任务 + ↓ +信息够不够? + ├─ 不够 / 有歧义 → 调用 ask_clarification(可带 options 卡片) + ↓ +用户回复(可能是「我选择:…」或自由文本) + ↓ +重复 2–5 轮 + ↓ +信息充分 → 自然语言汇总 + [INTENT_READY] + JSON + ↓ +停止追问(本轮及之后都不要再 ask_clarification) +``` + +**关键规则**: +- 澄清阶段 **只** 用 `ask_clarification` 提问;不要用纯文本空问「还有什么补充吗」。 +- 一旦输出 `[INTENT_READY]`,**同一轮对话立即结束**,不要再追加追问或工具调用。 +- **不做方案、不给技术建议、不写文件、不联网、不派活**——那是 Step 2 六个席位的事。 + +--- + +## 3. 唯一工具:`ask_clarification` + +当缺少信息、存在歧义、需要用户拍板时,**必须**调用此工具(不要只在正文里提问)。 + +### 3.1 参数说明 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `question` | ✅ | 一个聚焦的问题,一次只问一个决策点 | +| `clarification_type` | ✅ | 见下表 | +| `context` | 推荐 | 为什么需要用户确认(1 句话) | +| `options` | 有离散选项时必填 | 字符串列表,如 `["≤500万", "500–1000万", "暂不确定"]` | +| `allow_multiple` | 可选 | 默认 **不传 / False = 单选**;仅当用户**确实可同时选多项**时传 `True` | + +### 3.2 `clarification_type` 怎么选 + +| 类型 | 何时使用 | 选项 UI | +|------|----------|---------| +| `missing_info` | 缺预算、时间、范围等关键事实 | 默认单选;多项约束可同时成立时 `allow_multiple=True` | +| `ambiguous_requirement` | 同一句话多种理解 | 默认单选 | +| `approach_choice` | 在若干方案/路径中选一个 | **单选**(点击即发送) | +| `risk_confirmation` | 需要用户明确确认风险 | **单选** | +| `suggestion` | 你有建议,需用户批准 | **单选** | + +### 3.3 选项卡片(前端行为) + +- 你提供 `options` 后,前端会渲染**可点击卡片**。 +- **默认单选**:用户点一项即发送,无需再点「发送」。 +- **多选**(仅 `allow_multiple=True`):用户可多选后点「发送回答」。 +- 用户也可能在自定义输入框里打字;回复常见格式:`我选择:A、B;补充:…`——请据此更新理解并继续流程。 + +### 3.4 调用示例 + +**缺信息 + 单选预算档:** +``` +ask_clarification( + question="这次任务的预算大致在哪个区间?", + clarification_type="missing_info", + context="预算会影响后续圆桌的可行方案范围", + options=["≤ 300 万", "300–800 万", "≥ 800 万", "暂不确定"] +) +``` + +**选部署方式(单选):** +``` +ask_clarification( + question="优先采用哪种部署方式?", + clarification_type="approach_choice", + context="不同部署方式对工期和合规要求差异很大", + options=["仅内网私有化", "混合云", "全公有云"] +) +``` + +**多项硬约束可同时满足(多选):** +``` +ask_clarification( + question="以下哪些约束必须同时满足?(可多选)", + clarification_type="missing_info", + context="我需要确认哪些是硬性底线", + options=["预算上限", "72 小时内交付", "等保三级", "零停机"], + allow_multiple=True +) +``` + +### 3.5 同轮多个独立问题 + +若 **多个互不依赖** 的信息都缺失,可在 **同一轮** 多次调用 `ask_clarification`(每个问题一次调用,各自带 `options`)。 +若后一个问题依赖前一个答案,**只问第一个**,等用户回复后再问下一个。 + +--- + +## 4. 信息充分性判定 + +满足 **全部** 条件即可结束澄清: + +1. 能写出一句明确的 `objective`(用户希望实现 X) +2. `constraints` 至少 1 条硬约束(预算 / 时间 / 合规 / 范围 之一) +3. 没有会妨碍 Step 2 研讨的明显歧义 + +若用户**首条消息**已包含目标 + 约束,**直接汇总**,不要多余追问。 + +--- + +## 5. 结束协议:`[INTENT_READY]` + +信息充分时,回复结构必须是: + +1. **自然语言**向用户复述「我理解的任务是 …」(📋 汇总,让用户 review) +2. **紧接着**输出下方固定标记 + JSON(后端靠此解锁「进入 Step 2」) + +格式要求(**严格**): + +``` +[INTENT_READY] +```json +{ + "objective": "用一句话写出用户最终要做的事", + "constraints": ["预算 ≤ XXX 万", "时间 ≤ XXX 天", ...], + "assumptions": ["假设 1", "假设 2"] +} +``` +``` + +- `[INTENT_READY]` **单独成行**,前后不要夹其他字符 +- 标记后**紧跟** ```json 代码块 +- 字段名必须是 `objective` / `constraints` / `assumptions` +- `constraints` 至少 1 条;`assumptions` 可为 `[]` + +**输出 `[INTENT_READY]` 后禁止再调用 `ask_clarification` 或继续追问。** + +--- + +## 6. 输出风格 + +- 普通澄清轮:不超过 **3 句话**(工具调用前的引导语可更短) +- 提问要**具体**(❓),汇总要**结构化**(✅ 📋) +- 适度 emoji:❓ 提问、✅ 已明确、📋 汇总 + +--- + +## 7. 禁止事项 + +- ❌ `agent_orchestration`、读写文件、bash、联网搜索、memory 等——你无权使用 +- ❌ 输出具体实施方案、架构图、代码 +- ❌ 在已输出 `[INTENT_READY]` 之后继续提问 +- ❌ 用「还有什么要补充的吗」代替 `ask_clarification` + +--- + +## 8. 完整对话示例 + +**用户**:帮我规划一个生产系统安全改造 + +**你**(调用工具): +``` +ask_clarification( + question="预算大致在哪个区间?工期有没有硬性上限?", + clarification_type="missing_info", + context="这两项是后续圆桌排期的基础", + options=["预算 ≤300万 / 6个月内", "预算 300–800万 / 6–12个月", "预算 ≥800万 / 时间较灵活", "暂不确定,需先评估"] +) +``` + +**用户**:我选择:预算 300–800万 / 6–12个月 + +**你**(若还需确认合规): +``` +ask_clarification( + question="是否需要满足特定合规标准?", + clarification_type="missing_info", + options=["等保 2.0", "等保 3.0", "行业监管无特殊要求", "暂不确定"] +) +``` + +**用户**:我选择:等保 2.0 + +**你**(汇总,不再调用工具): +✅ 我理解的任务是:在 300–800 万预算、6–12 个月工期内,对生产系统做安全改造并满足等保 2.0。 + +📋 + +[INTENT_READY] +```json +{ + "objective": "对生产系统进行安全改造,满足等保 2.0 合规要求", + "constraints": ["预算 300–800 万元", "工期 6–12 个月", "必须满足等保 2.0"], + "assumptions": ["允许在预定窗口内安排必要停机", "现有系统具备基础数字化能力"] +} +``` +""" + + +RECOMMENDER_AGENT_ID = "roundtable-recommender" +RECOMMENDER_AGENT_NAME = "智能体推荐" +RECOMMENDER_AGENT_DESCRIPTION = ( + "智能体推荐专员,基于用户的任务目标与约束,从候选智能体池中挑选最相关的 " + "2-6 个智能体并给出推荐理由,为多智能体圆桌研讨准备最终参会名单" +) + +RECOMMENDER_AGENT_SOUL = """# 智能体推荐专员 + +## 角色定位 +你是多智能体圆桌研讨的"智能体推荐专员"。任务理解专员已经把用户需求整理成「任务目标 + 约束 + 关键假设」交给你,你的工作是从一个**候选智能体池**里,挑出**最适合**参加这次圆桌研讨的 2-6 个智能体,并为每一位写一句推荐理由。 + +## 核心职责 +- 仔细阅读用户的任务目标与约束 +- 浏览候选池中每个智能体的 `name` 与 `description` +- 选出与本次任务**最相关**的 2-6 个智能体(数量动态,不必凑齐 6 个) +- 为每个推荐的智能体写一句**具体、可验证**的推荐理由——必须能回答"它能为这次任务做什么" +- 不推荐重复职能的智能体(如果两个智能体能力高度重叠,只选一个) +- 不推荐与任务明显无关的智能体(宁缺毋滥) + +## 数量约束 +- **下限**:至少推荐 2 个(单个智能体谈不上"圆桌") +- **上限**:最多推荐 6 个(超过 6 个会让协调智能体派活混乱、上下文膨胀) +- 候选池不足 2 个时,有几个推几个 + +## 输出协议 +**你必须严格按下述格式输出**(后端用正则识别): + +1. 先用自然语言简短说明你的推荐思路(1-3 句话,让用户能 review) +2. 然后输出标记 `[RECOMMEND_READY]` 单独成行 +3. 紧接一个 ```json 代码块,内容为: + +```json +{ + "picks": [ + {"agent_id": "<候选池中的智能体 id>", "reason": "<一句推荐理由>"}, + ... + ] +} +``` + +格式注意: +- `[RECOMMEND_READY]` 标记必须**单独成行**,前后无其他字符 +- 标记紧接着一个 ```json 代码块 +- `picks` 数组长度严格在 [2, 6] 之间(候选池不足时按实际数量,但不要少于 2 除非候选池本身就 < 2) +- `agent_id` 必须**严格**来自候选池(不要造词) +- `reason` 必须**具体**,不要"很合适"、"很强"这类空话 + +## 输出风格 +- 思路部分简短:用一句话概括你为什么挑这些(例如"按情报→设计→风险→执行的研讨节奏挑选了 4 位") +- 推荐理由要**具体到任务**,例如"负责检索同类合规改造案例,直接对应本次等保 2.0 的对标需求" +- 适度用 emoji:📋 表示开始汇总 + +## 边界 +- 不调用任何工具(不联网、不执行命令、不读写文件) +- 不评价候选智能体的优劣(只判断"是否适合本次任务") +- 不发起对话、不反问用户——你只产出推荐结果 +- 候选池里没有的智能体绝不出现在 `picks` 中 + +## 示例 + +**用户输入**(由后端拼装): +``` +任务目标:对生产系统进行安全改造,满足等保 2.0 合规要求 +约束:预算 ≤ 500 万元;工期 ≤ 6 个月;必须满足等保 2.0 +关键假设:现有系统具备基础数字化能力;允许预定窗口停机 + +候选智能体池: +- roundtable-intelligence (情报收集):检索同类历史案例、行业最佳实践与外部情报 +- roundtable-environment (环境评估):评估硬件容量与系统量化承载力 +- roundtable-solution-design (方案设计):产出主备方案与关键路径 +- roundtable-risk-review (风险审查):合规红线检查与收敛建议 +- roundtable-execution-plan (执行规划):里程碑与资源排布 +- roundtable-summary (总结陈述):归纳冲突标记与盖印 +- my-cost-calc (成本测算):基于预算和资源单价计算总成本 +``` + +**你的输出**: +按"情报→设计→风险→执行"的研讨节奏,挑选 5 位最贴合本次等保 2.0 改造任务的智能体,并加入成本测算覆盖预算硬约束。📋 + +[RECOMMEND_READY] +```json +{ + "picks": [ + {"agent_id": "roundtable-intelligence", "reason": "检索同类等保 2.0 改造案例与监管口径,直接支撑本次合规对标"}, + {"agent_id": "roundtable-solution-design", "reason": "在 6 个月工期约束下产出主备改造方案与关键路径"}, + {"agent_id": "roundtable-risk-review", "reason": "对照等保 2.0 合规红线审查方案,避免后期返工"}, + {"agent_id": "roundtable-execution-plan", "reason": "把改造拆成可在 6 个月内交付的里程碑与资源排布"}, + {"agent_id": "my-cost-calc", "reason": "校验整体方案落在 500 万元预算硬约束内"} + ] +} +``` +""" + + +_BUILTIN_FUNCTIONAL_AGENTS: tuple[tuple[str, str, str, str], ...] = ( + (INTENT_AGENT_ID, INTENT_AGENT_NAME, INTENT_AGENT_DESCRIPTION, INTENT_AGENT_SOUL), + (RECOMMENDER_AGENT_ID, RECOMMENDER_AGENT_NAME, RECOMMENDER_AGENT_DESCRIPTION, RECOMMENDER_AGENT_SOUL), +) + + +def _ensure_one(agent_id: str, name: str, description: str, soul: str) -> bool: + """Create the on-disk files for one functional agent if missing. + + Returns True if files were just created, False if the directory already + existed (and we didn't touch it — respect any local edits). + """ + agent_dir = get_paths().agent_dir(agent_id) + if agent_dir.exists(): + return False + + agent_dir.mkdir(parents=True, exist_ok=True) + + # newline="\n" 强制 LF;否则 Windows 下 open() 把 \n 翻成 \r\n,跨 OS 部署时与 + # Linux 部署机字节级不一致(功能上 YAML/Markdown 都兼容,但 git diff / 哈希对不齐)。 + config_yaml = yaml.dump( + {"id": agent_id, "name": name, "description": description}, + default_flow_style=False, + allow_unicode=True, + ) + (agent_dir / "config.yaml").write_bytes(config_yaml.encode("utf-8")) + (agent_dir / "SOUL.md").write_bytes(soul.encode("utf-8")) + return True + + +def ensure_roundtable_functional_agents() -> list[str]: + """Seed `roundtable-intent` + `roundtable-recommender` if their directories + are missing. Idempotent — fast no-op when both exist. + + Called from the entry of intent/init, intent/stream, and recommend/stream + so the routes self-heal on fresh internal deployments where `.deer-flow/` + is gitignored and the agent directories were never shipped. + + Returns the list of agent ids that were just created (empty when nothing + needed seeding). Errors are logged but never raised — a partial failure + must not block the request; the downstream route will surface its own + actionable error if the agent really cannot be resolved. + """ + created: list[str] = [] + for agent_id, name, description, soul in _BUILTIN_FUNCTIONAL_AGENTS: + try: + if _ensure_one(agent_id, name, description, soul): + created.append(agent_id) + except Exception: + logger.exception("Failed to seed roundtable functional agent '%s'", agent_id) + if created: + logger.info("Seeded missing roundtable functional agents: %s", ", ".join(created)) + return created + + +def required_agent_ids() -> Iterable[str]: + """Stable ids of agents this seeder owns — exposed for tests/diagnostics.""" + return tuple(a[0] for a in _BUILTIN_FUNCTIONAL_AGENTS) diff --git a/offline-backend-20260512/backend/app/gateway/routers/intent.py b/offline-backend-20260512/backend/app/gateway/routers/intent.py index becb393f..7990aed8 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/intent.py +++ b/offline-backend-20260512/backend/app/gateway/routers/intent.py @@ -63,13 +63,16 @@ from fastapi.responses import StreamingResponse # Reuse the building blocks already in multi_agent.py — keeps both routers # identical in connection handling, payload shape, and error mapping. +from app.gateway.routers._roundtable_seed import ensure_roundtable_functional_agents from app.gateway.routers.multi_agent import ( _auth_headers, _create_thread, + _diagnose_exception, _find_dispatch_message, _find_trailing_ai_text, _flatten_content, _loopback_base, + _make_loopback_client, _parse_last_messages, _run_payload, _stream_upstream, @@ -94,8 +97,9 @@ _INTENT_READY_PATTERN = re.compile( async def init_intent(request: Request, payload: dict = Body(...)) -> dict[str, Any]: """创建任务理解会话用的空 thread。 - 请求体:``{ "model": "deepseek-chat" }``(可选,当前 init 未把 model 写入 thread, - 实际模型在 ``/stream`` 的每次 run 里指定)。 + 请求体:``{ "model": "<可选>" }``(可选,当前 init 未把 model 写入 thread, + 实际模型在 ``/stream`` 的每次 run 里指定;缺省由 lead_agent 回退到 + ``config.yaml`` 的 ``models[0]``,前端通常传当前下拉框选中的 model 名)。 响应:: @@ -104,15 +108,29 @@ async def init_intent(request: Request, payload: dict = Body(...)) -> dict[str, 与 ``POST /api/multi-agent/init`` 的区别:不创建协调智能体、不建多席位 thread 映射;agent 本身已通过磁盘 ``.deer-flow/agents/`` 同步注册。 """ + # 调用前自检:内网部署 .deer-flow/ 被 gitignore,首次启动时本 agent 目录可能不存在, + # 缺失则就地用嵌入模板补建,避免下一步 /stream 报 500。 + ensure_roundtable_functional_agents() + base = _loopback_base(request) headers = _auth_headers(request) - # trust_env=False: this client only talks to the same-process gateway at - # 127.0.0.1, so honoring HTTP_PROXY / HTTPS_PROXY env vars (set system-wide - # on the dev machine) makes httpx route loopback calls through an upstream - # proxy that can't reach 127.0.0.1, costing 40+ seconds of timeout per - # request before falling back. Symptom in logs: `headers_in=42.160s`. - async with httpx.AsyncClient(timeout=None, trust_env=False) as client: - thread_id = await _create_thread(client, base, headers) + # TCP loopback 到 127.0.0.1:(详见 multi_agent._make_loopback_client)。 + # 不要在这里替换成 httpx.ASGITransport——曾试过,会整段 buffer SSE 流导致前端 + # 看不到逐 token 增量。 + try: + async with _make_loopback_client(request) as client: + thread_id = await _create_thread(client, base, headers) + except HTTPException: + # 上游已用结构化方式失败,原样传给前端不需要二次包装。 + raise + except Exception as exc: + # 把根因暴露给前端而不是吞成裸 500——内网部署常见的「config.yaml 模型外网不通」 + # / 「内置 agent 目录缺失」/ 「loopback 走代理」都能在前端面板直接看到。 + # FastAPI 把 detail=dict 序列化为 {"detail": {...}};前端 initIntent + # 在 !res.ok 分支里读 detail.hint / trace_tail 渲染诊断面板。 + diagnostic = _diagnose_exception(exc, context="intent_init") + logger.error("intent init failed: %s", diagnostic, exc_info=exc) + raise HTTPException(status_code=500, detail=diagnostic) from exc return { "status": "success", "agent_id": INTENT_AGENT_ID, @@ -138,21 +156,25 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin """ thread_id = str(payload.get("thread_id") or "").strip() message = str(payload.get("message") or "").strip() - model_name = str(payload.get("model") or "deepseek-chat") + # 不写死任何默认模型名:空串会让 lead_agent._resolve_model_name() 回退到 + # config.yaml 的 models[0],与 /api/ai-writing 等其它路由一致,内网部署只配 + # 自己的本地模型即可工作。 + model_name = str(payload.get("model") or "") if not thread_id: raise HTTPException(status_code=400, detail="thread_id is required") if not message: raise HTTPException(status_code=400, detail="message is required") + # 调用前自检:防御性二次检查——若管理员在 init 之后手动删了目录,这里也能自愈。 + ensure_roundtable_functional_agents() + base = _loopback_base(request) headers = _auth_headers(request) async def stream_generator() -> AsyncIterator[str]: - # trust_env=False: same-process loopback to 127.0.0.1 — honoring - # HTTP_PROXY / HTTPS_PROXY env vars (commonly set on dev machines) - # causes httpx to route loopback calls through an unreachable upstream - # proxy and wait ~40 seconds for timeout before falling back. - async with httpx.AsyncClient(timeout=None, trust_env=False) as client: + # ASGITransport:进程内直调本机 ASGI app,**不走 TCP / 不经过网络栈**, + # 因此既无须 trust_env=False 防代理,也不存在 loopback 40s 超时这种坑。 + async with _make_loopback_client(request) as client: try: body = _run_payload( agent_name=INTENT_AGENT_ID, @@ -266,11 +288,40 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin except HTTPException as exc: logger.warning("intent stream HTTPException: %s %s", exc.status_code, exc.detail) - err = {"status": "error", "content": f"[{exc.status_code}] {exc.detail}", "summary": None} + # HTTPException 的 detail 可能本身就是 dict(上游已结构化),也可能是 str。 + # 统一包装成与 _diagnose_exception 同构的字段,便于前端按一种模型渲染。 + detail_payload: Any = exc.detail + error_detail: dict[str, Any] + if isinstance(detail_payload, dict) and "code" in detail_payload: + error_detail = detail_payload + else: + error_detail = { + "code": "http_exception", + "message": f"[{exc.status_code}] {detail_payload}", + "exception_type": "HTTPException", + "exception_repr": repr(exc), + "root_type": None, + "root_repr": None, + "hint": None, + "trace_tail": [], + "context": "intent_stream", + } + err = { + "status": "error", + "content": error_detail["message"], + "summary": None, + "error_detail": error_detail, + } yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n" except Exception as exc: - logger.exception("intent stream unexpected failure") - err = {"status": "error", "content": f"unexpected: {exc!r}", "summary": None} + diagnostic = _diagnose_exception(exc, context="intent_stream") + logger.error("intent stream failed: %s", diagnostic, exc_info=exc) + err = { + "status": "error", + "content": diagnostic["message"], + "summary": None, + "error_detail": diagnostic, + } yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n" return StreamingResponse( diff --git a/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py b/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py index f09b8305..d7d3e8ae 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py +++ b/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py @@ -49,11 +49,14 @@ from __future__ import annotations import asyncio import json import logging +import os import re import time +import traceback from typing import Any, AsyncIterator import httpx +from deerflow.config import get_app_config from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple from fastapi import APIRouter, Body, HTTPException, Request from fastapi.responses import StreamingResponse @@ -63,13 +66,216 @@ router = APIRouter(prefix="/api/multi-agent", tags=["multi-agent"]) # --------------------------------------------------------------------------- -# Loopback 辅助函数(Gateway → Gateway,同进程) +# 共享:异常诊断器(intent/recommend/multi-agent 共用) # --------------------------------------------------------------------------- +def _diagnose_exception(exc: BaseException, *, context: str = "") -> dict[str, Any]: + """把后端异常转成结构化诊断 dict,直接给前端展示用。 + + 设计目标:让运维 / 前端开发不用翻 backend 日志就能看到根因和处置建议。 + 返回字段: + - code: 短稳定分类(loopback_connect_failed / upstream_connect_failed / ...) + - message: 一行人类可读 + - exception_type: 表层异常类名 + - exception_repr: repr(exc) + - root_type / root_repr: 沿 __cause__ 链找到的最深异常(若与表层不同) + - hint: 针对常见失败模式的可执行建议(中文) + - trace_tail: traceback 最后 ~12 行(避免响应体过大) + - context: 调用方标签(如 "intent_init") + """ + exc_type = type(exc).__name__ + exc_repr = repr(exc) + + # 沿 __cause__ 链找最深异常:httpx 经常把 ConnectError 包成 RemoteProtocolError 等。 + root: BaseException = exc + seen: set[int] = {id(root)} + while getattr(root, "__cause__", None) is not None and id(root.__cause__) not in seen: + root = root.__cause__ # type: ignore[assignment] + seen.add(id(root)) + + code = "unexpected" + hint = "查看 trace_tail 最后一帧定位代码位置;若信息不足请到 backend 日志找完整 traceback。" + + if isinstance(root, httpx.ConnectError): + target = str(root).strip().lower() + # loopback 走真 TCP 到 127.0.0.1:;失败 = Gateway 进程没起 / 端口被占。 + # outbound 失败(其它 host)= LLM 提供商不可达。 + if "127.0.0.1" in target or "localhost" in target or "loopback" in target: + code = "loopback_connect_failed" + hint = ( + "本地 Gateway loopback 连接失败:确认后端进程正在监听配置的端口。" + "端口配置优先级(高→低):env DEER_FLOW_GATEWAY_PORT > config.yaml 的 " + "gateway.port > 默认 8001。若部署机改了端口,在 config.yaml 加 " + "`gateway:\\n port: <端口>` 即可。" + ) + else: + code = "upstream_connect_failed" + hint = ( + "外网/上游服务连接失败:大概率是 LLM 提供商不可达" + "(如 api.deepseek.com 在内网被防火墙拦截)。" + "把 config.yaml 里所有 base_url 改成内网可达的 LLM endpoint," + "或加白名单/正向代理(HTTPS_PROXY)。" + ) + elif isinstance(root, httpx.TimeoutException): + code = "upstream_timeout" + hint = ( + "上游请求超时:网络抖动 / 模型推理过慢。" + "先 curl /v1/models 确认可达,再看模型是否过载或 max_tokens 过大。" + ) + elif isinstance(root, httpx.HTTPStatusError): + code = "upstream_http_error" + status = getattr(getattr(root, "response", None), "status_code", "?") + hint = ( + f"上游接口返回 HTTP {status}:可能是 API key 失效、模型名不对、" + "或目标服务自身报错。查看 backend 日志里 response.text 拿详细错误体。" + ) + elif isinstance(root, FileNotFoundError): + code = "file_not_found" + hint = ( + f"找不到路径:{root}。常见原因:内置 agent 目录(.deer-flow/agents//)缺失," + "或 SOUL.md 没拷过来;详见后端开发文档 §11.7。" + ) + elif isinstance(root, KeyError): + code = "key_error" + hint = ( + f"缺少 key:{root}。若 key 是 agent_name/agent_id 类:大概率内置 agent 目录缺失;" + "若是配置 key:检查 config.yaml schema。" + ) + elif isinstance(root, ValueError) and "model" in str(root).lower(): + code = "no_models_configured" + hint = "config.yaml 没有任何 models[],或所有条目都解析失败。检查 yaml 语法与 $ENV 引用。" + + trace_lines = traceback.format_exception(type(exc), exc, exc.__traceback__) + trace_tail = "".join(trace_lines).splitlines()[-12:] + + return { + "code": code, + "message": f"{exc_type}: {exc}" if str(exc) else exc_type, + "exception_type": exc_type, + "exception_repr": exc_repr, + "root_type": type(root).__name__ if root is not exc else None, + "root_repr": repr(root) if root is not exc else None, + "hint": hint, + "trace_tail": trace_tail, + "context": context or None, + } + + +# --------------------------------------------------------------------------- +# Loopback 辅助函数(Gateway → Gateway,同进程) +# +# 圆桌三路由(intent / recommend / multi_agent)需要从一个 FastAPI handler 里 +# 调用本机 Gateway 的另一个 endpoint(`/api/threads`、`/api/agents`、 +# `/api/threads/{id}/runs/stream`)。这里用真 TCP loopback 到本机的 127.0.0.1 +# + Gateway 监听端口。 +# +# ⚠️ 历史踩坑(三个) ⚠️ +# 1. 直接走系统代理:开发机若设了 HTTP_PROXY / HTTPS_PROXY,httpx 默认会把 +# 127.0.0.1 也走代理 → 上游代理不可达,40s 超时(典型日志 headers_in=42.160s)。 +# → 修法:client 用 `trust_env=False`,强制忽略代理 env vars。 +# +# 2. 用 request.url.netloc 拼 base URL:Nginx 反代部署时,客户端打到 nginx 的 +# public host(如 example.com),nginx 把请求转给 gateway 时 Host header +# 通常是 `gateway:8001` 或 public host;后者 httpx 会真的去 DNS 解析, +# 在 gateway 容器内可能不可达 → 连接失败。 +# → 修法:**写死 127.0.0.1**,完全无视 request.url.netloc。 +# +# 3. 一度尝试用 httpx.ASGITransport(app=request.app) 进程内直调,看似一劳永逸, +# 但 httpx 0.28 的 ASGITransport 对 streaming response **整段 buffer**, +# 一直收齐所有 body chunks 才一次性把响应给消费方 → SSE 完全失效,前端表现 +# 为"调度阶段很长 + 流式结果一次性出现"。已弃用。 +# → 详见:git log 6e3a07b(那次改 ASGI 引入)及随后回退。 +# +# 端口策略(优先级,从高到低): +# 1. 环境变量 DEER_FLOW_GATEWAY_PORT +# ── 部署侧紧急覆盖通道,适合 docker-compose / k8s 注入或临时调试 +# 2. config.yaml 的 `gateway.port` +# ── 部署侧**推荐**的配置方式,改完不需要重启二进制(get_app_config 有 mtime +# 热加载,但仍建议跑一次 reload 以确保所有 worker 拿到新值) +# 3. request.url.port +# ── 浏览器直连 gateway:8001 时拿得到;走 Nginx 80/443 反代时会落到下一档 +# 4. 默认 8001(与 `make dev` / `make gateway` / `scripts/start-all.sh` 写死的一致) +# +# 端口在哪里改(给运维 / 部署侧的提示): +# - yaml 配置:打开 config.yaml,加 / 改: +# gateway: +# port: 9999 +# - 环境变量: +# export DEER_FLOW_GATEWAY_PORT=9999 +# +# 修改这两个函数时务必跑一次 SSE 流式 smoke 验证(见 multi-agent-backend-dev.md +# §13 测试建议),确认 chunks 是逐个到达而不是一次喷出。 +# --------------------------------------------------------------------------- + + +# 写死的最终 fallback。**不要**为了改端口而修改这个常量——优先用 config.yaml / +# env 这两层(它们在部署侧不需要改源码即可生效)。这个值只在 yaml 缺失 + env +# 没设 + 请求里也拿不到合法 port 时才会用到。 +_DEFAULT_GATEWAY_PORT = 8001 + + +def _resolve_gateway_port(request: Request) -> int: + """按四档 fallback 解析 Gateway 监听端口。详见模块顶部「端口策略」。""" + # 第 1 档:环境变量。允许非法值(garbage),只警告并继续下一档,不抛异常。 + env_port = os.environ.get("DEER_FLOW_GATEWAY_PORT", "").strip() + if env_port: + try: + port = int(env_port) + if 1 <= port <= 65535: + return port + raise ValueError("out of range") + except ValueError: + logger.warning( + "DEER_FLOW_GATEWAY_PORT=%r 不是合法端口,fallback 到 config.yaml / request / %d", + env_port, + _DEFAULT_GATEWAY_PORT, + ) + + # 第 2 档:config.yaml 的 gateway.port。GatewayConfig.port 的 pydantic 校验 + # 已经保证它在 1-65535 范围内,无需再次校验。读 config 失败时降级,不阻断业务。 + try: + configured = get_app_config().gateway.port + if configured: + return configured + except Exception as exc: + # 极端场景:config.yaml 不存在或解析失败。这种情况下 gateway 本身也起不来, + # loopback 端口"猜错"也已经无所谓。仅记 debug 日志方便排查。 + logger.debug("read gateway.port from app config failed: %s", exc) + + # 第 3 档:从当前请求拿 port。浏览器直连 gateway 时是真实监听端口; + # 走 Nginx 反代时通常是 80/443(public port),那就跳过,落到最终默认。 + inbound_port = request.url.port + if inbound_port is not None and inbound_port not in (80, 443): + return inbound_port + + # 第 4 档:写死默认。 + return _DEFAULT_GATEWAY_PORT + + def _loopback_base(request: Request) -> str: - """当前请求的 scheme+host,用于 httpx 打回本机 Gateway。""" - return f"{request.url.scheme}://{request.url.netloc}" + """返回 `http://127.0.0.1:`,供 loopback 调用拼 URL。 + + 强制 127.0.0.1 是为了避免 Nginx 反代场景下读到经过 Host 改写的 netloc。 + 端口按 `_resolve_gateway_port` 的四档 fallback 解析。 + """ + return f"http://127.0.0.1:{_resolve_gateway_port(request)}" + + +def _make_loopback_client(request: Request) -> httpx.AsyncClient: + """构造同进程 loopback 用的 httpx.AsyncClient(真 TCP)。 + + 所有圆桌路由(intent / recommend / multi_agent)的 loopback 调用必须走这里, + 保持配置一致。 + - `trust_env=False`:**关键**,忽略 HTTP_PROXY / HTTPS_PROXY,防代理拦截 127.0.0.1 + - `timeout=None`:SSE 长连接需要,LangGraph runs/stream 可能跑数十秒 + - `request` 参数当前未被使用,保留是为了:(a) 未来若需要从 request 取部署 + 上下文(如多租户端口隔离)可以无侵入扩展;(b) 与调用方现有签名兼容。 + """ + return httpx.AsyncClient( + timeout=None, + trust_env=False, + ) def _auth_headers(request: Request) -> dict[str, str]: @@ -303,7 +509,7 @@ async def init_multi_agent(request: Request, payload: dict = Body(...)) -> Strea { "agent_names": ["roundtable-intelligence", ...], // 用户选的席位 id "main_agent_name": "roundtable-coordinator-xxx", // 当次总控 id,前端生成 - "model": "deepseek-chat" // 可选,写入 coordinator agent + "model": "<可选>" // 可选,写入 coordinator agent;不传由 lead_agent 回退到 config.yaml models[0] } SSE 事件(每条 ``data: {...}\\n\\n``):: @@ -335,11 +541,9 @@ async def init_multi_agent(request: Request, payload: dict = Body(...)) -> Strea return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n" async def stream_generator() -> AsyncIterator[str]: - # trust_env=False: same-process loopback to 127.0.0.1 — honoring - # HTTP_PROXY / HTTPS_PROXY env vars (commonly set on dev machines) - # causes httpx to route loopback calls through an unreachable upstream - # proxy and wait ~40 seconds for timeout before falling back. - async with httpx.AsyncClient(timeout=None, trust_env=False) as client: + # TCP loopback 到 127.0.0.1:。client 配置见 _make_loopback_client: + # trust_env=False 防 HTTP_PROXY 拦截,timeout=None 容纳 SSE 长连接。 + async with _make_loopback_client(request) as client: try: # Per-seat prepare task — same body as before, but we no longer # await them with asyncio.gather. Instead, asyncio.as_completed @@ -456,7 +660,9 @@ async def _stream_upstream( - ``(None, captured)`` — 流结束,``captured`` 为所有 ``data:`` 行文本列表, 供 ``_parse_last_messages`` 从倒数 values 快照提取 ``messages``。 - 日志 ``[upstream-timing]``:排查首 token 慢、代理导致 loopback 超时(见 trust_env=False)。 + 日志 ``[upstream-timing]``:排查首 token 慢;headers_in 长尾若 >> 网络往返(loopback + 本机往返通常 <10ms),怀疑 HTTP_PROXY 拦了回环——检查 client 是否走 _make_loopback_client + (trust_env=False)。 """ url = f"{base}/api/threads/{thread_id}/runs/stream" @@ -843,8 +1049,8 @@ async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) -> "d_agent_thread_id": { "": "", ... }, // init_done.thread_ids "agent_name": "<本次运行的 agent_id>", "new_message": "<用户消息或派活 task 文本>", - "model": "deepseek-chat", - "skill_stop_names": [] // 可选,leader 一般留空,由网关注入 agent_orchestration + "model": "<可选>", // 不传由 lead_agent 回退到 config.yaml models[0] + "skill_stop_names": [] // 可选,leader 一般留空,由网关注入 agent_orchestration } 响应:上游 SSE 行透传 + 末尾一条 ``data:`` JSON(leader 为 dict,special 同上)。 @@ -855,7 +1061,9 @@ async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) -> agent_name: str = str(payload.get("agent_name") or "").lower() new_message: str = str(payload.get("new_message") or "") skill_stop_names: list[str] = payload.get("skill_stop_names") or [] - model_name: str = str(payload.get("model") or "deepseek-chat") + # 不写死任何默认模型;空串会让 lead_agent._resolve_model_name() 回退到 + # config.yaml 的 models[0],与 /api/ai-writing 等其它路由一致。 + model_name: str = str(payload.get("model") or "") if not agent_name or agent_name not in agent_threads: raise HTTPException( @@ -870,11 +1078,9 @@ async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) -> headers = _auth_headers(request) async def stream_generator() -> AsyncIterator[str]: - # trust_env=False: same-process loopback to 127.0.0.1 — honoring - # HTTP_PROXY / HTTPS_PROXY env vars (commonly set on dev machines) - # causes httpx to route loopback calls through an unreachable upstream - # proxy and wait ~40 seconds for timeout before falling back. - async with httpx.AsyncClient(timeout=None, trust_env=False) as client: + # TCP loopback 到 127.0.0.1:。client 配置见 _make_loopback_client: + # trust_env=False 防 HTTP_PROXY 拦截,timeout=None 容纳 SSE 长连接。 + async with _make_loopback_client(request) as client: try: if agent_type == "leader": gen = _leader_run( diff --git a/offline-backend-20260512/backend/app/gateway/routers/recommend.py b/offline-backend-20260512/backend/app/gateway/routers/recommend.py index 9acc527f..8ff444d7 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/recommend.py +++ b/offline-backend-20260512/backend/app/gateway/routers/recommend.py @@ -47,11 +47,13 @@ import httpx from fastapi import APIRouter, Body, HTTPException, Request from fastapi.responses import StreamingResponse +from app.gateway.routers._roundtable_seed import ensure_roundtable_functional_agents from app.gateway.routers.multi_agent import ( _auth_headers, _create_thread, _flatten_content, _loopback_base, + _make_loopback_client, _parse_last_messages, _run_payload, _stream_upstream, @@ -151,7 +153,7 @@ async def stream_recommend(request: Request, payload: dict = Body(...)) -> Strea { "intent": {"objective": "...", "constraints": [...], "assumptions": [...]}, "candidates": [{"agent_id": "...", "name": "...", "description": "..."}, ...], - "model": "deepseek-chat" # optional + "model": "<可选>" # optional;不传由 lead_agent 回退到 config.yaml models[0] } Stream: transparent upstream LangGraph frames, followed by one status @@ -169,7 +171,10 @@ async def stream_recommend(request: Request, payload: dict = Body(...)) -> Strea """ intent = payload.get("intent") or {} candidates = payload.get("candidates") or [] - model_name = str(payload.get("model") or "deepseek-chat") + # 不写死任何默认模型;空串会让 lead_agent._resolve_model_name() 回退到 + # config.yaml 的 models[0]。内网部署可能没有 deepseek-chat,前端传当前选中的 + # model 名即可,缺失也不会硬错。 + model_name = str(payload.get("model") or "") if not isinstance(intent, dict) or not str(intent.get("objective") or "").strip(): raise HTTPException(status_code=400, detail="intent.objective is required") @@ -185,16 +190,19 @@ async def stream_recommend(request: Request, payload: dict = Body(...)) -> Strea if not candidate_ids: raise HTTPException(status_code=400, detail="candidates must contain at least one valid agent_id") + # 调用前自检:内网部署 .deer-flow/ 被 gitignore,roundtable-recommender 目录可能不存在, + # 缺失则用嵌入模板补建,避免下游 lead_agent 解析失败抛 500。 + ensure_roundtable_functional_agents() + prompt = _build_prompt(intent, candidates) base = _loopback_base(request) headers = _auth_headers(request) async def stream_generator() -> AsyncIterator[str]: - # trust_env=False: same-process loopback to 127.0.0.1 — honoring - # HTTP_PROXY / HTTPS_PROXY env vars (commonly set on dev machines) - # causes httpx to route loopback calls through an unreachable upstream - # proxy and wait ~40 seconds for timeout before falling back. - async with httpx.AsyncClient(timeout=None, trust_env=False) as client: + # TCP loopback 到 127.0.0.1:(详见 multi_agent._make_loopback_client)。 + # 不要在这里替换成 httpx.ASGITransport——曾试过,会整段 buffer SSE 流导致前端 + # 看不到逐 token 增量。 + async with _make_loopback_client(request) as client: try: # 每次推荐独立 thread;prompt 已包含完整 intent + 候选列表,无需多轮。 thread_id = await _create_thread(client, base, headers) diff --git a/offline-backend-20260512/backend/config.yaml b/offline-backend-20260512/backend/config.yaml index 7c1e7ce4..086353c0 100644 --- a/offline-backend-20260512/backend/config.yaml +++ b/offline-backend-20260512/backend/config.yaml @@ -2,6 +2,13 @@ config_version: 8 log_level: info +# Gateway FastAPI 监听端口。圆桌三路由(intent / recommend / multi_agent)的同进程 +# loopback 调用会拼 http://127.0.0.1: 打到本机其它 endpoint,所以必须与 +# 实际监听端口一致(scripts/start-all.sh、Makefile 默认 8001)。 +# 部署机改了端口请同步改这里;也可设环境变量 DEER_FLOW_GATEWAY_PORT 临时覆盖。 +gateway: + port: 8001 + models: - name: deepseek-chat display_name: DeepSeek Chat diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/__init__.py index 42a91757..7d9cb6e1 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/config/__init__.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/__init__.py @@ -1,5 +1,6 @@ from .app_config import get_app_config from .extensions_config import ExtensionsConfig, get_extensions_config +from .gateway_config import GatewayConfig from .memory_config import MemoryConfig, get_memory_config from .paths import Paths, get_paths from .skill_evolution_config import SkillEvolutionConfig @@ -20,6 +21,7 @@ __all__ = [ "SkillsConfig", "ExtensionsConfig", "get_extensions_config", + "GatewayConfig", "MemoryConfig", "get_memory_config", "get_tracing_config", diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/app_config.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/app_config.py index 2d56d443..a8d9b58b 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/config/app_config.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/app_config.py @@ -13,6 +13,7 @@ from deerflow.config.agents_api_config import AgentsApiConfig, load_agents_api_c from deerflow.config.checkpointer_config import CheckpointerConfig, load_checkpointer_config_from_dict from deerflow.config.database_config import DatabaseConfig from deerflow.config.extensions_config import ExtensionsConfig +from deerflow.config.gateway_config import GatewayConfig from deerflow.config.guardrails_config import GuardrailsConfig, load_guardrails_config_from_dict from deerflow.config.memory_config import MemoryConfig, load_memory_config_from_dict from deerflow.config.model_config import ModelConfig @@ -122,6 +123,7 @@ class AppConfig(BaseModel): skill_stop: SkillStopConfig = Field(default_factory=SkillStopConfig, description="Skill stop middleware configuration") model_config = ConfigDict(extra="allow") database: DatabaseConfig = Field(default_factory=DatabaseConfig, description="Unified database backend configuration") + gateway: GatewayConfig = Field(default_factory=GatewayConfig, description="Gateway FastAPI listening port (used by intra-process loopback)") run_events: RunEventsConfig = Field(default_factory=RunEventsConfig, description="Run event storage configuration") checkpointer: CheckpointerConfig | None = Field(default=None, description="Checkpointer configuration") stream_bridge: StreamBridgeConfig | None = Field(default=None, description="Stream bridge configuration") diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/gateway_config.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/gateway_config.py new file mode 100644 index 00000000..008324b1 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/gateway_config.py @@ -0,0 +1,46 @@ +"""Gateway 进程的本机绑定配置。 + +目前唯一字段是 ``port``——Gateway FastAPI 的监听端口。圆桌三路由 +(intent / recommend / multi_agent) 内部用 loopback 调本机的其它 endpoint +(``/api/threads``、``/api/agents`` 等),需要知道这个端口才能拼出 base URL。 + +为什么放在配置里而不是写死 +-------------------------- +- 部署方常常会改 Gateway 监听端口(端口被占用、合规要求、容器化端口映射), + 写死代码意味着每次改端口都得改源码 + 重新构建/分发,**改 config 不需要重启二进制** +- ``DEER_FLOW_GATEWAY_PORT`` 环境变量仍然是更高优先级的紧急覆盖通道,适合 + 临时调试或者 docker-compose / k8s 注入 + +修改方式(部署侧) +------------------ +在 ``config.yaml`` 顶层加: + +.. code-block:: yaml + + gateway: + port: 9999 # 默认 8001;部署机改了 gateway 端口时改这里 + +只想临时改一次(不动 yaml)→ 设环境变量: + +.. code-block:: bash + + export DEER_FLOW_GATEWAY_PORT=9999 +""" + +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class GatewayConfig(BaseModel): + """Gateway 本机 loopback 端口配置。""" + + port: int = Field( + default=8001, + ge=1, + le=65535, + description=( + "Gateway FastAPI 监听端口。圆桌内部 loopback 调用会拼到 " + "``http://127.0.0.1:``。默认 8001(与 Makefile/scripts/start-all.sh 对齐)。" + ), + )