Merge remote-tracking branch 'origin/main' into feature/channel-unification
# Conflicts: # EvoScientist/EvoScientist.py # EvoScientist/channels/imessage/serve.py # EvoScientist/cli/commands.py
This commit is contained in:
+108
-44
@@ -13,6 +13,7 @@ Usage:
|
||||
...
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
@@ -27,12 +28,8 @@ from .middleware import create_memory_middleware, create_skills_middleware
|
||||
from .prompts import RESEARCHER_INSTRUCTIONS, get_system_prompt
|
||||
from .utils import load_subagents
|
||||
from .tools import tavily_search, think_tool, skill_manager, view_image
|
||||
from .paths import (
|
||||
default_workspace_dir,
|
||||
set_active_workspace,
|
||||
MEMORY_DIR as _MEMORY_DIR_PATH,
|
||||
USER_SKILLS_DIR as _USER_SKILLS_DIR_PATH,
|
||||
)
|
||||
from . import paths as _paths_mod
|
||||
from .paths import set_active_workspace, set_workspace_root
|
||||
|
||||
# =============================================================================
|
||||
# Configuration
|
||||
@@ -42,16 +39,25 @@ from .paths import (
|
||||
_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)
|
||||
WORKSPACE_DIR = str(default_workspace_dir())
|
||||
# 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(_MEMORY_DIR_PATH) # Shared across sessions (not per-session)
|
||||
MEMORY_DIR = str(_paths_mod.MEMORY_DIR) # Shared across sessions (not per-session)
|
||||
SKILLS_DIR = str(Path(__file__).parent / "skills")
|
||||
USER_SKILLS_DIR = str(_USER_SKILLS_DIR_PATH)
|
||||
USER_SKILLS_DIR = str(_paths_mod.USER_SKILLS_DIR)
|
||||
SUBAGENTS_CONFIG = Path(__file__).parent / "subagent.yaml"
|
||||
|
||||
# =============================================================================
|
||||
@@ -110,6 +116,44 @@ tool_registry = {
|
||||
# Base tools that every agent variant gets (before MCP)
|
||||
BASE_TOOLS = [think_tool, skill_manager, view_image]
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
def _mcp_config_signature() -> str:
|
||||
"""Return a stable signature for the effective MCP config."""
|
||||
from .mcp.client import load_mcp_config
|
||||
|
||||
cfg = load_mcp_config()
|
||||
if not cfg:
|
||||
return ""
|
||||
try:
|
||||
return json.dumps(cfg, sort_keys=True, ensure_ascii=True)
|
||||
except TypeError:
|
||||
# Fallback for non-JSON-serializable values (should be rare)
|
||||
return repr(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
|
||||
|
||||
cfg_key = _mcp_config_signature()
|
||||
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()
|
||||
_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()}
|
||||
|
||||
|
||||
def _build_base_kwargs(base_backend, base_middleware):
|
||||
"""Build agent kwargs *without* MCP (fast, no subprocess spawning)."""
|
||||
@@ -131,12 +175,12 @@ def _build_base_kwargs(base_backend, base_middleware):
|
||||
|
||||
|
||||
def load_mcp_and_build_kwargs(base_backend, base_middleware):
|
||||
"""(Re-)load MCP tools and build agent kwargs.
|
||||
"""Load MCP tools (cached by config) and build agent kwargs.
|
||||
|
||||
Called on every ``create_cli_agent()`` call so that ``/new`` picks up
|
||||
MCP config changes. Falls back to base kwargs if no MCP configured.
|
||||
Re-connects to MCP servers only when the effective MCP config changes.
|
||||
Falls back to base kwargs if no MCP configured.
|
||||
"""
|
||||
mcp_by_agent = load_mcp_tools()
|
||||
mcp_by_agent = _load_mcp_tools_cached()
|
||||
if not mcp_by_agent:
|
||||
return _build_base_kwargs(base_backend, base_middleware)
|
||||
|
||||
@@ -205,45 +249,65 @@ def __getattr__(name: str):
|
||||
def create_cli_agent(workspace_dir: str | None = None, checkpointer=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: Optional per-session workspace directory. If provided,
|
||||
creates a fresh backend rooted at this path. If None, uses the
|
||||
module-level default backend (./workspace).
|
||||
checkpointer: Optional LangGraph checkpointer. If None, falls back
|
||||
to ``InMemorySaver`` (non-persistent).
|
||||
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).
|
||||
"""
|
||||
import os as _os
|
||||
from . import paths as _paths
|
||||
|
||||
if checkpointer is None:
|
||||
from langgraph.checkpoint.memory import InMemorySaver # type: ignore[import-untyped]
|
||||
checkpointer = InMemorySaver()
|
||||
|
||||
if workspace_dir:
|
||||
set_active_workspace(workspace_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,
|
||||
)
|
||||
# Memory always uses SHARED directory (not per-session) for cross-session persistence
|
||||
mem_backend = FilesystemBackend(
|
||||
root_dir=MEMORY_DIR,
|
||||
virtual_mode=True,
|
||||
)
|
||||
be = CompositeBackend(
|
||||
default=ws_backend,
|
||||
routes={
|
||||
"/skills/": sk_backend,
|
||||
"/memory/": mem_backend,
|
||||
},
|
||||
)
|
||||
else:
|
||||
be = backend
|
||||
# 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 _config.default_workdir:
|
||||
set_workspace_root(
|
||||
_os.path.abspath(_os.path.expanduser(_config.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 = [
|
||||
create_memory_middleware(MEMORY_DIR, extraction_model=chat_model),
|
||||
create_memory_middleware(_mem_dir, extraction_model=chat_model),
|
||||
]
|
||||
|
||||
# Re-load MCP tools from current config (picks up /mcp add changes)
|
||||
|
||||
@@ -4,7 +4,7 @@ import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from ..paths import new_run_dir, RUNS_DIR
|
||||
from ..paths import new_run_dir
|
||||
|
||||
|
||||
def _shorten_path(path: str) -> str:
|
||||
@@ -21,8 +21,11 @@ def _shorten_path(path: str) -> str:
|
||||
return path
|
||||
|
||||
|
||||
def _deduplicate_run_name(name: str, runs_dir: Path = RUNS_DIR) -> str:
|
||||
def _deduplicate_run_name(name: str, runs_dir: Path | None = None) -> str:
|
||||
"""Return *name* if available, otherwise *name_1*, *name_2*, etc."""
|
||||
if runs_dir is None:
|
||||
from ..paths import RUNS_DIR
|
||||
runs_dir = RUNS_DIR
|
||||
if not (runs_dir / name).exists():
|
||||
return name
|
||||
i = 1
|
||||
@@ -40,7 +43,8 @@ def _create_session_workspace(name: str | None = None) -> str:
|
||||
if *name* is None.
|
||||
"""
|
||||
if name:
|
||||
session_id = _deduplicate_run_name(name)
|
||||
from ..paths import RUNS_DIR
|
||||
session_id = _deduplicate_run_name(name, RUNS_DIR)
|
||||
else:
|
||||
session_id = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
workspace_dir = str(new_run_dir(session_id))
|
||||
|
||||
@@ -11,7 +11,7 @@ import typer # type: ignore[import-untyped]
|
||||
from rich.table import Table
|
||||
|
||||
from ..stream.display import console
|
||||
from ..paths import ensure_dirs, default_workspace_dir
|
||||
from ..paths import ensure_dirs, default_workspace_dir, set_workspace_root
|
||||
from ._app import app, config_app, mcp_app, channel_app
|
||||
from .agent import _deduplicate_run_name, _create_session_workspace, _load_agent, _shorten_path
|
||||
from .channel import _channels_stop, _start_channels_bus_mode
|
||||
@@ -456,16 +456,19 @@ def _main_callback(
|
||||
# --use-cwd is kept for backward compat but is now the default behavior
|
||||
if use_cwd:
|
||||
workspace_dir = os.getcwd()
|
||||
set_workspace_root(workspace_dir)
|
||||
workspace_fixed = True
|
||||
elif workdir:
|
||||
workspace_dir = os.path.abspath(os.path.expanduser(workdir))
|
||||
os.makedirs(workspace_dir, exist_ok=True)
|
||||
set_workspace_root(workspace_dir)
|
||||
workspace_fixed = True
|
||||
elif mode:
|
||||
# Explicit --mode overrides default_workdir
|
||||
effective_mode = mode
|
||||
workspace_root = config.default_workdir or os.getcwd()
|
||||
workspace_root = os.path.abspath(os.path.expanduser(workspace_root))
|
||||
set_workspace_root(workspace_root)
|
||||
if effective_mode == "run":
|
||||
runs_dir = Path(workspace_root, "runs")
|
||||
session_id = _deduplicate_run_name(name, runs_dir) if name else datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
@@ -478,6 +481,7 @@ def _main_callback(
|
||||
elif config.default_workdir:
|
||||
# Use configured default workdir with configured mode
|
||||
workspace_root = os.path.abspath(os.path.expanduser(config.default_workdir))
|
||||
set_workspace_root(workspace_root)
|
||||
effective_mode = config.default_mode
|
||||
if effective_mode == "run":
|
||||
runs_dir = Path(workspace_root, "runs")
|
||||
@@ -490,11 +494,13 @@ def _main_callback(
|
||||
workspace_fixed = True
|
||||
else:
|
||||
effective_mode = config.default_mode
|
||||
workspace_root = os.getcwd()
|
||||
set_workspace_root(workspace_root)
|
||||
if effective_mode == "run":
|
||||
workspace_dir = _create_session_workspace(name)
|
||||
workspace_fixed = False
|
||||
else: # daemon mode (default) — use current directory
|
||||
workspace_dir = os.getcwd()
|
||||
workspace_dir = workspace_root
|
||||
workspace_fixed = True
|
||||
|
||||
# Ensure memory and skills subdirs exist in workspace
|
||||
|
||||
@@ -197,8 +197,8 @@ def cmd_interactive(
|
||||
import nest_asyncio
|
||||
nest_asyncio.apply()
|
||||
|
||||
from ..EvoScientist import MEMORY_DIR
|
||||
memory_dir = MEMORY_DIR
|
||||
from .. import paths
|
||||
memory_dir = str(paths.MEMORY_DIR)
|
||||
|
||||
history_file = str(os.path.expanduser("~/.EvoScientist_history"))
|
||||
session = PromptSession(
|
||||
|
||||
@@ -429,7 +429,9 @@ def _prompt_and_validate_api_key(
|
||||
raise KeyboardInterrupt()
|
||||
|
||||
new_key = new_key.strip()
|
||||
key_to_validate = new_key if new_key else current
|
||||
|
||||
# Determine which key to validate: new input or existing
|
||||
key_to_validate = new_key or current
|
||||
|
||||
if not key_to_validate:
|
||||
return None
|
||||
@@ -443,6 +445,7 @@ def _prompt_and_validate_api_key(
|
||||
else:
|
||||
console.print(f"\r [red]\u2717 {msg}[/red] ")
|
||||
if not new_key:
|
||||
# Existing key is invalid — warn but keep (user didn't change it)
|
||||
return None
|
||||
save_anyway = questionary.confirm(
|
||||
"Save anyway?",
|
||||
@@ -671,8 +674,12 @@ def _step_workspace(config: EvoScientistConfig) -> tuple[str, str]:
|
||||
|
||||
# Custom workdir (optional)
|
||||
current_default = config.default_workdir or ""
|
||||
if current_default:
|
||||
prompt_text = f"Workspace directory (Enter to keep '{current_default}'):"
|
||||
else:
|
||||
prompt_text = f"Workspace directory (Enter to use ./{cwd_short}/):"
|
||||
workdir = questionary.text(
|
||||
f"Workspace directory (Enter to use ./{cwd_short}/):",
|
||||
prompt_text,
|
||||
default=current_default,
|
||||
style=WIZARD_STYLE,
|
||||
qmark=QMARK,
|
||||
|
||||
@@ -52,7 +52,7 @@ class EvoScientistConfig:
|
||||
provider: Default LLM provider ('anthropic', 'openai', 'google-genai', or 'nvidia').
|
||||
model: Default model name (short name or full ID).
|
||||
default_mode: Default workspace mode ('daemon' or 'run').
|
||||
default_workdir: Default workspace directory (empty = use ./workspace).
|
||||
default_workdir: Default workspace directory (empty = use current working directory).
|
||||
max_concurrent: Maximum concurrent sub-agents.
|
||||
max_iterations: Maximum delegation iterations.
|
||||
show_thinking: Whether to show thinking panels in CLI.
|
||||
|
||||
@@ -26,6 +26,21 @@ MEMORY_DIR = _env_path("EVOSCIENTIST_MEMORY_DIR") or (WORKSPACE_ROOT / "memory")
|
||||
USER_SKILLS_DIR = _env_path("EVOSCIENTIST_SKILLS_DIR") or (WORKSPACE_ROOT / "skills")
|
||||
|
||||
|
||||
def set_workspace_root(path: str | Path) -> None:
|
||||
"""Update workspace root and re-derive dependent directories.
|
||||
|
||||
Directories with an explicit environment-variable override keep their
|
||||
env-var value; all others are re-derived from the new root.
|
||||
Also resets ``_active_workspace`` to the new root as a safe default.
|
||||
"""
|
||||
global WORKSPACE_ROOT, RUNS_DIR, MEMORY_DIR, USER_SKILLS_DIR, _active_workspace
|
||||
WORKSPACE_ROOT = Path(path).resolve()
|
||||
_active_workspace = WORKSPACE_ROOT
|
||||
RUNS_DIR = _env_path("EVOSCIENTIST_RUNS_DIR") or (WORKSPACE_ROOT / "runs")
|
||||
MEMORY_DIR = _env_path("EVOSCIENTIST_MEMORY_DIR") or (WORKSPACE_ROOT / "memory")
|
||||
USER_SKILLS_DIR = _env_path("EVOSCIENTIST_SKILLS_DIR") or (WORKSPACE_ROOT / "skills")
|
||||
|
||||
|
||||
def ensure_dirs() -> None:
|
||||
"""Create runtime subdirectories (memory, skills) if they do not exist.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Skill installation and management for EvoScientist.
|
||||
|
||||
This module provides functions for installing, listing, and uninstalling user skills.
|
||||
Skills are installed to USER_SKILLS_DIR (./workspace/skills/).
|
||||
Skills are installed to USER_SKILLS_DIR (defaults to <workspace>/skills/).
|
||||
|
||||
Supported installation sources:
|
||||
- Local directory paths
|
||||
@@ -37,7 +37,7 @@ from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from ..paths import USER_SKILLS_DIR
|
||||
from .. import paths
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -220,7 +220,7 @@ def install_skill(source: str, dest_dir: str | None = None) -> dict:
|
||||
- path: installed path (if successful)
|
||||
- error: error message (if failed)
|
||||
"""
|
||||
dest_dir = dest_dir or str(USER_SKILLS_DIR)
|
||||
dest_dir = dest_dir or str(paths.USER_SKILLS_DIR)
|
||||
os.makedirs(dest_dir, exist_ok=True)
|
||||
|
||||
if _is_github_url(source):
|
||||
@@ -370,7 +370,7 @@ def list_skills(include_system: bool = False) -> list[SkillInfo]:
|
||||
skills: list[SkillInfo] = []
|
||||
|
||||
# User skills
|
||||
user_dir = Path(USER_SKILLS_DIR)
|
||||
user_dir = Path(paths.USER_SKILLS_DIR)
|
||||
if user_dir.exists():
|
||||
for entry in sorted(user_dir.iterdir()):
|
||||
if entry.is_dir() and _validate_skill_dir(entry):
|
||||
@@ -421,7 +421,7 @@ def uninstall_skill(name: str) -> dict:
|
||||
- success: bool
|
||||
- error: error message (if failed)
|
||||
"""
|
||||
user_dir = Path(USER_SKILLS_DIR).resolve()
|
||||
user_dir = Path(paths.USER_SKILLS_DIR).resolve()
|
||||
|
||||
# Validate name to prevent path traversal
|
||||
clean_name = _sanitize_name(name)
|
||||
|
||||
@@ -239,25 +239,27 @@ The channel can also be started manually with `/channel` in the interactive CLI.
|
||||
|
||||
### Runtime Directories
|
||||
|
||||
By default, the **workspace** is created under the current directory:
|
||||
By default, the **workspace root** is the current working directory. Sub-directories
|
||||
are created automatically:
|
||||
|
||||
```
|
||||
./workspace/
|
||||
<cwd>/
|
||||
memory/ # shared MEMORY.md (persistent across sessions)
|
||||
skills/ # user-installed skills
|
||||
runs/ # per-session workspaces
|
||||
runs/ # per-session workspaces (run mode only)
|
||||
```
|
||||
|
||||
You can force workspace to be the current directory via `--use-cwd`.
|
||||
Use `--workdir` to set a different workspace root, or configure it via
|
||||
`EvoSci config set default_workdir /path/to/workspace`.
|
||||
|
||||
Override individual paths via environment variables:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `EVOSCIENTIST_WORKSPACE_DIR` | `./workspace` | Root workspace directory |
|
||||
| `EVOSCIENTIST_RUNS_DIR` | `./workspace/runs` | Per-session run directories |
|
||||
| `EVOSCIENTIST_MEMORY_DIR` | `./workspace/memory` | Shared memory storage |
|
||||
| `EVOSCIENTIST_SKILLS_DIR` | `./workspace/skills` | User-installed skills |
|
||||
| `EVOSCIENTIST_WORKSPACE_DIR` | current directory | Root workspace directory |
|
||||
| `EVOSCIENTIST_RUNS_DIR` | `<workspace>/runs` | Per-session run directories |
|
||||
| `EVOSCIENTIST_MEMORY_DIR` | `<workspace>/memory` | Shared memory storage |
|
||||
| `EVOSCIENTIST_SKILLS_DIR` | `<workspace>/skills` | User-installed skills |
|
||||
|
||||
### Script Inference
|
||||
```python
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Tests for MCP tool caching in EvoScientist.EvoScientist."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import EvoScientist.EvoScientist as agent_module
|
||||
|
||||
|
||||
def _reset_mcp_cache() -> None:
|
||||
agent_module._MCP_TOOLS_CACHE_KEY = None
|
||||
agent_module._MCP_TOOLS_CACHE_VALUE = None
|
||||
|
||||
|
||||
class TestMcpToolCaching:
|
||||
def setup_method(self) -> None:
|
||||
_reset_mcp_cache()
|
||||
|
||||
def test_reuses_cached_tools_when_config_unchanged(self, monkeypatch):
|
||||
calls = {"load": 0}
|
||||
tool = object()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"EvoScientist.mcp.client.load_mcp_config",
|
||||
lambda: {"srv": {"transport": "stdio", "command": "demo"}},
|
||||
)
|
||||
|
||||
def fake_load_mcp_tools():
|
||||
calls["load"] += 1
|
||||
return {"main": [tool]}
|
||||
|
||||
monkeypatch.setattr(agent_module, "load_mcp_tools", fake_load_mcp_tools)
|
||||
|
||||
first = agent_module._load_mcp_tools_cached()
|
||||
second = agent_module._load_mcp_tools_cached()
|
||||
|
||||
assert calls["load"] == 1
|
||||
assert first == second
|
||||
assert first is not second
|
||||
assert first["main"] is not second["main"]
|
||||
|
||||
def test_reload_when_config_changes(self, monkeypatch):
|
||||
calls = {"load": 0}
|
||||
state = {"cfg": {"srv": {"transport": "stdio", "command": "v1"}}}
|
||||
|
||||
def fake_load_config():
|
||||
return state["cfg"]
|
||||
|
||||
def fake_load_mcp_tools():
|
||||
calls["load"] += 1
|
||||
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)
|
||||
|
||||
first = agent_module._load_mcp_tools_cached()
|
||||
state["cfg"] = {"srv": {"transport": "stdio", "command": "v2"}}
|
||||
second = agent_module._load_mcp_tools_cached()
|
||||
|
||||
assert calls["load"] == 2
|
||||
assert first != second
|
||||
@@ -283,6 +283,45 @@ class TestStepWorkspace:
|
||||
assert result == ("run", "/custom/path")
|
||||
|
||||
|
||||
class TestPromptAndValidateApiKey:
|
||||
def test_keep_existing_key_still_validates(self):
|
||||
"""Pressing Enter to keep current key should validate the existing key."""
|
||||
from EvoScientist.config.onboard import _prompt_and_validate_api_key
|
||||
|
||||
validate_fn = Mock(return_value=(True, "Valid"))
|
||||
|
||||
with patch("EvoScientist.config.onboard.questionary") as mock_q, \
|
||||
patch("EvoScientist.config.onboard.console"):
|
||||
mock_q.password.return_value.ask.return_value = "" # keep existing
|
||||
result = _prompt_and_validate_api_key(
|
||||
"Enter key:",
|
||||
current="existing-key",
|
||||
validate_fn=validate_fn,
|
||||
skip_validation=False,
|
||||
)
|
||||
|
||||
assert result is None # None means "keep existing, don't overwrite"
|
||||
validate_fn.assert_called_once_with("existing-key")
|
||||
|
||||
def test_new_key_still_validates(self):
|
||||
"""Entering a new key should still run validation."""
|
||||
from EvoScientist.config.onboard import _prompt_and_validate_api_key
|
||||
|
||||
validate_fn = Mock(return_value=(True, "valid"))
|
||||
|
||||
with patch("EvoScientist.config.onboard.questionary") as mock_q:
|
||||
mock_q.password.return_value.ask.return_value = "new-key"
|
||||
result = _prompt_and_validate_api_key(
|
||||
"Enter key:",
|
||||
current="old-key",
|
||||
validate_fn=validate_fn,
|
||||
skip_validation=False,
|
||||
)
|
||||
|
||||
assert result == "new-key"
|
||||
validate_fn.assert_called_once_with("new-key")
|
||||
|
||||
|
||||
class TestValidateImessage:
|
||||
def test_valid_when_cli_found_with_rpc(self):
|
||||
"""Test validate_imessage returns valid when imsg CLI found and RPC works."""
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Tests for EvoScientist.paths — set_workspace_root and ensure_dirs."""
|
||||
|
||||
import os
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from EvoScientist import paths
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _restore_paths():
|
||||
"""Snapshot module-level path globals and restore after each test."""
|
||||
orig = {
|
||||
"WORKSPACE_ROOT": paths.WORKSPACE_ROOT,
|
||||
"RUNS_DIR": paths.RUNS_DIR,
|
||||
"MEMORY_DIR": paths.MEMORY_DIR,
|
||||
"USER_SKILLS_DIR": paths.USER_SKILLS_DIR,
|
||||
"_active_workspace": paths._active_workspace,
|
||||
}
|
||||
yield
|
||||
paths.WORKSPACE_ROOT = orig["WORKSPACE_ROOT"]
|
||||
paths.RUNS_DIR = orig["RUNS_DIR"]
|
||||
paths.MEMORY_DIR = orig["MEMORY_DIR"]
|
||||
paths.USER_SKILLS_DIR = orig["USER_SKILLS_DIR"]
|
||||
paths._active_workspace = orig["_active_workspace"]
|
||||
|
||||
|
||||
class TestSetWorkspaceRoot:
|
||||
"""Tests for set_workspace_root()."""
|
||||
|
||||
def test_updates_derived_dirs(self, tmp_path):
|
||||
"""set_workspace_root should update WORKSPACE_ROOT and all derived dirs."""
|
||||
new_root = tmp_path / "my_workspace"
|
||||
new_root.mkdir()
|
||||
|
||||
paths.set_workspace_root(new_root)
|
||||
|
||||
assert paths.WORKSPACE_ROOT == new_root.resolve()
|
||||
assert paths.RUNS_DIR == new_root.resolve() / "runs"
|
||||
assert paths.MEMORY_DIR == new_root.resolve() / "memory"
|
||||
assert paths.USER_SKILLS_DIR == new_root.resolve() / "skills"
|
||||
|
||||
def test_resets_active_workspace(self, tmp_path):
|
||||
"""set_workspace_root should reset _active_workspace to new root."""
|
||||
new_root = tmp_path / "ws"
|
||||
new_root.mkdir()
|
||||
|
||||
# Set active workspace to something different first
|
||||
paths._active_workspace = tmp_path / "other"
|
||||
|
||||
paths.set_workspace_root(new_root)
|
||||
|
||||
assert paths._active_workspace == new_root.resolve()
|
||||
|
||||
def test_preserves_env_overrides(self, tmp_path):
|
||||
"""Dirs set via env vars should NOT be overwritten by set_workspace_root."""
|
||||
custom_mem = tmp_path / "custom_memory"
|
||||
custom_skills = tmp_path / "custom_skills"
|
||||
custom_runs = tmp_path / "custom_runs"
|
||||
|
||||
env = {
|
||||
"EVOSCIENTIST_MEMORY_DIR": str(custom_mem),
|
||||
"EVOSCIENTIST_SKILLS_DIR": str(custom_skills),
|
||||
"EVOSCIENTIST_RUNS_DIR": str(custom_runs),
|
||||
}
|
||||
|
||||
new_root = tmp_path / "ws"
|
||||
new_root.mkdir()
|
||||
|
||||
with mock.patch.dict(os.environ, env):
|
||||
paths.set_workspace_root(new_root)
|
||||
|
||||
# WORKSPACE_ROOT and _active_workspace should still update
|
||||
assert paths.WORKSPACE_ROOT == new_root.resolve()
|
||||
assert paths._active_workspace == new_root.resolve()
|
||||
|
||||
# Derived dirs should reflect the env overrides, not the new root
|
||||
assert paths.MEMORY_DIR == custom_mem.expanduser()
|
||||
assert paths.USER_SKILLS_DIR == custom_skills.expanduser()
|
||||
assert paths.RUNS_DIR == custom_runs.expanduser()
|
||||
|
||||
def test_accepts_string_path(self, tmp_path):
|
||||
"""set_workspace_root should accept str as well as Path."""
|
||||
new_root = tmp_path / "str_ws"
|
||||
new_root.mkdir()
|
||||
|
||||
paths.set_workspace_root(str(new_root))
|
||||
|
||||
assert paths.WORKSPACE_ROOT == new_root.resolve()
|
||||
|
||||
|
||||
class TestEnsureDirsUsesUpdatedPaths:
|
||||
"""ensure_dirs should create dirs at the currently set paths."""
|
||||
|
||||
def test_ensure_dirs_uses_updated_paths(self, tmp_path):
|
||||
"""After set_workspace_root, ensure_dirs creates dirs at new location."""
|
||||
new_root = tmp_path / "workspace"
|
||||
new_root.mkdir()
|
||||
|
||||
paths.set_workspace_root(new_root)
|
||||
paths.ensure_dirs()
|
||||
|
||||
assert (new_root / "memory").is_dir()
|
||||
assert (new_root / "skills").is_dir()
|
||||
@@ -272,7 +272,7 @@ class TestListSkills:
|
||||
"""Tests for list_skills function."""
|
||||
|
||||
def test_list_empty_dir(self, temp_skills_dir):
|
||||
with patch("EvoScientist.tools.skills_manager.USER_SKILLS_DIR", temp_skills_dir):
|
||||
with patch("EvoScientist.paths.USER_SKILLS_DIR", temp_skills_dir):
|
||||
skills = list_skills(include_system=False)
|
||||
assert skills == []
|
||||
|
||||
@@ -280,7 +280,7 @@ class TestListSkills:
|
||||
# Install a skill
|
||||
install_skill(str(sample_skill_dir), str(temp_skills_dir))
|
||||
|
||||
with patch("EvoScientist.tools.skills_manager.USER_SKILLS_DIR", temp_skills_dir):
|
||||
with patch("EvoScientist.paths.USER_SKILLS_DIR", temp_skills_dir):
|
||||
skills = list_skills(include_system=False)
|
||||
|
||||
assert len(skills) == 1
|
||||
@@ -302,7 +302,7 @@ description: Skill number {i}
|
||||
)
|
||||
install_skill(str(skill_dir), str(temp_skills_dir))
|
||||
|
||||
with patch("EvoScientist.tools.skills_manager.USER_SKILLS_DIR", temp_skills_dir):
|
||||
with patch("EvoScientist.paths.USER_SKILLS_DIR", temp_skills_dir):
|
||||
skills = list_skills(include_system=False)
|
||||
|
||||
assert len(skills) == 3
|
||||
@@ -324,7 +324,7 @@ class TestUninstallSkill:
|
||||
# Install first
|
||||
install_skill(str(sample_skill_dir), str(temp_skills_dir))
|
||||
|
||||
with patch("EvoScientist.tools.skills_manager.USER_SKILLS_DIR", temp_skills_dir):
|
||||
with patch("EvoScientist.paths.USER_SKILLS_DIR", temp_skills_dir):
|
||||
result = uninstall_skill("sample-skill")
|
||||
|
||||
assert result["success"] is True
|
||||
@@ -334,7 +334,7 @@ class TestUninstallSkill:
|
||||
assert not skill_path.exists()
|
||||
|
||||
def test_uninstall_nonexistent_skill(self, temp_skills_dir):
|
||||
with patch("EvoScientist.tools.skills_manager.USER_SKILLS_DIR", temp_skills_dir):
|
||||
with patch("EvoScientist.paths.USER_SKILLS_DIR", temp_skills_dir):
|
||||
result = uninstall_skill("nonexistent-skill")
|
||||
|
||||
assert result["success"] is False
|
||||
|
||||
Reference in New Issue
Block a user