| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527 |
- import copy
- import pytest
- from app.core.mcp.identity import AgentIdentity
- from app.core.orchestration.agent.evaluator import replay_candidate_plan
- from app.core.orchestration.agent.planner import (
- OpenAICompatiblePlanningModel,
- SchedulingPlanner,
- )
- DATAFLOW_UID = "01900000-0000-7000-8000-000000000054"
- SOURCE_UID = "01900000-0000-7000-8000-000000000055"
- def identity():
- return AgentIdentity(
- subject="v54-planner",
- roles=frozenset({"scheduler"}),
- business_domains=frozenset({"sales"}),
- environments=frozenset({"test"}),
- correlation_id="01900000-0000-7000-8000-000000000056",
- )
- def planning_request():
- return {
- "business_goal": "Refresh the customer summary before the 08:00 SLA",
- "dataflow_uid": DATAFLOW_UID,
- "business_domain": "sales",
- "environment": "test",
- "available_window": {
- "start": "2026-07-20T06:00:00+08:00",
- "end": "2026-07-20T08:00:00+08:00",
- },
- "authorized_resources": [SOURCE_UID],
- "canary_inputs": {"minimum_id": 1},
- "baseline_execution_id": "n8n-baseline-1",
- }
- def model_plan():
- return {
- "schema_version": "1.0",
- "workflow_spec": {
- "schema_version": "1.0",
- "dataflow_uid": DATAFLOW_UID,
- "name": "Customer summary read-only canary",
- "nodes": [
- {
- "id": "read_customers",
- "type": "sql.query",
- "data_source_uid": SOURCE_UID,
- "purpose": "read",
- "config": {
- "statement": (
- "SELECT customer_name FROM customers "
- "WHERE id >= :minimum_id ORDER BY id"
- ),
- "parameters": {"minimum_id": "${parameters.minimum_id}"},
- },
- }
- ],
- "edges": [],
- "parameters": {"minimum_id": {"type": "integer", "required": True}},
- },
- "schedule_plan": {
- "schema_version": "1.0",
- "timezone": "Asia/Shanghai",
- "triggers": [{"type": "manual"}],
- "max_concurrency": 1,
- "conflict_policy": "skip",
- "timeout_seconds": 600,
- "retry": {"max_attempts": 1, "delay_seconds": 0},
- "backfill": {"max_days": 1, "max_runs": 1},
- },
- "decision_summary": "Use one bounded read-only canary.",
- }
- class Model:
- provider = "test-provider"
- model_name = "test-model"
- def __init__(self, response=None):
- self.response = copy.deepcopy(response or model_plan())
- self.calls = []
- def generate(self, *, messages, response_schema, timeout_seconds):
- self.calls.append(
- {
- "messages": messages,
- "response_schema": response_schema,
- "timeout_seconds": timeout_seconds,
- }
- )
- return copy.deepcopy(self.response)
- class FailingModel(Model):
- def __init__(self, error):
- super().__init__()
- self.error = error
- def generate(self, *, messages, response_schema, timeout_seconds):
- super().generate(
- messages=messages,
- response_schema=response_schema,
- timeout_seconds=timeout_seconds,
- )
- raise self.error
- class ContextTools:
- def __init__(self):
- self.calls = []
- def call_tool(self, name, arguments):
- self.calls.append((name, arguments))
- responses = {
- "get_sla_constraints": {
- "timezone": "Asia/Shanghai",
- "deadline": "08:00",
- "max_duration_seconds": 1800,
- },
- "list_datasource_capabilities": {
- "items": [
- {
- "uid": SOURCE_UID,
- "purposes": ["read"],
- "health": "healthy",
- "capacity": {"available": 4, "max_concurrency": 4},
- }
- ],
- "count": 1,
- "truncated": False,
- },
- "get_execution_history": {
- "items": [{"status": "success"}],
- "count": 1,
- "truncated": False,
- },
- "validate_workflow_spec": {"valid": True},
- "validate_schedule_plan": {"valid": True},
- "estimate_schedule_capacity": {
- "feasible": True,
- "required_slots": 1,
- "available_slots": 4,
- "warnings": [],
- },
- "simulate_schedule": {
- "next_runs": [],
- "warnings": [],
- },
- }
- return copy.deepcopy(responses[name])
- class SchedulingTools:
- def __init__(self):
- self.calls = []
- def call_tool(self, name, arguments):
- self.calls.append((name, copy.deepcopy(arguments)))
- responses = {
- "create_candidate_plan": {
- "candidate_id": "candidate-v54",
- "workflow_hash": "a" * 64,
- "status": "candidate",
- },
- "deploy_disabled_version": {
- "candidate_id": "candidate-v54",
- "status": "deployed_disabled",
- },
- "run_canary": {
- "candidate_id": "candidate-v54",
- "execution_id": "kestra-canary-v54",
- "status": "started",
- },
- }
- return copy.deepcopy(responses[name])
- class FailingSchedulingTools(SchedulingTools):
- def __init__(self, failed_tool):
- super().__init__()
- self.failed_tool = failed_tool
- def call_tool(self, name, arguments):
- if name == self.failed_tool:
- self.calls.append((name, copy.deepcopy(arguments)))
- raise RuntimeError("upstream secret-looking text must not be audited")
- return super().call_tool(name, arguments)
- class Verifier:
- def __init__(self):
- self.calls = []
- def verify(self, candidate_id, *, baseline_execution_id=None):
- self.calls.append((candidate_id, baseline_execution_id))
- return {
- "candidate_id": candidate_id,
- "execution_id": "kestra-canary-v54",
- "status": "passed",
- "sample_runs": 1,
- "verified_by": "v54-verifier",
- "verification_summary": {"equivalent": True},
- }
- class Audit:
- def __init__(self):
- self.events = []
- def record(self, event):
- self.events.append(copy.deepcopy(event))
- def test_planner_runs_fixed_read_only_canary_sequence_and_records_decision():
- model = Model()
- context = ContextTools()
- scheduling = SchedulingTools()
- verifier = Verifier()
- audit = Audit()
- planner = SchedulingPlanner(
- model=model,
- context_tools=context,
- scheduling_tools=scheduling,
- canary_verifier=verifier,
- audit=audit,
- model_timeout_seconds=15,
- )
- result = planner.run(identity(), planning_request())
- assert result["status"] == "canary_passed"
- assert result["candidate_id"] == "candidate-v54"
- assert result["promotion_recommendation"] == "shadow_only"
- assert result["formal_engine_changed"] is False
- assert [name for name, _ in context.calls] == [
- "get_sla_constraints",
- "list_datasource_capabilities",
- "get_execution_history",
- "validate_workflow_spec",
- "validate_schedule_plan",
- "estimate_schedule_capacity",
- "simulate_schedule",
- ]
- assert [name for name, _ in scheduling.calls] == [
- "create_candidate_plan",
- "deploy_disabled_version",
- "run_canary",
- ]
- assert verifier.calls == [("candidate-v54", "n8n-baseline-1")]
- assert model.calls[0]["timeout_seconds"] == 15
- assert model.calls[0]["response_schema"]["additionalProperties"] is False
- event = audit.events[-1]
- assert event["action"] == "ai_planning_closed_loop"
- assert event["decision"] == "allowed"
- assert event["model_provider"] == "test-provider"
- assert event["model_name"] == "test-model"
- assert event["prompt_version"]
- assert event["schema_version"] == "v54"
- assert len(event["context_hash"]) == 64
- assert event["detail"]["candidate_plan"]["schema_version"] == "1.0"
- assert event["detail"]["validation"]["capacity"]["feasible"] is True
- assert [item["tool"] for item in event["detail"]["action_results"]] == [
- "create_candidate_plan",
- "deploy_disabled_version",
- "run_canary",
- "verify_canary",
- ]
- def test_model_timeout_degrades_to_fixed_schedule_without_mutating_tools():
- scheduling = SchedulingTools()
- audit = Audit()
- planner = SchedulingPlanner(
- model=FailingModel(TimeoutError("provider timeout")),
- context_tools=ContextTools(),
- scheduling_tools=scheduling,
- canary_verifier=Verifier(),
- audit=audit,
- )
- result = planner.run(identity(), planning_request())
- assert result == {
- "status": "degraded",
- "mode": "fixed_schedule",
- "reason_code": "model_unavailable",
- "action": "continue_published_schedule",
- "formal_engine_changed": False,
- "correlation_id": identity().correlation_id,
- }
- assert scheduling.calls == []
- assert audit.events[-1]["decision"] == "failed"
- assert audit.events[-1]["detail"]["reason_code"] == "model_unavailable"
- assert "provider timeout" not in str(audit.events[-1])
- def test_invalid_or_write_model_plan_is_safely_rejected_before_scheduling():
- write_plan = model_plan()
- write_plan["workflow_spec"]["nodes"][0].update(
- {
- "type": "sql.execute",
- "purpose": "write",
- "idempotency": {
- "strategy": "deduplication_key",
- "key": "customer-summary-v54",
- },
- }
- )
- scheduling = SchedulingTools()
- audit = Audit()
- planner = SchedulingPlanner(
- model=Model(write_plan),
- context_tools=ContextTools(),
- scheduling_tools=scheduling,
- canary_verifier=Verifier(),
- audit=audit,
- )
- result = planner.run(identity(), planning_request())
- assert result["status"] == "rejected"
- assert result["reason_code"] == "invalid_model_output"
- assert result["action"] == "continue_published_schedule"
- assert scheduling.calls == []
- assert audit.events[-1]["decision"] == "rejected"
- assert "candidate_plan" not in audit.events[-1]["detail"]
- assert len(audit.events[-1]["detail"]["candidate_plan_hash"]) == 64
- def test_target_conflict_is_rejected_without_calling_model_or_tools():
- request = planning_request()
- request["target_conflicts"] = [
- "two workflows claim the same customer_summary target"
- ]
- model = Model()
- context = ContextTools()
- scheduling = SchedulingTools()
- audit = Audit()
- planner = SchedulingPlanner(
- model=model,
- context_tools=context,
- scheduling_tools=scheduling,
- canary_verifier=Verifier(),
- audit=audit,
- )
- result = planner.run(identity(), request)
- assert result["status"] == "rejected"
- assert result["reason_code"] == "target_conflict"
- assert model.calls == []
- assert context.calls == []
- assert scheduling.calls == []
- assert audit.events[-1]["decision"] == "rejected"
- def test_tool_failure_stops_at_disabled_candidate_and_audits_safe_result():
- scheduling = FailingSchedulingTools("deploy_disabled_version")
- audit = Audit()
- planner = SchedulingPlanner(
- model=Model(),
- context_tools=ContextTools(),
- scheduling_tools=scheduling,
- canary_verifier=Verifier(),
- audit=audit,
- )
- result = planner.run(identity(), planning_request())
- assert result["status"] == "degraded"
- assert result["reason_code"] == "tool_failure"
- assert result["candidate_id"] == "candidate-v54"
- assert result["action"] == "continue_published_schedule"
- assert [name for name, _ in scheduling.calls] == [
- "create_candidate_plan",
- "deploy_disabled_version",
- ]
- assert audit.events[-1]["decision"] == "failed"
- assert audit.events[-1]["detail"]["action_results"][0]["tool"] == (
- "create_candidate_plan"
- )
- assert "secret-looking" not in str(audit.events[-1])
- def test_openai_compatible_model_requests_deterministic_json_and_supplies_schema():
- class Message:
- content = '{"schema_version":"1.0"}'
- class Choice:
- message = Message()
- class Completions:
- def __init__(self):
- self.calls = []
- def create(self, **kwargs):
- self.calls.append(kwargs)
- return type("Response", (), {"choices": [Choice()]})()
- completions = Completions()
- client = type(
- "Client",
- (),
- {"chat": type("Chat", (), {"completions": completions})()},
- )()
- model = OpenAICompatiblePlanningModel(
- client=client,
- provider="deepseek",
- model_name="deepseek-chat",
- )
- result = model.generate(
- messages=[{"role": "user", "content": "plan"}],
- response_schema={"type": "object", "additionalProperties": False},
- timeout_seconds=12,
- )
- assert result == '{"schema_version":"1.0"}'
- call = completions.calls[0]
- assert call["model"] == "deepseek-chat"
- assert call["temperature"] == 0
- assert call["timeout"] == 12
- assert call["response_format"] == {"type": "json_object"}
- assert "OUTPUT_JSON_SCHEMA" in call["messages"][-1]["content"]
- assert '"additionalProperties":false' in call["messages"][-1]["content"]
- def test_saved_candidate_can_be_replayed_offline_without_model_or_tools():
- first = replay_candidate_plan(model_plan(), planning_request())
- second = replay_candidate_plan(model_plan(), planning_request())
- assert first == second
- assert first["allowed"] is True
- assert first["schema_version"] == "v54"
- assert len(first["context_hash"]) == 64
- assert len(first["candidate_hash"]) == 64
- assert len(first["workflow_hash"]) == 64
- def test_planner_monitors_canary_until_trusted_terminal_evidence():
- class ProgressiveVerifier(Verifier):
- def __init__(self):
- super().__init__()
- self.responses = [
- {
- "candidate_id": "candidate-v54",
- "status": "started",
- "sample_runs": 0,
- },
- {
- "candidate_id": "candidate-v54",
- "status": "started",
- "sample_runs": 0,
- },
- {
- "candidate_id": "candidate-v54",
- "status": "passed",
- "sample_runs": 1,
- "verified_by": "v54-verifier",
- },
- ]
- def verify(self, candidate_id, *, baseline_execution_id=None):
- self.calls.append((candidate_id, baseline_execution_id))
- return copy.deepcopy(self.responses.pop(0))
- verifier = ProgressiveVerifier()
- sleeps = []
- ticks = iter([0.0, 0.1, 0.2, 0.3])
- planner = SchedulingPlanner(
- model=Model(),
- context_tools=ContextTools(),
- scheduling_tools=SchedulingTools(),
- canary_verifier=verifier,
- audit=Audit(),
- canary_timeout_seconds=5,
- canary_poll_interval_seconds=0.25,
- sleep=sleeps.append,
- monotonic=lambda: next(ticks),
- )
- result = planner.run(identity(), planning_request())
- assert result["status"] == "canary_passed"
- assert len(verifier.calls) == 3
- assert sleeps == [0.25, 0.25]
- @pytest.mark.parametrize(
- "mutate",
- [
- lambda candidate: candidate["schedule_plan"]["retry"].update(
- {"max_attempts": 1000000}
- ),
- lambda candidate: candidate["workflow_spec"]["nodes"][0]["config"].update(
- {"password": "model-requested-secret"}
- ),
- lambda candidate: candidate.update(
- {"backfill_partitions": ["already-successful-2026-07-18"]}
- ),
- ],
- )
- def test_fixed_scenarios_reject_unbounded_secret_or_model_backfill_advice(
- mutate,
- ):
- candidate = model_plan()
- mutate(candidate)
- scheduling = SchedulingTools()
- planner = SchedulingPlanner(
- model=Model(candidate),
- context_tools=ContextTools(),
- scheduling_tools=scheduling,
- canary_verifier=Verifier(),
- audit=Audit(),
- )
- result = planner.run(identity(), planning_request())
- assert result["status"] == "rejected"
- assert result["reason_code"] == "invalid_model_output"
- assert scheduling.calls == []
|