test_executor.py 50 KB

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