From 2510cc61de76a68d0d98d4b4f5b4490b77fc6a0c Mon Sep 17 00:00:00 2001 From: jimmyuconn1982 Date: Mon, 13 Oct 2025 22:35:57 -0700 Subject: [PATCH] feat: Add intelligent clarification feature in coordinate step for research queries (#613) * fix: support local models by making thought field optional in Plan model - Make thought field optional in Plan model to fix Pydantic validation errors with local models - Add Ollama configuration example to conf.yaml.example - Update documentation to include local model support - Improve planner prompt with better JSON format requirements Fixes local model integration issues where models like qwen3:14b would fail due to missing thought field in JSON output. * feat: Add intelligent clarification feature for research queries - Add multi-turn clarification process to refine vague research questions - Implement three-dimension clarification standard (Tech/App, Focus, Scope) - Add clarification state management in coordinator node - Update coordinator prompt with detailed clarification guidelines - Add UI settings to enable/disable clarification feature (disabled by default) - Update workflow to handle clarification rounds recursively - Add comprehensive test coverage for clarification functionality - Update documentation with clarification feature usage guide Key components: - src/graph/nodes.py: Core clarification logic and state management - src/prompts/coordinator.md: Detailed clarification guidelines - src/workflow.py: Recursive clarification handling - web/: UI settings integration - tests/: Comprehensive test coverage - docs/: Updated configuration guide * fix: Improve clarification conversation continuity - Add comprehensive conversation history to clarification context - Include previous exchanges summary in system messages - Add explicit guidelines for continuing rounds in coordinator prompt - Prevent LLM from starting new topics during clarification - Ensure topic continuity across clarification rounds Fixes issue where LLM would restart clarification instead of building upon previous exchanges. * fix: Add conversation history to clarification context * fix: resolve clarification feature message to planer, prompt, test issues - Optimize coordinator.md prompt template for better clarification flow - Simplify final message sent to planner after clarification - Fix API key assertion issues in test_search.py * fix: Add configurable max_clarification_rounds and comprehensive tests - Add max_clarification_rounds parameter for external configuration - Add comprehensive test cases for clarification feature in test_app.py - Fixes issues found during interactive mode testing where: - Recursive call failed due to missing initial_state parameter - Clarification exited prematurely at max rounds - Incorrect logging of max rounds reached * Move clarification tests to test_nodes.py and add max_clarification_rounds to zh.json --- README.md | 7 + README_zh.md | 7 + docs/configuration_guide.md | 45 ++- main.py | 28 ++ src/graph/builder.py | 6 + src/graph/nodes.py | 348 ++++++++++++++++-- src/graph/types.py | 15 + src/prompts/coordinator.md | 47 +++ src/server/app.py | 6 + src/server/chat_request.py | 8 + src/tools/search_postprocessor.py | 4 +- .../tavily_search_api_wrapper.py | 3 +- src/utils/context_manager.py | 9 +- src/workflow.py | 66 +++- tests/integration/test_nodes.py | 181 +++++++++ tests/unit/graph/test_builder.py | 3 +- tests/unit/rag/test_milvus.py | 13 +- tests/unit/server/test_app.py | 14 + tests/unit/tools/test_search_postprocessor.py | 1 + tests/unit/utils/test_context_manager.py | 4 +- web/messages/en.json | 3 + web/messages/zh.json | 3 + web/src/app/settings/tabs/general-tab.tsx | 50 +++ web/src/core/api/chat.ts | 1 + web/src/core/store/settings-store.ts | 14 + web/src/core/store/store.ts | 1 + 26 files changed, 830 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 58920f4..731c833 100644 --- a/README.md +++ b/README.md @@ -223,6 +223,13 @@ DeerFlow support private knowledgebase such as ragflow and vikingdb, so that you ### Human Collaboration +- 💬 **Intelligent Clarification Feature** + - Multi-turn dialogue to clarify vague research topics + - Improve research precision and report quality + - Reduce ineffective searches and token usage + - Configurable switch for flexible enable/disable control + - See [Configuration Guide - Clarification](./docs/configuration_guide.md#multi-turn-clarification-feature) for details + - 🧠 **Human-in-the-loop** - Supports interactive modification of research plans using natural language - Supports auto-acceptance of research plans diff --git a/README_zh.md b/README_zh.md index d7feab9..0b9086b 100644 --- a/README_zh.md +++ b/README_zh.md @@ -236,6 +236,13 @@ DeerFlow 支持基于私有域知识的检索,您可以将文档上传到多 ### 人机协作 +- 💬 **智能澄清功能** + - 多轮对话澄清模糊的研究主题 + - 提高研究精准度和报告质量 + - 减少无效搜索和 token 使用 + - 可配置开关,灵活控制启用/禁用 + - 详见 [配置指南 - 澄清功能](./docs/configuration_guide.md#multi-turn-clarification-feature) + - 🧠 **人在环中** - 支持使用自然语言交互式修改研究计划 - 支持自动接受研究计划 diff --git a/docs/configuration_guide.md b/docs/configuration_guide.md index 0b5ee69..3245da0 100644 --- a/docs/configuration_guide.md +++ b/docs/configuration_guide.md @@ -288,4 +288,47 @@ MILVUS_EMBEDDING_PROVIDER=openai MILVUS_EMBEDDING_BASE_URL= MILVUS_EMBEDDING_MODEL= MILVUS_EMBEDDING_API_KEY= -``` \ No newline at end of file +``` + +--- + +## Multi-Turn Clarification (Optional) + +An optional feature that helps clarify vague research questions through conversation. **Disabled by default.** + +### Enable via Command Line + +```bash +# Enable clarification for vague questions +uv run main.py "Research AI" --enable-clarification + +# Set custom maximum clarification rounds +uv run main.py "Research AI" --enable-clarification --max-clarification-rounds 3 + +# Interactive mode with clarification +uv run main.py --interactive --enable-clarification --max-clarification-rounds 3 +``` + +### Enable via API + +```json +{ + "messages": [{"role": "user", "content": "Research AI"}], + "enable_clarification": true, + "max_clarification_rounds": 3 +} +``` + +### Enable via UI Settings + +1. Open DeerFlow web interface +2. Navigate to **Settings** → **General** tab +3. Find **"Enable Clarification"** toggle +4. Turn it **ON** to enable multi-turn clarification. Clarification is **disabled** by default. You need to manually enable it through any of the above methods. When clarification is enabled, you'll see **"Max Clarification Rounds"** field appear below the toggle +6. Set the maximum number of clarification rounds (default: 3, minimum: 1) +7. Click **Save** to apply changes + +**When enabled**, the Coordinator will ask up to the specified number of clarifying questions for vague topics before starting research, improving report relevance and depth. The `max_clarification_rounds` parameter controls how many rounds of clarification are allowed. + + +**Note**: The `max_clarification_rounds` parameter only takes effect when `enable_clarification` is set to `true`. If clarification is disabled, this parameter is ignored. diff --git a/main.py b/main.py index e23e137..ae7ba40 100644 --- a/main.py +++ b/main.py @@ -20,6 +20,8 @@ def ask( max_plan_iterations=1, max_step_num=3, enable_background_investigation=True, + enable_clarification=False, + max_clarification_rounds=None, ): """Run the agent workflow with the given question. @@ -29,6 +31,8 @@ def ask( max_plan_iterations: Maximum number of plan iterations max_step_num: Maximum number of steps in a plan enable_background_investigation: If True, performs web search before planning to enhance context + enable_clarification: If False (default), skip clarification; if True, enable multi-turn clarification + max_clarification_rounds: Maximum number of clarification rounds (default: None, uses State default=3) """ asyncio.run( run_agent_workflow_async( @@ -37,6 +41,8 @@ def ask( max_plan_iterations=max_plan_iterations, max_step_num=max_step_num, enable_background_investigation=enable_background_investigation, + enable_clarification=enable_clarification, + max_clarification_rounds=max_clarification_rounds, ) ) @@ -46,6 +52,8 @@ def main( max_plan_iterations=1, max_step_num=3, enable_background_investigation=True, + enable_clarification=False, + max_clarification_rounds=None, ): """Interactive mode with built-in questions. @@ -54,6 +62,8 @@ def main( debug: If True, enables debug level logging max_plan_iterations: Maximum number of plan iterations max_step_num: Maximum number of steps in a plan + enable_clarification: If False (default), skip clarification; if True, enable multi-turn clarification + max_clarification_rounds: Maximum number of clarification rounds (default: None, uses State default=3) """ # First select language language = inquirer.select( @@ -93,6 +103,8 @@ def main( max_plan_iterations=max_plan_iterations, max_step_num=max_step_num, enable_background_investigation=enable_background_investigation, + enable_clarification=enable_clarification, + max_clarification_rounds=max_clarification_rounds, ) @@ -124,6 +136,18 @@ if __name__ == "__main__": dest="enable_background_investigation", help="Disable background investigation before planning", ) + parser.add_argument( + "--enable-clarification", + action="store_true", + dest="enable_clarification", + help="Enable multi-turn clarification for vague questions (default: disabled)", + ) + parser.add_argument( + "--max-clarification-rounds", + type=int, + dest="max_clarification_rounds", + help="Maximum number of clarification rounds (default: 3)", + ) args = parser.parse_args() @@ -134,6 +158,8 @@ if __name__ == "__main__": max_plan_iterations=args.max_plan_iterations, max_step_num=args.max_step_num, enable_background_investigation=args.enable_background_investigation, + enable_clarification=args.enable_clarification, + max_clarification_rounds=args.max_clarification_rounds, ) else: # Parse user input from command line arguments or user input @@ -153,4 +179,6 @@ if __name__ == "__main__": max_plan_iterations=args.max_plan_iterations, max_step_num=args.max_step_num, enable_background_investigation=args.enable_background_investigation, + enable_clarification=args.enable_clarification, + max_clarification_rounds=args.max_clarification_rounds, ) diff --git a/src/graph/builder.py b/src/graph/builder.py index 45576b7..aeedaff 100644 --- a/src/graph/builder.py +++ b/src/graph/builder.py @@ -63,6 +63,12 @@ def _build_base_graph(): ["planner", "researcher", "coder"], ) builder.add_edge("reporter", END) + # Add conditional edges for coordinator to handle clarification flow + builder.add_conditional_edges( + "coordinator", + lambda state: state.get("goto", "planner"), + ["planner", "background_investigator", "coordinator", END], + ) return builder diff --git a/src/graph/nodes.py b/src/graph/nodes.py index 179cb8b..6c1d360 100644 --- a/src/graph/nodes.py +++ b/src/graph/nodes.py @@ -4,6 +4,7 @@ import json import logging import os +from functools import partial from typing import Annotated, Literal from langchain_core.messages import AIMessage, HumanMessage, ToolMessage @@ -11,7 +12,6 @@ from langchain_core.runnables import RunnableConfig from langchain_core.tools import tool from langchain_mcp_adapters.client import MultiServerMCPClient from langgraph.types import Command, interrupt -from functools import partial from src.agents import create_agent from src.config.agents import AGENT_LLM_MAP @@ -26,8 +26,8 @@ from src.tools import ( python_repl_tool, ) from src.tools.search import LoggedTavilySearch -from src.utils.json_utils import repair_json_output from src.utils.context_manager import ContextManager +from src.utils.json_utils import repair_json_output from ..config import SELECTED_SEARCH_ENGINE, SearchEngine from .types import State @@ -46,6 +46,35 @@ def handoff_to_planner( return +@tool +def handoff_after_clarification( + locale: Annotated[str, "The user's detected language locale (e.g., en-US, zh-CN)."], +): + """Handoff to planner after clarification rounds are complete. Pass all clarification history to planner for analysis.""" + return + + +def needs_clarification(state: dict) -> bool: + """ + Check if clarification is needed based on current state. + Centralized logic for determining when to continue clarification. + """ + if not state.get("enable_clarification", False): + return False + + clarification_rounds = state.get("clarification_rounds", 0) + is_clarification_complete = state.get("is_clarification_complete", False) + max_clarification_rounds = state.get("max_clarification_rounds", 3) + + # Need clarification if: enabled + has rounds + not complete + not exceeded max + # Use <= because after asking the Nth question, we still need to wait for the Nth answer + return ( + clarification_rounds > 0 + and not is_clarification_complete + and clarification_rounds <= max_clarification_rounds + ) + + def background_investigation_node(state: State, config: RunnableConfig): logger.info("background investigation node is running.") configurable = Configuration.from_runnable_config(config) @@ -89,7 +118,22 @@ def planner_node( logger.info("Planner generating full plan") configurable = Configuration.from_runnable_config(config) plan_iterations = state["plan_iterations"] if state.get("plan_iterations", 0) else 0 - messages = apply_prompt_template("planner", state, configurable) + + # For clarification feature: only send the final clarified question to planner + if state.get("enable_clarification", False) and state.get("clarified_question"): + # Create a clean state with only the clarified question + clean_state = { + "messages": [{"role": "user", "content": state["clarified_question"]}], + "locale": state.get("locale", "en-US"), + "research_topic": state["clarified_question"], + } + messages = apply_prompt_template("planner", clean_state, configurable) + logger.info( + f"Clarification mode: Using clarified question: {state['clarified_question']}" + ) + else: + # Normal mode: use full conversation history + messages = apply_prompt_template("planner", state, configurable) if state.get("enable_background_investigation") and state.get( "background_investigation_results" @@ -209,53 +253,285 @@ def human_feedback_node( def coordinator_node( state: State, config: RunnableConfig -) -> Command[Literal["planner", "background_investigator", "__end__"]]: - """Coordinator node that communicate with customers.""" +) -> Command[Literal["planner", "background_investigator", "coordinator", "__end__"]]: + """Coordinator node that communicate with customers and handle clarification.""" logger.info("Coordinator talking.") configurable = Configuration.from_runnable_config(config) - messages = apply_prompt_template("coordinator", state) - response = ( - get_llm_by_type(AGENT_LLM_MAP["coordinator"]) - .bind_tools([handoff_to_planner]) - .invoke(messages) - ) - logger.debug(f"Current state messages: {state['messages']}") - goto = "__end__" - locale = state.get("locale", "en-US") # Default locale if not specified - research_topic = state.get("research_topic", "") + # Check if clarification is enabled + enable_clarification = state.get("enable_clarification", False) - if len(response.tool_calls) > 0: - goto = "planner" - if state.get("enable_background_investigation"): - # if the search_before_planning is True, add the web search tool to the planner agent - goto = "background_investigator" - try: - for tool_call in response.tool_calls: - if tool_call.get("name", "") != "handoff_to_planner": - continue - if tool_call.get("args", {}).get("locale") and tool_call.get( - "args", {} - ).get("research_topic"): - locale = tool_call.get("args", {}).get("locale") - research_topic = tool_call.get("args", {}).get("research_topic") - break - except Exception as e: - logger.error(f"Error processing tool calls: {e}") - else: - logger.warning( - "Coordinator response contains no tool calls. Terminating workflow execution." + # ============================================================ + # BRANCH 1: Clarification DISABLED (Legacy Mode) + # ============================================================ + if not enable_clarification: + # Use normal prompt with explicit instruction to skip clarification + messages = apply_prompt_template("coordinator", state) + messages.append( + { + "role": "system", + "content": "CRITICAL: Clarification is DISABLED. You MUST immediately call handoff_to_planner tool with the user's query as-is. Do NOT ask questions or mention needing more information.", + } ) - logger.debug(f"Coordinator response: {response}") + + # Only bind handoff_to_planner tool + tools = [handoff_to_planner] + response = ( + get_llm_by_type(AGENT_LLM_MAP["coordinator"]) + .bind_tools(tools) + .invoke(messages) + ) + + # Process response - should directly handoff to planner + goto = "__end__" + locale = state.get("locale", "en-US") + research_topic = state.get("research_topic", "") + + # Process tool calls for legacy mode + if response.tool_calls: + try: + for tool_call in response.tool_calls: + tool_name = tool_call.get("name", "") + tool_args = tool_call.get("args", {}) + + if tool_name == "handoff_to_planner": + logger.info("Handing off to planner") + goto = "planner" + + # Extract locale and research_topic if provided + if tool_args.get("locale") and tool_args.get("research_topic"): + locale = tool_args.get("locale") + research_topic = tool_args.get("research_topic") + break + + except Exception as e: + logger.error(f"Error processing tool calls: {e}") + goto = "planner" + + # ============================================================ + # BRANCH 2: Clarification ENABLED (New Feature) + # ============================================================ + else: + # Load clarification state + clarification_rounds = state.get("clarification_rounds", 0) + clarification_history = state.get("clarification_history", []) + max_clarification_rounds = state.get("max_clarification_rounds", 3) + + # Prepare the messages for the coordinator + messages = apply_prompt_template("coordinator", state) + + # Add clarification status for first round + if clarification_rounds == 0: + messages.append( + { + "role": "system", + "content": "Clarification mode is ENABLED. Follow the 'Clarification Process' guidelines in your instructions.", + } + ) + + # Add clarification context if continuing conversation (round > 0) + elif clarification_rounds > 0: + logger.info( + f"Clarification enabled (rounds: {clarification_rounds}/{max_clarification_rounds}): Continuing conversation" + ) + + # Add user's response to clarification history (only user messages) + last_message = None + if state.get("messages"): + last_message = state["messages"][-1] + # Extract content from last message for logging + if isinstance(last_message, dict): + content = last_message.get("content", "No content") + else: + content = getattr(last_message, "content", "No content") + logger.info(f"Last message content: {content}") + # Handle dict format + if isinstance(last_message, dict): + if last_message.get("role") == "user": + clarification_history.append(last_message["content"]) + logger.info( + f"Added user response to clarification history: {last_message['content']}" + ) + # Handle object format (like HumanMessage) + elif hasattr(last_message, "role") and last_message.role == "user": + clarification_history.append(last_message.content) + logger.info( + f"Added user response to clarification history: {last_message.content}" + ) + # Handle object format with content attribute (like the one in logs) + elif hasattr(last_message, "content"): + clarification_history.append(last_message.content) + logger.info( + f"Added user response to clarification history: {last_message.content}" + ) + + # Build comprehensive clarification context with conversation history + current_response = "No response" + if last_message: + # Handle dict format + if isinstance(last_message, dict): + if last_message.get("role") == "user": + current_response = last_message.get("content", "No response") + else: + # If last message is not from user, try to get the latest user message + messages = state.get("messages", []) + for msg in reversed(messages): + if isinstance(msg, dict) and msg.get("role") == "user": + current_response = msg.get("content", "No response") + break + # Handle object format (like HumanMessage) + elif hasattr(last_message, "role") and last_message.role == "user": + current_response = last_message.content + # Handle object format with content attribute (like the one in logs) + elif hasattr(last_message, "content"): + current_response = last_message.content + else: + # If last message is not from user, try to get the latest user message + messages = state.get("messages", []) + for msg in reversed(messages): + if isinstance(msg, dict) and msg.get("role") == "user": + current_response = msg.get("content", "No response") + break + elif hasattr(msg, "role") and msg.role == "user": + current_response = msg.content + break + elif hasattr(msg, "content"): + current_response = msg.content + break + + # Create conversation history summary + conversation_summary = "" + if clarification_history: + conversation_summary = "Previous conversation:\n" + for i, response in enumerate(clarification_history, 1): + conversation_summary += f"- Round {i}: {response}\n" + + clarification_context = f"""Continuing clarification (round {clarification_rounds}/{max_clarification_rounds}): + User's latest response: {current_response} + Ask for remaining missing dimensions. Do NOT repeat questions or start new topics.""" + + # Log the clarification context for debugging + logger.info(f"Clarification context: {clarification_context}") + + messages.append({"role": "system", "content": clarification_context}) + + # Bind both clarification tools + tools = [handoff_to_planner, handoff_after_clarification] + response = ( + get_llm_by_type(AGENT_LLM_MAP["coordinator"]) + .bind_tools(tools) + .invoke(messages) + ) + logger.debug(f"Current state messages: {state['messages']}") + + # Initialize response processing variables + goto = "__end__" + locale = state.get("locale", "en-US") + research_topic = state.get("research_topic", "") + + # --- Process LLM response --- + # No tool calls - LLM is asking a clarifying question + if not response.tool_calls and response.content: + if clarification_rounds < max_clarification_rounds: + # Continue clarification process + clarification_rounds += 1 + # Do NOT add LLM response to clarification_history - only user responses + logger.info( + f"Clarification response: {clarification_rounds}/{max_clarification_rounds}: {response.content}" + ) + + # Append coordinator's question to messages + state_messages = state.get("messages", []) + if response.content: + state_messages.append( + HumanMessage(content=response.content, name="coordinator") + ) + + return Command( + update={ + "messages": state_messages, + "locale": locale, + "research_topic": research_topic, + "resources": configurable.resources, + "clarification_rounds": clarification_rounds, + "clarification_history": clarification_history, + "is_clarification_complete": False, + "clarified_question": "", + "goto": goto, + "__interrupt__": [("coordinator", response.content)], + }, + goto=goto, + ) + else: + # Max rounds reached - no more questions allowed + logger.warning( + f"Max clarification rounds ({max_clarification_rounds}) reached. Handing off to planner." + ) + goto = "planner" + if state.get("enable_background_investigation"): + goto = "background_investigator" + else: + # LLM called a tool (handoff) or has no content - clarification complete + if response.tool_calls: + logger.info( + f"Clarification completed after {clarification_rounds} rounds. LLM called handoff tool." + ) + else: + logger.warning("LLM response has no content and no tool calls.") + # goto will be set in the final section based on tool calls + + # ============================================================ + # Final: Build and return Command + # ============================================================ messages = state.get("messages", []) if response.content: messages.append(HumanMessage(content=response.content, name="coordinator")) + + # Process tool calls for BOTH branches (legacy and clarification) + if response.tool_calls: + try: + for tool_call in response.tool_calls: + tool_name = tool_call.get("name", "") + tool_args = tool_call.get("args", {}) + + if tool_name in ["handoff_to_planner", "handoff_after_clarification"]: + logger.info("Handing off to planner") + goto = "planner" + + # Extract locale and research_topic if provided + if tool_args.get("locale") and tool_args.get("research_topic"): + locale = tool_args.get("locale") + research_topic = tool_args.get("research_topic") + break + + except Exception as e: + logger.error(f"Error processing tool calls: {e}") + goto = "planner" + else: + # No tool calls - both modes should goto __end__ + logger.warning("LLM didn't call any tools. Staying at __end__.") + goto = "__end__" + + # Apply background_investigation routing if enabled (unified logic) + if goto == "planner" and state.get("enable_background_investigation"): + goto = "background_investigator" + + # Set default values for state variables (in case they're not defined in legacy mode) + if not enable_clarification: + clarification_rounds = 0 + clarification_history = [] + return Command( update={ "messages": messages, "locale": locale, "research_topic": research_topic, "resources": configurable.resources, + "clarification_rounds": clarification_rounds, + "clarification_history": clarification_history, + "is_clarification_complete": goto != "coordinator", + "clarified_question": research_topic if goto != "coordinator" else "", + "goto": goto, }, goto=goto, ) diff --git a/src/graph/types.py b/src/graph/types.py index a2a29aa..f85ad57 100644 --- a/src/graph/types.py +++ b/src/graph/types.py @@ -22,3 +22,18 @@ class State(MessagesState): auto_accepted_plan: bool = False enable_background_investigation: bool = True background_investigation_results: str = None + + # Clarification state tracking (disabled by default) + enable_clarification: bool = ( + False # Enable/disable clarification feature (default: False) + ) + clarification_rounds: int = 0 + clarification_history: list[str] = [] + is_clarification_complete: bool = False + clarified_question: str = "" + max_clarification_rounds: int = ( + 3 # Default: 3 rounds (only used when enable_clarification=True) + ) + + # Workflow control + goto: str = "planner" # Default next node diff --git a/src/prompts/coordinator.md b/src/prompts/coordinator.md index c354135..a4d8881 100644 --- a/src/prompts/coordinator.md +++ b/src/prompts/coordinator.md @@ -44,9 +44,56 @@ Your primary responsibilities are: - Respond in plain text with a polite rejection - If you need to ask user for more context: - Respond in plain text with an appropriate question + - **For vague or overly broad research questions**: Ask clarifying questions to narrow down the scope + - Examples needing clarification: "research AI", "analyze market", "AI impact on e-commerce"(which AI application?), "research cloud computing"(which aspect?) + - Ask about: specific applications, aspects, timeframe, geographic scope, or target audience + - Maximum 3 clarification rounds, then use `handoff_after_clarification()` tool - For all other inputs (category 3 - which includes most questions): - call `handoff_to_planner()` tool to handoff to planner for research without ANY thoughts. +# Clarification Process (When Enabled) + +Goal: Get 2+ dimensions before handing off to planner. + +## Three Key Dimensions + +A specific research question needs at least 2 of these 3 dimensions: + +1. Specific Tech/App: "Kubernetes", "GPT model" vs "cloud computing", "AI" +2. Clear Focus: "architecture design", "performance optimization" vs "technology aspect" +3. Scope: "2024 China e-commerce", "financial sector" + +## When to Continue vs. Handoff + +- 0-1 dimensions: Ask for missing ones with 3-5 concrete examples +- 2+ dimensions: Call handoff_to_planner() or handoff_after_clarification() +- Max rounds reached: Must call handoff_after_clarification() regardless + +## Response Guidelines + +When user responses are missing specific dimensions, ask clarifying questions: + +**Missing specific technology:** +- User says: "AI technology" +- Ask: "Which specific technology: machine learning, natural language processing, computer vision, robotics, or deep learning?" + +**Missing clear focus:** +- User says: "blockchain" +- Ask: "What aspect: technical implementation, market adoption, regulatory issues, or business applications?" + +**Missing scope boundary:** +- User says: "renewable energy" +- Ask: "Which type (solar, wind, hydro), what geographic scope (global, specific country), and what time frame (current status, future trends)?" + +## Continuing Rounds + +When continuing clarification (rounds > 0): + +1. Reference previous exchanges +2. Ask for missing dimensions only +3. Focus on gaps +4. Stay on topic + # Notes - Always identify yourself as DeerFlow when relevant diff --git a/src/server/app.py b/src/server/app.py index f8f933a..ad8ea2f 100644 --- a/src/server/app.py +++ b/src/server/app.py @@ -113,6 +113,8 @@ async def chat_stream(request: ChatRequest): request.enable_background_investigation, request.report_style, request.enable_deep_thinking, + request.enable_clarification, + request.max_clarification_rounds, ), media_type="text/event-stream", ) @@ -288,6 +290,8 @@ async def _astream_workflow_generator( enable_background_investigation: bool, report_style: ReportStyle, enable_deep_thinking: bool, + enable_clarification: bool, + max_clarification_rounds: int, ): # Process initial messages for message in messages: @@ -304,6 +308,8 @@ async def _astream_workflow_generator( "auto_accepted_plan": auto_accepted_plan, "enable_background_investigation": enable_background_investigation, "research_topic": messages[-1]["content"] if messages else "", + "enable_clarification": enable_clarification, + "max_clarification_rounds": max_clarification_rounds, } if not auto_accepted_plan and interrupt_feedback: diff --git a/src/server/chat_request.py b/src/server/chat_request.py index df904e3..00f293c 100644 --- a/src/server/chat_request.py +++ b/src/server/chat_request.py @@ -65,6 +65,14 @@ class ChatRequest(BaseModel): enable_deep_thinking: Optional[bool] = Field( False, description="Whether to enable deep thinking" ) + enable_clarification: Optional[bool] = Field( + None, + description="Whether to enable multi-turn clarification (default: None, uses State default=False)", + ) + max_clarification_rounds: Optional[int] = Field( + None, + description="Maximum number of clarification rounds (default: None, uses State default=3)", + ) class TTSRequest(BaseModel): diff --git a/src/tools/search_postprocessor.py b/src/tools/search_postprocessor.py index 0f7719e..ddf9b82 100644 --- a/src/tools/search_postprocessor.py +++ b/src/tools/search_postprocessor.py @@ -1,8 +1,8 @@ # src/tools/search_postprocessor.py -import re import base64 import logging -from typing import List, Dict, Any +import re +from typing import Any, Dict, List from urllib.parse import urlparse logger = logging.getLogger(__name__) diff --git a/src/tools/tavily_search/tavily_search_api_wrapper.py b/src/tools/tavily_search/tavily_search_api_wrapper.py index f42aa35..ae90736 100644 --- a/src/tools/tavily_search/tavily_search_api_wrapper.py +++ b/src/tools/tavily_search/tavily_search_api_wrapper.py @@ -11,8 +11,9 @@ from langchain_tavily._utilities import TAVILY_API_URL from langchain_tavily.tavily_search import ( TavilySearchAPIWrapper as OriginalTavilySearchAPIWrapper, ) -from src.tools.search_postprocessor import SearchResultPostProcessor + from src.config import load_yaml_config +from src.tools.search_postprocessor import SearchResultPostProcessor def get_search_config(): diff --git a/src/utils/context_manager.py b/src/utils/context_manager.py index 8015a1a..b56d6a6 100644 --- a/src/utils/context_manager.py +++ b/src/utils/context_manager.py @@ -1,14 +1,15 @@ # src/utils/token_manager.py +import copy +import logging from typing import List + from langchain_core.messages import ( + AIMessage, BaseMessage, HumanMessage, - AIMessage, - ToolMessage, SystemMessage, + ToolMessage, ) -import logging -import copy from src.config import load_yaml_config diff --git a/src/workflow.py b/src/workflow.py index 6fc173b..7687ce4 100644 --- a/src/workflow.py +++ b/src/workflow.py @@ -30,6 +30,9 @@ async def run_agent_workflow_async( max_plan_iterations: int = 1, max_step_num: int = 3, enable_background_investigation: bool = True, + enable_clarification: bool | None = None, + max_clarification_rounds: int | None = None, + initial_state: dict | None = None, ): """Run the agent workflow asynchronously with the given user input. @@ -39,6 +42,9 @@ async def run_agent_workflow_async( max_plan_iterations: Maximum number of plan iterations max_step_num: Maximum number of steps in a plan enable_background_investigation: If True, performs web search before planning to enhance context + enable_clarification: If None, use default from State class (False); if True/False, override + max_clarification_rounds: Maximum number of clarification rounds allowed + initial_state: Initial state to use (for recursive calls during clarification) Returns: The final state after the workflow completes @@ -50,12 +56,24 @@ async def run_agent_workflow_async( enable_debug_logging() logger.info(f"Starting async workflow with user input: {user_input}") - initial_state = { - # Runtime Variables - "messages": [{"role": "user", "content": user_input}], - "auto_accepted_plan": True, - "enable_background_investigation": enable_background_investigation, - } + + # Use provided initial_state or create a new one + if initial_state is None: + initial_state = { + # Runtime Variables + "messages": [{"role": "user", "content": user_input}], + "auto_accepted_plan": True, + "enable_background_investigation": enable_background_investigation, + } + + # Only set clarification parameter if explicitly provided + # If None, State class default will be used (enable_clarification=False) + if enable_clarification is not None: + initial_state["enable_clarification"] = enable_clarification + + if max_clarification_rounds is not None: + initial_state["max_clarification_rounds"] = max_clarification_rounds + config = { "configurable": { "thread_id": "default", @@ -76,10 +94,12 @@ async def run_agent_workflow_async( "recursion_limit": get_recursion_limit(default=100), } last_message_cnt = 0 + final_state = None async for s in graph.astream( input=initial_state, config=config, stream_mode="values" ): try: + final_state = s if isinstance(s, dict) and "messages" in s: if len(s["messages"]) <= last_message_cnt: continue @@ -90,12 +110,44 @@ async def run_agent_workflow_async( else: message.pretty_print() else: - # For any other output format print(f"Output: {s}") except Exception as e: logger.error(f"Error processing stream output: {e}") print(f"Error processing output: {str(e)}") + # Check if clarification is needed using centralized logic + if final_state and isinstance(final_state, dict): + from src.graph.nodes import needs_clarification + + if needs_clarification(final_state): + # Wait for user input + print() + clarification_rounds = final_state.get("clarification_rounds", 0) + max_clarification_rounds = final_state.get("max_clarification_rounds", 3) + user_response = input( + f"Your response ({clarification_rounds}/{max_clarification_rounds}): " + ).strip() + + if not user_response: + logger.warning("Empty response, ending clarification") + return final_state + + # Continue workflow with user response + current_state = final_state.copy() + current_state["messages"] = final_state["messages"] + [ + {"role": "user", "content": user_response} + ] + # Recursive call for clarification continuation + return await run_agent_workflow_async( + user_input=user_response, + max_plan_iterations=max_plan_iterations, + max_step_num=max_step_num, + enable_background_investigation=enable_background_investigation, + enable_clarification=enable_clarification, + max_clarification_rounds=max_clarification_rounds, + initial_state=current_state, + ) + logger.info("Async workflow completed successfully") diff --git a/tests/integration/test_nodes.py b/tests/integration/test_nodes.py index 161e556..caae2aa 100644 --- a/tests/integration/test_nodes.py +++ b/tests/integration/test_nodes.py @@ -514,6 +514,7 @@ def mock_state_coordinator(): return { "messages": [{"role": "user", "content": "test"}], "locale": "en-US", + "enable_clarification": False, } @@ -1385,3 +1386,183 @@ async def test_researcher_node_without_resources( tools = args[3] assert patch_get_web_search_tool.return_value in tools assert result == "RESEARCHER_RESULT" + + +# ============================================================================ +# Clarification Feature Tests +# ============================================================================ + + +@pytest.mark.asyncio +async def test_clarification_workflow_integration(): + """Test the complete clarification workflow integration.""" + import inspect + + from src.workflow import run_agent_workflow_async + + # Verify that the function accepts clarification parameters + sig = inspect.signature(run_agent_workflow_async) + assert "max_clarification_rounds" in sig.parameters + assert "enable_clarification" in sig.parameters + assert "initial_state" in sig.parameters + + +def test_clarification_parameters_combinations(): + """Test various combinations of clarification parameters.""" + from src.graph.nodes import needs_clarification + + test_cases = [ + # (enable_clarification, clarification_rounds, max_rounds, is_complete, expected) + (True, 0, 3, False, False), # No rounds started + (True, 1, 3, False, True), # In progress + (True, 2, 3, False, True), # In progress + (True, 3, 3, False, True), # At max - still waiting for last answer + (True, 4, 3, False, False), # Exceeded max + (True, 1, 3, True, False), # Completed + (False, 1, 3, False, False), # Disabled + ] + + for enable, rounds, max_rounds, complete, expected in test_cases: + state = { + "enable_clarification": enable, + "clarification_rounds": rounds, + "max_clarification_rounds": max_rounds, + "is_clarification_complete": complete, + } + + result = needs_clarification(state) + assert result == expected, f"Failed for case: {state}" + + +def test_handoff_tools(): + """Test that handoff tools are properly defined.""" + from src.graph.nodes import handoff_after_clarification, handoff_to_planner + + # Test handoff_to_planner tool - use invoke() method + result = handoff_to_planner.invoke( + {"research_topic": "renewable energy", "locale": "en-US"} + ) + assert result is None # Tool should return None (no-op) + + # Test handoff_after_clarification tool - use invoke() method + result = handoff_after_clarification.invoke({"locale": "en-US"}) + assert result is None # Tool should return None (no-op) + + +@patch("src.graph.nodes.get_llm_by_type") +def test_coordinator_tools_with_clarification_enabled(mock_get_llm): + """Test that coordinator binds correct tools when clarification is enabled.""" + # Mock LLM response + mock_llm = MagicMock() + mock_response = MagicMock() + mock_response.content = "Let me clarify..." + mock_response.tool_calls = [] + mock_llm.bind_tools.return_value.invoke.return_value = mock_response + mock_get_llm.return_value = mock_llm + + # State with clarification enabled (in progress) + state = { + "messages": [{"role": "user", "content": "Tell me about something"}], + "enable_clarification": True, + "clarification_rounds": 2, + "max_clarification_rounds": 3, + "is_clarification_complete": False, + "clarification_history": ["response 1", "response 2"], + "locale": "en-US", + "research_topic": "", + } + + # Mock config + config = {"configurable": {"resources": []}} + + # Call coordinator_node + coordinator_node(state, config) + + # Verify that LLM was called with bind_tools + assert mock_llm.bind_tools.called + bound_tools = mock_llm.bind_tools.call_args[0][0] + + # Should bind 2 tools when clarification is enabled + assert len(bound_tools) == 2 + tool_names = [tool.name for tool in bound_tools] + assert "handoff_to_planner" in tool_names + assert "handoff_after_clarification" in tool_names + + +@patch("src.graph.nodes.get_llm_by_type") +def test_coordinator_tools_with_clarification_disabled(mock_get_llm): + """Test that coordinator binds only one tool when clarification is disabled.""" + # Mock LLM response with tool call + mock_llm = MagicMock() + mock_response = MagicMock() + mock_response.content = "" + mock_response.tool_calls = [ + { + "name": "handoff_to_planner", + "args": {"research_topic": "test", "locale": "en-US"}, + } + ] + mock_llm.bind_tools.return_value.invoke.return_value = mock_response + mock_get_llm.return_value = mock_llm + + # State with clarification disabled + state = { + "messages": [{"role": "user", "content": "Tell me about something"}], + "enable_clarification": False, + "locale": "en-US", + "research_topic": "", + } + + # Mock config + config = {"configurable": {"resources": []}} + + # Call coordinator_node + coordinator_node(state, config) + + # Verify that LLM was called with bind_tools + assert mock_llm.bind_tools.called + bound_tools = mock_llm.bind_tools.call_args[0][0] + + # Should bind only 1 tool when clarification is disabled + assert len(bound_tools) == 1 + assert bound_tools[0].name == "handoff_to_planner" + + +@patch("src.graph.nodes.get_llm_by_type") +def test_coordinator_empty_llm_response_corner_case(mock_get_llm): + """ + Corner case test: LLM returns empty response when clarification is enabled. + + This tests error handling when LLM fails to return any content or tool calls + in the initial state (clarification_rounds=0). The system should gracefully + handle this by going to __end__ instead of crashing. + + Note: This is NOT a typical clarification workflow test, but rather tests + fault tolerance when LLM misbehaves. + """ + # Mock LLM response - empty response (failure scenario) + mock_llm = MagicMock() + mock_response = MagicMock() + mock_response.content = "" + mock_response.tool_calls = [] + mock_llm.bind_tools.return_value.invoke.return_value = mock_response + mock_get_llm.return_value = mock_llm + + # State with clarification enabled but initial round + state = { + "messages": [{"role": "user", "content": "test"}], + "enable_clarification": True, + # clarification_rounds: 0 (default, not started) + "locale": "en-US", + "research_topic": "", + } + + # Mock config + config = {"configurable": {"resources": []}} + + # Call coordinator_node - should not crash + result = coordinator_node(state, config) + + # Should gracefully handle empty response by going to __end__ + assert result.goto == "__end__" + assert result.update["locale"] == "en-US" diff --git a/tests/unit/graph/test_builder.py b/tests/unit/graph/test_builder.py index e76b41c..31a35df 100644 --- a/tests/unit/graph/test_builder.py +++ b/tests/unit/graph/test_builder.py @@ -96,7 +96,8 @@ def test_build_base_graph_adds_nodes_and_edges(MockStateGraph): # Check that all nodes and edges are added assert mock_builder.add_edge.call_count >= 2 assert mock_builder.add_node.call_count >= 8 - mock_builder.add_conditional_edges.assert_called_once() + # Now we have 2 conditional edges: research_team and coordinator + assert mock_builder.add_conditional_edges.call_count == 2 @patch("src.graph.builder._build_base_graph") diff --git a/tests/unit/rag/test_milvus.py b/tests/unit/rag/test_milvus.py index f054fa8..bb79019 100644 --- a/tests/unit/rag/test_milvus.py +++ b/tests/unit/rag/test_milvus.py @@ -157,7 +157,9 @@ def test_list_local_markdown_resources_populated(temp_examples_dir): # File without heading -> fallback title (temp_examples_dir / "file_two.md").write_text("No heading here.", encoding="utf-8") # Non-markdown file should be ignored - (temp_examples_dir / "ignore.txt").write_text("Should not be picked up.", encoding="utf-8") + (temp_examples_dir / "ignore.txt").write_text( + "Should not be picked up.", encoding="utf-8" + ) resources = retriever._list_local_markdown_resources() # Order not guaranteed; sort by uri for assertions @@ -815,7 +817,9 @@ def test_load_example_files_directory_missing(monkeypatch): assert called["insert"] == 0 # sanity (no insertion attempted) -def test_load_example_files_loads_and_skips_existing(monkeypatch, temp_load_skip_examples_dir): +def test_load_example_files_loads_and_skips_existing( + monkeypatch, temp_load_skip_examples_dir +): _patch_init(monkeypatch) examples_dir_name = temp_load_skip_examples_dir.name @@ -863,7 +867,9 @@ def test_load_example_files_loads_and_skips_existing(monkeypatch, temp_load_skip assert all(c["title"] == "Title Two" for c in calls) -def test_load_example_files_single_chunk_no_suffix(monkeypatch, temp_single_chunk_examples_dir): +def test_load_example_files_single_chunk_no_suffix( + monkeypatch, temp_single_chunk_examples_dir +): _patch_init(monkeypatch) examples_dir_name = temp_single_chunk_examples_dir.name @@ -901,6 +907,7 @@ def test_load_example_files_single_chunk_no_suffix(monkeypatch, temp_single_chun # Clean up test database file after tests import atexit + def cleanup_test_database(): """Clean up milvus_demo.db file created during testing.""" import os diff --git a/tests/unit/server/test_app.py b/tests/unit/server/test_app.py index a82fbef..e73ea2f 100644 --- a/tests/unit/server/test_app.py +++ b/tests/unit/server/test_app.py @@ -532,6 +532,8 @@ class TestAstreamWorkflowGenerator: enable_background_investigation=False, report_style=ReportStyle.ACADEMIC, enable_deep_thinking=False, + enable_clarification=False, + max_clarification_rounds=3, ) events = [] @@ -571,6 +573,8 @@ class TestAstreamWorkflowGenerator: enable_background_investigation=False, report_style=ReportStyle.ACADEMIC, enable_deep_thinking=False, + enable_clarification=False, + max_clarification_rounds=3, ) events = [] @@ -605,6 +609,8 @@ class TestAstreamWorkflowGenerator: enable_background_investigation=False, report_style=ReportStyle.ACADEMIC, enable_deep_thinking=False, + enable_clarification=False, + max_clarification_rounds=3, ) events = [] @@ -641,6 +647,8 @@ class TestAstreamWorkflowGenerator: enable_background_investigation=False, report_style=ReportStyle.ACADEMIC, enable_deep_thinking=False, + enable_clarification=False, + max_clarification_rounds=3, ) events = [] @@ -682,6 +690,8 @@ class TestAstreamWorkflowGenerator: enable_background_investigation=False, report_style=ReportStyle.ACADEMIC, enable_deep_thinking=False, + enable_clarification=False, + max_clarification_rounds=3, ) events = [] @@ -723,6 +733,8 @@ class TestAstreamWorkflowGenerator: enable_background_investigation=False, report_style=ReportStyle.ACADEMIC, enable_deep_thinking=False, + enable_clarification=False, + max_clarification_rounds=3, ) events = [] @@ -761,6 +773,8 @@ class TestAstreamWorkflowGenerator: enable_background_investigation=False, report_style=ReportStyle.ACADEMIC, enable_deep_thinking=False, + enable_clarification=False, + max_clarification_rounds=3, ) events = [] diff --git a/tests/unit/tools/test_search_postprocessor.py b/tests/unit/tools/test_search_postprocessor.py index 5064b25..619f7d5 100644 --- a/tests/unit/tools/test_search_postprocessor.py +++ b/tests/unit/tools/test_search_postprocessor.py @@ -1,4 +1,5 @@ import pytest + from src.tools.search_postprocessor import SearchResultPostProcessor diff --git a/tests/unit/utils/test_context_manager.py b/tests/unit/utils/test_context_manager.py index ed213ec..4967c46 100644 --- a/tests/unit/utils/test_context_manager.py +++ b/tests/unit/utils/test_context_manager.py @@ -1,5 +1,6 @@ import pytest -from langchain_core.messages import SystemMessage, HumanMessage, AIMessage, ToolMessage +from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage + from src.utils.context_manager import ContextManager @@ -140,7 +141,6 @@ class TestContextManager: # return the original messages assert len(compressed["messages"]) == 4 - def test_count_message_tokens_with_additional_kwargs(self): """Test counting tokens for messages with additional kwargs""" context_manager = ContextManager(token_limit=1000) diff --git a/web/messages/en.json b/web/messages/en.json index f2e0723..4aa4917 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -36,6 +36,9 @@ "general": { "title": "General", "autoAcceptPlan": "Allow automatic acceptance of plans", + "enableClarification": "Allow clarification", + "maxClarificationRounds": "Max clarification rounds", + "maxClarificationRoundsDescription": "Maximum number of clarification rounds when clarification is enabled (default: 3).", "maxPlanIterations": "Max plan iterations", "maxPlanIterationsDescription": "Set to 1 for single-step planning. Set to 2 or more to enable re-planning.", "maxStepsOfPlan": "Max steps of a research plan", diff --git a/web/messages/zh.json b/web/messages/zh.json index 90a17aa..ee8f13e 100644 --- a/web/messages/zh.json +++ b/web/messages/zh.json @@ -36,6 +36,9 @@ "general": { "title": "通用", "autoAcceptPlan": "允许自动接受计划", + "enableClarification": "允许澄清", + "maxClarificationRounds": "最大澄清回合数", + "maxClarificationRoundsDescription": "启用澄清时允许的最大澄清回合数,默认是3。", "maxPlanIterations": "最大计划迭代次数", "maxPlanIterationsDescription": "设置为 1 进行单步规划。设置为 2 或更多以启用重新规划。", "maxStepsOfPlan": "研究计划的最大步骤数", diff --git a/web/src/app/settings/tabs/general-tab.tsx b/web/src/app/settings/tabs/general-tab.tsx index 4980027..aecdcf5 100644 --- a/web/src/app/settings/tabs/general-tab.tsx +++ b/web/src/app/settings/tabs/general-tab.tsx @@ -26,6 +26,10 @@ import type { Tab } from "./types"; const generalFormSchema = z.object({ autoAcceptedPlan: z.boolean(), + enableClarification: z.boolean(), + maxClarificationRounds: z.number().min(1, { + message: "Max clarification rounds must be at least 1.", + }), maxPlanIterations: z.number().min(1, { message: "Max plan iterations must be at least 1.", }), @@ -102,6 +106,52 @@ export const GeneralTab: Tab = ({ )} /> + ( + + +
+ + +
+
+
+ )} + /> + {form.watch("enableClarification") && ( + ( + + {t("maxClarificationRounds")} + + + field.onChange(parseInt(event.target.value || "1")) + } + /> + + + {t("maxClarificationRoundsDescription")} + + + + )} + /> + )} ; auto_accepted_plan: boolean; + enable_clarification?: boolean; max_plan_iterations: number; max_step_num: number; max_search_results?: number; diff --git a/web/src/core/store/settings-store.ts b/web/src/core/store/settings-store.ts index 08d50fd..de59a8d 100644 --- a/web/src/core/store/settings-store.ts +++ b/web/src/core/store/settings-store.ts @@ -10,6 +10,8 @@ const SETTINGS_KEY = "deerflow.settings"; const DEFAULT_SETTINGS: SettingsState = { general: { autoAcceptedPlan: false, + enableClarification: false, + maxClarificationRounds: 3, enableDeepThinking: false, enableBackgroundInvestigation: false, maxPlanIterations: 1, @@ -25,6 +27,8 @@ const DEFAULT_SETTINGS: SettingsState = { export type SettingsState = { general: { autoAcceptedPlan: boolean; + enableClarification: boolean; + maxClarificationRounds: number; enableDeepThinking: boolean; enableBackgroundInvestigation: boolean; maxPlanIterations: number; @@ -160,4 +164,14 @@ export function setEnableBackgroundInvestigation(value: boolean) { })); saveSettings(); } + +export function setEnableClarification(value: boolean) { + useSettingsStore.setState((state) => ({ + general: { + ...state.general, + enableClarification: value, + }, + })); + saveSettings(); +} loadSettings(); diff --git a/web/src/core/store/store.ts b/web/src/core/store/store.ts index b052f4b..34af095 100644 --- a/web/src/core/store/store.ts +++ b/web/src/core/store/store.ts @@ -104,6 +104,7 @@ export async function sendMessage( interrupt_feedback: interruptFeedback, resources, auto_accepted_plan: settings.autoAcceptedPlan, + enable_clarification: settings.enableClarification ?? false, enable_deep_thinking: settings.enableDeepThinking ?? false, enable_background_investigation: settings.enableBackgroundInvestigation ?? true,