reactifact 0.10.0__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 (248) hide show
  1. {reactifact-0.10.0/reactifact.egg-info → reactifact-0.11.0}/PKG-INFO +74 -9
  2. {reactifact-0.10.0 → reactifact-0.11.0}/README.md +73 -8
  3. {reactifact-0.10.0 → reactifact-0.11.0}/pyproject.toml +2 -2
  4. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/__init__.py +1 -1
  5. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/budget.py +2 -0
  6. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/context.py +2 -0
  7. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/effects.py +9 -3
  8. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/replay.py +92 -1
  9. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/resources.py +19 -0
  10. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/runtime.py +3 -1
  11. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/testing/fault.py +15 -8
  12. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/testing/lab.py +11 -8
  13. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/testing/mock.py +24 -10
  14. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/tracing/__init__.py +20 -2
  15. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/tracing/models.py +33 -0
  16. reactifact-0.11.0/reactifact/tracing/postgres.py +567 -0
  17. reactifact-0.11.0/reactifact/tracing/store.py +647 -0
  18. reactifact-0.11.0/reactifact/tracing/templates/app.css +326 -0
  19. reactifact-0.11.0/reactifact/tracing/templates/sessions.html +145 -0
  20. reactifact-0.11.0/reactifact/tracing/templates/ui.html +487 -0
  21. reactifact-0.11.0/reactifact/tracing/templates/ui_run.html +386 -0
  22. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/tracing/tracer.py +11 -3
  23. reactifact-0.11.0/reactifact/tracing/web.py +291 -0
  24. {reactifact-0.10.0 → reactifact-0.11.0/reactifact.egg-info}/PKG-INFO +74 -9
  25. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact.egg-info/SOURCES.txt +4 -0
  26. reactifact-0.11.0/tests/test_determinism.py +155 -0
  27. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_devops_web.py +2 -1
  28. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_testing_lab.py +46 -0
  29. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_testing_mock.py +64 -1
  30. reactifact-0.11.0/tests/test_trace_tags.py +321 -0
  31. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_tracing.py +96 -0
  32. reactifact-0.10.0/reactifact/tracing/postgres.py +0 -220
  33. reactifact-0.10.0/reactifact/tracing/store.py +0 -254
  34. reactifact-0.10.0/reactifact/tracing/templates/ui.html +0 -196
  35. reactifact-0.10.0/reactifact/tracing/templates/ui_run.html +0 -264
  36. reactifact-0.10.0/reactifact/tracing/web.py +0 -117
  37. {reactifact-0.10.0 → reactifact-0.11.0}/LICENSE +0 -0
  38. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/__main__.py +0 -0
  39. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/_extras.py +0 -0
  40. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/_httpx.py +0 -0
  41. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/agent_tool.py +0 -0
  42. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/agents.py +0 -0
  43. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/artifacts.py +0 -0
  44. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/audit.py +0 -0
  45. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/branching.py +0 -0
  46. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/chat.py +0 -0
  47. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/checkpoints.py +0 -0
  48. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/cli/__init__.py +0 -0
  49. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/cli/branch.py +0 -0
  50. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/cli/common.py +0 -0
  51. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/cli/context.py +0 -0
  52. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/cli/graph.py +0 -0
  53. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/cli/replay.py +0 -0
  54. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/cli/scenario.py +0 -0
  55. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/cli/trace.py +0 -0
  56. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/commit.py +0 -0
  57. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/commit_log.py +0 -0
  58. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/consume.py +0 -0
  59. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/context_builder.py +0 -0
  60. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/eval.py +0 -0
  61. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/events.py +0 -0
  62. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/interrupt.py +0 -0
  63. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/llm_agent.py +0 -0
  64. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/mcp/__init__.py +0 -0
  65. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/mcp/client.py +0 -0
  66. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/mcp/oauth.py +0 -0
  67. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/mcp/server.py +0 -0
  68. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/native_tool_use.py +0 -0
  69. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/operations.py +0 -0
  70. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/patches.py +0 -0
  71. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/produce.py +0 -0
  72. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/prompts.py +0 -0
  73. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/providers/__init__.py +0 -0
  74. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/providers/_retry.py +0 -0
  75. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/providers/anthropic.py +0 -0
  76. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/providers/azure.py +0 -0
  77. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/providers/cerebras.py +0 -0
  78. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/providers/chat.py +0 -0
  79. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/providers/contracts.py +0 -0
  80. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/providers/deepseek.py +0 -0
  81. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/providers/fake.py +0 -0
  82. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/providers/fireworks.py +0 -0
  83. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/providers/gemini.py +0 -0
  84. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/providers/github_models.py +0 -0
  85. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/providers/groq.py +0 -0
  86. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/providers/image.py +0 -0
  87. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/providers/mistral.py +0 -0
  88. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/providers/nvidia.py +0 -0
  89. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/providers/ollama.py +0 -0
  90. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/providers/openai.py +0 -0
  91. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/providers/openrouter.py +0 -0
  92. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/providers/perplexity.py +0 -0
  93. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/providers/qwen.py +0 -0
  94. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/providers/speech.py +0 -0
  95. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/providers/together.py +0 -0
  96. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/providers/video.py +0 -0
  97. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/providers/xai.py +0 -0
  98. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/providers/zai.py +0 -0
  99. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/py.typed +0 -0
  100. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/quick/__init__.py +0 -0
  101. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/quick/_shared.py +0 -0
  102. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/quick/agent.py +0 -0
  103. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/quick/chat.py +0 -0
  104. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/quick/models.py +0 -0
  105. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/quick/rag.py +0 -0
  106. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/quick/tools_agent.py +0 -0
  107. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/recipes/__init__.py +0 -0
  108. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/recipes/cleanup.py +0 -0
  109. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/recipes/identity.py +0 -0
  110. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/recipes/inputs.py +0 -0
  111. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/recipes/memory.py +0 -0
  112. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/recipes/plan_execute.py +0 -0
  113. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/recipes/reflection.py +0 -0
  114. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/recipes/resolve.py +0 -0
  115. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/recipes/rollback.py +0 -0
  116. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/recipes/search.py +0 -0
  117. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/recipes/skills.py +0 -0
  118. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/recipes/status.py +0 -0
  119. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/recipes/supervisor.py +0 -0
  120. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/recipes/text.py +0 -0
  121. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/recipes/tool_loop.py +0 -0
  122. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/redaction.py +0 -0
  123. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/relations.py +0 -0
  124. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/request.py +0 -0
  125. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/scheduler.py +0 -0
  126. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/session.py +0 -0
  127. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/sources.py +0 -0
  128. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/streaming.py +0 -0
  129. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/structured.py +0 -0
  130. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/testing/__init__.py +0 -0
  131. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/testing/assertions.py +0 -0
  132. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/testing/exceptions.py +0 -0
  133. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/testing/golden.py +0 -0
  134. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/testing/record.py +0 -0
  135. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/testing/registry.py +0 -0
  136. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/tool_use.py +0 -0
  137. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/tools.py +0 -0
  138. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/tracing/_otlp.py +0 -0
  139. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/tracing/langfuse.py +0 -0
  140. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/tracing/otlp.py +0 -0
  141. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/triggers.py +0 -0
  142. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/verify.py +0 -0
  143. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/viz.py +0 -0
  144. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact/web.py +0 -0
  145. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact.egg-info/dependency_links.txt +0 -0
  146. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact.egg-info/entry_points.txt +0 -0
  147. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact.egg-info/requires.txt +0 -0
  148. {reactifact-0.10.0 → reactifact-0.11.0}/reactifact.egg-info/top_level.txt +0 -0
  149. {reactifact-0.10.0 → reactifact-0.11.0}/setup.cfg +0 -0
  150. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_absent_consume.py +0 -0
  151. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_adaptive.py +0 -0
  152. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_agent_tool.py +0 -0
  153. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_anthropic_provider.py +0 -0
  154. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_artifacts.py +0 -0
  155. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_audit.py +0 -0
  156. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_backbone.py +0 -0
  157. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_branching.py +0 -0
  158. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_budget.py +0 -0
  159. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_canonical_ports.py +0 -0
  160. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_chat_web.py +0 -0
  161. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_checkpoint.py +0 -0
  162. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_checkpoints_concurrency.py +0 -0
  163. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_cli.py +0 -0
  164. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_cli_scenario.py +0 -0
  165. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_commit_log.py +0 -0
  166. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_concurrency.py +0 -0
  167. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_consumes_produces.py +0 -0
  168. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_context_builder.py +0 -0
  169. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_correlated_consume.py +0 -0
  170. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_debounce.py +0 -0
  171. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_devops.py +0 -0
  172. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_effects.py +0 -0
  173. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_eval.py +0 -0
  174. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_fintech_audit.py +0 -0
  175. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_forklab.py +0 -0
  176. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_forklab_web.py +0 -0
  177. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_friendly_api.py +0 -0
  178. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_gemini_provider.py +0 -0
  179. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_golden.py +0 -0
  180. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_hitl.py +0 -0
  181. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_image_provider.py +0 -0
  182. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_incident_commander.py +0 -0
  183. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_invalidation.py +0 -0
  184. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_join_consume.py +0 -0
  185. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_knowledge.py +0 -0
  186. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_knowledge_web.py +0 -0
  187. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_llm_ladder.py +0 -0
  188. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_loop_bound_client.py +0 -0
  189. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_mcp.py +0 -0
  190. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_medic_lab.py +0 -0
  191. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_medic_lab_web.py +0 -0
  192. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_multisource.py +0 -0
  193. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_native_tool_use.py +0 -0
  194. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_openai_provider.py +0 -0
  195. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_patches.py +0 -0
  196. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_ports.py +0 -0
  197. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_produce_styles.py +0 -0
  198. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_produce_trigger.py +0 -0
  199. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_prompts.py +0 -0
  200. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_provider_auth.py +0 -0
  201. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_provider_retry.py +0 -0
  202. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_providers_integration.py +0 -0
  203. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_quick.py +0 -0
  204. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_reacts_to.py +0 -0
  205. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_recipe_plan_execute.py +0 -0
  206. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_recipe_reflection.py +0 -0
  207. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_recipe_supervisor.py +0 -0
  208. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_recipe_tool_loop.py +0 -0
  209. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_recipes.py +0 -0
  210. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_recipes_inputs.py +0 -0
  211. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_recipes_memory.py +0 -0
  212. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_recipes_skills.py +0 -0
  213. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_redaction.py +0 -0
  214. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_relation_graph.py +0 -0
  215. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_relations.py +0 -0
  216. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_repair.py +0 -0
  217. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_repair_web.py +0 -0
  218. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_replay.py +0 -0
  219. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_repo_agent.py +0 -0
  220. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_request.py +0 -0
  221. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_research.py +0 -0
  222. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_resources.py +0 -0
  223. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_retry.py +0 -0
  224. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_runtime.py +0 -0
  225. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_runtime_errors.py +0 -0
  226. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_scheduler_semantics.py +0 -0
  227. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_sessions.py +0 -0
  228. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_sources.py +0 -0
  229. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_sources_search.py +0 -0
  230. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_sources_vector.py +0 -0
  231. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_speech_provider.py +0 -0
  232. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_starter_app.py +0 -0
  233. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_streaming.py +0 -0
  234. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_structured.py +0 -0
  235. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_support_copilot.py +0 -0
  236. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_testing_assertions.py +0 -0
  237. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_testing_registry.py +0 -0
  238. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_tools.py +0 -0
  239. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_vendor_factories.py +0 -0
  240. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_vendor_multimodal_factories.py +0 -0
  241. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_verify.py +0 -0
  242. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_video_provider.py +0 -0
  243. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_view.py +0 -0
  244. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_viz.py +0 -0
  245. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_web_source.py +0 -0
  246. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_workspace.py +0 -0
  247. {reactifact-0.10.0 → reactifact-0.11.0}/tests/test_workspace_with_sources.py +0 -0
  248. {reactifact-0.10.0 → 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.10.0
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
 
@@ -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
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "reactifact"
7
- version = "0.10.0"
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"]
@@ -49,7 +49,7 @@ from .session import Session, SessionStore
49
49
  from .tools import FunctionTool, Tool, ToolOutput, tool
50
50
  from .triggers import Trigger
51
51
 
52
- __version__ = "0.10.0"
52
+ __version__ = "0.11.0"
53
53
 
54
54
  __all__ = [
55
55
  "Agent",
@@ -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).
@@ -124,6 +124,8 @@ class Context:
124
124
  """
125
125
  if id is not None and id in self._artifacts:
126
126
  return self._artifacts[id]
127
+ if id is None and self.resources.id_factory is not None:
128
+ id = self.resources.id_factory(type(data).__name__)
127
129
  artifact = Artifact(data=data, id=id)
128
130
  self._artifacts[artifact.id] = artifact
129
131
  self._by_type.setdefault(type(data), set()).add(artifact.id)
@@ -102,9 +102,15 @@ class Effects:
102
102
  site when that "may already exist" intent should be explicit instead
103
103
  of implicit in a plain `create(..., id=...)`.
104
104
  """
105
- artifact_id = id or _auto_id(type(data).__name__)
106
- self.operations.append(Create(data, id=artifact_id))
107
- return Handle(self, artifact_id, data)
105
+ if id is None:
106
+ factory = self._context.resources.id_factory
107
+ id = (
108
+ factory(type(data).__name__)
109
+ if factory is not None
110
+ else _auto_id(type(data).__name__)
111
+ )
112
+ self.operations.append(Create(data, id=id))
113
+ return Handle(self, id, data)
108
114
 
109
115
  def create_once(self, data: Any, *, id: str) -> Handle | None:
110
116
  """Idempotent create (§42): `None` if `id` already exists in the
@@ -21,17 +21,23 @@ Two complementary halves:
21
21
  from __future__ import annotations
22
22
 
23
23
  import hashlib
24
+ import itertools
24
25
  import json
25
26
  import logging
27
+ from collections.abc import Awaitable, Callable
26
28
  from pathlib import Path
27
29
  from typing import TYPE_CHECKING, Any, Literal
28
30
 
31
+ from pydantic import BaseModel
32
+
33
+ from .audit import context_hash
29
34
  from .context import Context
30
35
  from .providers import LLMProvider, LLMRequest, LLMResponse, LLMResponseChunk
31
36
 
32
37
  if TYPE_CHECKING:
33
38
  from collections.abc import AsyncIterator
34
39
 
40
+ from .resources import RuntimeResources
35
41
  from .session import SessionStore
36
42
 
37
43
  logger = logging.getLogger(__name__)
@@ -168,6 +174,83 @@ async def replay_context(
168
174
  return context
169
175
 
170
176
 
177
+ def counter_ids(start: int = 0) -> Callable[[str], str]:
178
+ """A deterministic id factory: `Model:0000`, `Model:0001`, … per run.
179
+
180
+ Assign it to `RuntimeResources(id_factory=…)` and every artifact created
181
+ without an explicit id gets a stable, order-derived id instead of a
182
+ `uuid4` — enough to make an otherwise-unmodified app's `context_hash`
183
+ reproducible run to run. Pair with `ReplayLLM` (recorded model calls) for
184
+ full reproducibility; `verify_run` wires both.
185
+ """
186
+ counter = itertools.count(start)
187
+
188
+ def make(model_name: str) -> str:
189
+ return f"{model_name.lower()}:{next(counter):04d}"
190
+
191
+ return make
192
+
193
+
194
+ class ReproReport(BaseModel):
195
+ """Outcome of `verify_run`: were repeated runs byte-identical?"""
196
+
197
+ ok: bool
198
+ hashes: list[str]
199
+ repeat: int
200
+ recording: str | None = None
201
+
202
+
203
+ #: A run builder: given resources, produce the finished `Context`.
204
+ RunBuilder = Callable[["RuntimeResources"], Awaitable["Context"]]
205
+
206
+
207
+ async def verify_run(
208
+ build: RunBuilder,
209
+ *,
210
+ recording: str | Path | None = None,
211
+ repeat: int = 2,
212
+ resources_factory: Callable[[], RuntimeResources] | None = None,
213
+ ) -> ReproReport:
214
+ """Runs `build` `repeat` times and checks the `context_hash` is identical.
215
+
216
+ Each run gets fresh resources with **deterministic ids**
217
+ (`counter_ids()`) and — when `recording` is given — a `ReplayLLM` replaying
218
+ it, so a difference between runs is real nondeterminism in the app (time,
219
+ randomness, unstable ids, order), not model variance. `build(resources)`
220
+ must build the app on the given resources and return the finished
221
+ `Context` (it must not create resources itself).
222
+
223
+ report = await verify_run(build, recording="calls.jsonl")
224
+ assert report.ok, report.hashes
225
+
226
+ Returns a `ReproReport`; `ok` is False (with every hash) when they differ.
227
+ """
228
+ if repeat < 2:
229
+ raise ValueError("verify_run needs repeat >= 2 to compare runs")
230
+ hashes: list[str] = []
231
+ for _ in range(repeat):
232
+ resources = (
233
+ resources_factory() if resources_factory is not None else _resources()
234
+ )
235
+ resources.id_factory = counter_ids()
236
+ if recording is not None:
237
+ resources.llm = ReplayLLM(recording, mode="replay")
238
+ context = await build(resources)
239
+ hashes.append(context_hash(context))
240
+ return ReproReport(
241
+ ok=len(set(hashes)) == 1,
242
+ hashes=hashes,
243
+ repeat=repeat,
244
+ recording=str(recording) if recording is not None else None,
245
+ )
246
+
247
+
248
+ def _resources() -> RuntimeResources:
249
+ from .resources import RuntimeResources
250
+
251
+ return RuntimeResources()
252
+
253
+
171
254
  def replay_summary(context: Context) -> dict[str, Any]:
172
255
  """A compact "state at this point" summary for the replay CLI."""
173
256
  artifacts = context.list_artifacts()
@@ -184,4 +267,12 @@ def replay_summary(context: Context) -> dict[str, Any]:
184
267
  }
185
268
 
186
269
 
187
- __all__ = ["ReplayLLM", "ReplayMiss", "replay_context", "replay_summary"]
270
+ __all__ = [
271
+ "ReproReport",
272
+ "ReplayLLM",
273
+ "ReplayMiss",
274
+ "counter_ids",
275
+ "replay_context",
276
+ "replay_summary",
277
+ "verify_run",
278
+ ]
@@ -15,6 +15,10 @@ if TYPE_CHECKING:
15
15
 
16
16
  T = TypeVar("T")
17
17
 
18
+ #: Mints an artifact id from its model/type name — injectable for deterministic
19
+ #: runs (`reactifact.replay.counter_ids`). `None` keeps the uuid default.
20
+ IdFactory = Callable[[str], str]
21
+
18
22
 
19
23
  class ResourceKey(Generic[T]):
20
24
  """A typed key for registering a resource when the *type* isn't a good key.
@@ -47,11 +51,17 @@ class RuntimeResources:
47
51
  context_builder: ContextBuilder | None = None,
48
52
  verification_threshold: float | None = None,
49
53
  redactor: Redactor | None = None,
54
+ id_factory: IdFactory | None = None,
50
55
  **additional: Any,
51
56
  ):
52
57
  self.llm = llm
53
58
  self.embedder = embedder
54
59
  self.sources = sources or {}
60
+ # Injected id source for artifacts created without an explicit id
61
+ # (`None` = the uuid default). A deterministic factory
62
+ # (`reactifact.replay.counter_ids`) makes an unmodified app's
63
+ # `context_hash` reproducible run to run — see `reactifact.replay.verify_run`.
64
+ self.id_factory = id_factory
55
65
  # Applied to trace text only (artifact `data`, LLM messages/responses,
56
66
  # errors) before it reaches a sink — never to the live `Context` or a
57
67
  # persisted session. `None` (default) reproduces the pre-hook behavior.
@@ -133,6 +143,15 @@ class RuntimeResources:
133
143
  """The keys of every registered typed resource (for diagnostics)."""
134
144
  return frozenset(self._typed)
135
145
 
146
+ def typed_values(self) -> list[Any]:
147
+ """Every registered typed resource value (read-only introspection).
148
+
149
+ `registered` gives the keys; this gives the values — e.g. so a generic
150
+ tool scanner (`reactifact.testing.fault`) can find a `list[Tool]`
151
+ registered with `register(...)`, not just one stashed via `set(...)`.
152
+ """
153
+ return list(self._typed.values())
154
+
136
155
  def set(self, name: str, value: Any) -> None:
137
156
  self.additional[name] = value
138
157
 
@@ -498,7 +498,9 @@ class Runtime:
498
498
  self._warn_no_runs()
499
499
  await self._trace.end_turn(
500
500
  session_id=self.session.session_id if self.session is not None else "",
501
- duration_ms=time.monotonic() - self._turn_started_at,
501
+ # `time.monotonic()` is seconds; the trace's `duration_ms` (and every
502
+ # sink/UI reading it) is milliseconds — same unit as span latency.
503
+ duration_ms=(time.monotonic() - self._turn_started_at) * 1000,
502
504
  outcome=self.outcome.value,
503
505
  )
504
506
  if self.session is not None and self.session_save_policy == "per_turn":
@@ -108,17 +108,24 @@ def _iter_tool_dicts(agents: Sequence[Agent]) -> Iterator[dict[str, Tool]]:
108
108
 
109
109
 
110
110
  def _iter_tool_lists(resources: RuntimeResources | None) -> Iterator[list[Tool]]:
111
- """Yields every `list[Tool]`-shaped value found in `resources.additional`.
112
-
113
- Covers tools resolved dynamically at produce-time (`context.resources.get(...)`)
114
- rather than fixed on a `Produce` instance at construction — `_iter_tool_dicts`
115
- has nothing to scan for those, since no static attribute holds them. Same
116
- duck-typing as `_iter_tool_dicts`: any non-empty list whose items all have
117
- `.execute` is treated as a tool list, whatever key it's stored under.
111
+ """Yields every `list[Tool]`-shaped value found on `resources`.
112
+
113
+ Covers tools resolved dynamically at produce-time
114
+ (`context.resources.get(...)` / `resources.require(...)`) rather than fixed
115
+ on a `Produce` instance at construction `_iter_tool_dicts` has nothing to
116
+ scan for those, since no static attribute holds them. Both storage shapes
117
+ are scanned: the string-keyed `additional` escape hatch, and resources
118
+ registered typedly via `resources.register(...)` (`_typed`, surfaced by
119
+ `RuntimeResources.typed_values()`). Missing the latter silently made
120
+ `result.tools.called/never_called` blind to tools registered that way.
121
+
122
+ Same duck-typing as `_iter_tool_dicts`: any non-empty list whose items all
123
+ have `.execute` is treated as a tool list, wherever it is stored. The
124
+ caller de-dupes by identity, so a list present in both is wrapped once.
118
125
  """
119
126
  if resources is None:
120
127
  return
121
- for value in resources.additional.values():
128
+ for value in (*resources.additional.values(), *resources.typed_values()):
122
129
  if (
123
130
  isinstance(value, list)
124
131
  and value
@@ -33,7 +33,7 @@ from pydantic import BaseModel
33
33
  from reactifact.agents import Agent
34
34
  from reactifact.budget import Budget, RunStats
35
35
  from reactifact.context import Context
36
- from reactifact.resources import RuntimeResources
36
+ from reactifact.resources import ResourceKey, RuntimeResources
37
37
  from reactifact.runtime import Runtime
38
38
  from reactifact.streaming import ProgressEvent, QueueEvent
39
39
  from reactifact.tracing.models import RunTrace
@@ -176,27 +176,30 @@ class ScenarioLab:
176
176
 
177
177
  def fail_resource(
178
178
  self,
179
- name: str,
179
+ resource: str | type[Any] | ResourceKey[Any],
180
180
  error: BaseException | Callable[[], BaseException],
181
181
  *,
182
182
  method: str | None = None,
183
183
  times: int | None = None,
184
184
  ) -> None:
185
- """Queues a fault for a named resource — the general-purpose analog
186
- of `fail()` for anything that isn't a tool: `"llm"`, `"embedder"`, a
187
- source id (`resources.sources[id]`), or a name set via
188
- `resources.set(name, ...)`.
185
+ """Queues a fault for a resource — the general-purpose analog of
186
+ `fail()` for anything that isn't a tool.
187
+
188
+ `resource` addresses it either by **string name** — `"llm"`,
189
+ `"embedder"`, a source id (`resources.sources[id]`), or a name set via
190
+ `resources.set(name, ...)` — or by a **typed key**: a class or a
191
+ `ResourceKey` registered with `resources.register(...)`.
189
192
 
190
193
  Wraps the resource in a duck-typed proxy for the next `run()`/
191
194
  `.turn()`: `method=None` (default) fails every callable on it;
192
195
  naming one method (e.g. `"embed"`, `"search"`) faults only that
193
196
  method. `times=None` faults every call; `times=N` faults the first
194
197
  `N`, then delegates to the real resource — same shape as `fail()`.
195
- Raises `ScenarioError` at run time if `name` doesn't match any
198
+ Raises `ScenarioError` at run time if `resource` doesn't match any
196
199
  resource, or matches one that's `None` (nothing configured to fail).
197
200
  """
198
201
  self._resource_faults.append(
199
- ResourceFault(name, error, method=method, times=times)
202
+ ResourceFault(resource, error, method=method, times=times)
200
203
  )
201
204
 
202
205
  def _build_resources(self) -> RuntimeResources:
@@ -24,15 +24,17 @@ from typing import TYPE_CHECKING, Any
24
24
  from .exceptions import ScenarioError
25
25
 
26
26
  if TYPE_CHECKING:
27
- from reactifact.resources import RuntimeResources
27
+ from reactifact.resources import ResourceKey, RuntimeResources
28
28
 
29
29
 
30
30
  @dataclass
31
31
  class ResourceFault:
32
- """A queued fault for one named resource.
32
+ """A queued fault for one resource.
33
33
 
34
- `resource` is `"llm"`, `"embedder"`, a source id (`resources.sources
35
- [id]`), or a name set via `resources.set(name, ...)`. `method=None`
34
+ `resource` addresses it either by **string name** — `"llm"`, `"embedder"`,
35
+ a source id (`resources.sources[id]`), or a name set via
36
+ `resources.set(name, ...)` — or by a **typed key**: a class or a
37
+ `ResourceKey` registered with `resources.register(...)`. `method=None`
36
38
  (default) fails every callable on the resource; naming one (e.g.
37
39
  `"embed"`) faults only that method, leaving the rest of the resource
38
40
  working normally. `times=None` faults every call; `times=N` faults the
@@ -40,15 +42,20 @@ class ResourceFault:
40
42
  `fault.ToolFault`.
41
43
  """
42
44
 
43
- resource: str
45
+ resource: str | type[Any] | ResourceKey[Any]
44
46
  error: BaseException | Callable[[], BaseException]
45
47
  method: str | None = None
46
48
  times: int | None = None
47
49
 
48
50
 
49
- def _get_resource(resources: RuntimeResources, name: str) -> tuple[Any, bool]:
51
+ def _get_resource(
52
+ resources: RuntimeResources, key: str | type[Any] | ResourceKey[Any]
53
+ ) -> tuple[Any, bool]:
50
54
  """Returns `(value, found)` — `found=False` means no such resource exists
51
55
  at all (as opposed to existing but being `None`)."""
56
+ if not isinstance(key, str):
57
+ return (resources.get(key), True) if resources.has(key) else (None, False)
58
+ name = key
52
59
  if name == "llm":
53
60
  return resources.llm, True
54
61
  if name == "embedder":
@@ -60,7 +67,13 @@ def _get_resource(resources: RuntimeResources, name: str) -> tuple[Any, bool]:
60
67
  return None, False
61
68
 
62
69
 
63
- def _set_resource(resources: RuntimeResources, name: str, value: Any) -> None:
70
+ def _set_resource(
71
+ resources: RuntimeResources, key: str | type[Any] | ResourceKey[Any], value: Any
72
+ ) -> None:
73
+ if not isinstance(key, str):
74
+ resources.register(key, value)
75
+ return
76
+ name = key
64
77
  if name == "llm":
65
78
  resources.llm = value
66
79
  elif name == "embedder":
@@ -140,7 +153,7 @@ class ResourceFaultInstaller:
140
153
  ) -> None:
141
154
  self._resources = resources
142
155
  self._faults = {f.resource: f for f in faults}
143
- self._originals: list[tuple[str, Any]] = []
156
+ self._originals: list[tuple[str | type[Any] | ResourceKey[Any], Any]] = []
144
157
 
145
158
  def __enter__(self) -> ResourceFaultInstaller:
146
159
  for name, fault in self._faults.items():
@@ -148,8 +161,9 @@ class ResourceFaultInstaller:
148
161
  if not found:
149
162
  raise ScenarioError(
150
163
  f"fail_resource({name!r}, ...): no such resource — expected "
151
- '"llm", "embedder", a source id, or a name set via '
152
- "resources.set(...)"
164
+ '"llm", "embedder", a source id, a name set via '
165
+ "resources.set(...), or a key registered via "
166
+ "resources.register(Type | ResourceKey, ...)"
153
167
  )
154
168
  if original is None:
155
169
  raise ScenarioError(
@@ -6,10 +6,24 @@ but without `web`, so that importing the package does not pull in FastAPI.
6
6
  """
7
7
 
8
8
  from .langfuse import LangfuseTracer
9
- from .models import AgentSpan, ArtifactRef, LLMCall, RelationRef, RunTrace
9
+ from .models import (
10
+ AgentSpan,
11
+ ArtifactRef,
12
+ LLMCall,
13
+ RelationRef,
14
+ RunTrace,
15
+ Tag,
16
+ TagAssignment,
17
+ )
10
18
  from .otlp import OTLPTracer
11
19
  from .postgres import PostgresStore
12
- from .store import TraceReader, TraceSink, TraceStore
20
+ from .store import (
21
+ TraceAnnotator,
22
+ TraceReader,
23
+ TraceSink,
24
+ TraceStore,
25
+ TraceStoreProtocol,
26
+ )
13
27
  from .tracer import CompositeTracer, RecordingLLM, RunTracer, Tracer
14
28
 
15
29
  __all__ = [
@@ -24,8 +38,12 @@ __all__ = [
24
38
  "RelationRef",
25
39
  "RunTrace",
26
40
  "RunTracer",
41
+ "Tag",
42
+ "TagAssignment",
43
+ "TraceAnnotator",
27
44
  "TraceReader",
28
45
  "TraceSink",
29
46
  "TraceStore",
47
+ "TraceStoreProtocol",
30
48
  "Tracer",
31
49
  ]