Skip to content

fix: reorder fake tool call messages at tail to correct timing - #9451

Open
Rail1bc wants to merge 5 commits into
AstrBotDevs:masterfrom
Rail1bc:fix/fake-tool-call-reorder
Open

fix: reorder fake tool call messages at tail to correct timing#9451
Rail1bc wants to merge 5 commits into
AstrBotDevs:masterfrom
Rail1bc:fix/fake-tool-call-reorder

Conversation

@Rail1bc

@Rail1bc Rail1bc commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Motivation / 动机

通过向 req.contexts 尾部注入 assistant(tool_calls) → tool(result) 消息对,可以强制 LLM 认为自己已经调用了某个工具并拿到了结果,从而:

  • 跳过 LLM 自身的工具调用决策,节省 token
  • 降低延迟(不需要等 LLM 生成工具调用)
  • 增加确定性(固定或规则触发,不受模型输出波动影响,增强确定性)

这是一种通用手法,已在 LivingMemory 插件中长期记忆注入中使用,未来可能被更多插件用于各种确定性工具调用场景。

但在 OpenAI_Provider._prepare_chat_payload() 中,处理顺序是:

  1. deepcopy(contexts) —— 此时尾部已含伪造工具调用对
  2. append(new_record) —— 追加当前用户消息

最终发给 LLM API 的 messages 序列为:

... assistant(tool_calls=...), tool(result), user("...")

LLM 视角下,assistant 在用户提问之前就"预知"了查询内容。正确顺序应为:

... user("..."), assistant(tool_calls=...), tool(result)

该问题影响所有使用该手法的插件,以及所有走 OpenAI_Provider 的 LLM 路径

Refs: #9450

Modifications / 改动点

astrbot/core/provider/sources/openai_source.py

_sanitize_assistant_messages() 末尾新增检测与重排逻辑:

  1. 从尾部弹出 user 消息
  2. 用 while 循环收集所有 assistant(tool_calls) + tool 成对消息(验证 tool_call_id 匹配)
  3. 重排为 user → assistant₁, tool₁ → ... → assistant_N, tool_N

支持多插件各自注入多轮伪造对的场景。

  • 不依赖具体插件实现,通用模式检测
  • 不改动 req 对象,不破坏其他 on_llm_request handler
  • 覆盖 _query / _query_streaming 全部路径
  • 真实工具调用场景下 tool 后不会直接跟 user,不会误杀

此为轻量妥协修复。未来若实现专用的上下文操作钩子或伪造工具调用钩子,可考虑移除此方法。

  • This is NOT a breaking change. / 这不是一个破坏性变更。

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 / 检查清单

  • 😊 新功能已通过 Issue 与作者讨论
  • 👀 我的更改经过了充分测试,测试结果已在上方提供
  • 🤓 无新依赖引入
  • 😮 无恶意代码

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:

  • Correct the ordering of injected fake assistant(tool_calls) and tool messages so they follow the triggering user message instead of preceding it, preventing inconsistent tool-calling timelines and API errors.

Tests:

  • Add comprehensive unit tests for the reorder_tailing_tool_call_user helper covering single/multiple pairs, no-op scenarios, and interaction with real tool calls.
  • Verify that all OpenAI-compatible, Anthropic, and Gemini providers apply the reordering logic in both non-streaming and streaming text_chat paths.

@dosubot dosubot Bot added size:M This PR changes 30-99 lines, ignoring generated files. area:provider The bug / feature is about AI Provider, Models, LLM Agent, LLM Agent Runner. labels Jul 30, 2026
@dosubot

dosubot Bot commented Jul 30, 2026

Copy link
Copy Markdown

📄 Knowledge review

Dosu skipped reviewing this PR because your organization has used its 200 included credits for the month. Your usage will reset on 2026-08-01. To have Dosu review this PR before then, ask your organization admin to upgrade to a pro account.


Leave Feedback Ask Dosu about AstrBot Add Dosu to your team

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread astrbot/core/provider/sources/openai_source.py Outdated

@xiaoyuyu6420 xiaoyuyu6420 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. Validates tool_call_id matching — the code checks tool_msg.get("tool_call_id") not in tc_ids before treating a pair as a tool-call pair. This prevents misidentifying unrelated assistant → tool sequences.
  2. Reverses pairs before re-insertion — correctly handles the LIFO collection order.
  3. Handles the no-op case — when no fake pairs are found, the user message 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.

@Rail1bc
Rail1bc marked this pull request as draft July 31, 2026 03:52
@Rail1bc

Rail1bc commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

修复伪造工具调用(fake tool call)消息对在 OpenAI / Anthropic / Gemini 格式 provider 中的时序错乱问题。

修复从openai扩展到OpenAI / Anthropic / Gemini 格式
覆盖了测试

Modifications / 改动点

  • astrbot/core/provider/provider.py 新增共享函数 reorder_tailing_tool_call_user(messages: list):从尾部弹出 user 消息,持续收集 assistant(tool_calls) + tool 成对消息(校验 tool_call_id 匹配),重排为 user → assistant → tool

  • astrbot/core/provider/__init__.py 中 re-export 该函数。

  • openai_source.py:删除原 static method _reorder_tailing_tool_call_user,改用共享函数(_query_query_streaming 两条路径均覆盖)。

  • anthropic_source.py:在 text_chat / text_chat_stream 调用 _prepare_payload 之前,对 OpenAI-format 的 context_query 应用同一重排函数。

  • gemini_source.py:在 text_chat / text_chat_stream 组装 context_query 之后(_prepare_conversation 转换之前)应用同一重排函数。Gemini 的 functionCall 回合位置约束使其在重排前直接报 400,修复后合法且时序正确。

  • 测试:tests/test_openai_source.py 更新为重排函数的直接调用(16 个用例);tests/test_anthropic_kimi_code_provider.py 新增 2 个用例验证 Anthropic 格式转换后的时序;tests/test_gemini_source.py 新增 2 个用例验证 Gemini 的 messages 重排与转换后 user → model(functionCall) → user(functionResponse) 结构。

  • This is NOT a breaking change. / 这不是一个破坏性变更。

Screenshots or Test Results / 运行截图或测试结果

$ python -m pytest tests/test_openai_source.py -k reorder -q
16 passed

$ python -m pytest tests/test_anthropic_kimi_code_provider.py -q
29 passed

$ python -m pytest tests/test_gemini_source.py -q
5 passed

$ python -m ruff check astrbot/core/provider/
All checks passed!

@Rail1bc
Rail1bc marked this pull request as ready for review July 31, 2026 04:20
@Rail1bc

Rail1bc commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

@sourcery-ai review

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've reviewed your changes and they look great!


Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 issue, and left some high level feedback:

  • 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.
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>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread astrbot/core/provider/provider.py Outdated
@Rail1bc
Rail1bc requested a review from xiaoyuyu6420 July 31, 2026 04:34
Rail1bc and others added 5 commits July 31, 2026 12:43
…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>
@Rail1bc
Rail1bc force-pushed the fix/fake-tool-call-reorder branch from 4d98cdf to fd46839 Compare July 31, 2026 04:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:provider The bug / feature is about AI Provider, Models, LLM Agent, LLM Agent Runner. size:M This PR changes 30-99 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants