From 37bd8c6591ebb8fe25e39db00eb1d15b896c88a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=AE=B8=E6=B5=B7=E6=B4=8B?= Date: Wed, 20 May 2026 17:38:04 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=99=BA=E8=83=BD=E4=BD=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-05-20-router-subagent-design.md | 222 ++++++++++++++++++ .../backend/app/gateway/services.py | 36 ++- .../deerflow/agents/lead_agent/agent.py | 104 +++++--- .../deerflow/agents/lead_agent/prompt.py | 3 +- .../deerflow/tools/builtins/__init__.py | 3 + .../deerflow/tools/builtins/fast_chat_tool.py | 140 +++++++++++ .../tools/builtins/router_quality_tool.py | 75 ++++++ .../packages/harness/deerflow/tools/tools.py | 4 +- .../backend/pyproject.toml | 101 ++++---- offline-backend-20260512/backend/uv.lock | 2 + 10 files changed, 600 insertions(+), 90 deletions(-) create mode 100644 docs/superpowers/specs/2026-05-20-router-subagent-design.md create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/fast_chat_tool.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/router_quality_tool.py diff --git a/docs/superpowers/specs/2026-05-20-router-subagent-design.md b/docs/superpowers/specs/2026-05-20-router-subagent-design.md new file mode 100644 index 00000000..5ba73bd7 --- /dev/null +++ b/docs/superpowers/specs/2026-05-20-router-subagent-design.md @@ -0,0 +1,222 @@ +# 问答模式路由智能体设计方案 + +## 1. 背景与目标 + +路由器智能体(`f0e75f44f7594053ba1567ed89258f35`)根据用户问题判断最合适的问答模式(fast/rag/bigMode/moreModes/taskChat/hxchat/deerFlow),选择后需要调用相应子 Agent 执行,路由器对执行结果进行**完整性质检**,通过则流式输出到前端,不通过则重新匹配模式并再次执行。 + +## 2. 整体流程 + +``` +用户问题 + │ + ▼ +┌──────────────────────────────┐ +│ 路由器智能体 │ +│ (已有 SOUL.md) │ +│ ① 判断最佳模式 │ +└─────────────┬────────────────┘ + │ 选择模式 + ▼ +┌──────────────────────────────┐ +│ 子 Agent 执行 │ +│ (完整执行完毕,结果缓存) │ +└─────────────┬────────────────┘ + │ 执行完毕 + ▼ +┌──────────────────────────────┐ +│ 路由器质检 │ +│ LLM 判断结果是否完整覆盖用户问题 │ +└─────────────┬────────────────┘ + │ + ③ 质检结果 + ┌────┴────┐ + │通过 │不通过 + ▼ ▼ + 流式输出 重新匹配模式 + 到前端 (最多重试2次) +``` + +- **最大重试次数**:每个问题最多 2 次重试(总共最多 3 次执行),超过后强制输出当前结果并标注"结果可能不完整" + +## 3. 目录结构 + +``` +.deer-flow/agents/f0e75f44f7594053ba1567ed89258f35/ +├── SOUL.md ← 路由器智能体(已有) +├── config.yaml ← 路由器配置 +└── subagents/ + ├── fast/ + │ ├── SOUL.md ← 快速问答子 Agent + │ └── config.yaml ← tool_groups 声明 + ├── rag/ + │ ├── SOUL.md + │ └── config.yaml + ├── bigMode/ + │ ├── SOUL.md + │ └── config.yaml + ├── moreModes/ + │ ├── SOUL.md + │ └── config.yaml + ├── taskChat/ + │ ├── SOUL.md + │ └── config.yaml + ├── hxchat/ + │ ├── SOUL.md + │ └── config.yaml + └── deerFlow/ + ├── SOUL.md + └── config.yaml +``` + +**父子关系**:子 Agent 目录放在路由器下二级 `subagents/`,通过目录层级体现父子结构。 + +## 4. 子 Agent 配置 + +### 4.1 config.yaml 格式 + +```yaml +id: rag +name: 知识库问答子Agent +description: 从知识库检索并返回引用 +tool_groups: + - knowledge # 知识库检索工具组 + - file:read # 只读文件工具 +skills: [] +model: deepseek-chat +``` + +### 4.2 tool_groups 说明 + +- tool_groups 在系统级 `config.yaml` 中统一定义(`tool_groups` 列表) +- 子 Agent 的 config.yaml 通过 `tool_groups` 字段声明自己可以使用哪些工具组 +- 工具组示例:web(网页搜索)、knowledge(知识库检索)、file:read、file:write、bash 等 + +## 5. 调用方式 + +### 5.1 复用 Gateway API(方案 A — 推荐) + +路由器通过 DeerFlow 现有 `/api/threads/{thread_id}/runs/stream` 接口调用子 Agent: + +``` +POST /api/threads/{thread_id}/runs/stream +{ + "assistant_id": "f0e75f44f7594053ba1567ed89258f35", + "context": { + "subagent_mode": "rag" ← 路由器注入子模式 + }, + "input": { + "messages": [...] + }, + "stream_mode": ["values"] +} +``` + +**执行步骤**: +1. 路由器启动一个 run,assistant_id 指向路由器本身 +2. 路由器通过 `context.subagent_mode` 告知 DeerFlow 运行时本次应加载哪个子 Agent +3. DeerFlow 运行时加载 `subagents/{subagent_mode}/SOUL.md` 和对应的 tool_groups +4. 子 Agent 完整执行,结果缓存在 StreamBridge 中 +5. 执行完毕后,路由器读取完整结果 + +**context.subagent_mode 注入机制**: +- `build_run_config` 中新增 `subagent_mode` 到 `_CONTEXT_CONFIGURABLE_KEYS` +- `make_lead_agent` 读取 `configurable.subagent_mode`,如果存在则加载对应子 Agent 的 SOUL.md 和工具配置 +- 如果 `subagent_mode` 不存在,则正常运行路由器自己的 SOUL.md + +### 5.2 质检提示词 + +路由器在子 Agent 执行完毕后,用 LLM 进行完整性质检: + +``` +你是一个质检员。上游子 Agent 已根据问题执行完毕。 +请判断以下结果是否完整覆盖了用户问题的所有子问题。 + +用户原始问题:{original_question} + +子 Agent 执行结果: +{agent_output} + +请只返回 JSON 格式: +{ + "passed": true/false, + "reason": "判断理由(10字内)", + "missing_parts": ["缺失的子问题1", "缺失的子问题2"] // 如果 passed=false +} +``` + +**质检维度**:完整性(结果是否覆盖了用户问题的所有子问题) + +## 6. 关键代码改动 + +### 6.1 DeerFlow 运行时改动 + +**文件**:`packages/harness/deerflow/agents/lead_agent/agent.py` + +改动点: +- `make_lead_agent` 读取 `configurable.subagent_mode` +- 如果存在,加载 `.deer-flow/agents/{agent_id}/subagents/{subagent_mode}/SOUL.md` 和对应 `config.yaml` +- 工具绑定使用子 Agent 的 tool_groups,而非父 Agent 的 + +### 6.2 build_run_config 改动 + +**文件**:`app/gateway/services.py` + +改动点: +- `_CONTEXT_CONFIGURABLE_KEYS` 新增 `subagent_mode` +- `merge_run_context_overrides` 将 `subagent_mode` 透传到 configurable + +### 6.3 路由器 SOUL.md 改动 + +路由器 SOUL.md 新增功能描述: +- 角色扩展:增加"在确定模式后,调用相应子 Agent 执行并对结果进行质检"的职责 +- 工具描述:新增一个质检工具(路由器自己调用 LLM 执行质检) + +### 6.4 新增路由器质检工具 + +**文件**:`packages/harness/deerflow/tools/builtins/router_quality_tool.py`(新建) + +```python +@tool +def router_quality_check(original_question: str, agent_output: str) -> str: + """质检工具 — 判断子 Agent 输出是否完整覆盖用户问题 + + 返回 JSON:{"passed": bool, "reason": str, "missing_parts": list[str]} + """ + # 调用 LLM 执行质检逻辑 +``` + +## 7. 数据流 + +``` +用户问题 + │ + ▼ +路由器 SOUL.md 判断模式 + │ + ▼ +启动子 Agent Run(context.subagent_mode = rag) + │ + ▼ +子 Agent SOUL.md + tool_groups 执行 + │ + ▼ +StreamBridge 缓存完整结果 + │ + ▼ +路由器调用 router_quality_check 质检 + │ + ├── passed=true → 流式输出到前端 + └── passed=false → 重新匹配模式(最多2次重试) +``` + +## 8. 测试计划 + +- **单元测试**:`tests/test_router_subagent.py` — 测试模式判断、质检逻辑、重试机制 +- **集成测试**:手动调用 `/api/threads/{thread_id}/runs/stream`,验证完整流程 +- **质检提示词测试**:针对不同问题类型测试质检结果的准确性 + +## 9. 依赖项 + +- 路由器复用现有 Gateway API,无需新建接口 +- 质检使用 LLM(deepseek-chat 或配置中指定的模型) +- tool_groups 机制依赖系统级 config.yaml 中的工具组定义 diff --git a/offline-backend-20260512/backend/app/gateway/services.py b/offline-backend-20260512/backend/app/gateway/services.py index f14d8df4..3ac63fa7 100644 --- a/offline-backend-20260512/backend/app/gateway/services.py +++ b/offline-backend-20260512/backend/app/gateway/services.py @@ -152,14 +152,19 @@ _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset( "model_name", "mode", "thinking_enabled", - "reasoning_effort", - "writing_mode", - "writing_artifact_path", - "is_plan_mode", + "reasoning_effort", + "writing_mode", + "writing_artifact_path", + "is_plan_mode", "subagent_enabled", "max_concurrent_subagents", "agent_name", "is_bootstrap", + "subagent_mode", + "search_space_id", + "mentioned_document_ids", + "pic_query", + "subagent_model_name", } ) @@ -302,12 +307,16 @@ def _collect_requested_agent_ids(body: Any) -> set[str]: async def _sync_legacy_agent_records(store) -> None: """Import existing folder-backed agents as built-in records.""" + import sys from deerflow.config.agents_config import list_custom_agents + print(f"[_sync_legacy_agent_records] store type: {type(store).__name__}, id: {id(store)}", file=sys.stderr) + logger.info("_sync_legacy_agent_records: starting sync") for agent_cfg in list_custom_agents(): agent_id = agent_cfg.id or agent_cfg.name if not agent_id: continue + logger.info(f" Syncing agent: {agent_id}") await store.ensure_builtin( { "id": agent_id, @@ -316,6 +325,7 @@ async def _sync_legacy_agent_records(store) -> None: "published": True, } ) + logger.info("_sync_legacy_agent_records: done") async def _enforce_agent_access(body: Any, request: Request) -> None: @@ -324,12 +334,20 @@ async def _enforce_agent_access(body: Any, request: Request) -> None: return from app.gateway.deps import get_agent_store - from deerflow.config.agents_config import validate_agent_id + from deerflow.config.agents_config import validate_agent_id, list_custom_agents from deerflow.runtime.user_context import get_effective_user_id store = get_agent_store(request) + import sys + print(f"[_enforce_agent_access] store type: {type(store).__name__}, id: {id(store)}", file=sys.stderr) await _sync_legacy_agent_records(store) user_id = get_effective_user_id() + logger.info(f"_enforce_agent_access: user_id={user_id}, agent_ids={agent_ids}") + + # Debug: list all agents from list_custom_agents + all_agents = list_custom_agents() + logger.info(f" list_custom_agents() returned {len(all_agents)} agents: {[a.id for a in all_agents]}") + for agent_id in agent_ids: try: normalized = validate_agent_id(agent_id) @@ -337,7 +355,13 @@ async def _enforce_agent_access(body: Any, request: Request) -> None: raise HTTPException(status_code=422, detail=str(exc)) from exc if normalized is None: continue - if await store.get_visible(normalized, user_id) is None: + record = await store.get_visible(normalized, user_id) + logger.info(f" get_visible({normalized}, {user_id}) = {record}") + # Also check get_any + any_record = await store.get_any(normalized) + import sys + print(f"[_enforce_agent_access] get_any({normalized}) = {any_record}", file=sys.stderr) + if record is None: raise HTTPException(status_code=404, detail=f"Agent {normalized} not found") diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py index ea7062e7..b7145f31 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py @@ -336,13 +336,13 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig): requested_model_name: str | None = cfg.get("model_name") or cfg.get("model") is_plan_mode = cfg.get("is_plan_mode", False) subagent_enabled = cfg.get("subagent_enabled", False) - max_concurrent_subagents = cfg.get("max_concurrent_subagents", 3) - is_bootstrap = cfg.get("is_bootstrap", False) - writing_mode = bool(cfg.get("writing_mode", False)) - writing_artifact_path = cfg.get("writing_artifact_path") if writing_mode else None - if not isinstance(writing_artifact_path, str) or not writing_artifact_path.strip(): - writing_artifact_path = None - agent_id = validate_agent_id(cfg.get("agent_id") or cfg.get("agent_name")) + max_concurrent_subagents = cfg.get("max_concurrent_subagents", 3) + is_bootstrap = cfg.get("is_bootstrap", False) + writing_mode = bool(cfg.get("writing_mode", False)) + writing_artifact_path = cfg.get("writing_artifact_path") if writing_mode else None + if not isinstance(writing_artifact_path, str) or not writing_artifact_path.strip(): + writing_artifact_path = None + agent_id = validate_agent_id(cfg.get("agent_id") or cfg.get("agent_name")) agent_config = load_agent_config(agent_id) if not is_bootstrap else None agent_display_name = agent_config.name if agent_config else agent_id @@ -360,16 +360,52 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig): logger.warning(f"Thinking mode is enabled but model '{model_name}' does not support it; fallback to non-thinking mode.") thinking_enabled = False + # Check if we're in subagent mode (router agent delegating to a subagent) + subagent_mode = cfg.get("subagent_mode") + if subagent_mode and agent_id: + # Load subagent config and SOUL.md from subagents/{subagent_mode}/ + from deerflow.config.paths import get_paths + + paths = get_paths() + subagent_dir = paths.agent_dir(agent_id) / "subagents" / subagent_mode + subagent_soul_path = subagent_dir / "SOUL.md" + subagent_config_path = subagent_dir / "config.yaml" + + if subagent_soul_path.exists(): + subagent_soul = subagent_soul_path.read_text(encoding="utf-8").strip() + else: + subagent_soul = None + + subagent_tool_groups = None + if subagent_config_path.exists(): + try: + import yaml + + with open(subagent_config_path, encoding="utf-8") as f: + subagent_data = yaml.safe_load(f) or {} + subagent_tool_groups = subagent_data.get("tool_groups") + except yaml.YAMLError: + logger.warning(f"Failed to parse subagent config: {subagent_config_path}") + + logger.info( + "Subagent mode(%s) for router(%s) -> subagent_soul: %s, tool_groups: %s", + subagent_mode, + agent_id, + "found" if subagent_soul else "not found", + subagent_tool_groups, + ) + logger.info( - "Create Agent(%s) -> thinking_enabled: %s, reasoning_effort: %s, model_name: %s, is_plan_mode: %s, subagent_enabled: %s, max_concurrent_subagents: %s, writing_mode: %s", - agent_id or "default", - thinking_enabled, - reasoning_effort, - model_name, - is_plan_mode, - subagent_enabled, - max_concurrent_subagents, - writing_mode, + "Create Agent(%s) -> thinking_enabled: %s, reasoning_effort: %s, model_name: %s, is_plan_mode: %s, subagent_enabled: %s, max_concurrent_subagents: %s, writing_mode: %s, subagent_mode: %s", + agent_id or "default", + thinking_enabled, + reasoning_effort, + model_name, + is_plan_mode, + subagent_enabled, + max_concurrent_subagents, + writing_mode, + subagent_mode, ) # Inject run metadata for LangSmith trace tagging @@ -393,10 +429,10 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig): "thinking_enabled": thinking_enabled, "reasoning_effort": reasoning_effort, "is_plan_mode": is_plan_mode, - "subagent_enabled": subagent_enabled, - "writing_mode": writing_mode, - "writing_artifact_path": writing_artifact_path, - "tool_groups": agent_config.tool_groups if agent_config else None, + "subagent_enabled": subagent_enabled, + "writing_mode": writing_mode, + "writing_artifact_path": writing_artifact_path, + "tool_groups": subagent_tool_groups if subagent_mode else (agent_config.tool_groups if agent_config else None), "available_skills": runtime_available_skills, } ) @@ -415,21 +451,24 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig): middleware=_build_middlewares(config, model_name=model_name, app_config=resolved_app_config), system_prompt=apply_prompt_template( subagent_enabled=subagent_enabled, - max_concurrent_subagents=max_concurrent_subagents, - writing_mode=writing_mode, - writing_artifact_path=writing_artifact_path, - available_skills=set(["bootstrap"]), - app_config=resolved_app_config, + max_concurrent_subagents=max_concurrent_subagents, + writing_mode=writing_mode, + writing_artifact_path=writing_artifact_path, + available_skills=set(["bootstrap"]), + app_config=resolved_app_config, ), state_schema=ThreadState, ) # Default lead agent (unchanged behavior) + # Use subagent tool groups if in subagent mode, otherwise use parent agent config + effective_tool_groups = subagent_tool_groups if subagent_mode else (agent_config.tool_groups if agent_config else None) + return create_agent( model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort, app_config=resolved_app_config), tools=get_available_tools( model_name=model_name, - groups=agent_config.tool_groups if agent_config else None, + groups=effective_tool_groups, subagent_enabled=subagent_enabled, is_scheduled_run=cfg.get("is_scheduled_run", False), app_config=resolved_app_config, @@ -438,12 +477,13 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig): system_prompt=apply_prompt_template( subagent_enabled=subagent_enabled, max_concurrent_subagents=max_concurrent_subagents, - agent_id=agent_id, - agent_name=agent_display_name, - writing_mode=writing_mode, - writing_artifact_path=writing_artifact_path, - available_skills=(set(agent_config.skills or []) if agent_config else None), - app_config=resolved_app_config, + agent_id=agent_id, + agent_name=agent_display_name, + writing_mode=writing_mode, + writing_artifact_path=writing_artifact_path, + available_skills=(set(agent_config.skills or []) if agent_config else None), + app_config=resolved_app_config, + soul_override=subagent_soul if subagent_mode else None, ), state_schema=ThreadState, ) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py index 62d84aa0..97aaec0e 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py @@ -823,6 +823,7 @@ def apply_prompt_template( writing_artifact_path: str | None = None, available_skills: set[str] | None = None, app_config: AppConfig | None = None, + soul_override: str | None = None, ) -> str: storage_agent_id = agent_id if agent_id is not None else agent_name @@ -894,7 +895,7 @@ Writing mode is enabled for this run. # Format the prompt with dynamic skills and memory prompt = SYSTEM_PROMPT_TEMPLATE.format( agent_name=agent_name or storage_agent_id or "CM 助手", - soul=get_agent_soul(storage_agent_id), + soul=f"\n{soul_override}\n\n" if soul_override else get_agent_soul(storage_agent_id), skills_section=skills_section, deferred_tools_section=deferred_tools_section, writing_mode_section=writing_mode_section, diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/__init__.py index 3cb07a8c..d5eb45fc 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/__init__.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/__init__.py @@ -1,4 +1,5 @@ from .clarification_tool import ask_clarification_tool +from .fast_chat_tool import fast_chat_tool, fast_llm_answer from .present_file_tool import present_file_tool from .scheduled_task_tool import scheduled_task_tool from .setup_agent_tool import setup_agent @@ -12,4 +13,6 @@ __all__ = [ "scheduled_task_tool", "view_image_tool", "task_tool", + "fast_chat_tool", + "fast_llm_answer", ] diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/fast_chat_tool.py b/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/fast_chat_tool.py new file mode 100644 index 00000000..86d8febd --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/fast_chat_tool.py @@ -0,0 +1,140 @@ +"""Fast chat tool - call new_chat API for fast Q&A mode.""" + +import json +import logging +from typing import Annotated, Any + +from langchain.tools import InjectedToolCallId, ToolRuntime, tool +from langchain_core.messages import HumanMessage +from langchain_core.runnables import RunnableConfig +from langgraph.typing import ContextT + +from deerflow.agents.thread_state import ThreadState +from deerflow.config.app_config import get_app_config +from deerflow.models import create_chat_model + +logger = logging.getLogger(__name__) + + +def _get_runtime_value(runtime: ToolRuntime[ContextT, ThreadState], key: str, default: Any = None) -> Any: + """Get value from runtime context or config.""" + if runtime.context and isinstance(runtime.context, dict): + value = runtime.context.get(key) + if value is not None: + return value + + runtime_config = getattr(runtime, "config", None) or {} + configurable = runtime_config.get("configurable", {}) + if isinstance(configurable, dict): + value = configurable.get(key) + if value is not None: + return value + + return default + + +@tool("fast_chat", parse_docstring=True, return_direct=True) +async def fast_chat_tool( + runtime: ToolRuntime[ContextT, ThreadState], + prompt: str, + tool_call_id: Annotated[str, InjectedToolCallId], + search_space_id: int | None = None, + mentioned_document_ids: list[int] | None = None, + pic_query: list[dict[str, Any]] | None = None, + model_name: str | None = None, +) -> str: + """Fast chat tool - 快速问答模式调用后端聊天接口. + + 用于 fast 子 Agent 直接调用后端 new_chat 接口进行快速问答. + + Args: + runtime: 运行时上下文,自动注入. + prompt: 用户问题内容. + tool_call_id: 工具调用 ID,自动注入. + search_space_id: 搜索空间 ID (可选,从 context.subagent_search_space_id 获取). + mentioned_document_ids: 引用的文档 ID 列表 (可选). + pic_query: 图片查询列表 (可选). + model_name: 使用的模型名称 (默认使用 context.subagent_model_name 或 OpenAI3). + + Returns: + API 响应结果的 JSON 字符串. + """ + import asyncio + import urllib.error + import urllib.request + + try: + # 从 context 读取参数,支持外部传入或运行时获取 + thread_id = _get_runtime_value(runtime, "thread_id") + if thread_id is None: + return json.dumps({"error": "thread_id not found in context", "passed": False}) + + # 参数优先级:显式传入 > context 注入 + effective_search_space_id = search_space_id if search_space_id is not None else _get_runtime_value(runtime, "subagent_search_space_id") + effective_model_name = model_name or _get_runtime_value(runtime, "subagent_model_name", "OpenAI3") + effective_mentioned_ids = mentioned_document_ids or _get_runtime_value(runtime, "subagent_mentioned_document_ids") or [] + effective_pic_query = pic_query or _get_runtime_value(runtime, "subagent_pic_query") or [] + + app_config = get_app_config() + backend_url = getattr(app_config, "backend_url", "http://localhost:8001") + + messages = [{"role": "user", "content": prompt}] + + req_body = { + "chat_id": thread_id, + "user_query": prompt, + "search_space_id": effective_search_space_id, + "messages": messages, + "mentioned_document_ids": effective_mentioned_ids, + "pic_query": effective_pic_query, + "model_name": effective_model_name, + } + + req_url = f"{backend_url}/api/v1/new_chat" + + def _make_request(): + data = json.dumps(req_body).encode("utf-8") + req = urllib.request.Request( + req_url, + data=data, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=30) as response: + return response.read().decode("utf-8") + + result = await asyncio.to_thread(_make_request) + return result + + except urllib.error.HTTPError as exc: + logger.warning(f"Fast chat API HTTP error: {exc.code} {exc.reason}") + return json.dumps({"error": f"HTTP {exc.code}: {exc.reason}", "passed": False}) + except Exception as exc: + logger.warning(f"Fast chat API failed: {exc}") + return json.dumps({"error": str(exc), "passed": False}) + + +@tool("fast_llm_answer", parse_docstring=True, return_direct=True) +def fast_llm_answer(prompt: str) -> str: + """Fast LLM answer tool - 使用 LLM 直接回答简单问题. + + 当 fast 模式不需要调用外部 API 时,直接使用 LLM 回答简单事实查询. + + Args: + prompt: 用户问题. + + Returns: + LLM 回答内容. + """ + try: + app_config = get_app_config() + model_name = app_config.models[0].name if app_config.models else None + model = create_chat_model(name=model_name, thinking_enabled=False, app_config=app_config) + + response = model.invoke([HumanMessage(content=prompt)], config=RunnableConfig()) + content = response.content if hasattr(response, "content") else str(response) + return content + + except Exception as exc: + logger.warning(f"Fast LLM answer failed: {exc}") + return f"抱歉,处理您的问题时出现错误: {str(exc)}" \ No newline at end of file diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/router_quality_tool.py b/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/router_quality_tool.py new file mode 100644 index 00000000..24115c24 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/router_quality_tool.py @@ -0,0 +1,75 @@ +"""Router quality check tool — validates sub-agent output completeness.""" + +import json +import logging + +from langchain_core.messages import HumanMessage +from langchain_core.runnables import RunnableConfig +from langchain_core.tools import tool + +from deerflow.config.app_config import get_app_config +from deerflow.models import create_chat_model + +logger = logging.getLogger(__name__) + +QUALITY_CHECK_PROMPT = """你是一个质检员。上游子 Agent 已根据问题执行完毕。 +请判断以下结果是否完整覆盖了用户问题的所有子问题。 + +用户原始问题:{original_question} + +子 Agent 执行结果: +{agent_output} + +请只返回 JSON 格式: +{{ + "passed": true/false, + "reason": "判断理由(10字内)", + "missing_parts": ["缺失的子问题1", "缺失的子问题2"] // 如果 passed=false +}}""" + + +@tool("router_quality_check", parse_docstring=True, return_direct=True) +def router_quality_check(original_question: str, agent_output: str) -> str: + """质检工具 — 判断子 Agent 输出是否完整覆盖用户问题。 + + 路由器在子 Agent 执行完毕后调用此工具进行完整性质检。 + 质检通过则流式输出到前端,不通过则重新匹配模式并再次执行。 + + Args: + original_question: 用户原始问题。 + agent_output: 子 Agent 的执行结果。 + + Returns: + JSON 格式的质检结果:{{"passed": bool, "reason": str, "missing_parts": list[str]}} + """ + try: + app_config = get_app_config() + model_name = app_config.models[0].name if app_config.models else None + model = create_chat_model(name=model_name, thinking_enabled=False, app_config=app_config) + + prompt = QUALITY_CHECK_PROMPT.format( + original_question=original_question, + agent_output=agent_output, + ) + + response = model.invoke([HumanMessage(content=prompt)], config=RunnableConfig()) + content = response.content if hasattr(response, "content") else str(response) + + # Try to parse JSON from the response + # The model might wrap it in ```json or just return raw JSON + json_str = content.strip() + if json_str.startswith("```json"): + json_str = json_str[7:] + if json_str.startswith("```"): + json_str = json_str[3:] + if json_str.endswith("```"): + json_str = json_str[:-3] + json_str = json_str.strip() + + result = json.loads(json_str) + return json.dumps(result, ensure_ascii=False) + + except Exception as exc: + logger.warning(f"Router quality check failed: {exc}") + # Fallback: pass with unknown reason on error + return json.dumps({"passed": True, "reason": "质检异常默认通过", "missing_parts": []}) \ No newline at end of file diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/tools/tools.py b/offline-backend-20260512/backend/packages/harness/deerflow/tools/tools.py index 2c58c0fb..98e3cd5e 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/tools/tools.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/tools/tools.py @@ -6,7 +6,7 @@ from deerflow.config import get_app_config from deerflow.config.app_config import AppConfig from deerflow.reflection import resolve_variable from deerflow.sandbox.security import is_host_bash_allowed -from deerflow.tools.builtins import ask_clarification_tool, present_file_tool, scheduled_task_tool, task_tool, view_image_tool +from deerflow.tools.builtins import ask_clarification_tool, fast_chat_tool, fast_llm_answer, present_file_tool, scheduled_task_tool, task_tool, view_image_tool from deerflow.tools.builtins.tool_search import reset_deferred_registry logger = logging.getLogger(__name__) @@ -14,6 +14,8 @@ logger = logging.getLogger(__name__) BUILTIN_TOOLS = [ present_file_tool, ask_clarification_tool, + fast_chat_tool, + fast_llm_answer, ] SUBAGENT_TOOLS = [ diff --git a/offline-backend-20260512/backend/pyproject.toml b/offline-backend-20260512/backend/pyproject.toml index d739191d..d3118965 100644 --- a/offline-backend-20260512/backend/pyproject.toml +++ b/offline-backend-20260512/backend/pyproject.toml @@ -1,50 +1,51 @@ -[project] -name = "deer-flow" -version = "0.1.0" -description = "LangGraph-based AI agent system with sandbox execution capabilities" -readme = "README.md" -requires-python = ">=3.12" -dependencies = [ - "deerflow-harness", - "fastapi>=0.115.0", - "httpx>=0.28.0", - "python-multipart>=0.0.26", - "sse-starlette>=2.1.0", - "uvicorn[standard]>=0.34.0", - "lark-oapi>=1.4.0", - "slack-sdk>=3.33.0", - "python-telegram-bot>=21.0", - "langgraph-sdk>=0.1.51", - "markdown-to-mrkdwn>=0.3.1", - "wecom-aibot-python-sdk>=0.1.6", - "dingtalk-stream>=0.24.3", - "bcrypt>=4.0.0", - "pyjwt>=2.9.0", - "email-validator>=2.0.0", - "croniter>=6.2.2", -] - -[project.optional-dependencies] -postgres = ["deerflow-harness[postgres]"] - -[dependency-groups] -dev = [ - "prompt-toolkit>=3.0.0", - "pytest>=9.0.3", - "pytest-asyncio>=1.3.0", - "ruff>=0.14.11", -] - -[tool.pytest.ini_options] -markers = [ - "no_auto_user: disable the conftest autouse contextvar fixture for this test", -] - -[tool.uv] -index-url = "https://pypi.org/simple" - -[tool.uv.workspace] -members = ["packages/harness"] - -[tool.uv.sources] -deerflow-harness = { workspace = true } +[project] +name = "deer-flow" +version = "0.1.0" +description = "LangGraph-based AI agent system with sandbox execution capabilities" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "deerflow-harness", + "fastapi>=0.115.0", + "httpx>=0.28.0", + "python-multipart>=0.0.26", + "sse-starlette>=2.1.0", + "uvicorn[standard]>=0.34.0", + "lark-oapi>=1.4.0", + "slack-sdk>=3.33.0", + "python-telegram-bot>=21.0", + "langgraph-sdk>=0.1.51", + "markdown-to-mrkdwn>=0.3.1", + "wecom-aibot-python-sdk>=0.1.6", + "dingtalk-stream>=0.24.3", + "bcrypt>=4.0.0", + "pyjwt>=2.9.0", + "email-validator>=2.0.0", + "croniter>=6.2.2", + "greenlet>=3.4.0", +] + +[project.optional-dependencies] +postgres = ["deerflow-harness[postgres]"] + +[dependency-groups] +dev = [ + "prompt-toolkit>=3.0.0", + "pytest>=9.0.3", + "pytest-asyncio>=1.3.0", + "ruff>=0.14.11", +] + +[tool.pytest.ini_options] +markers = [ + "no_auto_user: disable the conftest autouse contextvar fixture for this test", +] + +[tool.uv] +index-url = "https://pypi.org/simple" + +[tool.uv.workspace] +members = ["packages/harness"] + +[tool.uv.sources] +deerflow-harness = { workspace = true } diff --git a/offline-backend-20260512/backend/uv.lock b/offline-backend-20260512/backend/uv.lock index 43e8f4d5..2d252e76 100644 --- a/offline-backend-20260512/backend/uv.lock +++ b/offline-backend-20260512/backend/uv.lock @@ -750,6 +750,7 @@ dependencies = [ { name = "dingtalk-stream" }, { name = "email-validator" }, { name = "fastapi" }, + { name = "greenlet" }, { name = "httpx" }, { name = "langgraph-sdk" }, { name = "lark-oapi" }, @@ -785,6 +786,7 @@ requires-dist = [ { name = "dingtalk-stream", specifier = ">=0.24.3" }, { name = "email-validator", specifier = ">=2.0.0" }, { name = "fastapi", specifier = ">=0.115.0" }, + { name = "greenlet", specifier = ">=3.4.0" }, { name = "httpx", specifier = ">=0.28.0" }, { name = "langgraph-sdk", specifier = ">=0.1.51" }, { name = "lark-oapi", specifier = ">=1.4.0" },