feat: add create_deerflow_agent SDK entry point (Phase 1) (#1203)

This commit is contained in:
greatmengqi 2026-03-29 15:31:18 +08:00 committed by GitHub
parent 7eb3a150b5
commit 06a623f9c8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 2225 additions and 3 deletions

View 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` 阶段的执行顺序。

View 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.yamlsummarization、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 │ ← 唯一公开 APIchat/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 参数覆盖 configMemoryOptions, TitleOptions 等)
- @Next/@Prev 装饰器
- 补缺失 middlewareGuardrail, 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 → Sandboxbefore_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

View File

@ -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",
]

View 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

View 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

View File

@ -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):

View File

@ -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")

View 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()],
)

View 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