diff --git a/backend/.claude/settings.local.json b/backend/.claude/settings.local.json
new file mode 100644
index 0000000..e1a2aec
--- /dev/null
+++ b/backend/.claude/settings.local.json
@@ -0,0 +1,7 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(make lint:*)"
+ ]
+ }
+}
diff --git a/backend/AGENTS.md b/backend/AGENTS.md
new file mode 100644
index 0000000..4c7646b
--- /dev/null
+++ b/backend/AGENTS.md
@@ -0,0 +1,2 @@
+For the backend architeture and design patterns:
+@./CLAUDE.md
\ No newline at end of file
diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md
new file mode 100644
index 0000000..104afd5
--- /dev/null
+++ b/backend/CLAUDE.md
@@ -0,0 +1,76 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Project Overview
+
+DeerFlow is a LangGraph-based AI agent backend that provides a "super agent" with sandbox execution capabilities. The agent can execute code, browse the web, and manage files in isolated sandbox environments.
+
+## Commands
+
+```bash
+# Install dependencies
+make install
+
+# Run development server (LangGraph Studio)
+make dev
+
+# Lint
+make lint
+
+# Format code
+make format
+```
+
+## Architecture
+
+### Configuration System
+
+The app uses a YAML-based configuration system loaded from `config.yaml`. Configuration priority:
+1. Explicit `config_path` argument
+2. `DEER_FLOW_CONFIG_PATH` environment variable
+3. `config.yaml` in current directory
+4. `config.yaml` in parent directory
+
+Config values starting with `$` are resolved as environment variables (e.g., `$OPENAI_API_KEY`).
+
+### Core Components
+
+**Agent Graph** (`src/agents/`)
+- `lead_agent` is the main entry point registered in `langgraph.json`
+- Uses `ThreadState` which extends `AgentState` with sandbox state
+- Agent is created via `create_agent()` with model, tools, middleware, and system prompt
+
+**Sandbox System** (`src/sandbox/`)
+- Abstract `Sandbox` base class defines interface: `execute_command`, `read_file`, `write_file`, `list_dir`
+- `SandboxProvider` manages sandbox lifecycle: `acquire`, `get`, `release`
+- `SandboxMiddleware` automatically acquires sandbox on agent start and injects into state
+- `LocalSandboxProvider` is a singleton implementation for local execution
+- Sandbox tools (`bash`, `ls`, `read_file`, `write_file`, `str_replace`) extract sandbox from tool runtime
+
+**Model Factory** (`src/models/`)
+- `create_chat_model()` instantiates LLM from config using reflection
+- Supports `thinking_enabled` flag with per-model `when_thinking_enabled` overrides
+
+**Tool System** (`src/tools/`)
+- Tools defined in config with `use` path (e.g., `src.sandbox.tools:bash_tool`)
+- `get_available_tools()` resolves tool paths via reflection
+- Community tools in `src/community/`: Jina AI (web fetch), Tavily (web search)
+
+**Reflection System** (`src/reflection/`)
+- `resolve_variable()` imports module and returns variable (e.g., `module:variable`)
+- `resolve_class()` imports and validates class against base class
+
+### Config Schema
+
+Models, tools, and sandbox providers are configured in `config.yaml`:
+- `models[]`: LLM configurations with `use` class path
+- `tools[]`: Tool configurations with `use` variable path and `group`
+- `sandbox.use`: Sandbox provider class path
+
+## Code Style
+
+- Uses `ruff` for linting and formatting
+- Line length: 240 characters
+- Python 3.12+ with type hints
+- Double quotes, space indentation
diff --git a/backend/Makefile b/backend/Makefile
index 77dc02d..94f380b 100644
--- a/backend/Makefile
+++ b/backend/Makefile
@@ -8,4 +8,4 @@ lint:
uvx ruff check .
format:
- uvx ruff format .
+ uvx ruff check . --fix && uvx ruff format .
diff --git a/backend/docs/AUTO_TITLE_GENERATION.md b/backend/docs/AUTO_TITLE_GENERATION.md
new file mode 100644
index 0000000..855f2f9
--- /dev/null
+++ b/backend/docs/AUTO_TITLE_GENERATION.md
@@ -0,0 +1,256 @@
+# 自动 Thread Title 生成功能
+
+## 功能说明
+
+自动为对话线程生成标题,在用户首次提问并收到回复后自动触发。
+
+## 实现方式
+
+使用 `TitleMiddleware` 在 `after_agent` 钩子中:
+1. 检测是否是首次对话(1个用户消息 + 1个助手回复)
+2. 检查 state 是否已有 title
+3. 调用 LLM 生成简洁的标题(默认最多6个词)
+4. 将 title 存储到 `ThreadState` 中(会被 checkpointer 持久化)
+
+## ⚠️ 重要:存储机制
+
+### Title 存储位置
+
+Title 存储在 **`ThreadState.title`** 中,而非 thread metadata:
+
+```python
+class ThreadState(AgentState):
+ sandbox: SandboxState | None = None
+ title: str | None = None # ✅ Title stored here
+```
+
+### 持久化说明
+
+| 部署方式 | 持久化 | 说明 |
+|---------|--------|------|
+| **LangGraph Studio (本地)** | ❌ 否 | 仅内存存储,重启后丢失 |
+| **LangGraph Platform** | ✅ 是 | 自动持久化到数据库 |
+| **自定义 + Checkpointer** | ✅ 是 | 需配置 PostgreSQL/SQLite checkpointer |
+
+### 如何启用持久化
+
+如果需要在本地开发时也持久化 title,需要配置 checkpointer:
+
+```python
+# 在 langgraph.json 同级目录创建 checkpointer.py
+from langgraph.checkpoint.postgres import PostgresSaver
+
+checkpointer = PostgresSaver.from_conn_string(
+ "postgresql://user:pass@localhost/dbname"
+)
+```
+
+然后在 `langgraph.json` 中引用:
+
+```json
+{
+ "graphs": {
+ "lead_agent": "src.agents:lead_agent"
+ },
+ "checkpointer": "checkpointer:checkpointer"
+}
+```
+
+## 配置
+
+在 `config.yaml` 中添加(可选):
+
+```yaml
+title:
+ enabled: true
+ max_words: 6
+ max_chars: 60
+ model_name: null # 使用默认模型
+```
+
+或在代码中配置:
+
+```python
+from src.config.title_config import TitleConfig, set_title_config
+
+set_title_config(TitleConfig(
+ enabled=True,
+ max_words=8,
+ max_chars=80,
+))
+```
+
+## 客户端使用
+
+### 获取 Thread Title
+
+```typescript
+// 方式1: 从 thread state 获取
+const state = await client.threads.getState(threadId);
+const title = state.values.title || "New Conversation";
+
+// 方式2: 监听 stream 事件
+for await (const chunk of client.runs.stream(threadId, assistantId, {
+ input: { messages: [{ role: "user", content: "Hello" }] }
+})) {
+ if (chunk.event === "values" && chunk.data.title) {
+ console.log("Title:", chunk.data.title);
+ }
+}
+```
+
+### 显示 Title
+
+```typescript
+// 在对话列表中显示
+function ConversationList() {
+ const [threads, setThreads] = useState([]);
+
+ useEffect(() => {
+ async function loadThreads() {
+ const allThreads = await client.threads.list();
+
+ // 获取每个 thread 的 state 来读取 title
+ const threadsWithTitles = await Promise.all(
+ allThreads.map(async (t) => {
+ const state = await client.threads.getState(t.thread_id);
+ return {
+ id: t.thread_id,
+ title: state.values.title || "New Conversation",
+ updatedAt: t.updated_at,
+ };
+ })
+ );
+
+ setThreads(threadsWithTitles);
+ }
+ loadThreads();
+ }, []);
+
+ return (
+
+ );
+}
+```
+
+## 工作流程
+
+```mermaid
+sequenceDiagram
+ participant User
+ participant Client
+ participant LangGraph
+ participant TitleMiddleware
+ participant LLM
+ participant Checkpointer
+
+ User->>Client: 发送首条消息
+ Client->>LangGraph: POST /threads/{id}/runs
+ LangGraph->>Agent: 处理消息
+ Agent-->>LangGraph: 返回回复
+ LangGraph->>TitleMiddleware: after_agent()
+ TitleMiddleware->>TitleMiddleware: 检查是否需要生成 title
+ TitleMiddleware->>LLM: 生成 title
+ LLM-->>TitleMiddleware: 返回 title
+ TitleMiddleware->>LangGraph: return {"title": "..."}
+ LangGraph->>Checkpointer: 保存 state (含 title)
+ LangGraph-->>Client: 返回响应
+ Client->>Client: 从 state.values.title 读取
+```
+
+## 优势
+
+✅ **可靠持久化** - 使用 LangGraph 的 state 机制,自动持久化
+✅ **完全后端处理** - 客户端无需额外逻辑
+✅ **自动触发** - 首次对话后自动生成
+✅ **可配置** - 支持自定义长度、模型等
+✅ **容错性强** - 失败时使用 fallback 策略
+✅ **架构一致** - 与现有 SandboxMiddleware 保持一致
+
+## 注意事项
+
+1. **读取方式不同**:Title 在 `state.values.title` 而非 `thread.metadata.title`
+2. **性能考虑**:title 生成会增加约 0.5-1 秒延迟,可通过使用更快的模型优化
+3. **并发安全**:middleware 在 agent 执行后运行,不会阻塞主流程
+4. **Fallback 策略**:如果 LLM 调用失败,会使用用户消息的前几个词作为 title
+
+## 测试
+
+```python
+# 测试 title 生成
+import pytest
+from src.agents.title_middleware import TitleMiddleware
+
+def test_title_generation():
+ # TODO: 添加单元测试
+ pass
+```
+
+## 故障排查
+
+### Title 没有生成
+
+1. 检查配置是否启用:`get_title_config().enabled == True`
+2. 检查日志:查找 "Generated thread title" 或错误信息
+3. 确认是首次对话:只有 1 个用户消息和 1 个助手回复时才会触发
+
+### Title 生成但客户端看不到
+
+1. 确认读取位置:应该从 `state.values.title` 读取,而非 `thread.metadata.title`
+2. 检查 API 响应:确认 state 中包含 title 字段
+3. 尝试重新获取 state:`client.threads.getState(threadId)`
+
+### Title 重启后丢失
+
+1. 检查是否配置了 checkpointer(本地开发需要)
+2. 确认部署方式:LangGraph Platform 会自动持久化
+3. 查看数据库:确认 checkpointer 正常工作
+
+## 架构设计
+
+### 为什么使用 State 而非 Metadata?
+
+| 特性 | State | Metadata |
+|------|-------|----------|
+| **持久化** | ✅ 自动(通过 checkpointer) | ⚠️ 取决于实现 |
+| **版本控制** | ✅ 支持时间旅行 | ❌ 不支持 |
+| **类型安全** | ✅ TypedDict 定义 | ❌ 任意字典 |
+| **可追溯** | ✅ 每次更新都记录 | ⚠️ 只有最新值 |
+| **标准化** | ✅ LangGraph 核心机制 | ⚠️ 扩展功能 |
+
+### 实现细节
+
+```python
+# TitleMiddleware 核心逻辑
+@override
+def after_agent(self, state: TitleMiddlewareState, runtime: Runtime) -> dict | None:
+ """Generate and set thread title after the first agent response."""
+ if self._should_generate_title(state, runtime):
+ title = self._generate_title(runtime)
+ print(f"Generated thread title: {title}")
+
+ # ✅ 返回 state 更新,会被 checkpointer 自动持久化
+ return {"title": title}
+
+ return None
+```
+
+## 相关文件
+
+- [`src/agents/thread_state.py`](../src/agents/thread_state.py) - ThreadState 定义
+- [`src/agents/title_middleware.py`](../src/agents/title_middleware.py) - TitleMiddleware 实现
+- [`src/config/title_config.py`](../src/config/title_config.py) - 配置管理
+- [`config.yaml`](../config.yaml) - 配置文件
+- [`src/agents/lead_agent/agent.py`](../src/agents/lead_agent/agent.py) - Middleware 注册
+
+## 参考资料
+
+- [LangGraph Checkpointer 文档](https://langchain-ai.github.io/langgraph/concepts/persistence/)
+- [LangGraph State 管理](https://langchain-ai.github.io/langgraph/concepts/low_level/#state)
+- [LangGraph Middleware](https://langchain-ai.github.io/langgraph/concepts/middleware/)
diff --git a/backend/docs/BACKEND_TODO.md b/backend/docs/BACKEND_TODO.md
new file mode 100644
index 0000000..0b49b79
--- /dev/null
+++ b/backend/docs/BACKEND_TODO.md
@@ -0,0 +1,125 @@
+# DeerFlow Backend TODO List
+
+## 📋 项目概述
+
+DeerFlow Backend 是一个基于 LangGraph 的 AI Agent 框架,采用配置驱动架构,支持多种 Sandbox 实现和工具扩展。
+
+## 🚨 高优先级问题 (P0)
+
+### 1. LocalSandboxProvider 返回类型不一致
+**文件**: `src/sandbox/local/local_sandbox_provider.py`
+**问题**:
+- `acquire()` 声明返回 `Sandbox` 但实际返回 `str`
+- `get()` 声明返回 `None` 但实际返回 `LocalSandbox`
+**影响**: 类型安全破坏,IDE 检查报错
+**解决方案**: 修正方法签名,确保与抽象基类契约一致
+
+### 2. Sandbox 资源泄漏风险
+**文件**: `src/sandbox/middleware.py`
+**问题**:
+- 只有 `before_agent` 获取 sandbox
+- 没有 `after_agent` 释放机制
+- `LocalSandboxProvider.release()` 是空实现
+**影响**: 资源泄漏,Docker 容器堆积
+**解决方案**: 实现完整的生命周期管理
+
+## 🟡 中优先级问题 (P1)
+
+### 3. 硬编码路径和个人信息 ✅ 已完成
+**文件**: `src/agents/lead_agent/prompt.py`
+**问题**:
+- `MOUNT_POINT = "/Users/henry/mnt"`
+- 个人信息出现在系统提示中
+**影响**: 可移植性差,违反配置分离原则
+**解决方案**: 移至配置文件中
+
+### 4. 异常处理过于简单
+**文件**: `src/sandbox/tools.py`
+**问题**: 所有异常被吞掉,缺乏结构化错误信息
+**影响**: 调试困难,用户体验差
+**解决方案**: 实现分层异常处理机制
+
+### 5. 全局单例缺乏生命周期管理
+**文件**: `src/config/app_config.py`, `src/sandbox/sandbox_provider.py`
+**问题**: 全局变量难以测试,无法重新加载配置
+**影响**: 可测试性差,多线程风险
+**解决方案**: 引入依赖注入或 ContextVar
+
+## 🟢 低优先级问题 (P2)
+
+### 6. 缺乏异步支持
+**文件**: `src/community/aio_sandbox/aio_sandbox.py`
+**问题**: 所有操作都是同步的
+**影响**: 并发性能受限
+**解决方案**: 添加 async/await 支持
+
+### 7. 配置验证不足
+**文件**: `src/config/model_config.py`
+**问题**: `extra="allow"` 允许任意字段
+**影响**: 配置错误难以发现
+**解决方案**: 使用 `extra="forbid"` 并添加验证器
+
+### 8. 工具配置重复定义
+**文件**: `config.yaml` 和 `src/community/tavily/tools.py`
+**问题**: 同名工具在不同地方定义
+**影响**: 配置切换混淆
+**解决方案**: 使用唯一名称或别名机制
+
+## 🔧 架构优化建议
+
+### 9. 自动 Thread Title 生成 ✅ 已完成
+**目的**: 自动为对话线程生成标题
+**实现**:
+- 使用 `TitleMiddleware` 在首次对话后自动生成 title
+- Title 存储在 `ThreadState.title` 中(而非 metadata)
+- 支持通过 checkpointer 持久化
+- 详见 [AUTO_TITLE_GENERATION.md](docs/AUTO_TITLE_GENERATION.md)
+
+### 10. 引入依赖注入容器
+**目的**: 改善可测试性和模块化
+**实现**: 创建 `di.py` 提供类型安全的依赖管理
+
+### 11. 添加健康检查接口
+**目的**: 监控系统状态
+**实现**: 创建 `health.py` 提供系统健康状态检查
+
+### 12. 增加结构化日志
+**目的**: 改善可观测性
+**实现**: 集成 `structlog` 提供结构化日志输出
+
+## 📊 实施计划
+
+### Phase 1: 安全与稳定性 (Week 1-2)
+- [ ] 修复 LocalSandboxProvider 类型问题
+- [ ] 实现 Sandbox 生命周期管理
+- [ ] 添加异常处理机制
+
+### Phase 2: 架构优化 (Week 3-4)
+- [ ] 引入依赖注入
+- [ ] 添加健康检查
+- [ ] 实现配置验证
+- [ ] 移除硬编码路径
+
+### Phase 3: 性能与扩展性 (Week 5-6)
+- [ ] 添加异步支持
+- [ ] 实现结构化日志
+- [ ] 优化工具配置管理
+
+## 🎯 成功标准
+
+- ✅ 所有类型检查通过
+- ✅ 配置可安全共享
+- ✅ 资源管理无泄漏
+- ✅ 异常处理完善
+- ✅ 测试覆盖率提升
+- ✅ 部署配置标准化
+
+## 📝 备注
+
+- 优先处理高优先级问题,确保系统稳定性和安全性
+- 中优先级问题影响开发体验和可维护性
+- 低优先级问题可在系统稳定后逐步优化
+
+---
+
+*最后更新: 2026-01-14*
\ No newline at end of file
diff --git a/backend/docs/TITLE_GENERATION_IMPLEMENTATION.md b/backend/docs/TITLE_GENERATION_IMPLEMENTATION.md
new file mode 100644
index 0000000..351d73c
--- /dev/null
+++ b/backend/docs/TITLE_GENERATION_IMPLEMENTATION.md
@@ -0,0 +1,222 @@
+# 自动 Title 生成功能实现总结
+
+## ✅ 已完成的工作
+
+### 1. 核心实现文件
+
+#### [`src/agents/thread_state.py`](../src/agents/thread_state.py)
+- ✅ 添加 `title: str | None = None` 字段到 `ThreadState`
+
+#### [`src/config/title_config.py`](../src/config/title_config.py) (新建)
+- ✅ 创建 `TitleConfig` 配置类
+- ✅ 支持配置:enabled, max_words, max_chars, model_name, prompt_template
+- ✅ 提供 `get_title_config()` 和 `set_title_config()` 函数
+- ✅ 提供 `load_title_config_from_dict()` 从配置文件加载
+
+#### [`src/agents/title_middleware.py`](../src/agents/title_middleware.py) (新建)
+- ✅ 创建 `TitleMiddleware` 类
+- ✅ 实现 `_should_generate_title()` 检查是否需要生成
+- ✅ 实现 `_generate_title()` 调用 LLM 生成标题
+- ✅ 实现 `after_agent()` 钩子,在首次对话后自动触发
+- ✅ 包含 fallback 策略(LLM 失败时使用用户消息前几个词)
+
+#### [`src/config/app_config.py`](../src/config/app_config.py)
+- ✅ 导入 `load_title_config_from_dict`
+- ✅ 在 `from_file()` 中加载 title 配置
+
+#### [`src/agents/lead_agent/agent.py`](../src/agents/lead_agent/agent.py)
+- ✅ 导入 `TitleMiddleware`
+- ✅ 注册到 `middleware` 列表:`[SandboxMiddleware(), TitleMiddleware()]`
+
+### 2. 配置文件
+
+#### [`config.yaml`](../config.yaml)
+- ✅ 添加 title 配置段:
+```yaml
+title:
+ enabled: true
+ max_words: 6
+ max_chars: 60
+ model_name: null
+```
+
+### 3. 文档
+
+#### [`docs/AUTO_TITLE_GENERATION.md`](../docs/AUTO_TITLE_GENERATION.md) (新建)
+- ✅ 完整的功能说明文档
+- ✅ 实现方式和架构设计
+- ✅ 配置说明
+- ✅ 客户端使用示例(TypeScript)
+- ✅ 工作流程图(Mermaid)
+- ✅ 故障排查指南
+- ✅ State vs Metadata 对比
+
+#### [`BACKEND_TODO.md`](../BACKEND_TODO.md)
+- ✅ 添加功能完成记录
+
+### 4. 测试
+
+#### [`tests/test_title_generation.py`](../tests/test_title_generation.py) (新建)
+- ✅ 配置类测试
+- ✅ Middleware 初始化测试
+- ✅ TODO: 集成测试(需要 mock Runtime)
+
+---
+
+## 🎯 核心设计决策
+
+### 为什么使用 State 而非 Metadata?
+
+| 方面 | State (✅ 采用) | Metadata (❌ 未采用) |
+|------|----------------|---------------------|
+| **持久化** | 自动(通过 checkpointer) | 取决于实现,不可靠 |
+| **版本控制** | 支持时间旅行 | 不支持 |
+| **类型安全** | TypedDict 定义 | 任意字典 |
+| **标准化** | LangGraph 核心机制 | 扩展功能 |
+
+### 工作流程
+
+```
+用户发送首条消息
+ ↓
+Agent 处理并返回回复
+ ↓
+TitleMiddleware.after_agent() 触发
+ ↓
+检查:是否首次对话?是否已有 title?
+ ↓
+调用 LLM 生成 title
+ ↓
+返回 {"title": "..."} 更新 state
+ ↓
+Checkpointer 自动持久化(如果配置了)
+ ↓
+客户端从 state.values.title 读取
+```
+
+---
+
+## 📋 使用指南
+
+### 后端配置
+
+1. **启用/禁用功能**
+```yaml
+# config.yaml
+title:
+ enabled: true # 设为 false 禁用
+```
+
+2. **自定义配置**
+```yaml
+title:
+ enabled: true
+ max_words: 8 # 标题最多 8 个词
+ max_chars: 80 # 标题最多 80 个字符
+ model_name: null # 使用默认模型
+```
+
+3. **配置持久化(可选)**
+
+如果需要在本地开发时持久化 title:
+
+```python
+# checkpointer.py
+from langgraph.checkpoint.sqlite import SqliteSaver
+
+checkpointer = SqliteSaver.from_conn_string("checkpoints.db")
+```
+
+```json
+// langgraph.json
+{
+ "graphs": {
+ "lead_agent": "src.agents:lead_agent"
+ },
+ "checkpointer": "checkpointer:checkpointer"
+}
+```
+
+### 客户端使用
+
+```typescript
+// 获取 thread title
+const state = await client.threads.getState(threadId);
+const title = state.values.title || "New Conversation";
+
+// 显示在对话列表
+{title}
+```
+
+**⚠️ 注意**:Title 在 `state.values.title`,而非 `thread.metadata.title`
+
+---
+
+## 🧪 测试
+
+```bash
+# 运行测试
+pytest tests/test_title_generation.py -v
+
+# 运行所有测试
+pytest
+```
+
+---
+
+## 🔍 故障排查
+
+### Title 没有生成?
+
+1. 检查配置:`title.enabled = true`
+2. 查看日志:搜索 "Generated thread title"
+3. 确认是首次对话(1 个用户消息 + 1 个助手回复)
+
+### Title 生成但看不到?
+
+1. 确认读取位置:`state.values.title`(不是 `thread.metadata.title`)
+2. 检查 API 响应是否包含 title
+3. 重新获取 state
+
+### Title 重启后丢失?
+
+1. 本地开发需要配置 checkpointer
+2. LangGraph Platform 会自动持久化
+3. 检查数据库确认 checkpointer 工作正常
+
+---
+
+## 📊 性能影响
+
+- **延迟增加**:约 0.5-1 秒(LLM 调用)
+- **并发安全**:在 `after_agent` 中运行,不阻塞主流程
+- **资源消耗**:每个 thread 只生成一次
+
+### 优化建议
+
+1. 使用更快的模型(如 `gpt-3.5-turbo`)
+2. 减少 `max_words` 和 `max_chars`
+3. 调整 prompt 使其更简洁
+
+---
+
+## 🚀 下一步
+
+- [ ] 添加集成测试(需要 mock LangGraph Runtime)
+- [ ] 支持自定义 prompt template
+- [ ] 支持多语言 title 生成
+- [ ] 添加 title 重新生成功能
+- [ ] 监控 title 生成成功率和延迟
+
+---
+
+## 📚 相关资源
+
+- [完整文档](../docs/AUTO_TITLE_GENERATION.md)
+- [LangGraph Middleware](https://langchain-ai.github.io/langgraph/concepts/middleware/)
+- [LangGraph State 管理](https://langchain-ai.github.io/langgraph/concepts/low_level/#state)
+- [LangGraph Checkpointer](https://langchain-ai.github.io/langgraph/concepts/persistence/)
+
+---
+
+*实现完成时间: 2026-01-14*
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index 8df486c..40a9813 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -5,6 +5,8 @@ description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
+ "agent-sandbox>=0.0.19",
+ "dotenv>=0.9.9",
"langchain>=1.2.3",
"langchain-deepseek>=1.0.1",
"langchain-openai>=1.1.7",
@@ -18,5 +20,6 @@ dependencies = [
[dependency-groups]
dev = [
+ "pytest>=8.0.0",
"ruff>=0.14.11",
]
diff --git a/backend/src/agents/lead_agent/agent.py b/backend/src/agents/lead_agent/agent.py
index 9c57cef..3c31073 100644
--- a/backend/src/agents/lead_agent/agent.py
+++ b/backend/src/agents/lead_agent/agent.py
@@ -1,15 +1,18 @@
from langchain.agents import create_agent
from src.agents.lead_agent.prompt import apply_prompt_template
+from src.agents.middlewares.title_middleware import TitleMiddleware
from src.agents.thread_state import ThreadState
from src.models import create_chat_model
from src.sandbox.middleware import SandboxMiddleware
from src.tools import get_available_tools
+middlewares = [SandboxMiddleware(), TitleMiddleware()]
+
lead_agent = create_agent(
model=create_chat_model(thinking_enabled=True),
tools=get_available_tools(),
- middleware=[SandboxMiddleware()],
+ middleware=middlewares,
system_prompt=apply_prompt_template(),
state_schema=ThreadState,
)
diff --git a/backend/src/agents/lead_agent/prompt.py b/backend/src/agents/lead_agent/prompt.py
index 64f0b6e..0537921 100644
--- a/backend/src/agents/lead_agent/prompt.py
+++ b/backend/src/agents/lead_agent/prompt.py
@@ -1,6 +1,7 @@
+import os
from datetime import datetime
-MOUNT_POINT = "/Users/henry/mnt"
+MOUNT_POINT = os.path.expanduser("~/mnt")
SYSTEM_PROMPT = f"""
diff --git a/backend/src/agents/middlewares/title_middleware.py b/backend/src/agents/middlewares/title_middleware.py
new file mode 100644
index 0000000..967ced4
--- /dev/null
+++ b/backend/src/agents/middlewares/title_middleware.py
@@ -0,0 +1,93 @@
+"""Middleware for automatic thread title generation."""
+
+from typing import NotRequired, override
+
+from langchain.agents import AgentState
+from langchain.agents.middleware import AgentMiddleware
+from langgraph.runtime import Runtime
+
+from src.config.title_config import get_title_config
+from src.models import create_chat_model
+
+
+class TitleMiddlewareState(AgentState):
+ """Compatible with the `ThreadState` schema."""
+
+ title: NotRequired[str | None]
+
+
+class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
+ """Automatically generate a title for the thread after the first user message."""
+
+ state_schema = TitleMiddlewareState
+
+ def _should_generate_title(self, state: TitleMiddlewareState) -> bool:
+ """Check if we should generate a title for this thread."""
+ config = get_title_config()
+ if not config.enabled:
+ return False
+
+ # Check if thread already has a title in state
+ if state.get("title"):
+ return False
+
+ # Check if this is the first turn (has at least one user message and one assistant response)
+ messages = state.get("messages", [])
+ if len(messages) < 2:
+ return False
+
+ # Count user and assistant messages
+ user_messages = [m for m in messages if m.type == "human"]
+ assistant_messages = [m for m in messages if m.type == "ai"]
+
+ # Generate title after first complete exchange
+ return len(user_messages) == 1 and len(assistant_messages) >= 1
+
+ def _generate_title(self, state: TitleMiddlewareState) -> str:
+ """Generate a concise title based on the conversation."""
+ config = get_title_config()
+ messages = state.get("messages", [])
+
+ # Get first user message and first assistant response
+ user_msg_content = next((m.content for m in messages if m.type == "human"), "")
+ assistant_msg_content = next((m.content for m in messages if m.type == "ai"), "")
+
+ # Ensure content is string (LangChain messages can have list content)
+ user_msg = str(user_msg_content) if user_msg_content else ""
+ assistant_msg = str(assistant_msg_content) if assistant_msg_content else ""
+
+ # Use a lightweight model to generate title
+ model = create_chat_model(thinking_enabled=False)
+
+ prompt = config.prompt_template.format(
+ max_words=config.max_words,
+ user_msg=user_msg[:500],
+ assistant_msg=assistant_msg[:500],
+ )
+
+ try:
+ response = model.invoke(prompt)
+ # Ensure response content is string
+ title_content = str(response.content) if response.content else ""
+ title = title_content.strip().strip('"').strip("'")
+ # Limit to max characters
+ return title[: config.max_chars] if len(title) > config.max_chars else title
+ except Exception as e:
+ print(f"Failed to generate title: {e}")
+ # Fallback: use first part of user message (by character count)
+ fallback_chars = min(config.max_chars, 50) # Use max_chars or 50, whichever is smaller
+ if len(user_msg) > fallback_chars:
+ return user_msg[:fallback_chars].rstrip() + "..."
+ return user_msg if user_msg else "New Conversation"
+
+ @override
+ def after_agent(self, state: TitleMiddlewareState, runtime: Runtime) -> dict | None:
+ """Generate and set thread title after the first agent response."""
+ if self._should_generate_title(state):
+ title = self._generate_title(state)
+ print(f"Generated thread title: {title}")
+
+ # Store title in state (will be persisted by checkpointer if configured)
+ return {"title": title}
+
+ return None
diff --git a/backend/src/agents/thread_state.py b/backend/src/agents/thread_state.py
index 081df2d..fbafe15 100644
--- a/backend/src/agents/thread_state.py
+++ b/backend/src/agents/thread_state.py
@@ -1,11 +1,12 @@
-from typing import TypedDict
+from typing import NotRequired, TypedDict
from langchain.agents import AgentState
class SandboxState(TypedDict):
- sandbox_id: str | None = None
+ sandbox_id: NotRequired[str | None]
class ThreadState(AgentState):
- sandbox: SandboxState | None = None
+ sandbox: NotRequired[SandboxState | None]
+ title: NotRequired[str | None]
diff --git a/backend/src/community/aio_sandbox/__init__.py b/backend/src/community/aio_sandbox/__init__.py
new file mode 100644
index 0000000..41d3a1c
--- /dev/null
+++ b/backend/src/community/aio_sandbox/__init__.py
@@ -0,0 +1,7 @@
+from .aio_sandbox import AioSandbox
+from .aio_sandbox_provider import AioSandboxProvider
+
+__all__ = [
+ "AioSandbox",
+ "AioSandboxProvider",
+]
diff --git a/backend/src/community/aio_sandbox/aio_sandbox.py b/backend/src/community/aio_sandbox/aio_sandbox.py
new file mode 100644
index 0000000..52ef358
--- /dev/null
+++ b/backend/src/community/aio_sandbox/aio_sandbox.py
@@ -0,0 +1,113 @@
+import logging
+
+from agent_sandbox import Sandbox as AioSandboxClient
+
+from src.sandbox.sandbox import Sandbox
+
+logger = logging.getLogger(__name__)
+
+
+class AioSandbox(Sandbox):
+ """Sandbox implementation using the agent-infra/sandbox Docker container.
+
+ This sandbox connects to a running AIO sandbox container via HTTP API.
+ """
+
+ def __init__(self, id: str, base_url: str, home_dir: str | None = None):
+ """Initialize the AIO sandbox.
+
+ Args:
+ id: Unique identifier for this sandbox instance.
+ base_url: Base URL of the sandbox API (e.g., http://localhost:8080).
+ home_dir: Home directory inside the sandbox. If None, will be fetched from the sandbox.
+ """
+ super().__init__(id)
+ self._base_url = base_url
+ self._client = AioSandboxClient(base_url=base_url)
+ self._home_dir = home_dir
+
+ @property
+ def base_url(self) -> str:
+ return self._base_url
+
+ @property
+ def home_dir(self) -> str:
+ """Get the home directory inside the sandbox."""
+ if self._home_dir is None:
+ context = self._client.sandbox.get_context()
+ self._home_dir = context.home_dir
+ return self._home_dir
+
+ def execute_command(self, command: str) -> str:
+ """Execute a shell command in the sandbox.
+
+ Args:
+ command: The command to execute.
+
+ Returns:
+ The output of the command.
+ """
+ try:
+ result = self._client.shell.exec_command(command=command)
+ output = result.data.output if result.data else ""
+ return output if output else "(no output)"
+ except Exception as e:
+ logger.error(f"Failed to execute command in sandbox: {e}")
+ return f"Error: {e}"
+
+ def read_file(self, path: str) -> str:
+ """Read the content of a file in the sandbox.
+
+ Args:
+ path: The absolute path of the file to read.
+
+ Returns:
+ The content of the file.
+ """
+ try:
+ result = self._client.file.read_file(file=path)
+ return result.data.content if result.data else ""
+ except Exception as e:
+ logger.error(f"Failed to read file in sandbox: {e}")
+ return f"Error: {e}"
+
+ def list_dir(self, path: str, max_depth: int = 2) -> list[str]:
+ """List the contents of a directory in the sandbox.
+
+ Args:
+ path: The absolute path of the directory to list.
+ max_depth: The maximum depth to traverse. Default is 2.
+
+ Returns:
+ The contents of the directory.
+ """
+ try:
+ # Use shell command to list directory with depth limit
+ # The -L flag limits the depth for the tree command
+ result = self._client.shell.exec_command(command=f"find {path} -maxdepth {max_depth} -type f -o -type d 2>/dev/null | head -500")
+ output = result.data.output if result.data else ""
+ if output:
+ return [line.strip() for line in output.strip().split("\n") if line.strip()]
+ return []
+ except Exception as e:
+ logger.error(f"Failed to list directory in sandbox: {e}")
+ return []
+
+ def write_file(self, path: str, content: str, append: bool = False) -> None:
+ """Write content to a file in the sandbox.
+
+ Args:
+ path: The absolute path of the file to write to.
+ content: The text content to write to the file.
+ append: Whether to append the content to the file.
+ """
+ try:
+ if append:
+ # Read existing content first and append
+ existing = self.read_file(path)
+ if not existing.startswith("Error:"):
+ content = existing + content
+ self._client.file.write_file(file=path, content=content)
+ except Exception as e:
+ logger.error(f"Failed to write file in sandbox: {e}")
+ raise
diff --git a/backend/src/community/aio_sandbox/aio_sandbox_provider.py b/backend/src/community/aio_sandbox/aio_sandbox_provider.py
new file mode 100644
index 0000000..c24b8a5
--- /dev/null
+++ b/backend/src/community/aio_sandbox/aio_sandbox_provider.py
@@ -0,0 +1,233 @@
+import logging
+import subprocess
+import time
+import uuid
+
+import requests
+
+from src.config import get_app_config
+from src.sandbox.sandbox import Sandbox
+from src.sandbox.sandbox_provider import SandboxProvider
+
+from .aio_sandbox import AioSandbox
+
+logger = logging.getLogger(__name__)
+
+# Default configuration
+DEFAULT_IMAGE = "enterprise-public-cn-beijing.cr.volces.com/vefaas-public/all-in-one-sandbox:latest"
+DEFAULT_PORT = 8080
+DEFAULT_CONTAINER_PREFIX = "deer-flow-sandbox"
+
+
+class AioSandboxProvider(SandboxProvider):
+ """Sandbox provider that manages Docker containers running the AIO sandbox.
+
+ Configuration options in config.yaml under sandbox:
+ use: src.community.aio_sandbox:AioSandboxProvider
+ image: enterprise-public-cn-beijing.cr.volces.com/vefaas-public/all-in-one-sandbox:latest # Docker image to use
+ port: 8080 # Base port for sandbox containers
+ base_url: http://localhost:8080 # If set, uses existing sandbox instead of starting new container
+ auto_start: true # Whether to automatically start Docker container
+ container_prefix: deer-flow-sandbox # Prefix for container names
+ mounts: # List of volume mounts
+ - host_path: /path/on/host
+ container_path: /path/in/container
+ read_only: false
+ """
+
+ def __init__(self):
+ self._sandboxes: dict[str, AioSandbox] = {}
+ self._containers: dict[str, str] = {} # sandbox_id -> container_id
+ self._config = self._load_config()
+
+ def _load_config(self) -> dict:
+ """Load sandbox configuration from app config."""
+ config = get_app_config()
+ sandbox_config = config.sandbox
+
+ # Set defaults
+ return {
+ "image": sandbox_config.image or DEFAULT_IMAGE,
+ "port": sandbox_config.port or DEFAULT_PORT,
+ "base_url": sandbox_config.base_url,
+ "auto_start": sandbox_config.auto_start if sandbox_config.auto_start is not None else True,
+ "container_prefix": sandbox_config.container_prefix or DEFAULT_CONTAINER_PREFIX,
+ "mounts": sandbox_config.mounts or [],
+ }
+
+ def _is_sandbox_ready(self, base_url: str, timeout: int = 30) -> bool:
+ """Check if sandbox is ready to accept connections.
+
+ Args:
+ base_url: Base URL of the sandbox.
+ timeout: Maximum time to wait in seconds.
+
+ Returns:
+ True if sandbox is ready, False otherwise.
+ """
+ start_time = time.time()
+ while time.time() - start_time < timeout:
+ try:
+ response = requests.get(f"{base_url}/v1/sandbox", timeout=5)
+ if response.status_code == 200:
+ return True
+ except requests.exceptions.RequestException:
+ pass
+ time.sleep(1)
+ return False
+
+ def _start_container(self, sandbox_id: str, port: int) -> str:
+ """Start a new Docker container for the sandbox.
+
+ Args:
+ sandbox_id: Unique identifier for the sandbox.
+ port: Port to expose the sandbox API on.
+
+ Returns:
+ The container ID.
+ """
+ image = self._config["image"]
+ container_name = f"{self._config['container_prefix']}-{sandbox_id}"
+
+ cmd = [
+ "docker",
+ "run",
+ "--security-opt",
+ "seccomp=unconfined",
+ "--rm",
+ "-d",
+ "-p",
+ f"{port}:8080",
+ "--name",
+ container_name,
+ ]
+
+ # Add volume mounts
+ for mount in self._config["mounts"]:
+ host_path = mount.host_path
+ container_path = mount.container_path
+ read_only = mount.read_only
+ mount_spec = f"{host_path}:{container_path}"
+ if read_only:
+ mount_spec += ":ro"
+ cmd.extend(["-v", mount_spec])
+
+ cmd.append(image)
+
+ logger.info(f"Starting sandbox container: {' '.join(cmd)}")
+
+ try:
+ result = subprocess.run(cmd, capture_output=True, text=True, check=True)
+ container_id = result.stdout.strip()
+ logger.info(f"Started sandbox container {container_name} with ID {container_id}")
+ return container_id
+ except subprocess.CalledProcessError as e:
+ logger.error(f"Failed to start sandbox container: {e.stderr}")
+ raise RuntimeError(f"Failed to start sandbox container: {e.stderr}")
+
+ def _stop_container(self, container_id: str) -> None:
+ """Stop and remove a Docker container.
+
+ Args:
+ container_id: The container ID to stop.
+ """
+ try:
+ subprocess.run(["docker", "stop", container_id], capture_output=True, text=True, check=True)
+ logger.info(f"Stopped sandbox container {container_id}")
+ except subprocess.CalledProcessError as e:
+ logger.warning(f"Failed to stop sandbox container {container_id}: {e.stderr}")
+
+ def _find_available_port(self, start_port: int) -> int:
+ """Find an available port starting from start_port.
+
+ Args:
+ start_port: Port to start searching from.
+
+ Returns:
+ An available port number.
+ """
+ import socket
+
+ port = start_port
+ while port < start_port + 100: # Search up to 100 ports
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+ try:
+ s.bind(("localhost", port))
+ return port
+ except OSError:
+ port += 1
+ raise RuntimeError(f"No available port found in range {start_port}-{start_port + 100}")
+
+ def acquire(self) -> str:
+ """Acquire a sandbox environment and return its ID.
+
+ If base_url is configured, uses the existing sandbox.
+ Otherwise, starts a new Docker container.
+
+ Returns:
+ The ID of the acquired sandbox environment.
+ """
+ sandbox_id = str(uuid.uuid4())[:8]
+
+ # If base_url is configured, use existing sandbox
+ if self._config.get("base_url"):
+ base_url = self._config["base_url"]
+ logger.info(f"Using existing sandbox at {base_url}")
+
+ if not self._is_sandbox_ready(base_url, timeout=5):
+ raise RuntimeError(f"Sandbox at {base_url} is not ready")
+
+ sandbox = AioSandbox(id=sandbox_id, base_url=base_url)
+ self._sandboxes[sandbox_id] = sandbox
+ return sandbox_id
+
+ # Otherwise, start a new container
+ if not self._config.get("auto_start", True):
+ raise RuntimeError("auto_start is disabled and no base_url is configured")
+
+ port = self._find_available_port(self._config["port"])
+ container_id = self._start_container(sandbox_id, port)
+ self._containers[sandbox_id] = container_id
+
+ base_url = f"http://localhost:{port}"
+
+ # Wait for sandbox to be ready
+ if not self._is_sandbox_ready(base_url, timeout=60):
+ # Clean up container if it didn't start properly
+ self._stop_container(container_id)
+ del self._containers[sandbox_id]
+ raise RuntimeError("Sandbox container failed to start within timeout")
+
+ sandbox = AioSandbox(id=sandbox_id, base_url=base_url)
+ self._sandboxes[sandbox_id] = sandbox
+ logger.info(f"Acquired sandbox {sandbox_id} at {base_url}")
+ return sandbox_id
+
+ def get(self, sandbox_id: str) -> Sandbox | None:
+ """Get a sandbox environment by ID.
+
+ Args:
+ sandbox_id: The ID of the sandbox environment.
+
+ Returns:
+ The sandbox instance if found, None otherwise.
+ """
+ return self._sandboxes.get(sandbox_id)
+
+ def release(self, sandbox_id: str) -> None:
+ """Release a sandbox environment.
+
+ If the sandbox was started by this provider, stops the container.
+
+ Args:
+ sandbox_id: The ID of the sandbox environment to release.
+ """
+ if sandbox_id in self._sandboxes:
+ del self._sandboxes[sandbox_id]
+ logger.info(f"Released sandbox {sandbox_id}")
+
+ # Stop container if we started it
+ if sandbox_id in self._containers:
+ container_id = self._containers[sandbox_id]
+ self._stop_container(container_id)
+ del self._containers[sandbox_id]
diff --git a/backend/src/config/app_config.py b/backend/src/config/app_config.py
index bfb85c3..de34cdd 100644
--- a/backend/src/config/app_config.py
+++ b/backend/src/config/app_config.py
@@ -3,12 +3,16 @@ from pathlib import Path
from typing import Self
import yaml
+from dotenv import load_dotenv
from pydantic import BaseModel, ConfigDict, Field
from src.config.model_config import ModelConfig
from src.config.sandbox_config import SandboxConfig
+from src.config.title_config import load_title_config_from_dict
from src.config.tool_config import ToolConfig, ToolGroupConfig
+load_dotenv()
+
class AppConfig(BaseModel):
"""Config for the DeerFlow application"""
@@ -64,6 +68,11 @@ class AppConfig(BaseModel):
with open(resolved_path) as f:
config_data = yaml.safe_load(f)
cls.resolve_env_variables(config_data)
+
+ # Load title config if present
+ if "title" in config_data:
+ load_title_config_from_dict(config_data["title"])
+
result = cls.model_validate(config_data)
return result
diff --git a/backend/src/config/sandbox_config.py b/backend/src/config/sandbox_config.py
index 48a25a3..d4164c9 100644
--- a/backend/src/config/sandbox_config.py
+++ b/backend/src/config/sandbox_config.py
@@ -1,10 +1,56 @@
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, ConfigDict, Field
+
+
+class VolumeMountConfig(BaseModel):
+ """Configuration for a volume mount."""
+
+ host_path: str = Field(..., description="Path on the host machine")
+ container_path: str = Field(..., description="Path inside the container")
+ read_only: bool = Field(default=False, description="Whether the mount is read-only")
class SandboxConfig(BaseModel):
- """Config section for a sandbox"""
+ """Config section for a sandbox.
+
+ Common options:
+ use: Class path of the sandbox provider (required)
+
+ AioSandboxProvider specific options:
+ image: Docker image to use (default: enterprise-public-cn-beijing.cr.volces.com/vefaas-public/all-in-one-sandbox:latest)
+ port: Base port for sandbox containers (default: 8080)
+ base_url: If set, uses existing sandbox instead of starting new container
+ auto_start: Whether to automatically start Docker container (default: true)
+ container_prefix: Prefix for container names (default: deer-flow-sandbox)
+ mounts: List of volume mounts to share directories with the container
+ """
use: str = Field(
...,
- description="Class path of the sandbox provider(e.g. src.sandbox.local:LocalSandbox)",
+ description="Class path of the sandbox provider (e.g. src.sandbox.local:LocalSandboxProvider)",
)
+ image: str | None = Field(
+ default=None,
+ description="Docker image to use for the sandbox container",
+ )
+ port: int | None = Field(
+ default=None,
+ description="Base port for sandbox containers",
+ )
+ base_url: str | None = Field(
+ default=None,
+ description="If set, uses existing sandbox at this URL instead of starting new container",
+ )
+ auto_start: bool | None = Field(
+ default=None,
+ description="Whether to automatically start Docker container",
+ )
+ container_prefix: str | None = Field(
+ default=None,
+ description="Prefix for container names",
+ )
+ mounts: list[VolumeMountConfig] = Field(
+ default_factory=list,
+ description="List of volume mounts to share directories between host and container",
+ )
+
+ model_config = ConfigDict(extra="allow")
diff --git a/backend/src/config/title_config.py b/backend/src/config/title_config.py
new file mode 100644
index 0000000..f335b49
--- /dev/null
+++ b/backend/src/config/title_config.py
@@ -0,0 +1,53 @@
+"""Configuration for automatic thread title generation."""
+
+from pydantic import BaseModel, Field
+
+
+class TitleConfig(BaseModel):
+ """Configuration for automatic thread title generation."""
+
+ enabled: bool = Field(
+ default=True,
+ description="Whether to enable automatic title generation",
+ )
+ max_words: int = Field(
+ default=6,
+ ge=1,
+ le=20,
+ description="Maximum number of words in the generated title",
+ )
+ max_chars: int = Field(
+ default=60,
+ ge=10,
+ le=200,
+ description="Maximum number of characters in the generated title",
+ )
+ model_name: str | None = Field(
+ default=None,
+ description="Model name to use for title generation (None = use default model)",
+ )
+ prompt_template: str = Field(
+ default=("Generate a concise title (max {max_words} words) for this conversation.\nUser: {user_msg}\nAssistant: {assistant_msg}\n\nReturn ONLY the title, no quotes, no explanation."),
+ description="Prompt template for title generation",
+ )
+
+
+# Global configuration instance
+_title_config: TitleConfig = TitleConfig()
+
+
+def get_title_config() -> TitleConfig:
+ """Get the current title configuration."""
+ return _title_config
+
+
+def set_title_config(config: TitleConfig) -> None:
+ """Set the title configuration."""
+ global _title_config
+ _title_config = config
+
+
+def load_title_config_from_dict(config_dict: dict) -> None:
+ """Load title configuration from a dictionary."""
+ global _title_config
+ _title_config = TitleConfig(**config_dict)
diff --git a/backend/src/sandbox/middleware.py b/backend/src/sandbox/middleware.py
index f536031..630d7f2 100644
--- a/backend/src/sandbox/middleware.py
+++ b/backend/src/sandbox/middleware.py
@@ -1,4 +1,4 @@
-from typing import override
+from typing import NotRequired, override
from langchain.agents import AgentState
from langchain.agents.middleware import AgentMiddleware
@@ -11,7 +11,7 @@ from src.sandbox import get_sandbox_provider
class SandboxMiddlewareState(AgentState):
"""Compatible with the `ThreadState` schema."""
- sandbox: SandboxState | None = None
+ sandbox: NotRequired[SandboxState | None]
class SandboxMiddleware(AgentMiddleware[SandboxMiddlewareState]):
diff --git a/backend/tests/test_title_generation.py b/backend/tests/test_title_generation.py
new file mode 100644
index 0000000..13fd597
--- /dev/null
+++ b/backend/tests/test_title_generation.py
@@ -0,0 +1,90 @@
+"""Tests for automatic thread title generation."""
+
+import pytest
+
+from src.agents.middlewares.title_middleware import TitleMiddleware
+from src.config.title_config import TitleConfig, get_title_config, set_title_config
+
+
+class TestTitleConfig:
+ """Tests for TitleConfig."""
+
+ def test_default_config(self):
+ """Test default configuration values."""
+ config = TitleConfig()
+ assert config.enabled is True
+ assert config.max_words == 6
+ assert config.max_chars == 60
+ assert config.model_name is None
+
+ def test_custom_config(self):
+ """Test custom configuration."""
+ config = TitleConfig(
+ enabled=False,
+ max_words=10,
+ max_chars=100,
+ model_name="gpt-4",
+ )
+ assert config.enabled is False
+ assert config.max_words == 10
+ assert config.max_chars == 100
+ assert config.model_name == "gpt-4"
+
+ def test_config_validation(self):
+ """Test configuration validation."""
+ # max_words should be between 1 and 20
+ with pytest.raises(ValueError):
+ TitleConfig(max_words=0)
+ with pytest.raises(ValueError):
+ TitleConfig(max_words=21)
+
+ # max_chars should be between 10 and 200
+ with pytest.raises(ValueError):
+ TitleConfig(max_chars=5)
+ with pytest.raises(ValueError):
+ TitleConfig(max_chars=201)
+
+ def test_get_set_config(self):
+ """Test global config getter and setter."""
+ original_config = get_title_config()
+
+ # Set new config
+ new_config = TitleConfig(enabled=False, max_words=10)
+ set_title_config(new_config)
+
+ # Verify it was set
+ assert get_title_config().enabled is False
+ assert get_title_config().max_words == 10
+
+ # Restore original config
+ set_title_config(original_config)
+
+
+class TestTitleMiddleware:
+ """Tests for TitleMiddleware."""
+
+ def test_middleware_initialization(self):
+ """Test middleware can be initialized."""
+ middleware = TitleMiddleware()
+ assert middleware is not None
+ assert middleware.state_schema is not None
+
+ # TODO: Add integration tests with mock Runtime
+ # def test_should_generate_title(self):
+ # """Test title generation trigger logic."""
+ # pass
+
+ # def test_generate_title(self):
+ # """Test title generation."""
+ # pass
+
+ # def test_after_agent_hook(self):
+ # """Test after_agent hook."""
+ # pass
+
+
+# TODO: Add integration tests
+# - Test with real LangGraph runtime
+# - Test title persistence with checkpointer
+# - Test fallback behavior when LLM fails
+# - Test concurrent title generation
diff --git a/backend/uv.lock b/backend/uv.lock
index 46a950d..9be34eb 100644
--- a/backend/uv.lock
+++ b/backend/uv.lock
@@ -1,7 +1,21 @@
version = 1
-revision = 2
+revision = 3
requires-python = ">=3.12"
+[[package]]
+name = "agent-sandbox"
+version = "0.0.19"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "httpx", extra = ["socks"] },
+ { name = "pydantic" },
+ { name = "volcengine-python-sdk" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b0/21/62d527b1c671ad82f8f11b4caa585b85e829e5a23960ee83facae49da69b/agent_sandbox-0.0.19.tar.gz", hash = "sha256:724b40d7a20eedd1da67f254d02705a794d0835ebc30c9b5ca8aa148accf3bbd", size = 68114, upload-time = "2025-12-11T08:24:29.174Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/87/19/8c8f3d786ea65fb8a40ba7ac7e5fa0dd972fba413421a139cd6ca3679fe2/agent_sandbox-0.0.19-py2.py3-none-any.whl", hash = "sha256:063b6ffe7d035d84289e60339cbb0708169efe89f9d322e94c071ae2ee5bec5a", size = 152276, upload-time = "2025-12-11T08:24:27.682Z" },
+]
+
[[package]]
name = "annotated-types"
version = "0.7.0"
@@ -117,6 +131,8 @@ name = "deer-flow"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
+ { name = "agent-sandbox" },
+ { name = "dotenv" },
{ name = "langchain" },
{ name = "langchain-deepseek" },
{ name = "langchain-openai" },
@@ -130,11 +146,14 @@ dependencies = [
[package.dev-dependencies]
dev = [
+ { name = "pytest" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
+ { name = "agent-sandbox", specifier = ">=0.0.19" },
+ { name = "dotenv", specifier = ">=0.9.9" },
{ name = "langchain", specifier = ">=1.2.3" },
{ name = "langchain-deepseek", specifier = ">=1.0.1" },
{ name = "langchain-openai", specifier = ">=1.1.7" },
@@ -147,7 +166,10 @@ requires-dist = [
]
[package.metadata.requires-dev]
-dev = [{ name = "ruff", specifier = ">=0.14.11" }]
+dev = [
+ { name = "pytest", specifier = ">=8.0.0" },
+ { name = "ruff", specifier = ">=0.14.11" },
+]
[[package]]
name = "distro"
@@ -158,6 +180,17 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
]
+[[package]]
+name = "dotenv"
+version = "0.9.9"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "python-dotenv" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b2/b7/545d2c10c1fc15e48653c91efde329a790f2eecfbbf2bd16003b5db2bab0/dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9", size = 1892, upload-time = "2025-02-19T22:15:01.647Z" },
+]
+
[[package]]
name = "h11"
version = "0.16.0"
@@ -208,6 +241,11 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
+[package.optional-dependencies]
+socks = [
+ { name = "socksio" },
+]
+
[[package]]
name = "idna"
version = "3.11"
@@ -217,6 +255,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
+]
+
[[package]]
name = "jiter"
version = "0.12.0"
@@ -653,6 +700,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
]
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
+]
+
[[package]]
name = "pydantic"
version = "2.12.5"
@@ -739,6 +795,52 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
]
+[[package]]
+name = "pygments"
+version = "2.19.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
+]
+
+[[package]]
+name = "pytest"
+version = "9.0.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "iniconfig" },
+ { name = "packaging" },
+ { name = "pluggy" },
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
+]
+
+[[package]]
+name = "python-dateutil"
+version = "2.9.0.post0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "six" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
+]
+
+[[package]]
+name = "python-dotenv"
+version = "1.2.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
+]
+
[[package]]
name = "pyyaml"
version = "6.0.3"
@@ -949,6 +1051,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
+[[package]]
+name = "socksio"
+version = "1.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/48a7d9495be3d1c651198fd99dbb6ce190e2274d0f28b9051307bdec6b85/socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac", size = 19055, upload-time = "2020-04-17T15:50:34.664Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/37/c3/6eeb6034408dac0fa653d126c9204ade96b819c936e136c5e8a6897eee9c/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3", size = 12763, upload-time = "2020-04-17T15:50:31.878Z" },
+]
+
[[package]]
name = "soupsieve"
version = "2.8.1"
@@ -1092,6 +1203,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/69/37/674b3ce25cd715b831ea8ebbd828b74c40159f04c95d1bb963b2c876fe79/uuid_utils-0.13.0-cp39-abi3-win_arm64.whl", hash = "sha256:5447a680df6ef8a5a353976aaf4c97cc3a3a22b1ee13671c44227b921e3ae2a9", size = 183518, upload-time = "2026-01-08T15:47:59.148Z" },
]
+[[package]]
+name = "volcengine-python-sdk"
+version = "5.0.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "python-dateutil" },
+ { name = "six" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/31/5c/827674e1be2215f4e8205fc876d9a9e0267d9a1be1dbb31fb87213331288/volcengine_python_sdk-5.0.5.tar.gz", hash = "sha256:8c3b674ab5370d93dabb74356f60236418fea785d18e9c4b967390883e87d756", size = 7381857, upload-time = "2026-01-09T13:00:05.997Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f3/5c/f20856e9d337a9feb7f66a8d3d78a86886054d9fb32ff29a0a4d6ac0d2ed/volcengine_python_sdk-5.0.5-py2.py3-none-any.whl", hash = "sha256:c9b91261386d7f2c1ccfc48169c4b319c58f3c66cc5e492936b5dfb6d25e1a5f", size = 29018827, upload-time = "2026-01-09T13:00:01.827Z" },
+]
+
[[package]]
name = "webencodings"
version = "0.5.1"