fix: trim_messages prevents orphaned tool messages after history truncation

When conversation history exceeds max_history, the blind slice in trim_messages
could cut between an assistant(tool_calls) message and its tool result messages.
DeepSeek/OpenAI requires every role:"tool" message to follow a preceding
assistant message with matching tool_calls.

The fix detects when the slice boundary lands on a tool message, then scans
backwards to include the parent assistant(tool_calls) message(s). If the parent
cannot be found (e.g. cut at a user boundary), the orphaned tool messages are
removed. This fixes the "Messages with role 'tool' must be a response to a
preceding message with 'tool_calls'" error from DeepSeek.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
renjianbo
2026-05-07 23:59:42 +08:00
parent 0220be27e3
commit bc06bc12db

View File

@@ -514,6 +514,10 @@ class AgentMemory:
def trim_messages(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
裁剪消息列表:保留最近的 N 条,但始终保留第一条 system 消息。
同时保证 assistant(tool_calls) 与 tool 消息的配对完整性:
如果裁剪边界落在 assistant(tool_calls) 和其 tool 结果之间,
则向前扩展窗口包含该 assistant 消息,避免孤立的 tool 消息。
"""
if len(messages) <= self.max_history:
return messages
@@ -521,7 +525,37 @@ class AgentMemory:
system_msgs = [m for m in messages if m.get("role") == "system"]
other_msgs = [m for m in messages if m.get("role") != "system"]
trimmed = other_msgs[-(self.max_history - len(system_msgs)):]
max_keep = max(1, self.max_history - len(system_msgs))
start_idx = max(0, len(other_msgs) - max_keep)
# 如果裁剪后第一条是 tool 消息,向前找到其父 assistant(tool_calls)
if start_idx > 0 and start_idx < len(other_msgs) and other_msgs[start_idx].get("role") == "tool":
# 收集从 start_idx 开始连续的所有 tool 消息
tool_count = 0
for i in range(start_idx, len(other_msgs)):
if other_msgs[i].get("role") == "tool":
tool_count += 1
else:
break
# 向前查找对应的 assistant(tool_calls),一个 assistant 可包含多个 tool_calls
needed = tool_count
cursor = start_idx - 1
while cursor >= 0 and needed > 0:
role = other_msgs[cursor].get("role")
if role == "assistant" and other_msgs[cursor].get("tool_calls"):
needed -= len(other_msgs[cursor]["tool_calls"])
elif role == "user":
# 遇到 user 说明上一轮已结束,放弃扩展
break
cursor -= 1
if needed <= 0:
# 找到了所有父 assistant 消息,扩展窗口
start_idx = cursor + 1
trimmed = other_msgs[start_idx:]
# 最终安全检查:移除开头仍存在的孤立 tool 消息
while trimmed and trimmed[0].get("role") == "tool":
trimmed.pop(0)
return system_msgs + trimmed
@staticmethod