runtime.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. """Application registration, immutable binding, and AgentRunner assembly."""
  2. from __future__ import annotations
  3. from dataclasses import dataclass
  4. from types import MappingProxyType
  5. from typing import Any, Callable, Mapping
  6. from cyber_agent.application.models import (
  7. AgentApplication,
  8. AgentRole,
  9. ApplicationRef,
  10. RunLimits,
  11. application_manifest,
  12. role_manifest,
  13. stable_hash,
  14. )
  15. from cyber_agent.application.ports import TaskContextProvider
  16. from cyber_agent.core.artifacts import ArtifactResolver
  17. from cyber_agent.core.validation import ValidationPolicy
  18. from cyber_agent.tools.registry import ToolRegistry
  19. @dataclass(frozen=True)
  20. class ApplicationServices:
  21. """Non-serializable providers and private tool implementations."""
  22. tool_registry: ToolRegistry
  23. context_provider: TaskContextProvider | None = None
  24. artifact_resolver: ArtifactResolver | None = None
  25. candidate_repository: Any | None = None
  26. quality_provider: Any | None = None
  27. event_projector: Any | None = None
  28. runtime_validator: Callable[[], None] | None = None
  29. @dataclass(frozen=True)
  30. class RoleBinding:
  31. role: AgentRole
  32. role_hash: str
  33. tool_names: tuple[str, ...]
  34. effective_limits: RunLimits
  35. system_prompt: str
  36. @dataclass(frozen=True)
  37. class ApplicationBinding:
  38. application: AgentApplication
  39. application_ref: ApplicationRef
  40. roles: Mapping[str, RoleBinding]
  41. tool_registry: ToolRegistry
  42. services: ApplicationServices
  43. validation_policy: ValidationPolicy
  44. def role(self, role_id: str) -> RoleBinding:
  45. try:
  46. return self.roles[role_id]
  47. except KeyError as exc:
  48. raise ValueError(f"Unknown application role: {role_id}") from exc
  49. def configure_run_config(self, config: Any, role_id: str) -> RoleBinding:
  50. """Apply an already-bound role to an existing RunConfig instance."""
  51. binding = self.role(role_id)
  52. role = binding.role
  53. config.model = role.model
  54. config.temperature = role.temperature
  55. config.max_iterations = binding.effective_limits.max_iterations
  56. config.extra_llm_params = dict(role.model_parameters)
  57. config.tools = list(binding.tool_names)
  58. config.tool_groups = []
  59. config.exclude_tools = []
  60. config.agent_type = role.role_id
  61. config.system_prompt = binding.system_prompt
  62. config.skills = []
  63. config.max_parallel_children = binding.effective_limits.max_parallel_children
  64. config.application_ref = self.application_ref
  65. config.role_id = role.role_id
  66. config.role_hash = binding.role_hash
  67. config.effective_run_limits = binding.effective_limits.model_dump(mode="json")
  68. return binding
  69. class ApplicationRegistry:
  70. """Explicit, in-memory registry keyed by an exact application version."""
  71. def __init__(self) -> None:
  72. self._bindings: dict[tuple[str, str], ApplicationBinding] = {}
  73. def register(
  74. self,
  75. application: AgentApplication,
  76. services: ApplicationServices,
  77. ) -> ApplicationBinding:
  78. key = (application.application_id, application.application_version)
  79. if key in self._bindings:
  80. raise ValueError(
  81. f"Application is already registered: {application.application_id}@"
  82. f"{application.application_version}"
  83. )
  84. self._validate_services(application, services)
  85. selected_specs = [
  86. spec
  87. for tool_set in application.tool_sets
  88. for spec in tool_set.tools
  89. ]
  90. tool_names = [item.tool_name for item in selected_specs]
  91. if len(tool_names) != len(set(tool_names)):
  92. by_name: dict[str, Any] = {}
  93. for spec in selected_specs:
  94. prior = by_name.setdefault(spec.tool_name, spec)
  95. if prior != spec:
  96. raise ValueError(
  97. f"Conflicting declarations for tool: {spec.tool_name}"
  98. )
  99. definitions = services.tool_registry.export_definitions(
  100. sorted(set(tool_names))
  101. )
  102. private_registry = services.tool_registry.clone(
  103. sorted(set(tool_names)),
  104. freeze=True,
  105. )
  106. manifest = application_manifest(application, definitions)
  107. application_ref = ApplicationRef(
  108. application_id=application.application_id,
  109. application_version=application.application_version,
  110. config_hash=stable_hash(manifest),
  111. )
  112. definition_map = {item["tool_name"]: item for item in definitions}
  113. role_bindings: dict[str, RoleBinding] = {}
  114. for role in application.roles:
  115. role_tools = tuple(dict.fromkeys(
  116. spec.tool_name
  117. for tool_set_id in role.tool_sets
  118. for spec in application.tool_set(tool_set_id).tools
  119. ))
  120. role_limits = application.run_limits.tighten(role.run_limits)
  121. role_bindings[role.role_id] = RoleBinding(
  122. role=role,
  123. role_hash=stable_hash(
  124. role_manifest(application, role, definition_map)
  125. ),
  126. tool_names=role_tools,
  127. effective_limits=role_limits,
  128. system_prompt=self._compose_system_prompt(role),
  129. )
  130. validation_policy = (
  131. ValidationPolicy.model_validate(application.validation_policy)
  132. if application.validation_policy
  133. else ValidationPolicy()
  134. )
  135. binding = ApplicationBinding(
  136. application=application,
  137. application_ref=application_ref,
  138. roles=MappingProxyType(role_bindings),
  139. tool_registry=private_registry,
  140. services=services,
  141. validation_policy=validation_policy,
  142. )
  143. self._bindings[key] = binding
  144. return binding
  145. def resolve(
  146. self,
  147. application_id: str,
  148. application_version: str,
  149. ) -> ApplicationBinding:
  150. key = (application_id, application_version)
  151. try:
  152. return self._bindings[key]
  153. except KeyError as exc:
  154. raise ValueError(
  155. f"Application is not registered: {application_id}@{application_version}"
  156. ) from exc
  157. def resolve_ref(
  158. self,
  159. application_ref: ApplicationRef | Mapping[str, Any],
  160. ) -> ApplicationBinding:
  161. if not isinstance(application_ref, ApplicationRef):
  162. application_ref = ApplicationRef.model_validate(application_ref)
  163. binding = self.resolve(
  164. application_ref.application_id,
  165. application_ref.application_version,
  166. )
  167. if binding.application_ref != application_ref:
  168. raise ValueError("Application config hash does not match the registry")
  169. return binding
  170. @staticmethod
  171. def _compose_system_prompt(role: AgentRole) -> str:
  172. sections = [role.system_prompt.strip()]
  173. if role.skills:
  174. sections.append("# Application Skills")
  175. sections.extend(
  176. f"## {skill.name}@{skill.version}\n{skill.content.strip()}"
  177. for skill in role.skills
  178. )
  179. return "\n\n".join(sections)
  180. @staticmethod
  181. def _validate_services(
  182. application: AgentApplication,
  183. services: ApplicationServices,
  184. ) -> None:
  185. requirements = (
  186. (application.context_provider_ref, services.context_provider, "context_provider"),
  187. (application.artifact_resolver_ref, services.artifact_resolver, "artifact_resolver"),
  188. (application.candidate_repository_ref, services.candidate_repository, "candidate_repository"),
  189. (application.quality_provider_ref, services.quality_provider, "quality_provider"),
  190. (application.event_projector_ref, services.event_projector, "event_projector"),
  191. )
  192. missing = [name for ref, service, name in requirements if ref and service is None]
  193. if missing:
  194. raise ValueError(f"Application runtime services are missing: {missing}")
  195. class ApplicationRuntime:
  196. """Assemble the existing AgentRunner for new and restored application runs."""
  197. def __init__(
  198. self,
  199. *,
  200. registry: ApplicationRegistry,
  201. trace_store: Any,
  202. llm_call: Callable[..., Any],
  203. utility_llm_call: Callable[..., Any] | None = None,
  204. ) -> None:
  205. self.registry = registry
  206. self.trace_store = trace_store
  207. self.llm_call = llm_call
  208. self.utility_llm_call = utility_llm_call
  209. def build_runner(self, binding: ApplicationBinding):
  210. from cyber_agent.core.runner import AgentRunner
  211. candidate_service = None
  212. if binding.services.candidate_repository is not None:
  213. from cyber_agent.application.candidate_service import CandidateService
  214. if binding.services.artifact_resolver is None:
  215. raise ValueError(
  216. "CandidateRepository requires an ArtifactResolver"
  217. )
  218. candidate_service = CandidateService(
  219. store=self.trace_store,
  220. application_binding=binding,
  221. repository=binding.services.candidate_repository,
  222. artifact_resolver=binding.services.artifact_resolver,
  223. )
  224. return AgentRunner(
  225. trace_store=self.trace_store,
  226. tool_registry=binding.tool_registry,
  227. llm_call=self.llm_call,
  228. utility_llm_call=self.utility_llm_call,
  229. validation_policy=binding.validation_policy,
  230. artifact_resolver=binding.services.artifact_resolver,
  231. application_binding=binding,
  232. context_provider=binding.services.context_provider,
  233. candidate_service=candidate_service,
  234. )
  235. def new_run(
  236. self,
  237. application_id: str,
  238. application_version: str,
  239. *,
  240. uid: str | None = None,
  241. name: str | None = None,
  242. root_task_anchor: Any = None,
  243. max_iterations: int | None = None,
  244. ):
  245. from cyber_agent.core.runner import RunConfig
  246. binding = self.registry.resolve(application_id, application_version)
  247. self._validate_runtime_services(binding)
  248. role = binding.role(binding.application.root_role)
  249. if max_iterations is not None and max_iterations > role.effective_limits.max_iterations:
  250. raise ValueError(
  251. "max_iterations may only tighten the application run limit"
  252. )
  253. config = RunConfig(
  254. uid=uid,
  255. name=name,
  256. root_task_anchor=root_task_anchor,
  257. enable_research_flow=False,
  258. )
  259. binding.configure_run_config(config, role.role.role_id)
  260. effective = self._compile_deployment_limits(role.effective_limits)
  261. config.effective_run_limits = effective.model_dump(mode="json")
  262. config.max_iterations = effective.max_iterations
  263. config.max_parallel_children = effective.max_parallel_children
  264. if max_iterations is not None:
  265. config.max_iterations = max_iterations
  266. config.effective_run_limits = {
  267. **config.effective_run_limits,
  268. "max_iterations": max_iterations,
  269. }
  270. return self.build_runner(binding), config
  271. @staticmethod
  272. def _compile_deployment_limits(limits: RunLimits) -> RunLimits:
  273. from cyber_agent.core.resource_budget import ResourceBudget
  274. deployment = ResourceBudget.from_environment()
  275. return limits.model_copy(update={
  276. "max_total_agents": min(
  277. limits.max_total_agents,
  278. deployment.max_total_agents,
  279. ),
  280. "max_llm_calls": min(
  281. limits.max_llm_calls,
  282. deployment.max_llm_calls,
  283. ),
  284. "max_total_tokens": min(
  285. limits.max_total_tokens,
  286. deployment.max_total_tokens,
  287. ),
  288. "max_total_cost_usd": min(
  289. limits.max_total_cost_usd,
  290. deployment.max_total_cost_usd,
  291. ),
  292. "max_duration_seconds": min(
  293. limits.max_duration_seconds,
  294. deployment.max_duration_seconds,
  295. ),
  296. "max_validation_tool_calls": min(
  297. limits.max_validation_tool_calls,
  298. deployment.max_validation_tool_calls,
  299. ),
  300. "max_validation_material_chars": min(
  301. limits.max_validation_material_chars,
  302. deployment.max_validation_material_chars,
  303. ),
  304. })
  305. async def restore(self, trace_id: str):
  306. from cyber_agent.core.run_snapshot import (
  307. RunConfigSnapshotV2,
  308. load_run_config_snapshot,
  309. )
  310. from cyber_agent.core.runner import RunConfig
  311. trace = await self.trace_store.get_trace(trace_id)
  312. if trace is None:
  313. raise ValueError(f"Trace not found: {trace_id}")
  314. snapshot = load_run_config_snapshot(trace.context)
  315. if not isinstance(snapshot, RunConfigSnapshotV2):
  316. raise ValueError("RunConfigSnapshotV1 is not an application run")
  317. binding = self.registry.resolve_ref(snapshot.application_ref)
  318. self._validate_runtime_services(binding)
  319. role = binding.role(snapshot.role_id)
  320. if role.role_hash != snapshot.role_hash:
  321. raise ValueError("Application role hash does not match the registry")
  322. config = RunConfig(trace_id=trace_id)
  323. binding.configure_run_config(config, snapshot.role_id)
  324. config.apply_snapshot(snapshot)
  325. config.system_prompt = role.system_prompt
  326. config.skills = []
  327. runner = self.build_runner(binding)
  328. if snapshot.uid != trace.uid or snapshot.agent_type != (
  329. trace.agent_type or "default"
  330. ):
  331. raise ValueError(
  332. "Application snapshot identity does not match Trace metadata"
  333. )
  334. runner._validate_application_snapshot(snapshot, trace)
  335. return runner, config
  336. @staticmethod
  337. def _validate_runtime_services(binding: ApplicationBinding) -> None:
  338. validator = binding.services.runtime_validator
  339. if validator is not None:
  340. validator()