reactifact 0.6.0__tar.gz → 0.6.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 (188) hide show
  1. {reactifact-0.6.0/reactifact.egg-info → reactifact-0.6.1}/PKG-INFO +26 -1
  2. {reactifact-0.6.0 → reactifact-0.6.1}/README.md +12 -0
  3. {reactifact-0.6.0 → reactifact-0.6.1}/pyproject.toml +26 -3
  4. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/__init__.py +1 -1
  5. reactifact-0.6.1/reactifact/mcp/__init__.py +18 -0
  6. reactifact-0.6.1/reactifact/mcp/client.py +104 -0
  7. reactifact-0.6.1/reactifact/mcp/server.py +162 -0
  8. reactifact-0.6.1/reactifact/tracing/langfuse.py +227 -0
  9. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/tracing/templates/ui.html +1 -1
  10. {reactifact-0.6.0 → reactifact-0.6.1/reactifact.egg-info}/PKG-INFO +26 -1
  11. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact.egg-info/SOURCES.txt +4 -0
  12. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact.egg-info/requires.txt +3 -0
  13. reactifact-0.6.1/tests/test_mcp.py +169 -0
  14. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_tracing.py +40 -17
  15. reactifact-0.6.0/reactifact/tracing/langfuse.py +0 -125
  16. {reactifact-0.6.0 → reactifact-0.6.1}/LICENSE +0 -0
  17. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/__main__.py +0 -0
  18. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/_extras.py +0 -0
  19. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/agents.py +0 -0
  20. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/artifacts.py +0 -0
  21. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/branching.py +0 -0
  22. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/budget.py +0 -0
  23. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/chat.py +0 -0
  24. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/checkpoints.py +0 -0
  25. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/cli/__init__.py +0 -0
  26. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/cli/branch.py +0 -0
  27. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/cli/common.py +0 -0
  28. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/cli/context.py +0 -0
  29. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/cli/graph.py +0 -0
  30. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/cli/replay.py +0 -0
  31. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/cli/scenario.py +0 -0
  32. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/cli/trace.py +0 -0
  33. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/commit.py +0 -0
  34. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/commit_log.py +0 -0
  35. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/consume.py +0 -0
  36. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/context.py +0 -0
  37. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/effects.py +0 -0
  38. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/eval.py +0 -0
  39. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/events.py +0 -0
  40. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/interrupt.py +0 -0
  41. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/llm_agent.py +0 -0
  42. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/operations.py +0 -0
  43. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/patches.py +0 -0
  44. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/produce.py +0 -0
  45. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/prompts.py +0 -0
  46. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/providers/__init__.py +0 -0
  47. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/providers/_retry.py +0 -0
  48. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/providers/anthropic.py +0 -0
  49. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/providers/azure.py +0 -0
  50. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/providers/cerebras.py +0 -0
  51. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/providers/chat.py +0 -0
  52. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/providers/contracts.py +0 -0
  53. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/providers/deepseek.py +0 -0
  54. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/providers/fake.py +0 -0
  55. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/providers/fireworks.py +0 -0
  56. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/providers/gemini.py +0 -0
  57. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/providers/github_models.py +0 -0
  58. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/providers/groq.py +0 -0
  59. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/providers/image.py +0 -0
  60. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/providers/mistral.py +0 -0
  61. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/providers/nvidia.py +0 -0
  62. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/providers/ollama.py +0 -0
  63. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/providers/openai.py +0 -0
  64. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/providers/openrouter.py +0 -0
  65. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/providers/perplexity.py +0 -0
  66. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/providers/qwen.py +0 -0
  67. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/providers/speech.py +0 -0
  68. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/providers/together.py +0 -0
  69. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/providers/video.py +0 -0
  70. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/providers/xai.py +0 -0
  71. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/providers/zai.py +0 -0
  72. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/py.typed +0 -0
  73. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/recipes/__init__.py +0 -0
  74. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/recipes/inputs.py +0 -0
  75. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/recipes/memory.py +0 -0
  76. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/recipes/resolve.py +0 -0
  77. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/recipes/rollback.py +0 -0
  78. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/recipes/search.py +0 -0
  79. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/recipes/skills.py +0 -0
  80. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/recipes/status.py +0 -0
  81. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/recipes/text.py +0 -0
  82. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/relations.py +0 -0
  83. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/replay.py +0 -0
  84. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/resources.py +0 -0
  85. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/runtime.py +0 -0
  86. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/scheduler.py +0 -0
  87. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/session.py +0 -0
  88. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/sources.py +0 -0
  89. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/streaming.py +0 -0
  90. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/structured.py +0 -0
  91. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/testing/__init__.py +0 -0
  92. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/testing/assertions.py +0 -0
  93. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/testing/exceptions.py +0 -0
  94. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/testing/fault.py +0 -0
  95. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/testing/lab.py +0 -0
  96. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/testing/mock.py +0 -0
  97. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/testing/record.py +0 -0
  98. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/testing/registry.py +0 -0
  99. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/tool_use.py +0 -0
  100. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/tools.py +0 -0
  101. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/tracing/__init__.py +0 -0
  102. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/tracing/models.py +0 -0
  103. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/tracing/postgres.py +0 -0
  104. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/tracing/store.py +0 -0
  105. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/tracing/templates/ui_run.html +0 -0
  106. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/tracing/tracer.py +0 -0
  107. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/tracing/web.py +0 -0
  108. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/triggers.py +0 -0
  109. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/viz.py +0 -0
  110. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/web.py +0 -0
  111. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact.egg-info/dependency_links.txt +0 -0
  112. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact.egg-info/entry_points.txt +0 -0
  113. {reactifact-0.6.0 → reactifact-0.6.1}/reactifact.egg-info/top_level.txt +0 -0
  114. {reactifact-0.6.0 → reactifact-0.6.1}/setup.cfg +0 -0
  115. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_adaptive.py +0 -0
  116. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_anthropic_provider.py +0 -0
  117. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_artifacts.py +0 -0
  118. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_backbone.py +0 -0
  119. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_branching.py +0 -0
  120. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_budget.py +0 -0
  121. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_chat_web.py +0 -0
  122. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_checkpoint.py +0 -0
  123. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_checkpoints_concurrency.py +0 -0
  124. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_cli.py +0 -0
  125. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_cli_scenario.py +0 -0
  126. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_commit_log.py +0 -0
  127. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_concurrency.py +0 -0
  128. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_consumes_produces.py +0 -0
  129. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_devops.py +0 -0
  130. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_devops_web.py +0 -0
  131. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_effects.py +0 -0
  132. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_eval.py +0 -0
  133. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_forklab.py +0 -0
  134. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_forklab_web.py +0 -0
  135. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_friendly_api.py +0 -0
  136. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_gemini_provider.py +0 -0
  137. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_hitl.py +0 -0
  138. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_image_provider.py +0 -0
  139. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_invalidation.py +0 -0
  140. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_knowledge.py +0 -0
  141. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_knowledge_web.py +0 -0
  142. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_llm_ladder.py +0 -0
  143. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_medic_lab.py +0 -0
  144. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_medic_lab_web.py +0 -0
  145. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_multisource.py +0 -0
  146. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_openai_provider.py +0 -0
  147. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_patches.py +0 -0
  148. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_ports.py +0 -0
  149. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_produce_styles.py +0 -0
  150. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_prompts.py +0 -0
  151. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_provider_auth.py +0 -0
  152. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_provider_retry.py +0 -0
  153. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_providers_integration.py +0 -0
  154. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_recipes.py +0 -0
  155. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_recipes_inputs.py +0 -0
  156. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_recipes_memory.py +0 -0
  157. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_recipes_skills.py +0 -0
  158. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_relation_graph.py +0 -0
  159. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_relations.py +0 -0
  160. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_repair.py +0 -0
  161. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_repair_web.py +0 -0
  162. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_replay.py +0 -0
  163. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_research.py +0 -0
  164. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_resources.py +0 -0
  165. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_retry.py +0 -0
  166. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_runtime.py +0 -0
  167. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_runtime_errors.py +0 -0
  168. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_sessions.py +0 -0
  169. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_sources.py +0 -0
  170. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_sources_search.py +0 -0
  171. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_sources_vector.py +0 -0
  172. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_speech_provider.py +0 -0
  173. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_streaming.py +0 -0
  174. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_structured.py +0 -0
  175. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_testing_assertions.py +0 -0
  176. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_testing_lab.py +0 -0
  177. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_testing_mock.py +0 -0
  178. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_testing_registry.py +0 -0
  179. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_tools.py +0 -0
  180. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_vendor_factories.py +0 -0
  181. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_vendor_multimodal_factories.py +0 -0
  182. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_video_provider.py +0 -0
  183. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_view.py +0 -0
  184. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_viz.py +0 -0
  185. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_web_source.py +0 -0
  186. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_workspace.py +0 -0
  187. {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_workspace_with_sources.py +0 -0
  188. {reactifact-0.6.0 → reactifact-0.6.1}/tests/tests_checkpoints_sqlite.py +0 -0
@@ -1,10 +1,21 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reactifact
3
- Version: 0.6.0
3
+ Version: 0.6.1
4
4
  Summary: Reactive, artifact-driven agent runtime: agents transform versioned, typed, provenance-aware artifacts inside an evolving context
5
+ License-Expression: MIT
5
6
  Project-URL: Homepage, https://github.com/bzdvdn/reactifact
6
7
  Project-URL: Repository, https://github.com/bzdvdn/reactifact
7
8
  Project-URL: Documentation, https://github.com/bzdvdn/reactifact/tree/master/docs
9
+ Project-URL: Changelog, https://github.com/bzdvdn/reactifact/blob/master/CHANGELOG.md
10
+ Keywords: agents,llm,ai-agents,reactive,provenance,orchestration,agentic
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
14
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Typing :: Typed
8
19
  Requires-Python: >=3.11
9
20
  Description-Content-Type: text/markdown
10
21
  License-File: LICENSE
@@ -21,6 +32,8 @@ Requires-Dist: fastapi>=0.115; extra == "web"
21
32
  Requires-Dist: uvicorn[standard]>=0.30; extra == "web"
22
33
  Provides-Extra: pg
23
34
  Requires-Dist: psycopg[binary]>=3.2; extra == "pg"
35
+ Provides-Extra: mcp
36
+ Requires-Dist: mcp>=2.2; extra == "mcp"
24
37
  Dynamic: license-file
25
38
 
26
39
  # reactifact
@@ -41,6 +54,8 @@ reactifact flips the model. You describe **what artifacts exist and what agents
41
54
  do with them**; the runtime derives what runs next from **state changes**. Agents
42
55
  react to events — there is no graph, no node pipeline.
43
56
 
57
+ ![Left: a hand-wired fetch → verify → answer pipeline. Right: reactifact — search_agent and answer_agent each declare only what they consume and produce, wired together by Context, never each other.](docs/img/wiring.svg)
58
+
44
59
  ```bash
45
60
  pip install reactifact
46
61
  ```
@@ -107,6 +122,12 @@ print(answer.data.text) # "Refunds are available within 14 d
107
122
  print("supported_by:", evidence.data.text) # provenance you can trace, not just a string in a log
108
123
  ```
109
124
 
125
+ The same idea, live — the [`knowledge`](examples/knowledge) example's CLI answering a
126
+ harder, multi-source question (docs + a CSV) with a real computed number and
127
+ its sources, no LLM key required:
128
+
129
+ ![CLI demo: asking "how much does gpu cost in total?" — the runtime searches docs and a spreadsheet, computes the sum, verifies it, and answers with sources.](docs/img/knowledge-cli-demo.gif)
130
+
110
131
  ## How it works
111
132
 
112
133
  ```text
@@ -160,6 +181,10 @@ Full breakdown, including where reactifact is *not* the right choice:
160
181
  fallbacks instead of hallucinated answers; the model reasons, never "knows".
161
182
  - **Observability** — every run traces agent spans, reads/writes, LLM calls,
162
183
  tokens: SQLite store + web dashboard, exportable to Langfuse/Postgres (async sinks).
184
+ - **MCP, both ways** — call any MCP server's tools as a `Tool`
185
+ (`mcp_stdio_tools`/`mcp_http_tools`), or expose your own `Tool`s and a live
186
+ `Context` as an MCP server (`create_mcp_server`) for Claude Desktop, Claude
187
+ Code, or another agent to call into (`mcp` extra).
163
188
  - **Budgets & replanning** — cap by runs/time/iterations/tool-calls, replan on decline.
164
189
  - **Branching & replay** — `context.branch()`, three-way `merge()`, deterministic
165
190
  `ReplayLLM`, all for audit and safe alternative states.
@@ -16,6 +16,8 @@ reactifact flips the model. You describe **what artifacts exist and what agents
16
16
  do with them**; the runtime derives what runs next from **state changes**. Agents
17
17
  react to events — there is no graph, no node pipeline.
18
18
 
19
+ ![Left: a hand-wired fetch → verify → answer pipeline. Right: reactifact — search_agent and answer_agent each declare only what they consume and produce, wired together by Context, never each other.](docs/img/wiring.svg)
20
+
19
21
  ```bash
20
22
  pip install reactifact
21
23
  ```
@@ -82,6 +84,12 @@ print(answer.data.text) # "Refunds are available within 14 d
82
84
  print("supported_by:", evidence.data.text) # provenance you can trace, not just a string in a log
83
85
  ```
84
86
 
87
+ The same idea, live — the [`knowledge`](examples/knowledge) example's CLI answering a
88
+ harder, multi-source question (docs + a CSV) with a real computed number and
89
+ its sources, no LLM key required:
90
+
91
+ ![CLI demo: asking "how much does gpu cost in total?" — the runtime searches docs and a spreadsheet, computes the sum, verifies it, and answers with sources.](docs/img/knowledge-cli-demo.gif)
92
+
85
93
  ## How it works
86
94
 
87
95
  ```text
@@ -135,6 +143,10 @@ Full breakdown, including where reactifact is *not* the right choice:
135
143
  fallbacks instead of hallucinated answers; the model reasons, never "knows".
136
144
  - **Observability** — every run traces agent spans, reads/writes, LLM calls,
137
145
  tokens: SQLite store + web dashboard, exportable to Langfuse/Postgres (async sinks).
146
+ - **MCP, both ways** — call any MCP server's tools as a `Tool`
147
+ (`mcp_stdio_tools`/`mcp_http_tools`), or expose your own `Tool`s and a live
148
+ `Context` as an MCP server (`create_mcp_server`) for Claude Desktop, Claude
149
+ Code, or another agent to call into (`mcp` extra).
138
150
  - **Budgets & replanning** — cap by runs/time/iterations/tool-calls, replan on decline.
139
151
  - **Branching & replay** — `context.branch()`, three-way `merge()`, deterministic
140
152
  `ReplayLLM`, all for audit and safe alternative states.
@@ -4,10 +4,23 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "reactifact"
7
- version = "0.6.0"
7
+ version = "0.6.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"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ keywords = ["agents", "llm", "ai-agents", "reactive", "provenance", "orchestration", "agentic"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "Topic :: Software Development :: Libraries :: Python Modules",
18
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Typing :: Typed",
23
+ ]
11
24
  dependencies = [
12
25
  "pydantic>=2.13.4",
13
26
  "httpx>=0.27",
@@ -18,6 +31,7 @@ dependencies = [
18
31
  Homepage = "https://github.com/bzdvdn/reactifact"
19
32
  Repository = "https://github.com/bzdvdn/reactifact"
20
33
  Documentation = "https://github.com/bzdvdn/reactifact/tree/master/docs"
34
+ Changelog = "https://github.com/bzdvdn/reactifact/blob/master/CHANGELOG.md"
21
35
 
22
36
  [project.optional-dependencies]
23
37
  dev = [
@@ -33,10 +47,16 @@ web = [
33
47
  pg = [
34
48
  "psycopg[binary]>=3.2",
35
49
  ]
50
+ mcp = [
51
+ "mcp>=2.2",
52
+ ]
36
53
 
37
54
  # uv-native groups mirror the extras so `uv run`/`uv sync` installs the web demo
38
55
  # deps by default (no `--extra` flag needed to boot the examples). Extras stay
39
- # for PyPI (`pip install reactifact[web]`).
56
+ # for PyPI (`pip install reactifact[web]`). `mcp` joins the default groups too —
57
+ # unlike `pg`, its tests need no external service (in-memory MCP transport), so
58
+ # there's no reason not to run them for real in CI. `pg` stays opt-in; its own
59
+ # test only checks the graceful ImportError path without psycopg installed.
40
60
  [dependency-groups]
41
61
  dev = [
42
62
  "ruff>=0.8",
@@ -51,9 +71,12 @@ web = [
51
71
  pg = [
52
72
  "psycopg[binary]>=3.2",
53
73
  ]
74
+ mcp = [
75
+ "mcp>=2.2",
76
+ ]
54
77
 
55
78
  [tool.uv]
56
- default-groups = ["dev", "web"]
79
+ default-groups = ["dev", "web", "mcp"]
57
80
 
58
81
  # Публикация: в wheel едет только пакет `reactifact`; examples/tests остаются
59
82
  # в репозитории для демо и разработки, но не попадают в дистрибутив.
@@ -48,7 +48,7 @@ from .session import Session, SessionStore
48
48
  from .tools import FunctionTool, Tool, ToolOutput, tool
49
49
  from .triggers import Trigger
50
50
 
51
- __version__ = "0.6.0"
51
+ __version__ = "0.6.1"
52
52
 
53
53
  __all__ = [
54
54
  "Agent",
@@ -0,0 +1,18 @@
1
+ """MCP integration: talk to external MCP servers, or serve reactifact `Tool`s
2
+ and `Context` artifacts as one. Requires the `mcp` extra — nothing here is
3
+ imported by reactifact's core, so a bare `pip install reactifact` never pulls
4
+ in the `mcp` SDK.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from .client import MCPTool, mcp_http_tools, mcp_stdio_tools, mcp_tools
10
+ from .server import create_mcp_server
11
+
12
+ __all__ = [
13
+ "MCPTool",
14
+ "create_mcp_server",
15
+ "mcp_http_tools",
16
+ "mcp_stdio_tools",
17
+ "mcp_tools",
18
+ ]
@@ -0,0 +1,104 @@
1
+ """MCP client: turns tools exposed by an external MCP server into `Tool`s, so
2
+ `ToolUse`/`ToolUseHITL`/`LLMAgent` call remote MCP tools the same way they
3
+ call local ones — no separate code path for "MCP tool" vs. `@tool`. Requires
4
+ the `mcp` extra.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import AsyncGenerator
10
+ from contextlib import asynccontextmanager
11
+ from typing import Any
12
+
13
+ from .._extras import require_extra
14
+ from ..tools import Tool, ToolOutput
15
+
16
+
17
+ class MCPTool(Tool):
18
+ """One tool from a connected `mcp.ClientSession`, wrapped as a `Tool`."""
19
+
20
+ def __init__(
21
+ self,
22
+ session: Any,
23
+ *,
24
+ name: str,
25
+ description: str,
26
+ schema: dict[str, Any],
27
+ destructive: bool = False,
28
+ ):
29
+ self._session = session
30
+ self.name = name
31
+ self.description = description
32
+ self.schema = schema
33
+ self.destructive = destructive
34
+
35
+ async def execute(self, args: dict[str, Any]) -> ToolOutput:
36
+ result = await self._session.call_tool(self.name, args)
37
+ return _to_tool_output(result)
38
+
39
+
40
+ def _to_tool_output(result: Any) -> ToolOutput:
41
+ texts = [block.text for block in result.content if getattr(block, "text", None)]
42
+ text = "\n".join(texts)
43
+ if result.is_error:
44
+ return ToolOutput(text=text, error=text or "MCP tool call failed")
45
+ data = dict(result.structured_content) if result.structured_content else {}
46
+ return ToolOutput(text=text, data=data)
47
+
48
+
49
+ async def mcp_tools(session: Any) -> list[Tool]:
50
+ """Lists tools on a connected, initialized `mcp.ClientSession` and wraps each as a `Tool`."""
51
+ listed = await session.list_tools()
52
+ return [
53
+ MCPTool(
54
+ session,
55
+ name=t.name,
56
+ description=t.description or t.name,
57
+ schema=t.input_schema,
58
+ destructive=bool(t.annotations and t.annotations.destructive_hint),
59
+ )
60
+ for t in listed.tools
61
+ ]
62
+
63
+
64
+ @asynccontextmanager
65
+ async def mcp_stdio_tools(
66
+ command: str,
67
+ args: list[str] | None = None,
68
+ *,
69
+ env: dict[str, str] | None = None,
70
+ ) -> AsyncGenerator[list[Tool], None]:
71
+ """Spawns an MCP server over stdio and yields its tools as `Tool`s.
72
+
73
+ Example: `mcp_stdio_tools("npx", ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"])`.
74
+ The connection stays open for the `async with` block; tools called after
75
+ it exits will fail.
76
+ """
77
+ require_extra("mcp_stdio_tools", "mcp", "mcp")
78
+ from mcp.client.stdio import StdioServerParameters, stdio_client
79
+
80
+ from mcp import ClientSession
81
+
82
+ params = StdioServerParameters(command=command, args=args or [], env=env)
83
+ async with (
84
+ stdio_client(params) as (read, write),
85
+ ClientSession(read, write) as session,
86
+ ):
87
+ await session.initialize()
88
+ yield await mcp_tools(session)
89
+
90
+
91
+ @asynccontextmanager
92
+ async def mcp_http_tools(url: str) -> AsyncGenerator[list[Tool], None]:
93
+ """Connects to an MCP server over streamable HTTP and yields its tools as `Tool`s."""
94
+ require_extra("mcp_http_tools", "mcp", "mcp")
95
+ from mcp.client.streamable_http import streamable_http_client
96
+
97
+ from mcp import ClientSession
98
+
99
+ async with (
100
+ streamable_http_client(url) as (read, write),
101
+ ClientSession(read, write) as session,
102
+ ):
103
+ await session.initialize()
104
+ yield await mcp_tools(session)
@@ -0,0 +1,162 @@
1
+ """MCP server: exposes reactifact `Tool`s as MCP tools, and a `Context`'s
2
+ artifacts as MCP resources — so any MCP client (Claude Desktop, Claude Code,
3
+ another agent) can call into a running reactifact app. Requires the `mcp`
4
+ extra.
5
+
6
+ Tool schemas come from `Tool.schema` (already a JSON schema — every `Tool`
7
+ has one, hand-written or `@tool`-derived). The `mcp` SDK's own `add_tool`
8
+ only accepts a plain Python function and derives the schema from its
9
+ *signature*, so each `Tool` is bridged through a small function synthesized
10
+ from its schema (`exec`, §below) rather than reduced to a single opaque
11
+ `**kwargs` parameter — the point of serving these over MCP is that a caller
12
+ sees real argument names and types, not a blind bag of kwargs.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import Callable, Sequence
18
+ from typing import Any
19
+
20
+ from .._extras import require_extra
21
+ from ..context import Context
22
+ from ..tools import Tool
23
+
24
+ _JSON_TYPE_TO_PY = {
25
+ "string": "str",
26
+ "integer": "int",
27
+ "number": "float",
28
+ "boolean": "bool",
29
+ "array": "list",
30
+ "object": "dict",
31
+ }
32
+
33
+
34
+ def create_mcp_server(
35
+ tools: Sequence[Tool] = (),
36
+ *,
37
+ context: Context | None = None,
38
+ name: str = "reactifact",
39
+ instructions: str = "",
40
+ ) -> Any:
41
+ """Builds an `mcp.server.mcpserver.MCPServer` exposing `tools`.
42
+
43
+ With `context=`, two read-only resources are added so an MCP client can
44
+ inspect a running Context: `context://artifacts/{artifact_type}` (list,
45
+ e.g. `context://artifacts/Answer`) and `context://artifact/{artifact_id}`
46
+ (one artifact, its current data and version).
47
+
48
+ Run it with `server.run_stdio_async()`, or mount `server.streamable_http_app()`
49
+ on a FastAPI app alongside `create_trace_router` / `create_chat_router`.
50
+ """
51
+ require_extra("create_mcp_server", "mcp", "mcp")
52
+ from mcp.server.mcpserver import MCPServer
53
+
54
+ server = MCPServer(name=name, instructions=instructions or None)
55
+ for t in tools:
56
+ _register_tool(server, t)
57
+ if context is not None:
58
+ _register_context_resources(server, context)
59
+ return server
60
+
61
+
62
+ def _register_tool(server: Any, t: Tool) -> None:
63
+ from mcp.types import ToolAnnotations
64
+
65
+ fn = _bridge_function(t)
66
+ annotations = ToolAnnotations(destructive_hint=True) if t.destructive else None
67
+ server.add_tool(fn, name=t.name, description=t.description, annotations=annotations)
68
+
69
+
70
+ def _bridge_function(t: Tool) -> Callable[..., Any]:
71
+ """Synthesizes a real Python function matching `t.schema`'s properties,
72
+ so `add_tool`'s signature-introspection publishes `t`'s actual argument
73
+ names and types instead of one opaque `**kwargs`.
74
+ """
75
+ properties: dict[str, Any] = t.schema.get("properties", {}) or {}
76
+ required = set(t.schema.get("required", []) or [])
77
+ param_names = list(properties.keys())
78
+
79
+ params = []
80
+ dict_entries = []
81
+ for pname in param_names:
82
+ json_type = (properties[pname] or {}).get("type")
83
+ py_type = (
84
+ _JSON_TYPE_TO_PY.get(json_type, "Any")
85
+ if isinstance(json_type, str)
86
+ else "Any"
87
+ )
88
+ if pname in required:
89
+ params.append(f"{pname}: {py_type}")
90
+ else:
91
+ params.append(f"{pname}: {py_type} | None = None")
92
+ dict_entries.append(f"{pname!r}: {pname}")
93
+ signature = ", ".join(params)
94
+ call_dict = "{" + ", ".join(dict_entries) + "}"
95
+ src = f"async def _bridge({signature}):\n return await _call({call_dict})\n"
96
+
97
+ async def _call(args: dict[str, Any]) -> Any:
98
+ from mcp.server.mcpserver.exceptions import ToolError
99
+
100
+ # optional params default to None above; drop unset ones so `Tool`s
101
+ # with real (non-None) defaults in their own schema see those instead
102
+ args = {k: v for k, v in args.items() if v is not None or k in required}
103
+ result = await t.execute(args)
104
+ if result.error:
105
+ # `ToolError`, not a bare exception: an anticipated `Tool` failure
106
+ # reaches the MCP client as `is_error=True` with this message —
107
+ # any other exception type is masked to "Error executing tool …".
108
+ raise ToolError(result.error)
109
+ return result.data or result.text
110
+
111
+ namespace: dict[str, Any] = {"_call": _call, "Any": Any}
112
+ exec(src, namespace) # noqa: S102 — schema-derived source, no user input
113
+ fn: Callable[..., Any] = namespace["_bridge"]
114
+ fn.__name__ = t.name
115
+ fn.__doc__ = t.description
116
+ return fn
117
+
118
+
119
+ def _register_context_resources(server: Any, context: Context) -> None:
120
+ import json
121
+
122
+ async def list_artifacts_resource(artifact_type: str) -> str:
123
+ matches = [
124
+ a
125
+ for a in context.list_artifacts()
126
+ if type(a.data).__name__ == artifact_type
127
+ ]
128
+ return json.dumps(
129
+ [
130
+ {
131
+ "id": a.id,
132
+ "type": artifact_type,
133
+ "version": a.version,
134
+ "data": a.data.model_dump(),
135
+ }
136
+ for a in matches
137
+ ],
138
+ default=str,
139
+ )
140
+
141
+ async def get_artifact_resource(artifact_id: str) -> str:
142
+ artifact = context.get(artifact_id)
143
+ if artifact is None:
144
+ return json.dumps({"error": f"no artifact with id {artifact_id!r}"})
145
+ return json.dumps(
146
+ {
147
+ "id": artifact.id,
148
+ "type": type(artifact.data).__name__,
149
+ "version": artifact.version,
150
+ "data": artifact.data.model_dump(),
151
+ },
152
+ default=str,
153
+ )
154
+
155
+ server.resource(
156
+ "context://artifacts/{artifact_type}",
157
+ description="Lists artifacts of one type currently in the Context, newest first.",
158
+ )(list_artifacts_resource)
159
+ server.resource(
160
+ "context://artifact/{artifact_id}",
161
+ description="Reads one artifact by id: its type, version, and current data.",
162
+ )(get_artifact_resource)
@@ -0,0 +1,227 @@
1
+ """Langfuse sink: pushes `RunTrace` via OTLP/HTTP (`POST /api/public/otel/v1/traces`).
2
+
3
+ Langfuse's legacy REST ingestion (`POST /api/public/traces`,
4
+ `POST /api/public/observations`) is deprecated: it already 404s/400s on
5
+ Langfuse v4, and Langfuse Cloud sunsets it 2026-11-16. OTLP/HTTP is the only
6
+ forward-compatible path for a plain HTTP client (not an official SDK) — see
7
+ https://langfuse.com/integrations/native/opentelemetry/migration-to-v4.
8
+
9
+ Mapping: one OTLP span per `AgentSpan` (`langfuse.observation.type=span`), one
10
+ child span per `LLMCall` (`type=generation`, `gen_ai.*` attributes for model/
11
+ usage). Trace-level attributes (`langfuse.session.id`, trace metadata) are
12
+ copied onto every span, since Langfuse only aggregates by them when present on
13
+ each observation, not just the root.
14
+
15
+ Uses httpx (base dependency). For tests you can inject `client`.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import base64
21
+ import hashlib
22
+ import json
23
+ from datetime import datetime, timedelta
24
+ from typing import Any
25
+
26
+ from .models import AgentSpan, LLMCall, RunTrace
27
+ from .tracer import Tracer
28
+
29
+
30
+ def _unix_nanos(value: datetime) -> str:
31
+ return str(int(value.timestamp() * 1_000_000_000))
32
+
33
+
34
+ def _trace_id(seed: str) -> str:
35
+ return hashlib.sha256(f"trace:{seed}".encode()).hexdigest()[:32]
36
+
37
+
38
+ def _span_id(seed: str) -> str:
39
+ return hashlib.sha256(f"span:{seed}".encode()).hexdigest()[:16]
40
+
41
+
42
+ def _attr(key: str, value: Any) -> dict[str, Any]:
43
+ if isinstance(value, bool):
44
+ otlp_value: dict[str, Any] = {"boolValue": value}
45
+ elif isinstance(value, int):
46
+ otlp_value = {"intValue": str(value)}
47
+ elif isinstance(value, float):
48
+ otlp_value = {"doubleValue": value}
49
+ else:
50
+ if value is None:
51
+ value = ""
52
+ elif not isinstance(value, str):
53
+ value = json.dumps(value, default=str)
54
+ otlp_value = {"stringValue": value}
55
+ return {"key": key, "value": otlp_value}
56
+
57
+
58
+ def _type_summary(refs: list[Any]) -> dict[str, int]:
59
+ summary: dict[str, int] = {}
60
+ for ref in refs:
61
+ kind = getattr(ref, "data_type", None) or type(ref).__name__
62
+ summary[kind] = summary.get(kind, 0) + 1
63
+ return summary
64
+
65
+
66
+ class LangfuseTracer(Tracer):
67
+ """Observer that exports traces to Langfuse via OTLP/HTTP."""
68
+
69
+ def __init__(
70
+ self,
71
+ *,
72
+ public_key: str,
73
+ secret_key: str,
74
+ host: str = "https://cloud.langfuse.com",
75
+ api_url: str | None = None,
76
+ client: Any | None = None,
77
+ ):
78
+ base = (api_url or host).rstrip("/")
79
+ self._url = base + "/api/public/otel/v1/traces"
80
+ token = base64.b64encode(f"{public_key}:{secret_key}".encode()).decode()
81
+ self._headers = {
82
+ "Authorization": f"Basic {token}",
83
+ "x-langfuse-ingestion-version": "4",
84
+ "Content-Type": "application/json",
85
+ }
86
+ if client is not None:
87
+ self._client = client
88
+ else:
89
+ import httpx
90
+
91
+ self._client = httpx.AsyncClient(headers=self._headers)
92
+
93
+ async def on_turn_end(self, trace: RunTrace) -> None:
94
+ trace_id = _trace_id(trace.id)
95
+ run_start = trace.started_at
96
+ run_end = run_start + timedelta(milliseconds=trace.duration_ms)
97
+ root_span_id = _span_id(f"{trace.id}:root")
98
+
99
+ # Propagated to every span: Langfuse only aggregates trace-level
100
+ # fields (session, metadata) when they're present on each observation.
101
+ trace_attrs = [
102
+ _attr("langfuse.trace.name", "reactifact run"),
103
+ _attr("langfuse.trace.metadata.outcome", trace.outcome),
104
+ _attr("langfuse.trace.metadata.span_count", len(trace.spans)),
105
+ ]
106
+ if trace.session_id:
107
+ trace_attrs.append(_attr("langfuse.session.id", trace.session_id))
108
+
109
+ spans: list[dict[str, Any]] = [
110
+ {
111
+ "traceId": trace_id,
112
+ "spanId": root_span_id,
113
+ "name": "reactifact run",
114
+ "kind": 1, # SPAN_KIND_INTERNAL
115
+ "startTimeUnixNano": _unix_nanos(run_start),
116
+ "endTimeUnixNano": _unix_nanos(run_end),
117
+ "attributes": [
118
+ *trace_attrs,
119
+ _attr("langfuse.observation.type", "span"),
120
+ ],
121
+ }
122
+ ]
123
+
124
+ for i, span in enumerate(trace.spans):
125
+ spans.append(
126
+ self._agent_span(
127
+ trace_id, root_span_id, trace_attrs, run_start, i, span
128
+ )
129
+ )
130
+ span_start = span.started_at or run_start
131
+ agent_span_id = _span_id(f"{trace_id}:span:{i}:{span.agent}")
132
+ for j, call in enumerate(span.llm_calls):
133
+ spans.append(
134
+ self._llm_span(
135
+ trace_id,
136
+ agent_span_id,
137
+ trace_attrs,
138
+ span_start,
139
+ i,
140
+ j,
141
+ span.agent,
142
+ call,
143
+ )
144
+ )
145
+
146
+ body = {
147
+ "resourceSpans": [
148
+ {
149
+ "resource": {"attributes": []},
150
+ "scopeSpans": [{"scope": {"name": "reactifact"}, "spans": spans}],
151
+ }
152
+ ]
153
+ }
154
+ await self._client.post(self._url, json=body)
155
+
156
+ def _agent_span(
157
+ self,
158
+ trace_id: str,
159
+ parent_span_id: str,
160
+ trace_attrs: list[dict[str, Any]],
161
+ run_start: datetime,
162
+ index: int,
163
+ span: AgentSpan,
164
+ ) -> dict[str, Any]:
165
+ start = span.started_at or run_start
166
+ end = start + timedelta(milliseconds=span.latency_ms)
167
+ attrs = [
168
+ *trace_attrs,
169
+ _attr("langfuse.observation.type", "span"),
170
+ _attr("langfuse.observation.input", [r.model_dump() for r in span.reads]),
171
+ _attr("langfuse.observation.output", [w.model_dump() for w in span.writes]),
172
+ _attr("langfuse.observation.metadata.event_type", span.event_type),
173
+ _attr(
174
+ "langfuse.observation.metadata.read_summary", _type_summary(span.reads)
175
+ ),
176
+ _attr(
177
+ "langfuse.observation.metadata.write_summary",
178
+ _type_summary(span.writes),
179
+ ),
180
+ ]
181
+ if span.error:
182
+ attrs.append(_attr("langfuse.observation.metadata.error", span.error))
183
+ return {
184
+ "traceId": trace_id,
185
+ "spanId": _span_id(f"{trace_id}:span:{index}:{span.agent}"),
186
+ "parentSpanId": parent_span_id,
187
+ "name": span.agent,
188
+ "kind": 1,
189
+ "startTimeUnixNano": _unix_nanos(start),
190
+ "endTimeUnixNano": _unix_nanos(end),
191
+ "attributes": attrs,
192
+ }
193
+
194
+ def _llm_span(
195
+ self,
196
+ trace_id: str,
197
+ parent_span_id: str,
198
+ trace_attrs: list[dict[str, Any]],
199
+ span_start: datetime,
200
+ span_index: int,
201
+ call_index: int,
202
+ agent: str,
203
+ call: LLMCall,
204
+ ) -> dict[str, Any]:
205
+ end = span_start + timedelta(milliseconds=call.latency_ms)
206
+ attrs = [
207
+ *trace_attrs,
208
+ _attr("langfuse.observation.type", "generation"),
209
+ _attr("langfuse.observation.input", call.messages),
210
+ _attr("langfuse.observation.output", call.response),
211
+ _attr("gen_ai.request.model", call.model or call.provider),
212
+ _attr("gen_ai.usage.input_tokens", call.prompt_tokens),
213
+ _attr("gen_ai.usage.output_tokens", call.completion_tokens),
214
+ _attr("langfuse.observation.metadata.provider", call.provider),
215
+ ]
216
+ if call.error:
217
+ attrs.append(_attr("langfuse.observation.metadata.error", call.error))
218
+ return {
219
+ "traceId": trace_id,
220
+ "spanId": _span_id(f"{trace_id}:llm:{span_index}:{call_index}:{agent}"),
221
+ "parentSpanId": parent_span_id,
222
+ "name": f"llm:{call.model or call.provider}",
223
+ "kind": 1,
224
+ "startTimeUnixNano": _unix_nanos(span_start),
225
+ "endTimeUnixNano": _unix_nanos(end),
226
+ "attributes": attrs,
227
+ }