mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 23:47:54 +08:00
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:
@@ -42,6 +42,7 @@ class ToolTag(Flag):
|
|||||||
YOLO = auto() # soft-confirm: eligible for YOLO auto-approve
|
YOLO = auto() # soft-confirm: eligible for YOLO auto-approve
|
||||||
INSTANCE_STATE = auto() # needs instance-scoped state
|
INSTANCE_STATE = auto() # needs instance-scoped state
|
||||||
SESSION_STATE = auto() # needs session-scoped state
|
SESSION_STATE = auto() # needs session-scoped state
|
||||||
|
READ_ONLY = auto() # never mutates host state; safe for read-only contexts
|
||||||
|
|
||||||
|
|
||||||
class ToolDefinition:
|
class ToolDefinition:
|
||||||
@@ -266,14 +267,22 @@ class ToolRegistry:
|
|||||||
session_state: object | None = None,
|
session_state: object | None = None,
|
||||||
yolo: bool | None = None,
|
yolo: bool | None = None,
|
||||||
auto_bind_state: bool | None = None,
|
auto_bind_state: bool | None = None,
|
||||||
|
read_only_only: bool = False,
|
||||||
) -> ToolRegistry:
|
) -> ToolRegistry:
|
||||||
"""New registry with the same tools/hooks and rebound host state.
|
"""New registry with the same tools/hooks and rebound host state.
|
||||||
|
|
||||||
*instance_state* / *session_state* are applied as given (use the parent
|
*instance_state* / *session_state* are applied as given (use the parent
|
||||||
registry's handles only when the caller passes them through).
|
registry's handles only when the caller passes them through).
|
||||||
|
|
||||||
|
With *read_only_only*, only :attr:`ToolTag.READ_ONLY` tools are kept
|
||||||
|
(e.g. for safe side turns / planning); hooks and policy flags are
|
||||||
|
preserved.
|
||||||
"""
|
"""
|
||||||
|
definitions = self.definitions()
|
||||||
|
if read_only_only:
|
||||||
|
definitions = [d for d in definitions if d.tags & ToolTag.READ_ONLY]
|
||||||
return ToolRegistry(
|
return ToolRegistry(
|
||||||
self.definitions(),
|
definitions,
|
||||||
danger=self._danger,
|
danger=self._danger,
|
||||||
on_confirm=self._on_confirm,
|
on_confirm=self._on_confirm,
|
||||||
yolo=self._yolo if yolo is None else yolo,
|
yolo=self._yolo if yolo is None else yolo,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from plyngent.agent import ToolTag, tool
|
|||||||
from plyngent.prompting import NonInteractiveError, ask_async
|
from plyngent.prompting import NonInteractiveError, ask_async
|
||||||
|
|
||||||
|
|
||||||
@tool(name="ask_user_line", tags=ToolTag.LOCAL)
|
@tool(name="ask_user_line", tags=ToolTag.LOCAL | ToolTag.READ_ONLY)
|
||||||
async def ask_user(question: str, default: str = "") -> str:
|
async def ask_user(question: str, default: str = "") -> str:
|
||||||
"""Ask the human a free-form one-line question and return their answer.
|
"""Ask the human a free-form one-line question and return their answer.
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ def parse_options(options: list[object]) -> list[ChoiceOption]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@tool(name="ask_user_choice", tags=ToolTag.LOCAL)
|
@tool(name="ask_user_choice", tags=ToolTag.LOCAL | ToolTag.READ_ONLY)
|
||||||
async def choose_user(
|
async def choose_user(
|
||||||
question: str,
|
question: str,
|
||||||
options: list[str] | list[dict[str, object]],
|
options: list[str] | list[dict[str, object]],
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ def parse_fields(fields: list[dict[str, object]]) -> list[FormField]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@tool(name="ask_user_form", tags=ToolTag.LOCAL)
|
@tool(name="ask_user_form", tags=ToolTag.LOCAL | ToolTag.READ_ONLY)
|
||||||
async def form_user(
|
async def form_user(
|
||||||
title: str,
|
title: str,
|
||||||
fields: list[dict[str, object]],
|
fields: list[dict[str, object]],
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from plyngent.prompting import get_prompt_backend, read_line_with_timeout, run_p
|
|||||||
WAIT_PROMPT = "Waiting for {duration}s. Press Enter to disturb. Feel free to ship any reason before pressing Enter. "
|
WAIT_PROMPT = "Waiting for {duration}s. Press Enter to disturb. Feel free to ship any reason before pressing Enter. "
|
||||||
|
|
||||||
|
|
||||||
@tool(name="wait", tags=ToolTag.LOCAL)
|
@tool(name="wait", tags=ToolTag.LOCAL | ToolTag.READ_ONLY)
|
||||||
async def wait(duration: int, *, reason: str | None = None) -> str:
|
async def wait(duration: int, *, reason: str | None = None) -> str:
|
||||||
"""Wait ``duration`` seconds before continuing.
|
"""Wait ``duration`` seconds before continuing.
|
||||||
|
|
||||||
|
|||||||
@@ -137,6 +137,29 @@ def bind_session(state: SessionState | None) -> Generator[SessionState | None]:
|
|||||||
_session.reset(token)
|
_session.reset(token)
|
||||||
|
|
||||||
|
|
||||||
|
_read_only: ContextVar[bool] = ContextVar("plyngent_read_only_context", default=False)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def read_only_context() -> Generator[None]:
|
||||||
|
"""Mark the current context as read-only for tool execution.
|
||||||
|
|
||||||
|
Tools that may mutate depending on arguments (e.g. ``fetch`` with a
|
||||||
|
non-GET method) check :func:`is_read_only_context` and refuse to mutate
|
||||||
|
while it is set.
|
||||||
|
"""
|
||||||
|
token = _read_only.set(True)
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
_read_only.reset(token)
|
||||||
|
|
||||||
|
|
||||||
|
def is_read_only_context() -> bool:
|
||||||
|
"""Whether tools currently run inside a read-only context."""
|
||||||
|
return _read_only.get()
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def bind_tool_context(
|
def bind_tool_context(
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from plyngent.tools.truncate_token import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
|
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
|
||||||
async def get_truncated(token: str, *, max_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS) -> str:
|
async def get_truncated(token: str, *, max_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS) -> str:
|
||||||
"""Fetch the next chunk of a truncated result using its ``truncate_token``.
|
"""Fetch the next chunk of a truncated result using its ``truncate_token``.
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ def _resolve_glob_base(path: str) -> tuple[Path, Path] | str:
|
|||||||
return root, base
|
return root, base
|
||||||
|
|
||||||
|
|
||||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
|
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
|
||||||
async def glob_paths(
|
async def glob_paths(
|
||||||
pattern: str,
|
pattern: str,
|
||||||
path: str = ".",
|
path: str = ".",
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ def _compile_pattern(pattern: str, *, case_insensitive: bool) -> re.Pattern[str]
|
|||||||
return f"error: invalid regex: {exc}"
|
return f"error: invalid regex: {exc}"
|
||||||
|
|
||||||
|
|
||||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
|
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
|
||||||
async def grep_files(
|
async def grep_files(
|
||||||
pattern: str,
|
pattern: str,
|
||||||
path: str = ".",
|
path: str = ".",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from plyngent.agent import ToolTag, tool
|
|||||||
from plyngent.tools.workspace import resolve_path
|
from plyngent.tools.workspace import resolve_path
|
||||||
|
|
||||||
|
|
||||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
|
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
|
||||||
async def listdir(path: str = ".") -> str:
|
async def listdir(path: str = ".") -> str:
|
||||||
"""List entries in a directory under the workspace (name and type)."""
|
"""List entries in a directory under the workspace (name and type)."""
|
||||||
target = resolve_path(path)
|
target = resolve_path(path)
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ def read_raw_text(path: str) -> tuple[str | None, str]:
|
|||||||
return target.read_text(encoding="utf-8", errors="replace"), ""
|
return target.read_text(encoding="utf-8", errors="replace"), ""
|
||||||
|
|
||||||
|
|
||||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
|
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
|
||||||
async def read_file(
|
async def read_file(
|
||||||
path: str,
|
path: str,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ def _resolve_skip_basenames(skip_dirs: Sequence[str] | None) -> frozenset[str]:
|
|||||||
return frozenset(name for name in skip_dirs if name)
|
return frozenset(name for name in skip_dirs if name)
|
||||||
|
|
||||||
|
|
||||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
|
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
|
||||||
async def tree(
|
async def tree(
|
||||||
path: str = ".",
|
path: str = ".",
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from plyngent.agent import ToolTag, tool
|
from plyngent.agent import ToolTag, tool
|
||||||
|
from plyngent.tools.context import is_read_only_context
|
||||||
from plyngent.tools.net.client import http_fetch
|
from plyngent.tools.net.client import http_fetch
|
||||||
from plyngent.tools.net.policy import (
|
from plyngent.tools.net.policy import (
|
||||||
DEFAULT_MAX_BODY_CHARS_IN,
|
DEFAULT_MAX_BODY_CHARS_IN,
|
||||||
@@ -66,7 +67,7 @@ def format_fetch_result(
|
|||||||
return "\n".join(parts)
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO | ToolTag.TRUSTABLE)
|
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO | ToolTag.TRUSTABLE | ToolTag.READ_ONLY)
|
||||||
async def fetch(
|
async def fetch(
|
||||||
url: str,
|
url: str,
|
||||||
*,
|
*,
|
||||||
@@ -92,9 +93,13 @@ async def fetch(
|
|||||||
Private/loopback/link-local hosts require an explicit human policy grant
|
Private/loopback/link-local hosts require an explicit human policy grant
|
||||||
(not skipped by YOLO). HTTPS→HTTP redirects are blocked unless
|
(not skipped by YOLO). HTTPS→HTTP redirects are blocked unless
|
||||||
``allow_http_downgrade`` is true.
|
``allow_http_downgrade`` is true.
|
||||||
|
|
||||||
|
Read-only contexts (e.g. ``/btw --tools=read``) accept only ``GET``.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
verb = normalize_method(method)
|
verb = normalize_method(method)
|
||||||
|
if is_read_only_context() and verb != "GET":
|
||||||
|
return "error: fetch method must be GET in a read-only context (POST/PUT/DELETE mutate)"
|
||||||
parsed = parse_fetch_url(url)
|
parsed = parse_fetch_url(url)
|
||||||
hdrs = normalize_request_headers(headers, user_agent=user_agent)
|
hdrs = normalize_request_headers(headers, user_agent=user_agent)
|
||||||
body_bytes: bytes | None = None
|
body_bytes: bytes | None = None
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ async def _with_todo_stack(mutator: Callable[[TodoStack], str]) -> str:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@tool(name="todo_list", tags=ToolTag.LOCAL | ToolTag.PUBLIC | ToolTag.SESSION_STATE)
|
@tool(name="todo_list", tags=ToolTag.LOCAL | ToolTag.PUBLIC | ToolTag.SESSION_STATE | ToolTag.READ_ONLY)
|
||||||
async def todo_list() -> str:
|
async def todo_list() -> str:
|
||||||
"""Show the LIFO stack of **task groups** (TOP group = current breakdown level).
|
"""Show the LIFO stack of **task groups** (TOP group = current breakdown level).
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ def _backend_or_error() -> VcsBackend | str:
|
|||||||
return backend
|
return backend
|
||||||
|
|
||||||
|
|
||||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
|
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
|
||||||
async def vcs_kind() -> str:
|
async def vcs_kind() -> str:
|
||||||
"""Return the detected VCS kind under the workspace (e.g. ``git``), or an error."""
|
"""Return the detected VCS kind under the workspace (e.g. ``git``), or an error."""
|
||||||
backend = _backend_or_error()
|
backend = _backend_or_error()
|
||||||
@@ -31,7 +31,7 @@ async def vcs_kind() -> str:
|
|||||||
return backend.kind
|
return backend.kind
|
||||||
|
|
||||||
|
|
||||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
|
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
|
||||||
async def vcs_status() -> str:
|
async def vcs_status() -> str:
|
||||||
"""Show working-tree status for the detected VCS (read-only)."""
|
"""Show working-tree status for the detected VCS (read-only)."""
|
||||||
backend = _backend_or_error()
|
backend = _backend_or_error()
|
||||||
@@ -40,7 +40,7 @@ async def vcs_status() -> str:
|
|||||||
return backend.status()
|
return backend.status()
|
||||||
|
|
||||||
|
|
||||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
|
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
|
||||||
async def vcs_diff(path: str = "", *, staged: bool = False) -> str:
|
async def vcs_diff(path: str = "", *, staged: bool = False) -> str:
|
||||||
"""Show a unified diff for the detected VCS (read-only).
|
"""Show a unified diff for the detected VCS (read-only).
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ async def vcs_diff(path: str = "", *, staged: bool = False) -> str:
|
|||||||
return backend.diff(staged=staged, path=rel)
|
return backend.diff(staged=staged, path=rel)
|
||||||
|
|
||||||
|
|
||||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
|
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
|
||||||
async def vcs_log(limit: int = 10) -> str:
|
async def vcs_log(limit: int = 10) -> str:
|
||||||
"""Show recent commits for the detected VCS (read-only)."""
|
"""Show recent commits for the detected VCS (read-only)."""
|
||||||
if limit < 1:
|
if limit < 1:
|
||||||
@@ -65,7 +65,7 @@ async def vcs_log(limit: int = 10) -> str:
|
|||||||
return backend.log(limit=limit)
|
return backend.log(limit=limit)
|
||||||
|
|
||||||
|
|
||||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
|
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.READ_ONLY)
|
||||||
async def vcs_branch() -> str:
|
async def vcs_branch() -> str:
|
||||||
"""Show the current branch / named head for the detected VCS (read-only)."""
|
"""Show the current branch / named head for the detected VCS (read-only)."""
|
||||||
backend = _backend_or_error()
|
backend = _backend_or_error()
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import inspect
|
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 import default_tool_definitions, register_builtin_tools
|
||||||
from plyngent.tools.catalog import ToolCatalog, ToolSource, catalog_scope, get_catalog, registration_source
|
from plyngent.tools.catalog import ToolCatalog, ToolSource, catalog_scope, get_catalog, registration_source
|
||||||
from plyngent.tools.chat import CHAT_TOOLS
|
from plyngent.tools.chat import CHAT_TOOLS
|
||||||
@@ -42,6 +42,45 @@ def test_public_surface_is_subset() -> None:
|
|||||||
assert "read_file" not 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",
|
||||||
|
"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:
|
def test_catalog_scope_empty_isolates_registration() -> None:
|
||||||
with catalog_scope(empty=True) as catalog:
|
with catalog_scope(empty=True) as catalog:
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, override
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from plyngent.tools.context import read_only_context
|
||||||
from plyngent.tools.danger import classify_danger
|
from plyngent.tools.danger import classify_danger
|
||||||
from plyngent.tools.net import fetch, grant_private_host
|
from plyngent.tools.net import fetch, grant_private_host
|
||||||
from plyngent.tools.net.policy import (
|
from plyngent.tools.net.policy import (
|
||||||
@@ -199,6 +200,23 @@ async def test_fetch_get_post_put_delete(workspace: Path, http_server: str) -> N
|
|||||||
assert "status=204" in out_del
|
assert "status=204" in out_del
|
||||||
|
|
||||||
|
|
||||||
|
async def test_fetch_read_only_context_blocks_non_get(workspace: Path, http_server: str) -> None:
|
||||||
|
"""In a read-only context fetch only allows GET; outside it POST works."""
|
||||||
|
del workspace
|
||||||
|
port = int(http_server.rsplit(":", 1)[1])
|
||||||
|
grant_private_host("127.0.0.1", port)
|
||||||
|
|
||||||
|
with read_only_context():
|
||||||
|
out = await call_async(fetch, f"{http_server}/hello")
|
||||||
|
assert "status=200" in out # GET is fine in a read-only context
|
||||||
|
blocked = await call_async(fetch, f"{http_server}/echo", method="POST", body="hi")
|
||||||
|
assert blocked.startswith("error:")
|
||||||
|
assert "GET" in blocked
|
||||||
|
|
||||||
|
out_post = await call_async(fetch, f"{http_server}/echo", method="POST", body="hi")
|
||||||
|
assert "POST:hi" in out_post
|
||||||
|
|
||||||
|
|
||||||
async def test_fetch_user_agent_passthrough(workspace: Path, http_server: str) -> None:
|
async def test_fetch_user_agent_passthrough(workspace: Path, http_server: str) -> None:
|
||||||
del workspace
|
del workspace
|
||||||
port = int(http_server.rsplit(":", 1)[1])
|
port = int(http_server.rsplit(":", 1)[1])
|
||||||
|
|||||||
Reference in New Issue
Block a user