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