diff --git a/app/agent/__init__.py b/app/agent/__init__.py index 3d03af773..83cb24833 100644 --- a/app/agent/__init__.py +++ b/app/agent/__init__.py @@ -68,6 +68,8 @@ from app.utils.identity import SYSTEM_INTERNAL_USER_ID class AgentChain(ChainBase): + """Agent 业务处理链。""" + pass @@ -713,7 +715,7 @@ class MoviePilotAgent: """ 通过链式事件解析本次 Agent 可用的 LLM 运行时配置。 - 若没有插件返回 selected_provider_id,则沿用系统配置,保持既有行为。 + 插件未返回有效配置时沿用系统配置,显式返回的配置优先。 """ if self._llm_runtime_config is not None: return self._llm_runtime_config @@ -726,7 +728,7 @@ class MoviePilotAgent: base_url_preset=settings.LLM_BASE_URL_PRESET, user_agent=settings.LLM_USER_AGENT, use_proxy=settings.LLM_USE_PROXY, - thinking_level=None, + thinking_level=settings.LLM_THINKING_LEVEL, ) selected_event = await eventmanager.async_send_event( ChainEventType.AgentLLMProvider, @@ -761,8 +763,11 @@ class MoviePilotAgent: use_proxy = self._get_event_value(resolved_data, "use_proxy") if use_proxy is None: use_proxy = settings.LLM_USE_PROXY - thinking_level = self._clean_optional_text( - self._get_event_value(resolved_data, "thinking_level") + thinking_level = ( + self._clean_optional_text( + self._get_event_value(resolved_data, "thinking_level") + ) + or settings.LLM_THINKING_LEVEL ) selected_provider_id = self._clean_optional_text( self._get_event_value(resolved_data, "selected_provider_id") @@ -1040,6 +1045,7 @@ class MoviePilotAgent: self.has_message_context, self.is_background, settings.AI_AGENT_VERBOSE, + settings.LLM_TEMPERATURE, settings.LLM_MAX_TOOLS, settings.LLM_MAX_ITERATIONS, self._public_runtime_config_signature(runtime_config), diff --git a/app/core/config.py b/app/core/config.py index 26b9fbf60..552a29ffb 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -749,8 +749,9 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel): converted = int(value) return converted, str(converted) != str(original_value) elif expected_type is float: - if isinstance(value, float): - return value, str(value) != str(original_value) + if isinstance(value, (int, float)) and not isinstance(value, bool): + converted = float(value) + return converted, str(converted) != str(original_value) if isinstance(value, str): converted = float(value) return converted, str(converted) != str(original_value) diff --git a/tests/test_agent_graph_cache.py b/tests/test_agent_graph_cache.py index 3027e7e84..1bfb3b14e 100644 --- a/tests/test_agent_graph_cache.py +++ b/tests/test_agent_graph_cache.py @@ -8,6 +8,7 @@ import pytest from langchain_core.messages import AIMessage, HumanMessage from app.agent import MoviePilotAgent, ReplyMode, _CompiledAgentBundle +from app.core.config import settings @pytest.fixture @@ -64,6 +65,34 @@ async def test_create_agent_reuses_cached_graph_when_signature_matches(): create_agent.assert_not_called() +@pytest.mark.anyio +async def test_agent_bundle_signature_changes_with_temperature(monkeypatch) -> None: + """温度配置变化时应使会话内 Agent 图缓存失效。""" + agent = MoviePilotAgent(session_id="temperature-change", user_id="user-1") + runtime_config = { + "provider": "openai", + "model": "gpt-test", + "api_key": "test-key", + "base_url": "https://llm.example.com/v1", + "base_url_preset": None, + "user_agent": None, + "use_proxy": False, + "thinking_level": "off", + } + + with patch.object( + agent, + "_resolve_llm_runtime_config", + new=AsyncMock(return_value=runtime_config), + ): + monkeypatch.setattr(settings, "LLM_TEMPERATURE", 0.3) + initial_signature = await agent._agent_bundle_signature(streaming=False) + monkeypatch.setattr(settings, "LLM_TEMPERATURE", 1.0) + updated_signature = await agent._agent_bundle_signature(streaming=False) + + assert updated_signature != initial_signature + + @pytest.mark.anyio async def test_execute_agent_sends_only_latest_message_on_cache_hit(): """缓存命中时只把本轮新消息交给 LangGraph,避免重复提交历史。""" diff --git a/tests/test_agent_llm_runtime_config.py b/tests/test_agent_llm_runtime_config.py new file mode 100644 index 000000000..ffe5d63b1 --- /dev/null +++ b/tests/test_agent_llm_runtime_config.py @@ -0,0 +1,47 @@ +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +from app.agent import MoviePilotAgent +from app.core.config import settings +from app.schemas import AgentLLMProviderEventData +from app.schemas.types import ChainEventType + + +def test_resolve_llm_runtime_config_uses_system_thinking_level(monkeypatch) -> None: + """插件未提供有效思考强度时应使用系统配置。""" + monkeypatch.setattr(settings, "LLM_THINKING_LEVEL", "xhigh") + agent = MoviePilotAgent(session_id="thinking-level-default", user_id="user-1") + + async def return_empty_config(event_type, event_data): + """模拟插件未返回有效运行时配置。""" + assert event_type == ChainEventType.AgentLLMProvider + assert event_data.thinking_level == "xhigh" + return SimpleNamespace(event_data=AgentLLMProviderEventData()) + + with patch( + "app.agent.eventmanager.async_send_event", + new=AsyncMock(side_effect=return_empty_config), + ): + runtime_config = asyncio.run(agent._resolve_llm_runtime_config()) + + assert runtime_config["thinking_level"] == "xhigh" + + +def test_resolve_llm_runtime_config_prefers_plugin_thinking_level(monkeypatch) -> None: + """插件显式覆盖思考强度时应优先使用插件值。""" + monkeypatch.setattr(settings, "LLM_THINKING_LEVEL", "xhigh") + agent = MoviePilotAgent(session_id="thinking-level-plugin", user_id="user-1") + + async def override_thinking_level(_event_type, event_data): + """模拟插件覆盖思考强度。""" + event_data.thinking_level = "high" + return SimpleNamespace(event_data=event_data) + + with patch( + "app.agent.eventmanager.async_send_event", + new=AsyncMock(side_effect=override_thinking_level), + ): + runtime_config = asyncio.run(agent._resolve_llm_runtime_config()) + + assert runtime_config["thinking_level"] == "high" diff --git a/tests/test_agent_tokens_events.py b/tests/test_agent_tokens_events.py index fcc28d90d..eb5aa8b25 100644 --- a/tests/test_agent_tokens_events.py +++ b/tests/test_agent_tokens_events.py @@ -1,4 +1,4 @@ -import unittest +import asyncio from types import SimpleNamespace from unittest.mock import AsyncMock, patch @@ -6,6 +6,7 @@ from langchain_core.messages import AIMessage from app.agent import MoviePilotAgent from app.agent.memory import memory_manager +from app.core.config import settings from app.schemas.types import ChainEventType, EventType @@ -39,130 +40,135 @@ class _FakeFailingAgent(_FakeAgent): raise RuntimeError("llm failed") -class AgentTokensEventsTest(unittest.IsolatedAsyncioTestCase): - async def test_initialize_llm_uses_chain_event_selection(self): - """Agent 初始化 LLM 时应优先使用链式事件返回的供应商配置。""" - agent = MoviePilotAgent(session_id="agent-tokens-test", user_id="user-1") - fake_llm = object() +def test_initialize_llm_uses_chain_event_selection(monkeypatch) -> None: + """Agent 初始化 LLM 时应优先使用链式事件返回的供应商配置。""" + monkeypatch.setattr(settings, "LLM_THINKING_LEVEL", "xhigh") + agent = MoviePilotAgent(session_id="agent-tokens-test", user_id="user-1") + fake_llm = object() - async def select_provider(etype, data): - """模拟 Agent Tokens 插件写入供应商配置。""" - self.assertEqual(ChainEventType.AgentLLMProvider, etype) - data.provider = "openai" - data.base_url = "https://tokens.example.com/v1" - data.api_key = "sk-agent-token" - data.model = "free-model" - data.base_url_preset = None - data.user_agent = "AgentTokens-UA/1.0" - data.selected_provider_id = "provider-1" - data.selected_provider_name = "Free Provider" - data.source = "AgentTokens" - return SimpleNamespace(event_data=data) + async def select_provider(event_type, event_data): + """模拟 Agent Tokens 插件写入供应商配置。""" + assert event_type == ChainEventType.AgentLLMProvider + event_data.provider = "openai" + event_data.base_url = "https://tokens.example.com/v1" + event_data.api_key = "sk-agent-token" + event_data.model = "free-model" + event_data.base_url_preset = None + event_data.user_agent = "AgentTokens-UA/1.0" + event_data.selected_provider_id = "provider-1" + event_data.selected_provider_name = "Free Provider" + event_data.source = "AgentTokens" + return SimpleNamespace(event_data=event_data) - with ( - patch( - "app.agent.eventmanager.async_send_event", - new=AsyncMock(side_effect=select_provider), - ) as send_event, - patch("app.agent.LLMHelper.get_llm", new=AsyncMock(return_value=fake_llm)) as get_llm, - ): - result = await agent._initialize_llm(streaming=True) - second_result = await agent._initialize_llm(streaming=False) + with ( + patch( + "app.agent.eventmanager.async_send_event", + new=AsyncMock(side_effect=select_provider), + ) as send_event, + patch( + "app.agent.LLMHelper.get_llm", + new=AsyncMock(return_value=fake_llm), + ) as get_llm, + ): + result = asyncio.run(agent._initialize_llm(streaming=True)) + second_result = asyncio.run(agent._initialize_llm(streaming=False)) - self.assertIs(result, fake_llm) - self.assertIs(second_result, fake_llm) - send_event.assert_awaited_once() - self.assertEqual(2, get_llm.await_count) - get_llm.assert_any_await( - streaming=True, - provider="openai", - model="free-model", - api_key="sk-agent-token", - base_url="https://tokens.example.com/v1", - base_url_preset=None, - user_agent="AgentTokens-UA/1.0", - use_proxy=True, - thinking_level=None, - ) - self.assertEqual("provider-1", agent._llm_provider_selection["selected_provider_id"]) + assert result is fake_llm + assert second_result is fake_llm + send_event.assert_awaited_once() + assert get_llm.await_count == 2 + get_llm.assert_any_await( + streaming=True, + provider="openai", + model="free-model", + api_key="sk-agent-token", + base_url="https://tokens.example.com/v1", + base_url_preset=None, + user_agent="AgentTokens-UA/1.0", + use_proxy=True, + thinking_level="xhigh", + ) + assert agent._llm_provider_selection["selected_provider_id"] == "provider-1" - async def test_execute_agent_broadcasts_usage_on_success(self): - """Agent 执行成功后应广播聚合 token 用量事件。""" - agent = MoviePilotAgent(session_id="usage-success", user_id="user-1") - agent._should_stream = lambda: False - agent.stream_handler = SimpleNamespace( - stop_streaming=AsyncMock(return_value=(False, "")) - ) - agent.send_agent_message = AsyncMock() - async def create_agent(_streaming=False, streaming=False): - """模拟创建 Agent 时完成供应商选择和用量统计。""" - agent._llm_provider_selection = { - "selected_provider_id": "provider-1", - "selected_provider_name": "Free Provider", - "provider": "openai", - "base_url": "https://tokens.example.com/v1", +def test_execute_agent_broadcasts_usage_on_success() -> None: + """Agent 执行成功后应广播聚合 token 用量事件。""" + agent = MoviePilotAgent(session_id="usage-success", user_id="user-1") + agent._should_stream = lambda: False + agent.stream_handler = SimpleNamespace( + stop_streaming=AsyncMock(return_value=(False, "")) + ) + agent.send_agent_message = AsyncMock() + + async def create_agent(_streaming=False, streaming=False): + """模拟创建 Agent 时完成供应商选择和用量统计。""" + agent._llm_provider_selection = { + "selected_provider_id": "provider-1", + "selected_provider_name": "Free Provider", + "provider": "openai", + "base_url": "https://tokens.example.com/v1", + "model": "free-model", + "source": "AgentTokens", + } + agent._record_usage( + { + "has_usage": True, "model": "free-model", - "source": "AgentTokens", + "input_tokens": 12, + "output_tokens": 8, + "total_tokens": 20, } - agent._record_usage( - { - "has_usage": True, - "model": "free-model", - "input_tokens": 12, - "output_tokens": 8, - "total_tokens": 20, - } - ) - return _FakeAgent([AIMessage(content="ok")]) - - with ( - patch.object(agent, "_create_agent", new=create_agent), - patch.object(memory_manager, "save_agent_messages"), - patch("app.agent.eventmanager.send_event") as send_event, - ): - await agent._execute_agent([]) - - send_event.assert_called_once() - self.assertEqual(EventType.AgentTokensUsage, send_event.call_args.args[0]) - usage = send_event.call_args.args[1] - self.assertTrue(usage.success) - self.assertEqual("provider-1", usage.selected_provider_id) - self.assertEqual(12, usage.input_tokens) - self.assertEqual(8, usage.output_tokens) - self.assertEqual(20, usage.total_tokens) - - async def test_execute_agent_broadcasts_usage_on_failure(self): - """Agent 执行失败后仍应广播用量事件。""" - agent = MoviePilotAgent(session_id="usage-failure", user_id="user-1") - agent._should_stream = lambda: False - agent.stream_handler = SimpleNamespace( - stop_streaming=AsyncMock(return_value=(False, "")) ) - agent.send_agent_message = AsyncMock() + return _FakeAgent([AIMessage(content="ok")]) - async def create_agent(_streaming=False, streaming=False): - """模拟创建 Agent 时已选中供应商但执行失败。""" - agent._llm_provider_selection = { - "selected_provider_id": "provider-2", - "selected_provider_name": "Backup Provider", - "provider": "openai", - "base_url": "https://backup.example.com/v1", - "model": "backup-model", - "source": "AgentTokens", - } - return _FakeFailingAgent([]) + with ( + patch.object(agent, "_create_agent", new=create_agent), + patch.object(memory_manager, "save_agent_messages"), + patch("app.agent.eventmanager.send_event") as send_event, + ): + asyncio.run(agent._execute_agent([])) - with ( - patch.object(agent, "_create_agent", new=create_agent), - patch("app.agent.eventmanager.send_event") as send_event, - ): - result, _ = await agent._execute_agent([]) + send_event.assert_called_once() + assert send_event.call_args.args[0] == EventType.AgentTokensUsage + usage = send_event.call_args.args[1] + assert usage.success + assert usage.selected_provider_id == "provider-1" + assert usage.input_tokens == 12 + assert usage.output_tokens == 8 + assert usage.total_tokens == 20 - self.assertIn("智能助手执行失败", result) - send_event.assert_called_once() - self.assertEqual(EventType.AgentTokensUsage, send_event.call_args.args[0]) - usage = send_event.call_args.args[1] - self.assertFalse(usage.success) - self.assertEqual("provider-2", usage.selected_provider_id) - self.assertIn("llm failed", usage.error) + +def test_execute_agent_broadcasts_usage_on_failure() -> None: + """Agent 执行失败后仍应广播用量事件。""" + agent = MoviePilotAgent(session_id="usage-failure", user_id="user-1") + agent._should_stream = lambda: False + agent.stream_handler = SimpleNamespace( + stop_streaming=AsyncMock(return_value=(False, "")) + ) + agent.send_agent_message = AsyncMock() + + async def create_agent(_streaming=False, streaming=False): + """模拟创建 Agent 时已选中供应商但执行失败。""" + agent._llm_provider_selection = { + "selected_provider_id": "provider-2", + "selected_provider_name": "Backup Provider", + "provider": "openai", + "base_url": "https://backup.example.com/v1", + "model": "backup-model", + "source": "AgentTokens", + } + return _FakeFailingAgent([]) + + with ( + patch.object(agent, "_create_agent", new=create_agent), + patch("app.agent.eventmanager.send_event") as send_event, + ): + result, _ = asyncio.run(agent._execute_agent([])) + + assert "智能助手执行失败" in result + send_event.assert_called_once() + assert send_event.call_args.args[0] == EventType.AgentTokensUsage + usage = send_event.call_args.args[1] + assert not usage.success + assert usage.selected_provider_id == "provider-2" + assert "llm failed" in usage.error diff --git a/tests/test_config_type_conversion.py b/tests/test_config_type_conversion.py new file mode 100644 index 000000000..2fde30f83 --- /dev/null +++ b/tests/test_config_type_conversion.py @@ -0,0 +1,40 @@ +from typing import Any + +from app.core.config import settings + + +def test_update_float_setting_accepts_json_integer(monkeypatch) -> None: + """浮点配置应接受 JSON 整数并按浮点值持久化。""" + persisted: dict[str, Any] = {} + + def persist_setting( + field_name: str, + original_value: Any, + converted_value: Any, + ) -> tuple[bool, None]: + """记录待持久化配置,避免测试写入真实配置文件。""" + persisted.update( + field_name=field_name, + original_value=original_value, + converted_value=converted_value, + ) + return True, None + + monkeypatch.setattr(settings, "LLM_TEMPERATURE", 0.3) + monkeypatch.setattr( + type(settings), + "update_env_config", + staticmethod(persist_setting), + ) + + success, message = settings.update_setting("LLM_TEMPERATURE", 1) + + assert success is True + assert message is None + assert settings.LLM_TEMPERATURE == 1.0 + assert isinstance(settings.LLM_TEMPERATURE, float) + assert persisted == { + "field_name": "LLM_TEMPERATURE", + "original_value": 1, + "converted_value": 1.0, + }