Add chat-friendly shell read rendering
Make workspace shell reads usable as direct chat-model input without changing the PTY or cursor model. This adds optional plain rendering and idle-window batching across CLI, SDK, and MCP while keeping raw reads backward-compatible. Implement the rendering and wait-for-idle logic in the manager layer so the existing guest/backend shell transport stays unchanged. The new helper strips ANSI and other terminal control noise, handles carriage-return overwrite and backspace, and preserves raw cursor semantics even when plain output is requested. Refresh the stable shell docs/examples to recommend --plain --wait-for-idle-ms 300, mark the 3.5.0 roadmap milestone done, and bump the package/catalog version to 3.5.0. Validation: uv lock; UV_CACHE_DIR=.uv-cache make check; UV_CACHE_DIR=.uv-cache make dist-check; real guest-backed Firecracker smoke covering shell open/write/read with ANSI plus delayed output.
This commit is contained in:
parent
eecfd7a7d7
commit
21a88312b6
22 changed files with 539 additions and 45 deletions
|
|
@ -330,12 +330,16 @@ class Pyro:
|
|||
*,
|
||||
cursor: int = 0,
|
||||
max_chars: int = 65536,
|
||||
plain: bool = False,
|
||||
wait_for_idle_ms: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return self._manager.read_shell(
|
||||
workspace_id,
|
||||
shell_id,
|
||||
cursor=cursor,
|
||||
max_chars=max_chars,
|
||||
plain=plain,
|
||||
wait_for_idle_ms=wait_for_idle_ms,
|
||||
)
|
||||
|
||||
def write_shell(
|
||||
|
|
@ -902,6 +906,8 @@ class Pyro:
|
|||
shell_id: str,
|
||||
cursor: int = 0,
|
||||
max_chars: int = 65536,
|
||||
plain: bool = False,
|
||||
wait_for_idle_ms: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Read merged PTY output from a workspace shell."""
|
||||
return self.read_shell(
|
||||
|
|
@ -909,6 +915,8 @@ class Pyro:
|
|||
shell_id,
|
||||
cursor=cursor,
|
||||
max_chars=max_chars,
|
||||
plain=plain,
|
||||
wait_for_idle_ms=wait_for_idle_ms,
|
||||
)
|
||||
|
||||
if _enabled("shell_write"):
|
||||
|
|
|
|||
|
|
@ -532,6 +532,8 @@ def _print_workspace_shell_read_human(payload: dict[str, Any]) -> None:
|
|||
f"cursor={int(payload.get('cursor', 0))} "
|
||||
f"next_cursor={int(payload.get('next_cursor', 0))} "
|
||||
f"truncated={bool(payload.get('truncated', False))} "
|
||||
f"plain={bool(payload.get('plain', False))} "
|
||||
f"wait_for_idle_ms={payload.get('wait_for_idle_ms')} "
|
||||
f"execution_mode={str(payload.get('execution_mode', 'unknown'))}",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
|
|
@ -1529,7 +1531,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
Examples:
|
||||
pyro workspace shell open WORKSPACE_ID
|
||||
pyro workspace shell write WORKSPACE_ID SHELL_ID --input 'pwd'
|
||||
pyro workspace shell read WORKSPACE_ID SHELL_ID
|
||||
pyro workspace shell read WORKSPACE_ID SHELL_ID --plain --wait-for-idle-ms 300
|
||||
pyro workspace shell signal WORKSPACE_ID SHELL_ID --signal INT
|
||||
pyro workspace shell close WORKSPACE_ID SHELL_ID
|
||||
|
||||
|
|
@ -1592,10 +1594,10 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
epilog=dedent(
|
||||
"""
|
||||
Example:
|
||||
pyro workspace shell read WORKSPACE_ID SHELL_ID --cursor 0
|
||||
pyro workspace shell read WORKSPACE_ID SHELL_ID --plain --wait-for-idle-ms 300
|
||||
|
||||
Shell output is written to stdout. The read summary is written to stderr.
|
||||
Use --json for a deterministic structured response.
|
||||
Use --plain for chat-friendly output and --json for a deterministic structured response.
|
||||
"""
|
||||
),
|
||||
formatter_class=_HelpFormatter,
|
||||
|
|
@ -1622,6 +1624,17 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
default=65536,
|
||||
help="Maximum number of characters to return from the current cursor position.",
|
||||
)
|
||||
workspace_shell_read_parser.add_argument(
|
||||
"--plain",
|
||||
action="store_true",
|
||||
help="Strip terminal control sequences and normalize shell output for chat consumption.",
|
||||
)
|
||||
workspace_shell_read_parser.add_argument(
|
||||
"--wait-for-idle-ms",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Wait for this many milliseconds of shell-idle time before returning output.",
|
||||
)
|
||||
workspace_shell_read_parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
|
|
@ -2652,6 +2665,8 @@ def main() -> None:
|
|||
args.shell_id,
|
||||
cursor=args.cursor,
|
||||
max_chars=args.max_chars,
|
||||
plain=bool(args.plain),
|
||||
wait_for_idle_ms=args.wait_for_idle_ms,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if bool(args.json):
|
||||
|
|
|
|||
|
|
@ -83,7 +83,13 @@ PUBLIC_CLI_WORKSPACE_SHELL_OPEN_FLAGS = (
|
|||
"--secret-env",
|
||||
"--json",
|
||||
)
|
||||
PUBLIC_CLI_WORKSPACE_SHELL_READ_FLAGS = ("--cursor", "--max-chars", "--json")
|
||||
PUBLIC_CLI_WORKSPACE_SHELL_READ_FLAGS = (
|
||||
"--cursor",
|
||||
"--max-chars",
|
||||
"--plain",
|
||||
"--wait-for-idle-ms",
|
||||
"--json",
|
||||
)
|
||||
PUBLIC_CLI_WORKSPACE_SHELL_WRITE_FLAGS = ("--input", "--no-newline", "--json")
|
||||
PUBLIC_CLI_WORKSPACE_SHELL_SIGNAL_FLAGS = ("--signal", "--json")
|
||||
PUBLIC_CLI_WORKSPACE_SHELL_CLOSE_FLAGS = ("--json",)
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ from typing import Any
|
|||
from pyro_mcp.runtime import DEFAULT_PLATFORM, RuntimePaths
|
||||
|
||||
DEFAULT_ENVIRONMENT_VERSION = "1.0.0"
|
||||
DEFAULT_CATALOG_VERSION = "3.4.0"
|
||||
DEFAULT_CATALOG_VERSION = "3.5.0"
|
||||
OCI_MANIFEST_ACCEPT = ", ".join(
|
||||
(
|
||||
"application/vnd.oci.image.index.v1+json",
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ from pyro_mcp.workspace_files import (
|
|||
write_workspace_file,
|
||||
)
|
||||
from pyro_mcp.workspace_ports import DEFAULT_PUBLISHED_PORT_HOST
|
||||
from pyro_mcp.workspace_shell_output import render_plain_shell_output
|
||||
from pyro_mcp.workspace_shells import (
|
||||
create_local_shell,
|
||||
get_local_shell,
|
||||
|
|
@ -97,6 +98,9 @@ WORKSPACE_SECRET_MAX_BYTES = 64 * 1024
|
|||
DEFAULT_SHELL_COLS = 120
|
||||
DEFAULT_SHELL_ROWS = 30
|
||||
DEFAULT_SHELL_MAX_CHARS = 65536
|
||||
DEFAULT_SHELL_WAIT_FOR_IDLE_MS = 300
|
||||
MAX_SHELL_WAIT_FOR_IDLE_MS = 10000
|
||||
SHELL_IDLE_POLL_INTERVAL_SECONDS = 0.05
|
||||
DEFAULT_WORKSPACE_DISK_READ_MAX_BYTES = 65536
|
||||
DEFAULT_WORKSPACE_FILE_READ_MAX_BYTES = DEFAULT_WORKSPACE_FILE_READ_LIMIT
|
||||
WORKSPACE_FILE_MAX_BYTES = WORKSPACE_FILE_MAX_LIMIT
|
||||
|
|
@ -4597,24 +4601,42 @@ class VmManager:
|
|||
*,
|
||||
cursor: int = 0,
|
||||
max_chars: int = DEFAULT_SHELL_MAX_CHARS,
|
||||
plain: bool = False,
|
||||
wait_for_idle_ms: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if cursor < 0:
|
||||
raise ValueError("cursor must not be negative")
|
||||
if max_chars <= 0:
|
||||
raise ValueError("max_chars must be positive")
|
||||
if wait_for_idle_ms is not None and (
|
||||
wait_for_idle_ms <= 0 or wait_for_idle_ms > MAX_SHELL_WAIT_FOR_IDLE_MS
|
||||
):
|
||||
raise ValueError(
|
||||
f"wait_for_idle_ms must be between 1 and {MAX_SHELL_WAIT_FOR_IDLE_MS}"
|
||||
)
|
||||
with self._lock:
|
||||
workspace = self._load_workspace_locked(workspace_id)
|
||||
instance = self._workspace_instance_for_live_shell_locked(workspace)
|
||||
shell = self._load_workspace_shell_locked(workspace_id, shell_id)
|
||||
redact_values = self._workspace_secret_redact_values_locked(workspace)
|
||||
try:
|
||||
payload = self._backend.read_shell(
|
||||
instance,
|
||||
workspace_id=workspace_id,
|
||||
shell_id=shell_id,
|
||||
cursor=cursor,
|
||||
max_chars=max_chars,
|
||||
)
|
||||
if wait_for_idle_ms is None:
|
||||
payload = self._backend.read_shell(
|
||||
instance,
|
||||
workspace_id=workspace_id,
|
||||
shell_id=shell_id,
|
||||
cursor=cursor,
|
||||
max_chars=max_chars,
|
||||
)
|
||||
else:
|
||||
payload = self._read_shell_with_idle_wait(
|
||||
instance=instance,
|
||||
workspace_id=workspace_id,
|
||||
shell_id=shell_id,
|
||||
cursor=cursor,
|
||||
max_chars=max_chars,
|
||||
wait_for_idle_ms=wait_for_idle_ms,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise _redact_exception(exc, redact_values) from exc
|
||||
updated_shell = self._workspace_shell_record_from_payload(
|
||||
|
|
@ -4631,17 +4653,84 @@ class VmManager:
|
|||
workspace.metadata = dict(instance.metadata)
|
||||
self._save_workspace_locked(workspace)
|
||||
self._save_workspace_shell_locked(updated_shell)
|
||||
raw_output = _redact_text(str(payload.get("output", "")), redact_values)
|
||||
rendered_output = render_plain_shell_output(raw_output) if plain else raw_output
|
||||
truncated = bool(payload.get("truncated", False))
|
||||
if plain and len(rendered_output) > max_chars:
|
||||
rendered_output = rendered_output[:max_chars]
|
||||
truncated = True
|
||||
response = self._serialize_workspace_shell(updated_shell)
|
||||
response.update(
|
||||
{
|
||||
"cursor": int(payload.get("cursor", cursor)),
|
||||
"next_cursor": int(payload.get("next_cursor", cursor)),
|
||||
"output": _redact_text(str(payload.get("output", "")), redact_values),
|
||||
"truncated": bool(payload.get("truncated", False)),
|
||||
"output": rendered_output,
|
||||
"truncated": truncated,
|
||||
"plain": plain,
|
||||
"wait_for_idle_ms": wait_for_idle_ms,
|
||||
}
|
||||
)
|
||||
return response
|
||||
|
||||
def _read_shell_with_idle_wait(
|
||||
self,
|
||||
*,
|
||||
instance: VmInstance,
|
||||
workspace_id: str,
|
||||
shell_id: str,
|
||||
cursor: int,
|
||||
max_chars: int,
|
||||
wait_for_idle_ms: int,
|
||||
) -> dict[str, Any]:
|
||||
wait_seconds = wait_for_idle_ms / 1000
|
||||
current_cursor = cursor
|
||||
remaining_chars = max_chars
|
||||
raw_chunks: list[str] = []
|
||||
last_payload: dict[str, Any] | None = None
|
||||
saw_output = False
|
||||
deadline = time.monotonic() + wait_seconds
|
||||
while True:
|
||||
payload = self._backend.read_shell(
|
||||
instance,
|
||||
workspace_id=workspace_id,
|
||||
shell_id=shell_id,
|
||||
cursor=current_cursor,
|
||||
max_chars=remaining_chars,
|
||||
)
|
||||
last_payload = payload
|
||||
next_cursor = int(payload.get("next_cursor", current_cursor))
|
||||
chunk = str(payload.get("output", ""))
|
||||
advanced = next_cursor > current_cursor or bool(chunk)
|
||||
if advanced:
|
||||
saw_output = True
|
||||
if chunk:
|
||||
raw_chunks.append(chunk)
|
||||
consumed = max(0, next_cursor - current_cursor)
|
||||
current_cursor = next_cursor
|
||||
remaining_chars = max(0, remaining_chars - consumed)
|
||||
deadline = time.monotonic() + wait_seconds
|
||||
if remaining_chars <= 0 or str(payload.get("state", "")) != "running":
|
||||
break
|
||||
time.sleep(SHELL_IDLE_POLL_INTERVAL_SECONDS)
|
||||
continue
|
||||
if str(payload.get("state", "")) != "running":
|
||||
break
|
||||
if time.monotonic() >= deadline:
|
||||
break
|
||||
if not saw_output and wait_seconds <= 0:
|
||||
break
|
||||
time.sleep(SHELL_IDLE_POLL_INTERVAL_SECONDS)
|
||||
if last_payload is None:
|
||||
raise RuntimeError(f"shell {shell_id} did not return a read payload")
|
||||
if current_cursor == cursor:
|
||||
return last_payload
|
||||
aggregated = dict(last_payload)
|
||||
aggregated["cursor"] = cursor
|
||||
aggregated["next_cursor"] = current_cursor
|
||||
aggregated["output"] = "".join(raw_chunks)
|
||||
aggregated["truncated"] = bool(last_payload.get("truncated", False)) or remaining_chars <= 0
|
||||
return aggregated
|
||||
|
||||
def write_shell(
|
||||
self,
|
||||
workspace_id: str,
|
||||
|
|
|
|||
116
src/pyro_mcp/workspace_shell_output.py
Normal file
116
src/pyro_mcp/workspace_shell_output.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
"""Helpers for chat-friendly workspace shell output rendering."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def _apply_csi(
|
||||
final: str,
|
||||
parameters: str,
|
||||
line: list[str],
|
||||
cursor: int,
|
||||
lines: list[str],
|
||||
) -> tuple[list[str], int, list[str]]:
|
||||
if final == "K":
|
||||
mode = parameters or "0"
|
||||
if mode in {"0", ""}:
|
||||
del line[cursor:]
|
||||
elif mode == "1":
|
||||
for index in range(min(cursor, len(line))):
|
||||
line[index] = " "
|
||||
elif mode == "2":
|
||||
line.clear()
|
||||
cursor = 0
|
||||
elif final == "J":
|
||||
mode = parameters or "0"
|
||||
if mode in {"2", "3"}:
|
||||
lines.clear()
|
||||
line.clear()
|
||||
cursor = 0
|
||||
return line, cursor, lines
|
||||
|
||||
|
||||
def _consume_escape_sequence(
|
||||
text: str,
|
||||
index: int,
|
||||
line: list[str],
|
||||
cursor: int,
|
||||
lines: list[str],
|
||||
) -> tuple[int, list[str], int, list[str]]:
|
||||
if index + 1 >= len(text):
|
||||
return len(text), line, cursor, lines
|
||||
leader = text[index + 1]
|
||||
if leader == "[":
|
||||
cursor_index = index + 2
|
||||
while cursor_index < len(text):
|
||||
char = text[cursor_index]
|
||||
if "\x40" <= char <= "\x7e":
|
||||
parameters = text[index + 2 : cursor_index]
|
||||
line, cursor, lines = _apply_csi(char, parameters, line, cursor, lines)
|
||||
return cursor_index + 1, line, cursor, lines
|
||||
cursor_index += 1
|
||||
return len(text), line, cursor, lines
|
||||
if leader in {"]", "P", "_", "^"}:
|
||||
cursor_index = index + 2
|
||||
while cursor_index < len(text):
|
||||
char = text[cursor_index]
|
||||
if char == "\x07":
|
||||
return cursor_index + 1, line, cursor, lines
|
||||
if char == "\x1b" and cursor_index + 1 < len(text) and text[cursor_index + 1] == "\\":
|
||||
return cursor_index + 2, line, cursor, lines
|
||||
cursor_index += 1
|
||||
return len(text), line, cursor, lines
|
||||
if leader == "O":
|
||||
return min(index + 3, len(text)), line, cursor, lines
|
||||
return min(index + 2, len(text)), line, cursor, lines
|
||||
|
||||
|
||||
def render_plain_shell_output(raw_text: str) -> str:
|
||||
"""Render PTY output into chat-friendly plain text."""
|
||||
lines: list[str] = []
|
||||
line: list[str] = []
|
||||
cursor = 0
|
||||
ended_with_newline = False
|
||||
index = 0
|
||||
while index < len(raw_text):
|
||||
char = raw_text[index]
|
||||
if char == "\x1b":
|
||||
index, line, cursor, lines = _consume_escape_sequence(
|
||||
raw_text,
|
||||
index,
|
||||
line,
|
||||
cursor,
|
||||
lines,
|
||||
)
|
||||
ended_with_newline = False
|
||||
continue
|
||||
if char == "\r":
|
||||
cursor = 0
|
||||
ended_with_newline = False
|
||||
index += 1
|
||||
continue
|
||||
if char == "\n":
|
||||
lines.append("".join(line))
|
||||
line = []
|
||||
cursor = 0
|
||||
ended_with_newline = True
|
||||
index += 1
|
||||
continue
|
||||
if char == "\b":
|
||||
if cursor > 0:
|
||||
cursor -= 1
|
||||
if cursor < len(line):
|
||||
del line[cursor]
|
||||
ended_with_newline = False
|
||||
index += 1
|
||||
continue
|
||||
if char == "\t" or (ord(char) >= 32 and ord(char) != 127):
|
||||
if cursor < len(line):
|
||||
line[cursor] = char
|
||||
else:
|
||||
line.append(char)
|
||||
cursor += 1
|
||||
ended_with_newline = False
|
||||
index += 1
|
||||
if line or ended_with_newline:
|
||||
lines.append("".join(line))
|
||||
return "\n".join(lines)
|
||||
Loading…
Add table
Add a link
Reference in a new issue