feat(agent): unified cross-platform shell execution contract (cmd/bash)
Windows runs 'cmd.exe /d /s /c <command>' as a string command line; Linux uses ['/bin/bash', '-lc', command]. Removes implicit shell=True behavior and aligns the agent system prompt per platform.
This commit is contained in:
+8
-16
@@ -32,6 +32,7 @@ from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from .types import (AgentMessage, AgentTool, AgentToolResult, AbortSignal,
|
||||
ToolCall, new_id)
|
||||
from ..platform_shell import kill_process_tree, popen_flags, shell_command # P1-02
|
||||
|
||||
try:
|
||||
from core.debug_log import debug_log as _dbg_log # 🆕 计时观察日志(线程安全/静默)
|
||||
@@ -448,8 +449,8 @@ def tool_bash(tool_call_id: str, args: Dict[str, Any], signal: AbortSignal,
|
||||
return AgentToolResult.text("command 不能为空", is_error=True)
|
||||
timeout = min(float(args.get("timeout", 120)), 600)
|
||||
cwd = ctx.get("cwd") or os.getcwd()
|
||||
# 对照 pi bash 工具:命令始终经 shell 解释(支持管道/别名/内置命令)
|
||||
use_shell = ctx.get("shell", True)
|
||||
# P1-02:显式 shell 契约(Windows cmd.exe /d /c;Linux /bin/bash -lc),
|
||||
# 不再依赖 shell=True 的平台默认值;ctx 的 "shell" 键已废弃(bash 工具即 shell 工具)
|
||||
on_timer = ctx.get("on_timer")
|
||||
t0 = time.time()
|
||||
# 🆕 计时观察①:计时器启动时刻 + 模型实际传的 timeout 值
|
||||
@@ -466,19 +467,9 @@ def tool_bash(tool_call_id: str, args: Dict[str, Any], signal: AbortSignal,
|
||||
pass
|
||||
|
||||
def _kill_tree(proc):
|
||||
"""杀整个进程树:Windows 上默认 kill 只杀 cmd 壳,孤儿子进程继续
|
||||
持管道 → 假超时(设定10s 实际20s)。taskkill /T 整树杀。"""
|
||||
try:
|
||||
if os.name == "nt":
|
||||
subprocess.run(["taskkill", "/F", "/T", "/PID", str(proc.pid)],
|
||||
capture_output=True, timeout=10)
|
||||
else:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
"""杀整个进程树:Windows taskkill /F /T;Linux 独立进程组
|
||||
SIGTERM → 宽限 → SIGKILL(见 core.platform_shell.kill_process_tree)。"""
|
||||
kill_process_tree(proc)
|
||||
|
||||
# 🐛 修复:text=True 不带 encoding 时按系统码页(中文 Windows=GBK)解码,
|
||||
# 子进程输出 UTF-8(python/git/中文 echo)→ _readerthread UnicodeDecodeError。
|
||||
@@ -486,10 +477,11 @@ def tool_bash(tool_call_id: str, args: Dict[str, Any], signal: AbortSignal,
|
||||
_env = dict(os.environ, PYTHONIOENCODING="utf-8")
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
command, shell=use_shell,
|
||||
shell_command(command), # P1-02:显式 shell 命令
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
text=True, encoding="utf-8", errors="replace",
|
||||
cwd=cwd, env=_env,
|
||||
**popen_flags(), # P1-02:Linux 独立进程组(start_new_session)
|
||||
)
|
||||
except Exception as e:
|
||||
return AgentToolResult.text(f"执行失败: {e}", is_error=True)
|
||||
|
||||
+14
-12
@@ -37,8 +37,10 @@ from core.agent import (Agent, AgentConfig, AgentEvent, AgentMessage, AgentRunne
|
||||
default_tools, from_openai_messages,
|
||||
openai_stream)
|
||||
from core.agent.stream_fn import _pick_reasoning, _pick_usage
|
||||
# P0-01:配置读取统一入口(环境变量 HAOCODE_CONFIG_FILE 优先,缺省回落项目内 data/config.json)
|
||||
from core.config_paths import load_config
|
||||
from core import platform_shell # P1-02:提示词平台段(通用正文 + 短 shell/path 段)
|
||||
|
||||
CONFIG_PATH = os.path.join(os.path.dirname(__file__), "..", "data", "config.json")
|
||||
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
# 🌟 Agent 系统提示词文件(项目根目录,core 前面那个 .md)
|
||||
SYSTEM_PROMPT_FILE = os.path.join(PROJECT_ROOT, "SYSTEM_PROMPT.md")
|
||||
@@ -49,25 +51,25 @@ _FALLBACK_SYSTEM_PROMPT = (
|
||||
|
||||
|
||||
def load_system_prompt() -> str:
|
||||
"""读取 SYSTEM_PROMPT.md;缺失时用兜底短提示词。"""
|
||||
"""读取 SYSTEM_PROMPT.md;缺失时用兜底短提示词。
|
||||
|
||||
P1-02:通用正文 + 运行时插入的短平台 shell/path 段(占位符替换);
|
||||
每次请求重读本函数即重读文件,Windows 段不会出现在 Linux 请求中,反之亦然。
|
||||
"""
|
||||
text = None
|
||||
try:
|
||||
with open(SYSTEM_PROMPT_FILE, "r", encoding="utf-8") as f:
|
||||
text = f.read().strip()
|
||||
if text:
|
||||
return text
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return _FALLBACK_SYSTEM_PROMPT
|
||||
if not text:
|
||||
text = _FALLBACK_SYSTEM_PROMPT
|
||||
return platform_shell.apply_platform_section(text)
|
||||
|
||||
|
||||
def _load_config() -> dict:
|
||||
if os.path.exists(CONFIG_PATH):
|
||||
try:
|
||||
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
print(f"[llm_engine] 读取配置失败: {e}")
|
||||
return {}
|
||||
"""P0-01:统一走 core.config_paths.load_config(环境变量优先、容错、可见警告)。"""
|
||||
return load_config()
|
||||
|
||||
|
||||
def _provider_info(config: dict, provider_name: str) -> dict:
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""P1-02:窄平台进程适配 —— 显式 shell 契约 + 进程树终止 + 提示词平台段。
|
||||
|
||||
契约(不依赖 shell=True 的平台默认值):
|
||||
- Windows:字符串命令行 ``cmd.exe /d /s /c "<command>"``(/s 保留内部引号;
|
||||
不能传 argv 列表——list2cmdline 的引号转义 cmd 不认)
|
||||
- Linux: ``/bin/bash -lc <command>``(登录 shell,可读 profile 别名/函数)
|
||||
|
||||
进程树终止:
|
||||
- Windows:taskkill /F /T(既有等价机制,整树强杀)
|
||||
- Linux: start_new_session 独立 POSIX 进程组;SIGTERM 整组 → 宽限期 → SIGKILL 整组
|
||||
(安全不变量:仅当 getpgid == 子进程 pid,即确认独立组后才 killpg,
|
||||
否则退化为单进程 kill,绝不误杀调用方所在组)
|
||||
|
||||
系统提示词:通用正文 + 运行时插入的短平台 shell/path 段(占位符
|
||||
``{{SHELL_PLATFORM_SECTION}}``)。不维护两份完整提示词。
|
||||
"""
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
#: 系统提示词中的平台段占位符
|
||||
SHELL_SECTION_PLACEHOLDER = "{{SHELL_PLATFORM_SECTION}}"
|
||||
|
||||
_KILL_GRACE_S = 3.0 # Linux:SIGTERM → SIGKILL 宽限
|
||||
_TASKKILL_TIMEOUT_S = 10 # Windows:taskkill 自身超时
|
||||
|
||||
|
||||
def is_windows() -> bool:
|
||||
return os.name == "nt"
|
||||
|
||||
|
||||
def shell_command(command: str):
|
||||
"""显式 shell 命令(bash 工具的 shell 契约,替代 shell=True 平台默认值)。
|
||||
|
||||
- Windows:返回**字符串命令行** ``cmd.exe /d /s /c "<command>"``。
|
||||
必须走字符串而非 argv 列表:列表形态会被 CPython ``list2cmdline``
|
||||
给内部引号加反斜杠转义,cmd.exe 不认 ``\"``;字符串形态下命令行
|
||||
原样交给 CreateProcessW,cmd 自行按 /s 规则解析 /c 参数
|
||||
(外层引号剥离、内部引号保留,实测引号路径/&&/管道/%VAR% 均正确)。
|
||||
- Linux:返回 argv 列表 ``[\ "/bin/bash", "-lc", command]``(POSIX 下
|
||||
列表参数无引号转义问题,-l 登录 shell 可读 profile 别名/函数)。
|
||||
"""
|
||||
if is_windows():
|
||||
return 'cmd.exe /d /s /c "' + command + '"'
|
||||
return ["/bin/bash", "-lc", command]
|
||||
|
||||
|
||||
def popen_flags() -> dict:
|
||||
"""进程树管理所需的 Popen 附加参数:Linux 独立进程组。"""
|
||||
if is_windows():
|
||||
return {}
|
||||
return {"start_new_session": True}
|
||||
|
||||
|
||||
def kill_process_tree(proc, grace_s: float = _KILL_GRACE_S) -> None:
|
||||
"""终止 proc 的整棵子进程树;最坏情况也保证返回(不抛异常)。
|
||||
|
||||
- Windows:taskkill /F /T 整树强杀(既有机制)。
|
||||
- Linux:独立进程组 SIGTERM → 宽限 grace_s 秒 → SIGKILL 整组。
|
||||
若无法确认独立组(进程已退 / 标志未生效)→ 退化单进程 kill。
|
||||
"""
|
||||
if proc is None:
|
||||
return
|
||||
|
||||
if is_windows():
|
||||
try:
|
||||
subprocess.run(["taskkill", "/F", "/T", "/PID", str(proc.pid)],
|
||||
capture_output=True, timeout=_TASKKILL_TIMEOUT_S)
|
||||
except Exception:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
# ---- POSIX ----
|
||||
pgid = None
|
||||
try:
|
||||
pgid = os.getpgid(proc.pid)
|
||||
except Exception:
|
||||
pass
|
||||
if pgid is None or pgid != proc.pid:
|
||||
# 进程已退出,或没有独立组 → 不能 killpg(可能误伤调用方组)
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
def _pg(sig: int) -> None:
|
||||
try:
|
||||
os.killpg(pgid, sig)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_pg(signal.SIGTERM)
|
||||
deadline = time.time() + grace_s
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
if proc.poll() is not None:
|
||||
return
|
||||
except Exception:
|
||||
return
|
||||
time.sleep(0.05)
|
||||
_pg(signal.SIGKILL)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 系统提示词平台段(短段,运行时插入;通用正文保持平台无关)
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
_WIN_SECTION = """### 1.1 平台 shell 与路径(Windows)
|
||||
|
||||
- 操作系统:Windows。路径形如 `C:\\Users\\14890\\Desktop\\haocode`;不确定真实路径时先 `dir` / `ls`。
|
||||
- bash 命令经 **cmd.exe** 执行(不是 git-bash);`C:\\Program Files\\Git\\usr\\bin` 在 PATH 上,
|
||||
`ls` `grep` `cat` `head` `tail` `wc` `rm` `sed` `awk` 可直接用,`|` `>` `2>&1` `&&` 可用。
|
||||
- ⚠️ cmd 陷阱:`;` 不是分隔符(用 `&&` 或换行);环境变量用 `%VAR%`(`$VAR` 不展开);
|
||||
内建命令不剥单引号(`echo 'x'` 原样带引号);每次调用都是新进程,`cd` 不跨调用保留
|
||||
(写 `cd core && <命令>`);需要 `for` / `$(...)` / `[ ]` 测试等真实 bash 语义时
|
||||
一律 `bash -c "..."`。"""
|
||||
|
||||
_LINUX_SECTION = """### 1.1 平台 shell 与路径(Linux)
|
||||
|
||||
- 操作系统:Linux。路径为 POSIX 风格(`/home/user/...`、`/tmp/...`)。
|
||||
- bash 命令经 **/bin/bash -lc** 执行(登录 shell):`;` `&&` `|` `$(...)`、单引号、
|
||||
环境变量 `$VAR`、profile 里的别名与函数都可用。
|
||||
- 每次调用都是新进程:`cd` 不跨调用保留(写 `cd core && <命令>` 或直接用绝对路径)。"""
|
||||
|
||||
|
||||
def shell_prompt_section() -> str:
|
||||
"""当前平台的短 shell/path 段。"""
|
||||
return _WIN_SECTION if is_windows() else _LINUX_SECTION
|
||||
|
||||
|
||||
def apply_platform_section(text: str) -> str:
|
||||
"""把占位符替换为当前平台段;无占位符(如兜底提示词)时原样返回。"""
|
||||
if SHELL_SECTION_PLACEHOLDER in text:
|
||||
return text.replace(SHELL_SECTION_PLACEHOLDER, shell_prompt_section())
|
||||
return text
|
||||
Reference in New Issue
Block a user