reactifact 0.9.1__tar.gz → 0.11.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (249) hide show
  1. {reactifact-0.9.1/reactifact.egg-info → reactifact-0.11.0}/PKG-INFO +82 -11
  2. {reactifact-0.9.1 → reactifact-0.11.0}/README.md +81 -10
  3. {reactifact-0.9.1 → reactifact-0.11.0}/pyproject.toml +5 -2
  4. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/__init__.py +6 -2
  5. reactifact-0.11.0/reactifact/_httpx.py +87 -0
  6. reactifact-0.11.0/reactifact/audit.py +255 -0
  7. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/budget.py +2 -0
  8. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/chat.py +44 -7
  9. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/checkpoints.py +29 -0
  10. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/cli/replay.py +20 -0
  11. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/context.py +2 -0
  12. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/effects.py +36 -3
  13. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/produce.py +13 -1
  14. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/prompts.py +70 -2
  15. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/providers/anthropic.py +8 -9
  16. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/providers/chat.py +15 -18
  17. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/providers/contracts.py +4 -0
  18. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/providers/gemini.py +8 -9
  19. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/providers/image.py +15 -15
  20. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/providers/speech.py +27 -28
  21. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/providers/video.py +10 -11
  22. reactifact-0.11.0/reactifact/quick/__init__.py +66 -0
  23. reactifact-0.11.0/reactifact/quick/_shared.py +72 -0
  24. reactifact-0.11.0/reactifact/quick/agent.py +147 -0
  25. reactifact-0.11.0/reactifact/quick/chat.py +85 -0
  26. reactifact-0.11.0/reactifact/quick/models.py +47 -0
  27. reactifact-0.11.0/reactifact/quick/rag.py +407 -0
  28. reactifact-0.11.0/reactifact/quick/tools_agent.py +167 -0
  29. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/recipes/__init__.py +4 -0
  30. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/recipes/text.py +22 -1
  31. reactifact-0.11.0/reactifact/recipes/tool_loop.py +190 -0
  32. reactifact-0.11.0/reactifact/redaction.py +94 -0
  33. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/replay.py +92 -1
  34. reactifact-0.11.0/reactifact/request.py +56 -0
  35. reactifact-0.11.0/reactifact/resources.py +213 -0
  36. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/runtime.py +37 -9
  37. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/sources.py +14 -7
  38. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/structured.py +79 -20
  39. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/testing/__init__.py +6 -0
  40. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/testing/fault.py +15 -8
  41. reactifact-0.11.0/reactifact/testing/golden.py +110 -0
  42. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/testing/lab.py +11 -8
  43. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/testing/mock.py +24 -10
  44. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/tools.py +21 -1
  45. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/tracing/__init__.py +20 -2
  46. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/tracing/langfuse.py +7 -7
  47. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/tracing/models.py +36 -0
  48. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/tracing/otlp.py +7 -7
  49. reactifact-0.11.0/reactifact/tracing/postgres.py +567 -0
  50. reactifact-0.11.0/reactifact/tracing/store.py +647 -0
  51. reactifact-0.11.0/reactifact/tracing/templates/app.css +326 -0
  52. reactifact-0.11.0/reactifact/tracing/templates/sessions.html +145 -0
  53. reactifact-0.11.0/reactifact/tracing/templates/ui.html +487 -0
  54. reactifact-0.11.0/reactifact/tracing/templates/ui_run.html +386 -0
  55. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/tracing/tracer.py +54 -11
  56. reactifact-0.11.0/reactifact/tracing/web.py +291 -0
  57. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/web.py +64 -11
  58. {reactifact-0.9.1 → reactifact-0.11.0/reactifact.egg-info}/PKG-INFO +82 -11
  59. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact.egg-info/SOURCES.txt +29 -0
  60. reactifact-0.11.0/tests/test_audit.py +134 -0
  61. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_chat_web.py +108 -0
  62. reactifact-0.11.0/tests/test_determinism.py +155 -0
  63. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_devops_web.py +2 -1
  64. reactifact-0.11.0/tests/test_fintech_audit.py +40 -0
  65. reactifact-0.11.0/tests/test_golden.py +87 -0
  66. reactifact-0.11.0/tests/test_loop_bound_client.py +72 -0
  67. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_prompts.py +36 -0
  68. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_provider_auth.py +7 -2
  69. reactifact-0.11.0/tests/test_quick.py +325 -0
  70. reactifact-0.11.0/tests/test_recipe_tool_loop.py +160 -0
  71. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_recipes.py +12 -0
  72. reactifact-0.11.0/tests/test_redaction.py +148 -0
  73. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_repair.py +47 -0
  74. reactifact-0.11.0/tests/test_repo_agent.py +60 -0
  75. reactifact-0.11.0/tests/test_request.py +88 -0
  76. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_resources.py +66 -1
  77. reactifact-0.11.0/tests/test_scheduler_semantics.py +120 -0
  78. reactifact-0.11.0/tests/test_starter_app.py +87 -0
  79. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_structured.py +105 -0
  80. reactifact-0.11.0/tests/test_support_copilot.py +52 -0
  81. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_testing_lab.py +46 -0
  82. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_testing_mock.py +64 -1
  83. reactifact-0.11.0/tests/test_trace_tags.py +321 -0
  84. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_tracing.py +122 -0
  85. reactifact-0.9.1/reactifact/resources.py +0 -69
  86. reactifact-0.9.1/reactifact/tracing/postgres.py +0 -220
  87. reactifact-0.9.1/reactifact/tracing/store.py +0 -254
  88. reactifact-0.9.1/reactifact/tracing/templates/ui.html +0 -196
  89. reactifact-0.9.1/reactifact/tracing/templates/ui_run.html +0 -264
  90. reactifact-0.9.1/reactifact/tracing/web.py +0 -117
  91. {reactifact-0.9.1 → reactifact-0.11.0}/LICENSE +0 -0
  92. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/__main__.py +0 -0
  93. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/_extras.py +0 -0
  94. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/agent_tool.py +0 -0
  95. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/agents.py +0 -0
  96. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/artifacts.py +0 -0
  97. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/branching.py +0 -0
  98. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/cli/__init__.py +0 -0
  99. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/cli/branch.py +0 -0
  100. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/cli/common.py +0 -0
  101. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/cli/context.py +0 -0
  102. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/cli/graph.py +0 -0
  103. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/cli/scenario.py +0 -0
  104. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/cli/trace.py +0 -0
  105. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/commit.py +0 -0
  106. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/commit_log.py +0 -0
  107. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/consume.py +0 -0
  108. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/context_builder.py +0 -0
  109. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/eval.py +0 -0
  110. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/events.py +0 -0
  111. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/interrupt.py +0 -0
  112. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/llm_agent.py +0 -0
  113. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/mcp/__init__.py +0 -0
  114. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/mcp/client.py +0 -0
  115. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/mcp/oauth.py +0 -0
  116. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/mcp/server.py +0 -0
  117. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/native_tool_use.py +0 -0
  118. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/operations.py +0 -0
  119. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/patches.py +0 -0
  120. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/providers/__init__.py +0 -0
  121. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/providers/_retry.py +0 -0
  122. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/providers/azure.py +0 -0
  123. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/providers/cerebras.py +0 -0
  124. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/providers/deepseek.py +0 -0
  125. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/providers/fake.py +0 -0
  126. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/providers/fireworks.py +0 -0
  127. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/providers/github_models.py +0 -0
  128. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/providers/groq.py +0 -0
  129. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/providers/mistral.py +0 -0
  130. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/providers/nvidia.py +0 -0
  131. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/providers/ollama.py +0 -0
  132. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/providers/openai.py +0 -0
  133. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/providers/openrouter.py +0 -0
  134. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/providers/perplexity.py +0 -0
  135. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/providers/qwen.py +0 -0
  136. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/providers/together.py +0 -0
  137. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/providers/xai.py +0 -0
  138. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/providers/zai.py +0 -0
  139. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/py.typed +0 -0
  140. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/recipes/cleanup.py +0 -0
  141. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/recipes/identity.py +0 -0
  142. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/recipes/inputs.py +0 -0
  143. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/recipes/memory.py +0 -0
  144. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/recipes/plan_execute.py +0 -0
  145. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/recipes/reflection.py +0 -0
  146. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/recipes/resolve.py +0 -0
  147. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/recipes/rollback.py +0 -0
  148. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/recipes/search.py +0 -0
  149. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/recipes/skills.py +0 -0
  150. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/recipes/status.py +0 -0
  151. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/recipes/supervisor.py +0 -0
  152. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/relations.py +0 -0
  153. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/scheduler.py +0 -0
  154. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/session.py +0 -0
  155. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/streaming.py +0 -0
  156. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/testing/assertions.py +0 -0
  157. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/testing/exceptions.py +0 -0
  158. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/testing/record.py +0 -0
  159. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/testing/registry.py +0 -0
  160. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/tool_use.py +0 -0
  161. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/tracing/_otlp.py +0 -0
  162. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/triggers.py +0 -0
  163. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/verify.py +0 -0
  164. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact/viz.py +0 -0
  165. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact.egg-info/dependency_links.txt +0 -0
  166. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact.egg-info/entry_points.txt +0 -0
  167. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact.egg-info/requires.txt +0 -0
  168. {reactifact-0.9.1 → reactifact-0.11.0}/reactifact.egg-info/top_level.txt +0 -0
  169. {reactifact-0.9.1 → reactifact-0.11.0}/setup.cfg +0 -0
  170. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_absent_consume.py +0 -0
  171. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_adaptive.py +0 -0
  172. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_agent_tool.py +0 -0
  173. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_anthropic_provider.py +0 -0
  174. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_artifacts.py +0 -0
  175. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_backbone.py +0 -0
  176. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_branching.py +0 -0
  177. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_budget.py +0 -0
  178. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_canonical_ports.py +0 -0
  179. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_checkpoint.py +0 -0
  180. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_checkpoints_concurrency.py +0 -0
  181. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_cli.py +0 -0
  182. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_cli_scenario.py +0 -0
  183. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_commit_log.py +0 -0
  184. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_concurrency.py +0 -0
  185. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_consumes_produces.py +0 -0
  186. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_context_builder.py +0 -0
  187. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_correlated_consume.py +0 -0
  188. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_debounce.py +0 -0
  189. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_devops.py +0 -0
  190. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_effects.py +0 -0
  191. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_eval.py +0 -0
  192. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_forklab.py +0 -0
  193. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_forklab_web.py +0 -0
  194. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_friendly_api.py +0 -0
  195. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_gemini_provider.py +0 -0
  196. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_hitl.py +0 -0
  197. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_image_provider.py +0 -0
  198. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_incident_commander.py +0 -0
  199. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_invalidation.py +0 -0
  200. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_join_consume.py +0 -0
  201. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_knowledge.py +0 -0
  202. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_knowledge_web.py +0 -0
  203. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_llm_ladder.py +0 -0
  204. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_mcp.py +0 -0
  205. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_medic_lab.py +0 -0
  206. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_medic_lab_web.py +0 -0
  207. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_multisource.py +0 -0
  208. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_native_tool_use.py +0 -0
  209. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_openai_provider.py +0 -0
  210. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_patches.py +0 -0
  211. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_ports.py +0 -0
  212. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_produce_styles.py +0 -0
  213. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_produce_trigger.py +0 -0
  214. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_provider_retry.py +0 -0
  215. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_providers_integration.py +0 -0
  216. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_reacts_to.py +0 -0
  217. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_recipe_plan_execute.py +0 -0
  218. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_recipe_reflection.py +0 -0
  219. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_recipe_supervisor.py +0 -0
  220. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_recipes_inputs.py +0 -0
  221. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_recipes_memory.py +0 -0
  222. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_recipes_skills.py +0 -0
  223. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_relation_graph.py +0 -0
  224. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_relations.py +0 -0
  225. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_repair_web.py +0 -0
  226. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_replay.py +0 -0
  227. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_research.py +0 -0
  228. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_retry.py +0 -0
  229. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_runtime.py +0 -0
  230. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_runtime_errors.py +0 -0
  231. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_sessions.py +0 -0
  232. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_sources.py +0 -0
  233. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_sources_search.py +0 -0
  234. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_sources_vector.py +0 -0
  235. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_speech_provider.py +0 -0
  236. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_streaming.py +0 -0
  237. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_testing_assertions.py +0 -0
  238. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_testing_registry.py +0 -0
  239. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_tools.py +0 -0
  240. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_vendor_factories.py +0 -0
  241. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_vendor_multimodal_factories.py +0 -0
  242. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_verify.py +0 -0
  243. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_video_provider.py +0 -0
  244. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_view.py +0 -0
  245. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_viz.py +0 -0
  246. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_web_source.py +0 -0
  247. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_workspace.py +0 -0
  248. {reactifact-0.9.1 → reactifact-0.11.0}/tests/test_workspace_with_sources.py +0 -0
  249. {reactifact-0.9.1 → reactifact-0.11.0}/tests/tests_checkpoints_sqlite.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reactifact
3
- Version: 0.9.1
3
+ Version: 0.11.0
4
4
  Summary: Reactive, artifact-driven agent runtime: agents transform versioned, typed, provenance-aware artifacts inside an evolving context
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/bzdvdn/reactifact
@@ -46,7 +46,7 @@ Dynamic: license-file
46
46
  <img src="docs/img/reactifact-hero.png" alt="reactifact — Agents that react to artifacts, not graphs" width="800">
47
47
  </p>
48
48
 
49
- **Stop drawing the graph. Build agents as reactions to versioned, provable artifacts.**
49
+ **Event-driven agents for Python developers tasks wake on typed artifacts, like Celery tasks wake on messages. No graph to draw.**
50
50
 
51
51
  [![CI](https://github.com/bzdvdn/reactifact/actions/workflows/ci.yml/badge.svg)](https://github.com/bzdvdn/reactifact/actions/workflows/ci.yml)
52
52
  [![codecov](https://codecov.io/gh/bzdvdn/reactifact/graph/badge.svg)](https://codecov.io/gh/bzdvdn/reactifact)
@@ -56,14 +56,79 @@ Dynamic: license-file
56
56
  [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/bzdvdn/reactifact)
57
57
  [![Docs](https://img.shields.io/badge/docs-bzdvdn.github.io%2Freactifact-blue)](https://bzdvdn.github.io/reactifact/)
58
58
 
59
- Most agent frameworks make you **draw the graph**: connect nodes, wire memory,
60
- declare control flow. But a knowledge question *"why did infra costs jump in
61
- Q2?"* needs Confluence + GitLab + CSV + calculations + verification, and the
62
- *next* question needs a different path. There is no universal graph to draw.
59
+ Python developers already know this model from Celery: define a **task**,
60
+ declare what triggers it, let the runtime run it. reactifact applies it to agents — a task
61
+ reacts to a **typed, versioned artifact** appearing in the context, not to a
62
+ queue message you push or a graph edge you draw. The runtime derives what runs
63
+ next from state.
63
64
 
64
- reactifact flips the model. You describe **what artifacts exist and what agents can
65
- do with them**; the runtime derives what runs next from **state changes**. Agents
66
- react to eventsthere is no graph, no node pipeline.
65
+ | Celery | reactifact |
66
+ | --- | --- |
67
+ | a task | `@produce(Model)` a unit of work that writes an artifact |
68
+ | `delay()` / `apply_async()` | you don't call it: creating the input artifact **is** the trigger |
69
+ | routing key / queue | `Consume(Type)` — which artifact type wakes the task |
70
+ | chain / group / chord | several `consumes` / `produces`; the runtime derives the order |
71
+ | retries, `acks_late` | guards + `Budget`, an honest `None` instead of a wrong result |
72
+ | result backend | the `Context` — typed, versioned artifacts |
73
+ | worker | `Runtime` |
74
+
75
+ Single process today (no broker, no worker pool) — the *model* is Celery-shaped,
76
+ not its distributed runtime.
77
+
78
+ On top of that model you get something a task queue doesn't: every artifact is
79
+ **versioned with provenance**, so a run is reproducible (`context_hash`) and
80
+ auditable (`audit.report`) for free. The model reasons; the arithmetic stays
81
+ deterministic; every claim carries provenance.
82
+
83
+ ## On top: a provable answer
84
+
85
+ [`examples/fintech_audit`](examples/fintech_audit) — a transactions CSV, a budget
86
+ CSV and a policy doc, **no API key**. The model never produces the number; plain
87
+ Python does, and the answer is linked to its evidence:
88
+
89
+ ```text
90
+ $ .venv/bin/python -m examples.fintech_audit.main
91
+
92
+ cloud spend: $45,000 (2026-04 $12k, 2026-05 $15k, 2026-06 $18k)
93
+ variance vs budget: +12.5% budget $40,000, policy threshold 10% → over
94
+
95
+ answer: Q2 cloud spend was $45,000 against a $40,000 budget (+12.5%) —
96
+ exceeds the 10% policy threshold. CFO approval is required.
97
+ citations: budget.csv, transactions.csv, policy.md
98
+ audit: Answer —supported_by→ {Variance, Spend, Table, Policy}
99
+ answer sha256 5461290d… · context sha256 24449f6f…
100
+
101
+ >>> re-running the pipeline hashes identically — or verify a saved run:
102
+ >>> reactifact replay <store> --session <id> --verify 24449f6f…
103
+ ```
104
+
105
+ That hash is the whole point: the answer is *reproducible* and its provenance is
106
+ a queryable graph (`context.related(answer.id, "supported_by")`), not a log line.
107
+
108
+ ![fintech_audit demo: the variance, the answer, and the reproducible context hash — a second run prints the same hash.](docs/img/fintech-audit-short.gif)
109
+
110
+ The number is computed, and its provenance recorded, in plain Python — the model
111
+ is never the source of truth (trimmed from
112
+ [`examples/fintech_audit/produce.py`](examples/fintech_audit/produce.py)):
113
+
114
+ ```python
115
+ @produce(Variance, reacts_to=Spend) # wakes when a Spend artifact exists
116
+ async def compute_variance(call: ProduceCall) -> None:
117
+ spend = call.trigger # the artifact that triggered this run
118
+ budget = cloud_budget(call.context) # read from budget.csv
119
+ pct = (spend.data.total - budget) / budget # deterministic — never the model
120
+ variance = call.effects.create_once_from(
121
+ spend, # stable id → idempotent re-runs (§42)
122
+ Variance(actual=spend.data.total, budget=budget, pct=pct,
123
+ threshold=0.10, within_policy=abs(pct) <= 0.10),
124
+ )
125
+ variance.link("calculated_from", spend) # provenance edge, queryable
126
+
127
+
128
+ answer = ctx.latest(AuditAnswer)
129
+ print(report_to_markdown(build_report(ctx, answer))) # hash per artifact + edges
130
+ print(context_hash(ctx)) # reproducible fingerprint
131
+ ```
67
132
 
68
133
  ![Left: a hand-wired fetch → verify → answer pipeline. Right: reactifact — search_agent and answer_agent each declare only what they consume and produce, wired together by Context, never each other.](docs/img/wiring.svg)
69
134
 
@@ -250,6 +315,9 @@ uv run python ./examples/devops/web.py # HITL ops assistant + trace dash
250
315
  Classic-pattern ports run as one-liners too:
251
316
  `python -m examples.{reflection,map_reduce,supervisor,summarize,time_travel,plan_execute,adaptive,ledger}.main`.
252
317
 
318
+ Prefer a notebook? [![Open in Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/bzdvdn/reactifact/blob/master/examples/quickstart.ipynb)
319
+ — `examples/quickstart.ipynb` covers the four `quick` cases (`agent`/`rag`/`tools_agent`/`chat_agent`), offline by default.
320
+
253
321
  ## Examples (in-repo, not shipped)
254
322
 
255
323
  - `knowledge` — multi-source chat: search → evidence → claim verification → answer, with CSV calculation.
@@ -267,8 +335,11 @@ Classic-pattern ports run as one-liners too:
267
335
 
268
336
  - [English](docs/en/index.md) · [Русский](docs/ru/index.md) — concepts, sources,
269
337
  providers, recipes, patterns, observability, eval, branching, replay, viz/CLI, API.
270
- - [Quickstart](docs/en/quickstart.md) — three runnable snippets: tool-calling
271
- agent, retrieval over your docs, session-persisted chat bot.
338
+ - [Quickstart](docs/en/quickstart.md) — the four `quick` cases (structured call,
339
+ RAG, tools, chat) in a few lines; also as a
340
+ [Colab notebook](examples/quickstart.ipynb).
341
+ - [Migrating](docs/en/migrating.md) — from LangGraph / CrewAI / LlamaIndex /
342
+ plain Python: concept map, port-one-node, interop, checklist.
272
343
  - [Why reactifact](docs/en/why-reactifact.md) — the *design argument*: why effects, why no graph, why determinism.
273
344
  - [Comparison](docs/en/comparison.md) — reactifact vs LangGraph/CrewAI, feature by feature, and when *not* to use reactifact.
274
345
  - [Tutorial · llm-ladder](docs/en/examples.md#tutorial-ladder) — learn the workflow.
@@ -2,7 +2,7 @@
2
2
  <img src="docs/img/reactifact-hero.png" alt="reactifact — Agents that react to artifacts, not graphs" width="800">
3
3
  </p>
4
4
 
5
- **Stop drawing the graph. Build agents as reactions to versioned, provable artifacts.**
5
+ **Event-driven agents for Python developers tasks wake on typed artifacts, like Celery tasks wake on messages. No graph to draw.**
6
6
 
7
7
  [![CI](https://github.com/bzdvdn/reactifact/actions/workflows/ci.yml/badge.svg)](https://github.com/bzdvdn/reactifact/actions/workflows/ci.yml)
8
8
  [![codecov](https://codecov.io/gh/bzdvdn/reactifact/graph/badge.svg)](https://codecov.io/gh/bzdvdn/reactifact)
@@ -12,14 +12,79 @@
12
12
  [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/bzdvdn/reactifact)
13
13
  [![Docs](https://img.shields.io/badge/docs-bzdvdn.github.io%2Freactifact-blue)](https://bzdvdn.github.io/reactifact/)
14
14
 
15
- Most agent frameworks make you **draw the graph**: connect nodes, wire memory,
16
- declare control flow. But a knowledge question *"why did infra costs jump in
17
- Q2?"* needs Confluence + GitLab + CSV + calculations + verification, and the
18
- *next* question needs a different path. There is no universal graph to draw.
15
+ Python developers already know this model from Celery: define a **task**,
16
+ declare what triggers it, let the runtime run it. reactifact applies it to agents — a task
17
+ reacts to a **typed, versioned artifact** appearing in the context, not to a
18
+ queue message you push or a graph edge you draw. The runtime derives what runs
19
+ next from state.
19
20
 
20
- reactifact flips the model. You describe **what artifacts exist and what agents can
21
- do with them**; the runtime derives what runs next from **state changes**. Agents
22
- react to eventsthere is no graph, no node pipeline.
21
+ | Celery | reactifact |
22
+ | --- | --- |
23
+ | a task | `@produce(Model)` a unit of work that writes an artifact |
24
+ | `delay()` / `apply_async()` | you don't call it: creating the input artifact **is** the trigger |
25
+ | routing key / queue | `Consume(Type)` — which artifact type wakes the task |
26
+ | chain / group / chord | several `consumes` / `produces`; the runtime derives the order |
27
+ | retries, `acks_late` | guards + `Budget`, an honest `None` instead of a wrong result |
28
+ | result backend | the `Context` — typed, versioned artifacts |
29
+ | worker | `Runtime` |
30
+
31
+ Single process today (no broker, no worker pool) — the *model* is Celery-shaped,
32
+ not its distributed runtime.
33
+
34
+ On top of that model you get something a task queue doesn't: every artifact is
35
+ **versioned with provenance**, so a run is reproducible (`context_hash`) and
36
+ auditable (`audit.report`) for free. The model reasons; the arithmetic stays
37
+ deterministic; every claim carries provenance.
38
+
39
+ ## On top: a provable answer
40
+
41
+ [`examples/fintech_audit`](examples/fintech_audit) — a transactions CSV, a budget
42
+ CSV and a policy doc, **no API key**. The model never produces the number; plain
43
+ Python does, and the answer is linked to its evidence:
44
+
45
+ ```text
46
+ $ .venv/bin/python -m examples.fintech_audit.main
47
+
48
+ cloud spend: $45,000 (2026-04 $12k, 2026-05 $15k, 2026-06 $18k)
49
+ variance vs budget: +12.5% budget $40,000, policy threshold 10% → over
50
+
51
+ answer: Q2 cloud spend was $45,000 against a $40,000 budget (+12.5%) —
52
+ exceeds the 10% policy threshold. CFO approval is required.
53
+ citations: budget.csv, transactions.csv, policy.md
54
+ audit: Answer —supported_by→ {Variance, Spend, Table, Policy}
55
+ answer sha256 5461290d… · context sha256 24449f6f…
56
+
57
+ >>> re-running the pipeline hashes identically — or verify a saved run:
58
+ >>> reactifact replay <store> --session <id> --verify 24449f6f…
59
+ ```
60
+
61
+ That hash is the whole point: the answer is *reproducible* and its provenance is
62
+ a queryable graph (`context.related(answer.id, "supported_by")`), not a log line.
63
+
64
+ ![fintech_audit demo: the variance, the answer, and the reproducible context hash — a second run prints the same hash.](docs/img/fintech-audit-short.gif)
65
+
66
+ The number is computed, and its provenance recorded, in plain Python — the model
67
+ is never the source of truth (trimmed from
68
+ [`examples/fintech_audit/produce.py`](examples/fintech_audit/produce.py)):
69
+
70
+ ```python
71
+ @produce(Variance, reacts_to=Spend) # wakes when a Spend artifact exists
72
+ async def compute_variance(call: ProduceCall) -> None:
73
+ spend = call.trigger # the artifact that triggered this run
74
+ budget = cloud_budget(call.context) # read from budget.csv
75
+ pct = (spend.data.total - budget) / budget # deterministic — never the model
76
+ variance = call.effects.create_once_from(
77
+ spend, # stable id → idempotent re-runs (§42)
78
+ Variance(actual=spend.data.total, budget=budget, pct=pct,
79
+ threshold=0.10, within_policy=abs(pct) <= 0.10),
80
+ )
81
+ variance.link("calculated_from", spend) # provenance edge, queryable
82
+
83
+
84
+ answer = ctx.latest(AuditAnswer)
85
+ print(report_to_markdown(build_report(ctx, answer))) # hash per artifact + edges
86
+ print(context_hash(ctx)) # reproducible fingerprint
87
+ ```
23
88
 
24
89
  ![Left: a hand-wired fetch → verify → answer pipeline. Right: reactifact — search_agent and answer_agent each declare only what they consume and produce, wired together by Context, never each other.](docs/img/wiring.svg)
25
90
 
@@ -206,6 +271,9 @@ uv run python ./examples/devops/web.py # HITL ops assistant + trace dash
206
271
  Classic-pattern ports run as one-liners too:
207
272
  `python -m examples.{reflection,map_reduce,supervisor,summarize,time_travel,plan_execute,adaptive,ledger}.main`.
208
273
 
274
+ Prefer a notebook? [![Open in Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/bzdvdn/reactifact/blob/master/examples/quickstart.ipynb)
275
+ — `examples/quickstart.ipynb` covers the four `quick` cases (`agent`/`rag`/`tools_agent`/`chat_agent`), offline by default.
276
+
209
277
  ## Examples (in-repo, not shipped)
210
278
 
211
279
  - `knowledge` — multi-source chat: search → evidence → claim verification → answer, with CSV calculation.
@@ -223,8 +291,11 @@ Classic-pattern ports run as one-liners too:
223
291
 
224
292
  - [English](docs/en/index.md) · [Русский](docs/ru/index.md) — concepts, sources,
225
293
  providers, recipes, patterns, observability, eval, branching, replay, viz/CLI, API.
226
- - [Quickstart](docs/en/quickstart.md) — three runnable snippets: tool-calling
227
- agent, retrieval over your docs, session-persisted chat bot.
294
+ - [Quickstart](docs/en/quickstart.md) — the four `quick` cases (structured call,
295
+ RAG, tools, chat) in a few lines; also as a
296
+ [Colab notebook](examples/quickstart.ipynb).
297
+ - [Migrating](docs/en/migrating.md) — from LangGraph / CrewAI / LlamaIndex /
298
+ plain Python: concept map, port-one-node, interop, checklist.
228
299
  - [Why reactifact](docs/en/why-reactifact.md) — the *design argument*: why effects, why no graph, why determinism.
229
300
  - [Comparison](docs/en/comparison.md) — reactifact vs LangGraph/CrewAI, feature by feature, and when *not* to use reactifact.
230
301
  - [Tutorial · llm-ladder](docs/en/examples.md#tutorial-ladder) — learn the workflow.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "reactifact"
7
- version = "0.9.1"
7
+ version = "0.11.0"
8
8
  description = "Reactive, artifact-driven agent runtime: agents transform versioned, typed, provenance-aware artifacts inside an evolving context"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -100,7 +100,7 @@ packages = { find = { where = ["."], include = ["reactifact*"], exclude = ["exam
100
100
 
101
101
  [tool.setuptools.package-data]
102
102
  reactifact = ["py.typed"]
103
- "reactifact.tracing" = ["templates/*.html"]
103
+ "reactifact.tracing" = ["templates/*.html", "templates/*.css"]
104
104
 
105
105
  [tool.pytest.ini_options]
106
106
  testpaths = ["tests"]
@@ -118,6 +118,9 @@ fail_under = 85
118
118
  target-version = "py311"
119
119
  line-length = 88
120
120
  src = ["reactifact", "examples"]
121
+ # Notebooks are Colab artifacts (IPython magics, top-level await) — not package
122
+ # code; don't lint/format them.
123
+ extend-exclude = ["*.ipynb"]
121
124
 
122
125
  [tool.ruff.lint]
123
126
  select = [
@@ -42,13 +42,14 @@ from .providers import (
42
42
  LLMResponseChunk,
43
43
  Message,
44
44
  )
45
- from .resources import RuntimeResources
45
+ from .request import current_request
46
+ from .resources import ResourceKey, ResourceScope, RuntimeResources
46
47
  from .runtime import Runtime
47
48
  from .session import Session, SessionStore
48
49
  from .tools import FunctionTool, Tool, ToolOutput, tool
49
50
  from .triggers import Trigger
50
51
 
51
- __version__ = "0.9.1"
52
+ __version__ = "0.11.0"
52
53
 
53
54
  __all__ = [
54
55
  "Agent",
@@ -74,6 +75,8 @@ __all__ = [
74
75
  "Produce",
75
76
  "ProduceCall",
76
77
  "Relation",
78
+ "ResourceKey",
79
+ "ResourceScope",
77
80
  "RunOutcome",
78
81
  "RunStats",
79
82
  "Runtime",
@@ -92,6 +95,7 @@ __all__ = [
92
95
  "View",
93
96
  "consume",
94
97
  "create_agent",
98
+ "current_request",
95
99
  "produce",
96
100
  "tool",
97
101
  ]
@@ -0,0 +1,87 @@
1
+ """Loop-aware `httpx.AsyncClient` ownership for providers and sinks.
2
+
3
+ `httpx.AsyncClient` binds its connection pool (and internal locks) to the event
4
+ loop it was created on. A provider/sink instance that caches the client forever
5
+ and is then reused on a *different* loop — pytest gives each test its own loop,
6
+ a CLI calls `asyncio.run(...)` more than once, uvicorn reloads — raises
7
+ `RuntimeError: Event loop is closed` (or "bound to a different event loop") on
8
+ the second use.
9
+
10
+ `LoopBoundClient` keeps the client **lazy** (created on first use, as before)
11
+ but **rebinds it to the current running loop**: when the loop changes, a fresh
12
+ client is created. The previous client's loop is already gone, so it is dropped
13
+ rather than awaited (its pool died with the loop). An injected `client=` (tests,
14
+ a custom transport) is returned as-is and never recreated.
15
+
16
+ Typical use inside a provider:
17
+
18
+ self._http = LoopBoundClient(
19
+ lambda: httpx.AsyncClient(timeout=self._timeout, headers=self._headers)
20
+ )
21
+
22
+ async def complete(self, ...):
23
+ response = await self._http.get().post(url, json=payload)
24
+
25
+ async def aclose(self) -> None:
26
+ await self._http.aclose()
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import asyncio
32
+ from collections.abc import Callable
33
+ from typing import Any, cast
34
+
35
+ import httpx
36
+
37
+
38
+ class LoopBoundClient:
39
+ """Owns an `httpx.AsyncClient` bound to the current event loop."""
40
+
41
+ def __init__(
42
+ self,
43
+ factory: Callable[[], httpx.AsyncClient],
44
+ *,
45
+ client: Any | None = None,
46
+ ):
47
+ self._factory = factory
48
+ self._fixed = client
49
+ self._client: httpx.AsyncClient | None = None
50
+ self._loop: asyncio.AbstractEventLoop | None = None
51
+
52
+ def get(self) -> httpx.AsyncClient:
53
+ """The client for the running loop (created/recreated as needed)."""
54
+ if self._fixed is not None:
55
+ return cast("httpx.AsyncClient", self._fixed)
56
+ loop = asyncio.get_running_loop()
57
+ if self._client is None or self._loop is not loop:
58
+ self._client = self._factory()
59
+ self._loop = loop
60
+ return self._client
61
+
62
+ async def aclose(self) -> None:
63
+ """Closes the current client, if it belongs to the running loop.
64
+
65
+ A client whose owner loop is closed (or is a different, still-running
66
+ loop) cannot be awaited here; it is dropped — nothing can safely close
67
+ it from this loop. The injected `client=`, if any, is left to its owner.
68
+ """
69
+ client, loop = self._client, self._loop
70
+ self._client = None
71
+ self._loop = None
72
+ if client is None:
73
+ return
74
+ try:
75
+ current = asyncio.get_running_loop()
76
+ except RuntimeError:
77
+ return
78
+ if loop is not None and loop is not current:
79
+ return
80
+ await client.aclose()
81
+
82
+ def raw(self) -> Any | None:
83
+ """The injected client if there is one, else the current one (or None)."""
84
+ return self._fixed if self._fixed is not None else self._client
85
+
86
+
87
+ __all__ = ["LoopBoundClient"]
@@ -0,0 +1,255 @@
1
+ """reactifact.audit — answer provenance and reproducibility (§34, §55, §56).
2
+
3
+ Two things an auditable agent needs, both offline and dependency-free:
4
+
5
+ 1. `build_report(context, answer)` — a self-contained record of *why* an answer
6
+ exists: the answer's content hash, the provenance chain behind it
7
+ (`supported_by` / `derived_from` / `materialized_from` / …), each artifact's
8
+ version, author and content hash, and the source locators it rests on.
9
+ Exportable as JSON (machine-checkable) or Markdown (human/regulator-readable).
10
+
11
+ 2. `context_hash(context)` — a sha256 over the run's canonical state
12
+ (artifacts + relations + version). Two runs that produce the same state hash
13
+ the same; recording it with a `ReplayLLM` recording turns "is this run
14
+ reproducible?" into a string comparison (`reactifact replay --verify <hash>`,
15
+ and the `fintech_audit` example).
16
+
17
+ Hashes are sha256 over canonical (sorted, JSON-safe) content, so the *hash*
18
+ fields are reproducible: the same state yields the same `context_sha256` and
19
+ per-artifact hashes, run to run. The report also carries `created_at` values
20
+ for a human reader, so the full JSON is not byte-identical across runs — the
21
+ fingerprint is.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import hashlib
27
+ import json
28
+ from collections.abc import Sequence
29
+ from datetime import datetime
30
+ from typing import Any
31
+
32
+ from pydantic import BaseModel
33
+
34
+ from .artifacts import Artifact
35
+ from .context import Context
36
+
37
+ #: Relations followed by default when walking an answer's provenance chain.
38
+ DEFAULT_RELATIONS: tuple[str, ...] = (
39
+ "supported_by",
40
+ "derived_from",
41
+ "extracted_from",
42
+ "materialized_from",
43
+ "resolved_from",
44
+ "calculated_from",
45
+ "refuted_by",
46
+ "contradicted_by",
47
+ )
48
+
49
+
50
+ def _canonical(value: Any) -> str:
51
+ """Canonical JSON for hashing (sorted keys; pydantic models dumped)."""
52
+ if isinstance(value, BaseModel):
53
+ payload: Any = value.model_dump(mode="json")
54
+ else:
55
+ payload = value
56
+ return json.dumps(payload, sort_keys=True, ensure_ascii=False, default=str)
57
+
58
+
59
+ def data_hash(value: Any) -> str:
60
+ """sha256 of a value's canonical JSON — a stable content fingerprint."""
61
+ return hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest()
62
+
63
+
64
+ def artifact_hash(artifact: Artifact[Any]) -> str:
65
+ """Content hash of one artifact's data (its type + id are not included)."""
66
+ return data_hash(artifact.data)
67
+
68
+
69
+ def context_hash(context: Context) -> str:
70
+ """sha256 over the run's canonical state: version, artifacts, relations.
71
+
72
+ Includes each artifact's id, type, version and content hash, plus every
73
+ relation edge — everything that makes two runs' states the same or
74
+ different. Timestamps are deliberately excluded, so a re-run that reaches
75
+ the same state hashes identically.
76
+ """
77
+ artifacts = [
78
+ {
79
+ "id": artifact.id,
80
+ "type": type(artifact.data).__name__,
81
+ "version": artifact.version,
82
+ "sha256": artifact_hash(artifact),
83
+ }
84
+ for artifact in sorted(context.list_artifacts(), key=lambda a: a.id)
85
+ ]
86
+ relations = sorted(
87
+ (rel.source_id, rel.relation, rel.target_id) for rel in context.relations()
88
+ )
89
+ payload = {
90
+ "version": context.version,
91
+ "artifacts": artifacts,
92
+ "relations": relations,
93
+ }
94
+ return hashlib.sha256(_canonical(payload).encode("utf-8")).hexdigest()
95
+
96
+
97
+ class ProvenanceEntry(BaseModel):
98
+ """One artifact in an answer's provenance chain, with its content hash."""
99
+
100
+ artifact_id: str
101
+ data_type: str
102
+ version: int
103
+ sha256: str
104
+ produced_by: str = ""
105
+ created_at: datetime | None = None
106
+
107
+
108
+ class AuditReport(BaseModel):
109
+ """A reproducible record of an answer and the evidence behind it."""
110
+
111
+ session_id: str = ""
112
+ context_version: int
113
+ context_sha256: str
114
+ answer: ProvenanceEntry
115
+ provenance: list[ProvenanceEntry] = []
116
+ relations: list[tuple[str, str, str]] = []
117
+ sources: list[str] = []
118
+
119
+
120
+ def _entry(artifact: Artifact[Any], produced_by: str) -> ProvenanceEntry:
121
+ return ProvenanceEntry(
122
+ artifact_id=artifact.id,
123
+ data_type=type(artifact.data).__name__,
124
+ version=artifact.version,
125
+ sha256=artifact_hash(artifact),
126
+ produced_by=produced_by,
127
+ created_at=artifact.created_at,
128
+ )
129
+
130
+
131
+ def _producers(context: Context) -> dict[str, str]:
132
+ """artifact_id → author of the commit that last wrote it (from history)."""
133
+ producers: dict[str, str] = {}
134
+ for commit in context.history():
135
+ for write in commit.writes:
136
+ producers[write.artifact_id] = commit.author
137
+ return producers
138
+
139
+
140
+ def _resolve_answer(context: Context, answer: Artifact[Any] | str) -> Artifact[Any]:
141
+ if isinstance(answer, Artifact):
142
+ return answer
143
+ artifact = context.get(answer)
144
+ if artifact is None:
145
+ raise KeyError(f"answer artifact {answer!r} not found in context")
146
+ return artifact
147
+
148
+
149
+ def build_report(
150
+ context: Context,
151
+ answer: Artifact[Any] | str,
152
+ *,
153
+ relations: Sequence[str] | None = None,
154
+ session_id: str = "",
155
+ ) -> AuditReport:
156
+ """Walks the provenance chain behind `answer` into a verifiable report.
157
+
158
+ Breadth-first over `context.relations()`, following `relations` (all by
159
+ default), so every artifact that contributed to the answer is included with
160
+ its content hash and producing author. `SourceRef` locators in the chain
161
+ become `sources`.
162
+ """
163
+ followed = set(relations) if relations is not None else set(DEFAULT_RELATIONS)
164
+ answer_artifact = _resolve_answer(context, answer)
165
+ producers = _producers(context)
166
+
167
+ entries: list[ProvenanceEntry] = []
168
+ edges: list[tuple[str, str, str]] = []
169
+ sources: list[str] = []
170
+ visited: set[str] = set()
171
+ queue = [answer_artifact]
172
+ while queue:
173
+ artifact = queue.pop(0)
174
+ if artifact.id in visited:
175
+ continue
176
+ visited.add(artifact.id)
177
+ entries.append(_entry(artifact, producers.get(artifact.id, "")))
178
+ locator = getattr(artifact.data, "locator", None)
179
+ if isinstance(locator, str) and locator and locator not in sources:
180
+ sources.append(locator)
181
+ for rel in context.relations(source_id=artifact.id):
182
+ if rel.relation not in followed:
183
+ continue
184
+ edges.append((rel.source_id, rel.relation, rel.target_id))
185
+ target = context.get(rel.target_id)
186
+ if target is not None and target.id not in visited:
187
+ queue.append(target)
188
+
189
+ return AuditReport(
190
+ session_id=session_id,
191
+ context_version=context.version,
192
+ context_sha256=context_hash(context),
193
+ answer=entries[0],
194
+ provenance=entries[1:],
195
+ relations=edges,
196
+ sources=sources,
197
+ )
198
+
199
+
200
+ def report_to_json(report: AuditReport, *, indent: int | None = 2) -> str:
201
+ """The report as canonical JSON (sorted keys → reproducible bytes)."""
202
+ return json.dumps(
203
+ report.model_dump(mode="json"),
204
+ indent=indent,
205
+ sort_keys=True,
206
+ ensure_ascii=False,
207
+ )
208
+
209
+
210
+ def report_to_markdown(report: AuditReport) -> str:
211
+ """The report as Markdown — for a PR comment, a ticket, or a regulator."""
212
+ lines = [
213
+ "# Audit report",
214
+ "",
215
+ f"- context version: {report.context_version}",
216
+ f"- context sha256: `{report.context_sha256}`",
217
+ f"- session: {report.session_id or '(none)'}",
218
+ "",
219
+ "## Answer",
220
+ "",
221
+ f"- `{report.answer.artifact_id}` ({report.answer.data_type}, "
222
+ f"v{report.answer.version})",
223
+ f"- sha256: `{report.answer.sha256}`",
224
+ f"- produced by: {report.answer.produced_by or '(seed)'}",
225
+ "",
226
+ "## Provenance",
227
+ "",
228
+ "| artifact | type | v | sha256 | produced by |",
229
+ "| --- | --- | --- | --- | --- |",
230
+ ]
231
+ for entry in [report.answer, *report.provenance]:
232
+ lines.append(
233
+ f"| `{entry.artifact_id}` | {entry.data_type} | {entry.version} | "
234
+ f"`{entry.sha256[:12]}…` | {entry.produced_by or '(seed)'} |"
235
+ )
236
+ if report.relations:
237
+ lines += ["", "## Edges", ""]
238
+ lines += [f"- `{s}` —{r}→ `{t}`" for s, r, t in report.relations]
239
+ if report.sources:
240
+ lines += ["", "## Sources", ""]
241
+ lines += [f"- `{source}`" for source in report.sources]
242
+ return "\n".join(lines) + "\n"
243
+
244
+
245
+ __all__ = [
246
+ "DEFAULT_RELATIONS",
247
+ "AuditReport",
248
+ "ProvenanceEntry",
249
+ "artifact_hash",
250
+ "build_report",
251
+ "context_hash",
252
+ "data_hash",
253
+ "report_to_json",
254
+ "report_to_markdown",
255
+ ]
@@ -35,6 +35,8 @@ class RunStats:
35
35
  runs: int
36
36
  iterations: int
37
37
  outcome: RunOutcome
38
+ #: Wall-clock duration of the turn, in **seconds** (`time.monotonic()`).
39
+ #: Distinct from `RunTrace.duration_ms`, which is in milliseconds.
38
40
  duration: float
39
41
  #: Agent executions that raised and were isolated (`Runtime(isolate_errors=True)`).
40
42
  #: Always 0 when isolation is off — an exception propagates instead (§69).