task_protocol.py 18 KB

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