mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 23:47:54 +08:00
tools: answer a call's shape before the confirm prompt
This commit is contained in:
@@ -41,6 +41,7 @@ from .tools import ToolDefinition as ToolDefinition
|
|||||||
from .tools import ToolOutput as ToolOutput
|
from .tools import ToolOutput as ToolOutput
|
||||||
from .tools import ToolRegistry as ToolRegistry
|
from .tools import ToolRegistry as ToolRegistry
|
||||||
from .tools import ToolTag as ToolTag
|
from .tools import ToolTag as ToolTag
|
||||||
|
from .tools import ToolValidator as ToolValidator
|
||||||
from .tools import format_approval as format_approval
|
from .tools import format_approval as format_approval
|
||||||
from .tools import most_specific_approval as most_specific_approval
|
from .tools import most_specific_approval as most_specific_approval
|
||||||
from .tools import schema_from_callable as schema_from_callable
|
from .tools import schema_from_callable as schema_from_callable
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ class Denial:
|
|||||||
|
|
||||||
|
|
||||||
type ToolHandler = Callable[..., Any | Awaitable[Any]]
|
type ToolHandler = Callable[..., Any | Awaitable[Any]]
|
||||||
|
type ToolValidator = Callable[[Mapping[str, object]], str | None]
|
||||||
|
"""A tool's pre-prompt shape check: the error to answer with, or ``None``."""
|
||||||
type DangerClassifier = Callable[[str, Mapping[str, object]], str | DangerReason | None]
|
type DangerClassifier = Callable[[str, Mapping[str, object]], str | DangerReason | None]
|
||||||
# Returns True to allow, or a Denial (or a bare str comment) to deny.
|
# Returns True to allow, or a Denial (or a bare str comment) to deny.
|
||||||
type ToolConfirmResult = bool | str | Denial
|
type ToolConfirmResult = bool | str | Denial
|
||||||
@@ -254,6 +256,11 @@ class ToolDefinition:
|
|||||||
``writes`` names the arguments whose values name files the handler writes.
|
``writes`` names the arguments whose values name files the handler writes.
|
||||||
Calls of one batch that name the same file apply one at a time, in call
|
Calls of one batch that name the same file apply one at a time, in call
|
||||||
order, so the later one sees what the earlier one wrote.
|
order, so the later one sees what the earlier one wrote.
|
||||||
|
|
||||||
|
``validate`` answers an argument-shape problem the tool can see without
|
||||||
|
running anything (a value that is not the type it needs, a tree that cannot
|
||||||
|
be built). The registry asks it before any confirm prompt, so the human is
|
||||||
|
never shown a call that could only fail.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
@@ -262,6 +269,7 @@ class ToolDefinition:
|
|||||||
handler: ToolHandler
|
handler: ToolHandler
|
||||||
tags: ToolTag
|
tags: ToolTag
|
||||||
writes: tuple[str, ...]
|
writes: tuple[str, ...]
|
||||||
|
validate: ToolValidator | None
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -272,6 +280,7 @@ class ToolDefinition:
|
|||||||
*,
|
*,
|
||||||
tags: ToolTag = ToolTag.LOCAL,
|
tags: ToolTag = ToolTag.LOCAL,
|
||||||
writes: tuple[str, ...] = (),
|
writes: tuple[str, ...] = (),
|
||||||
|
validate: ToolValidator | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if not (tags & (ToolTag.LOCAL | ToolTag.PUBLIC)):
|
if not (tags & (ToolTag.LOCAL | ToolTag.PUBLIC)):
|
||||||
msg = f"tool {name!r} tags must include LOCAL and/or PUBLIC"
|
msg = f"tool {name!r} tags must include LOCAL and/or PUBLIC"
|
||||||
@@ -282,6 +291,7 @@ class ToolDefinition:
|
|||||||
self.handler = handler
|
self.handler = handler
|
||||||
self.tags = tags
|
self.tags = tags
|
||||||
self.writes = writes
|
self.writes = writes
|
||||||
|
self.validate = validate
|
||||||
|
|
||||||
def to_tool_item(self) -> ToolFunctionItem:
|
def to_tool_item(self) -> ToolFunctionItem:
|
||||||
return ToolFunctionItem(
|
return ToolFunctionItem(
|
||||||
@@ -342,6 +352,7 @@ def _build_definition(
|
|||||||
description: str | None,
|
description: str | None,
|
||||||
tags: ToolTag,
|
tags: ToolTag,
|
||||||
writes: tuple[str, ...] = (),
|
writes: tuple[str, ...] = (),
|
||||||
|
validate: ToolValidator | None = None,
|
||||||
) -> ToolDefinition:
|
) -> ToolDefinition:
|
||||||
tool_name = name or func.__name__
|
tool_name = name or func.__name__
|
||||||
tool_description = description if description is not None else (inspect.getdoc(func) or "")
|
tool_description = description if description is not None else (inspect.getdoc(func) or "")
|
||||||
@@ -352,6 +363,7 @@ def _build_definition(
|
|||||||
handler=func,
|
handler=func,
|
||||||
tags=tags,
|
tags=tags,
|
||||||
writes=writes,
|
writes=writes,
|
||||||
|
validate=validate,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -376,6 +388,7 @@ def tool[**PS, R](
|
|||||||
description: str | None = None,
|
description: str | None = None,
|
||||||
tags: ToolTag = ToolTag.LOCAL,
|
tags: ToolTag = ToolTag.LOCAL,
|
||||||
writes: tuple[str, ...] = (),
|
writes: tuple[str, ...] = (),
|
||||||
|
validate: ToolValidator | None = None,
|
||||||
register: bool = True,
|
register: bool = True,
|
||||||
) -> Callable[[Callable[PS, R]], ToolDefinition]: ...
|
) -> Callable[[Callable[PS, R]], ToolDefinition]: ...
|
||||||
|
|
||||||
@@ -388,6 +401,7 @@ def tool[**PS, R](
|
|||||||
description: str | None = None,
|
description: str | None = None,
|
||||||
tags: ToolTag = ToolTag.LOCAL,
|
tags: ToolTag = ToolTag.LOCAL,
|
||||||
writes: tuple[str, ...] = (),
|
writes: tuple[str, ...] = (),
|
||||||
|
validate: ToolValidator | None = None,
|
||||||
register: bool = True,
|
register: bool = True,
|
||||||
) -> ToolDefinition | Callable[[Callable[PS, R]], ToolDefinition]:
|
) -> ToolDefinition | Callable[[Callable[PS, R]], ToolDefinition]:
|
||||||
"""Define (and by default catalog-register) a function as an agent tool.
|
"""Define (and by default catalog-register) a function as an agent tool.
|
||||||
@@ -395,6 +409,8 @@ def tool[**PS, R](
|
|||||||
Schema is inferred from type hints; description defaults to the docstring.
|
Schema is inferred from type hints; description defaults to the docstring.
|
||||||
Default ``tags`` is :attr:`ToolTag.LOCAL`. ``writes`` names the arguments
|
Default ``tags`` is :attr:`ToolTag.LOCAL`. ``writes`` names the arguments
|
||||||
that carry the files the handler writes (:attr:`ToolDefinition.writes`).
|
that carry the files the handler writes (:attr:`ToolDefinition.writes`).
|
||||||
|
``validate`` answers an argument-shape problem **before** the call is put in
|
||||||
|
front of the human (:attr:`ToolDefinition.validate`).
|
||||||
When ``register`` is true, the definition is added to the process
|
When ``register`` is true, the definition is added to the process
|
||||||
:class:`~plyngent.tools.catalog.ToolCatalog` with the current registration
|
:class:`~plyngent.tools.catalog.ToolCatalog` with the current registration
|
||||||
source (builtin unless a plugin context is set). Catalog registration does
|
source (builtin unless a plugin context is set). Catalog registration does
|
||||||
@@ -409,6 +425,7 @@ def tool[**PS, R](
|
|||||||
description=description,
|
description=description,
|
||||||
tags=tags,
|
tags=tags,
|
||||||
writes=writes,
|
writes=writes,
|
||||||
|
validate=validate,
|
||||||
)
|
)
|
||||||
if register:
|
if register:
|
||||||
_register_definition(definition)
|
_register_definition(definition)
|
||||||
@@ -443,6 +460,18 @@ def _missing_required_error(definition: ToolDefinition, args: Mapping[str, objec
|
|||||||
return f"error: `{definition.name}` is missing required argument(s): {', '.join(missing)}{hint}"
|
return f"error: `{definition.name}` is missing required argument(s): {', '.join(missing)}{hint}"
|
||||||
|
|
||||||
|
|
||||||
|
def _shape_error(definition: ToolDefinition, args: Mapping[str, object]) -> str | None:
|
||||||
|
"""Model-facing error when the tool's own pre-prompt check refuses the call.
|
||||||
|
|
||||||
|
Shape mistakes inside a *present* argument used to surface in the handler,
|
||||||
|
i.e. after the confirm prompt, so the human could be asked about a call that
|
||||||
|
could only fail. A tool that declares ``validate`` is answered first.
|
||||||
|
"""
|
||||||
|
if definition.validate is None:
|
||||||
|
return None
|
||||||
|
return definition.validate(args)
|
||||||
|
|
||||||
|
|
||||||
def _decode_tool_args(arguments_json: str) -> dict[str, object] | str:
|
def _decode_tool_args(arguments_json: str) -> dict[str, object] | str:
|
||||||
"""Decode one call's arguments; a returned string is the model-facing error."""
|
"""Decode one call's arguments; a returned string is the model-facing error."""
|
||||||
try:
|
try:
|
||||||
@@ -730,6 +759,8 @@ class ToolRegistry:
|
|||||||
args = _decode_tool_args(arguments_json)
|
args = _decode_tool_args(arguments_json)
|
||||||
if isinstance(args, str) or _missing_required_error(definition, args) is not None:
|
if isinstance(args, str) or _missing_required_error(definition, args) is not None:
|
||||||
return None
|
return None
|
||||||
|
if _shape_error(definition, args) is not None:
|
||||||
|
return None
|
||||||
reason = _as_danger_reason(self._danger(name, args))
|
reason = _as_danger_reason(self._danger(name, args))
|
||||||
if reason is None or (self._yolo and (definition.tags & ToolTag.YOLO) and reason.yolo_skip):
|
if reason is None or (self._yolo and (definition.tags & ToolTag.YOLO) and reason.yolo_skip):
|
||||||
return None
|
return None
|
||||||
@@ -866,6 +897,9 @@ class ToolRegistry:
|
|||||||
missing_error = _missing_required_error(definition, args)
|
missing_error = _missing_required_error(definition, args)
|
||||||
if missing_error is not None:
|
if missing_error is not None:
|
||||||
return ToolOutput(text=missing_error)
|
return ToolOutput(text=missing_error)
|
||||||
|
check_error = _shape_error(definition, args)
|
||||||
|
if check_error is not None:
|
||||||
|
return ToolOutput(text=check_error)
|
||||||
|
|
||||||
async def _run() -> ToolOutput:
|
async def _run() -> ToolOutput:
|
||||||
from plyngent.tools.context import collect_approvals
|
from plyngent.tools.context import collect_approvals
|
||||||
|
|||||||
@@ -45,6 +45,26 @@ def format_command_result(
|
|||||||
return "\n".join(parts)
|
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]:
|
def resolve_workdir(cwd: str) -> tuple["Path", str]:
|
||||||
"""Return (absolute workdir, display path relative to workspace when possible)."""
|
"""Return (absolute workdir, display path relative to workspace when possible)."""
|
||||||
workdir = resolve_path(cwd, required=AccessMode.EXEC)
|
workdir = resolve_path(cwd, required=AccessMode.EXEC)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from plyngent.agent import ToolTag, tool
|
from plyngent.agent import ToolTag, tool
|
||||||
from plyngent.tools.workspace import WorkspaceError, argv_shape_error
|
from plyngent.tools.workspace import WorkspaceError, argv_shape_error
|
||||||
|
|
||||||
@@ -7,10 +9,25 @@ from .command_exec import (
|
|||||||
DEFAULT_TIMEOUT_SECONDS,
|
DEFAULT_TIMEOUT_SECONDS,
|
||||||
execute_argv,
|
execute_argv,
|
||||||
format_command_result,
|
format_command_result,
|
||||||
|
timeout_shape_error,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Mapping
|
||||||
|
|
||||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
|
|
||||||
|
def run_argv_shape(args: Mapping[str, object]) -> str | None:
|
||||||
|
"""Answer a ``run_argv`` call whose arguments cannot run, before any prompt."""
|
||||||
|
shape_error = argv_shape_error(args.get("argv"))
|
||||||
|
if shape_error is not None:
|
||||||
|
return f"error: `run_argv` {shape_error}"
|
||||||
|
timeout_error = timeout_shape_error(args.get("timeout_seconds"))
|
||||||
|
if timeout_error is not None:
|
||||||
|
return f"error: `run_argv` {timeout_error}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO, validate=run_argv_shape)
|
||||||
async def run_argv(
|
async def run_argv(
|
||||||
argv: list[str],
|
argv: list[str],
|
||||||
*,
|
*,
|
||||||
@@ -20,9 +37,9 @@ async def run_argv(
|
|||||||
env: dict[str, str] | None = None,
|
env: dict[str, str] | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Run ``argv`` (argv, no shell) under the workspace; capture stdout/stderr."""
|
"""Run ``argv`` (argv, no shell) under the workspace; capture stdout/stderr."""
|
||||||
shape_error = argv_shape_error(argv)
|
shape_error = run_argv_shape({"argv": argv, "timeout_seconds": timeout_seconds})
|
||||||
if shape_error is not None:
|
if shape_error is not None:
|
||||||
return f"error: `run_argv` {shape_error}"
|
return shape_error
|
||||||
try:
|
try:
|
||||||
result = await execute_argv(
|
result = await execute_argv(
|
||||||
argv,
|
argv,
|
||||||
|
|||||||
@@ -3,17 +3,21 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import shlex
|
import shlex
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, cast
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
|
|
||||||
from plyngent.agent import ToolTag, tool
|
from plyngent.agent import ToolTag, tool
|
||||||
from plyngent.tools.workspace import WorkspaceError
|
from plyngent.tools.workspace import WorkspaceError
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Mapping
|
||||||
|
|
||||||
from .command_exec import (
|
from .command_exec import (
|
||||||
DEFAULT_MAX_BATCH_STEPS,
|
DEFAULT_MAX_BATCH_STEPS,
|
||||||
DEFAULT_TIMEOUT_SECONDS,
|
DEFAULT_TIMEOUT_SECONDS,
|
||||||
MAX_BATCH_DEPTH,
|
MAX_BATCH_DEPTH,
|
||||||
CommandStepResult,
|
CommandStepResult,
|
||||||
execute_argv,
|
execute_argv,
|
||||||
|
timeout_shape_error,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Execution budget: stop the batch once accumulated output crosses this factor
|
# Execution budget: stop the batch once accumulated output crosses this factor
|
||||||
@@ -118,6 +122,10 @@ def _parse_node(raw: object, *, where: str, depth: int, budget: _Budget, fed: bo
|
|||||||
if fed and stdin is not None:
|
if fed and stdin is not None:
|
||||||
msg = f"{where}: stdin comes from the pipe; drop stdin or split the command"
|
msg = f"{where}: stdin comes from the pipe; drop stdin or split the command"
|
||||||
raise WorkspaceError(msg)
|
raise WorkspaceError(msg)
|
||||||
|
timeout_error = timeout_shape_error(data.get("timeout_seconds"))
|
||||||
|
if timeout_error is not None:
|
||||||
|
msg = f"{where}.{timeout_error}"
|
||||||
|
raise WorkspaceError(msg)
|
||||||
cwd = data.get("cwd")
|
cwd = data.get("cwd")
|
||||||
stop = data.get("stop_on_error", None)
|
stop = data.get("stop_on_error", None)
|
||||||
node: dict[str, Any] = {
|
node: dict[str, Any] = {
|
||||||
@@ -141,7 +149,7 @@ def _parse_node(raw: object, *, where: str, depth: int, budget: _Budget, fed: bo
|
|||||||
return node
|
return node
|
||||||
|
|
||||||
|
|
||||||
def _normalize_steps(steps: list[dict[str, object]] | str) -> list[object]:
|
def _normalize_steps(steps: object) -> list[object]:
|
||||||
if isinstance(steps, str):
|
if isinstance(steps, str):
|
||||||
try:
|
try:
|
||||||
loaded: object = json.loads(steps)
|
loaded: object = json.loads(steps)
|
||||||
@@ -152,9 +160,33 @@ def _normalize_steps(steps: list[dict[str, object]] | str) -> list[object]:
|
|||||||
msg = "steps must be a JSON array of step objects"
|
msg = "steps must be a JSON array of step objects"
|
||||||
raise WorkspaceError(msg)
|
raise WorkspaceError(msg)
|
||||||
return cast("list[object]", loaded)
|
return cast("list[object]", loaded)
|
||||||
|
if not isinstance(steps, list):
|
||||||
|
msg = "steps must be a JSON array of step objects"
|
||||||
|
raise WorkspaceError(msg)
|
||||||
return cast("list[object]", steps)
|
return cast("list[object]", steps)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_batch(steps: object) -> tuple[list[dict[str, Any]], int]:
|
||||||
|
"""Parse a batch tree and count its nodes; raises when it cannot run."""
|
||||||
|
raw_steps = _normalize_steps(steps)
|
||||||
|
if not raw_steps:
|
||||||
|
msg = "steps must not be empty"
|
||||||
|
raise WorkspaceError(msg)
|
||||||
|
budget = _Budget()
|
||||||
|
return _parse_nodes(raw_steps, where="steps", depth=0, budget=budget, fed=False), budget.nodes
|
||||||
|
|
||||||
|
|
||||||
|
def run_argv_batch_shape(args: Mapping[str, object]) -> str | None:
|
||||||
|
"""Answer a batch that cannot run, before it reaches the human."""
|
||||||
|
try:
|
||||||
|
_ = _read_batch(args.get("steps"))
|
||||||
|
except WorkspaceError as exc:
|
||||||
|
return f"error: {exc}"
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
return f"error: invalid batch arguments: {exc}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class _Step:
|
class _Step:
|
||||||
"""One executed command, and whether its stdout fed another one."""
|
"""One executed command, and whether its stdout fed another one."""
|
||||||
@@ -288,7 +320,10 @@ class _Runner:
|
|||||||
return self.stop_on_error if node["stop_on_error"] is None else bool(node["stop_on_error"])
|
return self.stop_on_error if node["stop_on_error"] is None else bool(node["stop_on_error"])
|
||||||
|
|
||||||
|
|
||||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO)
|
@tool(
|
||||||
|
tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE | ToolTag.YOLO,
|
||||||
|
validate=run_argv_batch_shape,
|
||||||
|
)
|
||||||
async def run_argv_batch(
|
async def run_argv_batch(
|
||||||
steps: list[dict[str, object]] | str,
|
steps: list[dict[str, object]] | str,
|
||||||
*,
|
*,
|
||||||
@@ -323,12 +358,12 @@ async def run_argv_batch(
|
|||||||
long result carries a ``document_id`` to read on.
|
long result carries a ``document_id`` to read on.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
raw_steps = _normalize_steps(steps)
|
nodes, count = _read_batch(steps)
|
||||||
if not raw_steps:
|
except WorkspaceError as exc:
|
||||||
return "error: steps must not be empty"
|
return f"error: {exc}"
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
return f"error: invalid batch arguments: {exc}"
|
||||||
|
|
||||||
budget = _Budget()
|
|
||||||
nodes = _parse_nodes(raw_steps, where="steps", depth=0, budget=budget, fed=False)
|
|
||||||
runner = _Runner(cwd=cwd, env=env, stop_on_error=stop_on_error)
|
runner = _Runner(cwd=cwd, env=env, stop_on_error=stop_on_error)
|
||||||
await runner.run_list(nodes, depth=0)
|
await runner.run_list(nodes, depth=0)
|
||||||
|
|
||||||
@@ -336,7 +371,7 @@ async def run_argv_batch(
|
|||||||
last_exit = runner.steps[-1].result.exit_code if runner.steps else ""
|
last_exit = runner.steps[-1].result.exit_code if runner.steps else ""
|
||||||
return "\n".join(
|
return "\n".join(
|
||||||
[
|
[
|
||||||
f"steps={budget.nodes} ran={len(runner.steps)} "
|
f"steps={count} ran={len(runner.steps)} "
|
||||||
f"stop_on_error={'true' if stop_on_error else 'false'} "
|
f"stop_on_error={'true' if stop_on_error else 'false'} "
|
||||||
f"stopped_early={'true' if runner.stopped else 'false'}",
|
f"stopped_early={'true' if runner.stopped else 'false'}",
|
||||||
body,
|
body,
|
||||||
@@ -344,7 +379,3 @@ async def run_argv_batch(
|
|||||||
f"last_exit={last_exit if last_exit is not None else ''}",
|
f"last_exit={last_exit if last_exit is not None else ''}",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
except WorkspaceError as exc:
|
|
||||||
return f"error: {exc}"
|
|
||||||
except (TypeError, ValueError) as exc:
|
|
||||||
return f"error: invalid batch arguments: {exc}"
|
|
||||||
|
|||||||
@@ -105,6 +105,42 @@ async def test_round_asks_once_for_many_mutating_calls() -> None:
|
|||||||
assert single_prompts == []
|
assert single_prompts == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_call_that_cannot_run_is_left_out_of_the_round_prompt() -> None:
|
||||||
|
"""A tool's own shape check answers first, so the round prompt omits the call."""
|
||||||
|
batch_prompts: list[list[ToolConfirmRequest]] = []
|
||||||
|
|
||||||
|
def positive(args: Mapping[str, object]) -> str | None:
|
||||||
|
count = args.get("count")
|
||||||
|
if isinstance(count, int) and count <= 0:
|
||||||
|
return "error: count must be positive"
|
||||||
|
return None
|
||||||
|
|
||||||
|
@tool(tags=ToolTag.LOCAL, register=False, validate=positive)
|
||||||
|
async def scale(count: int = 1) -> str:
|
||||||
|
return f"scaled {count}"
|
||||||
|
|
||||||
|
def on_batch(requests: Sequence[ToolConfirmRequest]) -> BatchConfirmDecision:
|
||||||
|
batch_prompts.append(list(requests))
|
||||||
|
return BatchConfirmDecision(allowed=True)
|
||||||
|
|
||||||
|
reg = ToolRegistry([scale], danger=_danger, on_confirm_batch=on_batch)
|
||||||
|
calls = [("scale", '{"count": 2}'), ("scale", '{"count": 0}'), ("scale", '{"count": 3}')]
|
||||||
|
decisions = await reg.prepare_batch_confirm(calls)
|
||||||
|
|
||||||
|
assert len(batch_prompts) == 1
|
||||||
|
assert [request.args["count"] for request in batch_prompts[0]] == [2, 3]
|
||||||
|
assert decisions[1] is None
|
||||||
|
|
||||||
|
results = [
|
||||||
|
await reg.execute(name, arguments, decision=decisions[index]) for index, (name, arguments) in enumerate(calls)
|
||||||
|
]
|
||||||
|
assert results == [
|
||||||
|
_approved("user (round)", "scaled 2"),
|
||||||
|
"error: count must be positive",
|
||||||
|
_approved("user (round)", "scaled 3"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
async def test_yolo_calls_are_left_out_of_the_prompt() -> None:
|
async def test_yolo_calls_are_left_out_of_the_prompt() -> None:
|
||||||
"""Under YOLO mode a YOLO-tagged call runs unprompted, batch prompt included."""
|
"""Under YOLO mode a YOLO-tagged call runs unprompted, batch prompt included."""
|
||||||
batch_prompts: list[list[str]] = []
|
batch_prompts: list[list[str]] = []
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from plyngent.agent import ToolDefinition, ToolRegistry
|
||||||
|
from plyngent.tools.danger import classify_danger
|
||||||
from plyngent.tools.process import (
|
from plyngent.tools.process import (
|
||||||
ask_into_pty,
|
ask_into_pty,
|
||||||
close_pty,
|
close_pty,
|
||||||
@@ -20,6 +24,9 @@ from plyngent.tools.process.pty_session import PtyManager
|
|||||||
from plyngent.tools.workspace import set_command_denylist
|
from plyngent.tools.workspace import set_command_denylist
|
||||||
from tests.test_tools.helpers import call_async, call_sync
|
from tests.test_tools.helpers import call_async, call_sync
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Mapping
|
||||||
|
|
||||||
|
|
||||||
def _session_id(opened: str) -> int:
|
def _session_id(opened: str) -> int:
|
||||||
for line in opened.splitlines():
|
for line in opened.splitlines():
|
||||||
@@ -299,6 +306,49 @@ async def test_run_argv_batch_names_the_node_grammar(workspace: object) -> None:
|
|||||||
assert "then, else, pipe" in out
|
assert "then, else, pipe" in out
|
||||||
|
|
||||||
|
|
||||||
|
def _asking_registry(*definitions: ToolDefinition) -> tuple[ToolRegistry, list[str]]:
|
||||||
|
"""A registry whose confirm hook records the calls it was asked about."""
|
||||||
|
asked: list[str] = []
|
||||||
|
|
||||||
|
def on_confirm(name: str, args: Mapping[str, object], reason: str) -> bool:
|
||||||
|
del args, reason
|
||||||
|
asked.append(name)
|
||||||
|
return True
|
||||||
|
|
||||||
|
return ToolRegistry(list(definitions), danger=classify_danger, on_confirm=on_confirm), asked
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_bad_timeout_is_answered_before_the_prompt(workspace: object) -> None:
|
||||||
|
"""A call that cannot run is answered, never put in front of the human."""
|
||||||
|
del workspace
|
||||||
|
registry, asked = _asking_registry(run_argv)
|
||||||
|
out = await registry.execute(
|
||||||
|
"run_argv",
|
||||||
|
json.dumps({"argv": ["bash", "-c", "echo hi"], "timeout_seconds": "soon"}),
|
||||||
|
)
|
||||||
|
assert asked == []
|
||||||
|
assert "timeout_seconds" in out
|
||||||
|
assert "error:" in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_broken_batch_is_answered_before_the_prompt(workspace: object) -> None:
|
||||||
|
del workspace
|
||||||
|
registry, asked = _asking_registry(run_argv_batch)
|
||||||
|
out = await registry.execute(
|
||||||
|
"run_argv_batch",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"steps": [
|
||||||
|
{"argv": ["bash", "-c", "echo hi"]},
|
||||||
|
{"argv": ["echo", "x"], "pipe": {"argv": ["echo", "y"], "stdin": "own"}},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert asked == []
|
||||||
|
assert "stdin comes from the pipe" in out
|
||||||
|
|
||||||
|
|
||||||
async def test_run_argv_denied(workspace: object) -> None:
|
async def test_run_argv_denied(workspace: object) -> None:
|
||||||
del workspace
|
del workspace
|
||||||
out = await call_async(run_argv, ["rm", "-rf", "."])
|
out = await call_async(run_argv, ["rm", "-rf", "."])
|
||||||
|
|||||||
Reference in New Issue
Block a user