feat: Enhance local command handling and introduce local intents
- Refactor `run_local_command` to manage subprocesses more effectively, ensuring child processes are terminated on timeout. - Introduce `_terminate` function to handle process group termination and capture output. - Implement `_command_output` to format command results with a character limit. - Add local intent recognition in `intents.py` to handle commands like "stop", "go to sleep", and "come here" without server interaction. - Normalize user input to match local intents while stripping filler words. - Update tests to cover new local intent functionality and ensure proper command handling. - Enhance speech processing to handle abbreviations and improve spoken output clarity.
This commit is contained in:
@@ -19,6 +19,8 @@ Kept dependency-free beyond `requests` so it's easy to unit test with mocks.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Callable, NamedTuple, Optional
|
||||
@@ -29,6 +31,10 @@ from . import config, sudo_askpass
|
||||
|
||||
_MAX_RELAY_HOPS = 16
|
||||
|
||||
# Command output handed back up the relay is capped: it becomes part of the
|
||||
# server's prompt, and a runaway `find /` would blow the context window.
|
||||
_MAX_COMMAND_OUTPUT = 6000
|
||||
|
||||
|
||||
class ServerError(Exception):
|
||||
"""Raised when the server responds with an error payload or unreachable."""
|
||||
@@ -74,17 +80,61 @@ def run_local_command(command: str, timeout: int = None) -> str:
|
||||
timeout = timeout or config.SUDO_COMMAND_TIMEOUT_SECONDS
|
||||
timeout = timeout or config.COMMAND_TIMEOUT_SECONDS
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
command, shell=True, capture_output=True, text=True,
|
||||
timeout=timeout, cwd=str(Path.home()), env=env,
|
||||
# start_new_session puts the shell in its own process group so a timeout
|
||||
# can kill the whole tree. subprocess.run() would only SIGKILL the `sh`
|
||||
# itself, leaving whatever it spawned (a build, a `tail -f`, an ffmpeg)
|
||||
# running forever with no parent watching — one relayed command that
|
||||
# hangs shouldn't leak a process for the rest of the session.
|
||||
process = subprocess.Popen(
|
||||
command, shell=True, cwd=str(Path.home()), env=env, text=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
start_new_session=(os.name == "posix"),
|
||||
)
|
||||
output = (completed.stdout or "") + (completed.stderr or "")
|
||||
return f"[exit {completed.returncode}]\n{output}"[:6000]
|
||||
except subprocess.TimeoutExpired:
|
||||
return f"[command timed out after {timeout}s]"
|
||||
except Exception as exc:
|
||||
return f"[command failed: {exc}]"
|
||||
|
||||
try:
|
||||
stdout, stderr = process.communicate(timeout=timeout)
|
||||
return _command_output(f"[exit {process.returncode}]", stdout, stderr)
|
||||
except subprocess.TimeoutExpired:
|
||||
stdout, stderr = _terminate(process)
|
||||
# Whatever it managed to print before it hung is the useful part — a
|
||||
# bare "timed out" tells the model nothing it can act on, and the last
|
||||
# line of output usually says exactly what it was stuck waiting for.
|
||||
return _command_output(f"[command timed out after {timeout}s]", stdout, stderr)
|
||||
except Exception as exc:
|
||||
_terminate(process)
|
||||
return f"[command failed: {exc}]"
|
||||
|
||||
|
||||
def _terminate(process: subprocess.Popen) -> tuple[str, str]:
|
||||
"""Kill a timed-out command's whole process group and collect what it wrote.
|
||||
|
||||
SIGTERM first so a shell script can clean up, SIGKILL a moment later for
|
||||
anything that ignores it. The final drain is itself time-boxed: a
|
||||
grandchild holding the pipe open must not turn a timeout into a hang."""
|
||||
try:
|
||||
if os.name == "posix":
|
||||
group = os.getpgid(process.pid)
|
||||
os.killpg(group, signal.SIGTERM)
|
||||
try:
|
||||
process.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(group, signal.SIGKILL)
|
||||
else:
|
||||
process.kill()
|
||||
except (ProcessLookupError, PermissionError, OSError):
|
||||
pass # already gone, or never had its own group
|
||||
try:
|
||||
return process.communicate(timeout=2)
|
||||
except Exception:
|
||||
return "", ""
|
||||
|
||||
|
||||
def _command_output(header: str, stdout: Optional[str], stderr: Optional[str]) -> str:
|
||||
body = (stdout or "") + (stderr or "")
|
||||
return f"{header}\n{body}"[:_MAX_COMMAND_OUTPUT]
|
||||
|
||||
|
||||
def converse(
|
||||
text: str,
|
||||
@@ -132,6 +182,15 @@ def converse(
|
||||
voice_id=str(payload.get("voice_id") or ""),
|
||||
voice_name=str(payload.get("voice_name") or ""),
|
||||
)
|
||||
if payload.get("type") == "command":
|
||||
# Fell out of the loop still being handed commands. Worth its own
|
||||
# message: "unknown server response" sent everyone looking at the
|
||||
# payload shape, when what actually happened is a model that kept
|
||||
# calling tools and never answered.
|
||||
raise ServerError(
|
||||
f"the server kept relaying commands past the {_MAX_RELAY_HOPS}-hop cap "
|
||||
"without producing a reply"
|
||||
)
|
||||
raise ServerError(str(payload.get("error") or "unknown server response"))
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user