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
This commit is contained in:
jimmyuconn1982
2025-10-13 22:35:57 -07:00
committed by GitHub
parent 81c91dda43
commit 2510cc61de
26 changed files with 830 additions and 57 deletions

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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):

View File

@@ -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__)

View File

@@ -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():

View File

@@ -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

View File

@@ -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")