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.
- {reactifact-0.6.0/reactifact.egg-info → reactifact-0.6.1}/PKG-INFO +26 -1
- {reactifact-0.6.0 → reactifact-0.6.1}/README.md +12 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/pyproject.toml +26 -3
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/__init__.py +1 -1
- reactifact-0.6.1/reactifact/mcp/__init__.py +18 -0
- reactifact-0.6.1/reactifact/mcp/client.py +104 -0
- reactifact-0.6.1/reactifact/mcp/server.py +162 -0
- reactifact-0.6.1/reactifact/tracing/langfuse.py +227 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/tracing/templates/ui.html +1 -1
- {reactifact-0.6.0 → reactifact-0.6.1/reactifact.egg-info}/PKG-INFO +26 -1
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact.egg-info/SOURCES.txt +4 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact.egg-info/requires.txt +3 -0
- reactifact-0.6.1/tests/test_mcp.py +169 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_tracing.py +40 -17
- reactifact-0.6.0/reactifact/tracing/langfuse.py +0 -125
- {reactifact-0.6.0 → reactifact-0.6.1}/LICENSE +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/__main__.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/_extras.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/agents.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/artifacts.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/branching.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/budget.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/chat.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/checkpoints.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/cli/__init__.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/cli/branch.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/cli/common.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/cli/context.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/cli/graph.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/cli/replay.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/cli/scenario.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/cli/trace.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/commit.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/commit_log.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/consume.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/context.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/effects.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/eval.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/events.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/interrupt.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/llm_agent.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/operations.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/patches.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/produce.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/prompts.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/providers/__init__.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/providers/_retry.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/providers/anthropic.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/providers/azure.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/providers/cerebras.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/providers/chat.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/providers/contracts.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/providers/deepseek.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/providers/fake.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/providers/fireworks.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/providers/gemini.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/providers/github_models.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/providers/groq.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/providers/image.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/providers/mistral.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/providers/nvidia.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/providers/ollama.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/providers/openai.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/providers/openrouter.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/providers/perplexity.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/providers/qwen.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/providers/speech.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/providers/together.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/providers/video.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/providers/xai.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/providers/zai.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/py.typed +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/recipes/__init__.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/recipes/inputs.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/recipes/memory.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/recipes/resolve.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/recipes/rollback.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/recipes/search.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/recipes/skills.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/recipes/status.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/recipes/text.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/relations.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/replay.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/resources.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/runtime.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/scheduler.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/session.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/sources.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/streaming.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/structured.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/testing/__init__.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/testing/assertions.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/testing/exceptions.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/testing/fault.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/testing/lab.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/testing/mock.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/testing/record.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/testing/registry.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/tool_use.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/tools.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/tracing/__init__.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/tracing/models.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/tracing/postgres.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/tracing/store.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/tracing/templates/ui_run.html +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/tracing/tracer.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/tracing/web.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/triggers.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/viz.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact/web.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact.egg-info/dependency_links.txt +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact.egg-info/entry_points.txt +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/reactifact.egg-info/top_level.txt +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/setup.cfg +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_adaptive.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_anthropic_provider.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_artifacts.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_backbone.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_branching.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_budget.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_chat_web.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_checkpoint.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_checkpoints_concurrency.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_cli.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_cli_scenario.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_commit_log.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_concurrency.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_consumes_produces.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_devops.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_devops_web.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_effects.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_eval.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_forklab.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_forklab_web.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_friendly_api.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_gemini_provider.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_hitl.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_image_provider.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_invalidation.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_knowledge.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_knowledge_web.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_llm_ladder.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_medic_lab.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_medic_lab_web.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_multisource.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_openai_provider.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_patches.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_ports.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_produce_styles.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_prompts.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_provider_auth.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_provider_retry.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_providers_integration.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_recipes.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_recipes_inputs.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_recipes_memory.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_recipes_skills.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_relation_graph.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_relations.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_repair.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_repair_web.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_replay.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_research.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_resources.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_retry.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_runtime.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_runtime_errors.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_sessions.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_sources.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_sources_search.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_sources_vector.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_speech_provider.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_streaming.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_structured.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_testing_assertions.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_testing_lab.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_testing_mock.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_testing_registry.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_tools.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_vendor_factories.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_vendor_multimodal_factories.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_video_provider.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_view.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_viz.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_web_source.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_workspace.py +0 -0
- {reactifact-0.6.0 → reactifact-0.6.1}/tests/test_workspace_with_sources.py +0 -0
- {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.
|
|
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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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.
|
|
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
|
# в репозитории для демо и разработки, но не попадают в дистрибутив.
|
|
@@ -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
|
+
}
|