mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 07:17:54 +08:00
e9ee71e853
New `view_image` reads a local picture (JPEG, PNG, GIF, WebP, sniffed from its bytes) and hands it to the model as an image part, with an optional `detail` level — `low` measured 3.4x cheaper on a 1200x900 picture. `read_file` now points at it instead of returning decode noise. `fetch_local` returns an image response as the picture itself, and MCP image content blocks become image parts instead of `[image: ...]` placeholders, so a screenshot server finally reaches the model.
155 lines
4.9 KiB
Python
155 lines
4.9 KiB
Python
"""Tool catalog, tags, and default select parity."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import inspect
|
|
|
|
from plyngent.agent import ToolRegistry, ToolTag, tool
|
|
from plyngent.tools import default_tool_definitions, register_builtin_tools
|
|
from plyngent.tools.access import ACCESS_TOOLS
|
|
from plyngent.tools.catalog import ToolCatalog, ToolSource, catalog_scope, get_catalog, registration_source
|
|
from plyngent.tools.chat import CHAT_TOOLS
|
|
from plyngent.tools.file import FILE_TOOLS
|
|
from plyngent.tools.net import NET_TOOLS
|
|
from plyngent.tools.process import PROCESS_TOOLS
|
|
from plyngent.tools.skills import SKILL_TOOLS
|
|
from plyngent.tools.todo import TODO_TOOLS
|
|
from plyngent.tools.vcs import VCS_TOOLS
|
|
|
|
|
|
def test_default_tool_names_match_group_lists() -> None:
|
|
register_builtin_tools()
|
|
selected = default_tool_definitions(surface="local")
|
|
groups = [
|
|
*ACCESS_TOOLS,
|
|
*FILE_TOOLS,
|
|
*PROCESS_TOOLS,
|
|
*VCS_TOOLS,
|
|
*CHAT_TOOLS,
|
|
*TODO_TOOLS,
|
|
*SKILL_TOOLS,
|
|
*NET_TOOLS,
|
|
]
|
|
assert sorted(t.name for t in selected) == sorted(t.name for t in groups)
|
|
assert len(selected) == len(groups)
|
|
|
|
|
|
def test_all_builtins_are_async_and_tagged() -> None:
|
|
tools = default_tool_definitions()
|
|
assert tools
|
|
for definition in tools:
|
|
assert inspect.iscoroutinefunction(definition.handler), definition.name
|
|
assert definition.tags & (ToolTag.LOCAL | ToolTag.PUBLIC)
|
|
assert definition.tags & ToolTag.LOCAL or definition.tags & ToolTag.PUBLIC
|
|
|
|
|
|
def test_public_surface_is_subset() -> None:
|
|
local = {t.name for t in default_tool_definitions(surface="local")}
|
|
public = {t.name for t in default_tool_definitions(surface="public")}
|
|
assert public
|
|
assert public <= local
|
|
# Todo series is the main PUBLIC surface today.
|
|
assert "todo_list" in public
|
|
assert "read_file" not in public
|
|
|
|
|
|
def test_read_only_classification() -> None:
|
|
"""READ_ONLY marks exactly the non-mutating builtins."""
|
|
register_builtin_tools()
|
|
read_only = {t.name for t in default_tool_definitions(surface="local") if t.tags & ToolTag.READ_ONLY}
|
|
assert read_only == {
|
|
"read_file",
|
|
"view_image",
|
|
"get_truncated",
|
|
"glob_paths",
|
|
"regex_files",
|
|
"listdir",
|
|
"tree",
|
|
"todo_list",
|
|
"wait",
|
|
"ask_user_line",
|
|
"ask_user_choice",
|
|
"ask_user_form",
|
|
"vcs_kind",
|
|
"vcs_status",
|
|
"vcs_diff",
|
|
"vcs_log",
|
|
"vcs_branch",
|
|
"fetch_local",
|
|
"skill_list",
|
|
"skill_read",
|
|
"skill_search",
|
|
}
|
|
|
|
|
|
def test_registry_clone_read_only_only() -> None:
|
|
"""clone(read_only_only=True) keeps only READ_ONLY tools, nothing else."""
|
|
register_builtin_tools()
|
|
full = ToolRegistry(default_tool_definitions(surface="local"))
|
|
ro = full.clone(read_only_only=True)
|
|
assert 0 < len(ro) < len(full)
|
|
assert {t.name for t in ro.definitions()} == {t.name for t in full.definitions() if t.tags & ToolTag.READ_ONLY}
|
|
assert all(t.tags & ToolTag.READ_ONLY for t in ro.definitions())
|
|
assert "run_argv" not in ro
|
|
assert "edit_replace" not in ro
|
|
# Plain clone keeps every tool.
|
|
assert len(full.clone()) == len(full)
|
|
|
|
|
|
def test_catalog_scope_empty_isolates_registration() -> None:
|
|
with catalog_scope(empty=True) as catalog:
|
|
|
|
@tool(name="only_in_scope", register=True)
|
|
async def only_in_scope() -> str:
|
|
return "ok"
|
|
|
|
_ = only_in_scope
|
|
assert catalog.get("only_in_scope") is not None
|
|
# default_tool_definitions re-seeds builtins into the override; plugin-like
|
|
# names from this scope stay local to the override and leave process catalog.
|
|
assert get_catalog().get("only_in_scope") is None
|
|
|
|
|
|
def test_collision_refuses_shadow() -> None:
|
|
catalog = ToolCatalog()
|
|
|
|
@tool(register=False)
|
|
async def alpha() -> str:
|
|
return "a"
|
|
|
|
catalog.register(alpha, source=ToolSource(kind="builtin"))
|
|
try:
|
|
catalog.register(alpha, source=ToolSource(kind="plugin", plugin_id="acme"))
|
|
raise AssertionError("expected collision")
|
|
except ValueError as exc:
|
|
assert "collision" in str(exc)
|
|
|
|
|
|
def test_registration_source_context() -> None:
|
|
with catalog_scope(empty=True) as catalog:
|
|
with registration_source(ToolSource(kind="plugin", plugin_id="acme")):
|
|
|
|
@tool(name="plugin_tool")
|
|
async def plugin_tool() -> str:
|
|
return "p"
|
|
|
|
_ = plugin_tool
|
|
|
|
entry = catalog.get("plugin_tool")
|
|
assert entry is not None
|
|
assert entry.source.kind == "plugin"
|
|
assert entry.source.plugin_id == "acme"
|
|
|
|
|
|
def test_tool_tags_reject_neither_surface() -> None:
|
|
try:
|
|
|
|
@tool(tags=ToolTag.YOLO, register=False)
|
|
async def bad() -> str:
|
|
return "x"
|
|
|
|
_ = bad
|
|
raise AssertionError("expected ValueError")
|
|
except ValueError as exc:
|
|
assert "LOCAL" in str(exc)
|