fix: improve terminal timeouts and clean outputs

This commit is contained in:
JOJO 2025-12-15 18:13:53 +08:00
parent b78921b1ca
commit 4617e00693
5 changed files with 253 additions and 129 deletions

View File

@ -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"]:

View File

@ -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:
"""

View File

@ -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(

View File

@ -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()

View File

@ -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()