test_context_broker_integration.py 41 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157
  1. from __future__ import annotations
  2. import asyncio
  3. import json
  4. from dataclasses import asdict, dataclass
  5. from types import SimpleNamespace
  6. from typing import Any
  7. import pytest
  8. from agent.orchestration import (
  9. AgentRole,
  10. ArtifactRef,
  11. DecisionAction,
  12. RoleContextRequest,
  13. TaskStatus,
  14. )
  15. from script_build_host.application.context_semantics import AdaptiveSemanticSelector
  16. from script_build_host.application.mission_workbench import (
  17. MissionWorkbench,
  18. WorkbenchError,
  19. required_context_source_types,
  20. )
  21. from script_build_host.domain.context_broker import ContextCard, estimate_context_tokens
  22. from script_build_host.domain.task_contracts import (
  23. AcceptedDecisionRef,
  24. ScriptCriterion,
  25. ScriptIntentClass,
  26. ScriptTaskBudget,
  27. ScriptTaskContractV1,
  28. ScriptTaskKind,
  29. )
  30. class _TaskStore:
  31. def __init__(self, ledger: Any) -> None:
  32. self.ledger = ledger
  33. async def load(self, _root_trace_id: str) -> Any:
  34. return self.ledger
  35. @pytest.mark.parametrize(
  36. ("kind", "required"),
  37. [
  38. (ScriptTaskKind.PATTERN_RETRIEVAL, {"topic"}),
  39. (ScriptTaskKind.DECODE_RETRIEVAL, {"topic"}),
  40. (ScriptTaskKind.EXTERNAL_RETRIEVAL, {"topic"}),
  41. (ScriptTaskKind.KNOWLEDGE_RETRIEVAL, {"topic"}),
  42. (
  43. ScriptTaskKind.DIRECTION,
  44. {"topic", "persona", "section_pattern", "strategy"},
  45. ),
  46. (ScriptTaskKind.STRUCTURE, {"direction", "section_pattern"}),
  47. (ScriptTaskKind.PARAGRAPH, {"direction", "structure", "persona"}),
  48. (ScriptTaskKind.ELEMENT_SET, {"paragraph"}),
  49. (ScriptTaskKind.COMPARE, set()),
  50. (
  51. ScriptTaskKind.ROOT_DELIVERY,
  52. {"direction", "candidate-portfolio", "structured-script"},
  53. ),
  54. ],
  55. )
  56. def test_every_e2e_role_has_one_authoritative_context_source_policy(
  57. kind: ScriptTaskKind, required: set[str]
  58. ) -> None:
  59. assert set(required_context_source_types(kind)) == required
  60. class _Bindings:
  61. async def get_by_root(self, root_trace_id: str) -> Any:
  62. return SimpleNamespace(
  63. root_trace_id=root_trace_id,
  64. script_build_id=900049,
  65. input_snapshot_id=3,
  66. active_direction_artifact_version_id=None,
  67. )
  68. class _Snapshots:
  69. def __init__(self) -> None:
  70. self.value = SimpleNamespace(
  71. snapshot_id="3",
  72. topic_id=49,
  73. topic={"topic": {"result": "健身房中的职场反转故事"}},
  74. account={"account_name": "fixture"},
  75. persona_points=tuple(
  76. {
  77. "列": ("实质", "形式", "作用", "感受")[index % 4],
  78. "点名称": f"persona-{index}",
  79. "人设权重分": 1 - index / 100,
  80. "帖子覆盖率": 0.8,
  81. }
  82. for index in range(80)
  83. ),
  84. section_patterns=tuple(
  85. {"模式名称": f"section-{index}", "说明": "转折节奏"} for index in range(40)
  86. ),
  87. strategies=(
  88. {"name": "must", "mode": "always_on", "content": "始终使用可视化叙事"},
  89. {"name": "optional", "mode": "on_demand", "content": "按需策略" * 2_000},
  90. ),
  91. model_manifest={"presets": {"script_context_broker": {"model": "fake"}}},
  92. )
  93. async def get(self, _snapshot_id: str, *, script_build_id: int) -> Any:
  94. assert script_build_id == 900049
  95. return self.value
  96. class _Contracts:
  97. def __init__(self, values: dict[str, ScriptTaskContractV1]) -> None:
  98. self.values = values
  99. async def read(self, _root: str, ref: str) -> Any:
  100. return SimpleNamespace(contract=self.values[ref])
  101. @dataclass
  102. class _Artifact:
  103. text: str
  104. raw_artifact_ref: str | None = None
  105. def content_payload(self) -> dict[str, Any]:
  106. return {"schema_version": "fixture", "full_description": self.text}
  107. class _Artifacts:
  108. def __init__(self, values: dict[str, _Artifact]) -> None:
  109. self.values = values
  110. async def read_by_ref(self, ref: ArtifactRef, *, script_build_id: int, **_: Any) -> Any:
  111. assert script_build_id == 900049
  112. return SimpleNamespace(artifact=self.values[ref.uri])
  113. class _RawStore:
  114. def __init__(self, raw_ref: str, payload: dict[str, Any]) -> None:
  115. self.raw_ref = raw_ref
  116. self.content = json.dumps(payload, ensure_ascii=False).encode()
  117. self.reads = 0
  118. async def read_bytes(self, ref: str) -> bytes:
  119. assert ref == self.raw_ref
  120. self.reads += 1
  121. return self.content
  122. class _ReceiptStore:
  123. def __init__(self) -> None:
  124. self.events: list[dict[str, Any]] = []
  125. async def append_event(self, _trace_id: str, event: str, payload: dict[str, Any]) -> int:
  126. event_id = len(self.events) + 1
  127. self.events.append({"event_id": event_id, "event": event, **payload})
  128. return event_id
  129. async def get_events(self, _trace_id: str) -> list[dict[str, Any]]:
  130. return list(self.events)
  131. def _contract(kind: ScriptTaskKind, index: int) -> ScriptTaskContractV1:
  132. return ScriptTaskContractV1(
  133. task_kind=kind,
  134. scope_ref=f"script-build://scopes/full/part-{index}",
  135. intent_class=(
  136. ScriptIntentClass.EXPLORE
  137. if kind is not ScriptTaskKind.PATTERN_RETRIEVAL
  138. else ScriptIntentClass.EXPAND
  139. ),
  140. objective=f"task objective {index}",
  141. input_decision_refs=(),
  142. base_artifact_ref=None,
  143. write_scope=(f"script-build://writes/part-{index}",),
  144. gap_ref=None,
  145. output_schema=(
  146. "evidence-record/v1"
  147. if kind is ScriptTaskKind.PATTERN_RETRIEVAL
  148. else "paragraph-artifact/v1"
  149. ),
  150. criteria=(ScriptCriterion(f"c-{index}", "quality"),),
  151. budget=ScriptTaskBudget(),
  152. goal_ids=(f"g-{index % 6}",) if kind is not ScriptTaskKind.PATTERN_RETRIEVAL else (),
  153. compiler_manifest_digest="sha256:" + "a" * 64,
  154. )
  155. def _fixture() -> tuple[MissionWorkbench, Any, _Snapshots, str]:
  156. root = SimpleNamespace(
  157. task_id="root-task",
  158. parent_task_id=None,
  159. child_task_ids=[],
  160. display_path="1",
  161. status=TaskStatus.RUNNING,
  162. blocked_reason=None,
  163. current_spec=SimpleNamespace(objective="build complete script", context_refs=()),
  164. current_spec_version=1,
  165. attempt_ids=[],
  166. decision_ids=[],
  167. )
  168. tasks = {"root-task": root}
  169. attempts: dict[str, Any] = {}
  170. decisions: dict[str, Any] = {}
  171. contracts: dict[str, ScriptTaskContractV1] = {}
  172. artifacts: dict[str, _Artifact] = {}
  173. for index in range(58):
  174. task_id = f"task-{index}"
  175. contract_ref = f"script-build://task-contracts/sha256/{index:064x}"
  176. accepted = index < 33
  177. attempt_id = f"attempt-{index}"
  178. decision_id = f"decision-{index}"
  179. artifact_uri = f"script-build://artifact-versions/{index + 1}"
  180. task = SimpleNamespace(
  181. task_id=task_id,
  182. parent_task_id="root-task",
  183. child_task_ids=[],
  184. display_path=f"1.{index + 1}",
  185. status=TaskStatus.COMPLETED if accepted else TaskStatus.PENDING,
  186. blocked_reason=None,
  187. current_spec=SimpleNamespace(
  188. objective=f"objective {index}",
  189. context_refs=("script-build://task-kinds/paragraph", contract_ref),
  190. ),
  191. current_spec_version=1,
  192. attempt_ids=[attempt_id] if accepted else [],
  193. decision_ids=[decision_id] if accepted else [],
  194. )
  195. tasks[task_id] = task
  196. contracts[contract_ref] = _contract(ScriptTaskKind.PARAGRAPH, index)
  197. if accepted:
  198. ref = ArtifactRef(
  199. uri=artifact_uri,
  200. kind="paragraph",
  201. version=str(index + 1),
  202. digest="sha256:" + f"{index + 1:064x}",
  203. )
  204. attempts[attempt_id] = SimpleNamespace(
  205. task_id=task_id,
  206. submission=SimpleNamespace(artifact_refs=(ref,), evidence_refs=()),
  207. failure=None,
  208. status=SimpleNamespace(value="succeeded"),
  209. )
  210. decisions[decision_id] = SimpleNamespace(
  211. decision_id=decision_id,
  212. task_id=task_id,
  213. action=DecisionAction.ACCEPT,
  214. attempt_id=attempt_id,
  215. reason=f"FORGED REASON {index}",
  216. )
  217. artifacts[artifact_uri] = _Artifact(f"REAL ARTIFACT CONTENT {index}")
  218. raw_ref = "script-build://raw-artifacts/sha256/" + "b" * 64
  219. pattern_index = 58
  220. task_id = "pattern-task"
  221. contract_ref = "script-build://task-contracts/sha256/" + "f" * 64
  222. attempt_id = "pattern-attempt"
  223. decision_id = "pattern-decision"
  224. evidence_ref = ArtifactRef(
  225. uri="script-build://artifact-versions/100",
  226. kind="evidence",
  227. version="100",
  228. digest="sha256:" + "c" * 64,
  229. )
  230. tasks[task_id] = SimpleNamespace(
  231. task_id=task_id,
  232. parent_task_id="root-task",
  233. child_task_ids=[],
  234. display_path="1.59",
  235. status=TaskStatus.COMPLETED,
  236. blocked_reason=None,
  237. current_spec=SimpleNamespace(
  238. objective="retrieve patterns",
  239. context_refs=("script-build://task-kinds/pattern-retrieval", contract_ref),
  240. ),
  241. current_spec_version=1,
  242. attempt_ids=[attempt_id],
  243. decision_ids=[decision_id],
  244. )
  245. contracts[contract_ref] = _contract(ScriptTaskKind.PATTERN_RETRIEVAL, pattern_index)
  246. attempts[attempt_id] = SimpleNamespace(
  247. task_id=task_id,
  248. submission=SimpleNamespace(artifact_refs=(), evidence_refs=(evidence_ref,)),
  249. failure=None,
  250. status=SimpleNamespace(value="succeeded"),
  251. )
  252. decisions[decision_id] = SimpleNamespace(
  253. decision_id=decision_id,
  254. task_id=task_id,
  255. action=DecisionAction.ACCEPT,
  256. attempt_id=attempt_id,
  257. reason="pattern accepted",
  258. )
  259. artifacts[evidence_ref.uri] = _Artifact("pattern summary", raw_artifact_ref=raw_ref)
  260. ledger = SimpleNamespace(
  261. revision=49,
  262. root_task_id="root-task",
  263. focused_task_id="task-57",
  264. tasks=tasks,
  265. attempts=attempts,
  266. decisions=decisions,
  267. operations={},
  268. )
  269. patterns = {
  270. "candidates": [
  271. {"name": f"pattern-{index}", "content": "模式详情" * 350} for index in range(193)
  272. ]
  273. }
  274. snapshots = _Snapshots()
  275. workbench = MissionWorkbench(
  276. task_store=_TaskStore(ledger),
  277. bindings=_Bindings(),
  278. snapshots=snapshots,
  279. artifacts=_Artifacts(artifacts),
  280. contracts=_Contracts(contracts),
  281. raw_artifacts=_RawStore(raw_ref, patterns),
  282. )
  283. return workbench, ledger, snapshots, raw_ref
  284. @pytest.mark.asyncio
  285. async def test_planner_hot_context_is_bounded_and_every_item_is_pageable() -> None:
  286. workbench, _ledger, _snapshots, _ = _fixture()
  287. state = await workbench.planner_state("root")
  288. assert state.active_total == 26
  289. assert state.active_has_more is True
  290. assert state.active_subgraph[0]["task_id"] == "task-57"
  291. assert state.accepted_total == 34
  292. encoded = json.dumps(state.to_payload(), ensure_ascii=False)
  293. assert len(encoded) < 24_000
  294. assert "FORGED REASON" not in " ".join(str(item["summary"]) for item in state.accepted_frontier)
  295. assert any("REAL ARTIFACT CONTENT" in str(item["summary"]) for item in state.accepted_frontier)
  296. input_cards = state.context_bundle["cards"]
  297. assert input_cards
  298. assert input_cards[0]["source_type"] == "topic"
  299. assert input_cards[0]["handle"].startswith("input_")
  300. input_page = await workbench.read_mission_context(
  301. root_trace_id="root",
  302. role="planner",
  303. handle=input_cards[0]["handle"],
  304. )
  305. assert input_page["content_fragment"]
  306. cursor = None
  307. found: list[str] = []
  308. while True:
  309. page = await workbench.search_mission_context(
  310. root_trace_id="root",
  311. role="planner",
  312. collection="accepted_decisions",
  313. cursor=cursor,
  314. )
  315. found.extend(item["handle"] for item in page["page"]["items"])
  316. cursor = page["page"]["next_cursor"]
  317. if cursor is None:
  318. break
  319. assert len(found) == 34
  320. assert len(set(found)) == 34
  321. @pytest.mark.asyncio
  322. async def test_duplicate_long_inputs_have_unique_handles_and_budgeted_finite_pagination() -> None:
  323. workbench, _ledger, snapshots, _ = _fixture()
  324. snapshots.value.persona_points = tuple(
  325. {"列": "实质", "点名称": "相同人设" * 350, "人设权重分": 1, "帖子覆盖率": 1}
  326. for _ in range(30)
  327. )
  328. cursor = None
  329. handles: list[str] = []
  330. while True:
  331. page = await workbench.search_mission_context(
  332. root_trace_id="root",
  333. role="planner",
  334. collection="inputs",
  335. source_types=("persona",),
  336. page_size=20,
  337. cursor=cursor,
  338. )
  339. assert page["receipt"]["estimated_tokens"] <= 2_000
  340. handles.extend(item["handle"] for item in page["page"]["items"])
  341. cursor = page["page"]["next_cursor"]
  342. if cursor is None:
  343. break
  344. assert len(handles) == len(set(handles)) == 30
  345. @pytest.mark.asyncio
  346. async def test_role_selected_persona_handles_keep_snapshot_identity_and_are_readable() -> None:
  347. workbench, ledger, _snapshots, _ = _fixture()
  348. state = await workbench.worker_state(
  349. RoleContextRequest(
  350. role=AgentRole.WORKER,
  351. root_trace_id="root",
  352. task_id="task-57",
  353. spec_version=1,
  354. task_spec={},
  355. attempt_id="persona-attempt",
  356. ledger_revision=ledger.revision,
  357. )
  358. )
  359. persona_handles = [
  360. item["handle"] for item in state.context_bundle["cards"] if item["source_type"] == "persona"
  361. ]
  362. assert len(persona_handles) == 12
  363. for handle in persona_handles:
  364. page = await workbench.read_mission_context(
  365. root_trace_id="root",
  366. role="worker",
  367. task_id="task-57",
  368. attempt_id="persona-attempt",
  369. handle=handle,
  370. )
  371. assert page["source_handle"] == handle
  372. assert page["content_fragment"]
  373. @pytest.mark.asyncio
  374. async def test_cursor_revision_tamper_and_cross_root_are_rejected() -> None:
  375. workbench, ledger, _snapshots, _ = _fixture()
  376. first = await workbench.search_mission_context(
  377. root_trace_id="root", role="planner", collection="tasks", page_size=5
  378. )
  379. cursor = first["page"]["next_cursor"]
  380. assert cursor
  381. with pytest.raises(WorkbenchError, match="invalid context cursor"):
  382. await workbench.search_mission_context(
  383. root_trace_id="root",
  384. role="planner",
  385. collection="tasks",
  386. cursor=cursor[:-1] + ("A" if cursor[-1] != "A" else "B"),
  387. page_size=5,
  388. )
  389. with pytest.raises(WorkbenchError) as cross_root:
  390. await workbench.search_mission_context(
  391. root_trace_id="other-root",
  392. role="planner",
  393. collection="tasks",
  394. cursor=cursor,
  395. page_size=5,
  396. )
  397. assert cross_root.value.code == "CONTEXT_CURSOR_SCOPE_MISMATCH"
  398. ledger.revision += 1
  399. with pytest.raises(WorkbenchError) as stale:
  400. await workbench.search_mission_context(
  401. root_trace_id="root",
  402. role="planner",
  403. collection="tasks",
  404. cursor=cursor,
  405. page_size=5,
  406. )
  407. assert stale.value.code == "STALE_WORKBENCH_STATE"
  408. @pytest.mark.asyncio
  409. async def test_inputs_and_193_patterns_are_bounded_but_fully_recoverable() -> None:
  410. workbench, ledger, _snapshots, _ = _fixture()
  411. inputs = await workbench.search_mission_context(
  412. root_trace_id="root", role="planner", collection="inputs", query="optional"
  413. )
  414. strategy = next(item for item in inputs["page"]["items"] if item["source_type"] == "strategy")
  415. detail = await workbench.read_mission_context(
  416. root_trace_id="root", role="planner", handle=strategy["handle"]
  417. )
  418. assert estimate_context_tokens(detail) <= 2_000
  419. assert isinstance(detail["next_cursor"], str)
  420. with pytest.raises(WorkbenchError) as tampered_detail:
  421. await workbench.read_mission_context(
  422. root_trace_id="root",
  423. role="planner",
  424. handle=strategy["handle"],
  425. cursor=detail["next_cursor"][:-1] + "x",
  426. )
  427. assert tampered_detail.value.code == "CONTEXT_CURSOR_INVALID"
  428. fragments = [detail["content_fragment"]]
  429. while detail["next_cursor"] is not None:
  430. detail = await workbench.read_mission_context(
  431. root_trace_id="root",
  432. role="planner",
  433. handle=strategy["handle"],
  434. cursor=detail["next_cursor"],
  435. )
  436. fragments.append(detail["content_fragment"])
  437. assert "按需策略" in "".join(fragments)
  438. cursor = None
  439. immutable_cursor = None
  440. found: list[dict[str, Any]] = []
  441. while True:
  442. page = await workbench.search_mission_context(
  443. root_trace_id="root",
  444. role="planner",
  445. collection="patterns",
  446. cursor=cursor,
  447. page_size=20,
  448. )
  449. found.extend(page["page"]["items"])
  450. cursor = page["page"]["next_cursor"]
  451. immutable_cursor = immutable_cursor or cursor
  452. if cursor is None:
  453. break
  454. assert len(found) == 193
  455. assert len({item["handle"] for item in found}) == 193
  456. raw_prompt = json.dumps(
  457. (await workbench.planner_state("root")).to_payload(), ensure_ascii=False
  458. )
  459. assert len(raw_prompt) < 24_000
  460. assert "模式详情" * 20 not in raw_prompt
  461. ledger.revision += 1
  462. immutable_page = await workbench.search_mission_context(
  463. root_trace_id="root",
  464. role="planner",
  465. collection="patterns",
  466. cursor=immutable_cursor,
  467. page_size=20,
  468. )
  469. assert immutable_page["page"]["total"] == 193
  470. assert workbench.broker._raw_artifacts.reads == 1
  471. @pytest.mark.asyncio
  472. async def test_semantic_selector_cannot_escape_allowlist(tmp_path) -> None:
  473. calls = 0
  474. async def llm_call(**_: Any) -> dict[str, Any]:
  475. nonlocal calls
  476. calls += 1
  477. return {"content": json.dumps({"handles": ["bad_handle"]})}
  478. selector = AdaptiveSemanticSelector(llm_call=llm_call, cache_root=tmp_path)
  479. workbench, ledger, snapshots, _ = _fixture()
  480. workbench = MissionWorkbench(
  481. task_store=_TaskStore(ledger),
  482. bindings=_Bindings(),
  483. snapshots=snapshots,
  484. artifacts=workbench.broker._artifacts,
  485. contracts=workbench.broker._contracts,
  486. raw_artifacts=workbench.broker._raw_artifacts,
  487. semantic_selector=selector,
  488. )
  489. result = await workbench.search_mission_context(
  490. root_trace_id="root", role="planner", collection="inputs", query="not present"
  491. )
  492. handles = {item["handle"] for item in result["page"]["items"]}
  493. assert "bad_handle" not in handles
  494. assert result["receipt"]["semantic_calls"] == 1
  495. assert result["receipt"]["fallback_reason"]
  496. assert calls == 1
  497. @pytest.mark.asyncio
  498. async def test_semantic_selector_caches_valid_allowlisted_order(tmp_path) -> None:
  499. calls = 0
  500. async def llm_call(**kwargs: Any) -> dict[str, Any]:
  501. nonlocal calls
  502. calls += 1
  503. request = json.loads(kwargs["messages"][1]["content"])
  504. handles = [item["handle"] for item in request["candidates"]]
  505. return {"content": json.dumps({"handles": list(reversed(handles))})}
  506. selector = AdaptiveSemanticSelector(llm_call=llm_call, cache_root=tmp_path)
  507. cards = (
  508. ContextCard(handle="one", source_type="input", summary="alpha"),
  509. ContextCard(handle="two", source_type="input", summary="beta"),
  510. )
  511. first = await selector.select(
  512. root_trace_id="root", query="similar", cards=cards, model_config={"model": "fake"}
  513. )
  514. second = await selector.select(
  515. root_trace_id="root", query="similar", cards=cards, model_config={"model": "fake"}
  516. )
  517. assert first.handles == ("two", "one")
  518. assert second.handles == first.handles
  519. assert second.cache_hit is True
  520. assert calls == 1
  521. @pytest.mark.asyncio
  522. async def test_semantic_cache_survives_call_limit_and_timeout_falls_back(tmp_path) -> None:
  523. calls = 0
  524. async def llm_call(**_: Any) -> dict[str, Any]:
  525. nonlocal calls
  526. calls += 1
  527. return {"content": json.dumps({"handles": ["one", "two"]})}
  528. cards = (
  529. ContextCard(handle="one", source_type="input", summary="alpha"),
  530. ContextCard(handle="two", source_type="input", summary="beta"),
  531. )
  532. selector = AdaptiveSemanticSelector(
  533. llm_call=llm_call, cache_root=tmp_path, mission_call_limit=1
  534. )
  535. first = await selector.select(
  536. root_trace_id="root", query="same", cards=cards, model_config={"model": "fake"}
  537. )
  538. restarted_selector = AdaptiveSemanticSelector(
  539. llm_call=llm_call, cache_root=tmp_path, mission_call_limit=1
  540. )
  541. cached = await restarted_selector.select(
  542. root_trace_id="root", query="same", cards=cards, model_config={"model": "fake"}
  543. )
  544. limited = await restarted_selector.select(
  545. root_trace_id="root", query="different", cards=cards, model_config={"model": "fake"}
  546. )
  547. assert first.calls == 1
  548. assert cached.cache_hit is True
  549. assert limited.fallback_reason == "semantic_call_limit"
  550. assert calls == 1
  551. async def slow_call(**_: Any) -> dict[str, Any]:
  552. await asyncio.sleep(0.05)
  553. return {"content": "{}"}
  554. timeout_selector = AdaptiveSemanticSelector(
  555. llm_call=slow_call,
  556. cache_root=tmp_path / "timeout",
  557. timeout_seconds=0.001,
  558. )
  559. timed_out = await timeout_selector.select(
  560. root_trace_id="timeout-root",
  561. query="slow",
  562. cards=cards,
  563. model_config={"model": "fake"},
  564. )
  565. assert timed_out.calls == 1
  566. assert timed_out.fallback_reason == "TimeoutError:semantic_fallback"
  567. @pytest.mark.asyncio
  568. async def test_semantic_rerank_never_removes_candidates_beyond_its_window(tmp_path) -> None:
  569. semantic_first: list[str] = []
  570. async def llm_call(**kwargs: Any) -> dict[str, Any]:
  571. request = json.loads(kwargs["messages"][1]["content"])
  572. handles = [item["handle"] for item in request["candidates"]]
  573. semantic_first[:] = [handles[-1]]
  574. return {"content": json.dumps({"handles": list(reversed(handles))})}
  575. selector = AdaptiveSemanticSelector(llm_call=llm_call, cache_root=tmp_path)
  576. workbench, ledger, snapshots, _ = _fixture()
  577. workbench = MissionWorkbench(
  578. task_store=_TaskStore(ledger),
  579. bindings=_Bindings(),
  580. snapshots=snapshots,
  581. artifacts=workbench.broker._artifacts,
  582. contracts=workbench.broker._contracts,
  583. raw_artifacts=workbench.broker._raw_artifacts,
  584. semantic_selector=selector,
  585. )
  586. cursor = None
  587. handles: list[str] = []
  588. while True:
  589. result = await workbench.search_mission_context(
  590. root_trace_id="root",
  591. role="planner",
  592. collection="inputs",
  593. query="unmatched semantic query",
  594. page_size=20,
  595. cursor=cursor,
  596. )
  597. handles.extend(item["handle"] for item in result["page"]["items"])
  598. cursor = result["page"]["next_cursor"]
  599. if cursor is None:
  600. break
  601. assert len(handles) == 123
  602. assert len(set(handles)) == 123
  603. assert handles[0] == semantic_first[0]
  604. @pytest.mark.asyncio
  605. async def test_build_900049_shape_automatic_refresh_stays_bounded_and_reduces_by_half() -> None:
  606. workbench, ledger, _snapshots, _ = _fixture()
  607. refreshes = [await workbench.render("root", ledger) for _ in range(11)]
  608. # The framework refreshes every five turns and marks each result output-only-once,
  609. # so only the latest bounded projection remains in the provider prompt.
  610. assert len(set(refreshes)) == 1
  611. bounded = len(refreshes[-1])
  612. legacy_round = json.dumps(
  613. {
  614. "task_tree": [task.current_spec.objective for task in ledger.tasks.values()],
  615. "accepted_artifacts": ["x" * 4_000 for _ in range(33)],
  616. "pattern_payload": ["模式详情" * 350 for _ in range(193)],
  617. },
  618. ensure_ascii=False,
  619. )
  620. legacy = len(legacy_round) * 54
  621. assert bounded < legacy * 0.50
  622. assert bounded < 24_000
  623. @pytest.mark.asyncio
  624. async def test_root_validator_gets_full_closure_as_required_pages_when_large() -> None:
  625. workbench, ledger, snapshots, _ = _fixture()
  626. contract_ref = "script-build://task-contracts/sha256/" + "d" * 64
  627. contract = SimpleNamespace(
  628. task_kind=ScriptTaskKind.ROOT_DELIVERY,
  629. scope_ref="script-build://scopes/full",
  630. intent_class=ScriptIntentClass.DELIVER,
  631. objective="deliver",
  632. goal_ids=("g-1",),
  633. criteria=(ScriptCriterion("root", "complete"),),
  634. input_decision_refs=(),
  635. candidate_closure_decision_refs=(),
  636. comparison_decision_refs=(),
  637. base_artifact_ref=None,
  638. to_payload=lambda: {"task_kind": "root-delivery"},
  639. )
  640. ledger.tasks["root-task"].current_spec = SimpleNamespace(
  641. objective="deliver",
  642. context_refs=("script-build://task-kinds/root-delivery", contract_ref),
  643. )
  644. workbench.broker._contracts.values[contract_ref] = contract
  645. refs = tuple(
  646. ArtifactRef(
  647. uri=f"script-build://artifact-versions/{200 + index}",
  648. kind=kind,
  649. version=str(200 + index),
  650. digest="sha256:" + f"{200 + index:064x}",
  651. )
  652. for index, kind in enumerate(("direction", "candidate-portfolio", "structured-script"))
  653. )
  654. for index, ref in enumerate(refs):
  655. workbench.broker._artifacts.values[ref.uri] = _Artifact(
  656. f"ROOT CLOSURE {index} " + "正文" * 12_000
  657. )
  658. class RootTools:
  659. async def validation_evidence_refs(self, *, context: dict[str, Any]):
  660. assert context["task_id"] == "root-task"
  661. return refs
  662. root_workbench = MissionWorkbench(
  663. task_store=workbench.broker._task_store,
  664. bindings=_Bindings(),
  665. snapshots=snapshots,
  666. artifacts=workbench.broker._artifacts,
  667. contracts=workbench.broker._contracts,
  668. root_tools=RootTools(),
  669. )
  670. request = RoleContextRequest(
  671. role=AgentRole.VALIDATOR,
  672. root_trace_id="root",
  673. task_id="root-task",
  674. spec_version=1,
  675. task_spec={},
  676. attempt_id="root-attempt",
  677. ledger_revision=ledger.revision,
  678. snapshot_id="root-snapshot",
  679. artifact_snapshot={
  680. "snapshot_id": "root-snapshot",
  681. "artifact_refs": [asdict(refs[-1])],
  682. "evidence_refs": [],
  683. },
  684. )
  685. state = await root_workbench.validator_state(request)
  686. assert state.artifact["full_content_in_bootstrap"] is False
  687. assert len(state.required_handles) == 3
  688. assert state.context_bundle["sufficiency"]["status"] == "ambiguous"
  689. assert (
  690. root_workbench.pending_required_context(
  691. root_trace_id="root", task_id="root-task", attempt_id="root-attempt"
  692. )
  693. == 3
  694. )
  695. for handle in state.required_handles:
  696. cursor = None
  697. while True:
  698. page = await root_workbench.read_mission_context(
  699. root_trace_id="root",
  700. role="validator",
  701. task_id="root-task",
  702. attempt_id="root-attempt",
  703. handle=handle,
  704. cursor=cursor,
  705. )
  706. cursor = page["next_cursor"]
  707. if cursor is None:
  708. break
  709. assert (
  710. root_workbench.pending_required_context(
  711. root_trace_id="root", task_id="root-task", attempt_id="root-attempt"
  712. )
  713. == 0
  714. )
  715. @pytest.mark.asyncio
  716. async def test_root_worker_can_expand_structured_script_and_validator_reads_survive_restart() -> (
  717. None
  718. ):
  719. workbench, ledger, snapshots, _ = _fixture()
  720. contract_ref = "script-build://task-contracts/sha256/" + "e" * 64
  721. contract = SimpleNamespace(
  722. task_kind=ScriptTaskKind.ROOT_DELIVERY,
  723. scope_ref="script-build://scopes/full",
  724. intent_class=ScriptIntentClass.DELIVER,
  725. objective="deliver",
  726. goal_ids=("g-1",),
  727. criteria=(ScriptCriterion("root", "complete"),),
  728. input_decision_refs=(),
  729. candidate_closure_decision_refs=(),
  730. comparison_decision_refs=(),
  731. base_artifact_ref=None,
  732. to_payload=lambda: {"task_kind": "root-delivery"},
  733. )
  734. ledger.tasks["root-task"].current_spec = SimpleNamespace(
  735. objective="deliver",
  736. context_refs=("script-build://task-kinds/root-delivery", contract_ref),
  737. )
  738. workbench.broker._contracts.values[contract_ref] = contract
  739. refs = tuple(
  740. ArtifactRef(
  741. uri=f"script-build://artifact-versions/{500 + index}",
  742. kind=kind,
  743. version=str(500 + index),
  744. digest="sha256:" + f"{500 + index:064x}",
  745. )
  746. for index, kind in enumerate(("direction", "candidate-portfolio", "structured-script"))
  747. )
  748. for index, ref in enumerate(refs):
  749. workbench.broker._artifacts.values[ref.uri] = _Artifact(
  750. f"ROOT SOURCE {index} " + "正文" * 12_000
  751. )
  752. class RootTools:
  753. async def validation_evidence_refs(self, *, context: dict[str, Any]):
  754. assert context["task_id"] == "root-task"
  755. return refs
  756. receipt_store = _ReceiptStore()
  757. root_workbench = MissionWorkbench(
  758. task_store=workbench.broker._task_store,
  759. bindings=_Bindings(),
  760. snapshots=snapshots,
  761. artifacts=workbench.broker._artifacts,
  762. contracts=workbench.broker._contracts,
  763. root_tools=RootTools(),
  764. receipt_store=receipt_store,
  765. )
  766. worker_request = RoleContextRequest(
  767. role=AgentRole.WORKER,
  768. root_trace_id="root",
  769. task_id="root-task",
  770. spec_version=1,
  771. task_spec={},
  772. attempt_id="root-attempt",
  773. ledger_revision=ledger.revision,
  774. )
  775. worker = await root_workbench.worker_state(worker_request)
  776. source_types = {item["source_type"] for item in worker.context_bundle["cards"]}
  777. assert {"direction", "candidate-portfolio", "structured-script"}.issubset(source_types)
  778. structured_handle = next(
  779. item["handle"]
  780. for item in worker.context_bundle["cards"]
  781. if item["source_type"] == "structured-script"
  782. )
  783. detail = await root_workbench.read_mission_context(
  784. root_trace_id="root",
  785. role="worker",
  786. task_id="root-task",
  787. attempt_id="root-attempt",
  788. handle=structured_handle,
  789. )
  790. assert "ROOT SOURCE 2" in detail["content_fragment"]
  791. validator_request = RoleContextRequest(
  792. role=AgentRole.VALIDATOR,
  793. root_trace_id="root",
  794. task_id="root-task",
  795. spec_version=1,
  796. task_spec={},
  797. attempt_id="root-attempt",
  798. ledger_revision=ledger.revision,
  799. snapshot_id="root-snapshot",
  800. artifact_snapshot={
  801. "snapshot_id": "root-snapshot",
  802. "artifact_refs": [asdict(refs[-1])],
  803. "evidence_refs": [],
  804. },
  805. )
  806. validator_state = await root_workbench.validator_state(validator_request)
  807. restarted = MissionWorkbench(
  808. task_store=workbench.broker._task_store,
  809. bindings=_Bindings(),
  810. snapshots=snapshots,
  811. artifacts=workbench.broker._artifacts,
  812. contracts=workbench.broker._contracts,
  813. root_tools=RootTools(),
  814. receipt_store=receipt_store,
  815. )
  816. with pytest.raises(WorkbenchError) as incomplete:
  817. await restarted.verify_context_exhausted(
  818. root_trace_id="root", task_id="root-task", attempt_id="root-attempt"
  819. )
  820. assert incomplete.value.code == "CONTEXT_NOT_EXHAUSTED"
  821. for handle in validator_state.required_handles:
  822. cursor = None
  823. while True:
  824. page = await root_workbench.read_mission_context(
  825. root_trace_id="root",
  826. role="validator",
  827. task_id="root-task",
  828. attempt_id="root-attempt",
  829. handle=handle,
  830. cursor=cursor,
  831. )
  832. cursor = page["next_cursor"]
  833. if cursor is None:
  834. break
  835. await restarted.verify_context_exhausted(
  836. root_trace_id="root", task_id="root-task", attempt_id="root-attempt"
  837. )
  838. @pytest.mark.asyncio
  839. async def test_compare_worker_receives_all_and_only_frozen_candidates() -> None:
  840. workbench, ledger, _snapshots, _ = _fixture()
  841. task = ledger.tasks["task-57"]
  842. contract_ref = "script-build://task-contracts/sha256/" + "9" * 64
  843. refs = tuple(
  844. AcceptedDecisionRef(
  845. decision_id=f"decision-{index}",
  846. artifact_ref=ledger.attempts[f"attempt-{index}"].submission.artifact_refs[0],
  847. scope_ref=f"script-build://scopes/full/part-{index}",
  848. expected_task_kind=ScriptTaskKind.PARAGRAPH,
  849. )
  850. for index in range(25)
  851. )
  852. direction_ref = AcceptedDecisionRef(
  853. decision_id="decision-32",
  854. artifact_ref=ledger.attempts["attempt-32"].submission.artifact_refs[0],
  855. scope_ref="script-build://scopes/direction",
  856. expected_task_kind=ScriptTaskKind.DIRECTION,
  857. )
  858. contract = ScriptTaskContractV1(
  859. task_kind=ScriptTaskKind.COMPARE,
  860. scope_ref="script-build://scopes/full/compare",
  861. intent_class=ScriptIntentClass.COMPARE,
  862. objective="compare every frozen candidate",
  863. # Production contracts add Direction as a normal input. It is protected
  864. # policy context, not one of the candidates being compared.
  865. input_decision_refs=(direction_ref,),
  866. base_artifact_ref=None,
  867. write_scope=("script-build://writes/compare",),
  868. gap_ref=None,
  869. output_schema="comparison-artifact/v1",
  870. criteria=(ScriptCriterion("compare", "compare all candidates"),),
  871. budget=ScriptTaskBudget(),
  872. goal_ids=("g-1",),
  873. comparison_decision_refs=refs,
  874. compiler_manifest_digest="sha256:" + "a" * 64,
  875. )
  876. workbench.broker._contracts.values[contract_ref] = contract
  877. task.current_spec = SimpleNamespace(
  878. objective=contract.objective,
  879. context_refs=("script-build://task-kinds/compare", contract_ref),
  880. )
  881. state = await workbench.worker_state(
  882. RoleContextRequest(
  883. role=AgentRole.WORKER,
  884. root_trace_id="root",
  885. task_id=task.task_id,
  886. spec_version=1,
  887. task_spec={},
  888. attempt_id="compare-attempt",
  889. ledger_revision=ledger.revision,
  890. )
  891. )
  892. cards = state.context_bundle["cards"]
  893. assert len(cards) == 25
  894. assert {item["source_type"] for item in cards} == {"paragraph"}
  895. assert set(state.context_bundle["required_handles"]) == {item["handle"] for item in cards}
  896. @pytest.mark.asyncio
  897. async def test_element_worker_gets_target_text_existing_dimensions_and_link_gaps() -> None:
  898. workbench, ledger, snapshots, _ = _fixture()
  899. task = ledger.tasks["task-57"]
  900. contract_ref = "script-build://task-contracts/sha256/" + "8" * 64
  901. paragraph_ref = AcceptedDecisionRef(
  902. decision_id="decision-0",
  903. artifact_ref=ledger.attempts["attempt-0"].submission.artifact_refs[0],
  904. scope_ref="script-build://scopes/full/part-0",
  905. expected_task_kind=ScriptTaskKind.PARAGRAPH,
  906. )
  907. contract = ScriptTaskContractV1(
  908. task_kind=ScriptTaskKind.ELEMENT_SET,
  909. scope_ref="script-build://scopes/full/part-0/elements",
  910. intent_class=ScriptIntentClass.EXPAND,
  911. objective="extract linked elements",
  912. input_decision_refs=(paragraph_ref,),
  913. base_artifact_ref=None,
  914. write_scope=("script-build://writes/part-0/elements",),
  915. gap_ref=None,
  916. output_schema="element-set-artifact/v1",
  917. criteria=(ScriptCriterion("elements", "cover paragraph"),),
  918. budget=ScriptTaskBudget(),
  919. goal_ids=("g-1",),
  920. compiler_manifest_digest="sha256:" + "a" * 64,
  921. )
  922. workbench.broker._contracts.values[contract_ref] = contract
  923. task.current_spec = SimpleNamespace(
  924. objective=contract.objective,
  925. context_refs=("script-build://task-kinds/element-set", contract_ref),
  926. )
  927. class Candidates:
  928. calls = 0
  929. async def read_attempt_workspace(self, *, context: dict[str, Any]):
  930. self.calls += 1
  931. assert context["task_id"] == task.task_id
  932. base = {
  933. "level": 1,
  934. "theme": "theme",
  935. "form": "form",
  936. "function": "function",
  937. "feeling": "feeling",
  938. "description": "description",
  939. "is_active": True,
  940. }
  941. return {
  942. "paragraphs": [
  943. {
  944. **base,
  945. "paragraph_id": 1,
  946. "name": "opening",
  947. "paragraph_index": 1,
  948. "content_range": {"from": 0, "to": 20},
  949. "full_description": "real paragraph text",
  950. },
  951. {
  952. **base,
  953. "paragraph_id": 2,
  954. "name": "ending",
  955. "paragraph_index": 2,
  956. "content_range": {"from": 21, "to": 40},
  957. "full_description": "second paragraph text",
  958. },
  959. ],
  960. "elements": [
  961. {
  962. "element_id": 1,
  963. "name": "conflict",
  964. "dimension_primary": "实质",
  965. "dimension_secondary": "叙事冲突",
  966. "is_active": True,
  967. }
  968. ],
  969. "paragraph_element_links": [{"paragraph_id": 1, "element_id": 1}],
  970. }
  971. element_workbench = MissionWorkbench(
  972. task_store=workbench.broker._task_store,
  973. bindings=_Bindings(),
  974. snapshots=snapshots,
  975. artifacts=workbench.broker._artifacts,
  976. contracts=workbench.broker._contracts,
  977. candidates=Candidates(),
  978. )
  979. state = await element_workbench.worker_state(
  980. RoleContextRequest(
  981. role=AgentRole.WORKER,
  982. root_trace_id="root",
  983. task_id=task.task_id,
  984. spec_version=1,
  985. task_spec={},
  986. attempt_id="element-attempt",
  987. ledger_revision=ledger.revision,
  988. )
  989. )
  990. assert len(state.paragraph_targets) == 2
  991. assert all(
  992. "paragraph_id" not in item and "content_excerpt" not in item
  993. for item in state.paragraph_targets
  994. )
  995. assert state.workspace_summary["unlinked_paragraph_count"] == 1
  996. assert state.workspace_summary["unlinked_element_count"] == 0
  997. assert state.workspace_summary["unlinked_paragraph_target_keys"] == [
  998. state.paragraph_targets[1]["paragraph_target_key"]
  999. ]
  1000. assert state.workspace_summary["unlinked_element_handles"] == []
  1001. cards = state.context_bundle["cards"]
  1002. assert {item["source_type"] for item in cards} == {"paragraph", "existing_element"}
  1003. assert any("实质/叙事冲突" in item["summary"] for item in cards)
  1004. element_card = next(item for item in cards if item["source_type"] == "existing_element")
  1005. assert element_card["metadata"]["linked_paragraph_target_keys"] == [
  1006. state.paragraph_targets[0]["paragraph_target_key"]
  1007. ]
  1008. for _ in range(49):
  1009. await element_workbench.read_mission_context(
  1010. root_trace_id="root",
  1011. role="worker",
  1012. task_id=task.task_id,
  1013. attempt_id="element-attempt",
  1014. handle=state.paragraph_targets[0]["paragraph_target_key"],
  1015. section="outline",
  1016. )
  1017. assert element_workbench.broker._candidates.calls == 1
  1018. @pytest.mark.asyncio
  1019. async def test_direction_worker_always_receives_always_on_strategy_before_optional_inputs() -> None:
  1020. workbench, ledger, _snapshots, _ = _fixture()
  1021. task = ledger.tasks["task-57"]
  1022. contract_ref = "script-build://task-contracts/sha256/" + "7" * 64
  1023. contract = ScriptTaskContractV1(
  1024. task_kind=ScriptTaskKind.DIRECTION,
  1025. scope_ref="script-build://scopes/direction",
  1026. intent_class=ScriptIntentClass.EXPLORE,
  1027. objective="choose a direction",
  1028. input_decision_refs=(),
  1029. base_artifact_ref=None,
  1030. write_scope=("script-build://writes/direction",),
  1031. gap_ref=None,
  1032. output_schema="script-direction/v1",
  1033. criteria=(ScriptCriterion("direction", "coherent direction"),),
  1034. budget=ScriptTaskBudget(),
  1035. goal_ids=(),
  1036. compiler_manifest_digest="sha256:" + "a" * 64,
  1037. )
  1038. workbench.broker._contracts.values[contract_ref] = contract
  1039. task.current_spec = SimpleNamespace(
  1040. objective=contract.objective,
  1041. context_refs=("script-build://task-kinds/direction", contract_ref),
  1042. )
  1043. state = await workbench.worker_state(
  1044. RoleContextRequest(
  1045. role=AgentRole.WORKER,
  1046. root_trace_id="root",
  1047. task_id=task.task_id,
  1048. spec_version=1,
  1049. task_spec={},
  1050. attempt_id="direction-attempt",
  1051. ledger_revision=ledger.revision,
  1052. )
  1053. )
  1054. cards = state.context_bundle["cards"]
  1055. assert cards[0]["source_type"] == "strategy"
  1056. assert cards[0]["metadata"]["mode"] == "always_on"
  1057. assert {item["source_type"] for item in cards}.issubset(
  1058. {"topic", "persona", "section_pattern", "strategy"}
  1059. )