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 .factory import create_deerflow_agent
|
||||
from .features import Next, Prev, RuntimeFeatures
|
||||
from .lead_agent import make_lead_agent
|
||||
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 "artifacts" in event.data
|
||||
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
|
||||
def test_multi_turn_stateless(self, client):
|
||||
|
||||
@ -13,6 +13,7 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from deerflow.client import DeerFlowClient, StreamEvent
|
||||
from deerflow.uploads.manager import PathTraversalError
|
||||
|
||||
# Skip entire module in CI or when no config.yaml exists
|
||||
_skip_reason = None
|
||||
@ -321,5 +322,5 @@ class TestLiveErrorResilience:
|
||||
client.get_artifact("t", "invalid/path")
|
||||
|
||||
def test_path_traversal_blocked(self, client):
|
||||
with pytest.raises(PermissionError):
|
||||
with pytest.raises(PathTraversalError):
|
||||
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