runtime.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410
  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. from cyber_agent.application.events import RunEventService
  212. event_service = RunEventService(
  213. store=self.trace_store,
  214. application_ref=binding.application_ref,
  215. projector=binding.services.event_projector,
  216. )
  217. candidate_service = None
  218. if binding.services.candidate_repository is not None:
  219. from cyber_agent.application.candidate_service import CandidateService
  220. if binding.services.artifact_resolver is None:
  221. raise ValueError(
  222. "CandidateRepository requires an ArtifactResolver"
  223. )
  224. candidate_service = CandidateService(
  225. store=self.trace_store,
  226. application_binding=binding,
  227. repository=binding.services.candidate_repository,
  228. artifact_resolver=binding.services.artifact_resolver,
  229. event_service=event_service,
  230. )
  231. return AgentRunner(
  232. trace_store=self.trace_store,
  233. tool_registry=binding.tool_registry,
  234. llm_call=self.llm_call,
  235. utility_llm_call=self.utility_llm_call,
  236. validation_policy=binding.validation_policy,
  237. artifact_resolver=binding.services.artifact_resolver,
  238. application_binding=binding,
  239. context_provider=binding.services.context_provider,
  240. candidate_service=candidate_service,
  241. event_service=event_service,
  242. )
  243. async def reconcile_events(self, root_trace_id: str) -> int:
  244. trace = await self.trace_store.get_trace(root_trace_id)
  245. if trace is None:
  246. raise ValueError(f"Trace not found: {root_trace_id}")
  247. binding = self.registry.resolve_ref(trace.context.get("application_ref"))
  248. from cyber_agent.application.events import RunEventService
  249. return await RunEventService(
  250. store=self.trace_store,
  251. application_ref=binding.application_ref,
  252. projector=binding.services.event_projector,
  253. ).reconcile_root(root_trace_id)
  254. async def reconcile_all_events(self) -> int:
  255. roots = {
  256. item.context.get("root_trace_id") or item.trace_id
  257. for item in await self.trace_store.list_traces(limit=10_000)
  258. if item.context.get("application_ref") is not None
  259. }
  260. total = 0
  261. for root_trace_id in sorted(roots):
  262. total += await self.reconcile_events(root_trace_id)
  263. return total
  264. def new_run(
  265. self,
  266. application_id: str,
  267. application_version: str,
  268. *,
  269. uid: str | None = None,
  270. name: str | None = None,
  271. root_task_anchor: Any = None,
  272. max_iterations: int | None = None,
  273. ):
  274. from cyber_agent.core.runner import RunConfig
  275. binding = self.registry.resolve(application_id, application_version)
  276. self._validate_runtime_services(binding)
  277. role = binding.role(binding.application.root_role)
  278. if max_iterations is not None and max_iterations > role.effective_limits.max_iterations:
  279. raise ValueError(
  280. "max_iterations may only tighten the application run limit"
  281. )
  282. config = RunConfig(
  283. uid=uid,
  284. name=name,
  285. root_task_anchor=root_task_anchor,
  286. enable_research_flow=False,
  287. )
  288. binding.configure_run_config(config, role.role.role_id)
  289. effective = self._compile_deployment_limits(role.effective_limits)
  290. config.effective_run_limits = effective.model_dump(mode="json")
  291. config.max_iterations = effective.max_iterations
  292. config.max_parallel_children = effective.max_parallel_children
  293. if max_iterations is not None:
  294. config.max_iterations = max_iterations
  295. config.effective_run_limits = {
  296. **config.effective_run_limits,
  297. "max_iterations": max_iterations,
  298. }
  299. return self.build_runner(binding), config
  300. @staticmethod
  301. def _compile_deployment_limits(limits: RunLimits) -> RunLimits:
  302. from cyber_agent.core.resource_budget import ResourceBudget
  303. deployment = ResourceBudget.from_environment()
  304. return limits.model_copy(update={
  305. "max_total_agents": min(
  306. limits.max_total_agents,
  307. deployment.max_total_agents,
  308. ),
  309. "max_llm_calls": min(
  310. limits.max_llm_calls,
  311. deployment.max_llm_calls,
  312. ),
  313. "max_total_tokens": min(
  314. limits.max_total_tokens,
  315. deployment.max_total_tokens,
  316. ),
  317. "max_total_cost_usd": min(
  318. limits.max_total_cost_usd,
  319. deployment.max_total_cost_usd,
  320. ),
  321. "max_duration_seconds": min(
  322. limits.max_duration_seconds,
  323. deployment.max_duration_seconds,
  324. ),
  325. "max_validation_tool_calls": min(
  326. limits.max_validation_tool_calls,
  327. deployment.max_validation_tool_calls,
  328. ),
  329. "max_validation_material_chars": min(
  330. limits.max_validation_material_chars,
  331. deployment.max_validation_material_chars,
  332. ),
  333. })
  334. async def restore(self, trace_id: str):
  335. from cyber_agent.core.run_snapshot import (
  336. RunConfigSnapshotV2,
  337. load_run_config_snapshot,
  338. )
  339. from cyber_agent.core.runner import RunConfig
  340. trace = await self.trace_store.get_trace(trace_id)
  341. if trace is None:
  342. raise ValueError(f"Trace not found: {trace_id}")
  343. snapshot = load_run_config_snapshot(trace.context)
  344. if not isinstance(snapshot, RunConfigSnapshotV2):
  345. raise ValueError("RunConfigSnapshotV1 is not an application run")
  346. binding = self.registry.resolve_ref(snapshot.application_ref)
  347. self._validate_runtime_services(binding)
  348. role = binding.role(snapshot.role_id)
  349. if role.role_hash != snapshot.role_hash:
  350. raise ValueError("Application role hash does not match the registry")
  351. config = RunConfig(trace_id=trace_id)
  352. binding.configure_run_config(config, snapshot.role_id)
  353. config.apply_snapshot(snapshot)
  354. config.system_prompt = role.system_prompt
  355. config.skills = []
  356. runner = self.build_runner(binding)
  357. if snapshot.uid != trace.uid or snapshot.agent_type != (
  358. trace.agent_type or "default"
  359. ):
  360. raise ValueError(
  361. "Application snapshot identity does not match Trace metadata"
  362. )
  363. runner._validate_application_snapshot(snapshot, trace)
  364. await self.reconcile_events(
  365. trace.context.get("root_trace_id") or trace.trace_id
  366. )
  367. return runner, config
  368. @staticmethod
  369. def _validate_runtime_services(binding: ApplicationBinding) -> None:
  370. validator = binding.services.runtime_validator
  371. if validator is not None:
  372. validator()