test_phase_one_e2e.py 18 KB

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