mirror of
https://github.com/NCBM/plyngent.git
synced 2026-07-23 05:55:16 +08:00
core/tools/process: split write_pty (literal) vs write_pty_keys
This commit is contained in:
@@ -80,8 +80,8 @@ Module-level `@tool` handlers. Call `set_workspace_root()` before use.
|
||||
|
||||
- **`workspace`**: path resolve under root; path substring denylist; command basename denylist; clearer deny messages.
|
||||
- **`file`**: `read_file`, `write_file`, `listdir`, `tree` (VCS + default noise dirs + optional `skip_dirs` / denylist walk), `glob_paths`, `grep_files` (regex, skip VCS/binary), `edit_replace`, `edit_lineno` (1-based range), `copy_path` / `move_path` / `delete_path`.
|
||||
- **`process`**: `run_command` (argv, no shell, timeout, optional stdin/env); PTY `open_pty` / `read_pty` / `write_pty` / `close_pty` (POSIX: `pty`+`fork`; Windows: ConPTY via `pywinpty` env marker dep).
|
||||
- PTY: backend in `pty_backend.py`; structured status (`alive`/`exit_code`/`data`); `read_pty(..., until=)` (CSI sanitized for host safety); `write_pty` key escapes (`\xHH`, `ctrl+x`, `key=esc`); session limit/idle TTL/output budget; close terminate→kill + **host TTY restore**.
|
||||
- **`process`**: `run_command` (argv, no shell, timeout, optional stdin/env); PTY `open_pty` / `read_pty` / `write_pty` (literal) / `write_pty_keys` (escapes) / `close_pty` (POSIX: `pty`+`fork`; Windows: ConPTY via `pywinpty` env marker dep).
|
||||
- PTY: backend in `pty_backend.py`; structured status (`alive`/`exit_code`/`data`); `read_pty(..., until=)` (CSI sanitized for host safety); keys via `write_pty_keys` only (`\xHH`, `ctrl+x`, `key=esc`); session limit/idle TTL/output budget; close terminate→kill + **host TTY restore**.
|
||||
- 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`. Session YOLO: `/yolo on|off|once` and `--yes` (skip soft confirms; hard denylists unchanged; `once` expires after the next user turn).
|
||||
- **`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.
|
||||
|
||||
@@ -234,7 +234,7 @@ Safety defaults:
|
||||
- PTY sessions: caps, idle TTL, output budget; master FD is non-inheritable; sessions closed on chat exit.
|
||||
Prefer file tools over full-screen editors (`vim`/`nano`) for edits. `read_pty` escapes CSI so tool
|
||||
results cannot reprogram the host TTY; `close_pty` / chat exit also restore the host terminal.
|
||||
`write_pty` accepts escapes: `\xHH`, `ctrl+x`, `key=esc|enter|…`.
|
||||
`write_pty` is literal text only; use `write_pty_keys` for `\xHH`, `ctrl+x`, `key=esc|enter|…`.
|
||||
|
||||
## Usage / context (CLI)
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ from .process import open_pty as open_pty
|
||||
from .process import read_pty as read_pty
|
||||
from .process import run_command as run_command
|
||||
from .process import write_pty as write_pty
|
||||
from .process import write_pty_keys as write_pty_keys
|
||||
from .todo import TODO_TOOLS as TODO_TOOLS
|
||||
from .todo import get_todo_stack as get_todo_stack
|
||||
from .todo import set_todo_stack as set_todo_stack
|
||||
|
||||
@@ -6,5 +6,6 @@ from .pty_terminal import sanitize_pty_output_for_tool as sanitize_pty_output_fo
|
||||
from .read_pty import read_pty as read_pty
|
||||
from .run_command import run_command as run_command
|
||||
from .write_pty import write_pty as write_pty
|
||||
from .write_pty_keys import write_pty_keys as write_pty_keys
|
||||
|
||||
PROCESS_TOOLS = [run_command, open_pty, read_pty, write_pty, close_pty]
|
||||
PROCESS_TOOLS = [run_command, open_pty, read_pty, write_pty, write_pty_keys, close_pty]
|
||||
|
||||
@@ -4,25 +4,12 @@ from plyngent.agent import tool
|
||||
from plyngent.tools.workspace import WorkspaceError
|
||||
|
||||
from .pty_session import PtyManager
|
||||
from .pty_terminal import decode_write_data
|
||||
|
||||
|
||||
@tool
|
||||
def write_pty(session_id: int, data: str) -> str:
|
||||
"""Write text to a PTY session (interactive input). Does not append a newline.
|
||||
|
||||
Escapes (literal backslash sequences in ``data``): ``\\n`` ``\\r`` ``\\t``
|
||||
``\\e``/``\\E`` (ESC), ``\\xHH``, ``\\uHHHH``, ``ctrl+x`` / ``ctrl+c``, and
|
||||
``key=esc|enter|tab|up|down|left|right``.
|
||||
"""
|
||||
try:
|
||||
raw = decode_write_data(data)
|
||||
PtyManager.write(session_id, raw)
|
||||
session = PtyManager.refresh(session_id)
|
||||
except WorkspaceError as exc:
|
||||
return f"error: {exc}"
|
||||
except OSError as exc:
|
||||
return f"error: failed to write PTY: {exc}"
|
||||
def write_pty_payload(session_id: int, raw: str) -> str:
|
||||
"""Write raw bytes (as str) to the PTY and format the tool status string."""
|
||||
PtyManager.write(session_id, raw)
|
||||
session = PtyManager.refresh(session_id)
|
||||
exit_disp = "" if session.exit_code is None else str(session.exit_code)
|
||||
return "\n".join(
|
||||
[
|
||||
@@ -32,3 +19,18 @@ def write_pty(session_id: int, data: str) -> str:
|
||||
f"wrote={len(raw.encode())}",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@tool
|
||||
def write_pty(session_id: int, data: str) -> str:
|
||||
"""Write **literal** text to a PTY session. Does not append a newline.
|
||||
|
||||
``data`` is sent unchanged (no ``ctrl+x`` / ``\\\\xHH`` expansion). For
|
||||
control sequences use :func:`write_pty_keys`.
|
||||
"""
|
||||
try:
|
||||
return write_pty_payload(session_id, data)
|
||||
except WorkspaceError as exc:
|
||||
return f"error: {exc}"
|
||||
except OSError as exc:
|
||||
return f"error: failed to write PTY: {exc}"
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from plyngent.agent import tool
|
||||
from plyngent.tools.workspace import WorkspaceError
|
||||
|
||||
from .pty_terminal import decode_write_data
|
||||
from .write_pty import write_pty_payload
|
||||
|
||||
|
||||
@tool
|
||||
def write_pty_keys(session_id: int, data: str) -> str:
|
||||
"""Write to a PTY after expanding key escapes (never for normal typing).
|
||||
|
||||
Use this only for control sequences. Prefer :func:`write_pty` for plain text
|
||||
so strings like ``press ctrl+c`` are not rewritten.
|
||||
|
||||
Escapes (literal characters in ``data``):
|
||||
|
||||
- ``\\\\n`` ``\\\\r`` ``\\\\t`` ``\\\\e``/``\\\\E`` (ESC) ``\\\\0``
|
||||
- ``\\\\xHH`` byte, ``\\\\uHHHH`` Unicode code point
|
||||
- ``ctrl+c`` / ``ctrl+x`` ... (case-insensitive)
|
||||
- ``key=esc|enter|tab|up|down|left|right``
|
||||
"""
|
||||
try:
|
||||
raw = decode_write_data(data)
|
||||
return write_pty_payload(session_id, raw)
|
||||
except WorkspaceError as exc:
|
||||
return f"error: {exc}"
|
||||
except OSError as exc:
|
||||
return f"error: failed to write PTY: {exc}"
|
||||
@@ -6,7 +6,14 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from plyngent.tools.process import close_pty, open_pty, read_pty, run_command, write_pty
|
||||
from plyngent.tools.process import (
|
||||
close_pty,
|
||||
open_pty,
|
||||
read_pty,
|
||||
run_command,
|
||||
write_pty,
|
||||
write_pty_keys,
|
||||
)
|
||||
from plyngent.tools.process.pty_session import PtyManager
|
||||
from plyngent.tools.workspace import set_command_denylist
|
||||
from tests.test_tools.helpers import call_async, call_sync
|
||||
@@ -375,7 +382,7 @@ async def test_read_pty_sanitizes_esc(workspace: object) -> None:
|
||||
PtyManager.close_all()
|
||||
|
||||
|
||||
async def test_write_pty_ctrl_escape(workspace: object) -> None:
|
||||
async def test_write_pty_keys_ctrl_escape(workspace: object) -> None:
|
||||
del workspace
|
||||
try:
|
||||
opened = call_sync(
|
||||
@@ -388,17 +395,29 @@ async def test_write_pty_ctrl_escape(workspace: object) -> None:
|
||||
),
|
||||
)
|
||||
session_id = _session_id(opened)
|
||||
written = call_sync(write_pty, session_id, "ctrl+c")
|
||||
written = call_sync(write_pty_keys, session_id, "ctrl+c")
|
||||
assert "wrote=1" in written
|
||||
text = await call_async(read_pty, session_id, timeout=2.0)
|
||||
# ESC sanitized; Ctrl+C is 0x03 — may appear as raw or escaped depending
|
||||
# on printable; just ensure write succeeded and process got input.
|
||||
assert "error" not in text.lower() or "alive=" in text
|
||||
_ = await call_async(close_pty, session_id)
|
||||
finally:
|
||||
PtyManager.close_all()
|
||||
|
||||
|
||||
def test_write_pty_is_literal() -> None:
|
||||
"""Plain write_pty must not call the keys decoder."""
|
||||
import inspect
|
||||
|
||||
from plyngent.tools.process.pty_terminal import decode_write_data
|
||||
|
||||
# Decoder is intentionally aggressive; that is why write_pty stays literal.
|
||||
assert decode_write_data("press ctrl+c to cancel") == "press \x03 to cancel"
|
||||
src = inspect.getsource(write_pty.handler)
|
||||
assert "decode_write_data" not in src
|
||||
doc = write_pty.description or write_pty.handler.__doc__ or ""
|
||||
assert "literal" in doc.lower()
|
||||
|
||||
|
||||
def test_pty_backend_available() -> None:
|
||||
from plyngent.tools.process.pty_backend import pty_available
|
||||
|
||||
|
||||
Reference in New Issue
Block a user