test_planner_scenarios.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527
  1. import copy
  2. import pytest
  3. from app.core.mcp.identity import AgentIdentity
  4. from app.core.orchestration.agent.evaluator import replay_candidate_plan
  5. from app.core.orchestration.agent.planner import (
  6. OpenAICompatiblePlanningModel,
  7. SchedulingPlanner,
  8. )
  9. DATAFLOW_UID = "01900000-0000-7000-8000-000000000054"
  10. SOURCE_UID = "01900000-0000-7000-8000-000000000055"
  11. def identity():
  12. return AgentIdentity(
  13. subject="v54-planner",
  14. roles=frozenset({"scheduler"}),
  15. business_domains=frozenset({"sales"}),
  16. environments=frozenset({"test"}),
  17. correlation_id="01900000-0000-7000-8000-000000000056",
  18. )
  19. def planning_request():
  20. return {
  21. "business_goal": "Refresh the customer summary before the 08:00 SLA",
  22. "dataflow_uid": DATAFLOW_UID,
  23. "business_domain": "sales",
  24. "environment": "test",
  25. "available_window": {
  26. "start": "2026-07-20T06:00:00+08:00",
  27. "end": "2026-07-20T08:00:00+08:00",
  28. },
  29. "authorized_resources": [SOURCE_UID],
  30. "canary_inputs": {"minimum_id": 1},
  31. "baseline_execution_id": "n8n-baseline-1",
  32. }
  33. def model_plan():
  34. return {
  35. "schema_version": "1.0",
  36. "workflow_spec": {
  37. "schema_version": "1.0",
  38. "dataflow_uid": DATAFLOW_UID,
  39. "name": "Customer summary read-only canary",
  40. "nodes": [
  41. {
  42. "id": "read_customers",
  43. "type": "sql.query",
  44. "data_source_uid": SOURCE_UID,
  45. "purpose": "read",
  46. "config": {
  47. "statement": (
  48. "SELECT customer_name FROM customers "
  49. "WHERE id >= :minimum_id ORDER BY id"
  50. ),
  51. "parameters": {"minimum_id": "${parameters.minimum_id}"},
  52. },
  53. }
  54. ],
  55. "edges": [],
  56. "parameters": {"minimum_id": {"type": "integer", "required": True}},
  57. },
  58. "schedule_plan": {
  59. "schema_version": "1.0",
  60. "timezone": "Asia/Shanghai",
  61. "triggers": [{"type": "manual"}],
  62. "max_concurrency": 1,
  63. "conflict_policy": "skip",
  64. "timeout_seconds": 600,
  65. "retry": {"max_attempts": 1, "delay_seconds": 0},
  66. "backfill": {"max_days": 1, "max_runs": 1},
  67. },
  68. "decision_summary": "Use one bounded read-only canary.",
  69. }
  70. class Model:
  71. provider = "test-provider"
  72. model_name = "test-model"
  73. def __init__(self, response=None):
  74. self.response = copy.deepcopy(response or model_plan())
  75. self.calls = []
  76. def generate(self, *, messages, response_schema, timeout_seconds):
  77. self.calls.append(
  78. {
  79. "messages": messages,
  80. "response_schema": response_schema,
  81. "timeout_seconds": timeout_seconds,
  82. }
  83. )
  84. return copy.deepcopy(self.response)
  85. class FailingModel(Model):
  86. def __init__(self, error):
  87. super().__init__()
  88. self.error = error
  89. def generate(self, *, messages, response_schema, timeout_seconds):
  90. super().generate(
  91. messages=messages,
  92. response_schema=response_schema,
  93. timeout_seconds=timeout_seconds,
  94. )
  95. raise self.error
  96. class ContextTools:
  97. def __init__(self):
  98. self.calls = []
  99. def call_tool(self, name, arguments):
  100. self.calls.append((name, arguments))
  101. responses = {
  102. "get_sla_constraints": {
  103. "timezone": "Asia/Shanghai",
  104. "deadline": "08:00",
  105. "max_duration_seconds": 1800,
  106. },
  107. "list_datasource_capabilities": {
  108. "items": [
  109. {
  110. "uid": SOURCE_UID,
  111. "purposes": ["read"],
  112. "health": "healthy",
  113. "capacity": {"available": 4, "max_concurrency": 4},
  114. }
  115. ],
  116. "count": 1,
  117. "truncated": False,
  118. },
  119. "get_execution_history": {
  120. "items": [{"status": "success"}],
  121. "count": 1,
  122. "truncated": False,
  123. },
  124. "validate_workflow_spec": {"valid": True},
  125. "validate_schedule_plan": {"valid": True},
  126. "estimate_schedule_capacity": {
  127. "feasible": True,
  128. "required_slots": 1,
  129. "available_slots": 4,
  130. "warnings": [],
  131. },
  132. "simulate_schedule": {
  133. "next_runs": [],
  134. "warnings": [],
  135. },
  136. }
  137. return copy.deepcopy(responses[name])
  138. class SchedulingTools:
  139. def __init__(self):
  140. self.calls = []
  141. def call_tool(self, name, arguments):
  142. self.calls.append((name, copy.deepcopy(arguments)))
  143. responses = {
  144. "create_candidate_plan": {
  145. "candidate_id": "candidate-v54",
  146. "workflow_hash": "a" * 64,
  147. "status": "candidate",
  148. },
  149. "deploy_disabled_version": {
  150. "candidate_id": "candidate-v54",
  151. "status": "deployed_disabled",
  152. },
  153. "run_canary": {
  154. "candidate_id": "candidate-v54",
  155. "execution_id": "kestra-canary-v54",
  156. "status": "started",
  157. },
  158. }
  159. return copy.deepcopy(responses[name])
  160. class FailingSchedulingTools(SchedulingTools):
  161. def __init__(self, failed_tool):
  162. super().__init__()
  163. self.failed_tool = failed_tool
  164. def call_tool(self, name, arguments):
  165. if name == self.failed_tool:
  166. self.calls.append((name, copy.deepcopy(arguments)))
  167. raise RuntimeError("upstream secret-looking text must not be audited")
  168. return super().call_tool(name, arguments)
  169. class Verifier:
  170. def __init__(self):
  171. self.calls = []
  172. def verify(self, candidate_id, *, baseline_execution_id=None):
  173. self.calls.append((candidate_id, baseline_execution_id))
  174. return {
  175. "candidate_id": candidate_id,
  176. "execution_id": "kestra-canary-v54",
  177. "status": "passed",
  178. "sample_runs": 1,
  179. "verified_by": "v54-verifier",
  180. "verification_summary": {"equivalent": True},
  181. }
  182. class Audit:
  183. def __init__(self):
  184. self.events = []
  185. def record(self, event):
  186. self.events.append(copy.deepcopy(event))
  187. def test_planner_runs_fixed_read_only_canary_sequence_and_records_decision():
  188. model = Model()
  189. context = ContextTools()
  190. scheduling = SchedulingTools()
  191. verifier = Verifier()
  192. audit = Audit()
  193. planner = SchedulingPlanner(
  194. model=model,
  195. context_tools=context,
  196. scheduling_tools=scheduling,
  197. canary_verifier=verifier,
  198. audit=audit,
  199. model_timeout_seconds=15,
  200. )
  201. result = planner.run(identity(), planning_request())
  202. assert result["status"] == "canary_passed"
  203. assert result["candidate_id"] == "candidate-v54"
  204. assert result["promotion_recommendation"] == "shadow_only"
  205. assert result["formal_engine_changed"] is False
  206. assert [name for name, _ in context.calls] == [
  207. "get_sla_constraints",
  208. "list_datasource_capabilities",
  209. "get_execution_history",
  210. "validate_workflow_spec",
  211. "validate_schedule_plan",
  212. "estimate_schedule_capacity",
  213. "simulate_schedule",
  214. ]
  215. assert [name for name, _ in scheduling.calls] == [
  216. "create_candidate_plan",
  217. "deploy_disabled_version",
  218. "run_canary",
  219. ]
  220. assert verifier.calls == [("candidate-v54", "n8n-baseline-1")]
  221. assert model.calls[0]["timeout_seconds"] == 15
  222. assert model.calls[0]["response_schema"]["additionalProperties"] is False
  223. event = audit.events[-1]
  224. assert event["action"] == "ai_planning_closed_loop"
  225. assert event["decision"] == "allowed"
  226. assert event["model_provider"] == "test-provider"
  227. assert event["model_name"] == "test-model"
  228. assert event["prompt_version"]
  229. assert event["schema_version"] == "v54"
  230. assert len(event["context_hash"]) == 64
  231. assert event["detail"]["candidate_plan"]["schema_version"] == "1.0"
  232. assert event["detail"]["validation"]["capacity"]["feasible"] is True
  233. assert [item["tool"] for item in event["detail"]["action_results"]] == [
  234. "create_candidate_plan",
  235. "deploy_disabled_version",
  236. "run_canary",
  237. "verify_canary",
  238. ]
  239. def test_model_timeout_degrades_to_fixed_schedule_without_mutating_tools():
  240. scheduling = SchedulingTools()
  241. audit = Audit()
  242. planner = SchedulingPlanner(
  243. model=FailingModel(TimeoutError("provider timeout")),
  244. context_tools=ContextTools(),
  245. scheduling_tools=scheduling,
  246. canary_verifier=Verifier(),
  247. audit=audit,
  248. )
  249. result = planner.run(identity(), planning_request())
  250. assert result == {
  251. "status": "degraded",
  252. "mode": "fixed_schedule",
  253. "reason_code": "model_unavailable",
  254. "action": "continue_published_schedule",
  255. "formal_engine_changed": False,
  256. "correlation_id": identity().correlation_id,
  257. }
  258. assert scheduling.calls == []
  259. assert audit.events[-1]["decision"] == "failed"
  260. assert audit.events[-1]["detail"]["reason_code"] == "model_unavailable"
  261. assert "provider timeout" not in str(audit.events[-1])
  262. def test_invalid_or_write_model_plan_is_safely_rejected_before_scheduling():
  263. write_plan = model_plan()
  264. write_plan["workflow_spec"]["nodes"][0].update(
  265. {
  266. "type": "sql.execute",
  267. "purpose": "write",
  268. "idempotency": {
  269. "strategy": "deduplication_key",
  270. "key": "customer-summary-v54",
  271. },
  272. }
  273. )
  274. scheduling = SchedulingTools()
  275. audit = Audit()
  276. planner = SchedulingPlanner(
  277. model=Model(write_plan),
  278. context_tools=ContextTools(),
  279. scheduling_tools=scheduling,
  280. canary_verifier=Verifier(),
  281. audit=audit,
  282. )
  283. result = planner.run(identity(), planning_request())
  284. assert result["status"] == "rejected"
  285. assert result["reason_code"] == "invalid_model_output"
  286. assert result["action"] == "continue_published_schedule"
  287. assert scheduling.calls == []
  288. assert audit.events[-1]["decision"] == "rejected"
  289. assert "candidate_plan" not in audit.events[-1]["detail"]
  290. assert len(audit.events[-1]["detail"]["candidate_plan_hash"]) == 64
  291. def test_target_conflict_is_rejected_without_calling_model_or_tools():
  292. request = planning_request()
  293. request["target_conflicts"] = [
  294. "two workflows claim the same customer_summary target"
  295. ]
  296. model = Model()
  297. context = ContextTools()
  298. scheduling = SchedulingTools()
  299. audit = Audit()
  300. planner = SchedulingPlanner(
  301. model=model,
  302. context_tools=context,
  303. scheduling_tools=scheduling,
  304. canary_verifier=Verifier(),
  305. audit=audit,
  306. )
  307. result = planner.run(identity(), request)
  308. assert result["status"] == "rejected"
  309. assert result["reason_code"] == "target_conflict"
  310. assert model.calls == []
  311. assert context.calls == []
  312. assert scheduling.calls == []
  313. assert audit.events[-1]["decision"] == "rejected"
  314. def test_tool_failure_stops_at_disabled_candidate_and_audits_safe_result():
  315. scheduling = FailingSchedulingTools("deploy_disabled_version")
  316. audit = Audit()
  317. planner = SchedulingPlanner(
  318. model=Model(),
  319. context_tools=ContextTools(),
  320. scheduling_tools=scheduling,
  321. canary_verifier=Verifier(),
  322. audit=audit,
  323. )
  324. result = planner.run(identity(), planning_request())
  325. assert result["status"] == "degraded"
  326. assert result["reason_code"] == "tool_failure"
  327. assert result["candidate_id"] == "candidate-v54"
  328. assert result["action"] == "continue_published_schedule"
  329. assert [name for name, _ in scheduling.calls] == [
  330. "create_candidate_plan",
  331. "deploy_disabled_version",
  332. ]
  333. assert audit.events[-1]["decision"] == "failed"
  334. assert audit.events[-1]["detail"]["action_results"][0]["tool"] == (
  335. "create_candidate_plan"
  336. )
  337. assert "secret-looking" not in str(audit.events[-1])
  338. def test_openai_compatible_model_requests_deterministic_json_and_supplies_schema():
  339. class Message:
  340. content = '{"schema_version":"1.0"}'
  341. class Choice:
  342. message = Message()
  343. class Completions:
  344. def __init__(self):
  345. self.calls = []
  346. def create(self, **kwargs):
  347. self.calls.append(kwargs)
  348. return type("Response", (), {"choices": [Choice()]})()
  349. completions = Completions()
  350. client = type(
  351. "Client",
  352. (),
  353. {"chat": type("Chat", (), {"completions": completions})()},
  354. )()
  355. model = OpenAICompatiblePlanningModel(
  356. client=client,
  357. provider="deepseek",
  358. model_name="deepseek-chat",
  359. )
  360. result = model.generate(
  361. messages=[{"role": "user", "content": "plan"}],
  362. response_schema={"type": "object", "additionalProperties": False},
  363. timeout_seconds=12,
  364. )
  365. assert result == '{"schema_version":"1.0"}'
  366. call = completions.calls[0]
  367. assert call["model"] == "deepseek-chat"
  368. assert call["temperature"] == 0
  369. assert call["timeout"] == 12
  370. assert call["response_format"] == {"type": "json_object"}
  371. assert "OUTPUT_JSON_SCHEMA" in call["messages"][-1]["content"]
  372. assert '"additionalProperties":false' in call["messages"][-1]["content"]
  373. def test_saved_candidate_can_be_replayed_offline_without_model_or_tools():
  374. first = replay_candidate_plan(model_plan(), planning_request())
  375. second = replay_candidate_plan(model_plan(), planning_request())
  376. assert first == second
  377. assert first["allowed"] is True
  378. assert first["schema_version"] == "v54"
  379. assert len(first["context_hash"]) == 64
  380. assert len(first["candidate_hash"]) == 64
  381. assert len(first["workflow_hash"]) == 64
  382. def test_planner_monitors_canary_until_trusted_terminal_evidence():
  383. class ProgressiveVerifier(Verifier):
  384. def __init__(self):
  385. super().__init__()
  386. self.responses = [
  387. {
  388. "candidate_id": "candidate-v54",
  389. "status": "started",
  390. "sample_runs": 0,
  391. },
  392. {
  393. "candidate_id": "candidate-v54",
  394. "status": "started",
  395. "sample_runs": 0,
  396. },
  397. {
  398. "candidate_id": "candidate-v54",
  399. "status": "passed",
  400. "sample_runs": 1,
  401. "verified_by": "v54-verifier",
  402. },
  403. ]
  404. def verify(self, candidate_id, *, baseline_execution_id=None):
  405. self.calls.append((candidate_id, baseline_execution_id))
  406. return copy.deepcopy(self.responses.pop(0))
  407. verifier = ProgressiveVerifier()
  408. sleeps = []
  409. ticks = iter([0.0, 0.1, 0.2, 0.3])
  410. planner = SchedulingPlanner(
  411. model=Model(),
  412. context_tools=ContextTools(),
  413. scheduling_tools=SchedulingTools(),
  414. canary_verifier=verifier,
  415. audit=Audit(),
  416. canary_timeout_seconds=5,
  417. canary_poll_interval_seconds=0.25,
  418. sleep=sleeps.append,
  419. monotonic=lambda: next(ticks),
  420. )
  421. result = planner.run(identity(), planning_request())
  422. assert result["status"] == "canary_passed"
  423. assert len(verifier.calls) == 3
  424. assert sleeps == [0.25, 0.25]
  425. @pytest.mark.parametrize(
  426. "mutate",
  427. [
  428. lambda candidate: candidate["schedule_plan"]["retry"].update(
  429. {"max_attempts": 1000000}
  430. ),
  431. lambda candidate: candidate["workflow_spec"]["nodes"][0]["config"].update(
  432. {"password": "model-requested-secret"}
  433. ),
  434. lambda candidate: candidate.update(
  435. {"backfill_partitions": ["already-successful-2026-07-18"]}
  436. ),
  437. ],
  438. )
  439. def test_fixed_scenarios_reject_unbounded_secret_or_model_backfill_advice(
  440. mutate,
  441. ):
  442. candidate = model_plan()
  443. mutate(candidate)
  444. scheduling = SchedulingTools()
  445. planner = SchedulingPlanner(
  446. model=Model(candidate),
  447. context_tools=ContextTools(),
  448. scheduling_tools=scheduling,
  449. canary_verifier=Verifier(),
  450. audit=Audit(),
  451. )
  452. result = planner.run(identity(), planning_request())
  453. assert result["status"] == "rejected"
  454. assert result["reason_code"] == "invalid_model_output"
  455. assert scheduling.calls == []