core/tools: async builtins with policy tags

This commit is contained in:
2026-07-24 14:56:33 +08:00
parent 4feb0e0d26
commit 15dde7cdaf
23 changed files with 103 additions and 90 deletions
+2 -2
View File
@@ -1,10 +1,10 @@
from __future__ import annotations
from plyngent.agent import tool
from plyngent.agent import ToolTag, tool
from plyngent.prompting import NonInteractiveError, ask_async
@tool(name="ask_user_line")
@tool(name="ask_user_line", tags=ToolTag.LOCAL)
async def ask_user(question: str, default: str = "") -> str:
"""Ask the human a free-form one-line question and return their answer.
+2 -2
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import json
from typing import cast
from plyngent.agent import tool
from plyngent.agent import ToolTag, tool
from plyngent.prompting import ChoiceOption, NonInteractiveError, choose_async
@@ -46,7 +46,7 @@ def parse_options(raw: str) -> list[ChoiceOption]:
return out
@tool(name="ask_user_choice")
@tool(name="ask_user_choice", tags=ToolTag.LOCAL)
async def choose_user(
question: str,
options: str,
+2 -2
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import json
from typing import cast
from plyngent.agent import tool
from plyngent.agent import ToolTag, tool
from plyngent.prompting import FormField, NonInteractiveError, form_async
from plyngent.tools.chat.choose import parse_options
@@ -54,7 +54,7 @@ def parse_fields(raw: str) -> list[FormField]:
return out
@tool(name="ask_user_form")
@tool(name="ask_user_form", tags=ToolTag.LOCAL)
async def form_user(title: str, fields: str, *, confirm_submit: bool = True) -> str:
"""Run a multi-step form with the human; returns JSON object of answers.
+3 -3
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from plyngent.agent import tool
from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import resolve_path
if TYPE_CHECKING:
@@ -76,8 +76,8 @@ def _replace_range(
return f"replaced lines {start_line}-{end} ({removed} lines) with {len(replacement)} lines in {path}"
@tool
def edit_lineno(path: str, start_line: int, end_line: int, new_content: str) -> str:
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
async def edit_lineno(path: str, start_line: int, end_line: int, new_content: str) -> str:
"""Replace lines ``start_line``..``end_line`` (1-based, inclusive) in a file.
``new_content`` may be multi-line. Use an empty string to delete the range.
+3 -3
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from plyngent.agent import tool
from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import resolve_path
@@ -32,8 +32,8 @@ def _success_message(path: str, *, replaced: int, found: int) -> str:
)
@tool
def edit_replace(path: str, old_string: str, new_string: str, max_replaces: int = 1) -> str:
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
async def edit_replace(path: str, old_string: str, new_string: str, max_replaces: int = 1) -> str:
"""Replace occurrences of ``old_string`` with ``new_string`` in a file.
Replaces left-to-right, non-overlapping. Default ``max_replaces=1`` (first match
+7 -7
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import shutil
from pathlib import Path
from plyngent.agent import tool
from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import WorkspaceError, get_workspace_root, resolve_path
@@ -77,8 +77,8 @@ def _copy_or_move_validated(
return f"error: {action} failed: {exc}"
@tool
def copy_path(src: str, dst: str, *, overwrite: bool = False) -> str:
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
async def copy_path(src: str, dst: str, *, overwrite: bool = False) -> str:
"""Copy a file or directory under the workspace (``shutil.copy2`` / ``copytree``).
``dst`` parent directories are created as needed. If ``dst`` exists, set
@@ -93,8 +93,8 @@ def copy_path(src: str, dst: str, *, overwrite: bool = False) -> str:
return _copy_or_move_validated(source, dest, root, src=src, dst=dst, overwrite=overwrite, action="copy")
@tool
def move_path(src: str, dst: str, *, overwrite: bool = False) -> str:
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
async def move_path(src: str, dst: str, *, overwrite: bool = False) -> str:
"""Move/rename a file or directory under the workspace (``shutil.move``)."""
pair = _resolve_pair(src, dst)
if isinstance(pair, str):
@@ -129,8 +129,8 @@ def _delete_target(target: Path, path: str, *, recursive: bool) -> str:
return f"error: unsupported path type: {path}"
@tool
def delete_path(path: str, *, recursive: bool = False) -> str:
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
async def delete_path(path: str, *, recursive: bool = False) -> str:
"""Delete a file or directory under the workspace.
Files and empty directories are removed always. Non-empty directories require
+3 -3
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from plyngent.agent import tool
from plyngent.agent import ToolTag, tool
from plyngent.tools.file.tree import VCS_DIR_NAMES
from plyngent.tools.workspace import WorkspaceError, get_workspace_root, resolve_path
@@ -65,8 +65,8 @@ def _resolve_glob_base(path: str) -> tuple[Path, Path] | str:
return root, base
@tool
def glob_paths(
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
async def glob_paths(
pattern: str,
path: str = ".",
*,
+3 -3
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import re
from typing import TYPE_CHECKING
from plyngent.agent import tool
from plyngent.agent import ToolTag, tool
from plyngent.tools.file.tree import VCS_DIR_NAMES
from plyngent.tools.workspace import WorkspaceError, get_workspace_root, resolve_path
@@ -127,8 +127,8 @@ def _compile_pattern(pattern: str, *, case_insensitive: bool) -> re.Pattern[str]
return f"error: invalid regex: {exc}"
@tool
def grep_files(
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
async def grep_files(
pattern: str,
path: str = ".",
*,
+3 -3
View File
@@ -1,11 +1,11 @@
from __future__ import annotations
from plyngent.agent import tool
from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import resolve_path
@tool
def listdir(path: str = ".") -> str:
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
async def listdir(path: str = ".") -> str:
"""List entries in a directory under the workspace (name and type)."""
target = resolve_path(path)
if not target.is_dir():
+3 -3
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from plyngent.agent import tool
from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import resolve_path
_LINENO_WIDTH = 6
@@ -17,8 +17,8 @@ def _format_with_lineno(lines: list[str], *, start_lineno: int) -> str:
return "".join(out)
@tool
def read_file(
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
async def read_file(
path: str,
*,
offset: int = 0,
+3 -3
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from plyngent.agent import tool
from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import WorkspaceError, get_path_denylist, resolve_path
if TYPE_CHECKING:
@@ -151,8 +151,8 @@ def _resolve_skip_basenames(skip_dirs: Sequence[str] | None) -> frozenset[str]:
return frozenset(name for name in skip_dirs if name)
@tool
def tree(
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
async def tree(
path: str = ".",
*,
max_depth: int = DEFAULT_MAX_DEPTH,
+3 -3
View File
@@ -1,11 +1,11 @@
from __future__ import annotations
from plyngent.agent import tool
from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import resolve_path
@tool
def write_file(path: str, content: str) -> str:
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
async def write_file(path: str, content: str) -> str:
"""Write text content to a file under the workspace (creates parents)."""
target = resolve_path(path)
target.parent.mkdir(parents=True, exist_ok=True)
+2 -2
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import Literal
from plyngent.agent import tool
from plyngent.agent import ToolTag, tool
from plyngent.prompting import NonInteractiveError, ask_async, ask_secret_async
from plyngent.tools.workspace import WorkspaceError
@@ -38,7 +38,7 @@ async def _prompt_answer(label: str, *, secret: bool) -> _PromptResult:
return ("ok", answer)
@tool
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
async def ask_into_pty(
session_id: int,
message: str,
+2 -2
View File
@@ -2,12 +2,12 @@ from __future__ import annotations
import asyncio
from plyngent.agent import tool
from plyngent.agent import ToolTag, tool
from .pty_session import PtyManager, format_close_result
@tool
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
async def close_pty(session_id: int) -> str:
"""Close a PTY session (SIGTERM, then SIGKILL after a short grace period).
+3 -3
View File
@@ -2,14 +2,14 @@ from __future__ import annotations
import shlex
from plyngent.agent import tool
from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import WorkspaceError
from .pty_session import PtyManager
@tool
def open_pty(command: list[str], *, cwd: str = ".") -> str:
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
async def open_pty(command: list[str], *, cwd: str = ".") -> str:
"""Open a PTY session running ``command`` (argv) under the workspace.
POSIX uses openpty/fork; Windows uses ConPTY (pywinpty). Returns structured
+2 -2
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import asyncio
from plyngent.agent import tool
from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import WorkspaceError
from .pty_session import DEFAULT_PTY_READ_BYTES, PtyManager, format_read_result
@@ -11,7 +11,7 @@ from .pty_session import DEFAULT_PTY_READ_BYTES, PtyManager, format_read_result
_MAX_READ_TIMEOUT = 120.0
@tool
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
async def read_pty(
session_id: int,
*,
+2 -2
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from plyngent.agent import tool
from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import WorkspaceError
from .command_exec import (
@@ -10,7 +10,7 @@ from .command_exec import (
)
@tool
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
async def run_command(
command: list[str],
*,
@@ -4,7 +4,7 @@ import json
import shlex
from typing import Any, cast
from plyngent.agent import tool
from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import WorkspaceError
from .command_exec import (
@@ -154,7 +154,7 @@ async def _run_batch_steps(
return results, stopped_early
@tool
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
async def run_command_batch(
commands: list[dict[str, object]] | str,
*,
+3 -3
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from plyngent.agent import tool
from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import WorkspaceError
from .pty_session import PtyManager
@@ -21,8 +21,8 @@ def write_pty_payload(session_id: int, raw: str) -> str:
)
@tool
def write_pty(session_id: int, data: str) -> str:
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
async def write_pty(session_id: int, data: str) -> str:
"""Write **literal** text to a PTY session. Does not append a newline.
``data`` is sent unchanged (no ``ctrl+x`` / ``\\\\xHH`` expansion). For
+3 -3
View File
@@ -1,14 +1,14 @@
from __future__ import annotations
from plyngent.agent import tool
from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import WorkspaceError
from .pty_terminal import decode_write_data
from .write_pty import write_pty_payload
@tool
def write_pty_keys(session_id: int, data: str) -> str:
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
async def write_pty_keys(session_id: int, data: str) -> str:
"""Write to a PTY after expanding key escapes (never for normal typing).
Use this only for control sequences. Prefer :func:`write_pty` for plain text
+18 -12
View File
@@ -5,7 +5,7 @@ import shutil
import tempfile
from pathlib import Path
from plyngent.agent import tool
from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import (
MAX_TEMPORARY_WORKSPACES,
WorkspaceError,
@@ -61,17 +61,7 @@ def cleanup_temporary_workspaces() -> int:
return removed
@tool
def new_temporary_workspace(prefix: str = "ws") -> str:
"""Create a scratch directory under the system temp dir and allow tool paths in it.
The project workspace is unchanged: relative paths still resolve there.
Use the returned **absolute** path for file/process tools. Temporary
workspaces are deleted when this chat process exits (not on each turn).
Cross-platform (uses ``tempfile``; typically ``/tmp`` on POSIX, ``%TEMP%``
on Windows). At most 16 concurrent temporary workspaces per process.
"""
def _create_temporary_workspace(prefix: str) -> str:
if len(list_workspace_allowlist()) >= MAX_TEMPORARY_WORKSPACES:
return f"error: too many temporary workspaces (max {MAX_TEMPORARY_WORKSPACES})"
@@ -94,3 +84,19 @@ def new_temporary_workspace(prefix: str = "ws") -> str:
"note: project workspace unchanged; use this absolute path for tools; "
"removed when chat exits"
)
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
async def new_temporary_workspace(prefix: str = "ws") -> str:
"""Create a scratch directory under the system temp dir and allow tool paths in it.
The project workspace is unchanged: relative paths still resolve there.
Use the returned **absolute** path for file/process tools. Temporary
workspaces are deleted when this chat process exits (not on each turn).
Cross-platform (uses ``tempfile``; typically ``/tmp`` on POSIX, ``%TEMP%``
on Windows). At most 16 concurrent temporary workspaces per process.
"""
import asyncio
return await asyncio.to_thread(_create_temporary_workspace, prefix)
+18 -11
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING, cast
from plyngent.agent import tool
from plyngent.agent import ToolTag, tool
if TYPE_CHECKING:
from collections.abc import Callable
@@ -27,6 +27,12 @@ def get_todo_stack() -> TodoStack | None:
def _require_stack() -> TodoStack:
"""Prefer session-bound todo; fall back to process bind during migration."""
from plyngent.tools.context import get_session
session = get_session()
if session is not None and session.todo is not None:
return session.todo
if _stack is None:
msg = "todo stack is not available in this session"
raise RuntimeError(msg)
@@ -36,10 +42,11 @@ def _require_stack() -> TodoStack:
def _notify() -> None:
if _on_change is not None:
_on_change()
# Session-bound stacks may also persist via host on_change on the process bind.
@tool(name="todo_list")
def todo_list() -> str:
@tool(name="todo_list", tags=ToolTag.LOCAL | ToolTag.PUBLIC | ToolTag.SESSION_STATE)
async def todo_list() -> str:
"""Show the LIFO stack of **task groups** (TOP group = current breakdown level).
Non-empty stack with open (pending/in_progress) items = unfinished work.
@@ -53,8 +60,8 @@ def todo_list() -> str:
return stack.render()
@tool(name="todo_push")
def todo_push(titles: list[str], notes: str = "") -> str:
@tool(name="todo_push", tags=ToolTag.LOCAL | ToolTag.PUBLIC | ToolTag.SESSION_STATE)
async def todo_push(titles: list[str], notes: str = "") -> str:
"""Push **one task group** (siblings) onto the stack — not one level per title.
``titles``: JSON array of strings (tool schema type ``array``). All entries
@@ -74,8 +81,8 @@ def todo_push(titles: list[str], notes: str = "") -> str:
return f"pushed group (depth={stack.depth}) items=[{ids}]\n{stack.render()}"
@tool(name="todo_pop")
def todo_pop() -> str:
@tool(name="todo_pop", tags=ToolTag.LOCAL | ToolTag.PUBLIC | ToolTag.SESSION_STATE)
async def todo_pop() -> str:
"""Pop the entire **top group** (all siblings from that push).
Prefer after TOP items are done/cancelled so the stack does not stay
@@ -92,8 +99,8 @@ def todo_pop() -> str:
return f"popped TOP group ({titles}); new top group=[{top_s}]\n{stack.render()}"
@tool(name="todo_update")
def todo_update(
@tool(name="todo_update", tags=ToolTag.LOCAL | ToolTag.PUBLIC | ToolTag.SESSION_STATE)
async def todo_update(
item_id: str,
status: str = "",
title: str = "",
@@ -125,8 +132,8 @@ def todo_update(
return f"updated {item.id}{item.status}: {item.title}\n{stack.render()}"
@tool(name="todo_clear")
def todo_clear() -> str:
@tool(name="todo_clear", tags=ToolTag.LOCAL | ToolTag.PUBLIC | ToolTag.SESSION_STATE)
async def todo_clear() -> str:
"""Clear all groups on the stack."""
stack = _require_stack()
n = stack.clear()
+11 -11
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from plyngent.agent import tool
from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import WorkspaceError, get_workspace_root
from .detect import detect_vcs
@@ -22,8 +22,8 @@ def _backend_or_error() -> VcsBackend | str:
return backend
@tool
def vcs_kind() -> str:
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
async def vcs_kind() -> str:
"""Return the detected VCS kind under the workspace (e.g. ``git``), or an error."""
backend = _backend_or_error()
if isinstance(backend, str):
@@ -31,8 +31,8 @@ def vcs_kind() -> str:
return backend.kind
@tool
def vcs_status() -> str:
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
async def vcs_status() -> str:
"""Show working-tree status for the detected VCS (read-only)."""
backend = _backend_or_error()
if isinstance(backend, str):
@@ -40,8 +40,8 @@ def vcs_status() -> str:
return backend.status()
@tool
def vcs_diff(path: str = "", *, staged: bool = False) -> str:
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
async def vcs_diff(path: str = "", *, staged: bool = False) -> str:
"""Show a unified diff for the detected VCS (read-only).
``path`` is optional and relative to the workspace. ``staged=true`` is
@@ -54,8 +54,8 @@ def vcs_diff(path: str = "", *, staged: bool = False) -> str:
return backend.diff(staged=staged, path=rel)
@tool
def vcs_log(limit: int = 10) -> str:
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
async def vcs_log(limit: int = 10) -> str:
"""Show recent commits for the detected VCS (read-only)."""
if limit < 1:
return "error: limit must be >= 1"
@@ -65,8 +65,8 @@ def vcs_log(limit: int = 10) -> str:
return backend.log(limit=limit)
@tool
def vcs_branch() -> str:
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
async def vcs_branch() -> str:
"""Show the current branch / named head for the detected VCS (read-only)."""
backend = _backend_or_error()
if isinstance(backend, str):