diff --git a/app/agent/prompt/System Core Prompt.txt b/app/agent/prompt/System Core Prompt.txt index 4824d2f0d..25b581d48 100644 --- a/app/agent/prompt/System Core Prompt.txt +++ b/app/agent/prompt/System Core Prompt.txt @@ -81,7 +81,7 @@ You act as a proactive agent. Your goal is to fully resolve the user's media-rel - When implementation depends on a Python or Node.js API, first identify the installed or locked dependency version from environment metadata, requirements, package manifests, lockfiles, local source, and type declarations. Use `rg` against the relevant package directory, `.venv`, or `node_modules` instead of scanning the entire project without bounds. If local evidence is insufficient, use `search_web` and then `browse_webpage` to read the matching version of the official documentation. Do not guess signatures from memory, mix examples from incompatible versions, or install a package only to inspect its API. - Use structured file tools for source edits because they enforce file access boundaries and conflict checks. Never use shell redirection, inline scripts, or another tool to bypass a file-tool permission denial. - On Windows, follow the command environment injected in `` for the entire task. If Git Bash is selected, use Git Bash/POSIX syntax and prefer `git` for repository work. Otherwise, when PowerShell 7 is selected, use `pwsh` syntax consistently. Never mix cmd.exe, Windows PowerShell, PowerShell 7, and POSIX syntax in one workflow, and treat command and file text as UTF-8. -- Use `execute_command` for administrator-only multi-file diagnostics, tests, Git, service operations, SSH, or an exact command the user requested. Use `action=run` for short bounded commands. Use `action=start` for long-running or interactive commands, including SSH; then continue with `read`, `wait`, `write`, or `kill` using the returned `session_id`. Do not start a background session for a short command that can finish within `action=run`. +- Use `execute_command` for administrator-only multi-file diagnostics, tests, Git, service operations, SSH, or an exact command the user requested. Use `action=run` for short bounded commands. Use `action=start` for long-running or interactive commands, including SSH; then continue with `read`, `wait`, `write`, or `kill` using the returned `session_id`. Do not start a background session for a short command that can finish within `action=run`. When the project `venv` exists, `python`, `python3`, and `pip` commands automatically use it; prefer `python -m ...`, and use the project `uv pip ...` or the bound environment's `python -m pip ...` for dependencies. Do not switch to a system interpreter or use `sudo pip`. - A terminal response confirms only the output it actually delivered. Resume with both `since_seq=output_until_seq` and `since_offset=output_until_offset`; never advance using `last_seq`. Set `since_offset=0` for partial-chunk paging. `wait` wakes for new output or completion; `timeout_ms=0` only polls and does not stop the process. Continue until the output cursor reaches the end and `output_complete=true` when a complete log is needed. If `output_lost=true`, disclose the missing output. If a start/write/kill response contains `output_error`, keep its `session_id` and recover with `read` using the suggested byte limit; do not repeat the action merely to retrieve output. diff --git a/app/agent/prompt/__init__.py b/app/agent/prompt/__init__.py index 61c56a819..11a2b9a28 100644 --- a/app/agent/prompt/__init__.py +++ b/app/agent/prompt/__init__.py @@ -265,6 +265,7 @@ class PromptManager: f"- 当前日期: {strftime('%Y-%m-%d')}", f"- 运行环境: {SystemUtils.platform} {'docker' if SystemUtils.is_docker() else ''}", "- 详细运行状态和数据库通过 `query_doctor_report` 或 `execute_command` 查询;配置值先加载对应 Skill,再通过 `moviepilot_api` 的配置 operation 查询。", + "- Python 命令: 项目 `venv` 存在时,Agent 会自动将其放在命令 PATH 最前并设置 `VIRTUAL_ENV`;请使用 `python -m ...`,安装依赖优先使用项目 `uv pip ...` 或绑定环境中的 `python -m pip ...`,不要切换到系统 Python 或使用 `sudo pip`。", ] try: shell = resolve_agent_shell(cwd=str(get_runtime_setting("ROOT_PATH"))) diff --git a/app/agent/shell.py b/app/agent/shell.py index 31b66cd70..0faadc0a0 100644 --- a/app/agent/shell.py +++ b/app/agent/shell.py @@ -274,6 +274,39 @@ def build_agent_subprocess_env( return environment +def bind_agent_project_environment( + environment: Mapping[str, str], + *, + project_root: Path, + platform_name: Optional[str] = None, +) -> dict[str, str]: + """ + 将已安装项目的虚拟环境绑定到 Agent 命令子进程。 + + :param environment: 已完成调用方覆盖和平台编码处理的环境变量 + :param project_root: MoviePilot 项目根目录 + :param platform_name: 用于测试覆盖的 os.name,默认读取当前平台 + :return: 优先使用项目虚拟环境的子进程环境变量 + """ + bound_environment = dict(environment) + windows = (platform_name or os.name) == "nt" + venv_path = Path(project_root).expanduser() / "venv" + bin_path = venv_path / ("Scripts" if windows else "bin") + python_names = ("python.exe", "python3.exe", "moviepilot-python.exe") if windows else ( + "python", "python3", "moviepilot-python" + ) + if not any((bin_path / name).is_file() for name in python_names): + return bound_environment + + path_separator = ";" if windows else os.pathsep + existing_path = bound_environment.get("PATH", "") + bound_environment["PATH"] = path_separator.join( + part for part in (str(bin_path), existing_path) if part + ) + bound_environment["VIRTUAL_ENV"] = str(venv_path) + return bound_environment + + def agent_text_subprocess_kwargs( *, platform_name: Optional[str] = None ) -> dict[str, Any]: diff --git a/app/agent/terminal/manager.py b/app/agent/terminal/manager.py index 5d99b2e9a..0fe365ac1 100644 --- a/app/agent/terminal/manager.py +++ b/app/agent/terminal/manager.py @@ -12,7 +12,13 @@ import uuid from types import ModuleType from typing import Any, Optional -from app.agent.shell import AgentShell, build_agent_subprocess_env, resolve_agent_cwd, resolve_agent_shell +from app.agent.shell import ( + AgentShell, + bind_agent_project_environment, + build_agent_subprocess_env, + resolve_agent_cwd, + resolve_agent_shell, +) from app.agent.terminal.output import ( TERMINAL_DEFAULT_READ_BYTES, TerminalOutputError, @@ -125,8 +131,12 @@ class _TerminalSessionManager: if since_offset is not None and (type(since_offset) is not int or since_offset != 0): raise TerminalOutputError("新会话的 since_offset 只能为 0 或 null") initial_wait = self._normalize_yield_timeout(yield_time_ms) - normalized_cwd = resolve_agent_cwd(cwd, root_path=get_runtime_setting('ROOT_PATH')) - normalized_env = build_agent_subprocess_env(env) + project_root = get_runtime_setting("ROOT_PATH") + normalized_cwd = resolve_agent_cwd(cwd, root_path=project_root) + normalized_env = bind_agent_project_environment( + build_agent_subprocess_env(env), + project_root=project_root, + ) shell_policy = resolve_agent_shell(executable=shell, login=login, environment=normalized_env, cwd=normalized_cwd) should_use_pty = self._normalize_bool(use_pty, default=True) and os.name == "posix" diff --git a/app/agent/tools/impl/execute_command.py b/app/agent/tools/impl/execute_command.py index 9e80e96ba..c249e1869 100644 --- a/app/agent/tools/impl/execute_command.py +++ b/app/agent/tools/impl/execute_command.py @@ -15,7 +15,12 @@ from typing import Any, Literal, Optional, TextIO, Type from pydantic import BaseModel, Field -from app.agent.shell import build_agent_subprocess_env, resolve_agent_cwd, resolve_agent_shell +from app.agent.shell import ( + bind_agent_project_environment, + build_agent_subprocess_env, + resolve_agent_cwd, + resolve_agent_shell, +) from app.agent.terminal.manager import ( TERMINAL_WAIT_DEFAULT_MS, TERMINAL_YIELD_DEFAULT_MS, @@ -594,8 +599,12 @@ class ExecuteCommandTool(MoviePilotTool): ) -> str: """在已登记作用域下运行一次命令,并对封口和进程收尾保持可观察。""" normalized_timeout, timeout_note = self._normalize_timeout(timeout) - normalized_cwd = resolve_agent_cwd(cwd, root_path=get_runtime_setting("ROOT_PATH")) - normalized_env = build_agent_subprocess_env(env) + project_root = get_runtime_setting("ROOT_PATH") + normalized_cwd = resolve_agent_cwd(cwd, root_path=project_root) + normalized_env = bind_agent_project_environment( + build_agent_subprocess_env(env), + project_root=project_root, + ) shell_policy = resolve_agent_shell(executable=shell, login=login, environment=normalized_env, cwd=normalized_cwd) await self._acquire_command_slot(scope) diff --git a/docs/agent.md b/docs/agent.md index 54341bdaa..d7fb8596b 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -65,6 +65,8 @@ Agent 人格是全局运行时配置,不按用户隔离;所有用户共享 `execute_command(action="run")` 返回结构化 JSON:`exit_code`、`timed_out`、`execution_outcome` 和 `status` 表示实际执行结果,`output` 保存输出预览,`output_file` 指向超长输出的临时归档。仅正常退出且退出码为 0 时成功;非零退出或已停止的超时命令为失败,无法确认进程结束时为未知,不能凭“有输出”判断成功。超时和取消不会撤销命令已经产生的外部副作用。取消继续向外传播,同时回收输出读取任务、关闭归档文件;`run` 与 `start` 都支持 `env`,并采用同一工作目录与解释器策略。 +项目安装目录存在 `venv` 时,`execute_command` 的一次性命令、pipe 和 PTY 会话都会把该环境的 `bin`(Windows 为 `Scripts`)放在子进程 `PATH` 最前,并设置 `VIRTUAL_ENV`。因此 `python`、`python3` 和 `pip` 会使用项目环境;执行 Python 优先使用 `python -m ...`,安装依赖优先使用项目 `uv pip ...` 或绑定环境中的 `python -m pip ...`,不要改用系统解释器或 `sudo pip`。 + `run`、后台 pipe 和 PTY 共用 `cwd`、`shell`、`login` 解析。省略 `cwd` 使用 MoviePilot 根目录,相对路径也相对该目录,并支持 `~`;错误目录在进程启动前拒绝。POSIX 默认使用配置的 `SHELL`(未配置时 `/bin/sh`)且不启动登录模式,显式 `shell` 可选择已安装解释器,`login=true/false` 控制其支持的登录行为。登录启动文件可能改变目录或环境;PTY 本身不再隐式开启登录模式。Windows 未显式指定时保留 Git Bash → PowerShell 7 → cmd 的选择顺序与 UTF-8 策略;不支持的解释器/登录组合明确失败。回包的 `shell`、`login` 说明实际策略,默认解释器不可用不会阻断其它 Agent 业务能力。 管道会话可用 `write(input_text="末段输入", close_stdin=true)` 在交付末段后关闭输入,或用空输入显式关闭;输出仍通过 `read/wait` 读取,`stdin_closed` 表示输入已关闭。普通空 `write` 不发送 EOF;输入关闭后不能再追加数据,重复空关闭可确认已有状态。`run` 的 stdin 始终为 EOF。PTY 的输入和输出共用端点,因此拒绝 `close_stdin=true`,也不会先写入附带输入或关闭输出;PTY 控制字节的行为取决于终端模式,pipe 中 `\u0003`、`\u0004` 只是普通字节。 diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 9aad3fc04..7edb1a5b5 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -866,6 +866,10 @@ MCP、HTTP 工具管理接口、本地 CLI 和内置 Agent 都从同一严格目 `run`、pipe 和 PTY 共享 `cwd/shell/login`:默认及相对 `cwd` 使用 MoviePilot 根目录, POSIX 默认非登录;Windows 未指定时保留已有解释器/UTF-8 策略。回包包含实际 `shell/login`。 +项目安装目录存在 `venv` 时,三种模式都会把该环境的 `bin`(Windows 为 `Scripts`)放在 +子进程 `PATH` 最前并设置 `VIRTUAL_ENV`,所以 `python`、`python3` 和 `pip` 使用项目环境; +执行 Python 应优先使用 `python -m ...`,安装依赖优先使用项目 `uv pip ...` 或绑定环境中的 +`python -m pip ...`,不要改用系统解释器或 `sudo pip`。 `write(close_stdin=true)` 仅在 pipe 模式支持末段输入后 EOF,回包包含 `stdin_closed`;输出可继续读取。 PTY 会在写入之前拒绝 half-close,空 `write` 不代表 EOF,控制字节在 pipe 中也不等于信号。 新增 `interrupt` 只发送一次平台支持的中断并返回 `signal/signal_sent`,不会升级强杀; diff --git a/tests/test_agent_shell.py b/tests/test_agent_shell.py index d47432bcb..aa3a35cb0 100644 --- a/tests/test_agent_shell.py +++ b/tests/test_agent_shell.py @@ -18,6 +18,7 @@ from app.agent.prompt import PromptManager from app.agent.shell import ( AgentShell, agent_text_subprocess_kwargs, + bind_agent_project_environment, build_agent_subprocess_env, resolve_agent_cwd, resolve_agent_shell, @@ -172,6 +173,29 @@ def test_windows_subprocess_environment_forces_utf8_after_overrides() -> None: assert "PYTHONLEGACYWINDOWSSTDIO" not in environment +def test_project_python_environment_precedes_inherited_path(tmp_path: Path) -> None: + """项目 venv 存在时应优先于宿主 PATH,并声明虚拟环境。""" + bin_path = tmp_path / "venv" / ("Scripts" if os.name == "nt" else "bin") + bin_path.mkdir(parents=True) + python_path = bin_path / ("python.exe" if os.name == "nt" else "python") + python_path.write_text("#!/bin/sh\n", encoding="utf-8") + python_path.chmod(0o755) + + environment = bind_agent_project_environment( + {"PATH": "/system/bin", "VIRTUAL_ENV": "/old/venv"}, + project_root=tmp_path, + ) + + assert environment["PATH"].split(os.pathsep)[0] == str(bin_path) + assert environment["VIRTUAL_ENV"] == str(tmp_path / "venv") + + +def test_project_python_environment_keeps_environment_when_venv_missing(tmp_path: Path) -> None: + """项目尚未完成安装时不应伪造 PATH 或 VIRTUAL_ENV。""" + original = {"PATH": "/system/bin"} + assert bind_agent_project_environment(original, project_root=tmp_path) == original + + def test_prompt_injects_selected_windows_shell_without_executable_path() -> None: """模型提示词应知道实际 Windows shell,但不能暴露安装绝对路径。""" shell = AgentShell( @@ -228,6 +252,33 @@ async def test_execute_command_uses_selected_windows_shell_and_utf8_environment( create_shell.assert_not_called() +@pytest.mark.anyio +async def test_execute_command_binds_project_python_environment(tmp_path: Path, monkeypatch) -> None: + """一次性命令应把项目 venv 环境传给实际 shell。""" + bin_path = tmp_path / "venv" / ("Scripts" if os.name == "nt" else "bin") + bin_path.mkdir(parents=True) + python_path = bin_path / ("python.exe" if os.name == "nt" else "python") + python_path.write_text("#!/bin/sh\n", encoding="utf-8") + python_path.chmod(0o755) + monkeypatch.setattr( + "app.agent.tools.impl.execute_command.get_runtime_setting", + lambda key: tmp_path if key == "ROOT_PATH" else None, + ) + shell = AgentShell(kind="sh", executable="/bin/sh", arguments=("-c",)) + create_exec = AsyncMock(return_value=_fake_process()) + tool = ExecuteCommandTool(session_id="session", user_id="user") + + with ( + patch("app.agent.tools.impl.execute_command.resolve_agent_shell", return_value=shell), + patch("app.agent.tools.impl.execute_command.asyncio.create_subprocess_exec", create_exec), + ): + await tool.run(action="run", command="python -c 'print(1)'", timeout=1) + + environment = create_exec.await_args.kwargs["env"] + assert environment["PATH"].split(os.pathsep)[0] == str(bin_path) + assert environment["VIRTUAL_ENV"] == str(tmp_path / "venv") + + @pytest.mark.anyio async def test_terminal_session_uses_selected_windows_shell() -> None: """后台会话与一次性命令必须共享同一 Windows shell 策略。"""