task_protocol.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737
  1. """Recursive 任务报告与父级审核工具。
  2. 子 Agent 通过 ``submit_task_report`` 提交结构化结果;Validator 验收并汇合后,
  3. 直接父 Agent 必须通过 ``review_task_result`` 批准下一步,两者均不向 Legacy 暴露。
  4. """
  5. from __future__ import annotations
  6. from copy import deepcopy
  7. from datetime import datetime
  8. from hashlib import sha256
  9. from typing import Any
  10. from pydantic import ValidationError
  11. from cyber_agent.core.agent_mode import require_mutable_trace_policy
  12. from cyber_agent.core.context_policy import (
  13. ContextPolicyError,
  14. add_context_snapshot,
  15. build_child_context_access,
  16. create_context_snapshot,
  17. normalize_task_brief,
  18. replace_context_access,
  19. require_matching_root_task_anchor,
  20. require_root_task_anchor,
  21. )
  22. from cyber_agent.core.task_protocol import (
  23. TaskReport,
  24. TaskReportSubmission,
  25. TaskReview,
  26. ReviewDecision,
  27. TaskBrief,
  28. TaskProgress,
  29. task_progress_readiness_error,
  30. ensure_task_protocol,
  31. )
  32. from cyber_agent.core.task_protocol_service import (
  33. TaskProtocolService,
  34. TaskProtocolStateError,
  35. )
  36. from cyber_agent.core.validation import ValidationResult
  37. from cyber_agent.application.candidate import CandidateReviewAction
  38. from cyber_agent.application.models import canonical_json
  39. from cyber_agent.tools import tool
  40. _ALLOWED_REVIEW_DECISIONS_BY_VALIDATION: dict[str, set[ReviewDecision]] = {
  41. "passed": {
  42. "ACCEPT_REFINE",
  43. "REJECT_REFINE",
  44. "REVISE_CHILD",
  45. "DESCEND_AGAIN",
  46. "ASCEND",
  47. "FAIL",
  48. },
  49. "failed": {"REVISE_CHILD", "REPLAN_CURRENT", "FAIL"},
  50. "unknown": {"REVISE_CHILD", "REPLAN_CURRENT", "FAIL"},
  51. "error": {"REVISE_CHILD", "FAIL"},
  52. }
  53. def _error(message: str) -> dict[str, Any]:
  54. return {"status": "failed", "error": message}
  55. def _review_command_hash(
  56. *,
  57. child_trace_id: str,
  58. decision: ReviewDecision,
  59. reason: str,
  60. approved_next_task: TaskBrief | str | None,
  61. candidate_actions: list[CandidateReviewAction] | None,
  62. ) -> str:
  63. """Hash the persisted assistant command before protocol normalization."""
  64. def json_value(value: Any) -> Any:
  65. if hasattr(value, "model_dump"):
  66. return value.model_dump(mode="json")
  67. if isinstance(value, list):
  68. return [json_value(item) for item in value]
  69. if isinstance(value, tuple):
  70. return [json_value(item) for item in value]
  71. if isinstance(value, dict):
  72. return {key: json_value(item) for key, item in value.items()}
  73. return value
  74. payload = {
  75. "child_trace_id": child_trace_id,
  76. "decision": decision,
  77. "reason": reason,
  78. "approved_next_task": json_value(approved_next_task),
  79. "candidate_actions": json_value(candidate_actions or []),
  80. }
  81. return sha256(canonical_json(payload).encode("utf-8")).hexdigest()
  82. def _completed_review_response(
  83. review_record: dict[str, Any],
  84. state: dict[str, Any],
  85. ) -> dict[str, Any]:
  86. task_review = TaskReview.model_validate({
  87. field: review_record.get(field)
  88. for field in TaskReview.model_fields
  89. })
  90. return {
  91. "status": "completed",
  92. "task_review": task_review.model_dump(),
  93. "context_ref": review_record.get("context_ref"),
  94. "context_ref_warning": review_record.get("context_ref_warning"),
  95. "next_action_task_brief": review_record.get(
  96. "next_action_task_brief"
  97. ),
  98. "pending_review_count": len(state["pending_reviews"]),
  99. "next_action_count": len(state["next_actions"]),
  100. }
  101. async def _project_committed_review(
  102. *,
  103. store: Any,
  104. context: dict[str, Any],
  105. parent_trace_id: str,
  106. child_trace_id: str,
  107. state: dict[str, Any],
  108. review_record: dict[str, Any],
  109. ) -> None:
  110. """Idempotently finish projections after the parent protocol commit."""
  111. reviewed_at_sequence = int(review_record["reviewed_at_sequence"])
  112. if review_record["decision"] == "REVISE_CHILD":
  113. child = await store.get_trace(child_trace_id)
  114. if child is None:
  115. raise ValueError(
  116. "Committed REVISE_CHILD review references a missing child Trace"
  117. )
  118. child_state = ensure_task_protocol(child.context)
  119. old_report = child_state.get("task_report")
  120. if old_report and old_report not in child_state["report_history"]:
  121. child_state["report_history"].append(old_report)
  122. child_state["task_report"] = None
  123. child_state["task_report_submitted_at_sequence"] = None
  124. child_state["task_report_progress_revision"] = None
  125. child_state["task_report_validation"] = None
  126. child_state["protocol_correction_attempts"] = 0
  127. replace_context_access(
  128. child.context,
  129. [],
  130. root_task_anchor=require_root_task_anchor(child.context),
  131. task_brief=child_state.get("task_brief"),
  132. )
  133. await store.update_trace(child_trace_id, context=child.context)
  134. event_service = context.get("event_service")
  135. if event_service is not None:
  136. await event_service.emit_after_commit(
  137. source_trace_id=parent_trace_id,
  138. event_type="task.review.recorded",
  139. event_key=(
  140. f"task.review.recorded:{parent_trace_id}:"
  141. f"{child_trace_id}:{reviewed_at_sequence}"
  142. ),
  143. effective_at_sequence=reviewed_at_sequence,
  144. payload={"task_review": review_record},
  145. )
  146. pending_entry = review_record.get("pending_review") or {}
  147. goal_id = pending_entry.get("goal_id")
  148. if not goal_id:
  149. return
  150. decision = review_record["decision"]
  151. resolved_replan = review_record.get("resolved_replan")
  152. if state["pending_reviews"]:
  153. next_status = "pending_review"
  154. elif resolved_replan:
  155. next_status = "in_progress"
  156. goal_id = resolved_replan["goal_id"]
  157. elif decision == "ASCEND":
  158. next_status = "completed"
  159. elif decision == "FAIL":
  160. next_status = "failed"
  161. else:
  162. next_status = "in_progress"
  163. reason = review_record["reason"]
  164. await store.update_goal(
  165. parent_trace_id,
  166. goal_id,
  167. cascade_completion=False,
  168. status=next_status,
  169. summary=reason if next_status in {"completed", "failed"} else None,
  170. )
  171. tree = context.get("goal_tree")
  172. goal = tree.find(goal_id) if tree else None
  173. if goal:
  174. goal.status = next_status
  175. if next_status in {"completed", "failed"}:
  176. goal.summary = reason
  177. if tree.current_id == goal_id:
  178. tree.current_id = goal.parent_id
  179. elif resolved_replan:
  180. tree.current_id = goal_id
  181. if resolved_replan:
  182. persisted_tree = await store.get_goal_tree(parent_trace_id)
  183. persisted_goal = persisted_tree.find(goal_id) if persisted_tree else None
  184. if persisted_goal:
  185. persisted_goal.status = "in_progress"
  186. persisted_tree.current_id = goal_id
  187. await store.update_goal_tree(parent_trace_id, persisted_tree)
  188. @tool(
  189. description=(
  190. "Replace the current Recursive task's structured progress with one "
  191. "complete, version-checked snapshot."
  192. ),
  193. hidden_params=["context"],
  194. groups=["core"],
  195. )
  196. async def update_task_progress(
  197. expected_revision: int,
  198. progress: TaskProgress,
  199. context: dict | None = None,
  200. ) -> dict[str, Any]:
  201. """追加当前 Trace 的下一版 TaskProgress。"""
  202. if not context:
  203. return _error("context is required")
  204. if context.get("side_branch"):
  205. return _error("Side branches cannot update TaskProgress")
  206. service = context.get("task_protocol_service")
  207. trace_id = context.get("trace_id")
  208. if service is None or not trace_id:
  209. return _error("task_protocol_service and trace_id are required")
  210. try:
  211. revision = await service.update_progress(
  212. trace_id,
  213. expected_revision=expected_revision,
  214. progress=progress,
  215. effective_at_sequence=int(context.get("sequence", 0) or 0),
  216. )
  217. except (TaskProtocolStateError, ValueError) as exc:
  218. return _error(str(exc))
  219. return {
  220. "status": "completed",
  221. "task_progress": revision.model_dump(mode="json"),
  222. }
  223. @tool(
  224. description="Submit the current Recursive child Agent's structured task report before finishing.",
  225. hidden_params=["context"],
  226. groups=["core"],
  227. )
  228. async def submit_task_report(
  229. task_report: TaskReportSubmission,
  230. context: dict | None = None,
  231. ) -> dict[str, Any]:
  232. if not context:
  233. return _error("context is required")
  234. store = context.get("store")
  235. trace_id = context.get("trace_id")
  236. if not store or not trace_id:
  237. return _error("store and trace_id are required")
  238. service = context.get("task_protocol_service") or TaskProtocolService(store)
  239. async with service.transaction(trace_id):
  240. return await _submit_task_report_locked(task_report, context)
  241. async def _submit_task_report_locked(
  242. task_report: TaskReportSubmission,
  243. context: dict | None = None,
  244. ) -> dict[str, Any]:
  245. """提交当前 Recursive 非根 Agent 的结构化任务报告。
  246. 子 Agent 在结束前调用;Runner 会拒绝未提交报告或尚有待审核孩子的返回。
  247. """
  248. if not context:
  249. return _error("context is required")
  250. store = context.get("store")
  251. trace_id = context.get("trace_id")
  252. if not store or not trace_id:
  253. return _error("store and trace_id are required")
  254. trace = await store.get_trace(trace_id)
  255. if not trace:
  256. return _error(f"Trace not found: {trace_id}")
  257. try:
  258. policy = require_mutable_trace_policy(trace.context)
  259. except ValueError as exc:
  260. return _error(str(exc))
  261. if not policy.requires_task_protocol:
  262. return _error("submit_task_report requires a writable Recursive protocol")
  263. if not trace.parent_trace_id:
  264. return _error("Root Agent does not submit a TaskReport")
  265. root_trace_id = trace.context.get("root_trace_id") or trace.trace_id
  266. root = trace if root_trace_id == trace.trace_id else await store.get_trace(
  267. root_trace_id
  268. )
  269. if not root:
  270. return _error(f"Recursive root Trace not found: {root_trace_id}")
  271. try:
  272. require_matching_root_task_anchor(root.context, trace.context)
  273. except ContextPolicyError as exc:
  274. return _error(str(exc))
  275. state = ensure_task_protocol(trace.context)
  276. if state["pending_reviews"]:
  277. return _error("Review all child TaskReports before submitting this TaskReport")
  278. if state["next_actions"]:
  279. return _error("Execute every approved next action before submitting this TaskReport")
  280. if state["task_report"] is not None:
  281. return _error("A TaskReport has already been submitted")
  282. try:
  283. parsed_submission = (
  284. TaskReportSubmission.model_validate_json(task_report)
  285. if isinstance(task_report, str)
  286. else TaskReportSubmission.model_validate(task_report)
  287. )
  288. except ValidationError as exc:
  289. return _error(f"Invalid TaskReport: {exc}")
  290. if parsed_submission.candidate_refs:
  291. candidate_service = context.get("candidate_service")
  292. if candidate_service is None:
  293. return _error(
  294. "TaskReport CandidateRefs require the bound CandidateService"
  295. )
  296. try:
  297. await candidate_service.validate_report_refs(
  298. trace_id,
  299. parsed_submission.candidate_refs,
  300. active_head_sequence=context.get("head_sequence"),
  301. )
  302. except ValueError as exc:
  303. return _error(f"Invalid TaskReport CandidateRefs: {exc}")
  304. if policy.requires_task_progress and parsed_submission.outcome == "satisfied":
  305. readiness_error = task_progress_readiness_error(state)
  306. if readiness_error:
  307. return _error(readiness_error)
  308. try:
  309. report = TaskReport(
  310. child_trace_id=trace_id,
  311. **parsed_submission.model_dump(),
  312. )
  313. except ValidationError as exc:
  314. return _error(f"Invalid TaskReport: {exc}")
  315. state["task_report"] = report.model_dump()
  316. state["task_report_submitted_at_sequence"] = context.get("sequence", 0)
  317. state["task_report_progress_revision"] = state.get(
  318. "task_progress_head_revision"
  319. )
  320. state["task_report_validation"] = None
  321. state["protocol_correction_attempts"] = 0
  322. await store.update_trace(trace_id, context=trace.context)
  323. goal_id = context.get("goal_id")
  324. if goal_id:
  325. goal_status = (
  326. "failed"
  327. if report.outcome in {"failed", "protocol_error"}
  328. else "completed"
  329. )
  330. await store.update_goal(
  331. trace_id,
  332. goal_id,
  333. cascade_completion=False,
  334. status=goal_status,
  335. summary=report.summary,
  336. )
  337. tree = context.get("goal_tree")
  338. goal = tree.find(goal_id) if tree else None
  339. if goal:
  340. goal.status = goal_status
  341. goal.summary = report.summary
  342. return {"status": "completed", "task_report": report.model_dump()}
  343. @tool(
  344. description="Review one pending direct child's TaskReport and approve the parent's next action.",
  345. hidden_params=["context"],
  346. groups=["core"],
  347. )
  348. async def review_task_result(
  349. child_trace_id: str,
  350. decision: ReviewDecision,
  351. reason: str,
  352. approved_next_task: TaskBrief | None = None,
  353. candidate_actions: list[CandidateReviewAction] | None = None,
  354. context: dict | None = None,
  355. ) -> dict[str, Any]:
  356. if not context:
  357. return _error("context is required")
  358. store = context.get("store")
  359. parent_trace_id = context.get("trace_id")
  360. if not store or not parent_trace_id:
  361. return _error("store and trace_id are required")
  362. service = context.get("task_protocol_service") or TaskProtocolService(store)
  363. async with service.transaction_many(parent_trace_id, child_trace_id):
  364. return await _review_task_result_locked(
  365. child_trace_id=child_trace_id,
  366. decision=decision,
  367. reason=reason,
  368. approved_next_task=approved_next_task,
  369. candidate_actions=candidate_actions,
  370. context=context,
  371. )
  372. async def _review_task_result_locked(
  373. child_trace_id: str,
  374. decision: ReviewDecision,
  375. reason: str,
  376. approved_next_task: TaskBrief | None = None,
  377. candidate_actions: list[CandidateReviewAction] | None = None,
  378. context: dict | None = None,
  379. ) -> dict[str, Any]:
  380. """审核一份直属孩子的待处理 ``TaskReport``。
  381. 父 Agent 在 ``pending_review`` 阶段调用;决策受 Validator 结果约束并持久化下一动作。
  382. """
  383. if not context:
  384. return _error("context is required")
  385. store = context.get("store")
  386. parent_trace_id = context.get("trace_id")
  387. if not store or not parent_trace_id:
  388. return _error("store and trace_id are required")
  389. command_id = str(context.get("tool_call_id") or "") or None
  390. command_hash = _review_command_hash(
  391. child_trace_id=child_trace_id,
  392. decision=decision,
  393. reason=reason,
  394. approved_next_task=approved_next_task,
  395. candidate_actions=candidate_actions,
  396. )
  397. parent = await store.get_trace(parent_trace_id)
  398. child = await store.get_trace(child_trace_id)
  399. if not parent or not child:
  400. return _error("Parent or child Trace not found")
  401. try:
  402. policy = require_mutable_trace_policy(parent.context)
  403. except ValueError as exc:
  404. return _error(str(exc))
  405. if not policy.requires_task_protocol:
  406. return _error("review_task_result requires a writable Recursive protocol")
  407. if child.parent_trace_id != parent_trace_id or child.uid != parent.uid:
  408. return _error("TaskReport must belong to a direct child with the same owner")
  409. state = ensure_task_protocol(parent.context)
  410. committed = [
  411. item for item in state["reviews"]
  412. if command_id is not None
  413. and item.get("review_command_id") == command_id
  414. ]
  415. if len(committed) > 1:
  416. return _error("Persisted TaskReview command identity is duplicated")
  417. if committed:
  418. review_record = committed[0]
  419. if review_record.get("review_command_hash") != command_hash:
  420. return _error("TaskReview command payload changed during recovery")
  421. await _project_committed_review(
  422. store=store,
  423. context=context,
  424. parent_trace_id=parent_trace_id,
  425. child_trace_id=child_trace_id,
  426. state=state,
  427. review_record=review_record,
  428. )
  429. return _completed_review_response(review_record, state)
  430. pending = state["pending_reviews"]
  431. entry = pending.get(child_trace_id)
  432. if not entry:
  433. return _error("This direct child has no pending TaskReport")
  434. origin_goal_id = child.parent_goal_id
  435. if entry.get("goal_id") != origin_goal_id:
  436. return _error(
  437. "Pending TaskReport goal_id does not match the child's originating Goal"
  438. )
  439. revision_task_brief = None
  440. child_state = None
  441. try:
  442. root_trace_id = parent.context.get("root_trace_id") or parent.trace_id
  443. root = parent if root_trace_id == parent.trace_id else await store.get_trace(
  444. root_trace_id
  445. )
  446. if not root:
  447. return _error(f"Recursive root Trace not found: {root_trace_id}")
  448. root_task_anchor = require_matching_root_task_anchor(
  449. root.context,
  450. parent.context,
  451. )
  452. require_matching_root_task_anchor(root.context, child.context)
  453. parent_task_brief = state.get("task_brief")
  454. if isinstance(approved_next_task, str):
  455. approved_next_task = TaskBrief.model_validate_json(approved_next_task)
  456. if approved_next_task is not None:
  457. approved_next_task = normalize_task_brief(
  458. approved_next_task,
  459. parent_task_brief=parent_task_brief,
  460. root_task_anchor=root_task_anchor,
  461. )
  462. validation_result = ValidationResult.model_validate(
  463. entry.get("validation_result")
  464. )
  465. if validation_result.evaluated_trace_id != child_trace_id:
  466. return _error(
  467. "ValidationResult evaluated_trace_id does not match the direct child"
  468. )
  469. child_validation_state = ensure_task_protocol(child.context).get(
  470. "task_report_validation"
  471. )
  472. if not isinstance(child_validation_state, dict):
  473. return _error("Child has no persisted framework validation cache")
  474. if (
  475. child_validation_state.get("plan_hash") != validation_result.plan_hash
  476. or child_validation_state.get("aggregate_result")
  477. != validation_result.model_dump(mode="json")
  478. ):
  479. return _error(
  480. "Pending ValidationResult does not match the child's authoritative cache"
  481. )
  482. allowed_decisions = _ALLOWED_REVIEW_DECISIONS_BY_VALIDATION[
  483. validation_result.outcome
  484. ]
  485. if decision not in allowed_decisions:
  486. return _error(
  487. f"Validation {validation_result.outcome} only allows: "
  488. f"{', '.join(sorted(allowed_decisions))}"
  489. )
  490. review = TaskReview.model_validate({
  491. "parent_trace_id": parent_trace_id,
  492. "child_trace_id": child_trace_id,
  493. "decision": decision,
  494. "reason": reason,
  495. "approved_next_task": approved_next_task,
  496. "retry_from": (
  497. validation_result.retry_from
  498. if decision == "REPLAN_CURRENT"
  499. else None
  500. ),
  501. "candidate_actions": candidate_actions or [],
  502. })
  503. report = TaskReport.model_validate(entry["task_report"])
  504. if report.child_trace_id != child_trace_id:
  505. return _error(
  506. "Pending TaskReport child_trace_id does not match the direct child"
  507. )
  508. if review.decision == "REVISE_CHILD":
  509. child_state = ensure_task_protocol(child.context)
  510. revision_task_brief = normalize_task_brief(
  511. review.approved_next_task or child_state.get("task_brief"),
  512. parent_task_brief=parent_task_brief,
  513. root_task_anchor=root_task_anchor,
  514. )
  515. except (ContextPolicyError, ValidationError) as exc:
  516. return _error(f"Invalid TaskReview: {exc}")
  517. if report.outcome in {"failed", "protocol_error"} and review.decision not in {
  518. "REVISE_CHILD",
  519. "FAIL",
  520. }:
  521. return _error("Failed/protocol_error reports only allow REVISE_CHILD or FAIL")
  522. if review.decision == "REPLAN_CURRENT":
  523. goal_id = origin_goal_id
  524. persisted_tree = await store.get_goal_tree(parent_trace_id)
  525. if not goal_id or not persisted_tree or not persisted_tree.find(goal_id):
  526. return _error(
  527. "REPLAN_CURRENT requires the direct parent's originating Goal"
  528. )
  529. if review.decision in {"ASCEND", "FAIL"} and len(pending) != 1:
  530. return _error("ASCEND/FAIL is only allowed for the final pending report")
  531. if review.decision in {"ASCEND", "FAIL"} and state["next_actions"]:
  532. return _error("ASCEND/FAIL requires every approved next action to finish first")
  533. if review.decision in {"ACCEPT_REFINE", "DESCEND_AGAIN"}:
  534. child_limit = policy.max_children_per_parent
  535. if child_limit is not None:
  536. existing_children = await store.list_traces(
  537. parent_trace_id=parent_trace_id,
  538. created_by_tool="agent",
  539. limit=child_limit + 1,
  540. )
  541. reserved_children = sum(
  542. action.get("decision") in {"ACCEPT_REFINE", "DESCEND_AGAIN"}
  543. for action in state["next_actions"]
  544. )
  545. if len(existing_children) + reserved_children + 1 > child_limit:
  546. return _error(
  547. "Cannot approve another child: local child Agent limit "
  548. f"would be exceeded (existing={len(existing_children)}, "
  549. f"reserved={reserved_children}, max={child_limit})"
  550. )
  551. action_task_brief = revision_task_brief or review.approved_next_task
  552. if action_task_brief is not None:
  553. try:
  554. build_child_context_access(
  555. parent_context=parent.context,
  556. parent_trace_id=parent_trace_id,
  557. root_trace_id=root_trace_id,
  558. uid=parent.uid,
  559. parent_task_state=state,
  560. child_task_brief=action_task_brief,
  561. root_task_anchor=root_task_anchor,
  562. )
  563. except ContextPolicyError as exc:
  564. return _error(f"Approved TaskBrief context is invalid: {exc}")
  565. reviewed_at_sequence = int(context.get("sequence", 0) or 0)
  566. if report.candidate_refs or review.candidate_actions:
  567. candidate_service = context.get("candidate_service")
  568. if candidate_service is None:
  569. return _error("Candidate actions require the bound CandidateService")
  570. if not context.get("tool_call_id"):
  571. return _error(
  572. "Candidate actions require a persisted review tool_call_id"
  573. )
  574. try:
  575. await candidate_service.apply_review_actions(
  576. parent_trace_id,
  577. child_trace_id,
  578. report_refs=report.candidate_refs,
  579. actions=review.candidate_actions,
  580. effective_at_sequence=reviewed_at_sequence,
  581. command_id=str(context["tool_call_id"]),
  582. )
  583. except ValueError as exc:
  584. return _error(f"Invalid candidate action: {exc}")
  585. context_ref = None
  586. context_ref_warning = None
  587. try:
  588. snapshot = create_context_snapshot(
  589. kind="reviewed_task_result",
  590. summary=review.reason,
  591. source_trace_id=child_trace_id,
  592. root_trace_id=root_trace_id,
  593. uid=parent.uid,
  594. content={
  595. "task_report": deepcopy(entry["task_report"]),
  596. "validation_result": deepcopy(entry["validation_result"]),
  597. "task_review": review.model_dump(mode="json"),
  598. },
  599. granted_at_sequence=reviewed_at_sequence,
  600. )
  601. context_ref = add_context_snapshot(
  602. parent.context,
  603. snapshot,
  604. root_task_anchor=root_task_anchor,
  605. task_brief=state.get("task_brief"),
  606. )
  607. except ContextPolicyError as exc:
  608. context_ref_warning = str(exc)
  609. if context_ref is not None and action_task_brief is not None:
  610. try:
  611. action_task_brief = normalize_task_brief(
  612. action_task_brief.model_copy(update={
  613. "context_refs": [
  614. *action_task_brief.context_refs,
  615. context_ref,
  616. ],
  617. }),
  618. parent_task_brief=parent_task_brief,
  619. root_task_anchor=root_task_anchor,
  620. )
  621. build_child_context_access(
  622. parent_context=parent.context,
  623. parent_trace_id=parent_trace_id,
  624. root_trace_id=root_trace_id,
  625. uid=parent.uid,
  626. parent_task_state=state,
  627. child_task_brief=action_task_brief,
  628. root_task_anchor=root_task_anchor,
  629. )
  630. except ContextPolicyError as exc:
  631. action_task_brief = revision_task_brief or review.approved_next_task
  632. context_ref_warning = (
  633. f"{context_ref_warning}; {exc}"
  634. if context_ref_warning
  635. else str(exc)
  636. )
  637. pending.pop(child_trace_id)
  638. review_record = {
  639. **review.model_dump(),
  640. "review_command_id": command_id,
  641. "review_command_hash": command_hash,
  642. "pending_review": deepcopy(entry),
  643. "reviewed_at": datetime.now().isoformat(),
  644. "reviewed_at_sequence": reviewed_at_sequence,
  645. "context_ref": context_ref.model_dump() if context_ref else None,
  646. "context_ref_warning": context_ref_warning,
  647. "next_action_task_brief": (
  648. action_task_brief.model_dump(mode="json")
  649. if action_task_brief is not None
  650. else None
  651. ),
  652. }
  653. state["reviews"].append(review_record)
  654. state["protocol_correction_attempts"] = 0
  655. if review.decision in {"ACCEPT_REFINE", "DESCEND_AGAIN", "REVISE_CHILD"}:
  656. state["next_actions"].append({
  657. "decision": review.decision,
  658. "child_trace_id": child_trace_id,
  659. "goal_id": origin_goal_id,
  660. "task_brief": action_task_brief.model_dump() if action_task_brief else None,
  661. "created_at_sequence": reviewed_at_sequence,
  662. })
  663. resolved_replan = None
  664. if review.decision == "REPLAN_CURRENT":
  665. state["pending_replans"].append({
  666. "child_trace_id": child_trace_id,
  667. "goal_id": origin_goal_id,
  668. "retry_from": review.retry_from,
  669. "requested_at_sequence": context.get("sequence", 0),
  670. "received_at_sequence": entry.get("received_at_sequence"),
  671. })
  672. elif review.decision == "FAIL":
  673. state["pending_replans"].clear()
  674. if not pending and state["pending_replans"]:
  675. resolved_replan = state["pending_replans"][0]
  676. state["pending_replans"].clear()
  677. review_record["resolved_replan"] = deepcopy(resolved_replan)
  678. await store.update_trace(parent_trace_id, context=parent.context)
  679. await _project_committed_review(
  680. store=store,
  681. context=context,
  682. parent_trace_id=parent_trace_id,
  683. child_trace_id=child_trace_id,
  684. state=state,
  685. review_record=review_record,
  686. )
  687. return _completed_review_response(review_record, state)