reactifact 0.14.2__tar.gz → 0.15.1__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 (316) hide show
  1. {reactifact-0.14.2/reactifact.egg-info → reactifact-0.15.1}/PKG-INFO +2 -2
  2. {reactifact-0.14.2 → reactifact-0.15.1}/README.md +1 -1
  3. {reactifact-0.14.2 → reactifact-0.15.1}/pyproject.toml +1 -1
  4. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/__init__.py +1 -1
  5. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/a2a/server.py +37 -9
  6. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/chat.py +18 -4
  7. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/runtime.py +30 -7
  8. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/streaming.py +13 -2
  9. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/web.py +27 -5
  10. {reactifact-0.14.2 → reactifact-0.15.1/reactifact.egg-info}/PKG-INFO +2 -2
  11. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_a2a.py +66 -0
  12. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_chat_web.py +146 -0
  13. reactifact-0.15.1/tests/test_streaming.py +156 -0
  14. reactifact-0.14.2/tests/test_streaming.py +0 -68
  15. {reactifact-0.14.2 → reactifact-0.15.1}/LICENSE +0 -0
  16. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/__main__.py +0 -0
  17. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/_extras.py +0 -0
  18. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/_httpx.py +0 -0
  19. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/a2a/__init__.py +0 -0
  20. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/a2a/client.py +0 -0
  21. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/a2a/models.py +0 -0
  22. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/a2a/remote.py +0 -0
  23. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/agent_tool.py +0 -0
  24. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/agents.py +0 -0
  25. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/artifacts.py +0 -0
  26. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/audit.py +0 -0
  27. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/authz.py +0 -0
  28. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/branching.py +0 -0
  29. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/budget.py +0 -0
  30. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/cache.py +0 -0
  31. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/checkpoints.py +0 -0
  32. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/cli/__init__.py +0 -0
  33. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/cli/branch.py +0 -0
  34. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/cli/common.py +0 -0
  35. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/cli/context.py +0 -0
  36. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/cli/eval.py +0 -0
  37. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/cli/graph.py +0 -0
  38. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/cli/replay.py +0 -0
  39. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/cli/scenario.py +0 -0
  40. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/cli/skills.py +0 -0
  41. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/cli/trace.py +0 -0
  42. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/commit.py +0 -0
  43. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/commit_log.py +0 -0
  44. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/consume.py +0 -0
  45. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/context.py +0 -0
  46. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/context_builder.py +0 -0
  47. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/effects.py +0 -0
  48. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/eval/__init__.py +0 -0
  49. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/eval/dataset.py +0 -0
  50. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/eval/evaluators.py +0 -0
  51. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/eval/judge.py +0 -0
  52. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/eval/online.py +0 -0
  53. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/eval/runner.py +0 -0
  54. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/eval/scoring.py +0 -0
  55. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/events.py +0 -0
  56. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/guardrails.py +0 -0
  57. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/interrupt.py +0 -0
  58. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/llm_agent.py +0 -0
  59. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/logging.py +0 -0
  60. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/mcp/__init__.py +0 -0
  61. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/mcp/client.py +0 -0
  62. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/mcp/oauth.py +0 -0
  63. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/mcp/server.py +0 -0
  64. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/metrics.py +0 -0
  65. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/native_tool_use.py +0 -0
  66. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/operations.py +0 -0
  67. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/patches.py +0 -0
  68. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/pricing.py +0 -0
  69. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/produce.py +0 -0
  70. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/prompts.py +0 -0
  71. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/providers/__init__.py +0 -0
  72. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/providers/_retry.py +0 -0
  73. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/providers/anthropic.py +0 -0
  74. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/providers/azure.py +0 -0
  75. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/providers/cerebras.py +0 -0
  76. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/providers/chat.py +0 -0
  77. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/providers/contracts.py +0 -0
  78. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/providers/deepseek.py +0 -0
  79. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/providers/fake.py +0 -0
  80. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/providers/fireworks.py +0 -0
  81. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/providers/gemini.py +0 -0
  82. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/providers/github_models.py +0 -0
  83. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/providers/groq.py +0 -0
  84. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/providers/image.py +0 -0
  85. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/providers/mistral.py +0 -0
  86. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/providers/nvidia.py +0 -0
  87. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/providers/ollama.py +0 -0
  88. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/providers/openai.py +0 -0
  89. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/providers/openrouter.py +0 -0
  90. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/providers/perplexity.py +0 -0
  91. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/providers/qwen.py +0 -0
  92. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/providers/speech.py +0 -0
  93. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/providers/together.py +0 -0
  94. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/providers/video.py +0 -0
  95. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/providers/xai.py +0 -0
  96. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/providers/zai.py +0 -0
  97. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/py.typed +0 -0
  98. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/quick/__init__.py +0 -0
  99. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/quick/_shared.py +0 -0
  100. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/quick/agent.py +0 -0
  101. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/quick/chat.py +0 -0
  102. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/quick/models.py +0 -0
  103. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/quick/rag.py +0 -0
  104. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/quick/tools_agent.py +0 -0
  105. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/quota.py +0 -0
  106. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/recipes/__init__.py +0 -0
  107. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/recipes/cleanup.py +0 -0
  108. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/recipes/conversation.py +0 -0
  109. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/recipes/identity.py +0 -0
  110. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/recipes/inputs.py +0 -0
  111. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/recipes/memory.py +0 -0
  112. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/recipes/plan_execute.py +0 -0
  113. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/recipes/reflection.py +0 -0
  114. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/recipes/resolve.py +0 -0
  115. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/recipes/rollback.py +0 -0
  116. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/recipes/search.py +0 -0
  117. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/recipes/skills.py +0 -0
  118. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/recipes/status.py +0 -0
  119. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/recipes/supervisor.py +0 -0
  120. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/recipes/text.py +0 -0
  121. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/recipes/tool_loop.py +0 -0
  122. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/redaction.py +0 -0
  123. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/relations.py +0 -0
  124. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/replay.py +0 -0
  125. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/request.py +0 -0
  126. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/resources.py +0 -0
  127. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/routing.py +0 -0
  128. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/scheduler.py +0 -0
  129. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/session.py +0 -0
  130. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/skills/reactifact/SKILL.md +0 -0
  131. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/skills/reactifact/references/effects-and-state.md +0 -0
  132. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/skills/reactifact/references/trust-and-safety.md +0 -0
  133. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/skills/reactifact-agents/SKILL.md +0 -0
  134. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/skills/reactifact-agents/references/consume.md +0 -0
  135. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/skills/reactifact-agents/references/effects.md +0 -0
  136. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/skills/reactifact-eval/SKILL.md +0 -0
  137. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/skills/reactifact-eval/references/online.md +0 -0
  138. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/skills/reactifact-from-langchain/SKILL.md +0 -0
  139. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/skills/reactifact-llm/SKILL.md +0 -0
  140. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/skills/reactifact-llm/references/providers.md +0 -0
  141. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/skills/reactifact-llm/references/tools.md +0 -0
  142. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/skills/reactifact-observability/SKILL.md +0 -0
  143. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/skills/reactifact-rag/SKILL.md +0 -0
  144. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/skills/reactifact-rag/references/sources.md +0 -0
  145. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/skills/reactifact-testing/SKILL.md +0 -0
  146. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/sources.py +0 -0
  147. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/structured.py +0 -0
  148. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/testing/__init__.py +0 -0
  149. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/testing/assertions.py +0 -0
  150. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/testing/exceptions.py +0 -0
  151. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/testing/fault.py +0 -0
  152. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/testing/golden.py +0 -0
  153. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/testing/lab.py +0 -0
  154. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/testing/mock.py +0 -0
  155. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/testing/pytest_plugin.py +0 -0
  156. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/testing/record.py +0 -0
  157. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/testing/registry.py +0 -0
  158. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/tool_use.py +0 -0
  159. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/tools.py +0 -0
  160. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/tracing/__init__.py +0 -0
  161. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/tracing/_otlp.py +0 -0
  162. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/tracing/columns.py +0 -0
  163. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/tracing/langfuse.py +0 -0
  164. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/tracing/models.py +0 -0
  165. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/tracing/otlp.py +0 -0
  166. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/tracing/postgres.py +0 -0
  167. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/tracing/store.py +0 -0
  168. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/tracing/templates/app.css +0 -0
  169. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/tracing/templates/sessions.html +0 -0
  170. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/tracing/templates/ui.html +0 -0
  171. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/tracing/templates/ui_run.html +0 -0
  172. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/tracing/tracer.py +0 -0
  173. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/tracing/web.py +0 -0
  174. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/triggers.py +0 -0
  175. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/types.py +0 -0
  176. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/verify.py +0 -0
  177. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/viz.py +0 -0
  178. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact.egg-info/SOURCES.txt +0 -0
  179. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact.egg-info/dependency_links.txt +0 -0
  180. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact.egg-info/entry_points.txt +0 -0
  181. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact.egg-info/requires.txt +0 -0
  182. {reactifact-0.14.2 → reactifact-0.15.1}/reactifact.egg-info/top_level.txt +0 -0
  183. {reactifact-0.14.2 → reactifact-0.15.1}/setup.cfg +0 -0
  184. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_a2a_example.py +0 -0
  185. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_a2a_interop.py +0 -0
  186. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_absent_consume.py +0 -0
  187. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_adaptive.py +0 -0
  188. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_agent_tool.py +0 -0
  189. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_anthropic_provider.py +0 -0
  190. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_artifacts.py +0 -0
  191. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_audit.py +0 -0
  192. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_authz.py +0 -0
  193. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_backbone.py +0 -0
  194. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_branching.py +0 -0
  195. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_budget.py +0 -0
  196. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_cache.py +0 -0
  197. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_canonical_ports.py +0 -0
  198. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_chat_example.py +0 -0
  199. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_chat_memory.py +0 -0
  200. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_chat_outbox.py +0 -0
  201. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_checkpoint.py +0 -0
  202. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_checkpoints_concurrency.py +0 -0
  203. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_cli.py +0 -0
  204. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_cli_scenario.py +0 -0
  205. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_commit_log.py +0 -0
  206. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_compaction.py +0 -0
  207. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_concurrency.py +0 -0
  208. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_consumes_produces.py +0 -0
  209. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_context_builder.py +0 -0
  210. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_correlated_consume.py +0 -0
  211. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_debounce.py +0 -0
  212. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_determinism.py +0 -0
  213. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_devops.py +0 -0
  214. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_devops_online.py +0 -0
  215. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_devops_web.py +0 -0
  216. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_effects.py +0 -0
  217. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_eval.py +0 -0
  218. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_eval_dataset.py +0 -0
  219. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_eval_judge.py +0 -0
  220. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_eval_online.py +0 -0
  221. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_eval_trajectory.py +0 -0
  222. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_fintech_audit.py +0 -0
  223. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_forklab.py +0 -0
  224. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_forklab_web.py +0 -0
  225. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_friendly_api.py +0 -0
  226. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_gemini_provider.py +0 -0
  227. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_golden.py +0 -0
  228. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_guardrails.py +0 -0
  229. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_hitl.py +0 -0
  230. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_image_provider.py +0 -0
  231. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_incident_commander.py +0 -0
  232. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_invalidation.py +0 -0
  233. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_join_consume.py +0 -0
  234. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_knowledge.py +0 -0
  235. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_knowledge_web.py +0 -0
  236. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_llm_ladder.py +0 -0
  237. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_logging.py +0 -0
  238. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_loop_bound_client.py +0 -0
  239. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_mcp.py +0 -0
  240. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_medic_lab.py +0 -0
  241. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_medic_lab_web.py +0 -0
  242. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_metrics.py +0 -0
  243. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_multisource.py +0 -0
  244. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_native_tool_use.py +0 -0
  245. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_openai_provider.py +0 -0
  246. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_outbox.py +0 -0
  247. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_patches.py +0 -0
  248. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_pending_actions.py +0 -0
  249. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_ports.py +0 -0
  250. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_produce_styles.py +0 -0
  251. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_produce_trigger.py +0 -0
  252. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_prompts.py +0 -0
  253. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_provider_auth.py +0 -0
  254. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_provider_retry.py +0 -0
  255. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_providers_integration.py +0 -0
  256. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_public_api.py +0 -0
  257. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_quick.py +0 -0
  258. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_quota.py +0 -0
  259. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_reacts_to.py +0 -0
  260. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_recipe_conversation.py +0 -0
  261. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_recipe_plan_execute.py +0 -0
  262. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_recipe_reflection.py +0 -0
  263. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_recipe_supervisor.py +0 -0
  264. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_recipe_tool_loop.py +0 -0
  265. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_recipes.py +0 -0
  266. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_recipes_inputs.py +0 -0
  267. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_recipes_memory.py +0 -0
  268. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_recipes_skills.py +0 -0
  269. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_redaction.py +0 -0
  270. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_relation_graph.py +0 -0
  271. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_relations.py +0 -0
  272. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_repair.py +0 -0
  273. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_repair_web.py +0 -0
  274. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_replay.py +0 -0
  275. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_repo_agent.py +0 -0
  276. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_request.py +0 -0
  277. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_research.py +0 -0
  278. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_resources.py +0 -0
  279. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_retry.py +0 -0
  280. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_routing.py +0 -0
  281. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_runtime.py +0 -0
  282. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_runtime_errors.py +0 -0
  283. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_scheduler_semantics.py +0 -0
  284. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_sessions.py +0 -0
  285. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_shutdown.py +0 -0
  286. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_skills.py +0 -0
  287. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_sources.py +0 -0
  288. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_sources_search.py +0 -0
  289. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_sources_vector.py +0 -0
  290. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_speech_provider.py +0 -0
  291. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_starter_app.py +0 -0
  292. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_structured.py +0 -0
  293. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_support_copilot.py +0 -0
  294. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_testing_assertions.py +0 -0
  295. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_testing_golden.py +0 -0
  296. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_testing_lab.py +0 -0
  297. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_testing_mock.py +0 -0
  298. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_testing_provenance.py +0 -0
  299. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_testing_pytest_plugin.py +0 -0
  300. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_testing_registry.py +0 -0
  301. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_token_budget.py +0 -0
  302. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_tools.py +0 -0
  303. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_trace_columns.py +0 -0
  304. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_trace_tags.py +0 -0
  305. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_tracing.py +0 -0
  306. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_type_registry.py +0 -0
  307. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_vendor_factories.py +0 -0
  308. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_vendor_multimodal_factories.py +0 -0
  309. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_verify.py +0 -0
  310. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_video_provider.py +0 -0
  311. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_view.py +0 -0
  312. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_viz.py +0 -0
  313. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_web_source.py +0 -0
  314. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_workspace.py +0 -0
  315. {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_workspace_with_sources.py +0 -0
  316. {reactifact-0.14.2 → reactifact-0.15.1}/tests/tests_checkpoints_sqlite.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reactifact
3
- Version: 0.14.2
3
+ Version: 0.15.1
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
@@ -55,7 +55,7 @@ Dynamic: license-file
55
55
  [![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)
56
56
  [![PyPI version](https://img.shields.io/pypi/v/reactifact)](https://pypi.org/project/reactifact/)
57
57
  [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
58
- [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/bzdvdn/reactifact)
58
+ [![Ask DeepWiki](docs/img/deepwiki.svg)](https://deepwiki.com/bzdvdn/reactifact)
59
59
  [![Docs](https://img.shields.io/badge/docs-bzdvdn.github.io%2Freactifact-blue)](https://bzdvdn.github.io/reactifact/)
60
60
 
61
61
  > **Is:** a Python library (3 core deps) · single-process · typed, versioned artifacts with provenance · deterministic replay · runs offline, no API key
@@ -9,7 +9,7 @@
9
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
- [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/bzdvdn/reactifact)
12
+ [![Ask DeepWiki](docs/img/deepwiki.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
15
  > **Is:** a Python library (3 core deps) · single-process · typed, versioned artifacts with provenance · deterministic replay · runs offline, no API key
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "reactifact"
7
- version = "0.14.2"
7
+ version = "0.15.1"
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"
@@ -51,7 +51,7 @@ from .tools import FunctionTool, Tool, ToolOutput, tool
51
51
  from .triggers import Trigger
52
52
  from .types import register as register_type
53
53
 
54
- __version__ = "0.14.2"
54
+ __version__ = "0.15.1"
55
55
 
56
56
  __all__ = [
57
57
  "Agent",
@@ -25,6 +25,8 @@ tasks persists the `Context` with a `Session` itself.
25
25
  """
26
26
 
27
27
  import asyncio
28
+ import contextlib
29
+ from collections import OrderedDict
28
30
  from collections.abc import AsyncIterator, Callable, Sequence
29
31
  from dataclasses import dataclass, field
30
32
  from typing import Any
@@ -59,6 +61,10 @@ CreateMessage = Callable[[Context, str], str]
59
61
  Reply = Callable[[Context, str], str]
60
62
  ContextFactory = Callable[[], Context]
61
63
 
64
+ #: In-process task store bound. A long-lived A2A server must not grow `_tasks`
65
+ #: without limit; the oldest completed task is evicted first (see `_evict`).
66
+ _MAX_TASKS = 512
67
+
62
68
 
63
69
  def default_create_message(context: Context, text: str) -> str:
64
70
  """Default input hook: seed a `UserMessage` artifact (apps override this)."""
@@ -106,10 +112,11 @@ class _A2AServer:
106
112
  self._reply = reply
107
113
  self._budget = budget
108
114
  self._max_iterations = max_iterations
109
- self._tasks: dict[str, _TaskRecord] = {}
115
+ self._tasks: OrderedDict[str, _TaskRecord] = OrderedDict()
110
116
 
111
117
  def _record_for(self, message: Message) -> _TaskRecord:
112
118
  if message.taskId and message.taskId in self._tasks:
119
+ self._tasks.move_to_end(message.taskId)
113
120
  return self._tasks[message.taskId]
114
121
  record = _TaskRecord(
115
122
  id=message.taskId or uuid4().hex,
@@ -117,8 +124,20 @@ class _A2AServer:
117
124
  context=self._context_factory(),
118
125
  )
119
126
  self._tasks[record.id] = record
127
+ self._evict()
120
128
  return record
121
129
 
130
+ def _evict(self) -> None:
131
+ """Keep `_tasks` bounded: drop the oldest terminal task first."""
132
+ if len(self._tasks) <= _MAX_TASKS:
133
+ return
134
+ for task_id, record in list(self._tasks.items()):
135
+ if record.state in TERMINAL_STATES:
136
+ del self._tasks[task_id]
137
+ break
138
+ else:
139
+ self._tasks.popitem(last=False)
140
+
122
141
  async def _execute(
123
142
  self,
124
143
  record: _TaskRecord,
@@ -185,14 +204,23 @@ class _A2AServer:
185
204
  run = asyncio.create_task(
186
205
  self._execute(record, params.message.text, on_event=queue.put_nowait)
187
206
  )
188
- while not run.done() or not queue.empty():
189
- try:
190
- event = await asyncio.wait_for(queue.get(), timeout=0.05)
191
- except TimeoutError:
192
- continue
193
- yield _sse(request_id, _status_update(record, "working", event.message))
194
- await run
195
- yield _sse(request_id, self.task(record))
207
+ try:
208
+ while not run.done() or not queue.empty():
209
+ try:
210
+ event = await asyncio.wait_for(queue.get(), timeout=0.05)
211
+ except TimeoutError:
212
+ continue
213
+ yield _sse(request_id, _status_update(record, "working", event.message))
214
+ await run
215
+ yield _sse(request_id, self.task(record))
216
+ finally:
217
+ # A dropped SSE client closes this generator mid-run: cancel and
218
+ # await the run, or it keeps executing (tokens!) and is destroyed
219
+ # pending ("Task was destroyed but it is pending!").
220
+ if not run.done():
221
+ run.cancel()
222
+ with contextlib.suppress(asyncio.CancelledError):
223
+ await run
196
224
 
197
225
  def get(self, task_id: str) -> Task | None:
198
226
  record = self._tasks.get(task_id)
@@ -26,7 +26,7 @@ from collections.abc import (
26
26
  Mapping,
27
27
  Sequence,
28
28
  )
29
- from contextlib import asynccontextmanager
29
+ from contextlib import asynccontextmanager, suppress
30
30
  from dataclasses import dataclass
31
31
  from typing import Any, Literal
32
32
 
@@ -414,9 +414,16 @@ class ChatAssistant:
414
414
  self._session_locks.pop(session_id, None)
415
415
 
416
416
  async def _open(self, session_id: str) -> Session:
417
- return await self.store.open(
418
- session_id, resources=_resolve_with_session(self._resources, session_id)
419
- )
417
+ resources = _resolve_with_session(self._resources, session_id)
418
+ try:
419
+ return await self.store.open(session_id, resources=resources)
420
+ except Exception:
421
+ # A callable `resources=` built a fresh per-turn instance; if the
422
+ # store fails to open the session nothing else will close it.
423
+ if callable(self._resources):
424
+ with suppress(Exception):
425
+ await resources.aclose()
426
+ raise
420
427
 
421
428
  def _memory_agents(self) -> list[Agent]:
422
429
  """The bounded-memory agent implied by `memory=`, or none."""
@@ -497,6 +504,7 @@ class ChatAssistant:
497
504
  design (see the module docstring).
498
505
  """
499
506
  async with self._locked_session(session_id):
507
+ session: Session | None = None
500
508
  try:
501
509
  session = await self._open(session_id)
502
510
  runtime = self._build_runtime(session)
@@ -504,6 +512,12 @@ class ChatAssistant:
504
512
  logger.exception(
505
513
  "chat.ChatAssistant: failed to open session %r", session_id
506
514
  )
515
+ # If the session did open but building the runtime failed, its
516
+ # per-turn resources still need closing (a callable `resources=`
517
+ # is never referenced again).
518
+ if session is not None and callable(self._resources):
519
+ with suppress(Exception):
520
+ await session.context.resources.aclose()
507
521
  yield ChatEvent(
508
522
  kind="message",
509
523
  session_id=session_id,
@@ -59,6 +59,11 @@ class RunInfo:
59
59
  _ACTIVE_RUNS: dict[str, RunInfo] = {}
60
60
  _RUN_TASKS: dict[str, "asyncio.Task[Any]"] = {}
61
61
 
62
+ #: How long `astream` waits for a cancelled runner to unwind before giving up
63
+ #: (a well-behaved turn stops immediately; this only guards a misbehaving agent
64
+ #: that swallows `CancelledError`).
65
+ _CANCEL_GRACE_SECONDS = 5.0
66
+
62
67
 
63
68
  def active_runs() -> list[RunInfo]:
64
69
  """Snapshots of the turns currently executing in this process.
@@ -1031,13 +1036,22 @@ class Runtime:
1031
1036
  break
1032
1037
  get_event = asyncio.ensure_future(queue.get())
1033
1038
  wait_done = asyncio.ensure_future(done.wait())
1034
- finished, _ = await asyncio.wait(
1035
- {get_event, wait_done}, return_when=asyncio.FIRST_COMPLETED
1036
- )
1037
- if get_event in finished:
1038
- yield get_event.result()
1039
- else:
1040
- get_event.cancel()
1039
+ try:
1040
+ finished, _ = await asyncio.wait(
1041
+ {get_event, wait_done}, return_when=asyncio.FIRST_COMPLETED
1042
+ )
1043
+ if get_event in finished:
1044
+ yield get_event.result()
1045
+ finally:
1046
+ # Always cancel *and await* the losing future. A task that is
1047
+ # cancelled but never awaited stays pending until the loop
1048
+ # closes, producing "Task was destroyed but it is pending!"
1049
+ # (`Queue.get`/`Event.wait`) on every early generator close
1050
+ # (e.g. a dropped SSE client).
1051
+ for fut in (get_event, wait_done):
1052
+ if not fut.done():
1053
+ fut.cancel()
1054
+ await asyncio.gather(get_event, wait_done, return_exceptions=True)
1041
1055
  # Re-raise any agent/runtime exception instead of silently dropping it:
1042
1056
  # an error inside a run must reach the caller, not hide in the task.
1043
1057
  await task
@@ -1053,6 +1067,15 @@ class Runtime:
1053
1067
  )
1054
1068
  finally:
1055
1069
  task.cancel()
1070
+ # Settle the runner too, so an early generator close (client
1071
+ # disconnect) does not leave the run task pending at loop shutdown.
1072
+ # Bounded: an agent that swallows `CancelledError` must never hang
1073
+ # the caller's disconnect/shutdown path.
1074
+ with contextlib.suppress(Exception):
1075
+ await asyncio.wait_for(
1076
+ asyncio.gather(task, return_exceptions=True),
1077
+ timeout=_CANCEL_GRACE_SECONDS,
1078
+ )
1056
1079
  self.context.unsubscribe(queue)
1057
1080
 
1058
1081
  def _apply_patch(self, patch: Patch, commit: Commit) -> list[Write]:
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
+ import contextlib
4
5
  from dataclasses import dataclass, field
5
6
  from datetime import UTC, datetime
6
7
  from typing import Any
@@ -30,6 +31,11 @@ class ProgressEvent:
30
31
 
31
32
  QueueEvent = asyncio.Queue[ProgressEvent]
32
33
 
34
+ #: Per-subscriber queue bound. Status events are best-effort UI hints: a
35
+ #: subscriber that never drains must not grow the queue without bound, so the
36
+ #: oldest event is dropped when this is reached.
37
+ _DEFAULT_SUBSCRIBER_QUEUE_SIZE = 1024
38
+
33
39
 
34
40
  class EventHub:
35
41
  """Broadcaster of agent status events into a single- or multi-stream.
@@ -38,15 +44,16 @@ class EventHub:
38
44
  runs pay nothing for announce.
39
45
  """
40
46
 
41
- def __init__(self) -> None:
47
+ def __init__(self, maxsize: int = _DEFAULT_SUBSCRIBER_QUEUE_SIZE) -> None:
42
48
  self._subscribers: set[QueueEvent] = set()
49
+ self._maxsize = maxsize
43
50
 
44
51
  @property
45
52
  def has_subscribers(self) -> bool:
46
53
  return bool(self._subscribers)
47
54
 
48
55
  def subscribe(self) -> QueueEvent:
49
- queue: QueueEvent = asyncio.Queue()
56
+ queue: QueueEvent = asyncio.Queue(maxsize=self._maxsize)
50
57
  self._subscribers.add(queue)
51
58
  return queue
52
59
 
@@ -55,4 +62,8 @@ class EventHub:
55
62
 
56
63
  def publish(self, event: ProgressEvent) -> None:
57
64
  for queue in self._subscribers:
65
+ if queue.full():
66
+ # Drop the oldest status hint rather than block or grow forever.
67
+ with contextlib.suppress(asyncio.QueueEmpty):
68
+ queue.get_nowait()
58
69
  queue.put_nowait(event)
@@ -31,8 +31,9 @@ fastapi installed, and only `create_chat_router` requires the `web` extra —
31
31
 
32
32
  from __future__ import annotations
33
33
 
34
+ import inspect
34
35
  import json
35
- from collections.abc import AsyncIterator, Callable, Mapping, Sequence
36
+ from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence
36
37
  from typing import TYPE_CHECKING, Any
37
38
 
38
39
  from pydantic import BaseModel
@@ -41,7 +42,7 @@ from ._extras import require_extra
41
42
  from .chat import ChatAssistant, ChatEvent, ChatEventKind
42
43
 
43
44
  if TYPE_CHECKING:
44
- from fastapi import APIRouter
45
+ from fastapi import APIRouter, Request
45
46
  from fastapi.responses import JSONResponse, StreamingResponse
46
47
 
47
48
 
@@ -83,6 +84,10 @@ def create_chat_router(
83
84
  forward_kinds: Sequence[str] | None = None,
84
85
  payload_shaper: Callable[[ChatEvent], Mapping[str, Any]] | None = None,
85
86
  done_event: str | None = None,
87
+ request_factory: Callable[
88
+ [Request], Mapping[str, Any] | Awaitable[Mapping[str, Any]]
89
+ ]
90
+ | None = None,
86
91
  ) -> APIRouter:
87
92
  """Builds the chat router on top of a `ChatAssistant`.
88
93
 
@@ -100,6 +105,11 @@ def create_chat_router(
100
105
  - `payload_shaper(event) -> mapping` — reshape a frame's payload.
101
106
  - `done_event` — emit one extra terminal frame (e.g. `"done"`) when the
102
107
  stream ends.
108
+ - `request_factory(request) -> mapping` — build the per-turn `request=`
109
+ mapping (the same one `ChatAssistant.stream` takes) from the incoming
110
+ FastAPI `Request`, e.g. the authenticated user for ACL/identity-aware
111
+ runs. May be sync or async (an awaitable result is awaited). Without it
112
+ the turn runs with `request=None`.
103
113
 
104
114
  Defaults reproduce the canonical contract exactly, so existing clients are
105
115
  unaffected. The event schema (`ChatEvent`) and the effective names are
@@ -108,9 +118,15 @@ def create_chat_router(
108
118
  # Readable error when the `web` extra is missing — then a regular
109
119
  # (mypy-visible) import for the real types.
110
120
  require_extra("web.create_chat_router", "fastapi", "web")
111
- from fastapi import APIRouter
121
+ from fastapi import APIRouter, Request
112
122
  from fastapi.responses import JSONResponse, StreamingResponse
113
123
 
124
+ # `Request` is only referenced from the endpoint's *string* annotation
125
+ # (`from __future__ import annotations`); FastAPI resolves those against
126
+ # the module globals, and fastapi may be absent at import time — so publish
127
+ # it lazily here, right before the endpoint that uses it is defined.
128
+ globals().setdefault("Request", Request)
129
+
114
130
  names: dict[str, str] = {k: v for k, v in DEFAULT_EVENT_NAMES.items()}
115
131
  names.update(event_names or {})
116
132
  forward = set(forward_kinds) if forward_kinds is not None else None
@@ -140,9 +156,15 @@ def create_chat_router(
140
156
  }
141
157
  },
142
158
  )
143
- async def chat_stream(req: ChatMessage) -> StreamingResponse:
159
+ async def chat_stream(req: ChatMessage, request: Request) -> StreamingResponse:
160
+ turn_request = request_factory(request) if request_factory else None
161
+ if inspect.isawaitable(turn_request):
162
+ turn_request = await turn_request
163
+
144
164
  async def stream() -> AsyncIterator[str]:
145
- async for event in assistant.stream(req.message, req.session_id):
165
+ async for event in assistant.stream(
166
+ req.message, req.session_id, request=turn_request
167
+ ):
146
168
  if forward is not None and event.kind not in forward:
147
169
  continue
148
170
  yield sse(names.get(event.kind, event.kind), dict(shaper(event)))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reactifact
3
- Version: 0.14.2
3
+ Version: 0.15.1
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
@@ -55,7 +55,7 @@ Dynamic: license-file
55
55
  [![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)
56
56
  [![PyPI version](https://img.shields.io/pypi/v/reactifact)](https://pypi.org/project/reactifact/)
57
57
  [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
58
- [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/bzdvdn/reactifact)
58
+ [![Ask DeepWiki](docs/img/deepwiki.svg)](https://deepwiki.com/bzdvdn/reactifact)
59
59
  [![Docs](https://img.shields.io/badge/docs-bzdvdn.github.io%2Freactifact-blue)](https://bzdvdn.github.io/reactifact/)
60
60
 
61
61
  > **Is:** a Python library (3 core deps) · single-process · typed, versioned artifacts with provenance · deterministic replay · runs offline, no API key
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
+ import contextlib
6
7
 
7
8
  import httpx
8
9
  import pytest
@@ -48,6 +49,14 @@ class Echo(Produce[Answer]):
48
49
  call.effects.create(Answer(text=call.trigger.data.text.upper()))
49
50
 
50
51
 
52
+ class Slow(Produce[Answer]):
53
+ artifact_type = Answer
54
+
55
+ async def produce(self, call):
56
+ await asyncio.sleep(30)
57
+ call.effects.create(Answer(text="slow"))
58
+
59
+
51
60
  class AskOrAnswer(Produce[Answer]):
52
61
  """First a PendingQuestion (input-required), then an Answer once resumed."""
53
62
 
@@ -154,6 +163,63 @@ def test_unknown_method_errors():
154
163
  assert body["error"]["code"] == -32601
155
164
 
156
165
 
166
+ def test_server_task_store_is_bounded():
167
+ """A long-lived server must not grow its in-process task store without
168
+ bound: the oldest records are evicted once the cap is reached."""
169
+ from reactifact.a2a.server import (
170
+ _MAX_TASKS,
171
+ _A2AServer,
172
+ default_create_message,
173
+ default_reply,
174
+ )
175
+
176
+ server = _A2AServer(
177
+ [_echo_agent()],
178
+ context_factory=Context,
179
+ create_message=default_create_message,
180
+ reply=default_reply,
181
+ budget=None,
182
+ max_iterations=100,
183
+ )
184
+ for _ in range(_MAX_TASKS + 20):
185
+ server._record_for(Message.user("hi"))
186
+ assert len(server._tasks) <= _MAX_TASKS
187
+
188
+
189
+ def test_server_stream_cancels_run_when_the_client_disconnects():
190
+ """A dropped SSE client must cancel the in-flight run — otherwise it keeps
191
+ executing and is destroyed pending at loop shutdown ("Task was destroyed
192
+ but it is pending!")."""
193
+ from reactifact.a2a.models import MessageSendParams
194
+ from reactifact.a2a.server import _A2AServer, default_create_message, default_reply
195
+
196
+ slow = create_agent("slow", consumes=[Consume(UserMessage)], produces=[Slow()])
197
+ server = _A2AServer(
198
+ [slow],
199
+ context_factory=Context,
200
+ create_message=default_create_message,
201
+ reply=default_reply,
202
+ budget=None,
203
+ max_iterations=100,
204
+ )
205
+ params = MessageSendParams(message=Message.user("hi"))
206
+
207
+ async def run():
208
+ agen = server.stream("1", params)
209
+ assert (await agen.__anext__()).startswith("data:") # the working frame
210
+ puller = asyncio.create_task(agen.__anext__())
211
+ await asyncio.sleep(0) # enter the wait: the run task is in flight
212
+ puller.cancel()
213
+ with contextlib.suppress(asyncio.CancelledError):
214
+ await puller
215
+ await agen.aclose()
216
+ for _ in range(10):
217
+ await asyncio.sleep(0)
218
+ return [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
219
+
220
+ assert asyncio.run(run()) == []
221
+
222
+
157
223
  def test_hitl_input_required_then_resume():
158
224
  agent = create_agent(
159
225
  "asker",
@@ -157,6 +157,116 @@ def test_web_router_openapi_publishes_event_schema(tmp_path):
157
157
  assert "content" in responses["200"]["description"]
158
158
 
159
159
 
160
+ def test_web_router_request_factory_forwards_per_turn_context(tmp_path):
161
+ """`request_factory` maps the FastAPI `Request` into the turn's `request=`."""
162
+ seen: dict[str, object] = {}
163
+
164
+ async def capture(call):
165
+ seen["user"] = call.request.get("user")
166
+ call.effects.create(A(text="answer"))
167
+ return None
168
+
169
+ prod = produce(A)(capture)
170
+ agent = create_agent("cap", consumes=[Consume(Q)], produces=[prod])
171
+ store = SessionStore(FileKVBackend(os.path.join(tmp_path, "sess")))
172
+ assistant = ChatAssistant(
173
+ store=store,
174
+ agents=[agent],
175
+ user_message=Q,
176
+ reply=lambda ctx, msg_id: {"reply": "ok", "waiting": False},
177
+ max_concurrency=1,
178
+ )
179
+
180
+ app = FastAPI()
181
+ app.include_router(
182
+ create_chat_router(
183
+ assistant,
184
+ request_factory=lambda request: {"user": request.headers.get("x-user", "")},
185
+ )
186
+ )
187
+ client = TestClient(app)
188
+
189
+ with client.stream(
190
+ "POST",
191
+ "/api/chat/stream",
192
+ json={"message": "hi", "session_id": "s1"},
193
+ headers={"X-User": "alice"},
194
+ ) as response:
195
+ "".join(response.iter_text())
196
+
197
+ assert seen["user"] == "alice"
198
+
199
+
200
+ def test_web_router_async_request_factory_is_awaited(tmp_path):
201
+ """An async `request_factory` must be awaited, not passed as a coroutine."""
202
+ seen: dict[str, object] = {}
203
+
204
+ async def capture(call):
205
+ seen["user"] = call.request.get("user")
206
+ call.effects.create(A(text="answer"))
207
+ return None
208
+
209
+ prod = produce(A)(capture)
210
+ agent = create_agent("cap", consumes=[Consume(Q)], produces=[prod])
211
+ store = SessionStore(FileKVBackend(os.path.join(tmp_path, "sess")))
212
+ assistant = ChatAssistant(
213
+ store=store,
214
+ agents=[agent],
215
+ user_message=Q,
216
+ reply=lambda ctx, msg_id: {"reply": "ok", "waiting": False},
217
+ max_concurrency=1,
218
+ )
219
+
220
+ async def request_factory(request):
221
+ return {"user": request.headers.get("x-user", "")}
222
+
223
+ app = FastAPI()
224
+ app.include_router(create_chat_router(assistant, request_factory=request_factory))
225
+ client = TestClient(app)
226
+
227
+ with client.stream(
228
+ "POST",
229
+ "/api/chat/stream",
230
+ json={"message": "hi", "session_id": "s1"},
231
+ headers={"X-User": "bob"},
232
+ ) as response:
233
+ "".join(response.iter_text())
234
+
235
+ assert seen["user"] == "bob"
236
+
237
+
238
+ def test_web_router_without_request_factory_has_no_request(tmp_path):
239
+ """Default stays `request=None`: no behavior change for existing callers."""
240
+ seen: dict[str, object] = {}
241
+
242
+ async def capture(call):
243
+ seen["request"] = dict(call.request)
244
+ call.effects.create(A(text="answer"))
245
+ return None
246
+
247
+ prod = produce(A)(capture)
248
+ agent = create_agent("cap", consumes=[Consume(Q)], produces=[prod])
249
+ store = SessionStore(FileKVBackend(os.path.join(tmp_path, "sess")))
250
+ assistant = ChatAssistant(
251
+ store=store,
252
+ agents=[agent],
253
+ user_message=Q,
254
+ reply=lambda ctx, msg_id: {"reply": "ok", "waiting": False},
255
+ max_concurrency=1,
256
+ )
257
+
258
+ app = FastAPI()
259
+ app.include_router(create_chat_router(assistant))
260
+ client = TestClient(app)
261
+
262
+ with client.stream(
263
+ "POST", "/api/chat/stream", json={"message": "hi", "session_id": "s1"}
264
+ ) as response:
265
+ "".join(response.iter_text())
266
+
267
+ assert seen["request"] == {}
268
+
269
+
160
270
  def test_disconnect_cancels_the_running_turn(tmp_path):
161
271
  """A dropped SSE client must cancel in-flight work, not leave it burning.
162
272
 
@@ -355,6 +465,42 @@ def test_callable_resources_closed_after_each_turn(tmp_path):
355
465
  assert all(llm.closed for llm in built)
356
466
 
357
467
 
468
+ def test_callable_resources_closed_when_session_open_fails(tmp_path):
469
+ """A failing session open must not leak the freshly-built per-turn
470
+ ``resources=`` instance — ``_open`` closes it before re-raising."""
471
+ from reactifact import RuntimeResources
472
+
473
+ built: list[_SpyLLM] = []
474
+
475
+ def fresh_resources():
476
+ llm = _SpyLLM()
477
+ built.append(llm)
478
+ return RuntimeResources(llm=llm)
479
+
480
+ class BoomStore:
481
+ def open(self, session_id, resources=None):
482
+ raise RuntimeError("backend down")
483
+
484
+ def delete_session(self, session_id):
485
+ pass
486
+
487
+ assistant = ChatAssistant(
488
+ store=BoomStore(),
489
+ agents=[ANSWER_AGENT],
490
+ user_message=Q,
491
+ reply=lambda ctx, mid: {"reply": "never", "waiting": False},
492
+ resources=fresh_resources,
493
+ max_concurrency=1,
494
+ fallback_reply="degraded",
495
+ )
496
+
497
+ events = _drain(assistant.stream("hi", session_id="s6"))
498
+
499
+ assert events[-1].payload["reply"] == "degraded"
500
+ assert len(built) == 1
501
+ assert built[0].closed is True
502
+
503
+
358
504
  def test_shared_resources_instance_not_closed(tmp_path):
359
505
  """A plain (non-callable) resources= instance must survive the turn —
360
506
  it's shared across future turns/sessions, not closed automatically."""