deer-flow/backend/packages/harness/deerflow/agents/middlewares/terminal_response_middleware.py
Huixin615 6389fc03fd
fix: ensure visible response after tool runs (#4033)
* fix: ensure visible response after tool runs

* fix: clean up terminal response recovery state

* fix: bound terminal recovery state
2026-07-11 15:53:14 +08:00

215 lines
8.5 KiB
Python

"""Ensure tool-using lead-agent turns end with a visible assistant response."""
from __future__ import annotations
import threading
from collections.abc import Awaitable, Callable
from typing import Any, override
from langchain.agents import AgentState
from langchain.agents.middleware import AgentMiddleware
from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse, hook_config
from langchain_core.messages import AIMessage, HumanMessage, RemoveMessage, ToolMessage
from langgraph.runtime import Runtime
from deerflow.agents.middlewares._bounded_dict import BoundedDict
_RECOVERY_PROMPT = (
"<system_reminder>\n"
"Your previous response after the tool execution was empty. Review the tool results "
"already present in the conversation and provide a concise, user-visible final response. "
"Do not call another tool unless it is strictly necessary.\n"
"</system_reminder>"
)
_FALLBACK_CONTENT = "The model completed the tool run but returned no final response, including after one automatic retry. Please try again or use a different model."
_TOOL_CALL_FINISH_REASONS = {"tool_calls", "function_call"}
def _has_visible_content(message: AIMessage) -> bool:
"""Return whether an AI message contains user-visible text."""
content = message.content
if isinstance(content, str):
return bool(content.strip())
if isinstance(content, list):
for block in content:
if isinstance(block, str) and block.strip():
return True
if isinstance(block, dict) and block.get("type") in {"text", "output_text"}:
text = block.get("text")
if isinstance(text, str) and text.strip():
return True
return False
def _has_tool_call_intent_or_error(message: AIMessage) -> bool:
"""Keep tool routing and malformed tool-call handling out of this guard."""
if message.tool_calls or getattr(message, "invalid_tool_calls", None):
return True
additional_kwargs = message.additional_kwargs or {}
if additional_kwargs.get("tool_calls") or additional_kwargs.get("function_call"):
return True
response_metadata = message.response_metadata or {}
return response_metadata.get("finish_reason") in _TOOL_CALL_FINISH_REASONS
def _tool_result_in_current_turn(messages: list[Any]) -> bool:
"""Return whether a tool result follows the latest real user message."""
latest_user_index = -1
for index, message in enumerate(messages):
if not isinstance(message, HumanMessage):
continue
if (message.additional_kwargs or {}).get("hide_from_ui"):
continue
latest_user_index = index
# Scope: #4027 covers interactive post-tool turns. Scheduled/internal
# invocations without a real HumanMessage need a separate terminal-success
# invariant rather than being inferred from arbitrary historical tools.
if latest_user_index == -1:
return False
return any(isinstance(message, ToolMessage) for message in messages[latest_user_index + 1 :])
class TerminalResponseMiddleware(AgentMiddleware[AgentState]):
"""Retry one empty post-tool response, then persist a visible error fallback."""
def __init__(self) -> None:
super().__init__()
self._lock = threading.Lock()
self._retry_counts: BoundedDict[tuple[str, str], int] = BoundedDict(1000)
self._pending_prompts: BoundedDict[tuple[str, str], bool] = BoundedDict(1000)
@staticmethod
def _key(runtime: Runtime) -> tuple[str, str]:
context = getattr(runtime, "context", None)
if isinstance(context, dict):
thread_id = str(context.get("thread_id") or "unknown-thread")
run_id = str(context.get("run_id") or context.get("run_attempt_id") or id(runtime))
return thread_id, run_id
# Defensive fallback for tests/custom embeddings. Production Gateway
# runs always provide thread_id and run_id in Runtime.context.
return "unknown-thread", str(id(runtime))
def _clear(self, runtime: Runtime) -> None:
key = self._key(runtime)
with self._lock:
self._retry_counts.pop(key, None)
self._pending_prompts.pop(key, None)
def _clear_other_runs(self, runtime: Runtime) -> None:
thread_id, run_id = self._key(runtime)
with self._lock:
stale = [key for key in self._retry_counts if key[0] == thread_id and key[1] != run_id]
for key in stale:
self._retry_counts.pop(key, None)
self._pending_prompts.pop(key, None)
def _apply(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
messages = list(state.get("messages") or [])
if not messages or not isinstance(messages[-1], AIMessage):
return None
last = messages[-1]
if _has_visible_content(last) or _has_tool_call_intent_or_error(last):
return None
if not _tool_result_in_current_turn(messages):
return None
key = self._key(runtime)
with self._lock:
# The recovery budget is once per run, not once per empty message.
# A retry that calls another tool must not refresh the budget and
# create an unbounded empty -> retry -> tool loop.
retry_count = self._retry_counts.get(key, 0)
if retry_count == 0:
self._retry_counts[key] = 1
self._pending_prompts[key] = True
if retry_count == 0:
# The next model call gets a new message id. Remove this empty
# terminal message now so a successful recovery does not leave it
# in checkpoint history or future model context.
message_updates = [RemoveMessage(id=last.id)] if last.id else []
return {"messages": message_updates, "jump_to": "model"}
additional_kwargs = dict(last.additional_kwargs or {})
additional_kwargs.update(
{
"deerflow_error_fallback": True,
"error_reason": "Model returned an empty terminal response after one retry",
}
)
fallback = last.model_copy(
update={
"content": _FALLBACK_CONTENT,
"additional_kwargs": additional_kwargs,
}
)
return {"messages": [fallback]}
def _augment_request(self, request: ModelRequest) -> ModelRequest:
key = self._key(request.runtime)
with self._lock:
pending = key in self._pending_prompts
self._pending_prompts.pop(key, None)
if not pending:
return request
reminder = HumanMessage(
content=_RECOVERY_PROMPT,
name="terminal_response_recovery",
additional_kwargs={"hide_from_ui": True},
)
return request.override(messages=[*request.messages, reminder])
@override
def before_agent(self, state: AgentState, runtime: Runtime) -> dict | None:
self._clear_other_runs(runtime)
# A prior invocation can bypass after_agent via Command(goto=END).
# Reset the same run id here so resume starts with a fresh one-retry
# budget; internal jump_to=model loops do not re-run before_agent.
self._clear(runtime)
return None
@override
async def abefore_agent(self, state: AgentState, runtime: Runtime) -> dict | None:
self._clear_other_runs(runtime)
self._clear(runtime)
return None
@hook_config(can_jump_to=["model"])
@override
def after_model(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
return self._apply(state, runtime)
@hook_config(can_jump_to=["model"])
@override
async def aafter_model(self, state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
return self._apply(state, runtime)
@override
def wrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], ModelResponse],
) -> ModelCallResult:
return handler(self._augment_request(request))
@override
async def awrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
) -> ModelCallResult:
return await handler(self._augment_request(request))
@override
def after_agent(self, state: AgentState, runtime: Runtime) -> dict | None:
self._clear(runtime)
return None
@override
async def aafter_agent(self, state: AgentState, runtime: Runtime) -> dict | None:
self._clear(runtime)
return None