reactifact 0.15.0__tar.gz → 0.15.2__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.2}/PKG-INFO +2 -2
  2. {reactifact-0.15.0 → reactifact-0.15.2}/README.md +1 -1
  3. {reactifact-0.15.0 → reactifact-0.15.2}/pyproject.toml +1 -1
  4. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/__init__.py +1 -1
  5. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/a2a/server.py +37 -9
  6. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/agents.py +42 -1
  7. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/chat.py +18 -4
  8. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/produce.py +35 -0
  9. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/runtime.py +54 -16
  10. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/streaming.py +13 -2
  11. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/tracing/__init__.py +2 -0
  12. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/tracing/langfuse.py +73 -0
  13. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/tracing/models.py +20 -0
  14. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/tracing/otlp.py +11 -0
  15. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/tracing/postgres.py +31 -13
  16. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/tracing/store.py +29 -13
  17. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/tracing/templates/app.css +12 -1
  18. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/tracing/templates/ui_run.html +30 -4
  19. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/tracing/tracer.py +13 -1
  20. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/web.py +10 -3
  21. {reactifact-0.15.0 → reactifact-0.15.2/reactifact.egg-info}/PKG-INFO +2 -2
  22. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_a2a.py +66 -0
  23. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_chat_web.py +74 -0
  24. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_produce_styles.py +17 -0
  25. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_public_api.py +1 -0
  26. reactifact-0.15.2/tests/test_streaming.py +156 -0
  27. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_tracing.py +117 -2
  28. reactifact-0.15.0/tests/test_streaming.py +0 -68
  29. {reactifact-0.15.0 → reactifact-0.15.2}/LICENSE +0 -0
  30. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/__main__.py +0 -0
  31. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/_extras.py +0 -0
  32. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/_httpx.py +0 -0
  33. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/a2a/__init__.py +0 -0
  34. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/a2a/client.py +0 -0
  35. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/a2a/models.py +0 -0
  36. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/a2a/remote.py +0 -0
  37. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/agent_tool.py +0 -0
  38. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/artifacts.py +0 -0
  39. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/audit.py +0 -0
  40. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/authz.py +0 -0
  41. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/branching.py +0 -0
  42. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/budget.py +0 -0
  43. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/cache.py +0 -0
  44. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/checkpoints.py +0 -0
  45. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/cli/__init__.py +0 -0
  46. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/cli/branch.py +0 -0
  47. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/cli/common.py +0 -0
  48. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/cli/context.py +0 -0
  49. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/cli/eval.py +0 -0
  50. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/cli/graph.py +0 -0
  51. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/cli/replay.py +0 -0
  52. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/cli/scenario.py +0 -0
  53. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/cli/skills.py +0 -0
  54. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/cli/trace.py +0 -0
  55. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/commit.py +0 -0
  56. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/commit_log.py +0 -0
  57. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/consume.py +0 -0
  58. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/context.py +0 -0
  59. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/context_builder.py +0 -0
  60. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/effects.py +0 -0
  61. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/eval/__init__.py +0 -0
  62. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/eval/dataset.py +0 -0
  63. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/eval/evaluators.py +0 -0
  64. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/eval/judge.py +0 -0
  65. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/eval/online.py +0 -0
  66. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/eval/runner.py +0 -0
  67. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/eval/scoring.py +0 -0
  68. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/events.py +0 -0
  69. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/guardrails.py +0 -0
  70. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/interrupt.py +0 -0
  71. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/llm_agent.py +0 -0
  72. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/logging.py +0 -0
  73. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/mcp/__init__.py +0 -0
  74. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/mcp/client.py +0 -0
  75. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/mcp/oauth.py +0 -0
  76. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/mcp/server.py +0 -0
  77. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/metrics.py +0 -0
  78. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/native_tool_use.py +0 -0
  79. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/operations.py +0 -0
  80. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/patches.py +0 -0
  81. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/pricing.py +0 -0
  82. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/prompts.py +0 -0
  83. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/providers/__init__.py +0 -0
  84. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/providers/_retry.py +0 -0
  85. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/providers/anthropic.py +0 -0
  86. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/providers/azure.py +0 -0
  87. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/providers/cerebras.py +0 -0
  88. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/providers/chat.py +0 -0
  89. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/providers/contracts.py +0 -0
  90. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/providers/deepseek.py +0 -0
  91. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/providers/fake.py +0 -0
  92. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/providers/fireworks.py +0 -0
  93. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/providers/gemini.py +0 -0
  94. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/providers/github_models.py +0 -0
  95. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/providers/groq.py +0 -0
  96. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/providers/image.py +0 -0
  97. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/providers/mistral.py +0 -0
  98. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/providers/nvidia.py +0 -0
  99. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/providers/ollama.py +0 -0
  100. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/providers/openai.py +0 -0
  101. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/providers/openrouter.py +0 -0
  102. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/providers/perplexity.py +0 -0
  103. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/providers/qwen.py +0 -0
  104. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/providers/speech.py +0 -0
  105. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/providers/together.py +0 -0
  106. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/providers/video.py +0 -0
  107. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/providers/xai.py +0 -0
  108. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/providers/zai.py +0 -0
  109. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/py.typed +0 -0
  110. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/quick/__init__.py +0 -0
  111. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/quick/_shared.py +0 -0
  112. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/quick/agent.py +0 -0
  113. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/quick/chat.py +0 -0
  114. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/quick/models.py +0 -0
  115. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/quick/rag.py +0 -0
  116. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/quick/tools_agent.py +0 -0
  117. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/quota.py +0 -0
  118. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/recipes/__init__.py +0 -0
  119. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/recipes/cleanup.py +0 -0
  120. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/recipes/conversation.py +0 -0
  121. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/recipes/identity.py +0 -0
  122. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/recipes/inputs.py +0 -0
  123. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/recipes/memory.py +0 -0
  124. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/recipes/plan_execute.py +0 -0
  125. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/recipes/reflection.py +0 -0
  126. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/recipes/resolve.py +0 -0
  127. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/recipes/rollback.py +0 -0
  128. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/recipes/search.py +0 -0
  129. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/recipes/skills.py +0 -0
  130. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/recipes/status.py +0 -0
  131. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/recipes/supervisor.py +0 -0
  132. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/recipes/text.py +0 -0
  133. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/recipes/tool_loop.py +0 -0
  134. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/redaction.py +0 -0
  135. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/relations.py +0 -0
  136. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/replay.py +0 -0
  137. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/request.py +0 -0
  138. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/resources.py +0 -0
  139. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/routing.py +0 -0
  140. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/scheduler.py +0 -0
  141. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/session.py +0 -0
  142. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/skills/reactifact/SKILL.md +0 -0
  143. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/skills/reactifact/references/effects-and-state.md +0 -0
  144. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/skills/reactifact/references/trust-and-safety.md +0 -0
  145. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/skills/reactifact-agents/SKILL.md +0 -0
  146. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/skills/reactifact-agents/references/consume.md +0 -0
  147. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/skills/reactifact-agents/references/effects.md +0 -0
  148. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/skills/reactifact-eval/SKILL.md +0 -0
  149. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/skills/reactifact-eval/references/online.md +0 -0
  150. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/skills/reactifact-from-langchain/SKILL.md +0 -0
  151. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/skills/reactifact-llm/SKILL.md +0 -0
  152. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/skills/reactifact-llm/references/providers.md +0 -0
  153. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/skills/reactifact-llm/references/tools.md +0 -0
  154. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/skills/reactifact-observability/SKILL.md +0 -0
  155. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/skills/reactifact-rag/SKILL.md +0 -0
  156. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/skills/reactifact-rag/references/sources.md +0 -0
  157. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/skills/reactifact-testing/SKILL.md +0 -0
  158. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/sources.py +0 -0
  159. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/structured.py +0 -0
  160. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/testing/__init__.py +0 -0
  161. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/testing/assertions.py +0 -0
  162. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/testing/exceptions.py +0 -0
  163. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/testing/fault.py +0 -0
  164. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/testing/golden.py +0 -0
  165. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/testing/lab.py +0 -0
  166. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/testing/mock.py +0 -0
  167. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/testing/pytest_plugin.py +0 -0
  168. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/testing/record.py +0 -0
  169. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/testing/registry.py +0 -0
  170. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/tool_use.py +0 -0
  171. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/tools.py +0 -0
  172. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/tracing/_otlp.py +0 -0
  173. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/tracing/columns.py +0 -0
  174. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/tracing/templates/sessions.html +0 -0
  175. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/tracing/templates/ui.html +0 -0
  176. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/tracing/web.py +0 -0
  177. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/triggers.py +0 -0
  178. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/types.py +0 -0
  179. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/verify.py +0 -0
  180. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact/viz.py +0 -0
  181. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact.egg-info/SOURCES.txt +0 -0
  182. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact.egg-info/dependency_links.txt +0 -0
  183. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact.egg-info/entry_points.txt +0 -0
  184. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact.egg-info/requires.txt +0 -0
  185. {reactifact-0.15.0 → reactifact-0.15.2}/reactifact.egg-info/top_level.txt +0 -0
  186. {reactifact-0.15.0 → reactifact-0.15.2}/setup.cfg +0 -0
  187. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_a2a_example.py +0 -0
  188. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_a2a_interop.py +0 -0
  189. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_absent_consume.py +0 -0
  190. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_adaptive.py +0 -0
  191. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_agent_tool.py +0 -0
  192. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_anthropic_provider.py +0 -0
  193. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_artifacts.py +0 -0
  194. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_audit.py +0 -0
  195. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_authz.py +0 -0
  196. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_backbone.py +0 -0
  197. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_branching.py +0 -0
  198. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_budget.py +0 -0
  199. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_cache.py +0 -0
  200. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_canonical_ports.py +0 -0
  201. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_chat_example.py +0 -0
  202. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_chat_memory.py +0 -0
  203. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_chat_outbox.py +0 -0
  204. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_checkpoint.py +0 -0
  205. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_checkpoints_concurrency.py +0 -0
  206. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_cli.py +0 -0
  207. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_cli_scenario.py +0 -0
  208. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_commit_log.py +0 -0
  209. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_compaction.py +0 -0
  210. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_concurrency.py +0 -0
  211. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_consumes_produces.py +0 -0
  212. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_context_builder.py +0 -0
  213. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_correlated_consume.py +0 -0
  214. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_debounce.py +0 -0
  215. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_determinism.py +0 -0
  216. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_devops.py +0 -0
  217. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_devops_online.py +0 -0
  218. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_devops_web.py +0 -0
  219. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_effects.py +0 -0
  220. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_eval.py +0 -0
  221. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_eval_dataset.py +0 -0
  222. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_eval_judge.py +0 -0
  223. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_eval_online.py +0 -0
  224. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_eval_trajectory.py +0 -0
  225. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_fintech_audit.py +0 -0
  226. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_forklab.py +0 -0
  227. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_forklab_web.py +0 -0
  228. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_friendly_api.py +0 -0
  229. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_gemini_provider.py +0 -0
  230. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_golden.py +0 -0
  231. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_guardrails.py +0 -0
  232. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_hitl.py +0 -0
  233. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_image_provider.py +0 -0
  234. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_incident_commander.py +0 -0
  235. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_invalidation.py +0 -0
  236. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_join_consume.py +0 -0
  237. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_knowledge.py +0 -0
  238. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_knowledge_web.py +0 -0
  239. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_llm_ladder.py +0 -0
  240. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_logging.py +0 -0
  241. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_loop_bound_client.py +0 -0
  242. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_mcp.py +0 -0
  243. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_medic_lab.py +0 -0
  244. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_medic_lab_web.py +0 -0
  245. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_metrics.py +0 -0
  246. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_multisource.py +0 -0
  247. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_native_tool_use.py +0 -0
  248. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_openai_provider.py +0 -0
  249. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_outbox.py +0 -0
  250. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_patches.py +0 -0
  251. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_pending_actions.py +0 -0
  252. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_ports.py +0 -0
  253. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_produce_trigger.py +0 -0
  254. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_prompts.py +0 -0
  255. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_provider_auth.py +0 -0
  256. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_provider_retry.py +0 -0
  257. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_providers_integration.py +0 -0
  258. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_quick.py +0 -0
  259. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_quota.py +0 -0
  260. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_reacts_to.py +0 -0
  261. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_recipe_conversation.py +0 -0
  262. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_recipe_plan_execute.py +0 -0
  263. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_recipe_reflection.py +0 -0
  264. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_recipe_supervisor.py +0 -0
  265. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_recipe_tool_loop.py +0 -0
  266. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_recipes.py +0 -0
  267. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_recipes_inputs.py +0 -0
  268. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_recipes_memory.py +0 -0
  269. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_recipes_skills.py +0 -0
  270. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_redaction.py +0 -0
  271. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_relation_graph.py +0 -0
  272. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_relations.py +0 -0
  273. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_repair.py +0 -0
  274. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_repair_web.py +0 -0
  275. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_replay.py +0 -0
  276. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_repo_agent.py +0 -0
  277. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_request.py +0 -0
  278. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_research.py +0 -0
  279. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_resources.py +0 -0
  280. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_retry.py +0 -0
  281. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_routing.py +0 -0
  282. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_runtime.py +0 -0
  283. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_runtime_errors.py +0 -0
  284. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_scheduler_semantics.py +0 -0
  285. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_sessions.py +0 -0
  286. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_shutdown.py +0 -0
  287. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_skills.py +0 -0
  288. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_sources.py +0 -0
  289. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_sources_search.py +0 -0
  290. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_sources_vector.py +0 -0
  291. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_speech_provider.py +0 -0
  292. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_starter_app.py +0 -0
  293. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_structured.py +0 -0
  294. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_support_copilot.py +0 -0
  295. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_testing_assertions.py +0 -0
  296. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_testing_golden.py +0 -0
  297. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_testing_lab.py +0 -0
  298. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_testing_mock.py +0 -0
  299. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_testing_provenance.py +0 -0
  300. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_testing_pytest_plugin.py +0 -0
  301. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_testing_registry.py +0 -0
  302. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_token_budget.py +0 -0
  303. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_tools.py +0 -0
  304. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_trace_columns.py +0 -0
  305. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_trace_tags.py +0 -0
  306. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_type_registry.py +0 -0
  307. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_vendor_factories.py +0 -0
  308. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_vendor_multimodal_factories.py +0 -0
  309. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_verify.py +0 -0
  310. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_video_provider.py +0 -0
  311. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_view.py +0 -0
  312. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_viz.py +0 -0
  313. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_web_source.py +0 -0
  314. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_workspace.py +0 -0
  315. {reactifact-0.15.0 → reactifact-0.15.2}/tests/test_workspace_with_sources.py +0 -0
  316. {reactifact-0.15.0 → reactifact-0.15.2}/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.2
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
@@ -380,7 +380,7 @@ uv sync --extra dev --extra web
380
380
 
381
381
  ## Status & contributing
382
382
 
383
- Active, `0.14.x`, pre-1.0 — the API is stabilizing and `1.0` will freeze it
383
+ Active, `0.15.x`, pre-1.0 — the API is stabilizing and `1.0` will freeze it
384
384
  (see the [roadmap](docs/roadmap.md)); releases follow
385
385
  [Keep a Changelog](CHANGELOG.md). Questions and design discussion live in
386
386
  [GitHub Discussions](https://github.com/bzdvdn/reactifact/discussions); a star,
@@ -334,7 +334,7 @@ uv sync --extra dev --extra web
334
334
 
335
335
  ## Status & contributing
336
336
 
337
- Active, `0.14.x`, pre-1.0 — the API is stabilizing and `1.0` will freeze it
337
+ Active, `0.15.x`, pre-1.0 — the API is stabilizing and `1.0` will freeze it
338
338
  (see the [roadmap](docs/roadmap.md)); releases follow
339
339
  [Keep a Changelog](CHANGELOG.md). Questions and design discussion live in
340
340
  [GitHub Discussions](https://github.com/bzdvdn/reactifact/discussions); a star,
@@ -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.2"
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.2"
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)
@@ -1,17 +1,42 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import time
3
4
  from abc import ABC
4
5
  from collections.abc import Sequence
6
+ from contextvars import ContextVar, Token
5
7
  from typing import Any
6
8
 
7
9
  from .artifacts import Artifact
8
10
  from .consume import Consume
9
11
  from .context import Context
12
+ from .effects import current_effects
10
13
  from .events import Event, EventType
11
14
  from .patches import Patch
12
15
  from .produce import Produce, ProduceCall
13
16
  from .triggers import Trigger
14
17
 
18
+ #: Per-task log of the produces that ran for the current agent execution:
19
+ #: ``(produce_class_name, operations_authored, latency_ms)``. ``Runtime._execute``
20
+ #: opens it (and later records the span), ``Agent.execute`` appends — a
21
+ #: contextvar so concurrent executions sharing one ``Agent`` instance never race.
22
+ _produce_runs: ContextVar[list[tuple[str, int, float]] | None] = ContextVar(
23
+ "reactifact_produce_runs", default=None
24
+ )
25
+
26
+
27
+ def begin_produce_runs() -> Token[Any]:
28
+ """Opens the per-task produce-run log (see ``_produce_runs``)."""
29
+ return _produce_runs.set([])
30
+
31
+
32
+ def produce_runs() -> list[tuple[str, int, float]]:
33
+ """The produce runs recorded for the current task (empty if none)."""
34
+ return _produce_runs.get() or []
35
+
36
+
37
+ def end_produce_runs(token: Token[Any]) -> None:
38
+ _produce_runs.reset(token)
39
+
15
40
 
16
41
  class Agent(ABC): # noqa: B024 — interface without abstract methods, run() has a default
17
42
  """Base container: consumes some artifacts and produces others.
@@ -154,14 +179,30 @@ class Agent(ABC): # noqa: B024 — interface without abstract methods, run() ha
154
179
  if not self.produces:
155
180
  return None
156
181
  inputs = self._collect_inputs(context)
182
+ slot = current_effects()
183
+ log = _produce_runs.get()
157
184
  for p in self.produces:
158
185
  runs, trigger = self._resolve_produce_call(p, event, context)
159
186
  if not runs:
160
187
  continue
188
+ before = len(slot.operations) if slot is not None else 0
189
+ started = time.monotonic()
161
190
  call = ProduceCall(
162
191
  context=context, inputs=inputs, event=event, trigger=trigger
163
192
  )
164
- await p.produce(call)
193
+ try:
194
+ await p.produce(call)
195
+ finally:
196
+ # Record the produce even when it raises — a failing produce is
197
+ # exactly what a trace should show (the span is then the error
198
+ # span, with this produce's partial operations/latency).
199
+ if log is not None:
200
+ operations = (
201
+ len(slot.operations) if slot is not None else 0
202
+ ) - before
203
+ log.append(
204
+ (p.name, operations, (time.monotonic() - started) * 1000)
205
+ )
165
206
  return None
166
207
 
167
208
  @staticmethod
@@ -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,
@@ -266,6 +266,37 @@ class Produce(Generic[TOut]):
266
266
  """
267
267
  return None
268
268
 
269
+ #: Explicit display name (set by e.g. tool-use produces); when unset,
270
+ #: `name` derives a readable one from the class/function/artifact type.
271
+ _name: str | None = None
272
+
273
+ @property
274
+ def name(self) -> str:
275
+ """A human-readable name for traces/logs.
276
+
277
+ Subclass-style produces use their class name; an ``@produce``-decorated
278
+ function uses the wrapped function's name; a bare ``Produce(Model)``
279
+ (declared only to widen an agent's allowed ``Create`` types) falls back
280
+ to its output artifact type — otherwise every such no-op would show up
281
+ as a generic ``Produce``. An explicit ``name`` (e.g. a tool-use produce's)
282
+ always wins.
283
+ """
284
+ explicit = getattr(self, "_name", None)
285
+ if explicit:
286
+ return str(explicit)
287
+ cls = type(self).__name__
288
+ if cls not in ("Produce", "_FunctionProduce"):
289
+ return cls
290
+ func = getattr(self, "_func", None)
291
+ if func is not None:
292
+ return str(getattr(func, "__name__", cls))
293
+ artifact = getattr(self.artifact_type, "__name__", "")
294
+ return f"Produce[{artifact}]" if artifact else cls
295
+
296
+ @name.setter
297
+ def name(self, value: str) -> None:
298
+ self._name = value
299
+
269
300
  def _apply_result(self, result: Any) -> None:
270
301
  """Writes a `@produce`-decorated function's return-style result into
271
302
  the effect slot (§24).
@@ -319,6 +350,8 @@ def produce(
319
350
  )
320
351
 
321
352
  class _FunctionProduce(Produce[Any]):
353
+ _func: Callable[[ProduceCall], Any]
354
+
322
355
  async def produce(self, call: ProduceCall) -> None:
323
356
  result = func(call)
324
357
  if asyncio.iscoroutine(result):
@@ -329,6 +362,8 @@ def produce(
329
362
  instance = _FunctionProduce(
330
363
  artifact_type=artifact_type, also_creates=also_creates, reacts_to=reacts_to
331
364
  )
365
+ # Keep the function for `Produce.name` (a readable trace/log label).
366
+ instance._func = func
332
367
  return instance
333
368
 
334
369
  return decorator
@@ -7,7 +7,7 @@ from collections.abc import AsyncIterator, Awaitable, Callable, Mapping
7
7
  from dataclasses import dataclass
8
8
  from typing import Any, Literal, cast
9
9
 
10
- from .agents import Agent
10
+ from .agents import Agent, begin_produce_runs, end_produce_runs, produce_runs
11
11
  from .artifacts import Artifact
12
12
  from .budget import Budget, BudgetLLM, BudgetTracker, RunOutcome, RunStats
13
13
  from .commit import Commit, Read, Write
@@ -31,9 +31,17 @@ logger = get_logger(__name__)
31
31
  #: A scheduled patch with its trigger reads and (optional) trace span.
32
32
  PatchWork = tuple[Patch, Agent, list[Read], AgentSpan | None]
33
33
  #: One agent execution result: patch (if any), the agent/event/reads that
34
- #: produced it, latency, and the exception if the produce raised and
35
- #: `isolate_errors` was set.
36
- AgentResult = tuple[Patch | None, Agent, Event, list[Read], float, BaseException | None]
34
+ #: produced it, latency, the exception if the produce raised and
35
+ #: `isolate_errors` was set, and the produces that ran (name/ops/latency).
36
+ AgentResult = tuple[
37
+ Patch | None,
38
+ Agent,
39
+ Event,
40
+ list[Read],
41
+ float,
42
+ BaseException | None,
43
+ list[tuple[str, int, float]],
44
+ ]
37
45
  #: Performs one committed `PendingAction` (outbox). Called by the runtime after
38
46
  #: the intent is committed; the app performs the real I/O and returns (or
39
47
  #: raises). Must be idempotent on `idempotency_key` (§42).
@@ -59,6 +67,11 @@ class RunInfo:
59
67
  _ACTIVE_RUNS: dict[str, RunInfo] = {}
60
68
  _RUN_TASKS: dict[str, "asyncio.Task[Any]"] = {}
61
69
 
70
+ #: How long `astream` waits for a cancelled runner to unwind before giving up
71
+ #: (a well-behaved turn stops immediately; this only guards a misbehaving agent
72
+ #: that swallows `CancelledError`).
73
+ _CANCEL_GRACE_SECONDS = 5.0
74
+
62
75
 
63
76
  def active_runs() -> list[RunInfo]:
64
77
  """Snapshots of the turns currently executing in this process.
@@ -645,18 +658,22 @@ class Runtime:
645
658
  """
646
659
  patches_to_apply: list[PatchWork] = []
647
660
  runs = 0
648
- for patch, agent, event, reads, latency, error in results:
661
+ for patch, agent, event, reads, latency, error, produce_run_list in results:
649
662
  if self._budget_exhausted():
650
663
  break
651
664
  runs += 1
652
665
  self._runs_used += 1
653
666
  if error is not None:
654
667
  self._errors_used += 1
655
- self._trace.record_span(agent, event, reads, latency, error=error)
668
+ self._trace.record_span(
669
+ agent, event, reads, latency, error=error, produces=produce_run_list
670
+ )
656
671
  continue
657
672
  if patch is None or patch.is_empty():
658
673
  continue
659
- span = self._trace.record_span(agent, event, reads, latency)
674
+ span = self._trace.record_span(
675
+ agent, event, reads, latency, produces=produce_run_list
676
+ )
660
677
  self._validate_patch_types(patch, agent)
661
678
  self._enforce_authorization(patch)
662
679
  patch = self._apply_guardrails(patch)
@@ -789,6 +806,7 @@ class Runtime:
789
806
  task = asyncio.current_task()
790
807
  self._trace.register_task(task, agent.name)
791
808
  effects_token = set_effects(Effects(self.context))
809
+ produce_runs_token = begin_produce_runs()
792
810
  slot: Effects | None = None
793
811
  patch: Patch | None = None
794
812
  error: BaseException | None = None
@@ -812,6 +830,8 @@ class Runtime:
812
830
  if self.on_agent_error is not None:
813
831
  self.on_agent_error(agent, event, exc)
814
832
  finally:
833
+ runs = produce_runs()
834
+ end_produce_runs(produce_runs_token)
815
835
  reset_effects(effects_token)
816
836
  self._trace.unregister_task(task)
817
837
  latency = (time.monotonic() - started) * 1000
@@ -824,7 +844,7 @@ class Runtime:
824
844
  "error": repr(error),
825
845
  },
826
846
  )
827
- return None, agent, event, reads, latency, error
847
+ return None, agent, event, reads, latency, error, runs
828
848
  logger.debug(
829
849
  "agent finished",
830
850
  extra={"agent": agent.name, "latency_ms": round(latency, 2)},
@@ -838,7 +858,7 @@ class Runtime:
838
858
  combined.merge(patch)
839
859
  combined.merge(slot.to_patch())
840
860
  patch = combined
841
- return patch, agent, event, reads, latency, None
861
+ return patch, agent, event, reads, latency, None, runs
842
862
 
843
863
  async def arun(
844
864
  self,
@@ -1031,13 +1051,22 @@ class Runtime:
1031
1051
  break
1032
1052
  get_event = asyncio.ensure_future(queue.get())
1033
1053
  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()
1054
+ try:
1055
+ finished, _ = await asyncio.wait(
1056
+ {get_event, wait_done}, return_when=asyncio.FIRST_COMPLETED
1057
+ )
1058
+ if get_event in finished:
1059
+ yield get_event.result()
1060
+ finally:
1061
+ # Always cancel *and await* the losing future. A task that is
1062
+ # cancelled but never awaited stays pending until the loop
1063
+ # closes, producing "Task was destroyed but it is pending!"
1064
+ # (`Queue.get`/`Event.wait`) on every early generator close
1065
+ # (e.g. a dropped SSE client).
1066
+ for fut in (get_event, wait_done):
1067
+ if not fut.done():
1068
+ fut.cancel()
1069
+ await asyncio.gather(get_event, wait_done, return_exceptions=True)
1041
1070
  # Re-raise any agent/runtime exception instead of silently dropping it:
1042
1071
  # an error inside a run must reach the caller, not hide in the task.
1043
1072
  await task
@@ -1053,6 +1082,15 @@ class Runtime:
1053
1082
  )
1054
1083
  finally:
1055
1084
  task.cancel()
1085
+ # Settle the runner too, so an early generator close (client
1086
+ # disconnect) does not leave the run task pending at loop shutdown.
1087
+ # Bounded: an agent that swallows `CancelledError` must never hang
1088
+ # the caller's disconnect/shutdown path.
1089
+ with contextlib.suppress(Exception):
1090
+ await asyncio.wait_for(
1091
+ asyncio.gather(task, return_exceptions=True),
1092
+ timeout=_CANCEL_GRACE_SECONDS,
1093
+ )
1056
1094
  self.context.unsubscribe(queue)
1057
1095
 
1058
1096
  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)
@@ -11,6 +11,7 @@ from .models import (
11
11
  AgentSpan,
12
12
  ArtifactRef,
13
13
  LLMCall,
14
+ ProduceRun,
14
15
  RelationRef,
15
16
  RunTrace,
16
17
  Tag,
@@ -35,6 +36,7 @@ __all__ = [
35
36
  "LangfuseTracer",
36
37
  "OTLPTracer",
37
38
  "PostgresStore",
39
+ "ProduceRun",
38
40
  "RecordingLLM",
39
41
  "RelationRef",
40
42
  "RunTrace",
@@ -96,6 +96,11 @@ class LangfuseTracer(Tracer):
96
96
  )
97
97
  span_start = span.started_at or run_start
98
98
  agent_span_id = _span_id(f"{trace_id}:span:{i}:{span.agent}")
99
+ spans.extend(
100
+ self._produce_spans(
101
+ trace_id, agent_span_id, trace_attrs, run_start, i, span
102
+ )
103
+ )
99
104
  for j, call in enumerate(span.llm_calls):
100
105
  spans.append(
101
106
  self._llm_span(
@@ -145,8 +150,29 @@ class LangfuseTracer(Tracer):
145
150
  _type_summary(span.writes),
146
151
  ),
147
152
  ]
153
+ if span.event_artifact_type:
154
+ attrs.append(
155
+ _attr(
156
+ "langfuse.observation.metadata.event_artifact_type",
157
+ span.event_artifact_type,
158
+ )
159
+ )
148
160
  if span.error:
149
161
  attrs.append(_attr("langfuse.observation.metadata.error", span.error))
162
+ if span.produces:
163
+ attrs.append(
164
+ _attr(
165
+ "langfuse.observation.metadata.produces",
166
+ [
167
+ {
168
+ "name": p.name,
169
+ "operations": p.operations,
170
+ "latency_ms": p.latency_ms,
171
+ }
172
+ for p in span.produces
173
+ ],
174
+ )
175
+ )
150
176
  return {
151
177
  "traceId": trace_id,
152
178
  "spanId": _span_id(f"{trace_id}:span:{index}:{span.agent}"),
@@ -158,6 +184,53 @@ class LangfuseTracer(Tracer):
158
184
  "attributes": attrs,
159
185
  }
160
186
 
187
+ def _produce_spans(
188
+ self,
189
+ trace_id: str,
190
+ parent_span_id: str,
191
+ trace_attrs: list[dict[str, Any]],
192
+ run_start: datetime,
193
+ span_index: int,
194
+ span: AgentSpan,
195
+ ) -> list[dict[str, Any]]:
196
+ """One child observation per produce the agent ran, so Langfuse's
197
+ waterfall shows the ``Consume → Produce`` steps instead of a single
198
+ opaque agent span.
199
+
200
+ Produces run sequentially within an agent, so they are laid out
201
+ back-to-back from the span start by each one's measured latency.
202
+ """
203
+ if not span.produces:
204
+ return []
205
+ cursor = span.started_at or run_start
206
+ out: list[dict[str, Any]] = []
207
+ for k, produce in enumerate(span.produces):
208
+ end = cursor + timedelta(milliseconds=produce.latency_ms)
209
+ out.append(
210
+ {
211
+ "traceId": trace_id,
212
+ "spanId": _span_id(
213
+ f"{trace_id}:produce:{span_index}:{k}:{produce.name}"
214
+ ),
215
+ "parentSpanId": parent_span_id,
216
+ "name": produce.name,
217
+ "kind": 1,
218
+ "startTimeUnixNano": _unix_nanos(cursor),
219
+ "endTimeUnixNano": _unix_nanos(end),
220
+ "attributes": [
221
+ *trace_attrs,
222
+ _attr("langfuse.observation.type", "span"),
223
+ _attr("langfuse.observation.metadata.agent", span.agent),
224
+ _attr(
225
+ "langfuse.observation.metadata.operations",
226
+ produce.operations,
227
+ ),
228
+ ],
229
+ }
230
+ )
231
+ cursor = end
232
+ return out
233
+
161
234
  def _llm_span(
162
235
  self,
163
236
  trace_id: str,
@@ -88,11 +88,31 @@ class TagAssignment(BaseModel):
88
88
  updated_at: datetime | None = None
89
89
 
90
90
 
91
+ class ProduceRun(BaseModel):
92
+ """One produce execution within an agent span (§54).
93
+
94
+ An agent runs one or more ``Produce``s per event; the span records which
95
+ ones actually ran and how much each contributed (``operations`` = authored
96
+ effect operations, so the produce that did the work is distinguishable from
97
+ one that merely matched the event).
98
+ """
99
+
100
+ name: str
101
+ operations: int = 0
102
+ latency_ms: float = 0.0
103
+
104
+
91
105
  class AgentSpan(BaseModel):
92
106
  """A single agent execution within a run (§54)."""
93
107
 
94
108
  agent: str
95
109
  event_type: str = ""
110
+ #: The artifact type whose change triggered this agent (its matched
111
+ #: ``Consume``) — e.g. ``DraftAnswer`` vs ``FinalResponse`` for an agent that
112
+ #: consumes both. ``event_type`` alone (created/updated) can't tell them apart.
113
+ event_artifact_type: str = ""
114
+ #: The produces that ran for this event, in execution order.
115
+ produces: list[ProduceRun] = Field(default_factory=list)
96
116
  reads: list[ArtifactRef] = Field(default_factory=list)
97
117
  writes: list[ArtifactRef] = Field(default_factory=list)
98
118
  relations: list[RelationRef] = Field(default_factory=list)
@@ -123,8 +123,19 @@ class OTLPTracer(Tracer):
123
123
  attr("reactifact.reads", type_summary(span.reads)),
124
124
  attr("reactifact.writes", type_summary(span.writes)),
125
125
  ]
126
+ if span.event_artifact_type:
127
+ attrs.append(
128
+ attr("reactifact.event_artifact_type", span.event_artifact_type)
129
+ )
126
130
  if span.error:
127
131
  attrs.append(attr("error.type", span.error))
132
+ if span.produces:
133
+ attrs.append(
134
+ attr(
135
+ "reactifact.produces",
136
+ ", ".join(f"{p.name}({p.operations})" for p in span.produces),
137
+ )
138
+ )
128
139
  return {
129
140
  "traceId": tid,
130
141
  "spanId": span_id(f"{tid}:span:{index}:{span.agent}"),