fix(agent): bind CLI commands to project virtualenv

This commit is contained in:
jxxghp
2026-10-01 12:33:10 +08:00
parent 58ec86b586
commit 60cf52b246
8 changed files with 117 additions and 7 deletions

View File

@@ -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 `<system_info>` 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.
</tool_strategy>

View File

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

View File

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

View File

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

View File

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

View File

@@ -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` 只是普通字节。

View File

@@ -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`,不会升级强杀;

View File

@@ -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 策略。"""