task_protocol.py 17 KB

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