real_runs.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505
  1. from __future__ import annotations
  2. import json
  3. import os
  4. import re
  5. from datetime import datetime, timezone
  6. from pathlib import Path
  7. from typing import Any, Iterable
  8. from .models import (
  9. AgentStep,
  10. AttemptDetail,
  11. DataUsage,
  12. ExecutionView,
  13. LifecycleEvent,
  14. PlannerTurn,
  15. RunSummary,
  16. TaskDetail,
  17. TaskNode,
  18. )
  19. VISUALIZATION_ROOT = Path(__file__).resolve().parents[2]
  20. DEFAULT_AGENT_DATA_ROOT = VISUALIZATION_ROOT.parent / "script_build_host" / ".local" / "agent-data"
  21. BUILD_ID_PATTERN = re.compile(r"Script build (\d+)", re.IGNORECASE)
  22. TASK_KIND_PREFIX = "script-build://task-kinds/"
  23. CONTRACT_PREFIX = "script-build://task-contracts/sha256/"
  24. PLANNER_TOOLS = {
  25. "inspect_script_plan",
  26. "plan_script_tasks",
  27. "dispatch_script_tasks",
  28. "validate_attempt",
  29. "decide_script_task",
  30. "read_input_snapshot",
  31. }
  32. ACTION_LABELS = {
  33. "inspect_script_plan": "查看整棵任务树",
  34. "plan_script_tasks": "规划新 Task",
  35. "dispatch_script_tasks": "派发 Task 执行",
  36. "validate_attempt": "读取独立验收",
  37. "decide_script_task": "决定下一步",
  38. "read_input_snapshot": "读取冻结输入",
  39. }
  40. class RunNotFound(FileNotFoundError):
  41. pass
  42. def agent_data_root() -> Path:
  43. configured = os.getenv("SCRIPT_BUILD_AGENT_DATA_ROOT")
  44. return Path(configured).expanduser().resolve() if configured else DEFAULT_AGENT_DATA_ROOT.resolve()
  45. def _read_json(path: Path) -> dict[str, Any]:
  46. with path.open("r", encoding="utf-8") as handle:
  47. value = json.load(handle)
  48. return value if isinstance(value, dict) else {}
  49. def _message_files(root: Path, trace_id: str) -> list[Path]:
  50. return sorted((root / "traces" / trace_id / "messages").glob("*.json"))
  51. def _messages(root: Path, trace_id: str) -> list[dict[str, Any]]:
  52. return [_read_json(path) for path in _message_files(root, trace_id)]
  53. def _build_id_for_root(root: Path, root_trace_id: str) -> int | None:
  54. meta_path = root / "traces" / root_trace_id / "meta.json"
  55. if meta_path.exists():
  56. context = _read_json(meta_path).get("context")
  57. if isinstance(context, dict):
  58. build_id = context.get("script_build_id")
  59. if isinstance(build_id, int) and not isinstance(build_id, bool):
  60. return build_id
  61. # Legacy traces may only expose the Build ID through GoalTree or early messages.
  62. goal_path = root / "traces" / root_trace_id / "goal.json"
  63. if goal_path.exists():
  64. match = BUILD_ID_PATTERN.search(str(_read_json(goal_path).get("mission", "")))
  65. if match:
  66. return int(match.group(1))
  67. for message in _messages(root, root_trace_id)[:6]:
  68. content = message.get("content")
  69. if isinstance(content, str):
  70. try:
  71. payload = json.loads(content)
  72. except json.JSONDecodeError:
  73. continue
  74. if isinstance(payload, dict) and isinstance(payload.get("script_build_id"), int):
  75. return payload["script_build_id"]
  76. return None
  77. def _roots_by_build() -> dict[int, str]:
  78. root = agent_data_root()
  79. values: dict[int, str] = {}
  80. for ledger in sorted((root / "task-ledger").glob("*/orchestration/ledger.json")):
  81. root_trace_id = ledger.parents[1].name
  82. build_id = _build_id_for_root(root, root_trace_id)
  83. if build_id is not None:
  84. values[build_id] = root_trace_id
  85. return values
  86. def _load_run(script_build_id: int) -> tuple[Path, str, dict[str, Any], list[dict[str, Any]]]:
  87. root = agent_data_root()
  88. root_trace_id = _roots_by_build().get(script_build_id)
  89. if not root_trace_id:
  90. raise RunNotFound(f"Persisted script build {script_build_id} was not found")
  91. ledger_path = root / "task-ledger" / root_trace_id / "orchestration" / "ledger.json"
  92. return root, root_trace_id, _read_json(ledger_path), _messages(root, root_trace_id)
  93. def _task_kind(task: dict[str, Any]) -> str:
  94. if task.get("parent_task_id") is None:
  95. return "root"
  96. specs = task.get("specs") or []
  97. refs = (specs[-1].get("context_refs") or []) if specs else []
  98. for ref in refs:
  99. if isinstance(ref, str) and ref.startswith(TASK_KIND_PREFIX):
  100. return ref.removeprefix(TASK_KIND_PREFIX)
  101. return "task"
  102. def _phase(display_path: str, task_kind: str) -> str:
  103. if task_kind == "root":
  104. return "mission"
  105. if display_path.startswith("0.1"):
  106. return "phase-one"
  107. if display_path.startswith("0.2"):
  108. return "phase-two"
  109. return "delivery"
  110. def _task_view(task: dict[str, Any]) -> TaskNode:
  111. specs = task.get("specs") or [{}]
  112. spec = specs[-1]
  113. kind = _task_kind(task)
  114. path = str(task.get("display_path", "?"))
  115. return TaskNode(
  116. task_id=str(task.get("task_id", "")),
  117. display_path=path,
  118. parent_task_id=task.get("parent_task_id"),
  119. child_task_ids=list(task.get("child_task_ids") or []),
  120. task_kind=kind,
  121. phase=_phase(path, kind),
  122. objective=str(spec.get("objective") or "未记录目标"),
  123. status=str(task.get("status") or "unknown"),
  124. blocked_reason=task.get("blocked_reason"),
  125. attempt_count=len(task.get("attempt_ids") or []),
  126. validation_count=len(task.get("validation_ids") or []),
  127. decision_count=len(task.get("decision_ids") or []),
  128. current_spec_version=int(task.get("current_spec_version") or 1),
  129. acceptance_criteria=list(spec.get("acceptance_criteria") or []),
  130. context_refs=list(spec.get("context_refs") or []),
  131. created_at=str(task.get("created_at") or ""),
  132. updated_at=str(task.get("updated_at") or ""),
  133. )
  134. def _parse_object(value: Any) -> Any:
  135. if not isinstance(value, str):
  136. return value
  137. try:
  138. return json.loads(value)
  139. except json.JSONDecodeError:
  140. return value
  141. def _bounded_text(value: Any, limit: int = 3200) -> str:
  142. if value is None:
  143. return ""
  144. text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, indent=2)
  145. normalized = text.strip()
  146. return normalized if len(normalized) <= limit else f"{normalized[:limit]}\n…(已截断)"
  147. def _ids_from(value: Any, known_ids: set[str]) -> set[str]:
  148. found: set[str] = set()
  149. if isinstance(value, dict):
  150. for key, item in value.items():
  151. if key in {"task_id", "parent_task_id"} and isinstance(item, str) and item in known_ids:
  152. found.add(item)
  153. elif key in {"task_ids", "child_task_ids"} and isinstance(item, list):
  154. found.update(entry for entry in item if isinstance(entry, str) and entry in known_ids)
  155. found.update(_ids_from(item, known_ids))
  156. elif isinstance(value, list):
  157. for item in value:
  158. found.update(_ids_from(item, known_ids))
  159. return found
  160. def _result_status(result: Any) -> str:
  161. if isinstance(result, dict):
  162. encoded = json.dumps(result, ensure_ascii=False).lower()
  163. if any(token in encoded for token in ('"error"', "protocol_violation", "failed")) and '"error": null' not in encoded:
  164. return "failed"
  165. return "succeeded"
  166. return "unknown"
  167. def _planner_turns(
  168. messages: list[dict[str, Any]], task_views: list[TaskNode]
  169. ) -> tuple[list[PlannerTurn], str | None]:
  170. known_ids = {task.task_id for task in task_views}
  171. root_ids = {task.task_id for task in task_views if task.parent_task_id is None}
  172. tool_results = {
  173. message.get("tool_call_id"): message
  174. for message in messages
  175. if message.get("role") == "tool" and message.get("tool_call_id")
  176. }
  177. visible_ids = set(root_ids)
  178. current_phase = "mission"
  179. turns: list[PlannerTurn] = []
  180. current_focus: str | None = None
  181. for message in messages:
  182. if message.get("role") == "user":
  183. payload = _parse_object(message.get("content"))
  184. if isinstance(payload, dict) and payload.get("phase"):
  185. current_phase = f"phase-{payload['phase']}"
  186. continue
  187. if message.get("role") != "assistant" or not isinstance(message.get("content"), dict):
  188. continue
  189. content = message["content"]
  190. for call in content.get("tool_calls") or []:
  191. function = call.get("function") or {}
  192. tool_name = str(function.get("name") or "")
  193. if tool_name not in PLANNER_TOOLS:
  194. continue
  195. arguments = _parse_object(function.get("arguments") or "{}")
  196. arguments = arguments if isinstance(arguments, dict) else {"raw": arguments}
  197. result_message = tool_results.get(call.get("id"), {})
  198. result_content = result_message.get("content") or {}
  199. raw_result = result_content.get("result") if isinstance(result_content, dict) else result_content
  200. result = _parse_object(raw_result)
  201. affected = _ids_from(arguments, known_ids) | _ids_from(result, known_ids)
  202. newly_visible = _ids_from(result, known_ids)
  203. visible_ids.update(newly_visible)
  204. explicit_focus = arguments.get("task_id") or arguments.get("parent_task_id")
  205. if not explicit_focus and isinstance(arguments.get("task_ids"), list) and arguments["task_ids"]:
  206. explicit_focus = arguments["task_ids"][0]
  207. if explicit_focus in known_ids:
  208. current_focus = explicit_focus
  209. elif affected:
  210. current_focus = sorted(affected)[0]
  211. order = len(turns) + 1
  212. visible_snapshot = sorted(visible_ids)
  213. turns.append(
  214. PlannerTurn(
  215. turn_id=f"planner-{message.get('sequence', order)}-{len(turns)}",
  216. order=order,
  217. message_sequence=int(message.get("sequence") or order),
  218. phase=current_phase,
  219. action=tool_name,
  220. action_label=ACTION_LABELS[tool_name],
  221. summary=_bounded_text(content.get("text"), 520) or ACTION_LABELS[tool_name],
  222. observable_reasoning=_bounded_text(content.get("text"), 2200),
  223. result_summary=_bounded_text(result, 900),
  224. status=_result_status(result),
  225. focus_task_id=current_focus,
  226. affected_task_ids=sorted(affected),
  227. visible_task_ids=visible_snapshot,
  228. tool_name=tool_name,
  229. tool_arguments=arguments,
  230. tokens=int(message.get("tokens") or 0),
  231. cost=float(message.get("cost") or 0),
  232. created_at=str(message.get("created_at") or ""),
  233. )
  234. )
  235. if turns:
  236. turns[-1].visible_task_ids = sorted(known_ids)
  237. return turns, current_focus
  238. def _checkpoint(tasks: list[TaskNode]) -> str:
  239. kinds = {task.task_kind for task in tasks}
  240. if "candidate-portfolio" in kinds:
  241. if "compose" in kinds:
  242. return "Phase 2 · Compose 子任务回流"
  243. return "Phase 2 · Candidate Portfolio"
  244. if any(task.task_kind == "direction" and task.status == "completed" for task in tasks):
  245. return "Phase 1 · Direction 已验收"
  246. return "Phase 1 · 方向与证据"
  247. def _current_gap(tasks: list[TaskNode]) -> str:
  248. candidates = [task for task in tasks if task.status in {"needs_replan", "failed", "blocked"}]
  249. candidates.sort(key=lambda item: (item.display_path.count("."), item.updated_at), reverse=True)
  250. if candidates:
  251. task = candidates[0]
  252. reason = task.blocked_reason or task.status
  253. return f"{task.display_path} {task.task_kind} 等待 Planner 处理:{reason}"
  254. waiting = [task for task in tasks if task.status == "waiting_children"]
  255. if waiting:
  256. task = sorted(waiting, key=lambda item: item.display_path.count("."), reverse=True)[0]
  257. return f"{task.display_path} 正在等待子 Task 结果返回"
  258. return "当前持久化快照没有未处理缺口"
  259. def _run_summary(
  260. script_build_id: int,
  261. root_trace_id: str,
  262. ledger: dict[str, Any],
  263. tasks: list[TaskNode],
  264. turns: list[PlannerTurn],
  265. focus: str | None,
  266. ) -> RunSummary:
  267. root_id = ledger.get("root_task_id")
  268. root_task = next((task for task in tasks if task.task_id == root_id), tasks[0])
  269. return RunSummary(
  270. script_build_id=script_build_id,
  271. title=root_task.objective,
  272. status=root_task.status,
  273. checkpoint=_checkpoint(tasks),
  274. root_trace_id=root_trace_id,
  275. revision=int(ledger.get("revision") or 0),
  276. task_count=len(tasks),
  277. attempt_count=len(ledger.get("attempts") or {}),
  278. planner_turn_count=len(turns),
  279. current_focus_task_id=focus,
  280. current_gap=_current_gap(tasks),
  281. created_at=root_task.created_at,
  282. updated_at=max((task.updated_at for task in tasks), default=root_task.updated_at),
  283. )
  284. def execution_view(script_build_id: int) -> ExecutionView:
  285. _, root_trace_id, ledger, messages = _load_run(script_build_id)
  286. tasks = [_task_view(item) for item in (ledger.get("tasks") or {}).values()]
  287. tasks.sort(key=lambda item: [int(part) for part in item.display_path.split(".")])
  288. turns, focus = _planner_turns(messages, tasks)
  289. run = _run_summary(script_build_id, root_trace_id, ledger, tasks, turns, focus)
  290. warnings = [
  291. "树的生长顺序来自 Planner 工具结果;Task 状态来自当前 ledger,不伪造历史状态回放。",
  292. "Reasoning 仅展示模型主动输出的说明、工具参数与验收理由,不等同于隐藏思维链。",
  293. ]
  294. return ExecutionView(
  295. schema_version="script-build-execution-view/v1",
  296. data_mode="persisted-real-run",
  297. generated_at=datetime.now(timezone.utc).isoformat(),
  298. run=run,
  299. planner_turns=turns,
  300. tasks=tasks,
  301. snapshot_basis="Planner 工具调用顺序 + 当前 TaskLedger 持久化快照",
  302. warnings=warnings,
  303. )
  304. def list_runs() -> list[RunSummary]:
  305. values: list[RunSummary] = []
  306. for build_id in sorted(_roots_by_build(), reverse=True):
  307. try:
  308. values.append(execution_view(build_id).run)
  309. except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError):
  310. continue
  311. return values
  312. def _contract(root: Path, root_trace_id: str, task: TaskNode) -> dict[str, Any] | None:
  313. for ref in task.context_refs:
  314. if ref.startswith(CONTRACT_PREFIX):
  315. digest = ref.removeprefix(CONTRACT_PREFIX)
  316. path = root / "script-task-contracts" / root_trace_id / "sha256" / digest
  317. if path.exists():
  318. return _read_json(path)
  319. return None
  320. def _step_title(tool_name: str) -> str:
  321. if tool_name.startswith("read_"):
  322. return f"读取数据 · {tool_name}"
  323. if tool_name.startswith(("create_", "batch_create_", "append_")):
  324. return f"写入草稿 · {tool_name}"
  325. if tool_name.startswith(("update_", "batch_update_")):
  326. return f"修改草稿 · {tool_name}"
  327. if tool_name == "submit_attempt":
  328. return "提交 Attempt"
  329. return f"调用工具 · {tool_name}"
  330. def _agent_steps(root: Path, trace_id: str | None) -> tuple[str, list[AgentStep]]:
  331. if not trace_id:
  332. return "", []
  333. prompt = ""
  334. steps: list[AgentStep] = []
  335. for message in _messages(root, trace_id):
  336. role = str(message.get("role") or "unknown")
  337. content = message.get("content")
  338. if role == "system":
  339. prompt = _bounded_text(content, 9000)
  340. continue
  341. if role == "user":
  342. steps.append(
  343. AgentStep(
  344. sequence=int(message.get("sequence") or 0), role=role, step_kind="instruction",
  345. title="冻结的 TaskSpec", text=_bounded_text(content), tool_name=None, tool_arguments=None,
  346. tokens=int(message.get("tokens") or 0), cost=float(message.get("cost") or 0),
  347. duration_ms=message.get("duration_ms"), created_at=str(message.get("created_at") or ""),
  348. )
  349. )
  350. continue
  351. if role == "assistant" and isinstance(content, dict):
  352. text = _bounded_text(content.get("text"), 2200)
  353. if text:
  354. steps.append(
  355. AgentStep(
  356. sequence=int(message.get("sequence") or 0), role=role, step_kind="explanation",
  357. title="Worker 的可观察说明", text=text, tool_name=None, tool_arguments=None,
  358. tokens=int(message.get("tokens") or 0), cost=float(message.get("cost") or 0),
  359. duration_ms=message.get("duration_ms"), created_at=str(message.get("created_at") or ""),
  360. )
  361. )
  362. for call in content.get("tool_calls") or []:
  363. function = call.get("function") or {}
  364. name = str(function.get("name") or "unknown_tool")
  365. args = _parse_object(function.get("arguments") or "{}")
  366. steps.append(
  367. AgentStep(
  368. sequence=int(message.get("sequence") or 0), role=role, step_kind="tool_call",
  369. title=_step_title(name), text="", tool_name=name,
  370. tool_arguments=args if isinstance(args, dict) else {"raw": args},
  371. tokens=int(message.get("tokens") or 0), cost=float(message.get("cost") or 0),
  372. duration_ms=message.get("duration_ms"), created_at=str(message.get("created_at") or ""),
  373. )
  374. )
  375. elif role == "tool":
  376. tool_name = content.get("tool_name") if isinstance(content, dict) else None
  377. result = content.get("result") if isinstance(content, dict) else content
  378. steps.append(
  379. AgentStep(
  380. sequence=int(message.get("sequence") or 0), role=role, step_kind="tool_result",
  381. title=f"工具返回 · {tool_name or 'unknown'}", text=_bounded_text(_parse_object(result)),
  382. tool_name=tool_name, tool_arguments=None, tokens=0, cost=0,
  383. duration_ms=message.get("duration_ms"), created_at=str(message.get("created_at") or ""),
  384. )
  385. )
  386. return prompt, steps
  387. def _values_for_task(values: dict[str, Any], task_id: str) -> list[dict[str, Any]]:
  388. return [value for value in values.values() if value.get("task_id") == task_id]
  389. def _first_for_attempt(values: Iterable[dict[str, Any]], attempt_id: str) -> dict[str, Any] | None:
  390. return next((value for value in values if value.get("attempt_id") == attempt_id), None)
  391. def task_detail(script_build_id: int, task_id: str) -> TaskDetail:
  392. root, root_trace_id, ledger, _ = _load_run(script_build_id)
  393. raw_task = (ledger.get("tasks") or {}).get(task_id)
  394. if not raw_task:
  395. raise RunNotFound(f"Task {task_id} was not found in build {script_build_id}")
  396. task = _task_view(raw_task)
  397. contract = _contract(root, root_trace_id, task)
  398. validations = _values_for_task(ledger.get("validations") or {}, task_id)
  399. decisions = _values_for_task(ledger.get("decisions") or {}, task_id)
  400. raw_attempts = _values_for_task(ledger.get("attempts") or {}, task_id)
  401. attempt_details: list[AttemptDetail] = []
  402. reads: list[dict[str, Any]] = []
  403. outputs: list[dict[str, Any]] = []
  404. for attempt in sorted(raw_attempts, key=lambda item: item.get("created_at") or ""):
  405. prompt, steps = _agent_steps(root, attempt.get("worker_trace_id"))
  406. for step in steps:
  407. if step.step_kind == "tool_call" and step.tool_name and step.tool_name.startswith(("read_", "get_", "search_", "query_", "inspect_")):
  408. reads.append({"attempt_id": attempt.get("attempt_id"), "tool_name": step.tool_name, "arguments": step.tool_arguments, "proof": "worker_trace_tool_call"})
  409. submission = attempt.get("submission")
  410. if isinstance(submission, dict):
  411. for artifact in submission.get("artifact_refs") or []:
  412. outputs.append({"attempt_id": attempt.get("attempt_id"), "artifact": artifact, "proof": "attempt_submission"})
  413. validation = _first_for_attempt(validations, str(attempt.get("attempt_id")))
  414. decision = _first_for_attempt(decisions, str(attempt.get("attempt_id")))
  415. stats = attempt.get("execution_stats") or {}
  416. attempt_details.append(
  417. AttemptDetail(
  418. attempt_id=str(attempt.get("attempt_id") or ""), task_id=task_id,
  419. status=str(attempt.get("status") or "unknown"), worker_preset=attempt.get("worker_preset"),
  420. worker_trace_id=attempt.get("worker_trace_id"), execution_mode=attempt.get("execution_mode"),
  421. started_at=attempt.get("started_at"), completed_at=attempt.get("completed_at"),
  422. total_tokens=int(stats.get("total_tokens") or 0), total_cost=float(stats.get("total_cost") or 0),
  423. error=attempt.get("error"), submission=submission, validation=validation, decision=decision,
  424. prompt=prompt, steps=steps,
  425. )
  426. )
  427. lifecycle = [
  428. LifecycleEvent(event_id=f"task-{task_id}", kind="task", title="Planner 创建 Task", status="created", summary=task.objective, created_at=task.created_at)
  429. ]
  430. for attempt in raw_attempts:
  431. lifecycle.append(LifecycleEvent(event_id=str(attempt.get("attempt_id")), kind="attempt", title="Worker 执行 Attempt", status=str(attempt.get("status")), summary=str(attempt.get("worker_preset") or "worker"), created_at=str(attempt.get("created_at") or ""), attempt_id=attempt.get("attempt_id")))
  432. for validation in validations:
  433. lifecycle.append(LifecycleEvent(event_id=str(validation.get("validation_id")), kind="validation", title="Validator 独立验收", status=str(validation.get("verdict") or validation.get("status")), summary=str(validation.get("recommendation") or validation.get("summary") or ""), created_at=str(validation.get("created_at") or ""), attempt_id=validation.get("attempt_id")))
  434. for decision in decisions:
  435. lifecycle.append(LifecycleEvent(event_id=str(decision.get("decision_id")), kind="decision", title="Planner 决定下一步", status=str(decision.get("action")), summary=str(decision.get("reason") or ""), created_at=str(decision.get("created_at") or ""), attempt_id=decision.get("attempt_id")))
  436. lifecycle.sort(key=lambda item: item.created_at)
  437. inherited: list[dict[str, Any]] = [{"ref": ref, "proof": "task_spec_context_ref", "usage": "在冻结输入闭包中"} for ref in task.context_refs]
  438. if contract:
  439. for ref in contract.get("input_decision_refs") or []:
  440. inherited.append({"ref": ref, "proof": "task_contract_input_decision_ref", "usage": "显式接受的上游结果"})
  441. if contract.get("base_artifact_ref"):
  442. inherited.append({"ref": contract["base_artifact_ref"], "proof": "task_contract_base_artifact_ref", "usage": "本 Attempt 的基础产物"})
  443. adoption = [{"decision_id": value.get("decision_id"), "action": value.get("action"), "reason": value.get("reason"), "proof": "planner_decision"} for value in decisions]
  444. return TaskDetail(
  445. task=task, contract=contract, lifecycle=lifecycle, attempts=attempt_details,
  446. data_usage=DataUsage(inherited_inputs=inherited, confirmed_reads=reads, produced_outputs=outputs, adoption=adoption),
  447. )