models.py 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244
  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 RoleRunLimits(ApplicationModel):
  69. max_iterations: int | None = Field(default=None, gt=0)
  70. max_depth: int | None = Field(default=None, ge=0)
  71. max_children_per_parent: int | None = Field(default=None, ge=0)
  72. max_parallel_children: int | None = Field(default=None, gt=0)
  73. class RunLimits(ApplicationModel):
  74. max_iterations: int = Field(default=200, gt=0)
  75. max_depth: int = Field(default=10, ge=0)
  76. max_children_per_parent: int = Field(default=20, ge=0)
  77. max_parallel_children: int = Field(default=2, gt=0)
  78. max_total_agents: int = Field(default=100, gt=0)
  79. max_llm_calls: int = Field(default=1_000, gt=1)
  80. max_total_tokens: int = Field(default=10_000_000, gt=0)
  81. max_total_cost_usd: float = Field(default=100.0, gt=0)
  82. max_duration_seconds: int = Field(default=86_400, gt=0)
  83. max_validation_tool_calls: int = Field(default=100, gt=0)
  84. max_validation_material_chars: int = Field(default=1_000_000, gt=0)
  85. def tighten(self, role: RoleRunLimits | None) -> "RunLimits":
  86. if role is None:
  87. return self
  88. updates = {
  89. name: min(getattr(self, name), value)
  90. for name, value in role.model_dump().items()
  91. if value is not None
  92. }
  93. return self.model_copy(update=updates)
  94. class AgentRole(ApplicationModel):
  95. role_id: str = Field(min_length=1, max_length=200)
  96. description: str = Field(default="", max_length=2_000)
  97. model: str = Field(min_length=1, max_length=200)
  98. temperature: float = Field(default=0.3, ge=0, le=2)
  99. model_parameters: dict[str, Any] = Field(default_factory=dict)
  100. system_prompt: str = Field(min_length=1, max_length=200_000)
  101. skills: tuple[SkillSpec, ...] = ()
  102. tool_sets: tuple[str, ...] = ()
  103. allowed_child_roles: tuple[str, ...] = ()
  104. default_validation_scopes: tuple[ValidationScopeName, ...] = ()
  105. run_limits: RoleRunLimits | None = None
  106. @field_validator("tool_sets", "allowed_child_roles")
  107. @classmethod
  108. def validate_unique_refs(cls, values: tuple[str, ...]) -> tuple[str, ...]:
  109. if len(values) != len(set(values)):
  110. raise ValueError("role references must be unique")
  111. return values
  112. @model_validator(mode="after")
  113. def validate_model_parameters(self) -> "AgentRole":
  114. canonical_json(self.model_parameters)
  115. return self
  116. class AgentApplication(ApplicationModel):
  117. schema_version: Literal[1] = 1
  118. application_id: str = Field(min_length=1, max_length=200)
  119. application_version: str = Field(min_length=1, max_length=100)
  120. root_role: str = Field(min_length=1, max_length=200)
  121. roles: tuple[AgentRole, ...] = Field(min_length=1)
  122. tool_sets: tuple[ToolSet, ...] = ()
  123. context_provider_ref: ProviderRef | None = None
  124. artifact_resolver_ref: ProviderRef | None = None
  125. candidate_repository_ref: ProviderRef | None = None
  126. quality_provider_ref: ProviderRef | None = None
  127. event_projector_ref: ProviderRef | None = None
  128. validation_policy: dict[str, Any] = Field(default_factory=dict)
  129. run_limits: RunLimits = Field(default_factory=RunLimits)
  130. context_inheritance_policy: Literal[
  131. "explicit_only",
  132. "allow_inheritable",
  133. ] = "explicit_only"
  134. @model_validator(mode="after")
  135. def validate_graph(self) -> "AgentApplication":
  136. role_ids = [item.role_id for item in self.roles]
  137. if len(role_ids) != len(set(role_ids)):
  138. raise ValueError("AgentApplication role_id values must be unique")
  139. if self.root_role not in set(role_ids):
  140. raise ValueError("root_role must reference an existing role")
  141. tool_set_ids = [item.tool_set_id for item in self.tool_sets]
  142. if len(tool_set_ids) != len(set(tool_set_ids)):
  143. raise ValueError("AgentApplication tool_set_id values must be unique")
  144. known_roles = set(role_ids)
  145. known_tool_sets = set(tool_set_ids)
  146. for role in self.roles:
  147. missing_roles = set(role.allowed_child_roles) - known_roles
  148. if missing_roles:
  149. raise ValueError(
  150. f"role {role.role_id} references unknown child roles: "
  151. f"{sorted(missing_roles)}"
  152. )
  153. missing_sets = set(role.tool_sets) - known_tool_sets
  154. if missing_sets:
  155. raise ValueError(
  156. f"role {role.role_id} references unknown tool sets: "
  157. f"{sorted(missing_sets)}"
  158. )
  159. canonical_json(self.validation_policy)
  160. if self.candidate_repository_ref and not self.artifact_resolver_ref:
  161. raise ValueError(
  162. "candidate_repository_ref requires artifact_resolver_ref"
  163. )
  164. return self
  165. def role(self, role_id: str) -> AgentRole:
  166. return next(item for item in self.roles if item.role_id == role_id)
  167. def tool_set(self, tool_set_id: str) -> ToolSet:
  168. return next(item for item in self.tool_sets if item.tool_set_id == tool_set_id)
  169. def role_manifest(
  170. application: AgentApplication,
  171. role: AgentRole,
  172. tool_definitions: dict[str, dict[str, Any]],
  173. ) -> dict[str, Any]:
  174. selected_specs = [
  175. spec
  176. for tool_set_id in role.tool_sets
  177. for spec in application.tool_set(tool_set_id).tools
  178. ]
  179. return {
  180. "role": role.model_dump(mode="json"),
  181. "prompt_hash": stable_hash(role.system_prompt),
  182. "skill_hashes": [item.content_hash for item in role.skills],
  183. "tools": [
  184. {
  185. "declaration": spec.model_dump(mode="json"),
  186. "registry": tool_definitions[spec.tool_name],
  187. }
  188. for spec in selected_specs
  189. ],
  190. }
  191. def application_manifest(
  192. application: AgentApplication,
  193. tool_definitions: list[dict[str, Any]],
  194. ) -> dict[str, Any]:
  195. by_name = {item["tool_name"]: item for item in tool_definitions}
  196. return {
  197. "application": application.model_dump(mode="json"),
  198. "roles": [
  199. role_manifest(application, role, by_name)
  200. for role in application.roles
  201. ],
  202. }