1 Commits

Author SHA1 Message Date
m4 4ffae3c182 EvoScientist Ai4Sci 2026-07-14 17:57:34 +08:00
19 changed files with 1355 additions and 48 deletions
+57 -20
View File
@@ -641,9 +641,13 @@ def _get_default_middleware(
*, *,
for_async_subagent: bool = False, for_async_subagent: bool = False,
workspace_dir: str | Path | None = None, workspace_dir: str | Path | None = None,
memory_dir: str | Path | None = None,
cfg=None, cfg=None,
chat_model=None, chat_model=None,
memory_source_agent: str = "EvoScientist", 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. """Build the default middleware list.
@@ -684,7 +688,7 @@ def _get_default_middleware(
if cfg.model_fallbacks: if cfg.model_fallbacks:
load_fallback_chain(cfg.model_fallbacks) load_fallback_chain(cfg.model_fallbacks)
model = chat_model if chat_model is not None else _ensure_chat_model() 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 = ( source_type = (
MemorySourceType.SUBAGENT if for_async_subagent else MemorySourceType.TURN MemorySourceType.SUBAGENT if for_async_subagent else MemorySourceType.TURN
) )
@@ -699,18 +703,20 @@ def _get_default_middleware(
# ``ModelFallbackMiddleware``: a configurable.model override sets the # ``ModelFallbackMiddleware``: a configurable.model override sets the
# PRIMARY model only, leaving the fallback chain free to try its own # PRIMARY model only, leaving the fallback chain free to try its own
# alternatives instead of re-overriding every retry to the same model. # alternatives instead of re-overriding every retry to the same model.
memory_middleware = create_memory_middleware( memory_kwargs = {
memory_dir, "workspace_dir": workspace_dir,
workspace_dir=workspace_dir, "source_type": source_type,
source_type=source_type, "source_agent": memory_source_agent,
source_agent=memory_source_agent, "enable_profile_memory": memory_controls.profile_enabled,
enable_profile_memory=memory_controls.profile_enabled, "enable_observation_memory": memory_controls.observations_enabled,
enable_observation_memory=memory_controls.observations_enabled, "enable_observation_tool": memory_controls.observation_tool_enabled(
enable_observation_tool=memory_controls.observation_tool_enabled(
MemoryObservationTarget.AGENT 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 # 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). # 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 # context_editing stays on the main model — its model only sizes the
@@ -735,6 +741,11 @@ def _get_default_middleware(
ContextOverflowMapperMiddleware(), ContextOverflowMapperMiddleware(),
ToolErrorHandlerMiddleware(), ToolErrorHandlerMiddleware(),
*create_tool_selector_middleware( *create_tool_selector_middleware(
**(
{"threshold": tool_selector_threshold}
if tool_selector_threshold is not None
else {}
),
model=tool_selector_model, model=tool_selector_model,
track_stream_selection=not for_async_subagent, 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 / # Background-process tools (run_in_background / check_process / stop_process /
# list_processes) — main agent only. Async sub-agents run on langgraph-dev and # list_processes) — main agent only. Async sub-agents run on langgraph-dev and
# must not spawn local OS processes. # 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 from .middleware.background import BackgroundExecutionMiddleware
mw.append(BackgroundExecutionMiddleware()) mw.append(BackgroundExecutionMiddleware())
@@ -868,6 +879,12 @@ def create_cli_agent(
chat_model=None, chat_model=None,
*, *,
on_mcp_progress=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": ) -> "CompiledStateGraph":
"""Create agent with checkpointer for CLI multi-turn support. """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 chat_model: Optional pre-built chat model. Only triggers the pure
path when ``config`` is also explicit; otherwise it is ignored in path when ``config`` is also explicit; otherwise it is ignored in
favor of the ``_ensure_chat_model()`` fallback. 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 import os as _os
@@ -935,19 +962,21 @@ def create_cli_agent(
workspace_dir = str(_paths.WORKSPACE_ROOT) workspace_dir = str(_paths.WORKSPACE_ROOT)
# Read paths dynamically so runtime set_workspace_root() changes are picked up # 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) _usr_skills_dir = str(_paths.USER_SKILLS_DIR)
_global_skills_dir = str(_paths.GLOBAL_SKILLS_DIR) _global_skills_dir = str(_paths.GLOBAL_SKILLS_DIR)
# Always construct fresh backends from current paths (avoids stale # Always construct fresh backends from current paths (avoids stale
# module-level backend when workspace root changed at runtime). # module-level backend when workspace root changed at runtime).
set_active_workspace(workspace_dir) set_active_workspace(workspace_dir)
ws_backend = CustomSandboxBackend( ws_backend = workspace_backend
root_dir=workspace_dir, if ws_backend is None:
virtual_mode=True, ws_backend = CustomSandboxBackend(
timeout=cfg.sandbox_execute_timeout, root_dir=workspace_dir,
dangerous=cfg.dangerous_mode, virtual_mode=True,
) timeout=cfg.sandbox_execute_timeout,
dangerous=cfg.dangerous_mode,
)
sk_backend = MergedSkillsBackend( sk_backend = MergedSkillsBackend(
primary_dir=_usr_skills_dir, primary_dir=_usr_skills_dir,
global_dir=_global_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 # CLI agent never drifts from the default chain. Anything CLI-specific
# (e.g. ``HumanInTheLoopMiddleware``) is appended below. # (e.g. ``HumanInTheLoopMiddleware``) is appended below.
mw: list[AgentMiddleware] = _get_default_middleware( 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 # HITL on main agent only — passing `interrupt_on=` to create_deep_agent
@@ -995,6 +1030,8 @@ def create_cli_agent(
chat_model=chat_model, chat_model=chat_model,
workspace_dir=workspace_dir, workspace_dir=workspace_dir,
) )
if not enable_subagents:
kwargs = {**kwargs, "subagents": []}
return create_deep_agent( return create_deep_agent(
**kwargs, **kwargs,
+2
View File
@@ -9,6 +9,8 @@ from __future__ import annotations
from importlib import import_module from importlib import import_module
__version__ = "0.2.2"
_EXPORTS: dict[str, tuple[str, str]] = { _EXPORTS: dict[str, tuple[str, str]] = {
# Agent graph (lazy to avoid expensive initialization at import time) # Agent graph (lazy to avoid expensive initialization at import time)
"EvoScientist_agent": (".EvoScientist", "EvoScientist_agent"), "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: def get_config_dir() -> Path:
"""Get the configuration directory 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") xdg_config = os.environ.get("XDG_CONFIG_HOME")
if xdg_config: if xdg_config:
return Path(xdg_config) / "evoscientist" return Path(xdg_config).expanduser() / "evoscientist"
return Path.home() / ".config" / "evoscientist" return Path.home() / ".config" / "evoscientist"
+106 -19
View File
@@ -68,6 +68,11 @@ _THINKING_CAPABLE_PROVIDERS: set[str] = {"minimax"}
_TRUTHY_ENV_VALUES = {"1", "true", "yes", "on"} _TRUTHY_ENV_VALUES = {"1", "true", "yes", "on"}
_FALSEY_ENV_VALUES = {"0", "false", "no", "off"} _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) # Model registry: list of (short_name, model_id, provider)
# Allows same short_name across different providers. # Allows same short_name across different providers.
_MODEL_ENTRIES: list[tuple[str, str, str]] = [ _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 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: def _supports_openrouter_anthropic_prompt_cache(provider: str, model_id: str) -> bool:
"""Return whether EvoScientist should declare OpenRouter Claude caching.""" """Return whether EvoScientist should declare OpenRouter Claude caching."""
return provider == "openrouter" and model_id.startswith( 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 Mutates *kwargs* in place. Only sets keys that the caller hasn't already
provided, so explicit user settings are never overridden. 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 # 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 _supports_thinking = original_provider in _THINKING_CAPABLE_PROVIDERS
# Detect local proxy (e.g. ccproxy): thinking blocks in conversation # Detect local proxy (e.g. ccproxy): thinking blocks in conversation
# history cause 422 errors because the proxy doesn't accept 'thinking' # 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} kwargs["thinking"] = {"type": "enabled", "budget_tokens": 10000}
# OpenAI (native, not third-party routed): reasoning # OpenAI (native, not third-party routed): reasoning
if provider == "openai" and not is_third_party and "reasoning" not in kwargs: if (
if _is_ccproxy_codex(): 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. # ccproxy uses Chat Completions which doesn't support reasoning.
pass pass
else: else:
@@ -354,11 +381,11 @@ def _apply_auto_config(
kwargs["reasoning"] = {"effort": _eff, "summary": "auto"} kwargs["reasoning"] = {"effort": _eff, "summary": "auto"}
# Google GenAI: surface thinking traces # Google GenAI: surface thinking traces
if provider == "google-genai": if provider == "google-genai" and not disable_reasoning:
kwargs.setdefault("include_thoughts", True) kwargs.setdefault("include_thoughts", True)
# Ollama: separate reasoning content from response for thinking models # 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 kwargs["reasoning"] = True
@@ -385,7 +412,46 @@ def get_chat_model(
>>> model = get_chat_model("gpt-4o") # OpenAI model >>> model = get_chat_model("gpt-4o") # OpenAI model
>>> model = get_chat_model("claude-3-opus-20240229", provider="anthropic") # Full ID >>> 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) # Look up short name in registry (provider-aware)
model_id = None model_id = None
@@ -420,22 +486,35 @@ def get_chat_model(
_is_third_party = ( _is_third_party = (
provider in _OPENAI_ROUTED_PROVIDERS or provider in _ANTHROPIC_ROUTED_PROVIDERS 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 _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": if provider == "anthropic":
base_url = os.environ.get("ANTHROPIC_BASE_URL", "") base_url = os.environ.get("ANTHROPIC_BASE_URL", "")
if base_url: if base_url:
kwargs["base_url"] = base_url kwargs.setdefault("base_url", base_url)
api_key = os.environ.get("ANTHROPIC_API_KEY", "") api_key = os.environ.get("ANTHROPIC_API_KEY", "")
if 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) # Native OpenAI base_url override (e.g. ccproxy Codex at localhost:8000/codex/v1)
elif provider == "openai": elif provider == "openai":
base_url = os.environ.get("OPENAI_BASE_URL", "") base_url = os.environ.get("OPENAI_BASE_URL", "")
if base_url: if base_url:
kwargs["base_url"] = base_url kwargs.setdefault("base_url", base_url)
_is_openai_proxy = _is_ccproxy_codex() _is_openai_proxy = _is_ccproxy_codex(
kwargs.get("base_url"), kwargs.get("api_key")
)
if _is_openai_proxy: if _is_openai_proxy:
# Use Responses API for ccproxy: bypasses the format chain # Use Responses API for ccproxy: bypasses the format chain
# converter (Chat→Responses→Chat) which returns 502 on # converter (Chat→Responses→Chat) which returns 502 on
@@ -450,7 +529,7 @@ def get_chat_model(
kwargs.pop("streaming", None) # remove if set elsewhere kwargs.pop("streaming", None) # remove if set elsewhere
api_key = os.environ.get("OPENAI_API_KEY", "") api_key = os.environ.get("OPENAI_API_KEY", "")
if 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 # OpenAI-routed providers → route through OpenAI provider with base_url
elif provider in _OPENAI_ROUTED_PROVIDERS: elif provider in _OPENAI_ROUTED_PROVIDERS:
@@ -468,10 +547,10 @@ def get_chat_model(
else: else:
base_url = base_url_default base_url = base_url_default
if base_url: if base_url:
kwargs["base_url"] = base_url kwargs.setdefault("base_url", base_url)
api_key = os.environ.get(api_key_env, "") api_key = os.environ.get(api_key_env, "")
if api_key: if api_key:
kwargs["api_key"] = api_key kwargs.setdefault("api_key", api_key)
# SiliconFlow: disable thinking — LangChain drops reasoning_content # SiliconFlow: disable thinking — LangChain drops reasoning_content
# from history, causing error 20015 on multi-turn requests. # from history, causing error 20015 on multi-turn requests.
if provider == "siliconflow": if provider == "siliconflow":
@@ -488,7 +567,7 @@ def get_chat_model(
_is_third_party = True _is_third_party = True
api_key = os.environ.get("OPENROUTER_API_KEY", "") api_key = os.environ.get("OPENROUTER_API_KEY", "")
if 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 # Reasoning via `effort` + `summary: "auto"` so a readable reasoning
# summary is returned for display. OpenAI-Responses also emits encrypted # summary is returned for display. OpenAI-Responses also emits encrypted
# reasoning items (`rs_*` id) that can't be replayed on multi-turn # reasoning items (`rs_*` id) that can't be replayed on multi-turn
@@ -517,10 +596,10 @@ def get_chat_model(
else: else:
base_url = base_url_default base_url = base_url_default
if base_url: if base_url:
kwargs["base_url"] = base_url kwargs.setdefault("base_url", base_url)
api_key = os.environ.get(api_key_env, "") api_key = os.environ.get(api_key_env, "")
if api_key: if api_key:
kwargs["api_key"] = api_key kwargs.setdefault("api_key", api_key)
# Kimi Coding Plan requires claude-code User-Agent header # Kimi Coding Plan requires claude-code User-Agent header
if provider == "kimi-coding": if provider == "kimi-coding":
kwargs.setdefault("default_headers", {})["User-Agent"] = "claude-code/0.1.0" kwargs.setdefault("default_headers", {})["User-Agent"] = "claude-code/0.1.0"
@@ -529,8 +608,9 @@ def get_chat_model(
elif provider == "ollama": elif provider == "ollama":
base_url = os.environ.get("OLLAMA_BASE_URL", "") base_url = os.environ.get("OLLAMA_BASE_URL", "")
if 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_auto_config(provider, model_id, _is_third_party, kwargs, _original_provider)
_apply_openrouter_anthropic_prompt_cache(provider, model_id, kwargs) _apply_openrouter_anthropic_prompt_cache(provider, model_id, kwargs)
@@ -547,7 +627,14 @@ def get_chat_model(
elif _responses_api_setting == "true": elif _responses_api_setting == "true":
kwargs["use_responses_api"] = 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 # Flatten list content to strings for strict OpenAI-compatible providers
# (DeepSeek, SiliconFlow, OpenRouter, custom-openai, etc.) and # (DeepSeek, SiliconFlow, OpenRouter, custom-openai, etc.) and
+86 -3
View File
@@ -25,6 +25,7 @@ Utilities:
from __future__ import annotations from __future__ import annotations
import hashlib
import os import os
from typing import Any from typing import Any
@@ -178,15 +179,20 @@ _patch_ccproxy_codex_compat()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Utility: detect ccproxy's Codex adapter (as opposed to generic localhost). # 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. """Return True if the OpenAI endpoint is ccproxy's Codex adapter.
Checks for the ccproxy-specific markers set by ``setup_codex_env()`` Checks for the ccproxy-specific markers set by ``setup_codex_env()``
in ``ccproxy_manager.py``: the sentinel API key and the ``/codex/v1`` in ``ccproxy_manager.py``: the sentinel API key and the ``/codex/v1``
path. Plain localhost endpoints (vLLM, Ollama, etc.) are not affected. path. Plain localhost endpoints (vLLM, Ollama, etc.) are not affected.
""" """
base_url = os.environ.get("OPENAI_BASE_URL", "") if base_url is None:
api_key = os.environ.get("OPENAI_API_KEY", "") base_url = os.environ.get("OPENAI_BASE_URL", "")
if api_key is None:
api_key = os.environ.get("OPENAI_API_KEY", "")
return ( return (
("127.0.0.1" in base_url or "localhost" in base_url) ("127.0.0.1" in base_url or "localhost" in base_url)
and api_key == "ccproxy-oauth" 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 "" 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]: def _sanitize_messages(messages: list[Any], hoist_tool_media: bool = True) -> list[Any]:
"""Flatten list content for OpenAI-compatible APIs, preserving media. """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 from langchain_core.messages import HumanMessage
messages = _ensure_openai_tool_call_ids(messages)
out: list[Any] = [] out: list[Any] = []
pending_media: list[Any] = [] # media hoisted out of a run of tool messages 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_add_kwargs,
build_mcp_edit_fields, build_mcp_edit_fields,
edit_mcp_server, edit_mcp_server,
get_mcp_server_errors,
load_mcp_config, load_mcp_config,
load_mcp_tools, load_mcp_tools,
parse_mcp_add_args, parse_mcp_add_args,
@@ -38,6 +39,7 @@ __all__ = [
"find_server_by_name", "find_server_by_name",
"get_all_tags", "get_all_tags",
"get_installed_names", "get_installed_names",
"get_mcp_server_errors",
"install_mcp_server", "install_mcp_server",
"install_mcp_servers", "install_mcp_servers",
"load_mcp_config", "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. # still parallelizing the common 3–7 server case to completion.
_MAX_CONCURRENT_CONNECTIONS = 8 _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 # Env vars forwarded to stdio MCP subprocesses on top of the MCP SDK's
# minimal default set (HOME/PATH/USER/…). Without this, servers behind # minimal default set (HOME/PATH/USER/…). Without this, servers behind
# a proxy or with a custom CA bundle silently fail with long timeouts. # a proxy or with a custom CA bundle silently fail with long timeouts.
@@ -764,6 +768,9 @@ async def _load_tools(
if not connections: if not connections:
return {} 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] client = MultiServerMCPClient(connections) # type: ignore[invalid-argument-type]
def _report(event: str, name: str, detail: str = "") -> None: def _report(event: str, name: str, detail: str = "") -> None:
@@ -787,10 +794,13 @@ async def _load_tools(
_report("start", name) _report("start", name)
try: try:
tools = await client.get_tools(server_name=name) 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)) logger.info("MCP server %r: loaded %d tool(s)", name, len(tools))
_report("success", name, str(len(tools))) _report("success", name, str(len(tools)))
return name, tools return name, tools
except Exception as exc: 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 # When the caller wired up ``on_progress`` they own the
# user-facing display; downgrade the logger so we don't # user-facing display; downgrade the logger so we don't
# double-print. # double-print.
@@ -798,7 +808,7 @@ async def _load_tools(
logger.warning("MCP server %r: failed to load tools: %s", name, exc) logger.warning("MCP server %r: failed to load tools: %s", name, exc)
else: else:
logger.debug("MCP server %r: failed to load tools: %s", name, exc) logger.debug("MCP server %r: failed to load tools: %s", name, exc)
_report("error", name, str(exc)) _report("error", name, detail)
return name, [] return name, []
# ``return_exceptions=False`` is fine because ``_fetch`` already # ``return_exceptions=False`` is fine because ``_fetch`` already
@@ -807,6 +817,11 @@ async def _load_tools(
return dict(results) 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( async def aload_mcp_tools(
config: dict[str, Any] | None = None, config: dict[str, Any] | None = None,
*, *,
+63
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import logging import logging
import os import os
import shutil import shutil
from collections.abc import Iterator
from datetime import datetime from datetime import datetime
from pathlib import Path 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.""" """Resolve a virtual workspace path (e.g. /image.png) to a real filesystem path."""
vpath = virtual_path if virtual_path.startswith("/") else "/" + virtual_path vpath = virtual_path if virtual_path.startswith("/") else "/" + virtual_path
return (_active_workspace / vpath.lstrip("/")).resolve() 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 inspect
import mimetypes import mimetypes
import os import os
import warnings
from collections.abc import AsyncGenerator, AsyncIterator, Mapping from collections.abc import AsyncGenerator, AsyncIterator, Mapping
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, TypeAlias from typing import Any, TypeAlias
from langchain_core._api import LangChainBetaWarning
from langchain_core.messages import AIMessage, AIMessageChunk, BaseMessage, ToolMessage from langchain_core.messages import AIMessage, AIMessageChunk, BaseMessage, ToolMessage
from langgraph.graph import END from langgraph.graph import END
from langgraph.types import Command, Interrupt from langgraph.types import Command, Interrupt
@@ -43,6 +45,12 @@ GraphRunInput: TypeAlias = str | Command
LangGraphStreamInput: TypeAlias = dict[str, list[dict[str, object]]] | Command LangGraphStreamInput: TypeAlias = dict[str, list[dict[str, object]]] | Command
_ValueMessageKey: TypeAlias = tuple[str, ...] _ValueMessageKey: TypeAlias = tuple[str, ...]
warnings.filterwarnings(
"ignore",
message=r"The v3 streaming protocol on Pregel is experimental\.",
category=LangChainBetaWarning,
)
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class _AssistantValueMessage: 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): def temp_config_dir(tmp_path, monkeypatch):
"""Use a temporary directory for config during tests.""" """Use a temporary directory for config during tests."""
config_dir = tmp_path / "evoscientist" 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)) monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
# Prevent load_dotenv from loading the project's real .env file # Prevent load_dotenv from loading the project's real .env file
monkeypatch.setattr( monkeypatch.setattr(
@@ -199,14 +201,34 @@ class TestEvoScientistConfig:
class TestConfigPaths: 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): def test_get_config_dir_with_xdg(self, monkeypatch, tmp_path):
"""Test config dir uses XDG_CONFIG_HOME when set.""" """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)) monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
config_dir = get_config_dir() config_dir = get_config_dir()
assert config_dir == tmp_path / "evoscientist" assert config_dir == tmp_path / "evoscientist"
def test_get_config_dir_default(self, monkeypatch): def test_get_config_dir_default(self, monkeypatch):
"""Test config dir defaults to ~/.config/evoscientist.""" """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) monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
config_dir = get_config_dir() config_dir = get_config_dir()
assert config_dir == Path.home() / ".config" / "evoscientist" assert config_dir == Path.home() / ".config" / "evoscientist"
+169
View File
@@ -1,5 +1,6 @@
"""Tests for EvoScientist LLM module.""" """Tests for EvoScientist LLM module."""
from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import patch
import pytest import pytest
@@ -160,6 +161,68 @@ class TestGetModelInfo:
class TestGetChatModel: 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") @patch("EvoScientist.llm.models.init_chat_model")
def test_uses_default_model_when_none(self, mock_init): def test_uses_default_model_when_none(self, mock_init):
"""Test that get_chat_model uses default model when model=None.""" """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["temperature"] == 0.7
assert call_kwargs["max_tokens"] == 1000 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") @patch("EvoScientist.llm.models.init_chat_model")
def test_infers_openai_from_gpt_prefix(self, mock_init): def test_infers_openai_from_gpt_prefix(self, mock_init):
"""Test that OpenAI is inferred from gpt- prefix.""" """Test that OpenAI is inferred from gpt- prefix."""
@@ -957,6 +1053,52 @@ class TestPatchOpenAICompatContent:
model._astream = AsyncMock() model._astream = AsyncMock()
return model 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): def test_generate_flattened(self):
from langchain_core.messages import HumanMessage from langchain_core.messages import HumanMessage
@@ -2238,6 +2380,33 @@ class TestPatchOpenrouterStripResponsesReasoning:
class TestAutoConfig: 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") @patch("EvoScientist.llm.models.init_chat_model")
def test_anthropic_4_5_thinking(self, mock_init, monkeypatch): def test_anthropic_4_5_thinking(self, mock_init, monkeypatch):
"""Anthropic 4-5 models get enabled thinking with budget.""" """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): 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]] = [] events: list[tuple[str, str, str]] = []
self._patch_client(monkeypatch, {"srv": RuntimeError("boom")}) self._patch_client(monkeypatch, {"srv": RuntimeError("boom")})
monkeypatch.setattr(mcp_client, "_MCP_SERVER_ERRORS", {})
config = {"srv": {"transport": "stdio", "command": "demo"}} config = {"srv": {"transport": "stdio", "command": "demo"}}
def record(event, name, detail): def record(event, name, detail):
events.append((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 == [ assert events == [
("start", "srv", ""), ("start", "srv", ""),
("error", "srv", "boom"), ("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): async def test_mixed_fleet_reports_each_server_independently(self, monkeypatch):
from EvoScientist.mcp.client import _load_tools from EvoScientist.mcp.client import _load_tools
+59
View File
@@ -21,6 +21,7 @@ def _restore_paths():
"GLOBAL_MEMORIES_DIR": paths.GLOBAL_MEMORIES_DIR, "GLOBAL_MEMORIES_DIR": paths.GLOBAL_MEMORIES_DIR,
"USER_SKILLS_DIR": paths.USER_SKILLS_DIR, "USER_SKILLS_DIR": paths.USER_SKILLS_DIR,
"_active_workspace": paths._active_workspace, "_active_workspace": paths._active_workspace,
"_EVOSCIENTIST_DATA_ROOT": paths._EVOSCIENTIST_DATA_ROOT,
} }
yield yield
paths.WORKSPACE_ROOT = orig["WORKSPACE_ROOT"] paths.WORKSPACE_ROOT = orig["WORKSPACE_ROOT"]
@@ -32,6 +33,7 @@ def _restore_paths():
paths.GLOBAL_MEMORIES_DIR = orig["GLOBAL_MEMORIES_DIR"] paths.GLOBAL_MEMORIES_DIR = orig["GLOBAL_MEMORIES_DIR"]
paths.USER_SKILLS_DIR = orig["USER_SKILLS_DIR"] paths.USER_SKILLS_DIR = orig["USER_SKILLS_DIR"]
paths._active_workspace = orig["_active_workspace"] paths._active_workspace = orig["_active_workspace"]
paths._EVOSCIENTIST_DATA_ROOT = orig["_EVOSCIENTIST_DATA_ROOT"]
class TestSetWorkspaceRoot: class TestSetWorkspaceRoot:
@@ -140,6 +142,63 @@ class TestDataDir:
assert paths.GLOBAL_MEMORIES_DIR == paths.DATA_DIR / "memories" 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: class TestLegacySessionsDbMigration:
"""Tests for migrate_legacy_sessions_db() — transitional helper. """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]] [[package]]
name = "evoscientist" name = "evoscientist"
version = "0.2.1" version = "0.2.2"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "deepagents", extra = ["quickjs"] }, { name = "deepagents", extra = ["quickjs"] },