Skip to content

Commit 43913ba

Browse files
Rail1bcdeepseek-v4-flash
andcommitted
fix: consume injected tool call results in tool loop runner
Plugins can inject fake assistant(tool_calls) → tool(result) pairs via req.append_tool_calls_result(); reset() now appends them after the current user message so the block belongs to the current turn, matching how providers assemble the text_chat payload. Export the segment/result types from astrbot.api.provider and make _save_to_history skip any _no_save message regardless of role, so temp-marked fake pairs never persist. Co-Authored-By: deepseek-v4-flash <deepseek-ai@claude-code-best.win>
1 parent 08d0b1f commit 43913ba

7 files changed

Lines changed: 311 additions & 3 deletions

File tree

astrbot/api/provider/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,26 @@
1+
from astrbot.core.agent.message import (
2+
AssistantMessageSegment,
3+
ToolCallMessageSegment,
4+
)
15
from astrbot.core.db.po import Personality
26
from astrbot.core.provider import Provider, STTProvider
37
from astrbot.core.provider.entities import (
48
LLMResponse,
59
ProviderMetaData,
610
ProviderRequest,
711
ProviderType,
12+
ToolCallsResult,
813
)
914

1015
__all__ = [
16+
"AssistantMessageSegment",
1117
"LLMResponse",
1218
"Personality",
1319
"Provider",
1420
"ProviderMetaData",
1521
"ProviderRequest",
1622
"ProviderType",
1723
"STTProvider",
24+
"ToolCallMessageSegment",
25+
"ToolCallsResult",
1826
]

astrbot/core/agent/message.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,15 @@ class Message(BaseModel):
215215
_no_save: bool = PrivateAttr(default=False)
216216
_checkpoint_after: CheckpointData | None = PrivateAttr(default=None)
217217

218+
def mark_as_temp(self) -> "Message":
219+
"""Mark this message as provider-facing only, not persisted.
220+
221+
临时注入(如伪造工具调用对)应成对标记:assistant 与 tool 消息都调用
222+
本方法,避免历史中残留悬空的 tool 消息。
223+
"""
224+
self._no_save = True
225+
return self
226+
218227
@model_validator(mode="after")
219228
def check_content_required(self):
220229
if self.role == CHECKPOINT_ROLE:

astrbot/core/agent/runners/tool_loop_agent_runner.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,20 @@ async def reset(
315315
):
316316
m = await self._assemble_request_context_for_provider(request)
317317
messages.append(Message.model_validate(m))
318+
# Plugin-injected tool call results (on_llm_request → req.append_tool_calls_result)
319+
# follow the current user message so the assistant(tool_calls) → tool(result)
320+
# block belongs to the current turn. Providers consume the same
321+
# ProviderRequest.tool_calls_result in this order (see text_chat payload
322+
# assembly), so binding it here keeps the in-run context identical to what
323+
# a plain provider call would send.
324+
if request.tool_calls_result:
325+
tool_call_results = (
326+
request.tool_calls_result
327+
if isinstance(request.tool_calls_result, list)
328+
else [request.tool_calls_result]
329+
)
330+
for tcr in tool_call_results:
331+
messages.extend(tcr.to_openai_messages_model())
318332
if request.system_prompt:
319333
messages.insert(
320334
0,

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -465,7 +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-
if message.role in ["assistant", "user"] and message._no_save:
468+
# _no_save 语义与角色无关:临时注入的消息(含伪造工具调用对的
469+
# tool 消息)一律不落库,避免历史中残留悬空的 tool 消息。
470+
if message._no_save:
469471
continue
470472
messages_to_save.append(message)
471473

docs/zh/dev/star/plugin.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -561,6 +561,61 @@ async def my_custom_hook_1(self, event: AstrMessageEvent, req: ProviderRequest):
561561

562562
> 这里不能使用 yield 来发送消息。如需发送,请直接使用 `event.send()` 方法。
563563

564+
###### 注入工具调用结果
565+
566+
> 适用于 AstrBot 版本 > v4.27.3
567+
568+
如果希望 LLM 在回答本轮问题时"看到"一次工具调用及其结果(例如插件自行检索了长期记忆后,将结果包装成一次记忆召回的工具调用),不要直接向 `req.contexts` 追加消息,而应通过 `req.append_tool_calls_result()` 注入。runner 会保证最终消息顺序为:
569+
570+
```
571+
history → 当前 user → assistant(tool_calls) → tool(result)
572+
```
573+
574+
```python
575+
from astrbot.api.event import filter, AstrMessageEvent
576+
from astrbot.api.provider import (
577+
ProviderRequest,
578+
ToolCallsResult,
579+
AssistantMessageSegment,
580+
ToolCallMessageSegment,
581+
)
582+
583+
@filter.on_llm_request()
584+
async def inject_memory_recall(self, event: AstrMessageEvent, req: ProviderRequest):
585+
tool_call_id = "fake_recall_memory"
586+
req.append_tool_calls_result(
587+
ToolCallsResult(
588+
tool_calls_info=AssistantMessageSegment(
589+
tool_calls=[
590+
{
591+
"id": tool_call_id,
592+
"type": "function",
593+
"function": {
594+
"name": "recall_long_term_memory",
595+
"arguments": '{"query": "..."}',
596+
},
597+
}
598+
]
599+
),
600+
tool_calls_result=[
601+
ToolCallMessageSegment(
602+
tool_call_id=tool_call_id,
603+
content="<memory json>",
604+
)
605+
],
606+
)
607+
)
608+
```
609+
610+
注入的工具调用结果**默认会随会话历史持久化**。如果只希望参与本轮请求、不落库,请对 assistant 与 tool 消息**成对**调用 `.mark_as_temp()`
611+
612+
```python
613+
tool_calls_info=AssistantMessageSegment(...).mark_as_temp(),
614+
tool_calls_result=[ToolCallMessageSegment(...).mark_as_temp()],
615+
```
616+
617+
> 这里不能使用 yield 来发送消息。如需发送,请直接使用 `event.send()` 方法。
618+
564619
##### LLM 请求完成时
565620

566621
在 LLM 请求完成后,会触发 `on_llm_response` 钩子。

tests/test_conversation_checkpoint.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,65 @@ async def test_terminal_tool_result_persists_history_without_checkpoint():
331331
)
332332

333333

334+
@pytest.mark.asyncio
335+
async def test_temp_injected_tool_pair_not_persisted():
336+
"""临时注入的伪造工具调用对(成对 mark_as_temp)整对不落库,不残留悬空 tool 消息。"""
337+
conversation_manager = AsyncMock()
338+
stage = InternalAgentSubStage()
339+
stage.conv_manager = conversation_manager
340+
event = SimpleNamespace(
341+
unified_msg_origin="webchat:FriendMessage:test",
342+
get_extra=lambda _key: None,
343+
)
344+
fake_info = AssistantMessageSegment(
345+
tool_calls=[
346+
{
347+
"id": "fake_1",
348+
"type": "function",
349+
"function": {"name": "recall", "arguments": "{}"},
350+
}
351+
]
352+
).mark_as_temp()
353+
fake_result = ToolCallMessageSegment(
354+
tool_call_id="fake_1",
355+
content="mem",
356+
).mark_as_temp()
357+
request = ProviderRequest(
358+
conversation=Conversation(
359+
platform_id="webchat",
360+
user_id="webchat:FriendMessage:test",
361+
cid="conversation-1",
362+
),
363+
tool_calls_result=ToolCallsResult(
364+
tool_calls_info=fake_info,
365+
tool_calls_result=[fake_result],
366+
),
367+
)
368+
369+
await stage._save_to_history(
370+
event,
371+
request,
372+
LLMResponse(role="assistant", completion_text="ok"),
373+
[
374+
Message(role="user", content="hello"),
375+
fake_info,
376+
fake_result,
377+
ToolCallMessageSegment(tool_call_id="real_1", content="real result"),
378+
],
379+
runner_stats=None,
380+
)
381+
382+
conversation_manager.update_conversation.assert_awaited_once_with(
383+
"webchat:FriendMessage:test",
384+
"conversation-1",
385+
history=[
386+
{"role": "user", "content": "hello"},
387+
{"role": "tool", "tool_call_id": "real_1", "content": "real result"},
388+
],
389+
token_usage=None,
390+
)
391+
392+
334393
@pytest.mark.asyncio
335394
async def test_terminal_tool_result_with_checkpoint_uses_none_token_usage():
336395
conversation_manager = AsyncMock()

tests/test_tool_loop_agent_runner.py

Lines changed: 163 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,24 @@
1414
from astrbot.core.agent.agent import Agent
1515
from astrbot.core.agent.handoff import HandoffTool
1616
from astrbot.core.agent.hooks import BaseAgentRunHooks
17-
from astrbot.core.agent.message import ImageURLPart, Message, TextPart
17+
from astrbot.core.agent.message import (
18+
AssistantMessageSegment,
19+
ImageURLPart,
20+
Message,
21+
TextPart,
22+
ToolCallMessageSegment,
23+
)
1824
from astrbot.core.agent.run_context import ContextWrapper
1925
from astrbot.core.agent.runners.tool_loop_agent_runner import ToolLoopAgentRunner
2026
from astrbot.core.agent.tool import FunctionTool, ToolSet
2127
from astrbot.core.astr_agent_tool_exec import FunctionToolExecutor
2228
from astrbot.core.exceptions import EmptyModelOutputError
23-
from astrbot.core.provider.entities import LLMResponse, ProviderRequest, TokenUsage
29+
from astrbot.core.provider.entities import (
30+
LLMResponse,
31+
ProviderRequest,
32+
TokenUsage,
33+
ToolCallsResult,
34+
)
2435
from astrbot.core.provider.provider import Provider
2536

2637

@@ -1559,6 +1570,156 @@ async def test_tool_result_injects_follow_up_notice(
15591570
assert ticket2.consumed is True
15601571

15611572

1573+
@pytest.mark.asyncio
1574+
async def test_reset_appends_injected_tool_calls_result_after_user(
1575+
runner, mock_provider, mock_tool_executor, mock_hooks
1576+
):
1577+
"""reset() 把 on_llm_request 注入的 tool_calls_result 追加在当前 user 消息之后。
1578+
1579+
最终顺序:history → 当前 user → assistant(tool_calls) → tool(result)。
1580+
"""
1581+
request = ProviderRequest(
1582+
prompt="当前问题",
1583+
contexts=[
1584+
{"role": "user", "content": "历史消息"},
1585+
{"role": "assistant", "content": "历史回答"},
1586+
],
1587+
tool_calls_result=ToolCallsResult(
1588+
tool_calls_info=AssistantMessageSegment(
1589+
tool_calls=[
1590+
{
1591+
"id": "fake_1",
1592+
"type": "function",
1593+
"function": {
1594+
"name": "recall_long_term_memory",
1595+
"arguments": "{}",
1596+
},
1597+
}
1598+
]
1599+
),
1600+
tool_calls_result=[
1601+
ToolCallMessageSegment(
1602+
tool_call_id="fake_1",
1603+
content="memory json",
1604+
)
1605+
],
1606+
),
1607+
)
1608+
1609+
await runner.reset(
1610+
provider=mock_provider,
1611+
request=request,
1612+
run_context=ContextWrapper(context=None),
1613+
tool_executor=mock_tool_executor,
1614+
agent_hooks=mock_hooks,
1615+
streaming=False,
1616+
)
1617+
1618+
roles = [m.role for m in runner.run_context.messages]
1619+
assert roles == ["user", "assistant", "user", "assistant", "tool"]
1620+
assert runner.run_context.messages[-2].tool_calls[0].id == "fake_1"
1621+
assert runner.run_context.messages[-1].tool_call_id == "fake_1"
1622+
1623+
1624+
@pytest.mark.asyncio
1625+
async def test_runner_with_openai_provider_preserves_injected_tool_calls_order(
1626+
mock_tool_executor, mock_hooks
1627+
):
1628+
"""端到端:runner 消费注入的 tool_calls_result 后,OpenAI payload 顺序保持正确。
1629+
1630+
覆盖 ToolLoopAgentRunner → ProviderOpenAIOfficial.text_chat → _query 的完整
1631+
payload 组装路径(真实 provider,仅对 SDK create 的入口 _query 打桩捕获)。
1632+
顺序:history → 当前 user → assistant(tool_calls) → tool(result)。
1633+
"""
1634+
from astrbot.core.agent.tool import FunctionTool, ToolSet
1635+
from astrbot.core.provider.entities import ToolCallsResult
1636+
from astrbot.core.provider.sources.openai_source import ProviderOpenAIOfficial
1637+
1638+
provider = ProviderOpenAIOfficial(
1639+
provider_config={
1640+
"id": "test-openai",
1641+
"type": "openai_chat_completion",
1642+
"model": "gpt-4o-mini",
1643+
"key": ["test-key"],
1644+
},
1645+
provider_settings={},
1646+
)
1647+
captured: dict[str, list[dict[str, Any]]] = {}
1648+
1649+
async def fake_query(payloads, func_tool, *, request_max_retries=None):
1650+
captured["messages"] = [dict(m) for m in payloads["messages"]]
1651+
return LLMResponse(role="assistant", completion_text="ok")
1652+
1653+
provider._query = fake_query # type: ignore[method-assign]
1654+
1655+
tool_set = ToolSet(
1656+
tools=[
1657+
FunctionTool(
1658+
name="test_tool",
1659+
description="测试工具",
1660+
parameters={
1661+
"type": "object",
1662+
"properties": {"query": {"type": "string"}},
1663+
},
1664+
handler=AsyncMock(),
1665+
)
1666+
]
1667+
)
1668+
request = ProviderRequest(
1669+
prompt="当前问题",
1670+
func_tool=tool_set,
1671+
contexts=[
1672+
{"role": "user", "content": "历史消息"},
1673+
{"role": "assistant", "content": "历史回答"},
1674+
],
1675+
tool_calls_result=ToolCallsResult(
1676+
tool_calls_info=AssistantMessageSegment(
1677+
tool_calls=[
1678+
{
1679+
"id": "fake_1",
1680+
"type": "function",
1681+
"function": {
1682+
"name": "recall_long_term_memory",
1683+
"arguments": "{}",
1684+
},
1685+
}
1686+
]
1687+
),
1688+
tool_calls_result=[
1689+
ToolCallMessageSegment(
1690+
tool_call_id="fake_1",
1691+
content="memory json",
1692+
)
1693+
],
1694+
),
1695+
)
1696+
1697+
runner = ToolLoopAgentRunner()
1698+
try:
1699+
await runner.reset(
1700+
provider=provider,
1701+
request=request,
1702+
run_context=ContextWrapper(context=None),
1703+
tool_executor=mock_tool_executor,
1704+
agent_hooks=mock_hooks,
1705+
streaming=False,
1706+
)
1707+
async for _ in runner.step():
1708+
pass
1709+
finally:
1710+
await provider.terminate()
1711+
1712+
assert [m["role"] for m in captured["messages"]] == [
1713+
"user",
1714+
"assistant",
1715+
"user",
1716+
"assistant",
1717+
"tool",
1718+
]
1719+
assert captured["messages"][-2]["tool_calls"][0]["id"] == "fake_1"
1720+
assert captured["messages"][-1]["tool_call_id"] == "fake_1"
1721+
1722+
15621723
@pytest.mark.asyncio
15631724
async def test_follow_up_ticket_not_consumed_when_no_next_tool_call(
15641725
runner, mock_provider, provider_request, mock_tool_executor, mock_hooks

0 commit comments

Comments
 (0)