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 __future__ import annotations
from plyngent.agent import tool 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") @tool(name="ask_user_line", tags=ToolTag.LOCAL)
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.
+2 -2
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import json import json
from typing import cast from typing import cast
from plyngent.agent import tool from plyngent.agent import ToolTag, tool
from plyngent.prompting import ChoiceOption, NonInteractiveError, choose_async from plyngent.prompting import ChoiceOption, NonInteractiveError, choose_async
@@ -46,7 +46,7 @@ def parse_options(raw: str) -> list[ChoiceOption]:
return out return out
@tool(name="ask_user_choice") @tool(name="ask_user_choice", tags=ToolTag.LOCAL)
async def choose_user( async def choose_user(
question: str, question: str,
options: str, options: str,
+2 -2
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import json import json
from typing import cast 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.prompting import FormField, NonInteractiveError, form_async
from plyngent.tools.chat.choose import parse_options from plyngent.tools.chat.choose import parse_options
@@ -54,7 +54,7 @@ def parse_fields(raw: str) -> list[FormField]:
return out 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: 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. """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 typing import TYPE_CHECKING
from plyngent.agent import tool from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import resolve_path from plyngent.tools.workspace import resolve_path
if TYPE_CHECKING: 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}" return f"replaced lines {start_line}-{end} ({removed} lines) with {len(replacement)} lines in {path}"
@tool @tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
def edit_lineno(path: str, start_line: int, end_line: int, new_content: str) -> str: 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. """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. ``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 __future__ import annotations
from plyngent.agent import tool from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import resolve_path from plyngent.tools.workspace import resolve_path
@@ -32,8 +32,8 @@ def _success_message(path: str, *, replaced: int, found: int) -> str:
) )
@tool @tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
def edit_replace(path: str, old_string: str, new_string: str, max_replaces: int = 1) -> str: 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. """Replace occurrences of ``old_string`` with ``new_string`` in a file.
Replaces left-to-right, non-overlapping. Default ``max_replaces=1`` (first match 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 import shutil
from pathlib import Path 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 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}" return f"error: {action} failed: {exc}"
@tool @tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
def copy_path(src: str, dst: str, *, overwrite: bool = False) -> str: async def copy_path(src: str, dst: str, *, overwrite: bool = False) -> str:
"""Copy a file or directory under the workspace (``shutil.copy2`` / ``copytree``). """Copy a file or directory under the workspace (``shutil.copy2`` / ``copytree``).
``dst`` parent directories are created as needed. If ``dst`` exists, set ``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") return _copy_or_move_validated(source, dest, root, src=src, dst=dst, overwrite=overwrite, action="copy")
@tool @tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
def move_path(src: str, dst: str, *, overwrite: bool = False) -> str: async def move_path(src: str, dst: str, *, overwrite: bool = False) -> str:
"""Move/rename a file or directory under the workspace (``shutil.move``).""" """Move/rename a file or directory under the workspace (``shutil.move``)."""
pair = _resolve_pair(src, dst) pair = _resolve_pair(src, dst)
if isinstance(pair, str): 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}" return f"error: unsupported path type: {path}"
@tool @tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
def delete_path(path: str, *, recursive: bool = False) -> str: async def delete_path(path: str, *, recursive: bool = False) -> str:
"""Delete a file or directory under the workspace. """Delete a file or directory under the workspace.
Files and empty directories are removed always. Non-empty directories require 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 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.file.tree import VCS_DIR_NAMES
from plyngent.tools.workspace import WorkspaceError, get_workspace_root, resolve_path 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 return root, base
@tool @tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
def glob_paths( async def glob_paths(
pattern: str, pattern: str,
path: str = ".", path: str = ".",
*, *,
+3 -3
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import re import re
from typing import TYPE_CHECKING 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.file.tree import VCS_DIR_NAMES
from plyngent.tools.workspace import WorkspaceError, get_workspace_root, resolve_path 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}" return f"error: invalid regex: {exc}"
@tool @tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
def grep_files( async def grep_files(
pattern: str, pattern: str,
path: str = ".", path: str = ".",
*, *,
+3 -3
View File
@@ -1,11 +1,11 @@
from __future__ import annotations from __future__ import annotations
from plyngent.agent import tool from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import resolve_path from plyngent.tools.workspace import resolve_path
@tool @tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
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)
if not target.is_dir(): if not target.is_dir():
+3 -3
View File
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from plyngent.agent import tool from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import resolve_path from plyngent.tools.workspace import resolve_path
_LINENO_WIDTH = 6 _LINENO_WIDTH = 6
@@ -17,8 +17,8 @@ def _format_with_lineno(lines: list[str], *, start_lineno: int) -> str:
return "".join(out) return "".join(out)
@tool @tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
def read_file( async def read_file(
path: str, path: str,
*, *,
offset: int = 0, offset: int = 0,
+3 -3
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING 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 from plyngent.tools.workspace import WorkspaceError, get_path_denylist, resolve_path
if TYPE_CHECKING: 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) return frozenset(name for name in skip_dirs if name)
@tool @tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
def tree( async def tree(
path: str = ".", path: str = ".",
*, *,
max_depth: int = DEFAULT_MAX_DEPTH, max_depth: int = DEFAULT_MAX_DEPTH,
+3 -3
View File
@@ -1,11 +1,11 @@
from __future__ import annotations from __future__ import annotations
from plyngent.agent import tool from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import resolve_path from plyngent.tools.workspace import resolve_path
@tool @tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
def write_file(path: str, content: str) -> str: async def write_file(path: str, content: str) -> str:
"""Write text content to a file under the workspace (creates parents).""" """Write text content to a file under the workspace (creates parents)."""
target = resolve_path(path) target = resolve_path(path)
target.parent.mkdir(parents=True, exist_ok=True) 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 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.prompting import NonInteractiveError, ask_async, ask_secret_async
from plyngent.tools.workspace import WorkspaceError from plyngent.tools.workspace import WorkspaceError
@@ -38,7 +38,7 @@ async def _prompt_answer(label: str, *, secret: bool) -> _PromptResult:
return ("ok", answer) return ("ok", answer)
@tool @tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
async def ask_into_pty( async def ask_into_pty(
session_id: int, session_id: int,
message: str, message: str,
+2 -2
View File
@@ -2,12 +2,12 @@ from __future__ import annotations
import asyncio import asyncio
from plyngent.agent import tool from plyngent.agent import ToolTag, tool
from .pty_session import PtyManager, format_close_result from .pty_session import PtyManager, format_close_result
@tool @tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
async def close_pty(session_id: int) -> str: async def close_pty(session_id: int) -> str:
"""Close a PTY session (SIGTERM, then SIGKILL after a short grace period). """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 import shlex
from plyngent.agent import tool from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import WorkspaceError from plyngent.tools.workspace import WorkspaceError
from .pty_session import PtyManager from .pty_session import PtyManager
@tool @tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
def open_pty(command: list[str], *, cwd: str = ".") -> str: async def open_pty(command: list[str], *, cwd: str = ".") -> str:
"""Open a PTY session running ``command`` (argv) under the workspace. """Open a PTY session running ``command`` (argv) under the workspace.
POSIX uses openpty/fork; Windows uses ConPTY (pywinpty). Returns structured POSIX uses openpty/fork; Windows uses ConPTY (pywinpty). Returns structured
+2 -2
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import asyncio import asyncio
from plyngent.agent import tool from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import WorkspaceError from plyngent.tools.workspace import WorkspaceError
from .pty_session import DEFAULT_PTY_READ_BYTES, PtyManager, format_read_result 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 _MAX_READ_TIMEOUT = 120.0
@tool @tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
async def read_pty( async def read_pty(
session_id: int, session_id: int,
*, *,
+2 -2
View File
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from plyngent.agent import tool from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import WorkspaceError from plyngent.tools.workspace import WorkspaceError
from .command_exec import ( 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( async def run_command(
command: list[str], command: list[str],
*, *,
@@ -4,7 +4,7 @@ import json
import shlex import shlex
from typing import Any, cast from typing import Any, cast
from plyngent.agent import tool from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import WorkspaceError from plyngent.tools.workspace import WorkspaceError
from .command_exec import ( from .command_exec import (
@@ -154,7 +154,7 @@ async def _run_batch_steps(
return results, stopped_early return results, stopped_early
@tool @tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
async def run_command_batch( async def run_command_batch(
commands: list[dict[str, object]] | str, commands: list[dict[str, object]] | str,
*, *,
+3 -3
View File
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from plyngent.agent import tool from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import WorkspaceError from plyngent.tools.workspace import WorkspaceError
from .pty_session import PtyManager from .pty_session import PtyManager
@@ -21,8 +21,8 @@ def write_pty_payload(session_id: int, raw: str) -> str:
) )
@tool @tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
def write_pty(session_id: int, data: str) -> str: async def write_pty(session_id: int, data: str) -> str:
"""Write **literal** text to a PTY session. Does not append a newline. """Write **literal** text to a PTY session. Does not append a newline.
``data`` is sent unchanged (no ``ctrl+x`` / ``\\\\xHH`` expansion). For ``data`` is sent unchanged (no ``ctrl+x`` / ``\\\\xHH`` expansion). For
+3 -3
View File
@@ -1,14 +1,14 @@
from __future__ import annotations from __future__ import annotations
from plyngent.agent import tool from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import WorkspaceError from plyngent.tools.workspace import WorkspaceError
from .pty_terminal import decode_write_data from .pty_terminal import decode_write_data
from .write_pty import write_pty_payload from .write_pty import write_pty_payload
@tool @tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
def write_pty_keys(session_id: int, data: str) -> str: async def write_pty_keys(session_id: int, data: str) -> str:
"""Write to a PTY after expanding key escapes (never for normal typing). """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 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 import tempfile
from pathlib import Path from pathlib import Path
from plyngent.agent import tool from plyngent.agent import ToolTag, tool
from plyngent.tools.workspace import ( from plyngent.tools.workspace import (
MAX_TEMPORARY_WORKSPACES, MAX_TEMPORARY_WORKSPACES,
WorkspaceError, WorkspaceError,
@@ -61,17 +61,7 @@ def cleanup_temporary_workspaces() -> int:
return removed return removed
@tool def _create_temporary_workspace(prefix: str) -> str:
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.
"""
if len(list_workspace_allowlist()) >= MAX_TEMPORARY_WORKSPACES: if len(list_workspace_allowlist()) >= MAX_TEMPORARY_WORKSPACES:
return f"error: too many temporary workspaces (max {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; " "note: project workspace unchanged; use this absolute path for tools; "
"removed when chat exits" "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 typing import TYPE_CHECKING, cast
from plyngent.agent import tool from plyngent.agent import ToolTag, tool
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable from collections.abc import Callable
@@ -27,6 +27,12 @@ def get_todo_stack() -> TodoStack | None:
def _require_stack() -> TodoStack: 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: if _stack is None:
msg = "todo stack is not available in this session" msg = "todo stack is not available in this session"
raise RuntimeError(msg) raise RuntimeError(msg)
@@ -36,10 +42,11 @@ def _require_stack() -> TodoStack:
def _notify() -> None: def _notify() -> None:
if _on_change is not None: if _on_change is not None:
_on_change() _on_change()
# Session-bound stacks may also persist via host on_change on the process bind.
@tool(name="todo_list") @tool(name="todo_list", tags=ToolTag.LOCAL | ToolTag.PUBLIC | ToolTag.SESSION_STATE)
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).
Non-empty stack with open (pending/in_progress) items = unfinished work. Non-empty stack with open (pending/in_progress) items = unfinished work.
@@ -53,8 +60,8 @@ def todo_list() -> str:
return stack.render() return stack.render()
@tool(name="todo_push") @tool(name="todo_push", tags=ToolTag.LOCAL | ToolTag.PUBLIC | ToolTag.SESSION_STATE)
def todo_push(titles: list[str], notes: str = "") -> str: async def todo_push(titles: list[str], notes: str = "") -> str:
"""Push **one task group** (siblings) onto the stack — not one level per title. """Push **one task group** (siblings) onto the stack — not one level per title.
``titles``: JSON array of strings (tool schema type ``array``). All entries ``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()}" return f"pushed group (depth={stack.depth}) items=[{ids}]\n{stack.render()}"
@tool(name="todo_pop") @tool(name="todo_pop", tags=ToolTag.LOCAL | ToolTag.PUBLIC | ToolTag.SESSION_STATE)
def todo_pop() -> str: async def todo_pop() -> str:
"""Pop the entire **top group** (all siblings from that push). """Pop the entire **top group** (all siblings from that push).
Prefer after TOP items are done/cancelled so the stack does not stay 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()}" return f"popped TOP group ({titles}); new top group=[{top_s}]\n{stack.render()}"
@tool(name="todo_update") @tool(name="todo_update", tags=ToolTag.LOCAL | ToolTag.PUBLIC | ToolTag.SESSION_STATE)
def todo_update( async def todo_update(
item_id: str, item_id: str,
status: str = "", status: str = "",
title: str = "", title: str = "",
@@ -125,8 +132,8 @@ def todo_update(
return f"updated {item.id}{item.status}: {item.title}\n{stack.render()}" return f"updated {item.id}{item.status}: {item.title}\n{stack.render()}"
@tool(name="todo_clear") @tool(name="todo_clear", tags=ToolTag.LOCAL | ToolTag.PUBLIC | ToolTag.SESSION_STATE)
def todo_clear() -> str: async def todo_clear() -> str:
"""Clear all groups on the stack.""" """Clear all groups on the stack."""
stack = _require_stack() stack = _require_stack()
n = stack.clear() n = stack.clear()
+11 -11
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING 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 plyngent.tools.workspace import WorkspaceError, get_workspace_root
from .detect import detect_vcs from .detect import detect_vcs
@@ -22,8 +22,8 @@ def _backend_or_error() -> VcsBackend | str:
return backend return backend
@tool @tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
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()
if isinstance(backend, str): if isinstance(backend, str):
@@ -31,8 +31,8 @@ def vcs_kind() -> str:
return backend.kind return backend.kind
@tool @tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
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()
if isinstance(backend, str): if isinstance(backend, str):
@@ -40,8 +40,8 @@ def vcs_status() -> str:
return backend.status() return backend.status()
@tool @tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
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).
``path`` is optional and relative to the workspace. ``staged=true`` is ``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) return backend.diff(staged=staged, path=rel)
@tool @tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
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:
return "error: limit must be >= 1" return "error: limit must be >= 1"
@@ -65,8 +65,8 @@ def vcs_log(limit: int = 10) -> str:
return backend.log(limit=limit) return backend.log(limit=limit)
@tool @tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
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()
if isinstance(backend, str): if isinstance(backend, str):