diff --git a/.env.example b/.env.example index 778272bf3..7a0a6fd02 100644 --- a/.env.example +++ b/.env.example @@ -10,4 +10,7 @@ LANGSMITH_TRACING= SUPABASE_KEY= SUPABASE_URL= # Should be set to true for a production deployment on Open Agent Platform. Should be set to false otherwise, such as for local development. -GET_API_KEYS_FROM_CONFIG=false \ No newline at end of file +GET_API_KEYS_FROM_CONFIG=false + +# Setting it to true ensures that the think_tool tool can execute correctly. Otherwise, there is a very small probability that it will not be executed. +FORCE_THINK_TOOL=False \ No newline at end of file diff --git a/src/open_deep_research/configuration.py b/src/open_deep_research/configuration.py index 1c5bac9e9..8e2be6228 100644 --- a/src/open_deep_research/configuration.py +++ b/src/open_deep_research/configuration.py @@ -39,6 +39,16 @@ class Configuration(BaseModel): """Main configuration class for the Deep Research agent.""" # General Configuration + force_think_tool: bool = Field( + default=False, + metadata={ + "x_oap_ui_config": { + "type": "boolean", + "default": False, + "description": "Require agents to call think_tool before executing other tools" + } + } + ) max_structured_output_retries: int = Field( default=3, metadata={ diff --git a/src/open_deep_research/deep_researcher.py b/src/open_deep_research/deep_researcher.py index 279dbffd9..4d6f8bf28 100644 --- a/src/open_deep_research/deep_researcher.py +++ b/src/open_deep_research/deep_researcher.py @@ -27,6 +27,10 @@ lead_researcher_prompt, research_system_prompt, transform_messages_into_research_topic_prompt, + force_think_tool_supervisor_before_conduct_research_reminder, + force_think_tool_supervisor_after_conduct_research_reminder, + force_think_tool_researcher_after_call_search_tool_reminder, + has_mixed_think_tool_calls_reminder, ) from open_deep_research.state import ( AgentInputState, @@ -50,6 +54,8 @@ openai_websearch_called, remove_up_to_last_ai_message, think_tool, + has_mixed_think_tool_calls, + get_previous_tool_name, ) # Initialize a configurable model that we will use throughout the agent @@ -260,6 +266,17 @@ async def supervisor_tools(state: SupervisorState, config: RunnableConfig) -> Co "research_brief": state.get("research_brief", "") } ) + + # Check if think_tool is mixed with other tools + if has_mixed_think_tool_calls(most_recent_message.tool_calls): + return Command( + goto="supervisor", + update={ + "supervisor_messages": [ + HumanMessage(content=has_mixed_think_tool_calls_reminder) + ] + } + ) # Step 2: Process all tool calls together (both think_tool and ConductResearch) all_tool_messages = [] @@ -285,6 +302,33 @@ async def supervisor_tools(state: SupervisorState, config: RunnableConfig) -> Co if tool_call["name"] == "ConductResearch" ] + # Check if think_tool is called correctly before calling ConductResearch + if configurable.force_think_tool: + previous_tool_name = get_previous_tool_name(supervisor_messages) + + # Ensure that think_tool is called correctly before calling ConductResearch + if conduct_research_calls: + if previous_tool_name != "think_tool": + return Command( + goto="supervisor", + update={ + "supervisor_messages": [ + HumanMessage(content=force_think_tool_supervisor_before_conduct_research_reminder) + ] + } + ) + + # Ensure that think_tool is called correctly after calling ConductResearch + if previous_tool_name == "ConductResearch" and most_recent_message.tool_calls[0]["name"] != "think_tool": + return Command( + goto="supervisor", + update={ + "supervisor_messages": [ + HumanMessage(content=force_think_tool_supervisor_after_conduct_research_reminder) + ] + } + ) + if conduct_research_calls: try: # Limit concurrent research units to prevent resource exhaustion @@ -463,6 +507,17 @@ async def researcher_tools(state: ResearcherState, config: RunnableConfig) -> Co if not has_tool_calls and not has_native_search: return Command(goto="compress_research") + # Check if think_tool is mixed with other tools + if has_mixed_think_tool_calls(most_recent_message.tool_calls): + return Command( + goto="researcher", + update={ + "researcher_messages": [ + HumanMessage(content=has_mixed_think_tool_calls_reminder) + ], + } + ) + # Step 2: Handle other tool calls (search, MCP tools, etc.) tools = await get_all_tools(config) tools_by_name = { @@ -472,6 +527,22 @@ async def researcher_tools(state: ResearcherState, config: RunnableConfig) -> Co # Execute all tool calls in parallel tool_calls = most_recent_message.tool_calls + + # Check if think_tool is called correctly + if configurable.force_think_tool: + + previous_tool_name = get_previous_tool_name(researcher_messages) + + # Ensure that think_tool is called correctly after calling other tools + if previous_tool_name and previous_tool_name != "think_tool" and tool_calls[0]["name"] != "think_tool": + return Command( + goto="researcher", + update={ + "researcher_messages": [ + HumanMessage(content=force_think_tool_researcher_after_call_search_tool_reminder) + ] + } + ) tool_execution_tasks = [ execute_tool_safely(tools_by_name[tool_call["name"]], tool_call["args"], config) for tool_call in tool_calls diff --git a/src/open_deep_research/prompts.py b/src/open_deep_research/prompts.py index 1d9f5d996..6a0dc2023 100644 --- a/src/open_deep_research/prompts.py +++ b/src/open_deep_research/prompts.py @@ -365,4 +365,25 @@ Remember, your goal is to create a summary that can be easily understood and utilized by a downstream research agent while preserving the most critical information from the original webpage. Today's date is {date}. -""" \ No newline at end of file +""" + + + +force_think_tool_supervisor_before_conduct_research_reminder = ( + "Before delegating any research with ConductResearch you must first call think_tool to " + "plan your approach. Reflect with think_tool, then decide whether to delegate." +) + +force_think_tool_supervisor_after_conduct_research_reminder = ( + "After calling ConductResearch, you must call think_tool to reflect on the results. " + "Use think_tool now to analyze progress and plan the next action." +) + +force_think_tool_researcher_after_call_search_tool_reminder = ( + "You must call think_tool to reflect on the latest findings before using other tools. " + "Use think_tool now to analyze progress and plan the next action." +) + +has_mixed_think_tool_calls_reminder = ( + "You cannot call think_tool in parallel with other tools. Please call think_tool first, then call the other tools." +) \ No newline at end of file diff --git a/src/open_deep_research/utils.py b/src/open_deep_research/utils.py index 82ce304e2..e9627807c 100644 --- a/src/open_deep_research/utils.py +++ b/src/open_deep_research/utils.py @@ -14,6 +14,7 @@ AIMessage, HumanMessage, MessageLikeRepresentation, + ToolMessage, filter_messages, ) from langchain_core.runnables import RunnableConfig @@ -923,3 +924,20 @@ def get_tavily_api_key(config: RunnableConfig): return api_keys.get("TAVILY_API_KEY") else: return os.getenv("TAVILY_API_KEY") + + +def has_mixed_think_tool_calls(tool_calls: list[dict]) -> bool: + """Return True if think_tool is mixed with other tools in the same turn.""" + has_think = any(call["name"] == "think_tool" for call in tool_calls) + has_other = any(call["name"] != "think_tool" for call in tool_calls) + return has_think and has_other + +def get_previous_tool_name(messages: list[object]) -> str | None: + """Return the name of the most recent tool message before the latest message. If there is no previous tool message, return None.""" + # Skip the most recent message (typically the current AI reply with tool calls) + for i in range(len(messages) - 1, -1, -1): + if isinstance(messages[i], ToolMessage): + return messages[i].name + if isinstance(messages[i], dict) and messages[i].get("type") == "tool": + return messages[i].get("name") + return None \ No newline at end of file