fix: stabilize terminal tool timeouts
This commit is contained in:
parent
28383722cc
commit
8fe06753bb
@ -1303,7 +1303,7 @@ class MainTerminal:
|
|||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "terminal_input",
|
"name": "terminal_input",
|
||||||
"description": "向活动终端发送命令或输入。禁止启动会占用终端界面的程序(python/node/nano/vim 等);如遇卡死请结合 terminal_snapshot 并使用 terminal_reset 恢复。",
|
"description": "向活动终端发送命令或输入。禁止启动会占用终端界面的程序(python/node/nano/vim 等);如遇卡死请结合 terminal_snapshot 并使用 terminal_reset 恢复。默认等待输出120秒,最长300秒,超时会尝试中断并返回已捕获输出。",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@ -1321,7 +1321,7 @@ class MainTerminal:
|
|||||||
},
|
},
|
||||||
"timeout": {
|
"timeout": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"description": "等待输出的最长秒数,默认使用配置项 TERMINAL_OUTPUT_WAIT"
|
"description": "等待输出的最长秒数,默认120,最大300"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["command"]
|
"required": ["command"]
|
||||||
@ -1446,11 +1446,15 @@ class MainTerminal:
|
|||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "run_python",
|
"name": "run_python",
|
||||||
"description": "执行一次性 Python 脚本,可用于处理二进制或非 UTF-8 文件(如 Excel、Word、PDF、图片),或进行数据分析与验证。请在脚本内显式读取文件并输出结果,避免长时间阻塞。",
|
"description": "执行一次性 Python 脚本,可用于处理二进制或非 UTF-8 文件(如 Excel、Word、PDF、图片),或进行数据分析与验证。默认超时20秒,最长60秒;超时会尝试中断并返回已捕获输出。",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"code": {"type": "string", "description": "Python代码"}
|
"code": {"type": "string", "description": "Python代码"},
|
||||||
|
"timeout": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "超时时长(秒),默认20,最大60"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"required": ["code"]
|
"required": ["code"]
|
||||||
}
|
}
|
||||||
@ -1460,11 +1464,15 @@ class MainTerminal:
|
|||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "run_command",
|
"name": "run_command",
|
||||||
"description": "执行一次性终端命令,适合查看文件信息(file/ls/stat/iconv 等)、转换编码或调用 CLI 工具。禁止启动交互式程序;对已聚焦文件仅允许使用 grep -n 等定位命令。输出超过10000字符将被拒绝,可先限制返回体量。",
|
"description": "执行一次性终端命令,适合查看文件信息(file/ls/stat/iconv 等)、转换编码或调用 CLI 工具。禁止启动交互式程序;对已聚焦文件仅允许使用 grep -n 等定位命令。默认超时10秒,最长30秒,超时会尝试中断并返回已捕获输出;输出超过10000字符将被截断或拒绝。",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"command": {"type": "string", "description": "终端命令"}
|
"command": {"type": "string", "description": "终端命令"},
|
||||||
|
"timeout": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "超时时长(秒),默认10,最大30"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"required": ["command"]
|
"required": ["command"]
|
||||||
}
|
}
|
||||||
@ -2079,10 +2087,16 @@ class MainTerminal:
|
|||||||
}
|
}
|
||||||
|
|
||||||
elif tool_name == "run_python":
|
elif tool_name == "run_python":
|
||||||
result = await self.terminal_ops.run_python_code(arguments["code"])
|
result = await self.terminal_ops.run_python_code(
|
||||||
|
arguments["code"],
|
||||||
|
timeout=arguments.get("timeout")
|
||||||
|
)
|
||||||
|
|
||||||
elif tool_name == "run_command":
|
elif tool_name == "run_command":
|
||||||
result = await self.terminal_ops.run_command(arguments["command"])
|
result = await self.terminal_ops.run_command(
|
||||||
|
arguments["command"],
|
||||||
|
timeout=arguments.get("timeout")
|
||||||
|
)
|
||||||
|
|
||||||
# 字符数检查
|
# 字符数检查
|
||||||
if result.get("success") and "output" in result:
|
if result.get("success") and "output" in result:
|
||||||
|
|||||||
@ -5,8 +5,9 @@ import subprocess
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
import signal
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Callable, Dict, List
|
from typing import Optional, Callable, Dict, List, Tuple
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import threading
|
import threading
|
||||||
import queue
|
import queue
|
||||||
@ -507,16 +508,23 @@ class PersistentTerminal:
|
|||||||
|
|
||||||
def _process_output(self, output: str):
|
def _process_output(self, output: str):
|
||||||
"""处理输出行"""
|
"""处理输出行"""
|
||||||
# 添加到缓冲区
|
|
||||||
self.output_buffer.append(output)
|
|
||||||
self.total_output_size += len(output)
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
self.last_output_time = now
|
noisy_markers = (
|
||||||
|
"bash: cannot set terminal process group",
|
||||||
# 记录输出事件
|
"bash: no job control in this shell",
|
||||||
self._output_event_counter += 1
|
)
|
||||||
self.output_history.append((self._output_event_counter, now, output))
|
for line in output.splitlines(keepends=True):
|
||||||
self._append_io_event('output', output, timestamp=now)
|
if any(noise in line for noise in noisy_markers):
|
||||||
|
continue
|
||||||
|
self.output_buffer.append(line)
|
||||||
|
self.total_output_size += len(line)
|
||||||
|
now = time.time()
|
||||||
|
self.last_output_time = now
|
||||||
|
|
||||||
|
# 记录输出事件
|
||||||
|
self._output_event_counter += 1
|
||||||
|
self.output_history.append((self._output_event_counter, now, line))
|
||||||
|
self._append_io_event('output', line, timestamp=now)
|
||||||
|
|
||||||
# 控制输出历史长度
|
# 控制输出历史长度
|
||||||
if len(self.output_history) > 2000:
|
if len(self.output_history) > 2000:
|
||||||
@ -622,11 +630,18 @@ class PersistentTerminal:
|
|||||||
if not self.is_running or not self.process:
|
if not self.is_running or not self.process:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"error": "终端未运行",
|
"error": "终端未运行,请先打开终端会话。",
|
||||||
"session": self.session_name
|
"session": self.session_name
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# 清空残留输出,防止上一条命令的输出干扰
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
self.output_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
|
||||||
marker = self._capture_history_marker()
|
marker = self._capture_history_marker()
|
||||||
if timeout is None:
|
if timeout is None:
|
||||||
timeout = TERMINAL_OUTPUT_WAIT
|
timeout = TERMINAL_OUTPUT_WAIT
|
||||||
@ -674,12 +689,19 @@ class PersistentTerminal:
|
|||||||
else:
|
else:
|
||||||
command_bytes = to_send.encode('utf-8', errors='replace')
|
command_bytes = to_send.encode('utf-8', errors='replace')
|
||||||
|
|
||||||
self.process.stdin.write(command_bytes)
|
try:
|
||||||
self.process.stdin.flush()
|
self.process.stdin.write(command_bytes)
|
||||||
|
self.process.stdin.flush()
|
||||||
|
except Exception:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": "终端已不可用或输入失败,请重新打开终端会话。",
|
||||||
|
"session": self.session_name
|
||||||
|
}
|
||||||
|
|
||||||
# 如果需要等待输出
|
# 如果需要等待输出
|
||||||
if wait_for_output:
|
if wait_for_output:
|
||||||
output = self._wait_for_output(timeout=timeout)
|
output, timed_out = self._wait_for_output(timeout=timeout)
|
||||||
recent_output = self._get_output_since_marker(marker)
|
recent_output = self._get_output_since_marker(marker)
|
||||||
if recent_output:
|
if recent_output:
|
||||||
output = recent_output
|
output = recent_output
|
||||||
@ -690,7 +712,9 @@ class PersistentTerminal:
|
|||||||
output_clean = output.strip()
|
output_clean = output.strip()
|
||||||
has_output = bool(output_clean)
|
has_output = bool(output_clean)
|
||||||
status = "completed"
|
status = "completed"
|
||||||
if not has_output:
|
if timed_out:
|
||||||
|
status = "timeout"
|
||||||
|
elif not has_output:
|
||||||
if self.echo_loop_detected:
|
if self.echo_loop_detected:
|
||||||
status = "echo_loop"
|
status = "echo_loop"
|
||||||
elif self.is_interactive:
|
elif self.is_interactive:
|
||||||
@ -701,23 +725,27 @@ class PersistentTerminal:
|
|||||||
if self.echo_loop_detected:
|
if self.echo_loop_detected:
|
||||||
status = "output_with_echo"
|
status = "output_with_echo"
|
||||||
message_map = {
|
message_map = {
|
||||||
"completed": "命令执行完成,已捕获终端输出",
|
"completed": "命令执行完成",
|
||||||
"no_output": "未捕获任何输出,命令可能未产生可见结果或终端已卡死需要重制",
|
"no_output": "未捕获输出,命令可能未产生结果",
|
||||||
"awaiting_input": "命令已发送,终端正在等待进一步输入或进程仍在运行",
|
"awaiting_input": "命令已发送,终端等待进一步输入或仍在运行",
|
||||||
"echo_loop": "检测到终端正在回显输入,命令可能未成功执行",
|
"echo_loop": "检测到终端正在回显输入,命令可能未成功执行",
|
||||||
"output_with_echo": "命令产生输出,但终端疑似重复回显,请检查是否卡住"
|
"output_with_echo": "命令产生输出,但终端疑似重复回显",
|
||||||
|
"timeout": f"命令超时({int(timeout)}秒)"
|
||||||
}
|
}
|
||||||
message = message_map.get(status, "命令执行完成")
|
message = message_map.get(status, "命令执行完成")
|
||||||
if output_truncated:
|
if output_truncated:
|
||||||
message += f"(输出已截断,保留末尾{TERMINAL_INPUT_MAX_CHARS}字符)"
|
message += f"(输出已截断,保留末尾{TERMINAL_INPUT_MAX_CHARS}字符)"
|
||||||
|
elapsed_ms = int((time.time() - start_time) * 1000)
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": status in {"completed", "output_with_echo"},
|
||||||
"session": self.session_name,
|
"session": self.session_name,
|
||||||
"command": command_text,
|
"command": command_text,
|
||||||
"output": output,
|
"output": output,
|
||||||
"message": message,
|
"message": message,
|
||||||
"status": status,
|
"status": status,
|
||||||
"truncated": output_truncated
|
"truncated": output_truncated,
|
||||||
|
"elapsed_ms": elapsed_ms,
|
||||||
|
"timeout": timeout
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
return {
|
return {
|
||||||
@ -727,7 +755,9 @@ class PersistentTerminal:
|
|||||||
"output": "",
|
"output": "",
|
||||||
"message": "命令已发送至终端,后续输出将实时流式返回",
|
"message": "命令已发送至终端,后续输出将实时流式返回",
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"truncated": False
|
"truncated": False,
|
||||||
|
"elapsed_ms": int((time.time() - start_time) * 1000),
|
||||||
|
"timeout": timeout
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -739,11 +769,15 @@ class PersistentTerminal:
|
|||||||
"session": self.session_name
|
"session": self.session_name
|
||||||
}
|
}
|
||||||
|
|
||||||
def _wait_for_output(self, timeout: float = 5) -> str:
|
def _wait_for_output(self, timeout: float = 5) -> Tuple[str, bool]:
|
||||||
"""等待并收集输出"""
|
"""等待并收集输出,返回 (output, timed_out)。
|
||||||
|
|
||||||
|
策略:若尚未收到任何输出,则一直等到超时;一旦收到输出,若后续空闲>0.3s则视为命令结束。
|
||||||
|
"""
|
||||||
collected_output = []
|
collected_output = []
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
last_output_time = time.time()
|
last_output_time = time.time()
|
||||||
|
output_seen = False
|
||||||
|
|
||||||
if timeout is None or timeout <= 0:
|
if timeout is None or timeout <= 0:
|
||||||
timeout = 0
|
timeout = 0
|
||||||
@ -754,29 +788,33 @@ class PersistentTerminal:
|
|||||||
output = self.output_queue.get_nowait()
|
output = self.output_queue.get_nowait()
|
||||||
collected_output.append(output)
|
collected_output.append(output)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
return ''.join(collected_output)
|
return ''.join(collected_output), False
|
||||||
|
|
||||||
|
idle_threshold = 1.5
|
||||||
while time.time() - start_time < timeout:
|
while time.time() - start_time < timeout:
|
||||||
try:
|
try:
|
||||||
remaining = max(0.05, min(0.5, timeout - (time.time() - start_time)))
|
remaining = max(0.05, min(0.5, timeout - (time.time() - start_time)))
|
||||||
output = self.output_queue.get(timeout=remaining)
|
output = self.output_queue.get(timeout=remaining)
|
||||||
collected_output.append(output)
|
collected_output.append(output)
|
||||||
last_output_time = time.time()
|
last_output_time = time.time()
|
||||||
|
output_seen = True
|
||||||
# 快速收集剩余输出,直到短暂空闲
|
# 快速收集剩余输出,直到短暂空闲
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
output = self.output_queue.get(timeout=0.1)
|
output = self.output_queue.get(timeout=0.1)
|
||||||
collected_output.append(output)
|
collected_output.append(output)
|
||||||
last_output_time = time.time()
|
last_output_time = time.time()
|
||||||
|
output_seen = True
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
break
|
break
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
if collected_output and time.time() - last_output_time > 0.3:
|
if output_seen and (time.time() - last_output_time > idle_threshold):
|
||||||
break
|
break
|
||||||
if timeout == 0:
|
if timeout == 0:
|
||||||
break
|
break
|
||||||
|
|
||||||
return ''.join(collected_output)
|
timed_out = (time.time() - start_time) >= timeout and timeout > 0
|
||||||
|
return ''.join(collected_output), timed_out
|
||||||
|
|
||||||
def get_output(self, last_n_lines: int = 50) -> str:
|
def get_output(self, last_n_lines: int = 50) -> str:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -491,7 +491,32 @@ class TerminalManager:
|
|||||||
|
|
||||||
# 发送命令
|
# 发送命令
|
||||||
terminal = self.terminals[target_session]
|
terminal = self.terminals[target_session]
|
||||||
result = terminal.send_command(command, wait_for_output, timeout=timeout)
|
if timeout is None or timeout <= 0:
|
||||||
|
timeout = 120
|
||||||
|
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 # 留出终止与收尾输出
|
||||||
|
|
||||||
|
result = terminal.send_command(wrapped_command, wait_for_output, timeout=wait_timeout)
|
||||||
|
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
|
return result
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,8 @@ import sys
|
|||||||
import asyncio
|
import asyncio
|
||||||
import subprocess
|
import subprocess
|
||||||
import shutil
|
import shutil
|
||||||
|
import time
|
||||||
|
import signal
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Optional, Tuple, TYPE_CHECKING
|
from typing import Dict, Optional, Tuple, TYPE_CHECKING
|
||||||
try:
|
try:
|
||||||
@ -128,6 +130,13 @@ class TerminalOperator:
|
|||||||
return False, f"检测到危险命令模式: {pattern}"
|
return False, f"检测到危险命令模式: {pattern}"
|
||||||
|
|
||||||
return True, ""
|
return True, ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _clamp_timeout(requested: Optional[int], default: int, max_limit: int) -> int:
|
||||||
|
"""对timeout进行默认化与上限夹紧。"""
|
||||||
|
if not requested or requested <= 0:
|
||||||
|
return default
|
||||||
|
return min(int(requested), max_limit)
|
||||||
|
|
||||||
async def run_command(
|
async def run_command(
|
||||||
self,
|
self,
|
||||||
@ -146,7 +155,7 @@ class TerminalOperator:
|
|||||||
Returns:
|
Returns:
|
||||||
执行结果字典
|
执行结果字典
|
||||||
"""
|
"""
|
||||||
# 每次执行前重置工具容器,防止上一条命令的输出/状态干扰
|
# 每次执行前重置工具容器(保持隔离),但下面改用一次性子进程执行,仍保留重置以兼容后续逻辑
|
||||||
self._reset_toolbox()
|
self._reset_toolbox()
|
||||||
# 替换命令中的python3为实际可用的命令
|
# 替换命令中的python3为实际可用的命令
|
||||||
if "python3" in command and self.python_cmd != "python3":
|
if "python3" in command and self.python_cmd != "python3":
|
||||||
@ -176,17 +185,15 @@ class TerminalOperator:
|
|||||||
"return_code": -1
|
"return_code": -1
|
||||||
}
|
}
|
||||||
|
|
||||||
timeout = timeout or TERMINAL_COMMAND_TIMEOUT
|
# 默认10s,上限30s
|
||||||
|
timeout = self._clamp_timeout(timeout, default=10, max_limit=TERMINAL_COMMAND_TIMEOUT)
|
||||||
|
|
||||||
print(f"{OUTPUT_FORMATS['terminal']} 执行命令: {command}")
|
print(f"{OUTPUT_FORMATS['terminal']} 执行命令: {command}")
|
||||||
print(f"{OUTPUT_FORMATS['info']} 工作目录: {work_path}")
|
print(f"{OUTPUT_FORMATS['info']} 工作目录: {work_path}")
|
||||||
|
|
||||||
try:
|
start_ts = time.time()
|
||||||
toolbox_raw = await self._get_toolbox().run(command, work_path, timeout=timeout)
|
# 改为一次性子进程执行,确保等待到超时或命令结束
|
||||||
result_payload = self._format_toolbox_output(toolbox_raw)
|
result_payload = await self._run_command_subprocess(command, work_path, timeout)
|
||||||
except Exception as exc:
|
|
||||||
print(f"{OUTPUT_FORMATS['warning']} 工具容器执行失败,回退到本地子进程: {exc}")
|
|
||||||
result_payload = await self._run_command_subprocess(command, work_path, timeout)
|
|
||||||
|
|
||||||
# 字符数检查
|
# 字符数检查
|
||||||
if result_payload.get("success") and "output" in result_payload:
|
if result_payload.get("success") and "output" in result_payload:
|
||||||
@ -200,6 +207,9 @@ class TerminalOperator:
|
|||||||
"command": command
|
"command": command
|
||||||
}
|
}
|
||||||
|
|
||||||
|
result_payload.setdefault("status", "completed" if result_payload.get("success") else "error")
|
||||||
|
result_payload["timeout"] = timeout
|
||||||
|
result_payload["elapsed_ms"] = int((time.time() - start_ts) * 1000)
|
||||||
return result_payload
|
return result_payload
|
||||||
|
|
||||||
def _resolve_work_path(self, working_dir: Optional[str]) -> Path:
|
def _resolve_work_path(self, working_dir: Optional[str]) -> Path:
|
||||||
@ -212,6 +222,17 @@ class TerminalOperator:
|
|||||||
def _format_toolbox_output(self, payload: Dict) -> Dict:
|
def _format_toolbox_output(self, payload: Dict) -> Dict:
|
||||||
success = bool(payload.get("success"))
|
success = bool(payload.get("success"))
|
||||||
output_text = payload.get("output", "") or ""
|
output_text = payload.get("output", "") or ""
|
||||||
|
# 去掉常见的交互式shell警告
|
||||||
|
noisy_lines = (
|
||||||
|
"bash: cannot set terminal process group",
|
||||||
|
"bash: no job control in this shell",
|
||||||
|
)
|
||||||
|
filtered = []
|
||||||
|
for line in output_text.splitlines():
|
||||||
|
if any(noise in line for noise in noisy_lines):
|
||||||
|
continue
|
||||||
|
filtered.append(line)
|
||||||
|
output_text = "\n".join(filtered)
|
||||||
result = {
|
result = {
|
||||||
"success": success,
|
"success": success,
|
||||||
"output": output_text,
|
"output": output_text,
|
||||||
@ -228,67 +249,157 @@ class TerminalOperator:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
async def _run_command_subprocess(self, command: str, work_path: Path, timeout: int) -> Dict:
|
async def _run_command_subprocess(self, command: str, work_path: Path, timeout: int) -> Dict:
|
||||||
|
start_ts = time.time()
|
||||||
try:
|
try:
|
||||||
process = await asyncio.create_subprocess_shell(
|
process = None
|
||||||
command,
|
exec_cmd = None
|
||||||
stdout=asyncio.subprocess.PIPE,
|
use_shell = True
|
||||||
stderr=asyncio.subprocess.PIPE,
|
|
||||||
cwd=str(work_path),
|
# 如果存在容器会话且模式为docker,则在容器内执行
|
||||||
shell=True
|
if self.container_session and getattr(self.container_session, "mode", None) == "docker":
|
||||||
)
|
container_name = getattr(self.container_session, "container_name", None)
|
||||||
|
mount_path = getattr(self.container_session, "mount_path", "/workspace") or "/workspace"
|
||||||
try:
|
docker_bin = shutil.which("docker") or "docker"
|
||||||
stdout, stderr = await asyncio.wait_for(
|
try:
|
||||||
process.communicate(),
|
relative = work_path.relative_to(self.project_path).as_posix()
|
||||||
timeout=timeout
|
except ValueError:
|
||||||
|
relative = ""
|
||||||
|
container_workdir = mount_path.rstrip("/")
|
||||||
|
if relative:
|
||||||
|
container_workdir = f"{container_workdir}/{relative}"
|
||||||
|
exec_cmd = [
|
||||||
|
docker_bin,
|
||||||
|
"exec",
|
||||||
|
"-w",
|
||||||
|
container_workdir,
|
||||||
|
container_name,
|
||||||
|
"/bin/bash",
|
||||||
|
"-lc",
|
||||||
|
command,
|
||||||
|
]
|
||||||
|
use_shell = False
|
||||||
|
|
||||||
|
# 统一环境,确保 Python 输出无缓冲
|
||||||
|
env = os.environ.copy()
|
||||||
|
env.setdefault("PYTHONUNBUFFERED", "1")
|
||||||
|
|
||||||
|
if use_shell:
|
||||||
|
process = await asyncio.create_subprocess_shell(
|
||||||
|
command,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
cwd=str(work_path),
|
||||||
|
shell=True,
|
||||||
|
env=env,
|
||||||
|
start_new_session=True,
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
*exec_cmd,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
env=env,
|
||||||
|
start_new_session=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
stdout_buf: list[bytes] = []
|
||||||
|
stderr_buf: list[bytes] = []
|
||||||
|
|
||||||
|
async def _read_stream(stream, collector):
|
||||||
|
try:
|
||||||
|
async for chunk in stream:
|
||||||
|
if chunk:
|
||||||
|
collector.append(chunk)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
stdout_task = asyncio.create_task(_read_stream(process.stdout, stdout_buf))
|
||||||
|
stderr_task = asyncio.create_task(_read_stream(process.stderr, stderr_buf))
|
||||||
|
|
||||||
|
timed_out = False
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(process.wait(), timeout=timeout)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
process.kill()
|
timed_out = True
|
||||||
await process.wait()
|
try:
|
||||||
return {
|
os.killpg(process.pid, signal.SIGINT)
|
||||||
"success": False,
|
except Exception:
|
||||||
"error": f"命令执行超时 ({timeout}秒)",
|
pass
|
||||||
"output": "",
|
try:
|
||||||
"return_code": -1
|
await asyncio.wait_for(process.wait(), timeout=2)
|
||||||
}
|
except asyncio.TimeoutError:
|
||||||
|
try:
|
||||||
|
os.killpg(process.pid, signal.SIGKILL)
|
||||||
|
except Exception:
|
||||||
|
process.kill()
|
||||||
|
await process.wait()
|
||||||
|
|
||||||
|
# 确保读取协程结束
|
||||||
|
await asyncio.gather(stdout_task, stderr_task, return_exceptions=True)
|
||||||
|
|
||||||
|
# 兜底再读一次,防止剩余缓冲未被读取
|
||||||
|
try:
|
||||||
|
remaining_out = await process.stdout.read()
|
||||||
|
if remaining_out:
|
||||||
|
stdout_buf.append(remaining_out)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
remaining_err = await process.stderr.read()
|
||||||
|
if remaining_err:
|
||||||
|
stderr_buf.append(remaining_err)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
stdout = b"".join(stdout_buf)
|
||||||
|
stderr = b"".join(stderr_buf)
|
||||||
|
|
||||||
stdout_text = stdout.decode('utf-8', errors='replace') if stdout else ""
|
stdout_text = stdout.decode('utf-8', errors='replace') if stdout else ""
|
||||||
stderr_text = stderr.decode('utf-8', errors='replace') if stderr else ""
|
stderr_text = stderr.decode('utf-8', errors='replace') if stderr else ""
|
||||||
|
|
||||||
success = process.returncode == 0
|
success = (process.returncode == 0) and not timed_out
|
||||||
if success:
|
status = "completed" if success else ("timeout" if timed_out else "error")
|
||||||
print(f"{OUTPUT_FORMATS['success']} 命令执行成功")
|
|
||||||
else:
|
|
||||||
print(f"{OUTPUT_FORMATS['error']} 命令执行失败 (返回码: {process.returncode})")
|
|
||||||
|
|
||||||
output_parts = []
|
output_parts = []
|
||||||
if stdout_text:
|
if stdout_text:
|
||||||
output_parts.append(stdout_text)
|
output_parts.append(stdout_text)
|
||||||
if stderr_text:
|
if stderr_text:
|
||||||
output_parts.append(f"[stderr]\n{stderr_text}")
|
output_parts.append(stderr_text)
|
||||||
combined_output = "\n".join(output_parts)
|
combined_output = "\n".join(output_parts)
|
||||||
|
|
||||||
truncated = False
|
truncated = False
|
||||||
if MAX_RUN_COMMAND_CHARS and len(combined_output) > MAX_RUN_COMMAND_CHARS:
|
if MAX_RUN_COMMAND_CHARS and len(combined_output) > MAX_RUN_COMMAND_CHARS:
|
||||||
truncated = True
|
truncated = True
|
||||||
combined_output = combined_output[-MAX_RUN_COMMAND_CHARS:]
|
combined_output = combined_output[-MAX_RUN_COMMAND_CHARS:]
|
||||||
|
|
||||||
response = {
|
response = {
|
||||||
"success": success,
|
"success": success,
|
||||||
|
"status": status,
|
||||||
"command": command,
|
"command": command,
|
||||||
"output": combined_output,
|
"output": combined_output,
|
||||||
"return_code": process.returncode,
|
"return_code": process.returncode,
|
||||||
"truncated": truncated
|
"truncated": truncated,
|
||||||
|
"timeout": timeout,
|
||||||
|
"elapsed_ms": int((time.time() - start_ts) * 1000)
|
||||||
}
|
}
|
||||||
|
if not success and timed_out:
|
||||||
|
response["message"] = f"命令执行超时 ({timeout}秒)"
|
||||||
|
elif not success and process.returncode is not None:
|
||||||
|
response["message"] = f"命令执行失败 (返回码: {process.returncode})"
|
||||||
if stderr_text:
|
if stderr_text:
|
||||||
response["stderr"] = stderr_text
|
response["stderr"] = stderr_text
|
||||||
return response
|
return response
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
|
"status": "error",
|
||||||
"error": f"执行失败: {str(exc)}",
|
"error": f"执行失败: {str(exc)}",
|
||||||
"output": "",
|
"output": "",
|
||||||
"return_code": -1
|
"return_code": -1,
|
||||||
|
"timeout": timeout,
|
||||||
|
"elapsed_ms": int((time.time() - start_ts) * 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
async def run_python_code(
|
async def run_python_code(
|
||||||
@ -306,7 +417,7 @@ class TerminalOperator:
|
|||||||
Returns:
|
Returns:
|
||||||
执行结果字典
|
执行结果字典
|
||||||
"""
|
"""
|
||||||
timeout = timeout or CODE_EXECUTION_TIMEOUT
|
timeout = self._clamp_timeout(timeout, default=20, max_limit=CODE_EXECUTION_TIMEOUT)
|
||||||
|
|
||||||
# 强制重置工具容器,避免上一段代码仍在运行时输出混入
|
# 强制重置工具容器,避免上一段代码仍在运行时输出混入
|
||||||
self._reset_toolbox()
|
self._reset_toolbox()
|
||||||
@ -328,7 +439,7 @@ class TerminalOperator:
|
|||||||
|
|
||||||
# 使用检测到的Python命令执行文件(相对路径可兼容容器挂载路径)
|
# 使用检测到的Python命令执行文件(相对路径可兼容容器挂载路径)
|
||||||
result = await self.run_command(
|
result = await self.run_command(
|
||||||
f'{self.python_cmd} "{relative_temp}"',
|
f'{self.python_cmd} -u "{relative_temp}"',
|
||||||
timeout=timeout
|
timeout=timeout
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -118,6 +118,8 @@ const appOptions = {
|
|||||||
activeTools: new Map(),
|
activeTools: new Map(),
|
||||||
toolActionIndex: new Map(),
|
toolActionIndex: new Map(),
|
||||||
toolStacks: new Map(),
|
toolStacks: new Map(),
|
||||||
|
// 当前任务是否仍在进行中(用于保持输入区的“停止”状态)
|
||||||
|
taskInProgress: false,
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// 对话管理相关状态
|
// 对话管理相关状态
|
||||||
@ -302,7 +304,7 @@ const appOptions = {
|
|||||||
},
|
},
|
||||||
composerBusy() {
|
composerBusy() {
|
||||||
const monitorLock = this.monitorIsLocked && this.chatDisplayMode === 'monitor';
|
const monitorLock = this.monitorIsLocked && this.chatDisplayMode === 'monitor';
|
||||||
return this.streamingUi || monitorLock || this.stopRequested;
|
return this.streamingUi || this.taskInProgress || monitorLock || this.stopRequested;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -1038,6 +1040,7 @@ const appOptions = {
|
|||||||
this.streamingMessage = false;
|
this.streamingMessage = false;
|
||||||
this.currentMessageIndex = -1;
|
this.currentMessageIndex = -1;
|
||||||
this.stopRequested = false;
|
this.stopRequested = false;
|
||||||
|
this.taskInProgress = false;
|
||||||
this.dropToolEvents = false;
|
this.dropToolEvents = false;
|
||||||
|
|
||||||
// 清理工具状态
|
// 清理工具状态
|
||||||
@ -1957,6 +1960,8 @@ const appOptions = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 标记任务进行中,直到任务完成或用户手动停止
|
||||||
|
this.taskInProgress = true;
|
||||||
this.chatAddUserMessage(message);
|
this.chatAddUserMessage(message);
|
||||||
this.socket.emit('send_message', { message: message, conversation_id: this.currentConversationId });
|
this.socket.emit('send_message', { message: message, conversation_id: this.currentConversationId });
|
||||||
if (typeof this.monitorShowPendingReply === 'function') {
|
if (typeof this.monitorShowPendingReply === 'function') {
|
||||||
@ -1995,6 +2000,7 @@ const appOptions = {
|
|||||||
// 立即清理前端状态,避免出现“不可输入也不可停止”的卡死状态
|
// 立即清理前端状态,避免出现“不可输入也不可停止”的卡死状态
|
||||||
this.clearPendingTools('user_stop');
|
this.clearPendingTools('user_stop');
|
||||||
this.streamingMessage = false;
|
this.streamingMessage = false;
|
||||||
|
this.taskInProgress = false;
|
||||||
this.forceUnlockMonitor('user_stop');
|
this.forceUnlockMonitor('user_stop');
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -2035,6 +2041,7 @@ const appOptions = {
|
|||||||
this.toolActionIndex.clear();
|
this.toolActionIndex.clear();
|
||||||
this.toolStacks.clear();
|
this.toolStacks.clear();
|
||||||
this.stopRequested = false;
|
this.stopRequested = false;
|
||||||
|
this.taskInProgress = false;
|
||||||
},
|
},
|
||||||
|
|
||||||
async clearChat() {
|
async clearChat() {
|
||||||
|
|||||||
@ -690,6 +690,7 @@ export async function initializeLegacySocket(ctx: any) {
|
|||||||
resetStreamingBuffer();
|
resetStreamingBuffer();
|
||||||
ctx.monitorResetSpeech();
|
ctx.monitorResetSpeech();
|
||||||
ctx.cleanupStaleToolActions();
|
ctx.cleanupStaleToolActions();
|
||||||
|
ctx.taskInProgress = true;
|
||||||
ctx.chatStartAssistantMessage();
|
ctx.chatStartAssistantMessage();
|
||||||
streamingState.activeMessageIndex =
|
streamingState.activeMessageIndex =
|
||||||
typeof ctx.currentMessageIndex === 'number' ? ctx.currentMessageIndex : null;
|
typeof ctx.currentMessageIndex === 'number' ? ctx.currentMessageIndex : null;
|
||||||
@ -1114,12 +1115,14 @@ export async function initializeLegacySocket(ctx: any) {
|
|||||||
// 任务停止
|
// 任务停止
|
||||||
ctx.socket.on('task_stopped', (data) => {
|
ctx.socket.on('task_stopped', (data) => {
|
||||||
socketLog('任务已停止:', data.message);
|
socketLog('任务已停止:', data.message);
|
||||||
|
ctx.taskInProgress = false;
|
||||||
ctx.scheduleResetAfterTask('socket:task_stopped', { preserveMonitorWindows: true });
|
ctx.scheduleResetAfterTask('socket:task_stopped', { preserveMonitorWindows: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// 任务完成(重点:更新Token统计)
|
// 任务完成(重点:更新Token统计)
|
||||||
ctx.socket.on('task_complete', (data) => {
|
ctx.socket.on('task_complete', (data) => {
|
||||||
socketLog('任务完成', data);
|
socketLog('任务完成', data);
|
||||||
|
ctx.taskInProgress = false;
|
||||||
ctx.scheduleResetAfterTask('socket:task_complete', { preserveMonitorWindows: true });
|
ctx.scheduleResetAfterTask('socket:task_complete', { preserveMonitorWindows: true });
|
||||||
|
|
||||||
resetPendingToolEvents();
|
resetPendingToolEvents();
|
||||||
@ -1167,6 +1170,7 @@ export async function initializeLegacySocket(ctx: any) {
|
|||||||
// 仅标记当前流结束,避免状态错乱
|
// 仅标记当前流结束,避免状态错乱
|
||||||
ctx.streamingMessage = false;
|
ctx.streamingMessage = false;
|
||||||
ctx.stopRequested = false;
|
ctx.stopRequested = false;
|
||||||
|
ctx.taskInProgress = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 命令结果
|
// 命令结果
|
||||||
|
|||||||
@ -301,20 +301,45 @@ def _format_terminal_session(result_data: Dict[str, Any]) -> str:
|
|||||||
return result_data.get("message") or f"{tag} 操作已完成。"
|
return result_data.get("message") or f"{tag} 操作已完成。"
|
||||||
|
|
||||||
|
|
||||||
|
def _plain_command_output(result_data: Dict[str, Any]) -> str:
|
||||||
|
"""生成纯文本输出,按需要加状态前缀。"""
|
||||||
|
output = result_data.get("output") or ""
|
||||||
|
status = (result_data.get("status") or "").lower()
|
||||||
|
timeout = result_data.get("timeout")
|
||||||
|
return_code = result_data.get("return_code")
|
||||||
|
truncated = result_data.get("truncated")
|
||||||
|
|
||||||
|
prefixes = []
|
||||||
|
if status in {"timeout"} and timeout:
|
||||||
|
prefixes.append(f"[timeout after {int(timeout)}s]")
|
||||||
|
elif status in {"timeout"}:
|
||||||
|
prefixes.append("[timeout]")
|
||||||
|
elif status in {"killed"}:
|
||||||
|
prefixes.append("[killed]")
|
||||||
|
elif status in {"awaiting_input"}:
|
||||||
|
prefixes.append("[awaiting_input]")
|
||||||
|
elif status in {"no_output"} and not output:
|
||||||
|
prefixes.append("[no_output]")
|
||||||
|
elif status in {"error"} and return_code is not None:
|
||||||
|
prefixes.append(f"[error rc={return_code}]")
|
||||||
|
elif status in {"error"}:
|
||||||
|
prefixes.append("[error]")
|
||||||
|
|
||||||
|
if truncated:
|
||||||
|
prefixes.append("[truncated]")
|
||||||
|
|
||||||
|
prefix_text = "".join(prefixes)
|
||||||
|
if prefix_text and output:
|
||||||
|
return f"{prefix_text}\n{output}"
|
||||||
|
if prefix_text:
|
||||||
|
return prefix_text
|
||||||
|
if not output:
|
||||||
|
return "[no_output]"
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
def _format_terminal_input(result_data: Dict[str, Any]) -> str:
|
def _format_terminal_input(result_data: Dict[str, Any]) -> str:
|
||||||
if not result_data.get("success"):
|
return _plain_command_output(result_data)
|
||||||
return _format_failure("terminal_input", result_data)
|
|
||||||
session = result_data.get("session") or result_data.get("session_name") or "default"
|
|
||||||
command = result_data.get("command") or "(命令缺失)"
|
|
||||||
status = result_data.get("status") or "completed"
|
|
||||||
message = result_data.get("message") or ""
|
|
||||||
lines = [
|
|
||||||
f"terminal_input: 在 {session} 执行 `{command}`,状态 {status}",
|
|
||||||
]
|
|
||||||
if message:
|
|
||||||
lines.append(message)
|
|
||||||
lines.append(_summarize_output_block(result_data.get("output"), result_data.get("truncated")))
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def _format_sleep(result_data: Dict[str, Any]) -> str:
|
def _format_sleep(result_data: Dict[str, Any]) -> str:
|
||||||
@ -332,16 +357,19 @@ def _format_sleep(result_data: Dict[str, Any]) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _format_run_command(result_data: Dict[str, Any]) -> str:
|
def _format_run_command(result_data: Dict[str, Any]) -> str:
|
||||||
return _format_command_result("run_command", result_data)
|
text = _plain_command_output(result_data)
|
||||||
|
if (result_data.get("status") or "").lower() == "timeout":
|
||||||
|
suggestion = "建议:在持久终端中直接运行该命令(terminal_session + terminal_input),或缩短命令执行时间。"
|
||||||
|
text = f"{text}\n{suggestion}" if text else suggestion
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
def _format_run_python(result_data: Dict[str, Any]) -> str:
|
def _format_run_python(result_data: Dict[str, Any]) -> str:
|
||||||
base = _format_command_result("run_python", result_data)
|
text = _plain_command_output(result_data)
|
||||||
code = result_data.get("code")
|
if (result_data.get("status") or "").lower() == "timeout":
|
||||||
if not isinstance(code, str):
|
suggestion = "建议:将代码保存为脚本后,在持久终端中执行(terminal_session + terminal_input),或拆分/优化代码以缩短运行时间。"
|
||||||
return base
|
text = f"{text}\n{suggestion}" if text else suggestion
|
||||||
header = f"run_python: 执行临时代码({len(code)} 字符)"
|
return text
|
||||||
return "\n".join([header, base])
|
|
||||||
|
|
||||||
|
|
||||||
def _format_todo_create(result_data: Dict[str, Any]) -> str:
|
def _format_todo_create(result_data: Dict[str, Any]) -> str:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user