task_protocol.py 22 KB

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