test_phase_two_agent_tools.py 18 KB

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