runtime.py 16 KB

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