run_snapshot.py 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  1. """Versioned, hash-bound snapshots for reproducible Agent runs."""
  2. from __future__ import annotations
  3. import json
  4. from dataclasses import asdict, is_dataclass
  5. from hashlib import sha256
  6. from typing import Any, Literal
  7. from pydantic import BaseModel, ConfigDict, Field
  8. RUN_CONFIG_SNAPSHOT_CONTEXT_KEY = "run_config_snapshot"
  9. RUN_CONFIG_SNAPSHOT_HASH_CONTEXT_KEY = "run_config_snapshot_hash"
  10. class RunConfigSnapshotError(ValueError):
  11. """A persisted run snapshot is absent, malformed, or was modified."""
  12. class RunConfigSnapshotV1(BaseModel):
  13. """Persisted behavior-affecting fields from ``RunConfig``.
  14. Runtime routing fields (trace_id, parent IDs, rewind position and forced side
  15. branches) are intentionally excluded because they describe one invocation,
  16. not the immutable behavior of the run.
  17. """
  18. model_config = ConfigDict(extra="forbid")
  19. schema_version: Literal[1] = 1
  20. model: str
  21. temperature: float
  22. max_iterations: int = Field(gt=0)
  23. extra_llm_params: dict[str, Any]
  24. tools: list[str] | None
  25. tool_groups: list[str] | None
  26. exclude_tools: list[str]
  27. auto_execute_tools: bool
  28. agent_type: str
  29. uid: str | None
  30. skills: list[str] | None
  31. system_prompt_hash: str | None = Field(
  32. default=None,
  33. pattern=r"^[0-9a-f]{64}$",
  34. )
  35. enable_memory: bool
  36. memory: dict[str, Any] | None
  37. memory_identity: str | None = Field(
  38. default=None,
  39. pattern=r"^memory-v1:[0-9a-f]{64}$",
  40. )
  41. knowledge: dict[str, Any]
  42. parallel_tool_execution: bool
  43. child_execution_mode: Literal["sequential", "parallel"]
  44. max_parallel_children: int = Field(gt=0)
  45. side_branch_max_turns: int = Field(gt=0)
  46. goal_compression: Literal["none", "on_complete", "on_overflow"]
  47. enable_prompt_caching: bool
  48. enable_research_flow: bool
  49. project_name: str | None = None
  50. custom_context: dict[str, Any]
  51. legacy_inferred: bool = False
  52. @classmethod
  53. def from_run_config(
  54. cls,
  55. config: Any,
  56. *,
  57. memory_identity: str | None,
  58. system_prompt_hash: str | None = None,
  59. legacy_inferred: bool = False,
  60. ) -> "RunConfigSnapshotV1":
  61. def dump(value: Any) -> Any:
  62. if value is None:
  63. return None
  64. if is_dataclass(value):
  65. return asdict(value)
  66. if isinstance(value, BaseModel):
  67. return value.model_dump(mode="json")
  68. if isinstance(value, dict):
  69. return dict(value)
  70. raise TypeError(f"run snapshot field is not serializable: {type(value)!r}")
  71. context = config.context or {}
  72. return cls(
  73. model=config.model,
  74. temperature=config.temperature,
  75. max_iterations=config.max_iterations,
  76. extra_llm_params=dict(config.extra_llm_params),
  77. tools=list(config.tools) if config.tools is not None else None,
  78. tool_groups=(
  79. list(config.tool_groups) if config.tool_groups is not None else None
  80. ),
  81. exclude_tools=list(config.exclude_tools),
  82. auto_execute_tools=config.auto_execute_tools,
  83. agent_type=config.agent_type,
  84. uid=config.uid,
  85. skills=list(config.skills) if config.skills is not None else None,
  86. system_prompt_hash=system_prompt_hash,
  87. enable_memory=config.enable_memory,
  88. memory=dump(config.memory),
  89. memory_identity=memory_identity,
  90. knowledge=dump(config.knowledge),
  91. parallel_tool_execution=config.parallel_tool_execution,
  92. child_execution_mode=config.child_execution_mode,
  93. max_parallel_children=config.max_parallel_children,
  94. side_branch_max_turns=config.side_branch_max_turns,
  95. goal_compression=config.goal_compression,
  96. enable_prompt_caching=config.enable_prompt_caching,
  97. enable_research_flow=config.enable_research_flow,
  98. project_name=context.get("project_name"),
  99. custom_context=dict(context),
  100. legacy_inferred=legacy_inferred,
  101. )
  102. @property
  103. def snapshot_hash(self) -> str:
  104. payload = json.dumps(
  105. self.model_dump(mode="json"),
  106. ensure_ascii=False,
  107. sort_keys=True,
  108. separators=(",", ":"),
  109. allow_nan=False,
  110. )
  111. return sha256(payload.encode("utf-8")).hexdigest()
  112. class RunConfigSnapshotV2(RunConfigSnapshotV1):
  113. """Application-bound snapshot; V1's serialized shape remains unchanged."""
  114. schema_version: Literal[2] = 2
  115. application_ref: dict[str, Any]
  116. role_id: str = Field(min_length=1)
  117. role_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
  118. effective_run_limits: dict[str, Any]
  119. @classmethod
  120. def from_run_config(
  121. cls,
  122. config: Any,
  123. *,
  124. memory_identity: str | None,
  125. system_prompt_hash: str | None = None,
  126. legacy_inferred: bool = False,
  127. ) -> "RunConfigSnapshotV2":
  128. base = RunConfigSnapshotV1.from_run_config(
  129. config,
  130. memory_identity=memory_identity,
  131. system_prompt_hash=system_prompt_hash,
  132. legacy_inferred=legacy_inferred,
  133. ).model_dump(mode="json")
  134. base.pop("schema_version", None)
  135. application_ref = config.application_ref
  136. if isinstance(application_ref, BaseModel):
  137. application_ref = application_ref.model_dump(mode="json")
  138. if not isinstance(application_ref, dict):
  139. raise TypeError("application_ref is required for a V2 run snapshot")
  140. if not config.role_id or not config.role_hash:
  141. raise TypeError("role_id and role_hash are required for a V2 run snapshot")
  142. return cls(
  143. **base,
  144. application_ref=dict(application_ref),
  145. role_id=config.role_id,
  146. role_hash=config.role_hash,
  147. effective_run_limits=dict(config.effective_run_limits),
  148. )
  149. RunConfigSnapshot = RunConfigSnapshotV1 | RunConfigSnapshotV2
  150. def persist_run_config_snapshot(
  151. context: dict[str, Any],
  152. snapshot: RunConfigSnapshot,
  153. ) -> None:
  154. context[RUN_CONFIG_SNAPSHOT_CONTEXT_KEY] = snapshot.model_dump(mode="json")
  155. context[RUN_CONFIG_SNAPSHOT_HASH_CONTEXT_KEY] = snapshot.snapshot_hash
  156. def load_run_config_snapshot(context: dict[str, Any]) -> RunConfigSnapshot:
  157. raw = context.get(RUN_CONFIG_SNAPSHOT_CONTEXT_KEY)
  158. digest = context.get(RUN_CONFIG_SNAPSHOT_HASH_CONTEXT_KEY)
  159. if raw is None or digest is None:
  160. raise RunConfigSnapshotError("run config snapshot is missing")
  161. try:
  162. schema_version = raw.get("schema_version") if isinstance(raw, dict) else None
  163. if schema_version == 1:
  164. snapshot = RunConfigSnapshotV1.model_validate(raw)
  165. elif schema_version == 2:
  166. snapshot = RunConfigSnapshotV2.model_validate(raw)
  167. else:
  168. raise ValueError(f"unsupported schema_version: {schema_version!r}")
  169. except Exception as exc:
  170. raise RunConfigSnapshotError(f"invalid run config snapshot: {exc}") from exc
  171. if snapshot.snapshot_hash != digest:
  172. raise RunConfigSnapshotError("run config snapshot hash mismatch")
  173. return snapshot