core/tools/process: run_command stdin/env and write_pty

Optional stdin and env overlay for run_command; write_pty for
interactive PTY input.
This commit is contained in:
2026-07-14 19:36:46 +08:00
parent bd6f8f5d9a
commit 73c11cee36
6 changed files with 127 additions and 14 deletions
+1
View File
@@ -8,6 +8,7 @@ from .process import close_pty as close_pty
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 .workspace import (
DEFAULT_COMMAND_DENYLIST as DEFAULT_COMMAND_DENYLIST,
)
+2 -1
View File
@@ -2,5 +2,6 @@ from .close_pty import close_pty as close_pty
from .open_pty import open_pty as open_pty
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
PROCESS_TOOLS = [run_command, open_pty, read_pty, close_pty]
PROCESS_TOOLS = [run_command, open_pty, read_pty, write_pty, close_pty]
+59 -11
View File
@@ -1,7 +1,9 @@
from __future__ import annotations
import asyncio
import os
import shlex
from typing import TYPE_CHECKING
from plyngent.agent import tool
from plyngent.tools.workspace import (
@@ -11,6 +13,9 @@ from plyngent.tools.workspace import (
resolve_path,
)
if TYPE_CHECKING:
from pathlib import Path
DEFAULT_TIMEOUT_SECONDS = 30
DEFAULT_MAX_OUTPUT_CHARS = 32_000
@@ -41,16 +46,30 @@ def _format_result(
return "\n".join(parts)
def _merge_env(overrides: dict[str, str] | None) -> dict[str, str] | None:
if overrides is None:
return None
merged = dict(os.environ)
for key, value in overrides.items():
merged[str(key)] = str(value)
return merged
async def _run_exec(
command: list[str],
*,
workdir: str,
timeout_seconds: float,
stdin_data: bytes | None,
env: dict[str, str] | None,
) -> tuple[int | None, str, str] | str:
stdin = asyncio.subprocess.PIPE if stdin_data is not None else None
try:
proc = await asyncio.create_subprocess_exec(
*command,
cwd=workdir,
env=env,
stdin=stdin,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
@@ -60,7 +79,10 @@ async def _run_exec(
return f"error: failed to start command: {exc}"
try:
stdout_b, stderr_b = await asyncio.wait_for(proc.communicate(), timeout=timeout_seconds)
stdout_b, stderr_b = await asyncio.wait_for(
proc.communicate(input=stdin_data),
timeout=timeout_seconds,
)
except TimeoutError:
proc.kill()
_ = await proc.communicate()
@@ -71,17 +93,14 @@ async def _run_exec(
return proc.returncode, stdout, stderr
@tool
async def run_command(
def _validate_run_args(
command: list[str],
*,
cwd: str = ".",
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
) -> str:
"""Run a command without a shell (argv list) under the workspace.
``cwd`` is relative to or under the workspace root. Output is truncated.
"""
cwd: str,
timeout_seconds: float,
env: dict[str, str] | None,
) -> Path | str:
"""Return resolved workdir, or an error string."""
if not command:
return "error: command must not be empty"
try:
@@ -93,8 +112,37 @@ async def run_command(
return f"error: cwd is not a directory: {cwd}"
if timeout_seconds <= 0:
return "error: timeout_seconds must be > 0"
_ = env # typed as dict[str, str] | None; runtime JSON already constrained by tool schema
return workdir
result = await _run_exec(command, workdir=str(workdir), timeout_seconds=timeout_seconds)
@tool
async def run_command(
command: list[str],
*,
cwd: str = ".",
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
stdin: str | None = None,
env: dict[str, str] | None = None,
) -> str:
"""Run a command without a shell (argv list) under the workspace.
``cwd`` is relative to or under the workspace root. Optional ``stdin`` is
written to the process stdin. Optional ``env`` overlays process environment
variables (merged with the current environment). Output is truncated.
"""
workdir = _validate_run_args(command, cwd=cwd, timeout_seconds=timeout_seconds, env=env)
if isinstance(workdir, str):
return workdir
stdin_data = None if stdin is None else stdin.encode()
result = await _run_exec(
command,
workdir=str(workdir),
timeout_seconds=timeout_seconds,
stdin_data=stdin_data,
env=_merge_env(env),
)
if isinstance(result, str):
return result
returncode, stdout, stderr = result
+18
View File
@@ -0,0 +1,18 @@
from __future__ import annotations
from plyngent.agent import tool
from plyngent.tools.workspace import WorkspaceError
from .pty_session import PtyManager
@tool
def write_pty(session_id: int, data: str) -> str:
"""Write text to a PTY session (e.g. interactive input). Does not append a newline."""
try:
PtyManager.write(session_id, data)
except WorkspaceError as exc:
return f"error: {exc}"
except OSError as exc:
return f"error: failed to write PTY: {exc}"
return f"wrote {len(data)} characters to session_id={session_id}"