test_orchestration_validation_policy.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628
  1. from __future__ import annotations
  2. import asyncio
  3. from types import SimpleNamespace
  4. import pytest
  5. from agent.orchestration.config import OrchestrationConfig
  6. from agent.orchestration.evidence import (
  7. EvidenceBudgetExceeded,
  8. EvidenceOwnershipError,
  9. EvidenceProviderError,
  10. EvidenceProviderResult,
  11. EvidenceQuery,
  12. )
  13. from agent.orchestration.models import (
  14. AcceptanceCriterion,
  15. AgentRole,
  16. ArtifactRef,
  17. ArtifactSnapshot,
  18. AttemptSubmission,
  19. DecisionAction,
  20. TaskAttempt,
  21. TaskRecord,
  22. TaskSpec,
  23. ValidationMode,
  24. ValidationPlan,
  25. ValidationVerdict,
  26. )
  27. from agent.orchestration.protocols import WorkerRunResult
  28. from agent.orchestration.store import FileSystemArtifactStore, FileSystemTaskStore
  29. from agent.orchestration.validation_policy import (
  30. DefaultValidationPolicy,
  31. DeterministicRuleResult,
  32. RuleBasedDeterministicValidator,
  33. ValidationContext,
  34. )
  35. from agent.orchestration.wiring import wire_orchestration
  36. from test_coordinator_integration import (
  37. ROOT_TASK_SPEC,
  38. FakeExecutor,
  39. create_task,
  40. make_coordinator,
  41. )
  42. def make_context(root: str = "root") -> ValidationContext:
  43. task = TaskRecord(
  44. task_id="task-1",
  45. parent_task_id=None,
  46. display_path="1",
  47. specs=[
  48. TaskSpec(
  49. version=1,
  50. objective="validate",
  51. acceptance_criteria=[AcceptanceCriterion("criterion", "validate result")],
  52. )
  53. ],
  54. )
  55. attempt = TaskAttempt(
  56. attempt_id="attempt-1",
  57. task_id=task.task_id,
  58. spec_version=1,
  59. worker_trace_id="worker-1",
  60. worker_preset="worker",
  61. execution_mode="new",
  62. snapshot_id="snapshot-1",
  63. )
  64. snapshot = ArtifactSnapshot(
  65. snapshot_id="snapshot-1",
  66. attempt_id=attempt.attempt_id,
  67. normalized_content={"summary": "done"},
  68. sha256="digest",
  69. artifact_refs=[],
  70. evidence_refs=[],
  71. )
  72. return ValidationContext(root, task, attempt, snapshot)
  73. class StaticRule:
  74. def __init__(self, rule_id, verdict):
  75. self.rule_id = rule_id
  76. self.verdict = verdict
  77. def evaluate(self, context):
  78. assert context.snapshot.snapshot_id
  79. return DeterministicRuleResult(self.rule_id, self.verdict, "checked")
  80. class AsyncRule(StaticRule):
  81. async def evaluate(self, context):
  82. await asyncio.sleep(0)
  83. return super().evaluate(context)
  84. class ErrorRule:
  85. rule_id = "error"
  86. def evaluate(self, context):
  87. raise RuntimeError("broken rule")
  88. class DeterministicPolicy:
  89. def __init__(self, verdict=ValidationVerdict.PASSED):
  90. self.verdict = verdict
  91. def plan(self, context):
  92. return ValidationPlan(
  93. mode=ValidationMode.DETERMINISTIC,
  94. validator_preset=None,
  95. rule_ids=[item.criterion_id for item in context.task.current_spec.acceptance_criteria],
  96. )
  97. class FixedPolicy:
  98. def __init__(self, plan):
  99. self.fixed_plan = plan
  100. def plan(self, context):
  101. return self.fixed_plan
  102. class InvalidBlankPolicy:
  103. def plan(self, context):
  104. plan = ValidationPlan()
  105. object.__setattr__(plan, "validator_preset", " ")
  106. return plan
  107. @pytest.mark.asyncio
  108. async def test_default_policy_and_validation_plan_roundtrip():
  109. plan = DefaultValidationPolicy("careful-validator").plan(make_context())
  110. assert plan.mode == ValidationMode.AGENT
  111. assert plan.validator_preset == "careful-validator"
  112. assert ValidationPlan.from_dict(plan.to_dict()) == plan
  113. deterministic = ValidationPlan(
  114. mode=ValidationMode.DETERMINISTIC,
  115. validator_preset=None,
  116. rule_ids=["schema", "checksum"],
  117. max_evidence_queries=2,
  118. )
  119. assert ValidationPlan.from_dict(deterministic.to_dict()) == deterministic
  120. assert ValidationPlan.from_dict({"mode": "deterministic", "rule_ids": []}).validator_preset is None
  121. with pytest.raises(ValueError, match="cannot contain"):
  122. ValidationPlan(rule_ids=["unexpected"])
  123. with pytest.raises(ValueError, match="cannot be blank"):
  124. ValidationPlan(validator_preset=" ")
  125. for kwargs in (
  126. {"max_evidence_queries": True},
  127. {"max_evidence_queries": 1.5},
  128. {"max_evidence_items_per_query": False},
  129. {"evidence_timeout_seconds": True},
  130. ):
  131. with pytest.raises(ValueError):
  132. ValidationPlan(**kwargs)
  133. with pytest.raises(ValueError, match="cannot select"):
  134. ValidationPlan(mode=ValidationMode.DETERMINISTIC)
  135. with pytest.raises(ValueError, match="unique"):
  136. ValidationPlan(
  137. mode=ValidationMode.DETERMINISTIC,
  138. validator_preset=None,
  139. rule_ids=["same", "same"],
  140. )
  141. @pytest.mark.asyncio
  142. async def test_deterministic_rules_aggregate_failures_and_errors():
  143. context = make_context()
  144. validator = RuleBasedDeterministicValidator(
  145. [
  146. StaticRule("pass", ValidationVerdict.PASSED),
  147. AsyncRule("fail", ValidationVerdict.FAILED),
  148. ErrorRule(),
  149. ]
  150. )
  151. result = await validator.validate(
  152. context,
  153. ValidationPlan(
  154. mode=ValidationMode.DETERMINISTIC,
  155. validator_preset=None,
  156. rule_ids=["pass", "missing", "error", "fail"],
  157. ),
  158. )
  159. assert result.verdict == ValidationVerdict.FAILED
  160. assert len(result.errors) == 2
  161. assert result.to_dict()["verdict"] == "failed"
  162. empty = await validator.validate(
  163. context,
  164. ValidationPlan(mode=ValidationMode.DETERMINISTIC, validator_preset=None),
  165. )
  166. assert empty.verdict == ValidationVerdict.INCONCLUSIVE
  167. with pytest.raises(ValueError, match="deterministic plan"):
  168. await validator.validate(context, ValidationPlan())
  169. with pytest.raises(ValueError, match="Duplicate"):
  170. RuleBasedDeterministicValidator(
  171. [
  172. StaticRule("same", ValidationVerdict.PASSED),
  173. StaticRule("same", ValidationVerdict.PASSED),
  174. ]
  175. )
  176. @pytest.mark.asyncio
  177. async def test_coordinator_freezes_default_agent_plan(tmp_path):
  178. executor = FakeExecutor([ValidationVerdict.PASSED])
  179. coordinator, store, _ = await make_coordinator(tmp_path, executor)
  180. task_id = await create_task(coordinator, "default agent policy")
  181. result = (await coordinator.dispatch_tasks("root", [task_id]))[0]
  182. report = (await store.load("root")).validations[result.validation_id]
  183. assert executor.validator_calls == 1
  184. assert report.validation_plan.mode == ValidationMode.AGENT
  185. assert report.validation_plan.validator_preset == "validator"
  186. assert ValidationPlan.from_dict(report.validation_plan) == report.validation_plan
  187. @pytest.mark.asyncio
  188. async def test_same_frozen_snapshot_reuses_one_semantic_validation(tmp_path):
  189. class SameArtifactExecutor(FakeExecutor):
  190. async def run_worker(self, context):
  191. self.worker_calls += 1
  192. await self.coordinator.submit_attempt(
  193. {
  194. **context,
  195. "role": AgentRole.WORKER.value,
  196. "trace_id": context["worker_trace_id"],
  197. "tool_call_id": f"submit-{context['attempt_id']}",
  198. },
  199. AttemptSubmission(
  200. summary="same content",
  201. artifact_refs=[ArtifactRef(uri="memory://same", version="1")],
  202. ),
  203. )
  204. return WorkerRunResult(context["worker_trace_id"], "completed")
  205. executor = SameArtifactExecutor([ValidationVerdict.FAILED])
  206. coordinator, store, _ = await make_coordinator(tmp_path, executor)
  207. task_id = await create_task(coordinator, "semantic validation cache")
  208. first = (await coordinator.dispatch_tasks("root", [task_id]))[0]
  209. await coordinator.decide_task(
  210. "root",
  211. task_id,
  212. first.validation_id,
  213. DecisionAction.RETRY,
  214. {"reason": "retry unchanged content"},
  215. "retry-same-snapshot",
  216. )
  217. second = (await coordinator.dispatch_tasks("root", [task_id]))[0]
  218. report = (await store.load("root")).validations[second.validation_id]
  219. assert executor.validator_calls == 1
  220. assert report.verdict is ValidationVerdict.FAILED
  221. assert report.summary.startswith("semantic_validation_reused_from=")
  222. assert report.execution_stats.total_tokens == 0
  223. @pytest.mark.asyncio
  224. @pytest.mark.parametrize("verdict", [ValidationVerdict.PASSED, ValidationVerdict.FAILED])
  225. async def test_coordinator_executes_deterministic_plan_without_agent_validator(tmp_path, verdict):
  226. executor = FakeExecutor([])
  227. coordinator, store, _ = await make_coordinator(tmp_path, executor)
  228. coordinator.validation_policy = DeterministicPolicy()
  229. coordinator.deterministic_validator = RuleBasedDeterministicValidator([StaticRule("c1", verdict)])
  230. task_id = await create_task(coordinator, "deterministic")
  231. result = (await coordinator.dispatch_tasks("root", [task_id]))[0]
  232. report = (await store.load("root")).validations[result.validation_id]
  233. assert executor.worker_calls == 1
  234. assert executor.validator_calls == 0
  235. assert report.validation_plan.mode == ValidationMode.DETERMINISTIC
  236. assert report.verdict == verdict
  237. assert report.criterion_results[0].criterion_id == "c1"
  238. @pytest.mark.asyncio
  239. @pytest.mark.parametrize(
  240. ("preflight_verdict", "validator_calls", "final_verdict"),
  241. [
  242. (ValidationVerdict.FAILED, 0, ValidationVerdict.FAILED),
  243. (ValidationVerdict.PASSED, 1, ValidationVerdict.PASSED),
  244. ],
  245. )
  246. async def test_agent_validation_skips_model_on_hard_preflight_failure(
  247. tmp_path, preflight_verdict, validator_calls, final_verdict
  248. ):
  249. executor = FakeExecutor([ValidationVerdict.PASSED])
  250. coordinator, store, _ = await make_coordinator(tmp_path, executor)
  251. coordinator.validation_policy = FixedPolicy(ValidationPlan(preflight_rule_ids=("preflight",)))
  252. coordinator.deterministic_validator = RuleBasedDeterministicValidator([StaticRule("preflight", preflight_verdict)])
  253. task_id = await create_task(coordinator, "hybrid preflight")
  254. result = (await coordinator.dispatch_tasks("root", [task_id]))[0]
  255. report = (await store.load("root")).validations[result.validation_id]
  256. assert executor.validator_calls == validator_calls
  257. assert report.verdict == final_verdict
  258. if preflight_verdict is ValidationVerdict.FAILED:
  259. assert "Deterministic preflight rejected" in report.summary
  260. assert report.execution_stats.total_tokens == 0
  261. def test_wire_orchestration_preserves_legacy_positional_defaults(tmp_path):
  262. runner = SimpleNamespace(trace_store=None, task_coordinator=None)
  263. config = OrchestrationConfig(max_parallel_tasks=2)
  264. event_sink = object()
  265. coordinator = wire_orchestration(
  266. runner,
  267. FileSystemTaskStore(str(tmp_path)),
  268. FileSystemArtifactStore(str(tmp_path)),
  269. config,
  270. event_sink,
  271. )
  272. assert runner.task_coordinator is coordinator
  273. assert coordinator.config is config
  274. assert coordinator.event_sink is event_sink
  275. assert isinstance(coordinator.validation_policy, DefaultValidationPolicy)
  276. assert coordinator.deterministic_validator is None
  277. assert coordinator.evidence_provider is None
  278. @pytest.mark.asyncio
  279. async def test_wire_orchestration_applies_custom_validation_dependencies(tmp_path):
  280. runner = SimpleNamespace(trace_store=None, task_coordinator=None)
  281. policy = DeterministicPolicy()
  282. validator = RuleBasedDeterministicValidator([StaticRule("c1", ValidationVerdict.PASSED)])
  283. provider = EvidenceProvider()
  284. coordinator = wire_orchestration(
  285. runner,
  286. FileSystemTaskStore(str(tmp_path)),
  287. FileSystemArtifactStore(str(tmp_path)),
  288. validation_policy=policy,
  289. deterministic_validator=validator,
  290. evidence_provider=provider,
  291. )
  292. executor = FakeExecutor([])
  293. executor.coordinator = coordinator
  294. coordinator.set_executor(executor)
  295. await coordinator.ensure_ledger("root", ROOT_TASK_SPEC)
  296. task_id = await create_task(coordinator, "wired deterministic validation")
  297. result = (await coordinator.dispatch_tasks("root", [task_id]))[0]
  298. report = (await coordinator.task_store.load("root")).validations[result.validation_id]
  299. assert runner.task_coordinator is coordinator
  300. assert coordinator.validation_policy is policy
  301. assert coordinator.deterministic_validator is validator
  302. assert coordinator.evidence_provider is provider
  303. assert executor.validator_calls == 0
  304. assert report.validation_plan.mode == ValidationMode.DETERMINISTIC
  305. assert report.verdict == ValidationVerdict.PASSED
  306. @pytest.mark.asyncio
  307. @pytest.mark.parametrize("rule_ids", [[], ["unknown"]])
  308. async def test_deterministic_plan_must_cover_hard_criteria_without_unknowns(tmp_path, rule_ids):
  309. executor = FakeExecutor([])
  310. coordinator, _, _ = await make_coordinator(tmp_path, executor)
  311. coordinator.validation_policy = FixedPolicy(
  312. ValidationPlan(
  313. mode=ValidationMode.DETERMINISTIC,
  314. validator_preset=None,
  315. rule_ids=rule_ids,
  316. )
  317. )
  318. coordinator.deterministic_validator = RuleBasedDeterministicValidator([])
  319. task_id = await create_task(coordinator, "invalid deterministic mapping")
  320. result = (await coordinator.dispatch_tasks("root", [task_id]))[0]
  321. assert "criteria" in (result.error or "")
  322. assert executor.validator_calls == 0
  323. @pytest.mark.asyncio
  324. async def test_coordinator_rejects_custom_policy_with_blank_agent_preset(tmp_path):
  325. executor = FakeExecutor([])
  326. coordinator, _, _ = await make_coordinator(tmp_path, executor)
  327. coordinator.validation_policy = InvalidBlankPolicy()
  328. task_id = await create_task(coordinator, "invalid preset")
  329. result = (await coordinator.dispatch_tasks("root", [task_id]))[0]
  330. assert "cannot be blank" in (result.error or "")
  331. assert executor.validator_calls == 0
  332. class EvidencePolicy:
  333. def __init__(self, max_queries=1, timeout=0.1):
  334. self.max_queries = max_queries
  335. self.timeout = timeout
  336. def plan(self, context):
  337. return ValidationPlan(
  338. max_evidence_queries=self.max_queries,
  339. max_evidence_items_per_query=1,
  340. evidence_timeout_seconds=self.timeout,
  341. )
  342. class EvidenceProvider:
  343. def __init__(self, *, delay=0, error=None, item_count=1, items=None):
  344. self.delay = delay
  345. self.error = error
  346. self.item_count = item_count
  347. self.items = items
  348. self.requests = []
  349. async def query(self, request):
  350. self.requests.append(request)
  351. if self.delay:
  352. await asyncio.sleep(self.delay)
  353. if self.error:
  354. raise self.error
  355. return EvidenceProviderResult(
  356. items=(self.items if self.items is not None else [{"index": index} for index in range(self.item_count)]),
  357. evidence_refs=[ArtifactRef(uri="memory://evidence", version="1")],
  358. )
  359. class BlockingValidatorExecutor:
  360. def __init__(self):
  361. self.coordinator = None
  362. self.context_ready = asyncio.Event()
  363. self.context = None
  364. async def run_worker(self, context):
  365. await self.coordinator.submit_attempt(
  366. {
  367. **context,
  368. "role": AgentRole.WORKER.value,
  369. "trace_id": context["worker_trace_id"],
  370. "tool_call_id": f"submit-{context['attempt_id']}",
  371. },
  372. AttemptSubmission("done", [ArtifactRef(uri="memory://work", version="1")]),
  373. )
  374. return WorkerRunResult(context["worker_trace_id"], "completed")
  375. async def run_validator(self, context):
  376. self.context = context
  377. self.context_ready.set()
  378. await asyncio.Event().wait()
  379. async def stop(self, trace_id):
  380. return True
  381. async def running_evidence_validation(tmp_path, provider, *, max_queries=1, timeout=0.1):
  382. executor = BlockingValidatorExecutor()
  383. coordinator, store, _ = await make_coordinator(tmp_path, executor)
  384. coordinator.config = OrchestrationConfig(stop_grace_seconds=0)
  385. coordinator.validation_policy = EvidencePolicy(max_queries, timeout)
  386. coordinator.evidence_provider = provider
  387. task_id = await create_task(coordinator, "evidence")
  388. operation = await coordinator.start_operation("root", "dispatch", task_ids=[task_id])
  389. await asyncio.wait_for(executor.context_ready.wait(), timeout=1)
  390. context = executor.context
  391. actor = {
  392. **context,
  393. "role": AgentRole.VALIDATOR.value,
  394. "trace_id": context["validator_trace_id"],
  395. "tool_call_id": "evidence-1",
  396. }
  397. request = EvidenceQuery(
  398. root_trace_id="root",
  399. task_id=task_id,
  400. attempt_id=context["attempt_id"],
  401. snapshot_id=context["snapshot_id"],
  402. validation_id=context["validation_id"],
  403. query="facts",
  404. limit=100,
  405. )
  406. return coordinator, store, operation, actor, request
  407. @pytest.mark.asyncio
  408. async def test_wire_orchestration_applies_custom_evidence_provider(tmp_path):
  409. runner = SimpleNamespace(trace_store=None, task_coordinator=None)
  410. provider = EvidenceProvider()
  411. policy = EvidencePolicy(max_queries=1, timeout=0.1)
  412. coordinator = wire_orchestration(
  413. runner,
  414. FileSystemTaskStore(str(tmp_path)),
  415. FileSystemArtifactStore(str(tmp_path)),
  416. OrchestrationConfig(stop_grace_seconds=0),
  417. validation_policy=policy,
  418. evidence_provider=provider,
  419. )
  420. executor = BlockingValidatorExecutor()
  421. executor.coordinator = coordinator
  422. coordinator.set_executor(executor)
  423. await coordinator.ensure_ledger("root", ROOT_TASK_SPEC)
  424. task_id = await create_task(coordinator, "wired evidence provider")
  425. operation = await coordinator.start_operation(
  426. "root",
  427. "dispatch",
  428. task_ids=[task_id],
  429. )
  430. try:
  431. await asyncio.wait_for(executor.context_ready.wait(), timeout=1)
  432. context = executor.context
  433. actor = {
  434. **context,
  435. "role": AgentRole.VALIDATOR.value,
  436. "trace_id": context["validator_trace_id"],
  437. "tool_call_id": "wired-evidence-1",
  438. }
  439. request = EvidenceQuery(
  440. root_trace_id="root",
  441. task_id=task_id,
  442. attempt_id=context["attempt_id"],
  443. snapshot_id=context["snapshot_id"],
  444. validation_id=context["validation_id"],
  445. query="wired facts",
  446. limit=100,
  447. )
  448. response = await coordinator.query_evidence(actor, request)
  449. report = (await coordinator.task_store.load("root")).validations[request.validation_id]
  450. assert coordinator.validation_policy is policy
  451. assert coordinator.evidence_provider is provider
  452. assert len(provider.requests) == 1
  453. assert provider.requests[0].limit == 1
  454. assert response.items == [{"index": 0}]
  455. assert report.evidence_queries_used == 1
  456. finally:
  457. await coordinator.stop_operation("root", operation.operation_id)
  458. @pytest.mark.asyncio
  459. async def test_coordinator_evidence_ownership_and_persistent_budget(tmp_path):
  460. provider = EvidenceProvider()
  461. coordinator, store, operation, actor, request = await running_evidence_validation(tmp_path, provider)
  462. response = await coordinator.query_evidence(actor, request)
  463. assert response.queries_used == 1
  464. assert response.queries_remaining == 0
  465. assert provider.requests[0].limit == 1
  466. report = (await store.load("root")).validations[request.validation_id]
  467. assert report.evidence_queries_used == 1
  468. assert next(iter(report.evidence_query_results.values()))["status"] == "completed"
  469. replay = await coordinator.query_evidence(actor, request)
  470. assert replay.queries_used == 1
  471. assert len(provider.requests) == 1
  472. with pytest.raises(EvidenceBudgetExceeded):
  473. await coordinator.query_evidence(
  474. {**actor, "tool_call_id": "evidence-2"},
  475. EvidenceQuery(**{**request.__dict__, "query": "more"}),
  476. )
  477. with pytest.raises(EvidenceOwnershipError):
  478. await coordinator.query_evidence(
  479. {**actor, "tool_call_id": "foreign"},
  480. EvidenceQuery(**{**request.__dict__, "snapshot_id": "foreign"}),
  481. )
  482. with pytest.raises(EvidenceOwnershipError):
  483. await coordinator.query_evidence(
  484. {**actor, "trace_id": "foreign", "tool_call_id": "foreign-trace"},
  485. request,
  486. )
  487. await coordinator.stop_operation("root", operation.operation_id)
  488. @pytest.mark.asyncio
  489. @pytest.mark.parametrize(
  490. ("provider", "message"),
  491. [
  492. (EvidenceProvider(delay=0.05), "timed out"),
  493. (EvidenceProvider(error=RuntimeError("offline")), "offline"),
  494. (EvidenceProvider(item_count=2), "more than"),
  495. (EvidenceProvider(items=[{"bad": object()}]), "non-JSON"),
  496. ],
  497. )
  498. async def test_evidence_provider_failures_are_validation_errors(tmp_path, provider, message):
  499. timeout = 0.001 if provider.delay else 0.1
  500. coordinator, store, operation, actor, request = await running_evidence_validation(
  501. tmp_path,
  502. provider,
  503. timeout=timeout,
  504. )
  505. with pytest.raises(EvidenceProviderError, match=message):
  506. await coordinator.query_evidence(actor, request)
  507. assert (await store.load("root")).validations[request.validation_id].evidence_queries_used == 1
  508. with pytest.raises(EvidenceProviderError, match=message):
  509. await coordinator.query_evidence(actor, request)
  510. assert len(provider.requests) == 1
  511. await coordinator.stop_operation("root", operation.operation_id)
  512. @pytest.mark.asyncio
  513. async def test_concurrent_evidence_replay_calls_provider_once(tmp_path):
  514. provider = EvidenceProvider(delay=0.02)
  515. coordinator, _, operation, actor, request = await running_evidence_validation(
  516. tmp_path,
  517. provider,
  518. )
  519. responses = await asyncio.gather(
  520. coordinator.query_evidence(actor, request),
  521. coordinator.query_evidence(actor, request),
  522. )
  523. assert [item.queries_used for item in responses] == [1, 1]
  524. assert len(provider.requests) == 1
  525. await coordinator.stop_operation("root", operation.operation_id)
  526. def test_evidence_query_rejects_empty_scope_and_limit():
  527. values = {
  528. "root_trace_id": "root",
  529. "task_id": "task",
  530. "attempt_id": "attempt",
  531. "snapshot_id": "snapshot",
  532. "validation_id": "validation",
  533. "query": "facts",
  534. }
  535. with pytest.raises(ValueError, match="query"):
  536. EvidenceQuery(**{**values, "query": " "})
  537. with pytest.raises(ValueError, match="limit"):
  538. EvidenceQuery(**values, limit=0)
  539. with pytest.raises(ValueError, match="limit"):
  540. EvidenceQuery(**values, limit=True)
  541. with pytest.raises(ValueError, match="root_trace_id"):
  542. EvidenceQuery(**{**values, "root_trace_id": 123})