mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-11 14:38:38 +00:00
* fix(clarification): drop sibling tool calls before interrupt - Rewrite the AIMessage in ClarificationMiddleware.after_model so a parallel bash/write_file cannot run before the user answers - langchain return_direct only inspects the last ToolMessage; siblings both execute and can keep the agent loop alive - Skip the rewrite when disable_clarification is set - Prompt and tool docs: do not call other tools in the same turn Fixes #4906 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(clarification): enhance sibling tool call handling in ClarificationMiddleware - Update ClarificationMiddleware to ensure sibling tool calls are dropped when `ask_clarification` is invoked, preventing unintended execution before user input. - Modify documentation to clarify that the `return_direct` router now inspects all client-side tool calls of the last AIMessage, ensuring proper routing behavior. - Introduce a new integration test to validate that sibling tools do not execute when `ask_clarification` is present in the same turn. This change addresses potential issues with tool execution order and improves the overall reliability of the middleware. Fixes #4906 * fix(clarification): enhance tool call filtering in ClarificationMiddleware - Update _filter_content_tool_use to handle Gemini-style function_call blocks by matching on name when no id is present, ensuring proper filtering of tool calls. - Modify ClarificationMiddleware to maintain sibling tool call integrity by dropping unnecessary blocks, improving the clarity of the AIMessage content. - Add a new test to validate the correct stripping of idless function call content blocks, ensuring that sibling tool calls do not execute prematurely. This change improves the robustness of the middleware and addresses potential execution order issues. Fixes #4906 * fix(clarification): drop siblings when ask_clarification is malformed LangChain parks invalid args on invalid_tool_calls independently, so a valid sibling would otherwise still execute before the user answers. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
187 lines
6.8 KiB
Python
187 lines
6.8 KiB
Python
"""Factory-level wiring test for ClarificationMiddleware sibling-tool dropping.
|
|
|
|
Unit tests in ``test_clarification_middleware.py`` call ``after_model``
|
|
directly. This file builds a real ``langchain.agents.create_agent`` graph
|
|
so a langchain hook-dispatch regression or a same-id ``add_messages``
|
|
replacement failure would reintroduce #4906 instead of staying green.
|
|
A second graph path covers malformed ``ask_clarification`` parked on
|
|
``invalid_tool_calls`` beside a valid sibling.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from langchain.agents import create_agent
|
|
from langchain_core.language_models import BaseChatModel
|
|
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
|
from langchain_core.outputs import ChatGeneration, ChatResult
|
|
from langchain_core.tools import tool
|
|
|
|
from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware
|
|
from deerflow.tools.builtins.clarification_tool import ask_clarification_tool
|
|
|
|
_BASH_INVOCATIONS: list[str] = []
|
|
_MIXED_MESSAGE_ID = "ai-clarification-with-sibling"
|
|
_INVALID_MIXED_MESSAGE_ID = "ai-invalid-clarification-with-sibling"
|
|
|
|
|
|
@tool
|
|
def bash(command: str) -> str:
|
|
"""Pretend to run a shell command. Records the call for assertion."""
|
|
_BASH_INVOCATIONS.append(command)
|
|
return f"ran: {command}"
|
|
|
|
|
|
class _MixedBatchModel(BaseChatModel):
|
|
"""First call emits ``ask_clarification`` plus ``bash``; a second call is a wiring failure."""
|
|
|
|
call_count: int = 0
|
|
|
|
@property
|
|
def _llm_type(self) -> str:
|
|
return "fake-clarification-mixed-batch"
|
|
|
|
def bind_tools(self, tools, **kwargs):
|
|
return self
|
|
|
|
def _generate(self, messages, stop=None, run_manager=None, **kwargs):
|
|
self.call_count += 1
|
|
if self.call_count == 1:
|
|
message = AIMessage(
|
|
id=_MIXED_MESSAGE_ID,
|
|
content="",
|
|
tool_calls=[
|
|
{
|
|
"id": "call_clarify_1",
|
|
"name": "ask_clarification",
|
|
"args": {
|
|
"question": "Which directory should I use?",
|
|
"clarification_type": "missing_info",
|
|
},
|
|
},
|
|
{
|
|
"id": "call_bash_1",
|
|
"name": "bash",
|
|
"args": {"command": "rm -rf /tmp/foo"},
|
|
},
|
|
],
|
|
)
|
|
else:
|
|
message = AIMessage(content="should-not-happen")
|
|
return ChatResult(generations=[ChatGeneration(message=message)])
|
|
|
|
async def _agenerate(self, messages, stop=None, run_manager=None, **kwargs):
|
|
return self._generate(messages, stop=stop, run_manager=run_manager, **kwargs)
|
|
|
|
|
|
class _InvalidClarificationMixedBatchModel(BaseChatModel):
|
|
"""First call emits a malformed ask_clarification plus a valid bash sibling."""
|
|
|
|
call_count: int = 0
|
|
|
|
@property
|
|
def _llm_type(self) -> str:
|
|
return "fake-invalid-clarification-mixed-batch"
|
|
|
|
def bind_tools(self, tools, **kwargs):
|
|
return self
|
|
|
|
def _generate(self, messages, stop=None, run_manager=None, **kwargs):
|
|
self.call_count += 1
|
|
if self.call_count == 1:
|
|
message = AIMessage(
|
|
id=_INVALID_MIXED_MESSAGE_ID,
|
|
content="",
|
|
tool_calls=[
|
|
{
|
|
"id": "call_bash_1",
|
|
"name": "bash",
|
|
"args": {"command": "rm -rf /tmp/foo"},
|
|
},
|
|
],
|
|
invalid_tool_calls=[
|
|
{
|
|
"id": "call_clarify_1",
|
|
"name": "ask_clarification",
|
|
"args": "{",
|
|
"error": "Failed to parse tool arguments",
|
|
"type": "invalid_tool_call",
|
|
},
|
|
],
|
|
)
|
|
else:
|
|
message = AIMessage(content="should-not-happen")
|
|
return ChatResult(generations=[ChatGeneration(message=message)])
|
|
|
|
async def _agenerate(self, messages, stop=None, run_manager=None, **kwargs):
|
|
return self._generate(messages, stop=stop, run_manager=run_manager, **kwargs)
|
|
|
|
|
|
def test_mixed_clarification_batch_does_not_execute_siblings_or_loop():
|
|
"""after_model must be dispatched and replace the AIMessage in place.
|
|
|
|
(a) the bash handler never runs
|
|
(b) the run ends without a second model call
|
|
"""
|
|
_BASH_INVOCATIONS.clear()
|
|
model = _MixedBatchModel()
|
|
agent = create_agent(
|
|
model=model,
|
|
tools=[ask_clarification_tool, bash],
|
|
middleware=[ClarificationMiddleware()],
|
|
)
|
|
|
|
result = agent.invoke(
|
|
{"messages": [HumanMessage(content="clean up the temp dir")]},
|
|
config={"recursion_limit": 8},
|
|
)
|
|
|
|
assert _BASH_INVOCATIONS == [], f"bash ran before the user answered: {_BASH_INVOCATIONS}"
|
|
assert model.call_count == 1
|
|
|
|
ai_messages = [message for message in result["messages"] if isinstance(message, AIMessage)]
|
|
assert len(ai_messages) == 1
|
|
patched = ai_messages[0]
|
|
assert patched.id == _MIXED_MESSAGE_ID
|
|
assert [tc["name"] for tc in patched.tool_calls] == ["ask_clarification"]
|
|
|
|
tool_messages = [message for message in result["messages"] if isinstance(message, ToolMessage)]
|
|
assert [message.name for message in tool_messages] == ["ask_clarification"]
|
|
assert tool_messages[0].tool_call_id == "call_clarify_1"
|
|
|
|
|
|
def test_mixed_invalid_clarification_batch_does_not_execute_siblings_or_loop():
|
|
"""Malformed ask_clarification is still a stop signal for executable siblings.
|
|
|
|
LangChain parks the broken call on ``invalid_tool_calls`` while the valid
|
|
bash sibling stays on ``tool_calls``. after_model must still rewrite the
|
|
AIMessage so:
|
|
(a) the bash handler never runs
|
|
(b) the run ends without a second model call
|
|
"""
|
|
_BASH_INVOCATIONS.clear()
|
|
model = _InvalidClarificationMixedBatchModel()
|
|
agent = create_agent(
|
|
model=model,
|
|
tools=[ask_clarification_tool, bash],
|
|
middleware=[ClarificationMiddleware()],
|
|
)
|
|
|
|
result = agent.invoke(
|
|
{"messages": [HumanMessage(content="clean up the temp dir")]},
|
|
config={"recursion_limit": 8},
|
|
)
|
|
|
|
assert _BASH_INVOCATIONS == [], f"bash ran before the user answered: {_BASH_INVOCATIONS}"
|
|
assert model.call_count == 1
|
|
|
|
ai_messages = [message for message in result["messages"] if isinstance(message, AIMessage)]
|
|
assert len(ai_messages) == 1
|
|
patched = ai_messages[0]
|
|
assert patched.id == _INVALID_MIXED_MESSAGE_ID
|
|
assert patched.tool_calls == []
|
|
assert [tc["name"] for tc in patched.invalid_tool_calls] == ["ask_clarification"]
|
|
assert patched.invalid_tool_calls[0]["id"] == "call_clarify_1"
|
|
|
|
tool_messages = [message for message in result["messages"] if isinstance(message, ToolMessage)]
|
|
assert tool_messages == []
|