models.py 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  1. """Serializable application declarations and their stable identity hashes."""
  2. from __future__ import annotations
  3. from hashlib import sha256
  4. import json
  5. from typing import Any, Literal
  6. from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
  7. ValidationScopeName = Literal["evidence", "hypothesis", "output", "task"]
  8. class ApplicationModel(BaseModel):
  9. model_config = ConfigDict(
  10. extra="forbid",
  11. frozen=True,
  12. str_strip_whitespace=True,
  13. )
  14. def canonical_json(value: Any) -> str:
  15. """Return the only JSON representation used by application hashes."""
  16. if isinstance(value, BaseModel):
  17. value = value.model_dump(mode="json")
  18. try:
  19. return json.dumps(
  20. value,
  21. ensure_ascii=False,
  22. sort_keys=True,
  23. separators=(",", ":"),
  24. allow_nan=False,
  25. )
  26. except (TypeError, ValueError) as exc:
  27. raise ValueError(f"application declaration must be JSON serializable: {exc}") from exc
  28. def stable_hash(value: Any) -> str:
  29. return sha256(canonical_json(value).encode("utf-8")).hexdigest()
  30. class ApplicationRef(ApplicationModel):
  31. schema_version: Literal[1] = 1
  32. application_id: str = Field(min_length=1, max_length=200)
  33. application_version: str = Field(min_length=1, max_length=100)
  34. config_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
  35. class ProviderRef(ApplicationModel):
  36. provider_id: str = Field(min_length=1, max_length=200)
  37. provider_version: str = Field(min_length=1, max_length=100)
  38. public_config: dict[str, Any] = Field(default_factory=dict)
  39. @model_validator(mode="after")
  40. def validate_public_config(self) -> "ProviderRef":
  41. canonical_json(self.public_config)
  42. return self
  43. class SkillSpec(ApplicationModel):
  44. name: str = Field(min_length=1, max_length=200)
  45. version: str = Field(min_length=1, max_length=100)
  46. content: str = Field(min_length=1, max_length=100_000)
  47. @property
  48. def content_hash(self) -> str:
  49. return stable_hash({
  50. "name": self.name,
  51. "version": self.version,
  52. "content": self.content,
  53. })
  54. class ToolSpec(ApplicationModel):
  55. tool_name: str = Field(min_length=1, max_length=200)
  56. implementation_version: str = Field(min_length=1, max_length=100)
  57. side_effect_class: Literal["none", "read", "write", "external"] = "none"
  58. idempotent: bool = True
  59. class ToolSet(ApplicationModel):
  60. tool_set_id: str = Field(min_length=1, max_length=200)
  61. tools: tuple[ToolSpec, ...] = ()
  62. @model_validator(mode="after")
  63. def validate_unique_tools(self) -> "ToolSet":
  64. names = [item.tool_name for item in self.tools]
  65. if len(names) != len(set(names)):
  66. raise ValueError(f"ToolSet {self.tool_set_id} contains duplicate tools")
  67. return self
  68. class QualityRuleSpec(ApplicationModel):
  69. """A deterministic rule frozen into an application version."""
  70. rule_id: str = Field(pattern=r"^[a-z][a-z0-9_.-]{2,100}$")
  71. version: str = Field(min_length=1, max_length=100)
  72. scope: ValidationScopeName = "output"
  73. subject_types: tuple[Literal["trace", "candidate"], ...] = ("candidate",)
  74. criterion: str = Field(min_length=1, max_length=2_000)
  75. config: dict[str, Any] = Field(default_factory=dict)
  76. timeout_seconds: float = Field(default=10.0, gt=0, le=120)
  77. @model_validator(mode="after")
  78. def validate_config(self) -> "QualityRuleSpec":
  79. canonical_json(self.config)
  80. if not self.subject_types or len(self.subject_types) != len(
  81. set(self.subject_types)
  82. ):
  83. raise ValueError("subject_types must be non-empty and unique")
  84. return self
  85. class RoleRunLimits(ApplicationModel):
  86. max_iterations: int | None = Field(default=None, gt=0)
  87. max_depth: int | None = Field(default=None, ge=0)
  88. max_children_per_parent: int | None = Field(default=None, ge=0)
  89. max_parallel_children: int | None = Field(default=None, gt=0)
  90. class RunLimits(ApplicationModel):
  91. max_iterations: int = Field(default=200, gt=0)
  92. max_depth: int = Field(default=10, ge=0)
  93. max_children_per_parent: int = Field(default=20, ge=0)
  94. max_parallel_children: int = Field(default=2, gt=0)
  95. max_total_agents: int = Field(default=100, gt=0)
  96. max_llm_calls: int = Field(default=1_000, gt=1)
  97. max_total_tokens: int = Field(default=10_000_000, gt=0)
  98. max_total_cost_usd: float = Field(default=100.0, gt=0)
  99. max_duration_seconds: int = Field(default=86_400, gt=0)
  100. max_validation_tool_calls: int = Field(default=100, gt=0)
  101. max_validation_material_chars: int = Field(default=1_000_000, gt=0)
  102. def tighten(self, role: RoleRunLimits | None) -> "RunLimits":
  103. if role is None:
  104. return self
  105. updates = {
  106. name: min(getattr(self, name), value)
  107. for name, value in role.model_dump().items()
  108. if value is not None
  109. }
  110. return self.model_copy(update=updates)
  111. class AgentRole(ApplicationModel):
  112. role_id: str = Field(min_length=1, max_length=200)
  113. description: str = Field(default="", max_length=2_000)
  114. model: str = Field(min_length=1, max_length=200)
  115. temperature: float = Field(default=0.3, ge=0, le=2)
  116. model_parameters: dict[str, Any] = Field(default_factory=dict)
  117. system_prompt: str = Field(min_length=1, max_length=200_000)
  118. skills: tuple[SkillSpec, ...] = ()
  119. tool_sets: tuple[str, ...] = ()
  120. allowed_child_roles: tuple[str, ...] = ()
  121. default_validation_scopes: tuple[ValidationScopeName, ...] = ()
  122. run_limits: RoleRunLimits | None = None
  123. @field_validator("tool_sets", "allowed_child_roles")
  124. @classmethod
  125. def validate_unique_refs(cls, values: tuple[str, ...]) -> tuple[str, ...]:
  126. if len(values) != len(set(values)):
  127. raise ValueError("role references must be unique")
  128. return values
  129. @model_validator(mode="after")
  130. def validate_model_parameters(self) -> "AgentRole":
  131. canonical_json(self.model_parameters)
  132. return self
  133. class AgentApplication(ApplicationModel):
  134. schema_version: Literal[1] = 1
  135. application_id: str = Field(min_length=1, max_length=200)
  136. application_version: str = Field(min_length=1, max_length=100)
  137. root_role: str = Field(min_length=1, max_length=200)
  138. roles: tuple[AgentRole, ...] = Field(min_length=1)
  139. tool_sets: tuple[ToolSet, ...] = ()
  140. context_provider_ref: ProviderRef | None = None
  141. artifact_resolver_ref: ProviderRef | None = None
  142. candidate_repository_ref: ProviderRef | None = None
  143. quality_provider_ref: ProviderRef | None = None
  144. quality_rules: tuple[QualityRuleSpec, ...] = ()
  145. event_projector_ref: ProviderRef | None = None
  146. validation_policy: dict[str, Any] = Field(default_factory=dict)
  147. run_limits: RunLimits = Field(default_factory=RunLimits)
  148. context_inheritance_policy: Literal[
  149. "explicit_only",
  150. "allow_inheritable",
  151. ] = "explicit_only"
  152. @model_validator(mode="after")
  153. def validate_graph(self) -> "AgentApplication":
  154. role_ids = [item.role_id for item in self.roles]
  155. if len(role_ids) != len(set(role_ids)):
  156. raise ValueError("AgentApplication role_id values must be unique")
  157. if self.root_role not in set(role_ids):
  158. raise ValueError("root_role must reference an existing role")
  159. tool_set_ids = [item.tool_set_id for item in self.tool_sets]
  160. if len(tool_set_ids) != len(set(tool_set_ids)):
  161. raise ValueError("AgentApplication tool_set_id values must be unique")
  162. known_roles = set(role_ids)
  163. known_tool_sets = set(tool_set_ids)
  164. for role in self.roles:
  165. missing_roles = set(role.allowed_child_roles) - known_roles
  166. if missing_roles:
  167. raise ValueError(
  168. f"role {role.role_id} references unknown child roles: "
  169. f"{sorted(missing_roles)}"
  170. )
  171. missing_sets = set(role.tool_sets) - known_tool_sets
  172. if missing_sets:
  173. raise ValueError(
  174. f"role {role.role_id} references unknown tool sets: "
  175. f"{sorted(missing_sets)}"
  176. )
  177. canonical_json(self.validation_policy)
  178. if self.candidate_repository_ref and not self.artifact_resolver_ref:
  179. raise ValueError(
  180. "candidate_repository_ref requires artifact_resolver_ref"
  181. )
  182. rule_ids = [item.rule_id for item in self.quality_rules]
  183. if len(rule_ids) != len(set(rule_ids)):
  184. raise ValueError("quality_rules must contain unique rule_id values")
  185. if self.quality_rules and not self.quality_provider_ref:
  186. raise ValueError("quality_rules require quality_provider_ref")
  187. return self
  188. def role(self, role_id: str) -> AgentRole:
  189. return next(item for item in self.roles if item.role_id == role_id)
  190. def tool_set(self, tool_set_id: str) -> ToolSet:
  191. return next(item for item in self.tool_sets if item.tool_set_id == tool_set_id)
  192. def role_manifest(
  193. application: AgentApplication,
  194. role: AgentRole,
  195. tool_definitions: dict[str, dict[str, Any]],
  196. ) -> dict[str, Any]:
  197. selected_specs = [
  198. spec
  199. for tool_set_id in role.tool_sets
  200. for spec in application.tool_set(tool_set_id).tools
  201. ]
  202. return {
  203. "role": role.model_dump(mode="json"),
  204. "prompt_hash": stable_hash(role.system_prompt),
  205. "skill_hashes": [item.content_hash for item in role.skills],
  206. "tools": [
  207. {
  208. "declaration": spec.model_dump(mode="json"),
  209. "registry": tool_definitions[spec.tool_name],
  210. }
  211. for spec in selected_specs
  212. ],
  213. }
  214. def application_manifest(
  215. application: AgentApplication,
  216. tool_definitions: list[dict[str, Any]],
  217. ) -> dict[str, Any]:
  218. by_name = {item["tool_name"]: item for item in tool_definitions}
  219. return {
  220. "application": application.model_dump(mode="json"),
  221. "roles": [
  222. role_manifest(application, role, by_name)
  223. for role in application.roles
  224. ],
  225. }