runtime.py 16 KB

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