test_agent_surface.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  1. from __future__ import annotations
  2. from dataclasses import replace
  3. from datetime import UTC, datetime
  4. from hashlib import sha256
  5. from types import SimpleNamespace
  6. import pytest
  7. from agent import AgentRole, ToolRegistry, get_preset
  8. from script_build_host.agents.model_resolver import (
  9. ScriptRoleRunConfigResolver,
  10. SnapshotModelManifestSource,
  11. )
  12. from script_build_host.agents.presets import register_script_presets
  13. from script_build_host.agents.prompt_resolver import SnapshotRoleSystemPromptResolver
  14. from script_build_host.agents.prompts import (
  15. phase_three_prompt_manifest,
  16. script_build_prompt_manifest,
  17. )
  18. from script_build_host.domain.canonical_json import canonical_sha256
  19. from script_build_host.domain.errors import ProtocolViolation
  20. from script_build_host.domain.input_snapshot import ScriptBuildInputSnapshotV1
  21. from script_build_host.tools.gateway import LegacyScriptToolGateway
  22. from script_build_host.tools.registry import register_script_tools
  23. def test_script_presets_are_exact_and_never_expose_legacy_control_tools() -> None:
  24. register_script_presets()
  25. expected = {
  26. "script_planner",
  27. "script_direction_worker",
  28. "script_pattern_retrieval_worker",
  29. "script_decode_retrieval_worker",
  30. "script_external_retrieval_worker",
  31. "script_knowledge_retrieval_worker",
  32. "script_retrieval_validator",
  33. "script_candidate_validator",
  34. "script_structure_worker",
  35. "script_paragraph_worker",
  36. "script_element_set_worker",
  37. "script_candidate_compare_worker",
  38. "script_compose_worker",
  39. "script_candidate_portfolio_worker",
  40. }
  41. forbidden = {
  42. "agent",
  43. "evaluate",
  44. "bash_command",
  45. "write_file",
  46. "dispatch_tasks",
  47. "implement_paths",
  48. "begin_round",
  49. "multipath",
  50. }
  51. for name in expected:
  52. preset = get_preset(name)
  53. assert preset.role in {AgentRole.PLANNER, AgentRole.WORKER, AgentRole.VALIDATOR}
  54. assert forbidden.isdisjoint(set(preset.allowed_tools or []))
  55. assert set(preset.allowed_tools or []).isdisjoint(set(preset.denied_tools or []))
  56. planner = get_preset("script_planner")
  57. assert set(planner.allowed_tools or []) == {
  58. "plan_script_tasks",
  59. "decide_script_task",
  60. "inspect_script_plan",
  61. "dispatch_script_tasks",
  62. "validate_attempt",
  63. "read_input_snapshot",
  64. }
  65. compose = get_preset("script_compose_worker")
  66. assert "read_active_frontier" in (compose.allowed_tools or [])
  67. assert "read_accepted_artifact" not in (compose.allowed_tools or [])
  68. compare = get_preset("script_candidate_compare_worker")
  69. assert "read_pinned_candidates" in (compare.allowed_tools or [])
  70. assert "create_script_paragraph" not in (compare.allowed_tools or [])
  71. portfolio = get_preset("script_candidate_portfolio_worker")
  72. assert "save_candidate_portfolio" in (portfolio.allowed_tools or [])
  73. assert "save_structured_script_candidate" not in (portfolio.allowed_tools or [])
  74. for validator_name in ("script_retrieval_validator", "script_candidate_validator"):
  75. assert "view_frozen_images" in (get_preset(validator_name).allowed_tools or [])
  76. assert set(get_preset("script_root_worker").allowed_tools or []) == {
  77. "read_input_snapshot",
  78. "read_accepted_portfolio",
  79. "legacy_projection_dry_run",
  80. "save_root_delivery_manifest",
  81. "submit_attempt",
  82. }
  83. assert set(get_preset("script_root_validator").allowed_tools or []) == {
  84. "read_input_snapshot",
  85. "read_accepted_portfolio",
  86. "query_validation_evidence",
  87. "deterministic_precheck",
  88. "submit_validation",
  89. }
  90. def test_new_build_prompt_manifest_freezes_every_phase_one_and_phase_two_role() -> None:
  91. manifest = script_build_prompt_manifest()
  92. assert len(manifest) == 14
  93. assert len({str(item["preset"]) for item in manifest}) == len(manifest)
  94. assert {str(item["preset"]) for item in manifest} >= {
  95. "script_planner",
  96. "script_structure_worker",
  97. "script_paragraph_worker",
  98. "script_element_set_worker",
  99. "script_candidate_compare_worker",
  100. "script_compose_worker",
  101. "script_candidate_portfolio_worker",
  102. }
  103. for item in manifest:
  104. assert str(item["content"])
  105. assert str(item["content_sha256"]).startswith("sha256:")
  106. assert len(str(item["content_sha256"])) == 71
  107. def test_phase_three_prompt_manifest_is_versioned_separately() -> None:
  108. manifest = phase_three_prompt_manifest()
  109. assert {str(item["preset"]) for item in manifest} == {
  110. "script_root_worker",
  111. "script_root_validator",
  112. }
  113. assert all(str(item["content_sha256"]).startswith("sha256:") for item in manifest)
  114. class _Gateway:
  115. coordinator = SimpleNamespace(task_store=None)
  116. def test_legacy_retrieval_tool_schemas_keep_old_argument_shapes() -> None:
  117. registry = ToolRegistry()
  118. register_script_tools(registry, _Gateway()) # type: ignore[arg-type]
  119. schemas = {
  120. item["function"]["name"]: item["function"]["parameters"] for item in registry.get_schemas()
  121. }
  122. decode = schemas["search_script_decode_case"]
  123. assert decode["required"] == ["return_field"]
  124. assert decode["properties"]["match_fields"]["type"] == "object"
  125. assert decode["properties"]["top_k"]["default"] == 3
  126. external = schemas["external_search_case"]
  127. assert external["required"] == ["keyword"]
  128. assert external["properties"]["platform_channel"]["default"] == "xhs"
  129. knowledge = schemas["search_knowledge"]
  130. assert knowledge["required"] == ["keyword"]
  131. assert knowledge["properties"]["max_count"]["default"] == 3
  132. assert schemas["query_pattern_qa"]["required"] == ["message"]
  133. assert schemas["load_images"]["required"] == ["image_urls"]
  134. assert schemas["load_frozen_strategy"]["required"] == ["strategy_ref"]
  135. assert schemas["submit_attempt"].get("properties") == {}
  136. assert schemas["submit_validation"]["required"] == [
  137. "verdict",
  138. "criterion_results",
  139. "defects",
  140. ]
  141. assert "summary" not in schemas["submit_attempt"].get("properties", {})
  142. assert "artifact_refs" not in schemas["submit_attempt"].get("properties", {})
  143. assert {item.value for item in registry.get_capabilities("search_script_decode_case")} == {
  144. "external_send",
  145. "read",
  146. "write",
  147. }
  148. class _Bindings:
  149. async def get_by_root(self, root_trace_id: str) -> SimpleNamespace:
  150. assert root_trace_id == "root-a"
  151. return SimpleNamespace(script_build_id=9, input_snapshot_id=3)
  152. class _Snapshots:
  153. async def get(self, snapshot_id: str, *, script_build_id: int) -> ScriptBuildInputSnapshotV1:
  154. assert (snapshot_id, script_build_id) == ("3", 9)
  155. return ScriptBuildInputSnapshotV1(
  156. snapshot_id="3",
  157. script_build_id=9,
  158. execution_id=1,
  159. topic_build_id=2,
  160. topic_id=3,
  161. topic={},
  162. account={},
  163. persona_points=(),
  164. section_patterns=(),
  165. strategies=(),
  166. prompt_manifest=(),
  167. datasource_manifest={},
  168. model_manifest={
  169. "presets": {
  170. "script_direction_worker": {
  171. "model": "frozen-direction-model",
  172. "temperature": 0.2,
  173. "max_iterations": 18,
  174. }
  175. }
  176. },
  177. canonical_sha256="sha256:" + "a" * 64,
  178. created_at=datetime.now(UTC),
  179. )
  180. @pytest.mark.asyncio
  181. async def test_role_model_resolver_reads_each_roots_frozen_manifest() -> None:
  182. resolver = ScriptRoleRunConfigResolver(
  183. {}, manifest_source=SnapshotModelManifestSource(_Bindings(), _Snapshots())
  184. )
  185. value = await resolver.resolve(
  186. role=AgentRole.WORKER,
  187. preset="script_direction_worker",
  188. context={"root_trace_id": "root-a"},
  189. )
  190. assert value.model == "frozen-direction-model"
  191. assert value.temperature == 0.2
  192. assert value.max_iterations == 18
  193. @pytest.mark.asyncio
  194. async def test_role_prompt_resolver_uses_task_pinned_snapshot_and_rechecks_digest() -> None:
  195. content = "Frozen paragraph worker policy"
  196. snapshot = ScriptBuildInputSnapshotV1(
  197. snapshot_id="3",
  198. script_build_id=9,
  199. execution_id=1,
  200. topic_build_id=2,
  201. topic_id=3,
  202. topic={},
  203. account={},
  204. persona_points=(),
  205. section_patterns=(),
  206. strategies=(),
  207. prompt_manifest=(
  208. {
  209. "preset": "script_paragraph_worker",
  210. "role": "worker",
  211. "content": content,
  212. "content_sha256": f"sha256:{sha256(content.encode('utf-8')).hexdigest()}",
  213. },
  214. ),
  215. datasource_manifest={},
  216. model_manifest={},
  217. canonical_sha256="sha256:" + "a" * 64,
  218. created_at=datetime.now(UTC),
  219. )
  220. class Snapshots:
  221. async def get(self, snapshot_id: str, *, script_build_id: int):
  222. assert (snapshot_id, script_build_id) == ("3", 9)
  223. return snapshot
  224. resolver = SnapshotRoleSystemPromptResolver(_Bindings(), Snapshots())
  225. value = await resolver.resolve(
  226. role=AgentRole.WORKER,
  227. preset="script_paragraph_worker",
  228. context={
  229. "root_trace_id": "root-a",
  230. "task_spec": {"context_refs": ["script-build://inputs/3"]},
  231. },
  232. )
  233. assert value is not None
  234. assert value.content == content
  235. tampered = replace(
  236. snapshot,
  237. prompt_manifest=(
  238. {
  239. **snapshot.prompt_manifest[0],
  240. "content_sha256": "sha256:" + "f" * 64,
  241. },
  242. ),
  243. )
  244. class TamperedSnapshots:
  245. async def get(self, _snapshot_id: str, *, script_build_id: int):
  246. assert script_build_id == 9
  247. return tampered
  248. with pytest.raises(ProtocolViolation, match="digest"):
  249. await SnapshotRoleSystemPromptResolver(_Bindings(), TamperedSnapshots()).resolve(
  250. role=AgentRole.WORKER,
  251. preset="script_paragraph_worker",
  252. context={
  253. "root_trace_id": "root-a",
  254. "task_spec": {"context_refs": ["script-build://inputs/3"]},
  255. },
  256. )
  257. @pytest.mark.asyncio
  258. async def test_snapshot_tool_hides_on_demand_body_until_explicit_digest_verified_load() -> None:
  259. strategy_ref = "script-build://strategies/8/versions/2"
  260. snapshot = ScriptBuildInputSnapshotV1(
  261. snapshot_id="3",
  262. script_build_id=9,
  263. execution_id=1,
  264. topic_build_id=2,
  265. topic_id=3,
  266. topic={},
  267. account={},
  268. persona_points=(),
  269. section_patterns=(),
  270. strategies=(
  271. {
  272. "strategy_id": 7,
  273. "version": 1,
  274. "mode": "always_on",
  275. "content": "always body",
  276. "content_sha256": "sha256:" + "1" * 64,
  277. },
  278. {
  279. "strategy_id": 8,
  280. "version": 2,
  281. "mode": "on_demand",
  282. "description": "optional",
  283. "content": "on demand body",
  284. "content_sha256": canonical_sha256("on demand body").wire,
  285. },
  286. ),
  287. prompt_manifest=(),
  288. datasource_manifest={},
  289. model_manifest={},
  290. canonical_sha256="sha256:" + "a" * 64,
  291. created_at=datetime.now(UTC),
  292. )
  293. class Snapshots:
  294. async def get(self, _snapshot_id: str, *, script_build_id: int):
  295. assert script_build_id == 9
  296. return snapshot
  297. task = SimpleNamespace(current_spec=SimpleNamespace(context_refs=["script-build://inputs/3"]))
  298. coordinator = SimpleNamespace(
  299. task_store=SimpleNamespace(load=lambda _root: _async(SimpleNamespace(tasks={"task": task})))
  300. )
  301. gateway = LegacyScriptToolGateway(
  302. bindings=_Bindings(),
  303. snapshots=Snapshots(),
  304. artifacts=SimpleNamespace(),
  305. coordinator=coordinator,
  306. )
  307. context = {"root_trace_id": "root-a", "task_id": "task"}
  308. summary = await gateway.read_input_snapshot(context)
  309. assert summary["strategies"][0]["content"] == "always body"
  310. assert "content" not in summary["strategies"][1]
  311. loaded = await gateway.load_frozen_strategy(strategy_ref, context)
  312. assert loaded["content"] == "on demand body"
  313. tampered = replace(
  314. snapshot,
  315. strategies=(
  316. snapshot.strategies[0],
  317. {**snapshot.strategies[1], "content": "changed after freeze"},
  318. ),
  319. )
  320. class TamperedSnapshots:
  321. async def get(self, _snapshot_id: str, *, script_build_id: int):
  322. assert script_build_id == 9
  323. return tampered
  324. gateway.snapshots = TamperedSnapshots()
  325. with pytest.raises(ProtocolViolation, match="strategy digest"):
  326. await gateway.load_frozen_strategy(strategy_ref, context)
  327. async def _async(value):
  328. return value