From b2abfecf675324e4d016004a92d589c50bd948b8 Mon Sep 17 00:00:00 2001 From: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com> Date: Wed, 14 Jan 2026 23:29:18 +0800 Subject: [PATCH] feat: add AIO sandbox provider and auto title generation (#1) - Add AioSandboxProvider for Docker-based sandbox execution with configurable container lifecycle, volume mounts, and port management - Add TitleMiddleware to auto-generate thread titles after first user-assistant exchange using LLM - Add Claude Code documentation (CLAUDE.md, AGENTS.md) - Extend SandboxConfig with Docker-specific options (image, port, mounts) - Fix hardcoded mount path to use expanduser - Add agent-sandbox and dotenv dependencies Co-authored-by: Claude Opus 4.5 --- backend/.claude/settings.local.json | 7 + backend/AGENTS.md | 2 + backend/CLAUDE.md | 76 ++++++ backend/Makefile | 2 +- backend/docs/AUTO_TITLE_GENERATION.md | 256 ++++++++++++++++++ backend/docs/BACKEND_TODO.md | 125 +++++++++ .../docs/TITLE_GENERATION_IMPLEMENTATION.md | 222 +++++++++++++++ backend/pyproject.toml | 3 + backend/src/agents/lead_agent/agent.py | 5 +- backend/src/agents/lead_agent/prompt.py | 3 +- .../agents/middlewares/title_middleware.py | 93 +++++++ backend/src/agents/thread_state.py | 7 +- backend/src/community/aio_sandbox/__init__.py | 7 + .../src/community/aio_sandbox/aio_sandbox.py | 113 ++++++++ .../aio_sandbox/aio_sandbox_provider.py | 233 ++++++++++++++++ backend/src/config/app_config.py | 9 + backend/src/config/sandbox_config.py | 52 +++- backend/src/config/title_config.py | 53 ++++ backend/src/sandbox/middleware.py | 4 +- backend/tests/test_title_generation.py | 90 ++++++ backend/uv.lock | 130 ++++++++- 21 files changed, 1479 insertions(+), 13 deletions(-) create mode 100644 backend/.claude/settings.local.json create mode 100644 backend/AGENTS.md create mode 100644 backend/CLAUDE.md create mode 100644 backend/docs/AUTO_TITLE_GENERATION.md create mode 100644 backend/docs/BACKEND_TODO.md create mode 100644 backend/docs/TITLE_GENERATION_IMPLEMENTATION.md create mode 100644 backend/src/agents/middlewares/title_middleware.py create mode 100644 backend/src/community/aio_sandbox/__init__.py create mode 100644 backend/src/community/aio_sandbox/aio_sandbox.py create mode 100644 backend/src/community/aio_sandbox/aio_sandbox_provider.py create mode 100644 backend/src/config/title_config.py create mode 100644 backend/tests/test_title_generation.py 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"