task_protocol.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426
  1. """Recursive revision 2 的父子任务协议与持久化状态。
  2. Sub-Agent 使用 TaskBrief 下发任务,子 Agent 通过 TaskReport 上报,父 Agent 使用
  3. TaskReview 审核。Runner 和两个协议工具共同把待审、后续动作和回退状态保存在 Trace context。
  4. """
  5. from __future__ import annotations
  6. from copy import deepcopy
  7. from datetime import datetime
  8. import json
  9. from typing import Any, Literal
  10. from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
  11. from cyber_agent.core.validation import RetryFrom
  12. TaskOutcome = Literal["satisfied", "partial", "failed", "protocol_error"]
  13. NextStepDirection = Literal["DESCEND", "REFINE", "ASCEND", "NONE"]
  14. ReviewDecision = Literal[
  15. "ACCEPT_REFINE",
  16. "REJECT_REFINE",
  17. "REVISE_CHILD",
  18. "DESCEND_AGAIN",
  19. "REPLAN_CURRENT",
  20. "ASCEND",
  21. "FAIL",
  22. ]
  23. class StrictProtocolModel(BaseModel):
  24. """协议模型的统一严格基类,拒绝未定义字段并清理字符串空白。"""
  25. model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
  26. class RootTaskAnchor(StrictProtocolModel):
  27. """Recursive 根任务不可变的目标锚点。
  28. 调用方创建根 Trace 时提供;Runner 固化后由每层孩子和 Validator读取,
  29. 中间 Agent不能重新概括或覆盖它。
  30. """
  31. objective: str = Field(min_length=1)
  32. completion_criteria: list[str] = Field(min_length=1)
  33. constraints: list[str] = Field(default_factory=list)
  34. @field_validator("completion_criteria", "constraints")
  35. @classmethod
  36. def validate_non_empty_items(cls, items: list[str]) -> list[str]:
  37. if any(not item.strip() for item in items):
  38. raise ValueError("items must be non-empty strings")
  39. return items
  40. class ContextRef(StrictProtocolModel):
  41. """模型可在 TaskBrief 中转交的最小上下文引用句柄。
  42. 来源、类型、摘要和内容由框架保管;模型只能选择已获授权的 ID与版本。
  43. """
  44. ref_id: str = Field(min_length=1)
  45. version: str = Field(pattern=r"^[0-9a-f]{64}$")
  46. class TaskBrief(StrictProtocolModel):
  47. """父 Agent 下发给直接孩子的结构化任务说明。
  48. `agent()` 在创建/续跑子 Trace 前规范化,Runner 再将格式化内容作为子 Agent 的任务上下文。
  49. """
  50. objective: str = Field(min_length=1)
  51. reason: str = Field(min_length=1)
  52. completion_criteria: list[str] = Field(min_length=1)
  53. expected_outputs: list[str] = Field(min_length=1)
  54. parent_findings: list[str] = Field(default_factory=list)
  55. context: dict[str, Any] = Field(default_factory=dict)
  56. constraints: list[str] = Field(default_factory=list)
  57. context_refs: list[ContextRef] = Field(default_factory=list)
  58. @field_validator(
  59. "completion_criteria",
  60. "expected_outputs",
  61. "parent_findings",
  62. "constraints",
  63. )
  64. @classmethod
  65. def validate_non_empty_items(cls, items: list[str]) -> list[str]:
  66. if any(not item.strip() for item in items):
  67. raise ValueError("items must be non-empty strings")
  68. return items
  69. class Validation(StrictProtocolModel):
  70. """子 Agent 在 TaskReport 中提交的执行者自检。
  71. `submit_task_report` 校验并持久化它;它不等于框架独立 Validator 产生的权威 ValidationResult。
  72. """
  73. hard_passed: bool
  74. open_issues: list[str] = Field(default_factory=list)
  75. @field_validator("open_issues")
  76. @classmethod
  77. def validate_open_issues(cls, items: list[str]) -> list[str]:
  78. if any(not item.strip() for item in items):
  79. raise ValueError("open_issues must contain non-empty strings")
  80. return items
  81. class NextStepSuggestion(StrictProtocolModel):
  82. """子 Agent 对直接父级的下一步建议,本身不会创建同级任务。
  83. 父 Agent 在 `review_task_result` 中结合独立验收决定是否采纳、修订或上行。
  84. """
  85. direction: NextStepDirection
  86. reason: str = Field(min_length=1)
  87. suggested_next_task: TaskBrief | None = None
  88. @model_validator(mode="after")
  89. def validate_suggested_task(self) -> "NextStepSuggestion":
  90. if self.direction in {"DESCEND", "REFINE"} and not self.suggested_next_task:
  91. raise ValueError("DESCEND/REFINE requires suggested_next_task")
  92. if self.direction in {"ASCEND", "NONE"} and self.suggested_next_task:
  93. raise ValueError("ASCEND/NONE does not accept suggested_next_task")
  94. return self
  95. class TaskReportSubmission(StrictProtocolModel):
  96. """子 Agent 可提交的 TaskReport 负载,不允许模型填写 Trace ID。
  97. `submit_task_report` 接收该结构,检查结果与自检是否一致后由框架注入子 Trace ID。
  98. """
  99. summary: str = Field(min_length=1)
  100. outcome: TaskOutcome
  101. validation: Validation
  102. next_step_suggestion: NextStepSuggestion
  103. outputs: list[dict[str, Any]] = Field(default_factory=list)
  104. evidence: list[dict[str, Any]] = Field(default_factory=list)
  105. remaining_issues: list[str] = Field(default_factory=list)
  106. @field_validator("remaining_issues")
  107. @classmethod
  108. def validate_remaining_issues(cls, items: list[str]) -> list[str]:
  109. if any(not item.strip() for item in items):
  110. raise ValueError("remaining_issues must contain non-empty strings")
  111. return items
  112. @model_validator(mode="after")
  113. def validate_outcome_consistency(self) -> "TaskReportSubmission":
  114. if self.outcome == "satisfied" and (
  115. not self.validation.hard_passed
  116. or self.validation.open_issues
  117. or self.remaining_issues
  118. ):
  119. raise ValueError(
  120. "satisfied requires hard_passed with no open or remaining issues"
  121. )
  122. if self.outcome in {"failed", "protocol_error"} and self.validation.hard_passed:
  123. raise ValueError(f"{self.outcome} cannot have hard_passed=true")
  124. return self
  125. class TaskReport(TaskReportSubmission):
  126. """框架补全 `child_trace_id` 后的权威子任务报告。
  127. Sub-Agent 汇合孩子结果后将其与 ValidationResult 一起写入父 Trace 的待审队列。
  128. """
  129. child_trace_id: str = Field(min_length=1)
  130. class TaskReview(StrictProtocolModel):
  131. """父 Agent 对一份直接孩子报告的结构化审核结果。
  132. `review_task_result` 根据 ValidationResult 的决策矩阵构建它,然后持久化修订、新任务或回退动作。
  133. """
  134. parent_trace_id: str = Field(min_length=1)
  135. child_trace_id: str = Field(min_length=1)
  136. decision: ReviewDecision
  137. reason: str = Field(min_length=1)
  138. approved_next_task: TaskBrief | None = None
  139. retry_from: RetryFrom | None = None
  140. @model_validator(mode="after")
  141. def validate_approved_next_task(self) -> "TaskReview":
  142. if self.decision in {"ACCEPT_REFINE", "DESCEND_AGAIN"}:
  143. if not self.approved_next_task:
  144. raise ValueError(f"{self.decision} requires approved_next_task")
  145. elif self.decision != "REVISE_CHILD" and self.approved_next_task:
  146. raise ValueError(
  147. f"{self.decision} does not accept approved_next_task"
  148. )
  149. if self.decision == "REPLAN_CURRENT":
  150. if self.retry_from is None:
  151. raise ValueError("REPLAN_CURRENT requires retry_from")
  152. elif self.retry_from is not None:
  153. raise ValueError(f"{self.decision} does not accept retry_from")
  154. return self
  155. def new_task_protocol(task_brief: TaskBrief | dict | None = None) -> dict[str, Any]:
  156. """创建一份完整的 Recursive 任务协议初始状态。
  157. Runner 初始化根 Trace,Sub-Agent 创建子 Trace 时调用,状态随 Trace context 持久化。
  158. """
  159. brief = TaskBrief.model_validate(task_brief).model_dump() if task_brief else None
  160. return {
  161. "task_brief": brief,
  162. "task_brief_version": 1 if brief else 0,
  163. "task_brief_effective_at_sequence": 0,
  164. "task_brief_history": [],
  165. "task_report": None,
  166. "task_report_submitted_at_sequence": None,
  167. "task_report_validation": None,
  168. "report_history": [],
  169. "pending_reviews": {},
  170. "reviews": [],
  171. "next_actions": [],
  172. "pending_replans": [],
  173. "root_validation_attempts": 0,
  174. "root_validation_history": [],
  175. "root_validation_passed": False,
  176. "protocol_correction_attempts": 0,
  177. }
  178. def replace_task_brief(
  179. state: dict[str, Any],
  180. task_brief: TaskBrief | dict[str, Any],
  181. *,
  182. effective_at_sequence: int,
  183. ) -> int:
  184. """以唯一入口替换当前 TaskBrief并保留旧版本。
  185. `review_task_result`批准修订后,`agent(continue_from=...)`真正恢复孩子时调用,
  186. 避免审核阶段提前改写孩子,也避免覆盖后丢失任务合同的审计版本。
  187. """
  188. brief = TaskBrief.model_validate(task_brief).model_dump(mode="json")
  189. current = state.get("task_brief")
  190. current_version = state.get("task_brief_version", 0)
  191. if not isinstance(current_version, int) or current_version < 0:
  192. current_version = 0
  193. if current is not None:
  194. history_entry = {
  195. "version": current_version or 1,
  196. "task_brief": deepcopy(current),
  197. "effective_at_sequence": state.get(
  198. "task_brief_effective_at_sequence",
  199. 0,
  200. ),
  201. }
  202. history = state.setdefault("task_brief_history", [])
  203. if history_entry not in history:
  204. history.append(history_entry)
  205. next_version = max(current_version, 0) + 1 if current is not None else 1
  206. state["task_brief"] = brief
  207. state["task_brief_version"] = next_version
  208. state["task_brief_effective_at_sequence"] = max(
  209. 0,
  210. int(effective_at_sequence),
  211. )
  212. return next_version
  213. def ensure_task_protocol(context: dict[str, Any]) -> dict[str, Any]:
  214. """获取 Trace context 中的协议状态,并幂等补齐缺失字段。
  215. Runner、Sub-Agent 和报告/审核工具在读写任务状态前统一调用此入口。
  216. """
  217. state = context.get("task_protocol")
  218. if not isinstance(state, dict):
  219. state = new_task_protocol()
  220. context["task_protocol"] = state
  221. defaults = new_task_protocol()
  222. for key, value in defaults.items():
  223. state.setdefault(key, deepcopy(value))
  224. return state
  225. def rebuild_pending_replans(state: dict[str, Any]) -> list[dict[str, Any]]:
  226. """在 Trace 回溯截断审核历史后,重建尚未解决的重规划请求。
  227. `AgentRunner._rewind` 清理截断点之后的协议记录时调用,避免丢失同批次的 `REPLAN_CURRENT`。
  228. """
  229. pending_batches = {
  230. entry.get("received_at_sequence")
  231. for entry in state.get("pending_reviews", {}).values()
  232. }
  233. pending_batches.discard(None)
  234. if not pending_batches:
  235. return []
  236. replans = []
  237. for review in state.get("reviews", []):
  238. pending_entry = review.get("pending_review", {})
  239. if (
  240. review.get("decision") == "REPLAN_CURRENT"
  241. and pending_entry.get("received_at_sequence") in pending_batches
  242. ):
  243. replans.append({
  244. "child_trace_id": review.get("child_trace_id"),
  245. "goal_id": pending_entry.get("goal_id"),
  246. "retry_from": review.get("retry_from"),
  247. "requested_at_sequence": review.get("reviewed_at_sequence", 0),
  248. "received_at_sequence": pending_entry.get(
  249. "received_at_sequence"
  250. ),
  251. })
  252. return replans
  253. def protocol_error_report(child_trace_id: str, reason: str) -> TaskReport:
  254. """在子 Agent 未能提交合法报告时生成框架级失败报告。
  255. Runner 的完成门禁和 Sub-Agent 结果汇合阶段调用,使父级仍能审核并恢复。
  256. """
  257. return TaskReport(
  258. child_trace_id=child_trace_id,
  259. summary="Task protocol failed before a valid report was submitted.",
  260. outcome="protocol_error",
  261. validation=Validation(hard_passed=False, open_issues=[reason]),
  262. next_step_suggestion=NextStepSuggestion(
  263. direction="ASCEND",
  264. reason="Parent review is required to revise or fail this task.",
  265. ),
  266. remaining_issues=[reason],
  267. )
  268. def stopped_task_report(child_trace_id: str, reason: str) -> TaskReport:
  269. """为被协作式停止的子 Agent 生成 `failed` TaskReport。
  270. Sub-Agent 的停止汇合路径调用,然后交由父 Agent 选择修订孩子或失败结束。
  271. """
  272. return TaskReport(
  273. child_trace_id=child_trace_id,
  274. summary="Child Agent execution was stopped before completion.",
  275. outcome="failed",
  276. validation=Validation(hard_passed=False, open_issues=[reason]),
  277. next_step_suggestion=NextStepSuggestion(
  278. direction="NONE",
  279. reason="Parent review must revise the stopped child or fail the task.",
  280. ),
  281. remaining_issues=[reason],
  282. )
  283. def pending_review_entry(
  284. *,
  285. goal_id: str | None,
  286. report: TaskReport,
  287. validation_result: dict[str, Any],
  288. received_at_sequence: int,
  289. ) -> dict[str, Any]:
  290. """组装父 Trace `pending_reviews` 中的一份待审记录。
  291. Sub-Agent 完成子 Trace 验收后调用,将 TaskReport、ValidationResult、原 Goal 和序列绑定。
  292. """
  293. return {
  294. "goal_id": goal_id,
  295. "task_report": report.model_dump(),
  296. "validation_result": validation_result,
  297. "received_at_sequence": received_at_sequence,
  298. "received_at": datetime.now().isoformat(),
  299. }
  300. def format_task_brief(
  301. task_brief: TaskBrief,
  302. *,
  303. available_context_refs: list[dict[str, Any]] | None = None,
  304. ) -> str:
  305. """将结构化 TaskBrief 格式化为子 Agent 可执行的任务文本。
  306. ``agent`` 完成规范化后调用,生成 Runner 作为 user message 传给子 Agent 的任务说明。
  307. """
  308. criteria = "\n".join(
  309. f"{index}. {item}"
  310. for index, item in enumerate(task_brief.completion_criteria, start=1)
  311. )
  312. constraints = "\n".join(f"- {item}" for item in task_brief.constraints)
  313. expected_outputs = "\n".join(
  314. f"- {item}" for item in task_brief.expected_outputs
  315. )
  316. parent_findings = "\n".join(
  317. f"- {item}" for item in task_brief.parent_findings
  318. )
  319. parts = [
  320. "# Task Brief",
  321. f"\n## Objective\n{task_brief.objective}",
  322. f"\n## Reason\n{task_brief.reason}",
  323. f"\n## Completion Criteria\n{criteria}",
  324. f"\n## Expected Outputs\n{expected_outputs}",
  325. ]
  326. if parent_findings:
  327. parts.append(f"\n## Parent Findings\n{parent_findings}")
  328. if task_brief.context:
  329. parts.append(
  330. "\n## Context\n"
  331. + json.dumps(
  332. task_brief.context,
  333. ensure_ascii=False,
  334. sort_keys=True,
  335. separators=(",", ":"),
  336. )
  337. )
  338. if constraints:
  339. parts.append(f"\n## Constraints\n{constraints}")
  340. if available_context_refs:
  341. parts.append(
  342. "\n## Available Context References\n"
  343. + json.dumps(
  344. available_context_refs,
  345. ensure_ascii=False,
  346. sort_keys=True,
  347. separators=(",", ":"),
  348. )
  349. )
  350. parts.append(
  351. "\nSubmit a validated TaskReport with submit_task_report before finishing."
  352. )
  353. return "\n".join(parts)