task_protocol.py 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. """Recursive revision 2 parent/child task protocol."""
  2. from __future__ import annotations
  3. from copy import deepcopy
  4. from datetime import datetime
  5. from typing import Any, Literal
  6. from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
  7. from cyber_agent.core.validation import RetryFrom
  8. TaskOutcome = Literal["satisfied", "partial", "failed", "protocol_error"]
  9. NextStepDirection = Literal["DESCEND", "REFINE", "ASCEND", "NONE"]
  10. ReviewDecision = Literal[
  11. "ACCEPT_REFINE",
  12. "REJECT_REFINE",
  13. "REVISE_CHILD",
  14. "DESCEND_AGAIN",
  15. "REPLAN_CURRENT",
  16. "ASCEND",
  17. "FAIL",
  18. ]
  19. class StrictProtocolModel(BaseModel):
  20. model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
  21. class TaskBrief(StrictProtocolModel):
  22. objective: str = Field(min_length=1)
  23. reason: str = Field(min_length=1)
  24. completion_criteria: list[str] = Field(min_length=1)
  25. expected_outputs: list[str] = Field(min_length=1)
  26. parent_findings: list[str] = Field(default_factory=list)
  27. context: dict[str, Any] = Field(default_factory=dict)
  28. constraints: list[str] = Field(default_factory=list)
  29. @field_validator(
  30. "completion_criteria",
  31. "expected_outputs",
  32. "parent_findings",
  33. "constraints",
  34. )
  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 Validation(StrictProtocolModel):
  41. hard_passed: bool
  42. open_issues: list[str] = Field(default_factory=list)
  43. @field_validator("open_issues")
  44. @classmethod
  45. def validate_open_issues(cls, items: list[str]) -> list[str]:
  46. if any(not item.strip() for item in items):
  47. raise ValueError("open_issues must contain non-empty strings")
  48. return items
  49. class NextStepSuggestion(StrictProtocolModel):
  50. direction: NextStepDirection
  51. reason: str = Field(min_length=1)
  52. suggested_next_task: TaskBrief | None = None
  53. @model_validator(mode="after")
  54. def validate_suggested_task(self) -> "NextStepSuggestion":
  55. if self.direction in {"DESCEND", "REFINE"} and not self.suggested_next_task:
  56. raise ValueError("DESCEND/REFINE requires suggested_next_task")
  57. if self.direction in {"ASCEND", "NONE"} and self.suggested_next_task:
  58. raise ValueError("ASCEND/NONE does not accept suggested_next_task")
  59. return self
  60. class TaskReportSubmission(StrictProtocolModel):
  61. summary: str = Field(min_length=1)
  62. outcome: TaskOutcome
  63. validation: Validation
  64. next_step_suggestion: NextStepSuggestion
  65. outputs: list[dict[str, Any]] = Field(default_factory=list)
  66. evidence: list[dict[str, Any]] = Field(default_factory=list)
  67. remaining_issues: list[str] = Field(default_factory=list)
  68. @field_validator("remaining_issues")
  69. @classmethod
  70. def validate_remaining_issues(cls, items: list[str]) -> list[str]:
  71. if any(not item.strip() for item in items):
  72. raise ValueError("remaining_issues must contain non-empty strings")
  73. return items
  74. @model_validator(mode="after")
  75. def validate_outcome_consistency(self) -> "TaskReportSubmission":
  76. if self.outcome == "satisfied" and (
  77. not self.validation.hard_passed
  78. or self.validation.open_issues
  79. or self.remaining_issues
  80. ):
  81. raise ValueError(
  82. "satisfied requires hard_passed with no open or remaining issues"
  83. )
  84. if self.outcome in {"failed", "protocol_error"} and self.validation.hard_passed:
  85. raise ValueError(f"{self.outcome} cannot have hard_passed=true")
  86. return self
  87. class TaskReport(TaskReportSubmission):
  88. child_trace_id: str = Field(min_length=1)
  89. class TaskReview(StrictProtocolModel):
  90. parent_trace_id: str = Field(min_length=1)
  91. child_trace_id: str = Field(min_length=1)
  92. decision: ReviewDecision
  93. reason: str = Field(min_length=1)
  94. approved_next_task: TaskBrief | None = None
  95. retry_from: RetryFrom | None = None
  96. @model_validator(mode="after")
  97. def validate_approved_next_task(self) -> "TaskReview":
  98. if self.decision in {"ACCEPT_REFINE", "DESCEND_AGAIN"}:
  99. if not self.approved_next_task:
  100. raise ValueError(f"{self.decision} requires approved_next_task")
  101. elif self.decision != "REVISE_CHILD" and self.approved_next_task:
  102. raise ValueError(
  103. f"{self.decision} does not accept approved_next_task"
  104. )
  105. if self.decision == "REPLAN_CURRENT":
  106. if self.retry_from is None:
  107. raise ValueError("REPLAN_CURRENT requires retry_from")
  108. elif self.retry_from is not None:
  109. raise ValueError(f"{self.decision} does not accept retry_from")
  110. return self
  111. def new_task_protocol(task_brief: TaskBrief | dict | None = None) -> dict[str, Any]:
  112. brief = TaskBrief.model_validate(task_brief).model_dump() if task_brief else None
  113. return {
  114. "task_brief": brief,
  115. "task_report": None,
  116. "task_report_submitted_at_sequence": None,
  117. "task_report_validation": None,
  118. "report_history": [],
  119. "pending_reviews": {},
  120. "reviews": [],
  121. "next_actions": [],
  122. "pending_replans": [],
  123. "root_validation_attempts": 0,
  124. "root_validation_history": [],
  125. "root_validation_passed": False,
  126. "protocol_correction_attempts": 0,
  127. }
  128. def ensure_task_protocol(context: dict[str, Any]) -> dict[str, Any]:
  129. state = context.get("task_protocol")
  130. if not isinstance(state, dict):
  131. state = new_task_protocol()
  132. context["task_protocol"] = state
  133. defaults = new_task_protocol()
  134. for key, value in defaults.items():
  135. state.setdefault(key, deepcopy(value))
  136. return state
  137. def rebuild_pending_replans(state: dict[str, Any]) -> list[dict[str, Any]]:
  138. """Rebuild unresolved replans after review history is rewound."""
  139. pending_batches = {
  140. entry.get("received_at_sequence")
  141. for entry in state.get("pending_reviews", {}).values()
  142. }
  143. pending_batches.discard(None)
  144. if not pending_batches:
  145. return []
  146. replans = []
  147. for review in state.get("reviews", []):
  148. pending_entry = review.get("pending_review", {})
  149. if (
  150. review.get("decision") == "REPLAN_CURRENT"
  151. and pending_entry.get("received_at_sequence") in pending_batches
  152. ):
  153. replans.append({
  154. "child_trace_id": review.get("child_trace_id"),
  155. "goal_id": pending_entry.get("goal_id"),
  156. "retry_from": review.get("retry_from"),
  157. "requested_at_sequence": review.get("reviewed_at_sequence", 0),
  158. "received_at_sequence": pending_entry.get(
  159. "received_at_sequence"
  160. ),
  161. })
  162. return replans
  163. def protocol_error_report(child_trace_id: str, reason: str) -> TaskReport:
  164. return TaskReport(
  165. child_trace_id=child_trace_id,
  166. summary="Task protocol failed before a valid report was submitted.",
  167. outcome="protocol_error",
  168. validation=Validation(hard_passed=False, open_issues=[reason]),
  169. next_step_suggestion=NextStepSuggestion(
  170. direction="ASCEND",
  171. reason="Parent review is required to revise or fail this task.",
  172. ),
  173. remaining_issues=[reason],
  174. )
  175. def stopped_task_report(child_trace_id: str, reason: str) -> TaskReport:
  176. return TaskReport(
  177. child_trace_id=child_trace_id,
  178. summary="Child Agent execution was stopped before completion.",
  179. outcome="failed",
  180. validation=Validation(hard_passed=False, open_issues=[reason]),
  181. next_step_suggestion=NextStepSuggestion(
  182. direction="NONE",
  183. reason="Parent review must revise the stopped child or fail the task.",
  184. ),
  185. remaining_issues=[reason],
  186. )
  187. def pending_review_entry(
  188. *,
  189. goal_id: str | None,
  190. report: TaskReport,
  191. validation_result: dict[str, Any],
  192. received_at_sequence: int,
  193. ) -> dict[str, Any]:
  194. return {
  195. "goal_id": goal_id,
  196. "task_report": report.model_dump(),
  197. "validation_result": validation_result,
  198. "received_at_sequence": received_at_sequence,
  199. "received_at": datetime.now().isoformat(),
  200. }
  201. def format_task_brief(task_brief: TaskBrief) -> str:
  202. criteria = "\n".join(
  203. f"{index}. {item}"
  204. for index, item in enumerate(task_brief.completion_criteria, start=1)
  205. )
  206. constraints = "\n".join(f"- {item}" for item in task_brief.constraints)
  207. expected_outputs = "\n".join(
  208. f"- {item}" for item in task_brief.expected_outputs
  209. )
  210. parent_findings = "\n".join(
  211. f"- {item}" for item in task_brief.parent_findings
  212. )
  213. parts = [
  214. "# Task Brief",
  215. f"\n## Objective\n{task_brief.objective}",
  216. f"\n## Reason\n{task_brief.reason}",
  217. f"\n## Completion Criteria\n{criteria}",
  218. f"\n## Expected Outputs\n{expected_outputs}",
  219. ]
  220. if parent_findings:
  221. parts.append(f"\n## Parent Findings\n{parent_findings}")
  222. if task_brief.context:
  223. parts.append(f"\n## Context\n{task_brief.context}")
  224. if constraints:
  225. parts.append(f"\n## Constraints\n{constraints}")
  226. parts.append(
  227. "\nSubmit a validated TaskReport with submit_task_report before finishing."
  228. )
  229. return "\n".join(parts)