test_application_runtime.py 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256
  1. import asyncio
  2. import json
  3. import unittest
  4. from pydantic import ValidationError
  5. from cyber_agent.application import (
  6. AgentApplication,
  7. AgentRole,
  8. ApplicationRegistry,
  9. ApplicationServices,
  10. ProviderRef,
  11. RoleRunLimits,
  12. RunLimits,
  13. SkillSpec,
  14. ToolSet,
  15. ToolSpec,
  16. )
  17. from cyber_agent.tools.registry import ToolRegistry
  18. def application(*, prompt="write carefully", provider_version="1", limit=20):
  19. return AgentApplication(
  20. application_id="creative.reference",
  21. application_version="0.1.0",
  22. root_role="writer",
  23. roles=(AgentRole(
  24. role_id="writer",
  25. model="fake-model",
  26. system_prompt=prompt,
  27. skills=(SkillSpec(
  28. name="evidence-writing",
  29. version="1",
  30. content="Use checked evidence.",
  31. ),),
  32. tool_sets=("writing",),
  33. allowed_child_roles=("writer",),
  34. run_limits=RoleRunLimits(max_iterations=limit),
  35. ),),
  36. tool_sets=(ToolSet(
  37. tool_set_id="writing",
  38. tools=(ToolSpec(
  39. tool_name="save",
  40. implementation_version="1",
  41. side_effect_class="write",
  42. idempotent=True,
  43. ),),
  44. ),),
  45. context_provider_ref=ProviderRef(
  46. provider_id="context",
  47. provider_version=provider_version,
  48. ),
  49. run_limits=RunLimits(max_iterations=30),
  50. )
  51. def registry(output):
  52. tools = ToolRegistry()
  53. async def save(value: str):
  54. return {"application": output, "value": value}
  55. tools.register(save, groups=["application"])
  56. return tools
  57. class ApplicationDeclarationTest(unittest.TestCase):
  58. def test_role_graph_and_tool_set_references_fail_closed(self):
  59. with self.assertRaisesRegex(ValidationError, "unknown child roles"):
  60. AgentApplication(
  61. application_id="broken",
  62. application_version="1",
  63. root_role="root",
  64. roles=(AgentRole(
  65. role_id="root",
  66. model="fake",
  67. system_prompt="root",
  68. allowed_child_roles=("missing",),
  69. ),),
  70. )
  71. with self.assertRaisesRegex(ValidationError, "unknown tool sets"):
  72. AgentApplication(
  73. application_id="broken",
  74. application_version="1",
  75. root_role="root",
  76. roles=(AgentRole(
  77. role_id="root",
  78. model="fake",
  79. system_prompt="root",
  80. tool_sets=("missing",),
  81. ),),
  82. )
  83. def test_registry_freezes_private_tool_copy_and_resets_stats(self):
  84. source = registry("A")
  85. source._stats["save"].call_count = 7
  86. binding = ApplicationRegistry().register(
  87. application(),
  88. ApplicationServices(
  89. tool_registry=source,
  90. context_provider=object(),
  91. ),
  92. )
  93. self.assertTrue(binding.tool_registry.frozen)
  94. self.assertEqual(0, binding.tool_registry.get_stats("save")["save"]["call_count"])
  95. with self.assertRaisesRegex(RuntimeError, "frozen"):
  96. binding.tool_registry.register(lambda: None)
  97. self.assertFalse(source.frozen)
  98. def test_same_tool_name_is_isolated_between_application_bindings(self):
  99. app_a = application()
  100. app_b = app_a.model_copy(update={
  101. "application_id": "creative.other",
  102. "roles": (
  103. app_a.roles[0].model_copy(update={"system_prompt": "other prompt"}),
  104. ),
  105. })
  106. binding_a = ApplicationRegistry().register(
  107. app_a,
  108. ApplicationServices(
  109. tool_registry=registry("A"),
  110. context_provider=object(),
  111. ),
  112. )
  113. binding_b = ApplicationRegistry().register(
  114. app_b,
  115. ApplicationServices(
  116. tool_registry=registry("B"),
  117. context_provider=object(),
  118. ),
  119. )
  120. self.assertIsNot(binding_a.tool_registry, binding_b.tool_registry)
  121. self.assertNotEqual(
  122. binding_a.application_ref.config_hash,
  123. binding_b.application_ref.config_hash,
  124. )
  125. self.assertNotEqual(
  126. binding_a.role("writer").role_hash,
  127. binding_b.role("writer").role_hash,
  128. )
  129. def test_hash_covers_prompt_provider_limits_and_tool_schema(self):
  130. base = ApplicationRegistry().register(
  131. application(),
  132. ApplicationServices(
  133. tool_registry=registry("base"),
  134. context_provider=object(),
  135. ),
  136. ).application_ref.config_hash
  137. variants = [
  138. application(prompt="changed prompt"),
  139. application(provider_version="2"),
  140. application(limit=10),
  141. ]
  142. hashes = []
  143. for item in variants:
  144. hashes.append(ApplicationRegistry().register(
  145. item,
  146. ApplicationServices(
  147. tool_registry=registry("base"),
  148. context_provider=object(),
  149. ),
  150. ).application_ref.config_hash)
  151. self.assertTrue(all(item != base for item in hashes))
  152. changed_schema = registry("base")
  153. definition = changed_schema._tools["save"]
  154. definition["schema"] = {
  155. **definition["schema"],
  156. "function": {
  157. **definition["schema"]["function"],
  158. "description": "changed schema",
  159. },
  160. }
  161. schema_hash = ApplicationRegistry().register(
  162. application(),
  163. ApplicationServices(
  164. tool_registry=changed_schema,
  165. context_provider=object(),
  166. ),
  167. ).application_ref.config_hash
  168. self.assertNotEqual(base, schema_hash)
  169. class ApplicationToolIsolationTest(unittest.IsolatedAsyncioTestCase):
  170. async def test_same_named_tools_execute_in_private_registries_concurrently(self):
  171. app_a = application()
  172. app_b = app_a.model_copy(update={"application_id": "creative.other"})
  173. binding_a = ApplicationRegistry().register(
  174. app_a,
  175. ApplicationServices(
  176. tool_registry=registry("A"),
  177. context_provider=object(),
  178. ),
  179. )
  180. binding_b = ApplicationRegistry().register(
  181. app_b,
  182. ApplicationServices(
  183. tool_registry=registry("B"),
  184. context_provider=object(),
  185. ),
  186. )
  187. async def execute(binding, expected, index):
  188. raw = await binding.tool_registry.execute(
  189. "save",
  190. {"value": str(index)},
  191. uid="user",
  192. allowed_tool_names={"save"},
  193. )
  194. payload = json.loads(raw) if isinstance(raw, str) else raw
  195. self.assertEqual(expected, payload["application"])
  196. self.assertEqual(str(index), payload["value"])
  197. await asyncio.gather(*(
  198. execute(binding, expected, index)
  199. for binding, expected in ((binding_a, "A"), (binding_b, "B"))
  200. for index in range(20)
  201. ))
  202. self.assertEqual(
  203. 20,
  204. binding_a.tool_registry.get_stats("save")["save"]["call_count"],
  205. )
  206. self.assertEqual(
  207. 20,
  208. binding_b.tool_registry.get_stats("save")["save"]["call_count"],
  209. )
  210. def test_runtime_service_identity_is_not_part_of_config_hash(self):
  211. first = ApplicationRegistry().register(
  212. application(),
  213. ApplicationServices(
  214. tool_registry=registry("same"),
  215. context_provider=object(),
  216. ),
  217. )
  218. second = ApplicationRegistry().register(
  219. application(),
  220. ApplicationServices(
  221. tool_registry=registry("same"),
  222. context_provider=object(),
  223. ),
  224. )
  225. self.assertEqual(first.application_ref, second.application_ref)
  226. def test_missing_declared_provider_is_rejected(self):
  227. with self.assertRaisesRegex(ValueError, "context_provider"):
  228. ApplicationRegistry().register(
  229. application(),
  230. ApplicationServices(tool_registry=registry("A")),
  231. )
  232. if __name__ == "__main__":
  233. unittest.main()