fix: reorder fake tool call messages at tail to correct timing - #9451
fix: reorder fake tool call messages at tail to correct timing#9451Rail1bc wants to merge 5 commits into
Conversation
📄 Knowledge reviewDosu skipped reviewing this PR because your organization has used its |
There was a problem hiding this comment.
Hey - I've found 1 issue
Prompt for AI Agents
Please address the comments from this code review:
## Individual Comments
### Comment 1
<location path="astrbot/core/provider/sources/openai_source.py" line_range="534" />
<code_context>
+ ProviderOpenAIOfficial._reorder_tailing_tool_call_user(payloads)
+
+ @staticmethod
+ def _reorder_tailing_tool_call_user(payloads: dict) -> None:
+ """重排因伪造工具调用导致尾部 assistant(tc) → tool → user 乱序的消息。
+
</code_context>
<issue_to_address>
**issue (complexity):** Consider refactoring `_reorder_tailing_tool_call_user` to use an index-based scan with a separate pair-validation helper instead of pop-based mutation to clarify its control flow and data handling.
You can simplify `_reorder_tailing_tool_call_user` by making it index-based and separating pair validation from mutation. This reduces control-flow nesting, avoids repeated `pop()`/reverse operations, and makes the behavior easier to reason about.
### 1. Extract a small validator helper
```python
@staticmethod
def _is_valid_tool_pair(asst_msg: dict, tool_msg: dict) -> bool:
if asst_msg.get("role") != "assistant" or tool_msg.get("role") != "tool":
return False
tool_calls = asst_msg.get("tool_calls")
if not tool_calls:
return False
tc_ids = {tc.get("id") for tc in tool_calls if isinstance(tc, dict)}
return tool_msg.get("tool_call_id") in tc_ids
```
### 2. Use indices + slicing instead of pops + reverse
```python
@staticmethod
def _reorder_tailing_tool_call_user(payloads: dict) -> None:
messages = payloads.get("messages")
if not isinstance(messages, list) or len(messages) < 2:
return
# 必须以 user 结尾
if messages[-1].get("role") != "user":
return
# 从尾部向前扫描匹配的 assistant/tool 成对消息
end = len(messages) - 1 # index of tailing user
i = end - 1
while i >= 1:
asst_msg = messages[i - 1]
tool_msg = messages[i]
if not ProviderOpenAIOfficial._is_valid_tool_pair(asst_msg, tool_msg):
break
i -= 2 # 每次向前跳过一对
# 没有成对工具调用,保持原样
if i == end - 1:
return
# 现在 messages 结构为: [ ... prefix ..., pair_start..pair_end, user ]
prefix = messages[:i + 1]
pairs = messages[i + 1:end] # contiguous assistant/tool pairs
user_msg = messages[end]
# 重排:prefix + [user] + pairs
payloads["messages"] = prefix + [user_msg] + pairs
```
This keeps all behavior but removes the mutable stack-like operations and nested breaks, making the tail reordering logic more straightforward and testable.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
xiaoyuyu6420
left a comment
There was a problem hiding this comment.
Review
Clean fix for #9450. The approach of popping the trailing user message and re-inserting it before the assistant(tool_calls) → tool pairs is correct and minimal.
Strengths
- Validates
tool_call_idmatching — the code checkstool_msg.get("tool_call_id") not in tc_idsbefore treating a pair as a tool-call pair. This prevents misidentifying unrelatedassistant → toolsequences. - Reverses
pairsbefore re-insertion — correctly handles the LIFO collection order. - Handles the no-op case — when no fake pairs are found, the
usermessage is put back in place.
Issues
1. No tests
This is a non-trivial message reordering algorithm with several branches (no pairs, one pair, multiple pairs, mismatched tool_call_id). There should be at least one test per branch:
- Trailing user after a single fake tool call pair
- Trailing user after multiple fake tool call pairs
- No fake pairs (message order unchanged)
- assistant → tool without tool_calls (should stop collecting)
2. Only handles the OpenAI official provider
This fix is in ProviderOpenAIOfficial, but the issue (#9450) says the problem occurs in _prepare_chat_payload. If other providers (e.g. OpenAI-compatible providers) have the same _prepare_chat_payload pattern, they'll have the same bug. Worth checking if this needs to be applied more broadly.
3. Edge case: what if the user message itself contains tool-call-related content?
If a plugin injects a fake tool call AND the user message has role "user" but also contains function-related content, the reordering still treats it as a plain user message. This is probably correct (OpenAI expects user messages after tool results), but worth a comment.
4. Performance: O(n) pop from the end in a while loop
Each messages.pop() is O(1) from the end, and the while loop processes at most len(messages)/2 iterations, so this is fine. Just noting that the list is mutated in place.
Summary
Solid algorithm, correct logic. Main gap is missing tests — a message reordering function with 4+ branches should have test coverage before merging.
|
修复伪造工具调用(fake tool call)消息对在 OpenAI / Anthropic / Gemini 格式 provider 中的时序错乱问题。 修复从openai扩展到OpenAI / Anthropic / Gemini 格式 Modifications / 改动点
Screenshots or Test Results / 运行截图或测试结果 |
|
@sourcery-ai review |
There was a problem hiding this comment.
Hey - I've found 1 issue, and left some high level feedback:
- The helper
_FAKE_TOOL_CALL_CONTEXTSis duplicated across the Anthropic and Gemini tests; consider extracting it (and related expected structures) into a shared test utility to avoid drift and keep the scenarios consistent. - You might want to tighten the type hints on
reorder_tailing_tool_call_user(e.g.,list[dict[str, Any]]instead of plainlist) to better document the expected shape of themessagesparameter and help static analysis.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- The helper `_FAKE_TOOL_CALL_CONTEXTS` is duplicated across the Anthropic and Gemini tests; consider extracting it (and related expected structures) into a shared test utility to avoid drift and keep the scenarios consistent.
- You might want to tighten the type hints on `reorder_tailing_tool_call_user` (e.g., `list[dict[str, Any]]` instead of plain `list`) to better document the expected shape of the `messages` parameter and help static analysis.
## Individual Comments
### Comment 1
<location path="astrbot/core/provider/provider.py" line_range="214" />
<code_context>
)
+def reorder_tailing_tool_call_user(messages: list) -> None:
+ """重排因伪造工具调用导致尾部 assistant(tc) → tool → user 乱序的消息。
+
</code_context>
<issue_to_address>
**issue (complexity):** Consider refactoring `reorder_tailing_tool_call_user` to avoid in-place popping and instead scan with indices and rebuild the tail to make the control flow clearer.
The current implementation works but the in-place mutation plus nested loop makes it harder to reason about. You can keep the behavior while simplifying control flow by:
- Walking backwards with indices instead of popping inside the loop.
- Reconstructing the tail in one shot.
- (Optionally) splitting the tool-call pairing check into a small helper.
For example, you can refactor `reorder_tailing_tool_call_user` like this:
```python
def reorder_tailing_tool_call_user(messages: list) -> None:
if not isinstance(messages, list) or len(messages) < 2:
return
# 尾部必须是 user
last = messages[-1]
if last.get("role") != "user":
return
pairs: list[tuple[dict, dict]] = []
i = len(messages) - 2 # 从最后一个非 user 开始向前扫
while i >= 1:
asst_msg = messages[i - 1]
tool_msg = messages[i]
if asst_msg.get("role") != "assistant" or tool_msg.get("role") != "tool":
break
tool_calls = asst_msg.get("tool_calls")
if not tool_calls:
break
tc_ids = {tc.get("id") for tc in tool_calls if isinstance(tc, dict)}
if tool_msg.get("tool_call_id") not in tc_ids:
break
pairs.append((asst_msg, tool_msg))
i -= 2 # 每次向前跳过一对 assistant + tool
if not pairs:
return # 没有伪造对,无需重排
# prefix: 未参与重排的前半段
prefix = messages[:i + 1]
user_msg = last
new_tail: list[dict] = [user_msg]
for asst_msg, tool_msg in reversed(pairs):
new_tail.append(asst_msg)
new_tail.append(tool_msg)
# 一次性写回,保持原列表对象
messages[:] = prefix + new_tail
```
If you want to further clarify the intent, you can factor out the pairing check into a helper without changing behavior:
```python
def _is_valid_tool_pair(asst_msg: dict, tool_msg: dict) -> bool:
if asst_msg.get("role") != "assistant" or tool_msg.get("role") != "tool":
return False
tool_calls = asst_msg.get("tool_calls")
if not tool_calls:
return False
tc_ids = {tc.get("id") for tc in tool_calls if isinstance(tc, dict)}
return tool_msg.get("tool_call_id") in tc_ids
```
Then use it inside the loop:
```python
while i >= 1:
asst_msg = messages[i - 1]
tool_msg = messages[i]
if not _is_valid_tool_pair(asst_msg, tool_msg):
break
pairs.append((asst_msg, tool_msg))
i -= 2
```
This keeps all current functionality but reduces mutation during pattern matching, makes the index invariants explicit, and isolates the tool-call validation logic.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
…nAI provider
When plugins inject assistant(tool_calls) + tool(result) pairs into
contexts to force tool invocation (fake tool call),
_prepare_chat_payload deepcopies contexts (with pairs at tail) then
appends user_msg, resulting in:
..., assistant(tc), tool, user
LLM sees assistant "predicting" user query. Reorder to:
..., user, assistant(tc), tool
Co-Authored-By: deepseek-v4-flash <deepseek-ai@claude-code-best.win>
16 new tests covering: single pair, multiple pairs, no-op branches (tool_call_id mismatch, no tool_calls, no trailing user, empty list), real tool call unaffected, non-contiguous pair stop, and 7 parametrized subclass tests (Groq, LongCat, AIHubMix, OpenRouter, XAI, Xiaomi, Zhipu) confirming the fix applies through inheritance. Co-Authored-By: deepseek-v4-flash <deepseek-ai@claude-code-best.win>
Move the tail reorder logic out of openai_source's static method into a shared module-level function reorder_tailing_tool_call_user in astrbot/core/provider/provider.py, and apply it in the Anthropic provider (text_chat / text_chat_stream) before _prepare_payload converts contexts to tool_use / tool_result blocks. This aligns the fake tool call sequence with real tool-call timing (user -> assistant(tool_use) -> user(tool_result)) in both formats. Co-Authored-By: deepseek-v4-flash <deepseek-ai@claude-code-best.win>
Gemini API rejects functionCall turns that do not immediately follow a user turn or a function response turn (400 INVALID_ARGUMENT). The fake tool call pair injected at the contexts tail converts to model(functionCall) -> user([functionResponse, 新问题]), so the functionCall follows a model turn and is rejected outright. Apply the shared reorder_tailing_tool_call_user in text_chat / text_chat_stream before _prepare_conversation, producing the legal and correctly timed user(新问题) -> model(functionCall) -> user(functionResponse). Co-Authored-By: deepseek-v4-flash <deepseek-ai@claude-code-best.win>
Address AI review feedback: - Rewrite reorder_tailing_tool_call_user to scan backwards with indices and rebuild the tail in one shot instead of in-place popping during pattern matching; extract the tool-call pairing check into the _is_valid_tool_pair helper. Behavior is unchanged (verified against the previous implementation). - Tighten the messages parameter type to list[dict[str, Any]]. - Extract the duplicated FAKE_TOOL_CALL_CONTEXTS into tests/fixtures/fake_tool_call.py, shared by the Anthropic and Gemini provider tests. Co-Authored-By: deepseek-v4-flash <deepseek-ai@claude-code-best.win>
4d98cdf to
fd46839
Compare
Motivation / 动机
通过向
req.contexts尾部注入assistant(tool_calls) → tool(result)消息对,可以强制 LLM 认为自己已经调用了某个工具并拿到了结果,从而:这是一种通用手法,已在 LivingMemory 插件中长期记忆注入中使用,未来可能被更多插件用于各种确定性工具调用场景。
但在
OpenAI_Provider._prepare_chat_payload()中,处理顺序是:deepcopy(contexts)—— 此时尾部已含伪造工具调用对append(new_record)—— 追加当前用户消息最终发给 LLM API 的 messages 序列为:
LLM 视角下,assistant 在用户提问之前就"预知"了查询内容。正确顺序应为:
该问题影响所有使用该手法的插件,以及所有走
OpenAI_Provider的 LLM 路径Refs: #9450
Modifications / 改动点
astrbot/core/provider/sources/openai_source.py在
_sanitize_assistant_messages()末尾新增检测与重排逻辑:user消息assistant(tool_calls) + tool成对消息(验证tool_call_id匹配)user → assistant₁, tool₁ → ... → assistant_N, tool_N支持多插件各自注入多轮伪造对的场景。
req对象,不破坏其他on_llm_requesthandler_query/_query_streaming全部路径tool后不会直接跟user,不会误杀此为轻量妥协修复。未来若实现专用的上下文操作钩子或伪造工具调用钩子,可考虑移除此方法。
Test Results / 测试结果
逻辑验证(手动):
..., asst(tc), tool, user→..., user, asst(tc), tool..., asst₁, tool₁, asst₂, tool₂, user→..., user, asst₁, tool₁, asst₂, tool₂..., asst(tc), tool, asst(content), user→ 不变,不误杀Checklist / 检查清单
Summary by Sourcery
Ensure fake tool-call message pairs are reordered so that user messages precede assistant/tool messages across OpenAI-compatible, Anthropic, and Gemini providers.
Bug Fixes:
Tests: