task_protocol.py 23 KB

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