test_phase_one_e2e.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596
  1. from __future__ import annotations
  2. import json
  3. from dataclasses import replace
  4. from datetime import UTC, datetime
  5. from types import SimpleNamespace
  6. import pytest
  7. from agent import AgentRunner, FileSystemArtifactStore, FileSystemTaskStore, FileSystemTraceStore
  8. from agent.orchestration import ArtifactRef, TaskStatus, ValidationVerdict
  9. from script_build_host.composition import HostDependencies, compose_host
  10. from script_build_host.domain.artifacts import (
  11. ArtifactKind,
  12. ArtifactState,
  13. ArtifactVersion,
  14. EvidenceRecordV1,
  15. )
  16. from script_build_host.domain.errors import ArtifactNotFound
  17. from script_build_host.domain.input_snapshot import ScriptBuildInputSnapshotV1
  18. from script_build_host.domain.records import (
  19. BuildStatus,
  20. MissionBinding,
  21. Principal,
  22. PublicationState,
  23. )
  24. from script_build_host.infrastructure.canonical_json import canonical_sha256
  25. from script_build_host.tools.gateway import RetrievalResult
  26. def _tool(call_id: str, name: str, arguments: dict[str, object]) -> dict[str, object]:
  27. return {
  28. "content": "",
  29. "tool_calls": [
  30. {
  31. "id": call_id,
  32. "type": "function",
  33. "function": {"name": name, "arguments": json.dumps(arguments)},
  34. }
  35. ],
  36. "finish_reason": "tool_calls",
  37. }
  38. def _prompt(messages: list[dict[str, object]]) -> dict[str, object]:
  39. for message in reversed(messages):
  40. if message.get("role") != "user" or not isinstance(message.get("content"), str):
  41. continue
  42. try:
  43. value = json.loads(message["content"])
  44. except ValueError:
  45. continue
  46. if isinstance(value, dict) and "task_spec" in value:
  47. return value
  48. raise AssertionError("missing role prompt")
  49. def _last_tool_json(messages: list[dict[str, object]]) -> dict[str, object] | None:
  50. for message in reversed(messages):
  51. if message.get("role") != "tool":
  52. continue
  53. content = message.get("content")
  54. if isinstance(content, str):
  55. try:
  56. value = json.loads(content)
  57. return value if isinstance(value, dict) else None
  58. except ValueError:
  59. return None
  60. return None
  61. def _contract(
  62. kind: str,
  63. objective: str,
  64. criterion_id: str,
  65. description: str,
  66. *,
  67. scope: str,
  68. ) -> dict[str, object]:
  69. criterion = (
  70. {
  71. "client_key": criterion_id,
  72. "criterion_type": "evidence_nonempty",
  73. "params": {"minimum_count": 1},
  74. "hard": True,
  75. }
  76. if kind.endswith("-retrieval")
  77. else {
  78. "client_key": criterion_id,
  79. "criterion_type": "custom",
  80. "description": description,
  81. "hard": True,
  82. }
  83. )
  84. return {
  85. "task_kind": kind,
  86. "scope_selector": {
  87. "anchor": "mission",
  88. "path": scope.removeprefix("script-build://scopes/").split("/")[:4],
  89. },
  90. "intent_class": "explore",
  91. "objective": objective,
  92. "input_decision_ids": [],
  93. "base_decision_id": None,
  94. "gap_key": None,
  95. "criteria": [criterion],
  96. "goal_ids": [],
  97. "supersedes_decision_ids": [],
  98. "comparison_decision_ids": [],
  99. }
  100. class _ScriptedLLM:
  101. def __init__(self, store: FileSystemTaskStore, root: str) -> None:
  102. self.store = store
  103. self.root = root
  104. self.calls = 0
  105. self.decode_versions: list[int] = []
  106. self.validated_artifact_kinds: list[str] = []
  107. self.replayed_dispatch = False
  108. def call(self, name: str, arguments: dict[str, object]) -> dict[str, object]:
  109. self.calls += 1
  110. return _tool(f"call-{self.calls}", name, arguments)
  111. def call_with_id(
  112. self,
  113. call_id: str,
  114. name: str,
  115. arguments: dict[str, object],
  116. ) -> dict[str, object]:
  117. self.calls += 1
  118. return _tool(call_id, name, arguments)
  119. async def __call__(self, messages, tools, **_kwargs):
  120. names = {item["function"]["name"] for item in tools or []}
  121. if "plan_script_tasks" in names:
  122. return await self._planner()
  123. if "submit_validation" in names:
  124. return self._validator(messages)
  125. if "submit_attempt" in names:
  126. return self._worker(messages, names)
  127. raise AssertionError(sorted(names))
  128. async def _planner(self):
  129. ledger = await self.store.load(self.root)
  130. root = ledger.tasks[ledger.root_task_id]
  131. direction = next(
  132. (
  133. item
  134. for item in ledger.tasks.values()
  135. if "script-build://task-kinds/direction" in item.current_spec.context_refs
  136. ),
  137. None,
  138. )
  139. if direction is None:
  140. return self.call(
  141. "plan_script_tasks",
  142. {
  143. "contracts": [
  144. _contract(
  145. "direction",
  146. "build direction",
  147. "direction-ready",
  148. "direction is grounded",
  149. scope="script-build://scopes/direction",
  150. )
  151. ],
  152. },
  153. )
  154. retrieval = next(
  155. (
  156. item
  157. for item in ledger.tasks.values()
  158. if "script-build://task-kinds/decode-retrieval" in item.current_spec.context_refs
  159. ),
  160. None,
  161. )
  162. if retrieval is None:
  163. return self.call(
  164. "plan_script_tasks",
  165. {
  166. "parent_task_id": direction.task_id,
  167. "contracts": [
  168. _contract(
  169. "decode-retrieval",
  170. "decode evidence",
  171. "evidence-ready",
  172. "evidence is relevant",
  173. scope="script-build://scopes/direction/decode",
  174. )
  175. ],
  176. },
  177. )
  178. if retrieval.status in {TaskStatus.PENDING, TaskStatus.NEEDS_REPLAN}:
  179. return self.call_with_id(
  180. f"decode-dispatch-v{retrieval.current_spec_version}",
  181. "dispatch_script_tasks",
  182. {"task_ids": [retrieval.task_id]},
  183. )
  184. if retrieval.status == TaskStatus.AWAITING_DECISION:
  185. if retrieval.current_spec_version == 1 and not self.replayed_dispatch:
  186. self.replayed_dispatch = True
  187. return self.call_with_id(
  188. "decode-dispatch-v1",
  189. "dispatch_script_tasks",
  190. {"task_ids": [retrieval.task_id]},
  191. )
  192. validation = ledger.validations[retrieval.validation_ids[-1]]
  193. if validation.verdict == ValidationVerdict.FAILED:
  194. return self.call(
  195. "decide_script_task",
  196. {
  197. "task_id": retrieval.task_id,
  198. "validation_id": validation.validation_id,
  199. "action": "revise",
  200. "reason": "make decode query precise",
  201. "replacement_contract": _contract(
  202. "decode-retrieval",
  203. "decode evidence revised",
  204. "evidence-ready",
  205. "evidence is relevant",
  206. scope="script-build://scopes/direction/decode",
  207. ),
  208. },
  209. )
  210. return self.call(
  211. "decide_script_task",
  212. {
  213. "task_id": retrieval.task_id,
  214. "validation_id": validation.validation_id,
  215. "action": "accept",
  216. "reason": "decode evidence passed",
  217. },
  218. )
  219. if direction.status in {TaskStatus.PENDING, TaskStatus.NEEDS_REPLAN}:
  220. return self.call("dispatch_script_tasks", {"task_ids": [direction.task_id]})
  221. if direction.status == TaskStatus.AWAITING_DECISION:
  222. validation = ledger.validations[direction.validation_ids[-1]]
  223. return self.call(
  224. "decide_script_task",
  225. {
  226. "task_id": direction.task_id,
  227. "validation_id": validation.validation_id,
  228. "action": "accept",
  229. "reason": "direction passed",
  230. },
  231. )
  232. assert direction.status == TaskStatus.COMPLETED
  233. if root.current_spec_version == 1:
  234. return self.call(
  235. "decide_script_task",
  236. {
  237. "task_id": root.task_id,
  238. "action": "revise",
  239. "reason": "record accepted direction in root objective",
  240. "replacement_contract": {
  241. "objective": root.current_spec.objective + " using accepted direction",
  242. },
  243. },
  244. )
  245. return self.call(
  246. "decide_script_task",
  247. {
  248. "task_id": root.task_id,
  249. "action": "block",
  250. "reason": "PHASE_ONE_CAPABILITY_BOUNDARY",
  251. },
  252. )
  253. def _worker(self, messages, names):
  254. prompt = _prompt(messages)
  255. spec = prompt["task_spec"]
  256. last = _last_tool_json(messages)
  257. if "search_script_decode_case" in names:
  258. if last is None or not last.get("submitted"):
  259. self.decode_versions.append(spec["version"])
  260. return self.call(
  261. "search_script_decode_case",
  262. {"return_field": "主脉络", "keyword": "focus", "top_k": 3},
  263. )
  264. return self.call(
  265. "submit_attempt",
  266. {},
  267. )
  268. if last is None or "direction_handle" not in last:
  269. accepted = prompt["role_context"]["accepted_inputs"]
  270. return self.call(
  271. "save_direction_candidate",
  272. {
  273. "goals": [
  274. {
  275. "client_key": "grounded-direction",
  276. "statement": "make one grounded direction",
  277. "rationale": "accepted evidence",
  278. "success_criteria": ["the direction remains grounded"],
  279. }
  280. ],
  281. "constraints": [
  282. {
  283. "client_key": "c1",
  284. "statement": "remain grounded",
  285. "rationale": "accepted evidence is mandatory",
  286. }
  287. ],
  288. "preferences": [],
  289. "strategy_handles": [],
  290. "evidence_decision_ids": [accepted[0]["decision_id"]],
  291. },
  292. )
  293. return self.call(
  294. "submit_attempt",
  295. {},
  296. )
  297. def _validator(self, messages):
  298. prompt = _prompt(messages)
  299. spec = prompt["task_spec"]
  300. kind = (
  301. "direction"
  302. if "script-build://task-kinds/direction" in spec["context_refs"]
  303. else "evidence"
  304. )
  305. role_context = prompt["role_context"]
  306. assert role_context["artifact"]["items"], "validator must receive the frozen artifact body"
  307. artifact = role_context["artifact"]["items"][0]["content"]
  308. assert isinstance(artifact, dict)
  309. self.validated_artifact_kinds.append(kind)
  310. failed = spec["objective"] == "decode evidence" and spec["version"] == 1
  311. verdict = "failed" if failed else "passed"
  312. evidence_handle = role_context["evidence_handles"][0]["evidence_handle"]
  313. return self.call(
  314. "submit_validation",
  315. {
  316. "verdict": verdict,
  317. "criterion_results": [
  318. {
  319. "criterion_id": item["criterion_id"],
  320. "verdict": verdict,
  321. "reason": "checked frozen snapshot",
  322. "evidence_handle_ids": [] if failed else [evidence_handle],
  323. }
  324. for item in spec["acceptance_criteria"]
  325. ],
  326. "defects": (
  327. [
  328. {
  329. "defect_code": "DECODE_QUERY_TOO_BROAD",
  330. "criterion_id": spec["acceptance_criteria"][0]["criterion_id"],
  331. "scope_selector": {
  332. "anchor": "mission",
  333. "path": ["direction", "decode"],
  334. },
  335. "observed_excerpt": "v1 decode evidence was too broad",
  336. "evidence_handle_ids": [],
  337. "severity": "hard",
  338. "invalidated_inputs": [],
  339. "recommended_action_class": "revise",
  340. }
  341. ]
  342. if failed
  343. else []
  344. ),
  345. "recommendation": "revise" if failed else "accept",
  346. },
  347. )
  348. class _Bindings:
  349. def __init__(self, binding: MissionBinding) -> None:
  350. self.binding = binding
  351. self.active = None
  352. async def get_by_build(self, _: int) -> MissionBinding:
  353. return self.binding
  354. async def get_by_root(self, _: str) -> MissionBinding:
  355. return self.binding
  356. async def set_active_direction(self, **values) -> MissionBinding:
  357. self.active = values["artifact_version_id"]
  358. return replace(self.binding, active_direction_artifact_version_id=self.active)
  359. class _Snapshots:
  360. def __init__(self, snapshot: ScriptBuildInputSnapshotV1) -> None:
  361. self.snapshot = snapshot
  362. async def get(self, *_args, **_kwargs) -> ScriptBuildInputSnapshotV1:
  363. return self.snapshot
  364. class _Artifacts:
  365. def __init__(self) -> None:
  366. self.values = {}
  367. self.next_id = 1
  368. async def freeze(self, *, artifact, script_build_id, task_id, attempt_id, spec_version):
  369. payload = artifact.content_payload()
  370. digest = canonical_sha256(payload).wire
  371. if isinstance(artifact, EvidenceRecordV1):
  372. artifact = replace(artifact, content_sha256=digest)
  373. kind = ArtifactKind.EVIDENCE
  374. else:
  375. artifact = replace(artifact, canonical_sha256=digest)
  376. kind = ArtifactKind.DIRECTION
  377. value = ArtifactVersion(
  378. self.next_id,
  379. script_build_id,
  380. task_id,
  381. attempt_id,
  382. spec_version,
  383. kind,
  384. digest,
  385. ArtifactState.FROZEN,
  386. artifact,
  387. datetime.now(UTC),
  388. datetime.now(UTC),
  389. )
  390. ref = ArtifactRef(
  391. uri=f"script-build://artifact-versions/{self.next_id}",
  392. kind=kind.value,
  393. version=str(self.next_id),
  394. digest=digest,
  395. metadata={
  396. "script_build_id": script_build_id,
  397. "task_id": task_id,
  398. "attempt_id": attempt_id,
  399. },
  400. )
  401. self.values[self.next_id] = (value, ref)
  402. self.next_id += 1
  403. return value, ref
  404. async def read_by_ref(self, ref, **_kwargs):
  405. return self.values[int(ref.version)][0]
  406. async def get_by_attempt(self, *, script_build_id, task_id, attempt_id):
  407. try:
  408. return next(
  409. value
  410. for value, _ref in self.values.values()
  411. if value.script_build_id == script_build_id
  412. and value.task_id == task_id
  413. and value.attempt_id == attempt_id
  414. )
  415. except StopIteration as exc:
  416. raise ArtifactNotFound() from exc
  417. class _Legacy:
  418. def __init__(self) -> None:
  419. self.status = BuildStatus.RUNNING
  420. self.direction = None
  421. async def get_status(self, _: int) -> BuildStatus:
  422. return self.status
  423. async def set_status(self, _id, status, **_kwargs):
  424. self.status = status
  425. async def project_direction(self, _id, value):
  426. assert self.status != BuildStatus.STOPPING
  427. self.direction = value
  428. class _Publications:
  429. def __init__(self) -> None:
  430. self.state = PublicationState.PENDING
  431. async def prepare(self, **values):
  432. return SimpleNamespace(publication_id=1, state=self.state, **values)
  433. async def mark_published(self, _id):
  434. self.state = PublicationState.PUBLISHED
  435. async def mark_failed(self, _id, **_values):
  436. self.state = PublicationState.FAILED
  437. class _Decode:
  438. async def retrieve(self, *, query, snapshot):
  439. del snapshot
  440. return RetrievalResult(
  441. source_refs=(f"decode:{query['keyword']}",),
  442. summary="evidence",
  443. supports=("direction",),
  444. confidence="high",
  445. metadata={"index_sha256": "sha256:" + "1" * 64, "ranks": [1]},
  446. )
  447. class _PrincipalProvider:
  448. async def current(self, request_context=None):
  449. del request_context
  450. return Principal("owner")
  451. class _Authorizer:
  452. async def require_source_access(self, _principal, **_source):
  453. return None
  454. async def require_access(self, _principal, _build):
  455. return None
  456. @pytest.mark.asyncio
  457. async def test_real_runner_phase_one_fail_revise_pass_direction_accept_then_root_block(tmp_path):
  458. now = datetime.now(UTC)
  459. binding = MissionBinding(1, 7, "phase-one-root", 11, None, None, "test", "v1", now, now)
  460. model_presets = {
  461. name: {"model": "fake-model", "temperature": 0, "max_iterations": 50}
  462. for name in (
  463. "script_decode_retrieval_worker",
  464. "script_direction_worker",
  465. "script_retrieval_validator",
  466. "script_candidate_validator",
  467. )
  468. }
  469. snapshot = ScriptBuildInputSnapshotV1(
  470. "11",
  471. 7,
  472. 1,
  473. 2,
  474. 3,
  475. {"topic": {"id": 3, "result": "topic"}},
  476. {"account_name": "acct"},
  477. (),
  478. (),
  479. (),
  480. (),
  481. {},
  482. {"presets": model_presets},
  483. "sha256:" + "a" * 64,
  484. now,
  485. )
  486. task_store = FileSystemTaskStore(str(tmp_path / "ledger"))
  487. llm = _ScriptedLLM(task_store, binding.root_trace_id)
  488. runner = AgentRunner(trace_store=FileSystemTraceStore(str(tmp_path / "trace")), llm_call=llm)
  489. bindings = _Bindings(binding)
  490. snapshots = _Snapshots(snapshot)
  491. artifacts = _Artifacts()
  492. legacy = _Legacy()
  493. publications = _Publications()
  494. composition = compose_host(
  495. HostDependencies(
  496. runner=runner,
  497. task_store=task_store,
  498. framework_artifact_store=FileSystemArtifactStore(str(tmp_path / "artifacts")),
  499. input_snapshot_service=SimpleNamespace(get=snapshots.get),
  500. input_snapshots=snapshots,
  501. bindings=bindings,
  502. business_artifacts=artifacts,
  503. publications=publications,
  504. legacy_state=legacy,
  505. principal_provider=_PrincipalProvider(),
  506. build_authorizer=_Authorizer(),
  507. retrieval_adapters={"decode": _Decode()},
  508. )
  509. )
  510. await composition.mission_service.run(7)
  511. ledger = await task_store.load(binding.root_trace_id)
  512. root = ledger.tasks[ledger.root_task_id]
  513. assert root.status == TaskStatus.BLOCKED
  514. assert root.blocked_reason == "PHASE_ONE_CAPABILITY_BOUNDARY"
  515. assert root.current_spec_version == 2
  516. assert llm.decode_versions == [1, 2]
  517. assert llm.replayed_dispatch
  518. assert len(ledger.operations) == 3
  519. assert llm.validated_artifact_kinds == ["evidence", "evidence", "direction"]
  520. assert legacy.status == BuildStatus.PARTIAL
  521. assert legacy.direction.startswith("# Direction\n\n## Goals")
  522. assert "### goal-" in legacy.direction
  523. assert ": make one grounded direction" in legacy.direction
  524. assert "## Constraints" in legacy.direction
  525. assert publications.state == PublicationState.PUBLISHED
  526. assert bindings.active is not None
  527. assert not hasattr(bindings.binding, "branch_id")
  528. model_calls = llm.calls
  529. reloaded_ledger = await FileSystemTaskStore(str(tmp_path / "ledger")).load(
  530. binding.root_trace_id
  531. )
  532. assert reloaded_ledger.tasks[reloaded_ledger.root_task_id].status == TaskStatus.BLOCKED
  533. reloaded_traces = FileSystemTraceStore(str(tmp_path / "trace"))
  534. reloaded_trace = await reloaded_traces.get_trace(binding.root_trace_id)
  535. assert reloaded_trace is not None
  536. assert await reloaded_traces.get_goal_tree(binding.root_trace_id) is None
  537. submitted = next(
  538. attempt for attempt in reloaded_ledger.attempts.values() if attempt.snapshot_id
  539. )
  540. assert await FileSystemArtifactStore(str(tmp_path / "artifacts")).get(
  541. binding.root_trace_id,
  542. submitted.snapshot_id,
  543. )
  544. assert llm.calls == model_calls