task_protocol.py 24 KB

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