"""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 = ( "\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" "" ) _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