"""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