test_application_events.py 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249
  1. import tempfile
  2. import unittest
  3. from unittest.mock import patch
  4. from cyber_agent.application import (
  5. AgentApplication,
  6. AgentRole,
  7. ApplicationRegistry,
  8. ApplicationRuntime,
  9. ApplicationServices,
  10. ProviderRef,
  11. )
  12. from cyber_agent.application.events import RunEvent
  13. from cyber_agent.core.task_protocol import TaskProgress
  14. from cyber_agent.tools.registry import ToolRegistry
  15. from cyber_agent.trace.store import FileSystemTraceStore
  16. async def unused_llm(**kwargs):
  17. raise AssertionError(f"LLM must not run: {kwargs}")
  18. class TransactionalProjector:
  19. def __init__(self):
  20. self.cursor = 0
  21. self.view = {}
  22. self.calls = []
  23. self.fail_key = None
  24. self.failed_once = False
  25. async def load_cursor(self, application_ref, root_trace_id):
  26. del application_ref, root_trace_id
  27. return self.cursor
  28. async def project(self, event, expected_cursor):
  29. if expected_cursor != self.cursor:
  30. raise ValueError("stale projector cursor")
  31. self.calls.append(event.event_key)
  32. self.view.setdefault(event.event_key, event.payload)
  33. if event.event_key == self.fail_key and not self.failed_once:
  34. self.failed_once = True
  35. raise RuntimeError("failed after business data write")
  36. self.cursor = event.event_id
  37. async def advance_cursor(
  38. self,
  39. application_ref,
  40. root_trace_id,
  41. event_id,
  42. expected_cursor,
  43. ):
  44. del application_ref, root_trace_id
  45. if expected_cursor != self.cursor:
  46. raise ValueError("stale projector cursor")
  47. self.cursor = event_id
  48. class ApplicationEventsTest(unittest.IsolatedAsyncioTestCase):
  49. async def asyncSetUp(self):
  50. self.temp = tempfile.TemporaryDirectory()
  51. self.store = FileSystemTraceStore(self.temp.name)
  52. self.projector = TransactionalProjector()
  53. application = AgentApplication(
  54. application_id="events.reference",
  55. application_version="1",
  56. root_role="writer",
  57. roles=(AgentRole(
  58. role_id="writer",
  59. model="fake",
  60. system_prompt="write",
  61. ),),
  62. event_projector_ref=ProviderRef(
  63. provider_id="projector",
  64. provider_version="1",
  65. ),
  66. )
  67. self.registry = ApplicationRegistry()
  68. self.binding = self.registry.register(
  69. application,
  70. ApplicationServices(
  71. tool_registry=ToolRegistry(),
  72. event_projector=self.projector,
  73. ),
  74. )
  75. self.runtime = ApplicationRuntime(
  76. registry=self.registry,
  77. trace_store=self.store,
  78. llm_call=unused_llm,
  79. )
  80. self.runner, config = self.runtime.new_run(
  81. "events.reference",
  82. "1",
  83. uid="user-1",
  84. root_task_anchor={
  85. "objective": "write",
  86. "completion_criteria": ["complete"],
  87. "constraints": [],
  88. },
  89. )
  90. with patch.dict("os.environ", {"AGENT_MODE": "recursive"}, clear=False):
  91. self.trace, _goal, _sequence = await self.runner._prepare_new_trace(
  92. [{"role": "user", "content": "start"}],
  93. config,
  94. )
  95. async def asyncTearDown(self):
  96. self.temp.cleanup()
  97. async def test_projection_stops_in_order_and_replay_is_idempotent(self):
  98. service = self.runner.event_service
  99. await service.emit(
  100. source_trace_id=self.trace.trace_id,
  101. event_type="task.progress.revised",
  102. event_key="first",
  103. effective_at_sequence=1,
  104. payload={"value": 1},
  105. )
  106. self.projector.fail_key = "second"
  107. await service.emit(
  108. source_trace_id=self.trace.trace_id,
  109. event_type="candidate.version_registered",
  110. event_key="second",
  111. effective_at_sequence=2,
  112. payload={"value": 2},
  113. )
  114. self.assertEqual({"first", "second"}, set(self.projector.view))
  115. self.projector.failed_once = False
  116. await service.emit(
  117. source_trace_id=self.trace.trace_id,
  118. event_type="validation.completed",
  119. event_key="third",
  120. effective_at_sequence=3,
  121. payload={"value": 3},
  122. )
  123. self.assertEqual({"first", "second"}, set(self.projector.view))
  124. self.assertNotIn("third", self.projector.view)
  125. self.projector.fail_key = None
  126. await service.pump.pump(self.trace.trace_id)
  127. self.assertEqual({"first", "second", "third"}, set(self.projector.view))
  128. self.assertEqual(1, list(self.projector.view).count("second"))
  129. self.assertEqual(3, self.projector.calls.count("second"))
  130. async def test_unknown_schema_and_event_id_gap_advance_without_projection(self):
  131. await self.store.append_event(
  132. self.trace.trace_id,
  133. "ordinary",
  134. {"ignored": True},
  135. )
  136. event_id = await self.store.append_event(
  137. self.trace.trace_id,
  138. "run_event",
  139. {
  140. "schema_version": 99,
  141. "event_type": "future.event",
  142. "event_key": "future",
  143. "application_ref": self.binding.application_ref.model_dump(
  144. mode="json"
  145. ),
  146. "root_trace_id": self.trace.trace_id,
  147. "source_trace_id": self.trace.trace_id,
  148. "effective_at_sequence": 1,
  149. "payload": {"future": True},
  150. },
  151. )
  152. await self.runner.event_service.pump.pump(self.trace.trace_id)
  153. self.assertEqual(event_id, self.projector.cursor)
  154. self.assertNotIn("future", self.projector.view)
  155. self.assertNotIn({"future": True}, self.projector.view.values())
  156. unknown_type_id = await self.store.append_event(
  157. self.trace.trace_id,
  158. "run_event",
  159. {
  160. "schema_version": 1,
  161. "event_type": "unknown.current-schema-event",
  162. "event_key": "unknown-type",
  163. "application_ref": self.binding.application_ref.model_dump(
  164. mode="json"
  165. ),
  166. "root_trace_id": self.trace.trace_id,
  167. "source_trace_id": self.trace.trace_id,
  168. "effective_at_sequence": 2,
  169. "payload": {"must_not_reach_view": True},
  170. },
  171. )
  172. await self.runner.event_service.pump.pump(self.trace.trace_id)
  173. self.assertEqual(unknown_type_id, self.projector.cursor)
  174. self.assertNotIn("unknown-type", self.projector.view)
  175. async def test_same_event_key_cannot_hide_a_conflicting_payload(self):
  176. service = self.runner.event_service
  177. await service.emit(
  178. source_trace_id=self.trace.trace_id,
  179. event_type="task.progress.revised",
  180. event_key="stable-operation",
  181. effective_at_sequence=1,
  182. payload={"revision": 1},
  183. )
  184. with self.assertRaisesRegex(ValueError, "conflicting payload"):
  185. await service.emit(
  186. source_trace_id=self.trace.trace_id,
  187. event_type="task.progress.revised",
  188. event_key="stable-operation",
  189. effective_at_sequence=1,
  190. payload={"revision": 2},
  191. )
  192. async def test_event_append_failure_preserves_state_and_reconcile_repairs_it(self):
  193. service = self.runner.event_service
  194. original_append = self.store.append_event
  195. async def fail_run_event(trace_id, event_type, payload):
  196. if event_type == "run_event":
  197. raise OSError("journal unavailable")
  198. return await original_append(trace_id, event_type, payload)
  199. with patch.object(self.store, "append_event", side_effect=fail_run_event):
  200. revision = await self.runner.task_protocol_service.update_progress(
  201. self.trace.trace_id,
  202. expected_revision=1,
  203. progress=TaskProgress(
  204. phase="investigating",
  205. decision_rationale="verify one fact before writing",
  206. ),
  207. effective_at_sequence=2,
  208. )
  209. self.assertEqual(2, revision.revision)
  210. events = await self.store.get_events(self.trace.trace_id)
  211. self.assertFalse(any(
  212. item.get("event_key", "").endswith(":2") for item in events
  213. ))
  214. repaired_count = await self.runtime.reconcile_events(self.trace.trace_id)
  215. self.assertGreater(repaired_count, 0)
  216. self.assertEqual(
  217. 0,
  218. await self.runtime.reconcile_events(self.trace.trace_id),
  219. )
  220. events = await self.store.get_events(self.trace.trace_id)
  221. repaired = [
  222. RunEvent.model_validate({
  223. key: item[key] for key in RunEvent.model_fields if key in item
  224. })
  225. for item in events
  226. if item.get("event_key", "").endswith(":2")
  227. ]
  228. self.assertEqual(1, len(repaired))
  229. self.assertEqual("task.progress.revised", repaired[0].event_type)