test_agent_runtime.py 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  1. import asyncio
  2. import hashlib
  3. import json
  4. from datetime import timedelta
  5. from uuid import UUID, uuid4
  6. import pytest
  7. from langchain_core.messages import AIMessage, message_to_dict
  8. from sqlalchemy import func, select
  9. from test_after_sales import scenario # noqa: F401
  10. from zhifu.core.errors import BusinessError
  11. from zhifu.modules.after_sales.models import Ticket
  12. from zhifu.modules.agent_runtime.capacity import acquire, keys
  13. from zhifu.modules.agent_runtime.config import AgentSettings
  14. from zhifu.modules.agent_runtime.models import AgentRun
  15. from zhifu.modules.agent_runtime.runtime import build_tool_graph
  16. from zhifu.modules.agent_runtime.worker import claim
  17. from zhifu.modules.identity.access_models import AccessSession
  18. from zhifu.modules.identity.auth import now
  19. pytestmark = pytest.mark.integration
  20. async def tool_run(context, name, arguments):
  21. token = context["headers"]["customer"]["Authorization"].split()[1]
  22. async with context["app"].state.sessions.begin() as session:
  23. run = AgentRun(
  24. id=uuid4().bytes,
  25. tenant_id=context["tenant"].bytes,
  26. actor_id=context["accounts"]["customer"].bytes,
  27. store_id=context["store"].bytes,
  28. token_hash=hashlib.sha256(token.encode()).digest(),
  29. request_key=uuid4().hex,
  30. request_hash=hashlib.sha256(json.dumps(arguments).encode()).digest(),
  31. messages=[
  32. message_to_dict(
  33. AIMessage(
  34. content="",
  35. tool_calls=[
  36. {
  37. "name": name,
  38. "args": arguments,
  39. "id": uuid4().hex,
  40. "type": "tool_call",
  41. }
  42. ],
  43. )
  44. )
  45. ],
  46. next_node="tools",
  47. model_name="",
  48. model_url="",
  49. model_credential_env="",
  50. created_at=now(),
  51. deadline_at=now() + timedelta(minutes=10),
  52. )
  53. session.add(run)
  54. await session.flush()
  55. return run
  56. async def test_tool_graph_creates_one_proposal_on_replay(scenario): # noqa: F811
  57. run = await tool_run(scenario, "create_exchange_proposal", scenario["proposal"])
  58. sessions = scenario["app"].state.sessions
  59. run_id, token = await claim(sessions, 2)
  60. graph = build_tool_graph(sessions)
  61. state = {"run_id": run_id, "token": token, "next_node": "tools"}
  62. await graph.ainvoke(state)
  63. await graph.ainvoke(state)
  64. async with sessions() as session:
  65. count = await session.scalar(
  66. select(func.count())
  67. .select_from(Ticket)
  68. .where(Ticket.tenant_id == scenario["tenant"].bytes)
  69. )
  70. assert count == 1
  71. current = await session.get(AgentRun, run.id)
  72. result = json.loads(current.messages[-1]["data"]["content"])
  73. assert result["status"] == "DRAFT"
  74. assert current.step == 1
  75. @pytest.mark.parametrize("name", ["confirm", "decide", "submit_exchange", "cancel"])
  76. async def test_unauthorized_tools_are_rejected(scenario, name): # noqa: F811
  77. await tool_run(scenario, name, {})
  78. sessions = scenario["app"].state.sessions
  79. run_id, token = await claim(sessions, 2)
  80. with pytest.raises(BusinessError, match="工具不在授权清单"):
  81. await build_tool_graph(sessions).ainvoke({"run_id": run_id, "token": token})
  82. async def test_tool_cannot_read_another_customer_order(scenario): # noqa: F811
  83. run = await tool_run(scenario, "get_order", {"order_id": str(scenario["order_id"])})
  84. sessions = scenario["app"].state.sessions
  85. other_token = scenario["headers"]["other"]["Authorization"].split()[1]
  86. async with sessions.begin() as session:
  87. current = await session.get(AgentRun, run.id)
  88. current.actor_id = scenario["accounts"]["other"].bytes
  89. current.token_hash = hashlib.sha256(other_token.encode()).digest()
  90. run_id, token = await claim(sessions, 2)
  91. with pytest.raises(BusinessError, match="不可访问"):
  92. await build_tool_graph(sessions).ainvoke({"run_id": run_id, "token": token})
  93. async def test_revoked_session_stops_tool_execution(scenario): # noqa: F811
  94. run = await tool_run(scenario, "search_orders", {})
  95. sessions = scenario["app"].state.sessions
  96. run_id, token = await claim(sessions, 2)
  97. async with sessions.begin() as session:
  98. access = await session.get(AccessSession, run.token_hash)
  99. access.revoked = True
  100. with pytest.raises(BusinessError, match="访问令牌无效"):
  101. await build_tool_graph(sessions).ainvoke({"run_id": run_id, "token": token})
  102. async def test_cancelled_run_rejects_old_execution(scenario): # noqa: F811
  103. run = await tool_run(scenario, "search_orders", {})
  104. sessions = scenario["app"].state.sessions
  105. run_id, token = await claim(sessions, 2)
  106. response = await scenario["call"]("POST", f"/openapi/v1/agent/runs/{UUID(bytes=run.id)}/cancel")
  107. assert response.status_code == 200
  108. with pytest.raises(BusinessError, match="执行权已失效"):
  109. await build_tool_graph(sessions).ainvoke({"run_id": run_id, "token": token})
  110. response = await scenario["call"](
  111. "GET", f"/openapi/v1/agent/runs/{UUID(bytes=run.id)}", "foreign"
  112. )
  113. assert response.status_code == 404
  114. async def test_tenant_and_global_concurrency_limits(scenario): # noqa: F811
  115. for _ in range(3):
  116. await tool_run(scenario, "search_orders", {})
  117. sessions = scenario["app"].state.sessions
  118. claims = await asyncio.gather(*(claim(sessions, 2) for _ in range(3)))
  119. assert sum(value is not None for value in claims) == 2
  120. state = scenario["app"].state.state
  121. key = "verify:agent:capacity:" + uuid4().hex
  122. try:
  123. results = await asyncio.gather(
  124. *(
  125. acquire(state, AgentSettings(), "background", uuid4().hex, prefix=key)
  126. for _ in range(8)
  127. )
  128. )
  129. assert sum(results) == 2
  130. finally:
  131. await state.delete(*keys(key))
  132. async def test_expired_lease_resumes_committed_tool_without_duplicate(scenario): # noqa: F811
  133. run = await tool_run(scenario, "create_exchange_proposal", scenario["proposal"])
  134. sessions = scenario["app"].state.sessions
  135. run_id, token = await claim(sessions, 2)
  136. await build_tool_graph(sessions).ainvoke({"run_id": run_id, "token": token})
  137. async with sessions.begin() as session:
  138. current = await session.get(AgentRun, run.id)
  139. current.lease_until = now() - timedelta(seconds=1)
  140. recovered_id, recovered_token = await claim(sessions, 2)
  141. assert recovered_id == run_id and recovered_token > token
  142. with pytest.raises(BusinessError, match="执行权已失效"):
  143. await build_tool_graph(sessions).ainvoke({"run_id": run_id, "token": token})
  144. result = await build_tool_graph(sessions).ainvoke({"run_id": run_id, "token": recovered_token})
  145. assert result["next_node"] == "model"
  146. async with sessions() as session:
  147. count = await session.scalar(
  148. select(func.count())
  149. .select_from(Ticket)
  150. .where(Ticket.tenant_id == scenario["tenant"].bytes)
  151. )
  152. assert count == 1