mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
fix(acp): resolve a bare agent command through PATH before spawning (#6598)
* fix(acp): resolve a bare agent command through PATH before spawning `invoke_acp_agent` handed `acp_agents.<name>.command` straight to the ACP SDK, which spawns it with `asyncio.create_subprocess_exec`. That API applies no `PATHEXT` lookup on Windows, so a bare npm launcher (`npx`, `mcode`) raised `FileNotFoundError` although its `.cmd` shim was on `PATH`. The documented `command: npx` and `command: mcode` shapes could not start, and the tool reported "was not found on PATH" for an installed launcher. Resolve the configured command with `shutil.which` before spawning — the way the MCP Python SDK normalizes stdio server commands — and keep the configured value when nothing matches, so the existing install guidance still fires. Fixes #6597 * fix(acp): resolve the ACP agent command off the event loop `_resolve_agent_command` calls `shutil.which`, which stats the filesystem. Calling it inline in the async `_invoke` tripped the strict BlockBuster gate (`BlockingError: Blocking call to os.access`) for every command that resolves, and would block the Gateway event loop in production. Offload the lookup with `asyncio.to_thread`, matching the neighbouring `_get_work_dir`, `_build_acp_mcp_servers`, and `_format_invocation_error` calls. The blocking-io anchor configured `command="test-agent"`, a name that resolves nowhere, so `shutil.which` short-circuited before `os.access` and the gate never covered the new call. Point it at `sys.executable` so the lookup is really exercised, and cover the unresolvable-guidance branch end-to-end with a new test. Refs #6597 * fix(acp): keep the configured command in ACP remediation text Review follow-ups on the PATH resolution: - Only bare names are resolved. A configured path is interpreted by the spawn against the per-thread ACP workspace, so resolving it against the Gateway's working directory could pick a different file or hand the spawn a path that then fails. - `_format_invocation_error` receives `agent_config.command` again. A command can resolve on PATH and still fail to launch (a broken shebang, for example), and reporting the resolved path made "was not found on PATH" false and dropped the `codex-acp` hint. - The blocking-io anchor uses a bare name backed by a shim on a controlled PATH instead of `sys.executable`, which the bare-name scoping no longer resolves. Tests: the pass-through assertion in `test_invoke_acp_agent_uses_fixed_acp_workspace` pins PATH, and new coverage covers the bare-name scoping and the spawn-failure message. Refs #6597 * docs(acp): correct the path-resolution rationale A configured path is resolved by the spawn in its own process, so the pass-through reason must not claim it is resolved against the ACP workspace: that only holds on POSIX, while Windows resolves a relative executable against the calling process's directory. State the platform-neutral reason instead. Refs #6597 * fix(acp): resolve the launcher against the agent's own PATH The ACP SDK merges acp_agents.<name>.env over its inherited environment at spawn time, so a configured PATH, not the Gateway's, decides which launcher the agent would find. Resolve a bare command against that effective PATH, and use the Gateway's only when the agent does not override it. Return a successful lookup as an absolute path: a relative PATH entry makes shutil.which yield a result relative to the Gateway's cwd, while the spawn runs with cwd set to the ACP workspace, where that path resolves elsewhere or not at all. Regressions: a real-subprocess test with same-named launchers in the Gateway and agent PATHs (asserting which one ran), and a real-subprocess test whose PATH holds a relative directory while the ACP workspace is elsewhere. Both are red on the pre-fix code.
This commit is contained in:
parent
a333ac3189
commit
f623e64e07
@ -367,6 +367,7 @@ streams simulate chunks from a non-streaming response, while no-tool streams sta
|
||||
- `CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR` accepts a UTF-8 token handoff and reuses it for later model instances in the same process. Undecodable handoffs are skipped so Claude Code can still try its override or default credentials file.
|
||||
- CLI credential JSON files accept UTF-8 with or without a BOM, independently of the host locale. `make doctor` accepts the same files when checking CLI authentication. Invalid text encoding is treated as an unreadable source; Claude Code can still try its default file after an invalid override.
|
||||
- ACP agent entries are separate from model providers — if you configure `acp_agents.codex`, point it at a Codex ACP adapter such as `npx -y @zed-industries/codex-acp`
|
||||
- A bare ACP agent `command` is resolved before the agent starts, so npm-installed launchers work on Windows too: `npx` or `mcode` is spawned as its `npx.cmd`/`mcode.cmd` shim instead of failing with a command-not-found error. The lookup uses the `PATH` the agent subprocess will actually see (`acp_agents.<name>.env.PATH` when set, otherwise the Gateway's) and hands the spawn an absolute path, so a relative `PATH` entry cannot be re-interpreted inside the agent's own workspace. A `command` containing a path separator is used as configured, and a bare name that cannot be resolved is still reported with the install guidance below.
|
||||
- Each ACP agent's `timeout_seconds` (default: 1800) is one shared budget for initialization, session creation, and the prompt, starting after the subprocess launches. On timeout, DeerFlow aborts the invocation and closes the subprocess before returning an error. Workspace/MCP preparation and subprocess cleanup are outside this budget. A `TimeoutError` raised by the SDK before this deadline expires retains its own error message.
|
||||
- MiniMax Code speaks ACP directly. Install and authenticate it, then add it as an ACP agent:
|
||||
|
||||
|
||||
@ -93,6 +93,7 @@ E2B output sync records remote file versions and actual host file metadata in a
|
||||
- `timeout_seconds` is one deadline shared by initialization, session creation, and prompt after process launch. Keep the SDK process context outside the timeout so teardown finishes before the tool returns; workspace/MCP preparation and teardown are outside the protocol budget. Real-process regressions in `tests/test_invoke_acp_agent_tool.py` cover every hung phase, the shared budget, success, and caller cancellation. Only report budget expiry when the timeout context's `expired()` is true; otherwise propagate SDK `TimeoutError` to `_format_invocation_error`.
|
||||
- ACP results collect only `agent_message_chunk` text. Thought chunks remain internal and must not be concatenated into the tool result
|
||||
- Missing ACP executables now return an actionable error message instead of a raw `[Errno 2]`
|
||||
- A bare configured `command` is resolved before the spawn (`_resolve_agent_command`), off the event loop because the lookup stats the filesystem. The ACP SDK's stdio transport calls `asyncio.create_subprocess_exec`, which performs no `PATHEXT` lookup on Windows, so a bare npm launcher (`npx`, `mcode`) would raise `FileNotFoundError` although its `.cmd` shim is on `PATH`. Resolution consults the `PATH` the agent subprocess will actually see (`acp_agents.<name>.env.PATH` when the agent config sets one, the Gateway's otherwise) and returns an absolute path, so a relative `PATH` entry cannot be re-interpreted inside the ACP workspace. A `command` containing a path separator goes to the spawn unchanged, so the spawn — not `shutil.which` — decides what it refers to, and `_format_invocation_error` always receives the configured value so its remediation still matches `config.yaml`
|
||||
- Each ACP agent uses a per-thread workspace at `{base_dir}/users/{user_id}/threads/{thread_id}/acp-workspace/`. The workspace is accessible to the lead agent via the virtual path `/mnt/acp-workspace/` (read-only). In docker sandbox mode, the directory is volume-mounted into the container at `/mnt/acp-workspace` (read-only); in local sandbox mode, path translation is handled by `tools.py`
|
||||
|
||||
Ordinary `task` results forward bounded `artifact.knowledge_sources` records
|
||||
|
||||
@ -125,6 +125,56 @@ def _build_permission_response(options: list[Any], *, auto_approve: bool) -> Any
|
||||
return RequestPermissionResponse(outcome=DeniedOutcome(outcome="cancelled"))
|
||||
|
||||
|
||||
def _agent_path(env: dict[str, str] | None) -> str | None:
|
||||
"""Return the ``PATH`` the ACP SDK will give the agent subprocess.
|
||||
|
||||
The SDK builds the child environment by inheriting a trimmed copy of the
|
||||
Gateway's environment and then applying ``acp_agents.<name>.env`` on top, so
|
||||
a configured ``PATH`` -- not the Gateway's -- decides which launcher the
|
||||
agent actually finds. ``None`` means the agent does not override ``PATH``,
|
||||
which lets ``shutil.which`` fall back to the Gateway's own.
|
||||
|
||||
The lookup is case-insensitive because environment variable names are on
|
||||
Windows, where ``Path`` and ``PATH`` name the same variable.
|
||||
"""
|
||||
if not env:
|
||||
return None
|
||||
return next((value for key, value in env.items() if key.upper() == "PATH"), None)
|
||||
|
||||
|
||||
def _resolve_agent_command(command: str, path: str | None = None) -> str:
|
||||
"""Return the host path to spawn for a configured ACP agent command.
|
||||
|
||||
``asyncio.create_subprocess_exec``, which the ACP SDK's stdio transport
|
||||
uses, does not apply ``PATHEXT`` on Windows: a bare ``npx`` or ``mcode``
|
||||
raises ``FileNotFoundError`` even though the npm shim (``npx.cmd``) is on
|
||||
``PATH``. Resolve the name the way the MCP Python SDK normalizes stdio
|
||||
commands, and keep the configured value when nothing matches so the
|
||||
not-found remediation still fires.
|
||||
|
||||
Only bare names are resolved. A configured path is handed to the spawn
|
||||
unchanged, so the spawn -- not ``shutil.which`` -- decides what it refers
|
||||
to.
|
||||
|
||||
``path`` is the ``PATH`` the agent subprocess will actually see (see
|
||||
``_agent_path``); the Gateway's own is only the fallback. A successful
|
||||
lookup is returned absolute: for a relative ``PATH`` entry ``shutil.which``
|
||||
yields a result relative to the Gateway's cwd, while the spawn runs with
|
||||
``cwd`` set to the ACP workspace, where that path resolves elsewhere -- or
|
||||
not at all.
|
||||
|
||||
The lookup stats the filesystem (``shutil.which`` -> ``os.access``), so
|
||||
callers must run it off the event loop.
|
||||
"""
|
||||
if os.path.dirname(command):
|
||||
return command
|
||||
try:
|
||||
resolved = shutil.which(command, path=path)
|
||||
except OSError:
|
||||
return command
|
||||
return os.path.abspath(resolved) if resolved else command
|
||||
|
||||
|
||||
def _format_invocation_error(agent: str, cmd: str, exc: Exception) -> str:
|
||||
"""Return a user-facing ACP invocation error with actionable remediation."""
|
||||
if not isinstance(exc, FileNotFoundError):
|
||||
@ -213,7 +263,12 @@ def build_invoke_acp_agent_tool(agents: dict) -> BaseTool:
|
||||
return response
|
||||
|
||||
client = _CollectingClient()
|
||||
cmd = agent_config.command
|
||||
agent_env: dict[str, str] | None = None
|
||||
if agent_config.env:
|
||||
agent_env = {k: (os.environ.get(v[1:], "") if v.startswith("$") else v) for k, v in agent_config.env.items()}
|
||||
# Resolve against the PATH the agent subprocess will see, not the Gateway's:
|
||||
# the SDK merges agent_env over its inherited environment at spawn time.
|
||||
cmd = await asyncio.to_thread(_resolve_agent_command, agent_config.command, _agent_path(agent_env))
|
||||
args = agent_config.args or []
|
||||
physical_cwd = await asyncio.to_thread(_get_work_dir, thread_id)
|
||||
try:
|
||||
@ -225,9 +280,6 @@ def build_invoke_acp_agent_tool(agents: dict) -> BaseTool:
|
||||
exc,
|
||||
)
|
||||
mcp_servers = []
|
||||
agent_env: dict[str, str] | None = None
|
||||
if agent_config.env:
|
||||
agent_env = {k: (os.environ.get(v[1:], "") if v.startswith("$") else v) for k, v in agent_config.env.items()}
|
||||
|
||||
try:
|
||||
from acp import spawn_agent_process
|
||||
@ -268,7 +320,9 @@ def build_invoke_acp_agent_tool(agents: dict) -> BaseTool:
|
||||
return result or "(no response)"
|
||||
except Exception as e:
|
||||
logger.error("ACP agent '%s' invocation failed: %s", agent, e)
|
||||
return await asyncio.to_thread(_format_invocation_error, agent, cmd, e)
|
||||
# Report the configured command, not the resolved path: remediation
|
||||
# text has to match what the operator wrote in config.yaml.
|
||||
return await asyncio.to_thread(_format_invocation_error, agent, agent_config.command, e)
|
||||
|
||||
return StructuredTool.from_function(
|
||||
name="invoke_acp_agent",
|
||||
|
||||
@ -1,9 +1,15 @@
|
||||
"""Regression test for ACP invocation setup on the event loop."""
|
||||
"""Regression test for ACP invocation setup on the event loop.
|
||||
|
||||
The configured command is a bare name backed by a launcher shim on a controlled
|
||||
``PATH``, so the gate really runs the ``shutil.which`` lookup (``os.access``)
|
||||
instead of short-circuiting on a name that resolves nowhere.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
@ -32,6 +38,14 @@ async def test_invoke_acp_agent_setup_does_not_block_event_loop(monkeypatch, tmp
|
||||
)
|
||||
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(config_path))
|
||||
|
||||
shim_dir = tmp_path / "bin"
|
||||
shim_dir.mkdir()
|
||||
shim = shim_dir / ("test-agent.cmd" if sys.platform == "win32" else "test-agent")
|
||||
shim.write_text("@echo off\n" if sys.platform == "win32" else "#!/bin/sh\n", encoding="utf-8")
|
||||
if sys.platform != "win32":
|
||||
shim.chmod(0o755)
|
||||
monkeypatch.setenv("PATH", str(shim_dir))
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
class _Connection:
|
||||
|
||||
@ -2,8 +2,10 @@
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
@ -11,11 +13,13 @@ import pytest
|
||||
from deerflow.config.acp_config import ACPAgentConfig
|
||||
from deerflow.config.extensions_config import ExtensionsConfig, McpServerConfig, reset_extensions_config, set_extensions_config
|
||||
from deerflow.tools.builtins.invoke_acp_agent_tool import (
|
||||
_agent_path,
|
||||
_build_acp_mcp_servers,
|
||||
_build_mcp_servers,
|
||||
_build_permission_response,
|
||||
_format_invocation_error,
|
||||
_get_work_dir,
|
||||
_resolve_agent_command,
|
||||
build_invoke_acp_agent_tool,
|
||||
)
|
||||
from deerflow.tools.tools import get_available_tools
|
||||
@ -190,6 +194,9 @@ async def test_invoke_acp_agent_uses_fixed_acp_workspace(monkeypatch, tmp_path):
|
||||
"""ACP agent uses {base_dir}/acp-workspace/ when no thread_id is available (no config)."""
|
||||
from deerflow.config import paths as paths_module
|
||||
|
||||
# An empty PATH directory keeps the configured command unresolvable, so the
|
||||
# spawn assertion below checks the configuration rather than the host PATH.
|
||||
monkeypatch.setenv("PATH", str(tmp_path / "empty-bin"))
|
||||
monkeypatch.setattr(paths_module, "get_paths", lambda: paths_module.Paths(base_dir=tmp_path))
|
||||
|
||||
monkeypatch.setattr(
|
||||
@ -992,3 +999,275 @@ async def test_invoke_acp_agent_preserves_sdk_timeout_errors(acp_subprocess_tool
|
||||
assert result == "Error invoking ACP agent 'test': SDK transport timed out"
|
||||
assert "timeout_seconds" not in result
|
||||
assert acp_subprocess_tool.captured["proc"].returncode is not None
|
||||
|
||||
|
||||
def _write_launcher_shim(directory: Path, name: str, command: str) -> Path:
|
||||
"""Write an executable launcher shim for ``name`` under ``directory``."""
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
if sys.platform == "win32":
|
||||
shim = directory / f"{name}.cmd"
|
||||
shim.write_text(f"@echo off\n{command}\n", encoding="utf-8")
|
||||
else:
|
||||
shim = directory / name
|
||||
shim.write_text(f"#!/bin/sh\nexec {command}\n", encoding="utf-8")
|
||||
shim.chmod(0o755)
|
||||
return shim
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_invoke_acp_agent_spawns_the_path_resolved_command(monkeypatch, tmp_path):
|
||||
"""A bare configured command reaches the ACP SDK as its PATH-resolved path.
|
||||
|
||||
Windows resolves npm-installed launchers through ``PATHEXT`` (``npx`` ->
|
||||
``npx.cmd``) and ``asyncio.create_subprocess_exec`` does not, so the
|
||||
resolved path must be handed to the SDK instead of the bare name.
|
||||
"""
|
||||
import acp as acp_module
|
||||
|
||||
from deerflow.config import paths as paths_module
|
||||
|
||||
monkeypatch.setattr(paths_module, "get_paths", lambda: paths_module.Paths(base_dir=tmp_path))
|
||||
monkeypatch.setattr(
|
||||
"deerflow.config.extensions_config.ExtensionsConfig.from_file",
|
||||
classmethod(lambda cls: ExtensionsConfig(mcp_servers={}, skills={})),
|
||||
)
|
||||
script_path = tmp_path / "test_acp_agent.py"
|
||||
script_path.write_text(_ACP_AGENT_SCRIPT, encoding="utf-8")
|
||||
phase_path = tmp_path / "phase.txt"
|
||||
|
||||
shim_name = "deerflow-acp-probe"
|
||||
shim = _write_launcher_shim(tmp_path / "bin", shim_name, f'"{sys.executable}" "{script_path}" "" 0 "{phase_path}"')
|
||||
monkeypatch.setenv("PATH", str(shim.parent))
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
real_spawn_agent_process = acp_module.spawn_agent_process
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def _spying_spawn_agent_process(client, cmd, *args, env=None, cwd=None):
|
||||
captured["cmd"] = cmd
|
||||
async with real_spawn_agent_process(client, cmd, *args, env=env, cwd=cwd) as (conn, proc):
|
||||
yield conn, proc
|
||||
|
||||
monkeypatch.setattr(acp_module, "spawn_agent_process", _spying_spawn_agent_process)
|
||||
|
||||
tool = build_invoke_acp_agent_tool({"probe": ACPAgentConfig(command=shim_name, description="Probe", timeout_seconds=30)})
|
||||
result = await asyncio.wait_for(tool.coroutine(agent="probe", prompt="do work"), timeout=20)
|
||||
|
||||
# Windows PATHEXT matching reports the shim with its extension case
|
||||
# normalized, so compare paths rather than raw strings.
|
||||
assert Path(captured["cmd"]) == shim
|
||||
assert result == "(no response)"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_invoke_acp_agent_reports_guidance_for_an_unresolvable_command(monkeypatch, tmp_path):
|
||||
"""A command PATH cannot resolve still reports the configured name.
|
||||
|
||||
``_resolve_agent_command`` falls back to the configured value when nothing
|
||||
matches, so the not-found guidance must name what the user configured.
|
||||
"""
|
||||
from deerflow.config import paths as paths_module
|
||||
|
||||
monkeypatch.setattr(paths_module, "get_paths", lambda: paths_module.Paths(base_dir=tmp_path))
|
||||
monkeypatch.setattr(
|
||||
"deerflow.config.extensions_config.ExtensionsConfig.from_file",
|
||||
classmethod(lambda cls: ExtensionsConfig(mcp_servers={}, skills={})),
|
||||
)
|
||||
|
||||
tool = build_invoke_acp_agent_tool({"probe": ACPAgentConfig(command="deerflow-missing-acp-agent", description="Probe", timeout_seconds=30)})
|
||||
result = await asyncio.wait_for(tool.coroutine(agent="probe", prompt="do work"), timeout=20)
|
||||
|
||||
assert "Command 'deerflow-missing-acp-agent' was not found on PATH" in result
|
||||
assert "acp_agents.probe.command" in result
|
||||
|
||||
|
||||
def test_agent_path_reads_the_configured_override_case_insensitively():
|
||||
"""The env the SDK will apply decides which PATH resolves the launcher."""
|
||||
assert _agent_path(None) is None
|
||||
assert _agent_path({}) is None
|
||||
assert _agent_path({"FOO": "bar"}) is None
|
||||
assert _agent_path({"PATH": "/agent-bin"}) == "/agent-bin"
|
||||
assert _agent_path({"Path": "/agent-bin"}) == "/agent-bin"
|
||||
|
||||
|
||||
def test_resolve_agent_command_only_resolves_bare_names(monkeypatch, tmp_path):
|
||||
"""A configured path is left to the spawn, which runs in the ACP workspace."""
|
||||
resolved_dir = tmp_path / "resolved"
|
||||
monkeypatch.setattr(
|
||||
"deerflow.tools.builtins.invoke_acp_agent_tool.shutil.which",
|
||||
lambda command, path=None: str(resolved_dir / command),
|
||||
)
|
||||
|
||||
assert _resolve_agent_command("npx") == str(resolved_dir / "npx")
|
||||
assert _resolve_agent_command("bin/agent") == "bin/agent"
|
||||
assert _resolve_agent_command("./bin/agent") == "./bin/agent"
|
||||
|
||||
|
||||
def test_resolve_agent_command_hands_the_effective_path_to_the_lookup(monkeypatch, tmp_path):
|
||||
"""The agent's own PATH, not the Gateway's, is what the lookup consults."""
|
||||
seen: list[str | None] = []
|
||||
|
||||
def _which(command, path=None):
|
||||
seen.append(path)
|
||||
return str(tmp_path / (path or "gateway-bin") / command)
|
||||
|
||||
monkeypatch.setattr("deerflow.tools.builtins.invoke_acp_agent_tool.shutil.which", _which)
|
||||
|
||||
assert _resolve_agent_command("npx", str(tmp_path / "agent-bin")) == str(tmp_path / "agent-bin" / "npx")
|
||||
assert _resolve_agent_command("npx") == str(tmp_path / "gateway-bin" / "npx")
|
||||
assert seen == [str(tmp_path / "agent-bin"), None]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_invoke_acp_agent_reports_the_configured_command_when_spawn_fails(monkeypatch, tmp_path):
|
||||
"""A command PATH resolves can still fail to launch.
|
||||
|
||||
The remediation text has to stay anchored to the configured command, so the
|
||||
``codex-acp`` hint below survives the resolution.
|
||||
"""
|
||||
import acp as acp_module
|
||||
|
||||
from deerflow.config import paths as paths_module
|
||||
|
||||
monkeypatch.setattr(paths_module, "get_paths", lambda: paths_module.Paths(base_dir=tmp_path))
|
||||
monkeypatch.setattr(
|
||||
"deerflow.config.extensions_config.ExtensionsConfig.from_file",
|
||||
classmethod(lambda cls: ExtensionsConfig(mcp_servers={}, skills={})),
|
||||
)
|
||||
shim_dir = tmp_path / "bin"
|
||||
_write_launcher_shim(shim_dir, "codex-acp", "echo")
|
||||
_write_launcher_shim(shim_dir, "codex", "echo")
|
||||
monkeypatch.setenv("PATH", str(shim_dir))
|
||||
|
||||
def _raise_missing(*args, **kwargs):
|
||||
raise FileNotFoundError(2, "No such file or directory")
|
||||
|
||||
monkeypatch.setattr(acp_module, "spawn_agent_process", _raise_missing)
|
||||
|
||||
tool = build_invoke_acp_agent_tool({"codex": ACPAgentConfig(command="codex-acp", description="Codex CLI", timeout_seconds=30)})
|
||||
result = await asyncio.wait_for(tool.coroutine(agent="codex", prompt="do work"), timeout=20)
|
||||
|
||||
assert "Command 'codex-acp' was not found on PATH" in result
|
||||
assert "does not speak ACP directly" in result
|
||||
|
||||
|
||||
def _write_marking_shim(directory: Path, name: str, marker: Path, command: str) -> Path:
|
||||
"""Write a launcher shim for ``name`` that records that this copy ran."""
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
body = f'\necho ran > "{marker}"\n{command}\n'
|
||||
if sys.platform == "win32":
|
||||
shim = directory / f"{name}.cmd"
|
||||
shim.write_text(f"@echo off{body}", encoding="utf-8")
|
||||
else:
|
||||
shim = directory / name
|
||||
shim.write_text(f"#!/bin/sh{body}", encoding="utf-8")
|
||||
shim.chmod(0o755)
|
||||
return shim
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_invoke_acp_agent_resolves_against_the_agent_configured_path(monkeypatch, tmp_path):
|
||||
"""The agent's ``env.PATH`` wins when both PATHs hold the same launcher name.
|
||||
|
||||
The ACP SDK merges ``acp_agents.<name>.env`` over the inherited environment
|
||||
at spawn time, so resolving the bare name against the Gateway's ``PATH``
|
||||
would launch a different same-named launcher than the agent was configured
|
||||
for.
|
||||
"""
|
||||
import acp as acp_module
|
||||
|
||||
from deerflow.config import paths as paths_module
|
||||
|
||||
monkeypatch.setattr(paths_module, "get_paths", lambda: paths_module.Paths(base_dir=tmp_path))
|
||||
monkeypatch.setattr(
|
||||
"deerflow.config.extensions_config.ExtensionsConfig.from_file",
|
||||
classmethod(lambda cls: ExtensionsConfig(mcp_servers={}, skills={})),
|
||||
)
|
||||
script_path = tmp_path / "test_acp_agent.py"
|
||||
script_path.write_text(_ACP_AGENT_SCRIPT, encoding="utf-8")
|
||||
phase_path = tmp_path / "phase.txt"
|
||||
shim_name = "deerflow-acp-probe"
|
||||
launch = f'"{sys.executable}" "{script_path}" "" 0 "{phase_path}"'
|
||||
|
||||
gateway_marker = tmp_path / "gateway-ran.txt"
|
||||
agent_marker = tmp_path / "agent-ran.txt"
|
||||
_write_marking_shim(tmp_path / "gateway-bin", shim_name, gateway_marker, launch)
|
||||
agent_shim = _write_marking_shim(tmp_path / "agent-bin", shim_name, agent_marker, launch)
|
||||
monkeypatch.setenv("PATH", str(tmp_path / "gateway-bin"))
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
real_spawn_agent_process = acp_module.spawn_agent_process
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def _spying_spawn_agent_process(client, cmd, *args, env=None, cwd=None):
|
||||
captured["cmd"] = cmd
|
||||
async with real_spawn_agent_process(client, cmd, *args, env=env, cwd=cwd) as (conn, proc):
|
||||
yield conn, proc
|
||||
|
||||
monkeypatch.setattr(acp_module, "spawn_agent_process", _spying_spawn_agent_process)
|
||||
|
||||
tool = build_invoke_acp_agent_tool(
|
||||
{
|
||||
"probe": ACPAgentConfig(
|
||||
command=shim_name,
|
||||
env={"PATH": str(tmp_path / "agent-bin")},
|
||||
description="Probe",
|
||||
timeout_seconds=30,
|
||||
)
|
||||
}
|
||||
)
|
||||
result = await asyncio.wait_for(tool.coroutine(agent="probe", prompt="do work"), timeout=20)
|
||||
|
||||
assert Path(captured["cmd"]) == agent_shim
|
||||
assert agent_marker.read_text(encoding="utf-8").strip() == "ran"
|
||||
assert not gateway_marker.exists()
|
||||
assert result == "(no response)"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_invoke_acp_agent_spawns_an_absolute_path_for_a_relative_path_entry(monkeypatch, tmp_path):
|
||||
"""A relative PATH entry must not be re-interpreted inside the ACP workspace.
|
||||
|
||||
``shutil.which`` yields a result relative to the Gateway's cwd when a
|
||||
relative directory is on ``PATH``, but the SDK spawns with ``cwd`` set to
|
||||
the per-thread ACP workspace, where that relative path does not exist.
|
||||
"""
|
||||
import acp as acp_module
|
||||
|
||||
from deerflow.config import paths as paths_module
|
||||
|
||||
monkeypatch.setattr(paths_module, "get_paths", lambda: paths_module.Paths(base_dir=tmp_path))
|
||||
monkeypatch.setattr(
|
||||
"deerflow.config.extensions_config.ExtensionsConfig.from_file",
|
||||
classmethod(lambda cls: ExtensionsConfig(mcp_servers={}, skills={})),
|
||||
)
|
||||
script_path = tmp_path / "test_acp_agent.py"
|
||||
script_path.write_text(_ACP_AGENT_SCRIPT, encoding="utf-8")
|
||||
phase_path = tmp_path / "phase.txt"
|
||||
|
||||
# Pin the process cwd so the relative PATH entry below points at a shim
|
||||
# beside the ACP workspace rather than inside it.
|
||||
monkeypatch.chdir(tmp_path)
|
||||
shim_name = "deerflow-acp-probe"
|
||||
shim = _write_launcher_shim(tmp_path / "bin", shim_name, f'"{sys.executable}" "{script_path}" "" 0 "{phase_path}"')
|
||||
monkeypatch.setenv("PATH", "bin")
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
real_spawn_agent_process = acp_module.spawn_agent_process
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def _spying_spawn_agent_process(client, cmd, *args, env=None, cwd=None):
|
||||
captured["cmd"] = cmd
|
||||
captured["cwd"] = cwd
|
||||
async with real_spawn_agent_process(client, cmd, *args, env=env, cwd=cwd) as (conn, proc):
|
||||
yield conn, proc
|
||||
|
||||
monkeypatch.setattr(acp_module, "spawn_agent_process", _spying_spawn_agent_process)
|
||||
|
||||
tool = build_invoke_acp_agent_tool({"probe": ACPAgentConfig(command=shim_name, description="Probe", timeout_seconds=30)})
|
||||
result = await asyncio.wait_for(tool.coroutine(agent="probe", prompt="do work"), timeout=20)
|
||||
|
||||
assert os.path.isabs(captured["cmd"])
|
||||
assert Path(captured["cmd"]) == shim
|
||||
assert Path(captured["cwd"]) != shim.parent
|
||||
assert result == "(no response)"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user