| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315 |
- """多业务路由、真实图结构化结果、启停及持久化恢复。"""
- import asyncio
- from concurrent.futures import ThreadPoolExecutor
- from dataclasses import replace
- import json
- from types import SimpleNamespace
- import httpx
- import pytest
- from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
- from langchain_core.messages import AIMessage, AIMessageChunk
- from langchain_core.outputs import ChatGenerationChunk
- from agents import agent_factory, agent_registry
- from db import business_store as store, chat_store, db_conn
- from extensions import registry as extensions
- from extensions.registry import ExtensionRegistry, GatewayBinding
- from extensions.main_fan.schemas import MainFanInput, MainFanResult
- from extensions.main_fan.tools import inspect_main_fan_materials
- from runtime import business
- from runtime.business_models import JudgmentResult
- from runtime.business_tasks import BusinessTaskController, submit
- from runtime.engine import ExecutionResult
- from runtime.environment import RuntimeEnvironment
- from runtime.execution import execute
- from sandbox_agent_helpers import sandbox_messages, tool_call
- from runtime.python_sandbox import capability
- INPUT = {"mine_code": "test-mine", "mine_name": "测试煤矿", "materials": []}
- class StructuredAgent:
- def __init__(self, value, waiting=None):
- self.value, self.waiting, self.called = value, waiting, False
- async def aget_state(self, config):
- return SimpleNamespace(tasks=[])
- async def astream(self, *args, **kwargs):
- self.called = True
- yield {"type": "messages", "data": (AIMessageChunk(content="", usage_metadata={
- "input_tokens": 8, "output_tokens": 2, "total_tokens": 10}), {})}
- if self.waiting:
- await self.waiting.wait()
- yield {"type": "updates", "data": {"model": {"structured_response": self.value}}}
- class ToolModel(GenericFakeChatModel):
- def bind_tools(self, tools, *, tool_choice=None, **kwargs):
- return self
- async def _astream(self, *args, **kwargs):
- message = next(self.messages)
- yield ChatGenerationChunk(message=AIMessageChunk(content=message.content,
- tool_calls=message.tool_calls, usage_metadata=message.usage_metadata))
- @pytest.fixture
- def two_businesses(monkeypatch):
- base = extensions.load_extensions()
- catalog = ExtensionRegistry()
- catalog.tools = dict(base.tools)
- catalog.agents = dict(base.agents)
- catalog.business_agents = dict(base.business_agents)
- catalog.register_business_agent(name="test_business", display_name="测试业务",
- description="测试独立业务", prompt="仅用于离线测试", tools=[], input_schema=MainFanInput,
- result_schema=JudgmentResult, gateway=GatewayBinding("test-business-code", "test-check"))
- monkeypatch.setattr(extensions, "_loaded", catalog)
- agent_registry.invalidate()
- yield catalog
- agent_registry.invalidate()
- def test_business_registration_checks_platform_identity_and_schema():
- catalog = ExtensionRegistry()
- args = dict(display_name="业务", description="业务", prompt="处理业务", tools=[],
- input_schema=MainFanInput, result_schema=JudgmentResult)
- catalog.register_business_agent(name="one", gateway=GatewayBinding("code-one", "check-one"), **args)
- with pytest.raises(ValueError, match="重复"):
- catalog.register_business_agent(name="two", gateway=GatewayBinding("code-one", "check-two"), **args)
- assert list(catalog.business_agents) == ["one"]
- with pytest.raises(ValueError, match="BaseModel"):
- catalog.register_business_agent(name="bad", **{**args, "input_schema": dict})
- with pytest.raises(ValueError, match="并发"):
- catalog.register_business_agent(name="bad", max_concurrency=0, **args)
- def test_named_cache_is_single_flight_and_invalidated_independently(monkeypatch, two_businesses):
- calls = []
- def build(name="main"):
- calls.append(name)
- return object()
- monkeypatch.setattr(agent_factory, "build_agent", build)
- with ThreadPoolExecutor(max_workers=8) as pool:
- fans = list(pool.map(lambda _: agent_registry.get_agent("main_fan"), range(16)))
- assert all(a is fans[0] for a in fans)
- other = agent_registry.get_agent("test_business")
- main = agent_registry.get_agent()
- assert len({id(fans[0]), id(other), id(main)}) == 3
- assert calls.count("main_fan") == 1
- agent_registry.invalidate("main_fan")
- assert agent_registry.get_agent("main_fan") is not fans[0]
- assert agent_registry.get_agent("test_business") is other
- with pytest.raises(ValueError, match="未注册"):
- agent_registry.get_agent("does_not_exist")
- @pytest.mark.asyncio
- async def test_real_business_graph_uses_own_tools_and_structured_result(monkeypatch):
- if not capability()['available']:
- pytest.skip('当前平台未安装执行沙箱')
- model = ToolModel(messages=sandbox_messages('named-user', INPUT, encoded_lists=True))
- monkeypatch.setattr(agent_factory.model_factory, "get_model", lambda: model)
- async with RuntimeEnvironment(prewarm=False, billing_retry=False):
- session = chat_store.create_session("named-user")
- result = await execute(agent_name="main_fan", user_name="named-user", session_id=session["id"],
- message=json.dumps(INPUT))
- assert result.status == "success", result.error
- assert result.structured_output["status"] == "needs_materials"
- assert store.session_agent(session["id"]) == "main_fan"
- assert chat_store.get_messages(session["id"])[-1]["content"]
- captured = {}
- monkeypatch.setattr("deepagents.create_deep_agent", lambda **kwargs: captured.update(kwargs) or kwargs)
- agent_factory.build_agent("main_fan")
- names = {tool.name for tool in captured["tools"]}
- assert {"prepare_main_fan_task", "fetch_main_fan_data", "query_fan_timeseries",
- "query_fan_sensor_realtime", "query_fan_equipment_realtime", "query_fan_equipment_timeseries",
- "run_python", "submit_main_fan_report"} <= names
- assert "compare_main_fan_data" not in names
- assert "search_demo_knowledge" not in names
- assert captured["response_format"] is None
- @pytest.mark.asyncio
- async def test_real_coordinator_can_delegate_business_without_overwriting_its_answer(monkeypatch):
- if not capability()['available']:
- pytest.skip('当前平台未安装执行沙箱')
- def script():
- yield tool_call('task', {'subagent_type': 'main_fan', 'description': json.dumps(INPUT)})
- yield from sandbox_messages('coordinator', INPUT)
- yield AIMessage(content="主通风机业务尚缺少材料,当前无法形成正式结论。")
- model = ToolModel(messages=script())
- monkeypatch.setattr(agent_factory.model_factory, "get_model", lambda: model)
- async with RuntimeEnvironment(prewarm=False, billing_retry=False):
- session = chat_store.create_session("coordinator")
- result = await execute(user_name="coordinator", session_id=session["id"], message="请让主通风机智能体检查材料")
- assert result.status == "success", result.error
- assert result.output == "主通风机业务尚缺少材料,当前无法形成正式结论。"
- assert result.structured_output is None
- @pytest.mark.asyncio
- async def test_named_chat_history_keeps_agent_and_rejects_switch(monkeypatch, pricing_ready):
- from main import app
- fake = StructuredAgent(inspect_main_fan_materials([], "m", "矿"))
- selected = []
- def get_agent(name="main"):
- selected.append(name)
- return fake
- monkeypatch.setattr(agent_registry, "get_agent", get_agent)
- async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
- first = await client.post("/api/chat", data={"message": "检查材料", "agent_name": "main_fan", "user_name": "chat-user"})
- assert first.status_code == 200, first.text
- session_id = first.headers["X-Session-Id"]
- assert first.headers["X-Agent-Name"] == "main_fan"
- events = [json.loads(frame[6:]) for frame in first.text.split("\n\n") if frame.startswith("data: ")]
- assert any(e["type"] == "token" and "无法形成正式研判" in e["content"] for e in events)
- again = await client.post("/api/chat", data={"message": "继续", "session_id": session_id, "user_name": "chat-user"})
- assert again.status_code == 200
- assert selected == ["main_fan", "main_fan"]
- history = await client.get(f"/api/chat/history/{session_id}", params={"user_name": "chat-user"})
- assert history.json()["agent_name"] == "main_fan"
- switched = await client.post("/api/chat", data={"message": "切换", "session_id": session_id, "agent_name": "main", "user_name": "chat-user"})
- assert switched.status_code == 409
- @pytest.mark.asyncio
- async def test_structured_child_result_does_not_replace_root(monkeypatch, pricing_ready):
- parent = StructuredAgent({"root": 1})
- original = parent.astream
- async def stream(*args, **kwargs):
- async for item in original(*args, **kwargs):
- yield item
- yield {"type": "updates", "ns": ("child:123",), "data": {"model": {"structured_response": {"child": 2}}}}
- parent.astream = stream
- session = chat_store.create_session("structured")
- result = await execute(user_name="structured", session_id=session["id"], message="任务", agent=parent)
- assert result.structured_output == {"root": 1}
- @pytest.mark.asyncio
- async def test_session_cannot_change_agent_and_stop_is_scoped(pricing_ready, two_businesses):
- fan = StructuredAgent(inspect_main_fan_materials([], **{k: INPUT[k] for k in ("mine_code", "mine_name")}))
- session = chat_store.create_session("scope")
- result = await execute(agent_name="main_fan", user_name="scope", session_id=session["id"], message="任务", agent=fan)
- assert result.status == "success"
- other = StructuredAgent({})
- with pytest.raises(ValueError, match="绑定"):
- await execute(agent_name="test_business", user_name="scope", session_id=session["id"], message="任务", agent=other)
- assert not other.called
- await business.stop("main_fan")
- fresh = chat_store.create_session("scope")
- with pytest.raises(business.BusinessStopped):
- await execute(agent_name="main_fan", user_name="scope", session_id=fresh["id"], message="任务", agent=fan)
- business.ensure_enabled("test_business")
- await business.start("main_fan")
- business.ensure_enabled("main_fan")
- @pytest.mark.asyncio
- async def test_stop_cancels_named_execution_and_releases_limits(pricing_ready):
- gate = asyncio.Event()
- agent = StructuredAgent({}, gate)
- session = chat_store.create_session("stop-user")
- task = asyncio.create_task(execute(agent_name="main_fan", user_name="stop-user", session_id=session["id"], message="任务", agent=agent))
- for _ in range(100):
- if agent.called:
- break
- await asyncio.sleep(.01)
- assert agent.called
- await business.stop("main_fan")
- with pytest.raises(asyncio.CancelledError):
- await task
- assert not business._counts.get("main_fan")
- assert not business._active.get("main_fan")
- await business.start("main_fan")
- @pytest.mark.asyncio
- async def test_data_gateway_status_does_not_stop_business_execution(monkeypatch, pricing_ready, two_businesses):
- spec = two_businesses.business_agents["main_fan"]
- monkeypatch.setitem(two_businesses.business_agents, "main_fan", replace(spec, gateway=GatewayBinding("fan-code", "fan-check")))
- monkeypatch.setenv("SHANXI_GATEWAY_ENABLED", "1")
- store.set_state("main_fan", gateway_online=False)
- business.ensure_enabled("main_fan")
- agent = StructuredAgent(inspect_main_fan_materials([], "m", "矿"))
- session = chat_store.create_session("remote-stop")
- result = await execute(agent_name="main_fan", user_name="remote-stop", session_id=session["id"], message="任务", agent=agent)
- assert result.status == "success" and agent.called
- assert store.state("main_fan")["local_enabled"] == 1
- business.ensure_enabled("main_fan")
- business.ensure_enabled("test_business")
- def test_job_idempotency_rejects_different_input_and_skeleton_is_not_completed():
- first = submit("main_fan", INPUT, request_id="one")
- assert submit("main_fan", INPUT, request_id="one")["id"] == first["id"]
- with pytest.raises(ValueError, match="不同"):
- submit("main_fan", {**INPUT, "fan_id": "other"}, request_id="one")
- with pytest.raises(ValueError):
- MainFanResult.model_validate({**inspect_main_fan_materials([], "m", "n"), "status": "completed"})
- @pytest.mark.asyncio
- async def test_controller_runs_skeleton_without_publishing_and_cleans_up():
- calls = []
- async def executor(**kwargs):
- calls.append(kwargs)
- value = inspect_main_fan_materials([], "test-mine", "测试煤矿")
- return ExecutionResult("fake-run", kwargs["session_id"], "success", "", "", {}, [], [], value)
- controller = BusinessTaskController(executor=executor)
- await controller.start()
- try:
- job = submit("main_fan", INPUT)
- for _ in range(100):
- if store.get(job["id"])["status"] != "queued":
- await asyncio.sleep(.03)
- if store.get(job["id"])["status"] == "needs_materials":
- break
- await asyncio.sleep(.01)
- saved = store.get(job["id"])
- assert saved["status"] == "needs_materials", saved
- assert saved["push_status"] == "none"
- assert calls[0]["agent_name"] == "main_fan"
- assert store.metrics("main_fan")["completed"] == 0
- finally:
- await controller.close()
- assert not [task for task in asyncio.all_tasks() if task.get_name().startswith("business-") and not task.done()]
- def test_sqlite_reopen_retains_jobs_and_pending_pushes(monkeypatch, tmp_path):
- adapter = db_conn._SqliteAdapter(str(tmp_path / "business.sqlite"))
- monkeypatch.setattr(db_conn, "_adapter", adapter)
- monkeypatch.setattr(db_conn, "_schema_ready", False)
- db_conn.init_schema()
- job, _ = store.enqueue("main_fan", "u", INPUT, [], request_id="persist")
- store.update(job["id"], status="completed", push_status="pending", result_json='{"result":"saved"}')
- running, _ = store.enqueue("main_fan", "u", INPUT, [], request_id="running")
- store.update(running["id"], status="running")
- adapter.close()
- store.recover()
- assert store.get(running["id"])["status"] == "interrupted"
- assert store.pending_pushes(db_conn.fmt_dt())[0]["id"] == job["id"]
- adapter.close()
- @pytest.mark.asyncio
- async def test_business_api_requires_own_token_and_keeps_job_ownership(monkeypatch):
- from main import app
- from runtime import business_tasks
- monkeypatch.setenv("BUSINESS_API_TOKEN", "local-test-token")
- monkeypatch.setattr(business_tasks, "_controller", object())
- headers = {"Authorization": "Bearer local-test-token"}
- base = "/api/business-agents/main_fan"
- async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
- assert (await client.get("/api/business-agents")).json()["items"][0]["name"] == "main_fan"
- assert (await client.post(base + "/jobs", json={"input": INPUT})).status_code == 401
- created = await client.post(base + "/jobs", headers=headers, json={"input": INPUT, "request_id": "api-1"})
- assert created.status_code == 202, created.text
- job_id = created.json()["id"]
- assert (await client.get(base + "/jobs/" + job_id, headers=headers, params={"user_name": "other"})).status_code == 404
- assert (await client.post(base + "/manage?action=stop", headers=headers)).status_code == 200
- assert (await client.post(base + "/jobs", headers=headers, json={"input": INPUT})).status_code == 409
- assert (await client.post(base + "/manage?action=start", headers=headers)).status_code == 200
|