Add background process management with process tool, wait, PTY, and stdin support

New process registry and tool for managing long-running background processes
across all terminal backends (local, Docker, Singularity, Modal, SSH).

Process Registry (tools/process_registry.py):
- ProcessSession tracking with rolling 200KB output buffer
- spawn_local() with optional PTY via ptyprocess for interactive CLIs
- spawn_via_env() for non-local backends (runs inside sandbox, never on host)
- Background reader threads per process (Popen stdout or PTY)
- wait() with timeout clamping, interrupt support, and transparent limit reporting
- JSON checkpoint to ~/.hermes/processes.json for gateway crash recovery
- Module-level singleton shared across agent loop, gateway, and RL

Process Tool (model_tools.py):
- 7 actions: list, poll, log, wait, kill, write, submit
- Paired with terminal in all toolsets (CLI, messaging, RL)
- Timeout clamping with transparent notes in response

Terminal Tool Updates (tools/terminal_tool.py):
- Replaced nohup background mode with registry spawn (returns session_id)
- Added workdir parameter for per-command working directory
- Added check_interval parameter for gateway auto-check watchers
- Added pty parameter for interactive CLI tools (Codex, Claude Code)
- Updated TERMINAL_TOOL_DESCRIPTION with full background workflow docs
- Cleanup thread now respects active background processes (won't reap sandbox)

Gateway Integration (gateway/run.py, session.py, config.py):
- Session reset protection: sessions with active processes exempt from reset
- Default idle timeout increased from 2 hours to 24 hours
- from_dict fallback aligned to match (was 120, now 1440)
- session_key env var propagated to process registry for session mapping
- Crash recovery on gateway startup via checkpoint probe
- check_interval watcher: asyncio task polls process, delivers updates to platform

RL Safety (environments/):
- tool_context.py cleanup() kills background processes on episode end
- hermes_base_env.py warns when enabled_toolsets is None (loads all tools)
- Process tool safe in RL via wait() blocking the agent loop

Also:
- Added ptyprocess as optional dependency (in pyproject.toml [pty] extra + [all])
- Fixed pre-existing bug: rl_test_inference missing from TOOL_TO_TOOLSET_MAP
- Updated AGENTS.md with process management docs and project structure
- Updated README.md terminal section with process management overview
This commit is contained in:
teknium1 2026-02-17 02:51:31 -08:00
parent 48b5cfd085
commit 061fa70907
12 changed files with 1142 additions and 40 deletions

View file

@ -320,6 +320,20 @@ def get_terminal_tool_definitions() -> List[Dict[str, Any]]:
"type": "integer",
"description": "Command timeout in seconds (optional)",
"minimum": 1
},
"workdir": {
"type": "string",
"description": "Working directory for this command (absolute path). Defaults to the session working directory."
},
"check_interval": {
"type": "integer",
"description": "Seconds between automatic status checks for background processes (gateway/messaging only, minimum 30). When set, I'll proactively report progress.",
"minimum": 30
},
"pty": {
"type": "boolean",
"description": "Run in pseudo-terminal (PTY) mode for interactive CLI tools like Codex, Claude Code, or Python REPL. Only works with local and SSH backends. Default: false.",
"default": False
}
},
"required": ["command"]
@ -930,6 +944,64 @@ def get_send_message_tool_definitions():
]
def get_process_tool_definitions() -> List[Dict[str, Any]]:
"""
Get tool definitions for the process management tool.
The process tool manages background processes started with terminal(background=true).
Actions: list, poll, log, wait, kill. Phase 2 adds: write, submit.
"""
return [
{
"type": "function",
"function": {
"name": "process",
"description": (
"Manage background processes started with terminal(background=true). "
"Actions: 'list' (show all), 'poll' (check status + new output), "
"'log' (full output with pagination), 'wait' (block until done or timeout), "
"'kill' (terminate), 'write' (send raw data to stdin), 'submit' (send data + Enter). "
"Use 'wait' when you have nothing else to do and want "
"to block until a background process finishes."
),
"parameters": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["list", "poll", "log", "wait", "kill", "write", "submit"],
"description": "Action to perform on background processes"
},
"session_id": {
"type": "string",
"description": "Process session ID (from terminal background output). Required for poll/log/wait/kill."
},
"data": {
"type": "string",
"description": "Text to send to process stdin (for 'write' and 'submit' actions)"
},
"timeout": {
"type": "integer",
"description": "Max seconds to block for 'wait' action. Returns partial output on timeout.",
"minimum": 1
},
"offset": {
"type": "integer",
"description": "Line offset for 'log' action (default: last 200 lines)"
},
"limit": {
"type": "integer",
"description": "Max lines to return for 'log' action",
"minimum": 1
}
},
"required": ["action"]
}
}
}
]
def get_all_tool_names() -> List[str]:
"""
Get the names of all available tools across all toolsets.
@ -945,7 +1017,7 @@ def get_all_tool_names() -> List[str]:
# Terminal tools (mini-swe-agent backend)
if check_terminal_requirements():
tool_names.extend(["terminal"])
tool_names.extend(["terminal", "process"])
# Vision tools
if check_vision_requirements():
@ -1011,6 +1083,7 @@ TOOL_TO_TOOLSET_MAP = {
"web_search": "web_tools",
"web_extract": "web_tools",
"terminal": "terminal_tools",
"process": "terminal_tools",
"vision_analyze": "vision_tools",
"mixture_of_agents": "moa_tools",
"image_generate": "image_tools",
@ -1042,6 +1115,7 @@ TOOL_TO_TOOLSET_MAP = {
"rl_stop_training": "rl_tools",
"rl_get_results": "rl_tools",
"rl_list_runs": "rl_tools",
"rl_test_inference": "rl_tools",
# Text-to-speech tools
"text_to_speech": "tts_tools",
# File manipulation tools
@ -1113,6 +1187,9 @@ def get_tool_definitions(
if check_terminal_requirements():
for tool in get_terminal_tool_definitions():
all_available_tools_map[tool["function"]["name"]] = tool
# Process management tool (paired with terminal)
for tool in get_process_tool_definitions():
all_available_tools_map[tool["function"]["name"]] = tool
if check_vision_requirements():
for tool in get_vision_tool_definitions():
@ -1339,15 +1416,70 @@ def handle_terminal_function_call(function_name: str, function_args: Dict[str, A
command = function_args.get("command")
background = function_args.get("background", False)
timeout = function_args.get("timeout")
# Note: force parameter exists internally but is NOT exposed to the model
# Dangerous command approval is handled via user prompts only
workdir = function_args.get("workdir")
check_interval = function_args.get("check_interval")
pty = function_args.get("pty", False)
return terminal_tool(command=command, background=background, timeout=timeout, task_id=task_id)
return terminal_tool(command=command, background=background, timeout=timeout, task_id=task_id, workdir=workdir, check_interval=check_interval, pty=pty)
else:
return json.dumps({"error": f"Unknown terminal function: {function_name}"}, ensure_ascii=False)
def handle_process_function_call(function_name: str, function_args: Dict[str, Any], task_id: Optional[str] = None) -> str:
"""
Handle function calls for the process management tool.
Routes actions (list, poll, log, wait, kill) to the ProcessRegistry.
"""
from tools.process_registry import process_registry
action = function_args.get("action", "")
session_id = function_args.get("session_id", "")
if action == "list":
sessions = process_registry.list_sessions(task_id=task_id)
return json.dumps({"processes": sessions}, ensure_ascii=False)
elif action == "poll":
if not session_id:
return json.dumps({"error": "session_id is required for poll"}, ensure_ascii=False)
return json.dumps(process_registry.poll(session_id), ensure_ascii=False)
elif action == "log":
if not session_id:
return json.dumps({"error": "session_id is required for log"}, ensure_ascii=False)
offset = function_args.get("offset", 0)
limit = function_args.get("limit", 200)
return json.dumps(process_registry.read_log(session_id, offset=offset, limit=limit), ensure_ascii=False)
elif action == "wait":
if not session_id:
return json.dumps({"error": "session_id is required for wait"}, ensure_ascii=False)
timeout = function_args.get("timeout")
return json.dumps(process_registry.wait(session_id, timeout=timeout), ensure_ascii=False)
elif action == "kill":
if not session_id:
return json.dumps({"error": "session_id is required for kill"}, ensure_ascii=False)
return json.dumps(process_registry.kill_process(session_id), ensure_ascii=False)
elif action == "write":
if not session_id:
return json.dumps({"error": "session_id is required for write"}, ensure_ascii=False)
data = function_args.get("data", "")
return json.dumps(process_registry.write_stdin(session_id, data), ensure_ascii=False)
elif action == "submit":
if not session_id:
return json.dumps({"error": "session_id is required for submit"}, ensure_ascii=False)
data = function_args.get("data", "")
return json.dumps(process_registry.submit_stdin(session_id, data), ensure_ascii=False)
else:
return json.dumps({"error": f"Unknown process action: {action}. Use: list, poll, log, wait, kill, write, submit"}, ensure_ascii=False)
def handle_vision_function_call(function_name: str, function_args: Dict[str, Any]) -> str:
"""
Handle function calls for vision tools.
@ -1779,6 +1911,10 @@ def handle_function_call(
elif function_name in ["terminal"]:
return handle_terminal_function_call(function_name, function_args, task_id)
# Route process management tools
elif function_name in ["process"]:
return handle_process_function_call(function_name, function_args, task_id)
# Route vision tools
elif function_name in ["vision_analyze"]:
return handle_vision_function_call(function_name, function_args)