task_protocol.py 20 KB

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