reactifact 0.7.0__tar.gz → 0.9.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.7.0/reactifact.egg-info → reactifact-0.9.0}/PKG-INFO +16 -9
- {reactifact-0.7.0 → reactifact-0.9.0}/README.md +8 -7
- {reactifact-0.7.0 → reactifact-0.9.0}/pyproject.toml +14 -2
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/__init__.py +3 -2
- reactifact-0.9.0/reactifact/agent_tool.py +115 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/agents.py +89 -12
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/branching.py +2 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/chat.py +73 -7
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/checkpoints.py +9 -2
- reactifact-0.9.0/reactifact/consume.py +387 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/context.py +43 -3
- reactifact-0.9.0/reactifact/context_builder.py +202 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/effects.py +2 -2
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/llm_agent.py +2 -0
- reactifact-0.9.0/reactifact/native_tool_use.py +167 -0
- reactifact-0.9.0/reactifact/produce.py +322 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/recipes/__init__.py +44 -1
- reactifact-0.9.0/reactifact/recipes/cleanup.py +92 -0
- reactifact-0.9.0/reactifact/recipes/identity.py +94 -0
- reactifact-0.9.0/reactifact/recipes/memory.py +308 -0
- reactifact-0.9.0/reactifact/recipes/plan_execute.py +203 -0
- reactifact-0.9.0/reactifact/recipes/reflection.py +257 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/recipes/status.py +4 -9
- reactifact-0.9.0/reactifact/recipes/supervisor.py +229 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/resources.py +14 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/runtime.py +117 -9
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/structured.py +191 -3
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/testing/assertions.py +59 -9
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/testing/fault.py +58 -10
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/testing/lab.py +46 -8
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/tool_use.py +136 -21
- reactifact-0.9.0/reactifact/triggers.py +69 -0
- reactifact-0.9.0/reactifact/verify.py +157 -0
- {reactifact-0.7.0 → reactifact-0.9.0/reactifact.egg-info}/PKG-INFO +16 -9
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact.egg-info/SOURCES.txt +23 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact.egg-info/requires.txt +5 -0
- reactifact-0.9.0/tests/test_absent_consume.py +111 -0
- reactifact-0.9.0/tests/test_agent_tool.py +198 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_chat_web.py +4 -4
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_checkpoint.py +22 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_concurrency.py +56 -0
- reactifact-0.9.0/tests/test_consumes_produces.py +165 -0
- reactifact-0.9.0/tests/test_context_builder.py +212 -0
- reactifact-0.9.0/tests/test_correlated_consume.py +87 -0
- reactifact-0.9.0/tests/test_debounce.py +111 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_effects.py +4 -3
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_friendly_api.py +3 -3
- reactifact-0.9.0/tests/test_incident_commander.py +114 -0
- reactifact-0.9.0/tests/test_join_consume.py +121 -0
- reactifact-0.9.0/tests/test_native_tool_use.py +192 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_produce_styles.py +16 -15
- reactifact-0.9.0/tests/test_produce_trigger.py +204 -0
- reactifact-0.9.0/tests/test_reacts_to.py +160 -0
- reactifact-0.9.0/tests/test_recipe_plan_execute.py +106 -0
- reactifact-0.9.0/tests/test_recipe_reflection.py +124 -0
- reactifact-0.9.0/tests/test_recipe_supervisor.py +170 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_recipes.py +234 -11
- reactifact-0.9.0/tests/test_recipes_memory.py +231 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_replay.py +2 -1
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_runtime_errors.py +4 -4
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_sessions.py +84 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_structured.py +162 -1
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_testing_lab.py +31 -3
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_tools.py +160 -22
- reactifact-0.9.0/tests/test_verify.py +129 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_viz.py +10 -2
- reactifact-0.7.0/reactifact/consume.py +0 -96
- reactifact-0.7.0/reactifact/produce.py +0 -182
- reactifact-0.7.0/reactifact/recipes/memory.py +0 -166
- reactifact-0.7.0/reactifact/triggers.py +0 -41
- reactifact-0.7.0/tests/test_consumes_produces.py +0 -54
- reactifact-0.7.0/tests/test_recipes_memory.py +0 -105
- {reactifact-0.7.0 → reactifact-0.9.0}/LICENSE +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/__main__.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/_extras.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/artifacts.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/budget.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/cli/__init__.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/cli/branch.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/cli/common.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/cli/context.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/cli/graph.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/cli/replay.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/cli/scenario.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/cli/trace.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/commit.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/commit_log.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/eval.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/events.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/interrupt.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/mcp/__init__.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/mcp/client.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/mcp/oauth.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/mcp/server.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/operations.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/patches.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/prompts.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/providers/__init__.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/providers/_retry.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/providers/anthropic.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/providers/azure.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/providers/cerebras.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/providers/chat.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/providers/contracts.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/providers/deepseek.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/providers/fake.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/providers/fireworks.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/providers/gemini.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/providers/github_models.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/providers/groq.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/providers/image.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/providers/mistral.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/providers/nvidia.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/providers/ollama.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/providers/openai.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/providers/openrouter.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/providers/perplexity.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/providers/qwen.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/providers/speech.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/providers/together.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/providers/video.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/providers/xai.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/providers/zai.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/py.typed +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/recipes/inputs.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/recipes/resolve.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/recipes/rollback.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/recipes/search.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/recipes/skills.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/recipes/text.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/relations.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/replay.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/scheduler.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/session.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/sources.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/streaming.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/testing/__init__.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/testing/exceptions.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/testing/mock.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/testing/record.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/testing/registry.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/tools.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/tracing/__init__.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/tracing/_otlp.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/tracing/langfuse.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/tracing/models.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/tracing/otlp.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/tracing/postgres.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/tracing/store.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/tracing/templates/ui.html +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/tracing/templates/ui_run.html +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/tracing/tracer.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/tracing/web.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/viz.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact/web.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact.egg-info/dependency_links.txt +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact.egg-info/entry_points.txt +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/reactifact.egg-info/top_level.txt +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/setup.cfg +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_adaptive.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_anthropic_provider.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_artifacts.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_backbone.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_branching.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_budget.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_canonical_ports.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_checkpoints_concurrency.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_cli.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_cli_scenario.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_commit_log.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_devops.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_devops_web.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_eval.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_forklab.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_forklab_web.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_gemini_provider.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_hitl.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_image_provider.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_invalidation.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_knowledge.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_knowledge_web.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_llm_ladder.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_mcp.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_medic_lab.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_medic_lab_web.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_multisource.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_openai_provider.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_patches.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_ports.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_prompts.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_provider_auth.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_provider_retry.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_providers_integration.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_recipes_inputs.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_recipes_skills.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_relation_graph.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_relations.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_repair.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_repair_web.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_research.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_resources.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_retry.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_runtime.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_sources.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_sources_search.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_sources_vector.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_speech_provider.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_streaming.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_testing_assertions.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_testing_mock.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_testing_registry.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_tracing.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_vendor_factories.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_vendor_multimodal_factories.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_video_provider.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_view.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_web_source.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_workspace.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/test_workspace_with_sources.py +0 -0
- {reactifact-0.7.0 → reactifact-0.9.0}/tests/tests_checkpoints_sqlite.py +0 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: reactifact
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.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
|
|
7
7
|
Project-URL: Repository, https://github.com/bzdvdn/reactifact
|
|
8
|
-
Project-URL: Documentation, https://github.
|
|
8
|
+
Project-URL: Documentation, https://bzdvdn.github.io/reactifact/
|
|
9
9
|
Project-URL: Changelog, https://github.com/bzdvdn/reactifact/blob/master/CHANGELOG.md
|
|
10
10
|
Keywords: agents,llm,ai-agents,reactive,provenance,orchestration,agentic
|
|
11
11
|
Classifier: Development Status :: 4 - Beta
|
|
@@ -15,6 +15,8 @@ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
|
15
15
|
Classifier: Programming Language :: Python :: 3
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.11
|
|
17
17
|
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
20
|
Classifier: Typing :: Typed
|
|
19
21
|
Requires-Python: >=3.11
|
|
20
22
|
Description-Content-Type: text/markdown
|
|
@@ -34,6 +36,10 @@ Provides-Extra: pg
|
|
|
34
36
|
Requires-Dist: psycopg[binary]>=3.2; extra == "pg"
|
|
35
37
|
Provides-Extra: mcp
|
|
36
38
|
Requires-Dist: mcp>=2.2; extra == "mcp"
|
|
39
|
+
Provides-Extra: docs
|
|
40
|
+
Requires-Dist: mkdocs>=1.6; extra == "docs"
|
|
41
|
+
Requires-Dist: mkdocs-material>=9.5; extra == "docs"
|
|
42
|
+
Requires-Dist: mkdocstrings[python]>=0.27; extra == "docs"
|
|
37
43
|
Dynamic: license-file
|
|
38
44
|
|
|
39
45
|
<p align="center">
|
|
@@ -44,10 +50,11 @@ Dynamic: license-file
|
|
|
44
50
|
|
|
45
51
|
[](https://github.com/bzdvdn/reactifact/actions/workflows/ci.yml)
|
|
46
52
|
[](https://codecov.io/gh/bzdvdn/reactifact)
|
|
47
|
-
[](https://github.com/bzdvdn/reactifact)
|
|
53
|
+
[](https://github.com/bzdvdn/reactifact)
|
|
48
54
|
[](https://pypi.org/project/reactifact/)
|
|
49
55
|
[](LICENSE)
|
|
50
56
|
[](https://deepwiki.com/bzdvdn/reactifact)
|
|
57
|
+
[](https://bzdvdn.github.io/reactifact/)
|
|
51
58
|
|
|
52
59
|
Most agent frameworks make you **draw the graph**: connect nodes, wire memory,
|
|
53
60
|
declare control flow. But a knowledge question — *"why did infra costs jump in
|
|
@@ -99,21 +106,21 @@ DOCS = {
|
|
|
99
106
|
|
|
100
107
|
|
|
101
108
|
@produce(Evidence)
|
|
102
|
-
async def find_evidence(
|
|
103
|
-
question = next((a for a in inputs if isinstance(a.data, Question)), None)
|
|
109
|
+
async def find_evidence(call):
|
|
110
|
+
question = next((a for a in call.inputs if isinstance(a.data, Question)), None)
|
|
104
111
|
if question is None:
|
|
105
112
|
return None
|
|
106
113
|
hit = next((v for k, v in DOCS.items() if k in question.data.text.lower()), None)
|
|
107
114
|
if hit is not None:
|
|
108
|
-
effects.create(Evidence(text=hit))
|
|
115
|
+
call.effects.create(Evidence(text=hit))
|
|
109
116
|
|
|
110
117
|
|
|
111
118
|
@produce(Answer)
|
|
112
|
-
async def answer_from_evidence(
|
|
113
|
-
evidence = next((a for a in inputs if isinstance(a.data, Evidence)), None)
|
|
119
|
+
async def answer_from_evidence(call):
|
|
120
|
+
evidence = next((a for a in call.inputs if isinstance(a.data, Evidence)), None)
|
|
114
121
|
if evidence is None:
|
|
115
122
|
return None
|
|
116
|
-
effects.create(Answer(text=evidence.data.text)).link("supported_by", evidence)
|
|
123
|
+
call.effects.create(Answer(text=evidence.data.text)).link("supported_by", evidence)
|
|
117
124
|
|
|
118
125
|
|
|
119
126
|
search_agent = create_agent("search", consumes=[Consume(Question)], produces=[find_evidence])
|
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
|
|
7
7
|
[](https://github.com/bzdvdn/reactifact/actions/workflows/ci.yml)
|
|
8
8
|
[](https://codecov.io/gh/bzdvdn/reactifact)
|
|
9
|
-
[](https://github.com/bzdvdn/reactifact)
|
|
9
|
+
[](https://github.com/bzdvdn/reactifact)
|
|
10
10
|
[](https://pypi.org/project/reactifact/)
|
|
11
11
|
[](LICENSE)
|
|
12
12
|
[](https://deepwiki.com/bzdvdn/reactifact)
|
|
13
|
+
[](https://bzdvdn.github.io/reactifact/)
|
|
13
14
|
|
|
14
15
|
Most agent frameworks make you **draw the graph**: connect nodes, wire memory,
|
|
15
16
|
declare control flow. But a knowledge question — *"why did infra costs jump in
|
|
@@ -61,21 +62,21 @@ DOCS = {
|
|
|
61
62
|
|
|
62
63
|
|
|
63
64
|
@produce(Evidence)
|
|
64
|
-
async def find_evidence(
|
|
65
|
-
question = next((a for a in inputs if isinstance(a.data, Question)), None)
|
|
65
|
+
async def find_evidence(call):
|
|
66
|
+
question = next((a for a in call.inputs if isinstance(a.data, Question)), None)
|
|
66
67
|
if question is None:
|
|
67
68
|
return None
|
|
68
69
|
hit = next((v for k, v in DOCS.items() if k in question.data.text.lower()), None)
|
|
69
70
|
if hit is not None:
|
|
70
|
-
effects.create(Evidence(text=hit))
|
|
71
|
+
call.effects.create(Evidence(text=hit))
|
|
71
72
|
|
|
72
73
|
|
|
73
74
|
@produce(Answer)
|
|
74
|
-
async def answer_from_evidence(
|
|
75
|
-
evidence = next((a for a in inputs if isinstance(a.data, Evidence)), None)
|
|
75
|
+
async def answer_from_evidence(call):
|
|
76
|
+
evidence = next((a for a in call.inputs if isinstance(a.data, Evidence)), None)
|
|
76
77
|
if evidence is None:
|
|
77
78
|
return None
|
|
78
|
-
effects.create(Answer(text=evidence.data.text)).link("supported_by", evidence)
|
|
79
|
+
call.effects.create(Answer(text=evidence.data.text)).link("supported_by", evidence)
|
|
79
80
|
|
|
80
81
|
|
|
81
82
|
search_agent = create_agent("search", consumes=[Consume(Question)], produces=[find_evidence])
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "reactifact"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.9.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"
|
|
@@ -19,6 +19,8 @@ classifiers = [
|
|
|
19
19
|
"Programming Language :: Python :: 3",
|
|
20
20
|
"Programming Language :: Python :: 3.11",
|
|
21
21
|
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Programming Language :: Python :: 3.14",
|
|
22
24
|
"Typing :: Typed",
|
|
23
25
|
]
|
|
24
26
|
dependencies = [
|
|
@@ -30,7 +32,7 @@ dependencies = [
|
|
|
30
32
|
[project.urls]
|
|
31
33
|
Homepage = "https://github.com/bzdvdn/reactifact"
|
|
32
34
|
Repository = "https://github.com/bzdvdn/reactifact"
|
|
33
|
-
Documentation = "https://github.
|
|
35
|
+
Documentation = "https://bzdvdn.github.io/reactifact/"
|
|
34
36
|
Changelog = "https://github.com/bzdvdn/reactifact/blob/master/CHANGELOG.md"
|
|
35
37
|
|
|
36
38
|
[project.optional-dependencies]
|
|
@@ -50,6 +52,11 @@ pg = [
|
|
|
50
52
|
mcp = [
|
|
51
53
|
"mcp>=2.2",
|
|
52
54
|
]
|
|
55
|
+
docs = [
|
|
56
|
+
"mkdocs>=1.6",
|
|
57
|
+
"mkdocs-material>=9.5",
|
|
58
|
+
"mkdocstrings[python]>=0.27",
|
|
59
|
+
]
|
|
53
60
|
|
|
54
61
|
# uv-native groups mirror the extras so `uv run`/`uv sync` installs the web demo
|
|
55
62
|
# deps by default (no `--extra` flag needed to boot the examples). Extras stay
|
|
@@ -74,6 +81,11 @@ pg = [
|
|
|
74
81
|
mcp = [
|
|
75
82
|
"mcp>=2.2",
|
|
76
83
|
]
|
|
84
|
+
docs = [
|
|
85
|
+
"mkdocs>=1.6",
|
|
86
|
+
"mkdocs-material>=9.5",
|
|
87
|
+
"mkdocstrings[python]>=0.27",
|
|
88
|
+
]
|
|
77
89
|
|
|
78
90
|
[tool.uv]
|
|
79
91
|
default-groups = ["dev", "web", "mcp"]
|
|
@@ -31,7 +31,7 @@ from .effects import Effects, Handle
|
|
|
31
31
|
from .events import Event, EventType
|
|
32
32
|
from .interrupt import PendingQuestion
|
|
33
33
|
from .patches import Create, Delete, Link, Patch, Relation, Unlink, Update
|
|
34
|
-
from .produce import Produce, produce
|
|
34
|
+
from .produce import Produce, ProduceCall, produce
|
|
35
35
|
from .providers import (
|
|
36
36
|
EmbeddingProvider,
|
|
37
37
|
FakeEmbedder,
|
|
@@ -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.
|
|
51
|
+
__version__ = "0.9.0"
|
|
52
52
|
|
|
53
53
|
__all__ = [
|
|
54
54
|
"Agent",
|
|
@@ -72,6 +72,7 @@ __all__ = [
|
|
|
72
72
|
"Patch",
|
|
73
73
|
"PendingQuestion",
|
|
74
74
|
"Produce",
|
|
75
|
+
"ProduceCall",
|
|
75
76
|
"Relation",
|
|
76
77
|
"RunOutcome",
|
|
77
78
|
"RunStats",
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""reactifact.agent_tool — a sub-agent as a `Tool` (delegation, §46).
|
|
2
|
+
|
|
3
|
+
`AgentAsTool` lets an `LLMAgent`/`HITLLMAgent` loop call another agent as an
|
|
4
|
+
ordinary tool: "delegate to X" runs a fresh, isolated nested `Runtime` to
|
|
5
|
+
completion and returns its final answer as the tool result. The delegating
|
|
6
|
+
loop sees only the final text — not the sub-agent's own tool calls, retries,
|
|
7
|
+
or internal reasoning.
|
|
8
|
+
|
|
9
|
+
Two deliberate constraints, not omissions:
|
|
10
|
+
|
|
11
|
+
- **Isolation is a fresh `Context`, not `context.branch()`.** The sub-agent
|
|
12
|
+
sees only the `query` it's given, nothing of the parent's history — no
|
|
13
|
+
prompt bloat from an unrelated conversation, and no accidental fork/merge
|
|
14
|
+
semantics (`branch()` exists for alternative-state exploration meant to be
|
|
15
|
+
merged back later, §39/§40; a one-shot delegate-and-discard call needs
|
|
16
|
+
neither). If a sub-agent genuinely needs to see the parent's state, build
|
|
17
|
+
its `Context` yourself and don't use this tool.
|
|
18
|
+
- **HITL sub-agents are rejected, not silently broken.** `Tool.execute` has
|
|
19
|
+
no channel back to a human — nothing would ever answer a `PendingQuestion`
|
|
20
|
+
the sub-agent raises inside its own isolated `Context`, and the nested run
|
|
21
|
+
would just finish with no `ToolAnswer`. `execute()` checks for this and
|
|
22
|
+
returns a `ToolOutput(error=...)` naming the unanswered question, instead
|
|
23
|
+
of returning empty text with no explanation.
|
|
24
|
+
|
|
25
|
+
Because `Tool.execute` receives only `args` (no `context`, see `tools.py`),
|
|
26
|
+
the sub-agent's `LLMProvider`/budget cannot be inherited implicitly from
|
|
27
|
+
whatever loop is calling this tool — pass them explicitly via `resources=`.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from collections.abc import Callable
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from pydantic import BaseModel
|
|
36
|
+
|
|
37
|
+
from .agents import Agent
|
|
38
|
+
from .budget import Budget
|
|
39
|
+
from .context import Context
|
|
40
|
+
from .resources import RuntimeResources
|
|
41
|
+
from .runtime import Runtime
|
|
42
|
+
from .tool_use import ToolAnswer
|
|
43
|
+
from .tools import Tool, ToolOutput
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class SubTask(BaseModel):
|
|
47
|
+
"""Default input artifact for `AgentAsTool` — override via `input_type`
|
|
48
|
+
with any model that has a `text: str` field (the convention `ToolUse`'s
|
|
49
|
+
own `_goal` reads, `tool_use.py`)."""
|
|
50
|
+
|
|
51
|
+
text: str
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class AgentAsTool(Tool):
|
|
55
|
+
"""Wraps `agent_factory()` as a callable tool (see module docstring)."""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
*,
|
|
60
|
+
name: str,
|
|
61
|
+
description: str,
|
|
62
|
+
agent_factory: Callable[[], Agent],
|
|
63
|
+
resources: RuntimeResources,
|
|
64
|
+
input_type: type[BaseModel] = SubTask,
|
|
65
|
+
output_type: type[BaseModel] | None = None,
|
|
66
|
+
max_runs: int = 20,
|
|
67
|
+
destructive: bool = False,
|
|
68
|
+
):
|
|
69
|
+
self.name = name
|
|
70
|
+
self.description = description
|
|
71
|
+
self.agent_factory = agent_factory
|
|
72
|
+
self.resources = resources
|
|
73
|
+
self.input_type = input_type
|
|
74
|
+
self.output_type = output_type or ToolAnswer
|
|
75
|
+
self.max_runs = max_runs
|
|
76
|
+
self.destructive = destructive
|
|
77
|
+
self.schema = {
|
|
78
|
+
"type": "object",
|
|
79
|
+
"properties": {
|
|
80
|
+
"query": {
|
|
81
|
+
"type": "string",
|
|
82
|
+
"description": "The task/question to delegate to the sub-agent.",
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
"required": ["query"],
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async def execute(self, args: dict[str, Any]) -> ToolOutput:
|
|
89
|
+
query = str(args.get("query", ""))
|
|
90
|
+
sub_context = Context(resources=self.resources)
|
|
91
|
+
sub_agent = self.agent_factory()
|
|
92
|
+
sub_runtime = Runtime(
|
|
93
|
+
sub_context, agents=[sub_agent], budget=Budget(max_runs=self.max_runs)
|
|
94
|
+
)
|
|
95
|
+
sub_context.create(self.input_type(text=query))
|
|
96
|
+
await sub_runtime.arun()
|
|
97
|
+
|
|
98
|
+
if sub_context.has_pending_question():
|
|
99
|
+
pending = sub_context.latest_pending_question()
|
|
100
|
+
question = pending.data.question if pending is not None else ""
|
|
101
|
+
return ToolOutput(
|
|
102
|
+
error=(
|
|
103
|
+
f"sub-agent '{sub_agent.name}' needs clarification it wasn't "
|
|
104
|
+
f"given and can't ask for: {question!r}. Give '{self.name}' a "
|
|
105
|
+
"more complete query and try again."
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
outputs = sub_context.list_artifacts(self.output_type)
|
|
110
|
+
if not outputs:
|
|
111
|
+
return ToolOutput(
|
|
112
|
+
error=f"sub-agent '{sub_agent.name}' produced no {self.output_type.__name__}"
|
|
113
|
+
)
|
|
114
|
+
text = getattr(outputs[-1].data, "text", "") or ""
|
|
115
|
+
return ToolOutput(text=text)
|
|
@@ -7,9 +7,9 @@ from typing import Any
|
|
|
7
7
|
from .artifacts import Artifact
|
|
8
8
|
from .consume import Consume
|
|
9
9
|
from .context import Context
|
|
10
|
-
from .events import Event
|
|
10
|
+
from .events import Event, EventType
|
|
11
11
|
from .patches import Patch
|
|
12
|
-
from .produce import Produce
|
|
12
|
+
from .produce import Produce, ProduceCall
|
|
13
13
|
from .triggers import Trigger
|
|
14
14
|
|
|
15
15
|
|
|
@@ -25,6 +25,15 @@ class Agent(ABC): # noqa: B024 — interface without abstract methods, run() ha
|
|
|
25
25
|
produces: Sequence[Produce[Any]] | None = None
|
|
26
26
|
#: Declarative capability labels (§25), consumed by the adaptive policy.
|
|
27
27
|
capabilities: tuple[str, ...] = ()
|
|
28
|
+
#: Explicit trigger override — for the *imperative* style (`Agent`
|
|
29
|
+
#: subclass overriding `run()` directly, no `consumes`/`produces` at
|
|
30
|
+
#: all, see `run()`'s own docstring). That style never calls
|
|
31
|
+
#: `_collect_inputs()`, so `Consume` has nothing to attach to; `triggers`
|
|
32
|
+
#: is the only way such an agent says when to wake up. For the
|
|
33
|
+
#: declarative style (`consumes`/`produces`, the common case), leave this
|
|
34
|
+
#: unset — `triggers` auto-derives from `consumes` instead, and a
|
|
35
|
+
#: `Consume` that should feed inputs without also waking the agent uses
|
|
36
|
+
#: `Consume(..., wakes=False)` rather than a second, hand-maintained list.
|
|
28
37
|
triggers: list[Trigger] = []
|
|
29
38
|
# Run priority within a single generation: lower value runs earlier.
|
|
30
39
|
# Useful for "finishers"/evaluators that logically run last (§24).
|
|
@@ -80,7 +89,17 @@ class Agent(ABC): # noqa: B024 — interface without abstract methods, run() ha
|
|
|
80
89
|
)
|
|
81
90
|
|
|
82
91
|
def matches(self, event: Event, context: Context | None = None) -> bool:
|
|
83
|
-
return
|
|
92
|
+
return bool(self.matching_triggers(event, context))
|
|
93
|
+
|
|
94
|
+
def matching_triggers(
|
|
95
|
+
self, event: Event, context: Context | None = None
|
|
96
|
+
) -> list[Trigger]:
|
|
97
|
+
"""Every trigger that matches `event` — `matches()` is just
|
|
98
|
+
`bool(...)` of this. `Runtime._arun_once_impl` uses the full list
|
|
99
|
+
(not just the bool) to decide whether *every* matching trigger asks
|
|
100
|
+
for debouncing (`Trigger.debounce`) before collapsing repeat events
|
|
101
|
+
into one run."""
|
|
102
|
+
return [trigger for trigger in self.triggers if trigger.matches(event, context)]
|
|
84
103
|
|
|
85
104
|
def collect_inputs(self, context: Context) -> list[Artifact[Any]]:
|
|
86
105
|
"""Public access to the consumed artifacts.
|
|
@@ -90,15 +109,22 @@ class Agent(ABC): # noqa: B024 — interface without abstract methods, run() ha
|
|
|
90
109
|
return self._collect_inputs(context)
|
|
91
110
|
|
|
92
111
|
def _collect_inputs(self, context: Context) -> list[Artifact[Any]]:
|
|
93
|
-
"""Collects all artifacts matching consumes and conditions.
|
|
112
|
+
"""Collects all artifacts matching consumes and conditions.
|
|
113
|
+
|
|
114
|
+
Ranking/truncation, if any, is a `Runtime`-level policy
|
|
115
|
+
(`context.resources.context_builder`, see `context_builder.py`), not
|
|
116
|
+
this agent's — applied here so both this and `collect_inputs()`
|
|
117
|
+
(used by the runtime for provenance) see the identical, already
|
|
118
|
+
built list.
|
|
119
|
+
"""
|
|
94
120
|
if not self.consumes:
|
|
95
121
|
return []
|
|
96
122
|
inputs: list[Artifact[Any]] = []
|
|
97
123
|
for c in self.consumes:
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
inputs.
|
|
124
|
+
inputs.extend(c.collect(context))
|
|
125
|
+
builder = context.resources.context_builder
|
|
126
|
+
if builder is not None:
|
|
127
|
+
inputs = builder.build(context, self, inputs)
|
|
102
128
|
return inputs
|
|
103
129
|
|
|
104
130
|
async def run(self, event: Event, context: Context) -> Patch | None:
|
|
@@ -118,8 +144,9 @@ class Agent(ABC): # noqa: B024 — interface without abstract methods, run() ha
|
|
|
118
144
|
async def execute(self, context: Context, event: Event | None = None) -> None:
|
|
119
145
|
"""Runs the agent's produces (usually on an event).
|
|
120
146
|
|
|
121
|
-
Effects-first (§24): produces write `self.effects.*`
|
|
122
|
-
the *runtime* compiles the effect slot into one
|
|
147
|
+
Effects-first (§24): produces write `self.effects.*`/`call.effects.*`
|
|
148
|
+
and return None; the *runtime* compiles the effect slot into one
|
|
149
|
+
atomic patch. This method
|
|
123
150
|
only *runs* the produces — it does not build a patch. (`run` remains the
|
|
124
151
|
agent-level escape hatch for custom Agent subclasses that assemble a
|
|
125
152
|
change-set by hand; the runtime merges its result after the effects.)
|
|
@@ -128,9 +155,56 @@ class Agent(ABC): # noqa: B024 — interface without abstract methods, run() ha
|
|
|
128
155
|
return None
|
|
129
156
|
inputs = self._collect_inputs(context)
|
|
130
157
|
for p in self.produces:
|
|
131
|
-
|
|
158
|
+
runs, trigger = self._resolve_produce_call(p, event, context)
|
|
159
|
+
if not runs:
|
|
160
|
+
continue
|
|
161
|
+
call = ProduceCall(
|
|
162
|
+
context=context, inputs=inputs, event=event, trigger=trigger
|
|
163
|
+
)
|
|
164
|
+
await p.produce(call)
|
|
132
165
|
return None
|
|
133
166
|
|
|
167
|
+
@staticmethod
|
|
168
|
+
def _resolve_produce_call(
|
|
169
|
+
p: Produce[Any], event: Event | None, context: Context
|
|
170
|
+
) -> tuple[bool, Artifact[Any] | None]:
|
|
171
|
+
"""Whether to call `p.produce()` for `event`, and the triggering
|
|
172
|
+
artifact to put on the `ProduceCall.trigger` field (see `Produce`'s
|
|
173
|
+
docstring, and `ProduceCall`'s, for the full contract).
|
|
174
|
+
|
|
175
|
+
`p.reacts_to is None` (the default): unrestricted, matching the
|
|
176
|
+
pre-`reacts_to` behavior where every produce ran on every event this
|
|
177
|
+
agent got — `trigger` is still resolved on a best-effort basis (for
|
|
178
|
+
a produce that wants it without narrowing `reacts_to`), but never a
|
|
179
|
+
reason to skip the call, since there's no per-type contract to hold
|
|
180
|
+
it to.
|
|
181
|
+
|
|
182
|
+
`p.reacts_to` set: exact type equality against `event.artifact_type`
|
|
183
|
+
(same convention as `Trigger.artifact_type`, not `issubclass` — that
|
|
184
|
+
one's a `Context.list_artifacts()` convention for querying by base
|
|
185
|
+
type). For a CREATED/UPDATED/STALE event, also requires
|
|
186
|
+
`context.get(event.artifact_id)` to still resolve — the artifact may
|
|
187
|
+
have been deleted by another agent earlier in the same generation
|
|
188
|
+
(the same race `Trigger.matches()` documents) — so `reacts_to`, once
|
|
189
|
+
set, guarantees `call.trigger` is always a live, correctly-typed
|
|
190
|
+
artifact. A DELETED event is exempt from that liveness requirement:
|
|
191
|
+
`context.get(...)` correctly returning `None` *is* the event there,
|
|
192
|
+
not a race, so the produce still runs with `trigger=None` —
|
|
193
|
+
deletion-reacting code is expected to handle that itself.
|
|
194
|
+
"""
|
|
195
|
+
if event is None:
|
|
196
|
+
return (p.reacts_to is None), None
|
|
197
|
+
if p.reacts_to is not None and event.artifact_type not in p.reacts_to:
|
|
198
|
+
return False, None
|
|
199
|
+
trigger = context.get(event.artifact_id)
|
|
200
|
+
if (
|
|
201
|
+
p.reacts_to is not None
|
|
202
|
+
and trigger is None
|
|
203
|
+
and event.type is not EventType.ARTIFACT_DELETED
|
|
204
|
+
):
|
|
205
|
+
return False, None
|
|
206
|
+
return True, trigger
|
|
207
|
+
|
|
134
208
|
|
|
135
209
|
def create_agent(
|
|
136
210
|
name: str,
|
|
@@ -156,7 +230,10 @@ def create_agent(
|
|
|
156
230
|
)
|
|
157
231
|
```
|
|
158
232
|
|
|
159
|
-
Falls back to `name` defaults the same way as `Agent.__init__`.
|
|
233
|
+
Falls back to `name` defaults the same way as `Agent.__init__`. Pass
|
|
234
|
+
`triggers` only for the rare imperative style with no `consumes` at all
|
|
235
|
+
(see `Agent.triggers`'s own docstring) — the ordinary declarative case
|
|
236
|
+
should leave it unset and use `Consume(..., wakes=False)` instead.
|
|
160
237
|
"""
|
|
161
238
|
agent = Agent(
|
|
162
239
|
name=name, triggers=triggers if triggers is not None else [], priority=priority
|
|
@@ -63,6 +63,7 @@ def clone_context(source: Context) -> Context:
|
|
|
63
63
|
new_ws._log = source._log.copy()
|
|
64
64
|
new_ws._relations = source._relations.copy()
|
|
65
65
|
new_ws._recompute_stale()
|
|
66
|
+
new_ws._reindex_by_type()
|
|
66
67
|
return new_ws
|
|
67
68
|
|
|
68
69
|
|
|
@@ -209,6 +210,7 @@ def merge_contexts(
|
|
|
209
210
|
# `delete()` — resync `_stale` from scratch rather than risk it
|
|
210
211
|
# drifting from the post-merge state.
|
|
211
212
|
target._recompute_stale()
|
|
213
|
+
target._reindex_by_type()
|
|
212
214
|
|
|
213
215
|
|
|
214
216
|
class BranchStore:
|
|
@@ -16,10 +16,11 @@ Two levels of use:
|
|
|
16
16
|
from __future__ import annotations
|
|
17
17
|
|
|
18
18
|
import asyncio
|
|
19
|
+
import inspect
|
|
19
20
|
import logging
|
|
20
21
|
from collections.abc import AsyncGenerator, AsyncIterator, Callable, Sequence
|
|
21
22
|
from contextlib import asynccontextmanager
|
|
22
|
-
from typing import Any
|
|
23
|
+
from typing import Any, Literal
|
|
23
24
|
|
|
24
25
|
from pydantic import BaseModel
|
|
25
26
|
|
|
@@ -64,7 +65,7 @@ async def run_message(
|
|
|
64
65
|
user_message: type[BaseModel],
|
|
65
66
|
reply: Callable[[Context, str], dict[str, Any]],
|
|
66
67
|
session_id: str = "",
|
|
67
|
-
create_message: Callable[
|
|
68
|
+
create_message: Callable[..., str] | None = None,
|
|
68
69
|
status_kinds: Sequence[str] = ("status",),
|
|
69
70
|
fallback_reply: str = "No reply assembled.",
|
|
70
71
|
) -> AsyncIterator[ChatEvent]:
|
|
@@ -78,7 +79,11 @@ async def run_message(
|
|
|
78
79
|
`create_message(ctx, text) -> msg_id` overrides how the turn enters the
|
|
79
80
|
context (default: create a `user_message` artifact) — HITL apps where a
|
|
80
81
|
new turn resumes a pending question instead of appending a message
|
|
81
|
-
(devops-style clarify) pass their own.
|
|
82
|
+
(devops-style clarify) pass their own. Also accepts the 3-arg shape
|
|
83
|
+
`create_message(ctx, text, session_id) -> msg_id` for a hook that needs
|
|
84
|
+
the current turn's `session_id` (e.g. to stamp it onto the created
|
|
85
|
+
artifact) — detected from the callable's own arity, so the 2-arg shape
|
|
86
|
+
keeps working unchanged.
|
|
82
87
|
|
|
83
88
|
`status_kinds` selects which progress event kinds are forwarded as `status`
|
|
84
89
|
frames (default: only `status`; e.g. tool-announcing demos also forward
|
|
@@ -92,7 +97,7 @@ async def run_message(
|
|
|
92
97
|
ctx = runtime.context
|
|
93
98
|
try:
|
|
94
99
|
if create_message is not None:
|
|
95
|
-
msg_id = create_message
|
|
100
|
+
msg_id = _call_create_message(create_message, ctx, text, session_id)
|
|
96
101
|
else:
|
|
97
102
|
msg_id = ctx.create(user_message(text=text, session_id=session_id)).id
|
|
98
103
|
except Exception:
|
|
@@ -164,6 +169,45 @@ def _resolve(value: Any) -> Any:
|
|
|
164
169
|
return value() if callable(value) else value
|
|
165
170
|
|
|
166
171
|
|
|
172
|
+
def _accepts_arg(func: Callable[..., Any], count: int) -> bool:
|
|
173
|
+
"""Whether `func` (already known callable) declares at least `count`
|
|
174
|
+
positional-or-keyword parameters — used to detect the opt-in, per-request
|
|
175
|
+
call shapes below without breaking the plain zero/two-arg factories that
|
|
176
|
+
predate them. A callable whose signature can't be inspected (a builtin, a
|
|
177
|
+
C extension) is assumed to be the old, arg-less shape."""
|
|
178
|
+
try:
|
|
179
|
+
params = inspect.signature(func).parameters
|
|
180
|
+
except (TypeError, ValueError):
|
|
181
|
+
return False
|
|
182
|
+
return len(params) >= count
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _resolve_with_session(value: Any, session_id: str) -> Any:
|
|
186
|
+
"""Like `_resolve`, but passes `session_id` to `value` when it declares a
|
|
187
|
+
parameter for it — the per-request factory shape
|
|
188
|
+
(`resources=lambda session_id: build_resources(session_id)`) alongside
|
|
189
|
+
the pre-existing zero-arg one (`resources=lambda: build_resources()`),
|
|
190
|
+
which keeps working unchanged. `ChatAssistant` already knows `session_id`
|
|
191
|
+
at the point it resolves `resources=`; this just lets a factory opt into
|
|
192
|
+
reading it instead of reaching for a contextvar/side-channel to get
|
|
193
|
+
per-request data (e.g. an authenticated user) into `RuntimeResources`.
|
|
194
|
+
"""
|
|
195
|
+
if not callable(value):
|
|
196
|
+
return value
|
|
197
|
+
return value(session_id) if _accepts_arg(value, 1) else value()
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _call_create_message(
|
|
201
|
+
create_message: Callable[..., str], ctx: Context, text: str, session_id: str
|
|
202
|
+
) -> str:
|
|
203
|
+
"""Calls `create_message` with `session_id` appended when it declares a
|
|
204
|
+
third parameter for it (`(ctx, text, session_id) -> msg_id`), alongside
|
|
205
|
+
the pre-existing two-arg shape (`(ctx, text) -> msg_id`)."""
|
|
206
|
+
if _accepts_arg(create_message, 3):
|
|
207
|
+
return create_message(ctx, text, session_id)
|
|
208
|
+
return create_message(ctx, text)
|
|
209
|
+
|
|
210
|
+
|
|
167
211
|
# --- the canonical chat assistant ----------------------------------------- #
|
|
168
212
|
|
|
169
213
|
|
|
@@ -182,6 +226,23 @@ class ChatAssistant:
|
|
|
182
226
|
turns/sessions — that instance is never closed automatically; close it
|
|
183
227
|
yourself at real shutdown.
|
|
184
228
|
|
|
229
|
+
`session_save_policy=` passes straight through to `Runtime` — `"per_turn"`
|
|
230
|
+
trades finer crash-resilience granularity (a save at every commit, the
|
|
231
|
+
default) for one `session.save()` per turn instead of one per commit,
|
|
232
|
+
worthwhile once a multi-stage pipeline routinely produces several commits
|
|
233
|
+
per turn (see `Runtime.__init__`'s own docstring for the trade-off).
|
|
234
|
+
|
|
235
|
+
`resources=`/`create_message=` may optionally take the current turn's
|
|
236
|
+
`session_id` — `resources=lambda session_id: build_resources(session_id)`
|
|
237
|
+
(e.g. to attach an authenticated user looked up from the session) and
|
|
238
|
+
`create_message=lambda ctx, text, session_id: ...` — detected from each
|
|
239
|
+
callable's own arity, so the pre-existing zero/two-arg shapes keep
|
|
240
|
+
working unchanged. Without this, per-request data (who's asking, not
|
|
241
|
+
just what they asked) has no way into a turn short of a contextvar/
|
|
242
|
+
side-channel set by the caller before `stream()`/`invoke()` — `resources=`
|
|
243
|
+
and `create_message=` were otherwise the only two hooks in this class
|
|
244
|
+
that never saw it.
|
|
245
|
+
|
|
185
246
|
Base usage:
|
|
186
247
|
|
|
187
248
|
assistant = ChatAssistant(
|
|
@@ -203,15 +264,16 @@ class ChatAssistant:
|
|
|
203
264
|
user_message: type[BaseModel],
|
|
204
265
|
reply: Callable[[Context, str], dict[str, Any]],
|
|
205
266
|
session_state: Callable[[Context], dict[str, Any]] | None = None,
|
|
206
|
-
resources: RuntimeResources | Callable[
|
|
267
|
+
resources: RuntimeResources | Callable[..., RuntimeResources] | None = None,
|
|
207
268
|
budget: Budget | None = None,
|
|
208
269
|
max_concurrency: int | None = None,
|
|
209
270
|
tracer: Any = None,
|
|
210
|
-
create_message: Callable[
|
|
271
|
+
create_message: Callable[..., str] | None = None,
|
|
211
272
|
status_kinds: Sequence[str] = ("status",),
|
|
212
273
|
fallback_reply: str = "No reply assembled.",
|
|
213
274
|
isolate_errors: bool = False,
|
|
214
275
|
on_agent_error: Callable[[Agent, Event, BaseException], None] | None = None,
|
|
276
|
+
session_save_policy: Literal["per_commit", "per_turn"] = "per_commit",
|
|
215
277
|
):
|
|
216
278
|
self.store = store
|
|
217
279
|
self._agents = agents
|
|
@@ -227,6 +289,7 @@ class ChatAssistant:
|
|
|
227
289
|
self._fallback_reply = fallback_reply
|
|
228
290
|
self._isolate_errors = isolate_errors
|
|
229
291
|
self._on_agent_error = on_agent_error
|
|
292
|
+
self._session_save_policy = session_save_policy
|
|
230
293
|
# Serializes concurrent turns on the *same* session_id (a double
|
|
231
294
|
# submit, a client retry): without this, two overlapping stream()
|
|
232
295
|
# calls both load the same starting state and the later save() wins,
|
|
@@ -262,7 +325,9 @@ class ChatAssistant:
|
|
|
262
325
|
self._session_locks.pop(session_id, None)
|
|
263
326
|
|
|
264
327
|
async def _open(self, session_id: str) -> Session:
|
|
265
|
-
return await self.store.open(
|
|
328
|
+
return await self.store.open(
|
|
329
|
+
session_id, resources=_resolve_with_session(self._resources, session_id)
|
|
330
|
+
)
|
|
266
331
|
|
|
267
332
|
def _build_runtime(self, session: Session) -> Runtime:
|
|
268
333
|
return Runtime(
|
|
@@ -274,6 +339,7 @@ class ChatAssistant:
|
|
|
274
339
|
tracer=_resolve(self._tracer),
|
|
275
340
|
isolate_errors=self._isolate_errors,
|
|
276
341
|
on_agent_error=self._on_agent_error,
|
|
342
|
+
session_save_policy=self._session_save_policy,
|
|
277
343
|
)
|
|
278
344
|
|
|
279
345
|
async def stream(self, text: str, session_id: str = "") -> AsyncIterator[ChatEvent]:
|
|
@@ -112,11 +112,18 @@ class FileBackend(CheckpointBackend):
|
|
|
112
112
|
"""File backend: state is stored in a single JSON file."""
|
|
113
113
|
|
|
114
114
|
def __init__(self, path: str):
|
|
115
|
-
self.path = path
|
|
115
|
+
self.path = Path(path)
|
|
116
116
|
|
|
117
117
|
def _save_sync(self, data: dict[str, Any]) -> None:
|
|
118
|
-
|
|
118
|
+
# Atomic write (tmp + rename), same reasoning as `FileKVBackend._set_sync`:
|
|
119
|
+
# a direct write left a truncated/corrupt file on a process kill mid-write
|
|
120
|
+
# (OOM, deploy, kill -9) — the next `load()` would raise JSONDecodeError
|
|
121
|
+
# and the session would be unrecoverable.
|
|
122
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
tmp = self.path.with_suffix(self.path.suffix + ".tmp")
|
|
124
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
119
125
|
json.dump(data, f, indent=2)
|
|
126
|
+
tmp.replace(self.path)
|
|
120
127
|
|
|
121
128
|
async def save(self, data: dict[str, Any]) -> None:
|
|
122
129
|
await asyncio.to_thread(self._save_sync, data)
|