test_phase_two_agent_tools.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468
  1. from __future__ import annotations
  2. import base64
  3. import hashlib
  4. from collections.abc import Mapping
  5. from types import SimpleNamespace
  6. from typing import Any
  7. import pytest
  8. from agent import ToolRegistry
  9. from agent.orchestration import (
  10. ArtifactRef,
  11. ValidationEvidenceGrant,
  12. ValidationVerdict,
  13. )
  14. from agent.orchestration.evidence import EvidenceResponse
  15. from agent.orchestration.models import ValidationEvidenceSnapshot
  16. from agent.orchestration.validation_evidence import (
  17. evidence_handle,
  18. extend_snapshot,
  19. start_read_session,
  20. )
  21. from script_build_host.domain.errors import (
  22. ProtocolViolation,
  23. ValidationEvidenceUnauthorized,
  24. )
  25. from script_build_host.tools.contracts import AttemptManifest
  26. from script_build_host.tools.gateway import LegacyScriptToolGateway
  27. from script_build_host.tools.registry import register_script_tools
  28. class _CandidateTools:
  29. def __init__(self, artifact_ref: ArtifactRef) -> None:
  30. self.artifact_ref = artifact_ref
  31. self.last_payload: dict[str, Any] | None = None
  32. self.last_context: dict[str, Any] | None = None
  33. self.images: list[dict[str, Any]] = []
  34. async def resolve_attempt_manifest(self, *, context: Mapping[str, Any]) -> AttemptManifest:
  35. self.last_context = dict(context)
  36. return AttemptManifest(
  37. artifact_ref=self.artifact_ref,
  38. scope_ref="script-build://scopes/body",
  39. )
  40. async def save_script_paragraphs(
  41. self,
  42. *,
  43. paragraphs: list[Mapping[str, Any]],
  44. expected_state_revision: str,
  45. context: Mapping[str, Any],
  46. ) -> dict[str, Any]:
  47. self.last_payload = {
  48. "paragraphs": list(paragraphs),
  49. "expected_state_revision": expected_state_revision,
  50. }
  51. self.last_context = dict(context)
  52. return {"created": 1}
  53. async def view_frozen_images(
  54. self,
  55. *,
  56. image_handles: list[str],
  57. context: Mapping[str, Any],
  58. ) -> list[dict[str, Any]]:
  59. self.last_payload = {"image_handles": image_handles}
  60. self.last_context = dict(context)
  61. return self.images
  62. class _TaskStore:
  63. def __init__(self, task: Any, ref: ArtifactRef) -> None:
  64. self.task = task
  65. snapshot = extend_snapshot(
  66. ValidationEvidenceSnapshot(),
  67. (ValidationEvidenceGrant(ref, evidence_handle(ref)),),
  68. )
  69. self.validation = SimpleNamespace(
  70. evidence_snapshot=snapshot,
  71. read_session=start_read_session(snapshot, ()),
  72. )
  73. async def load(self, _root_trace_id: str) -> Any:
  74. return SimpleNamespace(
  75. tasks={"task-1": self.task},
  76. attempts={"attempt-a": SimpleNamespace(attempt_id="attempt-a", task_id="task-1")},
  77. validations={"validation-a": self.validation},
  78. context_documents={},
  79. )
  80. class _ArtifactStore:
  81. def __init__(self, ref: ArtifactRef) -> None:
  82. self.ref = ref
  83. async def get(self, _root_trace_id: str, _snapshot_id: str) -> Any:
  84. return SimpleNamespace(attempt_id="attempt-a", artifact_refs=[self.ref], evidence_refs=[])
  85. class _Bindings:
  86. async def get_by_root(self, _root_trace_id: str) -> Any:
  87. return SimpleNamespace(script_build_id=9, input_snapshot_id=3)
  88. class _BudgetGuard:
  89. def __init__(self, error: Exception | None = None) -> None:
  90. self.error = error
  91. self.calls: list[tuple[str, str, str]] = []
  92. async def ensure_tool_budget(self, *, root_trace_id: str, task_id: str, entry: str) -> None:
  93. self.calls.append((root_trace_id, task_id, entry))
  94. if self.error is not None:
  95. raise self.error
  96. class _BusinessArtifacts:
  97. def __init__(self, ref: ArtifactRef) -> None:
  98. self.ref = ref
  99. async def read_by_ref(
  100. self,
  101. ref: ArtifactRef,
  102. *,
  103. script_build_id: int,
  104. task_id: str,
  105. attempt_id: str,
  106. ) -> Any:
  107. assert (ref, script_build_id, task_id, attempt_id) == (
  108. self.ref,
  109. 9,
  110. "task-1",
  111. "attempt-a",
  112. )
  113. return SimpleNamespace(
  114. artifact_type=SimpleNamespace(value=ref.kind),
  115. canonical_sha256=ref.digest,
  116. state=SimpleNamespace(value="frozen"),
  117. )
  118. class _Coordinator:
  119. def __init__(self, ref: ArtifactRef) -> None:
  120. criterion = SimpleNamespace(criterion_id="criterion-a", hard=True)
  121. task = SimpleNamespace(
  122. task_id="task-1",
  123. validation_ids=(),
  124. current_spec=SimpleNamespace(
  125. acceptance_criteria=(criterion,),
  126. context_refs=("script-build://task-kinds/paragraph",),
  127. ),
  128. )
  129. self.task_store = _TaskStore(task, ref)
  130. self.artifact_store = _ArtifactStore(ref)
  131. self.attempt_submission: tuple[dict[str, Any], Any] | None = None
  132. self.validation_submission: tuple[dict[str, Any], tuple[Any, ...]] | None = None
  133. async def submit_attempt(self, context: Mapping[str, Any], submission: Any) -> dict[str, Any]:
  134. self.attempt_submission = (dict(context), submission)
  135. return {
  136. "attempt_id": context["attempt_id"],
  137. "snapshot_id": "snapshot-a",
  138. "status": "awaiting_validation",
  139. }
  140. async def submit_validation(self, context: Mapping[str, Any], *values: Any) -> dict[str, Any]:
  141. self.validation_submission = (dict(context), values)
  142. return {
  143. "validation_id": context["validation_id"],
  144. "verdict": values[0].value,
  145. "status": "awaiting_decision",
  146. }
  147. def _gateway(ref: ArtifactRef) -> tuple[LegacyScriptToolGateway, _Coordinator, _CandidateTools]:
  148. coordinator = _Coordinator(ref)
  149. candidates = _CandidateTools(ref)
  150. gateway = LegacyScriptToolGateway(
  151. bindings=_Bindings(), # type: ignore[arg-type]
  152. snapshots=SimpleNamespace(),
  153. artifacts=_BusinessArtifacts(ref), # type: ignore[arg-type]
  154. coordinator=coordinator,
  155. candidate_tools=candidates, # type: ignore[arg-type]
  156. )
  157. return gateway, coordinator, candidates
  158. @pytest.mark.asyncio
  159. async def test_validation_query_returns_only_bounded_evidence_handles() -> None:
  160. ref = ArtifactRef(
  161. uri="script-build://artifact-versions/9",
  162. kind="paragraph",
  163. version="9",
  164. digest="sha256:" + "c" * 64,
  165. )
  166. gateway, coordinator, _candidates = _gateway(ref)
  167. async def query_evidence(_context: Mapping[str, Any], _request: Any) -> EvidenceResponse:
  168. return EvidenceResponse(items=[], evidence_refs=[], queries_used=1, queries_remaining=4)
  169. coordinator.query_evidence = query_evidence # type: ignore[attr-defined]
  170. class Artifacts:
  171. async def read_by_ref(self, _ref: ArtifactRef, **_owners: Any) -> Any:
  172. return SimpleNamespace(
  173. artifact=SimpleNamespace(
  174. content_payload=lambda: {
  175. "schema_version": "paragraph-artifact/v1",
  176. "paragraph_key": "opening",
  177. "text": "grounded opening",
  178. }
  179. )
  180. )
  181. gateway.artifacts = Artifacts() # type: ignore[assignment]
  182. result = await gateway.query_validation_evidence(
  183. "current candidate",
  184. 5,
  185. {
  186. "root_trace_id": "root-a",
  187. "task_id": "task-1",
  188. "attempt_id": "attempt-a",
  189. "snapshot_id": "snapshot-a",
  190. "validation_id": "validation-a",
  191. },
  192. )
  193. assert result == {
  194. "items": [],
  195. "queries_used": 1,
  196. "queries_remaining": 4,
  197. "evidence_handles": [],
  198. }
  199. @pytest.mark.asyncio
  200. async def test_attempt_submission_is_derived_from_protected_attempt_artifact() -> None:
  201. ref = ArtifactRef(
  202. uri="script-build://artifact-versions/7",
  203. kind="paragraph",
  204. version="7",
  205. digest="sha256:" + "a" * 64,
  206. )
  207. gateway, coordinator, candidates = _gateway(ref)
  208. budget = _BudgetGuard()
  209. gateway.budget_guard = budget
  210. context = {
  211. "root_trace_id": "root-a",
  212. "task_id": "task-1",
  213. "attempt_id": "attempt-a",
  214. "spec_version": 1,
  215. }
  216. await gateway.submit_current_attempt(context)
  217. assert coordinator.attempt_submission is not None
  218. submitted_context, submission = coordinator.attempt_submission
  219. assert submitted_context == context
  220. assert submission.artifact_refs == [ref]
  221. assert submission.evidence_refs == []
  222. assert "paragraph" in submission.summary
  223. assert "sha256:" in submission.summary
  224. assert candidates.last_context == context
  225. assert budget.calls == [("root-a", "task-1", "submit")]
  226. @pytest.mark.asyncio
  227. async def test_candidate_write_receives_identity_only_from_protected_context() -> None:
  228. ref = ArtifactRef(
  229. uri="script-build://artifact-versions/7",
  230. kind="paragraph",
  231. version="7",
  232. digest="sha256:" + "a" * 64,
  233. )
  234. gateway, _, candidates = _gateway(ref)
  235. context = {
  236. "root_trace_id": "root-a",
  237. "task_id": "task-1",
  238. "attempt_id": "attempt-a",
  239. "spec_version": 1,
  240. }
  241. await gateway.candidate_command(
  242. "save_script_paragraphs",
  243. {
  244. "paragraphs": [{"client_key": "opening", "name": "opening", "content_range": {}}],
  245. "expected_state_revision": "revision-a",
  246. },
  247. context,
  248. )
  249. assert candidates.last_context == context
  250. assert candidates.last_payload == {
  251. "paragraphs": [{"client_key": "opening", "name": "opening", "content_range": {}}],
  252. "expected_state_revision": "revision-a",
  253. }
  254. @pytest.mark.asyncio
  255. async def test_structured_validation_keeps_excerpt_out_of_framework_summary() -> None:
  256. ref = ArtifactRef(
  257. uri="script-build://artifact-versions/7",
  258. kind="paragraph",
  259. version="7",
  260. digest="sha256:" + "a" * 64,
  261. )
  262. gateway, coordinator, _ = _gateway(ref)
  263. context = {
  264. "root_trace_id": "root-a",
  265. "task_id": "task-1",
  266. "attempt_id": "attempt-a",
  267. "snapshot_id": "snapshot-a",
  268. "validation_id": "validation-a",
  269. }
  270. excerpt = "this exact candidate passage must not enter the bounded summary"
  271. await gateway.submit_structured_validation(
  272. verdict="failed",
  273. criterion_results=[
  274. {
  275. "criterion_id": "criterion-a",
  276. "verdict": "failed",
  277. "reason": "not realized",
  278. "evidence_handle_ids": [],
  279. }
  280. ],
  281. defects=[
  282. {
  283. "defect_code": "REALIZATION_PLACEHOLDER",
  284. "criterion_id": "criterion-a",
  285. "scope_selector": {"anchor": "mission", "path": ["body", "end"]},
  286. "observed_excerpt": excerpt,
  287. "evidence_handle_ids": [evidence_handle(ref)],
  288. "severity": "hard",
  289. "invalidated_inputs": ["decision-a"],
  290. "recommended_action_class": "split",
  291. }
  292. ],
  293. recommendation="split",
  294. context=context,
  295. )
  296. assert coordinator.validation_submission is not None
  297. _, values = coordinator.validation_submission
  298. verdict, results, summary, evidence_refs, unverified, risks, recommendation = values
  299. assert verdict is ValidationVerdict.FAILED
  300. assert results[0].reason.endswith("defect_codes=REALIZATION_PLACEHOLDER")
  301. assert excerpt not in summary
  302. assert len(summary) <= 500
  303. assert evidence_refs == (ref,)
  304. assert unverified == ()
  305. assert risks == ("REALIZATION_PLACEHOLDER",)
  306. assert recommendation == "split"
  307. @pytest.mark.asyncio
  308. async def test_passed_validation_rejects_blocking_defect_and_unfrozen_evidence() -> None:
  309. ref = ArtifactRef(
  310. uri="script-build://artifact-versions/7",
  311. kind="paragraph",
  312. version="7",
  313. digest="sha256:" + "a" * 64,
  314. )
  315. gateway, _, _ = _gateway(ref)
  316. context = {
  317. "root_trace_id": "root-a",
  318. "task_id": "task-1",
  319. "attempt_id": "attempt-a",
  320. "snapshot_id": "snapshot-a",
  321. "validation_id": "validation-a",
  322. }
  323. defect: dict[str, Any] = {
  324. "defect_code": "WRITE_SCOPE_CONFLICT",
  325. "criterion_id": "criterion-a",
  326. "scope_selector": {"anchor": "mission", "path": ["body"]},
  327. "observed_excerpt": "overlapping body",
  328. "evidence_handle_ids": [],
  329. "severity": "hard",
  330. "invalidated_inputs": [],
  331. "recommended_action_class": "replace",
  332. }
  333. with pytest.raises(ProtocolViolation, match="cannot contain hard"):
  334. await gateway.submit_structured_validation(
  335. verdict="passed",
  336. criterion_results=[
  337. {
  338. "criterion_id": "criterion-a",
  339. "verdict": "passed",
  340. "reason": "ok",
  341. "evidence_handle_ids": [evidence_handle(ref)],
  342. }
  343. ],
  344. defects=[defect],
  345. recommendation="replace",
  346. context=context,
  347. )
  348. defect["severity"] = "warning"
  349. defect["evidence_handle_ids"] = ["src_" + "f" * 20]
  350. with pytest.raises(
  351. ValidationEvidenceUnauthorized, match="unknown evidence handle"
  352. ):
  353. await gateway.submit_structured_validation(
  354. verdict="passed",
  355. criterion_results=[
  356. {
  357. "criterion_id": "criterion-a",
  358. "verdict": "passed",
  359. "reason": "ok",
  360. "evidence_handle_ids": [evidence_handle(ref)],
  361. }
  362. ],
  363. defects=[defect],
  364. recommendation="accept",
  365. context=context,
  366. )
  367. @pytest.mark.asyncio
  368. async def test_view_frozen_images_returns_multimodal_without_url_or_data_in_text() -> None:
  369. ref = ArtifactRef(
  370. uri="script-build://artifact-versions/7",
  371. kind="paragraph",
  372. version="7",
  373. digest="sha256:" + "a" * 64,
  374. )
  375. gateway, _, candidates = _gateway(ref)
  376. encoded = base64.b64encode(b"safe-image-bytes").decode("ascii")
  377. image_handle = "image_" + "a" * 20
  378. candidates.images = [
  379. {
  380. "type": "base64",
  381. "media_type": "image/png",
  382. "data": encoded,
  383. "image_handle": image_handle,
  384. "sha256": "sha256:" + hashlib.sha256(b"safe-image-bytes").hexdigest(),
  385. }
  386. ]
  387. registry = ToolRegistry()
  388. register_script_tools(registry, gateway)
  389. result = await registry.execute(
  390. "view_frozen_images",
  391. {"image_handles": [image_handle]},
  392. context={"root_trace_id": "root-a", "task_id": "task-1"},
  393. )
  394. assert isinstance(result, dict)
  395. assert result["images"] == [{"type": "base64", "media_type": "image/png", "data": encoded}]
  396. assert encoded not in result["text"]
  397. assert "http" not in result["text"]
  398. candidates.images = [
  399. {
  400. "type": "base64",
  401. "media_type": "image/png",
  402. "data": encoded,
  403. "url": "https://forbidden.invalid/image.png",
  404. }
  405. ]
  406. rejected = await registry.execute(
  407. "view_frozen_images",
  408. {"image_handles": [image_handle]},
  409. context={"root_trace_id": "root-a", "task_id": "task-1"},
  410. )
  411. assert isinstance(rejected, dict)
  412. assert rejected["_control"]["failure"] == {
  413. "code": "INVALID_TOOL_ARGUMENT",
  414. "message": "frozen images must not contain network URLs",
  415. "disposition": "retry_call",
  416. "source_tool": "view_frozen_images",
  417. "details": {"task_id": "task-1"},
  418. }
  419. assert "must not contain network URLs" in rejected["text"]