feat(cli): enhance agent loading with optional config parameter for improved initialization

This commit is contained in:
X-iZhang
2026-02-25 00:35:40 +00:00
parent 7a6488d51d
commit abf783d2d3
8 changed files with 205 additions and 128 deletions
+174 -111
View File
@@ -1,11 +1,15 @@
"""EvoScientist Agent graph construction.
This module creates and exports the compiled agent graph.
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 agent
from EvoScientist import EvoScientist_agent
# Notebook / programmatic usage
for state in agent.stream(
for state in EvoScientist_agent.stream(
{"messages": [HumanMessage(content="your question")]},
config={"configurable": {"thread_id": "1"}},
stream_mode="values",
@@ -17,107 +21,81 @@ import json
from datetime import datetime
from pathlib import Path
from deepagents import create_deep_agent
from deepagents.backends import FilesystemBackend, CompositeBackend
from .backends import CustomSandboxBackend, MergedReadOnlyBackend
from .config import get_effective_config, apply_config_to_env
from .llm import get_chat_model
from .mcp import load_mcp_tools
from .middleware import create_memory_middleware, ToolErrorHandlerMiddleware
from .prompts import RESEARCHER_INSTRUCTIONS, get_system_prompt
from .utils import load_subagents
from .tools import tavily_search, think_tool, skill_manager
from . import paths as _paths_mod
from .paths import set_active_workspace, set_workspace_root
# =============================================================================
# Configuration
# Constants
# =============================================================================
# Load configuration from file/env/defaults
_config = get_effective_config()
apply_config_to_env(_config)
# NOTE: We intentionally do NOT call set_workspace_root() at module level.
# The CLI (commands.py) calls set_workspace_root() *before* importing this
# module. A module-level call here would overwrite the CLI's --workdir
# value with config.default_workdir, violating the priority chain
# (CLI args > config file). Instead, config.default_workdir is applied
# as a fallback inside create_cli_agent() when no explicit workspace_dir
# is provided.
# Research limits (from config)
MAX_CONCURRENT = _config.max_concurrent
MAX_ITERATIONS = _config.max_iterations
# Workspace settings (defer dir creation to CLI; here we just resolve paths)
# Read from the paths module so values reflect any earlier set_workspace_root().
WORKSPACE_DIR = str(_paths_mod.WORKSPACE_ROOT)
set_active_workspace(WORKSPACE_DIR)
MEMORY_DIR = str(_paths_mod.MEMORY_DIR) # Shared across sessions (not per-session)
SKILLS_DIR = str(Path(__file__).parent / "skills")
USER_SKILLS_DIR = str(_paths_mod.USER_SKILLS_DIR)
SUBAGENTS_CONFIG = Path(__file__).parent / "subagent.yaml"
SKILLS_DIR = str(Path(__file__).parent / "skills")
# =============================================================================
# Initialization
# Lazy state — initialized on first use, not at import time
# =============================================================================
# Generate system prompt with limits
SYSTEM_PROMPT = get_system_prompt(
max_concurrent=MAX_CONCURRENT,
max_iterations=MAX_ITERATIONS,
)
# Initialize chat model using the LLM module (respects config settings)
chat_model = get_chat_model(
model=_config.model,
provider=_config.provider,
)
# Initialize workspace backend
_workspace_backend = CustomSandboxBackend(
root_dir=WORKSPACE_DIR,
virtual_mode=True,
timeout=300,
)
# Skills backend: merge user-installed (./skills/) and system (package) skills
_skills_backend = MergedReadOnlyBackend(
primary_dir=USER_SKILLS_DIR, # user-installed, takes priority
secondary_dir=SKILLS_DIR, # package built-in, fallback
)
# Memory backend: persistent filesystem for long-term memory (shared across sessions)
_memory_backend = FilesystemBackend(
root_dir=MEMORY_DIR,
virtual_mode=True,
)
# Composite backend: workspace as default, skills and memory mounted
backend = CompositeBackend(
default=_workspace_backend,
routes={
"/skills/": _skills_backend,
"/memory/": _memory_backend,
},
)
tool_registry = {
"think_tool": think_tool,
"tavily_search": tavily_search,
}
# Base tools that every agent variant gets (before MCP)
BASE_TOOLS = [think_tool, skill_manager]
_config = None
_chat_model = None
_system_prompt = 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
def _ensure_system_prompt():
"""Return cached system prompt, creating it on first call."""
global _system_prompt
if _system_prompt is None:
cfg = _ensure_config()
_system_prompt = get_system_prompt(
max_concurrent=cfg.max_concurrent,
max_iterations=cfg.max_iterations,
)
return _system_prompt
# =============================================================================
# MCP caching
# =============================================================================
def _mcp_config_signature() -> str:
"""Return a stable signature for the effective MCP config."""
@@ -137,6 +115,8 @@ 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 = _mcp_config_signature()
if not cfg_key:
_MCP_TOOLS_CACHE_KEY = ""
@@ -152,6 +132,11 @@ def _load_mcp_tools_cached() -> dict[str, list]:
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.
@@ -159,12 +144,29 @@ def _inject_subagent_middleware(subs: list[dict]) -> None:
ToolNode handler which produces terse messages without tracebacks or
retry guidance — reducing the subagent's ability to self-recover.
"""
from .middleware import ToolErrorHandlerMiddleware
for sa in subs:
sa.setdefault("middleware", []).append(ToolErrorHandlerMiddleware())
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 .utils import load_subagents
from .tools import tavily_search, think_tool, skill_manager
tool_registry = {"think_tool": think_tool, "tavily_search": tavily_search}
base_tools = [think_tool, skill_manager]
subs = load_subagents(
SUBAGENTS_CONFIG,
tool_registry=tool_registry,
@@ -173,12 +175,12 @@ def _build_base_kwargs(base_backend, base_middleware):
_inject_subagent_middleware(subs)
return dict(
name="EvoScientist",
model=chat_model,
tools=list(BASE_TOOLS),
model=_ensure_chat_model(),
tools=list(base_tools),
backend=base_backend,
subagents=subs,
middleware=base_middleware,
system_prompt=SYSTEM_PROMPT,
system_prompt=_ensure_system_prompt(),
skills=["/skills/"],
)
@@ -189,10 +191,16 @@ def load_mcp_and_build_kwargs(base_backend, base_middleware):
Re-connects to MCP servers only when the effective MCP config changes.
Falls back to base kwargs if no MCP configured.
"""
from .utils import load_subagents
from .tools import tavily_search, think_tool, skill_manager
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, "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():
@@ -216,40 +224,73 @@ def load_mcp_and_build_kwargs(base_backend, base_middleware):
return dict(
name="EvoScientist",
model=chat_model,
tools=BASE_TOOLS + mcp_main,
model=_ensure_chat_model(),
tools=base_tools + mcp_main,
backend=base_backend,
subagents=subs,
middleware=base_middleware,
system_prompt=SYSTEM_PROMPT,
system_prompt=_ensure_system_prompt(),
skills=["/skills/"],
)
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"),
),
}
# =============================================================================
# Default agent (langgraph dev / notebooks)
# =============================================================================
base_middleware = [
ToolErrorHandlerMiddleware(),
create_memory_middleware(MEMORY_DIR, extraction_model=chat_model),
]
# 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
def _get_default_backend():
"""Build the default composite backend from current paths."""
from deepagents.backends import FilesystemBackend, CompositeBackend
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 create_memory_middleware, ToolErrorHandlerMiddleware
memory_dir = str(_paths_mod.MEMORY_DIR)
return [
ToolErrorHandlerMiddleware(),
create_memory_middleware(memory_dir, extraction_model=_ensure_chat_model()),
]
def _get_default_agent():
"""Build the default agent (with MCP, no checkpointer) on first access."""
global _EvoScientist_agent
if _EvoScientist_agent is None:
kwargs = load_mcp_and_build_kwargs(backend, base_middleware)
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": 500}
)
@@ -259,10 +300,22 @@ def _get_default_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 _ensure_system_prompt()
if name == "backend":
return _get_default_backend()
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
def create_cli_agent(workspace_dir: str | None = None, checkpointer=None):
# =============================================================================
# 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
@@ -274,10 +327,20 @@ def create_cli_agent(workspace_dir: str | None = None, checkpointer=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 FilesystemBackend, CompositeBackend
from .backends import CustomSandboxBackend, MergedReadOnlyBackend
from .middleware import create_memory_middleware, ToolErrorHandlerMiddleware
from . import paths as _paths
cfg = _ensure_config(config)
if checkpointer is None:
from langgraph.checkpoint.memory import InMemorySaver # type: ignore[import-untyped]
checkpointer = InMemorySaver()
@@ -287,9 +350,9 @@ def create_cli_agent(workspace_dir: str | None = None, checkpointer=None):
# 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 _config.default_workdir:
if cfg.default_workdir:
set_workspace_root(
_os.path.abspath(_os.path.expanduser(_config.default_workdir))
_os.path.abspath(_os.path.expanduser(cfg.default_workdir))
)
workspace_dir = str(_paths.WORKSPACE_ROOT)
@@ -324,7 +387,7 @@ def create_cli_agent(workspace_dir: str | None = None, checkpointer=None):
mw = [
ToolErrorHandlerMiddleware(),
create_memory_middleware(_mem_dir, extraction_model=chat_model),
create_memory_middleware(_mem_dir, extraction_model=_ensure_chat_model()),
]
# Re-load MCP tools from current config (picks up /mcp add changes)
+4 -2
View File
@@ -52,13 +52,15 @@ def _create_session_workspace(name: str | None = None) -> str:
return workspace_dir
def _load_agent(workspace_dir: str | None = None, checkpointer=None):
def _load_agent(workspace_dir: str | None = None, checkpointer=None, config=None):
"""Load the CLI agent with optional persistent checkpointer.
Args:
workspace_dir: Optional per-session workspace directory.
checkpointer: Optional LangGraph checkpointer (e.g. ``AsyncSqliteSaver``).
Falls back to ``InMemorySaver`` when ``None``.
config: Optional pre-loaded ``EvoScientistConfig``. Forwarded to
``create_cli_agent`` to avoid double config loading.
"""
from ..EvoScientist import create_cli_agent
return create_cli_agent(workspace_dir=workspace_dir, checkpointer=checkpointer)
return create_cli_agent(workspace_dir=workspace_dir, checkpointer=checkpointer, config=config)
+3 -2
View File
@@ -122,7 +122,7 @@ def serve(
ensure_dirs()
console.print("[dim]Loading agent...[/dim]")
agent = _load_agent(workspace_dir=ws)
agent = _load_agent(workspace_dir=ws, config=config)
tid = str(uuid.uuid4())
_start_channels_bus_mode(
@@ -532,7 +532,7 @@ def _main_callback(
async def _single_shot():
async with get_checkpointer() as checkpointer:
console.print("[dim]Loading agent...[/dim]")
agent = _load_agent(workspace_dir=workspace_dir, checkpointer=checkpointer)
agent = _load_agent(workspace_dir=workspace_dir, checkpointer=checkpointer, config=config)
tid = thread_id or generate_thread_id()
cmd_run(
agent,
@@ -560,6 +560,7 @@ def _main_callback(
run_name=name,
thread_id=thread_id,
ui_backend=config.ui_backend,
config=config,
)
+10 -7
View File
@@ -163,6 +163,7 @@ def cmd_interactive(
run_name: str | None = None,
thread_id: str | None = None,
ui_backend: str = "rich",
config=None,
) -> None:
"""Interactive conversation mode with streaming output.
@@ -186,6 +187,8 @@ def cmd_interactive(
resolved_ui_backend = resolve_ui_backend(ui_backend, warn_fallback=True)
if resolved_ui_backend == "textual":
from functools import partial
load_agent = partial(_load_agent, config=config)
run_textual_interactive(
show_thinking=show_thinking,
channel_send_thinking=channel_send_thinking,
@@ -196,7 +199,7 @@ def cmd_interactive(
provider=provider,
run_name=run_name,
thread_id=thread_id,
load_agent=_load_agent,
load_agent=load_agent,
create_session_workspace=_create_session_workspace,
)
return
@@ -382,7 +385,7 @@ def cmd_interactive(
if ws:
state["workspace_dir"] = ws
console.print("[dim]Loading session...[/dim]")
state["agent"] = _load_agent(workspace_dir=state["workspace_dir"], checkpointer=checkpointer)
state["agent"] = _load_agent(workspace_dir=state["workspace_dir"], checkpointer=checkpointer, config=config)
# Sync shared refs if channel is running
if _channels_is_running():
_ch_mod._cli_agent = state["agent"]
@@ -425,7 +428,7 @@ def cmd_interactive(
state["workspace_dir"] = ws
console.print("[dim]Loading agent...[/dim]")
state["agent"] = _load_agent(workspace_dir=state["workspace_dir"], checkpointer=checkpointer)
state["agent"] = _load_agent(workspace_dir=state["workspace_dir"], checkpointer=checkpointer, config=config)
# Print banner
if state["resumed"]:
@@ -561,12 +564,12 @@ def cmd_interactive(
# Auto-start channel if enabled in config
from ..config import load_config
config = load_config()
if config and config.channel_enabled and not _channels_is_running():
_channel_cfg = load_config()
if _channel_cfg and _channel_cfg.channel_enabled and not _channels_is_running():
_auto_start_channel(
state["agent"],
state["thread_id"],
config,
_channel_cfg,
send_thinking=channel_send_thinking,
)
@@ -616,7 +619,7 @@ def cmd_interactive(
if not workspace_fixed:
state["workspace_dir"] = _create_session_workspace(run_name)
console.print("[dim]Loading new session...[/dim]")
state["agent"] = _load_agent(workspace_dir=state["workspace_dir"], checkpointer=checkpointer)
state["agent"] = _load_agent(workspace_dir=state["workspace_dir"], checkpointer=checkpointer, config=config)
state["thread_id"] = generate_thread_id()
state["resumed"] = False
# Sync channel refs so the queue checker uses the new agent
-1
View File
@@ -920,7 +920,6 @@ def run_textual_interactive(
metadata=msg.metadata,
),
"Media",
timeout=30,
)
response = ""
+11 -2
View File
@@ -3,6 +3,9 @@
Re-exports all public symbols from settings and onboard submodules
so that existing ``from EvoScientist.config import X`` imports continue
to work without modification.
The onboard module is loaded lazily because it pulls in heavy dependencies
(langchain, llm) that are not needed for normal config operations.
"""
from .settings import (
@@ -18,7 +21,6 @@ from .settings import (
get_effective_config,
apply_config_to_env,
)
from .onboard import run_onboard
__all__ = [
# settings
@@ -33,6 +35,13 @@ __all__ = [
"list_config",
"get_effective_config",
"apply_config_to_env",
# onboard
# onboard (lazy)
"run_onboard",
]
def __getattr__(name: str):
if name == "run_onboard":
from .onboard import run_onboard
return run_onboard
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+2 -2
View File
@@ -27,7 +27,7 @@ class TestMcpToolCaching:
calls["load"] += 1
return {"main": [tool]}
monkeypatch.setattr(agent_module, "load_mcp_tools", fake_load_mcp_tools)
monkeypatch.setattr("EvoScientist.mcp.load_mcp_tools", fake_load_mcp_tools)
first = agent_module._load_mcp_tools_cached()
second = agent_module._load_mcp_tools_cached()
@@ -49,7 +49,7 @@ class TestMcpToolCaching:
return {"main": [f"tool-v{calls['load']}"]}
monkeypatch.setattr("EvoScientist.mcp.client.load_mcp_config", fake_load_config)
monkeypatch.setattr(agent_module, "load_mcp_tools", fake_load_mcp_tools)
monkeypatch.setattr("EvoScientist.mcp.load_mcp_tools", fake_load_mcp_tools)
first = agent_module._load_mcp_tools_cached()
state["cfg"] = {"srv": {"transport": "stdio", "command": "v2"}}
+1 -1
View File
@@ -40,7 +40,7 @@ def _run_serve_once(
def _fake_ensure_dirs():
order.append(("ensure_dirs", None))
def _fake_load_agent(workspace_dir=None, checkpointer=None):
def _fake_load_agent(workspace_dir=None, checkpointer=None, config=None):
captured["workspace_dir"] = workspace_dir
return object()