Files
EvoScientist-Multi/EvoScientist/EvoScientist.py
T

430 lines
14 KiB
Python

"""EvoScientist Agent graph construction.
This module defines the agent graph and its factory functions. All heavy
initialization (deepagents, backends, LLM, middleware) is deferred to first
use so that importing this module is fast and non-agent CLI commands
(``EvoSci config list``, ``EvoSci onboard``) never pay the cost.
Usage:
from EvoScientist import EvoScientist_agent
# Notebook / programmatic usage
for state in EvoScientist_agent.stream(
{"messages": [HumanMessage(content="your question")]},
config={"configurable": {"thread_id": "1"}},
stream_mode="values",
):
...
"""
import json
import logging
import os
from datetime import datetime
from pathlib import Path
from langchain.agents.middleware import AgentMiddleware
from . import paths as _paths_mod
from .config import apply_config_to_env, get_effective_config
from .paths import set_active_workspace, set_workspace_root
from .prompts import RESEARCHER_INSTRUCTIONS, get_system_prompt
# Suppress noisy warnings from deepagents skill loader (non-string frontmatter fields, etc.)
logging.getLogger("deepagents.middleware.skills").setLevel(logging.ERROR)
# =============================================================================
# Constants
# =============================================================================
SUBAGENTS_CONFIG = Path(__file__).parent / "subagent.yaml"
SKILLS_DIR = str(Path(__file__).parent / "skills")
# =============================================================================
# Lazy state — initialized on first use, not at import time
# =============================================================================
_config = None
_chat_model = None
# Cache MCP tools by the effective config signature to avoid reconnecting
# to MCP servers on every `/new` when config is unchanged.
_MCP_TOOLS_CACHE_KEY: str | None = None
_MCP_TOOLS_CACHE_VALUE: dict[str, list] | None = None
# Default agent (no checkpointer) — used by langgraph dev / LangSmith / notebooks.
# Lazily constructed on first access so MCP tools are included without
# spawning subprocesses at import time.
_EvoScientist_agent = None
# =============================================================================
# Lazy initialization helpers
# =============================================================================
def _ensure_config(config=None):
"""Return cached config. If *config* is passed, cache and use it."""
global _config
if config is not None:
_config = config
apply_config_to_env(_config)
if _config is None:
_config = get_effective_config()
apply_config_to_env(_config)
return _config
def _ensure_chat_model():
"""Return cached chat model, creating it on first call."""
global _chat_model
if _chat_model is None:
from .llm import get_chat_model
cfg = _ensure_config()
_chat_model = get_chat_model(model=cfg.model, provider=cfg.provider)
return _chat_model
# =============================================================================
# MCP caching
# =============================================================================
def _load_mcp_config_once() -> tuple[str, dict]:
"""Load MCP config and return ``(signature, config)``."""
from .mcp.client import load_mcp_config
cfg = load_mcp_config()
if not cfg:
return "", {}
try:
sig = json.dumps(cfg, sort_keys=True, ensure_ascii=True)
except TypeError:
sig = repr(cfg)
return sig, cfg
def _load_mcp_tools_cached() -> dict[str, list]:
"""Load MCP tools with config-aware caching."""
global _MCP_TOOLS_CACHE_KEY, _MCP_TOOLS_CACHE_VALUE
from .mcp import load_mcp_tools
cfg_key, cfg = _load_mcp_config_once()
if not cfg_key:
_MCP_TOOLS_CACHE_KEY = ""
_MCP_TOOLS_CACHE_VALUE = {}
return {}
if _MCP_TOOLS_CACHE_KEY == cfg_key and _MCP_TOOLS_CACHE_VALUE is not None:
return {k: list(v) for k, v in _MCP_TOOLS_CACHE_VALUE.items()}
loaded = load_mcp_tools(config=cfg)
_MCP_TOOLS_CACHE_KEY = cfg_key
_MCP_TOOLS_CACHE_VALUE = {k: list(v) for k, v in loaded.items()}
return {k: list(v) for k, v in loaded.items()}
# =============================================================================
# Agent construction helpers
# =============================================================================
def _inject_subagent_middleware(subs: list[dict]) -> None:
"""Ensure every subagent gets ToolErrorHandlerMiddleware.
Without this, subagent tool errors are caught by LangGraph's default
ToolNode handler which produces terse messages without tracebacks or
retry guidance — reducing the subagent's ability to self-recover.
"""
from .middleware import ContextOverflowMapperMiddleware, ToolErrorHandlerMiddleware
for sa in subs:
sa.setdefault("middleware", []).append(
ToolErrorHandlerMiddleware(), ContextOverflowMapperMiddleware()
)
def _build_prompt_refs() -> dict:
"""Build prompt references with the current date (not frozen at import)."""
return {
"RESEARCHER_INSTRUCTIONS": RESEARCHER_INSTRUCTIONS.format(
date=datetime.now().strftime("%Y-%m-%d"),
),
}
def _build_base_kwargs(base_backend, base_middleware):
"""Build agent kwargs *without* MCP (fast, no subprocess spawning)."""
from .tools import skill_manager, tavily_search, think_tool
from .utils import load_subagents
tool_registry = {"think_tool": think_tool}
if os.environ.get("TAVILY_API_KEY"):
tool_registry["tavily_search"] = tavily_search
base_tools = [think_tool, skill_manager]
subs = load_subagents(
SUBAGENTS_CONFIG,
tool_registry=tool_registry,
prompt_refs=_build_prompt_refs(),
)
_inject_subagent_middleware(subs)
return {
"name": "EvoScientist",
"model": _ensure_chat_model(),
"tools": list(base_tools),
"backend": base_backend,
"subagents": subs,
"middleware": base_middleware,
"system_prompt": get_system_prompt(),
"skills": ["/skills/"],
}
def load_mcp_and_build_kwargs(base_backend, base_middleware):
"""Load MCP tools (cached by config) and build agent kwargs.
Re-connects to MCP servers only when the effective MCP config changes.
Falls back to base kwargs if no MCP configured.
"""
from .tools import skill_manager, tavily_search, think_tool
from .utils import load_subagents
mcp_by_agent = _load_mcp_tools_cached()
if not mcp_by_agent:
return _build_base_kwargs(base_backend, base_middleware)
tool_registry = {"think_tool": think_tool}
if os.environ.get("TAVILY_API_KEY"):
tool_registry["tavily_search"] = tavily_search
base_tools = [think_tool, skill_manager]
# Fresh tool registry — start from base tools + MCP tools
registry = dict(tool_registry)
for tools in mcp_by_agent.values():
for t in tools:
registry[t.name] = t
mcp_main = mcp_by_agent.pop("main", [])
subs = load_subagents(
SUBAGENTS_CONFIG,
tool_registry=registry,
prompt_refs=_build_prompt_refs(),
)
_inject_subagent_middleware(subs)
# Inject MCP tools into subagents by name
for sa in subs:
if sa_tools := mcp_by_agent.get(sa["name"], []):
sa.setdefault("tools", []).extend(sa_tools)
return {
"name": "EvoScientist",
"model": _ensure_chat_model(),
"tools": base_tools + mcp_main,
"backend": base_backend,
"subagents": subs,
"middleware": base_middleware,
"system_prompt": get_system_prompt(),
"skills": ["/skills/"],
}
# =============================================================================
# Default agent (langgraph dev / notebooks)
# =============================================================================
def _get_default_backend():
"""Build the default composite backend from current paths."""
from deepagents.backends import CompositeBackend, FilesystemBackend
from .backends import CustomSandboxBackend, MergedReadOnlyBackend
workspace_dir = str(_paths_mod.WORKSPACE_ROOT)
set_active_workspace(workspace_dir)
memory_dir = str(_paths_mod.MEMORY_DIR)
user_skills_dir = str(_paths_mod.USER_SKILLS_DIR)
ws_backend = CustomSandboxBackend(
root_dir=workspace_dir,
virtual_mode=True,
timeout=300,
)
sk_backend = MergedReadOnlyBackend(
primary_dir=user_skills_dir,
secondary_dir=SKILLS_DIR,
)
mem_backend = FilesystemBackend(
root_dir=memory_dir,
virtual_mode=True,
)
return CompositeBackend(
default=ws_backend,
routes={
"/skills/": sk_backend,
"/memory/": mem_backend,
},
)
def _get_default_middleware():
"""Build the default middleware list."""
from .middleware import (
ContextOverflowMapperMiddleware,
ToolErrorHandlerMiddleware,
create_memory_middleware,
)
cfg = _ensure_config()
memory_dir = str(_paths_mod.MEMORY_DIR)
mw = [
ContextOverflowMapperMiddleware(),
ToolErrorHandlerMiddleware(),
create_memory_middleware(memory_dir, extraction_model=_ensure_chat_model()),
]
if cfg.enable_ask_user and not cfg.auto_approve:
from .middleware.ask_user import AskUserMiddleware
mw.insert(0, AskUserMiddleware())
return mw
def _get_default_agent():
"""Build the default agent (with MCP, no checkpointer) on first access."""
global _EvoScientist_agent
if _EvoScientist_agent is None:
from deepagents import create_deep_agent
be = _get_default_backend()
mw = _get_default_middleware()
kwargs = load_mcp_and_build_kwargs(be, mw)
_EvoScientist_agent = create_deep_agent(**kwargs).with_config(
{"recursion_limit": 1000}
)
return _EvoScientist_agent
def __getattr__(name: str):
if name == "EvoScientist_agent":
return _get_default_agent()
# Backward compat for module-level names
if name == "chat_model":
return _ensure_chat_model()
if name == "SYSTEM_PROMPT":
return get_system_prompt()
if name == "backend":
return _get_default_backend()
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
# =============================================================================
# CLI agent factory
# =============================================================================
def create_cli_agent(workspace_dir: str | None = None, checkpointer=None, config=None):
"""Create agent with checkpointer for CLI multi-turn support.
A fresh backend is constructed on every call using the current
``paths.WORKSPACE_ROOT`` (or the explicit *workspace_dir*), so
runtime ``set_workspace_root()`` changes are always respected.
Args:
workspace_dir: Per-session workspace directory. If ``None``,
defaults to the current ``paths.WORKSPACE_ROOT``.
checkpointer: Optional LangGraph checkpointer. If ``None``,
falls back to ``InMemorySaver`` (non-persistent).
config: Optional pre-loaded ``EvoScientistConfig``. If ``None``,
loads from file/env/defaults. Passing this avoids double
loading when the CLI has already loaded config.
"""
import os as _os
from deepagents import create_deep_agent
from deepagents.backends import CompositeBackend, FilesystemBackend
from . import paths as _paths
from .backends import CustomSandboxBackend, MergedReadOnlyBackend
from .middleware import (
ContextOverflowMapperMiddleware,
ToolErrorHandlerMiddleware,
create_memory_middleware,
)
cfg = _ensure_config(config)
if checkpointer is None:
from langgraph.checkpoint.memory import InMemorySaver
checkpointer = InMemorySaver()
# When no explicit workspace_dir is provided, apply config.default_workdir
# as a fallback. This covers direct callers (notebooks, iMessage server)
# that never call set_workspace_root() themselves. CLI callers always
# pass workspace_dir explicitly, so their --workdir is never overwritten.
if workspace_dir is None:
if cfg.default_workdir:
set_workspace_root(
_os.path.abspath(_os.path.expanduser(cfg.default_workdir))
)
workspace_dir = str(_paths.WORKSPACE_ROOT)
# Read paths dynamically so runtime set_workspace_root() changes are picked up
_mem_dir = str(_paths.MEMORY_DIR)
_usr_skills_dir = str(_paths.USER_SKILLS_DIR)
# Always construct fresh backends from current paths (avoids stale
# module-level backend when workspace root changed at runtime).
set_active_workspace(workspace_dir)
ws_backend = CustomSandboxBackend(
root_dir=workspace_dir,
virtual_mode=True,
timeout=300,
)
sk_backend = MergedReadOnlyBackend(
primary_dir=_usr_skills_dir,
secondary_dir=SKILLS_DIR,
)
# Memory always uses SHARED directory (not per-session) for cross-session persistence
mem_backend = FilesystemBackend(
root_dir=_mem_dir,
virtual_mode=True,
)
be = CompositeBackend(
default=ws_backend,
routes={
"/skills/": sk_backend,
"/memory/": mem_backend,
},
)
mw: list[AgentMiddleware] = [
ContextOverflowMapperMiddleware(),
ToolErrorHandlerMiddleware(),
create_memory_middleware(_mem_dir, extraction_model=_ensure_chat_model()),
]
if cfg.enable_ask_user and not cfg.auto_approve:
from .middleware.ask_user import AskUserMiddleware
mw.insert(0, AskUserMiddleware())
# Re-load MCP tools from current config (picks up /mcp add changes)
kwargs = load_mcp_and_build_kwargs(be, mw)
# HITL: gate shell execution for user approval
_interrupt_on: dict[str, bool] | None = None
if not cfg.auto_approve:
_interrupt_on = {"execute": True}
return create_deep_agent(
**kwargs,
checkpointer=checkpointer,
interrupt_on=_interrupt_on,
).with_config({"recursion_limit": 1000})