models.py 9.9 KB

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