tools: check every prompting tool's arguments before the prompt

This commit is contained in:
2026-11-02 14:47:50 +08:00
parent 809715bff6
commit 9027901168
13 changed files with 272 additions and 47 deletions
+2 -13
View File
@@ -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:
+28 -5
View File
@@ -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.
+20 -1
View 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.
+34 -1
View 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)
+14 -2
View File
@@ -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.
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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
+19 -1
View File
@@ -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).
+69
View File
@@ -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