diff --git a/EvoScientist/cli/tui_interactive.py b/EvoScientist/cli/tui_interactive.py index ea2a7a5..4a63c6a 100644 --- a/EvoScientist/cli/tui_interactive.py +++ b/EvoScientist/cli/tui_interactive.py @@ -1036,11 +1036,19 @@ def run_textual_interactive( await container.mount(w) if tool_id: tool_widgets[tool_id] = w - # Update todo widget on write_todos + # Update todo widget on write_todos. + # Insert before tool call widget so Task List + # panel appears above the tool call. if tool_name == "write_todos" and state.todo_items: if todo_w is None: todo_w = TodoWidget(state.todo_items) - await container.mount(todo_w) + if tool_id and tool_id in tool_widgets: + await container.mount( + todo_w, + before=tool_widgets[tool_id], + ) + else: + await container.mount(todo_w) else: todo_w.update_items(state.todo_items) diff --git a/EvoScientist/cli/widgets/timestamp_mixin.py b/EvoScientist/cli/widgets/timestamp_mixin.py index 302c802..735903f 100644 --- a/EvoScientist/cli/widgets/timestamp_mixin.py +++ b/EvoScientist/cli/widgets/timestamp_mixin.py @@ -40,4 +40,5 @@ class TimestampClickMixin: def on_click(self, event) -> None: """Show timestamp toast on click.""" - show_timestamp_toast(self) + # TODO: re-enable after UX review + # show_timestamp_toast(self) diff --git a/EvoScientist/cli/widgets/tool_call_widget.py b/EvoScientist/cli/widgets/tool_call_widget.py index b2b4127..4b3fc47 100644 --- a/EvoScientist/cli/widgets/tool_call_widget.py +++ b/EvoScientist/cli/widgets/tool_call_widget.py @@ -256,7 +256,8 @@ class ToolCallWidget(Vertical): return if not self._should_collapse(): - show_timestamp_toast(self) + # TODO: re-enable after UX review + # show_timestamp_toast(self) return # Short output is always visible, nothing to toggle if self._collapsed: # Expand diff --git a/EvoScientist/config/onboard.py b/EvoScientist/config/onboard.py index 06d8aec..3095f4c 100644 --- a/EvoScientist/config/onboard.py +++ b/EvoScientist/config/onboard.py @@ -1293,6 +1293,43 @@ def _step_model( return model +def _step_reasoning_effort(config: EvoScientistConfig) -> str: + """Step 3.5: Configure OpenRouter reasoning effort level. + + Only shown when the selected provider is OpenRouter. See: + https://openrouter.ai/docs/guides/best-practices/reasoning-tokens + + Args: + config: Current configuration. + + Returns: + Selected reasoning effort level, or empty string to use default. + """ + effort_choices = [ + Choice(title="xhigh — ~95% of max_tokens for reasoning", value="xhigh"), + Choice(title="high — ~80% of max_tokens (recommended)", value="high"), + Choice(title="medium — ~50% of max_tokens", value="medium"), + Choice(title="low — ~20% of max_tokens", value="low"), + Choice(title="minimal — ~10% of max_tokens", value="minimal"), + Choice(title="none — disable reasoning entirely", value="none"), + ] + + current = config.reasoning_effort or "high" + effort = questionary.select( + "Select reasoning effort level:", + choices=effort_choices, + default=current, + style=WIZARD_STYLE, + qmark=QMARK, + use_indicator=True, + ).ask() + + if effort is None: + raise KeyboardInterrupt() + + return effort + + def _step_tavily_key( config: EvoScientistConfig, skip_validation: bool = False, @@ -2774,6 +2811,11 @@ def run_onboard(skip_validation: bool = False) -> bool: ) config.model = model + # Step 3.5: Reasoning Effort (OpenRouter only) + if provider == "openrouter": + effort = _step_reasoning_effort(config) + config.reasoning_effort = effort + # Step 4: Tavily Key new_tavily_key = _step_tavily_key(config, skip_validation) if new_tavily_key is not None: diff --git a/EvoScientist/config/settings.py b/EvoScientist/config/settings.py index 267ab6f..75b34a8 100644 --- a/EvoScientist/config/settings.py +++ b/EvoScientist/config/settings.py @@ -83,6 +83,7 @@ class EvoScientistConfig: # LLM Settings provider: str = "anthropic" model: str = "claude-sonnet-4-5" + reasoning_effort: str = "" # OpenRouter reasoning effort: "xhigh"|"high"|"medium"|"low"|"minimal"|"none"|"" (default=high) # Workspace Settings default_mode: Literal["daemon", "run"] = "daemon" @@ -476,3 +477,5 @@ def apply_config_to_env(config: EvoScientistConfig) -> None: "EVOSCIENTIST_USE_RESPONSES_API" ): os.environ["EVOSCIENTIST_USE_RESPONSES_API"] = config.use_responses_api + if config.reasoning_effort and not os.environ.get("EVOSCIENTIST_REASONING_EFFORT"): + os.environ["EVOSCIENTIST_REASONING_EFFORT"] = config.reasoning_effort diff --git a/EvoScientist/llm/models.py b/EvoScientist/llm/models.py index 848edc8..f1cf6f8 100644 --- a/EvoScientist/llm/models.py +++ b/EvoScientist/llm/models.py @@ -14,149 +14,15 @@ from typing import Any from langchain.chat_models import init_chat_model - -# --------------------------------------------------------------------------- -# Patch: langchain-anthropic (>=1.3.4) calls .model_dump() on -# context_management / container objects returned by the Anthropic SDK. -# Proxies like ccproxy may return plain dicts which lack that method. -# We wrap the class method to pre-convert dicts before the original runs. -# --------------------------------------------------------------------------- -def _patch_anthropic_proxy_compat() -> None: - try: - import types as _types - - from langchain_anthropic.chat_models import ChatAnthropic as _CA - - _orig = _CA._make_message_chunk_from_anthropic_event - - def _safe(self: Any, event: Any, *args: Any, **kwargs: Any) -> Any: - for obj, attrs in [ - (event, ("context_management",)), - (getattr(event, "delta", None), ("container",)), - ]: - if obj is None: - continue - for attr in attrs: - val = getattr(obj, attr, None) - if isinstance(val, dict): - d = val.copy() - setattr( - obj, - attr, - _types.SimpleNamespace(model_dump=lambda d=d, **kw: d), - ) - return _orig(self, event, *args, **kwargs) - - _CA._make_message_chunk_from_anthropic_event = _safe - except Exception: - pass - - -_patch_anthropic_proxy_compat() - - -def _is_ccproxy_codex() -> 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", "") - return ( - ("127.0.0.1" in base_url or "localhost" in base_url) - and api_key == "ccproxy-oauth" - and "/codex/" in base_url - ) - - -_SKIP_CONTENT_TYPES = frozenset({"thinking", "reasoning", "reasoning_content"}) - - -def _flatten_message_content(content: Any) -> str | Any: - """Convert list-of-blocks content to a plain string. - - OpenAI-compatible APIs (DeepSeek, SiliconFlow, etc.) reject assistant - messages whose ``content`` is a list rather than a string. - - Args: - content: Message content — either a string, a list of content blocks - (dicts with ``type`` and ``text`` keys), or another type. - - Returns: - A plain string with text blocks joined by double newlines. - Thinking/reasoning blocks are skipped. Non-list input is - returned unchanged. - """ - if isinstance(content, str): - return content - if not isinstance(content, list): - return content - parts: list[str] = [] - for block in content: - if isinstance(block, dict): - if block.get("type") in _SKIP_CONTENT_TYPES: - continue - text = block.get("text") - if text: - parts.append(text) - elif isinstance(block, str): - parts.append(block) - return "\n\n".join(parts) if parts else "" - - -def _patch_openai_compat_content(model: Any) -> None: - """Flatten list content to strings before OpenAI-compatible API calls. - - Wraps ``_generate`` / ``_agenerate`` to prevent "invalid type: sequence, - expected a string" errors from strict APIs like DeepSeek. Follows the - same monkey-patching pattern as ``_patch_anthropic_proxy_compat``. - - Args: - model: A LangChain chat model instance to patch in-place. - """ - import copy - import functools - - from langchain_core.messages import BaseMessage - - def _sanitize_messages(messages: list[BaseMessage]) -> list[BaseMessage]: - out: list[BaseMessage] = [] - for msg in messages: - if isinstance(msg.content, list): - msg = copy.copy(msg) - msg.content = _flatten_message_content(msg.content) - out.append(msg) - return out - - orig_generate = getattr(model, "_generate", None) - if orig_generate is None: - return - - @functools.wraps(orig_generate) - def _patched_generate( - messages: list[BaseMessage], *args: Any, **kwargs: Any - ) -> Any: - return orig_generate(_sanitize_messages(messages), *args, **kwargs) - - model._generate = _patched_generate - - orig_agenerate = getattr(model, "_agenerate", None) - if orig_agenerate is not None: - - @functools.wraps(orig_agenerate) - async def _patched_agenerate( - messages: list[BaseMessage], *args: Any, **kwargs: Any - ) -> Any: - return await orig_agenerate(_sanitize_messages(messages), *args, **kwargs) - - model._agenerate = _patched_agenerate - +from .patches import ( + _is_ccproxy_codex, + _patch_openai_compat_content, + _patch_openrouter_reasoning_details, +) _MINIMAX_ANTHROPIC_BASE_URL = "https://api.minimaxi.com/anthropic" _SILICONFLOW_BASE_URL = "https://api.siliconflow.cn/v1" -_OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1" + _ZHIPU_BASE_URL = "https://open.bigmodel.cn/api/paas/v4" _ZHIPU_CODE_BASE_URL = "https://open.bigmodel.cn/api/coding/paas/v4" _VOLCENGINE_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3" @@ -169,7 +35,6 @@ _DEEPSEEK_BASE_URL = "https://api.deepseek.com" _OPENAI_ROUTED_PROVIDERS: dict[str, tuple[str | None, str]] = { "deepseek": (_DEEPSEEK_BASE_URL, "DEEPSEEK_API_KEY"), "siliconflow": (_SILICONFLOW_BASE_URL, "SILICONFLOW_API_KEY"), - "openrouter": (_OPENROUTER_BASE_URL, "OPENROUTER_API_KEY"), "zhipu": (_ZHIPU_BASE_URL, "ZHIPU_API_KEY"), "zhipu-code": (_ZHIPU_CODE_BASE_URL, "ZHIPU_API_KEY"), "volcengine": (_VOLCENGINE_BASE_URL, "VOLCENGINE_API_KEY"), @@ -469,6 +334,17 @@ def get_chat_model( kwargs.setdefault("extra_body", {})["enable_thinking"] = False provider = "openai" + # OpenRouter → native ChatOpenRouter via init_chat_model. + elif provider == "openrouter": + _patch_openrouter_reasoning_details() + _is_third_party = True + api_key = os.environ.get("OPENROUTER_API_KEY", "") + if api_key: + kwargs["api_key"] = api_key + # Enable reasoning; disable summary to avoid multi-turn schema errors. + effort = os.environ.get("EVOSCIENTIST_REASONING_EFFORT", "").strip() or "high" + kwargs.setdefault("reasoning", {"effort": effort, "summary": "disabled"}) + # Anthropic-routed providers → route through Anthropic provider with base_url elif provider in _ANTHROPIC_ROUTED_PROVIDERS: base_url_default, api_key_env = _ANTHROPIC_ROUTED_PROVIDERS[provider] @@ -500,15 +376,16 @@ def get_chat_model( # User-level override for the OpenAI Responses API vs Chat Completions. # When "false", force Chat Completions and drop reasoning (which triggers - # the Responses API path in langchain-openai). - _responses_api_setting = ( - os.environ.get("EVOSCIENTIST_USE_RESPONSES_API", "").strip().lower() - ) - if _responses_api_setting == "false": - kwargs["use_responses_api"] = False - kwargs.pop("reasoning", None) - elif _responses_api_setting == "true": - kwargs["use_responses_api"] = True + # the Responses API path in langchain-openai). Only applies to OpenAI. + if provider == "openai": + _responses_api_setting = ( + os.environ.get("EVOSCIENTIST_USE_RESPONSES_API", "").strip().lower() + ) + if _responses_api_setting == "false": + kwargs["use_responses_api"] = False + kwargs.pop("reasoning", None) + elif _responses_api_setting == "true": + kwargs["use_responses_api"] = True chat_model = init_chat_model(model=model_id, model_provider=provider, **kwargs) diff --git a/EvoScientist/llm/patches.py b/EvoScientist/llm/patches.py new file mode 100644 index 0000000..abf9bee --- /dev/null +++ b/EvoScientist/llm/patches.py @@ -0,0 +1,192 @@ +"""Monkey-patches and utilities for third-party LangChain provider quirks. + +All patches follow the same pattern: wrap an existing method/function to +fix upstream bugs, applied at import time or on first use. + +Patches: + - _patch_anthropic_proxy_compat: ccproxy dict→Pydantic model mismatch + - _patch_openrouter_reasoning_details: reasoning_details schema errors + - _patch_openai_compat_content: list content→string for strict APIs + +Utilities: + - _is_ccproxy_codex: detect ccproxy Codex OAuth adapter + - _flatten_message_content: convert content blocks to plain string +""" + +from __future__ import annotations + +import os +from typing import Any + + +# --------------------------------------------------------------------------- +# Patch: langchain-anthropic (>=1.3.4) calls .model_dump() on +# context_management / container objects returned by the Anthropic SDK. +# Proxies like ccproxy may return plain dicts which lack that method. +# We wrap the class method to pre-convert dicts before the original runs. +# --------------------------------------------------------------------------- +def _patch_anthropic_proxy_compat() -> None: + try: + import types as _types + + from langchain_anthropic.chat_models import ChatAnthropic as _CA + + _orig = _CA._make_message_chunk_from_anthropic_event + + def _safe(self: Any, event: Any, *args: Any, **kwargs: Any) -> Any: + for obj, attrs in [ + (event, ("context_management",)), + (getattr(event, "delta", None), ("container",)), + ]: + if obj is None: + continue + for attr in attrs: + val = getattr(obj, attr, None) + if isinstance(val, dict): + d = val.copy() + setattr( + obj, + attr, + _types.SimpleNamespace(model_dump=lambda d=d, **kw: d), + ) + return _orig(self, event, *args, **kwargs) + + _CA._make_message_chunk_from_anthropic_event = _safe + except Exception: + pass + + +_patch_anthropic_proxy_compat() + + +# --------------------------------------------------------------------------- +# Patch: langchain-openrouter v0.2.1 — _convert_message_to_dict() serializes +# reasoning_details back to the API, but streaming chunks use wrong field +# names per type (thinking→content, reasoning.summary→summary, +# reasoning.encrypted→data), causing Pydantic errors on multi-turn. +# Fix: wrap the function to drop reasoning_details from output. +# --------------------------------------------------------------------------- +_openrouter_patched = False + + +def _patch_openrouter_reasoning_details() -> None: + global _openrouter_patched + if _openrouter_patched: + return + try: + import langchain_openrouter.chat_models as _mod + + _orig = _mod._convert_message_to_dict + + def _patched(message: Any) -> Any: + result = _orig(message) + result.pop("reasoning_details", None) + return result + + _mod._convert_message_to_dict = _patched + _openrouter_patched = True + except Exception: + pass + + +# --------------------------------------------------------------------------- +# Utility: detect ccproxy's Codex adapter (as opposed to generic localhost). +# --------------------------------------------------------------------------- +def _is_ccproxy_codex() -> 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", "") + return ( + ("127.0.0.1" in base_url or "localhost" in base_url) + and api_key == "ccproxy-oauth" + and "/codex/" in base_url + ) + + +# --------------------------------------------------------------------------- +# Utility + Patch: Flatten list content to strings for OpenAI-compatible APIs. +# DeepSeek, SiliconFlow, etc. reject assistant messages whose content is a +# list rather than a string. +# --------------------------------------------------------------------------- +_SKIP_CONTENT_TYPES = frozenset({"thinking", "reasoning", "reasoning_content"}) + + +def _flatten_message_content(content: Any) -> str | Any: + """Convert list-of-blocks content to a plain string. + + Args: + content: Message content — either a string, a list of content blocks + (dicts with ``type`` and ``text`` keys), or another type. + + Returns: + A plain string with text blocks joined by double newlines. + Thinking/reasoning blocks are skipped. Non-list input is + returned unchanged. + """ + if isinstance(content, str): + return content + if not isinstance(content, list): + return content + parts: list[str] = [] + for block in content: + if isinstance(block, dict): + if block.get("type") in _SKIP_CONTENT_TYPES: + continue + text = block.get("text") + if text: + parts.append(text) + elif isinstance(block, str): + parts.append(block) + return "\n\n".join(parts) if parts else "" + + +def _patch_openai_compat_content(model: Any) -> None: + """Flatten list content to strings before OpenAI-compatible API calls. + + Wraps ``_generate`` / ``_agenerate`` to prevent "invalid type: sequence, + expected a string" errors from strict APIs like DeepSeek. + + Args: + model: A LangChain chat model instance to patch in-place. + """ + import copy + import functools + + from langchain_core.messages import BaseMessage + + def _sanitize_messages(messages: list[BaseMessage]) -> list[BaseMessage]: + out: list[BaseMessage] = [] + for msg in messages: + if isinstance(msg.content, list): + msg = copy.copy(msg) + msg.content = _flatten_message_content(msg.content) + out.append(msg) + return out + + orig_generate = getattr(model, "_generate", None) + if orig_generate is None: + return + + @functools.wraps(orig_generate) + def _patched_generate( + messages: list[BaseMessage], *args: Any, **kwargs: Any + ) -> Any: + return orig_generate(_sanitize_messages(messages), *args, **kwargs) + + model._generate = _patched_generate + + orig_agenerate = getattr(model, "_agenerate", None) + if orig_agenerate is not None: + + @functools.wraps(orig_agenerate) + async def _patched_agenerate( + messages: list[BaseMessage], *args: Any, **kwargs: Any + ) -> Any: + return await orig_agenerate(_sanitize_messages(messages), *args, **kwargs) + + model._agenerate = _patched_agenerate diff --git a/EvoScientist/middleware/tool_selector.py b/EvoScientist/middleware/tool_selector.py index baf6e62..bd027ec 100644 --- a/EvoScientist/middleware/tool_selector.py +++ b/EvoScientist/middleware/tool_selector.py @@ -35,8 +35,8 @@ _total_tools_count: int = 0 # total tools before selection _selector_active: bool = False # Default threshold: only run tool selection when tools exceed this count. -# Base tools are ~14; selector activates when MCP tools push count above 20. -DEFAULT_TOOL_THRESHOLD = 20 +# Base tools are ~14; selector activates when MCP tools push count above 26. +DEFAULT_TOOL_THRESHOLD = 26 class _ConditionalToolSelectorMiddleware(AgentMiddleware): @@ -87,7 +87,7 @@ class _ConditionalToolSelectorMiddleware(AgentMiddleware): if _handler_called: raise # Error from downstream model — don't retry # Selector itself failed (e.g., structured output not supported). - logger.warning("Tool selector failed, using all tools", exc_info=True) + logger.debug("Tool selector failed, using all tools", exc_info=True) _selector_active = False return handler(request) finally: @@ -121,7 +121,7 @@ class _ConditionalToolSelectorMiddleware(AgentMiddleware): except Exception: if _handler_called: raise - logger.warning("Tool selector failed, using all tools", exc_info=True) + logger.debug("Tool selector failed, using all tools", exc_info=True) _selector_active = False return await handler(request) finally: diff --git a/EvoScientist/stream/events.py b/EvoScientist/stream/events.py index 79d4661..92a3fec 100644 --- a/EvoScientist/stream/events.py +++ b/EvoScientist/stream/events.py @@ -508,6 +508,10 @@ async def stream_agent_events( if any(n == "ToolSelectionResponse" for n in _tc_names): _tool_selection_was_active = True continue + # Skip follow-up tool call chunks with empty names + # (streaming fragments of the ToolSelectionResponse) + if _tool_selection_was_active and all(n == "" for n in _tc_names): + continue if _tool_selection_was_active and isinstance(_raw, list): if any( isinstance(b, dict) and b.get("type") == "input_json_delta" diff --git a/pyproject.toml b/pyproject.toml index 238f037..dbb3fcc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ dependencies = [ "langchain-nvidia-ai-endpoints>=0.3", "langchain-google-genai>=4.2.1", "langchain-ollama>=1.0", + "langchain-openrouter>=0.2.1", "tavily-python>=0.7", "pyyaml>=6.0", "rich>=14.0", diff --git a/tests/test_llm.py b/tests/test_llm.py index 3ce8d28..3d535e2 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -388,17 +388,44 @@ class TestThirdPartyRouting: assert call_kwargs["extra_body"]["enable_thinking"] is False @patch("EvoScientist.llm.models.init_chat_model") - def test_openrouter_routes_through_openai(self, mock_init, monkeypatch): - """OpenRouter provider should route through OpenAI with correct base_url.""" + def test_openrouter_uses_native_provider(self, mock_init, monkeypatch): + """OpenRouter should use native 'openrouter' provider via init_chat_model.""" mock_init.return_value = "mock_model" monkeypatch.setenv("OPENROUTER_API_KEY", "or-key-456") get_chat_model("x-ai/grok-4.1-fast", provider="openrouter") call_kwargs = mock_init.call_args[1] - assert call_kwargs["model_provider"] == "openai" - assert call_kwargs["base_url"] == "https://openrouter.ai/api/v1" + assert call_kwargs["model_provider"] == "openrouter" assert call_kwargs["api_key"] == "or-key-456" + assert call_kwargs["reasoning"] == {"effort": "high", "summary": "disabled"} + + @patch("EvoScientist.llm.models.init_chat_model") + def test_openrouter_reasoning_user_override(self, mock_init, monkeypatch): + """User-supplied reasoning config should not be overridden.""" + mock_init.return_value = "mock_model" + monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") + + get_chat_model( + "x-ai/grok-4.1-fast", + provider="openrouter", + reasoning={"effort": "low"}, + ) + + call_kwargs = mock_init.call_args[1] + assert call_kwargs["reasoning"] == {"effort": "low"} + + @patch("EvoScientist.llm.models.init_chat_model") + def test_openrouter_reasoning_effort_from_env(self, mock_init, monkeypatch): + """Reasoning effort should be configurable via env var.""" + mock_init.return_value = "mock_model" + monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") + monkeypatch.setenv("EVOSCIENTIST_REASONING_EFFORT", "medium") + + get_chat_model("x-ai/grok-4.1-fast", provider="openrouter") + + call_kwargs = mock_init.call_args[1] + assert call_kwargs["reasoning"] == {"effort": "medium", "summary": "disabled"} @patch("EvoScientist.llm.models.init_chat_model") def test_custom_routes_through_openai(self, mock_init, monkeypatch): @@ -447,9 +474,9 @@ class TestThirdPartyRouting: def test_third_party_no_reasoning(self, mock_init, monkeypatch): """Third-party providers routed through OpenAI should NOT get auto-reasoning.""" mock_init.return_value = "mock_model" - monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") + monkeypatch.setenv("SILICONFLOW_API_KEY", "sf-key") - get_chat_model("x-ai/grok-4.1-fast", provider="openrouter") + get_chat_model("deepseek-v3", provider="siliconflow") call_kwargs = mock_init.call_args[1] assert "reasoning" not in call_kwargs @@ -607,18 +634,18 @@ class TestFlattenMessageContent: """Tests for the content-flattening utility used by OpenAI-compatible providers.""" def test_string_passthrough(self): - from EvoScientist.llm.models import _flatten_message_content + from EvoScientist.llm.patches import _flatten_message_content assert _flatten_message_content("hello") == "hello" def test_non_list_passthrough(self): - from EvoScientist.llm.models import _flatten_message_content + from EvoScientist.llm.patches import _flatten_message_content assert _flatten_message_content(42) == 42 assert _flatten_message_content(None) is None def test_text_blocks(self): - from EvoScientist.llm.models import _flatten_message_content + from EvoScientist.llm.patches import _flatten_message_content content = [ {"type": "text", "text": "Hello"}, @@ -627,7 +654,7 @@ class TestFlattenMessageContent: assert _flatten_message_content(content) == "Hello\n\nWorld" def test_skips_thinking_blocks(self): - from EvoScientist.llm.models import _flatten_message_content + from EvoScientist.llm.patches import _flatten_message_content content = [ {"type": "thinking", "text": "Let me think..."}, @@ -638,13 +665,13 @@ class TestFlattenMessageContent: assert _flatten_message_content(content) == "The answer is 42" def test_string_blocks(self): - from EvoScientist.llm.models import _flatten_message_content + from EvoScientist.llm.patches import _flatten_message_content content = ["hello", "world"] assert _flatten_message_content(content) == "hello\n\nworld" def test_mixed_blocks(self): - from EvoScientist.llm.models import _flatten_message_content + from EvoScientist.llm.patches import _flatten_message_content content = [ {"type": "thinking", "text": "skip me"}, @@ -654,12 +681,12 @@ class TestFlattenMessageContent: assert _flatten_message_content(content) == "plain string\n\ndict text" def test_empty_list(self): - from EvoScientist.llm.models import _flatten_message_content + from EvoScientist.llm.patches import _flatten_message_content assert _flatten_message_content([]) == "" def test_only_thinking_blocks(self): - from EvoScientist.llm.models import _flatten_message_content + from EvoScientist.llm.patches import _flatten_message_content content = [{"type": "thinking", "text": "thought"}] assert _flatten_message_content(content) == ""