reactifact 0.15.0__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.15.0/reactifact.egg-info → reactifact-0.15.1}/PKG-INFO +1 -1
  2. {reactifact-0.15.0 → reactifact-0.15.1}/pyproject.toml +1 -1
  3. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/__init__.py +1 -1
  4. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/a2a/server.py +37 -9
  5. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/chat.py +18 -4
  6. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/runtime.py +30 -7
  7. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/streaming.py +13 -2
  8. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/web.py +10 -3
  9. {reactifact-0.15.0 → reactifact-0.15.1/reactifact.egg-info}/PKG-INFO +1 -1
  10. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_a2a.py +66 -0
  11. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_chat_web.py +74 -0
  12. reactifact-0.15.1/tests/test_streaming.py +156 -0
  13. reactifact-0.15.0/tests/test_streaming.py +0 -68
  14. {reactifact-0.15.0 → reactifact-0.15.1}/LICENSE +0 -0
  15. {reactifact-0.15.0 → reactifact-0.15.1}/README.md +0 -0
  16. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/__main__.py +0 -0
  17. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/_extras.py +0 -0
  18. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/_httpx.py +0 -0
  19. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/a2a/__init__.py +0 -0
  20. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/a2a/client.py +0 -0
  21. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/a2a/models.py +0 -0
  22. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/a2a/remote.py +0 -0
  23. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/agent_tool.py +0 -0
  24. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/agents.py +0 -0
  25. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/artifacts.py +0 -0
  26. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/audit.py +0 -0
  27. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/authz.py +0 -0
  28. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/branching.py +0 -0
  29. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/budget.py +0 -0
  30. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/cache.py +0 -0
  31. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/checkpoints.py +0 -0
  32. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/cli/__init__.py +0 -0
  33. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/cli/branch.py +0 -0
  34. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/cli/common.py +0 -0
  35. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/cli/context.py +0 -0
  36. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/cli/eval.py +0 -0
  37. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/cli/graph.py +0 -0
  38. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/cli/replay.py +0 -0
  39. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/cli/scenario.py +0 -0
  40. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/cli/skills.py +0 -0
  41. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/cli/trace.py +0 -0
  42. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/commit.py +0 -0
  43. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/commit_log.py +0 -0
  44. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/consume.py +0 -0
  45. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/context.py +0 -0
  46. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/context_builder.py +0 -0
  47. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/effects.py +0 -0
  48. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/eval/__init__.py +0 -0
  49. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/eval/dataset.py +0 -0
  50. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/eval/evaluators.py +0 -0
  51. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/eval/judge.py +0 -0
  52. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/eval/online.py +0 -0
  53. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/eval/runner.py +0 -0
  54. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/eval/scoring.py +0 -0
  55. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/events.py +0 -0
  56. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/guardrails.py +0 -0
  57. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/interrupt.py +0 -0
  58. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/llm_agent.py +0 -0
  59. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/logging.py +0 -0
  60. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/mcp/__init__.py +0 -0
  61. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/mcp/client.py +0 -0
  62. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/mcp/oauth.py +0 -0
  63. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/mcp/server.py +0 -0
  64. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/metrics.py +0 -0
  65. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/native_tool_use.py +0 -0
  66. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/operations.py +0 -0
  67. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/patches.py +0 -0
  68. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/pricing.py +0 -0
  69. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/produce.py +0 -0
  70. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/prompts.py +0 -0
  71. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/providers/__init__.py +0 -0
  72. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/providers/_retry.py +0 -0
  73. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/providers/anthropic.py +0 -0
  74. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/providers/azure.py +0 -0
  75. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/providers/cerebras.py +0 -0
  76. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/providers/chat.py +0 -0
  77. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/providers/contracts.py +0 -0
  78. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/providers/deepseek.py +0 -0
  79. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/providers/fake.py +0 -0
  80. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/providers/fireworks.py +0 -0
  81. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/providers/gemini.py +0 -0
  82. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/providers/github_models.py +0 -0
  83. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/providers/groq.py +0 -0
  84. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/providers/image.py +0 -0
  85. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/providers/mistral.py +0 -0
  86. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/providers/nvidia.py +0 -0
  87. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/providers/ollama.py +0 -0
  88. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/providers/openai.py +0 -0
  89. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/providers/openrouter.py +0 -0
  90. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/providers/perplexity.py +0 -0
  91. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/providers/qwen.py +0 -0
  92. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/providers/speech.py +0 -0
  93. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/providers/together.py +0 -0
  94. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/providers/video.py +0 -0
  95. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/providers/xai.py +0 -0
  96. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/providers/zai.py +0 -0
  97. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/py.typed +0 -0
  98. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/quick/__init__.py +0 -0
  99. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/quick/_shared.py +0 -0
  100. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/quick/agent.py +0 -0
  101. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/quick/chat.py +0 -0
  102. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/quick/models.py +0 -0
  103. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/quick/rag.py +0 -0
  104. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/quick/tools_agent.py +0 -0
  105. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/quota.py +0 -0
  106. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/recipes/__init__.py +0 -0
  107. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/recipes/cleanup.py +0 -0
  108. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/recipes/conversation.py +0 -0
  109. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/recipes/identity.py +0 -0
  110. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/recipes/inputs.py +0 -0
  111. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/recipes/memory.py +0 -0
  112. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/recipes/plan_execute.py +0 -0
  113. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/recipes/reflection.py +0 -0
  114. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/recipes/resolve.py +0 -0
  115. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/recipes/rollback.py +0 -0
  116. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/recipes/search.py +0 -0
  117. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/recipes/skills.py +0 -0
  118. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/recipes/status.py +0 -0
  119. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/recipes/supervisor.py +0 -0
  120. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/recipes/text.py +0 -0
  121. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/recipes/tool_loop.py +0 -0
  122. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/redaction.py +0 -0
  123. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/relations.py +0 -0
  124. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/replay.py +0 -0
  125. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/request.py +0 -0
  126. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/resources.py +0 -0
  127. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/routing.py +0 -0
  128. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/scheduler.py +0 -0
  129. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/session.py +0 -0
  130. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/skills/reactifact/SKILL.md +0 -0
  131. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/skills/reactifact/references/effects-and-state.md +0 -0
  132. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/skills/reactifact/references/trust-and-safety.md +0 -0
  133. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/skills/reactifact-agents/SKILL.md +0 -0
  134. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/skills/reactifact-agents/references/consume.md +0 -0
  135. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/skills/reactifact-agents/references/effects.md +0 -0
  136. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/skills/reactifact-eval/SKILL.md +0 -0
  137. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/skills/reactifact-eval/references/online.md +0 -0
  138. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/skills/reactifact-from-langchain/SKILL.md +0 -0
  139. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/skills/reactifact-llm/SKILL.md +0 -0
  140. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/skills/reactifact-llm/references/providers.md +0 -0
  141. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/skills/reactifact-llm/references/tools.md +0 -0
  142. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/skills/reactifact-observability/SKILL.md +0 -0
  143. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/skills/reactifact-rag/SKILL.md +0 -0
  144. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/skills/reactifact-rag/references/sources.md +0 -0
  145. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/skills/reactifact-testing/SKILL.md +0 -0
  146. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/sources.py +0 -0
  147. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/structured.py +0 -0
  148. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/testing/__init__.py +0 -0
  149. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/testing/assertions.py +0 -0
  150. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/testing/exceptions.py +0 -0
  151. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/testing/fault.py +0 -0
  152. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/testing/golden.py +0 -0
  153. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/testing/lab.py +0 -0
  154. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/testing/mock.py +0 -0
  155. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/testing/pytest_plugin.py +0 -0
  156. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/testing/record.py +0 -0
  157. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/testing/registry.py +0 -0
  158. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/tool_use.py +0 -0
  159. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/tools.py +0 -0
  160. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/tracing/__init__.py +0 -0
  161. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/tracing/_otlp.py +0 -0
  162. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/tracing/columns.py +0 -0
  163. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/tracing/langfuse.py +0 -0
  164. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/tracing/models.py +0 -0
  165. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/tracing/otlp.py +0 -0
  166. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/tracing/postgres.py +0 -0
  167. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/tracing/store.py +0 -0
  168. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/tracing/templates/app.css +0 -0
  169. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/tracing/templates/sessions.html +0 -0
  170. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/tracing/templates/ui.html +0 -0
  171. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/tracing/templates/ui_run.html +0 -0
  172. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/tracing/tracer.py +0 -0
  173. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/tracing/web.py +0 -0
  174. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/triggers.py +0 -0
  175. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/types.py +0 -0
  176. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/verify.py +0 -0
  177. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/viz.py +0 -0
  178. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact.egg-info/SOURCES.txt +0 -0
  179. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact.egg-info/dependency_links.txt +0 -0
  180. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact.egg-info/entry_points.txt +0 -0
  181. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact.egg-info/requires.txt +0 -0
  182. {reactifact-0.15.0 → reactifact-0.15.1}/reactifact.egg-info/top_level.txt +0 -0
  183. {reactifact-0.15.0 → reactifact-0.15.1}/setup.cfg +0 -0
  184. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_a2a_example.py +0 -0
  185. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_a2a_interop.py +0 -0
  186. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_absent_consume.py +0 -0
  187. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_adaptive.py +0 -0
  188. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_agent_tool.py +0 -0
  189. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_anthropic_provider.py +0 -0
  190. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_artifacts.py +0 -0
  191. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_audit.py +0 -0
  192. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_authz.py +0 -0
  193. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_backbone.py +0 -0
  194. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_branching.py +0 -0
  195. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_budget.py +0 -0
  196. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_cache.py +0 -0
  197. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_canonical_ports.py +0 -0
  198. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_chat_example.py +0 -0
  199. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_chat_memory.py +0 -0
  200. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_chat_outbox.py +0 -0
  201. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_checkpoint.py +0 -0
  202. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_checkpoints_concurrency.py +0 -0
  203. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_cli.py +0 -0
  204. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_cli_scenario.py +0 -0
  205. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_commit_log.py +0 -0
  206. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_compaction.py +0 -0
  207. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_concurrency.py +0 -0
  208. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_consumes_produces.py +0 -0
  209. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_context_builder.py +0 -0
  210. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_correlated_consume.py +0 -0
  211. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_debounce.py +0 -0
  212. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_determinism.py +0 -0
  213. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_devops.py +0 -0
  214. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_devops_online.py +0 -0
  215. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_devops_web.py +0 -0
  216. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_effects.py +0 -0
  217. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_eval.py +0 -0
  218. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_eval_dataset.py +0 -0
  219. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_eval_judge.py +0 -0
  220. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_eval_online.py +0 -0
  221. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_eval_trajectory.py +0 -0
  222. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_fintech_audit.py +0 -0
  223. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_forklab.py +0 -0
  224. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_forklab_web.py +0 -0
  225. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_friendly_api.py +0 -0
  226. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_gemini_provider.py +0 -0
  227. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_golden.py +0 -0
  228. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_guardrails.py +0 -0
  229. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_hitl.py +0 -0
  230. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_image_provider.py +0 -0
  231. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_incident_commander.py +0 -0
  232. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_invalidation.py +0 -0
  233. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_join_consume.py +0 -0
  234. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_knowledge.py +0 -0
  235. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_knowledge_web.py +0 -0
  236. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_llm_ladder.py +0 -0
  237. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_logging.py +0 -0
  238. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_loop_bound_client.py +0 -0
  239. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_mcp.py +0 -0
  240. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_medic_lab.py +0 -0
  241. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_medic_lab_web.py +0 -0
  242. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_metrics.py +0 -0
  243. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_multisource.py +0 -0
  244. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_native_tool_use.py +0 -0
  245. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_openai_provider.py +0 -0
  246. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_outbox.py +0 -0
  247. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_patches.py +0 -0
  248. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_pending_actions.py +0 -0
  249. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_ports.py +0 -0
  250. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_produce_styles.py +0 -0
  251. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_produce_trigger.py +0 -0
  252. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_prompts.py +0 -0
  253. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_provider_auth.py +0 -0
  254. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_provider_retry.py +0 -0
  255. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_providers_integration.py +0 -0
  256. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_public_api.py +0 -0
  257. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_quick.py +0 -0
  258. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_quota.py +0 -0
  259. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_reacts_to.py +0 -0
  260. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_recipe_conversation.py +0 -0
  261. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_recipe_plan_execute.py +0 -0
  262. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_recipe_reflection.py +0 -0
  263. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_recipe_supervisor.py +0 -0
  264. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_recipe_tool_loop.py +0 -0
  265. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_recipes.py +0 -0
  266. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_recipes_inputs.py +0 -0
  267. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_recipes_memory.py +0 -0
  268. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_recipes_skills.py +0 -0
  269. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_redaction.py +0 -0
  270. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_relation_graph.py +0 -0
  271. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_relations.py +0 -0
  272. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_repair.py +0 -0
  273. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_repair_web.py +0 -0
  274. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_replay.py +0 -0
  275. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_repo_agent.py +0 -0
  276. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_request.py +0 -0
  277. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_research.py +0 -0
  278. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_resources.py +0 -0
  279. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_retry.py +0 -0
  280. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_routing.py +0 -0
  281. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_runtime.py +0 -0
  282. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_runtime_errors.py +0 -0
  283. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_scheduler_semantics.py +0 -0
  284. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_sessions.py +0 -0
  285. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_shutdown.py +0 -0
  286. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_skills.py +0 -0
  287. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_sources.py +0 -0
  288. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_sources_search.py +0 -0
  289. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_sources_vector.py +0 -0
  290. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_speech_provider.py +0 -0
  291. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_starter_app.py +0 -0
  292. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_structured.py +0 -0
  293. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_support_copilot.py +0 -0
  294. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_testing_assertions.py +0 -0
  295. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_testing_golden.py +0 -0
  296. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_testing_lab.py +0 -0
  297. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_testing_mock.py +0 -0
  298. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_testing_provenance.py +0 -0
  299. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_testing_pytest_plugin.py +0 -0
  300. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_testing_registry.py +0 -0
  301. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_token_budget.py +0 -0
  302. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_tools.py +0 -0
  303. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_trace_columns.py +0 -0
  304. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_trace_tags.py +0 -0
  305. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_tracing.py +0 -0
  306. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_type_registry.py +0 -0
  307. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_vendor_factories.py +0 -0
  308. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_vendor_multimodal_factories.py +0 -0
  309. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_verify.py +0 -0
  310. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_video_provider.py +0 -0
  311. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_view.py +0 -0
  312. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_viz.py +0 -0
  313. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_web_source.py +0 -0
  314. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_workspace.py +0 -0
  315. {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_workspace_with_sources.py +0 -0
  316. {reactifact-0.15.0 → 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.15.0
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "reactifact"
7
- version = "0.15.0"
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.15.0"
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
@@ -83,7 +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,
86
- request_factory: Callable[[Request], Mapping[str, Any]] | None = None,
87
+ request_factory: Callable[
88
+ [Request], Mapping[str, Any] | Awaitable[Mapping[str, Any]]
89
+ ]
90
+ | None = None,
87
91
  ) -> APIRouter:
88
92
  """Builds the chat router on top of a `ChatAssistant`.
89
93
 
@@ -104,7 +108,8 @@ def create_chat_router(
104
108
  - `request_factory(request) -> mapping` — build the per-turn `request=`
105
109
  mapping (the same one `ChatAssistant.stream` takes) from the incoming
106
110
  FastAPI `Request`, e.g. the authenticated user for ACL/identity-aware
107
- runs. Without it the turn runs with `request=None`.
111
+ runs. May be sync or async (an awaitable result is awaited). Without it
112
+ the turn runs with `request=None`.
108
113
 
109
114
  Defaults reproduce the canonical contract exactly, so existing clients are
110
115
  unaffected. The event schema (`ChatEvent`) and the effective names are
@@ -153,6 +158,8 @@ def create_chat_router(
153
158
  )
154
159
  async def chat_stream(req: ChatMessage, request: Request) -> StreamingResponse:
155
160
  turn_request = request_factory(request) if request_factory else None
161
+ if inspect.isawaitable(turn_request):
162
+ turn_request = await turn_request
156
163
 
157
164
  async def stream() -> AsyncIterator[str]:
158
165
  async for event in assistant.stream(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reactifact
3
- Version: 0.15.0
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
@@ -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",
@@ -197,6 +197,44 @@ def test_web_router_request_factory_forwards_per_turn_context(tmp_path):
197
197
  assert seen["user"] == "alice"
198
198
 
199
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
+
200
238
  def test_web_router_without_request_factory_has_no_request(tmp_path):
201
239
  """Default stays `request=None`: no behavior change for existing callers."""
202
240
  seen: dict[str, object] = {}
@@ -427,6 +465,42 @@ def test_callable_resources_closed_after_each_turn(tmp_path):
427
465
  assert all(llm.closed for llm in built)
428
466
 
429
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
+
430
504
  def test_shared_resources_instance_not_closed(tmp_path):
431
505
  """A plain (non-callable) resources= instance must survive the turn —
432
506
  it's shared across future turns/sessions, not closed automatically."""
@@ -0,0 +1,156 @@
1
+ import asyncio
2
+ import contextlib
3
+
4
+ from pydantic import BaseModel
5
+ from reactifact import Agent, Consume, Context, Patch, Runtime
6
+ from reactifact.streaming import EventHub, ProgressEvent
7
+
8
+
9
+ class UserMsg(BaseModel):
10
+ text: str
11
+
12
+
13
+ class Done(BaseModel):
14
+ text: str
15
+
16
+
17
+ class Announcer(Agent):
18
+ consumes = [Consume(UserMsg)]
19
+
20
+ async def run(self, event, context):
21
+ context.announce("Думаю над вопросом...", kind="status", source="brain")
22
+ context.announce("Найдено 3 соответствия", kind="status")
23
+ return Patch().create(Done(text="ok"))
24
+
25
+
26
+ class Slow(Agent):
27
+ consumes = [Consume(UserMsg)]
28
+
29
+ async def run(self, event, context):
30
+ await asyncio.sleep(30) # stays running while the consumer goes away
31
+ return Patch().create(Done(text="ok"))
32
+
33
+
34
+ class Stubborn(Agent):
35
+ consumes = [Consume(UserMsg)]
36
+
37
+ async def run(self, event, context):
38
+ try:
39
+ await asyncio.sleep(30)
40
+ except asyncio.CancelledError:
41
+ await asyncio.sleep(0.5) # ignores the cancel for a bit
42
+ return Patch().create(Done(text="ok"))
43
+
44
+
45
+ async def collect_events(runtime):
46
+ return [ev async for ev in runtime.astream()]
47
+
48
+
49
+ def test_astream_yields_status_and_run_end():
50
+ ctx = Context()
51
+ runtime = Runtime(ctx, agents=[Announcer()])
52
+ ctx.create(UserMsg(text="привет"))
53
+
54
+ events = asyncio.run(collect_events(runtime))
55
+
56
+ assert events[0].kind == "run_start"
57
+ assert events[-1].kind == "run_end"
58
+ statuses = [e for e in events if e.kind == "status"]
59
+ assert [e.message for e in statuses] == [
60
+ "Думаю над вопросом...",
61
+ "Найдено 3 соответствия",
62
+ ]
63
+ assert statuses[0].data["source"] == "brain"
64
+ assert events[-1].data["runs"] >= 1
65
+ assert events[-1].data["outcome"] in {"completed", "budget_runs_exceeded"}
66
+
67
+
68
+ def test_announce_is_noop_without_subscribers():
69
+ ctx = Context()
70
+ ctx.announce("никто не слушает", kind="status") # must not raise
71
+ assert isinstance(ctx._hub, EventHub)
72
+
73
+
74
+ def test_astream_leaves_no_pending_tasks_after_consuming():
75
+ """Regression: the runner used to leave a cancelled-but-unawaited
76
+ ``queue.get()``/``done.wait()`` future pending, which surfaced as
77
+ "Task was destroyed but it is pending!" when the event loop closed."""
78
+
79
+ async def run():
80
+ ctx = Context()
81
+ runtime = Runtime(ctx, agents=[Announcer()])
82
+ ctx.create(UserMsg(text="привет"))
83
+ async for _ in runtime.astream():
84
+ pass
85
+ await asyncio.sleep(0)
86
+ return [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
87
+
88
+ assert asyncio.run(run()) == []
89
+
90
+
91
+ def test_astream_close_while_waiting_leaves_no_pending_tasks():
92
+ """A dropped consumer closed while the stream is awaiting
93
+ ``queue.get()``/``done.wait()`` must not leave those futures pending."""
94
+
95
+ async def run():
96
+ ctx = Context()
97
+ runtime = Runtime(ctx, agents=[Slow()])
98
+ ctx.create(UserMsg(text="привет"))
99
+ agen = runtime.astream()
100
+ assert (await agen.__anext__()).kind == "run_start"
101
+
102
+ puller = asyncio.create_task(agen.__anext__())
103
+ await asyncio.sleep(0) # enter the wait: queue.get()/done.wait() created
104
+ puller.cancel()
105
+ with contextlib.suppress(asyncio.CancelledError):
106
+ await puller
107
+ await agen.aclose()
108
+ for _ in range(10):
109
+ await asyncio.sleep(0)
110
+ return [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
111
+
112
+ assert asyncio.run(run()) == []
113
+
114
+
115
+ def test_eventhub_multiple_subscribers():
116
+ hub = EventHub()
117
+ q1 = hub.subscribe()
118
+ q2 = hub.subscribe()
119
+
120
+ hub.publish(ProgressEvent(kind="status", message="x"))
121
+ ev1 = q1.get_nowait()
122
+ ev2 = q2.get_nowait()
123
+ assert ev1.message == ev2.message == "x"
124
+
125
+ hub.unsubscribe(q1)
126
+ hub.unsubscribe(q2)
127
+ assert not hub.has_subscribers
128
+ hub.publish(ProgressEvent(kind="status", message="y"))
129
+ assert q2.empty()
130
+
131
+
132
+ def test_eventhub_bounds_a_stuck_subscriber_by_dropping_oldest():
133
+ hub = EventHub(maxsize=3)
134
+ q = hub.subscribe()
135
+ for i in range(5):
136
+ hub.publish(ProgressEvent(kind="status", message=str(i)))
137
+ assert q.qsize() == 3
138
+ assert [q.get_nowait().message for _ in range(3)] == ["2", "3", "4"]
139
+
140
+
141
+ def test_astream_close_is_bounded_when_runner_ignores_cancel(monkeypatch):
142
+ """A misbehaving agent that swallows `CancelledError` must not hang the
143
+ caller's disconnect path: `astream` gives up after a bounded grace."""
144
+ monkeypatch.setattr("reactifact.runtime._CANCEL_GRACE_SECONDS", 0.05)
145
+
146
+ async def run():
147
+ ctx = Context()
148
+ runtime = Runtime(ctx, agents=[Stubborn()])
149
+ ctx.create(UserMsg(text="привет"))
150
+ agen = runtime.astream()
151
+ assert (await agen.__anext__()).kind == "run_start"
152
+ started = asyncio.get_running_loop().time()
153
+ await agen.aclose()
154
+ return asyncio.get_running_loop().time() - started
155
+
156
+ assert asyncio.run(run()) < 0.4