test_business_agents.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
  1. """多业务路由、真实图结构化结果、启停及持久化恢复。"""
  2. import asyncio
  3. from concurrent.futures import ThreadPoolExecutor
  4. from dataclasses import replace
  5. import json
  6. from types import SimpleNamespace
  7. import httpx
  8. import pytest
  9. from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
  10. from langchain_core.messages import AIMessage, AIMessageChunk
  11. from langchain_core.outputs import ChatGenerationChunk
  12. from agents import agent_factory, agent_registry
  13. from db import business_store as store, chat_store, db_conn
  14. from extensions import registry as extensions
  15. from extensions.registry import ExtensionRegistry, GatewayBinding
  16. from extensions.main_fan.schemas import MainFanInput, MainFanResult
  17. from extensions.main_fan.tools import inspect_main_fan_materials
  18. from runtime import business
  19. from runtime.business_models import JudgmentResult
  20. from runtime.business_tasks import BusinessTaskController, submit
  21. from runtime.engine import ExecutionResult
  22. from runtime.environment import RuntimeEnvironment
  23. from runtime.execution import execute
  24. from sandbox_agent_helpers import sandbox_messages, tool_call
  25. from runtime.python_sandbox import capability
  26. INPUT = {"mine_code": "test-mine", "mine_name": "测试煤矿", "materials": []}
  27. class StructuredAgent:
  28. def __init__(self, value, waiting=None):
  29. self.value, self.waiting, self.called = value, waiting, False
  30. async def aget_state(self, config):
  31. return SimpleNamespace(tasks=[])
  32. async def astream(self, *args, **kwargs):
  33. self.called = True
  34. yield {"type": "messages", "data": (AIMessageChunk(content="", usage_metadata={
  35. "input_tokens": 8, "output_tokens": 2, "total_tokens": 10}), {})}
  36. if self.waiting:
  37. await self.waiting.wait()
  38. yield {"type": "updates", "data": {"model": {"structured_response": self.value}}}
  39. class ToolModel(GenericFakeChatModel):
  40. def bind_tools(self, tools, *, tool_choice=None, **kwargs):
  41. return self
  42. async def _astream(self, *args, **kwargs):
  43. message = next(self.messages)
  44. yield ChatGenerationChunk(message=AIMessageChunk(content=message.content,
  45. tool_calls=message.tool_calls, usage_metadata=message.usage_metadata))
  46. @pytest.fixture
  47. def two_businesses(monkeypatch):
  48. base = extensions.load_extensions()
  49. catalog = ExtensionRegistry()
  50. catalog.tools = dict(base.tools)
  51. catalog.agents = dict(base.agents)
  52. catalog.business_agents = dict(base.business_agents)
  53. catalog.register_business_agent(name="test_business", display_name="测试业务",
  54. description="测试独立业务", prompt="仅用于离线测试", tools=[], input_schema=MainFanInput,
  55. result_schema=JudgmentResult, gateway=GatewayBinding("test-business-code", "test-check"))
  56. monkeypatch.setattr(extensions, "_loaded", catalog)
  57. agent_registry.invalidate()
  58. yield catalog
  59. agent_registry.invalidate()
  60. def test_business_registration_checks_platform_identity_and_schema():
  61. catalog = ExtensionRegistry()
  62. args = dict(display_name="业务", description="业务", prompt="处理业务", tools=[],
  63. input_schema=MainFanInput, result_schema=JudgmentResult)
  64. catalog.register_business_agent(name="one", gateway=GatewayBinding("code-one", "check-one"), **args)
  65. with pytest.raises(ValueError, match="重复"):
  66. catalog.register_business_agent(name="two", gateway=GatewayBinding("code-one", "check-two"), **args)
  67. assert list(catalog.business_agents) == ["one"]
  68. with pytest.raises(ValueError, match="BaseModel"):
  69. catalog.register_business_agent(name="bad", **{**args, "input_schema": dict})
  70. with pytest.raises(ValueError, match="并发"):
  71. catalog.register_business_agent(name="bad", max_concurrency=0, **args)
  72. def test_named_cache_is_single_flight_and_invalidated_independently(monkeypatch, two_businesses):
  73. calls = []
  74. def build(name="main"):
  75. calls.append(name)
  76. return object()
  77. monkeypatch.setattr(agent_factory, "build_agent", build)
  78. with ThreadPoolExecutor(max_workers=8) as pool:
  79. fans = list(pool.map(lambda _: agent_registry.get_agent("main_fan"), range(16)))
  80. assert all(a is fans[0] for a in fans)
  81. other = agent_registry.get_agent("test_business")
  82. main = agent_registry.get_agent()
  83. assert len({id(fans[0]), id(other), id(main)}) == 3
  84. assert calls.count("main_fan") == 1
  85. agent_registry.invalidate("main_fan")
  86. assert agent_registry.get_agent("main_fan") is not fans[0]
  87. assert agent_registry.get_agent("test_business") is other
  88. with pytest.raises(ValueError, match="未注册"):
  89. agent_registry.get_agent("does_not_exist")
  90. @pytest.mark.asyncio
  91. async def test_real_business_graph_uses_own_tools_and_structured_result(monkeypatch):
  92. if not capability()['available']:
  93. pytest.skip('当前平台未安装执行沙箱')
  94. model = ToolModel(messages=sandbox_messages('named-user', INPUT, encoded_lists=True))
  95. monkeypatch.setattr(agent_factory.model_factory, "get_model", lambda: model)
  96. async with RuntimeEnvironment(prewarm=False, billing_retry=False):
  97. session = chat_store.create_session("named-user")
  98. result = await execute(agent_name="main_fan", user_name="named-user", session_id=session["id"],
  99. message=json.dumps(INPUT))
  100. assert result.status == "success", result.error
  101. assert result.structured_output["status"] == "needs_materials"
  102. assert store.session_agent(session["id"]) == "main_fan"
  103. assert chat_store.get_messages(session["id"])[-1]["content"]
  104. captured = {}
  105. monkeypatch.setattr("deepagents.create_deep_agent", lambda **kwargs: captured.update(kwargs) or kwargs)
  106. agent_factory.build_agent("main_fan")
  107. names = {tool.name for tool in captured["tools"]}
  108. assert {"prepare_main_fan_task", "fetch_main_fan_data", "query_fan_timeseries",
  109. "query_fan_sensor_realtime", "query_fan_equipment_realtime", "query_fan_equipment_timeseries",
  110. "run_python", "submit_main_fan_report"} <= names
  111. assert "compare_main_fan_data" not in names
  112. assert "search_demo_knowledge" not in names
  113. assert captured["response_format"] is None
  114. @pytest.mark.asyncio
  115. async def test_real_coordinator_can_delegate_business_without_overwriting_its_answer(monkeypatch):
  116. if not capability()['available']:
  117. pytest.skip('当前平台未安装执行沙箱')
  118. def script():
  119. yield tool_call('task', {'subagent_type': 'main_fan', 'description': json.dumps(INPUT)})
  120. yield from sandbox_messages('coordinator', INPUT)
  121. yield AIMessage(content="主通风机业务尚缺少材料,当前无法形成正式结论。")
  122. model = ToolModel(messages=script())
  123. monkeypatch.setattr(agent_factory.model_factory, "get_model", lambda: model)
  124. async with RuntimeEnvironment(prewarm=False, billing_retry=False):
  125. session = chat_store.create_session("coordinator")
  126. result = await execute(user_name="coordinator", session_id=session["id"], message="请让主通风机智能体检查材料")
  127. assert result.status == "success", result.error
  128. assert result.output == "主通风机业务尚缺少材料,当前无法形成正式结论。"
  129. assert result.structured_output is None
  130. @pytest.mark.asyncio
  131. async def test_named_chat_history_keeps_agent_and_rejects_switch(monkeypatch, pricing_ready):
  132. from main import app
  133. fake = StructuredAgent(inspect_main_fan_materials([], "m", "矿"))
  134. selected = []
  135. def get_agent(name="main"):
  136. selected.append(name)
  137. return fake
  138. monkeypatch.setattr(agent_registry, "get_agent", get_agent)
  139. async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
  140. first = await client.post("/api/chat", data={"message": "检查材料", "agent_name": "main_fan", "user_name": "chat-user"})
  141. assert first.status_code == 200, first.text
  142. session_id = first.headers["X-Session-Id"]
  143. assert first.headers["X-Agent-Name"] == "main_fan"
  144. events = [json.loads(frame[6:]) for frame in first.text.split("\n\n") if frame.startswith("data: ")]
  145. assert any(e["type"] == "token" and "无法形成正式研判" in e["content"] for e in events)
  146. again = await client.post("/api/chat", data={"message": "继续", "session_id": session_id, "user_name": "chat-user"})
  147. assert again.status_code == 200
  148. assert selected == ["main_fan", "main_fan"]
  149. history = await client.get(f"/api/chat/history/{session_id}", params={"user_name": "chat-user"})
  150. assert history.json()["agent_name"] == "main_fan"
  151. switched = await client.post("/api/chat", data={"message": "切换", "session_id": session_id, "agent_name": "main", "user_name": "chat-user"})
  152. assert switched.status_code == 409
  153. @pytest.mark.asyncio
  154. async def test_structured_child_result_does_not_replace_root(monkeypatch, pricing_ready):
  155. parent = StructuredAgent({"root": 1})
  156. original = parent.astream
  157. async def stream(*args, **kwargs):
  158. async for item in original(*args, **kwargs):
  159. yield item
  160. yield {"type": "updates", "ns": ("child:123",), "data": {"model": {"structured_response": {"child": 2}}}}
  161. parent.astream = stream
  162. session = chat_store.create_session("structured")
  163. result = await execute(user_name="structured", session_id=session["id"], message="任务", agent=parent)
  164. assert result.structured_output == {"root": 1}
  165. @pytest.mark.asyncio
  166. async def test_session_cannot_change_agent_and_stop_is_scoped(pricing_ready, two_businesses):
  167. fan = StructuredAgent(inspect_main_fan_materials([], **{k: INPUT[k] for k in ("mine_code", "mine_name")}))
  168. session = chat_store.create_session("scope")
  169. result = await execute(agent_name="main_fan", user_name="scope", session_id=session["id"], message="任务", agent=fan)
  170. assert result.status == "success"
  171. other = StructuredAgent({})
  172. with pytest.raises(ValueError, match="绑定"):
  173. await execute(agent_name="test_business", user_name="scope", session_id=session["id"], message="任务", agent=other)
  174. assert not other.called
  175. await business.stop("main_fan")
  176. fresh = chat_store.create_session("scope")
  177. with pytest.raises(business.BusinessStopped):
  178. await execute(agent_name="main_fan", user_name="scope", session_id=fresh["id"], message="任务", agent=fan)
  179. business.ensure_enabled("test_business")
  180. await business.start("main_fan")
  181. business.ensure_enabled("main_fan")
  182. @pytest.mark.asyncio
  183. async def test_stop_cancels_named_execution_and_releases_limits(pricing_ready):
  184. gate = asyncio.Event()
  185. agent = StructuredAgent({}, gate)
  186. session = chat_store.create_session("stop-user")
  187. task = asyncio.create_task(execute(agent_name="main_fan", user_name="stop-user", session_id=session["id"], message="任务", agent=agent))
  188. for _ in range(100):
  189. if agent.called:
  190. break
  191. await asyncio.sleep(.01)
  192. assert agent.called
  193. await business.stop("main_fan")
  194. with pytest.raises(asyncio.CancelledError):
  195. await task
  196. assert not business._counts.get("main_fan")
  197. assert not business._active.get("main_fan")
  198. await business.start("main_fan")
  199. @pytest.mark.asyncio
  200. async def test_data_gateway_status_does_not_stop_business_execution(monkeypatch, pricing_ready, two_businesses):
  201. spec = two_businesses.business_agents["main_fan"]
  202. monkeypatch.setitem(two_businesses.business_agents, "main_fan", replace(spec, gateway=GatewayBinding("fan-code", "fan-check")))
  203. monkeypatch.setenv("SHANXI_GATEWAY_ENABLED", "1")
  204. store.set_state("main_fan", gateway_online=False)
  205. business.ensure_enabled("main_fan")
  206. agent = StructuredAgent(inspect_main_fan_materials([], "m", "矿"))
  207. session = chat_store.create_session("remote-stop")
  208. result = await execute(agent_name="main_fan", user_name="remote-stop", session_id=session["id"], message="任务", agent=agent)
  209. assert result.status == "success" and agent.called
  210. assert store.state("main_fan")["local_enabled"] == 1
  211. business.ensure_enabled("main_fan")
  212. business.ensure_enabled("test_business")
  213. def test_job_idempotency_rejects_different_input_and_skeleton_is_not_completed():
  214. first = submit("main_fan", INPUT, request_id="one")
  215. assert submit("main_fan", INPUT, request_id="one")["id"] == first["id"]
  216. with pytest.raises(ValueError, match="不同"):
  217. submit("main_fan", {**INPUT, "fan_id": "other"}, request_id="one")
  218. with pytest.raises(ValueError):
  219. MainFanResult.model_validate({**inspect_main_fan_materials([], "m", "n"), "status": "completed"})
  220. @pytest.mark.asyncio
  221. async def test_controller_runs_skeleton_without_publishing_and_cleans_up():
  222. calls = []
  223. async def executor(**kwargs):
  224. calls.append(kwargs)
  225. value = inspect_main_fan_materials([], "test-mine", "测试煤矿")
  226. return ExecutionResult("fake-run", kwargs["session_id"], "success", "", "", {}, [], [], value)
  227. controller = BusinessTaskController(executor=executor)
  228. await controller.start()
  229. try:
  230. job = submit("main_fan", INPUT)
  231. for _ in range(100):
  232. if store.get(job["id"])["status"] != "queued":
  233. await asyncio.sleep(.03)
  234. if store.get(job["id"])["status"] == "needs_materials":
  235. break
  236. await asyncio.sleep(.01)
  237. saved = store.get(job["id"])
  238. assert saved["status"] == "needs_materials", saved
  239. assert saved["push_status"] == "none"
  240. assert calls[0]["agent_name"] == "main_fan"
  241. assert store.metrics("main_fan")["completed"] == 0
  242. finally:
  243. await controller.close()
  244. assert not [task for task in asyncio.all_tasks() if task.get_name().startswith("business-") and not task.done()]
  245. def test_sqlite_reopen_retains_jobs_and_pending_pushes(monkeypatch, tmp_path):
  246. adapter = db_conn._SqliteAdapter(str(tmp_path / "business.sqlite"))
  247. monkeypatch.setattr(db_conn, "_adapter", adapter)
  248. monkeypatch.setattr(db_conn, "_schema_ready", False)
  249. db_conn.init_schema()
  250. job, _ = store.enqueue("main_fan", "u", INPUT, [], request_id="persist")
  251. store.update(job["id"], status="completed", push_status="pending", result_json='{"result":"saved"}')
  252. running, _ = store.enqueue("main_fan", "u", INPUT, [], request_id="running")
  253. store.update(running["id"], status="running")
  254. adapter.close()
  255. store.recover()
  256. assert store.get(running["id"])["status"] == "interrupted"
  257. assert store.pending_pushes(db_conn.fmt_dt())[0]["id"] == job["id"]
  258. adapter.close()
  259. @pytest.mark.asyncio
  260. async def test_business_api_requires_own_token_and_keeps_job_ownership(monkeypatch):
  261. from main import app
  262. from runtime import business_tasks
  263. monkeypatch.setenv("BUSINESS_API_TOKEN", "local-test-token")
  264. monkeypatch.setattr(business_tasks, "_controller", object())
  265. headers = {"Authorization": "Bearer local-test-token"}
  266. base = "/api/business-agents/main_fan"
  267. async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
  268. assert (await client.get("/api/business-agents")).json()["items"][0]["name"] == "main_fan"
  269. assert (await client.post(base + "/jobs", json={"input": INPUT})).status_code == 401
  270. created = await client.post(base + "/jobs", headers=headers, json={"input": INPUT, "request_id": "api-1"})
  271. assert created.status_code == 202, created.text
  272. job_id = created.json()["id"]
  273. assert (await client.get(base + "/jobs/" + job_id, headers=headers, params={"user_name": "other"})).status_code == 404
  274. assert (await client.post(base + "/manage?action=stop", headers=headers)).status_code == 200
  275. assert (await client.post(base + "/jobs", headers=headers, json={"input": INPUT})).status_code == 409
  276. assert (await client.post(base + "/manage?action=start", headers=headers)).status_code == 200