From 4617e00693b96557477d6cfea33a4c0ac2cbe2cb Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Mon, 15 Dec 2025 18:13:53 +0800 Subject: [PATCH] fix: improve terminal timeouts and clean outputs --- core/main_terminal.py | 23 ++- modules/persistent_terminal.py | 270 ++++++++++++++++++++++----------- modules/terminal_manager.py | 67 +++++--- modules/terminal_ops.py | 18 ++- modules/toolbox_container.py | 4 +- 5 files changed, 253 insertions(+), 129 deletions(-) diff --git a/core/main_terminal.py b/core/main_terminal.py index 4a9da2f..0534475 100644 --- a/core/main_terminal.py +++ b/core/main_terminal.py @@ -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"]: diff --git a/modules/persistent_terminal.py b/modules/persistent_terminal.py index 3149d2d..95f63e6 100644 --- a/modules/persistent_terminal.py +++ b/modules/persistent_terminal.py @@ -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,66 +715,65 @@ class PersistentTerminal: "session": self.session_name } - # 如果需要等待输出 - if wait_for_output: - output, timed_out = self._wait_for_output(timeout=timeout) - recent_output = self._get_output_since_marker(marker) - if recent_output: - output = recent_output - 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" + # 等待输出 + 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: - if self.echo_loop_detected: - status = "output_with_echo" - message_map = { - "completed": "命令执行完成", - "no_output": "未捕获输出,命令可能未产生结果", - "awaiting_input": "命令已发送,终端等待进一步输入或仍在运行", - "echo_loop": "检测到终端正在回显输入,命令可能未成功执行", - "output_with_echo": "命令产生输出,但终端疑似重复回显", - "timeout": f"命令超时({int(timeout)}秒)" - } - message = message_map.get(status, "命令执行完成") - if output_truncated: - message += f"(输出已截断,保留末尾{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 - } + status = "no_output" 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 - } + if self.echo_loop_detected: + status = "output_with_echo" + if marker_seen and status == "completed": + # 明确捕获到结束标记,视为完成 + status = "completed" + message_map = { + "completed": "命令执行完成", + "no_output": "未捕获输出,命令可能未产生结果", + "awaiting_input": "命令已发送,终端等待进一步输入或仍在运行", + "echo_loop": "检测到终端正在回显输入,命令可能未成功执行", + "output_with_echo": "命令产生输出,但终端疑似重复回显", + "timeout": f"命令超时({int(timeout)}秒)" + } + message = message_map.get(status, "命令执行完成") + if output_truncated: + message += f"(输出已截断,保留末尾{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 = f"发送命令失败: {str(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() - collected_output.append(output) - except queue.Empty: - return ''.join(collected_output), False - - 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: - try: - output = self.output_queue.get(timeout=0.1) + if sentinel and sentinel in output: + output = output.replace(sentinel, "") + marker_seen = True + if output: collected_output.append(output) - last_output_time = time.time() - output_seen = True + # 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: - if output_seen and (time.time() - last_output_time > idle_threshold): - break - if timeout == 0: - break + pass - timed_out = (time.time() - start_time) >= timeout and timeout > 0 - return ''.join(collected_output), timed_out + 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: """ diff --git a/modules/terminal_manager.py b/modules/terminal_manager.py index 2cf2b4b..bfe5644 100644 --- a/modules/terminal_manager.py +++ b/modules/terminal_manager.py @@ -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}" - wait_timeout = timeout + 3 # 留出终止与收尾输出 + marker = f"__CMD_DONE__{int(time.time()*1000)}__" - result = terminal.send_command(wrapped_command, wait_for_output, timeout=wait_timeout) + # 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, + 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( diff --git a/modules/terminal_ops.py b/modules/terminal_ops.py index eb3e005..f779079 100644 --- a/modules/terminal_ops.py +++ b/modules/terminal_ops.py @@ -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() diff --git a/modules/toolbox_container.py b/modules/toolbox_container.py index 3ec0d0b..fe49e81 100644 --- a/modules/toolbox_container.py +++ b/modules/toolbox_container.py @@ -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()