task_protocol.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  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 policy_from_context
  11. from cyber_agent.core.context_policy import ContextPolicyError, normalize_task_brief
  12. from cyber_agent.core.task_protocol import (
  13. TaskReport,
  14. TaskReportSubmission,
  15. TaskReview,
  16. ReviewDecision,
  17. TaskBrief,
  18. ensure_task_protocol,
  19. )
  20. from cyber_agent.core.validation import ValidationResult
  21. from cyber_agent.tools import tool
  22. _ALLOWED_REVIEW_DECISIONS_BY_VALIDATION: dict[str, set[ReviewDecision]] = {
  23. "passed": {
  24. "ACCEPT_REFINE",
  25. "REJECT_REFINE",
  26. "REVISE_CHILD",
  27. "DESCEND_AGAIN",
  28. "ASCEND",
  29. "FAIL",
  30. },
  31. "failed": {"REVISE_CHILD", "REPLAN_CURRENT", "FAIL"},
  32. "error": {"REVISE_CHILD", "FAIL"},
  33. }
  34. def _error(message: str) -> dict[str, Any]:
  35. return {"status": "failed", "error": message}
  36. @tool(
  37. description="Submit the current Recursive child Agent's structured task report before finishing.",
  38. hidden_params=["context"],
  39. groups=["core"],
  40. )
  41. async def submit_task_report(
  42. task_report: TaskReportSubmission,
  43. context: dict | None = None,
  44. ) -> dict[str, Any]:
  45. """提交当前 Recursive 非根 Agent 的结构化任务报告。
  46. 子 Agent 在结束前调用;Runner 会拒绝未提交报告或尚有待审核孩子的返回。
  47. """
  48. if not context:
  49. return _error("context is required")
  50. store = context.get("store")
  51. trace_id = context.get("trace_id")
  52. if not store or not trace_id:
  53. return _error("store and trace_id are required")
  54. trace = await store.get_trace(trace_id)
  55. if not trace:
  56. return _error(f"Trace not found: {trace_id}")
  57. policy = policy_from_context(trace.context)
  58. if not policy.requires_task_protocol:
  59. return _error("submit_task_report is only available in Recursive revision 2")
  60. if not trace.parent_trace_id:
  61. return _error("Root Agent does not submit a TaskReport")
  62. state = ensure_task_protocol(trace.context)
  63. if state["pending_reviews"]:
  64. return _error("Review all child TaskReports before submitting this TaskReport")
  65. if state["next_actions"]:
  66. return _error("Execute every approved next action before submitting this TaskReport")
  67. if state["task_report"] is not None:
  68. return _error("A TaskReport has already been submitted")
  69. try:
  70. submission = (
  71. TaskReportSubmission.model_validate_json(task_report)
  72. if isinstance(task_report, str)
  73. else TaskReportSubmission.model_validate(task_report)
  74. )
  75. report = TaskReport(
  76. child_trace_id=trace_id,
  77. **submission.model_dump(),
  78. )
  79. except ValidationError as exc:
  80. return _error(f"Invalid TaskReport: {exc}")
  81. state["task_report"] = report.model_dump()
  82. state["task_report_submitted_at_sequence"] = context.get("sequence", 0)
  83. state["task_report_validation"] = None
  84. state["protocol_correction_attempts"] = 0
  85. await store.update_trace(trace_id, context=trace.context)
  86. goal_id = context.get("goal_id")
  87. if goal_id:
  88. goal_status = (
  89. "failed"
  90. if report.outcome in {"failed", "protocol_error"}
  91. else "completed"
  92. )
  93. await store.update_goal(
  94. trace_id,
  95. goal_id,
  96. cascade_completion=False,
  97. status=goal_status,
  98. summary=report.summary,
  99. )
  100. tree = context.get("goal_tree")
  101. goal = tree.find(goal_id) if tree else None
  102. if goal:
  103. goal.status = goal_status
  104. goal.summary = report.summary
  105. return {"status": "completed", "task_report": report.model_dump()}
  106. @tool(
  107. description="Review one pending direct child's TaskReport and approve the parent's next action.",
  108. hidden_params=["context"],
  109. groups=["core"],
  110. )
  111. async def review_task_result(
  112. child_trace_id: str,
  113. decision: ReviewDecision,
  114. reason: str,
  115. approved_next_task: TaskBrief | None = None,
  116. context: dict | None = None,
  117. ) -> dict[str, Any]:
  118. """审核一份直属孩子的待处理 ``TaskReport``。
  119. 父 Agent 在 ``pending_review`` 阶段调用;决策受 Validator 结果约束并持久化下一动作。
  120. """
  121. if not context:
  122. return _error("context is required")
  123. store = context.get("store")
  124. parent_trace_id = context.get("trace_id")
  125. if not store or not parent_trace_id:
  126. return _error("store and trace_id are required")
  127. parent = await store.get_trace(parent_trace_id)
  128. child = await store.get_trace(child_trace_id)
  129. if not parent or not child:
  130. return _error("Parent or child Trace not found")
  131. policy = policy_from_context(parent.context)
  132. if not policy.requires_task_protocol:
  133. return _error("review_task_result is only available in Recursive revision 2")
  134. if child.parent_trace_id != parent_trace_id or child.uid != parent.uid:
  135. return _error("TaskReport must belong to a direct child with the same owner")
  136. state = ensure_task_protocol(parent.context)
  137. pending = state["pending_reviews"]
  138. entry = pending.get(child_trace_id)
  139. if not entry:
  140. return _error("This direct child has no pending TaskReport")
  141. origin_goal_id = child.parent_goal_id
  142. if entry.get("goal_id") != origin_goal_id:
  143. return _error(
  144. "Pending TaskReport goal_id does not match the child's originating Goal"
  145. )
  146. revision_task_brief = None
  147. child_state = None
  148. try:
  149. parent_task_brief = state.get("task_brief")
  150. if isinstance(approved_next_task, str):
  151. approved_next_task = TaskBrief.model_validate_json(approved_next_task)
  152. if approved_next_task is not None:
  153. approved_next_task = normalize_task_brief(
  154. approved_next_task,
  155. parent_task_brief=parent_task_brief,
  156. )
  157. validation_result = ValidationResult.model_validate(
  158. entry.get("validation_result")
  159. )
  160. if validation_result.evaluated_trace_id != child_trace_id:
  161. return _error(
  162. "ValidationResult evaluated_trace_id does not match the direct child"
  163. )
  164. allowed_decisions = _ALLOWED_REVIEW_DECISIONS_BY_VALIDATION[
  165. validation_result.outcome
  166. ]
  167. if decision not in allowed_decisions:
  168. return _error(
  169. f"Validation {validation_result.outcome} only allows: "
  170. f"{', '.join(sorted(allowed_decisions))}"
  171. )
  172. review = TaskReview.model_validate({
  173. "parent_trace_id": parent_trace_id,
  174. "child_trace_id": child_trace_id,
  175. "decision": decision,
  176. "reason": reason,
  177. "approved_next_task": approved_next_task,
  178. "retry_from": (
  179. validation_result.retry_from
  180. if decision == "REPLAN_CURRENT"
  181. else None
  182. ),
  183. })
  184. report = TaskReport.model_validate(entry["task_report"])
  185. if report.child_trace_id != child_trace_id:
  186. return _error(
  187. "Pending TaskReport child_trace_id does not match the direct child"
  188. )
  189. if review.decision == "REVISE_CHILD":
  190. child_state = ensure_task_protocol(child.context)
  191. revision_task_brief = normalize_task_brief(
  192. review.approved_next_task or child_state.get("task_brief"),
  193. parent_task_brief=parent_task_brief,
  194. )
  195. except (ContextPolicyError, ValidationError) as exc:
  196. return _error(f"Invalid TaskReview: {exc}")
  197. if report.outcome in {"failed", "protocol_error"} and review.decision not in {
  198. "REVISE_CHILD",
  199. "FAIL",
  200. }:
  201. return _error("Failed/protocol_error reports only allow REVISE_CHILD or FAIL")
  202. if review.decision == "REPLAN_CURRENT":
  203. goal_id = origin_goal_id
  204. persisted_tree = await store.get_goal_tree(parent_trace_id)
  205. if not goal_id or not persisted_tree or not persisted_tree.find(goal_id):
  206. return _error(
  207. "REPLAN_CURRENT requires the direct parent's originating Goal"
  208. )
  209. if review.decision in {"ASCEND", "FAIL"} and len(pending) != 1:
  210. return _error("ASCEND/FAIL is only allowed for the final pending report")
  211. if review.decision in {"ASCEND", "FAIL"} and state["next_actions"]:
  212. return _error("ASCEND/FAIL requires every approved next action to finish first")
  213. if review.decision in {"ACCEPT_REFINE", "DESCEND_AGAIN"}:
  214. child_limit = policy.max_children_per_parent
  215. if child_limit is not None:
  216. existing_children = await store.list_traces(
  217. parent_trace_id=parent_trace_id,
  218. created_by_tool="agent",
  219. limit=child_limit + 1,
  220. )
  221. reserved_children = sum(
  222. action.get("decision") in {"ACCEPT_REFINE", "DESCEND_AGAIN"}
  223. for action in state["next_actions"]
  224. )
  225. if len(existing_children) + reserved_children + 1 > child_limit:
  226. return _error(
  227. "Cannot approve another child: local child Agent limit "
  228. f"would be exceeded (existing={len(existing_children)}, "
  229. f"reserved={reserved_children}, max={child_limit})"
  230. )
  231. pending.pop(child_trace_id)
  232. review_record = {
  233. **review.model_dump(),
  234. "pending_review": deepcopy(entry),
  235. "reviewed_at": datetime.now().isoformat(),
  236. "reviewed_at_sequence": context.get("sequence", 0),
  237. }
  238. state["reviews"].append(review_record)
  239. state["protocol_correction_attempts"] = 0
  240. if review.decision == "REVISE_CHILD":
  241. assert child_state is not None and revision_task_brief is not None
  242. old_report = child_state.get("task_report")
  243. if old_report:
  244. child_state["report_history"].append(old_report)
  245. child_state["task_report"] = None
  246. child_state["task_report_submitted_at_sequence"] = None
  247. child_state["task_report_validation"] = None
  248. child_state["protocol_correction_attempts"] = 0
  249. child_state["task_brief"] = revision_task_brief.model_dump()
  250. await store.update_trace(child_trace_id, context=child.context)
  251. if review.decision in {"ACCEPT_REFINE", "DESCEND_AGAIN", "REVISE_CHILD"}:
  252. action_task_brief = revision_task_brief or review.approved_next_task
  253. state["next_actions"].append({
  254. "decision": review.decision,
  255. "child_trace_id": child_trace_id,
  256. "goal_id": origin_goal_id,
  257. "task_brief": action_task_brief.model_dump() if action_task_brief else None,
  258. "created_at_sequence": context.get("sequence", 0),
  259. })
  260. resolved_replan = None
  261. if review.decision == "REPLAN_CURRENT":
  262. state["pending_replans"].append({
  263. "child_trace_id": child_trace_id,
  264. "goal_id": origin_goal_id,
  265. "retry_from": review.retry_from,
  266. "requested_at_sequence": context.get("sequence", 0),
  267. "received_at_sequence": entry.get("received_at_sequence"),
  268. })
  269. elif review.decision == "FAIL":
  270. state["pending_replans"].clear()
  271. if not pending and state["pending_replans"]:
  272. resolved_replan = state["pending_replans"][0]
  273. state["pending_replans"].clear()
  274. await store.update_trace(parent_trace_id, context=parent.context)
  275. goal_id = origin_goal_id
  276. if goal_id:
  277. if pending:
  278. next_status = "pending_review"
  279. elif resolved_replan:
  280. next_status = "in_progress"
  281. goal_id = resolved_replan["goal_id"]
  282. elif review.decision == "ASCEND":
  283. next_status = "completed"
  284. elif review.decision == "FAIL":
  285. next_status = "failed"
  286. else:
  287. next_status = "in_progress"
  288. await store.update_goal(
  289. parent_trace_id,
  290. goal_id,
  291. cascade_completion=False,
  292. status=next_status,
  293. summary=reason if next_status in {"completed", "failed"} else None,
  294. )
  295. tree = context.get("goal_tree")
  296. goal = tree.find(goal_id) if tree else None
  297. if goal:
  298. goal.status = next_status
  299. if next_status in {"completed", "failed"}:
  300. goal.summary = reason
  301. if tree.current_id == goal_id:
  302. tree.current_id = goal.parent_id
  303. elif resolved_replan:
  304. tree.current_id = goal_id
  305. if resolved_replan:
  306. persisted_tree = await store.get_goal_tree(parent_trace_id)
  307. persisted_goal = persisted_tree.find(goal_id) if persisted_tree else None
  308. if persisted_goal:
  309. persisted_goal.status = "in_progress"
  310. persisted_tree.current_id = goal_id
  311. await store.update_goal_tree(parent_trace_id, persisted_tree)
  312. return {
  313. "status": "completed",
  314. "task_review": review.model_dump(),
  315. "pending_review_count": len(pending),
  316. "next_action_count": len(state["next_actions"]),
  317. }