mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-21 17:25:26 -04:00
fix(code): defer server graph construction (#4300)
Import-only checks were constructing the server graph, which let runtime startup behavior leak into local validation and touch a developer’s real dcode config. The server graph is now exposed as a LangGraph factory so MCP discovery runs only when the server actually builds the graph, and the import checker runs against an isolated home directory. ## Changes - Switch the generated LangGraph reference from `server_graph.py:graph` to `server_graph.py:make_graph`, relying on LangGraph’s factory support instead of constructing the graph at module import time. - Keep startup error marker behavior inside `make_graph()` so server startup failures still surface cleanly to the parent process, while plain imports remain side-effect free. - Run `check_imports.py` with a temporary `HOME` so import validation cannot read or depend on local `~/.deepagents` config, MCP auth tokens, or other user state. - Update server graph tests to assert MCP discovery does not happen on import and still happens when `make_graph()` is invoked. - Override `UV_FROZEN` only for the `uv lock --check` command so `make check` performs the real lockfile freshness check without warning. ## Testing - `make -C libs/code check PYTHON_FILES= PYTEST_EXTRA="tests/unit_tests/test_server_graph.py tests/unit_tests/test_server_manager.py -q" COV_ARGS=` - `make -C libs/code check_imports` - Focused ruff, ty, and pytest checks for the touched server graph/server manager files
This commit is contained in:
+1
-1
@@ -105,7 +105,7 @@ check: lint check_imports test
|
||||
@echo "🔍 Verifying pyproject.toml and _version.py agree..."
|
||||
uv run --all-groups python "$(REPO_ROOT)/.github/scripts/check_version_equality.py"
|
||||
@echo "🔒 Verifying uv.lock is up-to-date..."
|
||||
uv lock --check
|
||||
UV_FROZEN=false uv lock --check
|
||||
@echo "🔗 Checking SDK pin against workspace SDK (advisory)..."
|
||||
@uv run --all-groups python "$(REPO_ROOT)/.github/scripts/check_sdk_pin.py" || \
|
||||
{ rc=$$?; [ $$rc -eq 1 ] && echo "⚠️ Stale SDK pin (advisory, non-fatal)." || exit $$rc; }
|
||||
|
||||
@@ -18,6 +18,7 @@ import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Self
|
||||
from urllib.parse import quote
|
||||
|
||||
from deepagents_code.config import _INHERITED_PYTHONPATH_ENV
|
||||
|
||||
@@ -136,7 +137,7 @@ def _extract_startup_error_marker(output: str) -> str | None:
|
||||
def generate_langgraph_json(
|
||||
output_dir: str | Path,
|
||||
*,
|
||||
graph_ref: str = "./server_graph.py:graph",
|
||||
graph_ref: str = "./server_graph.py:make_graph",
|
||||
env_file: str | None = None,
|
||||
checkpointer_path: str | None = None,
|
||||
) -> Path:
|
||||
@@ -144,7 +145,8 @@ def generate_langgraph_json(
|
||||
|
||||
Args:
|
||||
output_dir: Directory to write the config file.
|
||||
graph_ref: Python module:variable reference to the graph.
|
||||
graph_ref: Python "module:attribute" reference to the graph, where the
|
||||
attribute is a graph factory (e.g. `make_graph`) or a graph object.
|
||||
env_file: Optional path to an env file.
|
||||
checkpointer_path: Import path to an async context manager that yields a
|
||||
`BaseCheckpointSaver`. When set, the server persists checkpoint data
|
||||
@@ -517,6 +519,84 @@ class ServerProcess:
|
||||
self.stop()
|
||||
raise
|
||||
|
||||
async def wait_for_graph_ready(
|
||||
self,
|
||||
graph_name: str = "agent",
|
||||
*,
|
||||
timeout: float = _HEALTH_TIMEOUT, # noqa: ASYNC109
|
||||
) -> None:
|
||||
"""Resolve the served graph once so lazy startup failures surface early.
|
||||
|
||||
Args:
|
||||
graph_name: Registered graph name from `langgraph.json`.
|
||||
timeout: Max seconds to wait for the graph readiness request.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the server process exits or the graph endpoint
|
||||
does not return a successful response.
|
||||
"""
|
||||
import httpx
|
||||
|
||||
if self._process is None:
|
||||
msg = "Server process is not running"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
graph_url = f"{self.url}/assistants/{quote(graph_name, safe='')}/graph"
|
||||
deadline = time.monotonic() + timeout
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
while time.monotonic() < deadline:
|
||||
if self._process.poll() is not None:
|
||||
msg = f"Server process exited with code {self._process.returncode}"
|
||||
output = self._read_log_file()
|
||||
if output:
|
||||
summary = _extract_startup_error_marker(output)
|
||||
if summary:
|
||||
msg += f": {summary}"
|
||||
msg += f"\n{output[-_LOG_TAIL_CHARS:]}"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
remaining = max(0.1, deadline - time.monotonic())
|
||||
try:
|
||||
resp = await client.get(graph_url, timeout=remaining)
|
||||
except (httpx.TransportError, httpx.TimeoutException, OSError) as exc:
|
||||
output = self._read_log_file()
|
||||
summary = _extract_startup_error_marker(output)
|
||||
if self._process.poll() is not None:
|
||||
msg = (
|
||||
f"Server process exited with code "
|
||||
f"{self._process.returncode}"
|
||||
)
|
||||
else:
|
||||
msg = (
|
||||
f"Server graph '{graph_name}' did not initialize within "
|
||||
f"{timeout}s"
|
||||
)
|
||||
if summary:
|
||||
msg += f": {summary}"
|
||||
if output:
|
||||
msg += f"\n{output[-_LOG_TAIL_CHARS:]}"
|
||||
raise RuntimeError(msg) from exc
|
||||
|
||||
if resp.status_code == 200: # noqa: PLR2004
|
||||
logger.info("Server graph %s is ready at %s", graph_name, self.url)
|
||||
return
|
||||
|
||||
output = self._read_log_file()
|
||||
msg = (
|
||||
f"Server graph '{graph_name}' failed readiness check "
|
||||
f"(status: {resp.status_code})"
|
||||
)
|
||||
summary = _extract_startup_error_marker(output)
|
||||
if summary:
|
||||
msg += f": {summary}"
|
||||
if output:
|
||||
msg += f"\n{output[-_LOG_TAIL_CHARS:]}"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
msg = f"Server graph '{graph_name}' did not initialize within {timeout}s"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
def _stop_process(self) -> None:
|
||||
"""Stop only the server subprocess and its log file.
|
||||
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
"""Server-side graph entry point for `langgraph dev`.
|
||||
|
||||
This module is referenced by the generated `langgraph.json` and exposes the
|
||||
agent graph as a module-level variable that the LangGraph server can load
|
||||
and serve.
|
||||
This module is referenced by the generated `langgraph.json` and exposes a graph
|
||||
factory that the LangGraph server can load and serve.
|
||||
|
||||
The graph is created at module import time via `make_graph()`, which reads
|
||||
configuration from `ServerConfig.from_env()` — the same dataclass the CLI uses
|
||||
to *write* the configuration via `ServerConfig.to_env()`. This shared schema
|
||||
ensures the two sides stay in sync.
|
||||
The graph is created by `make_graph()`, which reads configuration from
|
||||
`ServerConfig.from_env()` — the same dataclass the CLI uses to *write* the
|
||||
configuration via `ServerConfig.to_env()`. This shared schema ensures the two
|
||||
sides stay in sync.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import atexit
|
||||
import logging
|
||||
import sys
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from deepagents_code._server_config import ServerConfig
|
||||
from deepagents_code._startup_error import (
|
||||
@@ -24,6 +24,9 @@ from deepagents_code._startup_error import (
|
||||
)
|
||||
from deepagents_code.project_utils import ProjectContext, get_server_project_context
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_sandbox_cm: Any = None
|
||||
@@ -63,7 +66,7 @@ def _get_mcp_session_manager() -> Any: # noqa: ANN401
|
||||
return _mcp_session_manager
|
||||
|
||||
|
||||
def _build_tools(
|
||||
async def _build_tools(
|
||||
config: ServerConfig,
|
||||
project_context: ProjectContext | None,
|
||||
) -> tuple[list[Any], list[Any] | None]:
|
||||
@@ -72,11 +75,12 @@ def _build_tools(
|
||||
Loads built-in tools (conditionally including web search when Tavily is
|
||||
available) and MCP tools when enabled.
|
||||
|
||||
MCP discovery runs synchronously via `asyncio.run` because this function is
|
||||
called during module-level graph construction (before the server's async
|
||||
event loop is available). `stateless=True` ensures discovery only uses
|
||||
throwaway sessions, while the shared runtime session manager binds real
|
||||
sessions lazily inside the server loop on first tool invocation.
|
||||
MCP discovery is awaited on the server's event loop: LangGraph invokes this
|
||||
async factory on its running loop, so discovery must use `await` rather than
|
||||
`asyncio.run` (which raises inside a running loop). `stateless=True` ensures
|
||||
discovery only uses throwaway sessions, while the shared runtime session
|
||||
manager binds real sessions lazily inside the server loop on first tool
|
||||
invocation.
|
||||
|
||||
Args:
|
||||
config: Deserialized server configuration.
|
||||
@@ -98,20 +102,16 @@ def _build_tools(
|
||||
|
||||
mcp_server_info: list[Any] | None = None
|
||||
if not config.no_mcp:
|
||||
import asyncio
|
||||
|
||||
from deepagents_code.mcp_tools import resolve_and_load_mcp_tools
|
||||
|
||||
try:
|
||||
mcp_tools, _, mcp_server_info = asyncio.run(
|
||||
resolve_and_load_mcp_tools(
|
||||
explicit_config_path=config.mcp_config_path,
|
||||
no_mcp=config.no_mcp,
|
||||
trust_project_mcp=config.trust_project_mcp,
|
||||
project_context=project_context,
|
||||
stateless=True,
|
||||
session_manager=_get_mcp_session_manager(),
|
||||
)
|
||||
mcp_tools, _, mcp_server_info = await resolve_and_load_mcp_tools(
|
||||
explicit_config_path=config.mcp_config_path,
|
||||
no_mcp=config.no_mcp,
|
||||
trust_project_mcp=config.trust_project_mcp,
|
||||
project_context=project_context,
|
||||
stateless=True,
|
||||
session_manager=_get_mcp_session_manager(),
|
||||
)
|
||||
except FileNotFoundError:
|
||||
logger.exception("MCP config file not found: %s", config.mcp_config_path)
|
||||
@@ -129,7 +129,7 @@ def _build_tools(
|
||||
return tools, mcp_server_info
|
||||
|
||||
|
||||
def make_graph() -> Any: # noqa: ANN401
|
||||
async def _make_graph() -> Any: # noqa: ANN401
|
||||
"""Create the agent graph from environment-based configuration.
|
||||
|
||||
Reads `DEEPAGENTS_CODE_SERVER_*` env vars via `ServerConfig.from_env()`
|
||||
@@ -151,11 +151,14 @@ def make_graph() -> Any: # noqa: ANN401
|
||||
result = create_model(config.model, extra_kwargs=config.model_params)
|
||||
result.apply_to_settings()
|
||||
|
||||
tools, mcp_server_info = _build_tools(config, project_context)
|
||||
tools, mcp_server_info = await _build_tools(config, project_context)
|
||||
|
||||
# Create sandbox backend if a sandbox provider is configured.
|
||||
# The context manager is held open at module level and cleaned up via
|
||||
# atexit so the sandbox lives for the entire server process lifetime.
|
||||
# The context manager is created here in the factory, but its reference is
|
||||
# stored in a module-level global (and cleaned up via atexit) so the sandbox
|
||||
# lives for the entire server process lifetime. `make_graph` caches the built
|
||||
# graph, so this runs once per process despite LangGraph's per-run factory
|
||||
# invocation.
|
||||
global _sandbox_cm, _sandbox_backend # noqa: PLW0603
|
||||
sandbox_backend = None
|
||||
if config.sandbox_type:
|
||||
@@ -236,8 +239,50 @@ def make_graph() -> Any: # noqa: ANN401
|
||||
return agent
|
||||
|
||||
|
||||
try:
|
||||
graph = make_graph()
|
||||
except Exception as exc: # noqa: BLE001 # top-level barrier: any failure must surface to parent
|
||||
emit_startup_failure(exc)
|
||||
sys.exit(1)
|
||||
def _build_graph_factory() -> Callable[[], Awaitable[Any]]:
|
||||
"""Build the cached async graph factory exposed to `langgraph dev`.
|
||||
|
||||
The returned coroutine function is what `langgraph.json` references. It keeps
|
||||
its cache and lock in this closure rather than in module-level globals, so
|
||||
importing the module (e.g. for import-only checks) introduces no shared
|
||||
mutable state.
|
||||
|
||||
Returns:
|
||||
A zero-arg async factory that builds the graph once and returns the
|
||||
cached instance on every subsequent call.
|
||||
"""
|
||||
missing = object()
|
||||
graph: Any = missing
|
||||
lock = asyncio.Lock()
|
||||
|
||||
async def make_graph() -> Any: # noqa: ANN401
|
||||
"""Create (or return the cached) agent graph for `langgraph dev`.
|
||||
|
||||
LangGraph loads this async factory from the generated `langgraph.json`
|
||||
and invokes it lazily on its event loop — and again on every run. The
|
||||
built graph is cached for the process lifetime so MCP discovery, sandbox
|
||||
creation, and `atexit` registration each happen exactly once; re-running
|
||||
them per request would re-discover MCP servers, leak sandbox sessions,
|
||||
and stack duplicate `atexit` handlers. Any construction failure is
|
||||
converted into a startup-error marker (scraped by the parent app
|
||||
process) before exiting.
|
||||
|
||||
Returns:
|
||||
Compiled LangGraph agent graph.
|
||||
"""
|
||||
nonlocal graph
|
||||
if graph is not missing:
|
||||
return graph
|
||||
async with lock:
|
||||
if graph is missing:
|
||||
try:
|
||||
graph = await _make_graph()
|
||||
except Exception as exc: # noqa: BLE001 # top-level barrier: any construction failure must surface to the parent as a marker
|
||||
emit_startup_failure(exc)
|
||||
sys.exit(1)
|
||||
return graph
|
||||
|
||||
return make_graph
|
||||
|
||||
|
||||
make_graph = _build_graph_factory()
|
||||
|
||||
@@ -110,7 +110,7 @@ def _scaffold_workspace(work_dir: Path) -> None:
|
||||
# here breaks Windows because importlib treats backslash paths as module names.
|
||||
generate_langgraph_json(
|
||||
work_dir,
|
||||
graph_ref="./server_graph.py:graph",
|
||||
graph_ref="./server_graph.py:make_graph",
|
||||
checkpointer_path="./checkpointer.py:create_checkpointer",
|
||||
)
|
||||
|
||||
@@ -374,6 +374,7 @@ async def start_server_and_get_agent(
|
||||
)
|
||||
try:
|
||||
await server.start()
|
||||
await server.wait_for_graph_ready("agent")
|
||||
except Exception:
|
||||
server.stop()
|
||||
raise
|
||||
|
||||
@@ -8,25 +8,37 @@ If loading a file fails, the script prints the problematic filename and the deta
|
||||
error traceback.
|
||||
"""
|
||||
|
||||
import os
|
||||
import random
|
||||
import string
|
||||
import sys
|
||||
import tempfile
|
||||
import traceback
|
||||
from importlib.machinery import SourceFileLoader
|
||||
|
||||
if __name__ == "__main__":
|
||||
files = sys.argv[1:]
|
||||
has_failure = False
|
||||
for file in files:
|
||||
try:
|
||||
module_name = "".join(
|
||||
random.choice(string.ascii_letters) for _ in range(20)
|
||||
)
|
||||
SourceFileLoader(module_name, file).load_module()
|
||||
except Exception:
|
||||
has_failure = True
|
||||
print(file)
|
||||
traceback.print_exc()
|
||||
print()
|
||||
with tempfile.TemporaryDirectory() as home:
|
||||
# Point the home directory at a throwaway dir so importing a module can't
|
||||
# read or depend on the developer's real `~` state (e.g. `~/.deepagents`
|
||||
# config, MCP auth tokens). `Path.home()` resolves from `HOME` on POSIX
|
||||
# and `USERPROFILE` / `HOMEDRIVE`+`HOMEPATH` on Windows, so override all
|
||||
# of them to keep the isolation cross-platform.
|
||||
os.environ["HOME"] = home
|
||||
os.environ["USERPROFILE"] = home
|
||||
os.environ.pop("HOMEDRIVE", None)
|
||||
os.environ.pop("HOMEPATH", None)
|
||||
for file in files:
|
||||
try:
|
||||
module_name = "".join(
|
||||
random.choice(string.ascii_letters) for _ in range(20)
|
||||
)
|
||||
SourceFileLoader(module_name, file).load_module()
|
||||
except Exception:
|
||||
has_failure = True
|
||||
print(file)
|
||||
traceback.print_exc()
|
||||
print()
|
||||
|
||||
sys.exit(1 if has_failure else 0)
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import os
|
||||
import socket
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING, Self
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
@@ -51,6 +52,31 @@ class _FakeSocket:
|
||||
return self._sockname
|
||||
|
||||
|
||||
class _FakeAsyncClient:
|
||||
"""Minimal async `httpx.AsyncClient` stand-in for readiness tests."""
|
||||
|
||||
def __init__(self, response: object) -> None:
|
||||
self.response = response
|
||||
self.urls: list[str] = []
|
||||
|
||||
async def __aenter__(self) -> Self:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_args: object) -> None:
|
||||
return None
|
||||
|
||||
async def get(
|
||||
self,
|
||||
url: str,
|
||||
*,
|
||||
timeout: float, # noqa: ARG002, ASYNC109 # mirrors httpx.AsyncClient.get
|
||||
) -> object:
|
||||
self.urls.append(url)
|
||||
if isinstance(self.response, BaseException):
|
||||
raise self.response
|
||||
return self.response
|
||||
|
||||
|
||||
class TestPortInUse:
|
||||
def test_free_port(self) -> None:
|
||||
fake_socket = _FakeSocket()
|
||||
@@ -204,6 +230,71 @@ class TestWaitForServerHealthy:
|
||||
|
||||
|
||||
class TestServerProcess:
|
||||
async def test_wait_for_graph_ready_resolves_graph_endpoint(self) -> None:
|
||||
"""Graph readiness should force LangGraph to resolve graph factories."""
|
||||
client = _FakeAsyncClient(SimpleNamespace(status_code=200))
|
||||
process = MagicMock()
|
||||
process.poll.return_value = None
|
||||
server = ServerProcess(host="127.0.0.1", port=2024)
|
||||
server._process = process
|
||||
|
||||
with patch("httpx.AsyncClient", return_value=client):
|
||||
await server.wait_for_graph_ready("agent")
|
||||
|
||||
assert client.urls == ["http://127.0.0.1:2024/assistants/agent/graph"]
|
||||
|
||||
async def test_wait_for_graph_ready_surfaces_startup_marker(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""Readiness failures should preserve marked subprocess startup errors."""
|
||||
log_path = tmp_path / "server.log"
|
||||
log_path.write_text(
|
||||
"booting\n"
|
||||
"DEEPAGENTS_STARTUP_ERROR:Sandbox creation failed for 'modal': boom\n"
|
||||
)
|
||||
|
||||
log_file = MagicMock()
|
||||
log_file.name = str(log_path)
|
||||
|
||||
client = _FakeAsyncClient(SimpleNamespace(status_code=500))
|
||||
process = MagicMock()
|
||||
process.poll.return_value = None
|
||||
server = ServerProcess(host="127.0.0.1", port=2024)
|
||||
server._process = process
|
||||
server._log_file = log_file
|
||||
|
||||
with (
|
||||
patch("httpx.AsyncClient", return_value=client),
|
||||
pytest.raises(RuntimeError, match="Sandbox creation failed"),
|
||||
):
|
||||
await server.wait_for_graph_ready("agent")
|
||||
|
||||
async def test_wait_for_graph_ready_checks_logs_after_transport_error(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""Dropped graph requests should still surface startup markers."""
|
||||
log_path = tmp_path / "server.log"
|
||||
log_path.write_text(
|
||||
"booting\nDEEPAGENTS_STARTUP_ERROR:ModelConfigError: missing API key\n"
|
||||
)
|
||||
|
||||
log_file = MagicMock()
|
||||
log_file.name = str(log_path)
|
||||
|
||||
client = _FakeAsyncClient(OSError("connection closed"))
|
||||
process = MagicMock()
|
||||
process.poll.return_value = 1
|
||||
process.returncode = 3
|
||||
server = ServerProcess(host="127.0.0.1", port=2024)
|
||||
server._process = process
|
||||
server._log_file = log_file
|
||||
|
||||
with (
|
||||
patch("httpx.AsyncClient", return_value=client),
|
||||
pytest.raises(RuntimeError, match="ModelConfigError: missing API key"),
|
||||
):
|
||||
await server.wait_for_graph_ready("agent")
|
||||
|
||||
async def test_start_cleans_up_partial_state_on_health_failure(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
|
||||
@@ -6,15 +6,13 @@ import importlib
|
||||
import os
|
||||
import sys
|
||||
from types import ModuleType, SimpleNamespace
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from deepagents_code._env_vars import SERVER_ENV_PREFIX
|
||||
from deepagents_code._server_config import ServerConfig
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import pytest
|
||||
|
||||
|
||||
def _import_fresh_server_graph() -> ModuleType:
|
||||
"""Import `deepagents_code.server_graph` from a clean module state."""
|
||||
@@ -33,8 +31,43 @@ def _module_with_attrs(name: str, **attrs: object) -> ModuleType:
|
||||
class TestServerGraph:
|
||||
"""Tests for server-mode graph bootstrap."""
|
||||
|
||||
def test_auto_discovery_loads_mcp_without_explicit_config(self) -> None:
|
||||
"""Server mode should auto-discover MCP configs when no path is passed."""
|
||||
async def test_make_graph_caches_first_constructed_graph(self) -> None:
|
||||
"""Repeated factory access should preserve process-lifetime resources."""
|
||||
graph_obj = object()
|
||||
module = _import_fresh_server_graph()
|
||||
|
||||
with patch.object(
|
||||
module, "_make_graph", new=AsyncMock(return_value=graph_obj)
|
||||
) as make_graph:
|
||||
assert await module.make_graph() is graph_obj
|
||||
assert await module.make_graph() is graph_obj
|
||||
|
||||
make_graph.assert_awaited_once_with()
|
||||
|
||||
async def test_make_graph_emits_marker_and_exits_on_failure(
|
||||
self, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""A construction failure must emit the startup marker, then exit non-zero."""
|
||||
from deepagents_code._startup_error import STARTUP_ERROR_MARKER
|
||||
|
||||
module = _import_fresh_server_graph()
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
module,
|
||||
"_make_graph",
|
||||
new=AsyncMock(side_effect=ValueError("boom: bad model")),
|
||||
),
|
||||
pytest.raises(SystemExit) as exc_info,
|
||||
):
|
||||
await module.make_graph()
|
||||
|
||||
assert exc_info.value.code == 1
|
||||
captured = capsys.readouterr()
|
||||
assert f"{STARTUP_ERROR_MARKER}ValueError: boom: bad model" in captured.err
|
||||
|
||||
async def test_auto_discovery_loads_mcp_without_explicit_config(self) -> None:
|
||||
"""Server mode should auto-discover MCP configs when the graph is built."""
|
||||
graph_obj = object()
|
||||
model_obj = object()
|
||||
fetch_tool = object()
|
||||
@@ -80,8 +113,6 @@ class TestServerGraph:
|
||||
resolve_and_load_mcp_tools=resolve_mcp_tools,
|
||||
)
|
||||
|
||||
# Build env from ServerConfig to exercise the same serialization
|
||||
# path the real CLI uses.
|
||||
config = ServerConfig(no_mcp=False)
|
||||
env_overrides = {}
|
||||
for suffix, value in config.to_env().items():
|
||||
@@ -113,6 +144,8 @@ class TestServerGraph:
|
||||
os.environ.pop(f"{SERVER_ENV_PREFIX}{suffix}", None)
|
||||
|
||||
module = _import_fresh_server_graph()
|
||||
resolve_mcp_tools.assert_not_awaited()
|
||||
assert await module.make_graph() is graph_obj
|
||||
|
||||
resolve_mcp_tools.assert_awaited_once()
|
||||
kwargs = resolve_mcp_tools.await_args_list[0].kwargs
|
||||
@@ -143,7 +176,6 @@ class TestServerGraph:
|
||||
project_context=None,
|
||||
async_subagents=None,
|
||||
)
|
||||
assert module.graph is graph_obj
|
||||
|
||||
|
||||
class TestStartupErrorMarker:
|
||||
|
||||
@@ -149,6 +149,7 @@ class TestStartServerAndGetAgent:
|
||||
|
||||
mock_server = MagicMock()
|
||||
mock_server.start = AsyncMock()
|
||||
mock_server.wait_for_graph_ready = AsyncMock()
|
||||
mock_server.url = "http://127.0.0.1:2024"
|
||||
mock_agent = object()
|
||||
|
||||
@@ -175,9 +176,10 @@ class TestStartServerAndGetAgent:
|
||||
assert agent is mock_agent
|
||||
assert server is mock_server
|
||||
assert manager is None
|
||||
mock_server.wait_for_graph_ready.assert_awaited_once_with("agent")
|
||||
|
||||
kwargs = mock_generate_langgraph_json.call_args.kwargs
|
||||
assert kwargs["graph_ref"] == "./server_graph.py:graph"
|
||||
assert kwargs["graph_ref"] == "./server_graph.py:make_graph"
|
||||
assert kwargs["checkpointer_path"] == "./checkpointer.py:create_checkpointer"
|
||||
|
||||
async def test_passes_scaffold_hook_to_server_process(
|
||||
@@ -193,6 +195,7 @@ class TestStartServerAndGetAgent:
|
||||
|
||||
mock_server = MagicMock()
|
||||
mock_server.start = AsyncMock()
|
||||
mock_server.wait_for_graph_ready = AsyncMock()
|
||||
mock_server.url = "http://127.0.0.1:2024"
|
||||
|
||||
with (
|
||||
@@ -216,6 +219,48 @@ class TestStartServerAndGetAgent:
|
||||
|
||||
assert mock_server_process.call_args.kwargs["scaffold"] is mock_scaffold
|
||||
|
||||
async def test_stops_server_when_graph_readiness_fails(
|
||||
self, tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""Lazy graph initialization failures should fail startup before return."""
|
||||
project_root = tmp_path / "project"
|
||||
project_root.mkdir()
|
||||
monkeypatch.chdir(project_root)
|
||||
|
||||
work_dir = tmp_path / "runtime"
|
||||
work_dir.mkdir()
|
||||
|
||||
mock_server = MagicMock()
|
||||
mock_server.start = AsyncMock()
|
||||
mock_server.wait_for_graph_ready = AsyncMock(
|
||||
side_effect=RuntimeError("graph failed")
|
||||
)
|
||||
mock_server.stop = MagicMock()
|
||||
mock_server.url = "http://127.0.0.1:2024"
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, {}, clear=False),
|
||||
patch(
|
||||
"deepagents_code.server_manager.tempfile.mkdtemp",
|
||||
return_value=str(work_dir),
|
||||
),
|
||||
patch("deepagents_code.server_manager.shutil.copy2"),
|
||||
patch("deepagents_code.server_manager._write_checkpointer"),
|
||||
patch("deepagents_code.server_manager._write_pyproject"),
|
||||
patch("deepagents_code.server.ServerProcess", return_value=mock_server),
|
||||
patch("deepagents_code.remote_client.RemoteAgent") as mock_agent,
|
||||
pytest.raises(RuntimeError, match="graph failed"),
|
||||
):
|
||||
await start_server_and_get_agent(
|
||||
assistant_id="agent",
|
||||
mcp_config_path=None,
|
||||
)
|
||||
|
||||
mock_server.start.assert_awaited_once()
|
||||
mock_server.wait_for_graph_ready.assert_awaited_once_with("agent")
|
||||
mock_server.stop.assert_called_once()
|
||||
mock_agent.assert_not_called()
|
||||
|
||||
def test_relative_paths_written_verbatim_to_langgraph_json(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
@@ -226,11 +271,11 @@ class TestStartServerAndGetAgent:
|
||||
|
||||
generate_langgraph_json(
|
||||
tmp_path,
|
||||
graph_ref="./server_graph.py:graph",
|
||||
graph_ref="./server_graph.py:make_graph",
|
||||
checkpointer_path="./checkpointer.py:create_checkpointer",
|
||||
)
|
||||
config = json.loads((tmp_path / "langgraph.json").read_text())
|
||||
assert config["graphs"]["agent"] == "./server_graph.py:graph"
|
||||
assert config["graphs"]["agent"] == "./server_graph.py:make_graph"
|
||||
assert config["checkpointer"]["path"] == "./checkpointer.py:create_checkpointer"
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user