mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-09 08:57:55 +08:00
tools: check every prompting tool's arguments before the prompt
This commit is contained in:
@@ -14,20 +14,9 @@ tools themselves stay free of unnecessary-isinstance warnings.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
_SHAPES: dict[type, str] = {
|
||||
str: "a string",
|
||||
list: "an array",
|
||||
dict: "an object",
|
||||
bool: "a boolean",
|
||||
int: "a number",
|
||||
float: "a number",
|
||||
type(None): "null",
|
||||
}
|
||||
from plyngent.tools.shape import shape_of
|
||||
|
||||
|
||||
def shape_of(value: object) -> str:
|
||||
"""Model-facing name for a decoded JSON value (``a string``, ``an array``…)."""
|
||||
return _SHAPES.get(type(value), type(value).__name__)
|
||||
__all__ = ["first_error", "list_error", "number_arg", "shape_of", "string_error"]
|
||||
|
||||
|
||||
def first_error(*errors: str | None) -> str | None:
|
||||
|
||||
@@ -3,9 +3,11 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from plyngent.agent import ToolTag, lineno_read_lines, lineno_read_mtime, tool
|
||||
from plyngent.tools.shape import shape_of
|
||||
from plyngent.tools.workspace import AccessMode, resolve_path
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
|
||||
# How many unread line numbers to preview in the error message.
|
||||
@@ -15,6 +17,28 @@ _UNREAD_PREVIEW = 8
|
||||
_READ_HINT_MARGIN = 3
|
||||
|
||||
|
||||
def _range_shape_error(start_line: object, end_line: object) -> str | None:
|
||||
"""The two range checks that need nothing but the two numbers."""
|
||||
if not isinstance(start_line, int) or not isinstance(end_line, int):
|
||||
return None # the schema declares integers; a bad pair stays the handler's to answer
|
||||
if start_line < 1:
|
||||
return "error: start_line must be >= 1"
|
||||
if end_line < start_line:
|
||||
return "error: end_line must be >= start_line"
|
||||
return None
|
||||
|
||||
|
||||
def edit_lineno_shape(args: Mapping[str, object]) -> str | None:
|
||||
"""Answer a range that cannot be applied, before anyone is asked about it."""
|
||||
shape_error = _range_shape_error(args.get("start_line"), args.get("end_line"))
|
||||
if shape_error is not None:
|
||||
return shape_error
|
||||
content = args.get("new_content")
|
||||
if content is not None and not isinstance(content, str):
|
||||
return f"error: `edit_lineno` expects `new_content` as a string, got {shape_of(content)}"
|
||||
return None
|
||||
|
||||
|
||||
def _detect_newline(lines: list[str]) -> str:
|
||||
for sample in lines:
|
||||
if sample.endswith("\r\n"):
|
||||
@@ -43,10 +67,9 @@ def _to_keepends(new_content: str, newline: str, *, force_trailing: bool) -> lis
|
||||
|
||||
|
||||
def _validate_range(start_line: int, end_line: int, n: int) -> str | None:
|
||||
if start_line < 1:
|
||||
return "error: start_line must be >= 1"
|
||||
if end_line < start_line:
|
||||
return "error: end_line must be >= start_line"
|
||||
shape_error = _range_shape_error(start_line, end_line)
|
||||
if shape_error is not None:
|
||||
return shape_error
|
||||
if start_line > n + 1:
|
||||
return f"error: start_line {start_line} past end of file ({n} lines)"
|
||||
if start_line == n + 1 and end_line != start_line:
|
||||
@@ -124,7 +147,7 @@ def _replace_range(
|
||||
)
|
||||
|
||||
|
||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO, writes=("path",))
|
||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO, writes=("path",), validate=edit_lineno_shape)
|
||||
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.
|
||||
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from plyngent.agent import ToolTag, tool
|
||||
from plyngent.tools.shape import shape_of
|
||||
from plyngent.tools.workspace import AccessMode, resolve_path
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping
|
||||
|
||||
|
||||
def edit_replace_shape(args: Mapping[str, object]) -> str | None:
|
||||
"""Answer a replacement that cannot apply, before anyone is asked about it."""
|
||||
old_string = args.get("old_string")
|
||||
if isinstance(old_string, str) and not old_string:
|
||||
return "error: old_string must not be empty"
|
||||
if not isinstance(old_string, str) and old_string is not None:
|
||||
return f"error: `edit_replace` expects `old_string` as a string, got {shape_of(old_string)}"
|
||||
limit = args.get("max_replaces")
|
||||
if isinstance(limit, int) and not isinstance(limit, bool) and limit < 1:
|
||||
return "error: max_replaces must be >= 1"
|
||||
return None
|
||||
|
||||
|
||||
def _count_non_overlapping(text: str, needle: str) -> int:
|
||||
"""Count left-to-right non-overlapping matches (same as ``str.replace``)."""
|
||||
@@ -48,7 +67,7 @@ def _success_message(
|
||||
)
|
||||
|
||||
|
||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO, writes=("path",))
|
||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO, writes=("path",), validate=edit_replace_shape)
|
||||
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.
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from plyngent.agent import ToolOutput, ToolTag, tool
|
||||
from plyngent.agent.images import image_part_from_bytes
|
||||
from plyngent.tools.context import is_read_only_context
|
||||
@@ -17,8 +19,36 @@ from plyngent.tools.net.policy import (
|
||||
parse_fetch_url,
|
||||
soft_confirm_reason,
|
||||
)
|
||||
from plyngent.tools.shape import shape_of, timeout_shape_error
|
||||
from plyngent.tools.workspace import image_input_refusal
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping
|
||||
|
||||
|
||||
def fetch_local_shape(args: Mapping[str, object]) -> str | None:
|
||||
"""Answer a request that cannot be built, before anyone is asked about it.
|
||||
|
||||
The verb and the URL are checked here (both are the request itself, and
|
||||
``parse_fetch_url`` touches nothing but the string); a host that needs a
|
||||
human grant still asks in the handler, once the call can actually run.
|
||||
"""
|
||||
url = args.get("url")
|
||||
if not isinstance(url, str):
|
||||
return f"error: `fetch_local` expects `url` as a string, got {shape_of(url)}"
|
||||
method = args.get("method", "GET")
|
||||
if not isinstance(method, str):
|
||||
return f"error: `fetch_local` expects `method` as a string, got {shape_of(method)}"
|
||||
timeout_error = timeout_shape_error(args.get("timeout_seconds"))
|
||||
if timeout_error is not None:
|
||||
return f"error: `fetch_local` {timeout_error}"
|
||||
try:
|
||||
_ = normalize_method(method)
|
||||
_ = parse_fetch_url(url)
|
||||
except FetchPolicyError as exc:
|
||||
return f"error: {exc}"
|
||||
return None
|
||||
|
||||
|
||||
def format_fetch_result(
|
||||
*,
|
||||
@@ -59,7 +89,10 @@ def format_fetch_result(
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO | ToolTag.TRUSTABLE | ToolTag.READ_ONLY)
|
||||
@tool(
|
||||
tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO | ToolTag.TRUSTABLE | ToolTag.READ_ONLY,
|
||||
validate=fetch_local_shape,
|
||||
)
|
||||
async def fetch_local(
|
||||
url: str,
|
||||
*,
|
||||
|
||||
@@ -45,26 +45,6 @@ def format_command_result(
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def timeout_shape_error(value: object) -> str | None:
|
||||
"""Model-facing error when ``timeout_seconds`` is not a positive number.
|
||||
|
||||
Numeric strings are accepted (models send them), anything else is answered
|
||||
before the call can start: a value that only fails inside ``wait_for`` has
|
||||
already spawned the process, so nothing would stop it.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool) or not isinstance(value, int | float | str):
|
||||
return f"timeout_seconds must be a number, not {type(value).__name__}"
|
||||
try:
|
||||
seconds = float(value)
|
||||
except ValueError:
|
||||
return f"timeout_seconds must be a number, not {value!r}"
|
||||
if seconds <= 0:
|
||||
return f"timeout_seconds must be greater than 0 (got {value!r})"
|
||||
return None
|
||||
|
||||
|
||||
def resolve_workdir(cwd: str) -> tuple["Path", str]:
|
||||
"""Return (absolute workdir, display path relative to workspace when possible)."""
|
||||
workdir = resolve_path(cwd, required=AccessMode.EXEC)
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shlex
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from plyngent.agent import ToolTag, tool
|
||||
from plyngent.tools.workspace import WorkspaceError
|
||||
from plyngent.tools.workspace import WorkspaceError, argv_shape_error
|
||||
|
||||
from .pty_session import active_pty_manager
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping
|
||||
|
||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
|
||||
|
||||
def open_pty_shape(args: Mapping[str, object]) -> str | None:
|
||||
"""Answer a session that cannot start, before anyone is asked about it."""
|
||||
shape_error = argv_shape_error(args.get("command"))
|
||||
if shape_error is not None:
|
||||
return f"error: `open_pty` {shape_error}"
|
||||
return None
|
||||
|
||||
|
||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO, validate=open_pty_shape)
|
||||
async def open_pty(command: list[str], *, cwd: str = ".") -> str:
|
||||
"""Open a PTY session running ``command`` (argv) under the workspace.
|
||||
|
||||
|
||||
@@ -3,13 +3,13 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from plyngent.agent import ToolTag, tool
|
||||
from plyngent.tools.shape import timeout_shape_error
|
||||
from plyngent.tools.workspace import WorkspaceError, argv_shape_error
|
||||
|
||||
from .command_exec import (
|
||||
DEFAULT_TIMEOUT_SECONDS,
|
||||
execute_argv,
|
||||
format_command_result,
|
||||
timeout_shape_error,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
@@ -6,6 +6,7 @@ from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from plyngent.agent import ToolTag, tool
|
||||
from plyngent.tools.shape import timeout_shape_error
|
||||
from plyngent.tools.workspace import WorkspaceError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -17,7 +18,6 @@ from .command_exec import (
|
||||
MAX_BATCH_DEPTH,
|
||||
CommandStepResult,
|
||||
execute_argv,
|
||||
timeout_shape_error,
|
||||
)
|
||||
|
||||
# Execution budget: stop the batch once accumulated output crosses this factor
|
||||
|
||||
@@ -1,13 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from plyngent.agent import ToolTag, tool
|
||||
from plyngent.tools.shape import shape_of
|
||||
from plyngent.tools.workspace import WorkspaceError
|
||||
|
||||
from .pty_terminal import KeySyntaxError, decode_write_data
|
||||
from .write_pty import write_pty_payload
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping
|
||||
|
||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
|
||||
|
||||
def write_pty_keys_shape(args: Mapping[str, object]) -> str | None:
|
||||
"""Answer a bad marker list, before anyone is asked about the call."""
|
||||
data = args.get("data")
|
||||
if not isinstance(data, str):
|
||||
return f"error: `write_pty_keys` expects `data` as a string, got {shape_of(data)}"
|
||||
try:
|
||||
_ = decode_write_data(data)
|
||||
except KeySyntaxError as exc:
|
||||
return f"error: {exc}"
|
||||
return None
|
||||
|
||||
|
||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO, validate=write_pty_keys_shape)
|
||||
async def write_pty_keys(session_id: int, data: str) -> str:
|
||||
"""Write to a PTY after expanding ``<key>`` markers (never for normal typing).
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Pre-prompt argument checks shared by tools.
|
||||
|
||||
Tool arguments arrive as decoded JSON, so a parameter declared ``float`` or
|
||||
``list[str]`` can still hold anything at runtime. A value the tool can judge
|
||||
without running a thing is answered *before* the call reaches a confirm prompt
|
||||
(a tool declares the check with ``@tool(validate=…)``, see
|
||||
:class:`plyngent.agent.tools.ToolDefinition`): the human is never asked about a
|
||||
call that could only fail, and a value that would only break halfway through
|
||||
(e.g. a deadline that fails inside ``wait_for``, after the process was spawned)
|
||||
never gets that far.
|
||||
|
||||
Every check takes ``object``, so the isinstance calls stay meaningful and the
|
||||
tools themselves stay free of unnecessary-isinstance warnings.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
_SHAPES: dict[type, str] = {
|
||||
str: "a string",
|
||||
list: "an array",
|
||||
dict: "an object",
|
||||
bool: "a boolean",
|
||||
int: "a number",
|
||||
float: "a number",
|
||||
type(None): "null",
|
||||
}
|
||||
|
||||
|
||||
def shape_of(value: object) -> str:
|
||||
"""Model-facing name for a decoded JSON value (``a string``, ``an array``…)."""
|
||||
return _SHAPES.get(type(value), type(value).__name__)
|
||||
|
||||
|
||||
def got(value: object) -> str:
|
||||
"""What a rejected value is, quoting a string (``'soon'``) instead of naming it."""
|
||||
return repr(value) if isinstance(value, str) else shape_of(value)
|
||||
|
||||
|
||||
def number_value(value: object) -> float | None:
|
||||
"""The number *value* carries, or ``None`` when it is not one.
|
||||
|
||||
Numeric strings are accepted (models send them); a boolean is not a number
|
||||
here, and neither is non-numeric text.
|
||||
"""
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
if isinstance(value, int | float):
|
||||
return float(value)
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return float(value.strip())
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def timeout_shape_error(value: object, *, name: str = "timeout_seconds") -> str | None:
|
||||
"""Error when *name* is not a positive number of seconds; ``None`` when it is.
|
||||
|
||||
``None`` means the argument was omitted (the tool's default applies).
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
seconds = number_value(value)
|
||||
if seconds is None:
|
||||
return f"{name} must be a number, not {got(value)}"
|
||||
if seconds <= 0:
|
||||
return f"{name} must be greater than 0 (got {value!r})"
|
||||
return None
|
||||
Reference in New Issue
Block a user