Merge branch 'dhk-1' into 'master'
代码修改 See merge request knowledge-base-bj/deerflow-server!55
This commit is contained in:
commit
2ecd50f5dc
7 files changed
+2041
-48
No files matched your search
@@ -13,6 +13,7 @@
|
|||||||
- 三个圆桌 Router(`intent / recommend / multi_agent`)**全部走 HTTP loopback**:在同进程内通过 `httpx.AsyncClient` 调本机 `127.0.0.1` 的 `/api/threads`、`/api/agents`、`/api/threads/{id}/runs/stream`。
|
- 三个圆桌 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`)。
|
- **所有 loopback `httpx.AsyncClient` 必须 `trust_env=False`**。开发机若设了 `HTTP_PROXY` / `HTTPS_PROXY`,httpx 默认会把 loopback 也走代理 → 不可达 → 单次请求多出 40 秒超时(典型症状日志:`headers_in=42.160s`)。
|
||||||
- **席位 agent 持久注册**(磁盘 `.deer-flow/agents/` + `_sync_legacy_agents`),**协调智能体每次新建**(命名 `roundtable-coordinator-{timestamp36}`,DB 累积,需后续 GC)。
|
- **席位 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` 是 SSE 而非一次性 JSON**:用 `asyncio.as_completed` 按真实完成顺序推送 `seat_ready`,前端因此能逐个点亮席位。
|
||||||
- **`init_done.thread_ids` 包含协调智能体自身**(`agent_threads[main_agent_name] = coord_thread`),前端 Step 3 列文件时需自行过滤。
|
- **`init_done.thread_ids` 包含协调智能体自身**(`agent_threads[main_agent_name] = coord_thread`),前端 Step 3 列文件时需自行过滤。
|
||||||
- **澄清协议统一**:Step 1 与 Step 2 leader 都借 `ask_clarification` + 终态 `clarification` 帧驱动同一套选项卡片组件;`allow_multiple` 默认 false(单选、点击即发送)。
|
- **澄清协议统一**:Step 1 与 Step 2 leader 都借 `ask_clarification` + 终态 `clarification` 帧驱动同一套选项卡片组件;`allow_multiple` 默认 false(单选、点击即发送)。
|
||||||
@@ -679,6 +680,235 @@ grep "special-timing" .runtime/logs/*.log # payload_built / first_upstream_l
|
|||||||
2. 前端候选池 / 推荐逻辑纳入该 id
|
2. 前端候选池 / 推荐逻辑纳入该 id
|
||||||
3. Step 2 `agent_names` 传入即可 — **multi_agent 路由无需改**
|
3. Step 2 `agent_names` 传入即可 — **multi_agent 路由无需改**
|
||||||
|
|
||||||
|
### 11.7 内置 agent 部署持久化(必读,关系到内网部署能否启动)
|
||||||
|
|
||||||
|
#### 11.7.1 问题背景
|
||||||
|
|
||||||
|
圆桌功能涉及的 agent 都靠**磁盘文件**激活(`.deer-flow/agents/<id>/{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/<id>/` 不存在就从源拷过去。
|
||||||
|
|
||||||
|
**步骤大纲**:
|
||||||
|
|
||||||
|
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 <TOKEN>" \
|
||||||
|
-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 <TOKEN>" \
|
||||||
|
-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/<id>/` 注册)|
|
||||||
|
|
||||||
|
`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 工具
|
### 11.4 新增 Step 1 工具
|
||||||
|
|
||||||
若需新工具,须同步修改 `intent.py` 的 `excluded_tools` 黑名单(当前默认禁 15 个工具,仅 `ask_clarification` 实际可用)。
|
若需新工具,须同步修改 `intent.py` 的 `excluded_tools` 黑名单(当前默认禁 15 个工具,仅 `ask_clarification` 实际可用)。
|
||||||
@@ -691,6 +921,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)。
|
`_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**,
|
||||||
|
让模型知道这种"非 roundtable-* 前缀"的 id 也是合法的。 -->
|
||||||
|
```
|
||||||
|
|
||||||
|
##### 定制方向
|
||||||
|
|
||||||
|
- **偏好新加的 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 <TOKEN>" 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 <TOKEN>" \
|
||||||
|
-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. 故障排查
|
## 12. 故障排查
|
||||||
|
|||||||
@@ -370,9 +370,9 @@ isOrchestrating = false
|
|||||||
- 追加一条「⚡ [模型偏好已保存] ...」对话气泡
|
- 追加一条「⚡ [模型偏好已保存] ...」对话气泡
|
||||||
- **不**立即重启编排;下一轮该席位被派活时 `runOrchestration` 内会读 `agentConfigs[meta.speakerId]?.model || selectedModel || undefined` 作为 special 调用的 `model` 参数
|
- **不**立即重启编排;下一轮该席位被派活时 `runOrchestration` 内会读 `agentConfigs[meta.speakerId]?.model || selectedModel || undefined` 作为 special 调用的 `model` 参数
|
||||||
|
|
||||||
### 5.6 全局模型选择器(顶栏)
|
### 5.6 全局模型选择器(右侧 sidebar 顶部)
|
||||||
|
|
||||||
- **位置**:顶栏右侧「新建任务」按钮左边,`<select>` 包在带 `Cpu` 图标的标签里
|
- **位置**:右侧 sidebar `currentStep !== 3` 时显示的第一个面板 —— 在「当前意图摘要」**正上方**。Step 3 时整个 sidebar 切换成「最终共识结论 / 风险与预算摘要 / 导出与交付」,所以模型选择器仅在 Step 1 / Step 2 可见(此时也是唯一会发起新模型调用的阶段)
|
||||||
- **state**:`const [selectedModel, setSelectedModel] = useState<string>("")`
|
- **state**:`const [selectedModel, setSelectedModel] = useState<string>("")`
|
||||||
- **初始化**:`useEffect(() => ...)` 在 `availableModels` 首次返回后把 `selectedModel` 设为 `availableModels[0].name`;后端配置变化导致当前选中 model 不在列表里时也回退到首项
|
- **初始化**:`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 覆盖
|
- **传参规则**:所有 5 个 API 调用点(`initIntent` / `streamIntent` / `streamRecommend` / `initMultiAgent` / `streamMultiAgent` leader / `streamMultiAgent` special)都传 `selectedModel || undefined`;special 还会先看 `agentConfigs[seat]?.model` per-seat 覆盖
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -21,9 +21,80 @@ export interface IntentInitResponse {
|
|||||||
thread_id: string;
|
thread_id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structured diagnostic payload surfaced by the backend whenever an intent
|
||||||
|
* route raises (init) or yields a final `status: "error"` frame (stream).
|
||||||
|
* Same shape across `init_intent` / `stream_intent`; renderer keys off `code`
|
||||||
|
* for quick visual triage, but also shows `hint` + `trace_tail` as a panel
|
||||||
|
* so deployment-time issues (config.yaml unreachable LLM, missing builtin
|
||||||
|
* agent dirs, loopback going through proxy) are debuggable from the UI alone
|
||||||
|
* without tail-ing backend logs.
|
||||||
|
*/
|
||||||
|
export interface IntentErrorDetail {
|
||||||
|
/** Stable short category — see backend `_diagnose_exception`. */
|
||||||
|
code: string;
|
||||||
|
/** One-line human-readable description (already includes exception type). */
|
||||||
|
message: string;
|
||||||
|
exception_type: string;
|
||||||
|
exception_repr: string;
|
||||||
|
/** Root of the `__cause__` chain when distinct from the outer exception. */
|
||||||
|
root_type?: string | null;
|
||||||
|
root_repr?: string | null;
|
||||||
|
/** Actionable hint in Chinese, keyed by `code`. May be null for unrecognized errors. */
|
||||||
|
hint?: string | null;
|
||||||
|
/** Last ~12 lines of the traceback for in-UI inspection. */
|
||||||
|
trace_tail?: string[];
|
||||||
|
/** Caller-supplied label (e.g. "intent_init"). */
|
||||||
|
context?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown by `initIntent` when the HTTP call fails. Carries the parsed
|
||||||
|
* `IntentErrorDetail` (if the backend emitted one) so the page can render
|
||||||
|
* a rich diagnostic panel instead of a one-line message.
|
||||||
|
*/
|
||||||
|
export class IntentInitError extends Error {
|
||||||
|
/** HTTP status (typically 500 for diagnosed failures, 401/403 for auth). */
|
||||||
|
readonly status: number;
|
||||||
|
/** Structured detail when the backend emitted `_diagnose_exception` output. */
|
||||||
|
readonly detail: IntentErrorDetail | null;
|
||||||
|
/** Raw body text when JSON parse failed (kept for completeness). */
|
||||||
|
readonly rawBody: string;
|
||||||
|
|
||||||
|
constructor(status: number, detail: IntentErrorDetail | null, rawBody: string) {
|
||||||
|
super(detail?.message || `init intent failed (${status}): ${rawBody}`);
|
||||||
|
this.name = "IntentInitError";
|
||||||
|
this.status = status;
|
||||||
|
this.detail = detail;
|
||||||
|
this.rawBody = rawBody;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIntentErrorDetail(value: unknown): IntentErrorDetail | null {
|
||||||
|
if (!value || typeof value !== "object") return null;
|
||||||
|
const obj = value as Record<string, unknown>;
|
||||||
|
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.
|
* Create a fresh thread for an intent-clarification session.
|
||||||
* The backend returns the built-in agent id so the frontend doesn't hardcode it.
|
* 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<IntentInitResponse> {
|
export async function initIntent(model?: string): Promise<IntentInitResponse> {
|
||||||
const res = await apiFetch(`${getBackendBaseURL()}/api/intent/init`, {
|
const res = await apiFetch(`${getBackendBaseURL()}/api/intent/init`, {
|
||||||
@@ -32,8 +103,18 @@ export async function initIntent(model?: string): Promise<IntentInitResponse> {
|
|||||||
body: JSON.stringify({ model }),
|
body: JSON.stringify({ model }),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text().catch(() => "");
|
const raw = await res.text().catch(() => "");
|
||||||
throw new Error(`init intent failed (${res.status}): ${text}`);
|
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;
|
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.
|
* cards + custom input instead of treating the turn as plain text only.
|
||||||
*/
|
*/
|
||||||
clarification: ClarificationPayload | null;
|
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[];
|
options?: unknown[];
|
||||||
allow_custom?: boolean;
|
allow_custom?: boolean;
|
||||||
allow_multiple?: boolean;
|
allow_multiple?: boolean;
|
||||||
|
error_detail?: unknown;
|
||||||
} {
|
} {
|
||||||
if (!value || typeof value !== "object") return false;
|
if (!value || typeof value !== "object") return false;
|
||||||
if (!("status" in value) || !("content" in value)) return false;
|
if (!("status" in value) || !("content" in value)) return false;
|
||||||
@@ -192,6 +280,7 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
|
|||||||
content: "",
|
content: "",
|
||||||
summary: null,
|
summary: null,
|
||||||
clarification: null,
|
clarification: null,
|
||||||
|
errorDetail: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -231,6 +320,9 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
|
|||||||
if (parsed.summary && typeof parsed.summary === "object") {
|
if (parsed.summary && typeof parsed.summary === "object") {
|
||||||
result.summary = parsed.summary as IntentSummary;
|
result.summary = parsed.summary as IntentSummary;
|
||||||
}
|
}
|
||||||
|
if (parsed.status === "error") {
|
||||||
|
result.errorDetail = parseIntentErrorDetail(parsed.error_detail);
|
||||||
|
}
|
||||||
if (parsed.status === "clarification") {
|
if (parsed.status === "clarification") {
|
||||||
const clarificationType =
|
const clarificationType =
|
||||||
typeof parsed.clarification_type === "string" ? parsed.clarification_type : "";
|
typeof parsed.clarification_type === "string" ? parsed.clarification_type : "";
|
||||||
|
|||||||
@@ -57,7 +57,9 @@ import {
|
|||||||
} from "@/roundtable-planning/api/multi-agent";
|
} from "@/roundtable-planning/api/multi-agent";
|
||||||
import {
|
import {
|
||||||
initIntent,
|
initIntent,
|
||||||
|
IntentInitError,
|
||||||
streamIntent,
|
streamIntent,
|
||||||
|
type IntentErrorDetail,
|
||||||
type IntentStreamPhase,
|
type IntentStreamPhase,
|
||||||
type IntentSummary,
|
type IntentSummary,
|
||||||
} from "@/roundtable-planning/api/intent";
|
} from "@/roundtable-planning/api/intent";
|
||||||
@@ -504,6 +506,8 @@ export default function RoundtablePlanningPage() {
|
|||||||
const [intentThreadId, setIntentThreadId] = useState<string | null>(null);
|
const [intentThreadId, setIntentThreadId] = useState<string | null>(null);
|
||||||
const [intentReady, setIntentReady] = useState<IntentSummary | null>(null);
|
const [intentReady, setIntentReady] = useState<IntentSummary | null>(null);
|
||||||
const [intentInitError, setIntentInitError] = useState<string | null>(null);
|
const [intentInitError, setIntentInitError] = useState<string | null>(null);
|
||||||
|
// 后端 _diagnose_exception 输出的结构化诊断,有就额外渲染 hint + trace_tail 面板。
|
||||||
|
const [intentInitErrorDetail, setIntentInitErrorDetail] = useState<IntentErrorDetail | null>(null);
|
||||||
const [isIntentSending, setIsIntentSending] = useState<boolean>(false);
|
const [isIntentSending, setIsIntentSending] = useState<boolean>(false);
|
||||||
const [pendingIntentClarificationMsgId, setPendingIntentClarificationMsgId] = useState<number | null>(null);
|
const [pendingIntentClarificationMsgId, setPendingIntentClarificationMsgId] = useState<number | null>(null);
|
||||||
const [intentClarificationSelections, setIntentClarificationSelections] = useState<
|
const [intentClarificationSelections, setIntentClarificationSelections] = useState<
|
||||||
@@ -1225,6 +1229,7 @@ export default function RoundtablePlanningPage() {
|
|||||||
const myGen = ++step1InitGenRef.current;
|
const myGen = ++step1InitGenRef.current;
|
||||||
setIntentReady(null);
|
setIntentReady(null);
|
||||||
setIntentInitError(null);
|
setIntentInitError(null);
|
||||||
|
setIntentInitErrorDetail(null);
|
||||||
setIntentThreadId(null);
|
setIntentThreadId(null);
|
||||||
setPendingIntentClarificationMsgId(null);
|
setPendingIntentClarificationMsgId(null);
|
||||||
setIntentClarificationSelections(new Map());
|
setIntentClarificationSelections(new Map());
|
||||||
@@ -1256,7 +1261,14 @@ export default function RoundtablePlanningPage() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (step1InitGenRef.current !== myGen) return;
|
if (step1InitGenRef.current !== myGen) return;
|
||||||
console.error("init intent failed:", err);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -1420,6 +1432,7 @@ export default function RoundtablePlanningPage() {
|
|||||||
setIntentThreadId(draft.step1.intentThreadId);
|
setIntentThreadId(draft.step1.intentThreadId);
|
||||||
setIntentReady(draft.step1.intentReady);
|
setIntentReady(draft.step1.intentReady);
|
||||||
setIntentInitError(null);
|
setIntentInitError(null);
|
||||||
|
setIntentInitErrorDetail(null);
|
||||||
setInteractiveMessages(draft.step1.interactiveMessages as typeof interactiveMessages);
|
setInteractiveMessages(draft.step1.interactiveMessages as typeof interactiveMessages);
|
||||||
setTimelineEntries(draft.step1.timelineEntries as typeof timelineEntries);
|
setTimelineEntries(draft.step1.timelineEntries as typeof timelineEntries);
|
||||||
step1InitRef.current = true;
|
step1InitRef.current = true;
|
||||||
@@ -1629,6 +1642,15 @@ export default function RoundtablePlanningPage() {
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
} else if (res.status === "error") {
|
} 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}`);
|
triggerToast(`❌ 智能体调用失败: ${res.content}`);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1955,41 +1977,6 @@ export default function RoundtablePlanningPage() {
|
|||||||
|
|
||||||
{/* Right Actions */}
|
{/* Right Actions */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* 全局模型选择器
|
|
||||||
─────────────────────────────────────────────
|
|
||||||
影响 Step 1 / 推荐 / Step 2 init / 总控 leader / 子席位 special
|
|
||||||
所有 API 调用。Persona 抽屉里 per-seat 设置的 model 优先级更高。
|
|
||||||
选项来自 /api/models(由 config.yaml 配置),内网部署只需配自家模型。 */}
|
|
||||||
<label className="flex items-center gap-1.5 bg-white border border-[#e2e8f0] rounded-xl px-2.5 py-1.5 text-xs font-bold text-[#334155] dark:border-border dark:bg-secondary dark:text-foreground">
|
|
||||||
<Cpu className="w-3.5 h-3.5 text-primary" />
|
|
||||||
<span className="text-[10px] text-slate-400 font-mono">模型</span>
|
|
||||||
<select
|
|
||||||
id="global-model-select"
|
|
||||||
value={selectedModel}
|
|
||||||
onChange={(e) => setSelectedModel(e.target.value)}
|
|
||||||
disabled={modelsLoading || availableModels.length === 0}
|
|
||||||
title={
|
|
||||||
modelsLoading
|
|
||||||
? "正在加载模型列表…"
|
|
||||||
: availableModels.length === 0
|
|
||||||
? "未在 config.yaml 中配置任何模型"
|
|
||||||
: "切换全局推理模型;不影响 Persona 抽屉里已设置的 per-seat 覆盖"
|
|
||||||
}
|
|
||||||
className="bg-transparent border-none outline-none text-xs font-bold text-slate-700 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed dark:text-foreground"
|
|
||||||
>
|
|
||||||
{availableModels.length === 0 && (
|
|
||||||
<option value="">{modelsLoading ? "加载中…" : "未配置模型"}</option>
|
|
||||||
)}
|
|
||||||
{availableModels.map((m) => (
|
|
||||||
<option key={m.name} value={m.name}>
|
|
||||||
{m.display_name && m.display_name !== m.name
|
|
||||||
? `${m.display_name} (${m.name})`
|
|
||||||
: m.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
id="new-task-btn"
|
id="new-task-btn"
|
||||||
onClick={handleStartNewTask}
|
onClick={handleStartNewTask}
|
||||||
@@ -2512,9 +2499,40 @@ export default function RoundtablePlanningPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{intentInitError && (
|
{intentInitError && (
|
||||||
<p className="mt-2 text-[11px] text-red-500 font-medium">
|
<div className="mt-2 rounded-lg border border-red-200 bg-red-50/70 p-3 text-[11px] leading-relaxed">
|
||||||
智能体初始化失败: {intentInitError}
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
</p>
|
<span className="font-bold text-red-700">智能体初始化失败</span>
|
||||||
|
{intentInitErrorDetail?.code && (
|
||||||
|
<span className="px-1.5 py-0.5 bg-red-100 text-red-700 rounded font-mono text-[10px]">
|
||||||
|
{intentInitErrorDetail.code}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{intentInitErrorDetail?.exception_type && (
|
||||||
|
<span className="text-[10px] text-red-600 font-mono">
|
||||||
|
{intentInitErrorDetail.exception_type}
|
||||||
|
{intentInitErrorDetail.root_type && intentInitErrorDetail.root_type !== intentInitErrorDetail.exception_type
|
||||||
|
? ` → ${intentInitErrorDetail.root_type}`
|
||||||
|
: ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-red-700 font-medium break-all">{intentInitError}</p>
|
||||||
|
{intentInitErrorDetail?.hint && (
|
||||||
|
<p className="mt-1.5 text-red-800 bg-amber-50 border-l-2 border-amber-400 px-2 py-1 rounded-sm">
|
||||||
|
<span className="font-bold">💡 建议:</span> {intentInitErrorDetail.hint}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{intentInitErrorDetail?.trace_tail && intentInitErrorDetail.trace_tail.length > 0 && (
|
||||||
|
<details className="mt-1.5">
|
||||||
|
<summary className="cursor-pointer text-red-600 hover:text-red-800 select-none text-[10px] font-bold">
|
||||||
|
展开 traceback 末段(共 {intentInitErrorDetail.trace_tail.length} 行)
|
||||||
|
</summary>
|
||||||
|
<pre className="mt-1 max-h-48 overflow-auto bg-slate-900 text-slate-100 text-[10px] leading-snug p-2 rounded font-mono whitespace-pre">
|
||||||
|
{intentInitErrorDetail.trace_tail.join("\n")}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -3652,6 +3670,44 @@ export default function RoundtablePlanningPage() {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
{/* Panel 0: 推理模型选择器
|
||||||
|
─────────────────────────────────────────────────────
|
||||||
|
全局选中模型(selectedModel)影响 Step 1 / 推荐 / Step 2 init /
|
||||||
|
总控 leader / 子席位 special 所有 API 调用。Persona 抽屉里
|
||||||
|
per-seat 覆盖优先级更高。
|
||||||
|
选项来自 /api/models(由 config.yaml 配置),内网部署只需配自家模型。 */}
|
||||||
|
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
|
||||||
|
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-2.5">
|
||||||
|
<Cpu className="w-4 h-4 text-primary" strokeWidth={2.5} />
|
||||||
|
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">推理模型</h2>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
id="global-model-select"
|
||||||
|
value={selectedModel}
|
||||||
|
onChange={(e) => 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 && (
|
||||||
|
<option value="">{modelsLoading ? "加载中…" : "未配置模型"}</option>
|
||||||
|
)}
|
||||||
|
{availableModels.map((m) => (
|
||||||
|
<option key={m.name} value={m.name}>
|
||||||
|
{m.display_name && m.display_name !== m.name
|
||||||
|
? `${m.display_name} (${m.name})`
|
||||||
|
: m.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Panel 1: 当前意图摘要 */}
|
{/* Panel 1: 当前意图摘要 */}
|
||||||
<div className="bg-white border border-slate-200/50 rounded-2xl p-4.5 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
|
<div className="bg-white border border-slate-200/50 rounded-2xl p-4.5 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
|
||||||
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-2.5">
|
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-2.5">
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ from fastapi.responses import StreamingResponse
|
|||||||
from app.gateway.routers.multi_agent import (
|
from app.gateway.routers.multi_agent import (
|
||||||
_auth_headers,
|
_auth_headers,
|
||||||
_create_thread,
|
_create_thread,
|
||||||
|
_diagnose_exception,
|
||||||
_find_dispatch_message,
|
_find_dispatch_message,
|
||||||
_find_trailing_ai_text,
|
_find_trailing_ai_text,
|
||||||
_flatten_content,
|
_flatten_content,
|
||||||
@@ -112,8 +113,20 @@ async def init_intent(request: Request, payload: dict = Body(...)) -> dict[str,
|
|||||||
# on the dev machine) makes httpx route loopback calls through an upstream
|
# 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
|
# 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`.
|
# request before falling back. Symptom in logs: `headers_in=42.160s`.
|
||||||
async with httpx.AsyncClient(timeout=None, trust_env=False) as client:
|
try:
|
||||||
thread_id = await _create_thread(client, base, headers)
|
async with httpx.AsyncClient(timeout=None, trust_env=False) 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 {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"agent_id": INTENT_AGENT_ID,
|
"agent_id": INTENT_AGENT_ID,
|
||||||
@@ -270,11 +283,40 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
|||||||
|
|
||||||
except HTTPException as exc:
|
except HTTPException as exc:
|
||||||
logger.warning("intent stream HTTPException: %s %s", exc.status_code, exc.detail)
|
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"
|
yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n"
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("intent stream unexpected failure")
|
diagnostic = _diagnose_exception(exc, context="intent_stream")
|
||||||
err = {"status": "error", "content": f"unexpected: {exc!r}", "summary": None}
|
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"
|
yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
|
import traceback
|
||||||
from typing import Any, AsyncIterator
|
from typing import Any, AsyncIterator
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -62,6 +63,101 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter(prefix="/api/multi-agent", tags=["multi-agent"])
|
router = APIRouter(prefix="/api/multi-agent", tags=["multi-agent"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 共享:异常诊断器(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 失败 = Gateway 自己没起 / 代理拦回环;outbound 失败 = LLM 提供商不可达。
|
||||||
|
if "127.0.0.1" in target or "localhost" in target or "loopback" in target:
|
||||||
|
code = "loopback_connect_failed"
|
||||||
|
hint = (
|
||||||
|
"本地 Gateway loopback 连接失败:确认后端进程在 8001 端口监听;"
|
||||||
|
"另检查 HTTP_PROXY / HTTPS_PROXY 是否把 127.0.0.1 也走了代理"
|
||||||
|
"(代码已显式 trust_env=False,理论上不该走代理)。"
|
||||||
|
)
|
||||||
|
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 <base_url>/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/<id>/)缺失,"
|
||||||
|
"或 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,同进程)
|
# Loopback 辅助函数(Gateway → Gateway,同进程)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in new issue
Block a user