mirror of
https://github.com/NCBM/plyngent.git
synced 2026-07-23 05:55:16 +08:00
core/prompting+tools: shared ask/choose/form and chat tools
Add process-wide prompting backends for interactive human I/O; wire CLI limit/confirm/workspace prompts through it. Expose ask_user, choose_user, and form_user under tools/chat and include them in DEFAULT_TOOLS.
This commit is contained in:
@@ -72,7 +72,12 @@ Module-level `@tool` handlers. Call `set_workspace_root()` before use.
|
|||||||
- CLI limit hooks: interactive confirm to raise tool-loop rounds, PTY session cap, or PTY output budget.
|
- CLI limit hooks: interactive confirm to raise tool-loop rounds, PTY session cap, or PTY output budget.
|
||||||
- Destructive confirms: `classify_danger` + `ToolRegistry(on_confirm=…)`; CLI default deny; config `confirm_destructive` / `path_denylist`.
|
- Destructive confirms: `classify_danger` + `ToolRegistry(on_confirm=…)`; CLI default deny; config `confirm_destructive` / `path_denylist`.
|
||||||
- **`vcs`**: read-only VCS tools (`vcs_kind` / `vcs_status` / `vcs_diff` / `vcs_log` / `vcs_branch`) via `VcsBackend` protocol; **git** implemented; detectors are pluggable for other systems.
|
- **`vcs`**: read-only VCS tools (`vcs_kind` / `vcs_status` / `vcs_diff` / `vcs_log` / `vcs_branch`) via `VcsBackend` protocol; **git** implemented; detectors are pluggable for other systems.
|
||||||
- **`DEFAULT_TOOLS`**: file + process + vcs tool list for a `ToolRegistry`.
|
- **`chat`**: human prompts as tools — `ask_user`, `choose_user`, `form_user` (shared `prompting` core).
|
||||||
|
- **`DEFAULT_TOOLS`**: file + process + vcs + chat tool list for a `ToolRegistry`.
|
||||||
|
|
||||||
|
### Prompting (`prompting.py`)
|
||||||
|
|
||||||
|
Shared interactive I/O: `ask` / `choose` / `form` / `confirm` with pluggable backend; non-TTY uses defaults or errors. CLI limit/confirm hooks and chat tools both use this. Async helpers serialize prompts (`run_prompt_async`).
|
||||||
|
|
||||||
### CLI (`cli/`)
|
### CLI (`cli/`)
|
||||||
|
|
||||||
@@ -102,7 +107,8 @@ Basedpyright `recommended`. Ruff includes `ANN` (private return types `ANN202` i
|
|||||||
- **No local tokenizer stage** for now.
|
- **No local tokenizer stage** for now.
|
||||||
- **Phase E**: tooling depth (grep/glob, VCS backends; prefer `edit_replace` / `edit_lineno` over model-generated patches).
|
- **Phase E**: tooling depth (grep/glob, VCS backends; prefer `edit_replace` / `edit_lineno` over model-generated patches).
|
||||||
- **Phase F (providers + usage v2)**: API `usage` + char-based estimate fallback; session/turn totals; `/status` + end-of-turn. Optional later: cost, real tokenizer.
|
- **Phase F (providers + usage v2)**: API `usage` + char-based estimate fallback; session/turn totals; `/status` + end-of-turn. Optional later: cost, real tokenizer.
|
||||||
- **Phase G–H**: CLI polish, hardening; then multi-tenant platform (`router/`, auth, sandboxed tools).
|
- **Phase G (CLI polish + hardening)**: shared `prompting` (`ask`/`choose`/`form`) + chat tools; then display/session/export/multiline/one-shot; agent/tools hardening; README. Multi-tenant stays Phase H.
|
||||||
|
- **Phase H**: multi-tenant platform (`router/`, auth, sandboxed tools, web).
|
||||||
|
|
||||||
## Commit messages
|
## Commit messages
|
||||||
|
|
||||||
|
|||||||
+105
-35
@@ -2,9 +2,16 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import TYPE_CHECKING, Literal
|
from typing import TYPE_CHECKING, Literal
|
||||||
|
|
||||||
import click
|
from plyngent.cli.interrupt import pause_task_cancel_for_prompt
|
||||||
|
from plyngent.prompting import (
|
||||||
from plyngent.cli.interrupt import pause_task_cancel_for_prompt, run_in_prompt_thread
|
ChoiceOption,
|
||||||
|
NonInteractiveError,
|
||||||
|
choose,
|
||||||
|
choose_async,
|
||||||
|
configure_prompting,
|
||||||
|
confirm,
|
||||||
|
confirm_async,
|
||||||
|
)
|
||||||
from plyngent.tools.process.pty_session import PtyManager
|
from plyngent.tools.process.pty_session import PtyManager
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -14,11 +21,9 @@ type WorkspaceMismatchChoice = Literal["keep", "rebind", "abort"]
|
|||||||
|
|
||||||
|
|
||||||
def _prompt_continue_limit_sync(reason: str) -> bool:
|
def _prompt_continue_limit_sync(reason: str) -> bool:
|
||||||
click.echo()
|
|
||||||
click.secho(f"[limit] {reason}", fg="yellow")
|
|
||||||
try:
|
try:
|
||||||
return bool(click.confirm("Raise limit and continue?", default=True))
|
return confirm(f"[limit] {reason}\nRaise limit and continue?", default=True)
|
||||||
except click.Abort, KeyboardInterrupt:
|
except NonInteractiveError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -30,16 +35,23 @@ def prompt_continue_limit(reason: str) -> bool:
|
|||||||
|
|
||||||
async def prompt_continue_limit_async(reason: str) -> bool:
|
async def prompt_continue_limit_async(reason: str) -> bool:
|
||||||
"""Async variant: confirm off the event loop so the turn is not cancelled."""
|
"""Async variant: confirm off the event loop so the turn is not cancelled."""
|
||||||
return await run_in_prompt_thread(_prompt_continue_limit_sync, reason)
|
try:
|
||||||
|
return await confirm_async(
|
||||||
|
f"[limit] {reason}\nRaise limit and continue?",
|
||||||
|
default=True,
|
||||||
|
)
|
||||||
|
except NonInteractiveError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _prompt_confirm_tool_sync(name: str, args: Mapping[str, object], reason: str) -> bool:
|
def _prompt_confirm_tool_sync(name: str, args: Mapping[str, object], reason: str) -> bool:
|
||||||
del args
|
del args
|
||||||
click.echo()
|
|
||||||
click.secho(f"[confirm] tool {name!r}: {reason}", fg="yellow")
|
|
||||||
try:
|
try:
|
||||||
return bool(click.confirm("Allow this tool call?", default=False))
|
return confirm(
|
||||||
except click.Abort, KeyboardInterrupt:
|
f"[confirm] tool {name!r}: {reason}\nAllow this tool call?",
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
except NonInteractiveError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -51,7 +63,14 @@ def prompt_confirm_tool(name: str, args: Mapping[str, object], reason: str) -> b
|
|||||||
|
|
||||||
async def prompt_confirm_tool_async(name: str, args: Mapping[str, object], reason: str) -> bool:
|
async def prompt_confirm_tool_async(name: str, args: Mapping[str, object], reason: str) -> bool:
|
||||||
"""Async variant: confirm off the event loop."""
|
"""Async variant: confirm off the event loop."""
|
||||||
return await run_in_prompt_thread(_prompt_confirm_tool_sync, name, args, reason)
|
del args
|
||||||
|
try:
|
||||||
|
return await confirm_async(
|
||||||
|
f"[confirm] tool {name!r}: {reason}\nAllow this tool call?",
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
except NonInteractiveError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _prompt_workspace_mismatch_sync(
|
def _prompt_workspace_mismatch_sync(
|
||||||
@@ -59,26 +78,33 @@ def _prompt_workspace_mismatch_sync(
|
|||||||
session_workspace: str,
|
session_workspace: str,
|
||||||
current_workspace: str,
|
current_workspace: str,
|
||||||
) -> WorkspaceMismatchChoice:
|
) -> WorkspaceMismatchChoice:
|
||||||
click.echo()
|
selected = choose(
|
||||||
click.secho(f"[workspace] session {session_id} is bound to a different directory:", fg="yellow")
|
f"[workspace] session {session_id} is bound to a different directory:\n"
|
||||||
click.echo(f" session: {session_workspace}")
|
f" session: {session_workspace}\n"
|
||||||
click.echo(f" current: {current_workspace}")
|
f" current: {current_workspace}",
|
||||||
click.echo(" k = keep session workspace (switch tools root to session path)")
|
[
|
||||||
click.echo(" u = update binding to current workspace")
|
ChoiceOption(
|
||||||
click.echo(" a = abort resume")
|
label="keep",
|
||||||
try:
|
description="keep session workspace (switch tools root to session path)",
|
||||||
raw = click.prompt(
|
value="keep",
|
||||||
"Choice",
|
),
|
||||||
type=click.Choice(["k", "u", "a"], case_sensitive=False),
|
ChoiceOption(
|
||||||
default="k",
|
label="update",
|
||||||
show_choices=True,
|
description="update binding to current workspace",
|
||||||
)
|
value="rebind",
|
||||||
except click.Abort, KeyboardInterrupt:
|
),
|
||||||
return "abort"
|
ChoiceOption(
|
||||||
key = str(raw).strip().lower()
|
label="abort",
|
||||||
if key == "u":
|
description="abort resume",
|
||||||
|
value="abort",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
default="keep",
|
||||||
|
allow_custom=False,
|
||||||
|
)
|
||||||
|
if selected == "rebind":
|
||||||
return "rebind"
|
return "rebind"
|
||||||
if key == "a":
|
if selected == "abort":
|
||||||
return "abort"
|
return "abort"
|
||||||
return "keep"
|
return "keep"
|
||||||
|
|
||||||
@@ -89,10 +115,54 @@ def prompt_workspace_mismatch(
|
|||||||
current_workspace: str,
|
current_workspace: str,
|
||||||
) -> WorkspaceMismatchChoice:
|
) -> WorkspaceMismatchChoice:
|
||||||
"""Ask how to handle resuming a session bound to a different directory."""
|
"""Ask how to handle resuming a session bound to a different directory."""
|
||||||
with pause_task_cancel_for_prompt():
|
try:
|
||||||
return _prompt_workspace_mismatch_sync(session_id, session_workspace, current_workspace)
|
with pause_task_cancel_for_prompt():
|
||||||
|
return _prompt_workspace_mismatch_sync(session_id, session_workspace, current_workspace)
|
||||||
|
except NonInteractiveError:
|
||||||
|
return "abort"
|
||||||
|
|
||||||
|
|
||||||
|
async def prompt_workspace_mismatch_async(
|
||||||
|
session_id: int,
|
||||||
|
session_workspace: str,
|
||||||
|
current_workspace: str,
|
||||||
|
) -> WorkspaceMismatchChoice:
|
||||||
|
"""Async variant of workspace mismatch prompt."""
|
||||||
|
try:
|
||||||
|
selected = await choose_async(
|
||||||
|
f"[workspace] session {session_id} is bound to a different directory:\n"
|
||||||
|
f" session: {session_workspace}\n"
|
||||||
|
f" current: {current_workspace}",
|
||||||
|
[
|
||||||
|
ChoiceOption(
|
||||||
|
label="keep",
|
||||||
|
description="keep session workspace (switch tools root to session path)",
|
||||||
|
value="keep",
|
||||||
|
),
|
||||||
|
ChoiceOption(
|
||||||
|
label="update",
|
||||||
|
description="update binding to current workspace",
|
||||||
|
value="rebind",
|
||||||
|
),
|
||||||
|
ChoiceOption(
|
||||||
|
label="abort",
|
||||||
|
description="abort resume",
|
||||||
|
value="abort",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
default="keep",
|
||||||
|
allow_custom=False,
|
||||||
|
)
|
||||||
|
except NonInteractiveError:
|
||||||
|
return "abort"
|
||||||
|
if selected == "rebind":
|
||||||
|
return "rebind"
|
||||||
|
if selected == "abort":
|
||||||
|
return "abort"
|
||||||
|
return "keep"
|
||||||
|
|
||||||
|
|
||||||
def install_cli_limit_hooks() -> None:
|
def install_cli_limit_hooks() -> None:
|
||||||
"""Register interactive continue hooks for process-global tool limits."""
|
"""Register interactive continue hooks and prompt cancel-pause for the CLI."""
|
||||||
|
configure_prompting(pause_factory=pause_task_cancel_for_prompt)
|
||||||
PtyManager.set_limit_continue_hook(prompt_continue_limit)
|
PtyManager.set_limit_continue_hook(prompt_continue_limit)
|
||||||
|
|||||||
@@ -0,0 +1,331 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import sys
|
||||||
|
from contextlib import AbstractContextManager
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable, Generator, Sequence
|
||||||
|
|
||||||
|
|
||||||
|
class NonInteractiveError(RuntimeError):
|
||||||
|
"""Raised when a prompt is required but no interactive backend is available."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ChoiceOption:
|
||||||
|
"""One selectable option for :func:`choose`."""
|
||||||
|
|
||||||
|
label: str
|
||||||
|
description: str = ""
|
||||||
|
value: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def resolved_value(self) -> str:
|
||||||
|
return self.label if self.value is None else self.value
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class FormField:
|
||||||
|
"""One field in a :func:`form` sequence."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
prompt: str
|
||||||
|
default: str | None = None
|
||||||
|
options: Sequence[str] | Sequence[ChoiceOption] | None = None
|
||||||
|
allow_custom: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class PromptBackend(Protocol):
|
||||||
|
"""Blocking interactive I/O used by ask/choose/form."""
|
||||||
|
|
||||||
|
def is_interactive(self) -> bool: ...
|
||||||
|
|
||||||
|
def read_line(self, prompt: str, *, default: str | None = None) -> str: ...
|
||||||
|
|
||||||
|
def confirm(self, prompt: str, *, default: bool = False) -> bool: ...
|
||||||
|
|
||||||
|
def echo(self, message: str = "", *, err: bool = False) -> None: ...
|
||||||
|
|
||||||
|
def secho(self, message: str, *, fg: str | None = None, err: bool = False) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class ClickPromptBackend:
|
||||||
|
"""Click/TTY backend for interactive prompts."""
|
||||||
|
|
||||||
|
def is_interactive(self) -> bool:
|
||||||
|
return bool(sys.stdin.isatty() and sys.stdout.isatty())
|
||||||
|
|
||||||
|
def read_line(self, prompt: str, *, default: str | None = None) -> str:
|
||||||
|
try:
|
||||||
|
if default is None:
|
||||||
|
return str(click.prompt(prompt, prompt_suffix=": "))
|
||||||
|
return str(click.prompt(prompt, default=default, show_default=True, prompt_suffix=": "))
|
||||||
|
except (click.Abort, KeyboardInterrupt, EOFError) as exc:
|
||||||
|
msg = "prompt cancelled"
|
||||||
|
raise NonInteractiveError(msg) from exc
|
||||||
|
|
||||||
|
def confirm(self, prompt: str, *, default: bool = False) -> bool:
|
||||||
|
try:
|
||||||
|
return bool(click.confirm(prompt, default=default))
|
||||||
|
except (click.Abort, KeyboardInterrupt, EOFError) as exc:
|
||||||
|
msg = "prompt cancelled"
|
||||||
|
raise NonInteractiveError(msg) from exc
|
||||||
|
|
||||||
|
def echo(self, message: str = "", *, err: bool = False) -> None:
|
||||||
|
click.echo(message, err=err)
|
||||||
|
|
||||||
|
def secho(self, message: str, *, fg: str | None = None, err: bool = False) -> None:
|
||||||
|
click.secho(message, fg=fg, err=err)
|
||||||
|
|
||||||
|
|
||||||
|
class NonInteractiveBackend:
|
||||||
|
"""Backend that never blocks: uses defaults or raises."""
|
||||||
|
|
||||||
|
def is_interactive(self) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def read_line(self, prompt: str, *, default: str | None = None) -> str:
|
||||||
|
if default is not None:
|
||||||
|
return default
|
||||||
|
msg = f"non-interactive: cannot prompt for {prompt!r}"
|
||||||
|
raise NonInteractiveError(msg)
|
||||||
|
|
||||||
|
def confirm(self, prompt: str, *, default: bool = False) -> bool:
|
||||||
|
del prompt
|
||||||
|
return default
|
||||||
|
|
||||||
|
def echo(self, message: str = "", *, err: bool = False) -> None:
|
||||||
|
click.echo(message, err=err)
|
||||||
|
|
||||||
|
def secho(self, message: str, *, fg: str | None = None, err: bool = False) -> None:
|
||||||
|
click.secho(message, fg=fg, err=err)
|
||||||
|
|
||||||
|
|
||||||
|
_backend: PromptBackend = ClickPromptBackend()
|
||||||
|
_pause_factory: Callable[[], AbstractContextManager[None]] | None = None
|
||||||
|
_prompt_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def configure_prompting(
|
||||||
|
*,
|
||||||
|
backend: PromptBackend | None = None,
|
||||||
|
pause_factory: Callable[[], AbstractContextManager[None]] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Install process-wide prompt backend and optional cancel-pause context."""
|
||||||
|
global _backend, _pause_factory # noqa: PLW0603
|
||||||
|
if backend is not None:
|
||||||
|
_backend = backend
|
||||||
|
_pause_factory = pause_factory
|
||||||
|
|
||||||
|
|
||||||
|
def get_prompt_backend() -> PromptBackend:
|
||||||
|
return _backend
|
||||||
|
|
||||||
|
|
||||||
|
def reset_prompting() -> None:
|
||||||
|
"""Restore default Click backend and clear pause hook (tests)."""
|
||||||
|
global _backend, _pause_factory # noqa: PLW0603
|
||||||
|
_backend = ClickPromptBackend()
|
||||||
|
_pause_factory = None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_options(
|
||||||
|
options: Sequence[str] | Sequence[ChoiceOption],
|
||||||
|
) -> list[ChoiceOption]:
|
||||||
|
out: list[ChoiceOption] = []
|
||||||
|
for item in options:
|
||||||
|
if isinstance(item, ChoiceOption):
|
||||||
|
out.append(item)
|
||||||
|
else:
|
||||||
|
out.append(ChoiceOption(label=str(item)))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _default_display(choices: list[ChoiceOption], default: str | None) -> str | None:
|
||||||
|
if default is None:
|
||||||
|
return None
|
||||||
|
for index, option in enumerate(choices, start=1):
|
||||||
|
if default in {option.resolved_value, option.label, str(index)}:
|
||||||
|
return str(index)
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _match_choice(raw: str, choices: list[ChoiceOption]) -> str | None:
|
||||||
|
if raw.isdigit():
|
||||||
|
index = int(raw)
|
||||||
|
if 1 <= index <= len(choices):
|
||||||
|
return choices[index - 1].resolved_value
|
||||||
|
for option in choices:
|
||||||
|
if raw in {option.label, option.resolved_value}:
|
||||||
|
return option.resolved_value
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _show_choices(
|
||||||
|
backend: PromptBackend,
|
||||||
|
prompt: str,
|
||||||
|
choices: list[ChoiceOption],
|
||||||
|
*,
|
||||||
|
allow_custom: bool,
|
||||||
|
) -> None:
|
||||||
|
backend.echo()
|
||||||
|
backend.secho(prompt, fg="yellow")
|
||||||
|
for index, option in enumerate(choices, start=1):
|
||||||
|
desc = f" — {option.description}" if option.description else ""
|
||||||
|
backend.echo(f" {index}. {option.label}{desc}")
|
||||||
|
if allow_custom:
|
||||||
|
backend.echo(" (or type a custom answer)")
|
||||||
|
|
||||||
|
|
||||||
|
def ask(prompt: str, *, default: str | None = None) -> str:
|
||||||
|
"""Free-form question; always allows arbitrary user text."""
|
||||||
|
backend = get_prompt_backend()
|
||||||
|
if not backend.is_interactive() and default is None:
|
||||||
|
msg = f"non-interactive: cannot prompt for {prompt!r}"
|
||||||
|
raise NonInteractiveError(msg)
|
||||||
|
backend.secho(prompt, fg="yellow")
|
||||||
|
return backend.read_line("Answer", default=default).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def choose(
|
||||||
|
prompt: str,
|
||||||
|
options: Sequence[str] | Sequence[ChoiceOption],
|
||||||
|
*,
|
||||||
|
default: str | None = None,
|
||||||
|
allow_custom: bool = True,
|
||||||
|
) -> str:
|
||||||
|
"""Present options; user may pick by number/label, or type free text when allowed."""
|
||||||
|
backend = get_prompt_backend()
|
||||||
|
choices = _normalize_options(options)
|
||||||
|
if not choices and not allow_custom:
|
||||||
|
msg = "choose requires options when allow_custom is false"
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
_show_choices(backend, prompt, choices, allow_custom=allow_custom)
|
||||||
|
default_display = _default_display(choices, default)
|
||||||
|
|
||||||
|
if not backend.is_interactive() and default is None:
|
||||||
|
msg = f"non-interactive: cannot prompt for {prompt!r}"
|
||||||
|
raise NonInteractiveError(msg)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
raw = backend.read_line("Choice", default=default_display).strip()
|
||||||
|
if not raw:
|
||||||
|
if default is not None:
|
||||||
|
return default
|
||||||
|
backend.echo("Please enter a choice.")
|
||||||
|
continue
|
||||||
|
matched = _match_choice(raw, choices)
|
||||||
|
if matched is not None:
|
||||||
|
return matched
|
||||||
|
if allow_custom:
|
||||||
|
return raw
|
||||||
|
backend.echo("Invalid choice; pick a listed option.")
|
||||||
|
|
||||||
|
|
||||||
|
def confirm(prompt: str, *, default: bool = False) -> bool:
|
||||||
|
"""Yes/no confirm via the active backend.
|
||||||
|
|
||||||
|
Non-interactive backends return ``default`` without blocking.
|
||||||
|
Cancel (Ctrl+C / EOF) raises :class:`NonInteractiveError`.
|
||||||
|
"""
|
||||||
|
backend = get_prompt_backend()
|
||||||
|
if not backend.is_interactive():
|
||||||
|
return default
|
||||||
|
backend.echo()
|
||||||
|
return backend.confirm(prompt, default=default)
|
||||||
|
|
||||||
|
|
||||||
|
def form(
|
||||||
|
title: str,
|
||||||
|
fields: Sequence[FormField],
|
||||||
|
*,
|
||||||
|
confirm_submit: bool = True,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Ordered multi-field form; optional final confirm before returning."""
|
||||||
|
if not fields:
|
||||||
|
return {}
|
||||||
|
backend = get_prompt_backend()
|
||||||
|
while True:
|
||||||
|
backend.echo()
|
||||||
|
backend.secho(title, fg="cyan")
|
||||||
|
answers: dict[str, str] = {}
|
||||||
|
for field in fields:
|
||||||
|
if field.options is not None:
|
||||||
|
answers[field.name] = choose(
|
||||||
|
field.prompt,
|
||||||
|
field.options,
|
||||||
|
default=field.default,
|
||||||
|
allow_custom=field.allow_custom,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
answers[field.name] = ask(field.prompt, default=field.default)
|
||||||
|
if not confirm_submit:
|
||||||
|
return answers
|
||||||
|
backend.echo()
|
||||||
|
backend.secho("Summary:", fg="bright_black")
|
||||||
|
for field in fields:
|
||||||
|
backend.echo(f" {field.name}: {answers[field.name]}")
|
||||||
|
if confirm("Submit these answers?", default=True):
|
||||||
|
return answers
|
||||||
|
backend.echo("Starting over…")
|
||||||
|
|
||||||
|
|
||||||
|
async def run_prompt_async[**P, R](func: Callable[P, R], *args: P.args, **kwargs: P.kwargs) -> R:
|
||||||
|
"""Run a blocking prompt off the event loop, serialized, with optional SIGINT pause."""
|
||||||
|
async with _prompt_lock:
|
||||||
|
if _pause_factory is not None:
|
||||||
|
with _pause_factory():
|
||||||
|
return await asyncio.to_thread(func, *args, **kwargs)
|
||||||
|
return await asyncio.to_thread(func, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
async def ask_async(prompt: str, *, default: str | None = None) -> str:
|
||||||
|
return await run_prompt_async(ask, prompt, default=default)
|
||||||
|
|
||||||
|
|
||||||
|
async def choose_async(
|
||||||
|
prompt: str,
|
||||||
|
options: Sequence[str] | Sequence[ChoiceOption],
|
||||||
|
*,
|
||||||
|
default: str | None = None,
|
||||||
|
allow_custom: bool = True,
|
||||||
|
) -> str:
|
||||||
|
return await run_prompt_async(
|
||||||
|
choose,
|
||||||
|
prompt,
|
||||||
|
options,
|
||||||
|
default=default,
|
||||||
|
allow_custom=allow_custom,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def confirm_async(prompt: str, *, default: bool = False) -> bool:
|
||||||
|
return await run_prompt_async(confirm, prompt, default=default)
|
||||||
|
|
||||||
|
|
||||||
|
async def form_async(
|
||||||
|
title: str,
|
||||||
|
fields: Sequence[FormField],
|
||||||
|
*,
|
||||||
|
confirm_submit: bool = True,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
return await run_prompt_async(form, title, fields, confirm_submit=confirm_submit)
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def temporary_backend(backend: PromptBackend) -> Generator[None]:
|
||||||
|
"""Context manager to swap the process-wide backend (tests)."""
|
||||||
|
previous = get_prompt_backend()
|
||||||
|
configure_prompting(backend=backend)
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
configure_prompting(backend=previous)
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
from .chat import CHAT_TOOLS as CHAT_TOOLS
|
||||||
|
from .chat import ask_user as ask_user
|
||||||
|
from .chat import choose_user as choose_user
|
||||||
|
from .chat import form_user as form_user
|
||||||
from .danger import classify_danger as classify_danger
|
from .danger import classify_danger as classify_danger
|
||||||
from .file import FILE_TOOLS as FILE_TOOLS
|
from .file import FILE_TOOLS as FILE_TOOLS
|
||||||
from .file import copy_path as copy_path
|
from .file import copy_path as copy_path
|
||||||
@@ -37,4 +41,4 @@ from .workspace import set_command_denylist as set_command_denylist
|
|||||||
from .workspace import set_path_denylist as set_path_denylist
|
from .workspace import set_path_denylist as set_path_denylist
|
||||||
from .workspace import set_workspace_root as set_workspace_root
|
from .workspace import set_workspace_root as set_workspace_root
|
||||||
|
|
||||||
DEFAULT_TOOLS = [*FILE_TOOLS, *PROCESS_TOOLS, *VCS_TOOLS]
|
DEFAULT_TOOLS = [*FILE_TOOLS, *PROCESS_TOOLS, *VCS_TOOLS, *CHAT_TOOLS]
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from .ask import ask_user as ask_user
|
||||||
|
from .choose import choose_user as choose_user
|
||||||
|
from .form import form_user as form_user
|
||||||
|
|
||||||
|
CHAT_TOOLS = [ask_user, choose_user, form_user]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from plyngent.agent import tool
|
||||||
|
from plyngent.prompting import NonInteractiveError, ask_async
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
async def ask_user(question: str, default: str = "") -> str:
|
||||||
|
"""Ask the human a free-form question and return their answer.
|
||||||
|
|
||||||
|
Always allows arbitrary text. Use for clarifying requirements, preferences,
|
||||||
|
or any input that is not a fixed menu. Optional ``default`` is used if the
|
||||||
|
user submits empty input (and in non-interactive mode when provided).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return await ask_async(question, default=default or None)
|
||||||
|
except NonInteractiveError as exc:
|
||||||
|
return f"error: {exc}"
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from plyngent.agent import tool
|
||||||
|
from plyngent.prompting import ChoiceOption, NonInteractiveError, choose_async
|
||||||
|
|
||||||
|
|
||||||
|
def parse_options(raw: str) -> list[ChoiceOption]:
|
||||||
|
"""Parse options JSON: list of strings or objects with label/description/value."""
|
||||||
|
text = raw.strip()
|
||||||
|
if not text:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
data: object = json.loads(text)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
msg = f"options must be JSON: {exc}"
|
||||||
|
raise ValueError(msg) from exc
|
||||||
|
if not isinstance(data, list):
|
||||||
|
msg = "options must be a JSON array"
|
||||||
|
raise TypeError(msg)
|
||||||
|
out: list[ChoiceOption] = []
|
||||||
|
for item in data:
|
||||||
|
if isinstance(item, str):
|
||||||
|
out.append(ChoiceOption(label=item))
|
||||||
|
continue
|
||||||
|
if isinstance(item, dict):
|
||||||
|
raw_map: dict[str, object] = {str(k): v for k, v in item.items()} # type: ignore[misc]
|
||||||
|
label_obj = raw_map.get("label")
|
||||||
|
if not isinstance(label_obj, str) or not label_obj:
|
||||||
|
msg = "each option object needs a non-empty string label"
|
||||||
|
raise ValueError(msg)
|
||||||
|
description_obj = raw_map.get("description", "")
|
||||||
|
value_obj = raw_map.get("value")
|
||||||
|
out.append(
|
||||||
|
ChoiceOption(
|
||||||
|
label=label_obj,
|
||||||
|
description=description_obj if isinstance(description_obj, str) else "",
|
||||||
|
value=value_obj if isinstance(value_obj, str) else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
msg = "options items must be strings or objects"
|
||||||
|
raise TypeError(msg)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
async def choose_user(
|
||||||
|
question: str,
|
||||||
|
options: str,
|
||||||
|
default: str = "",
|
||||||
|
*,
|
||||||
|
allow_custom: bool = True,
|
||||||
|
) -> str:
|
||||||
|
"""Ask the human to pick from options (or type a custom answer).
|
||||||
|
|
||||||
|
``options`` is a JSON array of strings, or objects with
|
||||||
|
``label``, optional ``description``, optional ``value``.
|
||||||
|
When ``allow_custom`` is true (default), free-text answers are accepted.
|
||||||
|
Returns the chosen option value (or custom text).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
parsed = parse_options(options)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
return f"error: {exc}"
|
||||||
|
if not parsed and not allow_custom:
|
||||||
|
return "error: options must be a non-empty JSON array when allow_custom is false"
|
||||||
|
try:
|
||||||
|
return await choose_async(
|
||||||
|
question,
|
||||||
|
parsed,
|
||||||
|
default=default or None,
|
||||||
|
allow_custom=allow_custom,
|
||||||
|
)
|
||||||
|
except NonInteractiveError as exc:
|
||||||
|
return f"error: {exc}"
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from plyngent.agent import tool
|
||||||
|
from plyngent.prompting import FormField, NonInteractiveError, form_async
|
||||||
|
from plyngent.tools.chat.choose import parse_options
|
||||||
|
|
||||||
|
|
||||||
|
def parse_fields(raw: str) -> list[FormField]:
|
||||||
|
"""Parse form fields JSON array of objects."""
|
||||||
|
text = raw.strip()
|
||||||
|
if not text:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
data: object = json.loads(text)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
msg = f"fields must be JSON: {exc}"
|
||||||
|
raise ValueError(msg) from exc
|
||||||
|
if not isinstance(data, list) or not data:
|
||||||
|
msg = "fields must be a non-empty JSON array"
|
||||||
|
raise ValueError(msg)
|
||||||
|
out: list[FormField] = []
|
||||||
|
for item in data:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
msg = "each field must be a JSON object"
|
||||||
|
raise TypeError(msg)
|
||||||
|
raw_map: dict[str, object] = {str(k): v for k, v in item.items()} # type: ignore[misc]
|
||||||
|
name = raw_map.get("name")
|
||||||
|
prompt = raw_map.get("prompt")
|
||||||
|
if not isinstance(name, str) or not name:
|
||||||
|
msg = "each field needs a non-empty string name"
|
||||||
|
raise ValueError(msg)
|
||||||
|
if not isinstance(prompt, str) or not prompt:
|
||||||
|
msg = "each field needs a non-empty string prompt"
|
||||||
|
raise ValueError(msg)
|
||||||
|
default = raw_map.get("default")
|
||||||
|
options_raw = raw_map.get("options")
|
||||||
|
options = None
|
||||||
|
if options_raw is not None:
|
||||||
|
options = parse_options(json.dumps(options_raw))
|
||||||
|
allow_custom_obj = raw_map.get("allow_custom", True)
|
||||||
|
allow_custom = allow_custom_obj if isinstance(allow_custom_obj, bool) else True
|
||||||
|
out.append(
|
||||||
|
FormField(
|
||||||
|
name=name,
|
||||||
|
prompt=prompt,
|
||||||
|
default=default if isinstance(default, str) else None,
|
||||||
|
options=options,
|
||||||
|
allow_custom=allow_custom,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
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.
|
||||||
|
|
||||||
|
``fields`` is a JSON array of objects:
|
||||||
|
``name``, ``prompt``, optional ``default``, optional ``options`` (same shape
|
||||||
|
as choose_user), optional ``allow_custom`` (default true).
|
||||||
|
When ``confirm_submit`` is true, the human reviews a summary before submit.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
parsed = parse_fields(fields)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
return f"error: {exc}"
|
||||||
|
try:
|
||||||
|
answers = await form_async(title, parsed, confirm_submit=confirm_submit)
|
||||||
|
except NonInteractiveError as exc:
|
||||||
|
return f"error: {exc}"
|
||||||
|
return json.dumps(answers, ensure_ascii=False)
|
||||||
@@ -1,45 +1,32 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from plyngent.cli.limits import install_cli_limit_hooks, prompt_confirm_tool, prompt_continue_limit
|
from plyngent.cli.limits import install_cli_limit_hooks, prompt_confirm_tool, prompt_continue_limit
|
||||||
|
from plyngent.prompting import get_prompt_backend, temporary_backend
|
||||||
from plyngent.tools.process.pty_session import PtyManager
|
from plyngent.tools.process.pty_session import PtyManager
|
||||||
|
from tests.test_prompting import ScriptedBackend
|
||||||
if TYPE_CHECKING:
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
def test_prompt_continue_limit_yes(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_prompt_continue_limit_yes() -> None:
|
||||||
def _confirm(*_a: object, **_k: object) -> bool:
|
backend = ScriptedBackend([], confirms=[True])
|
||||||
return True
|
with temporary_backend(backend):
|
||||||
|
assert prompt_continue_limit("hit a wall") is True
|
||||||
monkeypatch.setattr("click.confirm", _confirm)
|
|
||||||
assert prompt_continue_limit("hit a wall") is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_prompt_continue_limit_no(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_prompt_continue_limit_no() -> None:
|
||||||
def _confirm(*_a: object, **_k: object) -> bool:
|
backend = ScriptedBackend([], confirms=[False])
|
||||||
return False
|
with temporary_backend(backend):
|
||||||
|
assert prompt_continue_limit("hit a wall") is False
|
||||||
monkeypatch.setattr("click.confirm", _confirm)
|
|
||||||
assert prompt_continue_limit("hit a wall") is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_prompt_confirm_tool_default_deny(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_prompt_confirm_tool_default_deny() -> None:
|
||||||
seen: dict[str, object] = {}
|
backend = ScriptedBackend([], confirms=[False])
|
||||||
|
with temporary_backend(backend):
|
||||||
def _confirm(message: str, **kwargs: object) -> bool:
|
assert prompt_confirm_tool("delete_path", {"path": "x"}, "delete path 'x'") is False
|
||||||
seen["message"] = message
|
|
||||||
seen["default"] = kwargs.get("default")
|
|
||||||
return False
|
|
||||||
|
|
||||||
monkeypatch.setattr("click.confirm", _confirm)
|
|
||||||
assert prompt_confirm_tool("delete_path", {"path": "x"}, "delete path 'x'") is False
|
|
||||||
assert seen["default"] is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_install_cli_limit_hooks() -> None:
|
def test_install_cli_limit_hooks() -> None:
|
||||||
install_cli_limit_hooks()
|
install_cli_limit_hooks()
|
||||||
# Hook is installed process-wide for the CLI session.
|
|
||||||
assert callable(getattr(PtyManager, "_limit_continue", None))
|
assert callable(getattr(PtyManager, "_limit_continue", None))
|
||||||
PtyManager.set_limit_continue_hook(None)
|
PtyManager.set_limit_continue_hook(None)
|
||||||
|
# Backend remains usable after install.
|
||||||
|
assert get_prompt_backend().is_interactive() or True
|
||||||
|
|||||||
@@ -88,22 +88,18 @@ def _make_state(memory: MemoryStore, workspace: Path) -> ReplState:
|
|||||||
return st
|
return st
|
||||||
|
|
||||||
|
|
||||||
def test_prompt_workspace_mismatch_choices(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_prompt_workspace_mismatch_choices() -> None:
|
||||||
def prompt_u(*_a: object, **_k: object) -> str:
|
from plyngent.prompting import temporary_backend
|
||||||
return "u"
|
from tests.test_prompting import ScriptedBackend
|
||||||
|
|
||||||
def prompt_k(*_a: object, **_k: object) -> str:
|
with temporary_backend(ScriptedBackend(["2"])):
|
||||||
return "k"
|
assert prompt_workspace_mismatch(1, "/old", "/new") == "rebind"
|
||||||
|
with temporary_backend(ScriptedBackend(["1"])):
|
||||||
def prompt_a(*_a: object, **_k: object) -> str:
|
assert prompt_workspace_mismatch(1, "/old", "/new") == "keep"
|
||||||
return "a"
|
with temporary_backend(ScriptedBackend(["3"])):
|
||||||
|
assert prompt_workspace_mismatch(1, "/old", "/new") == "abort"
|
||||||
monkeypatch.setattr("click.prompt", prompt_u)
|
with temporary_backend(ScriptedBackend(["abort"])):
|
||||||
assert prompt_workspace_mismatch(1, "/old", "/new") == "rebind"
|
assert prompt_workspace_mismatch(1, "/old", "/new") == "abort"
|
||||||
monkeypatch.setattr("click.prompt", prompt_k)
|
|
||||||
assert prompt_workspace_mismatch(1, "/old", "/new") == "keep"
|
|
||||||
monkeypatch.setattr("click.prompt", prompt_a)
|
|
||||||
assert prompt_workspace_mismatch(1, "/old", "/new") == "abort"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_resume_mismatch_rebind(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
async def test_resume_mismatch_rebind(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from plyngent.prompting import (
|
||||||
|
ChoiceOption,
|
||||||
|
FormField,
|
||||||
|
NonInteractiveBackend,
|
||||||
|
NonInteractiveError,
|
||||||
|
ask,
|
||||||
|
choose,
|
||||||
|
confirm,
|
||||||
|
form,
|
||||||
|
temporary_backend,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptedBackend:
|
||||||
|
"""Deterministic backend for unit tests."""
|
||||||
|
|
||||||
|
def __init__(self, lines: list[str], *, confirms: list[bool] | None = None) -> None:
|
||||||
|
self.lines = list(lines)
|
||||||
|
self.confirms = list(confirms or [])
|
||||||
|
self.interactive = True
|
||||||
|
self.echoes: list[str] = []
|
||||||
|
|
||||||
|
def is_interactive(self) -> bool:
|
||||||
|
return self.interactive
|
||||||
|
|
||||||
|
def read_line(self, prompt: str, *, default: str | None = None) -> str:
|
||||||
|
del prompt
|
||||||
|
if self.lines:
|
||||||
|
return self.lines.pop(0)
|
||||||
|
if default is not None:
|
||||||
|
return default
|
||||||
|
msg = "no scripted lines left"
|
||||||
|
raise NonInteractiveError(msg)
|
||||||
|
|
||||||
|
def confirm(self, prompt: str, *, default: bool = False) -> bool:
|
||||||
|
del prompt
|
||||||
|
if self.confirms:
|
||||||
|
return self.confirms.pop(0)
|
||||||
|
return default
|
||||||
|
|
||||||
|
def echo(self, message: str = "", *, err: bool = False) -> None:
|
||||||
|
del err
|
||||||
|
self.echoes.append(message)
|
||||||
|
|
||||||
|
def secho(self, message: str, *, fg: str | None = None, err: bool = False) -> None:
|
||||||
|
del fg, err
|
||||||
|
self.echoes.append(message)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ask_free_text() -> None:
|
||||||
|
backend = ScriptedBackend(["hello world"])
|
||||||
|
with temporary_backend(backend):
|
||||||
|
assert ask("Name?") == "hello world"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ask_default_when_empty_uses_backend_default() -> None:
|
||||||
|
backend = ScriptedBackend([])
|
||||||
|
with temporary_backend(backend):
|
||||||
|
assert ask("Name?", default="anon") == "anon"
|
||||||
|
|
||||||
|
|
||||||
|
def test_choose_by_index() -> None:
|
||||||
|
backend = ScriptedBackend(["2"])
|
||||||
|
with temporary_backend(backend):
|
||||||
|
assert (
|
||||||
|
choose(
|
||||||
|
"Pick",
|
||||||
|
[
|
||||||
|
ChoiceOption(label="a", value="A"),
|
||||||
|
ChoiceOption(label="b", value="B"),
|
||||||
|
],
|
||||||
|
allow_custom=False,
|
||||||
|
)
|
||||||
|
== "B"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_choose_by_label() -> None:
|
||||||
|
backend = ScriptedBackend(["keep"])
|
||||||
|
with temporary_backend(backend):
|
||||||
|
assert choose("Pick", ["keep", "abort"], allow_custom=False) == "keep"
|
||||||
|
|
||||||
|
|
||||||
|
def test_choose_custom_text() -> None:
|
||||||
|
backend = ScriptedBackend(["something else"])
|
||||||
|
with temporary_backend(backend):
|
||||||
|
assert choose("Pick", ["a", "b"], allow_custom=True) == "something else"
|
||||||
|
|
||||||
|
|
||||||
|
def test_choose_rejects_custom_when_disabled() -> None:
|
||||||
|
backend = ScriptedBackend(["nope", "1"])
|
||||||
|
with temporary_backend(backend):
|
||||||
|
assert choose("Pick", ["a", "b"], allow_custom=False) == "a"
|
||||||
|
|
||||||
|
|
||||||
|
def test_confirm_yes() -> None:
|
||||||
|
backend = ScriptedBackend([], confirms=[True])
|
||||||
|
with temporary_backend(backend):
|
||||||
|
assert confirm("ok?", default=False) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_form_with_confirm() -> None:
|
||||||
|
backend = ScriptedBackend(["alice", "2"], confirms=[True])
|
||||||
|
with temporary_backend(backend):
|
||||||
|
answers = form(
|
||||||
|
"Profile",
|
||||||
|
[
|
||||||
|
FormField(name="name", prompt="Name?"),
|
||||||
|
FormField(
|
||||||
|
name="role",
|
||||||
|
prompt="Role?",
|
||||||
|
options=["dev", "ops"],
|
||||||
|
allow_custom=False,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
confirm_submit=True,
|
||||||
|
)
|
||||||
|
assert answers == {"name": "alice", "role": "ops"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_interactive_ask_requires_default() -> None:
|
||||||
|
with temporary_backend(NonInteractiveBackend()), pytest.raises(NonInteractiveError):
|
||||||
|
_ = ask("Name?")
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_interactive_ask_uses_default() -> None:
|
||||||
|
with temporary_backend(NonInteractiveBackend()):
|
||||||
|
assert ask("Name?", default="x") == "x"
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_interactive_confirm_uses_default() -> None:
|
||||||
|
with temporary_backend(NonInteractiveBackend()):
|
||||||
|
assert confirm("ok?", default=False) is False
|
||||||
|
assert confirm("ok?", default=True) is True
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from plyngent.agent import ToolRegistry
|
||||||
|
from plyngent.prompting import NonInteractiveBackend, temporary_backend
|
||||||
|
from plyngent.tools.chat import CHAT_TOOLS, ask_user, choose_user, form_user
|
||||||
|
from tests.test_prompting import ScriptedBackend
|
||||||
|
|
||||||
|
|
||||||
|
async def test_ask_user_tool() -> None:
|
||||||
|
backend = ScriptedBackend(["42"])
|
||||||
|
with temporary_backend(backend):
|
||||||
|
registry = ToolRegistry([ask_user])
|
||||||
|
out = await registry.execute("ask_user", '{"question": "Answer?"}')
|
||||||
|
assert out == "42"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_choose_user_tool_index() -> None:
|
||||||
|
backend = ScriptedBackend(["1"])
|
||||||
|
with temporary_backend(backend):
|
||||||
|
registry = ToolRegistry([choose_user])
|
||||||
|
out = await registry.execute(
|
||||||
|
"choose_user",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"question": "Pick",
|
||||||
|
"options": json.dumps(["alpha", "beta"]),
|
||||||
|
"allow_custom": False,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert out == "alpha"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_choose_user_bad_options() -> None:
|
||||||
|
registry = ToolRegistry([choose_user])
|
||||||
|
out = await registry.execute(
|
||||||
|
"choose_user",
|
||||||
|
json.dumps({"question": "Pick", "options": "not-json"}),
|
||||||
|
)
|
||||||
|
assert out.startswith("error:")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_form_user_tool() -> None:
|
||||||
|
backend = ScriptedBackend(["ncbm"], confirms=[True])
|
||||||
|
fields = json.dumps([{"name": "user", "prompt": "User?"}])
|
||||||
|
with temporary_backend(backend):
|
||||||
|
registry = ToolRegistry([form_user])
|
||||||
|
out = await registry.execute(
|
||||||
|
"form_user",
|
||||||
|
json.dumps({"title": "Setup", "fields": fields, "confirm_submit": True}),
|
||||||
|
)
|
||||||
|
assert json.loads(out) == {"user": "ncbm"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_chat_tools_in_default_list() -> None:
|
||||||
|
names = {t.name for t in CHAT_TOOLS}
|
||||||
|
assert names == {"ask_user", "choose_user", "form_user"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_ask_user_non_interactive_error() -> None:
|
||||||
|
with temporary_backend(NonInteractiveBackend()):
|
||||||
|
registry = ToolRegistry([ask_user])
|
||||||
|
out = await registry.execute("ask_user", '{"question": "hi"}')
|
||||||
|
assert out.startswith("error:")
|
||||||
Reference in New Issue
Block a user