agent-Specialization/modules/persistent_terminal/command.py
JOJO a2cf547400 feat(i18n): 后端用户可见消息国际化(zh/en 双语 + ui_locale 偏好持久化)
- 新增 modules/i18n.py:tr() + 进程级语言缓存 + modules/i18n_messages/ 域文案包自动聚合
- 新增 29 个域文案包,共 1153 条双语 key;90+ 源文件 1146 处用户可见消息 tr 化
- ui_locale 存入 personalization.json(用户级共享),前后端双向同步
- 前端匹配点双语兼容(history/shared/ChatArea/taskPolling/upload 等正则)
- 修复语言判等陷阱:审批等待加稳定 code 字段;conversation.py 不存在判等改双语 helper
- 边界:日志/prompt 注入/子智能体工具回填/容器内嵌脚本不迁移
2026-08-29 07:58:29 +08:00

384 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# modules/persistent_terminal.py - 持久化终端实例(修复版)
import asyncio
import subprocess
import os
import sys
import time
import signal
from pathlib import Path
from typing import Optional, Callable, Dict, List, Tuple
from datetime import datetime
import threading
import queue
from collections import deque
import shutil
import uuid
import codecs
from modules.host_sandbox_runner import (
HostSandboxError,
build_host_sandbox_shell_plan,
host_sandbox_enabled,
)
try:
from config import (
OUTPUT_FORMATS,
TERMINAL_OUTPUT_WAIT,
TERMINAL_INPUT_MAX_CHARS,
TERMINAL_SANDBOX_MODE,
TERMINAL_SANDBOX_IMAGE,
TERMINAL_SANDBOX_MOUNT_PATH,
TERMINAL_SANDBOX_SHELL,
TERMINAL_SANDBOX_NETWORK,
TERMINAL_SANDBOX_CPUS,
TERMINAL_SANDBOX_MEMORY,
TERMINAL_SANDBOX_BINDS,
TERMINAL_SANDBOX_BIN,
TERMINAL_SANDBOX_NAME_PREFIX,
TERMINAL_SANDBOX_ENV,
TERMINAL_SANDBOX_REQUIRE,
)
except ImportError:
import sys
from pathlib import Path
project_root = Path(__file__).resolve().parents[1]
if str(project_root) not in sys.path:
sys.path.insert(0, str(project_root))
from config import (
OUTPUT_FORMATS,
TERMINAL_OUTPUT_WAIT,
TERMINAL_INPUT_MAX_CHARS,
TERMINAL_SANDBOX_MODE,
TERMINAL_SANDBOX_IMAGE,
TERMINAL_SANDBOX_MOUNT_PATH,
TERMINAL_SANDBOX_SHELL,
TERMINAL_SANDBOX_NETWORK,
TERMINAL_SANDBOX_CPUS,
TERMINAL_SANDBOX_MEMORY,
TERMINAL_SANDBOX_BINDS,
TERMINAL_SANDBOX_BIN,
TERMINAL_SANDBOX_NAME_PREFIX,
TERMINAL_SANDBOX_ENV,
TERMINAL_SANDBOX_REQUIRE,
)
from modules.i18n import tr
class CommandMixin:
"""PersistentTerminal command 能力 mixin。"""
def send_command(
self,
command: str,
timeout: float = None,
timeout_cutoff: float = None,
enforce_full_timeout: bool = False,
sentinel: str = None,
) -> Dict:
"""
发送命令到终端(统一编码处理)。
Args:
command: 要执行的命令文本
timeout: 等待输出的最大秒数(可大于真实超时,用于等待收尾输出)
timeout_cutoff: 将耗时大于此值视为超时,用于外层业务区分;默认为 timeout
enforce_full_timeout: 若为 True则不因空闲提前返回除非捕获 sentinel
sentinel: 若提供,在输出中捕获到该标记即认为命令结束,并从输出中移除
"""
if not self.is_running or not self.process:
return {
"success": False,
"error": tr("terminal.not_running"),
"session": self.session_name
}
try:
# 清空残留输出,防止上一条命令的输出干扰
try:
while True:
self.output_queue.get_nowait()
except queue.Empty:
pass
marker = self._capture_history_marker()
if timeout is None:
timeout = TERMINAL_OUTPUT_WAIT
else:
try:
timeout = float(timeout)
except (TypeError, ValueError):
timeout = TERMINAL_OUTPUT_WAIT
if timeout < 0:
timeout = 0
start_time = time.time()
command_text = command.rstrip('\n')
# 记录命令
self.command_history.append({
"command": command_text,
"timestamp": datetime.now().isoformat()
})
self.last_command = command_text
self.is_interactive = False
self.last_input_text = command_text
self.last_input_time = time.time()
self.echo_loop_detected = False
self._consecutive_echo_matches = 0
self._append_io_event('input', command_text + '\n', timestamp=self.last_input_time)
# 广播输入事件
if self.broadcast:
self.broadcast('terminal_input', {
'session': self.session_name,
'data': command_text + '\n',
'timestamp': time.time()
})
# 确保命令有换行符
to_send = command if command.endswith('\n') else command + '\n'
# 发送命令统一使用UTF-8编码
try:
# 首先尝试UTF-8
command_bytes = to_send.encode('utf-8')
except UnicodeEncodeError:
# 如果UTF-8失败Windows系统尝试GBK
if self.is_windows:
command_bytes = to_send.encode('gbk', errors='replace')
else:
command_bytes = to_send.encode('utf-8', errors='replace')
try:
self.process.stdin.write(command_bytes)
self.process.stdin.flush()
except Exception:
return {
"success": False,
"error": tr("terminal.input_failed"),
"session": self.session_name
}
# 等待输出
output, timed_out, marker_seen = self._wait_for_output(
timeout=timeout,
timeout_cutoff=timeout_cutoff,
enforce_full_timeout=enforce_full_timeout,
sentinel=sentinel,
command_echo=command_text,
)
recent_output = self._get_output_since_marker(marker)
if recent_output:
if sentinel:
recent_output = recent_output.replace(sentinel, "")
output = recent_output
output = self._clean_output(output, command_text, sentinel)
output_truncated = False
if len(output) > TERMINAL_INPUT_MAX_CHARS:
output = output[-TERMINAL_INPUT_MAX_CHARS:]
output_truncated = True
output_clean = output.strip()
has_output = bool(output_clean)
status = "completed"
if timed_out:
status = "timeout"
elif not has_output:
if self.echo_loop_detected:
status = "echo_loop"
elif self.is_interactive:
status = "awaiting_input"
else:
status = "no_output"
else:
if self.echo_loop_detected:
status = "output_with_echo"
if marker_seen and status == "completed":
# 明确捕获到结束标记,视为完成
status = "completed"
message_map = {
"completed": tr("terminal.cmd_completed"),
"no_output": tr("terminal.cmd_no_output"),
"awaiting_input": tr("terminal.cmd_awaiting_input"),
"echo_loop": tr("terminal.cmd_echo_loop"),
"output_with_echo": tr("terminal.cmd_output_with_echo"),
"timeout": tr("terminal.cmd_wait_timeout", timeout=int(timeout))
}
if timeout >= 60:
message = tr("terminal.collected_output_note", timeout=int(timeout))
else:
message = message_map.get(status, tr("terminal.cmd_completed"))
if output_truncated:
message += tr("terminal.output_truncated_appendix", chars=TERMINAL_INPUT_MAX_CHARS)
elapsed_ms = int((time.time() - start_time) * 1000)
return {
"success": status in {"completed", "output_with_echo"},
"session": self.session_name,
"command": command_text,
"output": output,
"message": message,
"status": status,
"truncated": output_truncated,
"elapsed_ms": elapsed_ms,
"timeout": timeout_cutoff or timeout
}
except Exception as e:
error_msg = tr("terminal.send_failed", error=e)
print(f"{OUTPUT_FORMATS['error']} {error_msg}")
return {
"success": False,
"error": error_msg,
"session": self.session_name
}
def _wait_for_output(
self,
timeout: float = 5,
timeout_cutoff: Optional[float] = None,
enforce_full_timeout: bool = False,
sentinel: Optional[str] = None,
command_echo: Optional[str] = None,
) -> Tuple[str, bool, bool]:
"""
等待并收集输出,返回 (output, timed_out, marker_seen)。
- 若提供 sentinel捕获后立即返回仍会吸干队列中的剩余片段
- 若 enforce_full_timeout=True则不因空闲提前返回否则在输出后短暂空闲可提前返回。
- timed_out 判定使用 timeout_cutoff若未提供则与 timeout 相同)。
"""
collected_output = []
start_time = time.time()
last_output_time = start_time
output_seen = False
marker_seen = False
if timeout is None or timeout <= 0:
timeout = 0
if timeout == 0:
try:
while True:
output = self.output_queue.get_nowait()
if sentinel and sentinel in output:
output = output.replace(sentinel, "")
marker_seen = True
if output:
collected_output.append(output)
# unreachable
except queue.Empty:
return ''.join(collected_output), False, marker_seen
end_time = start_time + timeout
cutoff = timeout_cutoff if timeout_cutoff is not None else timeout
# 空闲提前返回仅在未强制等待且未使用结束标记时有效
idle_threshold = None if enforce_full_timeout or sentinel else 1.5
while True:
now = time.time()
if now >= end_time:
break
remaining = max(0.05, min(0.5, end_time - now))
try:
output = self.output_queue.get(timeout=remaining)
if sentinel and sentinel in output:
# 避免把命令回显中的标记误判为完成信号
if command_echo and command_echo in output:
output = output.replace(sentinel, "")
else:
output = output.replace(sentinel, "")
marker_seen = True
if output:
collected_output.append(output)
last_output_time = time.time()
output_seen = True
# 尽量一次性收集当前批次,但受时间上限约束,避免无限循环
while time.time() < end_time:
try:
extra = self.output_queue.get(timeout=0.01)
if sentinel and sentinel in extra:
if command_echo and command_echo in extra:
extra = extra.replace(sentinel, "")
else:
extra = extra.replace(sentinel, "")
marker_seen = True
if extra:
collected_output.append(extra)
last_output_time = time.time()
output_seen = True
except queue.Empty:
break
except queue.Empty:
pass
if marker_seen:
# 捕获到结束标记,立即返回
break
if idle_threshold and output_seen and (time.time() - last_output_time) > idle_threshold:
break
elapsed = time.time() - start_time
timed_out = bool(cutoff and cutoff > 0 and elapsed >= cutoff)
return ''.join(collected_output), timed_out, marker_seen
@staticmethod
def _clean_output(output: str, command_text: str, sentinel: Optional[str]) -> str:
"""
移除封装命令回显和完成标记,保留纯净的命令输出。
"""
if not output:
return output
lines = output.splitlines()
cleaned = []
for idx, line in enumerate(lines):
# 去掉标记行
if sentinel and sentinel in line:
continue
# 尝试剥离提示符
for token in ("# ", "$ "):
pos = line.find(token)
if 0 <= pos <= 40 and "@" in line[:pos]:
line = line[pos + len(token):]
break
# 去掉封装命令回显
if idx == 0 and command_text:
if line.strip() == command_text.strip():
continue
# 包含 timeout/sh -c 的封装行也忽略
if "timeout -k" in line and "sh -c" in line:
continue
cleaned.append(line)
# 保持末尾换行与原输出一致
out = "\n".join(cleaned)
if output.endswith("\n") and cleaned:
out += "\n"
return out
def get_output(self, last_n_lines: int = 50) -> str:
"""
获取终端输出
Args:
last_n_lines: 获取最后N行
Returns:
输出内容
"""
if last_n_lines <= 0:
return ''.join(self.output_buffer)
# 获取最后N行
lines = []
for line in reversed(self.output_buffer):
lines.insert(0, line)
if len(lines) >= last_n_lines:
break
return ''.join(lines)
def get_display_output(self) -> str:
"""获取用于显示的输出截断到display_size"""
output = self.get_output()
if len(output) > self.display_size:
# 保留最后的display_size字符
output = output[-self.display_size:]
output = f"{tr('terminal.display_truncated_prefix', chars=self.display_size)}\n{output}"
return output