test_application_events.py 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  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. class ApplicationEventsTest(unittest.IsolatedAsyncioTestCase):
  38. async def asyncSetUp(self):
  39. self.temp = tempfile.TemporaryDirectory()
  40. self.store = FileSystemTraceStore(self.temp.name)
  41. self.projector = TransactionalProjector()
  42. application = AgentApplication(
  43. application_id="events.reference",
  44. application_version="1",
  45. root_role="writer",
  46. roles=(AgentRole(
  47. role_id="writer",
  48. model="fake",
  49. system_prompt="write",
  50. ),),
  51. event_projector_ref=ProviderRef(
  52. provider_id="projector",
  53. provider_version="1",
  54. ),
  55. )
  56. self.registry = ApplicationRegistry()
  57. self.binding = self.registry.register(
  58. application,
  59. ApplicationServices(
  60. tool_registry=ToolRegistry(),
  61. event_projector=self.projector,
  62. ),
  63. )
  64. self.runtime = ApplicationRuntime(
  65. registry=self.registry,
  66. trace_store=self.store,
  67. llm_call=unused_llm,
  68. )
  69. self.runner, config = self.runtime.new_run(
  70. "events.reference",
  71. "1",
  72. uid="user-1",
  73. root_task_anchor={
  74. "objective": "write",
  75. "completion_criteria": ["complete"],
  76. "constraints": [],
  77. },
  78. )
  79. with patch.dict("os.environ", {"AGENT_MODE": "recursive"}, clear=False):
  80. self.trace, _goal, _sequence = await self.runner._prepare_new_trace(
  81. [{"role": "user", "content": "start"}],
  82. config,
  83. )
  84. async def asyncTearDown(self):
  85. self.temp.cleanup()
  86. async def test_projection_stops_in_order_and_replay_is_idempotent(self):
  87. service = self.runner.event_service
  88. await service.emit(
  89. source_trace_id=self.trace.trace_id,
  90. event_type="test.first",
  91. event_key="first",
  92. effective_at_sequence=1,
  93. payload={"value": 1},
  94. )
  95. self.projector.fail_key = "second"
  96. await service.emit(
  97. source_trace_id=self.trace.trace_id,
  98. event_type="test.second",
  99. event_key="second",
  100. effective_at_sequence=2,
  101. payload={"value": 2},
  102. )
  103. self.assertEqual({"first", "second"}, set(self.projector.view))
  104. self.projector.failed_once = False
  105. await service.emit(
  106. source_trace_id=self.trace.trace_id,
  107. event_type="test.third",
  108. event_key="third",
  109. effective_at_sequence=3,
  110. payload={"value": 3},
  111. )
  112. self.assertEqual({"first", "second"}, set(self.projector.view))
  113. self.assertNotIn("third", self.projector.view)
  114. self.projector.fail_key = None
  115. await service.pump.pump(self.trace.trace_id)
  116. self.assertEqual({"first", "second", "third"}, set(self.projector.view))
  117. self.assertEqual(1, list(self.projector.view).count("second"))
  118. self.assertEqual(3, self.projector.calls.count("second"))
  119. async def test_unknown_schema_and_event_id_gap_are_safely_projected(self):
  120. await self.store.append_event(
  121. self.trace.trace_id,
  122. "ordinary",
  123. {"ignored": True},
  124. )
  125. event_id = await self.store.append_event(
  126. self.trace.trace_id,
  127. "run_event",
  128. {
  129. "schema_version": 99,
  130. "event_type": "future.event",
  131. "event_key": "future",
  132. "application_ref": self.binding.application_ref.model_dump(
  133. mode="json"
  134. ),
  135. "root_trace_id": self.trace.trace_id,
  136. "source_trace_id": self.trace.trace_id,
  137. "effective_at_sequence": 1,
  138. "payload": {"future": True},
  139. },
  140. )
  141. await self.runner.event_service.pump.pump(self.trace.trace_id)
  142. self.assertEqual(event_id, self.projector.cursor)
  143. self.assertEqual({"future": True}, self.projector.view["future"])
  144. async def test_event_append_failure_preserves_state_and_reconcile_repairs_it(self):
  145. service = self.runner.event_service
  146. original_append = self.store.append_event
  147. async def fail_run_event(trace_id, event_type, payload):
  148. if event_type == "run_event":
  149. raise OSError("journal unavailable")
  150. return await original_append(trace_id, event_type, payload)
  151. with patch.object(self.store, "append_event", side_effect=fail_run_event):
  152. revision = await self.runner.task_protocol_service.update_progress(
  153. self.trace.trace_id,
  154. expected_revision=1,
  155. progress=TaskProgress(
  156. phase="investigating",
  157. decision_rationale="verify one fact before writing",
  158. ),
  159. effective_at_sequence=2,
  160. )
  161. self.assertEqual(2, revision.revision)
  162. events = await self.store.get_events(self.trace.trace_id)
  163. self.assertFalse(any(
  164. item.get("event_key", "").endswith(":2") for item in events
  165. ))
  166. await self.runtime.reconcile_events(self.trace.trace_id)
  167. events = await self.store.get_events(self.trace.trace_id)
  168. repaired = [
  169. RunEvent.model_validate({
  170. key: item[key] for key in RunEvent.model_fields if key in item
  171. })
  172. for item in events
  173. if item.get("event_key", "").endswith(":2")
  174. ]
  175. self.assertEqual(1, len(repaired))
  176. self.assertEqual("task.progress.revised", repaired[0].event_type)