Skip to content

Commit 1879e59

Browse files
tsubasakongSoulter
andauthored
fix: keep all CallToolResult content items (#6149)
Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
1 parent b2d71e2 commit 1879e59

File tree

2 files changed

+135
-51
lines changed

2 files changed

+135
-51
lines changed

astrbot/core/agent/runners/tool_loop_agent_runner.py

Lines changed: 52 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -741,69 +741,70 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None:
741741
if isinstance(resp, CallToolResult):
742742
res = resp
743743
_final_resp = resp
744-
if isinstance(res.content[0], TextContent):
744+
if not res.content:
745745
_append_tool_call_result(
746746
func_tool_id,
747-
res.content[0].text,
747+
"The tool returned no content.",
748748
)
749-
elif isinstance(res.content[0], ImageContent):
750-
# Cache the image instead of sending directly
751-
cached_img = tool_image_cache.save_image(
752-
base64_data=res.content[0].data,
753-
tool_call_id=func_tool_id,
754-
tool_name=func_tool_name,
755-
index=0,
756-
mime_type=res.content[0].mimeType or "image/png",
757-
)
758-
_append_tool_call_result(
759-
func_tool_id,
760-
(
761-
f"Image returned and cached at path='{cached_img.file_path}'. "
762-
f"Review the image below. Use send_message_to_user to send it to the user if satisfied, "
763-
f"with type='image' and path='{cached_img.file_path}'."
764-
),
765-
)
766-
# Yield image info for LLM visibility (will be handled in step())
767-
yield _HandleFunctionToolsResult.from_cached_image(
768-
cached_img
769-
)
770-
elif isinstance(res.content[0], EmbeddedResource):
771-
resource = res.content[0].resource
772-
if isinstance(resource, TextResourceContents):
773-
_append_tool_call_result(
774-
func_tool_id,
775-
resource.text,
776-
)
777-
elif (
778-
isinstance(resource, BlobResourceContents)
779-
and resource.mimeType
780-
and resource.mimeType.startswith("image/")
781-
):
749+
continue
750+
751+
result_parts: list[str] = []
752+
for index, content_item in enumerate(res.content):
753+
if isinstance(content_item, TextContent):
754+
result_parts.append(content_item.text)
755+
elif isinstance(content_item, ImageContent):
782756
# Cache the image instead of sending directly
783757
cached_img = tool_image_cache.save_image(
784-
base64_data=resource.blob,
758+
base64_data=content_item.data,
785759
tool_call_id=func_tool_id,
786760
tool_name=func_tool_name,
787-
index=0,
788-
mime_type=resource.mimeType,
761+
index=index,
762+
mime_type=content_item.mimeType or "image/png",
789763
)
790-
_append_tool_call_result(
791-
func_tool_id,
792-
(
793-
f"Image returned and cached at path='{cached_img.file_path}'. "
794-
f"Review the image below. Use send_message_to_user to send it to the user if satisfied, "
795-
f"with type='image' and path='{cached_img.file_path}'."
796-
),
764+
result_parts.append(
765+
f"Image returned and cached at path='{cached_img.file_path}'. "
766+
f"Review the image below. Use send_message_to_user to send it to the user if satisfied, "
767+
f"with type='image' and path='{cached_img.file_path}'."
797768
)
798-
# Yield image info for LLM visibility
769+
# Yield image info for LLM visibility (will be handled in step())
799770
yield _HandleFunctionToolsResult.from_cached_image(
800771
cached_img
801772
)
802-
else:
803-
_append_tool_call_result(
804-
func_tool_id,
805-
"The tool has returned a data type that is not supported.",
806-
)
773+
elif isinstance(content_item, EmbeddedResource):
774+
resource = content_item.resource
775+
if isinstance(resource, TextResourceContents):
776+
result_parts.append(resource.text)
777+
elif (
778+
isinstance(resource, BlobResourceContents)
779+
and resource.mimeType
780+
and resource.mimeType.startswith("image/")
781+
):
782+
# Cache the image instead of sending directly
783+
cached_img = tool_image_cache.save_image(
784+
base64_data=resource.blob,
785+
tool_call_id=func_tool_id,
786+
tool_name=func_tool_name,
787+
index=index,
788+
mime_type=resource.mimeType,
789+
)
790+
result_parts.append(
791+
f"Image returned and cached at path='{cached_img.file_path}'. "
792+
f"Review the image below. Use send_message_to_user to send it to the user if satisfied, "
793+
f"with type='image' and path='{cached_img.file_path}'."
794+
)
795+
# Yield image info for LLM visibility
796+
yield _HandleFunctionToolsResult.from_cached_image(
797+
cached_img
798+
)
799+
else:
800+
result_parts.append(
801+
"The tool has returned a data type that is not supported."
802+
)
803+
if result_parts:
804+
_append_tool_call_result(
805+
func_tool_id,
806+
"\n\n".join(result_parts),
807+
)
807808

808809
elif resp is None:
809810
# Tool 直接请求发送消息给用户

tests/test_tool_loop_agent_runner.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,29 @@ async def generator():
9696
return generator()
9797

9898

99+
class MockMixedContentToolExecutor:
100+
"""模拟返回图片 + 文本的工具执行器"""
101+
102+
@classmethod
103+
def execute(cls, tool, run_context, **tool_args):
104+
async def generator():
105+
from mcp.types import CallToolResult, ImageContent, TextContent
106+
107+
result = CallToolResult(
108+
content=[
109+
ImageContent(
110+
type="image",
111+
data="dGVzdA==",
112+
mimeType="image/png",
113+
),
114+
TextContent(type="text", text="直播间标题:新游首发:零~红蝶~"),
115+
]
116+
)
117+
yield result
118+
119+
return generator()
120+
121+
99122
class MockFailingProvider(MockProvider):
100123
async def text_chat(self, **kwargs) -> LLMResponse:
101124
self.call_count += 1
@@ -438,6 +461,66 @@ async def test_hooks_called_with_max_step(
438461
assert mock_hooks.tool_end_called, "on_tool_end应该被调用"
439462

440463

464+
@pytest.mark.asyncio
465+
async def test_tool_result_includes_all_calltoolresult_content(
466+
runner, mock_provider, provider_request, mock_hooks, monkeypatch
467+
):
468+
"""工具返回多个 content 项时,tool result 应包含全部内容。"""
469+
470+
from astrbot.core.agent.tool_image_cache import tool_image_cache
471+
472+
mock_provider.should_call_tools = True
473+
mock_provider.max_calls_before_normal_response = 1
474+
475+
saved_images = []
476+
477+
def fake_save_image(
478+
base64_data, tool_call_id, tool_name, index=0, mime_type="image/png"
479+
):
480+
saved_images.append(
481+
{
482+
"base64_data": base64_data,
483+
"tool_call_id": tool_call_id,
484+
"tool_name": tool_name,
485+
"index": index,
486+
"mime_type": mime_type,
487+
}
488+
)
489+
return SimpleNamespace(file_path=f"/tmp/{tool_call_id}_{index}.png")
490+
491+
monkeypatch.setattr(tool_image_cache, "save_image", fake_save_image)
492+
493+
await runner.reset(
494+
provider=mock_provider,
495+
request=provider_request,
496+
run_context=ContextWrapper(context=None),
497+
tool_executor=MockMixedContentToolExecutor,
498+
agent_hooks=mock_hooks,
499+
streaming=False,
500+
)
501+
502+
async for _ in runner.step_until_done(3):
503+
pass
504+
505+
tool_messages = [
506+
m for m in runner.run_context.messages if getattr(m, "role", None) == "tool"
507+
]
508+
assert len(tool_messages) == 1
509+
510+
content = str(tool_messages[0].content)
511+
assert "Image returned and cached at path='/tmp/call_123_0.png'." in content
512+
assert "直播间标题:新游首发:零~红蝶~" in content
513+
assert saved_images == [
514+
{
515+
"base64_data": "dGVzdA==",
516+
"tool_call_id": "call_123",
517+
"tool_name": "test_tool",
518+
"index": 0,
519+
"mime_type": "image/png",
520+
}
521+
]
522+
523+
441524
@pytest.mark.asyncio
442525
async def test_fallback_provider_used_when_primary_raises(
443526
runner, provider_request, mock_tool_executor, mock_hooks

0 commit comments

Comments
 (0)