test_agent_model_offline.py 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
  1. """Opt-in V54 deterministic model-offline degradation acceptance."""
  2. import os
  3. import pytest
  4. from sqlalchemy import create_engine, text
  5. from app.core.common.identifiers import new_governance_uid
  6. from app.core.mcp.identity import AgentIdentity
  7. from app.core.mcp.persistence import PostgresAuditSink
  8. from app.core.orchestration.agent.planner import SchedulingPlanner
  9. pytestmark = pytest.mark.integration
  10. DATABASE_URL = "postgresql://dataops:dataops-test-password@127.0.0.1:15432/dataops"
  11. class OfflineModel:
  12. provider = "deepseek"
  13. model_name = "deepseek-chat"
  14. def generate(self, **_kwargs):
  15. raise TimeoutError("the model provider is unavailable")
  16. class ContextTools:
  17. def call_tool(self, name, _arguments):
  18. return {
  19. "get_sla_constraints": {"deadline": "08:00"},
  20. "list_datasource_capabilities": {
  21. "items": [],
  22. "count": 0,
  23. "truncated": False,
  24. },
  25. "get_execution_history": {
  26. "items": [{"status": "success"}],
  27. "count": 1,
  28. "truncated": False,
  29. },
  30. }[name]
  31. class ForbiddenSchedulingTools:
  32. def call_tool(self, name, _arguments):
  33. raise AssertionError(f"offline model must not call scheduling tool {name}")
  34. class ForbiddenVerifier:
  35. def verify(self, *_args, **_kwargs):
  36. raise AssertionError("offline model must not start Canary verification")
  37. def test_model_offline_keeps_fixed_schedule_and_persists_trace():
  38. if os.environ.get("RUN_V54_AGENT_L3") != "1":
  39. pytest.skip("set RUN_V54_AGENT_L3=1 for the V54 Agent acceptance")
  40. engine = create_engine(DATABASE_URL, pool_pre_ping=True)
  41. correlation_id = new_governance_uid()
  42. dataflow_uid = new_governance_uid()
  43. source_uid = new_governance_uid()
  44. identity = AgentIdentity(
  45. subject="v54-model-offline",
  46. roles=frozenset({"scheduler"}),
  47. business_domains=frozenset({"sales"}),
  48. environments=frozenset({"test"}),
  49. correlation_id=correlation_id,
  50. )
  51. planner = SchedulingPlanner(
  52. model=OfflineModel(),
  53. context_tools=ContextTools(),
  54. scheduling_tools=ForbiddenSchedulingTools(),
  55. canary_verifier=ForbiddenVerifier(),
  56. audit=PostgresAuditSink(engine),
  57. model_timeout_seconds=1,
  58. )
  59. try:
  60. result = planner.run(
  61. identity,
  62. {
  63. "business_goal": "Keep the published schedule stable",
  64. "dataflow_uid": dataflow_uid,
  65. "business_domain": "sales",
  66. "environment": "test",
  67. "available_window": {
  68. "start": "2026-07-20T06:00:00+08:00",
  69. "end": "2026-07-20T08:00:00+08:00",
  70. },
  71. "authorized_resources": [source_uid],
  72. "canary_inputs": {},
  73. },
  74. )
  75. assert result == {
  76. "status": "degraded",
  77. "mode": "fixed_schedule",
  78. "reason_code": "model_unavailable",
  79. "action": "continue_published_schedule",
  80. "formal_engine_changed": False,
  81. "correlation_id": correlation_id,
  82. }
  83. with engine.connect() as connection:
  84. row = (
  85. connection.execute(
  86. text(
  87. "SELECT actor_subject, model_provider, model_name, "
  88. "prompt_version, schema_version, decision, decision_detail "
  89. "FROM public.workflow_plan_audits "
  90. "WHERE correlation_id = CAST(:id AS uuid) "
  91. "AND action = 'ai_planning_closed_loop'"
  92. ),
  93. {"id": correlation_id},
  94. )
  95. .mappings()
  96. .one()
  97. )
  98. assert row["actor_subject"] == "v54-model-offline"
  99. assert row["model_provider"] == "deepseek"
  100. assert row["schema_version"] == "v54"
  101. assert row["decision"] == "failed"
  102. assert row["decision_detail"]["reason_code"] == "model_unavailable"
  103. assert "provider is unavailable" not in str(row["decision_detail"])
  104. finally:
  105. with engine.begin() as connection:
  106. connection.execute(
  107. text(
  108. "DELETE FROM public.workflow_plan_audits "
  109. "WHERE correlation_id = CAST(:id AS uuid)"
  110. ),
  111. {"id": correlation_id},
  112. )
  113. engine.dispose()