task_protocol.py 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  1. """Tools for Recursive revision 2 task reporting and parent review."""
  2. from __future__ import annotations
  3. from copy import deepcopy
  4. from datetime import datetime
  5. from typing import Any
  6. from pydantic import ValidationError
  7. from cyber_agent.core.agent_mode import policy_from_context
  8. from cyber_agent.core.task_protocol import (
  9. TaskReport,
  10. TaskReportSubmission,
  11. TaskReview,
  12. ReviewDecision,
  13. TaskBrief,
  14. ensure_task_protocol,
  15. )
  16. from cyber_agent.tools import tool
  17. def _error(message: str) -> dict[str, Any]:
  18. return {"status": "failed", "error": message}
  19. @tool(
  20. description="Submit the current Recursive child Agent's structured task report before finishing.",
  21. hidden_params=["context"],
  22. groups=["core"],
  23. )
  24. async def submit_task_report(
  25. task_report: TaskReportSubmission,
  26. context: dict | None = None,
  27. ) -> dict[str, Any]:
  28. if not context:
  29. return _error("context is required")
  30. store = context.get("store")
  31. trace_id = context.get("trace_id")
  32. if not store or not trace_id:
  33. return _error("store and trace_id are required")
  34. trace = await store.get_trace(trace_id)
  35. if not trace:
  36. return _error(f"Trace not found: {trace_id}")
  37. policy = policy_from_context(trace.context)
  38. if not policy.requires_task_protocol:
  39. return _error("submit_task_report is only available in Recursive revision 2")
  40. if not trace.parent_trace_id:
  41. return _error("Root Agent does not submit a TaskReport")
  42. state = ensure_task_protocol(trace.context)
  43. if state["pending_reviews"]:
  44. return _error("Review all child TaskReports before submitting this TaskReport")
  45. if state["next_actions"]:
  46. return _error("Execute every approved next action before submitting this TaskReport")
  47. if state["task_report"] is not None:
  48. return _error("A TaskReport has already been submitted")
  49. try:
  50. submission = (
  51. TaskReportSubmission.model_validate_json(task_report)
  52. if isinstance(task_report, str)
  53. else TaskReportSubmission.model_validate(task_report)
  54. )
  55. report = TaskReport(
  56. child_trace_id=trace_id,
  57. **submission.model_dump(),
  58. )
  59. except ValidationError as exc:
  60. return _error(f"Invalid TaskReport: {exc}")
  61. state["task_report"] = report.model_dump()
  62. state["task_report_submitted_at_sequence"] = context.get("sequence", 0)
  63. state["protocol_correction_attempts"] = 0
  64. await store.update_trace(trace_id, context=trace.context)
  65. goal_id = context.get("goal_id")
  66. if goal_id:
  67. goal_status = (
  68. "failed"
  69. if report.outcome in {"failed", "protocol_error"}
  70. else "completed"
  71. )
  72. await store.update_goal(
  73. trace_id,
  74. goal_id,
  75. cascade_completion=False,
  76. status=goal_status,
  77. summary=report.summary,
  78. )
  79. tree = context.get("goal_tree")
  80. goal = tree.find(goal_id) if tree else None
  81. if goal:
  82. goal.status = goal_status
  83. goal.summary = report.summary
  84. return {"status": "completed", "task_report": report.model_dump()}
  85. @tool(
  86. description="Review one pending direct child's TaskReport and approve the parent's next action.",
  87. hidden_params=["context"],
  88. groups=["core"],
  89. )
  90. async def review_task_result(
  91. child_trace_id: str,
  92. decision: ReviewDecision,
  93. reason: str,
  94. approved_next_task: TaskBrief | None = None,
  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. parent_trace_id = context.get("trace_id")
  101. if not store or not parent_trace_id:
  102. return _error("store and trace_id are required")
  103. parent = await store.get_trace(parent_trace_id)
  104. child = await store.get_trace(child_trace_id)
  105. if not parent or not child:
  106. return _error("Parent or child Trace not found")
  107. policy = policy_from_context(parent.context)
  108. if not policy.requires_task_protocol:
  109. return _error("review_task_result is only available in Recursive revision 2")
  110. if child.parent_trace_id != parent_trace_id or child.uid != parent.uid:
  111. return _error("TaskReport must belong to a direct child with the same owner")
  112. state = ensure_task_protocol(parent.context)
  113. pending = state["pending_reviews"]
  114. entry = pending.get(child_trace_id)
  115. if not entry:
  116. return _error("This direct child has no pending TaskReport")
  117. revision_task_brief = None
  118. child_state = None
  119. try:
  120. if isinstance(approved_next_task, str):
  121. approved_next_task = TaskBrief.model_validate_json(approved_next_task)
  122. review = TaskReview.model_validate({
  123. "parent_trace_id": parent_trace_id,
  124. "child_trace_id": child_trace_id,
  125. "decision": decision,
  126. "reason": reason,
  127. "approved_next_task": approved_next_task,
  128. })
  129. report = TaskReport.model_validate(entry["task_report"])
  130. if report.child_trace_id != child_trace_id:
  131. return _error(
  132. "Pending TaskReport child_trace_id does not match the direct child"
  133. )
  134. if review.decision == "REVISE_CHILD":
  135. child_state = ensure_task_protocol(child.context)
  136. revision_task_brief = TaskBrief.model_validate(
  137. review.approved_next_task or child_state.get("task_brief")
  138. )
  139. except ValidationError as exc:
  140. return _error(f"Invalid TaskReview: {exc}")
  141. if report.outcome in {"failed", "protocol_error"} and review.decision not in {
  142. "REVISE_CHILD",
  143. "FAIL",
  144. }:
  145. return _error("Failed/protocol_error reports only allow REVISE_CHILD or FAIL")
  146. if review.decision in {"ASCEND", "FAIL"} and len(pending) != 1:
  147. return _error("ASCEND/FAIL is only allowed for the final pending report")
  148. if review.decision in {"ASCEND", "FAIL"} and state["next_actions"]:
  149. return _error("ASCEND/FAIL requires every approved next action to finish first")
  150. if review.decision in {"ACCEPT_REFINE", "DESCEND_AGAIN"}:
  151. child_limit = policy.max_children_per_parent
  152. if child_limit is not None:
  153. existing_children = await store.list_traces(
  154. parent_trace_id=parent_trace_id,
  155. created_by_tool="agent",
  156. limit=child_limit + 1,
  157. )
  158. reserved_children = sum(
  159. action.get("decision") in {"ACCEPT_REFINE", "DESCEND_AGAIN"}
  160. for action in state["next_actions"]
  161. )
  162. if len(existing_children) + reserved_children + 1 > child_limit:
  163. return _error(
  164. "Cannot approve another child: local child Agent limit "
  165. f"would be exceeded (existing={len(existing_children)}, "
  166. f"reserved={reserved_children}, max={child_limit})"
  167. )
  168. pending.pop(child_trace_id)
  169. review_record = {
  170. **review.model_dump(),
  171. "pending_review": deepcopy(entry),
  172. "reviewed_at": datetime.now().isoformat(),
  173. "reviewed_at_sequence": context.get("sequence", 0),
  174. }
  175. state["reviews"].append(review_record)
  176. state["protocol_correction_attempts"] = 0
  177. if review.decision == "REVISE_CHILD":
  178. assert child_state is not None and revision_task_brief is not None
  179. old_report = child_state.get("task_report")
  180. if old_report:
  181. child_state["report_history"].append(old_report)
  182. child_state["task_report"] = None
  183. child_state["task_report_submitted_at_sequence"] = None
  184. child_state["protocol_correction_attempts"] = 0
  185. child_state["task_brief"] = revision_task_brief.model_dump()
  186. await store.update_trace(child_trace_id, context=child.context)
  187. if review.decision in {"ACCEPT_REFINE", "DESCEND_AGAIN", "REVISE_CHILD"}:
  188. action_task_brief = revision_task_brief or review.approved_next_task
  189. state["next_actions"].append({
  190. "decision": review.decision,
  191. "child_trace_id": child_trace_id,
  192. "goal_id": entry.get("goal_id"),
  193. "task_brief": action_task_brief.model_dump() if action_task_brief else None,
  194. "created_at_sequence": context.get("sequence", 0),
  195. })
  196. await store.update_trace(parent_trace_id, context=parent.context)
  197. goal_id = entry.get("goal_id")
  198. if goal_id:
  199. if pending:
  200. next_status = "pending_review"
  201. elif review.decision == "ASCEND":
  202. next_status = "completed"
  203. elif review.decision == "FAIL":
  204. next_status = "failed"
  205. else:
  206. next_status = "in_progress"
  207. await store.update_goal(
  208. parent_trace_id,
  209. goal_id,
  210. cascade_completion=False,
  211. status=next_status,
  212. summary=reason if next_status in {"completed", "failed"} else None,
  213. )
  214. tree = context.get("goal_tree")
  215. goal = tree.find(goal_id) if tree else None
  216. if goal:
  217. goal.status = next_status
  218. if next_status in {"completed", "failed"}:
  219. goal.summary = reason
  220. if tree.current_id == goal_id:
  221. tree.current_id = goal.parent_id
  222. return {
  223. "status": "completed",
  224. "task_review": review.model_dump(),
  225. "pending_review_count": len(pending),
  226. "next_action_count": len(state["next_actions"]),
  227. }