test_context_broker_integration.py 40 KB

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