core/tools: add READ_ONLY tool attribute

New ToolTag.READ_ONLY marks tools that never mutate host state, plus
ToolRegistry.clone(read_only_only=True) to select just those (for safe
side turns / planning). Builtin read-only set: read_file, get_truncated,
glob_paths, grep_files, listdir, tree, todo_list, wait, ask_user_*,
vcs_* and fetch.

fetch is tagged READ_ONLY but can mutate via method; a new
read_only_context ContextVar makes it accept GET only while set.
This commit is contained in:
2026-08-15 16:46:27 +08:00
parent ea8e64e913
commit ddca6e23d3
17 changed files with 113 additions and 19 deletions
+40 -1
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import inspect
from plyngent.agent import ToolTag, tool
from plyngent.agent import ToolRegistry, ToolTag, tool
from plyngent.tools import default_tool_definitions, register_builtin_tools
from plyngent.tools.catalog import ToolCatalog, ToolSource, catalog_scope, get_catalog, registration_source
from plyngent.tools.chat import CHAT_TOOLS
@@ -42,6 +42,45 @@ def test_public_surface_is_subset() -> None:
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",
"get_truncated",
"glob_paths",
"grep_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",
}
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: