core/tools/process: split write_pty (literal) vs write_pty_keys

This commit is contained in:
2026-07-18 15:46:11 +08:00
parent 4ff03d8135
commit 29230af911
7 changed files with 79 additions and 26 deletions
+2 -2
View File
@@ -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.
+1 -1
View File
@@ -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)
+1
View File
@@ -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
+2 -1
View File
@@ -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]
+19 -17
View File
@@ -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}"
+24 -5
View File
@@ -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