task_protocol.py 13 KB

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