diff --git a/tests/integration/test_agent_governed_e2e.py b/tests/integration/test_agent_governed_e2e.py new file mode 100644 index 0000000..fd76296 --- /dev/null +++ b/tests/integration/test_agent_governed_e2e.py @@ -0,0 +1,130 @@ +"""AgentLoop + GovernedLLMClient 集成测试。 + +验证 core/agent/ 通过 LLMProvider Protocol 与 adapters/llm.py 端到端协作: +- GovernedLLMClient 满足 LLMProvider Protocol +- AgentLoop 通过 GovernedLLMClient 完成 search + submit_answer 两步推理 +- token 用量正确累加 +- 遥测数据正确写入 SQLite +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any +from unittest.mock import patch + +import pytest + +from adapters.breaker import CircuitBreaker +from adapters.llm import GovernedLLMClient +from adapters.telemetry import SQLiteTelemetryRecorder +from core.agent.loop import AgentLoop +from core.protocols import LLMProvider + + +class _StubDispatcher: + """测试用工具调度器,支持 search_tree 和 submit_answer。""" + + async def dispatch( + self, tool_name: str, args: dict[str, Any], *, context: dict[str, Any] + ) -> str: + if tool_name == "submit_answer": + return "答案已提交" + if tool_name == "search_tree": + return "搜索结果: L2-3 节点" + raise ValueError(f"未知工具: {tool_name}") + + +def _streaming_result(content: str, thinking: str = "") -> tuple: + """构造 _call_streaming 的返回值。 + + 参数: + content: 模型输出的文本内容。 + thinking: 模型思考过程文本。 + + 返回: + (content, thinking, ttft_ms, max_inter_token_ms, usage_dict) 五元组。 + """ + return ( + content, + thinking, + 50.0, # ttft_ms + 10.0, # max_inter_token_ms + {"prompt_tokens": 10, "completion_tokens": 5}, + ) + + +@pytest.mark.asyncio +async def test_agent_loop_with_governed_client(tmp_path: Path) -> None: + """AgentLoop 通过 GovernedLLMClient 完成搜索+提交。""" + telemetry = SQLiteTelemetryRecorder(db_path=tmp_path / "telemetry.db") + client = GovernedLLMClient( + model="test-model", + base_url="https://api.test.com/v1", + api_key="sk-test", + provider="deepseek", + thinking=True, + breaker=CircuitBreaker(fail_threshold=5, cooldown_s=60.0), + cache=None, + telemetry=telemetry, + timeout_s=30.0, + ttft_timeout_s=10.0, + inter_token_timeout_s=5.0, + max_retries=1, + retry_base_delay_s=0.01, + retry_max_delay_s=0.05, + ) + + # 验证 GovernedLLMClient 满足 LLMProvider Protocol + assert isinstance(client, LLMProvider) + + call_count = 0 + responses = [ + _streaming_result( + json.dumps( + { + "reflect": {}, + "plan": {}, + "action": {"tool": "search_tree", "args": {"query": "什么是AI"}}, + }, + ensure_ascii=False, + ), + thinking="让我思考一下", + ), + _streaming_result( + json.dumps( + { + "reflect": {}, + "plan": {}, + "action": {"tool": "submit_answer", "args": {"answer": "AI是..."}}, + }, + ensure_ascii=False, + ), + ), + ] + + async def mock_call_streaming(messages: list[dict[str, Any]]) -> tuple: + nonlocal call_count + result = responses[call_count] + call_count += 1 + return result + + loop = AgentLoop(llm=client, max_steps=10) + + with patch.object(client, "_call_streaming", side_effect=mock_call_streaming): + result = await loop.run( + system_prompt="你是一个搜索助手", + user_prompt="什么是人工智能?", + tool_dispatcher=_StubDispatcher(), + session_id="test-session", + ) + + assert result.stop_reason == "finished" + assert result.result == {"answer": "AI是..."} + assert result.steps_used == 2 + assert result.steps[0].thought == "让我思考一下" + assert result.steps[0].tool_call["tool"] == "search_tree" + assert result.steps[1].tool_call["tool"] == "submit_answer" + assert result.token_usage["prompt_tokens"] == 20 # 10 * 2 + assert result.token_usage["completion_tokens"] == 10 # 5 * 2