test_context_report.py 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. from __future__ import annotations
  2. import json
  3. from pathlib import Path
  4. import httpx
  5. from langchain.agents import create_agent
  6. from langchain_core.language_models.fake_chat_models import (
  7. FakeMessagesListChatModel,
  8. )
  9. from langchain_core.messages import AIMessage
  10. from production_build_agents.observability.context_report import (
  11. ObAgentContextClient,
  12. build_context_report,
  13. local_checkpoint_ai_counts,
  14. render_text_report,
  15. )
  16. from production_build_agents.run.langgraph_checkpointer import (
  17. create_sqlite_checkpointer,
  18. )
  19. def _start(call_id: str, logical_key: str, index: int = 1) -> dict:
  20. return {
  21. "type": "note",
  22. "payload": {
  23. "event_kind": "model_context_start",
  24. "physical_call_id": call_id,
  25. "logical_call_index": index,
  26. "logical_call_key": logical_key,
  27. "identity": {
  28. "agent_run_id": "run:Segment1:v1:executor",
  29. "role": "segment_executor",
  30. "segment_id": "Segment1",
  31. "plan_version": 1,
  32. },
  33. "input": {
  34. "message_count": 3,
  35. "serialized_bytes": 900,
  36. "tool_messages": {"serialized_bytes": 200},
  37. "images": {"count": 1, "decoded_bytes": 300},
  38. },
  39. "previous": None,
  40. },
  41. }
  42. def _terminal(call_id: str, logical_key: str, index: int = 1) -> dict:
  43. return {
  44. "type": "llm",
  45. "payload": {
  46. "context_metrics": {
  47. "event_kind": "model_context_terminal",
  48. "physical_call_id": call_id,
  49. "logical_call_index": index,
  50. "logical_call_key": logical_key,
  51. "identity": {
  52. "agent_run_id": "run:Segment1:v1:executor",
  53. "role": "segment_executor",
  54. "segment_id": "Segment1",
  55. "plan_version": 1,
  56. },
  57. "status": "success",
  58. "usage": {
  59. "input_tokens": 100,
  60. "output_tokens": 10,
  61. "input_tokens_delta": None,
  62. },
  63. "transport": {
  64. "observed": True,
  65. "attempt_count": 2,
  66. "retry_count": 1,
  67. "attempts": [],
  68. },
  69. }
  70. },
  71. }
  72. def test_report_groups_calls_and_flags_incomplete_and_duplicates() -> None:
  73. observed = [
  74. {
  75. "run": {
  76. "id": 10,
  77. "agent": "production",
  78. "status": "failed",
  79. "meta": {
  80. "thread_id": "round-production",
  81. "pipeline_round_id": "round",
  82. },
  83. "input_tokens": 200,
  84. "output_tokens": 20,
  85. },
  86. "flat": {
  87. "instances": [
  88. {
  89. "flow": [
  90. _start("physical-1", "logical-1"),
  91. _terminal("physical-1", "logical-1"),
  92. _start("physical-2", "logical-1"),
  93. ]
  94. }
  95. ]
  96. },
  97. "message_call_counts": {"instance-1": 1},
  98. }
  99. ]
  100. report = build_context_report(observed)
  101. assert report["totals"] == {
  102. "agent_runs": 1,
  103. "physical_calls": 2,
  104. "terminal_calls": 1,
  105. "incomplete_calls": 1,
  106. "raw_message_calls": 1,
  107. "anomalies": 3,
  108. }
  109. agent = report["agent_runs"][0]
  110. assert agent["transport_retry_count"] == 1
  111. assert agent["max_input_tokens"] == 100
  112. assert agent["max_context_bytes"] == 900
  113. kinds = [item["kind"] for item in report["anomalies"]]
  114. assert kinds.count("duplicate_logical_call") == 2
  115. assert kinds.count("start_without_terminal") == 1
  116. assert "Segment1" in render_text_report(report)
  117. def test_report_never_introduces_raw_content() -> None:
  118. report = build_context_report(
  119. [
  120. {
  121. "run": {"id": 1, "meta": {}},
  122. "flat": {
  123. "instances": [
  124. {"flow": [_start("physical-1", "logical-1")]}
  125. ]
  126. },
  127. "message_call_counts": {},
  128. }
  129. ]
  130. )
  131. serialized = json.dumps(report, ensure_ascii=False)
  132. assert "https://" not in serialized
  133. assert "base64" not in serialized
  134. assert "prompt" not in serialized.lower()
  135. def test_local_checkpoint_counts_latest_ai_messages(tmp_path: Path) -> None:
  136. run_dir = tmp_path / "run"
  137. model = FakeMessagesListChatModel(
  138. responses=[AIMessage(content="done")]
  139. )
  140. with create_sqlite_checkpointer(
  141. run_dir,
  142. filename="agent_checkpoints.sqlite",
  143. ) as checkpointer:
  144. agent = create_agent(
  145. model=model,
  146. tools=[],
  147. system_prompt="system",
  148. checkpointer=checkpointer,
  149. )
  150. agent.invoke(
  151. {"messages": [{"role": "user", "content": "hello"}]},
  152. {"configurable": {"thread_id": "agent-1"}},
  153. )
  154. assert local_checkpoint_ai_counts([run_dir]) == {"agent-1": 1}
  155. def test_obagent_client_resolves_pipeline_runs_and_reads_message_counts() -> None:
  156. requests: list[str] = []
  157. def handler(request: httpx.Request) -> httpx.Response:
  158. requests.append(str(request.url))
  159. if request.url.path == "/api/runs":
  160. page = request.url.params.get("page")
  161. item = {
  162. "id": 10 if page == "1" else 11,
  163. "agent": "global_data" if page == "1" else "production",
  164. "meta": {
  165. "thread_id": (
  166. "round-global-data"
  167. if page == "1"
  168. else "round-production"
  169. ),
  170. "pipeline_round_id": "round",
  171. },
  172. }
  173. return httpx.Response(
  174. 200,
  175. json={
  176. "items": [item],
  177. "page": int(page),
  178. "page_size": 1,
  179. "total": 2,
  180. },
  181. )
  182. if request.url.path == "/api/runs/10/flat":
  183. return httpx.Response(
  184. 200,
  185. json={
  186. "instances": [
  187. {
  188. "id": 100,
  189. "flow": [{"type": "llm", "payload": {}}],
  190. }
  191. ]
  192. },
  193. )
  194. if request.url.path == "/api/instances/100/messages":
  195. return httpx.Response(
  196. 200,
  197. json={"inst_id": 100, "calls": [{}, {}]},
  198. )
  199. raise AssertionError(request.url)
  200. client = ObAgentContextClient(
  201. endpoint="https://obagent.example",
  202. project="project",
  203. )
  204. client.client.close()
  205. client.client = httpx.Client(
  206. base_url="https://obagent.example",
  207. transport=httpx.MockTransport(handler),
  208. )
  209. try:
  210. runs = client.resolve_runs("round")
  211. observed = client.observed_run(runs[0])
  212. finally:
  213. client.close()
  214. assert [run["id"] for run in runs] == [10, 11]
  215. assert observed["message_call_counts"] == {"100": 2}
  216. assert any("page=2" in url for url in requests)