test_executor.py 58 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666
  1. from __future__ import annotations
  2. import hashlib
  3. import json
  4. import tempfile
  5. import unittest
  6. from pathlib import Path
  7. from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
  8. from langchain_core.tools import tool
  9. from production_build_agents.capabilities import CAPABILITIES
  10. from production_build_agents.contracts.brief_paths import (
  11. build_allowed_source_paths,
  12. resolve_direct_json_path,
  13. )
  14. from production_build_agents.preprocess.brief_io import load_production_brief
  15. from production_build_agents.contracts.models import (
  16. CandidateArtifact,
  17. ExecutorCandidate,
  18. ArtifactBindingClaim,
  19. )
  20. from production_build_agents.agents.executor.agent import (
  21. ExecutorOutputError,
  22. run_executor_agent,
  23. )
  24. from production_build_agents.agents.invocation import (
  25. CORRECTION_PRESERVATION_INSTRUCTION,
  26. )
  27. from production_build_agents.agents.executor.context import (
  28. build_executor_system_prompt,
  29. build_executor_user_payload,
  30. create_executor_run_id,
  31. load_task_contract,
  32. )
  33. from production_build_agents.agents.executor.provenance import (
  34. ExecutorProvenanceError,
  35. validate_candidate_provenance,
  36. )
  37. from production_build_agents.agents.tool_trace import build_tool_call_records
  38. from production_build_agents.agents.executor.skills.registry import (
  39. ExecutorSkillError,
  40. load_executor_skill,
  41. )
  42. from production_build_agents.run.records import save_executor_delivery
  43. from production_build_agents.run.langgraph_checkpointer import (
  44. checkpoint_messages,
  45. create_sqlite_checkpointer,
  46. )
  47. from production_build_agents.tools.registry import ToolRegistry, create_default_tool_registry
  48. from tests.support.executor_fixtures import (
  49. build_executor_candidate,
  50. build_executor_model,
  51. )
  52. from tests.support.fake_models import ToolAwareFakeChatModel
  53. from tests.support.planner_fixtures import (
  54. build_planned_task,
  55. build_task_package,
  56. )
  57. BRIEF_PATH = (
  58. Path(__file__).parents[1] / "fixtures" / "minimal_brief.json"
  59. )
  60. IMAGE_URL = "https://example.test/generated-reference.png"
  61. SOURCE_URL = "https://example.test/research-source"
  62. VIDEO_URL = "https://example.test/generated-video.mp4"
  63. FRAME_URL = "https://example.test/generated-video-frame.jpg"
  64. _FAKE_MEDIA_TEMP = tempfile.TemporaryDirectory()
  65. _FAKE_MEDIA_DIR = Path(_FAKE_MEDIA_TEMP.name)
  66. def _fake_media_path(source: str, suffix: str) -> Path:
  67. path = _FAKE_MEDIA_DIR / (
  68. hashlib.sha256(source.encode("utf-8")).hexdigest() + suffix
  69. )
  70. path.write_bytes(source.encode("utf-8"))
  71. return path
  72. def _fake_tool_registry() -> ToolRegistry:
  73. @tool("search_tool")
  74. def search_tool(query: str, limit: int = 5) -> dict:
  75. """发现测试工具。"""
  76. return {
  77. "success": True,
  78. "results": [{"tool_id": "fake-tool", "summary": query}],
  79. "_duration_ms": 3,
  80. }
  81. @tool("inspect_tool")
  82. def inspect_tool(tool_ids: list[str]) -> dict:
  83. """返回测试工具参数。"""
  84. return {
  85. "success": True,
  86. "tools": {tool_id: {"input_schema": {}} for tool_id in tool_ids},
  87. "_duration_ms": 4,
  88. }
  89. @tool("run_tool")
  90. def run_tool(tool_id: str, params: dict) -> dict:
  91. """执行测试工具。"""
  92. url = params.get("result_url") or {
  93. "image": IMAGE_URL,
  94. "video": VIDEO_URL,
  95. }.get(params.get("kind"), SOURCE_URL)
  96. return {
  97. "success": True,
  98. "tool_id": tool_id,
  99. "url": url,
  100. "_duration_ms": 5,
  101. }
  102. @tool("view_images")
  103. def view_images(image_sources: list[str]) -> dict:
  104. """查看测试图片。"""
  105. return {
  106. "success": True,
  107. "images": [
  108. {
  109. "source": source,
  110. "local_path": str(
  111. _fake_media_path(source, ".png")
  112. ),
  113. "data_url": (
  114. "data:image/png;base64,"
  115. "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
  116. ),
  117. }
  118. for source in image_sources
  119. ],
  120. "_duration_ms": 6,
  121. }
  122. @tool("detect_faces")
  123. def detect_faces(source: str) -> dict:
  124. """返回测试图片的人脸三态判断。"""
  125. return {
  126. "success": True,
  127. "decision": "NO_FACE",
  128. "source": source,
  129. "local_path": source,
  130. "input_sha256": "a" * 64,
  131. }
  132. @tool("crop_image")
  133. def crop_image(image: str, box: list[float]) -> dict:
  134. """裁剪测试图片。"""
  135. return {"success": True, "image_urls": [image]}
  136. @tool("grid_collage")
  137. def grid_collage(images: list[str]) -> dict:
  138. """拼接测试图片。"""
  139. return {"success": True, "image_urls": images[:1]}
  140. @tool("overlay_text")
  141. def overlay_text(image: str, texts: list[dict]) -> dict:
  142. """叠加测试文字。"""
  143. return {"success": True, "image_urls": [image]}
  144. @tool("extract_frames")
  145. def extract_frames(video: str, num_frames: int = 5) -> dict:
  146. """抽取测试视频帧。"""
  147. return {
  148. "success": True,
  149. "source": video,
  150. "local_path": str(_fake_media_path(video, ".mp4")),
  151. "frames": [
  152. {
  153. "source": FRAME_URL,
  154. "timestamp_sec": 0,
  155. }
  156. ],
  157. "_duration_ms": 7,
  158. }
  159. @tool("video_trim")
  160. def video_trim(video: str, start_sec: float = 0) -> dict:
  161. """裁剪测试视频。"""
  162. return {"success": True, "video_urls": [video]}
  163. @tool("video_concat")
  164. def video_concat(videos: list[str]) -> dict:
  165. """拼接测试视频。"""
  166. return {"success": True, "video_urls": videos[:1]}
  167. @tool("video_mux_audio")
  168. def video_mux_audio(video: str, audio: str) -> dict:
  169. """合入测试音频。"""
  170. return {"success": True, "video_urls": [video]}
  171. tools = [
  172. search_tool,
  173. inspect_tool,
  174. run_tool,
  175. detect_faces,
  176. view_images,
  177. crop_image,
  178. grid_collage,
  179. overlay_text,
  180. extract_frames,
  181. video_trim,
  182. video_concat,
  183. video_mux_audio,
  184. ]
  185. return ToolRegistry({item.name: item for item in tools})
  186. def _tool_call(name: str, args: dict, call_id: str) -> AIMessage:
  187. return AIMessage(
  188. content="",
  189. tool_calls=[
  190. {
  191. "name": name,
  192. "args": args,
  193. "id": call_id,
  194. "type": "tool_call",
  195. }
  196. ],
  197. )
  198. class ExecutorSkillTest(unittest.TestCase):
  199. def test_generic_skills_have_distinct_tool_boundaries(self) -> None:
  200. analysis = load_executor_skill("structured-analysis")
  201. research = load_executor_skill("external-research")
  202. image = load_executor_skill("image-production")
  203. video = load_executor_skill("video-production")
  204. reference = load_executor_skill("reference-inspection")
  205. self.assertEqual(analysis.allowed_tools, ())
  206. self.assertIn("ProductionBrief", analysis.instructions)
  207. self.assertIn("派生阅读视图", analysis.instructions)
  208. self.assertEqual(
  209. research.allowed_tools,
  210. ("search_tool", "inspect_tool", "run_tool"),
  211. )
  212. self.assertIn("view_images", image.allowed_tools)
  213. self.assertIn("crop_image", image.allowed_tools)
  214. self.assertEqual(
  215. image.allowed_remote_tool_ids,
  216. (
  217. "seedream_generate_image",
  218. "gpt-image",
  219. "nano_banana_image",
  220. ),
  221. )
  222. self.assertNotIn("detect_faces", image.allowed_tools)
  223. self.assertNotIn("detect_faces", reference.allowed_tools)
  224. self.assertIn("detect_faces", video.allowed_tools)
  225. self.assertIn("extract_frames", video.allowed_tools)
  226. self.assertEqual(
  227. video.allowed_remote_tool_ids,
  228. ("seedream_generate_image", "seedance_generate_video"),
  229. )
  230. self.assertTrue(
  231. research.verification_policy.require_inspect_before_run
  232. )
  233. self.assertEqual(
  234. image.verification_policy.final_artifact_check_tool,
  235. "view_images",
  236. )
  237. self.assertEqual(
  238. video.verification_policy.final_artifact_check_tool,
  239. "extract_frames",
  240. )
  241. self.assertEqual(
  242. reference.allowed_tools,
  243. ("probe_media", "view_images", "extract_frames"),
  244. )
  245. self.assertEqual(
  246. set(CAPABILITIES[reference.skill_id].artifact_types),
  247. {"image", "video", "audio"},
  248. )
  249. def test_unknown_skill_is_rejected(self) -> None:
  250. with self.assertRaisesRegex(ExecutorSkillError, "未注册"):
  251. load_executor_skill("unknown")
  252. class ExecutorContextTest(unittest.TestCase):
  253. def test_model_receives_only_requested_brief_excerpt(self) -> None:
  254. with tempfile.TemporaryDirectory() as temp_dir:
  255. task = build_task_package(
  256. BRIEF_PATH,
  257. planned_task=build_planned_task(
  258. source_paths=["$.核心制作点[0]"]
  259. ),
  260. plan_path=Path(temp_dir) / "plan.json",
  261. )
  262. brief, plan, planned_task = load_task_contract(task)
  263. expectation = plan.stage_requirements[
  264. 0
  265. ].artifact_expectations[0]
  266. sibling = expectation.model_copy(
  267. update={
  268. "expectation_id": "Requirement1-Expectation2",
  269. "usage_scope": "不属于当前 Task 的兄弟期待",
  270. }
  271. )
  272. requirement = plan.stage_requirements[0].model_copy(
  273. update={
  274. "artifact_expectations": [expectation, sibling]
  275. }
  276. )
  277. plan = plan.model_copy(
  278. update={"stage_requirements": [requirement]}
  279. )
  280. Path(task.plan_uri).write_text(
  281. plan.model_dump_json(indent=2),
  282. encoding="utf-8",
  283. )
  284. brief, plan, planned_task = load_task_contract(task)
  285. run_id = create_executor_run_id(task)
  286. payload = build_executor_user_payload(
  287. executor_run_id=run_id,
  288. task=task,
  289. brief=brief,
  290. plan=plan,
  291. planned_task=planned_task,
  292. )
  293. self.assertEqual(
  294. set(payload["production_brief_excerpt"]),
  295. {"$.核心制作点[0]"},
  296. )
  297. self.assertNotIn("production_brief", payload)
  298. self.assertEqual(
  299. payload["allowed_source_paths"],
  300. planned_task.source_paths,
  301. )
  302. self.assertEqual(
  303. payload["resolved_task"],
  304. planned_task.model_dump(mode="json"),
  305. )
  306. self.assertNotIn("plan", payload)
  307. self.assertNotIn("stage_requirements", payload)
  308. self.assertNotIn("requirements", payload)
  309. self.assertEqual(
  310. [
  311. item["expectation"]["expectation_id"]
  312. for item in payload["selected_expectations"]
  313. ],
  314. ["Requirement1-Expectation1"],
  315. )
  316. self.assertNotIn(
  317. "Requirement1-Expectation2",
  318. json.dumps(payload, ensure_ascii=False),
  319. )
  320. self.assertEqual(
  321. set(payload["task_package"]),
  322. {
  323. "schema_version",
  324. "run_id",
  325. "plan_id",
  326. "plan_version",
  327. "task_id",
  328. "production_brief_uri",
  329. "plan_uri",
  330. "dependency_deliveries",
  331. },
  332. )
  333. prompt = build_executor_system_prompt(
  334. load_executor_skill(planned_task.skill_id)
  335. )
  336. self.assertIn("structured-analysis", prompt)
  337. self.assertNotIn("{{SKILL_ID}}", prompt)
  338. def test_shared_json_path_reader_keeps_stable_boundaries(self) -> None:
  339. _, brief = load_production_brief(BRIEF_PATH)
  340. payload = brief.model_dump(mode="json", by_alias=True)
  341. self.assertIn("$.制作表.形式结果[0]", build_allowed_source_paths(brief))
  342. self.assertEqual(
  343. resolve_direct_json_path(payload, "$.帖子类型"),
  344. payload["帖子类型"],
  345. )
  346. def test_dependency_manifest_loads_only_summary_and_artifact_refs(self) -> None:
  347. first_task = build_task_package(BRIEF_PATH)
  348. with tempfile.TemporaryDirectory() as temp_dir:
  349. root = Path(temp_dir)
  350. delivery = run_executor_agent(
  351. first_task,
  352. run_dir=root,
  353. model=build_executor_model(build_executor_candidate()),
  354. tool_registry=_fake_tool_registry(),
  355. )
  356. manifest = save_executor_delivery(root, delivery)
  357. second_task = build_task_package(
  358. BRIEF_PATH,
  359. planned_task=build_planned_task(
  360. task_id="Task2",
  361. depends_on=["Task1"],
  362. ),
  363. dependency_artifacts={"Task1": str(manifest)},
  364. )
  365. brief, plan, planned_task = load_task_contract(second_task)
  366. payload = build_executor_user_payload(
  367. executor_run_id=create_executor_run_id(second_task),
  368. task=second_task,
  369. brief=brief,
  370. plan=plan,
  371. planned_task=planned_task,
  372. )
  373. dependency = payload["dependency_artifacts"][0]
  374. self.assertEqual(dependency["task_id"], "Task1")
  375. self.assertIsInstance(dependency["summary"], str)
  376. self.assertEqual(dependency["summary"], delivery.summary)
  377. self.assertEqual(
  378. dependency["artifacts"][0]["uri"],
  379. delivery.artifacts[0].uri,
  380. )
  381. self.assertNotIn("tool_calls", dependency)
  382. class GeneralExecutorTest(unittest.TestCase):
  383. def test_structured_candidate_must_reference_every_task_source_path(
  384. self,
  385. ) -> None:
  386. planned = build_planned_task(
  387. source_paths=[
  388. "$.核心制作点[0]",
  389. "$.制作表.形式结果[0]",
  390. ]
  391. )
  392. task = build_task_package(BRIEF_PATH, planned_task=planned)
  393. candidate = build_executor_candidate(
  394. source_paths=["$.核心制作点[0]"]
  395. )
  396. with self.assertRaisesRegex(
  397. ExecutorProvenanceError,
  398. "未引用当前 Task 的全部来源路径",
  399. ):
  400. validate_candidate_provenance(
  401. candidate,
  402. task,
  403. planned,
  404. [],
  405. skill=load_executor_skill("structured-analysis"),
  406. input_refs=set(),
  407. )
  408. def test_structured_candidate_counts_nested_payload_source_paths(
  409. self,
  410. ) -> None:
  411. planned = build_planned_task(
  412. source_paths=[
  413. "$.核心制作点[0]",
  414. "$.制作表.形式结果[0]",
  415. ]
  416. )
  417. task = build_task_package(BRIEF_PATH, planned_task=planned)
  418. candidate = build_executor_candidate(
  419. source_paths=["$.核心制作点[0]"]
  420. ).model_copy(
  421. update={
  422. "payload": {
  423. "sections": [
  424. {
  425. "content": "保留形式结果中的精确规则",
  426. "source_paths": ["$.制作表.形式结果[0]"],
  427. }
  428. ]
  429. }
  430. }
  431. )
  432. validate_candidate_provenance(
  433. candidate,
  434. task,
  435. planned,
  436. [],
  437. skill=load_executor_skill("structured-analysis"),
  438. input_refs=set(),
  439. )
  440. def test_same_rule_in_different_scopes_keeps_both_source_paths(
  441. self,
  442. ) -> None:
  443. planned = build_planned_task(
  444. source_paths=[
  445. "$.制作表.段落结构[0]",
  446. "$.制作表.段落结构[1]",
  447. ]
  448. )
  449. task = build_task_package(BRIEF_PATH, planned_task=planned)
  450. candidate = build_executor_candidate().model_copy(
  451. update={
  452. "findings": [],
  453. "payload": {
  454. "sections": [
  455. {
  456. "content": "主体居中",
  457. "scope": "00:00:00-00:00:05",
  458. "source_paths": ["$.制作表.段落结构[0]"],
  459. },
  460. {
  461. "content": "主体居中",
  462. "scope": "00:00:05-00:00:10",
  463. "source_paths": ["$.制作表.段落结构[1]"],
  464. },
  465. ]
  466. },
  467. }
  468. )
  469. validate_candidate_provenance(
  470. candidate,
  471. task,
  472. planned,
  473. [],
  474. skill=load_executor_skill("structured-analysis"),
  475. input_refs=set(),
  476. )
  477. def test_reference_source_must_match_probe_local_mapping(self) -> None:
  478. source_a = "https://example.test/a.png"
  479. source_b = "https://example.test/b.png"
  480. local_a = str(BRIEF_PATH.resolve())
  481. planned = build_planned_task(
  482. skill_id="reference-inspection",
  483. deliverable_type="reference_collection",
  484. )
  485. task = build_task_package(BRIEF_PATH, planned_task=planned)
  486. _, _, resolved_task = load_task_contract(task)
  487. candidate = ExecutorCandidate(
  488. run_id=task.run_id,
  489. plan_id=task.plan_id,
  490. task_id=task.task_id,
  491. plan_version=task.plan_version,
  492. deliverable_type="reference_collection",
  493. artifacts=[
  494. CandidateArtifact(
  495. artifact_type="image",
  496. uri=local_a,
  497. source_uri=source_b,
  498. description="错误绑定的参考图",
  499. )
  500. ],
  501. artifact_binding_claims=[
  502. ArtifactBindingClaim(
  503. expectation_id="Requirement1-Expectation1",
  504. artifact_uri=local_a,
  505. )
  506. ],
  507. summary="错误地把 A 的缓存声明为 B",
  508. )
  509. messages = [
  510. AIMessage(
  511. content="",
  512. tool_calls=[
  513. {
  514. "name": "probe_media",
  515. "args": {"source": source_a},
  516. "id": "probe-a",
  517. "type": "tool_call",
  518. },
  519. {
  520. "name": "view_images",
  521. "args": {"image_sources": [local_a]},
  522. "id": "view-a",
  523. "type": "tool_call",
  524. },
  525. ],
  526. ),
  527. ToolMessage(
  528. content=json.dumps(
  529. {
  530. "success": True,
  531. "source": source_a,
  532. "local_path": local_a,
  533. "media_type": "image",
  534. }
  535. ),
  536. tool_call_id="probe-a",
  537. name="probe_media",
  538. ),
  539. ToolMessage(
  540. content=json.dumps(
  541. {
  542. "success": True,
  543. "images": [
  544. {
  545. "source": local_a,
  546. "local_path": local_a,
  547. }
  548. ],
  549. }
  550. ),
  551. tool_call_id="view-a",
  552. name="view_images",
  553. ),
  554. ]
  555. with self.assertRaisesRegex(
  556. ExecutorProvenanceError,
  557. "probe_media",
  558. ):
  559. validate_candidate_provenance(
  560. candidate,
  561. task,
  562. resolved_task,
  563. build_tool_call_records(messages),
  564. skill=load_executor_skill("reference-inspection"),
  565. input_refs={source_b},
  566. )
  567. def test_reference_inspection_adopts_real_viewed_image(self) -> None:
  568. with tempfile.TemporaryDirectory() as temp_dir:
  569. root = Path(temp_dir)
  570. image_path = root / "existing-reference.png"
  571. from PIL import Image
  572. Image.new("RGB", (24, 24), "green").save(image_path)
  573. brief_payload = json.loads(BRIEF_PATH.read_text(encoding="utf-8"))
  574. brief_payload["核心制作点"][0]["测试已有媒体"] = str(image_path)
  575. brief_path = root / "production_brief.json"
  576. brief_path.write_text(
  577. json.dumps(brief_payload, ensure_ascii=False),
  578. encoding="utf-8",
  579. )
  580. planned = build_planned_task(
  581. skill_id="reference-inspection",
  582. deliverable_type="reference_collection",
  583. max_attempts=2,
  584. )
  585. task = build_task_package(
  586. brief_path,
  587. planned_task=planned,
  588. )
  589. candidate = ExecutorCandidate(
  590. run_id=task.run_id,
  591. plan_id=task.plan_id,
  592. task_id=task.task_id,
  593. plan_version=task.plan_version,
  594. deliverable_type="reference_collection",
  595. artifacts=[
  596. CandidateArtifact(
  597. artifact_type="image",
  598. uri=str(image_path.resolve()),
  599. source_uri=str(image_path.resolve()),
  600. description="已检查并缓存的人物参考图",
  601. )
  602. ],
  603. artifact_binding_claims=[
  604. ArtifactBindingClaim(
  605. expectation_id="Requirement1-Expectation1",
  606. artifact_uri=str(image_path.resolve()),
  607. evidence_tool_call_ids=["probe-reference"],
  608. )
  609. ],
  610. summary="已有参考图可用于后续生产",
  611. )
  612. model = ToolAwareFakeChatModel(
  613. responses=[
  614. _tool_call(
  615. "probe_media",
  616. {"source": str(image_path.resolve())},
  617. "probe-reference",
  618. ),
  619. _tool_call(
  620. "view_images",
  621. {"image_sources": [str(image_path.resolve())]},
  622. "view-reference",
  623. ),
  624. AIMessage(content=candidate.model_dump_json()),
  625. AIMessage(content=candidate.model_dump_json()),
  626. ]
  627. )
  628. delivery = run_executor_agent(
  629. task,
  630. run_dir=root / "run",
  631. model=model,
  632. tool_registry=create_default_tool_registry(
  633. output_dir=root / "tool-output",
  634. ),
  635. )
  636. self.assertEqual(delivery.skill_id, "reference-inspection")
  637. self.assertEqual(delivery.artifacts[0].artifact_type, "image")
  638. self.assertEqual(
  639. delivery.artifacts[0].source_uri,
  640. str(image_path.resolve()),
  641. )
  642. def test_reference_review_accepts_separate_probe_and_view_caches(
  643. self,
  644. ) -> None:
  645. source_url = "https://example.test/existing-reference.png"
  646. # 使用一个真实存在、且与 source_url 不同的路径模拟 probe 缓存;
  647. # 文件内容不由该测试读取。
  648. probe_path = str(BRIEF_PATH.resolve())
  649. @tool("probe_media")
  650. def probe_media(source: str) -> dict:
  651. """探测测试媒体。"""
  652. return {
  653. "success": True,
  654. "source": source,
  655. "local_path": probe_path,
  656. "media_type": "image",
  657. "mime_type": "image/png",
  658. "width": 1080,
  659. "height": 1920,
  660. }
  661. @tool("view_images")
  662. def view_images(image_sources: list[str]) -> dict:
  663. """查看测试图片,并模拟另一套真实缓存路径。"""
  664. return {
  665. "success": True,
  666. "images": [
  667. {
  668. "source": source,
  669. "local_path": (
  670. "/cache/view/existing-reference.png"
  671. ),
  672. "data_url": (
  673. "data:image/png;base64,"
  674. "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
  675. ),
  676. }
  677. for source in image_sources
  678. ],
  679. }
  680. @tool("extract_frames")
  681. def extract_frames(video: str) -> dict:
  682. """占位视频抽帧工具。"""
  683. return {"success": True, "video": video, "frames": []}
  684. planned = build_planned_task(
  685. skill_id="reference-inspection",
  686. deliverable_type="reference_collection",
  687. max_attempts=1,
  688. )
  689. task = build_task_package(BRIEF_PATH, planned_task=planned)
  690. candidate = ExecutorCandidate(
  691. run_id=task.run_id,
  692. plan_id=task.plan_id,
  693. task_id=task.task_id,
  694. plan_version=task.plan_version,
  695. deliverable_type="reference_collection",
  696. artifacts=[
  697. CandidateArtifact(
  698. artifact_type="image",
  699. uri=probe_path,
  700. source_uri=source_url,
  701. description="已检查的人物参考图",
  702. )
  703. ],
  704. artifact_binding_claims=[
  705. ArtifactBindingClaim(
  706. expectation_id="Requirement1-Expectation1",
  707. artifact_uri=probe_path,
  708. evidence_tool_call_ids=["probe-reference"],
  709. )
  710. ],
  711. summary="参考图已经完成探测和查看",
  712. )
  713. model = ToolAwareFakeChatModel(
  714. responses=[
  715. _tool_call(
  716. "probe_media",
  717. {"source": source_url},
  718. "probe-reference",
  719. ),
  720. _tool_call(
  721. "view_images",
  722. {"image_sources": [source_url]},
  723. "view-reference",
  724. ),
  725. AIMessage(content=candidate.model_dump_json()),
  726. AIMessage(content=candidate.model_dump_json()),
  727. ]
  728. )
  729. with tempfile.TemporaryDirectory() as temp_dir:
  730. delivery = run_executor_agent(
  731. task,
  732. run_dir=Path(temp_dir),
  733. model=model,
  734. tool_registry=ToolRegistry(
  735. {
  736. item.name: item
  737. for item in (
  738. probe_media,
  739. view_images,
  740. extract_frames,
  741. )
  742. }
  743. ),
  744. )
  745. self.assertEqual(delivery.artifacts[0].uri, probe_path)
  746. self.assertEqual(delivery.artifacts[0].source_uri, source_url)
  747. def test_reference_video_review_links_source_to_probe_cache(
  748. self,
  749. ) -> None:
  750. source_url = "https://example.test/existing-reference.mp4"
  751. probe_path = str(BRIEF_PATH.resolve())
  752. frame_path = str(BRIEF_PATH.resolve())
  753. frame_url = "https://example.test/extracted-frame.png"
  754. @tool("probe_media")
  755. def probe_media(source: str) -> dict:
  756. """探测测试视频。"""
  757. return {
  758. "success": True,
  759. "source": source,
  760. "local_path": probe_path,
  761. "media_type": "video",
  762. "mime_type": "video/mp4",
  763. "duration_sec": 5,
  764. "width": 1080,
  765. "height": 1920,
  766. }
  767. @tool("extract_frames")
  768. def extract_frames(video: str) -> dict:
  769. """从原始来源抽取测试视频帧。"""
  770. return {
  771. "success": True,
  772. "video": video,
  773. "frames": [
  774. {
  775. "local_path": frame_path,
  776. "url": frame_url,
  777. }
  778. ],
  779. }
  780. @tool("view_images")
  781. def view_images(image_sources: list[str]) -> dict:
  782. """查看测试视频帧。"""
  783. return {
  784. "success": True,
  785. "images": [
  786. {
  787. "source": source,
  788. "local_path": source,
  789. "data_url": (
  790. "data:image/png;base64,"
  791. "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
  792. ),
  793. }
  794. for source in image_sources
  795. ],
  796. }
  797. planned = build_planned_task(
  798. skill_id="reference-inspection",
  799. deliverable_type="reference_collection",
  800. max_attempts=1,
  801. )
  802. task = build_task_package(
  803. BRIEF_PATH,
  804. planned_task=planned,
  805. expectation_artifact_type="video",
  806. )
  807. candidate = ExecutorCandidate(
  808. run_id=task.run_id,
  809. plan_id=task.plan_id,
  810. task_id=task.task_id,
  811. plan_version=task.plan_version,
  812. deliverable_type="reference_collection",
  813. artifacts=[
  814. CandidateArtifact(
  815. artifact_type="video",
  816. uri=probe_path,
  817. source_uri=source_url,
  818. description="已检查的动作参考视频",
  819. )
  820. ],
  821. artifact_binding_claims=[
  822. ArtifactBindingClaim(
  823. expectation_id="Requirement1-Expectation1",
  824. artifact_uri=probe_path,
  825. evidence_tool_call_ids=["probe-reference"],
  826. )
  827. ],
  828. summary="参考视频已经完成探测、抽帧和查看",
  829. )
  830. model = ToolAwareFakeChatModel(
  831. responses=[
  832. _tool_call(
  833. "probe_media",
  834. {"source": source_url},
  835. "probe-reference",
  836. ),
  837. _tool_call(
  838. "extract_frames",
  839. {"video": source_url},
  840. "extract-reference",
  841. ),
  842. _tool_call(
  843. "view_images",
  844. {"image_sources": [frame_url]},
  845. "view-reference",
  846. ),
  847. AIMessage(content=candidate.model_dump_json()),
  848. AIMessage(content=candidate.model_dump_json()),
  849. ]
  850. )
  851. with tempfile.TemporaryDirectory() as temp_dir:
  852. delivery = run_executor_agent(
  853. task,
  854. run_dir=Path(temp_dir),
  855. model=model,
  856. tool_registry=ToolRegistry(
  857. {
  858. item.name: item
  859. for item in (
  860. probe_media,
  861. view_images,
  862. extract_frames,
  863. )
  864. }
  865. ),
  866. )
  867. self.assertEqual(delivery.artifacts[0].uri, probe_path)
  868. self.assertEqual(delivery.artifacts[0].source_uri, source_url)
  869. def test_analysis_task_writes_local_structured_artifact(self) -> None:
  870. task = build_task_package(BRIEF_PATH)
  871. candidate = build_executor_candidate().model_copy(
  872. update={
  873. "run_id": "model-run",
  874. "plan_id": "ModelPlan",
  875. "task_id": "Task99",
  876. "plan_version": 99,
  877. "artifacts": [
  878. CandidateArtifact(
  879. artifact_type="structured_data",
  880. uri="delivery_artifact.json",
  881. description="由 Host 落盘的结构化结果",
  882. )
  883. ]
  884. }
  885. )
  886. self.assertEqual(candidate.schema_version, "0.3")
  887. with tempfile.TemporaryDirectory() as temp_dir:
  888. delivery = run_executor_agent(
  889. task,
  890. run_dir=Path(temp_dir),
  891. model=build_executor_model(candidate),
  892. tool_registry=_fake_tool_registry(),
  893. )
  894. self.assertEqual(delivery.skill_id, "structured-analysis")
  895. self.assertEqual(delivery.attempt_count, 1)
  896. self.assertEqual(
  897. delivery.artifacts[0].artifact_id,
  898. "Task1-v1-artifact-1",
  899. )
  900. self.assertIsInstance(delivery.summary, str)
  901. self.assertTrue(Path(delivery.manifest_uri).is_file())
  902. artifact_path = Path(delivery.artifacts[0].uri)
  903. self.assertTrue(artifact_path.is_file())
  904. self.assertNotEqual(
  905. artifact_path.resolve(),
  906. Path(delivery.manifest_uri).resolve(),
  907. )
  908. self.assertEqual(delivery.tool_calls, [])
  909. saved = ExecutorCandidate.model_validate_json(
  910. Path(delivery.manifest_uri).read_text(encoding="utf-8")
  911. )
  912. self.assertEqual(saved.artifacts, [])
  913. self.assertEqual(saved.run_id, task.run_id)
  914. self.assertEqual(saved.plan_id, task.plan_id)
  915. self.assertEqual(saved.task_id, task.task_id)
  916. self.assertEqual(saved.plan_version, task.plan_version)
  917. self.assertEqual(saved.payload["global_constraints"], ["统一主视觉"])
  918. materialized = json.loads(artifact_path.read_text(encoding="utf-8"))
  919. self.assertEqual(
  920. materialized["payload"]["global_constraints"],
  921. ["统一主视觉"],
  922. )
  923. def test_analysis_task_rejects_non_host_artifact_uri(self) -> None:
  924. planned = build_planned_task(max_attempts=1)
  925. task = build_task_package(BRIEF_PATH, planned_task=planned)
  926. candidate = build_executor_candidate().model_copy(
  927. update={
  928. "artifacts": [
  929. CandidateArtifact(
  930. artifact_type="structured_data",
  931. uri="another-result.json",
  932. description="未经 Host 声明的结构化结果",
  933. )
  934. ]
  935. }
  936. )
  937. with tempfile.TemporaryDirectory() as temp_dir:
  938. with self.assertRaisesRegex(
  939. ExecutorOutputError,
  940. "未出现在工具或依赖产物",
  941. ):
  942. run_executor_agent(
  943. task,
  944. run_dir=Path(temp_dir),
  945. model=build_executor_model(candidate),
  946. tool_registry=_fake_tool_registry(),
  947. )
  948. def test_same_task_execution_reuses_stable_agent_run(self) -> None:
  949. task = build_task_package(BRIEF_PATH)
  950. candidate = build_executor_candidate()
  951. model = build_executor_model(candidate, candidate)
  952. with tempfile.TemporaryDirectory() as temp_dir:
  953. first = run_executor_agent(
  954. task,
  955. run_dir=Path(temp_dir),
  956. model=model,
  957. tool_registry=_fake_tool_registry(),
  958. )
  959. second = run_executor_agent(
  960. task,
  961. run_dir=Path(temp_dir),
  962. model=model,
  963. tool_registry=_fake_tool_registry(),
  964. )
  965. self.assertEqual(first.executor_run_id, second.executor_run_id)
  966. def test_invalid_output_retries_inside_same_task(self) -> None:
  967. task = build_task_package(BRIEF_PATH)
  968. valid = build_executor_candidate()
  969. invalid = valid.model_dump(mode="json")
  970. invalid["payload"] = {}
  971. model = ToolAwareFakeChatModel(
  972. responses=[
  973. AIMessage(content=json.dumps(invalid, ensure_ascii=False)),
  974. AIMessage(content=valid.model_dump_json()),
  975. ]
  976. )
  977. with tempfile.TemporaryDirectory() as temp_dir:
  978. run_dir = Path(temp_dir)
  979. delivery = run_executor_agent(
  980. task,
  981. run_dir=run_dir,
  982. model=model,
  983. tool_registry=_fake_tool_registry(),
  984. )
  985. with create_sqlite_checkpointer(
  986. run_dir,
  987. filename="agent_checkpoints.sqlite",
  988. ) as checkpointer:
  989. messages = checkpoint_messages(
  990. checkpointer,
  991. {
  992. "configurable": {
  993. "thread_id": delivery.executor_run_id
  994. }
  995. },
  996. )
  997. self.assertEqual(delivery.attempt_count, 2)
  998. self.assertIn(task.task_id, delivery.executor_run_id)
  999. self.assertTrue(
  1000. any(
  1001. isinstance(message, HumanMessage)
  1002. and isinstance(message.content, str)
  1003. and CORRECTION_PRESERVATION_INSTRUCTION
  1004. in message.content
  1005. for message in messages
  1006. )
  1007. )
  1008. def test_host_derives_exact_evidence_call_ids_without_correction(
  1009. self,
  1010. ) -> None:
  1011. planned = build_planned_task(
  1012. skill_id="external-research",
  1013. deliverable_type="research_result",
  1014. max_attempts=2,
  1015. )
  1016. task = build_task_package(BRIEF_PATH, planned_task=planned)
  1017. candidate = build_executor_candidate(
  1018. deliverable_type="research_result",
  1019. artifact_uri=SOURCE_URL,
  1020. evidence_tool_call_ids=["model-invented-id"],
  1021. )
  1022. model = ToolAwareFakeChatModel(
  1023. responses=[
  1024. _tool_call("search_tool", {"query": "资料"}, "call-search"),
  1025. _tool_call(
  1026. "inspect_tool",
  1027. {"tool_ids": ["fake-tool"]},
  1028. "call-inspect",
  1029. ),
  1030. _tool_call(
  1031. "run_tool",
  1032. {"tool_id": "fake-tool", "params": {"kind": "research"}},
  1033. "call-run",
  1034. ),
  1035. AIMessage(content=candidate.model_dump_json()),
  1036. ]
  1037. )
  1038. with tempfile.TemporaryDirectory() as temp_dir:
  1039. run_dir = Path(temp_dir)
  1040. delivery = run_executor_agent(
  1041. task,
  1042. run_dir=run_dir,
  1043. model=model,
  1044. tool_registry=_fake_tool_registry(),
  1045. )
  1046. with create_sqlite_checkpointer(
  1047. run_dir,
  1048. filename="agent_checkpoints.sqlite",
  1049. ) as checkpointer:
  1050. messages = checkpoint_messages(
  1051. checkpointer,
  1052. {
  1053. "configurable": {
  1054. "thread_id": create_executor_run_id(task)
  1055. }
  1056. },
  1057. )
  1058. saved_candidate = ExecutorCandidate.model_validate_json(
  1059. Path(delivery.manifest_uri).read_bytes()
  1060. )
  1061. corrections = [
  1062. message.content
  1063. for message in messages
  1064. if isinstance(message, HumanMessage)
  1065. and isinstance(message.content, str)
  1066. and message.content.startswith("上一版未通过")
  1067. ]
  1068. self.assertEqual(delivery.attempt_count, 1)
  1069. self.assertEqual(corrections, [])
  1070. self.assertEqual(
  1071. delivery.artifact_expectation_bindings[
  1072. 0
  1073. ].evidence_tool_call_ids,
  1074. ["call-run"],
  1075. )
  1076. self.assertEqual(
  1077. saved_candidate.artifact_binding_claims[
  1078. 0
  1079. ].evidence_tool_call_ids,
  1080. ["call-run"],
  1081. )
  1082. def test_research_task_must_use_real_tool_output(self) -> None:
  1083. planned = build_planned_task(
  1084. skill_id="external-research",
  1085. deliverable_type="research_result",
  1086. )
  1087. task = build_task_package(BRIEF_PATH, planned_task=planned)
  1088. candidate = build_executor_candidate(
  1089. deliverable_type="research_result",
  1090. artifact_uri=SOURCE_URL,
  1091. evidence_tool_call_ids=["call-run"],
  1092. )
  1093. model = ToolAwareFakeChatModel(
  1094. responses=[
  1095. _tool_call("search_tool", {"query": "资料"}, "call-search"),
  1096. _tool_call(
  1097. "inspect_tool",
  1098. {"tool_ids": ["fake-tool"]},
  1099. "call-inspect",
  1100. ),
  1101. _tool_call(
  1102. "run_tool",
  1103. {"tool_id": "fake-tool", "params": {"kind": "research"}},
  1104. "call-run",
  1105. ),
  1106. AIMessage(content=candidate.model_dump_json()),
  1107. ]
  1108. )
  1109. with tempfile.TemporaryDirectory() as temp_dir:
  1110. delivery = run_executor_agent(
  1111. task,
  1112. run_dir=Path(temp_dir),
  1113. model=model,
  1114. tool_registry=_fake_tool_registry(),
  1115. )
  1116. self.assertEqual(
  1117. [item.tool_name for item in delivery.tool_calls],
  1118. ["search_tool", "inspect_tool", "run_tool"],
  1119. )
  1120. self.assertTrue(
  1121. [item.duration_ms for item in delivery.tool_calls] == [3, 4, 5]
  1122. )
  1123. self.assertEqual(
  1124. delivery.findings[0].source_urls,
  1125. [SOURCE_URL],
  1126. )
  1127. self.assertIn(
  1128. SOURCE_URL,
  1129. delivery.tool_calls[-1].output_excerpt or "",
  1130. )
  1131. def test_image_task_calls_generation_and_views_result(self) -> None:
  1132. planned = build_planned_task(
  1133. skill_id="image-production",
  1134. deliverable_type="image",
  1135. max_attempts=1,
  1136. )
  1137. task = build_task_package(BRIEF_PATH, planned_task=planned)
  1138. candidate = build_executor_candidate(
  1139. deliverable_type="image",
  1140. artifact_uri=IMAGE_URL,
  1141. evidence_tool_call_ids=["run"],
  1142. )
  1143. model = ToolAwareFakeChatModel(
  1144. responses=[
  1145. _tool_call("search_tool", {"query": "图片生成"}, "search"),
  1146. _tool_call(
  1147. "inspect_tool",
  1148. {"tool_ids": ["seedream_generate_image"]},
  1149. "inspect",
  1150. ),
  1151. _tool_call(
  1152. "run_tool",
  1153. {
  1154. "tool_id": "seedream_generate_image",
  1155. "params": {"kind": "image"},
  1156. },
  1157. "run",
  1158. ),
  1159. _tool_call(
  1160. "view_images",
  1161. {"image_sources": [IMAGE_URL]},
  1162. "view",
  1163. ),
  1164. AIMessage(content=candidate.model_dump_json()),
  1165. AIMessage(content=candidate.model_dump_json()),
  1166. ]
  1167. )
  1168. with tempfile.TemporaryDirectory() as temp_dir:
  1169. root = Path(temp_dir)
  1170. delivery = run_executor_agent(
  1171. task,
  1172. run_dir=root,
  1173. model=model,
  1174. tool_registry=_fake_tool_registry(),
  1175. )
  1176. config = {
  1177. "configurable": {
  1178. "thread_id": create_executor_run_id(task)
  1179. }
  1180. }
  1181. with create_sqlite_checkpointer(
  1182. root,
  1183. filename="agent_checkpoints.sqlite",
  1184. ) as checkpointer:
  1185. messages = checkpoint_messages(checkpointer, config)
  1186. self.assertEqual(
  1187. Path(delivery.artifacts[0].uri),
  1188. _fake_media_path(IMAGE_URL, ".png").resolve(),
  1189. )
  1190. self.assertEqual(delivery.artifacts[0].source_uri, IMAGE_URL)
  1191. self.assertFalse(
  1192. delivery.artifacts[0].uri.startswith(("http://", "https://"))
  1193. )
  1194. self.assertEqual(delivery.tool_calls[-1].tool_name, "view_images")
  1195. self.assertTrue(
  1196. any(
  1197. isinstance(message.content, list)
  1198. and any(
  1199. isinstance(block, dict)
  1200. and block.get("type") == "image_url"
  1201. for block in message.content
  1202. )
  1203. for message in messages
  1204. )
  1205. )
  1206. def test_research_run_requires_prior_matching_inspect(self) -> None:
  1207. planned = build_planned_task(
  1208. skill_id="external-research",
  1209. deliverable_type="research_result",
  1210. max_attempts=1,
  1211. )
  1212. task = build_task_package(BRIEF_PATH, planned_task=planned)
  1213. candidate = build_executor_candidate(
  1214. deliverable_type="research_result",
  1215. artifact_uri=SOURCE_URL,
  1216. )
  1217. model = ToolAwareFakeChatModel(
  1218. responses=[
  1219. _tool_call(
  1220. "run_tool",
  1221. {"tool_id": "fake-tool", "params": {"kind": "research"}},
  1222. "run",
  1223. ),
  1224. AIMessage(content=candidate.model_dump_json()),
  1225. ]
  1226. )
  1227. with tempfile.TemporaryDirectory() as temp_dir:
  1228. with self.assertRaisesRegex(
  1229. ExecutorOutputError,
  1230. "run_tool 前必须成功 inspect",
  1231. ):
  1232. run_executor_agent(
  1233. task,
  1234. run_dir=Path(temp_dir),
  1235. model=model,
  1236. tool_registry=_fake_tool_registry(),
  1237. )
  1238. def test_image_final_artifact_must_be_viewed(self) -> None:
  1239. planned = build_planned_task(
  1240. skill_id="image-production",
  1241. deliverable_type="image",
  1242. max_attempts=1,
  1243. )
  1244. task = build_task_package(BRIEF_PATH, planned_task=planned)
  1245. candidate = build_executor_candidate(
  1246. deliverable_type="image",
  1247. artifact_uri=IMAGE_URL,
  1248. )
  1249. model = ToolAwareFakeChatModel(
  1250. responses=[
  1251. _tool_call(
  1252. "inspect_tool",
  1253. {"tool_ids": ["seedream_generate_image"]},
  1254. "inspect",
  1255. ),
  1256. _tool_call(
  1257. "run_tool",
  1258. {
  1259. "tool_id": "seedream_generate_image",
  1260. "params": {"kind": "image"},
  1261. },
  1262. "run",
  1263. ),
  1264. _tool_call(
  1265. "view_images",
  1266. {"image_sources": [SOURCE_URL]},
  1267. "view",
  1268. ),
  1269. AIMessage(content=candidate.model_dump_json()),
  1270. ]
  1271. )
  1272. with tempfile.TemporaryDirectory() as temp_dir:
  1273. with self.assertRaisesRegex(
  1274. ExecutorOutputError,
  1275. "最终 Artifact 必须经过 view_images",
  1276. ):
  1277. run_executor_agent(
  1278. task,
  1279. run_dir=Path(temp_dir),
  1280. model=model,
  1281. tool_registry=_fake_tool_registry(),
  1282. )
  1283. def test_view_images_does_not_prove_image_origin(self) -> None:
  1284. planned = build_planned_task(
  1285. skill_id="image-production",
  1286. deliverable_type="image",
  1287. max_attempts=1,
  1288. )
  1289. task = build_task_package(BRIEF_PATH, planned_task=planned)
  1290. candidate = build_executor_candidate(
  1291. deliverable_type="image",
  1292. artifact_uri=IMAGE_URL,
  1293. )
  1294. model = ToolAwareFakeChatModel(
  1295. responses=[
  1296. _tool_call(
  1297. "view_images",
  1298. {"image_sources": [IMAGE_URL]},
  1299. "view",
  1300. ),
  1301. AIMessage(content=candidate.model_dump_json()),
  1302. ]
  1303. )
  1304. with tempfile.TemporaryDirectory() as temp_dir:
  1305. with self.assertRaisesRegex(
  1306. ExecutorOutputError,
  1307. "未出现在工具或依赖产物",
  1308. ):
  1309. run_executor_agent(
  1310. task,
  1311. run_dir=Path(temp_dir),
  1312. model=model,
  1313. tool_registry=_fake_tool_registry(),
  1314. )
  1315. def test_video_task_calls_generation_and_extracts_final_frames(self) -> None:
  1316. planned = build_planned_task(
  1317. skill_id="video-production",
  1318. deliverable_type="video",
  1319. )
  1320. task = build_task_package(BRIEF_PATH, planned_task=planned)
  1321. candidate = build_executor_candidate(
  1322. deliverable_type="video",
  1323. artifact_uri=VIDEO_URL,
  1324. evidence_tool_call_ids=["run"],
  1325. )
  1326. model = ToolAwareFakeChatModel(
  1327. responses=[
  1328. _tool_call(
  1329. "inspect_tool",
  1330. {"tool_ids": ["seedance_generate_video"]},
  1331. "inspect",
  1332. ),
  1333. _tool_call(
  1334. "run_tool",
  1335. {
  1336. "tool_id": "seedance_generate_video",
  1337. "params": {"kind": "video"},
  1338. },
  1339. "run",
  1340. ),
  1341. _tool_call(
  1342. "extract_frames",
  1343. {"video": VIDEO_URL, "num_frames": 3},
  1344. "extract",
  1345. ),
  1346. _tool_call(
  1347. "view_images",
  1348. {"image_sources": [FRAME_URL]},
  1349. "view-frames",
  1350. ),
  1351. AIMessage(content=candidate.model_dump_json()),
  1352. AIMessage(content=candidate.model_dump_json()),
  1353. ]
  1354. )
  1355. with tempfile.TemporaryDirectory() as temp_dir:
  1356. root = Path(temp_dir)
  1357. delivery = run_executor_agent(
  1358. task,
  1359. run_dir=root,
  1360. model=model,
  1361. tool_registry=_fake_tool_registry(),
  1362. )
  1363. config = {
  1364. "configurable": {
  1365. "thread_id": create_executor_run_id(task)
  1366. }
  1367. }
  1368. with create_sqlite_checkpointer(
  1369. root,
  1370. filename="agent_checkpoints.sqlite",
  1371. ) as checkpointer:
  1372. messages = checkpoint_messages(checkpointer, config)
  1373. self.assertEqual(
  1374. Path(delivery.artifacts[0].uri),
  1375. _fake_media_path(VIDEO_URL, ".mp4").resolve(),
  1376. )
  1377. self.assertEqual(delivery.artifacts[0].source_uri, VIDEO_URL)
  1378. self.assertEqual(delivery.tool_calls[-1].tool_name, "view_images")
  1379. self.assertTrue(
  1380. any(
  1381. isinstance(message.content, list)
  1382. and any(
  1383. isinstance(block, dict)
  1384. and block.get("type") == "image_url"
  1385. for block in message.content
  1386. )
  1387. for message in messages
  1388. )
  1389. )
  1390. def test_media_review_and_format_repairs_share_attempt_budget(self) -> None:
  1391. planned = build_planned_task(
  1392. skill_id="image-production",
  1393. deliverable_type="image",
  1394. max_attempts=2,
  1395. )
  1396. task = build_task_package(BRIEF_PATH, planned_task=planned)
  1397. candidate = build_executor_candidate(
  1398. deliverable_type="image",
  1399. artifact_uri=IMAGE_URL,
  1400. evidence_tool_call_ids=["run"],
  1401. )
  1402. model = ToolAwareFakeChatModel(
  1403. responses=[
  1404. _tool_call(
  1405. "inspect_tool",
  1406. {"tool_ids": ["seedream_generate_image"]},
  1407. "inspect",
  1408. ),
  1409. _tool_call(
  1410. "run_tool",
  1411. {
  1412. "tool_id": "seedream_generate_image",
  1413. "params": {"kind": "image"},
  1414. },
  1415. "run",
  1416. ),
  1417. _tool_call(
  1418. "view_images",
  1419. {"image_sources": [IMAGE_URL]},
  1420. "view",
  1421. ),
  1422. AIMessage(content="{}"),
  1423. AIMessage(content=candidate.model_dump_json()),
  1424. AIMessage(content=candidate.model_dump_json()),
  1425. ]
  1426. )
  1427. with tempfile.TemporaryDirectory() as temp_dir:
  1428. delivery = run_executor_agent(
  1429. task,
  1430. run_dir=Path(temp_dir),
  1431. model=model,
  1432. tool_registry=_fake_tool_registry(),
  1433. )
  1434. self.assertEqual(delivery.attempt_count, 2)
  1435. def test_media_artifact_changes_cannot_bypass_attempt_budget(self) -> None:
  1436. image_b = "https://example.test/generated-reference-b.png"
  1437. image_c = "https://example.test/generated-reference-c.png"
  1438. planned = build_planned_task(
  1439. skill_id="image-production",
  1440. deliverable_type="image",
  1441. max_attempts=2,
  1442. )
  1443. task = build_task_package(BRIEF_PATH, planned_task=planned)
  1444. candidate_a = build_executor_candidate(
  1445. deliverable_type="image",
  1446. artifact_uri=IMAGE_URL,
  1447. evidence_tool_call_ids=["run-a"],
  1448. )
  1449. candidate_b = build_executor_candidate(
  1450. deliverable_type="image",
  1451. artifact_uri=image_b,
  1452. evidence_tool_call_ids=["run-b"],
  1453. )
  1454. candidate_c = build_executor_candidate(
  1455. deliverable_type="image",
  1456. artifact_uri=image_c,
  1457. evidence_tool_call_ids=["run-c"],
  1458. )
  1459. model = ToolAwareFakeChatModel(
  1460. responses=[
  1461. _tool_call(
  1462. "inspect_tool",
  1463. {"tool_ids": ["seedream_generate_image"]},
  1464. "inspect",
  1465. ),
  1466. _tool_call(
  1467. "run_tool",
  1468. {
  1469. "tool_id": "seedream_generate_image",
  1470. "params": {
  1471. "kind": "image",
  1472. "result_url": IMAGE_URL,
  1473. },
  1474. },
  1475. "run-a",
  1476. ),
  1477. _tool_call(
  1478. "view_images",
  1479. {"image_sources": [IMAGE_URL]},
  1480. "view-a",
  1481. ),
  1482. AIMessage(content=candidate_a.model_dump_json()),
  1483. _tool_call(
  1484. "run_tool",
  1485. {
  1486. "tool_id": "seedream_generate_image",
  1487. "params": {
  1488. "kind": "image",
  1489. "result_url": image_b,
  1490. },
  1491. },
  1492. "run-b",
  1493. ),
  1494. _tool_call(
  1495. "view_images",
  1496. {"image_sources": [image_b]},
  1497. "view-b",
  1498. ),
  1499. AIMessage(content=candidate_b.model_dump_json()),
  1500. _tool_call(
  1501. "run_tool",
  1502. {
  1503. "tool_id": "seedream_generate_image",
  1504. "params": {
  1505. "kind": "image",
  1506. "result_url": image_c,
  1507. },
  1508. },
  1509. "run-c",
  1510. ),
  1511. _tool_call(
  1512. "view_images",
  1513. {"image_sources": [image_c]},
  1514. "view-c",
  1515. ),
  1516. AIMessage(content=candidate_c.model_dump_json()),
  1517. ]
  1518. )
  1519. with tempfile.TemporaryDirectory() as temp_dir:
  1520. with self.assertRaisesRegex(
  1521. ExecutorOutputError,
  1522. "耗尽修正次数",
  1523. ):
  1524. run_executor_agent(
  1525. task,
  1526. run_dir=Path(temp_dir),
  1527. model=model,
  1528. tool_registry=_fake_tool_registry(),
  1529. )
  1530. def test_fabricated_remote_uri_is_rejected(self) -> None:
  1531. planned = build_planned_task(
  1532. skill_id="external-research",
  1533. deliverable_type="research_result",
  1534. max_attempts=1,
  1535. )
  1536. task = build_task_package(BRIEF_PATH, planned_task=planned)
  1537. candidate = build_executor_candidate(
  1538. deliverable_type="research_result",
  1539. artifact_uri="https://fabricated.test/source",
  1540. )
  1541. model = ToolAwareFakeChatModel(
  1542. responses=[
  1543. _tool_call(
  1544. "run_tool",
  1545. {"tool_id": "fake-tool", "params": {"kind": "research"}},
  1546. "run",
  1547. ),
  1548. AIMessage(content=candidate.model_dump_json()),
  1549. ]
  1550. )
  1551. with tempfile.TemporaryDirectory() as temp_dir:
  1552. with self.assertRaisesRegex(
  1553. ExecutorOutputError,
  1554. "未出现在工具",
  1555. ):
  1556. run_executor_agent(
  1557. task,
  1558. run_dir=Path(temp_dir),
  1559. model=model,
  1560. tool_registry=_fake_tool_registry(),
  1561. )
  1562. def test_analysis_payload_cannot_hide_a_fabricated_url(self) -> None:
  1563. planned = build_planned_task(max_attempts=1)
  1564. task = build_task_package(BRIEF_PATH, planned_task=planned)
  1565. candidate = build_executor_candidate()
  1566. candidate.payload["fake_reference"] = "https://fabricated.test/image.png"
  1567. with tempfile.TemporaryDirectory() as temp_dir:
  1568. with self.assertRaisesRegex(
  1569. ExecutorOutputError,
  1570. "未出现在工具",
  1571. ):
  1572. run_executor_agent(
  1573. task,
  1574. run_dir=Path(temp_dir),
  1575. model=build_executor_model(candidate),
  1576. tool_registry=_fake_tool_registry(),
  1577. )
  1578. if __name__ == "__main__":
  1579. unittest.main()