Skip to content

Commit 1306655

Browse files
Rail1bcdeepseek-v4-flash
andcommitted
fix: enforce temp-marked tool-call pairs are marked together
Partial mark_as_temp() on an injected pair would persist a dangling assistant(tool_calls) message that providers reject on the next round. Validate _no_save consistency in ToolCallsResult.to_openai_messages_model() so mismatched pairs fail fast at injection time. Also switch Message. mark_as_temp() to a TypeVar return type (matching ContentPart) and move comments to English per AGENTS.md. Co-Authored-By: deepseek-v4-flash <deepseek-ai@claude-code-best.win>
1 parent 43913ba commit 1306655

5 files changed

Lines changed: 143 additions & 9 deletions

File tree

astrbot/core/agent/message.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from pydantic_core import core_schema
1515

1616
ContentPartT = TypeVar("ContentPartT", bound="ContentPart")
17+
MessageT = TypeVar("MessageT", bound="Message")
1718

1819

1920
class ContentPart(BaseModel):
@@ -215,11 +216,15 @@ class Message(BaseModel):
215216
_no_save: bool = PrivateAttr(default=False)
216217
_checkpoint_after: CheckpointData | None = PrivateAttr(default=None)
217218

218-
def mark_as_temp(self) -> "Message":
219+
def mark_as_temp(self: MessageT) -> MessageT:
219220
"""Mark this message as provider-facing only, not persisted.
220221
221-
临时注入(如伪造工具调用对)应成对标记:assistant 与 tool 消息都调用
222-
本方法,避免历史中残留悬空的 tool 消息。
222+
Injected pairs (e.g. fake tool-call results) should mark both the
223+
assistant and tool messages so no dangling tool message remains in
224+
history.
225+
226+
Returns:
227+
Self, for method chaining.
223228
"""
224229
self._no_save = True
225230
return self

astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -465,8 +465,9 @@ async def _save_to_history(
465465
if message.role == "system" and not skipped_initial_system:
466466
skipped_initial_system = True
467467
continue
468-
# _no_save 语义与角色无关:临时注入的消息(含伪造工具调用对的
469-
# tool 消息)一律不落库,避免历史中残留悬空的 tool 消息。
468+
# _no_save is role-agnostic: temp-injected messages (including the
469+
# tool message of a fake tool-call pair) are never persisted, so no
470+
# dangling tool message remains in history.
470471
if message._no_save:
471472
continue
472473
messages_to_save.append(message)

astrbot/core/provider/entities.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,16 @@ def to_openai_messages(self) -> list[dict]:
8181
def to_openai_messages_model(
8282
self,
8383
) -> list[AssistantMessageSegment | ToolCallMessageSegment]:
84+
# A tool-call pair must be marked temp together (or not at all): a
85+
# partial mark would persist a dangling assistant(tool_calls) that
86+
# providers reject on the next round.
87+
info_is_temp = self.tool_calls_info._no_save
88+
if any(message._no_save != info_is_temp for message in self.tool_calls_result):
89+
raise ValueError(
90+
"ToolCallsResult pair must be marked temp together: call "
91+
"mark_as_temp() on both tool_calls_info and every message in "
92+
"tool_calls_result, or on neither."
93+
)
8494
return [
8595
self.tool_calls_info,
8696
*self.tool_calls_result,

docs/zh/dev/star/plugin.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -614,6 +614,10 @@ tool_calls_info=AssistantMessageSegment(...).mark_as_temp(),
614614
tool_calls_result=[ToolCallMessageSegment(...).mark_as_temp()],
615615
```
616616

617+
> **注意**`.mark_as_temp()` 必须成对使用。只标记其中一方会导致历史中残留悬空的
618+
> `assistant(tool_calls)``tool` 消息,下一轮 provider API 会拒绝该请求。
619+
> 若标记不一致,runner 在组装上下文时会抛出 `ValueError` 提醒。
620+
617621
> 这里不能使用 yield 来发送消息。如需发送,请直接使用 `event.send()` 方法。
618622
619623
##### LLM 请求完成时

tests/test_tool_loop_agent_runner.py

Lines changed: 118 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1596,12 +1596,12 @@ async def test_reset_appends_injected_tool_calls_result_after_user(
15961596
},
15971597
}
15981598
]
1599-
),
1599+
).mark_as_temp(),
16001600
tool_calls_result=[
16011601
ToolCallMessageSegment(
16021602
tool_call_id="fake_1",
16031603
content="memory json",
1604-
)
1604+
).mark_as_temp()
16051605
],
16061606
),
16071607
)
@@ -1619,6 +1619,122 @@ async def test_reset_appends_injected_tool_calls_result_after_user(
16191619
assert roles == ["user", "assistant", "user", "assistant", "tool"]
16201620
assert runner.run_context.messages[-2].tool_calls[0].id == "fake_1"
16211621
assert runner.run_context.messages[-1].tool_call_id == "fake_1"
1622+
# _no_save 标记经 reset() 追加后保留,持久化时才会被过滤。
1623+
assert runner.run_context.messages[-2]._no_save is True
1624+
assert runner.run_context.messages[-1]._no_save is True
1625+
1626+
1627+
@pytest.mark.asyncio
1628+
async def test_reset_appends_multiple_injected_tool_calls_results_in_order(
1629+
runner, mock_provider, mock_tool_executor, mock_hooks
1630+
):
1631+
"""list 形式的多个 ToolCallsResult 按列表顺序追加在 user 消息之后。"""
1632+
request = ProviderRequest(
1633+
prompt="当前问题",
1634+
contexts=[
1635+
{"role": "user", "content": "历史消息"},
1636+
{"role": "assistant", "content": "历史回答"},
1637+
],
1638+
tool_calls_result=[
1639+
ToolCallsResult(
1640+
tool_calls_info=AssistantMessageSegment(
1641+
tool_calls=[
1642+
{
1643+
"id": "fake_1",
1644+
"type": "function",
1645+
"function": {
1646+
"name": "recall_long_term_memory",
1647+
"arguments": "{}",
1648+
},
1649+
}
1650+
]
1651+
),
1652+
tool_calls_result=[
1653+
ToolCallMessageSegment(
1654+
tool_call_id="fake_1",
1655+
content="memory json 1",
1656+
)
1657+
],
1658+
),
1659+
ToolCallsResult(
1660+
tool_calls_info=AssistantMessageSegment(
1661+
tool_calls=[
1662+
{
1663+
"id": "fake_2",
1664+
"type": "function",
1665+
"function": {
1666+
"name": "recall_short_term_memory",
1667+
"arguments": "{}",
1668+
},
1669+
}
1670+
]
1671+
),
1672+
tool_calls_result=[
1673+
ToolCallMessageSegment(
1674+
tool_call_id="fake_2",
1675+
content="memory json 2",
1676+
)
1677+
],
1678+
),
1679+
],
1680+
)
1681+
1682+
await runner.reset(
1683+
provider=mock_provider,
1684+
request=request,
1685+
run_context=ContextWrapper(context=None),
1686+
tool_executor=mock_tool_executor,
1687+
agent_hooks=mock_hooks,
1688+
streaming=False,
1689+
)
1690+
1691+
roles = [m.role for m in runner.run_context.messages]
1692+
assert roles == [
1693+
"user",
1694+
"assistant",
1695+
"user",
1696+
"assistant",
1697+
"tool",
1698+
"assistant",
1699+
"tool",
1700+
]
1701+
tool_calls = [
1702+
m.tool_calls[0].id
1703+
for m in runner.run_context.messages
1704+
if m.role == "assistant" and m.tool_calls
1705+
]
1706+
assert tool_calls == ["fake_1", "fake_2"]
1707+
tool_results = [
1708+
m.tool_call_id for m in runner.run_context.messages if m.role == "tool"
1709+
]
1710+
assert tool_results == ["fake_1", "fake_2"]
1711+
1712+
1713+
def test_injected_tool_calls_result_partial_temp_mark_raises():
1714+
"""只标记对中一方时抛 ValueError,防止悬空 assistant(tool_calls) 落库。"""
1715+
partial = ToolCallsResult(
1716+
tool_calls_info=AssistantMessageSegment(
1717+
tool_calls=[
1718+
{
1719+
"id": "fake_1",
1720+
"type": "function",
1721+
"function": {
1722+
"name": "recall",
1723+
"arguments": "{}",
1724+
},
1725+
}
1726+
]
1727+
).mark_as_temp(),
1728+
tool_calls_result=[
1729+
ToolCallMessageSegment(
1730+
tool_call_id="fake_1",
1731+
content="memory json",
1732+
)
1733+
],
1734+
)
1735+
1736+
with pytest.raises(ValueError, match="marked temp together"):
1737+
partial.to_openai_messages_model()
16221738

16231739

16241740
@pytest.mark.asyncio
@@ -1631,8 +1747,6 @@ async def test_runner_with_openai_provider_preserves_injected_tool_calls_order(
16311747
payload 组装路径(真实 provider,仅对 SDK create 的入口 _query 打桩捕获)。
16321748
顺序:history → 当前 user → assistant(tool_calls) → tool(result)。
16331749
"""
1634-
from astrbot.core.agent.tool import FunctionTool, ToolSet
1635-
from astrbot.core.provider.entities import ToolCallsResult
16361750
from astrbot.core.provider.sources.openai_source import ProviderOpenAIOfficial
16371751

16381752
provider = ProviderOpenAIOfficial(

0 commit comments

Comments
 (0)