task_protocol.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884
  1. """Recursive revision 3 的父子任务协议与持久化状态。
  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. from hashlib import sha256
  9. import json
  10. from typing import Any, Literal
  11. from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
  12. from cyber_agent.core.artifacts import ArtifactRef
  13. from cyber_agent.core.agent_mode import policy_from_context
  14. from cyber_agent.core.task_contract import TaskContractRef
  15. from cyber_agent.core.validation import RetryFrom
  16. from cyber_agent.application.candidate import CandidateRef, CandidateReviewAction
  17. TaskOutcome = Literal["satisfied", "partial", "failed", "protocol_error"]
  18. NextStepDirection = Literal["DESCEND", "REFINE", "ASCEND", "NONE"]
  19. ReviewDecision = Literal[
  20. "ACCEPT_REFINE",
  21. "REJECT_REFINE",
  22. "REVISE_CHILD",
  23. "DESCEND_AGAIN",
  24. "REPLAN_CURRENT",
  25. "ASCEND",
  26. "FAIL",
  27. ]
  28. TaskValidationScope = Literal["evidence", "hypothesis", "output", "task"]
  29. TaskProgressPhase = Literal[
  30. "planning",
  31. "investigating",
  32. "forming_hypothesis",
  33. "executing",
  34. "producing_output",
  35. "ready_to_submit",
  36. "blocked",
  37. ]
  38. _VALIDATION_SCOPE_ORDER = ("evidence", "hypothesis", "output", "task")
  39. TASK_PROTOCOL_SCHEMA_VERSION = 1
  40. MAX_TASK_PROGRESS_ITEMS_PER_KIND = 20
  41. MAX_TASK_PROGRESS_ITEMS = 60
  42. MAX_TASK_PROGRESS_CHARS = 24_000
  43. MAX_TASK_PROGRESS_REVISIONS = 64
  44. MAX_TASK_PROGRESS_HISTORY_CHARS = 512_000
  45. MAX_TASK_PROGRESS_CONTEXT_REFS = 8
  46. MAX_TASK_PROGRESS_ARTIFACT_REFS = 20
  47. class StrictProtocolModel(BaseModel):
  48. """协议模型的统一严格基类,拒绝未定义字段并清理字符串空白。"""
  49. model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
  50. class RootTaskAnchor(StrictProtocolModel):
  51. """Recursive 根任务不可变的目标锚点。
  52. 调用方创建根 Trace 时提供;Runner 固化后由每层孩子和 Validator读取,
  53. 中间 Agent不能重新概括或覆盖它。
  54. """
  55. objective: str = Field(min_length=1)
  56. completion_criteria: list[str] = Field(min_length=1)
  57. constraints: list[str] = Field(default_factory=list)
  58. @field_validator("completion_criteria", "constraints")
  59. @classmethod
  60. def validate_non_empty_items(cls, items: list[str]) -> list[str]:
  61. if any(not item.strip() for item in items):
  62. raise ValueError("items must be non-empty strings")
  63. return items
  64. class ContextRef(StrictProtocolModel):
  65. """模型可在 TaskBrief 中转交的最小上下文引用句柄。
  66. 来源、类型、摘要和内容由框架保管;模型只能选择已获授权的 ID与版本。
  67. """
  68. ref_id: str = Field(min_length=1)
  69. version: str = Field(pattern=r"^[0-9a-f]{64}$")
  70. class TaskBrief(StrictProtocolModel):
  71. """父 Agent 下发给直接孩子的结构化任务说明。
  72. `agent()` 在创建/续跑子 Trace 前规范化,Runner 再将格式化内容作为子 Agent 的任务上下文。
  73. """
  74. objective: str = Field(min_length=1)
  75. reason: str = Field(min_length=1)
  76. completion_criteria: list[str] = Field(min_length=1)
  77. expected_outputs: list[str] = Field(min_length=1)
  78. parent_findings: list[str] = Field(default_factory=list)
  79. context: dict[str, Any] = Field(default_factory=dict)
  80. constraints: list[str] = Field(default_factory=list)
  81. context_refs: list[ContextRef] = Field(default_factory=list)
  82. validation_scopes: list[TaskValidationScope] = Field(default_factory=list)
  83. @field_validator(
  84. "completion_criteria",
  85. "expected_outputs",
  86. "parent_findings",
  87. "constraints",
  88. )
  89. @classmethod
  90. def validate_non_empty_items(cls, items: list[str]) -> list[str]:
  91. if any(not item.strip() for item in items):
  92. raise ValueError("items must be non-empty strings")
  93. return items
  94. @field_validator("validation_scopes")
  95. @classmethod
  96. def normalize_validation_scopes(
  97. cls,
  98. scopes: list[TaskValidationScope],
  99. ) -> list[TaskValidationScope]:
  100. selected = set(scopes)
  101. return [scope for scope in _VALIDATION_SCOPE_ORDER if scope in selected]
  102. class Validation(StrictProtocolModel):
  103. """子 Agent 在 TaskReport 中提交的执行者自检。
  104. `submit_task_report` 校验并持久化它;它不等于框架独立 Validator 产生的权威 ValidationResult。
  105. """
  106. hard_passed: bool
  107. open_issues: list[str] = Field(default_factory=list)
  108. @field_validator("open_issues")
  109. @classmethod
  110. def validate_open_issues(cls, items: list[str]) -> list[str]:
  111. if any(not item.strip() for item in items):
  112. raise ValueError("open_issues must contain non-empty strings")
  113. return items
  114. class NextStepSuggestion(StrictProtocolModel):
  115. """子 Agent 对直接父级的下一步建议,本身不会创建同级任务。
  116. 父 Agent 在 `review_task_result` 中结合独立验收决定是否采纳、修订或上行。
  117. """
  118. direction: NextStepDirection
  119. reason: str = Field(min_length=1)
  120. suggested_next_task: TaskBrief | None = None
  121. @model_validator(mode="after")
  122. def validate_suggested_task(self) -> "NextStepSuggestion":
  123. if self.direction in {"DESCEND", "REFINE"} and not self.suggested_next_task:
  124. raise ValueError("DESCEND/REFINE requires suggested_next_task")
  125. if self.direction in {"ASCEND", "NONE"} and self.suggested_next_task:
  126. raise ValueError("ASCEND/NONE does not accept suggested_next_task")
  127. return self
  128. class TaskReportSubmission(StrictProtocolModel):
  129. """子 Agent 可提交的 TaskReport 负载,不允许模型填写 Trace ID。
  130. `submit_task_report` 接收该结构,检查结果与自检是否一致后由框架注入子 Trace ID。
  131. """
  132. summary: str = Field(min_length=1)
  133. outcome: TaskOutcome
  134. validation: Validation
  135. next_step_suggestion: NextStepSuggestion
  136. outputs: list[dict[str, Any]] = Field(default_factory=list)
  137. evidence: list[dict[str, Any]] = Field(default_factory=list)
  138. artifact_refs: list[ArtifactRef] = Field(default_factory=list, max_length=20)
  139. candidate_refs: list[CandidateRef] = Field(default_factory=list, max_length=20)
  140. source_urls: list[str] = Field(default_factory=list, max_length=20)
  141. remaining_issues: list[str] = Field(default_factory=list)
  142. @field_validator("remaining_issues")
  143. @classmethod
  144. def validate_remaining_issues(cls, items: list[str]) -> list[str]:
  145. if any(not item.strip() for item in items):
  146. raise ValueError("remaining_issues must contain non-empty strings")
  147. return items
  148. @field_validator("source_urls")
  149. @classmethod
  150. def validate_source_urls(cls, items: list[str]) -> list[str]:
  151. normalized: list[str] = []
  152. for item in items:
  153. value = item.strip()
  154. if not value:
  155. raise ValueError("source_urls must contain non-empty strings")
  156. if not value.lower().startswith(("http://", "https://")):
  157. raise ValueError("source_urls only accepts HTTP(S) URLs")
  158. if value not in normalized:
  159. normalized.append(value)
  160. return normalized
  161. @model_validator(mode="after")
  162. def validate_outcome_consistency(self) -> "TaskReportSubmission":
  163. if self.outcome == "satisfied" and (
  164. not self.validation.hard_passed
  165. or self.validation.open_issues
  166. or self.remaining_issues
  167. ):
  168. raise ValueError(
  169. "satisfied requires hard_passed with no open or remaining issues"
  170. )
  171. if self.outcome in {"failed", "protocol_error"} and self.validation.hard_passed:
  172. raise ValueError(f"{self.outcome} cannot have hard_passed=true")
  173. return self
  174. class TaskReport(TaskReportSubmission):
  175. """框架补全 `child_trace_id` 后的权威子任务报告。
  176. Sub-Agent 汇合孩子结果后将其与 ValidationResult 一起写入父 Trace 的待审队列。
  177. """
  178. child_trace_id: str = Field(min_length=1)
  179. class TaskReview(StrictProtocolModel):
  180. """父 Agent 对一份直接孩子报告的结构化审核结果。
  181. `review_task_result` 根据 ValidationResult 的决策矩阵构建它,然后持久化修订、新任务或回退动作。
  182. """
  183. parent_trace_id: str = Field(min_length=1)
  184. child_trace_id: str = Field(min_length=1)
  185. decision: ReviewDecision
  186. reason: str = Field(min_length=1)
  187. approved_next_task: TaskBrief | None = None
  188. retry_from: RetryFrom | None = None
  189. candidate_actions: list[CandidateReviewAction] = Field(
  190. default_factory=list,
  191. max_length=20,
  192. )
  193. @model_validator(mode="after")
  194. def validate_approved_next_task(self) -> "TaskReview":
  195. action_types = {item.action for item in self.candidate_actions}
  196. if "revise" in action_types and self.decision != "REVISE_CHILD":
  197. raise ValueError("candidate revise requires REVISE_CHILD")
  198. if self.decision == "REVISE_CHILD" and action_types - {"revise", "discard"}:
  199. raise ValueError("REVISE_CHILD cannot adopt a candidate")
  200. identities = [
  201. (item.candidate_ref.candidate_id, item.candidate_ref.revision)
  202. for item in self.candidate_actions
  203. ]
  204. if len(identities) != len(set(identities)):
  205. raise ValueError("candidate_actions must reference unique candidates")
  206. if self.decision in {"ACCEPT_REFINE", "DESCEND_AGAIN"}:
  207. if not self.approved_next_task:
  208. raise ValueError(f"{self.decision} requires approved_next_task")
  209. elif self.decision != "REVISE_CHILD" and self.approved_next_task:
  210. raise ValueError(
  211. f"{self.decision} does not accept approved_next_task"
  212. )
  213. if self.decision == "REPLAN_CURRENT":
  214. if self.retry_from is None:
  215. raise ValueError("REPLAN_CURRENT requires retry_from")
  216. elif self.retry_from is not None:
  217. raise ValueError(f"{self.decision} does not accept retry_from")
  218. return self
  219. class ProgressRefs(StrictProtocolModel):
  220. """任务推进条目可引用的现有上下文与产物句柄。"""
  221. context_refs: list[ContextRef] = Field(default_factory=list, max_length=8)
  222. artifact_refs: list[ArtifactRef] = Field(default_factory=list, max_length=20)
  223. class Question(ProgressRefs):
  224. item_id: str = Field(min_length=1, max_length=100)
  225. text: str = Field(min_length=1, max_length=2_000)
  226. state: Literal["open", "answered", "deferred"] = "open"
  227. answer: str | None = Field(default=None, min_length=1, max_length=2_000)
  228. @model_validator(mode="after")
  229. def validate_answer(self) -> "Question":
  230. if self.state == "answered" and not self.answer:
  231. raise ValueError("answered question requires answer")
  232. if self.state != "answered" and self.answer is not None:
  233. raise ValueError("only answered question accepts answer")
  234. return self
  235. class Blocker(ProgressRefs):
  236. item_id: str = Field(min_length=1, max_length=100)
  237. text: str = Field(min_length=1, max_length=2_000)
  238. state: Literal["active", "resolved"] = "active"
  239. resolution: str | None = Field(default=None, min_length=1, max_length=2_000)
  240. @model_validator(mode="after")
  241. def validate_resolution(self) -> "Blocker":
  242. if self.state == "resolved" and not self.resolution:
  243. raise ValueError("resolved blocker requires resolution")
  244. if self.state != "resolved" and self.resolution is not None:
  245. raise ValueError("only resolved blocker accepts resolution")
  246. return self
  247. class Finding(ProgressRefs):
  248. item_id: str = Field(min_length=1, max_length=100)
  249. statement: str = Field(min_length=1, max_length=2_000)
  250. basis: str = Field(min_length=1, max_length=2_000)
  251. class Hypothesis(ProgressRefs):
  252. item_id: str = Field(min_length=1, max_length=100)
  253. statement: str = Field(min_length=1, max_length=2_000)
  254. state: Literal["open", "supported", "rejected"] = "open"
  255. rationale: str | None = Field(default=None, min_length=1, max_length=2_000)
  256. @model_validator(mode="after")
  257. def validate_rationale(self) -> "Hypothesis":
  258. if self.state in {"supported", "rejected"} and not self.rationale:
  259. raise ValueError(f"{self.state} hypothesis requires rationale")
  260. if self.state == "open" and self.rationale is not None:
  261. raise ValueError("open hypothesis does not accept rationale")
  262. return self
  263. class WorkItem(ProgressRefs):
  264. item_id: str = Field(min_length=1, max_length=100)
  265. description: str = Field(min_length=1, max_length=2_000)
  266. state: Literal["pending", "in_progress", "done", "abandoned"] = "pending"
  267. result_summary: str | None = Field(default=None, min_length=1, max_length=2_000)
  268. @model_validator(mode="after")
  269. def validate_result(self) -> "WorkItem":
  270. if self.state == "done" and not self.result_summary:
  271. raise ValueError("done work item requires result_summary")
  272. if self.state in {"pending", "in_progress"} and self.result_summary is not None:
  273. raise ValueError(f"{self.state} work item does not accept result_summary")
  274. return self
  275. class TaskProgress(StrictProtocolModel):
  276. """Agent 提交的当前 Task 完整推进快照。"""
  277. phase: TaskProgressPhase
  278. questions: list[Question] = Field(
  279. default_factory=list,
  280. max_length=MAX_TASK_PROGRESS_ITEMS_PER_KIND,
  281. )
  282. blockers: list[Blocker] = Field(
  283. default_factory=list,
  284. max_length=MAX_TASK_PROGRESS_ITEMS_PER_KIND,
  285. )
  286. findings: list[Finding] = Field(
  287. default_factory=list,
  288. max_length=MAX_TASK_PROGRESS_ITEMS_PER_KIND,
  289. )
  290. hypotheses: list[Hypothesis] = Field(
  291. default_factory=list,
  292. max_length=MAX_TASK_PROGRESS_ITEMS_PER_KIND,
  293. )
  294. work_items: list[WorkItem] = Field(
  295. default_factory=list,
  296. max_length=MAX_TASK_PROGRESS_ITEMS_PER_KIND,
  297. )
  298. decision_rationale: str = Field(default="", max_length=2_000)
  299. @model_validator(mode="after")
  300. def validate_progress_limits(self) -> "TaskProgress":
  301. groups = (
  302. self.questions,
  303. self.blockers,
  304. self.findings,
  305. self.hypotheses,
  306. self.work_items,
  307. )
  308. items = [item for group in groups for item in group]
  309. if len(items) > MAX_TASK_PROGRESS_ITEMS:
  310. raise ValueError(
  311. f"TaskProgress accepts at most {MAX_TASK_PROGRESS_ITEMS} total items"
  312. )
  313. item_ids = [item.item_id for item in items]
  314. if len(set(item_ids)) != len(item_ids):
  315. raise ValueError("TaskProgress item_id values must be globally unique")
  316. context_refs = {
  317. (ref.ref_id, ref.version)
  318. for item in items
  319. for ref in item.context_refs
  320. }
  321. artifact_refs = {
  322. (ref.artifact_id, ref.version, ref.content_hash)
  323. for item in items
  324. for ref in item.artifact_refs
  325. }
  326. if len(context_refs) > MAX_TASK_PROGRESS_CONTEXT_REFS:
  327. raise ValueError(
  328. f"TaskProgress accepts at most {MAX_TASK_PROGRESS_CONTEXT_REFS} ContextRefs"
  329. )
  330. if len(artifact_refs) > MAX_TASK_PROGRESS_ARTIFACT_REFS:
  331. raise ValueError(
  332. f"TaskProgress accepts at most {MAX_TASK_PROGRESS_ARTIFACT_REFS} ArtifactRefs"
  333. )
  334. encoded = _canonical_json(self.model_dump(mode="json"))
  335. if len(encoded) > MAX_TASK_PROGRESS_CHARS:
  336. raise ValueError(
  337. f"TaskProgress exceeds {MAX_TASK_PROGRESS_CHARS} characters"
  338. )
  339. return self
  340. class TaskProgressRevision(TaskProgress):
  341. """框架注入版本、时间线与合同引用后的持久化记录。"""
  342. schema_version: Literal[1] = 1
  343. revision: int = Field(ge=1)
  344. parent_revision: int | None = Field(default=None, ge=1)
  345. effective_at_sequence: int = Field(ge=0)
  346. task_contract_ref: TaskContractRef
  347. def _canonical_json(value: Any) -> str:
  348. try:
  349. return json.dumps(
  350. value,
  351. ensure_ascii=False,
  352. sort_keys=True,
  353. separators=(",", ":"),
  354. allow_nan=False,
  355. )
  356. except (TypeError, ValueError) as exc:
  357. raise ValueError(f"Task protocol data must be JSON serializable: {exc}") from exc
  358. def task_brief_hash(task_brief: TaskBrief | dict[str, Any]) -> str:
  359. brief = TaskBrief.model_validate(task_brief)
  360. return sha256(
  361. _canonical_json(brief.model_dump(mode="json")).encode("utf-8")
  362. ).hexdigest()
  363. def task_contract_ref(
  364. state: dict[str, Any],
  365. *,
  366. root_task_anchor_hash: str | None = None,
  367. ) -> TaskContractRef:
  368. brief = state.get("task_brief")
  369. if brief is None:
  370. if not root_task_anchor_hash:
  371. raise ValueError("root TaskProgress requires root_task_anchor_hash")
  372. return TaskContractRef(
  373. kind="root_task_anchor",
  374. root_task_anchor_hash=root_task_anchor_hash,
  375. )
  376. version = state.get("task_brief_version")
  377. return TaskContractRef(
  378. kind="task_brief",
  379. task_brief_version=version,
  380. task_brief_hash=task_brief_hash(brief),
  381. )
  382. def current_task_progress(state: dict[str, Any]) -> TaskProgressRevision | None:
  383. head = state.get("task_progress_head_revision")
  384. if head is None:
  385. return None
  386. matches = [
  387. item
  388. for item in state.get("task_progress_revisions", [])
  389. if item.get("revision") == head
  390. ]
  391. if len(matches) != 1:
  392. raise ValueError("TaskProgress head does not reference exactly one revision")
  393. return TaskProgressRevision.model_validate(matches[0])
  394. def task_progress_at_revision(
  395. state: dict[str, Any],
  396. revision: int | None,
  397. ) -> TaskProgressRevision | None:
  398. if revision is None:
  399. return None
  400. matches = [
  401. raw
  402. for raw in state.get("task_progress_revisions", [])
  403. if raw.get("revision") == revision
  404. ]
  405. if len(matches) != 1:
  406. raise ValueError(
  407. f"TaskProgress revision {revision} does not reference exactly one record"
  408. )
  409. return TaskProgressRevision.model_validate(matches[0])
  410. def task_progress_artifact_refs(
  411. progress: TaskProgress | TaskProgressRevision | None,
  412. ) -> list[ArtifactRef]:
  413. if progress is None:
  414. return []
  415. unique: dict[tuple[str, str, str], ArtifactRef] = {}
  416. for item in (
  417. *progress.questions,
  418. *progress.blockers,
  419. *progress.findings,
  420. *progress.hypotheses,
  421. *progress.work_items,
  422. ):
  423. for ref in item.artifact_refs:
  424. unique[(ref.artifact_id, ref.version, ref.content_hash)] = ref
  425. return list(unique.values())
  426. def append_task_progress_revision(
  427. state: dict[str, Any],
  428. progress: TaskProgress | dict[str, Any],
  429. *,
  430. expected_revision: int,
  431. effective_at_sequence: int,
  432. contract_ref: TaskContractRef,
  433. ) -> TaskProgressRevision:
  434. """校验并追加一个完整快照;校验失败时不修改 state。"""
  435. revisions = list(state.get("task_progress_revisions", []))
  436. head = state.get("task_progress_head_revision")
  437. current_revision = int(head or 0)
  438. if expected_revision != current_revision:
  439. raise ValueError(
  440. f"stale TaskProgress revision: expected={expected_revision}, current={current_revision}"
  441. )
  442. if len(revisions) >= MAX_TASK_PROGRESS_REVISIONS:
  443. raise ValueError(
  444. f"TaskProgress accepts at most {MAX_TASK_PROGRESS_REVISIONS} revisions"
  445. )
  446. parsed = TaskProgress.model_validate(progress)
  447. known_revisions = [int(item.get("revision", 0)) for item in revisions]
  448. next_revision = max(known_revisions, default=0) + 1
  449. revision = TaskProgressRevision(
  450. **parsed.model_dump(mode="json"),
  451. revision=next_revision,
  452. parent_revision=head,
  453. effective_at_sequence=max(0, int(effective_at_sequence)),
  454. task_contract_ref=contract_ref,
  455. )
  456. candidate = [*revisions, revision.model_dump(mode="json")]
  457. if len(_canonical_json(candidate)) > MAX_TASK_PROGRESS_HISTORY_CHARS:
  458. raise ValueError(
  459. f"TaskProgress history exceeds {MAX_TASK_PROGRESS_HISTORY_CHARS} characters"
  460. )
  461. state["task_progress_revisions"] = candidate
  462. state["task_progress_head_revision"] = revision.revision
  463. return revision
  464. def initialize_task_progress(
  465. state: dict[str, Any],
  466. *,
  467. root_task_anchor_hash: str | None = None,
  468. effective_at_sequence: int = 0,
  469. ) -> TaskProgressRevision:
  470. existing = current_task_progress(state)
  471. if existing is not None:
  472. return existing
  473. return append_task_progress_revision(
  474. state,
  475. TaskProgress(phase="planning"),
  476. expected_revision=0,
  477. effective_at_sequence=effective_at_sequence,
  478. contract_ref=task_contract_ref(
  479. state,
  480. root_task_anchor_hash=root_task_anchor_hash,
  481. ),
  482. )
  483. def task_progress_readiness_error(state: dict[str, Any]) -> str | None:
  484. progress = current_task_progress(state)
  485. if progress is None:
  486. return "TaskProgress is required"
  487. if progress.phase != "ready_to_submit":
  488. return "TaskProgress phase must be ready_to_submit"
  489. if any(item.state == "open" for item in progress.questions):
  490. return "TaskProgress has open questions"
  491. if any(item.state == "active" for item in progress.blockers):
  492. return "TaskProgress has active blockers"
  493. if any(item.state == "open" for item in progress.hypotheses):
  494. return "TaskProgress has open hypotheses"
  495. if any(item.state in {"pending", "in_progress"} for item in progress.work_items):
  496. return "TaskProgress has unfinished work items"
  497. return None
  498. def rewind_task_progress(state: dict[str, Any], cutoff: int) -> int | None:
  499. """只沿当前 head 的祖先链回溯,不删除其他分支。"""
  500. by_revision = {
  501. item.revision: item
  502. for item in (
  503. TaskProgressRevision.model_validate(raw)
  504. for raw in state.get("task_progress_revisions", [])
  505. )
  506. }
  507. head = state.get("task_progress_head_revision")
  508. visited: set[int] = set()
  509. while head is not None:
  510. if head in visited or head not in by_revision:
  511. raise ValueError("TaskProgress revision ancestry is corrupt")
  512. visited.add(head)
  513. revision = by_revision[head]
  514. if revision.effective_at_sequence <= cutoff:
  515. break
  516. head = revision.parent_revision
  517. state["task_progress_head_revision"] = head
  518. retained_ancestry: set[int] = set()
  519. cursor = head
  520. while cursor is not None:
  521. if cursor in retained_ancestry or cursor not in by_revision:
  522. raise ValueError("TaskProgress revision ancestry is corrupt")
  523. retained_ancestry.add(cursor)
  524. cursor = by_revision[cursor].parent_revision
  525. report_revision = state.get("task_report_progress_revision")
  526. if report_revision is not None and report_revision not in retained_ancestry:
  527. state["task_report_progress_revision"] = None
  528. return head
  529. def new_task_protocol(task_brief: TaskBrief | dict | None = None) -> dict[str, Any]:
  530. """创建一份完整的 Recursive 任务协议初始状态。
  531. Runner 初始化根 Trace,Sub-Agent 创建子 Trace 时调用,状态随 Trace context 持久化。
  532. """
  533. brief = TaskBrief.model_validate(task_brief).model_dump() if task_brief else None
  534. return {
  535. "schema_version": TASK_PROTOCOL_SCHEMA_VERSION,
  536. "task_brief": brief,
  537. "task_brief_version": 1 if brief else 0,
  538. "task_brief_effective_at_sequence": 0,
  539. "task_brief_history": [],
  540. "task_report": None,
  541. "task_report_submitted_at_sequence": None,
  542. "task_report_validation": None,
  543. "report_history": [],
  544. "pending_reviews": {},
  545. "reviews": [],
  546. "next_actions": [],
  547. "pending_replans": [],
  548. "root_validation_attempts": 0,
  549. "root_validation_history": [],
  550. "root_validation_passed": False,
  551. "protocol_correction_attempts": 0,
  552. "task_progress_revisions": [],
  553. "task_progress_head_revision": None,
  554. "task_report_progress_revision": None,
  555. }
  556. def replace_task_brief(
  557. state: dict[str, Any],
  558. task_brief: TaskBrief | dict[str, Any],
  559. *,
  560. effective_at_sequence: int,
  561. ) -> int:
  562. """以唯一入口替换当前 TaskBrief并保留旧版本。
  563. `review_task_result`批准修订后,`agent(continue_from=...)`真正恢复孩子时调用,
  564. 避免审核阶段提前改写孩子,也避免覆盖后丢失任务合同的审计版本。
  565. """
  566. brief = TaskBrief.model_validate(task_brief).model_dump(mode="json")
  567. current = state.get("task_brief")
  568. current_version = state.get("task_brief_version", 0)
  569. if not isinstance(current_version, int) or current_version < 0:
  570. current_version = 0
  571. if current is not None:
  572. history_entry = {
  573. "version": current_version or 1,
  574. "task_brief": deepcopy(current),
  575. "effective_at_sequence": state.get(
  576. "task_brief_effective_at_sequence",
  577. 0,
  578. ),
  579. }
  580. history = state.setdefault("task_brief_history", [])
  581. if history_entry not in history:
  582. history.append(history_entry)
  583. next_version = max(current_version, 0) + 1 if current is not None else 1
  584. state["task_brief"] = brief
  585. state["task_brief_version"] = next_version
  586. state["task_brief_effective_at_sequence"] = max(
  587. 0,
  588. int(effective_at_sequence),
  589. )
  590. append_task_progress_revision(
  591. state,
  592. TaskProgress(phase="planning"),
  593. expected_revision=int(state.get("task_progress_head_revision") or 0),
  594. effective_at_sequence=effective_at_sequence,
  595. contract_ref=TaskContractRef(
  596. kind="task_brief",
  597. task_brief_version=next_version,
  598. task_brief_hash=task_brief_hash(brief),
  599. ),
  600. )
  601. return next_version
  602. def ensure_task_protocol(context: dict[str, Any]) -> dict[str, Any]:
  603. """获取 Trace context 中的协议状态,并幂等补齐缺失字段。
  604. Runner、Sub-Agent 和报告/审核工具在读写任务状态前统一调用此入口。
  605. """
  606. state = context.get("task_protocol")
  607. policy = policy_from_context(context)
  608. strict = policy.requires_task_progress
  609. if not isinstance(state, dict):
  610. if strict:
  611. raise ValueError(
  612. "Recursive revision 3 task_protocol is missing or invalid"
  613. )
  614. state = new_task_protocol()
  615. context["task_protocol"] = state
  616. defaults = new_task_protocol()
  617. if strict:
  618. missing = sorted(set(defaults).difference(state))
  619. if missing:
  620. raise ValueError(
  621. "Recursive revision 3 task_protocol is missing required fields: "
  622. + ", ".join(missing)
  623. )
  624. for key, value in defaults.items():
  625. state.setdefault(key, deepcopy(value))
  626. if state.get("schema_version") != TASK_PROTOCOL_SCHEMA_VERSION:
  627. raise ValueError("Unsupported task_protocol schema_version")
  628. return state
  629. def rebuild_pending_replans(state: dict[str, Any]) -> list[dict[str, Any]]:
  630. """在 Trace 回溯截断审核历史后,重建尚未解决的重规划请求。
  631. `AgentRunner._rewind` 清理截断点之后的协议记录时调用,避免丢失同批次的 `REPLAN_CURRENT`。
  632. """
  633. pending_batches = {
  634. entry.get("received_at_sequence")
  635. for entry in state.get("pending_reviews", {}).values()
  636. }
  637. pending_batches.discard(None)
  638. if not pending_batches:
  639. return []
  640. replans = []
  641. for review in state.get("reviews", []):
  642. pending_entry = review.get("pending_review", {})
  643. if (
  644. review.get("decision") == "REPLAN_CURRENT"
  645. and pending_entry.get("received_at_sequence") in pending_batches
  646. ):
  647. replans.append({
  648. "child_trace_id": review.get("child_trace_id"),
  649. "goal_id": pending_entry.get("goal_id"),
  650. "retry_from": review.get("retry_from"),
  651. "requested_at_sequence": review.get("reviewed_at_sequence", 0),
  652. "received_at_sequence": pending_entry.get(
  653. "received_at_sequence"
  654. ),
  655. })
  656. return replans
  657. def protocol_error_report(child_trace_id: str, reason: str) -> TaskReport:
  658. """在子 Agent 未能提交合法报告时生成框架级失败报告。
  659. Runner 的完成门禁和 Sub-Agent 结果汇合阶段调用,使父级仍能审核并恢复。
  660. """
  661. return TaskReport(
  662. child_trace_id=child_trace_id,
  663. summary="Task protocol failed before a valid report was submitted.",
  664. outcome="protocol_error",
  665. validation=Validation(hard_passed=False, open_issues=[reason]),
  666. next_step_suggestion=NextStepSuggestion(
  667. direction="ASCEND",
  668. reason="Parent review is required to revise or fail this task.",
  669. ),
  670. remaining_issues=[reason],
  671. )
  672. def stopped_task_report(child_trace_id: str, reason: str) -> TaskReport:
  673. """为被协作式停止的子 Agent 生成 `failed` TaskReport。
  674. Sub-Agent 的停止汇合路径调用,然后交由父 Agent 选择修订孩子或失败结束。
  675. """
  676. return TaskReport(
  677. child_trace_id=child_trace_id,
  678. summary="Child Agent execution was stopped before completion.",
  679. outcome="failed",
  680. validation=Validation(hard_passed=False, open_issues=[reason]),
  681. next_step_suggestion=NextStepSuggestion(
  682. direction="NONE",
  683. reason="Parent review must revise the stopped child or fail the task.",
  684. ),
  685. remaining_issues=[reason],
  686. )
  687. def pending_review_entry(
  688. *,
  689. goal_id: str | None,
  690. report: TaskReport,
  691. validation_result: dict[str, Any],
  692. received_at_sequence: int,
  693. ) -> dict[str, Any]:
  694. """组装父 Trace `pending_reviews` 中的一份待审记录。
  695. Sub-Agent 完成子 Trace 验收后调用,将 TaskReport、ValidationResult、原 Goal 和序列绑定。
  696. """
  697. return {
  698. "goal_id": goal_id,
  699. "task_report": report.model_dump(),
  700. "validation_result": validation_result,
  701. "received_at_sequence": received_at_sequence,
  702. "received_at": datetime.now().isoformat(),
  703. }
  704. def format_task_brief(
  705. task_brief: TaskBrief,
  706. *,
  707. available_context_refs: list[dict[str, Any]] | None = None,
  708. ) -> str:
  709. """将结构化 TaskBrief 格式化为子 Agent 可执行的任务文本。
  710. ``agent`` 完成规范化后调用,生成 Runner 作为 user message 传给子 Agent 的任务说明。
  711. """
  712. criteria = "\n".join(
  713. f"{index}. {item}"
  714. for index, item in enumerate(task_brief.completion_criteria, start=1)
  715. )
  716. constraints = "\n".join(f"- {item}" for item in task_brief.constraints)
  717. expected_outputs = "\n".join(
  718. f"- {item}" for item in task_brief.expected_outputs
  719. )
  720. parent_findings = "\n".join(
  721. f"- {item}" for item in task_brief.parent_findings
  722. )
  723. parts = [
  724. "# Task Brief",
  725. f"\n## Objective\n{task_brief.objective}",
  726. f"\n## Reason\n{task_brief.reason}",
  727. f"\n## Completion Criteria\n{criteria}",
  728. f"\n## Expected Outputs\n{expected_outputs}",
  729. ]
  730. if parent_findings:
  731. parts.append(f"\n## Parent Findings\n{parent_findings}")
  732. if task_brief.context:
  733. parts.append(
  734. "\n## Context\n"
  735. + json.dumps(
  736. task_brief.context,
  737. ensure_ascii=False,
  738. sort_keys=True,
  739. separators=(",", ":"),
  740. )
  741. )
  742. if constraints:
  743. parts.append(f"\n## Constraints\n{constraints}")
  744. effective_scopes = [*task_brief.validation_scopes]
  745. if "task" not in effective_scopes:
  746. effective_scopes.append("task")
  747. parts.append(
  748. "\n## Validation Scopes\n"
  749. + ", ".join(effective_scopes)
  750. )
  751. if available_context_refs:
  752. parts.append(
  753. "\n## Available Context References\n"
  754. + json.dumps(
  755. available_context_refs,
  756. ensure_ascii=False,
  757. sort_keys=True,
  758. separators=(",", ":"),
  759. )
  760. )
  761. parts.append(
  762. "\nSubmit a validated TaskReport with submit_task_report before finishing."
  763. )
  764. return "\n".join(parts)