From 4ffae3c1827d3b10fed56f5d8e982d035362bb75 Mon Sep 17 00:00:00 2001 From: m4 Date: Tue, 14 Jul 2026 17:57:34 +0800 Subject: [PATCH] EvoScientist Ai4Sci --- EvoScientist/EvoScientist.py | 77 +++++-- EvoScientist/__init__.py | 2 + EvoScientist/config/settings.py | 16 +- EvoScientist/llm/models.py | 125 ++++++++-- EvoScientist/llm/patches.py | 89 +++++++- EvoScientist/logging_config.py | 302 +++++++++++++++++++++++++ EvoScientist/mcp/__init__.py | 2 + EvoScientist/mcp/client.py | 17 +- EvoScientist/paths.py | 63 ++++++ EvoScientist/runtime_integrations.py | 116 ++++++++++ EvoScientist/stream/events.py | 8 + tests/test_agent_factory_extensions.py | 71 ++++++ tests/test_config.py | 22 ++ tests/test_llm.py | 169 ++++++++++++++ tests/test_logging_config.py | 129 +++++++++++ tests/test_mcp_client.py | 17 +- tests/test_paths.py | 59 +++++ tests/test_runtime_integrations.py | 117 ++++++++++ uv.lock | 2 +- 19 files changed, 1355 insertions(+), 48 deletions(-) create mode 100644 EvoScientist/logging_config.py create mode 100644 EvoScientist/runtime_integrations.py create mode 100644 tests/test_agent_factory_extensions.py create mode 100644 tests/test_logging_config.py create mode 100644 tests/test_runtime_integrations.py diff --git a/EvoScientist/EvoScientist.py b/EvoScientist/EvoScientist.py index 326e1e1..66d6824 100644 --- a/EvoScientist/EvoScientist.py +++ b/EvoScientist/EvoScientist.py @@ -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, diff --git a/EvoScientist/__init__.py b/EvoScientist/__init__.py index cabee0a..a58e6aa 100644 --- a/EvoScientist/__init__.py +++ b/EvoScientist/__init__.py @@ -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"), diff --git a/EvoScientist/config/settings.py b/EvoScientist/config/settings.py index 0793a9c..ed19620 100644 --- a/EvoScientist/config/settings.py +++ b/EvoScientist/config/settings.py @@ -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" diff --git a/EvoScientist/llm/models.py b/EvoScientist/llm/models.py index 5484676..b6e62f1 100644 --- a/EvoScientist/llm/models.py +++ b/EvoScientist/llm/models.py @@ -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 diff --git a/EvoScientist/llm/patches.py b/EvoScientist/llm/patches.py index db786d3..84d0b87 100644 --- a/EvoScientist/llm/patches.py +++ b/EvoScientist/llm/patches.py @@ -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 diff --git a/EvoScientist/logging_config.py b/EvoScientist/logging_config.py new file mode 100644 index 0000000..a8abdfd --- /dev/null +++ b/EvoScientist/logging_config.py @@ -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, + ) diff --git a/EvoScientist/mcp/__init__.py b/EvoScientist/mcp/__init__.py index 7747f55..1187f19 100644 --- a/EvoScientist/mcp/__init__.py +++ b/EvoScientist/mcp/__init__.py @@ -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", diff --git a/EvoScientist/mcp/client.py b/EvoScientist/mcp/client.py index bf931d7..ca984df 100644 --- a/EvoScientist/mcp/client.py +++ b/EvoScientist/mcp/client.py @@ -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, *, diff --git a/EvoScientist/paths.py b/EvoScientist/paths.py index c813f77..cfd247e 100644 --- a/EvoScientist/paths.py +++ b/EvoScientist/paths.py @@ -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" diff --git a/EvoScientist/runtime_integrations.py b/EvoScientist/runtime_integrations.py new file mode 100644 index 0000000..bc0ae2f --- /dev/null +++ b/EvoScientist/runtime_integrations.py @@ -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() diff --git a/EvoScientist/stream/events.py b/EvoScientist/stream/events.py index f22c161..309953b 100644 --- a/EvoScientist/stream/events.py +++ b/EvoScientist/stream/events.py @@ -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: diff --git a/tests/test_agent_factory_extensions.py b/tests/test_agent_factory_extensions.py new file mode 100644 index 0000000..b0f7301 --- /dev/null +++ b/tests/test_agent_factory_extensions.py @@ -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} diff --git a/tests/test_config.py b/tests/test_config.py index 8799da5..7160535 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -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" diff --git a/tests/test_llm.py b/tests/test_llm.py index 23168d0..3473870 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -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.""" diff --git a/tests/test_logging_config.py b/tests/test_logging_config.py new file mode 100644 index 0000000..eda8997 --- /dev/null +++ b/tests/test_logging_config.py @@ -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" diff --git a/tests/test_mcp_client.py b/tests/test_mcp_client.py index 5e1bb62..4302372 100644 --- a/tests/test_mcp_client.py +++ b/tests/test_mcp_client.py @@ -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 diff --git a/tests/test_paths.py b/tests/test_paths.py index 1b9d8c4..c29e94d 100644 --- a/tests/test_paths.py +++ b/tests/test_paths.py @@ -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. diff --git a/tests/test_runtime_integrations.py b/tests/test_runtime_integrations.py new file mode 100644 index 0000000..9e3ffa7 --- /dev/null +++ b/tests/test_runtime_integrations.py @@ -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")] diff --git a/uv.lock b/uv.lock index e15f1d8..947e9fa 100644 --- a/uv.lock +++ b/uv.lock @@ -944,7 +944,7 @@ wheels = [ [[package]] name = "evoscientist" -version = "0.2.1" +version = "0.2.2" source = { editable = "." } dependencies = [ { name = "deepagents", extra = ["quickjs"] },