candidate.py 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263
  1. """Application-owned candidate content behind framework-owned immutable references."""
  2. from __future__ import annotations
  3. from typing import Any, Literal, Protocol, runtime_checkable
  4. from pydantic import Field, model_validator
  5. from cyber_agent.application.models import ApplicationModel, ApplicationRef, canonical_json
  6. from cyber_agent.core.artifacts import ArtifactRef
  7. from cyber_agent.core.task_contract import TaskContractRef
  8. MAX_CANDIDATES_PER_ROOT = 256
  9. MAX_CANDIDATE_OPERATIONS_PER_ROOT = 2_048
  10. MAX_CANDIDATE_VALIDATIONS_PER_ROOT = 2_048
  11. class CandidatePointer(ApplicationModel):
  12. candidate_id: str = Field(min_length=1, max_length=200)
  13. revision: int = Field(ge=1)
  14. class CandidateRef(CandidatePointer):
  15. schema_version: Literal[1] = 1
  16. application_ref: ApplicationRef
  17. root_trace_id: str = Field(min_length=1)
  18. owner_trace_id: str = Field(min_length=1)
  19. task_contract_ref: TaskContractRef
  20. artifact_ref: ArtifactRef
  21. parent_refs: tuple[CandidatePointer, ...] = ()
  22. created_at_sequence: int = Field(ge=0)
  23. @model_validator(mode="after")
  24. def validate_parent_refs(self) -> "CandidateRef":
  25. identities = [
  26. (item.candidate_id, item.revision)
  27. for item in self.parent_refs
  28. ]
  29. if len(identities) != len(set(identities)):
  30. raise ValueError("CandidateRef parent_refs must be unique")
  31. if (self.candidate_id, self.revision) in set(identities):
  32. raise ValueError("CandidateRef cannot reference itself")
  33. return self
  34. class CandidateVersionRequest(ApplicationModel):
  35. operation_id: str = Field(min_length=1, max_length=300)
  36. application_ref: ApplicationRef
  37. root_trace_id: str = Field(min_length=1)
  38. owner_trace_id: str = Field(min_length=1)
  39. uid: str | None = None
  40. candidate_id: str = Field(min_length=1, max_length=200)
  41. revision: int = Field(ge=1)
  42. task_contract_ref: TaskContractRef
  43. parent_refs: tuple[CandidatePointer, ...] = ()
  44. content: Any
  45. @model_validator(mode="after")
  46. def validate_content(self) -> "CandidateVersionRequest":
  47. canonical_json(self.content)
  48. return self
  49. class CandidateAdoptionRequest(ApplicationModel):
  50. operation_id: str = Field(min_length=1, max_length=300)
  51. candidate_ref: CandidateRef
  52. class CandidateAdoptionReceipt(ApplicationModel):
  53. operation_id: str = Field(min_length=1, max_length=300)
  54. external_id: str = Field(min_length=1, max_length=500)
  55. committed: bool = True
  56. class CandidateRepositoryRejected(ValueError):
  57. """Deterministic rejection proving that no candidate version was committed."""
  58. class CandidateReviewAction(ApplicationModel):
  59. action: Literal["adopt", "discard", "revise"]
  60. candidate_ref: CandidateRef
  61. reason: str = Field(min_length=1, max_length=2_000)
  62. @runtime_checkable
  63. class CandidateRepository(Protocol):
  64. async def put_version(
  65. self,
  66. request: CandidateVersionRequest,
  67. ) -> ArtifactRef: ...
  68. async def load_version(self, candidate_ref: CandidateRef) -> Any: ...
  69. async def merge(
  70. self,
  71. request: CandidateVersionRequest,
  72. ) -> ArtifactRef: ...
  73. async def commit_adoption(
  74. self,
  75. request: CandidateAdoptionRequest,
  76. ) -> CandidateAdoptionReceipt: ...
  77. class CandidateLifecycleRecord(ApplicationModel):
  78. operation_id: str = Field(min_length=1, max_length=300)
  79. candidate: CandidatePointer
  80. state: Literal[
  81. "proposed",
  82. "adoption_pending",
  83. "adopted",
  84. "discarded",
  85. "adoption_failed",
  86. ]
  87. effective_at_sequence: int = Field(ge=0)
  88. source_trace_id: str | None = None
  89. reason: str | None = Field(default=None, max_length=2_000)
  90. receipt: CandidateAdoptionReceipt | None = None
  91. class CandidateOperationRecord(ApplicationModel):
  92. operation_id: str = Field(min_length=1, max_length=300)
  93. # Stable framework command identity. Candidate tool commands persist the
  94. # assistant tool_call_id here so retries of one call are idempotent while
  95. # two distinct calls with identical arguments remain distinct commands.
  96. command_id: str | None = Field(default=None, min_length=1, max_length=300)
  97. operation: Literal[
  98. "create", "fork", "merge", "adopt", "discard", "revise",
  99. "review_batch",
  100. ]
  101. input_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
  102. status: Literal["pending", "completed", "failed"] = "pending"
  103. candidate: CandidatePointer | None = None
  104. error: str | None = Field(default=None, max_length=2_000)
  105. class CandidateLedger(ApplicationModel):
  106. schema_version: Literal[1] = 1
  107. candidates: tuple[CandidateRef, ...] = ()
  108. lifecycle: tuple[CandidateLifecycleRecord, ...] = ()
  109. operations: tuple[CandidateOperationRecord, ...] = ()
  110. validations: tuple[dict[str, Any], ...] = ()
  111. validation_checkpoints: tuple[dict[str, Any], ...] = ()
  112. @model_validator(mode="after")
  113. def validate_ledger(self) -> "CandidateLedger":
  114. if len(self.candidates) > MAX_CANDIDATES_PER_ROOT:
  115. raise ValueError("Candidate ledger candidate limit exceeded")
  116. if len(self.operations) > MAX_CANDIDATE_OPERATIONS_PER_ROOT:
  117. raise ValueError("Candidate ledger operation limit exceeded")
  118. if len(self.validations) > MAX_CANDIDATE_VALIDATIONS_PER_ROOT:
  119. raise ValueError("Candidate ledger validation limit exceeded")
  120. if len(self.validation_checkpoints) > MAX_CANDIDATE_VALIDATIONS_PER_ROOT:
  121. raise ValueError("Candidate ledger validation checkpoint limit exceeded")
  122. identities = [
  123. (item.candidate_id, item.revision)
  124. for item in self.candidates
  125. ]
  126. if len(identities) != len(set(identities)):
  127. raise ValueError("Candidate ledger contains duplicate revisions")
  128. operation_ids = [item.operation_id for item in self.operations]
  129. if len(operation_ids) != len(set(operation_ids)):
  130. raise ValueError("Candidate ledger contains duplicate operation IDs")
  131. from cyber_agent.application.quality import (
  132. CandidateValidationCheckpoint,
  133. CandidateValidationRecord,
  134. )
  135. candidate_by_key = {
  136. (item.candidate_id, item.revision): item
  137. for item in self.candidates
  138. }
  139. validation_keys = []
  140. for raw in self.validations:
  141. validation = CandidateValidationRecord.model_validate(raw)
  142. key = (
  143. validation.candidate_ref.candidate_id,
  144. validation.candidate_ref.revision,
  145. )
  146. if candidate_by_key.get(key) != validation.candidate_ref:
  147. raise ValueError(
  148. "Candidate ledger validation references an altered candidate"
  149. )
  150. validation_keys.append((key, validation.plan_hash))
  151. if len(validation_keys) != len(set(validation_keys)):
  152. raise ValueError("Candidate ledger contains duplicate validations")
  153. checkpoint_keys = []
  154. for raw in self.validation_checkpoints:
  155. checkpoint = CandidateValidationCheckpoint.model_validate(raw)
  156. candidate_key = (
  157. checkpoint.candidate_ref.candidate_id,
  158. checkpoint.candidate_ref.revision,
  159. )
  160. if candidate_by_key.get(candidate_key) != checkpoint.candidate_ref:
  161. raise ValueError(
  162. "Candidate ledger checkpoint references an altered candidate"
  163. )
  164. checkpoint_keys.append((
  165. checkpoint.candidate_ref.candidate_id,
  166. checkpoint.candidate_ref.revision,
  167. checkpoint.plan_hash,
  168. ))
  169. if len(checkpoint_keys) != len(set(checkpoint_keys)):
  170. raise ValueError("Candidate ledger contains duplicate validation checkpoints")
  171. known = set(identities)
  172. parents_by_candidate: dict[
  173. tuple[str, int], tuple[tuple[str, int], ...]
  174. ] = {}
  175. for candidate in self.candidates:
  176. candidate_key = (candidate.candidate_id, candidate.revision)
  177. parent_keys: list[tuple[str, int]] = []
  178. for parent in candidate.parent_refs:
  179. parent_key = (parent.candidate_id, parent.revision)
  180. if parent_key not in known:
  181. raise ValueError("Candidate ledger contains an unknown parent")
  182. if (
  183. parent.candidate_id == candidate.candidate_id
  184. and parent.revision >= candidate.revision
  185. ):
  186. raise ValueError("Candidate lineage references a future revision")
  187. parent_keys.append(parent_key)
  188. parents_by_candidate[candidate_key] = tuple(parent_keys)
  189. visited: set[tuple[str, int]] = set()
  190. visiting: set[tuple[str, int]] = set()
  191. def visit(candidate_key: tuple[str, int]) -> None:
  192. if candidate_key in visiting:
  193. raise ValueError("Candidate ledger contains a lineage cycle")
  194. if candidate_key in visited:
  195. return
  196. visiting.add(candidate_key)
  197. for parent_key in parents_by_candidate[candidate_key]:
  198. visit(parent_key)
  199. visiting.remove(candidate_key)
  200. visited.add(candidate_key)
  201. for candidate_key in parents_by_candidate:
  202. visit(candidate_key)
  203. return self
  204. def candidate(self, pointer: CandidatePointer) -> CandidateRef:
  205. for item in self.candidates:
  206. if (
  207. item.candidate_id == pointer.candidate_id
  208. and item.revision == pointer.revision
  209. ):
  210. return item
  211. raise ValueError(
  212. f"Candidate revision not found: {pointer.candidate_id}@{pointer.revision}"
  213. )
  214. def current_state(self, pointer: CandidatePointer) -> str | None:
  215. state = None
  216. for item in self.lifecycle:
  217. if (
  218. item.candidate.candidate_id == pointer.candidate_id
  219. and item.candidate.revision == pointer.revision
  220. ):
  221. state = item.state
  222. return state