Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@
inserting synthetic ToolMessages with an error indicator immediately after the
AIMessage that made the tool calls, ensuring correct message ordering.

It also handles a related issue: when a tool call cycle completes (AIMessage →
ToolMessage(s)) but the conversation continues with a HumanMessage before the
AI has a chance to respond. This orphaned tool result pattern also causes LLM
errors, fixed by injecting a placeholder AIMessage after the last ToolMessage.

Note: Uses wrap_model_call instead of before_model to ensure patches are inserted
at the correct positions (immediately after each dangling AIMessage), not appended
to the end of the message list as before_model + add_messages reducer would do.
Expand All @@ -20,24 +25,33 @@
from langchain.agents import AgentState
from langchain.agents.middleware import AgentMiddleware
from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse
from langchain_core.messages import ToolMessage
from langchain_core.messages import AIMessage, ToolMessage

logger = logging.getLogger(__name__)


class DanglingToolCallMiddleware(AgentMiddleware[AgentState]):
"""Inserts placeholder ToolMessages for dangling tool calls before model invocation.

Scans the message history for AIMessages whose tool_calls lack corresponding
ToolMessages, and injects synthetic error responses immediately after the
offending AIMessage so the LLM receives a well-formed conversation.
"""Inserts placeholder messages for incomplete tool call patterns before model invocation.

Handles two cases:
1. Dangling tool calls: AIMessage has tool_calls with no corresponding ToolMessages.
Fix: inject placeholder ToolMessages after the AIMessage.
2. Orphaned tool results: a tool call cycle (AIMessage → ToolMessages) completes but
is immediately followed by a HumanMessage with no intervening AI response (e.g.,
user interrupted before the AI could reply to tool results).
Fix: inject a placeholder AIMessage after the last ToolMessage in the group.
"""

def _build_patched_messages(self, messages: list) -> list | None:
"""Return a new message list with patches inserted at the correct positions.

For each AIMessage with dangling tool_calls (no corresponding ToolMessage),
a synthetic ToolMessage is inserted immediately after that AIMessage.
Phase 1 – dangling tool calls: for each AIMessage whose tool_calls lack a
corresponding ToolMessage, insert a synthetic ToolMessage immediately after.

Phase 2 – orphaned tool results: for each ToolMessage that is directly followed
by a HumanMessage (not another ToolMessage or an AIMessage), insert a synthetic
AIMessage so the LLM receives a well-formed conversation.

Returns None if no patches are needed.
"""
# Collect IDs of all existing ToolMessages
Expand All @@ -46,34 +60,18 @@ def _build_patched_messages(self, messages: list) -> list | None:
if isinstance(msg, ToolMessage):
existing_tool_msg_ids.add(msg.tool_call_id)

# Check if any patching is needed
needs_patch = False
for msg in messages:
if getattr(msg, "type", None) != "ai":
continue
for tc in getattr(msg, "tool_calls", None) or []:
tc_id = tc.get("id")
if tc_id and tc_id not in existing_tool_msg_ids:
needs_patch = True
break
if needs_patch:
break

if not needs_patch:
return None

# Build new list with patches inserted right after each dangling AIMessage
patched: list = []
# --- Phase 1: fix dangling tool calls ---
phase1: list = []
patched_ids: set[str] = set()
patch_count = 0
phase1_count = 0
for msg in messages:
patched.append(msg)
phase1.append(msg)
if getattr(msg, "type", None) != "ai":
continue
for tc in getattr(msg, "tool_calls", None) or []:
tc_id = tc.get("id")
if tc_id and tc_id not in existing_tool_msg_ids and tc_id not in patched_ids:
patched.append(
phase1.append(
ToolMessage(
content="[Tool call was interrupted and did not return a result.]",
tool_call_id=tc_id,
Expand All @@ -82,10 +80,27 @@ def _build_patched_messages(self, messages: list) -> list | None:
)
)
patched_ids.add(tc_id)
patch_count += 1
phase1_count += 1

# --- Phase 2: fix orphaned tool results ---
# A ToolMessage followed directly by a HumanMessage means the AI never got
# to reply after the tool results came back. Inject a placeholder AIMessage.
phase2: list = []
phase2_count = 0
for i, msg in enumerate(phase1):
phase2.append(msg)
if isinstance(msg, ToolMessage):
next_msg = phase1[i + 1] if i + 1 < len(phase1) else None
if next_msg is not None and getattr(next_msg, "type", None) == "human":
phase2.append(AIMessage(content="[AI response was interrupted before completing.]"))
phase2_count += 1

total = phase1_count + phase2_count
if total == 0:
return None

logger.warning(f"Injecting {patch_count} placeholder ToolMessage(s) for dangling tool calls")
return patched
logger.warning(f"Injecting {total} placeholder message(s) for dangling/orphaned tool calls ({phase1_count} ToolMessage(s), {phase2_count} AIMessage(s))")
return phase2

@override
def wrap_model_call(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Middleware for automatic thread title generation."""

import logging
import re
from typing import NotRequired, override

from langchain.agents import AgentState
Expand Down Expand Up @@ -77,7 +78,7 @@ def _build_title_prompt(self, state: TitleMiddlewareState) -> tuple[str, str]:
assistant_msg_content = next((m.content for m in messages if m.type == "ai"), "")

user_msg = self._normalize_content(user_msg_content)
assistant_msg = self._normalize_content(assistant_msg_content)
assistant_msg = self._strip_think_tags(self._normalize_content(assistant_msg_content))

prompt = config.prompt_template.format(
max_words=config.max_words,
Expand All @@ -86,10 +87,15 @@ def _build_title_prompt(self, state: TitleMiddlewareState) -> tuple[str, str]:
)
return prompt, user_msg

def _strip_think_tags(self, text: str) -> str:
"""Remove <think>...</think> blocks emitted by reasoning models (e.g. minimax, DeepSeek-R1)."""
return re.sub(r"<think>[\s\S]*?</think>", "", text, flags=re.IGNORECASE).strip()

def _parse_title(self, content: object) -> str:
"""Normalize model output into a clean title string."""
config = get_title_config()
title_content = self._normalize_content(content)
title_content = self._strip_think_tags(title_content)
title = title_content.strip().strip('"').strip("'")
return title[: config.max_chars] if len(title) > config.max_chars else title

Expand Down
84 changes: 81 additions & 3 deletions backend/tests/test_dangling_tool_call_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,13 +79,17 @@ def test_patch_inserted_after_offending_ai_message(self):
]
patched = mw._build_patched_messages(msgs)
assert patched is not None
# HumanMessage, AIMessage, synthetic ToolMessage, HumanMessage
assert len(patched) == 4
# Phase 1 injects ToolMessage after the dangling AIMessage.
# Phase 2 then injects an AIMessage after that ToolMessage (since it's followed by HumanMessage).
# Result: HumanMessage, AIMessage, synthetic ToolMessage, synthetic AIMessage, HumanMessage
assert len(patched) == 5
assert isinstance(patched[0], HumanMessage)
assert isinstance(patched[1], AIMessage)
assert isinstance(patched[2], ToolMessage)
assert patched[2].tool_call_id == "call_1"
assert isinstance(patched[3], HumanMessage)
assert isinstance(patched[3], AIMessage)
assert "interrupted" in patched[3].content.lower()
assert isinstance(patched[4], HumanMessage)

def test_mixed_responded_and_dangling(self):
mw = DanglingToolCallMiddleware()
Expand Down Expand Up @@ -154,6 +158,80 @@ def test_patched_request_forwarded(self):
assert result == "response"


class TestOrphanedToolResults:
"""Tests for Phase 2: orphaned tool results (ToolMessage followed by HumanMessage)."""

def test_completed_cycle_followed_by_human_injects_ai(self):
"""AI calls tool, tool responds, but user interrupts before AI replies."""
mw = DanglingToolCallMiddleware()
msgs = [
HumanMessage(content="search for x"),
_ai_with_tool_calls([_tc("web_search", "call_1")]),
_tool_msg("call_1", "web_search"),
HumanMessage(content="any results?"),
]
patched = mw._build_patched_messages(msgs)
assert patched is not None
# ToolMessage should be followed by a synthetic AIMessage before the HumanMessage
tool_idx = next(i for i, m in enumerate(patched) if isinstance(m, ToolMessage))
assert isinstance(patched[tool_idx + 1], AIMessage)
assert "interrupted" in patched[tool_idx + 1].content.lower()
assert isinstance(patched[tool_idx + 2], HumanMessage)

def test_multiple_tool_messages_in_group_only_one_ai_injected(self):
"""When AI calls two tools and both respond, only one AIMessage is injected."""
mw = DanglingToolCallMiddleware()
msgs = [
_ai_with_tool_calls([_tc("bash", "call_1"), _tc("read", "call_2")]),
_tool_msg("call_1", "bash"),
_tool_msg("call_2", "read"),
HumanMessage(content="what happened?"),
]
patched = mw._build_patched_messages(msgs)
assert patched is not None
synthetic_ais = [m for m in patched if isinstance(m, AIMessage) and "interrupted" in m.content.lower()]
assert len(synthetic_ais) == 1

def test_completed_cycle_at_end_no_patch(self):
"""Tool results at end of history (no following HumanMessage) need no patch."""
mw = DanglingToolCallMiddleware()
msgs = [
_ai_with_tool_calls([_tc("bash", "call_1")]),
_tool_msg("call_1", "bash"),
]
assert mw._build_patched_messages(msgs) is None

def test_completed_cycle_followed_by_ai_no_patch(self):
"""Tool results properly followed by AIMessage need no Phase 2 patch."""
mw = DanglingToolCallMiddleware()
msgs = [
_ai_with_tool_calls([_tc("bash", "call_1")]),
_tool_msg("call_1", "bash"),
AIMessage(content="done"),
HumanMessage(content="thanks"),
]
assert mw._build_patched_messages(msgs) is None

def test_issue_1936_scenario(self):
"""Reproduces the exact scenario from issue #1936:
tool result → AI calls tool → tool result → multiple human messages.
"""
mw = DanglingToolCallMiddleware()
msgs = [
_tool_msg("prev_call", "some_tool"), # [26] previous tool result
_ai_with_tool_calls([_tc("web_search", "call_o6ml")]), # [27] AI requests tool
_tool_msg("call_o6ml", "web_search"), # [28] tool result
HumanMessage(content="follow up 1"), # [29] user interrupted
HumanMessage(content="follow up 2"), # [30]
]
patched = mw._build_patched_messages(msgs)
assert patched is not None
# Synthetic AIMessage should appear after [28] tool result, before [29] human
tool_28_idx = next(i for i, m in enumerate(patched) if isinstance(m, ToolMessage) and m.tool_call_id == "call_o6ml")
assert isinstance(patched[tool_28_idx + 1], AIMessage)
assert "interrupted" in patched[tool_28_idx + 1].content.lower()


class TestAwrapModelCall:
@pytest.mark.anyio
async def test_async_no_patch(self):
Expand Down
49 changes: 49 additions & 0 deletions backend/tests/test_title_middleware_core_logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,3 +181,52 @@ def test_sync_generate_title_respects_fallback_truncation(self):
result = middleware._generate_title_result(state)
assert result["title"].endswith("...")
assert result["title"].startswith("这是一个非常长的问题描述")

def test_parse_title_strips_think_tags(self):
"""Title model responses with <think>...</think> blocks are stripped before use."""
middleware = TitleMiddleware()
raw = "<think>用户想要研究贵阳发展情况。我需要使用 deep-research skill。</think>贵阳近5年发展报告研究"
result = middleware._parse_title(raw)
assert "<think>" not in result
assert result == "贵阳近5年发展报告研究"

def test_parse_title_strips_think_tags_only_response(self):
"""If model only outputs a think block and nothing else, title is empty string."""
middleware = TitleMiddleware()
raw = "<think>just thinking, no real title</think>"
result = middleware._parse_title(raw)
assert result == ""

def test_build_title_prompt_strips_assistant_think_tags(self):
"""<think> blocks in assistant messages are stripped before being included in the title prompt."""
_set_test_title_config(enabled=True)
middleware = TitleMiddleware()
state = {
"messages": [
HumanMessage(content="贵阳发展报告研究"),
AIMessage(content="<think>分析用户需求</think>我将为您研究贵阳的发展情况。"),
]
}
prompt, _ = middleware._build_title_prompt(state)
assert "<think>" not in prompt

def test_generate_title_async_strips_think_tags_in_response(self, monkeypatch):
"""Async title generation strips <think> blocks from the model response."""
_set_test_title_config(max_chars=50)
middleware = TitleMiddleware()
model = MagicMock()
model.ainvoke = AsyncMock(
return_value=AIMessage(content="<think>用户想研究贵阳。</think>贵阳发展研究")
)
monkeypatch.setattr(title_middleware_module, "create_chat_model", MagicMock(return_value=model))

state = {
"messages": [
HumanMessage(content="请帮我研究贵阳近5年发展情况"),
AIMessage(content="好的"),
]
}
result = asyncio.run(middleware._agenerate_title_result(state))
assert result is not None
assert "<think>" not in result["title"]
assert result["title"] == "贵阳发展研究"
Loading