fix: improve terminal timeouts and clean outputs
This commit is contained in:
parent
b78921b1ca
commit
4617e00693
@ -1303,7 +1303,7 @@ class MainTerminal:
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "terminal_input",
|
||||
"description": "向活动终端发送命令或输入。禁止启动会占用终端界面的程序(python/node/nano/vim 等);如遇卡死请结合 terminal_snapshot 并使用 terminal_reset 恢复。默认等待输出120秒,最长300秒,超时会尝试中断并返回已捕获输出。",
|
||||
"description": "向活动终端发送命令或输入。禁止启动会占用终端界面的程序(python/node/nano/vim 等);如遇卡死请结合 terminal_snapshot 并使用 terminal_reset 恢复。必须提供 timeout,超时仅终止当前命令,终端保持可用。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@ -1315,16 +1315,12 @@ class MainTerminal:
|
||||
"type": "string",
|
||||
"description": "目标终端会话名称(可选,默认使用活动终端)"
|
||||
},
|
||||
"wait_for_output": {
|
||||
"type": "boolean",
|
||||
"description": "是否等待输出(默认true)"
|
||||
},
|
||||
"timeout": {
|
||||
"type": "number",
|
||||
"description": "等待输出的最长秒数,默认120,最大300"
|
||||
"description": "等待输出的最长秒数,必填,最大300"
|
||||
}
|
||||
},
|
||||
"required": ["command"]
|
||||
"required": ["command", "timeout"]
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -1446,17 +1442,17 @@ class MainTerminal:
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "run_python",
|
||||
"description": "执行一次性 Python 脚本,可用于处理二进制或非 UTF-8 文件(如 Excel、Word、PDF、图片),或进行数据分析与验证。默认超时20秒,最长60秒;超时会尝试中断并返回已捕获输出。",
|
||||
"description": "执行一次性 Python 脚本,可用于处理二进制或非 UTF-8 文件(如 Excel、Word、PDF、图片),或进行数据分析与验证。必须提供 timeout(最长60秒);超时会尝试中断并返回已捕获输出。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {"type": "string", "description": "Python代码"},
|
||||
"timeout": {
|
||||
"type": "number",
|
||||
"description": "超时时长(秒),默认20,最大60"
|
||||
"description": "超时时长(秒),必填,最大60"
|
||||
}
|
||||
},
|
||||
"required": ["code"]
|
||||
"required": ["code", "timeout"]
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -1464,17 +1460,17 @@ class MainTerminal:
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "run_command",
|
||||
"description": "执行一次性终端命令,适合查看文件信息(file/ls/stat/iconv 等)、转换编码或调用 CLI 工具。禁止启动交互式程序;对已聚焦文件仅允许使用 grep -n 等定位命令。默认超时10秒,最长30秒,超时会尝试中断并返回已捕获输出;输出超过10000字符将被截断或拒绝。",
|
||||
"description": "执行一次性终端命令,适合查看文件信息(file/ls/stat/iconv 等)、转换编码或调用 CLI 工具。禁止启动交互式程序;对已聚焦文件仅允许使用 grep -n 等定位命令。必须提供 timeout(最长30秒),超时会尝试中断并返回已捕获输出;输出超过10000字符将被截断或拒绝。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {"type": "string", "description": "终端命令"},
|
||||
"timeout": {
|
||||
"type": "number",
|
||||
"description": "超时时长(秒),默认10,最大30"
|
||||
"description": "超时时长(秒),必填,最大30"
|
||||
}
|
||||
},
|
||||
"required": ["command"]
|
||||
"required": ["command", "timeout"]
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -1737,7 +1733,6 @@ class MainTerminal:
|
||||
result = self.terminal_manager.send_to_terminal(
|
||||
command=arguments["command"],
|
||||
session_name=arguments.get("session_name"),
|
||||
wait_for_output=arguments.get("wait_for_output", True),
|
||||
timeout=arguments.get("timeout")
|
||||
)
|
||||
if result["success"]:
|
||||
|
||||
@ -198,9 +198,9 @@ class PersistentTerminal:
|
||||
# 宿主机Windows初始化
|
||||
if self.is_windows and not self.using_container:
|
||||
time.sleep(0.5)
|
||||
self.send_command("chcp 65001", wait_for_output=False)
|
||||
self.send_command("chcp 65001", timeout=1)
|
||||
time.sleep(0.5)
|
||||
self.send_command("cls", wait_for_output=False)
|
||||
self.send_command("cls", timeout=1)
|
||||
time.sleep(0.3)
|
||||
self.output_buffer.clear()
|
||||
self.total_output_size = 0
|
||||
@ -625,8 +625,24 @@ class PersistentTerminal:
|
||||
return None
|
||||
return round(time.time() - self.last_output_time, 3)
|
||||
|
||||
def send_command(self, command: str, wait_for_output: bool = True, timeout: float = None) -> Dict:
|
||||
"""发送命令到终端(统一编码处理)"""
|
||||
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,
|
||||
@ -699,12 +715,20 @@ class PersistentTerminal:
|
||||
"session": self.session_name
|
||||
}
|
||||
|
||||
# 如果需要等待输出
|
||||
if wait_for_output:
|
||||
output, timed_out = self._wait_for_output(timeout=timeout)
|
||||
# 等待输出
|
||||
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:]
|
||||
@ -724,6 +748,9 @@ class PersistentTerminal:
|
||||
else:
|
||||
if self.echo_loop_detected:
|
||||
status = "output_with_echo"
|
||||
if marker_seen and status == "completed":
|
||||
# 明确捕获到结束标记,视为完成
|
||||
status = "completed"
|
||||
message_map = {
|
||||
"completed": "命令执行完成",
|
||||
"no_output": "未捕获输出,命令可能未产生结果",
|
||||
@ -745,19 +772,7 @@ class PersistentTerminal:
|
||||
"status": status,
|
||||
"truncated": output_truncated,
|
||||
"elapsed_ms": elapsed_ms,
|
||||
"timeout": timeout
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": True,
|
||||
"session": self.session_name,
|
||||
"command": command_text,
|
||||
"output": "",
|
||||
"message": "命令已发送至终端,后续输出将实时流式返回",
|
||||
"status": "pending",
|
||||
"truncated": False,
|
||||
"elapsed_ms": int((time.time() - start_time) * 1000),
|
||||
"timeout": timeout
|
||||
"timeout": timeout_cutoff or timeout
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
@ -769,15 +784,26 @@ class PersistentTerminal:
|
||||
"session": self.session_name
|
||||
}
|
||||
|
||||
def _wait_for_output(self, timeout: float = 5) -> Tuple[str, bool]:
|
||||
"""等待并收集输出,返回 (output, timed_out)。
|
||||
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)。
|
||||
|
||||
策略:若尚未收到任何输出,则一直等到超时;一旦收到输出,若后续空闲>0.3s则视为命令结束。
|
||||
- 若提供 sentinel,捕获后立即返回(仍会吸干队列中的剩余片段)。
|
||||
- 若 enforce_full_timeout=True,则不因空闲提前返回;否则在输出后短暂空闲可提前返回。
|
||||
- timed_out 判定使用 timeout_cutoff(若未提供则与 timeout 相同)。
|
||||
"""
|
||||
collected_output = []
|
||||
start_time = time.time()
|
||||
last_output_time = time.time()
|
||||
last_output_time = start_time
|
||||
output_seen = False
|
||||
marker_seen = False
|
||||
|
||||
if timeout is None or timeout <= 0:
|
||||
timeout = 0
|
||||
@ -786,35 +812,99 @@ class PersistentTerminal:
|
||||
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
|
||||
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
|
||||
|
||||
idle_threshold = 1.5
|
||||
while time.time() - start_time < timeout:
|
||||
try:
|
||||
remaining = max(0.05, min(0.5, timeout - (time.time() - start_time)))
|
||||
output = self.output_queue.get(timeout=remaining)
|
||||
collected_output.append(output)
|
||||
last_output_time = time.time()
|
||||
output_seen = True
|
||||
# 快速收集剩余输出,直到短暂空闲
|
||||
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=0.1)
|
||||
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:
|
||||
if output_seen and (time.time() - last_output_time > idle_threshold):
|
||||
pass
|
||||
|
||||
if marker_seen:
|
||||
# 捕获到结束标记,立即返回
|
||||
break
|
||||
if timeout == 0:
|
||||
if idle_threshold and output_seen and (time.time() - last_output_time) > idle_threshold:
|
||||
break
|
||||
|
||||
timed_out = (time.time() - start_time) >= timeout and timeout > 0
|
||||
return ''.join(collected_output), timed_out
|
||||
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:
|
||||
"""
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
# modules/terminal_manager.py - 终端会话管理器
|
||||
|
||||
import json
|
||||
import time
|
||||
import shlex
|
||||
from typing import Dict, List, Optional, Callable, TYPE_CHECKING
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
@ -255,10 +257,30 @@ class TerminalManager:
|
||||
'active': self.active_terminal
|
||||
})
|
||||
|
||||
# 对外返回容器视角/相对路径,避免暴露宿主绝对路径
|
||||
try:
|
||||
if terminal.using_container:
|
||||
mount_path = (self.sandbox_options.get("mount_path") or "/workspace").rstrip("/")
|
||||
mount_path = mount_path or "/workspace"
|
||||
try:
|
||||
rel = work_path.relative_to(self.project_path)
|
||||
if str(rel) == ".":
|
||||
display_work_dir = mount_path
|
||||
else:
|
||||
display_work_dir = f"{mount_path}/{rel.as_posix()}"
|
||||
except Exception:
|
||||
display_work_dir = mount_path
|
||||
else:
|
||||
display_work_dir = "."
|
||||
if work_path != self.project_path:
|
||||
display_work_dir = work_path.relative_to(self.project_path).as_posix()
|
||||
except Exception:
|
||||
display_work_dir = str(work_path)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"session": session_name,
|
||||
"working_dir": str(work_path),
|
||||
"working_dir": display_work_dir,
|
||||
"shell": terminal.shell_command or shell_command,
|
||||
"is_active": make_active,
|
||||
"total_sessions": len(self.terminals)
|
||||
@ -462,7 +484,6 @@ class TerminalManager:
|
||||
self,
|
||||
command: str,
|
||||
session_name: str = None,
|
||||
wait_for_output: bool = True,
|
||||
timeout: float = None
|
||||
) -> Dict:
|
||||
"""
|
||||
@ -471,7 +492,7 @@ class TerminalManager:
|
||||
Args:
|
||||
command: 要执行的命令
|
||||
session_name: 目标终端(None则使用活动终端)
|
||||
wait_for_output: 是否等待输出
|
||||
timeout: 必填,执行/等待的最长秒数
|
||||
|
||||
Returns:
|
||||
执行结果
|
||||
@ -500,32 +521,32 @@ class TerminalManager:
|
||||
# 发送命令
|
||||
terminal = self.terminals[target_session]
|
||||
if timeout is None or timeout <= 0:
|
||||
timeout = 120
|
||||
return {
|
||||
"success": False,
|
||||
"error": "timeout 参数必填且需大于0",
|
||||
"status": "error",
|
||||
"output": "timeout 参数缺失"
|
||||
}
|
||||
timeout = min(timeout, 300)
|
||||
|
||||
wrapped_command = command
|
||||
wait_timeout = timeout
|
||||
base_timeout = timeout
|
||||
if wait_for_output and timeout > 0:
|
||||
wrapped_command = f"timeout -k 2 {int(timeout)}s {command}"
|
||||
marker = f"__CMD_DONE__{int(time.time()*1000)}__"
|
||||
|
||||
# 1) timeout -k 2 : 超时后先发 SIGTERM,再 2 秒后 SIGKILL,只结束命令不关终端
|
||||
# 2) sh -c 'cmd; echo marker' 用标记区分正常结束;在终端侧过滤掉回显中的误判
|
||||
wrapped_inner = f"{command} ; echo {marker}"
|
||||
wrapped_command = f"timeout -k 2 {int(timeout)}s sh -c {shlex.quote(wrapped_inner)}"
|
||||
wait_timeout = timeout + 3 # 留出终止与收尾输出
|
||||
|
||||
result = terminal.send_command(wrapped_command, wait_for_output, timeout=wait_timeout)
|
||||
result = terminal.send_command(
|
||||
wrapped_command,
|
||||
timeout=wait_timeout,
|
||||
timeout_cutoff=base_timeout,
|
||||
enforce_full_timeout=True,
|
||||
sentinel=marker,
|
||||
)
|
||||
result["timeout"] = base_timeout
|
||||
|
||||
# 若超时阈值已到且状态仍是 completed,则视为超时(避免误判)
|
||||
elapsed_ms = result.get("elapsed_ms")
|
||||
if (
|
||||
wait_for_output
|
||||
and base_timeout
|
||||
and elapsed_ms is not None
|
||||
and elapsed_ms >= int(base_timeout * 1000)
|
||||
and result.get("status") == "completed"
|
||||
):
|
||||
result["status"] = "timeout"
|
||||
result["success"] = False
|
||||
result["message"] = result.get("message") or f"命令超时({int(base_timeout)}秒)"
|
||||
|
||||
return result
|
||||
|
||||
def get_terminal_output(
|
||||
|
||||
@ -155,6 +155,14 @@ class TerminalOperator:
|
||||
Returns:
|
||||
执行结果字典
|
||||
"""
|
||||
if timeout is None or timeout <= 0:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "timeout 参数必填且需大于0",
|
||||
"status": "error",
|
||||
"output": "timeout 参数缺失",
|
||||
"return_code": -1
|
||||
}
|
||||
# 每次执行前重置工具容器(保持隔离),但下面改用一次性子进程执行,仍保留重置以兼容后续逻辑
|
||||
self._reset_toolbox()
|
||||
# 替换命令中的python3为实际可用的命令
|
||||
@ -417,7 +425,15 @@ class TerminalOperator:
|
||||
Returns:
|
||||
执行结果字典
|
||||
"""
|
||||
timeout = self._clamp_timeout(timeout, default=20, max_limit=CODE_EXECUTION_TIMEOUT)
|
||||
if timeout is None or timeout <= 0:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "timeout 参数必填且需大于0",
|
||||
"status": "error",
|
||||
"output": "timeout 参数缺失",
|
||||
"return_code": -1
|
||||
}
|
||||
timeout = self._clamp_timeout(timeout, default=timeout, max_limit=CODE_EXECUTION_TIMEOUT)
|
||||
|
||||
# 强制重置工具容器,避免上一段代码仍在运行时输出混入
|
||||
self._reset_toolbox()
|
||||
|
||||
@ -163,8 +163,10 @@ class ToolboxContainer:
|
||||
result = await asyncio.to_thread(
|
||||
terminal.send_command,
|
||||
shell_command,
|
||||
True,
|
||||
timeout,
|
||||
timeout, # timeout_cutoff 与等待时间一致
|
||||
True, # enforce_full_timeout:避免因空闲过早返回
|
||||
None, # sentinel
|
||||
)
|
||||
self._last_used = time.time()
|
||||
self._cleanup_if_idle()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user