test_context_broker_integration.py 41 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147
  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. cursor = None
  297. found: list[str] = []
  298. while True:
  299. page = await workbench.search_mission_context(
  300. root_trace_id="root",
  301. role="planner",
  302. collection="accepted_decisions",
  303. cursor=cursor,
  304. )
  305. found.extend(item["handle"] for item in page["page"]["items"])
  306. cursor = page["page"]["next_cursor"]
  307. if cursor is None:
  308. break
  309. assert len(found) == 34
  310. assert len(set(found)) == 34
  311. @pytest.mark.asyncio
  312. async def test_duplicate_long_inputs_have_unique_handles_and_budgeted_finite_pagination() -> None:
  313. workbench, _ledger, snapshots, _ = _fixture()
  314. snapshots.value.persona_points = tuple(
  315. {"列": "实质", "点名称": "相同人设" * 350, "人设权重分": 1, "帖子覆盖率": 1}
  316. for _ in range(30)
  317. )
  318. cursor = None
  319. handles: list[str] = []
  320. while True:
  321. page = await workbench.search_mission_context(
  322. root_trace_id="root",
  323. role="planner",
  324. collection="inputs",
  325. source_types=("persona",),
  326. page_size=20,
  327. cursor=cursor,
  328. )
  329. assert page["receipt"]["estimated_tokens"] <= 2_000
  330. handles.extend(item["handle"] for item in page["page"]["items"])
  331. cursor = page["page"]["next_cursor"]
  332. if cursor is None:
  333. break
  334. assert len(handles) == len(set(handles)) == 30
  335. @pytest.mark.asyncio
  336. async def test_role_selected_persona_handles_keep_snapshot_identity_and_are_readable() -> None:
  337. workbench, ledger, _snapshots, _ = _fixture()
  338. state = await workbench.worker_state(
  339. RoleContextRequest(
  340. role=AgentRole.WORKER,
  341. root_trace_id="root",
  342. task_id="task-57",
  343. spec_version=1,
  344. task_spec={},
  345. attempt_id="persona-attempt",
  346. ledger_revision=ledger.revision,
  347. )
  348. )
  349. persona_handles = [
  350. item["handle"] for item in state.context_bundle["cards"] if item["source_type"] == "persona"
  351. ]
  352. assert len(persona_handles) == 12
  353. for handle in persona_handles:
  354. page = await workbench.read_mission_context(
  355. root_trace_id="root",
  356. role="worker",
  357. task_id="task-57",
  358. attempt_id="persona-attempt",
  359. handle=handle,
  360. )
  361. assert page["source_handle"] == handle
  362. assert page["content_fragment"]
  363. @pytest.mark.asyncio
  364. async def test_cursor_revision_tamper_and_cross_root_are_rejected() -> None:
  365. workbench, ledger, _snapshots, _ = _fixture()
  366. first = await workbench.search_mission_context(
  367. root_trace_id="root", role="planner", collection="tasks", page_size=5
  368. )
  369. cursor = first["page"]["next_cursor"]
  370. assert cursor
  371. with pytest.raises(WorkbenchError, match="invalid context cursor"):
  372. await workbench.search_mission_context(
  373. root_trace_id="root",
  374. role="planner",
  375. collection="tasks",
  376. cursor=cursor[:-1] + ("A" if cursor[-1] != "A" else "B"),
  377. page_size=5,
  378. )
  379. with pytest.raises(WorkbenchError) as cross_root:
  380. await workbench.search_mission_context(
  381. root_trace_id="other-root",
  382. role="planner",
  383. collection="tasks",
  384. cursor=cursor,
  385. page_size=5,
  386. )
  387. assert cross_root.value.code == "CONTEXT_CURSOR_SCOPE_MISMATCH"
  388. ledger.revision += 1
  389. with pytest.raises(WorkbenchError) as stale:
  390. await workbench.search_mission_context(
  391. root_trace_id="root",
  392. role="planner",
  393. collection="tasks",
  394. cursor=cursor,
  395. page_size=5,
  396. )
  397. assert stale.value.code == "STALE_WORKBENCH_STATE"
  398. @pytest.mark.asyncio
  399. async def test_inputs_and_193_patterns_are_bounded_but_fully_recoverable() -> None:
  400. workbench, ledger, _snapshots, _ = _fixture()
  401. inputs = await workbench.search_mission_context(
  402. root_trace_id="root", role="planner", collection="inputs", query="optional"
  403. )
  404. strategy = next(item for item in inputs["page"]["items"] if item["source_type"] == "strategy")
  405. detail = await workbench.read_mission_context(
  406. root_trace_id="root", role="planner", handle=strategy["handle"]
  407. )
  408. assert estimate_context_tokens(detail) <= 2_000
  409. assert isinstance(detail["next_cursor"], str)
  410. with pytest.raises(WorkbenchError) as tampered_detail:
  411. await workbench.read_mission_context(
  412. root_trace_id="root",
  413. role="planner",
  414. handle=strategy["handle"],
  415. cursor=detail["next_cursor"][:-1] + "x",
  416. )
  417. assert tampered_detail.value.code == "CONTEXT_CURSOR_INVALID"
  418. fragments = [detail["content_fragment"]]
  419. while detail["next_cursor"] is not None:
  420. detail = await workbench.read_mission_context(
  421. root_trace_id="root",
  422. role="planner",
  423. handle=strategy["handle"],
  424. cursor=detail["next_cursor"],
  425. )
  426. fragments.append(detail["content_fragment"])
  427. assert "按需策略" in "".join(fragments)
  428. cursor = None
  429. immutable_cursor = None
  430. found: list[dict[str, Any]] = []
  431. while True:
  432. page = await workbench.search_mission_context(
  433. root_trace_id="root",
  434. role="planner",
  435. collection="patterns",
  436. cursor=cursor,
  437. page_size=20,
  438. )
  439. found.extend(page["page"]["items"])
  440. cursor = page["page"]["next_cursor"]
  441. immutable_cursor = immutable_cursor or cursor
  442. if cursor is None:
  443. break
  444. assert len(found) == 193
  445. assert len({item["handle"] for item in found}) == 193
  446. raw_prompt = json.dumps(
  447. (await workbench.planner_state("root")).to_payload(), ensure_ascii=False
  448. )
  449. assert len(raw_prompt) < 24_000
  450. assert "模式详情" * 20 not in raw_prompt
  451. ledger.revision += 1
  452. immutable_page = await workbench.search_mission_context(
  453. root_trace_id="root",
  454. role="planner",
  455. collection="patterns",
  456. cursor=immutable_cursor,
  457. page_size=20,
  458. )
  459. assert immutable_page["page"]["total"] == 193
  460. assert workbench.broker._raw_artifacts.reads == 1
  461. @pytest.mark.asyncio
  462. async def test_semantic_selector_cannot_escape_allowlist(tmp_path) -> None:
  463. calls = 0
  464. async def llm_call(**_: Any) -> dict[str, Any]:
  465. nonlocal calls
  466. calls += 1
  467. return {"content": json.dumps({"handles": ["bad_handle"]})}
  468. selector = AdaptiveSemanticSelector(llm_call=llm_call, cache_root=tmp_path)
  469. workbench, ledger, snapshots, _ = _fixture()
  470. workbench = MissionWorkbench(
  471. task_store=_TaskStore(ledger),
  472. bindings=_Bindings(),
  473. snapshots=snapshots,
  474. artifacts=workbench.broker._artifacts,
  475. contracts=workbench.broker._contracts,
  476. raw_artifacts=workbench.broker._raw_artifacts,
  477. semantic_selector=selector,
  478. )
  479. result = await workbench.search_mission_context(
  480. root_trace_id="root", role="planner", collection="inputs", query="not present"
  481. )
  482. handles = {item["handle"] for item in result["page"]["items"]}
  483. assert "bad_handle" not in handles
  484. assert result["receipt"]["semantic_calls"] == 1
  485. assert result["receipt"]["fallback_reason"]
  486. assert calls == 1
  487. @pytest.mark.asyncio
  488. async def test_semantic_selector_caches_valid_allowlisted_order(tmp_path) -> None:
  489. calls = 0
  490. async def llm_call(**kwargs: Any) -> dict[str, Any]:
  491. nonlocal calls
  492. calls += 1
  493. request = json.loads(kwargs["messages"][1]["content"])
  494. handles = [item["handle"] for item in request["candidates"]]
  495. return {"content": json.dumps({"handles": list(reversed(handles))})}
  496. selector = AdaptiveSemanticSelector(llm_call=llm_call, cache_root=tmp_path)
  497. cards = (
  498. ContextCard(handle="one", source_type="input", summary="alpha"),
  499. ContextCard(handle="two", source_type="input", summary="beta"),
  500. )
  501. first = await selector.select(
  502. root_trace_id="root", query="similar", cards=cards, model_config={"model": "fake"}
  503. )
  504. second = await selector.select(
  505. root_trace_id="root", query="similar", cards=cards, model_config={"model": "fake"}
  506. )
  507. assert first.handles == ("two", "one")
  508. assert second.handles == first.handles
  509. assert second.cache_hit is True
  510. assert calls == 1
  511. @pytest.mark.asyncio
  512. async def test_semantic_cache_survives_call_limit_and_timeout_falls_back(tmp_path) -> None:
  513. calls = 0
  514. async def llm_call(**_: Any) -> dict[str, Any]:
  515. nonlocal calls
  516. calls += 1
  517. return {"content": json.dumps({"handles": ["one", "two"]})}
  518. cards = (
  519. ContextCard(handle="one", source_type="input", summary="alpha"),
  520. ContextCard(handle="two", source_type="input", summary="beta"),
  521. )
  522. selector = AdaptiveSemanticSelector(
  523. llm_call=llm_call, cache_root=tmp_path, mission_call_limit=1
  524. )
  525. first = await selector.select(
  526. root_trace_id="root", query="same", cards=cards, model_config={"model": "fake"}
  527. )
  528. restarted_selector = AdaptiveSemanticSelector(
  529. llm_call=llm_call, cache_root=tmp_path, mission_call_limit=1
  530. )
  531. cached = await restarted_selector.select(
  532. root_trace_id="root", query="same", cards=cards, model_config={"model": "fake"}
  533. )
  534. limited = await restarted_selector.select(
  535. root_trace_id="root", query="different", cards=cards, model_config={"model": "fake"}
  536. )
  537. assert first.calls == 1
  538. assert cached.cache_hit is True
  539. assert limited.fallback_reason == "semantic_call_limit"
  540. assert calls == 1
  541. async def slow_call(**_: Any) -> dict[str, Any]:
  542. await asyncio.sleep(0.05)
  543. return {"content": "{}"}
  544. timeout_selector = AdaptiveSemanticSelector(
  545. llm_call=slow_call,
  546. cache_root=tmp_path / "timeout",
  547. timeout_seconds=0.001,
  548. )
  549. timed_out = await timeout_selector.select(
  550. root_trace_id="timeout-root",
  551. query="slow",
  552. cards=cards,
  553. model_config={"model": "fake"},
  554. )
  555. assert timed_out.calls == 1
  556. assert timed_out.fallback_reason == "TimeoutError:semantic_fallback"
  557. @pytest.mark.asyncio
  558. async def test_semantic_rerank_never_removes_candidates_beyond_its_window(tmp_path) -> None:
  559. semantic_first: list[str] = []
  560. async def llm_call(**kwargs: Any) -> dict[str, Any]:
  561. request = json.loads(kwargs["messages"][1]["content"])
  562. handles = [item["handle"] for item in request["candidates"]]
  563. semantic_first[:] = [handles[-1]]
  564. return {"content": json.dumps({"handles": list(reversed(handles))})}
  565. selector = AdaptiveSemanticSelector(llm_call=llm_call, cache_root=tmp_path)
  566. workbench, ledger, snapshots, _ = _fixture()
  567. workbench = MissionWorkbench(
  568. task_store=_TaskStore(ledger),
  569. bindings=_Bindings(),
  570. snapshots=snapshots,
  571. artifacts=workbench.broker._artifacts,
  572. contracts=workbench.broker._contracts,
  573. raw_artifacts=workbench.broker._raw_artifacts,
  574. semantic_selector=selector,
  575. )
  576. cursor = None
  577. handles: list[str] = []
  578. while True:
  579. result = await workbench.search_mission_context(
  580. root_trace_id="root",
  581. role="planner",
  582. collection="inputs",
  583. query="unmatched semantic query",
  584. page_size=20,
  585. cursor=cursor,
  586. )
  587. handles.extend(item["handle"] for item in result["page"]["items"])
  588. cursor = result["page"]["next_cursor"]
  589. if cursor is None:
  590. break
  591. assert len(handles) == 123
  592. assert len(set(handles)) == 123
  593. assert handles[0] == semantic_first[0]
  594. @pytest.mark.asyncio
  595. async def test_build_900049_shape_automatic_refresh_stays_bounded_and_reduces_by_half() -> None:
  596. workbench, ledger, _snapshots, _ = _fixture()
  597. refreshes = [await workbench.render("root", ledger) for _ in range(11)]
  598. # The framework refreshes every five turns and marks each result output-only-once,
  599. # so only the latest bounded projection remains in the provider prompt.
  600. assert len(set(refreshes)) == 1
  601. bounded = len(refreshes[-1])
  602. legacy_round = json.dumps(
  603. {
  604. "task_tree": [task.current_spec.objective for task in ledger.tasks.values()],
  605. "accepted_artifacts": ["x" * 4_000 for _ in range(33)],
  606. "pattern_payload": ["模式详情" * 350 for _ in range(193)],
  607. },
  608. ensure_ascii=False,
  609. )
  610. legacy = len(legacy_round) * 54
  611. assert bounded < legacy * 0.50
  612. assert bounded < 24_000
  613. @pytest.mark.asyncio
  614. async def test_root_validator_gets_full_closure_as_required_pages_when_large() -> None:
  615. workbench, ledger, snapshots, _ = _fixture()
  616. contract_ref = "script-build://task-contracts/sha256/" + "d" * 64
  617. contract = SimpleNamespace(
  618. task_kind=ScriptTaskKind.ROOT_DELIVERY,
  619. scope_ref="script-build://scopes/full",
  620. intent_class=ScriptIntentClass.DELIVER,
  621. objective="deliver",
  622. goal_ids=("g-1",),
  623. criteria=(ScriptCriterion("root", "complete"),),
  624. input_decision_refs=(),
  625. candidate_closure_decision_refs=(),
  626. comparison_decision_refs=(),
  627. base_artifact_ref=None,
  628. to_payload=lambda: {"task_kind": "root-delivery"},
  629. )
  630. ledger.tasks["root-task"].current_spec = SimpleNamespace(
  631. objective="deliver",
  632. context_refs=("script-build://task-kinds/root-delivery", contract_ref),
  633. )
  634. workbench.broker._contracts.values[contract_ref] = contract
  635. refs = tuple(
  636. ArtifactRef(
  637. uri=f"script-build://artifact-versions/{200 + index}",
  638. kind=kind,
  639. version=str(200 + index),
  640. digest="sha256:" + f"{200 + index:064x}",
  641. )
  642. for index, kind in enumerate(("direction", "candidate-portfolio", "structured-script"))
  643. )
  644. for index, ref in enumerate(refs):
  645. workbench.broker._artifacts.values[ref.uri] = _Artifact(
  646. f"ROOT CLOSURE {index} " + "正文" * 12_000
  647. )
  648. class RootTools:
  649. async def validation_evidence_refs(self, *, context: dict[str, Any]):
  650. assert context["task_id"] == "root-task"
  651. return refs
  652. root_workbench = MissionWorkbench(
  653. task_store=workbench.broker._task_store,
  654. bindings=_Bindings(),
  655. snapshots=snapshots,
  656. artifacts=workbench.broker._artifacts,
  657. contracts=workbench.broker._contracts,
  658. root_tools=RootTools(),
  659. )
  660. request = RoleContextRequest(
  661. role=AgentRole.VALIDATOR,
  662. root_trace_id="root",
  663. task_id="root-task",
  664. spec_version=1,
  665. task_spec={},
  666. attempt_id="root-attempt",
  667. ledger_revision=ledger.revision,
  668. snapshot_id="root-snapshot",
  669. artifact_snapshot={
  670. "snapshot_id": "root-snapshot",
  671. "artifact_refs": [asdict(refs[-1])],
  672. "evidence_refs": [],
  673. },
  674. )
  675. state = await root_workbench.validator_state(request)
  676. assert state.artifact["full_content_in_bootstrap"] is False
  677. assert len(state.required_handles) == 3
  678. assert state.context_bundle["sufficiency"]["status"] == "ambiguous"
  679. assert (
  680. root_workbench.pending_required_context(
  681. root_trace_id="root", task_id="root-task", attempt_id="root-attempt"
  682. )
  683. == 3
  684. )
  685. for handle in state.required_handles:
  686. cursor = None
  687. while True:
  688. page = await root_workbench.read_mission_context(
  689. root_trace_id="root",
  690. role="validator",
  691. task_id="root-task",
  692. attempt_id="root-attempt",
  693. handle=handle,
  694. cursor=cursor,
  695. )
  696. cursor = page["next_cursor"]
  697. if cursor is None:
  698. break
  699. assert (
  700. root_workbench.pending_required_context(
  701. root_trace_id="root", task_id="root-task", attempt_id="root-attempt"
  702. )
  703. == 0
  704. )
  705. @pytest.mark.asyncio
  706. async def test_root_worker_can_expand_structured_script_and_validator_reads_survive_restart() -> (
  707. None
  708. ):
  709. workbench, ledger, snapshots, _ = _fixture()
  710. contract_ref = "script-build://task-contracts/sha256/" + "e" * 64
  711. contract = SimpleNamespace(
  712. task_kind=ScriptTaskKind.ROOT_DELIVERY,
  713. scope_ref="script-build://scopes/full",
  714. intent_class=ScriptIntentClass.DELIVER,
  715. objective="deliver",
  716. goal_ids=("g-1",),
  717. criteria=(ScriptCriterion("root", "complete"),),
  718. input_decision_refs=(),
  719. candidate_closure_decision_refs=(),
  720. comparison_decision_refs=(),
  721. base_artifact_ref=None,
  722. to_payload=lambda: {"task_kind": "root-delivery"},
  723. )
  724. ledger.tasks["root-task"].current_spec = SimpleNamespace(
  725. objective="deliver",
  726. context_refs=("script-build://task-kinds/root-delivery", contract_ref),
  727. )
  728. workbench.broker._contracts.values[contract_ref] = contract
  729. refs = tuple(
  730. ArtifactRef(
  731. uri=f"script-build://artifact-versions/{500 + index}",
  732. kind=kind,
  733. version=str(500 + index),
  734. digest="sha256:" + f"{500 + index:064x}",
  735. )
  736. for index, kind in enumerate(("direction", "candidate-portfolio", "structured-script"))
  737. )
  738. for index, ref in enumerate(refs):
  739. workbench.broker._artifacts.values[ref.uri] = _Artifact(
  740. f"ROOT SOURCE {index} " + "正文" * 12_000
  741. )
  742. class RootTools:
  743. async def validation_evidence_refs(self, *, context: dict[str, Any]):
  744. assert context["task_id"] == "root-task"
  745. return refs
  746. receipt_store = _ReceiptStore()
  747. root_workbench = MissionWorkbench(
  748. task_store=workbench.broker._task_store,
  749. bindings=_Bindings(),
  750. snapshots=snapshots,
  751. artifacts=workbench.broker._artifacts,
  752. contracts=workbench.broker._contracts,
  753. root_tools=RootTools(),
  754. receipt_store=receipt_store,
  755. )
  756. worker_request = RoleContextRequest(
  757. role=AgentRole.WORKER,
  758. root_trace_id="root",
  759. task_id="root-task",
  760. spec_version=1,
  761. task_spec={},
  762. attempt_id="root-attempt",
  763. ledger_revision=ledger.revision,
  764. )
  765. worker = await root_workbench.worker_state(worker_request)
  766. source_types = {item["source_type"] for item in worker.context_bundle["cards"]}
  767. assert {"direction", "candidate-portfolio", "structured-script"}.issubset(source_types)
  768. structured_handle = next(
  769. item["handle"]
  770. for item in worker.context_bundle["cards"]
  771. if item["source_type"] == "structured-script"
  772. )
  773. detail = await root_workbench.read_mission_context(
  774. root_trace_id="root",
  775. role="worker",
  776. task_id="root-task",
  777. attempt_id="root-attempt",
  778. handle=structured_handle,
  779. )
  780. assert "ROOT SOURCE 2" in detail["content_fragment"]
  781. validator_request = RoleContextRequest(
  782. role=AgentRole.VALIDATOR,
  783. root_trace_id="root",
  784. task_id="root-task",
  785. spec_version=1,
  786. task_spec={},
  787. attempt_id="root-attempt",
  788. ledger_revision=ledger.revision,
  789. snapshot_id="root-snapshot",
  790. artifact_snapshot={
  791. "snapshot_id": "root-snapshot",
  792. "artifact_refs": [asdict(refs[-1])],
  793. "evidence_refs": [],
  794. },
  795. )
  796. validator_state = await root_workbench.validator_state(validator_request)
  797. restarted = MissionWorkbench(
  798. task_store=workbench.broker._task_store,
  799. bindings=_Bindings(),
  800. snapshots=snapshots,
  801. artifacts=workbench.broker._artifacts,
  802. contracts=workbench.broker._contracts,
  803. root_tools=RootTools(),
  804. receipt_store=receipt_store,
  805. )
  806. with pytest.raises(WorkbenchError) as incomplete:
  807. await restarted.verify_context_exhausted(
  808. root_trace_id="root", task_id="root-task", attempt_id="root-attempt"
  809. )
  810. assert incomplete.value.code == "CONTEXT_NOT_EXHAUSTED"
  811. for handle in validator_state.required_handles:
  812. cursor = None
  813. while True:
  814. page = await root_workbench.read_mission_context(
  815. root_trace_id="root",
  816. role="validator",
  817. task_id="root-task",
  818. attempt_id="root-attempt",
  819. handle=handle,
  820. cursor=cursor,
  821. )
  822. cursor = page["next_cursor"]
  823. if cursor is None:
  824. break
  825. await restarted.verify_context_exhausted(
  826. root_trace_id="root", task_id="root-task", attempt_id="root-attempt"
  827. )
  828. @pytest.mark.asyncio
  829. async def test_compare_worker_receives_all_and_only_frozen_candidates() -> None:
  830. workbench, ledger, _snapshots, _ = _fixture()
  831. task = ledger.tasks["task-57"]
  832. contract_ref = "script-build://task-contracts/sha256/" + "9" * 64
  833. refs = tuple(
  834. AcceptedDecisionRef(
  835. decision_id=f"decision-{index}",
  836. artifact_ref=ledger.attempts[f"attempt-{index}"].submission.artifact_refs[0],
  837. scope_ref=f"script-build://scopes/full/part-{index}",
  838. expected_task_kind=ScriptTaskKind.PARAGRAPH,
  839. )
  840. for index in range(25)
  841. )
  842. direction_ref = AcceptedDecisionRef(
  843. decision_id="decision-32",
  844. artifact_ref=ledger.attempts["attempt-32"].submission.artifact_refs[0],
  845. scope_ref="script-build://scopes/direction",
  846. expected_task_kind=ScriptTaskKind.DIRECTION,
  847. )
  848. contract = ScriptTaskContractV1(
  849. task_kind=ScriptTaskKind.COMPARE,
  850. scope_ref="script-build://scopes/full/compare",
  851. intent_class=ScriptIntentClass.COMPARE,
  852. objective="compare every frozen candidate",
  853. # Production contracts add Direction as a normal input. It is protected
  854. # policy context, not one of the candidates being compared.
  855. input_decision_refs=(direction_ref,),
  856. base_artifact_ref=None,
  857. write_scope=("script-build://writes/compare",),
  858. gap_ref=None,
  859. output_schema="comparison-artifact/v1",
  860. criteria=(ScriptCriterion("compare", "compare all candidates"),),
  861. budget=ScriptTaskBudget(),
  862. goal_ids=("g-1",),
  863. comparison_decision_refs=refs,
  864. compiler_manifest_digest="sha256:" + "a" * 64,
  865. )
  866. workbench.broker._contracts.values[contract_ref] = contract
  867. task.current_spec = SimpleNamespace(
  868. objective=contract.objective,
  869. context_refs=("script-build://task-kinds/compare", contract_ref),
  870. )
  871. state = await workbench.worker_state(
  872. RoleContextRequest(
  873. role=AgentRole.WORKER,
  874. root_trace_id="root",
  875. task_id=task.task_id,
  876. spec_version=1,
  877. task_spec={},
  878. attempt_id="compare-attempt",
  879. ledger_revision=ledger.revision,
  880. )
  881. )
  882. cards = state.context_bundle["cards"]
  883. assert len(cards) == 25
  884. assert {item["source_type"] for item in cards} == {"paragraph"}
  885. assert set(state.context_bundle["required_handles"]) == {item["handle"] for item in cards}
  886. @pytest.mark.asyncio
  887. async def test_element_worker_gets_target_text_existing_dimensions_and_link_gaps() -> None:
  888. workbench, ledger, snapshots, _ = _fixture()
  889. task = ledger.tasks["task-57"]
  890. contract_ref = "script-build://task-contracts/sha256/" + "8" * 64
  891. paragraph_ref = AcceptedDecisionRef(
  892. decision_id="decision-0",
  893. artifact_ref=ledger.attempts["attempt-0"].submission.artifact_refs[0],
  894. scope_ref="script-build://scopes/full/part-0",
  895. expected_task_kind=ScriptTaskKind.PARAGRAPH,
  896. )
  897. contract = ScriptTaskContractV1(
  898. task_kind=ScriptTaskKind.ELEMENT_SET,
  899. scope_ref="script-build://scopes/full/part-0/elements",
  900. intent_class=ScriptIntentClass.EXPAND,
  901. objective="extract linked elements",
  902. input_decision_refs=(paragraph_ref,),
  903. base_artifact_ref=None,
  904. write_scope=("script-build://writes/part-0/elements",),
  905. gap_ref=None,
  906. output_schema="element-set-artifact/v1",
  907. criteria=(ScriptCriterion("elements", "cover paragraph"),),
  908. budget=ScriptTaskBudget(),
  909. goal_ids=("g-1",),
  910. compiler_manifest_digest="sha256:" + "a" * 64,
  911. )
  912. workbench.broker._contracts.values[contract_ref] = contract
  913. task.current_spec = SimpleNamespace(
  914. objective=contract.objective,
  915. context_refs=("script-build://task-kinds/element-set", contract_ref),
  916. )
  917. class Candidates:
  918. calls = 0
  919. async def read_attempt_workspace(self, *, context: dict[str, Any]):
  920. self.calls += 1
  921. assert context["task_id"] == task.task_id
  922. base = {
  923. "level": 1,
  924. "theme": "theme",
  925. "form": "form",
  926. "function": "function",
  927. "feeling": "feeling",
  928. "description": "description",
  929. "is_active": True,
  930. }
  931. return {
  932. "paragraphs": [
  933. {
  934. **base,
  935. "paragraph_id": 1,
  936. "name": "opening",
  937. "paragraph_index": 1,
  938. "content_range": {"from": 0, "to": 20},
  939. "full_description": "real paragraph text",
  940. },
  941. {
  942. **base,
  943. "paragraph_id": 2,
  944. "name": "ending",
  945. "paragraph_index": 2,
  946. "content_range": {"from": 21, "to": 40},
  947. "full_description": "second paragraph text",
  948. },
  949. ],
  950. "elements": [
  951. {
  952. "element_id": 1,
  953. "name": "conflict",
  954. "dimension_primary": "实质",
  955. "dimension_secondary": "叙事冲突",
  956. "is_active": True,
  957. }
  958. ],
  959. "paragraph_element_links": [{"paragraph_id": 1, "element_id": 1}],
  960. }
  961. element_workbench = MissionWorkbench(
  962. task_store=workbench.broker._task_store,
  963. bindings=_Bindings(),
  964. snapshots=snapshots,
  965. artifacts=workbench.broker._artifacts,
  966. contracts=workbench.broker._contracts,
  967. candidates=Candidates(),
  968. )
  969. state = await element_workbench.worker_state(
  970. RoleContextRequest(
  971. role=AgentRole.WORKER,
  972. root_trace_id="root",
  973. task_id=task.task_id,
  974. spec_version=1,
  975. task_spec={},
  976. attempt_id="element-attempt",
  977. ledger_revision=ledger.revision,
  978. )
  979. )
  980. assert len(state.paragraph_targets) == 2
  981. assert all(
  982. "paragraph_id" not in item and "content_excerpt" not in item
  983. for item in state.paragraph_targets
  984. )
  985. assert state.workspace_summary["unlinked_paragraph_count"] == 1
  986. assert state.workspace_summary["unlinked_element_count"] == 0
  987. assert state.workspace_summary["unlinked_paragraph_target_keys"] == [
  988. state.paragraph_targets[1]["paragraph_target_key"]
  989. ]
  990. assert state.workspace_summary["unlinked_element_handles"] == []
  991. cards = state.context_bundle["cards"]
  992. assert {item["source_type"] for item in cards} == {"paragraph", "existing_element"}
  993. assert any("实质/叙事冲突" in item["summary"] for item in cards)
  994. element_card = next(item for item in cards if item["source_type"] == "existing_element")
  995. assert element_card["metadata"]["linked_paragraph_target_keys"] == [
  996. state.paragraph_targets[0]["paragraph_target_key"]
  997. ]
  998. for _ in range(49):
  999. await element_workbench.read_mission_context(
  1000. root_trace_id="root",
  1001. role="worker",
  1002. task_id=task.task_id,
  1003. attempt_id="element-attempt",
  1004. handle=state.paragraph_targets[0]["paragraph_target_key"],
  1005. section="outline",
  1006. )
  1007. assert element_workbench.broker._candidates.calls == 1
  1008. @pytest.mark.asyncio
  1009. async def test_direction_worker_always_receives_always_on_strategy_before_optional_inputs() -> None:
  1010. workbench, ledger, _snapshots, _ = _fixture()
  1011. task = ledger.tasks["task-57"]
  1012. contract_ref = "script-build://task-contracts/sha256/" + "7" * 64
  1013. contract = ScriptTaskContractV1(
  1014. task_kind=ScriptTaskKind.DIRECTION,
  1015. scope_ref="script-build://scopes/direction",
  1016. intent_class=ScriptIntentClass.EXPLORE,
  1017. objective="choose a direction",
  1018. input_decision_refs=(),
  1019. base_artifact_ref=None,
  1020. write_scope=("script-build://writes/direction",),
  1021. gap_ref=None,
  1022. output_schema="script-direction/v1",
  1023. criteria=(ScriptCriterion("direction", "coherent direction"),),
  1024. budget=ScriptTaskBudget(),
  1025. goal_ids=(),
  1026. compiler_manifest_digest="sha256:" + "a" * 64,
  1027. )
  1028. workbench.broker._contracts.values[contract_ref] = contract
  1029. task.current_spec = SimpleNamespace(
  1030. objective=contract.objective,
  1031. context_refs=("script-build://task-kinds/direction", contract_ref),
  1032. )
  1033. state = await workbench.worker_state(
  1034. RoleContextRequest(
  1035. role=AgentRole.WORKER,
  1036. root_trace_id="root",
  1037. task_id=task.task_id,
  1038. spec_version=1,
  1039. task_spec={},
  1040. attempt_id="direction-attempt",
  1041. ledger_revision=ledger.revision,
  1042. )
  1043. )
  1044. cards = state.context_bundle["cards"]
  1045. assert cards[0]["source_type"] == "strategy"
  1046. assert cards[0]["metadata"]["mode"] == "always_on"
  1047. assert {item["source_type"] for item in cards}.issubset(
  1048. {"topic", "persona", "section_pattern", "strategy"}
  1049. )