reactifact 0.7.0__tar.gz → 0.9.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 (220) hide show
  1. {reactifact-0.7.0/reactifact.egg-info → reactifact-0.9.0}/PKG-INFO +16 -9
  2. {reactifact-0.7.0 → reactifact-0.9.0}/README.md +8 -7
  3. {reactifact-0.7.0 → reactifact-0.9.0}/pyproject.toml +14 -2
  4. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/__init__.py +3 -2
  5. reactifact-0.9.0/reactifact/agent_tool.py +115 -0
  6. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/agents.py +89 -12
  7. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/branching.py +2 -0
  8. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/chat.py +73 -7
  9. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/checkpoints.py +9 -2
  10. reactifact-0.9.0/reactifact/consume.py +387 -0
  11. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/context.py +43 -3
  12. reactifact-0.9.0/reactifact/context_builder.py +202 -0
  13. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/effects.py +2 -2
  14. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/llm_agent.py +2 -0
  15. reactifact-0.9.0/reactifact/native_tool_use.py +167 -0
  16. reactifact-0.9.0/reactifact/produce.py +322 -0
  17. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/recipes/__init__.py +44 -1
  18. reactifact-0.9.0/reactifact/recipes/cleanup.py +92 -0
  19. reactifact-0.9.0/reactifact/recipes/identity.py +94 -0
  20. reactifact-0.9.0/reactifact/recipes/memory.py +308 -0
  21. reactifact-0.9.0/reactifact/recipes/plan_execute.py +203 -0
  22. reactifact-0.9.0/reactifact/recipes/reflection.py +257 -0
  23. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/recipes/status.py +4 -9
  24. reactifact-0.9.0/reactifact/recipes/supervisor.py +229 -0
  25. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/resources.py +14 -0
  26. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/runtime.py +117 -9
  27. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/structured.py +191 -3
  28. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/testing/assertions.py +59 -9
  29. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/testing/fault.py +58 -10
  30. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/testing/lab.py +46 -8
  31. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/tool_use.py +136 -21
  32. reactifact-0.9.0/reactifact/triggers.py +69 -0
  33. reactifact-0.9.0/reactifact/verify.py +157 -0
  34. {reactifact-0.7.0 → reactifact-0.9.0/reactifact.egg-info}/PKG-INFO +16 -9
  35. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact.egg-info/SOURCES.txt +23 -0
  36. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact.egg-info/requires.txt +5 -0
  37. reactifact-0.9.0/tests/test_absent_consume.py +111 -0
  38. reactifact-0.9.0/tests/test_agent_tool.py +198 -0
  39. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_chat_web.py +4 -4
  40. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_checkpoint.py +22 -0
  41. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_concurrency.py +56 -0
  42. reactifact-0.9.0/tests/test_consumes_produces.py +165 -0
  43. reactifact-0.9.0/tests/test_context_builder.py +212 -0
  44. reactifact-0.9.0/tests/test_correlated_consume.py +87 -0
  45. reactifact-0.9.0/tests/test_debounce.py +111 -0
  46. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_effects.py +4 -3
  47. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_friendly_api.py +3 -3
  48. reactifact-0.9.0/tests/test_incident_commander.py +114 -0
  49. reactifact-0.9.0/tests/test_join_consume.py +121 -0
  50. reactifact-0.9.0/tests/test_native_tool_use.py +192 -0
  51. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_produce_styles.py +16 -15
  52. reactifact-0.9.0/tests/test_produce_trigger.py +204 -0
  53. reactifact-0.9.0/tests/test_reacts_to.py +160 -0
  54. reactifact-0.9.0/tests/test_recipe_plan_execute.py +106 -0
  55. reactifact-0.9.0/tests/test_recipe_reflection.py +124 -0
  56. reactifact-0.9.0/tests/test_recipe_supervisor.py +170 -0
  57. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_recipes.py +234 -11
  58. reactifact-0.9.0/tests/test_recipes_memory.py +231 -0
  59. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_replay.py +2 -1
  60. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_runtime_errors.py +4 -4
  61. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_sessions.py +84 -0
  62. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_structured.py +162 -1
  63. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_testing_lab.py +31 -3
  64. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_tools.py +160 -22
  65. reactifact-0.9.0/tests/test_verify.py +129 -0
  66. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_viz.py +10 -2
  67. reactifact-0.7.0/reactifact/consume.py +0 -96
  68. reactifact-0.7.0/reactifact/produce.py +0 -182
  69. reactifact-0.7.0/reactifact/recipes/memory.py +0 -166
  70. reactifact-0.7.0/reactifact/triggers.py +0 -41
  71. reactifact-0.7.0/tests/test_consumes_produces.py +0 -54
  72. reactifact-0.7.0/tests/test_recipes_memory.py +0 -105
  73. {reactifact-0.7.0 → reactifact-0.9.0}/LICENSE +0 -0
  74. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/__main__.py +0 -0
  75. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/_extras.py +0 -0
  76. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/artifacts.py +0 -0
  77. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/budget.py +0 -0
  78. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/cli/__init__.py +0 -0
  79. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/cli/branch.py +0 -0
  80. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/cli/common.py +0 -0
  81. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/cli/context.py +0 -0
  82. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/cli/graph.py +0 -0
  83. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/cli/replay.py +0 -0
  84. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/cli/scenario.py +0 -0
  85. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/cli/trace.py +0 -0
  86. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/commit.py +0 -0
  87. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/commit_log.py +0 -0
  88. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/eval.py +0 -0
  89. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/events.py +0 -0
  90. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/interrupt.py +0 -0
  91. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/mcp/__init__.py +0 -0
  92. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/mcp/client.py +0 -0
  93. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/mcp/oauth.py +0 -0
  94. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/mcp/server.py +0 -0
  95. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/operations.py +0 -0
  96. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/patches.py +0 -0
  97. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/prompts.py +0 -0
  98. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/providers/__init__.py +0 -0
  99. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/providers/_retry.py +0 -0
  100. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/providers/anthropic.py +0 -0
  101. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/providers/azure.py +0 -0
  102. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/providers/cerebras.py +0 -0
  103. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/providers/chat.py +0 -0
  104. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/providers/contracts.py +0 -0
  105. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/providers/deepseek.py +0 -0
  106. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/providers/fake.py +0 -0
  107. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/providers/fireworks.py +0 -0
  108. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/providers/gemini.py +0 -0
  109. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/providers/github_models.py +0 -0
  110. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/providers/groq.py +0 -0
  111. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/providers/image.py +0 -0
  112. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/providers/mistral.py +0 -0
  113. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/providers/nvidia.py +0 -0
  114. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/providers/ollama.py +0 -0
  115. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/providers/openai.py +0 -0
  116. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/providers/openrouter.py +0 -0
  117. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/providers/perplexity.py +0 -0
  118. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/providers/qwen.py +0 -0
  119. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/providers/speech.py +0 -0
  120. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/providers/together.py +0 -0
  121. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/providers/video.py +0 -0
  122. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/providers/xai.py +0 -0
  123. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/providers/zai.py +0 -0
  124. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/py.typed +0 -0
  125. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/recipes/inputs.py +0 -0
  126. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/recipes/resolve.py +0 -0
  127. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/recipes/rollback.py +0 -0
  128. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/recipes/search.py +0 -0
  129. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/recipes/skills.py +0 -0
  130. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/recipes/text.py +0 -0
  131. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/relations.py +0 -0
  132. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/replay.py +0 -0
  133. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/scheduler.py +0 -0
  134. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/session.py +0 -0
  135. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/sources.py +0 -0
  136. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/streaming.py +0 -0
  137. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/testing/__init__.py +0 -0
  138. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/testing/exceptions.py +0 -0
  139. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/testing/mock.py +0 -0
  140. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/testing/record.py +0 -0
  141. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/testing/registry.py +0 -0
  142. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/tools.py +0 -0
  143. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/tracing/__init__.py +0 -0
  144. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/tracing/_otlp.py +0 -0
  145. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/tracing/langfuse.py +0 -0
  146. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/tracing/models.py +0 -0
  147. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/tracing/otlp.py +0 -0
  148. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/tracing/postgres.py +0 -0
  149. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/tracing/store.py +0 -0
  150. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/tracing/templates/ui.html +0 -0
  151. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/tracing/templates/ui_run.html +0 -0
  152. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/tracing/tracer.py +0 -0
  153. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/tracing/web.py +0 -0
  154. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/viz.py +0 -0
  155. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/web.py +0 -0
  156. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact.egg-info/dependency_links.txt +0 -0
  157. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact.egg-info/entry_points.txt +0 -0
  158. {reactifact-0.7.0 → reactifact-0.9.0}/reactifact.egg-info/top_level.txt +0 -0
  159. {reactifact-0.7.0 → reactifact-0.9.0}/setup.cfg +0 -0
  160. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_adaptive.py +0 -0
  161. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_anthropic_provider.py +0 -0
  162. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_artifacts.py +0 -0
  163. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_backbone.py +0 -0
  164. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_branching.py +0 -0
  165. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_budget.py +0 -0
  166. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_canonical_ports.py +0 -0
  167. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_checkpoints_concurrency.py +0 -0
  168. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_cli.py +0 -0
  169. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_cli_scenario.py +0 -0
  170. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_commit_log.py +0 -0
  171. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_devops.py +0 -0
  172. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_devops_web.py +0 -0
  173. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_eval.py +0 -0
  174. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_forklab.py +0 -0
  175. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_forklab_web.py +0 -0
  176. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_gemini_provider.py +0 -0
  177. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_hitl.py +0 -0
  178. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_image_provider.py +0 -0
  179. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_invalidation.py +0 -0
  180. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_knowledge.py +0 -0
  181. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_knowledge_web.py +0 -0
  182. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_llm_ladder.py +0 -0
  183. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_mcp.py +0 -0
  184. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_medic_lab.py +0 -0
  185. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_medic_lab_web.py +0 -0
  186. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_multisource.py +0 -0
  187. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_openai_provider.py +0 -0
  188. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_patches.py +0 -0
  189. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_ports.py +0 -0
  190. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_prompts.py +0 -0
  191. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_provider_auth.py +0 -0
  192. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_provider_retry.py +0 -0
  193. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_providers_integration.py +0 -0
  194. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_recipes_inputs.py +0 -0
  195. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_recipes_skills.py +0 -0
  196. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_relation_graph.py +0 -0
  197. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_relations.py +0 -0
  198. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_repair.py +0 -0
  199. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_repair_web.py +0 -0
  200. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_research.py +0 -0
  201. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_resources.py +0 -0
  202. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_retry.py +0 -0
  203. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_runtime.py +0 -0
  204. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_sources.py +0 -0
  205. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_sources_search.py +0 -0
  206. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_sources_vector.py +0 -0
  207. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_speech_provider.py +0 -0
  208. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_streaming.py +0 -0
  209. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_testing_assertions.py +0 -0
  210. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_testing_mock.py +0 -0
  211. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_testing_registry.py +0 -0
  212. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_tracing.py +0 -0
  213. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_vendor_factories.py +0 -0
  214. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_vendor_multimodal_factories.py +0 -0
  215. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_video_provider.py +0 -0
  216. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_view.py +0 -0
  217. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_web_source.py +0 -0
  218. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_workspace.py +0 -0
  219. {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_workspace_with_sources.py +0 -0
  220. {reactifact-0.7.0 → reactifact-0.9.0}/tests/tests_checkpoints_sqlite.py +0 -0
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reactifact
3
- Version: 0.7.0
3
+ Version: 0.9.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
7
7
  Project-URL: Repository, https://github.com/bzdvdn/reactifact
8
- Project-URL: Documentation, https://github.com/bzdvdn/reactifact/tree/master/docs
8
+ Project-URL: Documentation, https://bzdvdn.github.io/reactifact/
9
9
  Project-URL: Changelog, https://github.com/bzdvdn/reactifact/blob/master/CHANGELOG.md
10
10
  Keywords: agents,llm,ai-agents,reactive,provenance,orchestration,agentic
11
11
  Classifier: Development Status :: 4 - Beta
@@ -15,6 +15,8 @@ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
15
15
  Classifier: Programming Language :: Python :: 3
16
16
  Classifier: Programming Language :: Python :: 3.11
17
17
  Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
18
20
  Classifier: Typing :: Typed
19
21
  Requires-Python: >=3.11
20
22
  Description-Content-Type: text/markdown
@@ -34,6 +36,10 @@ Provides-Extra: pg
34
36
  Requires-Dist: psycopg[binary]>=3.2; extra == "pg"
35
37
  Provides-Extra: mcp
36
38
  Requires-Dist: mcp>=2.2; extra == "mcp"
39
+ Provides-Extra: docs
40
+ Requires-Dist: mkdocs>=1.6; extra == "docs"
41
+ Requires-Dist: mkdocs-material>=9.5; extra == "docs"
42
+ Requires-Dist: mkdocstrings[python]>=0.27; extra == "docs"
37
43
  Dynamic: license-file
38
44
 
39
45
  <p align="center">
@@ -44,10 +50,11 @@ Dynamic: license-file
44
50
 
45
51
  [![CI](https://github.com/bzdvdn/reactifact/actions/workflows/ci.yml/badge.svg)](https://github.com/bzdvdn/reactifact/actions/workflows/ci.yml)
46
52
  [![codecov](https://codecov.io/gh/bzdvdn/reactifact/graph/badge.svg)](https://codecov.io/gh/bzdvdn/reactifact)
47
- [![Python](https://img.shields.io/badge/python-3.11%20%7C%203.12-blue)](https://github.com/bzdvdn/reactifact)
53
+ [![Python](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13%20%7C%203.14-blue)](https://github.com/bzdvdn/reactifact)
48
54
  [![PyPI version](https://img.shields.io/pypi/v/reactifact)](https://pypi.org/project/reactifact/)
49
55
  [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
50
56
  [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/bzdvdn/reactifact)
57
+ [![Docs](https://img.shields.io/badge/docs-bzdvdn.github.io%2Freactifact-blue)](https://bzdvdn.github.io/reactifact/)
51
58
 
52
59
  Most agent frameworks make you **draw the graph**: connect nodes, wire memory,
53
60
  declare control flow. But a knowledge question — *"why did infra costs jump in
@@ -99,21 +106,21 @@ DOCS = {
99
106
 
100
107
 
101
108
  @produce(Evidence)
102
- async def find_evidence(context, inputs, event, effects):
103
- question = next((a for a in inputs if isinstance(a.data, Question)), None)
109
+ async def find_evidence(call):
110
+ question = next((a for a in call.inputs if isinstance(a.data, Question)), None)
104
111
  if question is None:
105
112
  return None
106
113
  hit = next((v for k, v in DOCS.items() if k in question.data.text.lower()), None)
107
114
  if hit is not None:
108
- effects.create(Evidence(text=hit))
115
+ call.effects.create(Evidence(text=hit))
109
116
 
110
117
 
111
118
  @produce(Answer)
112
- async def answer_from_evidence(context, inputs, event, effects):
113
- evidence = next((a for a in inputs if isinstance(a.data, Evidence)), None)
119
+ async def answer_from_evidence(call):
120
+ evidence = next((a for a in call.inputs if isinstance(a.data, Evidence)), None)
114
121
  if evidence is None:
115
122
  return None
116
- effects.create(Answer(text=evidence.data.text)).link("supported_by", evidence)
123
+ call.effects.create(Answer(text=evidence.data.text)).link("supported_by", evidence)
117
124
 
118
125
 
119
126
  search_agent = create_agent("search", consumes=[Consume(Question)], produces=[find_evidence])
@@ -6,10 +6,11 @@
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)
9
- [![Python](https://img.shields.io/badge/python-3.11%20%7C%203.12-blue)](https://github.com/bzdvdn/reactifact)
9
+ [![Python](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13%20%7C%203.14-blue)](https://github.com/bzdvdn/reactifact)
10
10
  [![PyPI version](https://img.shields.io/pypi/v/reactifact)](https://pypi.org/project/reactifact/)
11
11
  [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
12
12
  [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/bzdvdn/reactifact)
13
+ [![Docs](https://img.shields.io/badge/docs-bzdvdn.github.io%2Freactifact-blue)](https://bzdvdn.github.io/reactifact/)
13
14
 
14
15
  Most agent frameworks make you **draw the graph**: connect nodes, wire memory,
15
16
  declare control flow. But a knowledge question — *"why did infra costs jump in
@@ -61,21 +62,21 @@ DOCS = {
61
62
 
62
63
 
63
64
  @produce(Evidence)
64
- async def find_evidence(context, inputs, event, effects):
65
- question = next((a for a in inputs if isinstance(a.data, Question)), None)
65
+ async def find_evidence(call):
66
+ question = next((a for a in call.inputs if isinstance(a.data, Question)), None)
66
67
  if question is None:
67
68
  return None
68
69
  hit = next((v for k, v in DOCS.items() if k in question.data.text.lower()), None)
69
70
  if hit is not None:
70
- effects.create(Evidence(text=hit))
71
+ call.effects.create(Evidence(text=hit))
71
72
 
72
73
 
73
74
  @produce(Answer)
74
- async def answer_from_evidence(context, inputs, event, effects):
75
- evidence = next((a for a in inputs if isinstance(a.data, Evidence)), None)
75
+ async def answer_from_evidence(call):
76
+ evidence = next((a for a in call.inputs if isinstance(a.data, Evidence)), None)
76
77
  if evidence is None:
77
78
  return None
78
- effects.create(Answer(text=evidence.data.text)).link("supported_by", evidence)
79
+ call.effects.create(Answer(text=evidence.data.text)).link("supported_by", evidence)
79
80
 
80
81
 
81
82
  search_agent = create_agent("search", consumes=[Consume(Question)], produces=[find_evidence])
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "reactifact"
7
- version = "0.7.0"
7
+ version = "0.9.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"
@@ -19,6 +19,8 @@ classifiers = [
19
19
  "Programming Language :: Python :: 3",
20
20
  "Programming Language :: Python :: 3.11",
21
21
  "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Programming Language :: Python :: 3.14",
22
24
  "Typing :: Typed",
23
25
  ]
24
26
  dependencies = [
@@ -30,7 +32,7 @@ dependencies = [
30
32
  [project.urls]
31
33
  Homepage = "https://github.com/bzdvdn/reactifact"
32
34
  Repository = "https://github.com/bzdvdn/reactifact"
33
- Documentation = "https://github.com/bzdvdn/reactifact/tree/master/docs"
35
+ Documentation = "https://bzdvdn.github.io/reactifact/"
34
36
  Changelog = "https://github.com/bzdvdn/reactifact/blob/master/CHANGELOG.md"
35
37
 
36
38
  [project.optional-dependencies]
@@ -50,6 +52,11 @@ pg = [
50
52
  mcp = [
51
53
  "mcp>=2.2",
52
54
  ]
55
+ docs = [
56
+ "mkdocs>=1.6",
57
+ "mkdocs-material>=9.5",
58
+ "mkdocstrings[python]>=0.27",
59
+ ]
53
60
 
54
61
  # uv-native groups mirror the extras so `uv run`/`uv sync` installs the web demo
55
62
  # deps by default (no `--extra` flag needed to boot the examples). Extras stay
@@ -74,6 +81,11 @@ pg = [
74
81
  mcp = [
75
82
  "mcp>=2.2",
76
83
  ]
84
+ docs = [
85
+ "mkdocs>=1.6",
86
+ "mkdocs-material>=9.5",
87
+ "mkdocstrings[python]>=0.27",
88
+ ]
77
89
 
78
90
  [tool.uv]
79
91
  default-groups = ["dev", "web", "mcp"]
@@ -31,7 +31,7 @@ from .effects import Effects, Handle
31
31
  from .events import Event, EventType
32
32
  from .interrupt import PendingQuestion
33
33
  from .patches import Create, Delete, Link, Patch, Relation, Unlink, Update
34
- from .produce import Produce, produce
34
+ from .produce import Produce, ProduceCall, produce
35
35
  from .providers import (
36
36
  EmbeddingProvider,
37
37
  FakeEmbedder,
@@ -48,7 +48,7 @@ from .session import Session, SessionStore
48
48
  from .tools import FunctionTool, Tool, ToolOutput, tool
49
49
  from .triggers import Trigger
50
50
 
51
- __version__ = "0.7.0"
51
+ __version__ = "0.9.0"
52
52
 
53
53
  __all__ = [
54
54
  "Agent",
@@ -72,6 +72,7 @@ __all__ = [
72
72
  "Patch",
73
73
  "PendingQuestion",
74
74
  "Produce",
75
+ "ProduceCall",
75
76
  "Relation",
76
77
  "RunOutcome",
77
78
  "RunStats",
@@ -0,0 +1,115 @@
1
+ """reactifact.agent_tool — a sub-agent as a `Tool` (delegation, §46).
2
+
3
+ `AgentAsTool` lets an `LLMAgent`/`HITLLMAgent` loop call another agent as an
4
+ ordinary tool: "delegate to X" runs a fresh, isolated nested `Runtime` to
5
+ completion and returns its final answer as the tool result. The delegating
6
+ loop sees only the final text — not the sub-agent's own tool calls, retries,
7
+ or internal reasoning.
8
+
9
+ Two deliberate constraints, not omissions:
10
+
11
+ - **Isolation is a fresh `Context`, not `context.branch()`.** The sub-agent
12
+ sees only the `query` it's given, nothing of the parent's history — no
13
+ prompt bloat from an unrelated conversation, and no accidental fork/merge
14
+ semantics (`branch()` exists for alternative-state exploration meant to be
15
+ merged back later, §39/§40; a one-shot delegate-and-discard call needs
16
+ neither). If a sub-agent genuinely needs to see the parent's state, build
17
+ its `Context` yourself and don't use this tool.
18
+ - **HITL sub-agents are rejected, not silently broken.** `Tool.execute` has
19
+ no channel back to a human — nothing would ever answer a `PendingQuestion`
20
+ the sub-agent raises inside its own isolated `Context`, and the nested run
21
+ would just finish with no `ToolAnswer`. `execute()` checks for this and
22
+ returns a `ToolOutput(error=...)` naming the unanswered question, instead
23
+ of returning empty text with no explanation.
24
+
25
+ Because `Tool.execute` receives only `args` (no `context`, see `tools.py`),
26
+ the sub-agent's `LLMProvider`/budget cannot be inherited implicitly from
27
+ whatever loop is calling this tool — pass them explicitly via `resources=`.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from collections.abc import Callable
33
+ from typing import Any
34
+
35
+ from pydantic import BaseModel
36
+
37
+ from .agents import Agent
38
+ from .budget import Budget
39
+ from .context import Context
40
+ from .resources import RuntimeResources
41
+ from .runtime import Runtime
42
+ from .tool_use import ToolAnswer
43
+ from .tools import Tool, ToolOutput
44
+
45
+
46
+ class SubTask(BaseModel):
47
+ """Default input artifact for `AgentAsTool` — override via `input_type`
48
+ with any model that has a `text: str` field (the convention `ToolUse`'s
49
+ own `_goal` reads, `tool_use.py`)."""
50
+
51
+ text: str
52
+
53
+
54
+ class AgentAsTool(Tool):
55
+ """Wraps `agent_factory()` as a callable tool (see module docstring)."""
56
+
57
+ def __init__(
58
+ self,
59
+ *,
60
+ name: str,
61
+ description: str,
62
+ agent_factory: Callable[[], Agent],
63
+ resources: RuntimeResources,
64
+ input_type: type[BaseModel] = SubTask,
65
+ output_type: type[BaseModel] | None = None,
66
+ max_runs: int = 20,
67
+ destructive: bool = False,
68
+ ):
69
+ self.name = name
70
+ self.description = description
71
+ self.agent_factory = agent_factory
72
+ self.resources = resources
73
+ self.input_type = input_type
74
+ self.output_type = output_type or ToolAnswer
75
+ self.max_runs = max_runs
76
+ self.destructive = destructive
77
+ self.schema = {
78
+ "type": "object",
79
+ "properties": {
80
+ "query": {
81
+ "type": "string",
82
+ "description": "The task/question to delegate to the sub-agent.",
83
+ }
84
+ },
85
+ "required": ["query"],
86
+ }
87
+
88
+ async def execute(self, args: dict[str, Any]) -> ToolOutput:
89
+ query = str(args.get("query", ""))
90
+ sub_context = Context(resources=self.resources)
91
+ sub_agent = self.agent_factory()
92
+ sub_runtime = Runtime(
93
+ sub_context, agents=[sub_agent], budget=Budget(max_runs=self.max_runs)
94
+ )
95
+ sub_context.create(self.input_type(text=query))
96
+ await sub_runtime.arun()
97
+
98
+ if sub_context.has_pending_question():
99
+ pending = sub_context.latest_pending_question()
100
+ question = pending.data.question if pending is not None else ""
101
+ return ToolOutput(
102
+ error=(
103
+ f"sub-agent '{sub_agent.name}' needs clarification it wasn't "
104
+ f"given and can't ask for: {question!r}. Give '{self.name}' a "
105
+ "more complete query and try again."
106
+ )
107
+ )
108
+
109
+ outputs = sub_context.list_artifacts(self.output_type)
110
+ if not outputs:
111
+ return ToolOutput(
112
+ error=f"sub-agent '{sub_agent.name}' produced no {self.output_type.__name__}"
113
+ )
114
+ text = getattr(outputs[-1].data, "text", "") or ""
115
+ return ToolOutput(text=text)
@@ -7,9 +7,9 @@ from typing import Any
7
7
  from .artifacts import Artifact
8
8
  from .consume import Consume
9
9
  from .context import Context
10
- from .events import Event
10
+ from .events import Event, EventType
11
11
  from .patches import Patch
12
- from .produce import Produce
12
+ from .produce import Produce, ProduceCall
13
13
  from .triggers import Trigger
14
14
 
15
15
 
@@ -25,6 +25,15 @@ class Agent(ABC): # noqa: B024 — interface without abstract methods, run() ha
25
25
  produces: Sequence[Produce[Any]] | None = None
26
26
  #: Declarative capability labels (§25), consumed by the adaptive policy.
27
27
  capabilities: tuple[str, ...] = ()
28
+ #: Explicit trigger override — for the *imperative* style (`Agent`
29
+ #: subclass overriding `run()` directly, no `consumes`/`produces` at
30
+ #: all, see `run()`'s own docstring). That style never calls
31
+ #: `_collect_inputs()`, so `Consume` has nothing to attach to; `triggers`
32
+ #: is the only way such an agent says when to wake up. For the
33
+ #: declarative style (`consumes`/`produces`, the common case), leave this
34
+ #: unset — `triggers` auto-derives from `consumes` instead, and a
35
+ #: `Consume` that should feed inputs without also waking the agent uses
36
+ #: `Consume(..., wakes=False)` rather than a second, hand-maintained list.
28
37
  triggers: list[Trigger] = []
29
38
  # Run priority within a single generation: lower value runs earlier.
30
39
  # Useful for "finishers"/evaluators that logically run last (§24).
@@ -80,7 +89,17 @@ class Agent(ABC): # noqa: B024 — interface without abstract methods, run() ha
80
89
  )
81
90
 
82
91
  def matches(self, event: Event, context: Context | None = None) -> bool:
83
- return any(trigger.matches(event, context) for trigger in self.triggers)
92
+ return bool(self.matching_triggers(event, context))
93
+
94
+ def matching_triggers(
95
+ self, event: Event, context: Context | None = None
96
+ ) -> list[Trigger]:
97
+ """Every trigger that matches `event` — `matches()` is just
98
+ `bool(...)` of this. `Runtime._arun_once_impl` uses the full list
99
+ (not just the bool) to decide whether *every* matching trigger asks
100
+ for debouncing (`Trigger.debounce`) before collapsing repeat events
101
+ into one run."""
102
+ return [trigger for trigger in self.triggers if trigger.matches(event, context)]
84
103
 
85
104
  def collect_inputs(self, context: Context) -> list[Artifact[Any]]:
86
105
  """Public access to the consumed artifacts.
@@ -90,15 +109,22 @@ class Agent(ABC): # noqa: B024 — interface without abstract methods, run() ha
90
109
  return self._collect_inputs(context)
91
110
 
92
111
  def _collect_inputs(self, context: Context) -> list[Artifact[Any]]:
93
- """Collects all artifacts matching consumes and conditions."""
112
+ """Collects all artifacts matching consumes and conditions.
113
+
114
+ Ranking/truncation, if any, is a `Runtime`-level policy
115
+ (`context.resources.context_builder`, see `context_builder.py`), not
116
+ this agent's — applied here so both this and `collect_inputs()`
117
+ (used by the runtime for provenance) see the identical, already
118
+ built list.
119
+ """
94
120
  if not self.consumes:
95
121
  return []
96
122
  inputs: list[Artifact[Any]] = []
97
123
  for c in self.consumes:
98
- artifacts = context.list_artifacts(c.artifact_type)
99
- if c.condition:
100
- artifacts = [a for a in artifacts if c.condition(a)]
101
- inputs.extend(artifacts)
124
+ inputs.extend(c.collect(context))
125
+ builder = context.resources.context_builder
126
+ if builder is not None:
127
+ inputs = builder.build(context, self, inputs)
102
128
  return inputs
103
129
 
104
130
  async def run(self, event: Event, context: Context) -> Patch | None:
@@ -118,8 +144,9 @@ class Agent(ABC): # noqa: B024 — interface without abstract methods, run() ha
118
144
  async def execute(self, context: Context, event: Event | None = None) -> None:
119
145
  """Runs the agent's produces (usually on an event).
120
146
 
121
- Effects-first (§24): produces write `self.effects.*` and return None;
122
- the *runtime* compiles the effect slot into one atomic patch. This method
147
+ Effects-first (§24): produces write `self.effects.*`/`call.effects.*`
148
+ and return None; the *runtime* compiles the effect slot into one
149
+ atomic patch. This method
123
150
  only *runs* the produces — it does not build a patch. (`run` remains the
124
151
  agent-level escape hatch for custom Agent subclasses that assemble a
125
152
  change-set by hand; the runtime merges its result after the effects.)
@@ -128,9 +155,56 @@ class Agent(ABC): # noqa: B024 — interface without abstract methods, run() ha
128
155
  return None
129
156
  inputs = self._collect_inputs(context)
130
157
  for p in self.produces:
131
- await p.produce(context, inputs, event)
158
+ runs, trigger = self._resolve_produce_call(p, event, context)
159
+ if not runs:
160
+ continue
161
+ call = ProduceCall(
162
+ context=context, inputs=inputs, event=event, trigger=trigger
163
+ )
164
+ await p.produce(call)
132
165
  return None
133
166
 
167
+ @staticmethod
168
+ def _resolve_produce_call(
169
+ p: Produce[Any], event: Event | None, context: Context
170
+ ) -> tuple[bool, Artifact[Any] | None]:
171
+ """Whether to call `p.produce()` for `event`, and the triggering
172
+ artifact to put on the `ProduceCall.trigger` field (see `Produce`'s
173
+ docstring, and `ProduceCall`'s, for the full contract).
174
+
175
+ `p.reacts_to is None` (the default): unrestricted, matching the
176
+ pre-`reacts_to` behavior where every produce ran on every event this
177
+ agent got — `trigger` is still resolved on a best-effort basis (for
178
+ a produce that wants it without narrowing `reacts_to`), but never a
179
+ reason to skip the call, since there's no per-type contract to hold
180
+ it to.
181
+
182
+ `p.reacts_to` set: exact type equality against `event.artifact_type`
183
+ (same convention as `Trigger.artifact_type`, not `issubclass` — that
184
+ one's a `Context.list_artifacts()` convention for querying by base
185
+ type). For a CREATED/UPDATED/STALE event, also requires
186
+ `context.get(event.artifact_id)` to still resolve — the artifact may
187
+ have been deleted by another agent earlier in the same generation
188
+ (the same race `Trigger.matches()` documents) — so `reacts_to`, once
189
+ set, guarantees `call.trigger` is always a live, correctly-typed
190
+ artifact. A DELETED event is exempt from that liveness requirement:
191
+ `context.get(...)` correctly returning `None` *is* the event there,
192
+ not a race, so the produce still runs with `trigger=None` —
193
+ deletion-reacting code is expected to handle that itself.
194
+ """
195
+ if event is None:
196
+ return (p.reacts_to is None), None
197
+ if p.reacts_to is not None and event.artifact_type not in p.reacts_to:
198
+ return False, None
199
+ trigger = context.get(event.artifact_id)
200
+ if (
201
+ p.reacts_to is not None
202
+ and trigger is None
203
+ and event.type is not EventType.ARTIFACT_DELETED
204
+ ):
205
+ return False, None
206
+ return True, trigger
207
+
134
208
 
135
209
  def create_agent(
136
210
  name: str,
@@ -156,7 +230,10 @@ def create_agent(
156
230
  )
157
231
  ```
158
232
 
159
- Falls back to `name` defaults the same way as `Agent.__init__`.
233
+ Falls back to `name` defaults the same way as `Agent.__init__`. Pass
234
+ `triggers` only for the rare imperative style with no `consumes` at all
235
+ (see `Agent.triggers`'s own docstring) — the ordinary declarative case
236
+ should leave it unset and use `Consume(..., wakes=False)` instead.
160
237
  """
161
238
  agent = Agent(
162
239
  name=name, triggers=triggers if triggers is not None else [], priority=priority
@@ -63,6 +63,7 @@ def clone_context(source: Context) -> Context:
63
63
  new_ws._log = source._log.copy()
64
64
  new_ws._relations = source._relations.copy()
65
65
  new_ws._recompute_stale()
66
+ new_ws._reindex_by_type()
66
67
  return new_ws
67
68
 
68
69
 
@@ -209,6 +210,7 @@ def merge_contexts(
209
210
  # `delete()` — resync `_stale` from scratch rather than risk it
210
211
  # drifting from the post-merge state.
211
212
  target._recompute_stale()
213
+ target._reindex_by_type()
212
214
 
213
215
 
214
216
  class BranchStore:
@@ -16,10 +16,11 @@ Two levels of use:
16
16
  from __future__ import annotations
17
17
 
18
18
  import asyncio
19
+ import inspect
19
20
  import logging
20
21
  from collections.abc import AsyncGenerator, AsyncIterator, Callable, Sequence
21
22
  from contextlib import asynccontextmanager
22
- from typing import Any
23
+ from typing import Any, Literal
23
24
 
24
25
  from pydantic import BaseModel
25
26
 
@@ -64,7 +65,7 @@ async def run_message(
64
65
  user_message: type[BaseModel],
65
66
  reply: Callable[[Context, str], dict[str, Any]],
66
67
  session_id: str = "",
67
- create_message: Callable[[Context, str], str] | None = None,
68
+ create_message: Callable[..., str] | None = None,
68
69
  status_kinds: Sequence[str] = ("status",),
69
70
  fallback_reply: str = "No reply assembled.",
70
71
  ) -> AsyncIterator[ChatEvent]:
@@ -78,7 +79,11 @@ async def run_message(
78
79
  `create_message(ctx, text) -> msg_id` overrides how the turn enters the
79
80
  context (default: create a `user_message` artifact) — HITL apps where a
80
81
  new turn resumes a pending question instead of appending a message
81
- (devops-style clarify) pass their own.
82
+ (devops-style clarify) pass their own. Also accepts the 3-arg shape
83
+ `create_message(ctx, text, session_id) -> msg_id` for a hook that needs
84
+ the current turn's `session_id` (e.g. to stamp it onto the created
85
+ artifact) — detected from the callable's own arity, so the 2-arg shape
86
+ keeps working unchanged.
82
87
 
83
88
  `status_kinds` selects which progress event kinds are forwarded as `status`
84
89
  frames (default: only `status`; e.g. tool-announcing demos also forward
@@ -92,7 +97,7 @@ async def run_message(
92
97
  ctx = runtime.context
93
98
  try:
94
99
  if create_message is not None:
95
- msg_id = create_message(ctx, text)
100
+ msg_id = _call_create_message(create_message, ctx, text, session_id)
96
101
  else:
97
102
  msg_id = ctx.create(user_message(text=text, session_id=session_id)).id
98
103
  except Exception:
@@ -164,6 +169,45 @@ def _resolve(value: Any) -> Any:
164
169
  return value() if callable(value) else value
165
170
 
166
171
 
172
+ def _accepts_arg(func: Callable[..., Any], count: int) -> bool:
173
+ """Whether `func` (already known callable) declares at least `count`
174
+ positional-or-keyword parameters — used to detect the opt-in, per-request
175
+ call shapes below without breaking the plain zero/two-arg factories that
176
+ predate them. A callable whose signature can't be inspected (a builtin, a
177
+ C extension) is assumed to be the old, arg-less shape."""
178
+ try:
179
+ params = inspect.signature(func).parameters
180
+ except (TypeError, ValueError):
181
+ return False
182
+ return len(params) >= count
183
+
184
+
185
+ def _resolve_with_session(value: Any, session_id: str) -> Any:
186
+ """Like `_resolve`, but passes `session_id` to `value` when it declares a
187
+ parameter for it — the per-request factory shape
188
+ (`resources=lambda session_id: build_resources(session_id)`) alongside
189
+ the pre-existing zero-arg one (`resources=lambda: build_resources()`),
190
+ which keeps working unchanged. `ChatAssistant` already knows `session_id`
191
+ at the point it resolves `resources=`; this just lets a factory opt into
192
+ reading it instead of reaching for a contextvar/side-channel to get
193
+ per-request data (e.g. an authenticated user) into `RuntimeResources`.
194
+ """
195
+ if not callable(value):
196
+ return value
197
+ return value(session_id) if _accepts_arg(value, 1) else value()
198
+
199
+
200
+ def _call_create_message(
201
+ create_message: Callable[..., str], ctx: Context, text: str, session_id: str
202
+ ) -> str:
203
+ """Calls `create_message` with `session_id` appended when it declares a
204
+ third parameter for it (`(ctx, text, session_id) -> msg_id`), alongside
205
+ the pre-existing two-arg shape (`(ctx, text) -> msg_id`)."""
206
+ if _accepts_arg(create_message, 3):
207
+ return create_message(ctx, text, session_id)
208
+ return create_message(ctx, text)
209
+
210
+
167
211
  # --- the canonical chat assistant ----------------------------------------- #
168
212
 
169
213
 
@@ -182,6 +226,23 @@ class ChatAssistant:
182
226
  turns/sessions — that instance is never closed automatically; close it
183
227
  yourself at real shutdown.
184
228
 
229
+ `session_save_policy=` passes straight through to `Runtime` — `"per_turn"`
230
+ trades finer crash-resilience granularity (a save at every commit, the
231
+ default) for one `session.save()` per turn instead of one per commit,
232
+ worthwhile once a multi-stage pipeline routinely produces several commits
233
+ per turn (see `Runtime.__init__`'s own docstring for the trade-off).
234
+
235
+ `resources=`/`create_message=` may optionally take the current turn's
236
+ `session_id` — `resources=lambda session_id: build_resources(session_id)`
237
+ (e.g. to attach an authenticated user looked up from the session) and
238
+ `create_message=lambda ctx, text, session_id: ...` — detected from each
239
+ callable's own arity, so the pre-existing zero/two-arg shapes keep
240
+ working unchanged. Without this, per-request data (who's asking, not
241
+ just what they asked) has no way into a turn short of a contextvar/
242
+ side-channel set by the caller before `stream()`/`invoke()` — `resources=`
243
+ and `create_message=` were otherwise the only two hooks in this class
244
+ that never saw it.
245
+
185
246
  Base usage:
186
247
 
187
248
  assistant = ChatAssistant(
@@ -203,15 +264,16 @@ class ChatAssistant:
203
264
  user_message: type[BaseModel],
204
265
  reply: Callable[[Context, str], dict[str, Any]],
205
266
  session_state: Callable[[Context], dict[str, Any]] | None = None,
206
- resources: RuntimeResources | Callable[[], RuntimeResources] | None = None,
267
+ resources: RuntimeResources | Callable[..., RuntimeResources] | None = None,
207
268
  budget: Budget | None = None,
208
269
  max_concurrency: int | None = None,
209
270
  tracer: Any = None,
210
- create_message: Callable[[Context, str], str] | None = None,
271
+ create_message: Callable[..., str] | None = None,
211
272
  status_kinds: Sequence[str] = ("status",),
212
273
  fallback_reply: str = "No reply assembled.",
213
274
  isolate_errors: bool = False,
214
275
  on_agent_error: Callable[[Agent, Event, BaseException], None] | None = None,
276
+ session_save_policy: Literal["per_commit", "per_turn"] = "per_commit",
215
277
  ):
216
278
  self.store = store
217
279
  self._agents = agents
@@ -227,6 +289,7 @@ class ChatAssistant:
227
289
  self._fallback_reply = fallback_reply
228
290
  self._isolate_errors = isolate_errors
229
291
  self._on_agent_error = on_agent_error
292
+ self._session_save_policy = session_save_policy
230
293
  # Serializes concurrent turns on the *same* session_id (a double
231
294
  # submit, a client retry): without this, two overlapping stream()
232
295
  # calls both load the same starting state and the later save() wins,
@@ -262,7 +325,9 @@ class ChatAssistant:
262
325
  self._session_locks.pop(session_id, None)
263
326
 
264
327
  async def _open(self, session_id: str) -> Session:
265
- return await self.store.open(session_id, resources=_resolve(self._resources))
328
+ return await self.store.open(
329
+ session_id, resources=_resolve_with_session(self._resources, session_id)
330
+ )
266
331
 
267
332
  def _build_runtime(self, session: Session) -> Runtime:
268
333
  return Runtime(
@@ -274,6 +339,7 @@ class ChatAssistant:
274
339
  tracer=_resolve(self._tracer),
275
340
  isolate_errors=self._isolate_errors,
276
341
  on_agent_error=self._on_agent_error,
342
+ session_save_policy=self._session_save_policy,
277
343
  )
278
344
 
279
345
  async def stream(self, text: str, session_id: str = "") -> AsyncIterator[ChatEvent]:
@@ -112,11 +112,18 @@ class FileBackend(CheckpointBackend):
112
112
  """File backend: state is stored in a single JSON file."""
113
113
 
114
114
  def __init__(self, path: str):
115
- self.path = path
115
+ self.path = Path(path)
116
116
 
117
117
  def _save_sync(self, data: dict[str, Any]) -> None:
118
- with open(self.path, "w", encoding="utf-8") as f:
118
+ # Atomic write (tmp + rename), same reasoning as `FileKVBackend._set_sync`:
119
+ # a direct write left a truncated/corrupt file on a process kill mid-write
120
+ # (OOM, deploy, kill -9) — the next `load()` would raise JSONDecodeError
121
+ # and the session would be unrecoverable.
122
+ self.path.parent.mkdir(parents=True, exist_ok=True)
123
+ tmp = self.path.with_suffix(self.path.suffix + ".tmp")
124
+ with open(tmp, "w", encoding="utf-8") as f:
119
125
  json.dump(data, f, indent=2)
126
+ tmp.replace(self.path)
120
127
 
121
128
  async def save(self, data: dict[str, Any]) -> None:
122
129
  await asyncio.to_thread(self._save_sync, data)