Merge remote-tracking branch 'origin/master' into ji
This commit is contained in:
commit
b671b49725
17 files changed
+2784
-130
No files matched your search
@@ -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)
|
||||
@@ -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:<gateway port>(详见 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(
|
||||
|
||||
@@ -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 port>;失败 = 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 <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,同进程)
|
||||
#
|
||||
# 圆桌三路由(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:<port>`,供 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:<gateway port>。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": { "<agent_id>": "<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:<gateway port>。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(
|
||||
|
||||
@@ -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:<gateway port>(详见 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)
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
config_version: 8
|
||||
log_level: info
|
||||
|
||||
# Gateway FastAPI 监听端口。圆桌三路由(intent / recommend / multi_agent)的同进程
|
||||
# loopback 调用会拼 http://127.0.0.1:<port> 打到本机其它 endpoint,所以必须与
|
||||
# 实际监听端口一致(scripts/start-all.sh、Makefile 默认 8001)。
|
||||
# 部署机改了端口请同步改这里;也可设环境变量 DEER_FLOW_GATEWAY_PORT 临时覆盖。
|
||||
gateway:
|
||||
port: 8001
|
||||
|
||||
models:
|
||||
- name: deepseek-chat
|
||||
display_name: DeepSeek Chat
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:<port>``。默认 8001(与 Makefile/scripts/start-all.sh 对齐)。"
|
||||
),
|
||||
)
|
||||
Reference in new issue
Block a user