2 Commits

Author SHA1 Message Date
m4 4ffae3c182 EvoScientist Ai4Sci 2026-07-14 17:57:34 +08:00
dinos 49770949da fix(langgraph): prefer executable in venv over path (#341) 2026-07-11 11:59:27 +00:00
21 changed files with 1438 additions and 53 deletions
+57 -20
View File
@@ -641,9 +641,13 @@ def _get_default_middleware(
*,
for_async_subagent: bool = False,
workspace_dir: str | Path | None = None,
memory_dir: str | Path | None = None,
cfg=None,
chat_model=None,
memory_source_agent: str = "EvoScientist",
tool_selector_threshold: int | None = None,
memory_max_inline_profile_chars: int | None = None,
enable_background_execution: bool = True,
):
"""Build the default middleware list.
@@ -684,7 +688,7 @@ def _get_default_middleware(
if cfg.model_fallbacks:
load_fallback_chain(cfg.model_fallbacks)
model = chat_model if chat_model is not None else _ensure_chat_model()
memory_dir = str(_paths_mod.MEMORIES_DIR)
memory_dir = str(memory_dir or _paths_mod.MEMORIES_DIR)
source_type = (
MemorySourceType.SUBAGENT if for_async_subagent else MemorySourceType.TURN
)
@@ -699,18 +703,20 @@ def _get_default_middleware(
# ``ModelFallbackMiddleware``: a configurable.model override sets the
# PRIMARY model only, leaving the fallback chain free to try its own
# alternatives instead of re-overriding every retry to the same model.
memory_middleware = create_memory_middleware(
memory_dir,
workspace_dir=workspace_dir,
source_type=source_type,
source_agent=memory_source_agent,
enable_profile_memory=memory_controls.profile_enabled,
enable_observation_memory=memory_controls.observations_enabled,
enable_observation_tool=memory_controls.observation_tool_enabled(
memory_kwargs = {
"workspace_dir": workspace_dir,
"source_type": source_type,
"source_agent": memory_source_agent,
"enable_profile_memory": memory_controls.profile_enabled,
"enable_observation_memory": memory_controls.observations_enabled,
"enable_observation_tool": memory_controls.observation_tool_enabled(
MemoryObservationTarget.AGENT
),
memory_scheduler=memory_scheduler,
)
"memory_scheduler": memory_scheduler,
}
if memory_max_inline_profile_chars is not None:
memory_kwargs["max_inline_profile_chars"] = memory_max_inline_profile_chars
memory_middleware = create_memory_middleware(memory_dir, **memory_kwargs)
# Main-agent tool selection may use the auxiliary model; async sub-agents
# keep the main model (they do real work, not a one-off helper call).
# context_editing stays on the main model — its model only sizes the
@@ -735,6 +741,11 @@ def _get_default_middleware(
ContextOverflowMapperMiddleware(),
ToolErrorHandlerMiddleware(),
*create_tool_selector_middleware(
**(
{"threshold": tool_selector_threshold}
if tool_selector_threshold is not None
else {}
),
model=tool_selector_model,
track_stream_selection=not for_async_subagent,
),
@@ -770,7 +781,7 @@ def _get_default_middleware(
# Background-process tools (run_in_background / check_process / stop_process /
# list_processes) — main agent only. Async sub-agents run on langgraph-dev and
# must not spawn local OS processes.
if not for_async_subagent:
if not for_async_subagent and enable_background_execution:
from .middleware.background import BackgroundExecutionMiddleware
mw.append(BackgroundExecutionMiddleware())
@@ -868,6 +879,12 @@ def create_cli_agent(
chat_model=None,
*,
on_mcp_progress=None,
workspace_backend=None,
memory_dir: str | Path | None = None,
tool_selector_threshold: int | None = None,
memory_max_inline_profile_chars: int | None = None,
enable_subagents: bool = True,
enable_background_execution: bool = True,
) -> "CompiledStateGraph":
"""Create agent with checkpointer for CLI multi-turn support.
@@ -894,6 +911,16 @@ def create_cli_agent(
chat_model: Optional pre-built chat model. Only triggers the pure
path when ``config`` is also explicit; otherwise it is ignored in
favor of the ``_ensure_chat_model()`` fallback.
workspace_backend: Optional host-provided backend for the workspace
route. The default remains ``CustomSandboxBackend``.
memory_dir: Optional memory root used by both the backend route and
memory middleware.
tool_selector_threshold: Optional adaptive tool-selection threshold.
memory_max_inline_profile_chars: Optional memory profile injection cap.
enable_subagents: Whether configured subagents are available to the agent.
enable_background_execution: Whether local background-process tools are
installed. Embedding hosts should disable this when process execution
is provided by an external backend.
"""
import os as _os
@@ -935,19 +962,21 @@ def create_cli_agent(
workspace_dir = str(_paths.WORKSPACE_ROOT)
# Read paths dynamically so runtime set_workspace_root() changes are picked up
_mem_dir = str(_paths.MEMORIES_DIR)
_mem_dir = str(memory_dir or _paths.MEMORIES_DIR)
_usr_skills_dir = str(_paths.USER_SKILLS_DIR)
_global_skills_dir = str(_paths.GLOBAL_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=cfg.sandbox_execute_timeout,
dangerous=cfg.dangerous_mode,
)
ws_backend = workspace_backend
if ws_backend is None:
ws_backend = CustomSandboxBackend(
root_dir=workspace_dir,
virtual_mode=True,
timeout=cfg.sandbox_execute_timeout,
dangerous=cfg.dangerous_mode,
)
sk_backend = MergedSkillsBackend(
primary_dir=_usr_skills_dir,
global_dir=_global_skills_dir,
@@ -969,7 +998,13 @@ def create_cli_agent(
# CLI agent never drifts from the default chain. Anything CLI-specific
# (e.g. ``HumanInTheLoopMiddleware``) is appended below.
mw: list[AgentMiddleware] = _get_default_middleware(
workspace_dir=workspace_dir, cfg=cfg, chat_model=chat_model
workspace_dir=workspace_dir,
memory_dir=_mem_dir,
cfg=cfg,
chat_model=chat_model,
tool_selector_threshold=tool_selector_threshold,
memory_max_inline_profile_chars=memory_max_inline_profile_chars,
enable_background_execution=enable_background_execution,
)
# HITL on main agent only — passing `interrupt_on=` to create_deep_agent
@@ -995,6 +1030,8 @@ def create_cli_agent(
chat_model=chat_model,
workspace_dir=workspace_dir,
)
if not enable_subagents:
kwargs = {**kwargs, "subagents": []}
return create_deep_agent(
**kwargs,
+2
View File
@@ -9,6 +9,8 @@ from __future__ import annotations
from importlib import import_module
__version__ = "0.2.2"
_EXPORTS: dict[str, tuple[str, str]] = {
# Agent graph (lazy to avoid expensive initialization at import time)
"EvoScientist_agent": (".EvoScientist", "EvoScientist_agent"),
+14 -2
View File
@@ -106,11 +106,23 @@ def _normalize_hhmm(value: Any) -> str | None:
def get_config_dir() -> Path:
"""Get the configuration directory path.
Uses XDG_CONFIG_HOME if set, otherwise ~/.config/evoscientist/
Priority:
1. EVOSCIENTIST_CONFIG_DIR
2. EVOSCIENTIST_HOME/config
3. XDG_CONFIG_HOME/evoscientist
4. ~/.config/evoscientist
"""
configured = os.environ.get("EVOSCIENTIST_CONFIG_DIR")
if configured:
return Path(configured).expanduser().resolve()
home = os.environ.get("EVOSCIENTIST_HOME")
if home:
return Path(home).expanduser().resolve() / "config"
xdg_config = os.environ.get("XDG_CONFIG_HOME")
if xdg_config:
return Path(xdg_config) / "evoscientist"
return Path(xdg_config).expanduser() / "evoscientist"
return Path.home() / ".config" / "evoscientist"
+11 -5
View File
@@ -306,14 +306,19 @@ def is_async_subagents_available() -> bool:
def _langgraph_exe() -> str | None:
"""Return the path to the langgraph CLI binary, or None if not found."""
import sys
executable_dir = os.path.dirname(sys.executable)
candidate_names = (
["langgraph.exe", "langgraph"] if os.name == "nt" else ["langgraph"]
)
for candidate_name in candidate_names:
candidate = os.path.join(executable_dir, candidate_name)
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
return candidate
found = shutil.which("langgraph")
if found:
return found
import sys as _sys
candidate = os.path.join(os.path.dirname(_sys.executable), "langgraph")
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
return candidate
return None
@@ -709,6 +714,7 @@ def start_langgraph_dev(
sub_env["EVOSCIENTIST_DEPLOY_MODE"] = "full" if deploy_mode else "stripped"
try:
logger.info("Starting langgraph dev with CLI: %s", exe)
proc = subprocess.Popen(
[
exe,
+106 -19
View File
@@ -68,6 +68,11 @@ _THINKING_CAPABLE_PROVIDERS: set[str] = {"minimax"}
_TRUTHY_ENV_VALUES = {"1", "true", "yes", "on"}
_FALSEY_ENV_VALUES = {"0", "false", "no", "off"}
# Legacy/provider-specific options that are not accepted by the installed
# LangChain chat model constructors. Leaving them at the top level makes
# LangChain move them into model_kwargs and can later leak them into SDK calls.
_UNSUPPORTED_CHAT_MODEL_KWARGS = frozenset({"sanitize_openai_sdk_headers"})
# Model registry: list of (short_name, model_id, provider)
# Allows same short_name across different providers.
_MODEL_ENTRIES: list[tuple[str, str, str]] = [
@@ -264,6 +269,15 @@ def _env_flag_disabled(name: str) -> bool:
return value is not None and value.strip().lower() in _FALSEY_ENV_VALUES
def _drop_unsupported_chat_model_kwargs(kwargs: dict[str, Any]) -> None:
for key in _UNSUPPORTED_CHAT_MODEL_KWARGS:
kwargs.pop(key, None)
model_kwargs = kwargs.get("model_kwargs")
if isinstance(model_kwargs, dict):
for key in _UNSUPPORTED_CHAT_MODEL_KWARGS:
model_kwargs.pop(key, None)
def _supports_openrouter_anthropic_prompt_cache(provider: str, model_id: str) -> bool:
"""Return whether EvoScientist should declare OpenRouter Claude caching."""
return provider == "openrouter" and model_id.startswith(
@@ -321,8 +335,16 @@ def _apply_auto_config(
Mutates *kwargs* in place. Only sets keys that the caller hasn't already
provided, so explicit user settings are never overridden.
"""
disable_reasoning = bool(kwargs.pop("_disable_reasoning", False))
disable_thinking = bool(kwargs.pop("_disable_thinking", False))
if disable_reasoning:
kwargs.pop("reasoning", None)
kwargs.pop("include_thoughts", None)
if disable_thinking:
kwargs.pop("thinking", None)
# Anthropic: extended thinking
if provider == "anthropic" and "thinking" not in kwargs:
if provider == "anthropic" and not disable_thinking and "thinking" not in kwargs:
_supports_thinking = original_provider in _THINKING_CAPABLE_PROVIDERS
# Detect local proxy (e.g. ccproxy): thinking blocks in conversation
# history cause 422 errors because the proxy doesn't accept 'thinking'
@@ -341,8 +363,13 @@ def _apply_auto_config(
kwargs["thinking"] = {"type": "enabled", "budget_tokens": 10000}
# OpenAI (native, not third-party routed): reasoning
if provider == "openai" and not is_third_party and "reasoning" not in kwargs:
if _is_ccproxy_codex():
if (
provider == "openai"
and not is_third_party
and not disable_reasoning
and "reasoning" not in kwargs
):
if _is_ccproxy_codex(kwargs.get("base_url"), kwargs.get("api_key")):
# ccproxy uses Chat Completions which doesn't support reasoning.
pass
else:
@@ -354,11 +381,11 @@ def _apply_auto_config(
kwargs["reasoning"] = {"effort": _eff, "summary": "auto"}
# Google GenAI: surface thinking traces
if provider == "google-genai":
if provider == "google-genai" and not disable_reasoning:
kwargs.setdefault("include_thoughts", True)
# Ollama: separate reasoning content from response for thinking models
if provider == "ollama" and "reasoning" not in kwargs:
if provider == "ollama" and not disable_reasoning and "reasoning" not in kwargs:
kwargs["reasoning"] = True
@@ -385,7 +412,46 @@ def get_chat_model(
>>> model = get_chat_model("gpt-4o") # OpenAI model
>>> model = get_chat_model("claude-3-opus-20240229", provider="anthropic") # Full ID
"""
model = model or DEFAULT_MODEL
skip_runtime_resolver = bool(kwargs.pop("_skip_runtime_model_resolver", False))
runtime_provider_name: str | None = None
runtime_supports_reasoning: bool | None = None
runtime_resolved = None
if not skip_runtime_resolver:
from EvoScientist.runtime_integrations import resolve_runtime_model
runtime_resolved = resolve_runtime_model(model, provider)
if runtime_resolved is not None:
resolved_params = dict(getattr(runtime_resolved, "params", {}) or {})
extra_body = resolved_params.pop("_extra_body", None)
default_headers = resolved_params.pop("_default_headers", None)
if extra_body:
resolved_params["extra_body"] = extra_body
if default_headers:
resolved_params["default_headers"] = default_headers
resolved_params.update(kwargs)
kwargs = resolved_params
resolved_api_key = str(getattr(runtime_resolved, "api_key", "") or "")
resolved_base_url = str(getattr(runtime_resolved, "base_url", "") or "")
if resolved_api_key:
kwargs.setdefault("api_key", resolved_api_key)
if resolved_base_url:
kwargs.setdefault("base_url", resolved_base_url.rstrip("/"))
runtime_provider_name = str(
getattr(runtime_resolved, "provider_name", "") or ""
)
runtime_supports_reasoning = bool(
getattr(runtime_resolved, "supports_reasoning", False)
)
if not runtime_supports_reasoning:
kwargs.setdefault("_disable_reasoning", True)
kwargs.setdefault("_disable_thinking", True)
model = str(runtime_resolved.model_id)
provider = str(runtime_resolved.protocol)
else:
model = model or DEFAULT_MODEL
# Look up short name in registry (provider-aware)
model_id = None
@@ -420,22 +486,35 @@ def get_chat_model(
_is_third_party = (
provider in _OPENAI_ROUTED_PROVIDERS or provider in _ANTHROPIC_ROUTED_PROVIDERS
)
if runtime_provider_name and runtime_provider_name != provider:
_is_third_party = True
if (
runtime_resolved is not None
and provider == "openai"
and resolved_base_url
and "api.openai.com" not in resolved_base_url.lower()
):
_is_third_party = True
_is_openai_proxy = False
_original_provider: str | None = None
_original_provider: str | None = (
runtime_provider_name if runtime_provider_name != provider else None
)
if provider == "anthropic":
base_url = os.environ.get("ANTHROPIC_BASE_URL", "")
if base_url:
kwargs["base_url"] = base_url
kwargs.setdefault("base_url", base_url)
api_key = os.environ.get("ANTHROPIC_API_KEY", "")
if api_key:
kwargs["api_key"] = api_key
kwargs.setdefault("api_key", api_key)
# Native OpenAI base_url override (e.g. ccproxy Codex at localhost:8000/codex/v1)
elif provider == "openai":
base_url = os.environ.get("OPENAI_BASE_URL", "")
if base_url:
kwargs["base_url"] = base_url
_is_openai_proxy = _is_ccproxy_codex()
kwargs.setdefault("base_url", base_url)
_is_openai_proxy = _is_ccproxy_codex(
kwargs.get("base_url"), kwargs.get("api_key")
)
if _is_openai_proxy:
# Use Responses API for ccproxy: bypasses the format chain
# converter (Chat→Responses→Chat) which returns 502 on
@@ -450,7 +529,7 @@ def get_chat_model(
kwargs.pop("streaming", None) # remove if set elsewhere
api_key = os.environ.get("OPENAI_API_KEY", "")
if api_key:
kwargs["api_key"] = api_key
kwargs.setdefault("api_key", api_key)
# OpenAI-routed providers → route through OpenAI provider with base_url
elif provider in _OPENAI_ROUTED_PROVIDERS:
@@ -468,10 +547,10 @@ def get_chat_model(
else:
base_url = base_url_default
if base_url:
kwargs["base_url"] = base_url
kwargs.setdefault("base_url", base_url)
api_key = os.environ.get(api_key_env, "")
if api_key:
kwargs["api_key"] = api_key
kwargs.setdefault("api_key", api_key)
# SiliconFlow: disable thinking — LangChain drops reasoning_content
# from history, causing error 20015 on multi-turn requests.
if provider == "siliconflow":
@@ -488,7 +567,7 @@ def get_chat_model(
_is_third_party = True
api_key = os.environ.get("OPENROUTER_API_KEY", "")
if api_key:
kwargs["api_key"] = api_key
kwargs.setdefault("api_key", api_key)
# Reasoning via `effort` + `summary: "auto"` so a readable reasoning
# summary is returned for display. OpenAI-Responses also emits encrypted
# reasoning items (`rs_*` id) that can't be replayed on multi-turn
@@ -517,10 +596,10 @@ def get_chat_model(
else:
base_url = base_url_default
if base_url:
kwargs["base_url"] = base_url
kwargs.setdefault("base_url", base_url)
api_key = os.environ.get(api_key_env, "")
if api_key:
kwargs["api_key"] = api_key
kwargs.setdefault("api_key", api_key)
# Kimi Coding Plan requires claude-code User-Agent header
if provider == "kimi-coding":
kwargs.setdefault("default_headers", {})["User-Agent"] = "claude-code/0.1.0"
@@ -529,8 +608,9 @@ def get_chat_model(
elif provider == "ollama":
base_url = os.environ.get("OLLAMA_BASE_URL", "")
if base_url:
kwargs["base_url"] = base_url
kwargs.setdefault("base_url", base_url)
_drop_unsupported_chat_model_kwargs(kwargs)
_apply_auto_config(provider, model_id, _is_third_party, kwargs, _original_provider)
_apply_openrouter_anthropic_prompt_cache(provider, model_id, kwargs)
@@ -547,7 +627,14 @@ def get_chat_model(
elif _responses_api_setting == "true":
kwargs["use_responses_api"] = True
chat_model = init_chat_model(model=model_id, model_provider=provider, **kwargs)
anthropic_auth_token = None
if provider == "anthropic" and kwargs.get("api_key"):
anthropic_auth_token = os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
try:
chat_model = init_chat_model(model=model_id, model_provider=provider, **kwargs)
finally:
if anthropic_auth_token is not None:
os.environ["ANTHROPIC_AUTH_TOKEN"] = anthropic_auth_token
# Flatten list content to strings for strict OpenAI-compatible providers
# (DeepSeek, SiliconFlow, OpenRouter, custom-openai, etc.) and
+86 -3
View File
@@ -25,6 +25,7 @@ Utilities:
from __future__ import annotations
import hashlib
import os
from typing import Any
@@ -178,15 +179,20 @@ _patch_ccproxy_codex_compat()
# ---------------------------------------------------------------------------
# Utility: detect ccproxy's Codex adapter (as opposed to generic localhost).
# ---------------------------------------------------------------------------
def _is_ccproxy_codex() -> bool:
def _is_ccproxy_codex(
base_url: str | None = None,
api_key: str | None = None,
) -> bool:
"""Return True if the OpenAI endpoint is ccproxy's Codex adapter.
Checks for the ccproxy-specific markers set by ``setup_codex_env()``
in ``ccproxy_manager.py``: the sentinel API key and the ``/codex/v1``
path. Plain localhost endpoints (vLLM, Ollama, etc.) are not affected.
"""
base_url = os.environ.get("OPENAI_BASE_URL", "")
api_key = os.environ.get("OPENAI_API_KEY", "")
if base_url is None:
base_url = os.environ.get("OPENAI_BASE_URL", "")
if api_key is None:
api_key = os.environ.get("OPENAI_API_KEY", "")
return (
("127.0.0.1" in base_url or "localhost" in base_url)
and api_key == "ccproxy-oauth"
@@ -267,6 +273,82 @@ def _flatten_message_content(content: Any) -> str | list[Any] | Any:
return "\n\n".join(parts) if parts else ""
def _stable_tool_call_id(message: Any, message_index: int, call_index: int) -> str:
seed = ":".join(
(
str(getattr(message, "id", "") or "message"),
str(message_index),
str(call_index),
)
)
return "call_" + hashlib.sha256(seed.encode("utf-8")).hexdigest()[:24]
def _ensure_openai_tool_call_ids(messages: list[Any]) -> list[Any]:
"""Copy messages and repair missing AI/ToolMessage call identifiers."""
import copy
from collections import deque
pending_call_ids: deque[str] = deque()
normalized: list[Any] = []
for message_index, message in enumerate(messages):
message_type = getattr(message, "type", None)
if message_type == "ai":
tool_calls = list(getattr(message, "tool_calls", None) or [])
if not tool_calls:
normalized.append(message)
continue
copied = copy.copy(message)
normalized_calls: list[dict[str, Any]] = []
for call_index, original_call in enumerate(tool_calls):
call = dict(original_call)
call_id = str(call.get("id") or "") or _stable_tool_call_id(
message, message_index, call_index
)
call["id"] = call_id
normalized_calls.append(call)
pending_call_ids.append(call_id)
copied.tool_calls = normalized_calls
if isinstance(copied.content, list):
call_index = 0
blocks: list[Any] = []
for original_block in copied.content:
if not isinstance(original_block, dict):
blocks.append(original_block)
continue
block = dict(original_block)
if block.get("type") in {"tool_call", "function_call"}:
if call_index < len(normalized_calls):
block["id"] = normalized_calls[call_index]["id"]
call_index += 1
blocks.append(block)
copied.content = blocks
normalized.append(copied)
continue
if message_type == "tool":
tool_call_id = str(getattr(message, "tool_call_id", "") or "")
if tool_call_id:
try:
pending_call_ids.remove(tool_call_id)
except ValueError:
pass
normalized.append(message)
continue
if pending_call_ids:
copied = copy.copy(message)
copied.tool_call_id = pending_call_ids.popleft()
normalized.append(copied)
continue
normalized.append(message)
return normalized
def _sanitize_messages(messages: list[Any], hoist_tool_media: bool = True) -> list[Any]:
"""Flatten list content for OpenAI-compatible APIs, preserving media.
@@ -282,6 +364,7 @@ def _sanitize_messages(messages: list[Any], hoist_tool_media: bool = True) -> li
from langchain_core.messages import HumanMessage
messages = _ensure_openai_tool_call_ids(messages)
out: list[Any] = []
pending_media: list[Any] = [] # media hoisted out of a run of tool messages
+302
View File
@@ -0,0 +1,302 @@
"""Shared logging configuration helpers."""
from __future__ import annotations
import logging
import os
import sys
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, TextIO
DEFAULT_LOG_RETENTION_DAYS = 30
DEFAULT_LOG_FORMAT = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
DEFAULT_LOG_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
MANAGED_HANDLER_ATTR = "_evoscientist_managed_handler"
def resolve_log_level(level: int | str | None, default: int = logging.INFO) -> int:
"""Resolve a logging level from config or environment input."""
if isinstance(level, int):
return level
raw = str(level or "").strip()
if not raw:
return default
if raw.isdigit():
return int(raw)
normalized = raw.upper()
if normalized == "WARN":
normalized = "WARNING"
resolved = logging.getLevelNamesMapping().get(normalized)
return resolved if isinstance(resolved, int) else default
def _mark_managed(handler: logging.Handler, kind: str) -> logging.Handler:
setattr(handler, MANAGED_HANDLER_ATTR, kind)
return handler
def _managed_kind(handler: logging.Handler) -> str | None:
kind = getattr(handler, MANAGED_HANDLER_ATTR, None)
return kind if isinstance(kind, str) else None
def remove_managed_handlers(
logger: logging.Logger | None = None,
*,
kinds: set[str] | None = None,
) -> None:
"""Remove handlers installed by this module without touching external ones."""
target = logger or logging.getLogger()
for handler in target.handlers[:]:
kind = _managed_kind(handler)
if kind and (kinds is None or kind in kinds):
target.removeHandler(handler)
handler.close()
def _standard_formatter() -> logging.Formatter:
return logging.Formatter(DEFAULT_LOG_FORMAT, datefmt=DEFAULT_LOG_DATE_FORMAT)
class DailyLogFileHandler(logging.FileHandler):
"""File handler that writes the active log to a date-based filename."""
def __init__(
self,
log_dir: str | Path,
*,
prefix: str = "evoscientist",
retention_days: int = DEFAULT_LOG_RETENTION_DAYS,
encoding: str = "utf-8",
utc: bool = False,
) -> None:
self.log_dir = Path(log_dir).expanduser()
self.prefix = prefix
self.retention_days = max(1, retention_days)
self.utc = utc
self.log_dir.mkdir(parents=True, exist_ok=True)
super().__init__(self._dated_log_path(), encoding=encoding, delay=True)
@property
def active_log_path(self) -> Path:
"""Return the active log path for the current date."""
return self._dated_log_path()
def _dated_log_path(self) -> Path:
now = datetime.now(UTC if self.utc else None)
return self.log_dir / f"{self.prefix}-{now:%Y-%m-%d}.log"
def emit(self, record: logging.LogRecord) -> None:
try:
expected = str(self.active_log_path)
if self.baseFilename != expected:
if self.stream:
self.stream.close()
self.stream = None
self.baseFilename = expected
self._delete_expired_logs()
super().emit(record)
except OSError:
self.handleError(record)
def getFilesToDelete(self) -> list[str]:
candidates = sorted(self.log_dir.glob(f"{self.prefix}-????-??-??.log"))
if len(candidates) <= self.retention_days:
return []
return [str(path) for path in candidates[: -self.retention_days]]
def _delete_expired_logs(self) -> None:
for path in self.getFilesToDelete():
try:
os.remove(path)
except OSError:
pass
def default_log_dir() -> Path:
"""Return the default runtime log directory."""
env_dir = os.environ.get("EVOSCIENTIST_LOG_DIR")
if env_dir:
return Path(env_dir).expanduser()
from EvoScientist.paths import DATA_DIR
return DATA_DIR / "logs"
def configure_daily_file_logging(
logger: logging.Logger | None = None,
*,
log_dir: str | Path | None = None,
prefix: str = "evoscientist",
level: int | str = logging.INFO,
retention_days: int = DEFAULT_LOG_RETENTION_DAYS,
) -> DailyLogFileHandler:
"""Attach a daily file handler, replacing older matching handlers."""
target = logger or logging.getLogger()
resolved_level = resolve_log_level(level, default=logging.INFO)
retention_days = max(1, int(retention_days))
resolved_dir = Path(log_dir).expanduser() if log_dir else default_log_dir()
for handler in target.handlers[:]:
if (
isinstance(handler, DailyLogFileHandler)
and handler.prefix == prefix
and handler.log_dir == resolved_dir
):
target.removeHandler(handler)
handler.close()
handler = DailyLogFileHandler(
resolved_dir,
prefix=prefix,
retention_days=retention_days,
)
_mark_managed(handler, "file")
handler.setLevel(resolved_level)
handler.setFormatter(_standard_formatter())
target.addHandler(handler)
if target.level == logging.NOTSET or target.level > resolved_level:
target.setLevel(resolved_level)
return handler
def configure_console_logging(
logger: logging.Logger | None = None,
*,
level: int | str | None = logging.INFO,
stream: TextIO | None = None,
replace: bool = True,
) -> logging.StreamHandler:
"""Attach a standard console handler for non-interactive entry points."""
target = logger or logging.getLogger()
resolved_level = resolve_log_level(level, default=logging.INFO)
if replace:
remove_managed_handlers(target, kinds={"console", "rich"})
handler = logging.StreamHandler(stream or sys.stderr)
_mark_managed(handler, "console")
handler.setLevel(resolved_level)
handler.setFormatter(_standard_formatter())
target.addHandler(handler)
target.setLevel(resolved_level)
return handler
def configure_rich_console_logging(
logger: logging.Logger | None = None,
*,
level: int | str | None = logging.INFO,
console: Any = None,
replace: bool = True,
dim_warnings: bool = False,
show_time: bool | None = None,
show_path: bool | None = None,
show_level: bool | None = None,
) -> logging.Handler:
"""Attach a Rich console handler for interactive CLI output."""
from rich.logging import RichHandler
from rich.markup import escape
target = logger or logging.getLogger()
resolved_level = resolve_log_level(level, default=logging.INFO)
verbose = resolved_level <= logging.DEBUG
if replace:
remove_managed_handlers(target, kinds={"console", "rich"})
class DimWarningHandler(RichHandler):
def emit(self, record: logging.LogRecord) -> None:
if dim_warnings and record.levelno == logging.WARNING and console is not None:
console.print(
"[dim yellow]\u26a0\ufe0f Warning:[/dim yellow] "
f"[dim]{escape(record.getMessage())}[/dim]"
)
return
super().emit(record)
handler = DimWarningHandler(
console=console,
show_time=verbose if show_time is None else show_time,
show_path=verbose if show_path is None else show_path,
show_level=verbose if show_level is None else show_level,
)
_mark_managed(handler, "rich")
handler.setLevel(resolved_level)
target.addHandler(handler)
target.setLevel(resolved_level)
return handler
def configure_logging(
logger: logging.Logger | None = None,
*,
level: int | str | None = logging.INFO,
log_dir: str | Path | None = None,
retention_days: int = DEFAULT_LOG_RETENTION_DAYS,
prefix: str = "evoscientist",
console: bool = True,
file: bool = True,
replace_managed: bool = True,
) -> list[logging.Handler]:
"""Configure standard EvoScientist console and daily file logging."""
target = logger or logging.getLogger()
resolved_level = resolve_log_level(level, default=logging.INFO)
if replace_managed:
remove_managed_handlers(target, kinds={"console", "rich", "file"})
handlers: list[logging.Handler] = []
if console:
handlers.append(
configure_console_logging(target, level=resolved_level, replace=False)
)
if file:
handlers.append(
configure_daily_file_logging(
target,
log_dir=log_dir,
prefix=prefix,
level=resolved_level,
retention_days=retention_days,
)
)
target.setLevel(resolved_level)
return handlers
def configure_logging_from_settings(
logger: logging.Logger | None = None,
*,
default_level: int = logging.INFO,
prefix: str = "evoscientist",
console: bool = True,
file: bool = True,
) -> list[logging.Handler]:
"""Configure logging from EvoScientist settings and environment overrides."""
level: int | str | None = os.environ.get("EVOSCIENTIST_LOG_LEVEL")
log_dir: str | Path | None = os.environ.get("EVOSCIENTIST_LOG_DIR") or None
retention_days = int(
os.environ.get("EVOSCIENTIST_LOG_RETENTION_DAYS", DEFAULT_LOG_RETENTION_DAYS)
)
try:
from EvoScientist.config import get_effective_config
cfg = get_effective_config()
level = level or getattr(cfg, "log_level", None)
log_dir = log_dir or getattr(cfg, "log_dir", None) or None
retention_days = int(
getattr(cfg, "log_retention_days", DEFAULT_LOG_RETENTION_DAYS)
)
except Exception:
level = level or default_level
return configure_logging(
logger,
level=resolve_log_level(level, default=default_level),
log_dir=log_dir,
retention_days=retention_days,
prefix=prefix,
console=console,
file=file,
)
+2
View File
@@ -10,6 +10,7 @@ from .client import (
build_mcp_add_kwargs,
build_mcp_edit_fields,
edit_mcp_server,
get_mcp_server_errors,
load_mcp_config,
load_mcp_tools,
parse_mcp_add_args,
@@ -38,6 +39,7 @@ __all__ = [
"find_server_by_name",
"get_all_tags",
"get_installed_names",
"get_mcp_server_errors",
"install_mcp_server",
"install_mcp_servers",
"load_mcp_config",
+16 -1
View File
@@ -114,6 +114,10 @@ _URL_TRANSPORTS = {"http", "streamable_http", "sse", "websocket"}
# still parallelizing the common 3–7 server case to completion.
_MAX_CONCURRENT_CONNECTIONS = 8
# Last connection error per configured server. This is process-local runtime
# diagnostics for the Web/CLI status surfaces, not persisted configuration.
_MCP_SERVER_ERRORS: dict[str, str] = {}
# Env vars forwarded to stdio MCP subprocesses on top of the MCP SDK's
# minimal default set (HOME/PATH/USER/…). Without this, servers behind
# a proxy or with a custom CA bundle silently fail with long timeouts.
@@ -764,6 +768,9 @@ async def _load_tools(
if not connections:
return {}
for stale_name in set(_MCP_SERVER_ERRORS) - set(connections):
_MCP_SERVER_ERRORS.pop(stale_name, None)
client = MultiServerMCPClient(connections) # type: ignore[invalid-argument-type]
def _report(event: str, name: str, detail: str = "") -> None:
@@ -787,10 +794,13 @@ async def _load_tools(
_report("start", name)
try:
tools = await client.get_tools(server_name=name)
_MCP_SERVER_ERRORS.pop(name, None)
logger.info("MCP server %r: loaded %d tool(s)", name, len(tools))
_report("success", name, str(len(tools)))
return name, tools
except Exception as exc:
detail = str(exc) or type(exc).__name__
_MCP_SERVER_ERRORS[name] = detail
# When the caller wired up ``on_progress`` they own the
# user-facing display; downgrade the logger so we don't
# double-print.
@@ -798,7 +808,7 @@ async def _load_tools(
logger.warning("MCP server %r: failed to load tools: %s", name, exc)
else:
logger.debug("MCP server %r: failed to load tools: %s", name, exc)
_report("error", name, str(exc))
_report("error", name, detail)
return name, []
# ``return_exceptions=False`` is fine because ``_fetch`` already
@@ -807,6 +817,11 @@ async def _load_tools(
return dict(results)
def get_mcp_server_errors() -> dict[str, str]:
"""Return a snapshot of the most recent per-server connection errors."""
return dict(_MCP_SERVER_ERRORS)
async def aload_mcp_tools(
config: dict[str, Any] | None = None,
*,
+63
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import logging
import os
import shutil
from collections.abc import Iterator
from datetime import datetime
from pathlib import Path
@@ -215,3 +216,65 @@ def resolve_virtual_path(virtual_path: str) -> Path:
"""Resolve a virtual workspace path (e.g. /image.png) to a real filesystem path."""
vpath = virtual_path if virtual_path.startswith("/") else "/" + virtual_path
return (_active_workspace / vpath.lstrip("/")).resolve()
def evoscientist_root() -> Path:
"""Return the application root used by Gateway-managed runtime data."""
env_root = os.environ.get("EVOSCIENTIST_HOME")
if env_root:
return Path(env_root).expanduser().resolve()
return DATA_DIR.expanduser().resolve()
_EVOSCIENTIST_DATA_ROOT: Path | None = None
def _data_root() -> Path:
"""Return the root directory for isolated Web user workspaces."""
global _EVOSCIENTIST_DATA_ROOT
if _EVOSCIENTIST_DATA_ROOT is not None:
return _EVOSCIENTIST_DATA_ROOT
env_root = os.environ.get("EVOSCIENTIST_DATA_ROOT")
if env_root:
root = Path(env_root).expanduser().resolve()
else:
root = evoscientist_root() / "data"
_EVOSCIENTIST_DATA_ROOT = root
return root
def user_data_dir(user_id: str) -> Path:
"""Return and create the isolated data directory for a Web user."""
path = _data_root() / user_id
path.mkdir(parents=True, exist_ok=True)
return path
def iter_user_data_dirs() -> Iterator[Path]:
"""Yield existing Web user directories without creating the data root."""
root = _data_root()
if not root.exists():
return
for path in root.iterdir():
if path.is_dir():
yield path
def thread_data_dir(user_id: str, thread_id: str) -> Path:
"""Return and create a user's isolated thread workspace."""
path = user_data_dir(user_id) / thread_id
path.mkdir(parents=True, exist_ok=True)
return path
def global_data_dir(user_id: str) -> Path:
"""Return and create a user's directory shared across all threads."""
path = user_data_dir(user_id) / "__global__"
path.mkdir(parents=True, exist_ok=True)
return path
def uploads_dir() -> Path:
"""Return the Gateway upload staging directory."""
return evoscientist_root() / "uploads"
+116
View File
@@ -0,0 +1,116 @@
"""Optional runtime services supplied by an application embedding EvoScientist.
The CLI package must not import a concrete web gateway. Applications such as
Ai4Sci-Web can register their database, storage, metering, and media services
at process startup through this module.
"""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, replace
from datetime import date
from pathlib import Path
from typing import Any
AsyncProvider = Callable[[], Awaitable[Any]]
AsyncFileHandler = Callable[[Path], Awaitable[Any]]
AsyncUsageRecorder = Callable[[str, str], Awaitable[Any]]
ModelResolver = Callable[[str | None, str | None], Any | None]
class RuntimeIntegrationUnavailable(RuntimeError):
"""Raised when an optional host-provided service is not configured."""
@dataclass(frozen=True)
class RuntimeIntegrations:
app_connection_provider: AsyncProvider | None = None
session_connection_provider: AsyncProvider | None = None
session_dsn_provider: Callable[[], str | None] | None = None
current_date_provider: Callable[[], date] | None = None
user_storage_root_provider: Callable[[str], Path] | None = None
knowledge_file_handler: AsyncFileHandler | None = None
usage_recorder: AsyncUsageRecorder | None = None
image_backend_factory: Callable[[], Any] | None = None
model_resolver: ModelResolver | None = None
_integrations = RuntimeIntegrations()
def configure_runtime_integrations(**services: Any) -> RuntimeIntegrations:
"""Register host-provided services and return the resulting configuration."""
global _integrations
_integrations = replace(_integrations, **services)
return _integrations
def reset_runtime_integrations() -> None:
"""Clear all host-provided services, primarily for tests."""
global _integrations
_integrations = RuntimeIntegrations()
def has_session_connection_provider() -> bool:
return _integrations.session_connection_provider is not None
def get_session_dsn() -> str | None:
provider = _integrations.session_dsn_provider
return provider() if provider is not None else None
async def get_session_connection() -> Any:
provider = _integrations.session_connection_provider
if provider is None:
raise RuntimeIntegrationUnavailable(
"No session connection provider is configured"
)
return await provider()
async def get_app_connection() -> Any:
provider = _integrations.app_connection_provider
if provider is None:
raise RuntimeIntegrationUnavailable(
"No application connection provider is configured"
)
return await provider()
def current_date() -> date:
provider = _integrations.current_date_provider
return provider() if provider is not None else date.today()
def resolve_user_storage_root(user_id: str) -> Path | None:
provider = _integrations.user_storage_root_provider
return provider(user_id) if provider is not None else None
def resolve_runtime_model(model: str | None, provider: str | None = None) -> Any | None:
"""Resolve a host-managed model configuration when one is registered."""
resolver = _integrations.model_resolver
return resolver(model, provider) if resolver is not None else None
async def handle_knowledge_file(path: Path) -> None:
handler = _integrations.knowledge_file_handler
if handler is not None:
await handler(path)
async def record_service_usage(service: str, action: str) -> None:
recorder = _integrations.usage_recorder
if recorder is not None:
await recorder(service, action)
def get_image_backend() -> Any:
factory = _integrations.image_backend_factory
if factory is None:
raise RuntimeIntegrationUnavailable(
"Image generation is unavailable in this runtime. Configure an image backend first."
)
return factory()
+8
View File
@@ -8,10 +8,12 @@ import base64
import inspect
import mimetypes
import os
import warnings
from collections.abc import AsyncGenerator, AsyncIterator, Mapping
from dataclasses import dataclass
from typing import Any, TypeAlias
from langchain_core._api import LangChainBetaWarning
from langchain_core.messages import AIMessage, AIMessageChunk, BaseMessage, ToolMessage
from langgraph.graph import END
from langgraph.types import Command, Interrupt
@@ -43,6 +45,12 @@ GraphRunInput: TypeAlias = str | Command
LangGraphStreamInput: TypeAlias = dict[str, list[dict[str, object]]] | Command
_ValueMessageKey: TypeAlias = tuple[str, ...]
warnings.filterwarnings(
"ignore",
message=r"The v3 streaming protocol on Pregel is experimental\.",
category=LangChainBetaWarning,
)
@dataclass(frozen=True, slots=True)
class _AssistantValueMessage:
+71
View File
@@ -0,0 +1,71 @@
def test_create_cli_agent_accepts_host_backend_and_memory_options(
monkeypatch, tmp_path
):
import EvoScientist.EvoScientist as agent_module
from EvoScientist.config.settings import EvoScientistConfig
calls = {}
workspace_backend = object()
chat_model = object()
class _CompositeBackend:
def __init__(self, *, default, routes):
calls["default_backend"] = default
calls["routes"] = routes
class _MemoryBackend:
def __init__(self, **kwargs):
calls["memory_backend_kwargs"] = kwargs
class _SkillsBackend:
def __init__(self, **kwargs):
calls["skills_backend_kwargs"] = kwargs
class _Agent:
def with_config(self, config):
calls["agent_config"] = config
return self
cfg = EvoScientistConfig(auto_approve=True, recursion_limit=321)
monkeypatch.setattr("deepagents.backends.CompositeBackend", _CompositeBackend)
monkeypatch.setattr("deepagents.create_deep_agent", lambda **kwargs: _Agent())
monkeypatch.setattr("EvoScientist.backends.MemoryFilesystemBackend", _MemoryBackend)
monkeypatch.setattr("EvoScientist.backends.MergedSkillsBackend", _SkillsBackend)
monkeypatch.setattr(agent_module, "set_active_workspace", lambda path: None)
monkeypatch.setattr(
agent_module,
"_get_default_middleware",
lambda **kwargs: calls.setdefault("middleware_kwargs", kwargs) or [],
)
monkeypatch.setattr(
agent_module,
"load_mcp_and_build_kwargs",
lambda *args, **kwargs: {"subagents": [{"name": "research"}]},
)
memory_dir = tmp_path / "memory"
result = agent_module.create_cli_agent(
workspace_dir=str(tmp_path / "workspace"),
checkpointer=object(),
config=cfg,
chat_model=chat_model,
workspace_backend=workspace_backend,
memory_dir=memory_dir,
tool_selector_threshold=8,
memory_max_inline_profile_chars=1000,
enable_subagents=False,
enable_background_execution=False,
)
assert isinstance(result, _Agent)
assert calls["default_backend"] is workspace_backend
assert calls["memory_backend_kwargs"] == {
"root_dir": str(memory_dir),
"virtual_mode": True,
}
assert calls["middleware_kwargs"]["memory_dir"] == str(memory_dir)
assert calls["middleware_kwargs"]["tool_selector_threshold"] == 8
assert calls["middleware_kwargs"]["memory_max_inline_profile_chars"] == 1000
assert calls["middleware_kwargs"]["enable_background_execution"] is False
assert calls["agent_config"] == {"recursion_limit": 321}
+22
View File
@@ -52,6 +52,8 @@ def _restore_dangerous_env():
def temp_config_dir(tmp_path, monkeypatch):
"""Use a temporary directory for config during tests."""
config_dir = tmp_path / "evoscientist"
monkeypatch.delenv("EVOSCIENTIST_CONFIG_DIR", raising=False)
monkeypatch.delenv("EVOSCIENTIST_HOME", raising=False)
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
# Prevent load_dotenv from loading the project's real .env file
monkeypatch.setattr(
@@ -199,14 +201,34 @@ class TestEvoScientistConfig:
class TestConfigPaths:
def test_get_config_dir_with_explicit_override(self, monkeypatch, tmp_path):
"""An explicit config directory has the highest priority."""
config_dir = tmp_path / "gateway-config"
monkeypatch.setenv("EVOSCIENTIST_CONFIG_DIR", str(config_dir))
monkeypatch.setenv("EVOSCIENTIST_HOME", str(tmp_path / "runtime-home"))
assert get_config_dir() == config_dir.resolve()
def test_get_config_dir_with_evoscientist_home(self, monkeypatch, tmp_path):
"""Runtime home keeps configuration and data under one root."""
home = tmp_path / "runtime-home"
monkeypatch.delenv("EVOSCIENTIST_CONFIG_DIR", raising=False)
monkeypatch.setenv("EVOSCIENTIST_HOME", str(home))
assert get_config_dir() == home.resolve() / "config"
def test_get_config_dir_with_xdg(self, monkeypatch, tmp_path):
"""Test config dir uses XDG_CONFIG_HOME when set."""
monkeypatch.delenv("EVOSCIENTIST_CONFIG_DIR", raising=False)
monkeypatch.delenv("EVOSCIENTIST_HOME", raising=False)
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
config_dir = get_config_dir()
assert config_dir == tmp_path / "evoscientist"
def test_get_config_dir_default(self, monkeypatch):
"""Test config dir defaults to ~/.config/evoscientist."""
monkeypatch.delenv("EVOSCIENTIST_CONFIG_DIR", raising=False)
monkeypatch.delenv("EVOSCIENTIST_HOME", raising=False)
monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
config_dir = get_config_dir()
assert config_dir == Path.home() / ".config" / "evoscientist"
+72
View File
@@ -8,6 +8,7 @@ to be available.
from __future__ import annotations
import dataclasses
import sys
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
@@ -32,6 +33,77 @@ def reset_module_state():
manager._LOG_OFFSET_AT_START = 0
# =============================================================================
# langgraph CLI resolution
# =============================================================================
class TestLanggraphCliResolution:
def _make_executable(self, path):
path.write_text("#!/bin/sh\n", encoding="utf-8")
path.chmod(0o755)
def test_prefers_current_python_environment_over_path(self, tmp_path, monkeypatch):
local_bin = tmp_path / "local" / "bin"
local_bin.mkdir(parents=True)
local_langgraph = local_bin / "langgraph"
self._make_executable(local_langgraph)
path_bin = tmp_path / "path" / "bin"
path_bin.mkdir(parents=True)
path_langgraph = path_bin / "langgraph"
self._make_executable(path_langgraph)
monkeypatch.setattr(sys, "executable", str(local_bin / "python"))
monkeypatch.setattr(
manager.shutil,
"which",
lambda command: str(path_langgraph) if command == "langgraph" else None,
)
assert manager._langgraph_exe() == str(local_langgraph)
def test_falls_back_to_path_when_environment_binary_missing(
self, tmp_path, monkeypatch
):
path_bin = tmp_path / "path" / "bin"
path_bin.mkdir(parents=True)
path_langgraph = path_bin / "langgraph"
self._make_executable(path_langgraph)
monkeypatch.setattr(
sys, "executable", str(tmp_path / "local" / "bin" / "python")
)
monkeypatch.setattr(
manager.shutil,
"which",
lambda command: str(path_langgraph) if command == "langgraph" else None,
)
assert manager._langgraph_exe() == str(path_langgraph)
def test_checks_windows_suffix_next_to_current_python(self, tmp_path, monkeypatch):
scripts_dir = tmp_path / "Scripts"
scripts_dir.mkdir()
local_langgraph = scripts_dir / "langgraph.exe"
self._make_executable(local_langgraph)
path_bin = tmp_path / "path" / "bin"
path_bin.mkdir(parents=True)
path_langgraph = path_bin / "langgraph.exe"
self._make_executable(path_langgraph)
monkeypatch.setattr(sys, "executable", str(scripts_dir / "python.exe"))
monkeypatch.setattr(manager.os, "name", "nt", raising=False)
monkeypatch.setattr(
manager.shutil,
"which",
lambda command: str(path_langgraph) if command == "langgraph" else None,
)
assert manager._langgraph_exe() == str(local_langgraph)
# =============================================================================
# is_langgraph_dev_running
# =============================================================================
+169
View File
@@ -1,5 +1,6 @@
"""Tests for EvoScientist LLM module."""
from types import SimpleNamespace
from unittest.mock import patch
import pytest
@@ -160,6 +161,68 @@ class TestGetModelInfo:
class TestGetChatModel:
@patch("EvoScientist.llm.models.init_chat_model")
def test_uses_host_model_resolver(self, mock_init):
"""An embedding host can provide model routing without a core dependency."""
from EvoScientist.runtime_integrations import (
configure_runtime_integrations,
reset_runtime_integrations,
)
mock_init.return_value = "mock_model"
resolved = SimpleNamespace(
provider_name="relay-a",
model_id="model-a",
protocol="openai",
api_key="sk-host",
base_url="https://relay.example/v1/",
params={"max_tokens": 8192, "_default_headers": {"X-Relay": "a"}},
supports_reasoning=False,
)
configure_runtime_integrations(model_resolver=lambda model, provider: resolved)
try:
assert get_chat_model("alias-a") == "mock_model"
finally:
reset_runtime_integrations()
call_kwargs = mock_init.call_args.kwargs
assert call_kwargs["model"] == "model-a"
assert call_kwargs["model_provider"] == "openai"
assert call_kwargs["api_key"] == "sk-host"
assert call_kwargs["base_url"] == "https://relay.example/v1"
assert call_kwargs["max_tokens"] == 8192
assert call_kwargs["default_headers"] == {"X-Relay": "a"}
assert "reasoning" not in call_kwargs
@patch("EvoScientist.llm.models._patch_openai_compat_content")
@patch("EvoScientist.llm.models.init_chat_model")
def test_host_openai_provider_with_custom_base_uses_compat_patch(
self, mock_init, mock_compat
):
from EvoScientist.runtime_integrations import (
configure_runtime_integrations,
reset_runtime_integrations,
)
model_instance = object()
mock_init.return_value = model_instance
resolved = SimpleNamespace(
provider_name="openai",
model_id="gpt-5.5",
protocol="openai",
api_key="sk-host",
base_url="https://relay.example/v1",
params={},
supports_reasoning=True,
)
configure_runtime_integrations(model_resolver=lambda model, provider: resolved)
try:
get_chat_model("gpt-5.5", provider="openai")
finally:
reset_runtime_integrations()
mock_compat.assert_called_once_with(model_instance, hoist_tool_media=True)
@patch("EvoScientist.llm.models.init_chat_model")
def test_uses_default_model_when_none(self, mock_init):
"""Test that get_chat_model uses default model when model=None."""
@@ -229,6 +292,39 @@ class TestGetChatModel:
assert call_kwargs["temperature"] == 0.7
assert call_kwargs["max_tokens"] == 1000
@patch("EvoScientist.llm.models.init_chat_model")
def test_drops_unsupported_legacy_model_kwargs(self, mock_init):
mock_init.return_value = "mock_model"
get_chat_model(
"gpt-5-nano",
provider="openai",
sanitize_openai_sdk_headers=True,
model_kwargs={"sanitize_openai_sdk_headers": False, "custom": "value"},
)
call_kwargs = mock_init.call_args.kwargs
assert "sanitize_openai_sdk_headers" not in call_kwargs
assert call_kwargs["model_kwargs"] == {"custom": "value"}
@patch("EvoScientist.llm.models.init_chat_model")
def test_explicit_credentials_override_environment(self, mock_init, monkeypatch):
"""Host-provided credentials take precedence over process defaults."""
mock_init.return_value = "mock_model"
monkeypatch.setenv("OPENAI_API_KEY", "sk-environment")
monkeypatch.setenv("OPENAI_BASE_URL", "https://environment.example/v1")
get_chat_model(
"gpt-5-nano",
provider="openai",
api_key="sk-explicit",
base_url="https://explicit.example/v1",
)
call_kwargs = mock_init.call_args.kwargs
assert call_kwargs["api_key"] == "sk-explicit"
assert call_kwargs["base_url"] == "https://explicit.example/v1"
@patch("EvoScientist.llm.models.init_chat_model")
def test_infers_openai_from_gpt_prefix(self, mock_init):
"""Test that OpenAI is inferred from gpt- prefix."""
@@ -957,6 +1053,52 @@ class TestPatchOpenAICompatContent:
model._astream = AsyncMock()
return model
def test_missing_tool_call_ids_are_repaired_without_mutating_history(self):
from langchain_core.messages import AIMessage, ToolMessage
from EvoScientist.llm.patches import _ensure_openai_tool_call_ids
ai = AIMessage(
content=[{"type": "tool_call", "id": "", "name": "execute", "args": {}}],
tool_calls=[{"id": "", "name": "execute", "args": {}}],
)
tool = ToolMessage(content="ok", tool_call_id="")
normalized = _ensure_openai_tool_call_ids([ai, tool])
call_id = normalized[0].tool_calls[0]["id"]
assert call_id.startswith("call_")
assert normalized[0].content[0]["id"] == call_id
assert normalized[1].tool_call_id == call_id
assert ai.tool_calls[0]["id"] == ""
assert tool.tool_call_id == ""
def test_missing_parallel_tool_call_ids_are_stable_and_ordered(self):
from langchain_core.messages import AIMessage, ToolMessage
from EvoScientist.llm.patches import _ensure_openai_tool_call_ids
messages = [
AIMessage(
id="assistant-1",
content="",
tool_calls=[
{"id": "", "name": "read_file", "args": {}},
{"id": "", "name": "execute", "args": {}},
],
),
ToolMessage(content="file", tool_call_id=""),
ToolMessage(content="command", tool_call_id=""),
]
first = _ensure_openai_tool_call_ids(messages)
second = _ensure_openai_tool_call_ids(messages)
call_ids = [call["id"] for call in first[0].tool_calls]
assert call_ids == [call["id"] for call in second[0].tool_calls]
assert len(set(call_ids)) == 2
assert [message.tool_call_id for message in first[1:]] == call_ids
def test_generate_flattened(self):
from langchain_core.messages import HumanMessage
@@ -2238,6 +2380,33 @@ class TestPatchOpenrouterStripResponsesReasoning:
class TestAutoConfig:
@patch("EvoScientist.llm.models.init_chat_model")
def test_internal_sentinels_disable_auto_reasoning(self, mock_init, monkeypatch):
"""Internal callers can disable reasoning without leaking sentinels."""
mock_init.return_value = "mock_model"
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
for model, provider in (
("claude-sonnet-4-6", "anthropic"),
("gpt-5-nano", "openai"),
("gemini-2.5-flash", "google-genai"),
("llama3.1:8b", "ollama"),
):
mock_init.reset_mock()
get_chat_model(
model,
provider=provider,
_disable_reasoning=True,
_disable_thinking=True,
)
call_kwargs = mock_init.call_args.kwargs
assert "_disable_reasoning" not in call_kwargs
assert "_disable_thinking" not in call_kwargs
assert "reasoning" not in call_kwargs
assert "thinking" not in call_kwargs
assert "include_thoughts" not in call_kwargs
@patch("EvoScientist.llm.models.init_chat_model")
def test_anthropic_4_5_thinking(self, mock_init, monkeypatch):
"""Anthropic 4-5 models get enabled thinking with budget."""
+129
View File
@@ -0,0 +1,129 @@
import logging
from datetime import UTC, datetime
from io import StringIO
from EvoScientist.logging_config import (
DailyLogFileHandler,
configure_console_logging,
configure_daily_file_logging,
configure_logging,
default_log_dir,
resolve_log_level,
)
def test_daily_log_file_handler_uses_dated_active_file(tmp_path):
handler = DailyLogFileHandler(tmp_path, prefix="evoscientist", retention_days=30)
logger = logging.getLogger("tests.daily_log_file_handler")
logger.handlers.clear()
logger.propagate = False
logger.setLevel(logging.INFO)
logger.addHandler(handler)
logger.info("hello")
handler.close()
today = datetime.now().strftime("%Y-%m-%d")
assert (tmp_path / f"evoscientist-{today}.log").read_text(encoding="utf-8").strip()
def test_daily_log_file_handler_keeps_latest_retention_days(tmp_path):
for day in range(1, 33):
(tmp_path / f"evoscientist-2026-01-{day:02d}.log").write_text(
"x", encoding="utf-8"
)
handler = DailyLogFileHandler(tmp_path, prefix="evoscientist", retention_days=30)
handler._delete_expired_logs()
handler.close()
remaining = sorted(path.name for path in tmp_path.glob("evoscientist-*.log"))
assert len(remaining) == 30
assert remaining[0] == "evoscientist-2026-01-03.log"
def test_configure_daily_file_logging_replaces_matching_handler(tmp_path):
logger = logging.getLogger("tests.configure_daily_file_logging")
logger.handlers.clear()
logger.propagate = False
first = configure_daily_file_logging(logger, log_dir=tmp_path)
second = configure_daily_file_logging(logger, log_dir=tmp_path)
try:
handlers = [h for h in logger.handlers if isinstance(h, DailyLogFileHandler)]
assert handlers == [second]
assert first.stream is None
finally:
for handler in logger.handlers[:]:
logger.removeHandler(handler)
handler.close()
def test_configure_logging_replaces_only_managed_handlers(tmp_path):
logger = logging.getLogger("tests.configure_logging")
logger.handlers.clear()
logger.propagate = False
external = logging.NullHandler()
logger.addHandler(external)
configure_logging(logger, log_dir=tmp_path, level="debug")
configure_logging(logger, log_dir=tmp_path, level="info")
try:
daily_handlers = [h for h in logger.handlers if isinstance(h, DailyLogFileHandler)]
stream_handlers = [
h
for h in logger.handlers
if isinstance(h, logging.StreamHandler)
and not isinstance(h, DailyLogFileHandler)
]
assert external in logger.handlers
assert len(daily_handlers) == 1
assert len(stream_handlers) == 1
assert logger.level == logging.INFO
finally:
for handler in logger.handlers[:]:
logger.removeHandler(handler)
handler.close()
def test_configure_console_logging_emits_to_stream():
logger = logging.getLogger("tests.configure_console_logging")
logger.handlers.clear()
logger.propagate = False
stream = StringIO()
configure_console_logging(logger, level="INFO", stream=stream)
try:
logger.info("hello")
assert "tests.configure_console_logging: hello" in stream.getvalue()
finally:
for handler in logger.handlers[:]:
logger.removeHandler(handler)
handler.close()
def test_resolve_log_level_accepts_alias_numeric_and_fallback():
assert resolve_log_level("warn") == logging.WARNING
assert resolve_log_level("10") == logging.DEBUG
assert resolve_log_level("", default=logging.ERROR) == logging.ERROR
assert resolve_log_level("not-a-level", default=logging.CRITICAL) == logging.CRITICAL
def test_daily_log_file_handler_supports_utc(tmp_path):
handler = DailyLogFileHandler(tmp_path, utc=True)
try:
today_utc = datetime.now(UTC).strftime("%Y-%m-%d")
assert handler.active_log_path.name == f"evoscientist-{today_utc}.log"
finally:
handler.close()
def test_default_log_dir_uses_current_data_dir(monkeypatch, tmp_path):
import EvoScientist.paths as paths
monkeypatch.delenv("EVOSCIENTIST_LOG_DIR", raising=False)
monkeypatch.setattr(paths, "DATA_DIR", tmp_path / "data")
assert default_log_dir() == tmp_path / "data" / "logs"
+15 -2
View File
@@ -1421,22 +1421,35 @@ class TestLoadToolsProgressCallback:
]
async def test_failure_emits_start_then_error_with_detail(self, monkeypatch):
from EvoScientist.mcp.client import _load_tools
from EvoScientist.mcp import client as mcp_client
events: list[tuple[str, str, str]] = []
self._patch_client(monkeypatch, {"srv": RuntimeError("boom")})
monkeypatch.setattr(mcp_client, "_MCP_SERVER_ERRORS", {})
config = {"srv": {"transport": "stdio", "command": "demo"}}
def record(event, name, detail):
events.append((event, name, detail))
await _load_tools(config, on_progress=record)
await mcp_client._load_tools(config, on_progress=record)
assert events == [
("start", "srv", ""),
("error", "srv", "boom"),
]
assert mcp_client.get_mcp_server_errors() == {"srv": "boom"}
async def test_success_clears_previous_server_error(self, monkeypatch):
from EvoScientist.mcp import client as mcp_client
self._patch_client(monkeypatch, {"srv": ["tool1"]})
monkeypatch.setattr(mcp_client, "_MCP_SERVER_ERRORS", {"srv": "old error"})
config = {"srv": {"transport": "stdio", "command": "demo"}}
await mcp_client._load_tools(config)
assert mcp_client.get_mcp_server_errors() == {}
async def test_mixed_fleet_reports_each_server_independently(self, monkeypatch):
from EvoScientist.mcp.client import _load_tools
+59
View File
@@ -21,6 +21,7 @@ def _restore_paths():
"GLOBAL_MEMORIES_DIR": paths.GLOBAL_MEMORIES_DIR,
"USER_SKILLS_DIR": paths.USER_SKILLS_DIR,
"_active_workspace": paths._active_workspace,
"_EVOSCIENTIST_DATA_ROOT": paths._EVOSCIENTIST_DATA_ROOT,
}
yield
paths.WORKSPACE_ROOT = orig["WORKSPACE_ROOT"]
@@ -32,6 +33,7 @@ def _restore_paths():
paths.GLOBAL_MEMORIES_DIR = orig["GLOBAL_MEMORIES_DIR"]
paths.USER_SKILLS_DIR = orig["USER_SKILLS_DIR"]
paths._active_workspace = orig["_active_workspace"]
paths._EVOSCIENTIST_DATA_ROOT = orig["_EVOSCIENTIST_DATA_ROOT"]
class TestSetWorkspaceRoot:
@@ -140,6 +142,63 @@ class TestDataDir:
assert paths.GLOBAL_MEMORIES_DIR == paths.DATA_DIR / "memories"
class TestGatewayDataDirs:
def test_evoscientist_root_prefers_home_override(self, tmp_path, monkeypatch):
home = tmp_path / "runtime-home"
monkeypatch.setenv("EVOSCIENTIST_HOME", str(home))
assert paths.evoscientist_root() == home.resolve()
def test_evoscientist_root_falls_back_to_data_dir(self, tmp_path, monkeypatch):
data_dir = tmp_path / "app-data"
monkeypatch.delenv("EVOSCIENTIST_HOME", raising=False)
monkeypatch.setattr(paths, "DATA_DIR", data_dir)
assert paths.evoscientist_root() == data_dir.resolve()
def test_data_root_respects_environment_override(self, tmp_path, monkeypatch):
data_root = tmp_path / "web-data"
monkeypatch.setenv("EVOSCIENTIST_DATA_ROOT", str(data_root))
paths._EVOSCIENTIST_DATA_ROOT = None
assert paths._data_root() == data_root.resolve()
def test_user_thread_and_global_dirs_are_created(self, tmp_path, monkeypatch):
data_root = tmp_path / "web-data"
monkeypatch.setenv("EVOSCIENTIST_DATA_ROOT", str(data_root))
paths._EVOSCIENTIST_DATA_ROOT = None
user_dir = paths.user_data_dir("user-a")
thread_dir = paths.thread_data_dir("user-a", "thread-1")
shared_dir = paths.global_data_dir("user-a")
assert user_dir == data_root / "user-a"
assert thread_dir == user_dir / "thread-1"
assert shared_dir == user_dir / "__global__"
assert user_dir.is_dir()
assert thread_dir.is_dir()
assert shared_dir.is_dir()
def test_iter_user_data_dirs_yields_directories_only(self, tmp_path, monkeypatch):
data_root = tmp_path / "web-data"
monkeypatch.setenv("EVOSCIENTIST_DATA_ROOT", str(data_root))
paths._EVOSCIENTIST_DATA_ROOT = None
paths.user_data_dir("user-a")
paths.user_data_dir("user-b")
(data_root / "metadata.json").write_text("{}", encoding="utf-8")
assert {path.name for path in paths.iter_user_data_dirs()} == {
"user-a",
"user-b",
}
def test_uploads_dir_uses_evoscientist_root(self, tmp_path, monkeypatch):
home = tmp_path / "runtime-home"
monkeypatch.setenv("EVOSCIENTIST_HOME", str(home))
assert paths.uploads_dir() == home.resolve() / "uploads"
class TestLegacySessionsDbMigration:
"""Tests for migrate_legacy_sessions_db() — transitional helper.
+117
View File
@@ -0,0 +1,117 @@
from __future__ import annotations
import ast
from pathlib import Path
import pytest
from EvoScientist.runtime_integrations import (
RuntimeIntegrationUnavailable,
configure_runtime_integrations,
get_app_connection,
get_image_backend,
get_session_connection,
get_session_dsn,
handle_knowledge_file,
record_service_usage,
reset_runtime_integrations,
resolve_runtime_model,
)
@pytest.fixture(autouse=True)
def reset_integrations():
reset_runtime_integrations()
yield
reset_runtime_integrations()
def test_core_package_does_not_import_gateway():
package_root = Path(__file__).resolve().parents[1] / "EvoScientist"
violations = []
for source_file in package_root.rglob("*.py"):
tree = ast.parse(
source_file.read_text(encoding="utf-8"), filename=str(source_file)
)
for node in ast.walk(tree):
if isinstance(node, ast.Import):
names = [alias.name for alias in node.names]
elif isinstance(node, ast.ImportFrom):
if node.level:
continue
names = [node.module or ""]
else:
continue
if any(name == "gateway" or name.startswith("gateway.") for name in names):
violations.append(
f"{source_file.relative_to(package_root)}:{node.lineno}"
)
assert violations == []
@pytest.mark.anyio
async def test_optional_integrations_are_safe_without_web_runtime(tmp_path):
assert get_session_dsn() is None
await handle_knowledge_file(tmp_path / "result.md")
await record_service_usage("search", "query")
with pytest.raises(RuntimeIntegrationUnavailable):
await get_app_connection()
with pytest.raises(RuntimeIntegrationUnavailable):
await get_session_connection()
with pytest.raises(RuntimeIntegrationUnavailable):
get_image_backend()
@pytest.mark.anyio
async def test_host_can_register_runtime_integrations(tmp_path):
app_connection = object()
session_connection = object()
knowledge_paths = []
usage = []
image_backend = object()
async def provide_app_connection():
return app_connection
async def provide_session_connection():
return session_connection
async def handle_file(path):
knowledge_paths.append(path)
async def record_usage(service, action):
usage.append((service, action))
configure_runtime_integrations(
app_connection_provider=provide_app_connection,
session_connection_provider=provide_session_connection,
session_dsn_provider=lambda: "postgresql://example/session",
knowledge_file_handler=handle_file,
usage_recorder=record_usage,
image_backend_factory=lambda: image_backend,
)
path = tmp_path / "result.md"
await handle_knowledge_file(path)
await record_service_usage("mineru", "parse")
assert await get_app_connection() is app_connection
assert await get_session_connection() is session_connection
assert get_session_dsn() == "postgresql://example/session"
assert get_image_backend() is image_backend
assert knowledge_paths == [path]
assert usage == [("mineru", "parse")]
def test_host_can_register_model_resolver():
resolved = object()
calls = []
def resolve_model(model, provider):
calls.append((model, provider))
return resolved
configure_runtime_integrations(model_resolver=resolve_model)
assert resolve_runtime_model("model-a", "provider-a") is resolved
assert calls == [("model-a", "provider-a")]
Generated
+1 -1
View File
@@ -944,7 +944,7 @@ wheels = [
[[package]]
name = "evoscientist"
version = "0.2.1"
version = "0.2.2"
source = { editable = "." }
dependencies = [
{ name = "deepagents", extra = ["quickjs"] },