diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py
index 88038473af..387c79c898 100644
--- a/astrbot/core/agent/runners/tool_loop_agent_runner.py
+++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py
@@ -871,6 +871,9 @@ async def step(self):
llm_resp.tools_call_name = requery_resp.tools_call_name
llm_resp.tools_call_args = requery_resp.tools_call_args
llm_resp.tools_call_ids = requery_resp.tools_call_ids
+ llm_resp.tools_call_extra_content = (
+ requery_resp.tools_call_extra_content
+ )
tool_call_result_blocks = []
cached_images = [] # Collect cached images for LLM visibility
@@ -1328,6 +1331,10 @@ async def _resolve_tool_exec(
abort_signal=self._abort_signal,
request_max_retries=self.request_max_retries,
)
+ if requery_resp and requery_resp.usage:
+ self.stats.token_usage += requery_resp.usage
+ if self.req.conversation:
+ self.req.conversation.token_usage += requery_resp.usage.total
if requery_resp:
llm_resp = requery_resp
self._sanitize_malformed_tool_calls(llm_resp)
@@ -1356,6 +1363,10 @@ async def _resolve_tool_exec(
abort_signal=self._abort_signal,
request_max_retries=self.request_max_retries,
)
+ if repair_resp and repair_resp.usage:
+ self.stats.token_usage += repair_resp.usage
+ if self.req.conversation:
+ self.req.conversation.token_usage += repair_resp.usage.total
if repair_resp:
llm_resp = repair_resp
self._sanitize_malformed_tool_calls(llm_resp)
diff --git a/tests/test_tool_loop_agent_runner.py b/tests/test_tool_loop_agent_runner.py
index b4464680fb..c3d91aaac4 100644
--- a/tests/test_tool_loop_agent_runner.py
+++ b/tests/test_tool_loop_agent_runner.py
@@ -1284,8 +1284,8 @@ async def test_follow_up_ticket_not_consumed_when_no_next_tool_call(
@pytest.mark.asyncio
-async def test_skills_like_requery_passes_extra_user_content_parts():
- """skills-like 模式 re-query 时应传递 extra_user_content_parts(如 image_caption)"""
+async def test_skills_like_requery_preserves_request_tool_metadata_and_usage():
+ """skills-like re-query preserves metadata and counts its token usage."""
from astrbot.core.agent.message import TextPart
captured_kwargs = {}
@@ -1301,7 +1301,12 @@ async def text_chat(self, **kwargs) -> LLMResponse:
tools_call_name=["test_tool"],
tools_call_args=[{"query": "test"}],
tools_call_ids=["call_1"],
- usage=TokenUsage(input_other=10, output=5),
+ tools_call_extra_content={
+ "call_1": {
+ "google": {"thought_signature": "selection-signature"}
+ }
+ },
+ usage=TokenUsage(input_other=10, input_cached=2, output=5),
)
if self.call_count == 2:
# 第二次调用:re-query with param schema
@@ -1312,7 +1317,10 @@ async def text_chat(self, **kwargs) -> LLMResponse:
tools_call_name=["test_tool"],
tools_call_args=[{"query": "actual"}],
tools_call_ids=["call_2"],
- usage=TokenUsage(input_other=10, output=5),
+ tools_call_extra_content={
+ "call_2": {"google": {"thought_signature": "requery-signature"}}
+ },
+ usage=TokenUsage(input_other=20, input_cached=3, output=7),
)
# 后续调用:正常回复
return LLMResponse(
@@ -1331,11 +1339,13 @@ async def text_chat(self, **kwargs) -> LLMResponse:
tool_set = ToolSet(tools=[tool])
caption_part = TextPart(text="一张猫的照片")
+ conversation = SimpleNamespace(cid="test-conversation", token_usage=0)
req = ProviderRequest(
prompt="看看这张图",
func_tool=tool_set,
contexts=[],
extra_user_content_parts=[caption_part],
+ conversation=cast(Any, conversation),
)
event = MockEvent(umo="test_umo", sender_id="test_sender")
@@ -1363,6 +1373,145 @@ async def text_chat(self, **kwargs) -> LLMResponse:
assert len(parts) == 1
assert parts[0].text == "一张猫的照片"
+ assistant_tool_message = next(
+ message
+ for message in run_context.messages
+ if message.role == "assistant" and message.tool_calls
+ )
+ assert assistant_tool_message.tool_calls[0].id == "call_2"
+ assert assistant_tool_message.tool_calls[0].extra_content == {
+ "google": {"thought_signature": "requery-signature"}
+ }
+ assert runner.stats.token_usage == TokenUsage(
+ input_other=30,
+ input_cached=5,
+ output=12,
+ )
+ assert conversation.token_usage == 47
+
+
+@pytest.mark.asyncio
+async def test_skills_like_requery_repair_counts_both_extra_requests():
+ """skills-like repair counts both provider requests after tool selection."""
+
+ class RepairProvider(MockProvider):
+ async def text_chat(self, **kwargs) -> LLMResponse:
+ self.call_count += 1
+ if self.call_count == 1:
+ return LLMResponse(
+ role="assistant",
+ tools_call_name=["test_tool"],
+ tools_call_args=[{"query": "test"}],
+ tools_call_ids=["call_1"],
+ usage=TokenUsage(input_other=2, input_cached=3, output=5),
+ )
+ if self.call_count == 2:
+ return LLMResponse(
+ role="assistant",
+ completion_text="",
+ usage=TokenUsage(input_other=7, input_cached=11, output=13),
+ )
+ if self.call_count == 3:
+ return LLMResponse(
+ role="assistant",
+ tools_call_name=["test_tool"],
+ tools_call_args=[{"query": "repaired"}],
+ tools_call_ids=["call_3"],
+ usage=TokenUsage(input_other=17, input_cached=19, output=23),
+ )
+ raise AssertionError("Unexpected provider request")
+
+ provider = RepairProvider()
+ tool = FunctionTool(
+ name="test_tool",
+ description="test",
+ parameters={"type": "object", "properties": {"query": {"type": "string"}}},
+ handler=AsyncMock(),
+ )
+ conversation = SimpleNamespace(cid="test-conversation", token_usage=0)
+ request = ProviderRequest(
+ prompt="run tool",
+ func_tool=ToolSet(tools=[tool]),
+ contexts=[],
+ conversation=cast(Any, conversation),
+ )
+ run_context = ContextWrapper(
+ context=MockAgentContext(MockEvent("test_umo", "test_sender"))
+ )
+ runner = ToolLoopAgentRunner()
+
+ await runner.reset(
+ provider=provider,
+ request=request,
+ run_context=run_context,
+ tool_executor=cast(Any, MockToolExecutor()),
+ agent_hooks=MockHooks(),
+ tool_schema_mode="skills_like",
+ )
+
+ async for _ in runner.step():
+ pass
+
+ assert provider.call_count == 3
+ assert runner.stats.token_usage == TokenUsage(
+ input_other=26,
+ input_cached=33,
+ output=41,
+ )
+ assert conversation.token_usage == 100
+
+
+@pytest.mark.asyncio
+async def test_skills_like_without_requery_does_not_double_count_usage():
+ """A tool selection without a matching schema is counted only once."""
+
+ class UnknownToolProvider(MockProvider):
+ async def text_chat(self, **kwargs) -> LLMResponse:
+ self.call_count += 1
+ return LLMResponse(
+ role="assistant",
+ tools_call_name=["missing_tool"],
+ tools_call_args=[{}],
+ tools_call_ids=["call_missing"],
+ usage=TokenUsage(input_other=29, input_cached=31, output=37),
+ )
+
+ provider = UnknownToolProvider()
+ tool = FunctionTool(
+ name="test_tool",
+ description="test",
+ parameters={"type": "object", "properties": {}},
+ handler=AsyncMock(),
+ )
+ conversation = SimpleNamespace(cid="test-conversation", token_usage=0)
+ request = ProviderRequest(
+ prompt="run tool",
+ func_tool=ToolSet(tools=[tool]),
+ contexts=[],
+ conversation=cast(Any, conversation),
+ )
+ runner = ToolLoopAgentRunner()
+
+ await runner.reset(
+ provider=provider,
+ request=request,
+ run_context=ContextWrapper(context=None),
+ tool_executor=cast(Any, MockToolExecutor()),
+ agent_hooks=MockHooks(),
+ tool_schema_mode="skills_like",
+ )
+
+ async for _ in runner.step():
+ pass
+
+ assert provider.call_count == 1
+ assert runner.stats.token_usage == TokenUsage(
+ input_other=29,
+ input_cached=31,
+ output=37,
+ )
+ assert conversation.token_usage == 97
+
@pytest.mark.asyncio
async def test_follow_up_accepted_when_active_and_not_stopping(