from typing import Any, cast import pytest from app.core.contracts import ( AgentDefinition, AgentRequest, IntentResult, RequestContext, ResolvedAgentConfig, SourceReference, ToolCallRecord, ) from app.core.errors import ForbiddenAgentError from app.service.agent.bootstrap import get_agent_factory from app.service.agent.factory import AgentFactory from app.service.agent.governance import review_output from app.service.agent.implementations import risk_agent as public_risk_agent from app.service.agent.implementations.risk_agent import ( EVIDENCE_TOOL, INTENT_EVIDENCE, INTENT_GENERAL, INTENT_OVERVIEW, INTENT_SEARCH, OVERVIEW_TOOL, SEARCH_TOOL, RiskAgent, ) from app.service.tool_executor import ToolExecution class StubGovernance: def __init__(self, tools_by_intent: dict[str, tuple[str, ...]] | None = None): self.tools_by_intent = tools_by_intent or {} async def resolve(self, definition: AgentDefinition, context: RequestContext): del definition, context return ResolvedAgentConfig( config_version="contract", prompt_version="contract", model_endpoint="", allowed_tools_by_intent=dict(self.tools_by_intent), ) async def recall(self, context: RequestContext): del context return () async def review(self, result, context, config, memories): return review_output(result, context, config, memories) class StubToolExecutor: def __init__(self, output: Any): self.output = output self.calls: list[tuple[str, str, dict[str, Any]]] = [] async def execute( self, *, name: str, arguments: dict[str, Any], intent: str, configured_tools: dict[str, tuple[str, ...]], context: RequestContext, ) -> ToolExecution: if name not in configured_tools.get(intent, ()): raise ForbiddenAgentError("工具不在当前意图白名单") self.calls.append((name, intent, arguments)) output = ( self.output.get(name, self.output) if isinstance(self.output, dict) else self.output ) return ToolExecution( output=output, record=ToolCallRecord(tool_name=name, status="succeeded"), references=( SourceReference( source_type="tool", source_id=f"{context.trace_id}:{name}", title=name, ), ), ) class StubRiskModelClient: def __init__(self, messages: list[dict[str, Any]]): self.messages = list(messages) self.calls: list[tuple[list[dict[str, Any]], list[dict[str, Any]]]] = [] async def chat( self, messages: list[dict[str, Any]], *, tools: list[dict[str, Any]], ) -> dict[str, Any]: self.calls.append((messages, tools)) if not self.messages: raise RuntimeError("测试模型消息已耗尽") return self.messages.pop(0) class FailingRiskModelClient: async def chat( self, _messages: list[dict[str, Any]], *, tools: list[dict[str, Any]], ) -> dict[str, Any]: del tools raise RuntimeError("测试模型不可用") def context() -> RequestContext: return RequestContext( user_id="990000002", trace_id="risk-agent-trace", roles=("risk_operator",), permissions=("agent:run", "risk:alert:read"), ) def request(message: str, intent: str) -> tuple[AgentRequest, IntentResult]: return ( AgentRequest( agent_type="risk", message=message, session_id="risk-session", idempotency_key="risk-contract-key-0001", ), IntentResult(intent=intent, confidence=0.95), ) def build_factory( tools_by_intent: dict[str, tuple[str, ...]], output: Any, *, model_client: Any | None = None, ) -> tuple[AgentFactory, StubToolExecutor]: executor = StubToolExecutor(output) factory = AgentFactory( cast(Any, StubGovernance(tools_by_intent)), tool_executor=cast(Any, executor), ) factory.register( RiskAgent.definition, lambda _context: RiskAgent( RiskAgent.definition, model_client=model_client or FailingRiskModelClient(), ), ) return factory, executor def test_risk_agent_and_tools_are_registered() -> None: factory = get_agent_factory() assert factory.definition("risk") == RiskAgent.definition for tool_name in (OVERVIEW_TOOL, SEARCH_TOOL, EVIDENCE_TOOL): tool = factory._tool_executor.registry.get(tool_name) assert tool.read_only is True assert tool.required_permission == "risk:alert:read" @pytest.mark.asyncio async def test_risk_overview_intent_calls_only_read_tool() -> None: factory, executor = build_factory( {INTENT_OVERVIEW: (OVERVIEW_TOOL,)}, {"total": 2, "levels": {"高": 1, "中": 1, "低": 0}, "pending": 2, "overdue": 0}, ) ctx = context() agent = factory.create("risk", ctx) agent._classified_intent = IntentResult(intent=INTENT_OVERVIEW, confidence=0.95) req, _ = request("请查看风险概览", INTENT_OVERVIEW) events = [event async for event in agent.execute(req, ctx, "run-risk-overview")] assert executor.calls == [(OVERVIEW_TOOL, INTENT_OVERVIEW, {})] assert "高风险 1 条" in events[-1].payload["result"]["result"]["text"] @pytest.mark.asyncio async def test_risk_search_extracts_supported_filters() -> None: factory, executor = build_factory( {INTENT_SEARCH: (SEARCH_TOOL,)}, [ { "alert_no": "ALERT-001", "risk_level": "高", "alert_type": "适当性错配", "customer_no": "CUST-001", "evidence_summary": "证据摘要", } ], ) ctx = context() agent = factory.create("risk", ctx) agent._classified_intent = IntentResult(intent=INTENT_SEARCH, confidence=0.95) req, _ = request("查询高风险预警,规则 RW-007,客户编号 CUST-001", INTENT_SEARCH) events = [event async for event in agent.execute(req, ctx, "run-risk-search")] assert executor.calls[0][2] == { "risk_level": "高", "rule_code": "RW-007", "customer_no": "CUST-001", } assert "ALERT-001" in events[-1].payload["result"]["result"]["text"] @pytest.mark.asyncio async def test_risk_evidence_requires_alert_number() -> None: factory, executor = build_factory( {INTENT_EVIDENCE: (EVIDENCE_TOOL,)}, { "alert": { "alert_no": "ALERT-001", "risk_level": "高", "alert_type": "适当性错配", "rule_codes": ["RW-007"], "evidence_summary": "证据摘要", }, "customer": {"customer_no": "CUST-001", "name": "张*"}, }, ) ctx = context() agent = factory.create("risk", ctx) agent._classified_intent = IntentResult(intent=INTENT_EVIDENCE, confidence=0.95) req, _ = request("查询预警编号 ALERT-001 的证据", INTENT_EVIDENCE) events = [event async for event in agent.execute(req, ctx, "run-risk-evidence")] assert executor.calls[0][2] == {"alert_no": "ALERT-001"} assert "预警编号:ALERT-001" in events[-1].payload["result"]["result"]["text"] @pytest.mark.asyncio async def test_missing_release_configuration_fails_closed() -> None: factory, executor = build_factory({}, {}) ctx = context() agent = factory.create("risk", ctx) agent._classified_intent = IntentResult(intent=INTENT_OVERVIEW, confidence=0.95) req, _ = request("查看概览", INTENT_OVERVIEW) with pytest.raises(ForbiddenAgentError, match="白名单"): async for _ in agent.execute(req, ctx, "run-risk-denied"): pass assert executor.calls == [] @pytest.mark.asyncio async def test_analysis_preset_routes_to_structured_analysis(monkeypatch) -> None: async def fake_generate(_context, alert_no, output_type): assert alert_no == "ALERT-001" assert output_type == "工单摘要" return {"type": output_type, "content": "工单标题:测试工单", "source": "模板降级输出"} monkeypatch.setattr( public_risk_agent.RiskAnalysisService, "generate_for_context", fake_generate, ) factory, executor = build_factory({}, {}) ctx = context() agent = factory.create("risk", ctx) req, _ = request("当前预警编号:ALERT-001。请生成工单摘要", INTENT_GENERAL) events = [event async for event in agent.execute(req, ctx, "run-risk-analysis")] assert executor.calls == [] assert events[-1].payload["result"]["result"]["text"] == "工单标题:测试工单" @pytest.mark.asyncio async def test_autonomous_tool_loop_supports_multiple_rounds() -> None: model_client = StubRiskModelClient([ { "content": "", "tool_calls": [{ "id": "call-search", "type": "function", "function": { "name": SEARCH_TOOL, "arguments": '{"risk_level":"高"}', }, }], }, { "content": "", "tool_calls": [{ "id": "call-evidence", "type": "function", "function": { "name": EVIDENCE_TOOL, "arguments": '{"alert_no":"ALERT-001"}', }, }], }, { "content": "ALERT-001 需要人工复核,现有证据支持继续调查。", }, ]) factory, executor = build_factory( { INTENT_SEARCH: (SEARCH_TOOL,), INTENT_EVIDENCE: (EVIDENCE_TOOL,), }, { SEARCH_TOOL: [{ "alert_no": "ALERT-001", "risk_level": "高", "alert_type": "适当性错配", "customer_no": "CUST-001", "evidence_summary": "证据摘要", }], EVIDENCE_TOOL: { "alert": { "id": 71, "alert_no": "ALERT-001", "risk_level": "高", "alert_type": "适当性错配", "rule_codes": ["RW-007"], "evidence_summary": "证据摘要", }, "customer": {"customer_no": "CUST-001", "name": "张*"}, }, }, model_client=model_client, ) ctx = context() agent = factory.create("risk", ctx) agent._classified_intent = IntentResult(intent=INTENT_GENERAL, confidence=0.95) req, _ = request("哪个预警更需要人工复核", INTENT_GENERAL) events = [event async for event in agent.execute(req, ctx, "run-risk-autonomous")] assert [call[0] for call in executor.calls] == [SEARCH_TOOL, EVIDENCE_TOOL] assert executor.calls[1][2] == {"alert_no": "ALERT-001"} assert events[-1].payload["result"]["result"]["text"].startswith("ALERT-001") @pytest.mark.asyncio async def test_invalid_protocol_marker_falls_back_without_leaking() -> None: model_client = StubRiskModelClient([ {"content": ""}, ]) factory, executor = build_factory( {INTENT_SEARCH: (SEARCH_TOOL,)}, [], model_client=model_client, ) ctx = context() agent = factory.create("risk", ctx) agent._classified_intent = IntentResult(intent=INTENT_GENERAL, confidence=0.95) req, _ = request("查看预警", INTENT_GENERAL) events = [event async for event in agent.execute(req, ctx, "run-risk-protocol")] text = events[-1].payload["result"]["result"]["text"] assert "" not in text assert executor.calls == [(SEARCH_TOOL, INTENT_SEARCH, {})] @pytest.mark.asyncio async def test_disposition_question_uses_read_only_fallback_without_model() -> None: factory, executor = build_factory( {INTENT_SEARCH: (SEARCH_TOOL,)}, [{ "alert_no": "ALERT-018", "risk_level": "低", "alert_type": "频繁交易初筛", "customer_no": "CUST-018", "rule_codes": ["RW-018"], "evidence_summary": "交易来自有效定投工单", }], ) ctx = context() agent = factory.create("risk", ctx) agent._classified_intent = IntentResult(intent=INTENT_GENERAL, confidence=0.95) req, _ = request("哪些预警可以按误报复核", INTENT_GENERAL) events = [event async for event in agent.execute(req, ctx, "run-risk-disposition")] text = events[-1].payload["result"]["result"]["text"] assert executor.calls[0][0] == SEARCH_TOOL assert "ALERT-018" in text assert "不构成最终处置结论" in text @pytest.mark.asyncio async def test_general_list_question_uses_complete_search_summary() -> None: factory, executor = build_factory( {INTENT_SEARCH: (SEARCH_TOOL,)}, { "total": 2, "filters": {"risk_level": "低"}, "summary": { "customer_groups": [ { "customer_no": "CUST-001", "customer_name": "张*", "alert_count": 1, "risk_levels": ["低"], }, { "customer_no": "CUST-002", "customer_name": "李*", "alert_count": 1, "risk_levels": ["低"], }, ], "product_groups": [ { "product_code": "P-001", "product_name": "稳健一号", "alert_count": 2, "risk_levels": ["低"], }, ], "disposition_counts": {"可考虑放行": 2}, "complete": True, }, "items": [], }, ) ctx = context() agent = factory.create("risk", ctx) agent._classified_intent = IntentResult(intent=INTENT_GENERAL, confidence=0.95) req, _ = request("当前低风险预警都是哪些客户的?他们买的什么产品?", INTENT_GENERAL) events = [event async for event in agent.execute(req, ctx, "run-risk-complete-list")] text = events[-1].payload["result"]["result"]["text"] assert executor.calls[0][0] == SEARCH_TOOL assert executor.calls[0][2]["risk_level"] == "低" assert "CUST-001" in text assert "CUST-002" in text assert "稳健一号" in text assert "全部命中记录" in text