A 批(tools/basic_tools.py):迁移 7 个无外部依赖工具类(FileToolBase/ReadFileTool/WriteFileTool/ListFilesTool/SimpleWebCrawlerTool/GetCurrentTimeTool/CalculatorTool) Wave1(subsystems/):迁移 5 个叶子子系统(CommandExecutor/SubprocessCommandExecutor/PromptManager/MemoryDatabase/MemoDatabase/SkillManager),aibox.py 减少 ~1440 行 新增 tests/ 单测 113 个用例全绿,依赖方向维持 common -> subsystems -> tools -> aibox 单向
350 lines
13 KiB
Python
350 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
subsystems.executors - 命令执行器
|
|
|
|
抽离自 aibox.py,提供命令执行的抽象基类与 subprocess 实现。
|
|
- CommandExecutor : 抽象基类,定义 execute / execute_streaming / cancel
|
|
- SubprocessCommandExecutor : 基于 subprocess.Popen 的实现,支持超时/工作目录/
|
|
1MB 输出上限/线程安全取消
|
|
|
|
依赖:common (ConfigManager, constants, Logger) + 标准库
|
|
不反向依赖 aibox 任何符号。
|
|
"""
|
|
|
|
import os
|
|
import queue
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
from abc import ABC, abstractmethod
|
|
from typing import Dict, Optional, Tuple
|
|
|
|
from common import ConfigManager, constants, Logger
|
|
|
|
|
|
class CommandExecutor(ABC):
|
|
"""命令执行器抽象基类 - 负责执行系统命令"""
|
|
|
|
def __init__(self, logger: Optional[Logger] = None):
|
|
self.logger = logger or Logger("CommandExecutor")
|
|
self.config = ConfigManager()
|
|
self.process = None
|
|
|
|
@abstractmethod
|
|
def execute(self, command: str, timeout: int = 30, **kwargs) -> str:
|
|
"""执行命令并返回结果(非流式)"""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def execute_streaming(self, command: str, output_queue: queue.Queue, timeout: int = 30, **kwargs):
|
|
"""流式执行命令,通过队列输出结果"""
|
|
pass
|
|
|
|
def is_enabled(self) -> bool:
|
|
"""检查命令执行是否启用"""
|
|
return constants.COMMANDS_ENABLED
|
|
|
|
def format_result(self, success: bool, message: str, data: Dict = None) -> str:
|
|
"""统一结果格式化"""
|
|
result = []
|
|
emoji = "✅" if success else "❌"
|
|
result.append(f"{emoji} {message}")
|
|
|
|
if data:
|
|
for key, value in data.items():
|
|
if isinstance(value, str) and "\n" in value:
|
|
result.append(f" {key}:")
|
|
for line in value.split("\n"):
|
|
result.append(f" {line}")
|
|
else:
|
|
result.append(f" {key}: {value}")
|
|
|
|
return "\n".join(result)
|
|
|
|
|
|
class SubprocessCommandExecutor(CommandExecutor):
|
|
"""基于subprocess的命令执行器 - 支持超时、工作目录、输出大小限制和取消"""
|
|
|
|
# 命令输出最大字节数(1MB),超出截断
|
|
MAX_OUTPUT_BYTES = 1 * 1024 * 1024
|
|
|
|
def __init__(self, logger=None):
|
|
super().__init__(logger)
|
|
self._current_process = None
|
|
self._process_lock = threading.Lock()
|
|
|
|
def cancel(self):
|
|
"""取消当前正在运行的命令(kill子进程)。线程安全。"""
|
|
with self._process_lock:
|
|
proc = self._current_process
|
|
if proc is not None:
|
|
try:
|
|
if proc.poll() is None:
|
|
proc.kill()
|
|
self.logger.info("已终止正在运行的子进程")
|
|
except Exception:
|
|
pass
|
|
|
|
def _read_with_limit(self, process, timeout: int, start_time: float, output_queue=None) -> Tuple[str, bool, bool]:
|
|
"""从进程读取输出,限制总大小和超时。
|
|
返回 (output_str, timeout_occurred, truncated)
|
|
"""
|
|
output_chunks = []
|
|
total_bytes = 0
|
|
timeout_occurred = False
|
|
truncated = False
|
|
|
|
while True:
|
|
# 检查超时
|
|
if timeout > 0 and time.time() - start_time > timeout:
|
|
process.terminate()
|
|
timeout_occurred = True
|
|
break
|
|
|
|
# 检查输出大小
|
|
if total_bytes >= self.MAX_OUTPUT_BYTES:
|
|
truncated = True
|
|
process.terminate()
|
|
break
|
|
|
|
# 读取一行
|
|
line = process.stdout.readline()
|
|
if not line and process.poll() is not None:
|
|
break
|
|
if line:
|
|
line_bytes = len(line.encode('utf-8', errors='replace'))
|
|
if total_bytes + line_bytes > self.MAX_OUTPUT_BYTES and not truncated:
|
|
allowed = self.MAX_OUTPUT_BYTES - total_bytes
|
|
if allowed > 0:
|
|
output_chunks.append(line.encode('utf-8', errors='replace')[:allowed].decode('utf-8', errors='replace'))
|
|
total_bytes = self.MAX_OUTPUT_BYTES
|
|
truncated = True
|
|
process.terminate()
|
|
break
|
|
|
|
output_chunks.append(line)
|
|
total_bytes += line_bytes
|
|
|
|
if output_queue:
|
|
output_queue.put(("line", line.rstrip('\n') + "\n"))
|
|
|
|
return ''.join(output_chunks), timeout_occurred, truncated
|
|
|
|
def execute(self, command: str, timeout: int = 30, work_dir: str = None, env: Dict = None, **kwargs) -> str:
|
|
"""执行命令并返回完整结果(带输出大小限制)"""
|
|
if not self.is_enabled():
|
|
return "❌ 命令执行功能已禁用"
|
|
|
|
# 默认工作目录为workspace,避免在启动目录散落文件
|
|
if work_dir is None or work_dir == ".":
|
|
work_dir = constants.WORKSPACE_DIR
|
|
|
|
self.logger.info(f"执行命令: {command} (work_dir={work_dir})")
|
|
|
|
# 准备环境变量
|
|
exec_env = os.environ.copy()
|
|
if env:
|
|
exec_env.update(env)
|
|
|
|
process = None
|
|
try:
|
|
process = subprocess.Popen(
|
|
command,
|
|
shell=True,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
stdin=subprocess.PIPE,
|
|
text=True,
|
|
bufsize=1,
|
|
universal_newlines=True,
|
|
env=exec_env,
|
|
cwd=os.path.expanduser(work_dir)
|
|
)
|
|
# 保存进程引用以便cancel()可以kill它
|
|
with self._process_lock:
|
|
self._current_process = process
|
|
|
|
start_time = time.time()
|
|
output_str, timeout_occurred, truncated = self._read_with_limit(process, timeout, start_time)
|
|
|
|
# 确保进程终止
|
|
if process.poll() is None:
|
|
if timeout_occurred or truncated:
|
|
process.kill()
|
|
else:
|
|
process.terminate()
|
|
try:
|
|
process.wait(timeout=2)
|
|
except:
|
|
process.kill()
|
|
|
|
return_code = process.returncode if process.returncode is not None else -1
|
|
|
|
data = {
|
|
"输出": output_str.strip() if output_str.strip() else "(无输出)",
|
|
"返回码": return_code
|
|
}
|
|
|
|
if timeout_occurred:
|
|
return self.format_result(False, f"命令执行超时({timeout}秒)", data)
|
|
|
|
if truncated:
|
|
data["警告"] = f"输出超过 {self.MAX_OUTPUT_BYTES/1024/1024:.0f}MB 限制,结果已截断"
|
|
return self.format_result(False, "命令执行完成(输出已截断)", data)
|
|
|
|
return self.format_result(return_code == 0, "命令执行完成", data)
|
|
|
|
except subprocess.TimeoutExpired:
|
|
return f"❌ 命令执行超时({timeout}秒)"
|
|
except FileNotFoundError as e:
|
|
return f"❌ 命令执行错误: 命令未找到 - {str(e)}"
|
|
except PermissionError as e:
|
|
return f"❌ 命令执行错误: 权限不足 - {str(e)}"
|
|
except Exception as e:
|
|
return f"❌ 执行错误: {str(e)}"
|
|
finally:
|
|
if process and process.poll() is None:
|
|
process.kill()
|
|
# 清除当前进程引用
|
|
with self._process_lock:
|
|
self._current_process = None
|
|
|
|
def execute_streaming(self, command: str, output_queue: queue.Queue, timeout: int = 30,
|
|
work_dir: str = None, env: Dict = None, **kwargs):
|
|
"""流式执行命令,支持超时控制、工作目录和输出大小限制"""
|
|
if not self.is_enabled():
|
|
output_queue.put(("line", "❌ 命令执行功能已禁用\n"))
|
|
output_queue.put(("complete", "❌ 命令执行功能已禁用"))
|
|
return
|
|
|
|
# 默认工作目录为workspace,避免在启动目录散落文件
|
|
if work_dir is None or work_dir == ".":
|
|
work_dir = constants.WORKSPACE_DIR
|
|
|
|
self.logger.info(f"流式执行命令: {command} (work_dir={work_dir})")
|
|
output_queue.put(("line", f"\n🔧 执行命令: {command}\n"))
|
|
output_queue.put(("line", f"⏱️ 超时设置: {timeout} 秒\n"))
|
|
output_queue.put(("line", f"📦 输出限制: {self.MAX_OUTPUT_BYTES/1024/1024:.0f}MB\n"))
|
|
output_queue.put(("line", f"📁 工作目录: {work_dir}\n"))
|
|
output_queue.put(("line", "-" * 50 + "\n"))
|
|
|
|
# 准备环境变量
|
|
exec_env = os.environ.copy()
|
|
if env:
|
|
exec_env.update(env)
|
|
|
|
process = None
|
|
try:
|
|
process = subprocess.Popen(
|
|
command,
|
|
shell=True,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
stdin=subprocess.PIPE,
|
|
text=True,
|
|
bufsize=1,
|
|
universal_newlines=True,
|
|
env=exec_env,
|
|
cwd=os.path.expanduser(work_dir)
|
|
)
|
|
self.process = process
|
|
# 保存进程引用以便cancel()可以kill它
|
|
with self._process_lock:
|
|
self._current_process = process
|
|
|
|
start_time = time.time()
|
|
output_lines = []
|
|
total_bytes = 0
|
|
timeout_occurred = False
|
|
truncated = False
|
|
|
|
# 实时读取输出
|
|
while True:
|
|
# 检查超时
|
|
if timeout > 0 and time.time() - start_time > timeout:
|
|
process.terminate()
|
|
timeout_occurred = True
|
|
output_queue.put(("line", f"\n⏰ 命令执行超时({timeout}秒)\n"))
|
|
break
|
|
|
|
# 检查输出大小
|
|
if total_bytes >= self.MAX_OUTPUT_BYTES:
|
|
truncated = True
|
|
process.terminate()
|
|
output_queue.put(("line", f"\n📦 输出超过 {self.MAX_OUTPUT_BYTES/1024/1024:.0f}MB 限制,结果已截断\n"))
|
|
break
|
|
|
|
# 读取一行输出
|
|
line = process.stdout.readline()
|
|
if not line and process.poll() is not None:
|
|
break
|
|
if line:
|
|
line_bytes = len(line.encode('utf-8', errors='replace'))
|
|
|
|
if total_bytes + line_bytes > self.MAX_OUTPUT_BYTES and not truncated:
|
|
allowed = self.MAX_OUTPUT_BYTES - total_bytes
|
|
if allowed > 0:
|
|
truncated_line = line.encode('utf-8', errors='replace')[:allowed].decode('utf-8', errors='replace')
|
|
output_queue.put(("line", truncated_line))
|
|
output_lines.append(truncated_line)
|
|
total_bytes = self.MAX_OUTPUT_BYTES
|
|
truncated = True
|
|
process.terminate()
|
|
output_queue.put(("line", f"\n📦 输出超过 {self.MAX_OUTPUT_BYTES/1024/1024:.0f}MB 限制,结果已截断\n"))
|
|
break
|
|
|
|
stripped = line.rstrip('\n')
|
|
output_queue.put(("line", stripped + "\n"))
|
|
output_lines.append(line)
|
|
total_bytes += line_bytes
|
|
|
|
# 如果超时或截断,确保进程被终止
|
|
if (timeout_occurred or truncated) and process.poll() is None:
|
|
process.kill()
|
|
try:
|
|
process.wait(timeout=2)
|
|
except:
|
|
pass
|
|
|
|
return_code = process.returncode if process.returncode is not None else -1
|
|
|
|
output_queue.put(("line", "-" * 50 + "\n"))
|
|
|
|
if timeout_occurred:
|
|
output_queue.put(("line", f"⏰ 命令执行超时({timeout}秒),返回码: {return_code}\n"))
|
|
elif truncated:
|
|
output_queue.put(("line", f"📦 输出已截断,返回码: {return_code}\n"))
|
|
else:
|
|
output_queue.put(("line", f"✅ 命令执行完成,返回码: {return_code}\n"))
|
|
|
|
# 准备完整结果
|
|
full_output = "".join(output_lines)
|
|
data = {
|
|
"输出": full_output.strip() if full_output.strip() else "(无输出)",
|
|
"返回码": return_code
|
|
}
|
|
|
|
if timeout_occurred:
|
|
result = self.format_result(False, f"命令执行超时({timeout}秒)", data)
|
|
elif truncated:
|
|
data["警告"] = f"输出超过 {self.MAX_OUTPUT_BYTES/1024/1024:.0f}MB 限制,结果已截断"
|
|
result = self.format_result(False, "命令执行完成(输出已截断)", data)
|
|
else:
|
|
result = self.format_result(return_code == 0, "命令执行完成", data)
|
|
|
|
output_queue.put(("complete", result))
|
|
|
|
except Exception as e:
|
|
output_queue.put(("line", f"❌ 执行错误: {str(e)}\n"))
|
|
output_queue.put(("complete", f"❌ 执行错误: {str(e)}"))
|
|
finally:
|
|
if process and process.poll() is None:
|
|
process.terminate()
|
|
try:
|
|
process.wait(timeout=2)
|
|
except:
|
|
process.kill()
|
|
self.process = None
|
|
with self._process_lock:
|
|
self._current_process = None
|