mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 15:37:55 +08:00
core/tools: add ask_into_pty for human-local PTY input
Prompt the human (optional no-echo secret) and write only to the PTY. Tool results never include the answer so secrets stay off external APIs. Empty/Ctrl-C cancel without writing; submit defaults to trailing newline.
This commit is contained in:
@@ -55,6 +55,10 @@ class PromptBackend(Protocol):
|
||||
completions: Sequence[str] | None = None,
|
||||
) -> str: ...
|
||||
|
||||
def read_secret_line(self, prompt: str) -> str:
|
||||
"""Read a line without echo (passwords). Cancel raises NonInteractiveError."""
|
||||
...
|
||||
|
||||
def confirm(self, prompt: str, *, default: bool = False) -> bool: ...
|
||||
|
||||
def echo(self, message: str = "", *, err: bool = False) -> None: ...
|
||||
@@ -117,6 +121,16 @@ class ClickPromptBackend:
|
||||
return default
|
||||
return raw
|
||||
|
||||
def read_secret_line(self, prompt: str) -> str:
|
||||
import getpass
|
||||
|
||||
try:
|
||||
display = f"{prompt}: " if not prompt.endswith(": ") else prompt
|
||||
return getpass.getpass(display)
|
||||
except (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))
|
||||
@@ -150,6 +164,10 @@ class NonInteractiveBackend:
|
||||
msg = f"non-interactive: cannot prompt for {prompt!r}"
|
||||
raise NonInteractiveError(msg)
|
||||
|
||||
def read_secret_line(self, prompt: str) -> str:
|
||||
msg = f"non-interactive: cannot prompt for secret {prompt!r}"
|
||||
raise NonInteractiveError(msg)
|
||||
|
||||
def confirm(self, prompt: str, *, default: bool = False) -> bool:
|
||||
del prompt
|
||||
return default
|
||||
@@ -255,6 +273,19 @@ def ask(
|
||||
return backend.read_line("Answer", default=default, completions=completions).strip()
|
||||
|
||||
|
||||
def ask_secret(prompt: str) -> str:
|
||||
"""Prompt for a secret line (no echo). Never use for values that return to the model.
|
||||
|
||||
Cancel (Ctrl+C / EOF) raises :class:`NonInteractiveError`.
|
||||
"""
|
||||
backend = get_prompt_backend()
|
||||
if not backend.is_interactive():
|
||||
msg = f"non-interactive: cannot prompt for secret {prompt!r}"
|
||||
raise NonInteractiveError(msg)
|
||||
backend.secho(prompt, fg="yellow")
|
||||
return backend.read_secret_line("Secret")
|
||||
|
||||
|
||||
def choose(
|
||||
prompt: str,
|
||||
options: Sequence[str] | Sequence[ChoiceOption],
|
||||
@@ -363,6 +394,10 @@ async def ask_async(prompt: str, *, default: str | None = None) -> str:
|
||||
return await run_prompt_async(ask, prompt, default=default)
|
||||
|
||||
|
||||
async def ask_secret_async(prompt: str) -> str:
|
||||
return await run_prompt_async(ask_secret, prompt)
|
||||
|
||||
|
||||
async def choose_async(
|
||||
prompt: str,
|
||||
options: Sequence[str] | Sequence[ChoiceOption],
|
||||
|
||||
@@ -16,6 +16,7 @@ from .file import read_file as read_file
|
||||
from .file import tree as tree
|
||||
from .file import write_file as write_file
|
||||
from .process import PROCESS_TOOLS as PROCESS_TOOLS
|
||||
from .process import ask_into_pty as ask_into_pty
|
||||
from .process import close_pty as close_pty
|
||||
from .process import open_pty as open_pty
|
||||
from .process import read_pty as read_pty
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from .ask_into_pty import ask_into_pty as ask_into_pty
|
||||
from .close_pty import close_pty as close_pty
|
||||
from .open_pty import open_pty as open_pty
|
||||
from .pty_terminal import decode_write_data as decode_write_data
|
||||
@@ -15,5 +16,6 @@ PROCESS_TOOLS = [
|
||||
read_pty,
|
||||
write_pty,
|
||||
write_pty_keys,
|
||||
ask_into_pty,
|
||||
close_pty,
|
||||
]
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from plyngent.agent import tool
|
||||
from plyngent.prompting import NonInteractiveError, ask_async, ask_secret_async
|
||||
from plyngent.tools.workspace import WorkspaceError
|
||||
|
||||
from .pty_session import PtyManager
|
||||
from .write_pty import write_pty_payload
|
||||
|
||||
type _PromptResult = tuple[Literal["ok"], str] | tuple[Literal["err"], str]
|
||||
|
||||
|
||||
def _status_without_payload(
|
||||
status: str,
|
||||
*,
|
||||
secret: bool,
|
||||
submit: bool,
|
||||
) -> str:
|
||||
lines = [line for line in status.splitlines() if not line.startswith("wrote=")]
|
||||
lines.append("wrote=true")
|
||||
lines.append(f"secret={'true' if secret else 'false'}")
|
||||
lines.append(f"submit={'true' if submit else 'false'}")
|
||||
lines.append("source=human")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def _prompt_answer(label: str, *, secret: bool) -> _PromptResult:
|
||||
try:
|
||||
answer = await ask_secret_async(label) if secret else await ask_async(label)
|
||||
except NonInteractiveError as exc:
|
||||
return ("err", f"error: {exc}")
|
||||
except KeyboardInterrupt, EOFError:
|
||||
return ("err", "error: prompt cancelled")
|
||||
if answer == "":
|
||||
return ("err", "error: empty input cancelled (nothing written to PTY)")
|
||||
return ("ok", answer)
|
||||
|
||||
|
||||
@tool
|
||||
async def ask_into_pty(
|
||||
session_id: int,
|
||||
message: str,
|
||||
*,
|
||||
secret: bool = False,
|
||||
submit: bool = True,
|
||||
) -> str:
|
||||
"""Prompt the **human** and write their answer into a PTY (local only).
|
||||
|
||||
Use for interactive prompts (e.g. sudo/ssh password). The answer is written
|
||||
to the PTY master on this machine and is **never** included in the tool
|
||||
result (so it is not sent to external model APIs).
|
||||
|
||||
``message`` is shown to the human (what to enter), not the secret itself.
|
||||
``secret=true`` uses no-echo input. ``submit=true`` (default) appends a
|
||||
newline after the answer. Empty input and Ctrl+C cancel without writing.
|
||||
"""
|
||||
label = message.strip() or ("Secret" if secret else "Input")
|
||||
try:
|
||||
# Validate session before blocking the human.
|
||||
_ = PtyManager.refresh(session_id)
|
||||
except WorkspaceError as exc:
|
||||
return f"error: {exc}"
|
||||
|
||||
kind, value = await _prompt_answer(label, secret=secret)
|
||||
if kind == "err":
|
||||
return value
|
||||
|
||||
payload = value + ("\n" if submit else "")
|
||||
try:
|
||||
status = write_pty_payload(session_id, payload)
|
||||
except WorkspaceError as exc:
|
||||
return f"error: {exc}"
|
||||
except OSError as exc:
|
||||
return f"error: failed to write PTY: {exc}"
|
||||
|
||||
return _status_without_payload(status, secret=secret, submit=submit)
|
||||
Reference in New Issue
Block a user