Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 52 additions & 51 deletions astrbot/core/agent/runners/tool_loop_agent_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -758,69 +758,70 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None:
if isinstance(resp, CallToolResult):
res = resp
_final_resp = resp
if isinstance(res.content[0], TextContent):
if not res.content:
_append_tool_call_result(
func_tool_id,
res.content[0].text,
"The tool returned no content.",
)
elif isinstance(res.content[0], ImageContent):
# Cache the image instead of sending directly
cached_img = tool_image_cache.save_image(
base64_data=res.content[0].data,
tool_call_id=func_tool_id,
tool_name=func_tool_name,
index=0,
mime_type=res.content[0].mimeType or "image/png",
)
_append_tool_call_result(
func_tool_id,
(
f"Image returned and cached at path='{cached_img.file_path}'. "
f"Review the image below. Use send_message_to_user to send it to the user if satisfied, "
f"with type='image' and path='{cached_img.file_path}'."
),
)
# Yield image info for LLM visibility (will be handled in step())
yield _HandleFunctionToolsResult.from_cached_image(
cached_img
)
elif isinstance(res.content[0], EmbeddedResource):
resource = res.content[0].resource
if isinstance(resource, TextResourceContents):
_append_tool_call_result(
func_tool_id,
resource.text,
)
elif (
isinstance(resource, BlobResourceContents)
and resource.mimeType
and resource.mimeType.startswith("image/")
):
continue

result_parts: list[str] = []
for index, content_item in enumerate(res.content):
if isinstance(content_item, TextContent):
result_parts.append(content_item.text)
elif isinstance(content_item, ImageContent):
# Cache the image instead of sending directly
cached_img = tool_image_cache.save_image(
base64_data=resource.blob,
base64_data=content_item.data,
tool_call_id=func_tool_id,
tool_name=func_tool_name,
index=0,
mime_type=resource.mimeType,
index=index,
mime_type=content_item.mimeType or "image/png",
)
_append_tool_call_result(
func_tool_id,
(
f"Image returned and cached at path='{cached_img.file_path}'. "
f"Review the image below. Use send_message_to_user to send it to the user if satisfied, "
f"with type='image' and path='{cached_img.file_path}'."
),
result_parts.append(
f"Image returned and cached at path='{cached_img.file_path}'. "
f"Review the image below. Use send_message_to_user to send it to the user if satisfied, "
f"with type='image' and path='{cached_img.file_path}'."
)
# Yield image info for LLM visibility
# Yield image info for LLM visibility (will be handled in step())
yield _HandleFunctionToolsResult.from_cached_image(
cached_img
)
else:
_append_tool_call_result(
func_tool_id,
"The tool has returned a data type that is not supported.",
)
elif isinstance(content_item, EmbeddedResource):
resource = content_item.resource
if isinstance(resource, TextResourceContents):
result_parts.append(resource.text)
elif (
isinstance(resource, BlobResourceContents)
and resource.mimeType
and resource.mimeType.startswith("image/")
):
# Cache the image instead of sending directly
cached_img = tool_image_cache.save_image(
base64_data=resource.blob,
tool_call_id=func_tool_id,
tool_name=func_tool_name,
index=index,
mime_type=resource.mimeType,
)
result_parts.append(
f"Image returned and cached at path='{cached_img.file_path}'. "
f"Review the image below. Use send_message_to_user to send it to the user if satisfied, "
f"with type='image' and path='{cached_img.file_path}'."
)
# Yield image info for LLM visibility
yield _HandleFunctionToolsResult.from_cached_image(
cached_img
)
else:
result_parts.append(
"The tool has returned a data type that is not supported."
)
if result_parts:
_append_tool_call_result(
func_tool_id,
"\n\n".join(result_parts),
)

elif resp is None:
# Tool 直接请求发送消息给用户
Expand Down
84 changes: 84 additions & 0 deletions tests/test_tool_loop_agent_runner.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import os
import sys
from types import SimpleNamespace
from unittest.mock import AsyncMock

import pytest
Expand Down Expand Up @@ -90,6 +91,29 @@ async def generator():
return generator()


class MockMixedContentToolExecutor:
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.

suggestion (testing): 考虑添加一个混合内容的 mock,以同时覆盖 EmbeddedResource 分支

MockMixedContentToolExecutor 目前很好地覆盖了 ImageContent + TextContent 路径,但实现中对 EmbeddedResourceTextResourceContents 和类图片的 BlobResourceContents)也有专门的处理。请扩展这个 mock(或新增一个),使其同时返回文本和图片类型的 EmbeddedResource,从而在测试中覆盖这些分支,减少资源处理相关回归的可能性。

建议实现如下:

class MockMixedContentToolExecutor:
    """模拟返回图片 + 文本的工具执行器
    同时覆盖 EmbeddedResource 文本与图片分支,以便测试资源处理逻辑。
    """

    @classmethod
    def execute(cls, tool, run_context, **tool_args):
        async def generator():
            # 内联文本内容
            inline_text = SimpleNamespace(
                type="text",
                text="mock inline text from mixed-content tool",
            )

            # 内联图片内容(保持与现有测试中图片内容约定一致,如需可调整字段名)
            inline_image = SimpleNamespace(
                type="image",
                image_url="mock://inline-image",
            )

            # 模拟 TextResourceContents 对应的 EmbeddedResource
            text_embedded_resource = SimpleNamespace(
                kind="text",
                uri="mock://embedded-text-resource",
                mime_type="text/plain",
                text="mock embedded text resource",
            )

            text_embedded_content = SimpleNamespace(
                type="embedded_resource",
                resource=text_embedded_resource,
            )

            # 模拟 BlobResourceContents(图片类)对应的 EmbeddedResource
            image_embedded_resource = SimpleNamespace(
                kind="image",
                uri="mock://embedded-image-resource",
                mime_type="image/png",
                data=b"\x89PNG\r\n\x1a\n",  # 仅作占位,不必是真实图片
            )

            image_embedded_content = SimpleNamespace(
                type="embedded_resource",
                resource=image_embedded_resource,
            )

            tool_name = getattr(tool, "name", "mock_mixed_content_tool")

            # 保持与其他 mock executor 一致:返回一次性流结果,
            # 其中包含文本、图片以及两种 EmbeddedResource
            yield [
                SimpleNamespace(
                    role="tool",
                    name=tool_name,
                    content=[
                        inline_text,
                        inline_image,
                        text_embedded_content,
                        image_embedded_content,
                    ],
                )
            ]

        return generator()
  1. 如果生产代码中 EmbeddedResource / TextResourceContents / BlobResourceContents 有专门的类或字段名(例如 EmbeddedResource, TextResourceContent, BlobResourceContent 等),请将上面使用的 kind, uri, mime_type, text, data、以及 type="embedded_resource" 等字段替换为项目中实际使用的类型和字段。
  2. 确认其他 mock 执行器在测试中的返回结构(例如是否直接返回 SimpleNamespace(role=..., content=[...])、或封装在更高层对象中),如果有差异,请将 yield [...] 部分调整为与现有 mock 的结构完全一致,以确保新的 EmbeddedResource 分支被现有测试路径正确消费。
Original comment in English

suggestion (testing): Consider adding a mixed content mock that also exercises EmbeddedResource branches

MockMixedContentToolExecutor usefully covers the ImageContent + TextContent path, but the implementation also has specific handling for EmbeddedResource (TextResourceContents and image-like BlobResourceContents). Please extend this mock (or add another) to return both text and image EmbeddedResource values so those branches are exercised by tests and resource-handling regressions are less likely.

Suggested implementation:

class MockMixedContentToolExecutor:
    """模拟返回图片 + 文本的工具执行器
    同时覆盖 EmbeddedResource 文本与图片分支,以便测试资源处理逻辑。
    """

    @classmethod
    def execute(cls, tool, run_context, **tool_args):
        async def generator():
            # 内联文本内容
            inline_text = SimpleNamespace(
                type="text",
                text="mock inline text from mixed-content tool",
            )

            # 内联图片内容(保持与现有测试中图片内容约定一致,如需可调整字段名)
            inline_image = SimpleNamespace(
                type="image",
                image_url="mock://inline-image",
            )

            # 模拟 TextResourceContents 对应的 EmbeddedResource
            text_embedded_resource = SimpleNamespace(
                kind="text",
                uri="mock://embedded-text-resource",
                mime_type="text/plain",
                text="mock embedded text resource",
            )

            text_embedded_content = SimpleNamespace(
                type="embedded_resource",
                resource=text_embedded_resource,
            )

            # 模拟 BlobResourceContents(图片类)对应的 EmbeddedResource
            image_embedded_resource = SimpleNamespace(
                kind="image",
                uri="mock://embedded-image-resource",
                mime_type="image/png",
                data=b"\x89PNG\r\n\x1a\n",  # 仅作占位,不必是真实图片
            )

            image_embedded_content = SimpleNamespace(
                type="embedded_resource",
                resource=image_embedded_resource,
            )

            tool_name = getattr(tool, "name", "mock_mixed_content_tool")

            # 保持与其他 mock executor 一致:返回一次性流结果,
            # 其中包含文本、图片以及两种 EmbeddedResource
            yield [
                SimpleNamespace(
                    role="tool",
                    name=tool_name,
                    content=[
                        inline_text,
                        inline_image,
                        text_embedded_content,
                        image_embedded_content,
                    ],
                )
            ]

        return generator()
  1. 如果生产代码中 EmbeddedResource / TextResourceContents / BlobResourceContents 有专门的类或字段名(例如 EmbeddedResource, TextResourceContent, BlobResourceContent 等),请将上面使用的 kind, uri, mime_type, text, data、以及 type="embedded_resource" 等字段替换为项目中实际使用的类型和字段。
  2. 确认其他 mock 执行器在测试中的返回结构(例如是否直接返回 SimpleNamespace(role=..., content=[...])、或封装在更高层对象中),如果有差异,请将 yield [...] 部分调整为与现有 mock 的结构完全一致,以确保新的 EmbeddedResource 分支被现有测试路径正确消费。

"""模拟返回图片 + 文本的工具执行器"""

@classmethod
def execute(cls, tool, run_context, **tool_args):
async def generator():
from mcp.types import CallToolResult, ImageContent, TextContent

result = CallToolResult(
content=[
ImageContent(
type="image",
data="dGVzdA==",
mimeType="image/png",
),
TextContent(type="text", text="直播间标题:新游首发:零~红蝶~"),
]
)
yield result

return generator()


class MockFailingProvider(MockProvider):
async def text_chat(self, **kwargs) -> LLMResponse:
self.call_count += 1
Expand Down Expand Up @@ -372,6 +396,66 @@ async def test_hooks_called_with_max_step(
assert mock_hooks.tool_end_called, "on_tool_end应该被调用"


@pytest.mark.asyncio
async def test_tool_result_includes_all_calltoolresult_content(
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.

suggestion (testing): 增加一个该测试的变体,在 CallToolResult.content 为空时,验证“无内容”分支

由于现在存在一个 if not res.content: 分支会追加 "The tool returned no content." 并提前返回,请新增一个测试,让 mock 工具返回 CallToolResult(content=[])(或在合法的情况下返回 content=None)。该测试应验证:的确走到了这个分支,并且不会调用 save_image

建议实现如下:

    async def text_chat(self, **kwargs) -> LLMResponse:
        self.call_count += 1
    assert mock_hooks.tool_end_called, "on_tool_end应该被调用"


@pytest.mark.asyncio
async def test_tool_result_handles_empty_calltoolresult_content(
    runner, mock_provider, provider_request, mock_hooks, monkeypatch
):
    """当工具返回空 content 时,应走 'no content' 分支且不保存图片。"""

    from astrbot.core.agent.tool_image_cache import tool_image_cache
    from astrbot.core.provider import CallToolResult

    mock_provider.should_call_tools = True
    mock_provider.max_calls_before_normal_response = 1

    saved_images = []

    def fake_save_image(*args, **kwargs):
        saved_images.append((args, kwargs))

    # monkeypatch 掉图片保存函数,方便断言不会被调用
    monkeypatch.setattr(tool_image_cache, "save_image", fake_save_image)

    # 让 mock 工具返回空的 CallToolResult.content,以覆盖 `if not res.content` 分支
    async def fake_tool_callable(*args, **kwargs):
        return CallToolResult(content=[])

    # 根据现有测试/MockProvider 约定,挂上假的工具实现
    mock_provider.tool_callable = fake_tool_callable

    result = await runner.run(provider_request)

    # 断言走到了 "no content" 分支
    # (如果实现使用了其它提示文案,请同步更新下面的断言)
    assert "The tool returned no content." in result.msgs[-1].content[0].text
    # 不应保存任何图片
    assert saved_images == []
    # 工具生命周期 Hook 仍然应该被调用
    assert mock_hooks.tool_end_called, "on_tool_end应该被调用"

@pytest.mark.asyncio
async def test_tool_result_includes_all_calltoolresult_content(
    runner, mock_provider, provider_request, mock_hooks, monkeypatch
):
  1. 确认 astrbot.core.provider.CallToolResult 的导入路径及构造签名是否为 CallToolResult(content=[...]),如不一致请调整导入路径和构造参数。
  2. 上面的断言 result.msgs[-1].content[0].text 假设最终的 LLM 输出以这种结构暴露文本;如果实际返回结构不同,请根据现有的 test_tool_result_includes_all_calltoolresult_content 中用于检查 tool result 文本的方式进行同步修改。
  3. mock_provider.tool_callable 是根据常见模式猜测的挂载点,请对照项目中 MockProvider 的实现,改为实际用于返回工具结果的属性或方法(例如 mock_provider.tools[0].fnmock_provider.mock_tool_result 等),保证在 runner 调用工具时会返回 CallToolResult(content=[])
  4. 如果 “no content” 分支的提示文案与 "The tool returned no content." 略有出入,请将断言字符串改成与实现完全一致,或使用 in 断言其中的关键片段。
Original comment in English

suggestion (testing): Add a variant of this test where CallToolResult.content is empty to verify the 'no content' path

Since there is now an if not res.content: branch that appends "The tool returned no content." and exits early, please add a test where the mock tool returns CallToolResult(content=[]) (or content=None if valid). That test should verify that this branch is taken and that no save_image calls are made.

Suggested implementation:

    async def text_chat(self, **kwargs) -> LLMResponse:
        self.call_count += 1
    assert mock_hooks.tool_end_called, "on_tool_end应该被调用"


@pytest.mark.asyncio
async def test_tool_result_handles_empty_calltoolresult_content(
    runner, mock_provider, provider_request, mock_hooks, monkeypatch
):
    """当工具返回空 content 时,应走 'no content' 分支且不保存图片。"""

    from astrbot.core.agent.tool_image_cache import tool_image_cache
    from astrbot.core.provider import CallToolResult

    mock_provider.should_call_tools = True
    mock_provider.max_calls_before_normal_response = 1

    saved_images = []

    def fake_save_image(*args, **kwargs):
        saved_images.append((args, kwargs))

    # monkeypatch 掉图片保存函数,方便断言不会被调用
    monkeypatch.setattr(tool_image_cache, "save_image", fake_save_image)

    # 让 mock 工具返回空的 CallToolResult.content,以覆盖 `if not res.content` 分支
    async def fake_tool_callable(*args, **kwargs):
        return CallToolResult(content=[])

    # 根据现有测试/MockProvider 约定,挂上假的工具实现
    mock_provider.tool_callable = fake_tool_callable

    result = await runner.run(provider_request)

    # 断言走到了 "no content" 分支
    # (如果实现使用了其它提示文案,请同步更新下面的断言)
    assert "The tool returned no content." in result.msgs[-1].content[0].text
    # 不应保存任何图片
    assert saved_images == []
    # 工具生命周期 Hook 仍然应该被调用
    assert mock_hooks.tool_end_called, "on_tool_end应该被调用"

@pytest.mark.asyncio
async def test_tool_result_includes_all_calltoolresult_content(
    runner, mock_provider, provider_request, mock_hooks, monkeypatch
):
  1. 确认 astrbot.core.provider.CallToolResult 的导入路径及构造签名是否为 CallToolResult(content=[...]),如不一致请调整导入路径和构造参数。
  2. 上面的断言 result.msgs[-1].content[0].text 假设最终的 LLM 输出以这种结构暴露文本;如果实际返回结构不同,请根据现有的 test_tool_result_includes_all_calltoolresult_content 中用于检查 tool result 文本的方式进行同步修改。
  3. mock_provider.tool_callable 是根据常见模式猜测的挂载点,请对照项目中 MockProvider 的实现,改为实际用于返回工具结果的属性或方法(例如 mock_provider.tools[0].fnmock_provider.mock_tool_result 等),保证在 runner 调用工具时会返回 CallToolResult(content=[])
  4. 如果 “no content” 分支的提示文案与 "The tool returned no content." 略有出入,请将断言字符串改成与实现完全一致,或使用 in 断言其中的关键片段。

runner, mock_provider, provider_request, mock_hooks, monkeypatch
):
"""工具返回多个 content 项时,tool result 应包含全部内容。"""

from astrbot.core.agent.tool_image_cache import tool_image_cache

mock_provider.should_call_tools = True
mock_provider.max_calls_before_normal_response = 1

saved_images = []

def fake_save_image(
base64_data, tool_call_id, tool_name, index=0, mime_type="image/png"
):
saved_images.append(
{
"base64_data": base64_data,
"tool_call_id": tool_call_id,
"tool_name": tool_name,
"index": index,
"mime_type": mime_type,
}
)
return SimpleNamespace(file_path=f"/tmp/{tool_call_id}_{index}.png")

monkeypatch.setattr(tool_image_cache, "save_image", fake_save_image)

await runner.reset(
provider=mock_provider,
request=provider_request,
run_context=ContextWrapper(context=None),
tool_executor=MockMixedContentToolExecutor,
agent_hooks=mock_hooks,
streaming=False,
)

async for _ in runner.step_until_done(3):
pass

tool_messages = [
m for m in runner.run_context.messages if getattr(m, "role", None) == "tool"
]
assert len(tool_messages) == 1

content = str(tool_messages[0].content)
assert "Image returned and cached at path='/tmp/call_123_0.png'." in content
assert "直播间标题:新游首发:零~红蝶~" in content
assert saved_images == [
{
"base64_data": "dGVzdA==",
"tool_call_id": "call_123",
"tool_name": "test_tool",
"index": 0,
"mime_type": "image/png",
}
]


@pytest.mark.asyncio
async def test_fallback_provider_used_when_primary_raises(
runner, provider_request, mock_tool_executor, mock_hooks
Expand Down