Files
plyngent/tests/test_agent/test_tool_output.py
T
NCBM 37e9395ac5 test: cover the image gate
The gate has four entry points and each now has a test: `view_image` and
`fetch_local` refuse with the message naming the config key, the registry drops
a plugin tool's picture and says why, and the workspace helpers keep `None` (no
host verdict) permissive. On the CLI side the verdict is asserted to follow the
selected model, and to reach the tools once an instance is bound.
2026-10-21 21:39:33 +08:00

137 lines
4.4 KiB
Python

"""A tool result that carries images beside its text."""
from __future__ import annotations
from msgspec import UNSET
from plyngent.agent import ToolOutput, ToolRegistry, tool
from plyngent.agent.budget import IMAGE_CHARS, estimate_message_chars
from plyngent.agent.loop import _run_one_tool
from plyngent.lmproto.openai_compatible.model import (
AssistantFunctionTool,
AssistantFunctionToolCall,
ChatImagePart,
ChatImageUrl,
ChatTextPart,
ToolChatMessage,
UserChatMessage,
)
from plyngent.tools.context import InstanceState
def _image() -> ChatImagePart:
return ChatImagePart(image_url=ChatImageUrl(url="data:image/png;base64,AAAA", detail="low"))
def _registry() -> ToolRegistry:
@tool(register=False)
def shot() -> ToolOutput:
"""Pretend to take a screenshot."""
return ToolOutput(text="image: shot.png (2 KB)", images=[_image()])
@tool(register=False)
def plain() -> str:
return "just text"
return ToolRegistry([shot, plain])
def _call(name: str) -> AssistantFunctionToolCall:
return AssistantFunctionToolCall(id="call-1", function=AssistantFunctionTool(name=name, arguments="{}"))
async def test_execute_keeps_the_text_only_contract() -> None:
registry = _registry()
assert await registry.execute("shot", "{}") == "image: shot.png (2 KB)"
assert await registry.execute("plain", "{}") == "just text"
async def test_execute_output_returns_the_images_beside_the_text() -> None:
registry = _registry()
output = await registry.execute_output("shot", "{}")
assert output.text == "image: shot.png (2 KB)"
assert output.has_images
assert output.images == [_image()]
async def test_execute_output_wraps_a_plain_string_result() -> None:
output = await _registry().execute_output("plain", "{}")
assert output.text == "just text"
assert not output.has_images
async def test_an_unknown_tool_is_error_text_without_images() -> None:
output = await _registry().execute_output("missing", "{}")
assert "unknown tool" in output.text
assert not output.has_images
async def test_the_loop_keeps_tool_images_on_the_tool_message() -> None:
message, err = await _run_one_tool(_registry(), _call("shot"), max_result_chars=32_000)
assert err is None
assert isinstance(message, ToolChatMessage)
assert message.content == "image: shot.png (2 KB)"
assert message.images == [_image()]
async def test_the_loop_leaves_the_images_field_unset_for_text_tools() -> None:
message, err = await _run_one_tool(_registry(), _call("plain"), max_result_chars=32_000)
assert err is None
assert message.images is UNSET
async def test_a_truncated_tool_result_keeps_its_images() -> None:
message, _ = await _run_one_tool(_registry(), _call("shot"), max_result_chars=1)
assert "[Truncated" in message.content
assert message.images == [_image()]
def test_the_char_estimate_counts_images() -> None:
assert estimate_message_chars(UserChatMessage(content="look:")) == len("look:")
assert estimate_message_chars(UserChatMessage(content=[ChatTextPart(text="look:"), _image()])) == (
len("look:") + IMAGE_CHARS
)
assert estimate_message_chars(ToolChatMessage(content="shot", tool_call_id="call-1", images=[_image()])) == (
len("shot") + len("call-1") + IMAGE_CHARS
)
def _registry_for(instance: InstanceState) -> ToolRegistry:
@tool(register=False)
def shot() -> ToolOutput:
"""Pretend to take a screenshot."""
return ToolOutput(text="image: shot.png (2 KB)", images=[_image()])
return ToolRegistry([shot], instance_state=instance)
async def test_a_text_only_model_never_receives_a_plugin_tool_image() -> None:
"""The registry is the safety net for tools that do not check ``image_in``."""
instance = InstanceState()
instance.workspace.image_in = False
instance.workspace.image_model = ("ds", "text-only")
output = await _registry_for(instance).execute_output("shot", "{}")
assert output.text.startswith("image: shot.png (2 KB)")
assert "image dropped" in output.text
assert "image_in = true" in output.text
assert not output.has_images
async def test_a_vision_model_keeps_a_plugin_tool_image() -> None:
instance = InstanceState()
instance.workspace.image_in = True
output = await _registry_for(instance).execute_output("shot", "{}")
assert output.text == "image: shot.png (2 KB)"
assert output.images == [_image()]