test_agent_surface.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505
  1. from __future__ import annotations
  2. import json
  3. from dataclasses import replace
  4. from datetime import UTC, datetime
  5. from hashlib import sha256
  6. from types import SimpleNamespace
  7. import pytest
  8. from agent import AgentRole, ToolRegistry, get_preset
  9. from script_build_host.agents.model_resolver import (
  10. ScriptRoleRunConfigResolver,
  11. SnapshotModelManifestSource,
  12. )
  13. from script_build_host.agents.presets import register_script_presets
  14. from script_build_host.agents.prompt_resolver import SnapshotRoleSystemPromptResolver
  15. from script_build_host.agents.prompts import (
  16. phase_three_prompt_manifest,
  17. script_build_prompt_manifest,
  18. )
  19. from script_build_host.domain.canonical_json import canonical_sha256
  20. from script_build_host.domain.errors import ProtocolViolation
  21. from script_build_host.domain.input_snapshot import ScriptBuildInputSnapshotV1
  22. from script_build_host.domain.workbench import strategy_handle
  23. from script_build_host.tools.gateway import LegacyScriptToolGateway, _project_snapshot
  24. from script_build_host.tools.registry import register_script_tools
  25. def test_script_presets_are_exact_and_never_expose_legacy_control_tools() -> None:
  26. register_script_presets()
  27. expected = {
  28. "script_planner",
  29. "script_direction_worker",
  30. "script_pattern_retrieval_worker",
  31. "script_decode_retrieval_worker",
  32. "script_external_retrieval_worker",
  33. "script_knowledge_retrieval_worker",
  34. "script_retrieval_validator",
  35. "script_candidate_validator",
  36. "script_structure_worker",
  37. "script_paragraph_worker",
  38. "script_element_set_worker",
  39. "script_candidate_compare_worker",
  40. "script_compose_worker",
  41. "script_candidate_portfolio_worker",
  42. }
  43. forbidden = {
  44. "agent",
  45. "evaluate",
  46. "bash_command",
  47. "write_file",
  48. "dispatch_tasks",
  49. "implement_paths",
  50. "begin_round",
  51. "multipath",
  52. }
  53. for name in expected:
  54. preset = get_preset(name)
  55. assert preset.role in {AgentRole.PLANNER, AgentRole.WORKER, AgentRole.VALIDATOR}
  56. assert forbidden.isdisjoint(set(preset.allowed_tools or []))
  57. assert set(preset.allowed_tools or []).isdisjoint(set(preset.denied_tools or []))
  58. planner = get_preset("script_planner")
  59. assert set(planner.allowed_tools or []) == {
  60. "plan_script_tasks",
  61. "decide_script_task",
  62. "read_workbench_detail",
  63. "dispatch_script_tasks",
  64. "validate_attempt",
  65. }
  66. compose = get_preset("script_compose_worker")
  67. assert set(compose.allowed_tools or []) == {"submit_attempt"}
  68. compare = get_preset("script_candidate_compare_worker")
  69. assert "save_comparison_candidate" 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 set(portfolio.allowed_tools or []) == {"submit_attempt"}
  73. assert "view_frozen_images" not in (
  74. get_preset("script_retrieval_validator").allowed_tools or []
  75. )
  76. assert "view_frozen_images" in (get_preset("script_candidate_validator").allowed_tools or [])
  77. element_tools = set(get_preset("script_element_set_worker").allowed_tools or [])
  78. assert "save_script_elements" in element_tools
  79. assert "create_script_element" not in element_tools
  80. assert "batch_link_paragraph_elements" not in element_tools
  81. assert set(get_preset("script_root_worker").allowed_tools or []) == {
  82. "read_workbench_detail",
  83. "legacy_projection_dry_run",
  84. "save_root_delivery_manifest",
  85. "submit_attempt",
  86. }
  87. assert set(get_preset("script_root_validator").allowed_tools or []) == {
  88. "query_validation_evidence",
  89. "deterministic_precheck",
  90. "submit_validation",
  91. }
  92. def test_new_build_prompt_manifest_freezes_every_phase_one_and_phase_two_role() -> None:
  93. manifest = script_build_prompt_manifest()
  94. assert len(manifest) == 14
  95. assert len({str(item["preset"]) for item in manifest}) == len(manifest)
  96. assert {str(item["preset"]) for item in manifest} >= {
  97. "script_planner",
  98. "script_structure_worker",
  99. "script_paragraph_worker",
  100. "script_element_set_worker",
  101. "script_candidate_compare_worker",
  102. "script_compose_worker",
  103. "script_candidate_portfolio_worker",
  104. }
  105. for item in manifest:
  106. assert str(item["content"])
  107. assert str(item["content_sha256"]).startswith("sha256:")
  108. assert len(str(item["content_sha256"])) == 71
  109. def test_phase_three_prompt_manifest_is_versioned_separately() -> None:
  110. manifest = phase_three_prompt_manifest()
  111. assert {str(item["preset"]) for item in manifest} == {
  112. "script_root_worker",
  113. "script_root_validator",
  114. }
  115. assert all(str(item["content_sha256"]).startswith("sha256:") for item in manifest)
  116. class _Gateway:
  117. coordinator = SimpleNamespace(task_store=None)
  118. def test_legacy_retrieval_tool_schemas_keep_old_argument_shapes() -> None:
  119. registry = ToolRegistry()
  120. register_script_tools(registry, _Gateway()) # type: ignore[arg-type]
  121. schemas = {
  122. item["function"]["name"]: item["function"]["parameters"] for item in registry.get_schemas()
  123. }
  124. decode = schemas["search_script_decode_case"]
  125. assert decode["required"] == ["return_field"]
  126. assert decode["properties"]["match_fields"]["type"] == "object"
  127. assert decode["properties"]["top_k"]["default"] == 3
  128. external = schemas["external_search_case"]
  129. assert external["required"] == ["keyword"]
  130. assert external["properties"]["platform_channel"]["default"] == "xhs"
  131. knowledge = schemas["search_knowledge"]
  132. assert knowledge["required"] == ["keyword"]
  133. assert knowledge["properties"]["max_count"]["default"] == 3
  134. assert schemas["query_pattern_qa"]["required"] == ["message"]
  135. assert schemas["load_images"]["required"] == ["image_urls"]
  136. assert schemas["load_frozen_strategy"]["required"] == ["strategy_handle"]
  137. assert schemas["save_script_elements"]["required"] == [
  138. "elements",
  139. "links",
  140. "expected_state_revision",
  141. ]
  142. assert schemas["save_script_elements"]["properties"]["links"]["items"]["required"] == [
  143. "paragraph_target_key",
  144. "element_client_keys",
  145. ]
  146. assert schemas["submit_attempt"].get("properties") == {}
  147. assert schemas["submit_validation"]["required"] == [
  148. "verdict",
  149. "criterion_results",
  150. "defects",
  151. ]
  152. assert "summary" not in schemas["submit_attempt"].get("properties", {})
  153. assert "artifact_refs" not in schemas["submit_attempt"].get("properties", {})
  154. assert {item.value for item in registry.get_capabilities("search_script_decode_case")} == {
  155. "external_send",
  156. "read",
  157. "write",
  158. }
  159. def test_agent_visible_write_schemas_never_expose_mechanical_identity_fields() -> None:
  160. registry = ToolRegistry()
  161. register_script_tools(registry, _Gateway()) # type: ignore[arg-type]
  162. schemas = {
  163. item["function"]["name"]: item["function"]["parameters"] for item in registry.get_schemas()
  164. }
  165. write_tools = {
  166. "plan_script_tasks",
  167. "decide_script_task",
  168. "save_direction_candidate",
  169. "save_script_paragraphs",
  170. "save_script_elements",
  171. "save_comparison_candidate",
  172. "save_root_delivery_manifest",
  173. "submit_validation",
  174. }
  175. forbidden = {
  176. "paragraph_id",
  177. "element_id",
  178. "branch_id",
  179. "artifact_ref",
  180. "evidence_refs",
  181. "write_scope",
  182. "output_schema",
  183. "compose_order",
  184. "context_refs",
  185. }
  186. def property_names(value: object) -> set[str]:
  187. if isinstance(value, dict):
  188. names = (
  189. set(value.get("properties", {}))
  190. if isinstance(value.get("properties"), dict)
  191. else set()
  192. )
  193. return names | set().union(*(property_names(item) for item in value.values()))
  194. if isinstance(value, list):
  195. return set().union(*(property_names(item) for item in value))
  196. return set()
  197. for name in write_tools:
  198. assert forbidden.isdisjoint(property_names(schemas[name])), name
  199. register_script_presets()
  200. forbidden_tools = {
  201. "read_input_snapshot",
  202. "read_attempt_workspace",
  203. "read_active_frontier",
  204. "read_pinned_candidates",
  205. "read_accepted_artifact",
  206. "create_script_paragraph",
  207. "create_script_element",
  208. "update_script_element",
  209. "batch_link_paragraph_elements",
  210. "append_paragraph_atoms",
  211. "delete_paragraph_atom",
  212. }
  213. for preset_name in (
  214. "script_direction_worker",
  215. "script_structure_worker",
  216. "script_paragraph_worker",
  217. "script_element_set_worker",
  218. "script_candidate_compare_worker",
  219. "script_compose_worker",
  220. "script_candidate_portfolio_worker",
  221. "script_root_worker",
  222. ):
  223. assert forbidden_tools.isdisjoint(get_preset(preset_name).allowed_tools or []), preset_name
  224. def test_input_snapshot_projection_is_bounded_and_keeps_business_inputs() -> None:
  225. persona = [
  226. {
  227. "列": ("实质", "形式", "作用", "感受")[index % 4],
  228. "点名称": f"point-{index}",
  229. "人设权重分": str(1 - index / 100),
  230. "帖子覆盖率": "0.8",
  231. "分类路径": ["/account/style"],
  232. }
  233. for index in range(87)
  234. ]
  235. payload = {
  236. "snapshot_id": "50",
  237. "script_build_id": 900049,
  238. "canonical_sha256": "sha256:" + "a" * 64,
  239. "account": {"account_name": "account"},
  240. "topic": {
  241. "topic": {"result": "gym story", "topic_direction": "satirical comic"},
  242. "composition_items": [
  243. {
  244. "id": index,
  245. "point_type": "关键点",
  246. "dimension": "形式",
  247. "element_name": f"item-{index}",
  248. "note": "useful",
  249. "created_at": "unused metadata",
  250. "is_active": True,
  251. }
  252. for index in range(30)
  253. ],
  254. },
  255. "persona_points": persona,
  256. "section_patterns": [{"模式名称": "parallel atlas"}],
  257. "strategies": [],
  258. "prompt_manifest": [{"content": "x" * 100_000}],
  259. "model_manifest": {"secret": "x" * 100_000},
  260. "datasource_manifest": {"endpoint": "x" * 100_000},
  261. }
  262. projected = _project_snapshot(payload, view="element-set")
  263. encoded = json.dumps(projected, ensure_ascii=False)
  264. assert projected["view"] == "element-set"
  265. assert projected["persona_points_total"] == 87
  266. assert len(projected["persona_points"]) == 12
  267. assert projected["section_patterns"] == [{"模式名称": "parallel atlas"}]
  268. assert "prompt_manifest" not in projected
  269. assert "model_manifest" not in projected
  270. assert "datasource_manifest" not in projected
  271. assert "created_at" not in projected["topic"]["composition_items"][0]
  272. assert len(encoded) < 20_000
  273. class _Bindings:
  274. async def get_by_root(self, root_trace_id: str) -> SimpleNamespace:
  275. assert root_trace_id == "root-a"
  276. return SimpleNamespace(script_build_id=9, input_snapshot_id=3)
  277. class _Snapshots:
  278. async def get(self, snapshot_id: str, *, script_build_id: int) -> ScriptBuildInputSnapshotV1:
  279. assert (snapshot_id, script_build_id) == ("3", 9)
  280. return ScriptBuildInputSnapshotV1(
  281. snapshot_id="3",
  282. script_build_id=9,
  283. execution_id=1,
  284. topic_build_id=2,
  285. topic_id=3,
  286. topic={},
  287. account={},
  288. persona_points=(),
  289. section_patterns=(),
  290. strategies=(),
  291. prompt_manifest=(),
  292. datasource_manifest={},
  293. model_manifest={
  294. "presets": {
  295. "script_direction_worker": {
  296. "model": "frozen-direction-model",
  297. "temperature": 0.2,
  298. "max_iterations": 18,
  299. }
  300. }
  301. },
  302. canonical_sha256="sha256:" + "a" * 64,
  303. created_at=datetime.now(UTC),
  304. )
  305. @pytest.mark.asyncio
  306. async def test_role_model_resolver_reads_each_roots_frozen_manifest() -> None:
  307. resolver = ScriptRoleRunConfigResolver(
  308. {}, manifest_source=SnapshotModelManifestSource(_Bindings(), _Snapshots())
  309. )
  310. value = await resolver.resolve(
  311. role=AgentRole.WORKER,
  312. preset="script_direction_worker",
  313. context={"root_trace_id": "root-a"},
  314. )
  315. assert value.model == "frozen-direction-model"
  316. assert value.temperature == 0.2
  317. assert value.max_iterations == 18
  318. @pytest.mark.asyncio
  319. async def test_role_prompt_resolver_uses_task_pinned_snapshot_and_rechecks_digest() -> None:
  320. content = "Frozen paragraph worker policy"
  321. snapshot = ScriptBuildInputSnapshotV1(
  322. snapshot_id="3",
  323. script_build_id=9,
  324. execution_id=1,
  325. topic_build_id=2,
  326. topic_id=3,
  327. topic={},
  328. account={},
  329. persona_points=(),
  330. section_patterns=(),
  331. strategies=(),
  332. prompt_manifest=(
  333. {
  334. "preset": "script_paragraph_worker",
  335. "role": "worker",
  336. "content": content,
  337. "content_sha256": f"sha256:{sha256(content.encode('utf-8')).hexdigest()}",
  338. },
  339. ),
  340. datasource_manifest={},
  341. model_manifest={},
  342. canonical_sha256="sha256:" + "a" * 64,
  343. created_at=datetime.now(UTC),
  344. )
  345. class Snapshots:
  346. async def get(self, snapshot_id: str, *, script_build_id: int):
  347. assert (snapshot_id, script_build_id) == ("3", 9)
  348. return snapshot
  349. resolver = SnapshotRoleSystemPromptResolver(_Bindings(), Snapshots())
  350. value = await resolver.resolve(
  351. role=AgentRole.WORKER,
  352. preset="script_paragraph_worker",
  353. context={
  354. "root_trace_id": "root-a",
  355. "task_spec": {"context_refs": ["script-build://inputs/3"]},
  356. },
  357. )
  358. assert value is not None
  359. assert value.content == content
  360. tampered = replace(
  361. snapshot,
  362. prompt_manifest=(
  363. {
  364. **snapshot.prompt_manifest[0],
  365. "content_sha256": "sha256:" + "f" * 64,
  366. },
  367. ),
  368. )
  369. class TamperedSnapshots:
  370. async def get(self, _snapshot_id: str, *, script_build_id: int):
  371. assert script_build_id == 9
  372. return tampered
  373. with pytest.raises(ProtocolViolation, match="digest"):
  374. await SnapshotRoleSystemPromptResolver(_Bindings(), TamperedSnapshots()).resolve(
  375. role=AgentRole.WORKER,
  376. preset="script_paragraph_worker",
  377. context={
  378. "root_trace_id": "root-a",
  379. "task_spec": {"context_refs": ["script-build://inputs/3"]},
  380. },
  381. )
  382. @pytest.mark.asyncio
  383. async def test_snapshot_tool_hides_on_demand_body_until_explicit_digest_verified_load() -> None:
  384. on_demand_body = "strategy detail " * 1_200
  385. snapshot = ScriptBuildInputSnapshotV1(
  386. snapshot_id="3",
  387. script_build_id=9,
  388. execution_id=1,
  389. topic_build_id=2,
  390. topic_id=3,
  391. topic={},
  392. account={},
  393. persona_points=(),
  394. section_patterns=(),
  395. strategies=(
  396. {
  397. "strategy_id": 7,
  398. "version": 1,
  399. "mode": "always_on",
  400. "content": "always body",
  401. "content_sha256": "sha256:" + "1" * 64,
  402. },
  403. {
  404. "strategy_id": 8,
  405. "version": 2,
  406. "mode": "on_demand",
  407. "description": "optional",
  408. "content": on_demand_body,
  409. "content_sha256": canonical_sha256(on_demand_body).wire,
  410. },
  411. ),
  412. prompt_manifest=(),
  413. datasource_manifest={},
  414. model_manifest={},
  415. canonical_sha256="sha256:" + "a" * 64,
  416. created_at=datetime.now(UTC),
  417. )
  418. class Snapshots:
  419. async def get(self, _snapshot_id: str, *, script_build_id: int):
  420. assert script_build_id == 9
  421. return snapshot
  422. task = SimpleNamespace(current_spec=SimpleNamespace(context_refs=["script-build://inputs/3"]))
  423. coordinator = SimpleNamespace(
  424. task_store=SimpleNamespace(load=lambda _root: _async(SimpleNamespace(tasks={"task": task})))
  425. )
  426. gateway = LegacyScriptToolGateway(
  427. bindings=_Bindings(),
  428. snapshots=Snapshots(),
  429. artifacts=SimpleNamespace(),
  430. coordinator=coordinator,
  431. )
  432. context = {"root_trace_id": "root-a", "task_id": "task"}
  433. summary = await gateway.read_input_snapshot(context)
  434. assert summary["strategies"][0]["content"] == "always body"
  435. assert "content" not in summary["strategies"][1]
  436. selected_handle = strategy_handle(snapshot.snapshot_id, 1, snapshot.strategies[1])
  437. loaded = await gateway.load_frozen_strategy(selected_handle, context)
  438. assert loaded["content_fragment"] == on_demand_body[:7_000]
  439. assert loaded["next_cursor"] == 7_000
  440. second = await gateway.load_frozen_strategy(
  441. selected_handle, context, cursor=loaded["next_cursor"]
  442. )
  443. assert second["content_fragment"] == on_demand_body[7_000:14_000]
  444. assert "content_sha256" not in loaded["metadata"]
  445. tampered = replace(
  446. snapshot,
  447. strategies=(
  448. snapshot.strategies[0],
  449. {**snapshot.strategies[1], "content": "changed after freeze"},
  450. ),
  451. )
  452. class TamperedSnapshots:
  453. async def get(self, _snapshot_id: str, *, script_build_id: int):
  454. assert script_build_id == 9
  455. return tampered
  456. gateway.snapshots = TamperedSnapshots()
  457. with pytest.raises(ProtocolViolation, match="strategy digest"):
  458. await gateway.load_frozen_strategy(selected_handle, context)
  459. async def _async(value):
  460. return value