mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-18 03:54:46 +08:00
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:
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user