mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-25 04:46:03 -04:00
5e01fec72d
`dcode` now opts its LangSmith tracing client into the SDK secret anonymizer before agent traces are uploaded. Redaction defaults on, can be disabled explicitly, and fails closed by turning tracing off if the redacting client cannot be installed. <img width="609" height="95" alt="Screenshot 5" src="https://github.com/user-attachments/assets/6a96f4d2-b115-41eb-9df0-ad8e7bf652c9" /> <img width="1013" height="390" alt="Screenshot 8" src="https://github.com/user-attachments/assets/c9251d0d-f7a9-4251-b811-76be9d1d0beb" /> ## Changes - Add the `tracing.langsmith_redact` config option and `DEEPAGENTS_CODE_LANGSMITH_REDACT` env var, defaulting to enabled. This controls whether `dcode` installs the LangSmith SDK [`Client(anonymizer=...)`](https://docs.langchain.com/langsmith/mask-inputs-outputs#rule-based-masking-of-inputs-and-outputs) option with the curated [`create_secret_anonymizer()`](https://reference.langchain.com/python/langsmith/anonymizer/create_secret_anonymizer) preset. - In practice, users get secret redaction without changing their setup. - Users who need to opt out for controlled local debugging can set `DEEPAGENTS_CODE_LANGSMITH_REDACT=false` or the matching `config.toml` value. - Install a `Client(anonymizer=create_secret_anonymizer())` in the process that emits the main `dcode` agent traces. - `dcode` runs the terminal UI in one process and the agent graph in a LangGraph server subprocess. - The server subprocess is the important path for this rollout: it runs the agent graph, so it emits the main LangSmith traces for model calls, tool calls, and agent turns. - The app process still configures the redacting client during bootstrap and `/auth` changes as a defensive consistency measure for any in-process SDK tracing, but that does not replace the server-side setup. - Treat redaction setup failures as fail-closed by disabling tracing rather than allowing unredacted trace uploads. - In practice, if `dcode` sees tracing is active but cannot create or install the redacting LangSmith client, it calls `langsmith.configure(enabled=False)`. - If even that fails, it clears the tracing-enable environment variables as a last-resort barrier. - The user loses tracing for that run instead of uploading prompts, tool I/O, shell commands, file contents, metadata, or model outputs without secret redaction. - Preserve support for LangSmith profile credentials, custom endpoints, replica tracing, and legacy `LANGCHAIN_API_KEY` while configuring the redacting client. - For example, a user with only `LANGSMITH_PROFILE` / `LANGSMITH_CONFIG_FILE` still gets a redacting client without forwarding an env API key. - For example, a local or self-hosted endpoint set with `LANGSMITH_ENDPOINT=http://localhost:1984` is passed through as `api_url` on the redacting client. - For example, `LANGSMITH_RUNS_ENDPOINTS` replica uploads also trigger redaction setup, even without a top-level API key. - For example, `DEEPAGENTS_CODE_LANGSMITH_REPLICA_PROJECTS=personal-traces` still routes through the existing replica path; the primary client used for the run is now redacting. - For example, environments that still provide `LANGCHAIN_API_KEY` instead of `LANGSMITH_API_KEY` continue to configure tracing with that key. - Cover the default, opt-out, keyless endpoint, profile-only, fail-closed, and server-startup paths in unit tests. - Default: redaction resolves to enabled with no user setting. - Opt-out: `DEEPAGENTS_CODE_LANGSMITH_REDACT=false` and `[tracing] langsmith_redact = false` skip client installation. - Keyless endpoint: tracing with a custom endpoint but no API key still installs an anonymizer. - Profile-only: profile credentials still install an anonymizer without an env key. - Fail-closed: setup errors disable tracing, and the fallback clears tracing env vars if SDK disable also fails. - Server-startup: the LangGraph server graph factory calls the redaction setup before constructing the agent graph.
322 lines
12 KiB
Python
322 lines
12 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 _warm_mcp_adapter_imports() -> None:
|
|
"""Eagerly import MCP adapter modules whose first import may block.
|
|
|
|
Called via `asyncio.to_thread` before MCP loading; kept as a small helper so
|
|
tests can patch it and assert the warmup stays off the server event loop.
|
|
"""
|
|
from langchain_mcp_adapters import (
|
|
sessions as _sessions, # noqa: F401
|
|
tools as _tools, # noqa: F401
|
|
)
|
|
|
|
|
|
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]:
|
|
"""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 first 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)`.
|
|
|
|
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
|
|
if not config.no_mcp:
|
|
from deepagents_code.mcp_tools import resolve_and_load_mcp_tools
|
|
|
|
await asyncio.to_thread(_warm_mcp_adapter_imports)
|
|
|
|
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,
|
|
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
|
|
|
|
|
|
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,
|
|
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
|
|
)
|
|
result.apply_to_settings()
|
|
|
|
tools, mcp_server_info = 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, _ = 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,
|
|
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,
|
|
)
|
|
return agent
|
|
|
|
return await asyncio.to_thread(_create_cli_agent_sync)
|
|
|
|
|
|
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()
|