mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-07-26 16:41:35 +08:00
fix(agent): apply thinking level and temperature settings
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,避免重复提交历史。"""
|
||||
|
||||
47
tests/test_agent_llm_runtime_config.py
Normal file
47
tests/test_agent_llm_runtime_config.py
Normal file
@@ -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"
|
||||
@@ -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
|
||||
|
||||
40
tests/test_config_type_conversion.py
Normal file
40
tests/test_config_type_conversion.py
Normal file
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user