diff --git a/backend/docs/middleware-execution-flow.md b/backend/docs/middleware-execution-flow.md
new file mode 100644
index 000000000..922cc9640
--- /dev/null
+++ b/backend/docs/middleware-execution-flow.md
@@ -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 ["before_agent 正序 0→N"]
+ direction TB
+ TD["[0] ThreadData
创建线程目录"] --> UL["[1] Uploads
扫描上传文件"] --> SB["[2] Sandbox
获取沙箱"]
+ end
+
+ subgraph BM ["before_model 正序 0→N"]
+ direction TB
+ VI["[10] ViewImage
注入图片 base64"]
+ end
+
+ SB --> VI
+ VI --> M["MODEL"]
+
+ subgraph AM ["after_model 反序 N→0"]
+ direction TB
+ CL["[13] Clarification
拦截 ask_clarification"] --> LD["[12] LoopDetection
检测循环"] --> SL["[11] SubagentLimit
截断多余 task"] --> TI["[8] Title
生成标题"] --> SM["[6] Summarization
上下文压缩"] --> DTC["[3] DanglingToolCall
补缺失 ToolMessage"]
+ end
+
+ M --> CL
+
+ subgraph AA ["after_agent 反序 N→0"]
+ direction TB
+ SBR["[2] Sandbox
释放沙箱"] --> MEM["[9] Memory
入队记忆"]
+ 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` 阶段的执行顺序。
diff --git a/backend/docs/rfc-create-deerflow-agent.md b/backend/docs/rfc-create-deerflow-agent.md
new file mode 100644
index 000000000..cfb5be2f1
--- /dev/null
+++ b/backend/docs/rfc-create-deerflow-agent.md
@@ -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
创建目录"] --> UL["Uploads
扫描文件"] --> SB["Sandbox
获取沙箱"]
+ end
+
+ subgraph BM ["before_model 正序 每轮"]
+ direction TB
+ VI["ViewImage
注入图片"]
+ end
+
+ SB --> VI
+ VI --> M["MODEL"]
+
+ subgraph AM ["after_model 反序 每轮"]
+ direction TB
+ CL["Clarification
拦截中断"] --> LD["LoopDetection
检测循环"] --> SL["SubagentLimit
截断 task"] --> TI["Title
生成标题"] --> DTC["DanglingToolCall
补缺失消息"]
+ end
+
+ M --> CL
+
+ subgraph AA ["after_agent 反序"]
+ direction TB
+ SBR["Sandbox
释放沙箱"] --> MEM["Memory
入队记忆"]
+ 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
diff --git a/backend/packages/harness/deerflow/agents/__init__.py b/backend/packages/harness/deerflow/agents/__init__.py
index cd0cb6687..32f300004 100644
--- a/backend/packages/harness/deerflow/agents/__init__.py
+++ b/backend/packages/harness/deerflow/agents/__init__.py
@@ -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",
+]
diff --git a/backend/packages/harness/deerflow/agents/factory.py b/backend/packages/harness/deerflow/agents/factory.py
new file mode 100644
index 000000000..76d8ea7ab
--- /dev/null
+++ b/backend/packages/harness/deerflow/agents/factory.py
@@ -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 = """
+
+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_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
diff --git a/backend/packages/harness/deerflow/agents/features.py b/backend/packages/harness/deerflow/agents/features.py
new file mode 100644
index 000000000..0fc485a3d
--- /dev/null
+++ b/backend/packages/harness/deerflow/agents/features.py
@@ -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
diff --git a/backend/packages/harness/deerflow/agents/middlewares/__init__.py b/backend/packages/harness/deerflow/agents/middlewares/__init__.py
new file mode 100644
index 000000000..8b1378917
--- /dev/null
+++ b/backend/packages/harness/deerflow/agents/middlewares/__init__.py
@@ -0,0 +1 @@
+
diff --git a/backend/tests/test_client_e2e.py b/backend/tests/test_client_e2e.py
index 88f410912..166862c71 100644
--- a/backend/tests/test_client_e2e.py
+++ b/backend/tests/test_client_e2e.py
@@ -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):
diff --git a/backend/tests/test_client_live.py b/backend/tests/test_client_live.py
index 3a5caada1..5c6eb61af 100644
--- a/backend/tests/test_client_live.py
+++ b/backend/tests/test_client_live.py
@@ -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")
diff --git a/backend/tests/test_create_deerflow_agent.py b/backend/tests/test_create_deerflow_agent.py
new file mode 100644
index 000000000..d55351295
--- /dev/null
+++ b/backend/tests/test_create_deerflow_agent.py
@@ -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()],
+ )
diff --git a/backend/tests/test_create_deerflow_agent_live.py b/backend/tests/test_create_deerflow_agent_live.py
new file mode 100644
index 000000000..0111bc0ea
--- /dev/null
+++ b/backend/tests/test_create_deerflow_agent_live.py
@@ -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