mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-16 17:58:43 +00:00
* fix: ensure visible response after tool runs * fix: clean up terminal response recovery state * fix: bound terminal recovery state
215 lines
8.5 KiB
Python
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
|