reactifact 0.9.1__tar.gz → 0.10.0__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.9.1/reactifact.egg-info → reactifact-0.10.0}/PKG-INFO +9 -3
- {reactifact-0.9.1 → reactifact-0.10.0}/README.md +8 -2
- {reactifact-0.9.1 → reactifact-0.10.0}/pyproject.toml +4 -1
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/__init__.py +6 -2
- reactifact-0.10.0/reactifact/_httpx.py +87 -0
- reactifact-0.10.0/reactifact/audit.py +255 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/chat.py +44 -7
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/checkpoints.py +29 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/cli/replay.py +20 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/effects.py +27 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/produce.py +13 -1
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/prompts.py +70 -2
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/providers/anthropic.py +8 -9
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/providers/chat.py +15 -18
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/providers/contracts.py +4 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/providers/gemini.py +8 -9
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/providers/image.py +15 -15
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/providers/speech.py +27 -28
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/providers/video.py +10 -11
- reactifact-0.10.0/reactifact/quick/__init__.py +66 -0
- reactifact-0.10.0/reactifact/quick/_shared.py +72 -0
- reactifact-0.10.0/reactifact/quick/agent.py +147 -0
- reactifact-0.10.0/reactifact/quick/chat.py +85 -0
- reactifact-0.10.0/reactifact/quick/models.py +47 -0
- reactifact-0.10.0/reactifact/quick/rag.py +407 -0
- reactifact-0.10.0/reactifact/quick/tools_agent.py +167 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/recipes/__init__.py +4 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/recipes/text.py +22 -1
- reactifact-0.10.0/reactifact/recipes/tool_loop.py +190 -0
- reactifact-0.10.0/reactifact/redaction.py +94 -0
- reactifact-0.10.0/reactifact/request.py +56 -0
- reactifact-0.10.0/reactifact/resources.py +194 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/runtime.py +34 -8
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/sources.py +14 -7
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/structured.py +79 -20
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/testing/__init__.py +6 -0
- reactifact-0.10.0/reactifact/testing/golden.py +110 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/tools.py +21 -1
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/tracing/langfuse.py +7 -7
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/tracing/models.py +3 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/tracing/otlp.py +7 -7
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/tracing/tracer.py +43 -8
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/web.py +64 -11
- {reactifact-0.9.1 → reactifact-0.10.0/reactifact.egg-info}/PKG-INFO +9 -3
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact.egg-info/SOURCES.txt +25 -0
- reactifact-0.10.0/tests/test_audit.py +134 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_chat_web.py +108 -0
- reactifact-0.10.0/tests/test_fintech_audit.py +40 -0
- reactifact-0.10.0/tests/test_golden.py +87 -0
- reactifact-0.10.0/tests/test_loop_bound_client.py +72 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_prompts.py +36 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_provider_auth.py +7 -2
- reactifact-0.10.0/tests/test_quick.py +325 -0
- reactifact-0.10.0/tests/test_recipe_tool_loop.py +160 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_recipes.py +12 -0
- reactifact-0.10.0/tests/test_redaction.py +148 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_repair.py +47 -0
- reactifact-0.10.0/tests/test_repo_agent.py +60 -0
- reactifact-0.10.0/tests/test_request.py +88 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_resources.py +66 -1
- reactifact-0.10.0/tests/test_scheduler_semantics.py +120 -0
- reactifact-0.10.0/tests/test_starter_app.py +87 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_structured.py +105 -0
- reactifact-0.10.0/tests/test_support_copilot.py +52 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_tracing.py +26 -0
- reactifact-0.9.1/reactifact/resources.py +0 -69
- {reactifact-0.9.1 → reactifact-0.10.0}/LICENSE +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/__main__.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/_extras.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/agent_tool.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/agents.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/artifacts.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/branching.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/budget.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/cli/__init__.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/cli/branch.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/cli/common.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/cli/context.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/cli/graph.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/cli/scenario.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/cli/trace.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/commit.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/commit_log.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/consume.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/context.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/context_builder.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/eval.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/events.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/interrupt.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/llm_agent.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/mcp/__init__.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/mcp/client.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/mcp/oauth.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/mcp/server.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/native_tool_use.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/operations.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/patches.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/providers/__init__.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/providers/_retry.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/providers/azure.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/providers/cerebras.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/providers/deepseek.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/providers/fake.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/providers/fireworks.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/providers/github_models.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/providers/groq.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/providers/mistral.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/providers/nvidia.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/providers/ollama.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/providers/openai.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/providers/openrouter.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/providers/perplexity.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/providers/qwen.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/providers/together.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/providers/xai.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/providers/zai.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/py.typed +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/recipes/cleanup.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/recipes/identity.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/recipes/inputs.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/recipes/memory.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/recipes/plan_execute.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/recipes/reflection.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/recipes/resolve.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/recipes/rollback.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/recipes/search.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/recipes/skills.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/recipes/status.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/recipes/supervisor.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/relations.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/replay.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/scheduler.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/session.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/streaming.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/testing/assertions.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/testing/exceptions.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/testing/fault.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/testing/lab.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/testing/mock.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/testing/record.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/testing/registry.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/tool_use.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/tracing/__init__.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/tracing/_otlp.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/tracing/postgres.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/tracing/store.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/tracing/templates/ui.html +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/tracing/templates/ui_run.html +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/tracing/web.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/triggers.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/verify.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact/viz.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact.egg-info/dependency_links.txt +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact.egg-info/entry_points.txt +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact.egg-info/requires.txt +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/reactifact.egg-info/top_level.txt +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/setup.cfg +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_absent_consume.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_adaptive.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_agent_tool.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_anthropic_provider.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_artifacts.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_backbone.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_branching.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_budget.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_canonical_ports.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_checkpoint.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_checkpoints_concurrency.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_cli.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_cli_scenario.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_commit_log.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_concurrency.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_consumes_produces.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_context_builder.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_correlated_consume.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_debounce.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_devops.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_devops_web.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_effects.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_eval.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_forklab.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_forklab_web.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_friendly_api.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_gemini_provider.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_hitl.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_image_provider.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_incident_commander.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_invalidation.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_join_consume.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_knowledge.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_knowledge_web.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_llm_ladder.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_mcp.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_medic_lab.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_medic_lab_web.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_multisource.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_native_tool_use.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_openai_provider.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_patches.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_ports.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_produce_styles.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_produce_trigger.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_provider_retry.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_providers_integration.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_reacts_to.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_recipe_plan_execute.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_recipe_reflection.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_recipe_supervisor.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_recipes_inputs.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_recipes_memory.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_recipes_skills.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_relation_graph.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_relations.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_repair_web.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_replay.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_research.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_retry.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_runtime.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_runtime_errors.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_sessions.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_sources.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_sources_search.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_sources_vector.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_speech_provider.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_streaming.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_testing_assertions.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_testing_lab.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_testing_mock.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_testing_registry.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_tools.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_vendor_factories.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_vendor_multimodal_factories.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_verify.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_video_provider.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_view.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_viz.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_web_source.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_workspace.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/test_workspace_with_sources.py +0 -0
- {reactifact-0.9.1 → reactifact-0.10.0}/tests/tests_checkpoints_sqlite.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: reactifact
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.0
|
|
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
|
|
@@ -250,6 +250,9 @@ uv run python ./examples/devops/web.py # HITL ops assistant + trace dash
|
|
|
250
250
|
Classic-pattern ports run as one-liners too:
|
|
251
251
|
`python -m examples.{reflection,map_reduce,supervisor,summarize,time_travel,plan_execute,adaptive,ledger}.main`.
|
|
252
252
|
|
|
253
|
+
Prefer a notebook? [](https://colab.research.google.com/github/bzdvdn/reactifact/blob/master/examples/quickstart.ipynb)
|
|
254
|
+
— `examples/quickstart.ipynb` covers the four `quick` cases (`agent`/`rag`/`tools_agent`/`chat_agent`), offline by default.
|
|
255
|
+
|
|
253
256
|
## Examples (in-repo, not shipped)
|
|
254
257
|
|
|
255
258
|
- `knowledge` — multi-source chat: search → evidence → claim verification → answer, with CSV calculation.
|
|
@@ -267,8 +270,11 @@ Classic-pattern ports run as one-liners too:
|
|
|
267
270
|
|
|
268
271
|
- [English](docs/en/index.md) · [Русский](docs/ru/index.md) — concepts, sources,
|
|
269
272
|
providers, recipes, patterns, observability, eval, branching, replay, viz/CLI, API.
|
|
270
|
-
- [Quickstart](docs/en/quickstart.md) —
|
|
271
|
-
|
|
273
|
+
- [Quickstart](docs/en/quickstart.md) — the four `quick` cases (structured call,
|
|
274
|
+
RAG, tools, chat) in a few lines; also as a
|
|
275
|
+
[Colab notebook](examples/quickstart.ipynb).
|
|
276
|
+
- [Migrating](docs/en/migrating.md) — from LangGraph / CrewAI / LlamaIndex /
|
|
277
|
+
plain Python: concept map, port-one-node, interop, checklist.
|
|
272
278
|
- [Why reactifact](docs/en/why-reactifact.md) — the *design argument*: why effects, why no graph, why determinism.
|
|
273
279
|
- [Comparison](docs/en/comparison.md) — reactifact vs LangGraph/CrewAI, feature by feature, and when *not* to use reactifact.
|
|
274
280
|
- [Tutorial · llm-ladder](docs/en/examples.md#tutorial-ladder) — learn the workflow.
|
|
@@ -206,6 +206,9 @@ uv run python ./examples/devops/web.py # HITL ops assistant + trace dash
|
|
|
206
206
|
Classic-pattern ports run as one-liners too:
|
|
207
207
|
`python -m examples.{reflection,map_reduce,supervisor,summarize,time_travel,plan_execute,adaptive,ledger}.main`.
|
|
208
208
|
|
|
209
|
+
Prefer a notebook? [](https://colab.research.google.com/github/bzdvdn/reactifact/blob/master/examples/quickstart.ipynb)
|
|
210
|
+
— `examples/quickstart.ipynb` covers the four `quick` cases (`agent`/`rag`/`tools_agent`/`chat_agent`), offline by default.
|
|
211
|
+
|
|
209
212
|
## Examples (in-repo, not shipped)
|
|
210
213
|
|
|
211
214
|
- `knowledge` — multi-source chat: search → evidence → claim verification → answer, with CSV calculation.
|
|
@@ -223,8 +226,11 @@ Classic-pattern ports run as one-liners too:
|
|
|
223
226
|
|
|
224
227
|
- [English](docs/en/index.md) · [Русский](docs/ru/index.md) — concepts, sources,
|
|
225
228
|
providers, recipes, patterns, observability, eval, branching, replay, viz/CLI, API.
|
|
226
|
-
- [Quickstart](docs/en/quickstart.md) —
|
|
227
|
-
|
|
229
|
+
- [Quickstart](docs/en/quickstart.md) — the four `quick` cases (structured call,
|
|
230
|
+
RAG, tools, chat) in a few lines; also as a
|
|
231
|
+
[Colab notebook](examples/quickstart.ipynb).
|
|
232
|
+
- [Migrating](docs/en/migrating.md) — from LangGraph / CrewAI / LlamaIndex /
|
|
233
|
+
plain Python: concept map, port-one-node, interop, checklist.
|
|
228
234
|
- [Why reactifact](docs/en/why-reactifact.md) — the *design argument*: why effects, why no graph, why determinism.
|
|
229
235
|
- [Comparison](docs/en/comparison.md) — reactifact vs LangGraph/CrewAI, feature by feature, and when *not* to use reactifact.
|
|
230
236
|
- [Tutorial · llm-ladder](docs/en/examples.md#tutorial-ladder) — learn the workflow.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "reactifact"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.10.0"
|
|
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"
|
|
@@ -118,6 +118,9 @@ fail_under = 85
|
|
|
118
118
|
target-version = "py311"
|
|
119
119
|
line-length = 88
|
|
120
120
|
src = ["reactifact", "examples"]
|
|
121
|
+
# Notebooks are Colab artifacts (IPython magics, top-level await) — not package
|
|
122
|
+
# code; don't lint/format them.
|
|
123
|
+
extend-exclude = ["*.ipynb"]
|
|
121
124
|
|
|
122
125
|
[tool.ruff.lint]
|
|
123
126
|
select = [
|
|
@@ -42,13 +42,14 @@ from .providers import (
|
|
|
42
42
|
LLMResponseChunk,
|
|
43
43
|
Message,
|
|
44
44
|
)
|
|
45
|
-
from .
|
|
45
|
+
from .request import current_request
|
|
46
|
+
from .resources import ResourceKey, ResourceScope, RuntimeResources
|
|
46
47
|
from .runtime import Runtime
|
|
47
48
|
from .session import Session, SessionStore
|
|
48
49
|
from .tools import FunctionTool, Tool, ToolOutput, tool
|
|
49
50
|
from .triggers import Trigger
|
|
50
51
|
|
|
51
|
-
__version__ = "0.
|
|
52
|
+
__version__ = "0.10.0"
|
|
52
53
|
|
|
53
54
|
__all__ = [
|
|
54
55
|
"Agent",
|
|
@@ -74,6 +75,8 @@ __all__ = [
|
|
|
74
75
|
"Produce",
|
|
75
76
|
"ProduceCall",
|
|
76
77
|
"Relation",
|
|
78
|
+
"ResourceKey",
|
|
79
|
+
"ResourceScope",
|
|
77
80
|
"RunOutcome",
|
|
78
81
|
"RunStats",
|
|
79
82
|
"Runtime",
|
|
@@ -92,6 +95,7 @@ __all__ = [
|
|
|
92
95
|
"View",
|
|
93
96
|
"consume",
|
|
94
97
|
"create_agent",
|
|
98
|
+
"current_request",
|
|
95
99
|
"produce",
|
|
96
100
|
"tool",
|
|
97
101
|
]
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Loop-aware `httpx.AsyncClient` ownership for providers and sinks.
|
|
2
|
+
|
|
3
|
+
`httpx.AsyncClient` binds its connection pool (and internal locks) to the event
|
|
4
|
+
loop it was created on. A provider/sink instance that caches the client forever
|
|
5
|
+
and is then reused on a *different* loop — pytest gives each test its own loop,
|
|
6
|
+
a CLI calls `asyncio.run(...)` more than once, uvicorn reloads — raises
|
|
7
|
+
`RuntimeError: Event loop is closed` (or "bound to a different event loop") on
|
|
8
|
+
the second use.
|
|
9
|
+
|
|
10
|
+
`LoopBoundClient` keeps the client **lazy** (created on first use, as before)
|
|
11
|
+
but **rebinds it to the current running loop**: when the loop changes, a fresh
|
|
12
|
+
client is created. The previous client's loop is already gone, so it is dropped
|
|
13
|
+
rather than awaited (its pool died with the loop). An injected `client=` (tests,
|
|
14
|
+
a custom transport) is returned as-is and never recreated.
|
|
15
|
+
|
|
16
|
+
Typical use inside a provider:
|
|
17
|
+
|
|
18
|
+
self._http = LoopBoundClient(
|
|
19
|
+
lambda: httpx.AsyncClient(timeout=self._timeout, headers=self._headers)
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
async def complete(self, ...):
|
|
23
|
+
response = await self._http.get().post(url, json=payload)
|
|
24
|
+
|
|
25
|
+
async def aclose(self) -> None:
|
|
26
|
+
await self._http.aclose()
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import asyncio
|
|
32
|
+
from collections.abc import Callable
|
|
33
|
+
from typing import Any, cast
|
|
34
|
+
|
|
35
|
+
import httpx
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class LoopBoundClient:
|
|
39
|
+
"""Owns an `httpx.AsyncClient` bound to the current event loop."""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
factory: Callable[[], httpx.AsyncClient],
|
|
44
|
+
*,
|
|
45
|
+
client: Any | None = None,
|
|
46
|
+
):
|
|
47
|
+
self._factory = factory
|
|
48
|
+
self._fixed = client
|
|
49
|
+
self._client: httpx.AsyncClient | None = None
|
|
50
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
51
|
+
|
|
52
|
+
def get(self) -> httpx.AsyncClient:
|
|
53
|
+
"""The client for the running loop (created/recreated as needed)."""
|
|
54
|
+
if self._fixed is not None:
|
|
55
|
+
return cast("httpx.AsyncClient", self._fixed)
|
|
56
|
+
loop = asyncio.get_running_loop()
|
|
57
|
+
if self._client is None or self._loop is not loop:
|
|
58
|
+
self._client = self._factory()
|
|
59
|
+
self._loop = loop
|
|
60
|
+
return self._client
|
|
61
|
+
|
|
62
|
+
async def aclose(self) -> None:
|
|
63
|
+
"""Closes the current client, if it belongs to the running loop.
|
|
64
|
+
|
|
65
|
+
A client whose owner loop is closed (or is a different, still-running
|
|
66
|
+
loop) cannot be awaited here; it is dropped — nothing can safely close
|
|
67
|
+
it from this loop. The injected `client=`, if any, is left to its owner.
|
|
68
|
+
"""
|
|
69
|
+
client, loop = self._client, self._loop
|
|
70
|
+
self._client = None
|
|
71
|
+
self._loop = None
|
|
72
|
+
if client is None:
|
|
73
|
+
return
|
|
74
|
+
try:
|
|
75
|
+
current = asyncio.get_running_loop()
|
|
76
|
+
except RuntimeError:
|
|
77
|
+
return
|
|
78
|
+
if loop is not None and loop is not current:
|
|
79
|
+
return
|
|
80
|
+
await client.aclose()
|
|
81
|
+
|
|
82
|
+
def raw(self) -> Any | None:
|
|
83
|
+
"""The injected client if there is one, else the current one (or None)."""
|
|
84
|
+
return self._fixed if self._fixed is not None else self._client
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
__all__ = ["LoopBoundClient"]
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""reactifact.audit — answer provenance and reproducibility (§34, §55, §56).
|
|
2
|
+
|
|
3
|
+
Two things an auditable agent needs, both offline and dependency-free:
|
|
4
|
+
|
|
5
|
+
1. `build_report(context, answer)` — a self-contained record of *why* an answer
|
|
6
|
+
exists: the answer's content hash, the provenance chain behind it
|
|
7
|
+
(`supported_by` / `derived_from` / `materialized_from` / …), each artifact's
|
|
8
|
+
version, author and content hash, and the source locators it rests on.
|
|
9
|
+
Exportable as JSON (machine-checkable) or Markdown (human/regulator-readable).
|
|
10
|
+
|
|
11
|
+
2. `context_hash(context)` — a sha256 over the run's canonical state
|
|
12
|
+
(artifacts + relations + version). Two runs that produce the same state hash
|
|
13
|
+
the same; recording it with a `ReplayLLM` recording turns "is this run
|
|
14
|
+
reproducible?" into a string comparison (`reactifact replay --verify <hash>`,
|
|
15
|
+
and the `fintech_audit` example).
|
|
16
|
+
|
|
17
|
+
Hashes are sha256 over canonical (sorted, JSON-safe) content, so the *hash*
|
|
18
|
+
fields are reproducible: the same state yields the same `context_sha256` and
|
|
19
|
+
per-artifact hashes, run to run. The report also carries `created_at` values
|
|
20
|
+
for a human reader, so the full JSON is not byte-identical across runs — the
|
|
21
|
+
fingerprint is.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import hashlib
|
|
27
|
+
import json
|
|
28
|
+
from collections.abc import Sequence
|
|
29
|
+
from datetime import datetime
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from pydantic import BaseModel
|
|
33
|
+
|
|
34
|
+
from .artifacts import Artifact
|
|
35
|
+
from .context import Context
|
|
36
|
+
|
|
37
|
+
#: Relations followed by default when walking an answer's provenance chain.
|
|
38
|
+
DEFAULT_RELATIONS: tuple[str, ...] = (
|
|
39
|
+
"supported_by",
|
|
40
|
+
"derived_from",
|
|
41
|
+
"extracted_from",
|
|
42
|
+
"materialized_from",
|
|
43
|
+
"resolved_from",
|
|
44
|
+
"calculated_from",
|
|
45
|
+
"refuted_by",
|
|
46
|
+
"contradicted_by",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _canonical(value: Any) -> str:
|
|
51
|
+
"""Canonical JSON for hashing (sorted keys; pydantic models dumped)."""
|
|
52
|
+
if isinstance(value, BaseModel):
|
|
53
|
+
payload: Any = value.model_dump(mode="json")
|
|
54
|
+
else:
|
|
55
|
+
payload = value
|
|
56
|
+
return json.dumps(payload, sort_keys=True, ensure_ascii=False, default=str)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def data_hash(value: Any) -> str:
|
|
60
|
+
"""sha256 of a value's canonical JSON — a stable content fingerprint."""
|
|
61
|
+
return hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def artifact_hash(artifact: Artifact[Any]) -> str:
|
|
65
|
+
"""Content hash of one artifact's data (its type + id are not included)."""
|
|
66
|
+
return data_hash(artifact.data)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def context_hash(context: Context) -> str:
|
|
70
|
+
"""sha256 over the run's canonical state: version, artifacts, relations.
|
|
71
|
+
|
|
72
|
+
Includes each artifact's id, type, version and content hash, plus every
|
|
73
|
+
relation edge — everything that makes two runs' states the same or
|
|
74
|
+
different. Timestamps are deliberately excluded, so a re-run that reaches
|
|
75
|
+
the same state hashes identically.
|
|
76
|
+
"""
|
|
77
|
+
artifacts = [
|
|
78
|
+
{
|
|
79
|
+
"id": artifact.id,
|
|
80
|
+
"type": type(artifact.data).__name__,
|
|
81
|
+
"version": artifact.version,
|
|
82
|
+
"sha256": artifact_hash(artifact),
|
|
83
|
+
}
|
|
84
|
+
for artifact in sorted(context.list_artifacts(), key=lambda a: a.id)
|
|
85
|
+
]
|
|
86
|
+
relations = sorted(
|
|
87
|
+
(rel.source_id, rel.relation, rel.target_id) for rel in context.relations()
|
|
88
|
+
)
|
|
89
|
+
payload = {
|
|
90
|
+
"version": context.version,
|
|
91
|
+
"artifacts": artifacts,
|
|
92
|
+
"relations": relations,
|
|
93
|
+
}
|
|
94
|
+
return hashlib.sha256(_canonical(payload).encode("utf-8")).hexdigest()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class ProvenanceEntry(BaseModel):
|
|
98
|
+
"""One artifact in an answer's provenance chain, with its content hash."""
|
|
99
|
+
|
|
100
|
+
artifact_id: str
|
|
101
|
+
data_type: str
|
|
102
|
+
version: int
|
|
103
|
+
sha256: str
|
|
104
|
+
produced_by: str = ""
|
|
105
|
+
created_at: datetime | None = None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class AuditReport(BaseModel):
|
|
109
|
+
"""A reproducible record of an answer and the evidence behind it."""
|
|
110
|
+
|
|
111
|
+
session_id: str = ""
|
|
112
|
+
context_version: int
|
|
113
|
+
context_sha256: str
|
|
114
|
+
answer: ProvenanceEntry
|
|
115
|
+
provenance: list[ProvenanceEntry] = []
|
|
116
|
+
relations: list[tuple[str, str, str]] = []
|
|
117
|
+
sources: list[str] = []
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _entry(artifact: Artifact[Any], produced_by: str) -> ProvenanceEntry:
|
|
121
|
+
return ProvenanceEntry(
|
|
122
|
+
artifact_id=artifact.id,
|
|
123
|
+
data_type=type(artifact.data).__name__,
|
|
124
|
+
version=artifact.version,
|
|
125
|
+
sha256=artifact_hash(artifact),
|
|
126
|
+
produced_by=produced_by,
|
|
127
|
+
created_at=artifact.created_at,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _producers(context: Context) -> dict[str, str]:
|
|
132
|
+
"""artifact_id → author of the commit that last wrote it (from history)."""
|
|
133
|
+
producers: dict[str, str] = {}
|
|
134
|
+
for commit in context.history():
|
|
135
|
+
for write in commit.writes:
|
|
136
|
+
producers[write.artifact_id] = commit.author
|
|
137
|
+
return producers
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _resolve_answer(context: Context, answer: Artifact[Any] | str) -> Artifact[Any]:
|
|
141
|
+
if isinstance(answer, Artifact):
|
|
142
|
+
return answer
|
|
143
|
+
artifact = context.get(answer)
|
|
144
|
+
if artifact is None:
|
|
145
|
+
raise KeyError(f"answer artifact {answer!r} not found in context")
|
|
146
|
+
return artifact
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def build_report(
|
|
150
|
+
context: Context,
|
|
151
|
+
answer: Artifact[Any] | str,
|
|
152
|
+
*,
|
|
153
|
+
relations: Sequence[str] | None = None,
|
|
154
|
+
session_id: str = "",
|
|
155
|
+
) -> AuditReport:
|
|
156
|
+
"""Walks the provenance chain behind `answer` into a verifiable report.
|
|
157
|
+
|
|
158
|
+
Breadth-first over `context.relations()`, following `relations` (all by
|
|
159
|
+
default), so every artifact that contributed to the answer is included with
|
|
160
|
+
its content hash and producing author. `SourceRef` locators in the chain
|
|
161
|
+
become `sources`.
|
|
162
|
+
"""
|
|
163
|
+
followed = set(relations) if relations is not None else set(DEFAULT_RELATIONS)
|
|
164
|
+
answer_artifact = _resolve_answer(context, answer)
|
|
165
|
+
producers = _producers(context)
|
|
166
|
+
|
|
167
|
+
entries: list[ProvenanceEntry] = []
|
|
168
|
+
edges: list[tuple[str, str, str]] = []
|
|
169
|
+
sources: list[str] = []
|
|
170
|
+
visited: set[str] = set()
|
|
171
|
+
queue = [answer_artifact]
|
|
172
|
+
while queue:
|
|
173
|
+
artifact = queue.pop(0)
|
|
174
|
+
if artifact.id in visited:
|
|
175
|
+
continue
|
|
176
|
+
visited.add(artifact.id)
|
|
177
|
+
entries.append(_entry(artifact, producers.get(artifact.id, "")))
|
|
178
|
+
locator = getattr(artifact.data, "locator", None)
|
|
179
|
+
if isinstance(locator, str) and locator and locator not in sources:
|
|
180
|
+
sources.append(locator)
|
|
181
|
+
for rel in context.relations(source_id=artifact.id):
|
|
182
|
+
if rel.relation not in followed:
|
|
183
|
+
continue
|
|
184
|
+
edges.append((rel.source_id, rel.relation, rel.target_id))
|
|
185
|
+
target = context.get(rel.target_id)
|
|
186
|
+
if target is not None and target.id not in visited:
|
|
187
|
+
queue.append(target)
|
|
188
|
+
|
|
189
|
+
return AuditReport(
|
|
190
|
+
session_id=session_id,
|
|
191
|
+
context_version=context.version,
|
|
192
|
+
context_sha256=context_hash(context),
|
|
193
|
+
answer=entries[0],
|
|
194
|
+
provenance=entries[1:],
|
|
195
|
+
relations=edges,
|
|
196
|
+
sources=sources,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def report_to_json(report: AuditReport, *, indent: int | None = 2) -> str:
|
|
201
|
+
"""The report as canonical JSON (sorted keys → reproducible bytes)."""
|
|
202
|
+
return json.dumps(
|
|
203
|
+
report.model_dump(mode="json"),
|
|
204
|
+
indent=indent,
|
|
205
|
+
sort_keys=True,
|
|
206
|
+
ensure_ascii=False,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def report_to_markdown(report: AuditReport) -> str:
|
|
211
|
+
"""The report as Markdown — for a PR comment, a ticket, or a regulator."""
|
|
212
|
+
lines = [
|
|
213
|
+
"# Audit report",
|
|
214
|
+
"",
|
|
215
|
+
f"- context version: {report.context_version}",
|
|
216
|
+
f"- context sha256: `{report.context_sha256}`",
|
|
217
|
+
f"- session: {report.session_id or '(none)'}",
|
|
218
|
+
"",
|
|
219
|
+
"## Answer",
|
|
220
|
+
"",
|
|
221
|
+
f"- `{report.answer.artifact_id}` ({report.answer.data_type}, "
|
|
222
|
+
f"v{report.answer.version})",
|
|
223
|
+
f"- sha256: `{report.answer.sha256}`",
|
|
224
|
+
f"- produced by: {report.answer.produced_by or '(seed)'}",
|
|
225
|
+
"",
|
|
226
|
+
"## Provenance",
|
|
227
|
+
"",
|
|
228
|
+
"| artifact | type | v | sha256 | produced by |",
|
|
229
|
+
"| --- | --- | --- | --- | --- |",
|
|
230
|
+
]
|
|
231
|
+
for entry in [report.answer, *report.provenance]:
|
|
232
|
+
lines.append(
|
|
233
|
+
f"| `{entry.artifact_id}` | {entry.data_type} | {entry.version} | "
|
|
234
|
+
f"`{entry.sha256[:12]}…` | {entry.produced_by or '(seed)'} |"
|
|
235
|
+
)
|
|
236
|
+
if report.relations:
|
|
237
|
+
lines += ["", "## Edges", ""]
|
|
238
|
+
lines += [f"- `{s}` —{r}→ `{t}`" for s, r, t in report.relations]
|
|
239
|
+
if report.sources:
|
|
240
|
+
lines += ["", "## Sources", ""]
|
|
241
|
+
lines += [f"- `{source}`" for source in report.sources]
|
|
242
|
+
return "\n".join(lines) + "\n"
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
__all__ = [
|
|
246
|
+
"DEFAULT_RELATIONS",
|
|
247
|
+
"AuditReport",
|
|
248
|
+
"ProvenanceEntry",
|
|
249
|
+
"artifact_hash",
|
|
250
|
+
"build_report",
|
|
251
|
+
"context_hash",
|
|
252
|
+
"data_hash",
|
|
253
|
+
"report_to_json",
|
|
254
|
+
"report_to_markdown",
|
|
255
|
+
]
|
|
@@ -18,7 +18,13 @@ from __future__ import annotations
|
|
|
18
18
|
import asyncio
|
|
19
19
|
import inspect
|
|
20
20
|
import logging
|
|
21
|
-
from collections.abc import
|
|
21
|
+
from collections.abc import (
|
|
22
|
+
AsyncGenerator,
|
|
23
|
+
AsyncIterator,
|
|
24
|
+
Callable,
|
|
25
|
+
Mapping,
|
|
26
|
+
Sequence,
|
|
27
|
+
)
|
|
22
28
|
from contextlib import asynccontextmanager
|
|
23
29
|
from typing import Any, Literal
|
|
24
30
|
|
|
@@ -35,15 +41,24 @@ from .session import Session, SessionStore
|
|
|
35
41
|
logger = logging.getLogger("reactifact.chat")
|
|
36
42
|
|
|
37
43
|
|
|
44
|
+
#: The three frame kinds of the canonical chat (start / progress / terminal).
|
|
45
|
+
ChatEventKind = Literal["session", "status", "message"]
|
|
46
|
+
|
|
47
|
+
|
|
38
48
|
class ChatEvent(BaseModel):
|
|
39
49
|
"""One frame of a chat turn, transport-neutral.
|
|
40
50
|
|
|
41
51
|
`kind` is the wire contract of the canonical chat:
|
|
42
52
|
``session`` (start), ``status`` (progress announcement) or ``message``
|
|
43
|
-
(terminal reply).
|
|
53
|
+
(terminal reply). It is a closed `Literal`, so the schema is
|
|
54
|
+
introspectable (`ChatEvent.model_json_schema()`) and a typo is a type
|
|
55
|
+
error, not a frame a client silently ignores. To *rename* these on the
|
|
56
|
+
wire for a client that speaks a different vocabulary, configure
|
|
57
|
+
`reactifact.web.create_chat_router(event_names=…)` — don't fork the
|
|
58
|
+
router.
|
|
44
59
|
"""
|
|
45
60
|
|
|
46
|
-
kind:
|
|
61
|
+
kind: ChatEventKind
|
|
47
62
|
session_id: str = ""
|
|
48
63
|
message: str = ""
|
|
49
64
|
waiting: bool = False
|
|
@@ -68,6 +83,7 @@ async def run_message(
|
|
|
68
83
|
create_message: Callable[..., str] | None = None,
|
|
69
84
|
status_kinds: Sequence[str] = ("status",),
|
|
70
85
|
fallback_reply: str = "No reply assembled.",
|
|
86
|
+
request: Mapping[str, Any] | None = None,
|
|
71
87
|
) -> AsyncIterator[ChatEvent]:
|
|
72
88
|
"""Run one user turn: create the input artifact, stream status events,
|
|
73
89
|
emit the terminal reply.
|
|
@@ -111,7 +127,7 @@ async def run_message(
|
|
|
111
127
|
|
|
112
128
|
last: str | None = None
|
|
113
129
|
try:
|
|
114
|
-
async for event in runtime.astream():
|
|
130
|
+
async for event in runtime.astream(request=request):
|
|
115
131
|
if event.kind not in forwarded:
|
|
116
132
|
continue
|
|
117
133
|
if event.message == last:
|
|
@@ -342,13 +358,26 @@ class ChatAssistant:
|
|
|
342
358
|
session_save_policy=self._session_save_policy,
|
|
343
359
|
)
|
|
344
360
|
|
|
345
|
-
async def stream(
|
|
361
|
+
async def stream(
|
|
362
|
+
self,
|
|
363
|
+
text: str,
|
|
364
|
+
session_id: str = "",
|
|
365
|
+
*,
|
|
366
|
+
request: Mapping[str, Any] | None = None,
|
|
367
|
+
) -> AsyncIterator[ChatEvent]:
|
|
346
368
|
"""Stream one turn: ``session`` → ``status``… → ``message``.
|
|
347
369
|
|
|
348
370
|
Never raises for app-level failures: session open / runtime crash /
|
|
349
371
|
reply hook all degrade to the fallback `message` and are logged via the
|
|
350
372
|
`reactifact.chat` logger, so a web layer never delivers a 500 mid-stream.
|
|
351
373
|
|
|
374
|
+
**Cancellation contract:** closing/`aclose()`-ing the returned generator
|
|
375
|
+
(what a dropped SSE client does to the server's streaming task) cancels
|
|
376
|
+
the in-flight turn — the `CancelledError` unwinds through `run_message`
|
|
377
|
+
to `Runtime.astream`, whose `finally` cancels the runner task, so a slow
|
|
378
|
+
agent or LLM call is stopped rather than left burning tokens. `stream`
|
|
379
|
+
still saves the session in its own `finally` first.
|
|
380
|
+
|
|
352
381
|
Turns on the same `session_id` are serialized (`_locked_session`): a
|
|
353
382
|
second concurrent call for the same session waits for the first to
|
|
354
383
|
finish instead of racing it to `session.save()` (§59 — no silent lost
|
|
@@ -382,6 +411,7 @@ class ChatAssistant:
|
|
|
382
411
|
status_kinds=self._status_kinds,
|
|
383
412
|
fallback_reply=self._fallback_reply,
|
|
384
413
|
session_id=session_id,
|
|
414
|
+
request=request,
|
|
385
415
|
):
|
|
386
416
|
yield event
|
|
387
417
|
finally:
|
|
@@ -405,10 +435,16 @@ class ChatAssistant:
|
|
|
405
435
|
session_id,
|
|
406
436
|
)
|
|
407
437
|
|
|
408
|
-
async def invoke(
|
|
438
|
+
async def invoke(
|
|
439
|
+
self,
|
|
440
|
+
text: str,
|
|
441
|
+
session_id: str = "",
|
|
442
|
+
*,
|
|
443
|
+
request: Mapping[str, Any] | None = None,
|
|
444
|
+
) -> dict[str, Any]:
|
|
409
445
|
"""Run one turn and return the terminal reply (aggregated stream)."""
|
|
410
446
|
message: dict[str, Any] = {}
|
|
411
|
-
async for event in self.stream(text, session_id=session_id):
|
|
447
|
+
async for event in self.stream(text, session_id=session_id, request=request):
|
|
412
448
|
if event.kind == "message":
|
|
413
449
|
message = dict(event.payload) or {"reply": self._fallback_reply}
|
|
414
450
|
return message
|
|
@@ -434,6 +470,7 @@ class ChatAssistant:
|
|
|
434
470
|
__all__ = [
|
|
435
471
|
"ChatAssistant",
|
|
436
472
|
"ChatEvent",
|
|
473
|
+
"ChatEventKind",
|
|
437
474
|
"default_session_state",
|
|
438
475
|
"run_message",
|
|
439
476
|
]
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
import copy
|
|
4
5
|
import json
|
|
5
6
|
import sqlite3
|
|
6
7
|
import time
|
|
@@ -46,6 +47,34 @@ class KVBackend(ABC):
|
|
|
46
47
|
"""Releases held resources (a pooled connection, ...). No-op by default."""
|
|
47
48
|
|
|
48
49
|
|
|
50
|
+
class InMemoryKVBackend(KVBackend):
|
|
51
|
+
"""Dict-backed KV store — no files, no external service.
|
|
52
|
+
|
|
53
|
+
The zero-config backend: ideal for tests, notebooks, and short-lived
|
|
54
|
+
scripts where a session only needs to survive within one process. State
|
|
55
|
+
is lost on exit (use `FileKVBackend`/`SQLiteKVBackend` to persist). Values
|
|
56
|
+
are deep-copied on the way in and out so callers can never alias the
|
|
57
|
+
stored state, matching the serialization boundary the file/SQL backends
|
|
58
|
+
impose.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(self) -> None:
|
|
62
|
+
self._data: dict[str, dict[str, Any]] = {}
|
|
63
|
+
|
|
64
|
+
async def set(self, key: str, data: dict[str, Any]) -> None:
|
|
65
|
+
self._data[key] = copy.deepcopy(data)
|
|
66
|
+
|
|
67
|
+
async def get(self, key: str) -> dict[str, Any] | None:
|
|
68
|
+
data = self._data.get(key)
|
|
69
|
+
return copy.deepcopy(data) if data is not None else None
|
|
70
|
+
|
|
71
|
+
async def delete(self, key: str) -> None:
|
|
72
|
+
self._data.pop(key, None)
|
|
73
|
+
|
|
74
|
+
async def keys(self) -> list[str]:
|
|
75
|
+
return list(self._data)
|
|
76
|
+
|
|
77
|
+
|
|
49
78
|
class FileKVBackend(KVBackend):
|
|
50
79
|
"""File KV backend: one JSON file per key in a directory.
|
|
51
80
|
|
|
@@ -37,6 +37,17 @@ async def _cmd_replay(args: argparse.Namespace) -> int:
|
|
|
37
37
|
)
|
|
38
38
|
for tname, count in sorted(summary["by_type"].items()):
|
|
39
39
|
print(f" {tname}: {count}")
|
|
40
|
+
if args.verify is not None or args.hash:
|
|
41
|
+
from ..audit import context_hash
|
|
42
|
+
|
|
43
|
+
digest = context_hash(context)
|
|
44
|
+
print(f"context sha256: {digest}")
|
|
45
|
+
if args.verify is not None:
|
|
46
|
+
if digest == args.verify:
|
|
47
|
+
print("verified: state matches the expected hash")
|
|
48
|
+
else:
|
|
49
|
+
print(f"MISMATCH: expected {args.verify}")
|
|
50
|
+
return 1
|
|
40
51
|
if args.diagram:
|
|
41
52
|
from ..viz import context_to_mermaid
|
|
42
53
|
|
|
@@ -63,6 +74,15 @@ def add_parser(sub: argparse._SubParsersAction[argparse.ArgumentParser]) -> None
|
|
|
63
74
|
"--version", type=int, default=None, help="replay to this commit"
|
|
64
75
|
)
|
|
65
76
|
p_replay.add_argument("--backend", choices=["file", "sqlite"], default="auto")
|
|
77
|
+
p_replay.add_argument(
|
|
78
|
+
"--hash", action="store_true", help="print the context's content hash"
|
|
79
|
+
)
|
|
80
|
+
p_replay.add_argument(
|
|
81
|
+
"--verify",
|
|
82
|
+
default=None,
|
|
83
|
+
metavar="HASH",
|
|
84
|
+
help="exit non-zero unless the context hash equals HASH (reproducibility check)",
|
|
85
|
+
)
|
|
66
86
|
p_replay.add_argument(
|
|
67
87
|
"--diagram", action="store_true", help="also print the provenance graph"
|
|
68
88
|
)
|
|
@@ -125,6 +125,33 @@ class Effects:
|
|
|
125
125
|
return None
|
|
126
126
|
return self.create(data, id=id)
|
|
127
127
|
|
|
128
|
+
def create_once_from(
|
|
129
|
+
self,
|
|
130
|
+
source: Artifact[Any] | Handle | str,
|
|
131
|
+
data: Any,
|
|
132
|
+
*,
|
|
133
|
+
prefix: str | None = None,
|
|
134
|
+
) -> Handle | None:
|
|
135
|
+
"""Idempotent create whose id is derived from another artifact (§42).
|
|
136
|
+
|
|
137
|
+
Folds the single most common way a re-derivable id is built by hand —
|
|
138
|
+
`f"answer:{question.id}"`, `f"review:{pr.id}"` — into the call, so a
|
|
139
|
+
produce doesn't have to invent the id string (nor get it subtly wrong
|
|
140
|
+
between its guard and its `create`):
|
|
141
|
+
|
|
142
|
+
answer = self.effects.create_once_from(question, Answer(...))
|
|
143
|
+
if answer is None:
|
|
144
|
+
return None # already answered this question
|
|
145
|
+
|
|
146
|
+
`prefix` defaults to the created model's class name, lowercased
|
|
147
|
+
(`Answer` → `answer:{source_id}`). `source` may be an `Artifact`, an
|
|
148
|
+
effects `Handle`, or a plain id string. `None` back means an artifact
|
|
149
|
+
with the derived id already exists — skip, exactly like `create_once`.
|
|
150
|
+
"""
|
|
151
|
+
source_id = _id_of(source)
|
|
152
|
+
artifact_id = f"{prefix or type(data).__name__.lower()}:{source_id}"
|
|
153
|
+
return self.create_once(data, id=artifact_id)
|
|
154
|
+
|
|
128
155
|
def upsert(self, data: Any, *, id: str) -> Handle:
|
|
129
156
|
"""Explicit create-or-refresh: same effect as `create(data, id=id)`.
|
|
130
157
|
|