reactifact 0.9.1__tar.gz → 0.10.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 (240) hide show
  1. {reactifact-0.9.1/reactifact.egg-info → reactifact-0.10.0}/PKG-INFO +9 -3
  2. {reactifact-0.9.1 → reactifact-0.10.0}/README.md +8 -2
  3. {reactifact-0.9.1 → reactifact-0.10.0}/pyproject.toml +4 -1
  4. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/__init__.py +6 -2
  5. reactifact-0.10.0/reactifact/_httpx.py +87 -0
  6. reactifact-0.10.0/reactifact/audit.py +255 -0
  7. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/chat.py +44 -7
  8. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/checkpoints.py +29 -0
  9. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/cli/replay.py +20 -0
  10. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/effects.py +27 -0
  11. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/produce.py +13 -1
  12. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/prompts.py +70 -2
  13. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/providers/anthropic.py +8 -9
  14. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/providers/chat.py +15 -18
  15. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/providers/contracts.py +4 -0
  16. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/providers/gemini.py +8 -9
  17. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/providers/image.py +15 -15
  18. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/providers/speech.py +27 -28
  19. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/providers/video.py +10 -11
  20. reactifact-0.10.0/reactifact/quick/__init__.py +66 -0
  21. reactifact-0.10.0/reactifact/quick/_shared.py +72 -0
  22. reactifact-0.10.0/reactifact/quick/agent.py +147 -0
  23. reactifact-0.10.0/reactifact/quick/chat.py +85 -0
  24. reactifact-0.10.0/reactifact/quick/models.py +47 -0
  25. reactifact-0.10.0/reactifact/quick/rag.py +407 -0
  26. reactifact-0.10.0/reactifact/quick/tools_agent.py +167 -0
  27. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/recipes/__init__.py +4 -0
  28. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/recipes/text.py +22 -1
  29. reactifact-0.10.0/reactifact/recipes/tool_loop.py +190 -0
  30. reactifact-0.10.0/reactifact/redaction.py +94 -0
  31. reactifact-0.10.0/reactifact/request.py +56 -0
  32. reactifact-0.10.0/reactifact/resources.py +194 -0
  33. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/runtime.py +34 -8
  34. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/sources.py +14 -7
  35. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/structured.py +79 -20
  36. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/testing/__init__.py +6 -0
  37. reactifact-0.10.0/reactifact/testing/golden.py +110 -0
  38. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/tools.py +21 -1
  39. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/tracing/langfuse.py +7 -7
  40. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/tracing/models.py +3 -0
  41. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/tracing/otlp.py +7 -7
  42. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/tracing/tracer.py +43 -8
  43. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/web.py +64 -11
  44. {reactifact-0.9.1 → reactifact-0.10.0/reactifact.egg-info}/PKG-INFO +9 -3
  45. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact.egg-info/SOURCES.txt +25 -0
  46. reactifact-0.10.0/tests/test_audit.py +134 -0
  47. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_chat_web.py +108 -0
  48. reactifact-0.10.0/tests/test_fintech_audit.py +40 -0
  49. reactifact-0.10.0/tests/test_golden.py +87 -0
  50. reactifact-0.10.0/tests/test_loop_bound_client.py +72 -0
  51. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_prompts.py +36 -0
  52. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_provider_auth.py +7 -2
  53. reactifact-0.10.0/tests/test_quick.py +325 -0
  54. reactifact-0.10.0/tests/test_recipe_tool_loop.py +160 -0
  55. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_recipes.py +12 -0
  56. reactifact-0.10.0/tests/test_redaction.py +148 -0
  57. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_repair.py +47 -0
  58. reactifact-0.10.0/tests/test_repo_agent.py +60 -0
  59. reactifact-0.10.0/tests/test_request.py +88 -0
  60. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_resources.py +66 -1
  61. reactifact-0.10.0/tests/test_scheduler_semantics.py +120 -0
  62. reactifact-0.10.0/tests/test_starter_app.py +87 -0
  63. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_structured.py +105 -0
  64. reactifact-0.10.0/tests/test_support_copilot.py +52 -0
  65. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_tracing.py +26 -0
  66. reactifact-0.9.1/reactifact/resources.py +0 -69
  67. {reactifact-0.9.1 → reactifact-0.10.0}/LICENSE +0 -0
  68. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/__main__.py +0 -0
  69. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/_extras.py +0 -0
  70. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/agent_tool.py +0 -0
  71. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/agents.py +0 -0
  72. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/artifacts.py +0 -0
  73. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/branching.py +0 -0
  74. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/budget.py +0 -0
  75. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/cli/__init__.py +0 -0
  76. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/cli/branch.py +0 -0
  77. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/cli/common.py +0 -0
  78. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/cli/context.py +0 -0
  79. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/cli/graph.py +0 -0
  80. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/cli/scenario.py +0 -0
  81. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/cli/trace.py +0 -0
  82. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/commit.py +0 -0
  83. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/commit_log.py +0 -0
  84. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/consume.py +0 -0
  85. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/context.py +0 -0
  86. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/context_builder.py +0 -0
  87. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/eval.py +0 -0
  88. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/events.py +0 -0
  89. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/interrupt.py +0 -0
  90. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/llm_agent.py +0 -0
  91. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/mcp/__init__.py +0 -0
  92. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/mcp/client.py +0 -0
  93. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/mcp/oauth.py +0 -0
  94. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/mcp/server.py +0 -0
  95. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/native_tool_use.py +0 -0
  96. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/operations.py +0 -0
  97. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/patches.py +0 -0
  98. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/providers/__init__.py +0 -0
  99. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/providers/_retry.py +0 -0
  100. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/providers/azure.py +0 -0
  101. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/providers/cerebras.py +0 -0
  102. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/providers/deepseek.py +0 -0
  103. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/providers/fake.py +0 -0
  104. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/providers/fireworks.py +0 -0
  105. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/providers/github_models.py +0 -0
  106. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/providers/groq.py +0 -0
  107. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/providers/mistral.py +0 -0
  108. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/providers/nvidia.py +0 -0
  109. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/providers/ollama.py +0 -0
  110. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/providers/openai.py +0 -0
  111. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/providers/openrouter.py +0 -0
  112. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/providers/perplexity.py +0 -0
  113. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/providers/qwen.py +0 -0
  114. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/providers/together.py +0 -0
  115. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/providers/xai.py +0 -0
  116. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/providers/zai.py +0 -0
  117. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/py.typed +0 -0
  118. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/recipes/cleanup.py +0 -0
  119. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/recipes/identity.py +0 -0
  120. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/recipes/inputs.py +0 -0
  121. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/recipes/memory.py +0 -0
  122. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/recipes/plan_execute.py +0 -0
  123. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/recipes/reflection.py +0 -0
  124. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/recipes/resolve.py +0 -0
  125. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/recipes/rollback.py +0 -0
  126. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/recipes/search.py +0 -0
  127. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/recipes/skills.py +0 -0
  128. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/recipes/status.py +0 -0
  129. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/recipes/supervisor.py +0 -0
  130. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/relations.py +0 -0
  131. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/replay.py +0 -0
  132. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/scheduler.py +0 -0
  133. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/session.py +0 -0
  134. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/streaming.py +0 -0
  135. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/testing/assertions.py +0 -0
  136. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/testing/exceptions.py +0 -0
  137. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/testing/fault.py +0 -0
  138. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/testing/lab.py +0 -0
  139. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/testing/mock.py +0 -0
  140. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/testing/record.py +0 -0
  141. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/testing/registry.py +0 -0
  142. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/tool_use.py +0 -0
  143. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/tracing/__init__.py +0 -0
  144. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/tracing/_otlp.py +0 -0
  145. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/tracing/postgres.py +0 -0
  146. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/tracing/store.py +0 -0
  147. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/tracing/templates/ui.html +0 -0
  148. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/tracing/templates/ui_run.html +0 -0
  149. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/tracing/web.py +0 -0
  150. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/triggers.py +0 -0
  151. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/verify.py +0 -0
  152. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/viz.py +0 -0
  153. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact.egg-info/dependency_links.txt +0 -0
  154. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact.egg-info/entry_points.txt +0 -0
  155. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact.egg-info/requires.txt +0 -0
  156. {reactifact-0.9.1 → reactifact-0.10.0}/reactifact.egg-info/top_level.txt +0 -0
  157. {reactifact-0.9.1 → reactifact-0.10.0}/setup.cfg +0 -0
  158. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_absent_consume.py +0 -0
  159. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_adaptive.py +0 -0
  160. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_agent_tool.py +0 -0
  161. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_anthropic_provider.py +0 -0
  162. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_artifacts.py +0 -0
  163. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_backbone.py +0 -0
  164. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_branching.py +0 -0
  165. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_budget.py +0 -0
  166. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_canonical_ports.py +0 -0
  167. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_checkpoint.py +0 -0
  168. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_checkpoints_concurrency.py +0 -0
  169. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_cli.py +0 -0
  170. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_cli_scenario.py +0 -0
  171. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_commit_log.py +0 -0
  172. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_concurrency.py +0 -0
  173. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_consumes_produces.py +0 -0
  174. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_context_builder.py +0 -0
  175. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_correlated_consume.py +0 -0
  176. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_debounce.py +0 -0
  177. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_devops.py +0 -0
  178. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_devops_web.py +0 -0
  179. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_effects.py +0 -0
  180. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_eval.py +0 -0
  181. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_forklab.py +0 -0
  182. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_forklab_web.py +0 -0
  183. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_friendly_api.py +0 -0
  184. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_gemini_provider.py +0 -0
  185. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_hitl.py +0 -0
  186. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_image_provider.py +0 -0
  187. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_incident_commander.py +0 -0
  188. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_invalidation.py +0 -0
  189. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_join_consume.py +0 -0
  190. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_knowledge.py +0 -0
  191. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_knowledge_web.py +0 -0
  192. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_llm_ladder.py +0 -0
  193. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_mcp.py +0 -0
  194. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_medic_lab.py +0 -0
  195. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_medic_lab_web.py +0 -0
  196. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_multisource.py +0 -0
  197. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_native_tool_use.py +0 -0
  198. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_openai_provider.py +0 -0
  199. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_patches.py +0 -0
  200. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_ports.py +0 -0
  201. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_produce_styles.py +0 -0
  202. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_produce_trigger.py +0 -0
  203. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_provider_retry.py +0 -0
  204. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_providers_integration.py +0 -0
  205. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_reacts_to.py +0 -0
  206. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_recipe_plan_execute.py +0 -0
  207. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_recipe_reflection.py +0 -0
  208. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_recipe_supervisor.py +0 -0
  209. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_recipes_inputs.py +0 -0
  210. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_recipes_memory.py +0 -0
  211. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_recipes_skills.py +0 -0
  212. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_relation_graph.py +0 -0
  213. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_relations.py +0 -0
  214. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_repair_web.py +0 -0
  215. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_replay.py +0 -0
  216. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_research.py +0 -0
  217. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_retry.py +0 -0
  218. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_runtime.py +0 -0
  219. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_runtime_errors.py +0 -0
  220. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_sessions.py +0 -0
  221. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_sources.py +0 -0
  222. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_sources_search.py +0 -0
  223. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_sources_vector.py +0 -0
  224. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_speech_provider.py +0 -0
  225. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_streaming.py +0 -0
  226. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_testing_assertions.py +0 -0
  227. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_testing_lab.py +0 -0
  228. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_testing_mock.py +0 -0
  229. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_testing_registry.py +0 -0
  230. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_tools.py +0 -0
  231. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_vendor_factories.py +0 -0
  232. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_vendor_multimodal_factories.py +0 -0
  233. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_verify.py +0 -0
  234. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_video_provider.py +0 -0
  235. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_view.py +0 -0
  236. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_viz.py +0 -0
  237. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_web_source.py +0 -0
  238. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_workspace.py +0 -0
  239. {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_workspace_with_sources.py +0 -0
  240. {reactifact-0.9.1 → reactifact-0.10.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.10.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
@@ -250,6 +250,9 @@ uv run python ./examples/devops/web.py # HITL ops assistant + trace dash
250
250
  Classic-pattern ports run as one-liners too:
251
251
  `python -m examples.{reflection,map_reduce,supervisor,summarize,time_travel,plan_execute,adaptive,ledger}.main`.
252
252
 
253
+ 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)
254
+ — `examples/quickstart.ipynb` covers the four `quick` cases (`agent`/`rag`/`tools_agent`/`chat_agent`), offline by default.
255
+
253
256
  ## Examples (in-repo, not shipped)
254
257
 
255
258
  - `knowledge` — multi-source chat: search → evidence → claim verification → answer, with CSV calculation.
@@ -267,8 +270,11 @@ Classic-pattern ports run as one-liners too:
267
270
 
268
271
  - [English](docs/en/index.md) · [Русский](docs/ru/index.md) — concepts, sources,
269
272
  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.
273
+ - [Quickstart](docs/en/quickstart.md) — the four `quick` cases (structured call,
274
+ RAG, tools, chat) in a few lines; also as a
275
+ [Colab notebook](examples/quickstart.ipynb).
276
+ - [Migrating](docs/en/migrating.md) — from LangGraph / CrewAI / LlamaIndex /
277
+ plain Python: concept map, port-one-node, interop, checklist.
272
278
  - [Why reactifact](docs/en/why-reactifact.md) — the *design argument*: why effects, why no graph, why determinism.
273
279
  - [Comparison](docs/en/comparison.md) — reactifact vs LangGraph/CrewAI, feature by feature, and when *not* to use reactifact.
274
280
  - [Tutorial · llm-ladder](docs/en/examples.md#tutorial-ladder) — learn the workflow.
@@ -206,6 +206,9 @@ uv run python ./examples/devops/web.py # HITL ops assistant + trace dash
206
206
  Classic-pattern ports run as one-liners too:
207
207
  `python -m examples.{reflection,map_reduce,supervisor,summarize,time_travel,plan_execute,adaptive,ledger}.main`.
208
208
 
209
+ 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)
210
+ — `examples/quickstart.ipynb` covers the four `quick` cases (`agent`/`rag`/`tools_agent`/`chat_agent`), offline by default.
211
+
209
212
  ## Examples (in-repo, not shipped)
210
213
 
211
214
  - `knowledge` — multi-source chat: search → evidence → claim verification → answer, with CSV calculation.
@@ -223,8 +226,11 @@ Classic-pattern ports run as one-liners too:
223
226
 
224
227
  - [English](docs/en/index.md) · [Русский](docs/ru/index.md) — concepts, sources,
225
228
  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.
229
+ - [Quickstart](docs/en/quickstart.md) — the four `quick` cases (structured call,
230
+ RAG, tools, chat) in a few lines; also as a
231
+ [Colab notebook](examples/quickstart.ipynb).
232
+ - [Migrating](docs/en/migrating.md) — from LangGraph / CrewAI / LlamaIndex /
233
+ plain Python: concept map, port-one-node, interop, checklist.
228
234
  - [Why reactifact](docs/en/why-reactifact.md) — the *design argument*: why effects, why no graph, why determinism.
229
235
  - [Comparison](docs/en/comparison.md) — reactifact vs LangGraph/CrewAI, feature by feature, and when *not* to use reactifact.
230
236
  - [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.10.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"
@@ -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.10.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
+ ]
@@ -18,7 +18,13 @@ from __future__ import annotations
18
18
  import asyncio
19
19
  import inspect
20
20
  import logging
21
- from collections.abc import AsyncGenerator, AsyncIterator, Callable, Sequence
21
+ from collections.abc import (
22
+ AsyncGenerator,
23
+ AsyncIterator,
24
+ Callable,
25
+ Mapping,
26
+ Sequence,
27
+ )
22
28
  from contextlib import asynccontextmanager
23
29
  from typing import Any, Literal
24
30
 
@@ -35,15 +41,24 @@ from .session import Session, SessionStore
35
41
  logger = logging.getLogger("reactifact.chat")
36
42
 
37
43
 
44
+ #: The three frame kinds of the canonical chat (start / progress / terminal).
45
+ ChatEventKind = Literal["session", "status", "message"]
46
+
47
+
38
48
  class ChatEvent(BaseModel):
39
49
  """One frame of a chat turn, transport-neutral.
40
50
 
41
51
  `kind` is the wire contract of the canonical chat:
42
52
  ``session`` (start), ``status`` (progress announcement) or ``message``
43
- (terminal reply).
53
+ (terminal reply). It is a closed `Literal`, so the schema is
54
+ introspectable (`ChatEvent.model_json_schema()`) and a typo is a type
55
+ error, not a frame a client silently ignores. To *rename* these on the
56
+ wire for a client that speaks a different vocabulary, configure
57
+ `reactifact.web.create_chat_router(event_names=…)` — don't fork the
58
+ router.
44
59
  """
45
60
 
46
- kind: str
61
+ kind: ChatEventKind
47
62
  session_id: str = ""
48
63
  message: str = ""
49
64
  waiting: bool = False
@@ -68,6 +83,7 @@ async def run_message(
68
83
  create_message: Callable[..., str] | None = None,
69
84
  status_kinds: Sequence[str] = ("status",),
70
85
  fallback_reply: str = "No reply assembled.",
86
+ request: Mapping[str, Any] | None = None,
71
87
  ) -> AsyncIterator[ChatEvent]:
72
88
  """Run one user turn: create the input artifact, stream status events,
73
89
  emit the terminal reply.
@@ -111,7 +127,7 @@ async def run_message(
111
127
 
112
128
  last: str | None = None
113
129
  try:
114
- async for event in runtime.astream():
130
+ async for event in runtime.astream(request=request):
115
131
  if event.kind not in forwarded:
116
132
  continue
117
133
  if event.message == last:
@@ -342,13 +358,26 @@ class ChatAssistant:
342
358
  session_save_policy=self._session_save_policy,
343
359
  )
344
360
 
345
- async def stream(self, text: str, session_id: str = "") -> AsyncIterator[ChatEvent]:
361
+ async def stream(
362
+ self,
363
+ text: str,
364
+ session_id: str = "",
365
+ *,
366
+ request: Mapping[str, Any] | None = None,
367
+ ) -> AsyncIterator[ChatEvent]:
346
368
  """Stream one turn: ``session`` → ``status``… → ``message``.
347
369
 
348
370
  Never raises for app-level failures: session open / runtime crash /
349
371
  reply hook all degrade to the fallback `message` and are logged via the
350
372
  `reactifact.chat` logger, so a web layer never delivers a 500 mid-stream.
351
373
 
374
+ **Cancellation contract:** closing/`aclose()`-ing the returned generator
375
+ (what a dropped SSE client does to the server's streaming task) cancels
376
+ the in-flight turn — the `CancelledError` unwinds through `run_message`
377
+ to `Runtime.astream`, whose `finally` cancels the runner task, so a slow
378
+ agent or LLM call is stopped rather than left burning tokens. `stream`
379
+ still saves the session in its own `finally` first.
380
+
352
381
  Turns on the same `session_id` are serialized (`_locked_session`): a
353
382
  second concurrent call for the same session waits for the first to
354
383
  finish instead of racing it to `session.save()` (§59 — no silent lost
@@ -382,6 +411,7 @@ class ChatAssistant:
382
411
  status_kinds=self._status_kinds,
383
412
  fallback_reply=self._fallback_reply,
384
413
  session_id=session_id,
414
+ request=request,
385
415
  ):
386
416
  yield event
387
417
  finally:
@@ -405,10 +435,16 @@ class ChatAssistant:
405
435
  session_id,
406
436
  )
407
437
 
408
- async def invoke(self, text: str, session_id: str = "") -> dict[str, Any]:
438
+ async def invoke(
439
+ self,
440
+ text: str,
441
+ session_id: str = "",
442
+ *,
443
+ request: Mapping[str, Any] | None = None,
444
+ ) -> dict[str, Any]:
409
445
  """Run one turn and return the terminal reply (aggregated stream)."""
410
446
  message: dict[str, Any] = {}
411
- async for event in self.stream(text, session_id=session_id):
447
+ async for event in self.stream(text, session_id=session_id, request=request):
412
448
  if event.kind == "message":
413
449
  message = dict(event.payload) or {"reply": self._fallback_reply}
414
450
  return message
@@ -434,6 +470,7 @@ class ChatAssistant:
434
470
  __all__ = [
435
471
  "ChatAssistant",
436
472
  "ChatEvent",
473
+ "ChatEventKind",
437
474
  "default_session_state",
438
475
  "run_message",
439
476
  ]
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
+ import copy
4
5
  import json
5
6
  import sqlite3
6
7
  import time
@@ -46,6 +47,34 @@ class KVBackend(ABC):
46
47
  """Releases held resources (a pooled connection, ...). No-op by default."""
47
48
 
48
49
 
50
+ class InMemoryKVBackend(KVBackend):
51
+ """Dict-backed KV store — no files, no external service.
52
+
53
+ The zero-config backend: ideal for tests, notebooks, and short-lived
54
+ scripts where a session only needs to survive within one process. State
55
+ is lost on exit (use `FileKVBackend`/`SQLiteKVBackend` to persist). Values
56
+ are deep-copied on the way in and out so callers can never alias the
57
+ stored state, matching the serialization boundary the file/SQL backends
58
+ impose.
59
+ """
60
+
61
+ def __init__(self) -> None:
62
+ self._data: dict[str, dict[str, Any]] = {}
63
+
64
+ async def set(self, key: str, data: dict[str, Any]) -> None:
65
+ self._data[key] = copy.deepcopy(data)
66
+
67
+ async def get(self, key: str) -> dict[str, Any] | None:
68
+ data = self._data.get(key)
69
+ return copy.deepcopy(data) if data is not None else None
70
+
71
+ async def delete(self, key: str) -> None:
72
+ self._data.pop(key, None)
73
+
74
+ async def keys(self) -> list[str]:
75
+ return list(self._data)
76
+
77
+
49
78
  class FileKVBackend(KVBackend):
50
79
  """File KV backend: one JSON file per key in a directory.
51
80
 
@@ -37,6 +37,17 @@ async def _cmd_replay(args: argparse.Namespace) -> int:
37
37
  )
38
38
  for tname, count in sorted(summary["by_type"].items()):
39
39
  print(f" {tname}: {count}")
40
+ if args.verify is not None or args.hash:
41
+ from ..audit import context_hash
42
+
43
+ digest = context_hash(context)
44
+ print(f"context sha256: {digest}")
45
+ if args.verify is not None:
46
+ if digest == args.verify:
47
+ print("verified: state matches the expected hash")
48
+ else:
49
+ print(f"MISMATCH: expected {args.verify}")
50
+ return 1
40
51
  if args.diagram:
41
52
  from ..viz import context_to_mermaid
42
53
 
@@ -63,6 +74,15 @@ def add_parser(sub: argparse._SubParsersAction[argparse.ArgumentParser]) -> None
63
74
  "--version", type=int, default=None, help="replay to this commit"
64
75
  )
65
76
  p_replay.add_argument("--backend", choices=["file", "sqlite"], default="auto")
77
+ p_replay.add_argument(
78
+ "--hash", action="store_true", help="print the context's content hash"
79
+ )
80
+ p_replay.add_argument(
81
+ "--verify",
82
+ default=None,
83
+ metavar="HASH",
84
+ help="exit non-zero unless the context hash equals HASH (reproducibility check)",
85
+ )
66
86
  p_replay.add_argument(
67
87
  "--diagram", action="store_true", help="also print the provenance graph"
68
88
  )
@@ -125,6 +125,33 @@ class Effects:
125
125
  return None
126
126
  return self.create(data, id=id)
127
127
 
128
+ def create_once_from(
129
+ self,
130
+ source: Artifact[Any] | Handle | str,
131
+ data: Any,
132
+ *,
133
+ prefix: str | None = None,
134
+ ) -> Handle | None:
135
+ """Idempotent create whose id is derived from another artifact (§42).
136
+
137
+ Folds the single most common way a re-derivable id is built by hand —
138
+ `f"answer:{question.id}"`, `f"review:{pr.id}"` — into the call, so a
139
+ produce doesn't have to invent the id string (nor get it subtly wrong
140
+ between its guard and its `create`):
141
+
142
+ answer = self.effects.create_once_from(question, Answer(...))
143
+ if answer is None:
144
+ return None # already answered this question
145
+
146
+ `prefix` defaults to the created model's class name, lowercased
147
+ (`Answer` → `answer:{source_id}`). `source` may be an `Artifact`, an
148
+ effects `Handle`, or a plain id string. `None` back means an artifact
149
+ with the derived id already exists — skip, exactly like `create_once`.
150
+ """
151
+ source_id = _id_of(source)
152
+ artifact_id = f"{prefix or type(data).__name__.lower()}:{source_id}"
153
+ return self.create_once(data, id=artifact_id)
154
+
128
155
  def upsert(self, data: Any, *, id: str) -> Handle:
129
156
  """Explicit create-or-refresh: same effect as `create(data, id=id)`.
130
157