mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-04-25 11:18:22 +00:00
feat: add create_deerflow_agent SDK entry point (Phase 1) (#1203)
This commit is contained in:
parent
7eb3a150b5
commit
06a623f9c8
291
backend/docs/middleware-execution-flow.md
Normal file
291
backend/docs/middleware-execution-flow.md
Normal file
@ -0,0 +1,291 @@
|
|||||||
|
# Middleware 执行流程
|
||||||
|
|
||||||
|
## Middleware 列表
|
||||||
|
|
||||||
|
`create_deerflow_agent` 通过 `RuntimeFeatures` 组装的完整 middleware 链(默认全开时):
|
||||||
|
|
||||||
|
| # | Middleware | `before_agent` | `before_model` | `after_model` | `after_agent` | `wrap_tool_call` | 主 Agent | Subagent | 来源 |
|
||||||
|
|---|-----------|:-:|:-:|:-:|:-:|:-:|:-:|:-:|------|
|
||||||
|
| 0 | ThreadDataMiddleware | ✓ | | | | | ✓ | ✓ | `sandbox` |
|
||||||
|
| 1 | UploadsMiddleware | ✓ | | | | | ✓ | ✗ | `sandbox` |
|
||||||
|
| 2 | SandboxMiddleware | ✓ | | | ✓ | | ✓ | ✓ | `sandbox` |
|
||||||
|
| 3 | DanglingToolCallMiddleware | | | ✓ | | | ✓ | ✗ | 始终开启 |
|
||||||
|
| 4 | GuardrailMiddleware | | | | | ✓ | ✓ | ✓ | *Phase 2 纳入* |
|
||||||
|
| 5 | ToolErrorHandlingMiddleware | | | | | ✓ | ✓ | ✓ | 始终开启 |
|
||||||
|
| 6 | SummarizationMiddleware | | | ✓ | | | ✓ | ✗ | `summarization` |
|
||||||
|
| 7 | TodoMiddleware | | | ✓ | | | ✓ | ✗ | `plan_mode` 参数 |
|
||||||
|
| 8 | TitleMiddleware | | | ✓ | | | ✓ | ✗ | `auto_title` |
|
||||||
|
| 9 | MemoryMiddleware | | | | ✓ | | ✓ | ✗ | `memory` |
|
||||||
|
| 10 | ViewImageMiddleware | | ✓ | | | | ✓ | ✗ | `vision` |
|
||||||
|
| 11 | SubagentLimitMiddleware | | | ✓ | | | ✓ | ✗ | `subagent` |
|
||||||
|
| 12 | LoopDetectionMiddleware | | | ✓ | | | ✓ | ✗ | 始终开启 |
|
||||||
|
| 13 | ClarificationMiddleware | | | ✓ | | | ✓ | ✗ | 始终最后 |
|
||||||
|
|
||||||
|
主 agent **14 个** middleware(`make_lead_agent`),subagent **4 个**(ThreadData、Sandbox、Guardrail、ToolErrorHandling)。`create_deerflow_agent` Phase 1 实现 **13 个**(Guardrail 仅支持自定义实例,无内置默认)。
|
||||||
|
|
||||||
|
## 执行流程
|
||||||
|
|
||||||
|
LangChain `create_agent` 的规则:
|
||||||
|
- **`before_*` 正序执行**(列表位置 0 → N)
|
||||||
|
- **`after_*` 反序执行**(列表位置 N → 0)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
START(["invoke"]) --> TD
|
||||||
|
|
||||||
|
subgraph BA ["<b>before_agent</b> 正序 0→N"]
|
||||||
|
direction TB
|
||||||
|
TD["[0] ThreadData<br/>创建线程目录"] --> UL["[1] Uploads<br/>扫描上传文件"] --> SB["[2] Sandbox<br/>获取沙箱"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph BM ["<b>before_model</b> 正序 0→N"]
|
||||||
|
direction TB
|
||||||
|
VI["[10] ViewImage<br/>注入图片 base64"]
|
||||||
|
end
|
||||||
|
|
||||||
|
SB --> VI
|
||||||
|
VI --> M["<b>MODEL</b>"]
|
||||||
|
|
||||||
|
subgraph AM ["<b>after_model</b> 反序 N→0"]
|
||||||
|
direction TB
|
||||||
|
CL["[13] Clarification<br/>拦截 ask_clarification"] --> LD["[12] LoopDetection<br/>检测循环"] --> SL["[11] SubagentLimit<br/>截断多余 task"] --> TI["[8] Title<br/>生成标题"] --> SM["[6] Summarization<br/>上下文压缩"] --> DTC["[3] DanglingToolCall<br/>补缺失 ToolMessage"]
|
||||||
|
end
|
||||||
|
|
||||||
|
M --> CL
|
||||||
|
|
||||||
|
subgraph AA ["<b>after_agent</b> 反序 N→0"]
|
||||||
|
direction TB
|
||||||
|
SBR["[2] Sandbox<br/>释放沙箱"] --> MEM["[9] Memory<br/>入队记忆"]
|
||||||
|
end
|
||||||
|
|
||||||
|
DTC --> SBR
|
||||||
|
MEM --> END(["response"])
|
||||||
|
|
||||||
|
classDef beforeNode fill:#a0a8b5,stroke:#636b7a,color:#2d3239
|
||||||
|
classDef modelNode fill:#b5a8a0,stroke:#7a6b63,color:#2d3239
|
||||||
|
classDef afterModelNode fill:#b5a0a8,stroke:#7a636b,color:#2d3239
|
||||||
|
classDef afterAgentNode fill:#a0b5a8,stroke:#637a6b,color:#2d3239
|
||||||
|
classDef terminalNode fill:#a8b5a0,stroke:#6b7a63,color:#2d3239
|
||||||
|
|
||||||
|
class TD,UL,SB,VI beforeNode
|
||||||
|
class M modelNode
|
||||||
|
class CL,LD,SL,TI,SM,DTC afterModelNode
|
||||||
|
class SBR,MEM afterAgentNode
|
||||||
|
class START,END terminalNode
|
||||||
|
```
|
||||||
|
|
||||||
|
## 时序图
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant U as User
|
||||||
|
participant TD as ThreadDataMiddleware
|
||||||
|
participant UL as UploadsMiddleware
|
||||||
|
participant SB as SandboxMiddleware
|
||||||
|
participant VI as ViewImageMiddleware
|
||||||
|
participant M as MODEL
|
||||||
|
participant CL as ClarificationMiddleware
|
||||||
|
participant SL as SubagentLimitMiddleware
|
||||||
|
participant TI as TitleMiddleware
|
||||||
|
participant SM as SummarizationMiddleware
|
||||||
|
participant DTC as DanglingToolCallMiddleware
|
||||||
|
participant MEM as MemoryMiddleware
|
||||||
|
|
||||||
|
U ->> TD: invoke
|
||||||
|
activate TD
|
||||||
|
Note right of TD: before_agent 创建目录
|
||||||
|
|
||||||
|
TD ->> UL: before_agent
|
||||||
|
activate UL
|
||||||
|
Note right of UL: before_agent 扫描上传文件
|
||||||
|
|
||||||
|
UL ->> SB: before_agent
|
||||||
|
activate SB
|
||||||
|
Note right of SB: before_agent 获取沙箱
|
||||||
|
|
||||||
|
SB ->> VI: before_model
|
||||||
|
activate VI
|
||||||
|
Note right of VI: before_model 注入图片 base64
|
||||||
|
|
||||||
|
VI ->> M: messages + tools
|
||||||
|
activate M
|
||||||
|
M -->> CL: AI response
|
||||||
|
deactivate M
|
||||||
|
|
||||||
|
activate CL
|
||||||
|
Note right of CL: after_model 拦截 ask_clarification
|
||||||
|
CL -->> SL: after_model
|
||||||
|
deactivate CL
|
||||||
|
|
||||||
|
activate SL
|
||||||
|
Note right of SL: after_model 截断多余 task
|
||||||
|
SL -->> TI: after_model
|
||||||
|
deactivate SL
|
||||||
|
|
||||||
|
activate TI
|
||||||
|
Note right of TI: after_model 生成标题
|
||||||
|
TI -->> SM: after_model
|
||||||
|
deactivate TI
|
||||||
|
|
||||||
|
activate SM
|
||||||
|
Note right of SM: after_model 上下文压缩
|
||||||
|
SM -->> DTC: after_model
|
||||||
|
deactivate SM
|
||||||
|
|
||||||
|
activate DTC
|
||||||
|
Note right of DTC: after_model 补缺失 ToolMessage
|
||||||
|
DTC -->> VI: done
|
||||||
|
deactivate DTC
|
||||||
|
|
||||||
|
VI -->> SB: done
|
||||||
|
deactivate VI
|
||||||
|
|
||||||
|
Note right of SB: after_agent 释放沙箱
|
||||||
|
SB -->> UL: done
|
||||||
|
deactivate SB
|
||||||
|
|
||||||
|
UL -->> TD: done
|
||||||
|
deactivate UL
|
||||||
|
|
||||||
|
Note right of MEM: after_agent 入队记忆
|
||||||
|
|
||||||
|
TD -->> U: response
|
||||||
|
deactivate TD
|
||||||
|
```
|
||||||
|
|
||||||
|
## 洋葱模型
|
||||||
|
|
||||||
|
列表位置决定在洋葱中的层级 — 位置 0 最外层,位置 N 最内层:
|
||||||
|
|
||||||
|
```
|
||||||
|
进入 before_*: [0] → [1] → [2] → ... → [10] → MODEL
|
||||||
|
退出 after_*: MODEL → [13] → [11] → ... → [6] → [3] → [2] → [0]
|
||||||
|
↑ 最内层最先执行
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!important] 核心规则
|
||||||
|
> 列表最后的 middleware,其 `after_model` **最先执行**。
|
||||||
|
> ClarificationMiddleware 在列表末尾,所以它第一个拦截 model 输出。
|
||||||
|
|
||||||
|
## 对比:真正的洋葱 vs DeerFlow 的实际情况
|
||||||
|
|
||||||
|
### 真正的洋葱(如 Koa/Express)
|
||||||
|
|
||||||
|
每个 middleware 同时负责 before 和 after,形成对称嵌套:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant U as User
|
||||||
|
participant A as AuthMiddleware
|
||||||
|
participant L as LogMiddleware
|
||||||
|
participant R as RateLimitMiddleware
|
||||||
|
participant H as Handler
|
||||||
|
|
||||||
|
U ->> A: request
|
||||||
|
activate A
|
||||||
|
Note right of A: before: 校验 token
|
||||||
|
|
||||||
|
A ->> L: next()
|
||||||
|
activate L
|
||||||
|
Note right of L: before: 记录请求时间
|
||||||
|
|
||||||
|
L ->> R: next()
|
||||||
|
activate R
|
||||||
|
Note right of R: before: 检查频率
|
||||||
|
|
||||||
|
R ->> H: next()
|
||||||
|
activate H
|
||||||
|
H -->> R: result
|
||||||
|
deactivate H
|
||||||
|
|
||||||
|
Note right of R: after: 更新计数器
|
||||||
|
R -->> L: result
|
||||||
|
deactivate R
|
||||||
|
|
||||||
|
Note right of L: after: 记录耗时
|
||||||
|
L -->> A: result
|
||||||
|
deactivate L
|
||||||
|
|
||||||
|
Note right of A: after: 清理上下文
|
||||||
|
A -->> U: response
|
||||||
|
deactivate A
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!tip] 洋葱特征
|
||||||
|
> 每个 middleware 都有 before/after 对称操作,`activate` 跨越整个内层执行,形成完美嵌套。
|
||||||
|
|
||||||
|
### DeerFlow 的实际情况
|
||||||
|
|
||||||
|
不是洋葱,是管道。大部分 middleware 只用一个钩子,不存在对称嵌套。多轮对话时 before_model / after_model 循环执行:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant U as User
|
||||||
|
participant TD as ThreadData
|
||||||
|
participant UL as Uploads
|
||||||
|
participant SB as Sandbox
|
||||||
|
participant VI as ViewImage
|
||||||
|
participant M as MODEL
|
||||||
|
participant CL as Clarification
|
||||||
|
participant SL as SubagentLimit
|
||||||
|
participant TI as Title
|
||||||
|
participant SM as Summarization
|
||||||
|
participant MEM as Memory
|
||||||
|
|
||||||
|
U ->> TD: invoke
|
||||||
|
Note right of TD: before_agent 创建目录
|
||||||
|
TD ->> UL: .
|
||||||
|
Note right of UL: before_agent 扫描文件
|
||||||
|
UL ->> SB: .
|
||||||
|
Note right of SB: before_agent 获取沙箱
|
||||||
|
|
||||||
|
loop 每轮对话(tool call 循环)
|
||||||
|
SB ->> VI: .
|
||||||
|
Note right of VI: before_model 注入图片
|
||||||
|
VI ->> M: messages + tools
|
||||||
|
M -->> CL: AI response
|
||||||
|
Note right of CL: after_model 拦截 ask_clarification
|
||||||
|
CL -->> SL: .
|
||||||
|
Note right of SL: after_model 截断多余 task
|
||||||
|
SL -->> TI: .
|
||||||
|
Note right of TI: after_model 生成标题
|
||||||
|
TI -->> SM: .
|
||||||
|
Note right of SM: after_model 上下文压缩
|
||||||
|
end
|
||||||
|
|
||||||
|
Note right of SB: after_agent 释放沙箱
|
||||||
|
SB -->> MEM: .
|
||||||
|
Note right of MEM: after_agent 入队记忆
|
||||||
|
MEM -->> U: response
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!warning] 不是洋葱
|
||||||
|
> 14 个 middleware 中只有 SandboxMiddleware 有 before/after 对称(获取/释放)。其余都是单向的:要么只在 `before_*` 做事,要么只在 `after_*` 做事。`before_agent` / `after_agent` 只跑一次,`before_model` / `after_model` 每轮循环都跑。
|
||||||
|
|
||||||
|
硬依赖只有 2 处:
|
||||||
|
|
||||||
|
1. **ThreadData 在 Sandbox 之前** — sandbox 需要线程目录
|
||||||
|
2. **Clarification 在列表最后** — `after_model` 反序时最先执行,第一个拦截 `ask_clarification`
|
||||||
|
|
||||||
|
### 结论
|
||||||
|
|
||||||
|
| | 真正的洋葱 | DeerFlow 实际 |
|
||||||
|
|---|---|---|
|
||||||
|
| 每个 middleware | before + after 对称 | 大多只用一个钩子 |
|
||||||
|
| 激活条 | 嵌套(外长内短) | 不嵌套(串行) |
|
||||||
|
| 反序的意义 | 清理与初始化配对 | 仅影响 after_model 的执行优先级 |
|
||||||
|
| 典型例子 | Auth: 校验 token / 清理上下文 | ThreadData: 只创建目录,没有清理 |
|
||||||
|
|
||||||
|
## 关键设计点
|
||||||
|
|
||||||
|
### ClarificationMiddleware 为什么在列表最后?
|
||||||
|
|
||||||
|
位置最后 = `after_model` 最先执行。它需要**第一个**看到 model 输出,检查是否有 `ask_clarification` tool call。如果有,立即中断(`Command(goto=END)`),后续 middleware 的 `after_model` 不再执行。
|
||||||
|
|
||||||
|
### SandboxMiddleware 的对称性
|
||||||
|
|
||||||
|
`before_agent`(正序第 3 个)获取沙箱,`after_agent`(反序第 1 个)释放沙箱。外层进入 → 外层退出,天然的洋葱对称。
|
||||||
|
|
||||||
|
### 大部分 middleware 只用一个钩子
|
||||||
|
|
||||||
|
14 个 middleware 中,只有 SandboxMiddleware 同时用了 `before_agent` + `after_agent`(获取/释放)。其余都只在一个阶段执行。洋葱模型的反序特性主要影响 `after_model` 阶段的执行顺序。
|
||||||
503
backend/docs/rfc-create-deerflow-agent.md
Normal file
503
backend/docs/rfc-create-deerflow-agent.md
Normal file
@ -0,0 +1,503 @@
|
|||||||
|
# RFC: `create_deerflow_agent` — 纯参数的 SDK 工厂 API
|
||||||
|
|
||||||
|
## 1. 问题
|
||||||
|
|
||||||
|
当前 harness 的唯一公开入口是 `make_lead_agent(config: RunnableConfig)`。它内部:
|
||||||
|
|
||||||
|
```
|
||||||
|
make_lead_agent
|
||||||
|
├─ get_app_config() ← 读 config.yaml
|
||||||
|
├─ _resolve_model_name() ← 读 config.yaml
|
||||||
|
├─ load_agent_config() ← 读 agents/{name}/config.yaml
|
||||||
|
├─ create_chat_model(name) ← 读 config.yaml(反射加载 model class)
|
||||||
|
├─ get_available_tools() ← 读 config.yaml + extensions_config.json
|
||||||
|
├─ apply_prompt_template() ← 读 skills 目录 + memory.json
|
||||||
|
└─ _build_middlewares() ← 读 config.yaml(summarization、model vision)
|
||||||
|
```
|
||||||
|
|
||||||
|
**6 处隐式 I/O** — 全部依赖文件系统。如果你想把 `deerflow-harness` 当 Python 库嵌入自己的应用,你必须准备 `config.yaml` + `extensions_config.json` + skills 目录。这对 SDK 用户是不可接受的。
|
||||||
|
|
||||||
|
### 对比
|
||||||
|
|
||||||
|
| | `langchain.create_agent` | `make_lead_agent` | `DeerFlowClient`(增强后) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 定位 | 底层原语 | 内部工厂 | **唯一公开 API** |
|
||||||
|
| 配置来源 | 纯参数 | YAML 文件 | **参数优先,config fallback** |
|
||||||
|
| 内置能力 | 无 | Sandbox/Memory/Skills/Subagent/... | **按需组合 + 管理 API** |
|
||||||
|
| 用户接口 | `graph.invoke(state)` | 内部使用 | **`client.chat("hello")`** |
|
||||||
|
| 适合谁 | 写 LangChain 的人 | 内部使用 | **所有 DeerFlow 用户** |
|
||||||
|
|
||||||
|
## 2. 设计原则
|
||||||
|
|
||||||
|
### Python 中的 DI 最佳实践
|
||||||
|
|
||||||
|
1. **函数参数即注入** — 不读全局状态,所有依赖通过参数传入
|
||||||
|
2. **Protocol 定义契约** — 不依赖具体类,依赖行为接口
|
||||||
|
3. **合理默认值** — `sandbox=True` 等价于 `sandbox=LocalSandboxProvider()`
|
||||||
|
4. **分层 API** — 简单用法一行搞定,复杂用法有逃生舱
|
||||||
|
|
||||||
|
### 分层架构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────┐
|
||||||
|
│ DeerFlowClient │ ← 唯一公开 API(chat/stream + 管理)
|
||||||
|
└──────────┬───────────┘
|
||||||
|
┌──────────▼───────────┐
|
||||||
|
│ make_lead_agent │ ← 内部:配置驱动工厂
|
||||||
|
└──────────┬───────────┘
|
||||||
|
┌──────────▼───────────┐
|
||||||
|
│ create_deerflow_agent │ ← 内部:纯参数工厂
|
||||||
|
└──────────┬───────────┘
|
||||||
|
┌──────────▼───────────┐
|
||||||
|
│ langchain.create_agent│ ← 底层原语
|
||||||
|
└──────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
`DeerFlowClient` 是唯一公开 API。`create_deerflow_agent` 和 `make_lead_agent` 都是内部实现。
|
||||||
|
|
||||||
|
用户通过 `DeerFlowClient` 三个参数控制行为:
|
||||||
|
|
||||||
|
| 参数 | 类型 | 职责 |
|
||||||
|
|------|------|------|
|
||||||
|
| `config` | `dict` | 覆盖 config.yaml 的任意配置项 |
|
||||||
|
| `features` | `RuntimeFeatures` | 替换内置 middleware 实现 |
|
||||||
|
| `extra_middleware` | `list[AgentMiddleware]` | 新增用户 middleware |
|
||||||
|
|
||||||
|
不传参数 → 读 config.yaml(现有行为,完全兼容)。
|
||||||
|
|
||||||
|
### 核心约束
|
||||||
|
|
||||||
|
- **配置覆盖** — `config` dict > config.yaml > 默认值
|
||||||
|
- **三层不重叠** — config 传参数,features 传实例,extra_middleware 传新增
|
||||||
|
- **向前兼容** — 现有 `DeerFlowClient()` 无参构造行为不变
|
||||||
|
- **harness 边界合规** — 不 import `app.*`(`test_harness_boundary.py` 强制)
|
||||||
|
|
||||||
|
## 3. API 设计
|
||||||
|
|
||||||
|
### 3.1 `DeerFlowClient` — 唯一公开 API
|
||||||
|
|
||||||
|
在现有构造函数上增加三个可选参数:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from deerflow.client import DeerFlowClient
|
||||||
|
from deerflow.agents.features import RuntimeFeatures
|
||||||
|
|
||||||
|
client = DeerFlowClient(
|
||||||
|
# 1. config — 覆盖 config.yaml 的任意 key(结构和 yaml 一致)
|
||||||
|
config={
|
||||||
|
"models": [{"name": "gpt-4o", "use": "langchain_openai:ChatOpenAI", "model": "gpt-4o", "api_key": "sk-..."}],
|
||||||
|
"memory": {"max_facts": 50, "enabled": True},
|
||||||
|
"title": {"enabled": False},
|
||||||
|
"summarization": {"enabled": True, "trigger": [{"type": "tokens", "value": 10000}]},
|
||||||
|
"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"},
|
||||||
|
},
|
||||||
|
|
||||||
|
# 2. features — 替换内置 middleware 实现
|
||||||
|
features=RuntimeFeatures(
|
||||||
|
memory=MyMemoryMiddleware(),
|
||||||
|
auto_title=MyTitleMiddleware(),
|
||||||
|
),
|
||||||
|
|
||||||
|
# 3. extra_middleware — 新增用户 middleware
|
||||||
|
extra_middleware=[
|
||||||
|
MyAuditMiddleware(), # @Next(SandboxMiddleware)
|
||||||
|
MyFilterMiddleware(), # @Prev(ClarificationMiddleware)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
三种典型用法:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 用法 1:全读 config.yaml(现有行为,不变)
|
||||||
|
client = DeerFlowClient()
|
||||||
|
|
||||||
|
# 用法 2:只改参数,不换实现
|
||||||
|
client = DeerFlowClient(config={"memory": {"max_facts": 50}})
|
||||||
|
|
||||||
|
# 用法 3:替换 middleware 实现
|
||||||
|
client = DeerFlowClient(features=RuntimeFeatures(auto_title=MyTitleMiddleware()))
|
||||||
|
|
||||||
|
# 用法 4:添加自定义 middleware
|
||||||
|
client = DeerFlowClient(extra_middleware=[MyAuditMiddleware()])
|
||||||
|
|
||||||
|
# 用法 5:纯 SDK(无 config.yaml)
|
||||||
|
client = DeerFlowClient(config={
|
||||||
|
"models": [{"name": "gpt-4o", "use": "langchain_openai:ChatOpenAI", ...}],
|
||||||
|
"tools": [{"name": "bash", "use": "deerflow.sandbox.tools:bash_tool", "group": "bash"}],
|
||||||
|
"memory": {"enabled": True},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
内部实现:`final_config = deep_merge(file_config, code_config)`
|
||||||
|
|
||||||
|
### 3.2 `create_deerflow_agent` — 内部工厂(不公开)
|
||||||
|
|
||||||
|
```python
|
||||||
|
def create_deerflow_agent(
|
||||||
|
model: BaseChatModel,
|
||||||
|
tools: list[BaseTool] | None = None,
|
||||||
|
*,
|
||||||
|
system_prompt: str | None = None,
|
||||||
|
middleware: list[AgentMiddleware] | None = None,
|
||||||
|
features: RuntimeFeatures | None = None,
|
||||||
|
state_schema: type | None = None,
|
||||||
|
checkpointer: BaseCheckpointSaver | None = None,
|
||||||
|
name: str = "default",
|
||||||
|
) -> CompiledStateGraph:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
`DeerFlowClient` 内部调用此函数。
|
||||||
|
|
||||||
|
### 3.3 `RuntimeFeatures` — 内置 Middleware 替换
|
||||||
|
|
||||||
|
只做一件事:用自定义实例替换内置 middleware。不管配置参数(参数走 `config` dict)。
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class RuntimeFeatures:
|
||||||
|
sandbox: bool | AgentMiddleware = True
|
||||||
|
memory: bool | AgentMiddleware = False
|
||||||
|
summarization: bool | AgentMiddleware = False
|
||||||
|
subagent: bool | AgentMiddleware = False
|
||||||
|
vision: bool | AgentMiddleware = False
|
||||||
|
auto_title: bool | AgentMiddleware = False
|
||||||
|
```
|
||||||
|
|
||||||
|
| 值 | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| `True` | 使用默认 middleware(参数从 config 读) |
|
||||||
|
| `False` | 关闭该功能 |
|
||||||
|
| `AgentMiddleware` 实例 | 替换整个实现 |
|
||||||
|
|
||||||
|
不再有 `MemoryOptions`、`TitleOptions` 等。参数调整走 `config` dict:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 改 memory 参数 → config
|
||||||
|
client = DeerFlowClient(config={"memory": {"max_facts": 50}})
|
||||||
|
|
||||||
|
# 换 memory 实现 → features
|
||||||
|
client = DeerFlowClient(features=RuntimeFeatures(memory=MyMemoryMiddleware()))
|
||||||
|
|
||||||
|
# 两者组合 — config 参数给默认 middleware,但 title 换实现
|
||||||
|
client = DeerFlowClient(
|
||||||
|
config={"memory": {"max_facts": 50}},
|
||||||
|
features=RuntimeFeatures(auto_title=MyTitleMiddleware()),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 Middleware 链组装
|
||||||
|
|
||||||
|
不使用 priority 数字排序。按固定顺序 append 构建列表:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _resolve(spec, default_cls):
|
||||||
|
"""bool → 默认实现 / AgentMiddleware → 替换"""
|
||||||
|
if isinstance(spec, AgentMiddleware):
|
||||||
|
return spec
|
||||||
|
return default_cls()
|
||||||
|
|
||||||
|
def _assemble_from_features(feat: RuntimeFeatures, config: AppConfig) -> tuple[list, list]:
|
||||||
|
chain = []
|
||||||
|
extra_tools = []
|
||||||
|
|
||||||
|
if feat.sandbox:
|
||||||
|
chain.append(_resolve(feat.sandbox, ThreadDataMiddleware))
|
||||||
|
chain.append(UploadsMiddleware())
|
||||||
|
chain.append(_resolve(feat.sandbox, SandboxMiddleware))
|
||||||
|
|
||||||
|
chain.append(DanglingToolCallMiddleware())
|
||||||
|
chain.append(ToolErrorHandlingMiddleware())
|
||||||
|
|
||||||
|
if feat.summarization:
|
||||||
|
chain.append(_resolve(feat.summarization, SummarizationMiddleware))
|
||||||
|
if config.title.enabled and feat.auto_title is not False:
|
||||||
|
chain.append(_resolve(feat.auto_title, TitleMiddleware))
|
||||||
|
if feat.memory:
|
||||||
|
chain.append(_resolve(feat.memory, MemoryMiddleware))
|
||||||
|
if feat.vision:
|
||||||
|
chain.append(ViewImageMiddleware())
|
||||||
|
extra_tools.append(view_image_tool)
|
||||||
|
if feat.subagent:
|
||||||
|
chain.append(_resolve(feat.subagent, SubagentLimitMiddleware))
|
||||||
|
extra_tools.append(task_tool)
|
||||||
|
if feat.loop_detection:
|
||||||
|
chain.append(_resolve(feat.loop_detection, LoopDetectionMiddleware))
|
||||||
|
|
||||||
|
# 插入 extra_middleware(按 @Next/@Prev 声明定位)
|
||||||
|
_insert_extra(chain, extra_middleware)
|
||||||
|
|
||||||
|
# Clarification 永远最后
|
||||||
|
chain.append(ClarificationMiddleware())
|
||||||
|
extra_tools.append(ask_clarification_tool)
|
||||||
|
|
||||||
|
return chain, extra_tools
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.6 Middleware 排序策略
|
||||||
|
|
||||||
|
**两阶段排序:内置固定 + 外置插入**
|
||||||
|
|
||||||
|
1. **内置链固定顺序** — 按代码中的 append 顺序确定,不参与 @Next/@Prev
|
||||||
|
2. **外置 middleware 插入** — `extra_middleware` 中的 middleware 通过 @Next/@Prev 声明锚点,自由锚定任意 middleware(内置或其他外置均可)
|
||||||
|
3. **冲突检测** — 两个外置 middleware 如果 @Next 或 @Prev 同一个目标 → `ValueError`
|
||||||
|
|
||||||
|
**这不是全排序。** 内置链的顺序在代码中已确定,外置 middleware 只做插入操作。这样可以避免内置和外置同时竞争同一个位置的问题。
|
||||||
|
|
||||||
|
### 3.7 `@Next` / `@Prev` 装饰器
|
||||||
|
|
||||||
|
用户自定义 middleware 通过装饰器声明在链中的位置,类型安全:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from deerflow.agents import Next, Prev
|
||||||
|
|
||||||
|
@Next(SandboxMiddleware)
|
||||||
|
class MyAuditMiddleware(AgentMiddleware):
|
||||||
|
"""排在 SandboxMiddleware 后面"""
|
||||||
|
def before_agent(self, state, runtime):
|
||||||
|
...
|
||||||
|
|
||||||
|
@Prev(ClarificationMiddleware)
|
||||||
|
class MyFilterMiddleware(AgentMiddleware):
|
||||||
|
"""排在 ClarificationMiddleware 前面"""
|
||||||
|
def after_model(self, state, runtime):
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
实现:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def Next(anchor: type[AgentMiddleware]):
|
||||||
|
"""装饰器:声明本 middleware 排在 anchor 的下一个位置。"""
|
||||||
|
def decorator(cls: type[AgentMiddleware]) -> type[AgentMiddleware]:
|
||||||
|
cls._next_anchor = anchor
|
||||||
|
return cls
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
def Prev(anchor: type[AgentMiddleware]):
|
||||||
|
"""装饰器:声明本 middleware 排在 anchor 的前一个位置。"""
|
||||||
|
def decorator(cls: type[AgentMiddleware]) -> type[AgentMiddleware]:
|
||||||
|
cls._prev_anchor = anchor
|
||||||
|
return cls
|
||||||
|
return decorator
|
||||||
|
```
|
||||||
|
|
||||||
|
`_insert_extra` 算法:
|
||||||
|
|
||||||
|
1. 遍历 `extra_middleware`,读取每个 middleware 的 `_next_anchor` / `_prev_anchor`
|
||||||
|
2. **冲突检测**:如果两个外置 middleware 的锚点相同(同方向同目标),抛出 `ValueError`
|
||||||
|
3. 有锚点的 middleware 插入到目标位置(@Next → 目标之后,@Prev → 目标之前)
|
||||||
|
4. 无声明的 middleware 追加到 Clarification 之前
|
||||||
|
|
||||||
|
## 4. Middleware 执行模型
|
||||||
|
|
||||||
|
### LangChain 的执行规则
|
||||||
|
|
||||||
|
```
|
||||||
|
before_agent 正序 → [0] → [1] → ... → [N]
|
||||||
|
before_model 正序 → [0] → [1] → ... → [N] ← 每轮循环
|
||||||
|
MODEL
|
||||||
|
after_model 反序 ← [N] → [N-1] → ... → [0] ← 每轮循环
|
||||||
|
after_agent 反序 ← [N] → [N-1] → ... → [0]
|
||||||
|
```
|
||||||
|
|
||||||
|
`before_agent` / `after_agent` 只跑一次。`before_model` / `after_model` 每轮 tool call 循环都跑。
|
||||||
|
|
||||||
|
### DeerFlow 的实际情况
|
||||||
|
|
||||||
|
**不是洋葱,是管道。** 11 个 middleware 中只有 SandboxMiddleware 有 before/after 对称(获取/释放),其余只用一个钩子。
|
||||||
|
|
||||||
|
硬依赖只有 2 处:
|
||||||
|
1. **ThreadData 在 Sandbox 之前** — sandbox 需要线程目录
|
||||||
|
2. **Clarification 在列表最后** — after_model 反序时最先执行,第一个拦截 `ask_clarification`
|
||||||
|
|
||||||
|
详见 [middleware-execution-flow.md](middleware-execution-flow.md)。
|
||||||
|
|
||||||
|
## 5. 使用示例
|
||||||
|
|
||||||
|
### 5.1 全读 config.yaml(现有行为不变)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from deerflow.client import DeerFlowClient
|
||||||
|
|
||||||
|
client = DeerFlowClient()
|
||||||
|
response = client.chat("Hello")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 覆盖配置参数
|
||||||
|
|
||||||
|
```python
|
||||||
|
client = DeerFlowClient(config={
|
||||||
|
"memory": {"max_facts": 50},
|
||||||
|
"title": {"enabled": False},
|
||||||
|
"summarization": {"trigger": [{"type": "tokens", "value": 10000}]},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 纯 SDK(无 config.yaml)
|
||||||
|
|
||||||
|
```python
|
||||||
|
client = DeerFlowClient(config={
|
||||||
|
"models": [{"name": "gpt-4o", "use": "langchain_openai:ChatOpenAI", "model": "gpt-4o", "api_key": "sk-..."}],
|
||||||
|
"tools": [
|
||||||
|
{"name": "bash", "group": "bash", "use": "deerflow.sandbox.tools:bash_tool"},
|
||||||
|
{"name": "web_search", "group": "web", "use": "deerflow.community.tavily.tools:web_search_tool"},
|
||||||
|
],
|
||||||
|
"memory": {"enabled": True, "max_facts": 50},
|
||||||
|
"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 替换内置 middleware
|
||||||
|
|
||||||
|
```python
|
||||||
|
from deerflow.agents.features import RuntimeFeatures
|
||||||
|
|
||||||
|
client = DeerFlowClient(
|
||||||
|
features=RuntimeFeatures(
|
||||||
|
memory=MyMemoryMiddleware(), # 替换
|
||||||
|
auto_title=MyTitleMiddleware(), # 替换
|
||||||
|
vision=False, # 关闭
|
||||||
|
),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.5 插入自定义 middleware
|
||||||
|
|
||||||
|
```python
|
||||||
|
from deerflow.agents import Next, Prev
|
||||||
|
from deerflow.sandbox.middleware import SandboxMiddleware
|
||||||
|
from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware
|
||||||
|
|
||||||
|
@Next(SandboxMiddleware)
|
||||||
|
class MyAuditMiddleware(AgentMiddleware):
|
||||||
|
def before_agent(self, state, runtime):
|
||||||
|
log_sandbox_acquired(state)
|
||||||
|
|
||||||
|
@Prev(ClarificationMiddleware)
|
||||||
|
class MyFilterMiddleware(AgentMiddleware):
|
||||||
|
def after_model(self, state, runtime):
|
||||||
|
filter_sensitive_output(state)
|
||||||
|
|
||||||
|
client = DeerFlowClient(
|
||||||
|
extra_middleware=[MyAuditMiddleware(), MyFilterMiddleware()],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Phase 1 限制
|
||||||
|
|
||||||
|
当前实现中以下 middleware 内部仍读 `config.yaml`,SDK 用户需注意:
|
||||||
|
|
||||||
|
| Middleware | 读取内容 | Phase 2 解决方案 |
|
||||||
|
|------------|---------|-----------------|
|
||||||
|
| TitleMiddleware | `get_title_config()` + `create_chat_model()` | `TitleOptions(model=...)` 参数覆盖 |
|
||||||
|
| MemoryMiddleware | `get_memory_config()` | `MemoryOptions(...)` 参数覆盖 |
|
||||||
|
| SandboxMiddleware | `get_sandbox_provider()` | `SandboxProvider` 实例直传 |
|
||||||
|
|
||||||
|
Phase 1 中 `auto_title` 默认为 `False` 以避免无 config 时崩溃。其他有 config 依赖的 feature 默认也为 `False`。
|
||||||
|
|
||||||
|
## 7. 迁移路径
|
||||||
|
|
||||||
|
```
|
||||||
|
Phase 1(当前 PR #1203):
|
||||||
|
✓ 新增 create_deerflow_agent + RuntimeFeatures(内部 API)
|
||||||
|
✓ 不改 DeerFlowClient 和 make_lead_agent
|
||||||
|
✗ middleware 内部仍读 config(已知限制)
|
||||||
|
|
||||||
|
Phase 2(#1380):
|
||||||
|
- DeerFlowClient 构造函数增加可选参数(model, tools, features, system_prompt)
|
||||||
|
- Options 参数覆盖 config(MemoryOptions, TitleOptions 等)
|
||||||
|
- @Next/@Prev 装饰器
|
||||||
|
- 补缺失 middleware(Guardrail, TokenUsage, DeferredToolFilter)
|
||||||
|
- make_lead_agent 改为薄壳调 create_deerflow_agent
|
||||||
|
|
||||||
|
Phase 3:
|
||||||
|
- SDK 文档和示例
|
||||||
|
- deerflow.client 稳定 API
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. 设计决议
|
||||||
|
|
||||||
|
| 问题 | 决议 | 理由 |
|
||||||
|
|------|------|------|
|
||||||
|
| 公开 API | `DeerFlowClient` 唯一入口 | 自顶向下,先改现有 API 再抽底层 |
|
||||||
|
| create_deerflow_agent | 内部实现,不公开 | 用户不需要接触 CompiledStateGraph |
|
||||||
|
| 配置覆盖 | `config` dict,和 config.yaml 结构一致 | 无新概念,deep merge 覆盖 |
|
||||||
|
| middleware 替换 | `features=RuntimeFeatures(memory=MyMW())` | bool 开关 + 实例替换 |
|
||||||
|
| middleware 扩展 | `extra_middleware` 独立参数 | 和内置 features 分开 |
|
||||||
|
| middleware 定位 | `@Next/@Prev` 装饰器 | 类型安全,不暴露排序细节 |
|
||||||
|
| 排序机制 | 顺序 append + @Next/@Prev | priority 数字无功能意义 |
|
||||||
|
| 运行时开关 | 保留 `RunnableConfig` | plan_mode、thread_id 等按请求切换 |
|
||||||
|
|
||||||
|
## 9. 附录:Middleware 链
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph BA ["before_agent 正序"]
|
||||||
|
direction TB
|
||||||
|
TD["ThreadData<br/>创建目录"] --> UL["Uploads<br/>扫描文件"] --> SB["Sandbox<br/>获取沙箱"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph BM ["before_model 正序 每轮"]
|
||||||
|
direction TB
|
||||||
|
VI["ViewImage<br/>注入图片"]
|
||||||
|
end
|
||||||
|
|
||||||
|
SB --> VI
|
||||||
|
VI --> M["MODEL"]
|
||||||
|
|
||||||
|
subgraph AM ["after_model 反序 每轮"]
|
||||||
|
direction TB
|
||||||
|
CL["Clarification<br/>拦截中断"] --> LD["LoopDetection<br/>检测循环"] --> SL["SubagentLimit<br/>截断 task"] --> TI["Title<br/>生成标题"] --> DTC["DanglingToolCall<br/>补缺失消息"]
|
||||||
|
end
|
||||||
|
|
||||||
|
M --> CL
|
||||||
|
|
||||||
|
subgraph AA ["after_agent 反序"]
|
||||||
|
direction TB
|
||||||
|
SBR["Sandbox<br/>释放沙箱"] --> MEM["Memory<br/>入队记忆"]
|
||||||
|
end
|
||||||
|
|
||||||
|
DTC --> SBR
|
||||||
|
|
||||||
|
classDef beforeNode fill:#a0a8b5,stroke:#636b7a,color:#2d3239
|
||||||
|
classDef modelNode fill:#b5a8a0,stroke:#7a6b63,color:#2d3239
|
||||||
|
classDef afterModelNode fill:#b5a0a8,stroke:#7a636b,color:#2d3239
|
||||||
|
classDef afterAgentNode fill:#a0b5a8,stroke:#637a6b,color:#2d3239
|
||||||
|
|
||||||
|
class TD,UL,SB,VI beforeNode
|
||||||
|
class M modelNode
|
||||||
|
class CL,LD,SL,TI,DTC afterModelNode
|
||||||
|
class SBR,MEM afterAgentNode
|
||||||
|
```
|
||||||
|
|
||||||
|
硬依赖:
|
||||||
|
- ThreadData → Uploads → Sandbox(before_agent 阶段)
|
||||||
|
- Clarification 必须在列表最后(after_model 反序时最先执行)
|
||||||
|
|
||||||
|
## 10. 主 Agent 与 Subagent 的 Middleware 差异
|
||||||
|
|
||||||
|
主 agent 和 subagent 共享基础 middleware 链(`_build_runtime_middlewares`),subagent 在此基础上做精简:
|
||||||
|
|
||||||
|
| Middleware | 主 Agent | Subagent | 说明 |
|
||||||
|
|------------|:-------:|:--------:|------|
|
||||||
|
| ThreadDataMiddleware | ✓ | ✓ | 共享:创建线程目录 |
|
||||||
|
| UploadsMiddleware | ✓ | ✗ | 主 agent 独有:扫描上传文件 |
|
||||||
|
| SandboxMiddleware | ✓ | ✓ | 共享:获取/释放沙箱 |
|
||||||
|
| DanglingToolCallMiddleware | ✓ | ✗ | 主 agent 独有:补缺失 ToolMessage |
|
||||||
|
| GuardrailMiddleware | ✓ | ✓ | 共享:工具调用授权(可选) |
|
||||||
|
| ToolErrorHandlingMiddleware | ✓ | ✓ | 共享:工具异常处理 |
|
||||||
|
| SummarizationMiddleware | ✓ | ✗ | |
|
||||||
|
| TodoMiddleware | ✓ | ✗ | |
|
||||||
|
| TitleMiddleware | ✓ | ✗ | |
|
||||||
|
| MemoryMiddleware | ✓ | ✗ | |
|
||||||
|
| ViewImageMiddleware | ✓ | ✗ | |
|
||||||
|
| SubagentLimitMiddleware | ✓ | ✗ | |
|
||||||
|
| LoopDetectionMiddleware | ✓ | ✗ | |
|
||||||
|
| ClarificationMiddleware | ✓ | ✗ | |
|
||||||
|
|
||||||
|
**设计原则**:
|
||||||
|
- `RuntimeFeatures`、`@Next/@Prev`、排序机制只作用于**主 agent**
|
||||||
|
- Subagent 链短且固定(4 个),不需要动态组装
|
||||||
|
- `extra_middleware` 当前只影响主 agent,不传递给 subagent
|
||||||
@ -1,5 +1,18 @@
|
|||||||
from .checkpointer import get_checkpointer, make_checkpointer, reset_checkpointer
|
from .checkpointer import get_checkpointer, make_checkpointer, reset_checkpointer
|
||||||
|
from .factory import create_deerflow_agent
|
||||||
|
from .features import Next, Prev, RuntimeFeatures
|
||||||
from .lead_agent import make_lead_agent
|
from .lead_agent import make_lead_agent
|
||||||
from .thread_state import SandboxState, ThreadState
|
from .thread_state import SandboxState, ThreadState
|
||||||
|
|
||||||
__all__ = ["make_lead_agent", "SandboxState", "ThreadState", "get_checkpointer", "reset_checkpointer", "make_checkpointer"]
|
__all__ = [
|
||||||
|
"create_deerflow_agent",
|
||||||
|
"RuntimeFeatures",
|
||||||
|
"Next",
|
||||||
|
"Prev",
|
||||||
|
"make_lead_agent",
|
||||||
|
"SandboxState",
|
||||||
|
"ThreadState",
|
||||||
|
"get_checkpointer",
|
||||||
|
"reset_checkpointer",
|
||||||
|
"make_checkpointer",
|
||||||
|
]
|
||||||
|
|||||||
392
backend/packages/harness/deerflow/agents/factory.py
Normal file
392
backend/packages/harness/deerflow/agents/factory.py
Normal file
@ -0,0 +1,392 @@
|
|||||||
|
"""Pure-argument factory for DeerFlow agents.
|
||||||
|
|
||||||
|
``create_deerflow_agent`` accepts plain Python arguments — no YAML files, no
|
||||||
|
global singletons. It is the SDK-level entry point sitting between the raw
|
||||||
|
``langchain.agents.create_agent`` primitive and the config-driven
|
||||||
|
``make_lead_agent`` application factory.
|
||||||
|
|
||||||
|
Note: the factory assembly itself is config-free, but some injected runtime
|
||||||
|
components (e.g. ``task_tool`` for subagent) may still read global config at
|
||||||
|
invocation time. Full config-free runtime is a Phase 2 goal.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from langchain.agents import create_agent
|
||||||
|
from langchain.agents.middleware import AgentMiddleware
|
||||||
|
|
||||||
|
from deerflow.agents.features import RuntimeFeatures
|
||||||
|
from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware
|
||||||
|
from deerflow.agents.middlewares.dangling_tool_call_middleware import DanglingToolCallMiddleware
|
||||||
|
from deerflow.agents.middlewares.tool_error_handling_middleware import ToolErrorHandlingMiddleware
|
||||||
|
from deerflow.agents.thread_state import ThreadState
|
||||||
|
from deerflow.tools.builtins import ask_clarification_tool
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from langchain_core.language_models import BaseChatModel
|
||||||
|
from langchain_core.tools import BaseTool
|
||||||
|
from langgraph.checkpoint.base import BaseCheckpointSaver
|
||||||
|
from langgraph.graph.state import CompiledStateGraph
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TodoMiddleware prompts (minimal SDK version)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_TODO_SYSTEM_PROMPT = """
|
||||||
|
<todo_list_system>
|
||||||
|
You have access to the `write_todos` tool to help you manage and track complex multi-step objectives.
|
||||||
|
|
||||||
|
**CRITICAL RULES:**
|
||||||
|
- Mark todos as completed IMMEDIATELY after finishing each step - do NOT batch completions
|
||||||
|
- Keep EXACTLY ONE task as `in_progress` at any time (unless tasks can run in parallel)
|
||||||
|
- Update the todo list in REAL-TIME as you work - this gives users visibility into your progress
|
||||||
|
- DO NOT use this tool for simple tasks (< 3 steps) - just complete them directly
|
||||||
|
</todo_list_system>
|
||||||
|
"""
|
||||||
|
|
||||||
|
_TODO_TOOL_DESCRIPTION = (
|
||||||
|
"Use this tool to create and manage a structured task list for complex work sessions. "
|
||||||
|
"Only use for complex tasks (3+ steps)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def create_deerflow_agent(
|
||||||
|
model: BaseChatModel,
|
||||||
|
tools: list[BaseTool] | None = None,
|
||||||
|
*,
|
||||||
|
system_prompt: str | None = None,
|
||||||
|
middleware: list[AgentMiddleware] | None = None,
|
||||||
|
features: RuntimeFeatures | None = None,
|
||||||
|
extra_middleware: list[AgentMiddleware] | None = None,
|
||||||
|
plan_mode: bool = False,
|
||||||
|
state_schema: type | None = None,
|
||||||
|
checkpointer: BaseCheckpointSaver | None = None,
|
||||||
|
name: str = "default",
|
||||||
|
) -> CompiledStateGraph:
|
||||||
|
"""Create a DeerFlow agent from plain Python arguments.
|
||||||
|
|
||||||
|
The factory assembly itself reads no config files. Some injected runtime
|
||||||
|
components (e.g. ``task_tool``) may still depend on global config at
|
||||||
|
invocation time — see Phase 2 roadmap for full config-free runtime.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
model:
|
||||||
|
Chat model instance.
|
||||||
|
tools:
|
||||||
|
User-provided tools. Feature-injected tools are appended automatically.
|
||||||
|
system_prompt:
|
||||||
|
System message. ``None`` uses a minimal default.
|
||||||
|
middleware:
|
||||||
|
**Full takeover** — if provided, this exact list is used.
|
||||||
|
Cannot be combined with *features* or *extra_middleware*.
|
||||||
|
features:
|
||||||
|
Declarative feature flags. Cannot be combined with *middleware*.
|
||||||
|
extra_middleware:
|
||||||
|
Additional middlewares inserted into the auto-assembled chain via
|
||||||
|
``@Next``/``@Prev`` positioning. Cannot be used with *middleware*.
|
||||||
|
plan_mode:
|
||||||
|
Enable TodoMiddleware for task tracking.
|
||||||
|
state_schema:
|
||||||
|
LangGraph state type. Defaults to ``ThreadState``.
|
||||||
|
checkpointer:
|
||||||
|
Optional persistence backend.
|
||||||
|
name:
|
||||||
|
Agent name (passed to middleware that cares, e.g. ``MemoryMiddleware``).
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
ValueError
|
||||||
|
If both *middleware* and *features*/*extra_middleware* are provided.
|
||||||
|
"""
|
||||||
|
if middleware is not None and features is not None:
|
||||||
|
raise ValueError("Cannot specify both 'middleware' and 'features'. Use one or the other.")
|
||||||
|
if middleware is not None and extra_middleware:
|
||||||
|
raise ValueError("Cannot use 'extra_middleware' with 'middleware' (full takeover).")
|
||||||
|
if extra_middleware:
|
||||||
|
for mw in extra_middleware:
|
||||||
|
if not isinstance(mw, AgentMiddleware):
|
||||||
|
raise TypeError(f"extra_middleware items must be AgentMiddleware instances, got {type(mw).__name__}")
|
||||||
|
|
||||||
|
effective_tools: list[BaseTool] = list(tools or [])
|
||||||
|
effective_state = state_schema or ThreadState
|
||||||
|
|
||||||
|
if middleware is not None:
|
||||||
|
effective_middleware = list(middleware)
|
||||||
|
else:
|
||||||
|
feat = features or RuntimeFeatures()
|
||||||
|
effective_middleware, extra_tools = _assemble_from_features(
|
||||||
|
feat, name=name, plan_mode=plan_mode, extra_middleware=extra_middleware or [],
|
||||||
|
)
|
||||||
|
# Deduplicate by tool name — user-provided tools take priority.
|
||||||
|
existing_names = {t.name for t in effective_tools}
|
||||||
|
for t in extra_tools:
|
||||||
|
if t.name not in existing_names:
|
||||||
|
effective_tools.append(t)
|
||||||
|
existing_names.add(t.name)
|
||||||
|
|
||||||
|
return create_agent(
|
||||||
|
model=model,
|
||||||
|
tools=effective_tools or None,
|
||||||
|
middleware=effective_middleware,
|
||||||
|
system_prompt=system_prompt,
|
||||||
|
state_schema=effective_state,
|
||||||
|
checkpointer=checkpointer,
|
||||||
|
name=name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Internal: feature-driven middleware assembly
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _assemble_from_features(
|
||||||
|
feat: RuntimeFeatures,
|
||||||
|
*,
|
||||||
|
name: str = "default",
|
||||||
|
plan_mode: bool = False,
|
||||||
|
extra_middleware: list[AgentMiddleware] | None = None,
|
||||||
|
) -> tuple[list[AgentMiddleware], list[BaseTool]]:
|
||||||
|
"""Build an ordered middleware chain + extra tools from *feat*.
|
||||||
|
|
||||||
|
Middleware order matches ``make_lead_agent`` (14 middlewares):
|
||||||
|
|
||||||
|
0-2. Sandbox infrastructure (ThreadData → Uploads → Sandbox)
|
||||||
|
3. DanglingToolCallMiddleware (always)
|
||||||
|
4. GuardrailMiddleware (guardrail feature)
|
||||||
|
5. ToolErrorHandlingMiddleware (always)
|
||||||
|
6. SummarizationMiddleware (summarization feature)
|
||||||
|
7. TodoMiddleware (plan_mode parameter)
|
||||||
|
8. TitleMiddleware (auto_title feature)
|
||||||
|
9. MemoryMiddleware (memory feature)
|
||||||
|
10. ViewImageMiddleware (vision feature)
|
||||||
|
11. SubagentLimitMiddleware (subagent feature)
|
||||||
|
12. LoopDetectionMiddleware (always)
|
||||||
|
13. ClarificationMiddleware (always last)
|
||||||
|
|
||||||
|
Two-phase ordering:
|
||||||
|
1. Built-in chain — fixed sequential append.
|
||||||
|
2. Extra middleware — inserted via @Next/@Prev.
|
||||||
|
|
||||||
|
Each feature value is handled as:
|
||||||
|
- ``False``: skip
|
||||||
|
- ``True``: create the built-in default middleware (not available for
|
||||||
|
``summarization`` and ``guardrail`` — these require a custom instance)
|
||||||
|
- ``AgentMiddleware`` instance: use directly (custom replacement)
|
||||||
|
"""
|
||||||
|
chain: list[AgentMiddleware] = []
|
||||||
|
extra_tools: list[BaseTool] = []
|
||||||
|
|
||||||
|
# --- [0-2] Sandbox infrastructure ---
|
||||||
|
if feat.sandbox is not False:
|
||||||
|
if isinstance(feat.sandbox, AgentMiddleware):
|
||||||
|
chain.append(feat.sandbox)
|
||||||
|
else:
|
||||||
|
from deerflow.agents.middlewares.thread_data_middleware import ThreadDataMiddleware
|
||||||
|
from deerflow.agents.middlewares.uploads_middleware import UploadsMiddleware
|
||||||
|
from deerflow.sandbox.middleware import SandboxMiddleware
|
||||||
|
|
||||||
|
chain.append(ThreadDataMiddleware(lazy_init=True))
|
||||||
|
chain.append(UploadsMiddleware())
|
||||||
|
chain.append(SandboxMiddleware(lazy_init=True))
|
||||||
|
|
||||||
|
# --- [3] DanglingToolCall (always) ---
|
||||||
|
chain.append(DanglingToolCallMiddleware())
|
||||||
|
|
||||||
|
# --- [4] Guardrail ---
|
||||||
|
if feat.guardrail is not False:
|
||||||
|
if isinstance(feat.guardrail, AgentMiddleware):
|
||||||
|
chain.append(feat.guardrail)
|
||||||
|
else:
|
||||||
|
raise ValueError("guardrail=True requires a custom AgentMiddleware instance (no built-in GuardrailMiddleware yet)")
|
||||||
|
|
||||||
|
# --- [5] ToolErrorHandling (always) ---
|
||||||
|
chain.append(ToolErrorHandlingMiddleware())
|
||||||
|
|
||||||
|
# --- [6] Summarization ---
|
||||||
|
if feat.summarization is not False:
|
||||||
|
if isinstance(feat.summarization, AgentMiddleware):
|
||||||
|
chain.append(feat.summarization)
|
||||||
|
else:
|
||||||
|
raise ValueError("summarization=True requires a custom AgentMiddleware instance (SummarizationMiddleware needs a model argument)")
|
||||||
|
|
||||||
|
# --- [7] TodoMiddleware (plan_mode) ---
|
||||||
|
if plan_mode:
|
||||||
|
from deerflow.agents.middlewares.todo_middleware import TodoMiddleware
|
||||||
|
|
||||||
|
chain.append(TodoMiddleware(system_prompt=_TODO_SYSTEM_PROMPT, tool_description=_TODO_TOOL_DESCRIPTION))
|
||||||
|
|
||||||
|
# --- [8] Auto Title ---
|
||||||
|
if feat.auto_title is not False:
|
||||||
|
if isinstance(feat.auto_title, AgentMiddleware):
|
||||||
|
chain.append(feat.auto_title)
|
||||||
|
else:
|
||||||
|
from deerflow.agents.middlewares.title_middleware import TitleMiddleware
|
||||||
|
|
||||||
|
chain.append(TitleMiddleware())
|
||||||
|
|
||||||
|
# --- [9] Memory ---
|
||||||
|
if feat.memory is not False:
|
||||||
|
if isinstance(feat.memory, AgentMiddleware):
|
||||||
|
chain.append(feat.memory)
|
||||||
|
else:
|
||||||
|
from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware
|
||||||
|
|
||||||
|
chain.append(MemoryMiddleware(agent_name=name))
|
||||||
|
|
||||||
|
# --- [10] Vision ---
|
||||||
|
if feat.vision is not False:
|
||||||
|
if isinstance(feat.vision, AgentMiddleware):
|
||||||
|
chain.append(feat.vision)
|
||||||
|
else:
|
||||||
|
from deerflow.agents.middlewares.view_image_middleware import ViewImageMiddleware
|
||||||
|
|
||||||
|
chain.append(ViewImageMiddleware())
|
||||||
|
from deerflow.tools.builtins import view_image_tool
|
||||||
|
|
||||||
|
extra_tools.append(view_image_tool)
|
||||||
|
|
||||||
|
# --- [11] Subagent ---
|
||||||
|
if feat.subagent is not False:
|
||||||
|
if isinstance(feat.subagent, AgentMiddleware):
|
||||||
|
chain.append(feat.subagent)
|
||||||
|
else:
|
||||||
|
from deerflow.agents.middlewares.subagent_limit_middleware import SubagentLimitMiddleware
|
||||||
|
|
||||||
|
chain.append(SubagentLimitMiddleware())
|
||||||
|
from deerflow.tools.builtins import task_tool
|
||||||
|
|
||||||
|
extra_tools.append(task_tool)
|
||||||
|
|
||||||
|
# --- [12] LoopDetection (always) ---
|
||||||
|
from deerflow.agents.middlewares.loop_detection_middleware import LoopDetectionMiddleware
|
||||||
|
|
||||||
|
chain.append(LoopDetectionMiddleware())
|
||||||
|
|
||||||
|
# --- [13] Clarification (always last among built-ins) ---
|
||||||
|
chain.append(ClarificationMiddleware())
|
||||||
|
extra_tools.append(ask_clarification_tool)
|
||||||
|
|
||||||
|
# --- Insert extra_middleware via @Next/@Prev ---
|
||||||
|
if extra_middleware:
|
||||||
|
_insert_extra(chain, extra_middleware)
|
||||||
|
# Invariant: ClarificationMiddleware must always be last.
|
||||||
|
# @Next(ClarificationMiddleware) could push it off the tail.
|
||||||
|
clar_idx = next(i for i, m in enumerate(chain) if isinstance(m, ClarificationMiddleware))
|
||||||
|
if clar_idx != len(chain) - 1:
|
||||||
|
chain.append(chain.pop(clar_idx))
|
||||||
|
|
||||||
|
return chain, extra_tools
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Internal: extra middleware insertion with @Next/@Prev
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_extra(chain: list[AgentMiddleware], extras: list[AgentMiddleware]) -> None:
|
||||||
|
"""Insert extra middlewares into *chain* using ``@Next``/``@Prev`` anchors.
|
||||||
|
|
||||||
|
Algorithm:
|
||||||
|
1. Validate: no middleware has both @Next and @Prev.
|
||||||
|
2. Conflict detection: two extras targeting same anchor (same or opposite direction) → error.
|
||||||
|
3. Insert unanchored extras before ClarificationMiddleware.
|
||||||
|
4. Insert anchored extras iteratively (supports cross-external anchoring).
|
||||||
|
5. If an anchor cannot be resolved after all rounds → error.
|
||||||
|
"""
|
||||||
|
next_targets: dict[type, type] = {}
|
||||||
|
prev_targets: dict[type, type] = {}
|
||||||
|
|
||||||
|
anchored: list[tuple[AgentMiddleware, str, type]] = []
|
||||||
|
unanchored: list[AgentMiddleware] = []
|
||||||
|
|
||||||
|
for mw in extras:
|
||||||
|
next_anchor = getattr(type(mw), "_next_anchor", None)
|
||||||
|
prev_anchor = getattr(type(mw), "_prev_anchor", None)
|
||||||
|
|
||||||
|
if next_anchor and prev_anchor:
|
||||||
|
raise ValueError(f"{type(mw).__name__} cannot have both @Next and @Prev")
|
||||||
|
|
||||||
|
if next_anchor:
|
||||||
|
if next_anchor in next_targets:
|
||||||
|
raise ValueError(
|
||||||
|
f"Conflict: {type(mw).__name__} and {next_targets[next_anchor].__name__} "
|
||||||
|
f"both @Next({next_anchor.__name__})"
|
||||||
|
)
|
||||||
|
if next_anchor in prev_targets:
|
||||||
|
raise ValueError(
|
||||||
|
f"Conflict: {type(mw).__name__} @Next({next_anchor.__name__}) and "
|
||||||
|
f"{prev_targets[next_anchor].__name__} @Prev({next_anchor.__name__}) "
|
||||||
|
f"— use cross-anchoring between extras instead"
|
||||||
|
)
|
||||||
|
next_targets[next_anchor] = type(mw)
|
||||||
|
anchored.append((mw, "next", next_anchor))
|
||||||
|
elif prev_anchor:
|
||||||
|
if prev_anchor in prev_targets:
|
||||||
|
raise ValueError(
|
||||||
|
f"Conflict: {type(mw).__name__} and {prev_targets[prev_anchor].__name__} "
|
||||||
|
f"both @Prev({prev_anchor.__name__})"
|
||||||
|
)
|
||||||
|
if prev_anchor in next_targets:
|
||||||
|
raise ValueError(
|
||||||
|
f"Conflict: {type(mw).__name__} @Prev({prev_anchor.__name__}) and "
|
||||||
|
f"{next_targets[prev_anchor].__name__} @Next({prev_anchor.__name__}) "
|
||||||
|
f"— use cross-anchoring between extras instead"
|
||||||
|
)
|
||||||
|
prev_targets[prev_anchor] = type(mw)
|
||||||
|
anchored.append((mw, "prev", prev_anchor))
|
||||||
|
else:
|
||||||
|
unanchored.append(mw)
|
||||||
|
|
||||||
|
# Unanchored → before ClarificationMiddleware
|
||||||
|
clarification_idx = next(i for i, m in enumerate(chain) if isinstance(m, ClarificationMiddleware))
|
||||||
|
for mw in unanchored:
|
||||||
|
chain.insert(clarification_idx, mw)
|
||||||
|
clarification_idx += 1
|
||||||
|
|
||||||
|
# Anchored → iterative insertion (supports external-to-external anchoring)
|
||||||
|
pending = list(anchored)
|
||||||
|
max_rounds = len(pending) + 1
|
||||||
|
for _ in range(max_rounds):
|
||||||
|
if not pending:
|
||||||
|
break
|
||||||
|
remaining = []
|
||||||
|
for mw, direction, anchor in pending:
|
||||||
|
idx = next(
|
||||||
|
(i for i, m in enumerate(chain) if isinstance(m, anchor)),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if idx is None:
|
||||||
|
remaining.append((mw, direction, anchor))
|
||||||
|
continue
|
||||||
|
if direction == "next":
|
||||||
|
chain.insert(idx + 1, mw)
|
||||||
|
else:
|
||||||
|
chain.insert(idx, mw)
|
||||||
|
if len(remaining) == len(pending):
|
||||||
|
names = [type(m).__name__ for m, _, _ in remaining]
|
||||||
|
anchor_types = {a for _, _, a in remaining}
|
||||||
|
remaining_types = {type(m) for m, _, _ in remaining}
|
||||||
|
circular = anchor_types & remaining_types
|
||||||
|
if circular:
|
||||||
|
raise ValueError(
|
||||||
|
f"Circular dependency among extra middlewares: "
|
||||||
|
f"{', '.join(t.__name__ for t in circular)}"
|
||||||
|
)
|
||||||
|
raise ValueError(
|
||||||
|
f"Cannot resolve positions for {', '.join(names)} "
|
||||||
|
f"— anchors {', '.join(a.__name__ for _, _, a in remaining)} not found in chain"
|
||||||
|
)
|
||||||
|
pending = remaining
|
||||||
62
backend/packages/harness/deerflow/agents/features.py
Normal file
62
backend/packages/harness/deerflow/agents/features.py
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
"""Declarative feature flags and middleware positioning for create_deerflow_agent.
|
||||||
|
|
||||||
|
Pure data classes and decorators — no I/O, no side effects.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from langchain.agents.middleware import AgentMiddleware
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RuntimeFeatures:
|
||||||
|
"""Declarative feature flags for ``create_deerflow_agent``.
|
||||||
|
|
||||||
|
Most features accept:
|
||||||
|
- ``True``: use the built-in default middleware
|
||||||
|
- ``False``: disable
|
||||||
|
- An ``AgentMiddleware`` instance: use this custom implementation instead
|
||||||
|
|
||||||
|
``summarization`` and ``guardrail`` have no built-in default — they only
|
||||||
|
accept ``False`` (disable) or an ``AgentMiddleware`` instance (custom).
|
||||||
|
"""
|
||||||
|
|
||||||
|
sandbox: bool | AgentMiddleware = True
|
||||||
|
memory: bool | AgentMiddleware = False
|
||||||
|
summarization: Literal[False] | AgentMiddleware = False
|
||||||
|
subagent: bool | AgentMiddleware = False
|
||||||
|
vision: bool | AgentMiddleware = False
|
||||||
|
auto_title: bool | AgentMiddleware = False
|
||||||
|
guardrail: Literal[False] | AgentMiddleware = False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Middleware positioning decorators
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def Next(anchor: type[AgentMiddleware]):
|
||||||
|
"""Declare this middleware should be placed after *anchor* in the chain."""
|
||||||
|
if not (isinstance(anchor, type) and issubclass(anchor, AgentMiddleware)):
|
||||||
|
raise TypeError(f"@Next expects an AgentMiddleware subclass, got {anchor!r}")
|
||||||
|
|
||||||
|
def decorator(cls: type[AgentMiddleware]) -> type[AgentMiddleware]:
|
||||||
|
cls._next_anchor = anchor # type: ignore[attr-defined]
|
||||||
|
return cls
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def Prev(anchor: type[AgentMiddleware]):
|
||||||
|
"""Declare this middleware should be placed before *anchor* in the chain."""
|
||||||
|
if not (isinstance(anchor, type) and issubclass(anchor, AgentMiddleware)):
|
||||||
|
raise TypeError(f"@Prev expects an AgentMiddleware subclass, got {anchor!r}")
|
||||||
|
|
||||||
|
def decorator(cls: type[AgentMiddleware]) -> type[AgentMiddleware]:
|
||||||
|
cls._prev_anchor = anchor # type: ignore[attr-defined]
|
||||||
|
return cls
|
||||||
|
|
||||||
|
return decorator
|
||||||
@ -0,0 +1 @@
|
|||||||
|
|
||||||
@ -183,7 +183,8 @@ class TestBasicChat:
|
|||||||
assert "messages" in event.data
|
assert "messages" in event.data
|
||||||
assert "artifacts" in event.data
|
assert "artifacts" in event.data
|
||||||
elif event.type == "end":
|
elif event.type == "end":
|
||||||
assert event.data == {}
|
# end event may contain usage stats after token tracking was added
|
||||||
|
assert isinstance(event.data, dict)
|
||||||
|
|
||||||
@requires_llm
|
@requires_llm
|
||||||
def test_multi_turn_stateless(self, client):
|
def test_multi_turn_stateless(self, client):
|
||||||
|
|||||||
@ -13,6 +13,7 @@ from pathlib import Path
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from deerflow.client import DeerFlowClient, StreamEvent
|
from deerflow.client import DeerFlowClient, StreamEvent
|
||||||
|
from deerflow.uploads.manager import PathTraversalError
|
||||||
|
|
||||||
# Skip entire module in CI or when no config.yaml exists
|
# Skip entire module in CI or when no config.yaml exists
|
||||||
_skip_reason = None
|
_skip_reason = None
|
||||||
@ -321,5 +322,5 @@ class TestLiveErrorResilience:
|
|||||||
client.get_artifact("t", "invalid/path")
|
client.get_artifact("t", "invalid/path")
|
||||||
|
|
||||||
def test_path_traversal_blocked(self, client):
|
def test_path_traversal_blocked(self, client):
|
||||||
with pytest.raises(PermissionError):
|
with pytest.raises(PathTraversalError):
|
||||||
client.delete_upload("t", "../../etc/passwd")
|
client.delete_upload("t", "../../etc/passwd")
|
||||||
|
|||||||
852
backend/tests/test_create_deerflow_agent.py
Normal file
852
backend/tests/test_create_deerflow_agent.py
Normal file
@ -0,0 +1,852 @@
|
|||||||
|
"""Tests for create_deerflow_agent SDK entry point."""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from deerflow.agents.factory import create_deerflow_agent
|
||||||
|
from deerflow.agents.features import Next, Prev, RuntimeFeatures
|
||||||
|
|
||||||
|
|
||||||
|
def _make_mock_model():
|
||||||
|
return MagicMock(name="mock_model")
|
||||||
|
|
||||||
|
|
||||||
|
def _make_mock_tool(name: str = "my_tool"):
|
||||||
|
tool = MagicMock(name=name)
|
||||||
|
tool.name = name
|
||||||
|
return tool
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. Minimal creation — only model
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@patch("deerflow.agents.factory.create_agent")
|
||||||
|
def test_minimal_creation(mock_create_agent):
|
||||||
|
mock_create_agent.return_value = MagicMock(name="compiled_graph")
|
||||||
|
model = _make_mock_model()
|
||||||
|
|
||||||
|
result = create_deerflow_agent(model)
|
||||||
|
|
||||||
|
mock_create_agent.assert_called_once()
|
||||||
|
assert result is mock_create_agent.return_value
|
||||||
|
call_kwargs = mock_create_agent.call_args[1]
|
||||||
|
assert call_kwargs["model"] is model
|
||||||
|
assert call_kwargs["system_prompt"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. With tools
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@patch("deerflow.agents.factory.create_agent")
|
||||||
|
def test_with_tools(mock_create_agent):
|
||||||
|
mock_create_agent.return_value = MagicMock()
|
||||||
|
model = _make_mock_model()
|
||||||
|
tool = _make_mock_tool("search")
|
||||||
|
|
||||||
|
create_deerflow_agent(model, tools=[tool])
|
||||||
|
|
||||||
|
call_kwargs = mock_create_agent.call_args[1]
|
||||||
|
tool_names = [t.name for t in call_kwargs["tools"]]
|
||||||
|
assert "search" in tool_names
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. With system_prompt
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@patch("deerflow.agents.factory.create_agent")
|
||||||
|
def test_with_system_prompt(mock_create_agent):
|
||||||
|
mock_create_agent.return_value = MagicMock()
|
||||||
|
prompt = "You are a helpful assistant."
|
||||||
|
|
||||||
|
create_deerflow_agent(_make_mock_model(), system_prompt=prompt)
|
||||||
|
|
||||||
|
call_kwargs = mock_create_agent.call_args[1]
|
||||||
|
assert call_kwargs["system_prompt"] == prompt
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4. Features mode — auto-assemble middleware chain
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@patch("deerflow.agents.factory.create_agent")
|
||||||
|
def test_features_mode(mock_create_agent):
|
||||||
|
mock_create_agent.return_value = MagicMock()
|
||||||
|
feat = RuntimeFeatures(sandbox=True, auto_title=True)
|
||||||
|
|
||||||
|
create_deerflow_agent(_make_mock_model(), features=feat)
|
||||||
|
|
||||||
|
call_kwargs = mock_create_agent.call_args[1]
|
||||||
|
middleware = call_kwargs["middleware"]
|
||||||
|
assert len(middleware) > 0
|
||||||
|
mw_types = [type(m).__name__ for m in middleware]
|
||||||
|
assert "ThreadDataMiddleware" in mw_types
|
||||||
|
assert "SandboxMiddleware" in mw_types
|
||||||
|
assert "TitleMiddleware" in mw_types
|
||||||
|
assert "ClarificationMiddleware" in mw_types
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 5. Middleware full takeover
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@patch("deerflow.agents.factory.create_agent")
|
||||||
|
def test_middleware_takeover(mock_create_agent):
|
||||||
|
mock_create_agent.return_value = MagicMock()
|
||||||
|
custom_mw = MagicMock(name="custom_middleware")
|
||||||
|
custom_mw.name = "custom"
|
||||||
|
|
||||||
|
create_deerflow_agent(_make_mock_model(), middleware=[custom_mw])
|
||||||
|
|
||||||
|
call_kwargs = mock_create_agent.call_args[1]
|
||||||
|
assert call_kwargs["middleware"] == [custom_mw]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 6. Conflict — middleware + features raises ValueError
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def test_middleware_and_features_conflict():
|
||||||
|
with pytest.raises(ValueError, match="Cannot specify both"):
|
||||||
|
create_deerflow_agent(
|
||||||
|
_make_mock_model(),
|
||||||
|
middleware=[MagicMock()],
|
||||||
|
features=RuntimeFeatures(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 7. Vision feature auto-injects view_image_tool
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@patch("deerflow.agents.factory.create_agent")
|
||||||
|
def test_vision_injects_view_image_tool(mock_create_agent):
|
||||||
|
mock_create_agent.return_value = MagicMock()
|
||||||
|
feat = RuntimeFeatures(vision=True, sandbox=False)
|
||||||
|
|
||||||
|
create_deerflow_agent(_make_mock_model(), features=feat)
|
||||||
|
|
||||||
|
call_kwargs = mock_create_agent.call_args[1]
|
||||||
|
tool_names = [t.name for t in call_kwargs["tools"]]
|
||||||
|
assert "view_image" in tool_names
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 8. Subagent feature auto-injects task_tool
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@patch("deerflow.agents.factory.create_agent")
|
||||||
|
def test_subagent_injects_task_tool(mock_create_agent):
|
||||||
|
mock_create_agent.return_value = MagicMock()
|
||||||
|
feat = RuntimeFeatures(subagent=True, sandbox=False)
|
||||||
|
|
||||||
|
create_deerflow_agent(_make_mock_model(), features=feat)
|
||||||
|
|
||||||
|
call_kwargs = mock_create_agent.call_args[1]
|
||||||
|
tool_names = [t.name for t in call_kwargs["tools"]]
|
||||||
|
assert "task" in tool_names
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 9. Middleware ordering — ClarificationMiddleware always last
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@patch("deerflow.agents.factory.create_agent")
|
||||||
|
def test_clarification_always_last(mock_create_agent):
|
||||||
|
mock_create_agent.return_value = MagicMock()
|
||||||
|
feat = RuntimeFeatures(sandbox=True, memory=True, vision=True)
|
||||||
|
|
||||||
|
create_deerflow_agent(_make_mock_model(), features=feat)
|
||||||
|
|
||||||
|
call_kwargs = mock_create_agent.call_args[1]
|
||||||
|
middleware = call_kwargs["middleware"]
|
||||||
|
last_mw = middleware[-1]
|
||||||
|
assert type(last_mw).__name__ == "ClarificationMiddleware"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 10. RuntimeFeatures default values
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def test_agent_features_defaults():
|
||||||
|
f = RuntimeFeatures()
|
||||||
|
assert f.sandbox is True
|
||||||
|
assert f.memory is False
|
||||||
|
assert f.summarization is False
|
||||||
|
assert f.subagent is False
|
||||||
|
assert f.vision is False
|
||||||
|
assert f.auto_title is False
|
||||||
|
assert f.guardrail is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 11. Tool deduplication — user-provided tools take priority
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@patch("deerflow.agents.factory.create_agent")
|
||||||
|
def test_tool_deduplication(mock_create_agent):
|
||||||
|
"""If user provides a tool with the same name as an auto-injected one, no duplicate."""
|
||||||
|
mock_create_agent.return_value = MagicMock()
|
||||||
|
user_clarification = _make_mock_tool("ask_clarification")
|
||||||
|
|
||||||
|
create_deerflow_agent(_make_mock_model(), tools=[user_clarification], features=RuntimeFeatures(sandbox=False))
|
||||||
|
|
||||||
|
call_kwargs = mock_create_agent.call_args[1]
|
||||||
|
names = [t.name for t in call_kwargs["tools"]]
|
||||||
|
assert names.count("ask_clarification") == 1
|
||||||
|
# The first one should be the user-provided tool
|
||||||
|
assert call_kwargs["tools"][0] is user_clarification
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 12. Sandbox disabled — no ThreadData/Uploads/Sandbox middleware
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@patch("deerflow.agents.factory.create_agent")
|
||||||
|
def test_sandbox_disabled(mock_create_agent):
|
||||||
|
mock_create_agent.return_value = MagicMock()
|
||||||
|
feat = RuntimeFeatures(sandbox=False)
|
||||||
|
|
||||||
|
create_deerflow_agent(_make_mock_model(), features=feat)
|
||||||
|
|
||||||
|
call_kwargs = mock_create_agent.call_args[1]
|
||||||
|
mw_types = [type(m).__name__ for m in call_kwargs["middleware"]]
|
||||||
|
assert "ThreadDataMiddleware" not in mw_types
|
||||||
|
assert "UploadsMiddleware" not in mw_types
|
||||||
|
assert "SandboxMiddleware" not in mw_types
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 13. Checkpointer passed through
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@patch("deerflow.agents.factory.create_agent")
|
||||||
|
def test_checkpointer_passthrough(mock_create_agent):
|
||||||
|
mock_create_agent.return_value = MagicMock()
|
||||||
|
cp = MagicMock(name="checkpointer")
|
||||||
|
|
||||||
|
create_deerflow_agent(_make_mock_model(), checkpointer=cp)
|
||||||
|
|
||||||
|
call_kwargs = mock_create_agent.call_args[1]
|
||||||
|
assert call_kwargs["checkpointer"] is cp
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 14. Custom AgentMiddleware instance replaces default
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@patch("deerflow.agents.factory.create_agent")
|
||||||
|
def test_custom_middleware_replaces_default(mock_create_agent):
|
||||||
|
"""Passing an AgentMiddleware instance uses it directly instead of the built-in default."""
|
||||||
|
from langchain.agents.middleware import AgentMiddleware
|
||||||
|
|
||||||
|
mock_create_agent.return_value = MagicMock()
|
||||||
|
|
||||||
|
class MyMemoryMiddleware(AgentMiddleware):
|
||||||
|
pass
|
||||||
|
|
||||||
|
custom_memory = MyMemoryMiddleware()
|
||||||
|
feat = RuntimeFeatures(sandbox=False, memory=custom_memory)
|
||||||
|
|
||||||
|
create_deerflow_agent(_make_mock_model(), features=feat)
|
||||||
|
|
||||||
|
call_kwargs = mock_create_agent.call_args[1]
|
||||||
|
middleware = call_kwargs["middleware"]
|
||||||
|
assert custom_memory in middleware
|
||||||
|
# Should NOT have the default MemoryMiddleware
|
||||||
|
mw_types = [type(m).__name__ for m in middleware]
|
||||||
|
assert "MemoryMiddleware" not in mw_types
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 15. Custom sandbox middleware replaces the 3-middleware group
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@patch("deerflow.agents.factory.create_agent")
|
||||||
|
def test_custom_sandbox_replaces_group(mock_create_agent):
|
||||||
|
"""Passing an AgentMiddleware for sandbox replaces ThreadData+Uploads+Sandbox with one."""
|
||||||
|
from langchain.agents.middleware import AgentMiddleware
|
||||||
|
|
||||||
|
mock_create_agent.return_value = MagicMock()
|
||||||
|
|
||||||
|
class MySandbox(AgentMiddleware):
|
||||||
|
pass
|
||||||
|
|
||||||
|
custom_sb = MySandbox()
|
||||||
|
feat = RuntimeFeatures(sandbox=custom_sb)
|
||||||
|
|
||||||
|
create_deerflow_agent(_make_mock_model(), features=feat)
|
||||||
|
|
||||||
|
call_kwargs = mock_create_agent.call_args[1]
|
||||||
|
middleware = call_kwargs["middleware"]
|
||||||
|
assert custom_sb in middleware
|
||||||
|
mw_types = [type(m).__name__ for m in middleware]
|
||||||
|
assert "ThreadDataMiddleware" not in mw_types
|
||||||
|
assert "UploadsMiddleware" not in mw_types
|
||||||
|
assert "SandboxMiddleware" not in mw_types
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 16. Always-on error handling middlewares are present
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@patch("deerflow.agents.factory.create_agent")
|
||||||
|
def test_always_on_error_handling(mock_create_agent):
|
||||||
|
mock_create_agent.return_value = MagicMock()
|
||||||
|
feat = RuntimeFeatures(sandbox=False)
|
||||||
|
|
||||||
|
create_deerflow_agent(_make_mock_model(), features=feat)
|
||||||
|
|
||||||
|
call_kwargs = mock_create_agent.call_args[1]
|
||||||
|
mw_types = [type(m).__name__ for m in call_kwargs["middleware"]]
|
||||||
|
assert "DanglingToolCallMiddleware" in mw_types
|
||||||
|
assert "ToolErrorHandlingMiddleware" in mw_types
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 17. Vision with custom middleware still injects tool
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@patch("deerflow.agents.factory.create_agent")
|
||||||
|
def test_vision_custom_middleware_still_injects_tool(mock_create_agent):
|
||||||
|
"""Custom vision middleware still gets the view_image_tool auto-injected."""
|
||||||
|
from langchain.agents.middleware import AgentMiddleware
|
||||||
|
|
||||||
|
mock_create_agent.return_value = MagicMock()
|
||||||
|
|
||||||
|
class MyVision(AgentMiddleware):
|
||||||
|
pass
|
||||||
|
|
||||||
|
feat = RuntimeFeatures(sandbox=False, vision=MyVision())
|
||||||
|
|
||||||
|
create_deerflow_agent(_make_mock_model(), features=feat)
|
||||||
|
|
||||||
|
call_kwargs = mock_create_agent.call_args[1]
|
||||||
|
tool_names = [t.name for t in call_kwargs["tools"]]
|
||||||
|
assert "view_image" in tool_names
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# @Next / @Prev decorators and extra_middleware insertion
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 18. @Next decorator sets _next_anchor
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def test_next_decorator():
|
||||||
|
from langchain.agents.middleware import AgentMiddleware
|
||||||
|
|
||||||
|
class Anchor(AgentMiddleware):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@Next(Anchor)
|
||||||
|
class MyMW(AgentMiddleware):
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert MyMW._next_anchor is Anchor
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 19. @Prev decorator sets _prev_anchor
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def test_prev_decorator():
|
||||||
|
from langchain.agents.middleware import AgentMiddleware
|
||||||
|
|
||||||
|
class Anchor(AgentMiddleware):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@Prev(Anchor)
|
||||||
|
class MyMW(AgentMiddleware):
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert MyMW._prev_anchor is Anchor
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 20. extra_middleware with @Next inserts after anchor
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@patch("deerflow.agents.factory.create_agent")
|
||||||
|
def test_extra_next_inserts_after_anchor(mock_create_agent):
|
||||||
|
from langchain.agents.middleware import AgentMiddleware
|
||||||
|
|
||||||
|
from deerflow.agents.middlewares.dangling_tool_call_middleware import DanglingToolCallMiddleware
|
||||||
|
|
||||||
|
mock_create_agent.return_value = MagicMock()
|
||||||
|
|
||||||
|
@Next(DanglingToolCallMiddleware)
|
||||||
|
class MyAudit(AgentMiddleware):
|
||||||
|
pass
|
||||||
|
|
||||||
|
audit = MyAudit()
|
||||||
|
create_deerflow_agent(
|
||||||
|
_make_mock_model(),
|
||||||
|
features=RuntimeFeatures(sandbox=False),
|
||||||
|
extra_middleware=[audit],
|
||||||
|
)
|
||||||
|
|
||||||
|
call_kwargs = mock_create_agent.call_args[1]
|
||||||
|
middleware = call_kwargs["middleware"]
|
||||||
|
mw_types = [type(m).__name__ for m in middleware]
|
||||||
|
dangling_idx = mw_types.index("DanglingToolCallMiddleware")
|
||||||
|
audit_idx = mw_types.index("MyAudit")
|
||||||
|
assert audit_idx == dangling_idx + 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 21. extra_middleware with @Prev inserts before anchor
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@patch("deerflow.agents.factory.create_agent")
|
||||||
|
def test_extra_prev_inserts_before_anchor(mock_create_agent):
|
||||||
|
from langchain.agents.middleware import AgentMiddleware
|
||||||
|
|
||||||
|
from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware
|
||||||
|
|
||||||
|
mock_create_agent.return_value = MagicMock()
|
||||||
|
|
||||||
|
@Prev(ClarificationMiddleware)
|
||||||
|
class MyFilter(AgentMiddleware):
|
||||||
|
pass
|
||||||
|
|
||||||
|
filt = MyFilter()
|
||||||
|
create_deerflow_agent(
|
||||||
|
_make_mock_model(),
|
||||||
|
features=RuntimeFeatures(sandbox=False),
|
||||||
|
extra_middleware=[filt],
|
||||||
|
)
|
||||||
|
|
||||||
|
call_kwargs = mock_create_agent.call_args[1]
|
||||||
|
middleware = call_kwargs["middleware"]
|
||||||
|
mw_types = [type(m).__name__ for m in middleware]
|
||||||
|
clar_idx = mw_types.index("ClarificationMiddleware")
|
||||||
|
filt_idx = mw_types.index("MyFilter")
|
||||||
|
assert filt_idx == clar_idx - 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 22. Unanchored extra_middleware goes before ClarificationMiddleware
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@patch("deerflow.agents.factory.create_agent")
|
||||||
|
def test_extra_unanchored_before_clarification(mock_create_agent):
|
||||||
|
from langchain.agents.middleware import AgentMiddleware
|
||||||
|
|
||||||
|
mock_create_agent.return_value = MagicMock()
|
||||||
|
|
||||||
|
class MyPlain(AgentMiddleware):
|
||||||
|
pass
|
||||||
|
|
||||||
|
plain = MyPlain()
|
||||||
|
create_deerflow_agent(
|
||||||
|
_make_mock_model(),
|
||||||
|
features=RuntimeFeatures(sandbox=False),
|
||||||
|
extra_middleware=[plain],
|
||||||
|
)
|
||||||
|
|
||||||
|
call_kwargs = mock_create_agent.call_args[1]
|
||||||
|
middleware = call_kwargs["middleware"]
|
||||||
|
mw_types = [type(m).__name__ for m in middleware]
|
||||||
|
assert mw_types[-1] == "ClarificationMiddleware"
|
||||||
|
assert mw_types[-2] == "MyPlain"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 23. Conflict: two extras @Next same anchor → ValueError
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def test_extra_conflict_same_next_target():
|
||||||
|
from langchain.agents.middleware import AgentMiddleware
|
||||||
|
|
||||||
|
from deerflow.agents.middlewares.dangling_tool_call_middleware import DanglingToolCallMiddleware
|
||||||
|
|
||||||
|
@Next(DanglingToolCallMiddleware)
|
||||||
|
class MW1(AgentMiddleware):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@Next(DanglingToolCallMiddleware)
|
||||||
|
class MW2(AgentMiddleware):
|
||||||
|
pass
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Conflict"):
|
||||||
|
create_deerflow_agent(
|
||||||
|
_make_mock_model(),
|
||||||
|
features=RuntimeFeatures(sandbox=False),
|
||||||
|
extra_middleware=[MW1(), MW2()],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 24. Conflict: two extras @Prev same anchor → ValueError
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def test_extra_conflict_same_prev_target():
|
||||||
|
from langchain.agents.middleware import AgentMiddleware
|
||||||
|
|
||||||
|
from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware
|
||||||
|
|
||||||
|
@Prev(ClarificationMiddleware)
|
||||||
|
class MW1(AgentMiddleware):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@Prev(ClarificationMiddleware)
|
||||||
|
class MW2(AgentMiddleware):
|
||||||
|
pass
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Conflict"):
|
||||||
|
create_deerflow_agent(
|
||||||
|
_make_mock_model(),
|
||||||
|
features=RuntimeFeatures(sandbox=False),
|
||||||
|
extra_middleware=[MW1(), MW2()],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 25. Both @Next and @Prev on same class → ValueError
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def test_extra_both_next_and_prev_error():
|
||||||
|
from langchain.agents.middleware import AgentMiddleware
|
||||||
|
|
||||||
|
from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware
|
||||||
|
from deerflow.agents.middlewares.dangling_tool_call_middleware import DanglingToolCallMiddleware
|
||||||
|
|
||||||
|
class MW(AgentMiddleware):
|
||||||
|
pass
|
||||||
|
|
||||||
|
MW._next_anchor = DanglingToolCallMiddleware
|
||||||
|
MW._prev_anchor = ClarificationMiddleware
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="both @Next and @Prev"):
|
||||||
|
create_deerflow_agent(
|
||||||
|
_make_mock_model(),
|
||||||
|
features=RuntimeFeatures(sandbox=False),
|
||||||
|
extra_middleware=[MW()],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 26. Cross-external anchoring: extra anchors to another extra
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@patch("deerflow.agents.factory.create_agent")
|
||||||
|
def test_extra_cross_external_anchoring(mock_create_agent):
|
||||||
|
from langchain.agents.middleware import AgentMiddleware
|
||||||
|
|
||||||
|
from deerflow.agents.middlewares.dangling_tool_call_middleware import DanglingToolCallMiddleware
|
||||||
|
|
||||||
|
mock_create_agent.return_value = MagicMock()
|
||||||
|
|
||||||
|
@Next(DanglingToolCallMiddleware)
|
||||||
|
class First(AgentMiddleware):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@Next(First)
|
||||||
|
class Second(AgentMiddleware):
|
||||||
|
pass
|
||||||
|
|
||||||
|
create_deerflow_agent(
|
||||||
|
_make_mock_model(),
|
||||||
|
features=RuntimeFeatures(sandbox=False),
|
||||||
|
extra_middleware=[Second(), First()], # intentionally reversed
|
||||||
|
)
|
||||||
|
|
||||||
|
call_kwargs = mock_create_agent.call_args[1]
|
||||||
|
middleware = call_kwargs["middleware"]
|
||||||
|
mw_types = [type(m).__name__ for m in middleware]
|
||||||
|
dangling_idx = mw_types.index("DanglingToolCallMiddleware")
|
||||||
|
first_idx = mw_types.index("First")
|
||||||
|
second_idx = mw_types.index("Second")
|
||||||
|
assert first_idx == dangling_idx + 1
|
||||||
|
assert second_idx == first_idx + 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 27. Unresolvable anchor → ValueError
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def test_extra_unresolvable_anchor():
|
||||||
|
from langchain.agents.middleware import AgentMiddleware
|
||||||
|
|
||||||
|
class Ghost(AgentMiddleware):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@Next(Ghost)
|
||||||
|
class MW(AgentMiddleware):
|
||||||
|
pass
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Cannot resolve"):
|
||||||
|
create_deerflow_agent(
|
||||||
|
_make_mock_model(),
|
||||||
|
features=RuntimeFeatures(sandbox=False),
|
||||||
|
extra_middleware=[MW()],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 28. extra_middleware + middleware (full takeover) → ValueError
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def test_extra_with_middleware_takeover_conflict():
|
||||||
|
with pytest.raises(ValueError, match="full takeover"):
|
||||||
|
create_deerflow_agent(
|
||||||
|
_make_mock_model(),
|
||||||
|
middleware=[MagicMock()],
|
||||||
|
extra_middleware=[MagicMock()],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# LoopDetection, TodoMiddleware, GuardrailMiddleware
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 29. LoopDetectionMiddleware is always present
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@patch("deerflow.agents.factory.create_agent")
|
||||||
|
def test_loop_detection_always_present(mock_create_agent):
|
||||||
|
mock_create_agent.return_value = MagicMock()
|
||||||
|
create_deerflow_agent(_make_mock_model(), features=RuntimeFeatures(sandbox=False))
|
||||||
|
|
||||||
|
call_kwargs = mock_create_agent.call_args[1]
|
||||||
|
mw_types = [type(m).__name__ for m in call_kwargs["middleware"]]
|
||||||
|
assert "LoopDetectionMiddleware" in mw_types
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 30. LoopDetection before Clarification
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@patch("deerflow.agents.factory.create_agent")
|
||||||
|
def test_loop_detection_before_clarification(mock_create_agent):
|
||||||
|
mock_create_agent.return_value = MagicMock()
|
||||||
|
create_deerflow_agent(_make_mock_model(), features=RuntimeFeatures(sandbox=False))
|
||||||
|
|
||||||
|
call_kwargs = mock_create_agent.call_args[1]
|
||||||
|
mw_types = [type(m).__name__ for m in call_kwargs["middleware"]]
|
||||||
|
loop_idx = mw_types.index("LoopDetectionMiddleware")
|
||||||
|
clar_idx = mw_types.index("ClarificationMiddleware")
|
||||||
|
assert loop_idx < clar_idx
|
||||||
|
assert loop_idx == clar_idx - 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 31. plan_mode=True adds TodoMiddleware
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@patch("deerflow.agents.factory.create_agent")
|
||||||
|
def test_plan_mode_adds_todo_middleware(mock_create_agent):
|
||||||
|
mock_create_agent.return_value = MagicMock()
|
||||||
|
create_deerflow_agent(_make_mock_model(), features=RuntimeFeatures(sandbox=False), plan_mode=True)
|
||||||
|
|
||||||
|
call_kwargs = mock_create_agent.call_args[1]
|
||||||
|
mw_types = [type(m).__name__ for m in call_kwargs["middleware"]]
|
||||||
|
assert "TodoMiddleware" in mw_types
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 32. plan_mode=False (default) — no TodoMiddleware
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@patch("deerflow.agents.factory.create_agent")
|
||||||
|
def test_plan_mode_default_no_todo(mock_create_agent):
|
||||||
|
mock_create_agent.return_value = MagicMock()
|
||||||
|
create_deerflow_agent(_make_mock_model(), features=RuntimeFeatures(sandbox=False))
|
||||||
|
|
||||||
|
call_kwargs = mock_create_agent.call_args[1]
|
||||||
|
mw_types = [type(m).__name__ for m in call_kwargs["middleware"]]
|
||||||
|
assert "TodoMiddleware" not in mw_types
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 33. summarization=True without model → ValueError
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def test_summarization_true_raises():
|
||||||
|
with pytest.raises(ValueError, match="requires a custom AgentMiddleware"):
|
||||||
|
create_deerflow_agent(
|
||||||
|
_make_mock_model(),
|
||||||
|
features=RuntimeFeatures(sandbox=False, summarization=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 34. guardrail=True without built-in → ValueError
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def test_guardrail_true_raises():
|
||||||
|
with pytest.raises(ValueError, match="requires a custom AgentMiddleware"):
|
||||||
|
create_deerflow_agent(
|
||||||
|
_make_mock_model(),
|
||||||
|
features=RuntimeFeatures(sandbox=False, guardrail=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 34. guardrail with custom AgentMiddleware replaces default
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@patch("deerflow.agents.factory.create_agent")
|
||||||
|
def test_guardrail_custom_middleware(mock_create_agent):
|
||||||
|
from langchain.agents.middleware import AgentMiddleware as AM
|
||||||
|
|
||||||
|
mock_create_agent.return_value = MagicMock()
|
||||||
|
|
||||||
|
class MyGuardrail(AM):
|
||||||
|
pass
|
||||||
|
|
||||||
|
custom = MyGuardrail()
|
||||||
|
create_deerflow_agent(
|
||||||
|
_make_mock_model(),
|
||||||
|
features=RuntimeFeatures(sandbox=False, guardrail=custom),
|
||||||
|
)
|
||||||
|
|
||||||
|
call_kwargs = mock_create_agent.call_args[1]
|
||||||
|
middleware = call_kwargs["middleware"]
|
||||||
|
assert custom in middleware
|
||||||
|
mw_types = [type(m).__name__ for m in middleware]
|
||||||
|
assert "GuardrailMiddleware" not in mw_types
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 35. guardrail=False (default) — no GuardrailMiddleware
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@patch("deerflow.agents.factory.create_agent")
|
||||||
|
def test_guardrail_default_off(mock_create_agent):
|
||||||
|
mock_create_agent.return_value = MagicMock()
|
||||||
|
create_deerflow_agent(_make_mock_model(), features=RuntimeFeatures(sandbox=False))
|
||||||
|
|
||||||
|
call_kwargs = mock_create_agent.call_args[1]
|
||||||
|
mw_types = [type(m).__name__ for m in call_kwargs["middleware"]]
|
||||||
|
assert "GuardrailMiddleware" not in mw_types
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 36. Full chain order matches make_lead_agent (all features on)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@patch("deerflow.agents.factory.create_agent")
|
||||||
|
def test_full_chain_order(mock_create_agent):
|
||||||
|
from langchain.agents.middleware import AgentMiddleware as AM
|
||||||
|
|
||||||
|
mock_create_agent.return_value = MagicMock()
|
||||||
|
|
||||||
|
class MyGuardrail(AM):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class MySummarization(AM):
|
||||||
|
pass
|
||||||
|
|
||||||
|
feat = RuntimeFeatures(
|
||||||
|
sandbox=True, memory=True, summarization=MySummarization(), subagent=True,
|
||||||
|
vision=True, auto_title=True, guardrail=MyGuardrail(),
|
||||||
|
)
|
||||||
|
create_deerflow_agent(_make_mock_model(), features=feat, plan_mode=True)
|
||||||
|
|
||||||
|
call_kwargs = mock_create_agent.call_args[1]
|
||||||
|
mw_types = [type(m).__name__ for m in call_kwargs["middleware"]]
|
||||||
|
|
||||||
|
expected_order = [
|
||||||
|
"ThreadDataMiddleware",
|
||||||
|
"UploadsMiddleware",
|
||||||
|
"SandboxMiddleware",
|
||||||
|
"DanglingToolCallMiddleware",
|
||||||
|
"MyGuardrail",
|
||||||
|
"ToolErrorHandlingMiddleware",
|
||||||
|
"MySummarization",
|
||||||
|
"TodoMiddleware",
|
||||||
|
"TitleMiddleware",
|
||||||
|
"MemoryMiddleware",
|
||||||
|
"ViewImageMiddleware",
|
||||||
|
"SubagentLimitMiddleware",
|
||||||
|
"LoopDetectionMiddleware",
|
||||||
|
"ClarificationMiddleware",
|
||||||
|
]
|
||||||
|
assert mw_types == expected_order
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 37. @Next(ClarificationMiddleware) does not break tail invariant
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@patch("deerflow.agents.factory.create_agent")
|
||||||
|
def test_next_clarification_preserves_tail_invariant(mock_create_agent):
|
||||||
|
"""Even with @Next(ClarificationMiddleware), Clarification stays last."""
|
||||||
|
from langchain.agents.middleware import AgentMiddleware
|
||||||
|
|
||||||
|
from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware
|
||||||
|
|
||||||
|
mock_create_agent.return_value = MagicMock()
|
||||||
|
|
||||||
|
@Next(ClarificationMiddleware)
|
||||||
|
class AfterClar(AgentMiddleware):
|
||||||
|
pass
|
||||||
|
|
||||||
|
create_deerflow_agent(
|
||||||
|
_make_mock_model(),
|
||||||
|
features=RuntimeFeatures(sandbox=False),
|
||||||
|
extra_middleware=[AfterClar()],
|
||||||
|
)
|
||||||
|
|
||||||
|
call_kwargs = mock_create_agent.call_args[1]
|
||||||
|
middleware = call_kwargs["middleware"]
|
||||||
|
mw_types = [type(m).__name__ for m in middleware]
|
||||||
|
assert mw_types[-1] == "ClarificationMiddleware"
|
||||||
|
assert "AfterClar" in mw_types
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 38. @Next(X) + @Prev(X) on same anchor from different extras → ValueError
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def test_extra_opposite_direction_same_anchor_conflict():
|
||||||
|
from langchain.agents.middleware import AgentMiddleware
|
||||||
|
|
||||||
|
from deerflow.agents.middlewares.dangling_tool_call_middleware import DanglingToolCallMiddleware
|
||||||
|
|
||||||
|
@Next(DanglingToolCallMiddleware)
|
||||||
|
class AfterDangling(AgentMiddleware):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@Prev(DanglingToolCallMiddleware)
|
||||||
|
class BeforeDangling(AgentMiddleware):
|
||||||
|
pass
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="cross-anchoring"):
|
||||||
|
create_deerflow_agent(
|
||||||
|
_make_mock_model(),
|
||||||
|
features=RuntimeFeatures(sandbox=False),
|
||||||
|
extra_middleware=[AfterDangling(), BeforeDangling()],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Input validation and error message hardening
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 39. @Next with non-AgentMiddleware anchor → TypeError
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def test_next_bad_anchor_type():
|
||||||
|
with pytest.raises(TypeError, match="AgentMiddleware subclass"):
|
||||||
|
|
||||||
|
@Next(str) # type: ignore[arg-type]
|
||||||
|
class MW:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 40. @Prev with non-AgentMiddleware anchor → TypeError
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def test_prev_bad_anchor_type():
|
||||||
|
with pytest.raises(TypeError, match="AgentMiddleware subclass"):
|
||||||
|
|
||||||
|
@Prev(42) # type: ignore[arg-type]
|
||||||
|
class MW:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 41. extra_middleware with non-AgentMiddleware item → TypeError
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def test_extra_middleware_bad_type():
|
||||||
|
with pytest.raises(TypeError, match="AgentMiddleware instances"):
|
||||||
|
create_deerflow_agent(
|
||||||
|
_make_mock_model(),
|
||||||
|
features=RuntimeFeatures(sandbox=False),
|
||||||
|
extra_middleware=[object()], # type: ignore[list-item]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 42. Circular dependency among extras → clear error message
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def test_extra_circular_dependency():
|
||||||
|
from langchain.agents.middleware import AgentMiddleware
|
||||||
|
|
||||||
|
class MW_A(AgentMiddleware):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class MW_B(AgentMiddleware):
|
||||||
|
pass
|
||||||
|
|
||||||
|
MW_A._next_anchor = MW_B # type: ignore[attr-defined]
|
||||||
|
MW_B._next_anchor = MW_A # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Circular dependency"):
|
||||||
|
create_deerflow_agent(
|
||||||
|
_make_mock_model(),
|
||||||
|
features=RuntimeFeatures(sandbox=False),
|
||||||
|
extra_middleware=[MW_A(), MW_B()],
|
||||||
|
)
|
||||||
106
backend/tests/test_create_deerflow_agent_live.py
Normal file
106
backend/tests/test_create_deerflow_agent_live.py
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
"""Live integration tests for create_deerflow_agent.
|
||||||
|
|
||||||
|
Verifies the factory produces a working LangGraph agent that can actually
|
||||||
|
process messages end-to-end with a real LLM.
|
||||||
|
|
||||||
|
Tests marked ``requires_llm`` are skipped in CI or when OPENAI_API_KEY is unset.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from langchain_core.tools import tool
|
||||||
|
|
||||||
|
requires_llm = pytest.mark.skipif(
|
||||||
|
os.getenv("CI", "").lower() in ("true", "1") or not os.getenv("OPENAI_API_KEY"),
|
||||||
|
reason="Requires LLM API key — skipped in CI or when OPENAI_API_KEY is unset",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_model():
|
||||||
|
"""Create a real chat model from environment variables."""
|
||||||
|
from langchain_openai import ChatOpenAI
|
||||||
|
|
||||||
|
return ChatOpenAI(
|
||||||
|
model=os.getenv("E2E_MODEL_ID", "ep-20251211175242-llcmh"),
|
||||||
|
base_url=os.getenv("E2E_BASE_URL", "https://ark-cn-beijing.bytedance.net/api/v3"),
|
||||||
|
api_key=os.getenv("OPENAI_API_KEY", ""),
|
||||||
|
max_tokens=256,
|
||||||
|
temperature=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. Minimal creation — model only, no features
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@requires_llm
|
||||||
|
def test_minimal_agent_responds():
|
||||||
|
"""create_deerflow_agent(model) produces a graph that returns a response."""
|
||||||
|
from deerflow.agents.factory import create_deerflow_agent
|
||||||
|
|
||||||
|
model = _make_model()
|
||||||
|
graph = create_deerflow_agent(model, features=None, middleware=[])
|
||||||
|
|
||||||
|
result = graph.invoke(
|
||||||
|
{"messages": [("user", "Say exactly: pong")]},
|
||||||
|
config={"configurable": {"thread_id": str(uuid.uuid4())}},
|
||||||
|
)
|
||||||
|
|
||||||
|
messages = result.get("messages", [])
|
||||||
|
assert len(messages) >= 2
|
||||||
|
last_msg = messages[-1]
|
||||||
|
assert hasattr(last_msg, "content")
|
||||||
|
assert len(last_msg.content) > 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. With custom tool — verifies tool injection and execution
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@requires_llm
|
||||||
|
def test_agent_with_custom_tool():
|
||||||
|
"""Agent can invoke a user-provided tool and return the result."""
|
||||||
|
from deerflow.agents.factory import create_deerflow_agent
|
||||||
|
|
||||||
|
@tool
|
||||||
|
def add(a: int, b: int) -> int:
|
||||||
|
"""Add two numbers."""
|
||||||
|
return a + b
|
||||||
|
|
||||||
|
model = _make_model()
|
||||||
|
graph = create_deerflow_agent(model, tools=[add], middleware=[])
|
||||||
|
|
||||||
|
result = graph.invoke(
|
||||||
|
{"messages": [("user", "Use the add tool to compute 3 + 7. Return only the result.")]},
|
||||||
|
config={"configurable": {"thread_id": str(uuid.uuid4())}},
|
||||||
|
)
|
||||||
|
|
||||||
|
messages = result.get("messages", [])
|
||||||
|
# Should have: user msg, AI tool_call, tool result, AI final
|
||||||
|
assert len(messages) >= 3
|
||||||
|
last_content = messages[-1].content
|
||||||
|
assert "10" in last_content
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. RuntimeFeatures mode — middleware chain runs without errors
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@requires_llm
|
||||||
|
def test_features_mode_middleware_chain():
|
||||||
|
"""RuntimeFeatures assembles a working middleware chain that executes."""
|
||||||
|
from deerflow.agents.factory import create_deerflow_agent
|
||||||
|
from deerflow.agents.features import RuntimeFeatures
|
||||||
|
|
||||||
|
model = _make_model()
|
||||||
|
feat = RuntimeFeatures(sandbox=False, auto_title=False, memory=False)
|
||||||
|
graph = create_deerflow_agent(model, features=feat)
|
||||||
|
|
||||||
|
result = graph.invoke(
|
||||||
|
{"messages": [("user", "What is 2+2?")]},
|
||||||
|
config={"configurable": {"thread_id": str(uuid.uuid4())}},
|
||||||
|
)
|
||||||
|
|
||||||
|
messages = result.get("messages", [])
|
||||||
|
assert len(messages) >= 2
|
||||||
|
last_content = messages[-1].content
|
||||||
|
assert len(last_content) > 0
|
||||||
Loading…
x
Reference in New Issue
Block a user