task_protocol.py 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  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. TaskOutcome = Literal["satisfied", "partial", "failed", "protocol_error"]
  8. NextStepDirection = Literal["DESCEND", "REFINE", "ASCEND", "NONE"]
  9. ReviewDecision = Literal[
  10. "ACCEPT_REFINE",
  11. "REJECT_REFINE",
  12. "REVISE_CHILD",
  13. "DESCEND_AGAIN",
  14. "ASCEND",
  15. "FAIL",
  16. ]
  17. class StrictProtocolModel(BaseModel):
  18. model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
  19. class TaskBrief(StrictProtocolModel):
  20. objective: str = Field(min_length=1)
  21. completion_criteria: list[str] = Field(min_length=1)
  22. context: dict[str, Any] | None = None
  23. constraints: list[str] = Field(default_factory=list)
  24. @field_validator("completion_criteria", "constraints")
  25. @classmethod
  26. def validate_non_empty_items(cls, items: list[str]) -> list[str]:
  27. if any(not item.strip() for item in items):
  28. raise ValueError("items must be non-empty strings")
  29. return items
  30. class Validation(StrictProtocolModel):
  31. hard_passed: bool
  32. open_issues: list[str] = Field(default_factory=list)
  33. @field_validator("open_issues")
  34. @classmethod
  35. def validate_open_issues(cls, items: list[str]) -> list[str]:
  36. if any(not item.strip() for item in items):
  37. raise ValueError("open_issues must contain non-empty strings")
  38. return items
  39. class NextStepSuggestion(StrictProtocolModel):
  40. direction: NextStepDirection
  41. reason: str = Field(min_length=1)
  42. suggested_next_task: TaskBrief | None = None
  43. @model_validator(mode="after")
  44. def validate_suggested_task(self) -> "NextStepSuggestion":
  45. if self.direction in {"DESCEND", "REFINE"} and not self.suggested_next_task:
  46. raise ValueError("DESCEND/REFINE requires suggested_next_task")
  47. if self.direction in {"ASCEND", "NONE"} and self.suggested_next_task:
  48. raise ValueError("ASCEND/NONE does not accept suggested_next_task")
  49. return self
  50. class TaskReportSubmission(StrictProtocolModel):
  51. summary: str = Field(min_length=1)
  52. outcome: TaskOutcome
  53. validation: Validation
  54. next_step_suggestion: NextStepSuggestion
  55. outputs: list[dict[str, Any]] = Field(default_factory=list)
  56. evidence: list[dict[str, Any]] = Field(default_factory=list)
  57. remaining_issues: list[str] = Field(default_factory=list)
  58. @field_validator("remaining_issues")
  59. @classmethod
  60. def validate_remaining_issues(cls, items: list[str]) -> list[str]:
  61. if any(not item.strip() for item in items):
  62. raise ValueError("remaining_issues must contain non-empty strings")
  63. return items
  64. @model_validator(mode="after")
  65. def validate_outcome_consistency(self) -> "TaskReportSubmission":
  66. if self.outcome == "satisfied" and (
  67. not self.validation.hard_passed
  68. or self.validation.open_issues
  69. or self.remaining_issues
  70. ):
  71. raise ValueError(
  72. "satisfied requires hard_passed with no open or remaining issues"
  73. )
  74. if self.outcome in {"failed", "protocol_error"} and self.validation.hard_passed:
  75. raise ValueError(f"{self.outcome} cannot have hard_passed=true")
  76. return self
  77. class TaskReport(TaskReportSubmission):
  78. child_trace_id: str = Field(min_length=1)
  79. class TaskReview(StrictProtocolModel):
  80. parent_trace_id: str = Field(min_length=1)
  81. child_trace_id: str = Field(min_length=1)
  82. decision: ReviewDecision
  83. reason: str = Field(min_length=1)
  84. approved_next_task: TaskBrief | None = None
  85. @model_validator(mode="after")
  86. def validate_approved_next_task(self) -> "TaskReview":
  87. if self.decision in {"ACCEPT_REFINE", "DESCEND_AGAIN"}:
  88. if not self.approved_next_task:
  89. raise ValueError(f"{self.decision} requires approved_next_task")
  90. elif self.decision != "REVISE_CHILD" and self.approved_next_task:
  91. raise ValueError(
  92. f"{self.decision} does not accept approved_next_task"
  93. )
  94. return self
  95. def new_task_protocol(task_brief: TaskBrief | dict | None = None) -> dict[str, Any]:
  96. brief = TaskBrief.model_validate(task_brief).model_dump() if task_brief else None
  97. return {
  98. "task_brief": brief,
  99. "task_report": None,
  100. "task_report_submitted_at_sequence": None,
  101. "report_history": [],
  102. "pending_reviews": {},
  103. "reviews": [],
  104. "next_actions": [],
  105. "protocol_correction_attempts": 0,
  106. }
  107. def ensure_task_protocol(context: dict[str, Any]) -> dict[str, Any]:
  108. state = context.get("task_protocol")
  109. if not isinstance(state, dict):
  110. state = new_task_protocol()
  111. context["task_protocol"] = state
  112. defaults = new_task_protocol()
  113. for key, value in defaults.items():
  114. state.setdefault(key, deepcopy(value))
  115. return state
  116. def protocol_error_report(child_trace_id: str, reason: str) -> TaskReport:
  117. return TaskReport(
  118. child_trace_id=child_trace_id,
  119. summary="Task protocol failed before a valid report was submitted.",
  120. outcome="protocol_error",
  121. validation=Validation(hard_passed=False, open_issues=[reason]),
  122. next_step_suggestion=NextStepSuggestion(
  123. direction="ASCEND",
  124. reason="Parent review is required to revise or fail this task.",
  125. ),
  126. remaining_issues=[reason],
  127. )
  128. def pending_review_entry(
  129. *,
  130. goal_id: str | None,
  131. report: TaskReport,
  132. received_at_sequence: int,
  133. ) -> dict[str, Any]:
  134. return {
  135. "goal_id": goal_id,
  136. "task_report": report.model_dump(),
  137. "received_at_sequence": received_at_sequence,
  138. "received_at": datetime.now().isoformat(),
  139. }
  140. def format_task_brief(task_brief: TaskBrief) -> str:
  141. criteria = "\n".join(
  142. f"{index}. {item}"
  143. for index, item in enumerate(task_brief.completion_criteria, start=1)
  144. )
  145. constraints = "\n".join(f"- {item}" for item in task_brief.constraints)
  146. parts = [
  147. "# Task Brief",
  148. f"\n## Objective\n{task_brief.objective}",
  149. f"\n## Completion Criteria\n{criteria}",
  150. ]
  151. if task_brief.context:
  152. parts.append(f"\n## Context\n{task_brief.context}")
  153. if constraints:
  154. parts.append(f"\n## Constraints\n{constraints}")
  155. parts.append(
  156. "\nSubmit a validated TaskReport with submit_task_report before finishing."
  157. )
  158. return "\n".join(parts)