task_protocol.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464
  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 (
  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. ensure_task_protocol,
  28. )
  29. from cyber_agent.core.validation import ValidationResult
  30. from cyber_agent.tools import tool
  31. _ALLOWED_REVIEW_DECISIONS_BY_VALIDATION: dict[str, set[ReviewDecision]] = {
  32. "passed": {
  33. "ACCEPT_REFINE",
  34. "REJECT_REFINE",
  35. "REVISE_CHILD",
  36. "DESCEND_AGAIN",
  37. "ASCEND",
  38. "FAIL",
  39. },
  40. "failed": {"REVISE_CHILD", "REPLAN_CURRENT", "FAIL"},
  41. "error": {"REVISE_CHILD", "FAIL"},
  42. }
  43. def _error(message: str) -> dict[str, Any]:
  44. return {"status": "failed", "error": message}
  45. @tool(
  46. description="Submit the current Recursive child Agent's structured task report before finishing.",
  47. hidden_params=["context"],
  48. groups=["core"],
  49. )
  50. async def submit_task_report(
  51. task_report: TaskReportSubmission,
  52. context: dict | None = None,
  53. ) -> dict[str, Any]:
  54. """提交当前 Recursive 非根 Agent 的结构化任务报告。
  55. 子 Agent 在结束前调用;Runner 会拒绝未提交报告或尚有待审核孩子的返回。
  56. """
  57. if not context:
  58. return _error("context is required")
  59. store = context.get("store")
  60. trace_id = context.get("trace_id")
  61. if not store or not trace_id:
  62. return _error("store and trace_id are required")
  63. trace = await store.get_trace(trace_id)
  64. if not trace:
  65. return _error(f"Trace not found: {trace_id}")
  66. policy = policy_from_context(trace.context)
  67. if not policy.requires_task_protocol:
  68. return _error("submit_task_report is only available in Recursive revision 2")
  69. if not trace.parent_trace_id:
  70. return _error("Root Agent does not submit a TaskReport")
  71. root_trace_id = trace.context.get("root_trace_id") or trace.trace_id
  72. root = trace if root_trace_id == trace.trace_id else await store.get_trace(
  73. root_trace_id
  74. )
  75. if not root:
  76. return _error(f"Recursive root Trace not found: {root_trace_id}")
  77. try:
  78. require_matching_root_task_anchor(root.context, trace.context)
  79. except ContextPolicyError as exc:
  80. return _error(str(exc))
  81. state = ensure_task_protocol(trace.context)
  82. if state["pending_reviews"]:
  83. return _error("Review all child TaskReports before submitting this TaskReport")
  84. if state["next_actions"]:
  85. return _error("Execute every approved next action before submitting this TaskReport")
  86. if state["task_report"] is not None:
  87. return _error("A TaskReport has already been submitted")
  88. try:
  89. submission = (
  90. TaskReportSubmission.model_validate_json(task_report)
  91. if isinstance(task_report, str)
  92. else TaskReportSubmission.model_validate(task_report)
  93. )
  94. report = TaskReport(
  95. child_trace_id=trace_id,
  96. **submission.model_dump(),
  97. )
  98. except ValidationError as exc:
  99. return _error(f"Invalid TaskReport: {exc}")
  100. state["task_report"] = report.model_dump()
  101. state["task_report_submitted_at_sequence"] = context.get("sequence", 0)
  102. state["task_report_validation"] = None
  103. state["protocol_correction_attempts"] = 0
  104. await store.update_trace(trace_id, context=trace.context)
  105. goal_id = context.get("goal_id")
  106. if goal_id:
  107. goal_status = (
  108. "failed"
  109. if report.outcome in {"failed", "protocol_error"}
  110. else "completed"
  111. )
  112. await store.update_goal(
  113. trace_id,
  114. goal_id,
  115. cascade_completion=False,
  116. status=goal_status,
  117. summary=report.summary,
  118. )
  119. tree = context.get("goal_tree")
  120. goal = tree.find(goal_id) if tree else None
  121. if goal:
  122. goal.status = goal_status
  123. goal.summary = report.summary
  124. return {"status": "completed", "task_report": report.model_dump()}
  125. @tool(
  126. description="Review one pending direct child's TaskReport and approve the parent's next action.",
  127. hidden_params=["context"],
  128. groups=["core"],
  129. )
  130. async def review_task_result(
  131. child_trace_id: str,
  132. decision: ReviewDecision,
  133. reason: str,
  134. approved_next_task: TaskBrief | None = None,
  135. context: dict | None = None,
  136. ) -> dict[str, Any]:
  137. """审核一份直属孩子的待处理 ``TaskReport``。
  138. 父 Agent 在 ``pending_review`` 阶段调用;决策受 Validator 结果约束并持久化下一动作。
  139. """
  140. if not context:
  141. return _error("context is required")
  142. store = context.get("store")
  143. parent_trace_id = context.get("trace_id")
  144. if not store or not parent_trace_id:
  145. return _error("store and trace_id are required")
  146. parent = await store.get_trace(parent_trace_id)
  147. child = await store.get_trace(child_trace_id)
  148. if not parent or not child:
  149. return _error("Parent or child Trace not found")
  150. policy = policy_from_context(parent.context)
  151. if not policy.requires_task_protocol:
  152. return _error("review_task_result is only available in Recursive revision 2")
  153. if child.parent_trace_id != parent_trace_id or child.uid != parent.uid:
  154. return _error("TaskReport must belong to a direct child with the same owner")
  155. state = ensure_task_protocol(parent.context)
  156. pending = state["pending_reviews"]
  157. entry = pending.get(child_trace_id)
  158. if not entry:
  159. return _error("This direct child has no pending TaskReport")
  160. origin_goal_id = child.parent_goal_id
  161. if entry.get("goal_id") != origin_goal_id:
  162. return _error(
  163. "Pending TaskReport goal_id does not match the child's originating Goal"
  164. )
  165. revision_task_brief = None
  166. child_state = None
  167. try:
  168. root_trace_id = parent.context.get("root_trace_id") or parent.trace_id
  169. root = parent if root_trace_id == parent.trace_id else await store.get_trace(
  170. root_trace_id
  171. )
  172. if not root:
  173. return _error(f"Recursive root Trace not found: {root_trace_id}")
  174. root_task_anchor = require_matching_root_task_anchor(
  175. root.context,
  176. parent.context,
  177. )
  178. require_matching_root_task_anchor(root.context, child.context)
  179. parent_task_brief = state.get("task_brief")
  180. if isinstance(approved_next_task, str):
  181. approved_next_task = TaskBrief.model_validate_json(approved_next_task)
  182. if approved_next_task is not None:
  183. approved_next_task = normalize_task_brief(
  184. approved_next_task,
  185. parent_task_brief=parent_task_brief,
  186. root_task_anchor=root_task_anchor,
  187. )
  188. validation_result = ValidationResult.model_validate(
  189. entry.get("validation_result")
  190. )
  191. if validation_result.evaluated_trace_id != child_trace_id:
  192. return _error(
  193. "ValidationResult evaluated_trace_id does not match the direct child"
  194. )
  195. allowed_decisions = _ALLOWED_REVIEW_DECISIONS_BY_VALIDATION[
  196. validation_result.outcome
  197. ]
  198. if decision not in allowed_decisions:
  199. return _error(
  200. f"Validation {validation_result.outcome} only allows: "
  201. f"{', '.join(sorted(allowed_decisions))}"
  202. )
  203. review = TaskReview.model_validate({
  204. "parent_trace_id": parent_trace_id,
  205. "child_trace_id": child_trace_id,
  206. "decision": decision,
  207. "reason": reason,
  208. "approved_next_task": approved_next_task,
  209. "retry_from": (
  210. validation_result.retry_from
  211. if decision == "REPLAN_CURRENT"
  212. else None
  213. ),
  214. })
  215. report = TaskReport.model_validate(entry["task_report"])
  216. if report.child_trace_id != child_trace_id:
  217. return _error(
  218. "Pending TaskReport child_trace_id does not match the direct child"
  219. )
  220. if review.decision == "REVISE_CHILD":
  221. child_state = ensure_task_protocol(child.context)
  222. revision_task_brief = normalize_task_brief(
  223. review.approved_next_task or child_state.get("task_brief"),
  224. parent_task_brief=parent_task_brief,
  225. root_task_anchor=root_task_anchor,
  226. )
  227. except (ContextPolicyError, ValidationError) as exc:
  228. return _error(f"Invalid TaskReview: {exc}")
  229. if report.outcome in {"failed", "protocol_error"} and review.decision not in {
  230. "REVISE_CHILD",
  231. "FAIL",
  232. }:
  233. return _error("Failed/protocol_error reports only allow REVISE_CHILD or FAIL")
  234. if review.decision == "REPLAN_CURRENT":
  235. goal_id = origin_goal_id
  236. persisted_tree = await store.get_goal_tree(parent_trace_id)
  237. if not goal_id or not persisted_tree or not persisted_tree.find(goal_id):
  238. return _error(
  239. "REPLAN_CURRENT requires the direct parent's originating Goal"
  240. )
  241. if review.decision in {"ASCEND", "FAIL"} and len(pending) != 1:
  242. return _error("ASCEND/FAIL is only allowed for the final pending report")
  243. if review.decision in {"ASCEND", "FAIL"} and state["next_actions"]:
  244. return _error("ASCEND/FAIL requires every approved next action to finish first")
  245. if review.decision in {"ACCEPT_REFINE", "DESCEND_AGAIN"}:
  246. child_limit = policy.max_children_per_parent
  247. if child_limit is not None:
  248. existing_children = await store.list_traces(
  249. parent_trace_id=parent_trace_id,
  250. created_by_tool="agent",
  251. limit=child_limit + 1,
  252. )
  253. reserved_children = sum(
  254. action.get("decision") in {"ACCEPT_REFINE", "DESCEND_AGAIN"}
  255. for action in state["next_actions"]
  256. )
  257. if len(existing_children) + reserved_children + 1 > child_limit:
  258. return _error(
  259. "Cannot approve another child: local child Agent limit "
  260. f"would be exceeded (existing={len(existing_children)}, "
  261. f"reserved={reserved_children}, max={child_limit})"
  262. )
  263. action_task_brief = revision_task_brief or review.approved_next_task
  264. if action_task_brief is not None:
  265. try:
  266. build_child_context_access(
  267. parent_context=parent.context,
  268. parent_trace_id=parent_trace_id,
  269. root_trace_id=root_trace_id,
  270. uid=parent.uid,
  271. parent_task_state=state,
  272. child_task_brief=action_task_brief,
  273. root_task_anchor=root_task_anchor,
  274. )
  275. except ContextPolicyError as exc:
  276. return _error(f"Approved TaskBrief context is invalid: {exc}")
  277. reviewed_at_sequence = int(context.get("sequence", 0) or 0)
  278. context_ref = None
  279. context_ref_warning = None
  280. try:
  281. snapshot = create_context_snapshot(
  282. kind="reviewed_task_result",
  283. summary=review.reason,
  284. source_trace_id=child_trace_id,
  285. root_trace_id=root_trace_id,
  286. uid=parent.uid,
  287. content={
  288. "task_report": deepcopy(entry["task_report"]),
  289. "validation_result": deepcopy(entry["validation_result"]),
  290. "task_review": review.model_dump(mode="json"),
  291. },
  292. granted_at_sequence=reviewed_at_sequence,
  293. )
  294. context_ref = add_context_snapshot(
  295. parent.context,
  296. snapshot,
  297. root_task_anchor=root_task_anchor,
  298. task_brief=state.get("task_brief"),
  299. )
  300. except ContextPolicyError as exc:
  301. context_ref_warning = str(exc)
  302. if context_ref is not None and action_task_brief is not None:
  303. try:
  304. action_task_brief = normalize_task_brief(
  305. action_task_brief.model_copy(update={
  306. "context_refs": [
  307. *action_task_brief.context_refs,
  308. context_ref,
  309. ],
  310. }),
  311. parent_task_brief=parent_task_brief,
  312. root_task_anchor=root_task_anchor,
  313. )
  314. build_child_context_access(
  315. parent_context=parent.context,
  316. parent_trace_id=parent_trace_id,
  317. root_trace_id=root_trace_id,
  318. uid=parent.uid,
  319. parent_task_state=state,
  320. child_task_brief=action_task_brief,
  321. root_task_anchor=root_task_anchor,
  322. )
  323. except ContextPolicyError as exc:
  324. action_task_brief = revision_task_brief or review.approved_next_task
  325. context_ref_warning = (
  326. f"{context_ref_warning}; {exc}"
  327. if context_ref_warning
  328. else str(exc)
  329. )
  330. pending.pop(child_trace_id)
  331. review_record = {
  332. **review.model_dump(),
  333. "pending_review": deepcopy(entry),
  334. "reviewed_at": datetime.now().isoformat(),
  335. "reviewed_at_sequence": reviewed_at_sequence,
  336. "context_ref": context_ref.model_dump() if context_ref else None,
  337. "context_ref_warning": context_ref_warning,
  338. }
  339. state["reviews"].append(review_record)
  340. state["protocol_correction_attempts"] = 0
  341. if review.decision == "REVISE_CHILD":
  342. assert child_state is not None and revision_task_brief is not None
  343. old_report = child_state.get("task_report")
  344. if old_report:
  345. child_state["report_history"].append(old_report)
  346. child_state["task_report"] = None
  347. child_state["task_report_submitted_at_sequence"] = None
  348. child_state["task_report_validation"] = None
  349. child_state["protocol_correction_attempts"] = 0
  350. replace_context_access(
  351. child.context,
  352. [],
  353. root_task_anchor=require_root_task_anchor(child.context),
  354. task_brief=child_state.get("task_brief"),
  355. )
  356. await store.update_trace(child_trace_id, context=child.context)
  357. if review.decision in {"ACCEPT_REFINE", "DESCEND_AGAIN", "REVISE_CHILD"}:
  358. state["next_actions"].append({
  359. "decision": review.decision,
  360. "child_trace_id": child_trace_id,
  361. "goal_id": origin_goal_id,
  362. "task_brief": action_task_brief.model_dump() if action_task_brief else None,
  363. "created_at_sequence": reviewed_at_sequence,
  364. })
  365. resolved_replan = None
  366. if review.decision == "REPLAN_CURRENT":
  367. state["pending_replans"].append({
  368. "child_trace_id": child_trace_id,
  369. "goal_id": origin_goal_id,
  370. "retry_from": review.retry_from,
  371. "requested_at_sequence": context.get("sequence", 0),
  372. "received_at_sequence": entry.get("received_at_sequence"),
  373. })
  374. elif review.decision == "FAIL":
  375. state["pending_replans"].clear()
  376. if not pending and state["pending_replans"]:
  377. resolved_replan = state["pending_replans"][0]
  378. state["pending_replans"].clear()
  379. await store.update_trace(parent_trace_id, context=parent.context)
  380. goal_id = origin_goal_id
  381. if goal_id:
  382. if pending:
  383. next_status = "pending_review"
  384. elif resolved_replan:
  385. next_status = "in_progress"
  386. goal_id = resolved_replan["goal_id"]
  387. elif review.decision == "ASCEND":
  388. next_status = "completed"
  389. elif review.decision == "FAIL":
  390. next_status = "failed"
  391. else:
  392. next_status = "in_progress"
  393. await store.update_goal(
  394. parent_trace_id,
  395. goal_id,
  396. cascade_completion=False,
  397. status=next_status,
  398. summary=reason if next_status in {"completed", "failed"} else None,
  399. )
  400. tree = context.get("goal_tree")
  401. goal = tree.find(goal_id) if tree else None
  402. if goal:
  403. goal.status = next_status
  404. if next_status in {"completed", "failed"}:
  405. goal.summary = reason
  406. if tree.current_id == goal_id:
  407. tree.current_id = goal.parent_id
  408. elif resolved_replan:
  409. tree.current_id = goal_id
  410. if resolved_replan:
  411. persisted_tree = await store.get_goal_tree(parent_trace_id)
  412. persisted_goal = persisted_tree.find(goal_id) if persisted_tree else None
  413. if persisted_goal:
  414. persisted_goal.status = "in_progress"
  415. persisted_tree.current_id = goal_id
  416. await store.update_goal_tree(parent_trace_id, persisted_tree)
  417. return {
  418. "status": "completed",
  419. "task_review": review.model_dump(),
  420. "context_ref": context_ref.model_dump() if context_ref else None,
  421. "context_ref_warning": context_ref_warning,
  422. "next_action_task_brief": (
  423. action_task_brief.model_dump(mode="json")
  424. if action_task_brief is not None
  425. else None
  426. ),
  427. "pending_review_count": len(pending),
  428. "next_action_count": len(state["next_actions"]),
  429. }