mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-08-26 18:16:58 -04:00
9fdd498937
`/goal` acceptance-criteria drafting now runs on the agent server and can use conversation, repository, web, and explicitly read-only configured MCP context while preserving resumable review state. --- Previously, `/goal` proposals were drafted in the client process from the explicit objective, so remote or sandbox repository context and references to the current conversation were unavailable. This moves proposal and amendment drafting into a nested agent in the main server graph, where it can use recent conversation context, bounded read-only repository search, `fetch_url`, optional web search, and configured MCP tools explicitly annotated as read-only. Repository context remains read-only and repository-rooted across backends: local reads use a virtual project root, while sandbox reads are constrained to the provider's declared working directory, including canonical-path checks that reject symlink escapes. Proposals are stored as pending checkpoint state rather than chat messages, so review survives remote interrupts and resumes without polluting the coding conversation. Each proposal is correlated with its originating request, and criteria requests are cleared after success, failure, or cancellation without clearing newer requests. Cancelling criteria generation stops the server run without adding normal chat-interruption messages. The client refreshes checkpoint state after generation and reuses the existing accept, edit, and reject flow; model profile overrides now cross the client/server boundary consistently.
377 lines
14 KiB
Python
377 lines
14 KiB
Python
"""Server-side graph entry point for `langgraph dev`.
|
|
|
|
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 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 TYPE_CHECKING, Any
|
|
|
|
from deepagents_code._server_config import ServerConfig
|
|
from deepagents_code._startup_error import (
|
|
STARTUP_ERROR_MARKER as _STARTUP_ERROR_MARKER,
|
|
emit_startup_failure,
|
|
)
|
|
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
|
|
_sandbox_backend: Any = None
|
|
_mcp_session_manager: Any = None
|
|
|
|
|
|
def _print_startup_error(message: str) -> None:
|
|
"""Print a startup error for both humans and the parent app process.
|
|
|
|
Args:
|
|
message: Concise startup failure to surface in the parent process.
|
|
"""
|
|
print(message, file=sys.stderr) # noqa: T201 # stderr fallback for logs
|
|
print( # noqa: T201 # machine-readable marker consumed by server.py
|
|
f"{_STARTUP_ERROR_MARKER}{message}",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
|
|
def _get_mcp_session_manager() -> Any: # noqa: ANN401
|
|
"""Return the process-wide MCP session manager singleton.
|
|
|
|
Sessions are bound to the langgraph dev server's event loop. Cleanup
|
|
therefore belongs to that loop's normal shutdown path, not `atexit` —
|
|
an atexit handler runs after the loop is already closed and cannot
|
|
await `AsyncExitStack.aclose()` safely. Subprocess handles held by
|
|
stdio transports are released when the Python process exits.
|
|
"""
|
|
global _mcp_session_manager # noqa: PLW0603
|
|
|
|
if _mcp_session_manager is None:
|
|
from deepagents_code.mcp_tools import MCPSessionManager
|
|
|
|
_mcp_session_manager = MCPSessionManager()
|
|
|
|
return _mcp_session_manager
|
|
|
|
|
|
async def _build_tools(
|
|
config: ServerConfig,
|
|
project_context: ProjectContext | None,
|
|
) -> tuple[list[Any], list[Any] | None, list[Any]]:
|
|
"""Assemble the tool list based on server config.
|
|
|
|
Loads built-in tools (conditionally including web search when Tavily is
|
|
available) and MCP tools when enabled.
|
|
|
|
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. MCP adapter imports are warmed in a worker thread inside
|
|
`_load_tools_from_config` (only when active servers exist) because first
|
|
import can perform blocking package-resource scans.
|
|
|
|
Args:
|
|
config: Deserialized server configuration.
|
|
project_context: Resolved project context for MCP discovery.
|
|
|
|
Returns:
|
|
Tuple of `(tools, mcp_server_info, mcp_tools)`.
|
|
|
|
Raises:
|
|
FileNotFoundError: If the MCP config file is not found.
|
|
RuntimeError: If MCP tool loading fails.
|
|
"""
|
|
from deepagents_code.config import settings
|
|
from deepagents_code.tools import fetch_url, get_current_thread_id, web_search
|
|
|
|
tools: list[Any] = [fetch_url, get_current_thread_id]
|
|
if settings.has_tavily:
|
|
tools.append(web_search)
|
|
|
|
mcp_server_info: list[Any] | None = None
|
|
mcp_tools: list[Any] = []
|
|
if not config.no_mcp:
|
|
from deepagents_code.mcp_tools import resolve_and_load_mcp_tools
|
|
from deepagents_code.plugins.adapters.mcp import discover_plugin_mcp_configs
|
|
|
|
project_dir = (
|
|
project_context.project_root or project_context.user_cwd
|
|
if project_context is not None
|
|
else None
|
|
)
|
|
# Offload plugin discovery: it does blocking disk IO (`os.mkdir` for
|
|
# per-plugin data dirs, plus state/manifest reads) that `blockbuster`
|
|
# rejects on the server event loop.
|
|
plugin_mcp_configs = await asyncio.to_thread(
|
|
discover_plugin_mcp_configs, project_dir=project_dir
|
|
)
|
|
try:
|
|
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,
|
|
additional_configs=plugin_mcp_configs,
|
|
stateless=True,
|
|
session_manager=_get_mcp_session_manager(),
|
|
)
|
|
except FileNotFoundError:
|
|
logger.exception("MCP config file not found: %s", config.mcp_config_path)
|
|
raise
|
|
except RuntimeError:
|
|
logger.exception(
|
|
"Failed to load MCP tools (config: %s)", config.mcp_config_path
|
|
)
|
|
raise
|
|
|
|
tools.extend(mcp_tools)
|
|
if mcp_tools:
|
|
logger.info("Loaded %d MCP tool(s)", len(mcp_tools))
|
|
|
|
return tools, mcp_server_info, mcp_tools
|
|
|
|
|
|
def _criteria_context_tools(
|
|
tools: list[Any],
|
|
mcp_tools: list[Any],
|
|
) -> list[Any]:
|
|
"""Select external context tools from the normal agent tool list.
|
|
|
|
Args:
|
|
tools: Main agent tools in execution order.
|
|
mcp_tools: Exact tool objects returned by MCP discovery.
|
|
|
|
Returns:
|
|
External context tools available to criteria generation. MCP tools are
|
|
included only when their protocol annotations explicitly declare them
|
|
read-only.
|
|
"""
|
|
from deepagents_code.tools import fetch_url, web_search
|
|
|
|
allowed_ids = {id(fetch_url), id(web_search)}
|
|
allowed_ids.update(
|
|
id(tool) for tool in mcp_tools if _mcp_tool_is_explicitly_read_only(tool)
|
|
)
|
|
return [tool for tool in tools if id(tool) in allowed_ids]
|
|
|
|
|
|
def _mcp_tool_is_explicitly_read_only(tool: Any) -> bool: # noqa: ANN401
|
|
"""Return whether a wrapped MCP tool is unambiguously read-only.
|
|
|
|
MCP `ToolAnnotations.readOnlyHint` is serialized by the installed adapter
|
|
into the LangChain tool's metadata as the camel-case `readOnlyHint` key.
|
|
Require the literal boolean `True` and reject a contradictory destructive
|
|
hint so absent, malformed, or ambiguous annotations fail closed.
|
|
|
|
Returns:
|
|
`True` only for an explicitly and consistently read-only MCP tool.
|
|
"""
|
|
metadata = getattr(tool, "metadata", None)
|
|
return (
|
|
isinstance(metadata, dict)
|
|
and metadata.get("readOnlyHint") is True
|
|
and metadata.get("destructiveHint") is not True
|
|
)
|
|
|
|
|
|
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()`
|
|
(the inverse of `ServerConfig.to_env()` used by the app process), resolves a
|
|
model, assembles tools, and compiles the agent graph.
|
|
|
|
Returns:
|
|
Compiled LangGraph agent graph.
|
|
"""
|
|
config = ServerConfig.from_env()
|
|
project_context = get_server_project_context()
|
|
|
|
from deepagents_code.agent import create_cli_agent, load_async_subagents
|
|
from deepagents_code.config import (
|
|
configure_langsmith_secret_redaction,
|
|
create_model,
|
|
is_memory_auto_save_enabled,
|
|
settings,
|
|
)
|
|
|
|
if project_context is not None:
|
|
settings.reload_from_environment(start_path=project_context.user_cwd)
|
|
configure_langsmith_secret_redaction()
|
|
|
|
# Offload to a worker thread: `create_model` does blocking disk IO for some
|
|
# providers (e.g. the `openai_codex` token store currently acquires a file
|
|
# lock via `langchain-openai` that calls `os.mkdir`), which `blockbuster`
|
|
# rejects on the server event loop.
|
|
result = await asyncio.to_thread(
|
|
create_model,
|
|
config.model,
|
|
extra_kwargs=config.model_params,
|
|
profile_overrides=config.profile_overrides,
|
|
)
|
|
result.apply_to_settings()
|
|
|
|
tools, mcp_server_info, mcp_tools = await _build_tools(config, project_context)
|
|
|
|
# Create sandbox backend if a sandbox provider is configured.
|
|
# 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:
|
|
from deepagents_code.integrations.sandbox_factory import create_sandbox
|
|
|
|
try:
|
|
_sandbox_cm = create_sandbox(
|
|
config.sandbox_type,
|
|
sandbox_id=config.sandbox_id,
|
|
snapshot_name=config.sandbox_snapshot_name,
|
|
setup_script_path=config.sandbox_setup,
|
|
)
|
|
_sandbox_backend = _sandbox_cm.__enter__() # noqa: PLC2801 # Context manager kept open for server process lifetime
|
|
sandbox_backend = _sandbox_backend
|
|
|
|
def _cleanup_sandbox() -> None:
|
|
if _sandbox_cm is not None:
|
|
_sandbox_cm.__exit__(None, None, None)
|
|
|
|
atexit.register(_cleanup_sandbox)
|
|
except ImportError:
|
|
logger.exception(
|
|
"Sandbox provider '%s' is not installed", config.sandbox_type
|
|
)
|
|
_print_startup_error(
|
|
f"Sandbox provider '{config.sandbox_type}' is not installed"
|
|
)
|
|
sys.exit(1)
|
|
except NotImplementedError:
|
|
logger.exception("Sandbox type '%s' is not supported", config.sandbox_type)
|
|
_print_startup_error(
|
|
f"Sandbox type '{config.sandbox_type}' is not supported"
|
|
)
|
|
sys.exit(1)
|
|
except ValueError as exc:
|
|
logger.exception(
|
|
"Invalid sandbox configuration for '%s'", config.sandbox_type
|
|
)
|
|
_print_startup_error(f"Invalid sandbox configuration: {exc}")
|
|
sys.exit(1)
|
|
except Exception as exc:
|
|
logger.exception("Sandbox creation failed for '%s'", config.sandbox_type)
|
|
_print_startup_error(
|
|
f"Sandbox creation failed for '{config.sandbox_type}': {exc}"
|
|
)
|
|
sys.exit(1)
|
|
|
|
def _create_cli_agent_sync() -> Any: # noqa: ANN401
|
|
async_subagents = load_async_subagents() or None
|
|
|
|
# These process-global settings writes are safe here because `make_graph`
|
|
# is lock-serialized and caches one graph for the server process lifetime.
|
|
if config.interpreter_ptc is not None:
|
|
settings.interpreter_ptc = config.interpreter_ptc
|
|
if config.interpreter_ptc_acknowledge_unsafe:
|
|
settings.interpreter_ptc_acknowledge_unsafe = True
|
|
if config.enable_interpreter:
|
|
settings.enable_interpreter = True
|
|
|
|
agent, _composite_backend = create_cli_agent(
|
|
model=result.model,
|
|
assistant_id=config.assistant_id,
|
|
tools=tools,
|
|
sandbox=sandbox_backend,
|
|
sandbox_type=config.sandbox_type,
|
|
system_prompt=config.system_prompt,
|
|
interactive=config.interactive,
|
|
auto_approve=config.auto_approve,
|
|
interrupt_shell_only=config.interrupt_shell_only,
|
|
shell_allow_list=config.shell_allow_list,
|
|
enable_ask_user=config.enable_ask_user,
|
|
enable_memory=config.enable_memory,
|
|
memory_auto_save=is_memory_auto_save_enabled(),
|
|
enable_skills=config.enable_skills,
|
|
enable_shell=config.enable_shell,
|
|
enable_interpreter=config.enable_interpreter,
|
|
rubric_model=config.rubric_model,
|
|
rubric_max_iterations=config.rubric_max_iterations,
|
|
mcp_server_info=mcp_server_info,
|
|
cwd=project_context.user_cwd if project_context is not None else config.cwd,
|
|
project_context=project_context,
|
|
async_subagents=async_subagents,
|
|
goal_criteria_tools=_criteria_context_tools(tools, mcp_tools),
|
|
)
|
|
return agent
|
|
|
|
return await asyncio.to_thread(_create_cli_agent_sync)
|
|
|
|
|
|
def _build_graph_factory(
|
|
builder: Callable[[], Awaitable[Any]] | None = None,
|
|
) -> 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.
|
|
|
|
Args:
|
|
builder: Optional alternate graph builder.
|
|
|
|
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 (builder or _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()
|