-
-
Notifications
You must be signed in to change notification settings - Fork 2.1k
feat(shell): add background command execution with output redirection and timeout support #7835
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
77040ca
f1206d9
d7a54ae
988961a
ffa7508
44e31bd
3f52c7a
33eeed1
451d545
fddfbb9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| import shlex | ||
|
|
||
| _BACKGROUND_SPAWN_SCRIPT = ( | ||
| "import subprocess, sys; " | ||
| "p = subprocess.Popen(" | ||
| "['bash', '-lc', sys.argv[1]], " | ||
| "stdin=subprocess.DEVNULL, " | ||
| "stdout=subprocess.DEVNULL, " | ||
| "stderr=subprocess.DEVNULL, " | ||
| "start_new_session=True, " | ||
| "close_fds=True" | ||
| "); " | ||
| "print(p.pid)" | ||
| ) | ||
|
|
||
|
|
||
| def build_detached_shell_command(command: str) -> str: | ||
| return f"python3 -c {shlex.quote(_BACKGROUND_SPAWN_SCRIPT)} {shlex.quote(command)}" |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -1,13 +1,17 @@ | ||||||
| import json | ||||||
| import os | ||||||
| import shlex | ||||||
| import uuid | ||||||
| from dataclasses import dataclass, field | ||||||
| from pathlib import Path | ||||||
| from typing import Any | ||||||
|
|
||||||
| from astrbot.api import FunctionTool | ||||||
| from astrbot.core.agent.run_context import ContextWrapper | ||||||
| from astrbot.core.agent.tool import ToolExecResult | ||||||
| from astrbot.core.astr_agent_context import AstrAgentContext | ||||||
| from astrbot.core.computer.computer_client import get_booter | ||||||
| from astrbot.core.utils.astrbot_path import get_astrbot_system_tmp_path | ||||||
|
|
||||||
| from ..registry import builtin_tool | ||||||
| from .util import check_admin_permission, is_local_runtime, workspace_root | ||||||
|
|
@@ -17,6 +21,32 @@ | |||||
| } | ||||||
|
|
||||||
|
|
||||||
| def _quote_redirect_path(path: str, *, local_runtime: bool) -> str: | ||||||
| if local_runtime and os.name == "nt": | ||||||
| escaped_path = path.replace('"', '""') | ||||||
| else: | ||||||
| escaped_path = path.replace("\\", "\\\\").replace('"', '\\"') | ||||||
| return f'"{escaped_path}"' | ||||||
|
|
||||||
|
|
||||||
| def _build_background_output_path(*, local_runtime: bool) -> str: | ||||||
| file_name = f"astrbot_shell_stdout_{uuid.uuid4().hex[:8]}.log" | ||||||
| if local_runtime: | ||||||
| output_dir = Path(get_astrbot_system_tmp_path()) / "shell" | ||||||
| output_dir.mkdir(parents=True, exist_ok=True) | ||||||
| return str((output_dir / file_name).resolve(strict=False)) | ||||||
| return f"/tmp/{file_name}" | ||||||
|
|
||||||
|
|
||||||
| def _redirect_background_stdout_command( | ||||||
| command: str, | ||||||
| *, | ||||||
| output_path: str, | ||||||
| local_runtime: bool, | ||||||
| ) -> str: | ||||||
| return f"({command}) > {_quote_redirect_path(output_path, local_runtime=local_runtime)} 2>&1" | ||||||
|
|
||||||
|
|
||||||
| @builtin_tool(config=_COMPUTER_RUNTIME_TOOL_CONFIG) | ||||||
| @dataclass | ||||||
| class ExecuteShellTool(FunctionTool): | ||||||
|
|
@@ -32,12 +62,17 @@ class ExecuteShellTool(FunctionTool): | |||||
| }, | ||||||
| "background": { | ||||||
| "type": "boolean", | ||||||
| "description": "Whether to run the command in the background.", | ||||||
| "description": "Run the command in the background. Use the file read tool to read the output later. For long running commands, using this option.", | ||||||
| "default": False, | ||||||
| }, | ||||||
| "timeout": { | ||||||
| "type": "integer", | ||||||
| "description": "Optional timeout in seconds for the command execution.", | ||||||
| "default": 300, | ||||||
| }, | ||||||
| "env": { | ||||||
| "type": "object", | ||||||
| "description": "Optional environment variables to set for the file creation process.", | ||||||
| "description": "Optional environment variables to set.", | ||||||
| "additionalProperties": {"type": "string"}, | ||||||
| "default": {}, | ||||||
| }, | ||||||
|
|
@@ -51,6 +86,7 @@ async def call( | |||||
| context: ContextWrapper[AstrAgentContext], | ||||||
| command: str, | ||||||
| background: bool = False, | ||||||
| timeout: int | None = 300, | ||||||
| env: dict[str, Any] | None = None, | ||||||
| ) -> ToolExecResult: | ||||||
| if permission_error := check_admin_permission(context, "Shell execution"): | ||||||
|
|
@@ -69,14 +105,32 @@ async def call( | |||||
| current_workspace_root.mkdir(parents=True, exist_ok=True) | ||||||
| cwd = str(current_workspace_root) | ||||||
|
|
||||||
| stdout_file: str | None = None | ||||||
| if background: | ||||||
| local_runtime = is_local_runtime(context) | ||||||
| stdout_file = _build_background_output_path( | ||||||
| local_runtime=local_runtime, | ||||||
| ) | ||||||
| command = _redirect_background_stdout_command( | ||||||
| command, | ||||||
| output_path=stdout_file, | ||||||
| local_runtime=local_runtime, | ||||||
| ) | ||||||
|
|
||||||
| env = dict(env or {}) | ||||||
| effective_background = background and not _is_self_detached_command(command) | ||||||
| result = await sb.shell.exec( | ||||||
| command, | ||||||
| cwd=cwd, | ||||||
| background=effective_background, | ||||||
| env=env, | ||||||
| timeout=timeout or 300, | ||||||
| ) | ||||||
| if stdout_file: | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The
Suggested change
|
||||||
| result["stdout"] = ( | ||||||
| f"Command is running in the background. stdout/stderr is being " | ||||||
| f"written to `{stdout_file}`. Use astrbot_file_read_tool to read it." | ||||||
| ) | ||||||
| return json.dumps(result, ensure_ascii=False) | ||||||
| except Exception as e: | ||||||
| detail = str(e) or type(e).__name__ | ||||||
|
|
||||||
Uh oh!
There was an error while loading. Please reload this page.