test_trace_attachments.py 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
  1. from __future__ import annotations
  2. import base64
  3. import inspect
  4. from hashlib import sha256
  5. import pytest
  6. from agent import AttachmentRef, TraceAttachmentStore
  7. from agent.core.runner import AgentRunner, RunConfig
  8. from agent.trace.models import Message, Trace
  9. from agent.trace.store import FileSystemTraceStore
  10. @pytest.mark.asyncio
  11. async def test_filesystem_trace_attachment_roundtrip_and_digest_verification(tmp_path):
  12. store = FileSystemTraceStore(str(tmp_path))
  13. await store.create_trace(Trace(trace_id="trace", mode="agent"))
  14. await store.add_message(
  15. Message.create(
  16. trace_id="trace",
  17. role="tool",
  18. sequence=1,
  19. content="attachment source",
  20. )
  21. )
  22. content = b"\x89PNG\r\ncontrolled-image"
  23. digest = f"sha256:{sha256(content).hexdigest()}"
  24. ref = await store.store_message_attachment(
  25. trace_id="trace",
  26. message_id="trace-0001",
  27. media_type="image/png",
  28. content=content,
  29. sha256=digest,
  30. )
  31. assert isinstance(store, TraceAttachmentStore)
  32. assert isinstance(ref, AttachmentRef)
  33. assert ref.size_bytes == len(content)
  34. assert await store.read_message_attachment(ref) == content
  35. reloaded = FileSystemTraceStore(str(tmp_path))
  36. assert await reloaded.read_message_attachment(ref) == content
  37. assert (
  38. await store.store_message_attachment(
  39. trace_id="trace",
  40. message_id="trace-0001",
  41. media_type="image/png",
  42. content=content,
  43. sha256=digest,
  44. )
  45. == ref
  46. )
  47. with pytest.raises(ValueError, match="digest mismatch"):
  48. await store.store_message_attachment(
  49. trace_id="trace",
  50. message_id="message-2",
  51. media_type="image/png",
  52. content=content,
  53. sha256=f"sha256:{'0' * 64}",
  54. )
  55. forged_media_type = AttachmentRef(
  56. trace_id=ref.trace_id,
  57. message_id=ref.message_id,
  58. media_type="image/jpeg",
  59. sha256=ref.sha256,
  60. size_bytes=ref.size_bytes,
  61. )
  62. with pytest.raises(ValueError, match="does not match metadata"):
  63. await reloaded.read_message_attachment(forged_media_type)
  64. payload_path = (
  65. tmp_path
  66. / ref.trace_id
  67. / "attachments"
  68. / ref.message_id
  69. / f"{ref.sha256.removeprefix('sha256:')}.bin"
  70. )
  71. payload_path.write_bytes(b"X" * len(content))
  72. with pytest.raises(ValueError, match="integrity check failed"):
  73. await reloaded.read_message_attachment(ref)
  74. @pytest.mark.asyncio
  75. async def test_runner_uses_optional_attachment_port_and_old_store_stays_compatible(
  76. tmp_path,
  77. ):
  78. content = b"image bytes"
  79. image = {
  80. "type": "base64",
  81. "media_type": "image/png",
  82. "data": base64.b64encode(content).decode("ascii"),
  83. }
  84. store = FileSystemTraceStore(str(tmp_path))
  85. await store.create_trace(Trace(trace_id="trace", mode="agent"))
  86. await store.add_message(
  87. Message.create(
  88. trace_id="trace",
  89. role="tool",
  90. sequence=1,
  91. content="attachment source",
  92. )
  93. )
  94. runner = AgentRunner(trace_store=store)
  95. await runner._persist_tool_attachments("trace", "trace-0001", [image])
  96. ref = AttachmentRef(
  97. trace_id="trace",
  98. message_id="trace-0001",
  99. media_type="image/png",
  100. sha256=f"sha256:{sha256(content).hexdigest()}",
  101. size_bytes=len(content),
  102. )
  103. assert await store.read_message_attachment(ref) == content
  104. class LegacyStore:
  105. pass
  106. legacy_runner = AgentRunner(trace_store=LegacyStore())
  107. await legacy_runner._persist_tool_attachments("trace", "message", [image])
  108. with pytest.raises(ValueError, match="invalid base64"):
  109. await runner._persist_tool_attachments(
  110. "trace",
  111. "bad-message",
  112. [{"type": "base64", "media_type": "image/png", "data": "%%%"}],
  113. )
  114. @pytest.mark.asyncio
  115. async def test_non_multimodal_run_works_with_trace_store_without_attachment_port(
  116. tmp_path,
  117. ):
  118. inner = FileSystemTraceStore(str(tmp_path))
  119. class LegacyTraceStore:
  120. def __getattr__(self, name):
  121. if name in {"store_message_attachment", "read_message_attachment"}:
  122. raise AttributeError(name)
  123. return getattr(inner, name)
  124. async def llm_call(**_kwargs):
  125. return {"content": "done", "tool_calls": None, "finish_reason": "stop"}
  126. legacy = LegacyTraceStore()
  127. assert not isinstance(legacy, TraceAttachmentStore)
  128. runner = AgentRunner(trace_store=legacy, llm_call=llm_call)
  129. result = await runner.run_result(
  130. [{"role": "user", "content": "plain text task"}],
  131. RunConfig(name="legacy text run", max_iterations=1),
  132. )
  133. assert result["status"] == "completed"
  134. assert "_get_messages_dir" not in inspect.getsource(AgentRunner)
  135. def test_attachment_ref_rejects_unsafe_or_invalid_fields():
  136. digest = f"sha256:{'0' * 64}"
  137. with pytest.raises(ValueError, match="media_type"):
  138. AttachmentRef("trace", "message", "", digest, 1)
  139. with pytest.raises(ValueError, match="size_bytes"):
  140. AttachmentRef("trace", "message", "image/png", digest, -1)