reactifact 0.14.2__tar.gz → 0.15.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {reactifact-0.14.2/reactifact.egg-info → reactifact-0.15.1}/PKG-INFO +2 -2
- {reactifact-0.14.2 → reactifact-0.15.1}/README.md +1 -1
- {reactifact-0.14.2 → reactifact-0.15.1}/pyproject.toml +1 -1
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/__init__.py +1 -1
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/a2a/server.py +37 -9
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/chat.py +18 -4
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/runtime.py +30 -7
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/streaming.py +13 -2
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/web.py +27 -5
- {reactifact-0.14.2 → reactifact-0.15.1/reactifact.egg-info}/PKG-INFO +2 -2
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_a2a.py +66 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_chat_web.py +146 -0
- reactifact-0.15.1/tests/test_streaming.py +156 -0
- reactifact-0.14.2/tests/test_streaming.py +0 -68
- {reactifact-0.14.2 → reactifact-0.15.1}/LICENSE +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/__main__.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/_extras.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/_httpx.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/a2a/__init__.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/a2a/client.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/a2a/models.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/a2a/remote.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/agent_tool.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/agents.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/artifacts.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/audit.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/authz.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/branching.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/budget.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/cache.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/checkpoints.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/cli/__init__.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/cli/branch.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/cli/common.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/cli/context.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/cli/eval.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/cli/graph.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/cli/replay.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/cli/scenario.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/cli/skills.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/cli/trace.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/commit.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/commit_log.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/consume.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/context.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/context_builder.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/effects.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/eval/__init__.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/eval/dataset.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/eval/evaluators.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/eval/judge.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/eval/online.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/eval/runner.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/eval/scoring.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/events.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/guardrails.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/interrupt.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/llm_agent.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/logging.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/mcp/__init__.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/mcp/client.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/mcp/oauth.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/mcp/server.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/metrics.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/native_tool_use.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/operations.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/patches.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/pricing.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/produce.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/prompts.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/providers/__init__.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/providers/_retry.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/providers/anthropic.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/providers/azure.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/providers/cerebras.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/providers/chat.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/providers/contracts.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/providers/deepseek.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/providers/fake.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/providers/fireworks.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/providers/gemini.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/providers/github_models.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/providers/groq.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/providers/image.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/providers/mistral.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/providers/nvidia.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/providers/ollama.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/providers/openai.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/providers/openrouter.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/providers/perplexity.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/providers/qwen.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/providers/speech.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/providers/together.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/providers/video.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/providers/xai.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/providers/zai.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/py.typed +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/quick/__init__.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/quick/_shared.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/quick/agent.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/quick/chat.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/quick/models.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/quick/rag.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/quick/tools_agent.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/quota.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/recipes/__init__.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/recipes/cleanup.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/recipes/conversation.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/recipes/identity.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/recipes/inputs.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/recipes/memory.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/recipes/plan_execute.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/recipes/reflection.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/recipes/resolve.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/recipes/rollback.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/recipes/search.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/recipes/skills.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/recipes/status.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/recipes/supervisor.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/recipes/text.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/recipes/tool_loop.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/redaction.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/relations.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/replay.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/request.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/resources.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/routing.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/scheduler.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/session.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/skills/reactifact/SKILL.md +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/skills/reactifact/references/effects-and-state.md +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/skills/reactifact/references/trust-and-safety.md +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/skills/reactifact-agents/SKILL.md +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/skills/reactifact-agents/references/consume.md +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/skills/reactifact-agents/references/effects.md +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/skills/reactifact-eval/SKILL.md +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/skills/reactifact-eval/references/online.md +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/skills/reactifact-from-langchain/SKILL.md +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/skills/reactifact-llm/SKILL.md +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/skills/reactifact-llm/references/providers.md +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/skills/reactifact-llm/references/tools.md +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/skills/reactifact-observability/SKILL.md +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/skills/reactifact-rag/SKILL.md +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/skills/reactifact-rag/references/sources.md +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/skills/reactifact-testing/SKILL.md +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/sources.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/structured.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/testing/__init__.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/testing/assertions.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/testing/exceptions.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/testing/fault.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/testing/golden.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/testing/lab.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/testing/mock.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/testing/pytest_plugin.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/testing/record.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/testing/registry.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/tool_use.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/tools.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/tracing/__init__.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/tracing/_otlp.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/tracing/columns.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/tracing/langfuse.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/tracing/models.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/tracing/otlp.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/tracing/postgres.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/tracing/store.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/tracing/templates/app.css +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/tracing/templates/sessions.html +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/tracing/templates/ui.html +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/tracing/templates/ui_run.html +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/tracing/tracer.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/tracing/web.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/triggers.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/types.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/verify.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact/viz.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact.egg-info/SOURCES.txt +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact.egg-info/dependency_links.txt +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact.egg-info/entry_points.txt +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact.egg-info/requires.txt +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/reactifact.egg-info/top_level.txt +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/setup.cfg +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_a2a_example.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_a2a_interop.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_absent_consume.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_adaptive.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_agent_tool.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_anthropic_provider.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_artifacts.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_audit.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_authz.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_backbone.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_branching.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_budget.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_cache.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_canonical_ports.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_chat_example.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_chat_memory.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_chat_outbox.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_checkpoint.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_checkpoints_concurrency.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_cli.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_cli_scenario.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_commit_log.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_compaction.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_concurrency.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_consumes_produces.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_context_builder.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_correlated_consume.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_debounce.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_determinism.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_devops.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_devops_online.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_devops_web.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_effects.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_eval.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_eval_dataset.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_eval_judge.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_eval_online.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_eval_trajectory.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_fintech_audit.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_forklab.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_forklab_web.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_friendly_api.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_gemini_provider.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_golden.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_guardrails.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_hitl.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_image_provider.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_incident_commander.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_invalidation.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_join_consume.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_knowledge.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_knowledge_web.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_llm_ladder.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_logging.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_loop_bound_client.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_mcp.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_medic_lab.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_medic_lab_web.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_metrics.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_multisource.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_native_tool_use.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_openai_provider.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_outbox.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_patches.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_pending_actions.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_ports.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_produce_styles.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_produce_trigger.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_prompts.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_provider_auth.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_provider_retry.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_providers_integration.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_public_api.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_quick.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_quota.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_reacts_to.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_recipe_conversation.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_recipe_plan_execute.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_recipe_reflection.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_recipe_supervisor.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_recipe_tool_loop.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_recipes.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_recipes_inputs.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_recipes_memory.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_recipes_skills.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_redaction.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_relation_graph.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_relations.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_repair.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_repair_web.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_replay.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_repo_agent.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_request.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_research.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_resources.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_retry.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_routing.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_runtime.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_runtime_errors.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_scheduler_semantics.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_sessions.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_shutdown.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_skills.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_sources.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_sources_search.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_sources_vector.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_speech_provider.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_starter_app.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_structured.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_support_copilot.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_testing_assertions.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_testing_golden.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_testing_lab.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_testing_mock.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_testing_provenance.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_testing_pytest_plugin.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_testing_registry.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_token_budget.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_tools.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_trace_columns.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_trace_tags.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_tracing.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_type_registry.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_vendor_factories.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_vendor_multimodal_factories.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_verify.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_video_provider.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_view.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_viz.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_web_source.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_workspace.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/tests/test_workspace_with_sources.py +0 -0
- {reactifact-0.14.2 → reactifact-0.15.1}/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.15.1
|
|
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
|
|
@@ -55,7 +55,7 @@ Dynamic: license-file
|
|
|
55
55
|
[](https://github.com/bzdvdn/reactifact)
|
|
56
56
|
[](https://pypi.org/project/reactifact/)
|
|
57
57
|
[](LICENSE)
|
|
58
|
-
[](https://deepwiki.com/bzdvdn/reactifact)
|
|
59
59
|
[](https://bzdvdn.github.io/reactifact/)
|
|
60
60
|
|
|
61
61
|
> **Is:** a Python library (3 core deps) · single-process · typed, versioned artifacts with provenance · deterministic replay · runs offline, no API key
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
[](https://github.com/bzdvdn/reactifact)
|
|
10
10
|
[](https://pypi.org/project/reactifact/)
|
|
11
11
|
[](LICENSE)
|
|
12
|
-
[](https://deepwiki.com/bzdvdn/reactifact)
|
|
13
13
|
[](https://bzdvdn.github.io/reactifact/)
|
|
14
14
|
|
|
15
15
|
> **Is:** a Python library (3 core deps) · single-process · typed, versioned artifacts with provenance · deterministic replay · runs offline, no API key
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "reactifact"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.15.1"
|
|
8
8
|
description = "Reactive, artifact-driven agent runtime: agents transform versioned, typed, provenance-aware artifacts inside an evolving context"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -25,6 +25,8 @@ tasks persists the `Context` with a `Session` itself.
|
|
|
25
25
|
"""
|
|
26
26
|
|
|
27
27
|
import asyncio
|
|
28
|
+
import contextlib
|
|
29
|
+
from collections import OrderedDict
|
|
28
30
|
from collections.abc import AsyncIterator, Callable, Sequence
|
|
29
31
|
from dataclasses import dataclass, field
|
|
30
32
|
from typing import Any
|
|
@@ -59,6 +61,10 @@ CreateMessage = Callable[[Context, str], str]
|
|
|
59
61
|
Reply = Callable[[Context, str], str]
|
|
60
62
|
ContextFactory = Callable[[], Context]
|
|
61
63
|
|
|
64
|
+
#: In-process task store bound. A long-lived A2A server must not grow `_tasks`
|
|
65
|
+
#: without limit; the oldest completed task is evicted first (see `_evict`).
|
|
66
|
+
_MAX_TASKS = 512
|
|
67
|
+
|
|
62
68
|
|
|
63
69
|
def default_create_message(context: Context, text: str) -> str:
|
|
64
70
|
"""Default input hook: seed a `UserMessage` artifact (apps override this)."""
|
|
@@ -106,10 +112,11 @@ class _A2AServer:
|
|
|
106
112
|
self._reply = reply
|
|
107
113
|
self._budget = budget
|
|
108
114
|
self._max_iterations = max_iterations
|
|
109
|
-
self._tasks:
|
|
115
|
+
self._tasks: OrderedDict[str, _TaskRecord] = OrderedDict()
|
|
110
116
|
|
|
111
117
|
def _record_for(self, message: Message) -> _TaskRecord:
|
|
112
118
|
if message.taskId and message.taskId in self._tasks:
|
|
119
|
+
self._tasks.move_to_end(message.taskId)
|
|
113
120
|
return self._tasks[message.taskId]
|
|
114
121
|
record = _TaskRecord(
|
|
115
122
|
id=message.taskId or uuid4().hex,
|
|
@@ -117,8 +124,20 @@ class _A2AServer:
|
|
|
117
124
|
context=self._context_factory(),
|
|
118
125
|
)
|
|
119
126
|
self._tasks[record.id] = record
|
|
127
|
+
self._evict()
|
|
120
128
|
return record
|
|
121
129
|
|
|
130
|
+
def _evict(self) -> None:
|
|
131
|
+
"""Keep `_tasks` bounded: drop the oldest terminal task first."""
|
|
132
|
+
if len(self._tasks) <= _MAX_TASKS:
|
|
133
|
+
return
|
|
134
|
+
for task_id, record in list(self._tasks.items()):
|
|
135
|
+
if record.state in TERMINAL_STATES:
|
|
136
|
+
del self._tasks[task_id]
|
|
137
|
+
break
|
|
138
|
+
else:
|
|
139
|
+
self._tasks.popitem(last=False)
|
|
140
|
+
|
|
122
141
|
async def _execute(
|
|
123
142
|
self,
|
|
124
143
|
record: _TaskRecord,
|
|
@@ -185,14 +204,23 @@ class _A2AServer:
|
|
|
185
204
|
run = asyncio.create_task(
|
|
186
205
|
self._execute(record, params.message.text, on_event=queue.put_nowait)
|
|
187
206
|
)
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
207
|
+
try:
|
|
208
|
+
while not run.done() or not queue.empty():
|
|
209
|
+
try:
|
|
210
|
+
event = await asyncio.wait_for(queue.get(), timeout=0.05)
|
|
211
|
+
except TimeoutError:
|
|
212
|
+
continue
|
|
213
|
+
yield _sse(request_id, _status_update(record, "working", event.message))
|
|
214
|
+
await run
|
|
215
|
+
yield _sse(request_id, self.task(record))
|
|
216
|
+
finally:
|
|
217
|
+
# A dropped SSE client closes this generator mid-run: cancel and
|
|
218
|
+
# await the run, or it keeps executing (tokens!) and is destroyed
|
|
219
|
+
# pending ("Task was destroyed but it is pending!").
|
|
220
|
+
if not run.done():
|
|
221
|
+
run.cancel()
|
|
222
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
223
|
+
await run
|
|
196
224
|
|
|
197
225
|
def get(self, task_id: str) -> Task | None:
|
|
198
226
|
record = self._tasks.get(task_id)
|
|
@@ -26,7 +26,7 @@ from collections.abc import (
|
|
|
26
26
|
Mapping,
|
|
27
27
|
Sequence,
|
|
28
28
|
)
|
|
29
|
-
from contextlib import asynccontextmanager
|
|
29
|
+
from contextlib import asynccontextmanager, suppress
|
|
30
30
|
from dataclasses import dataclass
|
|
31
31
|
from typing import Any, Literal
|
|
32
32
|
|
|
@@ -414,9 +414,16 @@ class ChatAssistant:
|
|
|
414
414
|
self._session_locks.pop(session_id, None)
|
|
415
415
|
|
|
416
416
|
async def _open(self, session_id: str) -> Session:
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
417
|
+
resources = _resolve_with_session(self._resources, session_id)
|
|
418
|
+
try:
|
|
419
|
+
return await self.store.open(session_id, resources=resources)
|
|
420
|
+
except Exception:
|
|
421
|
+
# A callable `resources=` built a fresh per-turn instance; if the
|
|
422
|
+
# store fails to open the session nothing else will close it.
|
|
423
|
+
if callable(self._resources):
|
|
424
|
+
with suppress(Exception):
|
|
425
|
+
await resources.aclose()
|
|
426
|
+
raise
|
|
420
427
|
|
|
421
428
|
def _memory_agents(self) -> list[Agent]:
|
|
422
429
|
"""The bounded-memory agent implied by `memory=`, or none."""
|
|
@@ -497,6 +504,7 @@ class ChatAssistant:
|
|
|
497
504
|
design (see the module docstring).
|
|
498
505
|
"""
|
|
499
506
|
async with self._locked_session(session_id):
|
|
507
|
+
session: Session | None = None
|
|
500
508
|
try:
|
|
501
509
|
session = await self._open(session_id)
|
|
502
510
|
runtime = self._build_runtime(session)
|
|
@@ -504,6 +512,12 @@ class ChatAssistant:
|
|
|
504
512
|
logger.exception(
|
|
505
513
|
"chat.ChatAssistant: failed to open session %r", session_id
|
|
506
514
|
)
|
|
515
|
+
# If the session did open but building the runtime failed, its
|
|
516
|
+
# per-turn resources still need closing (a callable `resources=`
|
|
517
|
+
# is never referenced again).
|
|
518
|
+
if session is not None and callable(self._resources):
|
|
519
|
+
with suppress(Exception):
|
|
520
|
+
await session.context.resources.aclose()
|
|
507
521
|
yield ChatEvent(
|
|
508
522
|
kind="message",
|
|
509
523
|
session_id=session_id,
|
|
@@ -59,6 +59,11 @@ class RunInfo:
|
|
|
59
59
|
_ACTIVE_RUNS: dict[str, RunInfo] = {}
|
|
60
60
|
_RUN_TASKS: dict[str, "asyncio.Task[Any]"] = {}
|
|
61
61
|
|
|
62
|
+
#: How long `astream` waits for a cancelled runner to unwind before giving up
|
|
63
|
+
#: (a well-behaved turn stops immediately; this only guards a misbehaving agent
|
|
64
|
+
#: that swallows `CancelledError`).
|
|
65
|
+
_CANCEL_GRACE_SECONDS = 5.0
|
|
66
|
+
|
|
62
67
|
|
|
63
68
|
def active_runs() -> list[RunInfo]:
|
|
64
69
|
"""Snapshots of the turns currently executing in this process.
|
|
@@ -1031,13 +1036,22 @@ class Runtime:
|
|
|
1031
1036
|
break
|
|
1032
1037
|
get_event = asyncio.ensure_future(queue.get())
|
|
1033
1038
|
wait_done = asyncio.ensure_future(done.wait())
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1039
|
+
try:
|
|
1040
|
+
finished, _ = await asyncio.wait(
|
|
1041
|
+
{get_event, wait_done}, return_when=asyncio.FIRST_COMPLETED
|
|
1042
|
+
)
|
|
1043
|
+
if get_event in finished:
|
|
1044
|
+
yield get_event.result()
|
|
1045
|
+
finally:
|
|
1046
|
+
# Always cancel *and await* the losing future. A task that is
|
|
1047
|
+
# cancelled but never awaited stays pending until the loop
|
|
1048
|
+
# closes, producing "Task was destroyed but it is pending!"
|
|
1049
|
+
# (`Queue.get`/`Event.wait`) on every early generator close
|
|
1050
|
+
# (e.g. a dropped SSE client).
|
|
1051
|
+
for fut in (get_event, wait_done):
|
|
1052
|
+
if not fut.done():
|
|
1053
|
+
fut.cancel()
|
|
1054
|
+
await asyncio.gather(get_event, wait_done, return_exceptions=True)
|
|
1041
1055
|
# Re-raise any agent/runtime exception instead of silently dropping it:
|
|
1042
1056
|
# an error inside a run must reach the caller, not hide in the task.
|
|
1043
1057
|
await task
|
|
@@ -1053,6 +1067,15 @@ class Runtime:
|
|
|
1053
1067
|
)
|
|
1054
1068
|
finally:
|
|
1055
1069
|
task.cancel()
|
|
1070
|
+
# Settle the runner too, so an early generator close (client
|
|
1071
|
+
# disconnect) does not leave the run task pending at loop shutdown.
|
|
1072
|
+
# Bounded: an agent that swallows `CancelledError` must never hang
|
|
1073
|
+
# the caller's disconnect/shutdown path.
|
|
1074
|
+
with contextlib.suppress(Exception):
|
|
1075
|
+
await asyncio.wait_for(
|
|
1076
|
+
asyncio.gather(task, return_exceptions=True),
|
|
1077
|
+
timeout=_CANCEL_GRACE_SECONDS,
|
|
1078
|
+
)
|
|
1056
1079
|
self.context.unsubscribe(queue)
|
|
1057
1080
|
|
|
1058
1081
|
def _apply_patch(self, patch: Patch, commit: Commit) -> list[Write]:
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
import contextlib
|
|
4
5
|
from dataclasses import dataclass, field
|
|
5
6
|
from datetime import UTC, datetime
|
|
6
7
|
from typing import Any
|
|
@@ -30,6 +31,11 @@ class ProgressEvent:
|
|
|
30
31
|
|
|
31
32
|
QueueEvent = asyncio.Queue[ProgressEvent]
|
|
32
33
|
|
|
34
|
+
#: Per-subscriber queue bound. Status events are best-effort UI hints: a
|
|
35
|
+
#: subscriber that never drains must not grow the queue without bound, so the
|
|
36
|
+
#: oldest event is dropped when this is reached.
|
|
37
|
+
_DEFAULT_SUBSCRIBER_QUEUE_SIZE = 1024
|
|
38
|
+
|
|
33
39
|
|
|
34
40
|
class EventHub:
|
|
35
41
|
"""Broadcaster of agent status events into a single- or multi-stream.
|
|
@@ -38,15 +44,16 @@ class EventHub:
|
|
|
38
44
|
runs pay nothing for announce.
|
|
39
45
|
"""
|
|
40
46
|
|
|
41
|
-
def __init__(self) -> None:
|
|
47
|
+
def __init__(self, maxsize: int = _DEFAULT_SUBSCRIBER_QUEUE_SIZE) -> None:
|
|
42
48
|
self._subscribers: set[QueueEvent] = set()
|
|
49
|
+
self._maxsize = maxsize
|
|
43
50
|
|
|
44
51
|
@property
|
|
45
52
|
def has_subscribers(self) -> bool:
|
|
46
53
|
return bool(self._subscribers)
|
|
47
54
|
|
|
48
55
|
def subscribe(self) -> QueueEvent:
|
|
49
|
-
queue: QueueEvent = asyncio.Queue()
|
|
56
|
+
queue: QueueEvent = asyncio.Queue(maxsize=self._maxsize)
|
|
50
57
|
self._subscribers.add(queue)
|
|
51
58
|
return queue
|
|
52
59
|
|
|
@@ -55,4 +62,8 @@ class EventHub:
|
|
|
55
62
|
|
|
56
63
|
def publish(self, event: ProgressEvent) -> None:
|
|
57
64
|
for queue in self._subscribers:
|
|
65
|
+
if queue.full():
|
|
66
|
+
# Drop the oldest status hint rather than block or grow forever.
|
|
67
|
+
with contextlib.suppress(asyncio.QueueEmpty):
|
|
68
|
+
queue.get_nowait()
|
|
58
69
|
queue.put_nowait(event)
|
|
@@ -31,8 +31,9 @@ fastapi installed, and only `create_chat_router` requires the `web` extra —
|
|
|
31
31
|
|
|
32
32
|
from __future__ import annotations
|
|
33
33
|
|
|
34
|
+
import inspect
|
|
34
35
|
import json
|
|
35
|
-
from collections.abc import AsyncIterator, Callable, Mapping, Sequence
|
|
36
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence
|
|
36
37
|
from typing import TYPE_CHECKING, Any
|
|
37
38
|
|
|
38
39
|
from pydantic import BaseModel
|
|
@@ -41,7 +42,7 @@ from ._extras import require_extra
|
|
|
41
42
|
from .chat import ChatAssistant, ChatEvent, ChatEventKind
|
|
42
43
|
|
|
43
44
|
if TYPE_CHECKING:
|
|
44
|
-
from fastapi import APIRouter
|
|
45
|
+
from fastapi import APIRouter, Request
|
|
45
46
|
from fastapi.responses import JSONResponse, StreamingResponse
|
|
46
47
|
|
|
47
48
|
|
|
@@ -83,6 +84,10 @@ def create_chat_router(
|
|
|
83
84
|
forward_kinds: Sequence[str] | None = None,
|
|
84
85
|
payload_shaper: Callable[[ChatEvent], Mapping[str, Any]] | None = None,
|
|
85
86
|
done_event: str | None = None,
|
|
87
|
+
request_factory: Callable[
|
|
88
|
+
[Request], Mapping[str, Any] | Awaitable[Mapping[str, Any]]
|
|
89
|
+
]
|
|
90
|
+
| None = None,
|
|
86
91
|
) -> APIRouter:
|
|
87
92
|
"""Builds the chat router on top of a `ChatAssistant`.
|
|
88
93
|
|
|
@@ -100,6 +105,11 @@ def create_chat_router(
|
|
|
100
105
|
- `payload_shaper(event) -> mapping` — reshape a frame's payload.
|
|
101
106
|
- `done_event` — emit one extra terminal frame (e.g. `"done"`) when the
|
|
102
107
|
stream ends.
|
|
108
|
+
- `request_factory(request) -> mapping` — build the per-turn `request=`
|
|
109
|
+
mapping (the same one `ChatAssistant.stream` takes) from the incoming
|
|
110
|
+
FastAPI `Request`, e.g. the authenticated user for ACL/identity-aware
|
|
111
|
+
runs. May be sync or async (an awaitable result is awaited). Without it
|
|
112
|
+
the turn runs with `request=None`.
|
|
103
113
|
|
|
104
114
|
Defaults reproduce the canonical contract exactly, so existing clients are
|
|
105
115
|
unaffected. The event schema (`ChatEvent`) and the effective names are
|
|
@@ -108,9 +118,15 @@ def create_chat_router(
|
|
|
108
118
|
# Readable error when the `web` extra is missing — then a regular
|
|
109
119
|
# (mypy-visible) import for the real types.
|
|
110
120
|
require_extra("web.create_chat_router", "fastapi", "web")
|
|
111
|
-
from fastapi import APIRouter
|
|
121
|
+
from fastapi import APIRouter, Request
|
|
112
122
|
from fastapi.responses import JSONResponse, StreamingResponse
|
|
113
123
|
|
|
124
|
+
# `Request` is only referenced from the endpoint's *string* annotation
|
|
125
|
+
# (`from __future__ import annotations`); FastAPI resolves those against
|
|
126
|
+
# the module globals, and fastapi may be absent at import time — so publish
|
|
127
|
+
# it lazily here, right before the endpoint that uses it is defined.
|
|
128
|
+
globals().setdefault("Request", Request)
|
|
129
|
+
|
|
114
130
|
names: dict[str, str] = {k: v for k, v in DEFAULT_EVENT_NAMES.items()}
|
|
115
131
|
names.update(event_names or {})
|
|
116
132
|
forward = set(forward_kinds) if forward_kinds is not None else None
|
|
@@ -140,9 +156,15 @@ def create_chat_router(
|
|
|
140
156
|
}
|
|
141
157
|
},
|
|
142
158
|
)
|
|
143
|
-
async def chat_stream(req: ChatMessage) -> StreamingResponse:
|
|
159
|
+
async def chat_stream(req: ChatMessage, request: Request) -> StreamingResponse:
|
|
160
|
+
turn_request = request_factory(request) if request_factory else None
|
|
161
|
+
if inspect.isawaitable(turn_request):
|
|
162
|
+
turn_request = await turn_request
|
|
163
|
+
|
|
144
164
|
async def stream() -> AsyncIterator[str]:
|
|
145
|
-
async for event in assistant.stream(
|
|
165
|
+
async for event in assistant.stream(
|
|
166
|
+
req.message, req.session_id, request=turn_request
|
|
167
|
+
):
|
|
146
168
|
if forward is not None and event.kind not in forward:
|
|
147
169
|
continue
|
|
148
170
|
yield sse(names.get(event.kind, event.kind), dict(shaper(event)))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: reactifact
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.15.1
|
|
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
|
|
@@ -55,7 +55,7 @@ Dynamic: license-file
|
|
|
55
55
|
[](https://github.com/bzdvdn/reactifact)
|
|
56
56
|
[](https://pypi.org/project/reactifact/)
|
|
57
57
|
[](LICENSE)
|
|
58
|
-
[](https://deepwiki.com/bzdvdn/reactifact)
|
|
59
59
|
[](https://bzdvdn.github.io/reactifact/)
|
|
60
60
|
|
|
61
61
|
> **Is:** a Python library (3 core deps) · single-process · typed, versioned artifacts with provenance · deterministic replay · runs offline, no API key
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
+
import contextlib
|
|
6
7
|
|
|
7
8
|
import httpx
|
|
8
9
|
import pytest
|
|
@@ -48,6 +49,14 @@ class Echo(Produce[Answer]):
|
|
|
48
49
|
call.effects.create(Answer(text=call.trigger.data.text.upper()))
|
|
49
50
|
|
|
50
51
|
|
|
52
|
+
class Slow(Produce[Answer]):
|
|
53
|
+
artifact_type = Answer
|
|
54
|
+
|
|
55
|
+
async def produce(self, call):
|
|
56
|
+
await asyncio.sleep(30)
|
|
57
|
+
call.effects.create(Answer(text="slow"))
|
|
58
|
+
|
|
59
|
+
|
|
51
60
|
class AskOrAnswer(Produce[Answer]):
|
|
52
61
|
"""First a PendingQuestion (input-required), then an Answer once resumed."""
|
|
53
62
|
|
|
@@ -154,6 +163,63 @@ def test_unknown_method_errors():
|
|
|
154
163
|
assert body["error"]["code"] == -32601
|
|
155
164
|
|
|
156
165
|
|
|
166
|
+
def test_server_task_store_is_bounded():
|
|
167
|
+
"""A long-lived server must not grow its in-process task store without
|
|
168
|
+
bound: the oldest records are evicted once the cap is reached."""
|
|
169
|
+
from reactifact.a2a.server import (
|
|
170
|
+
_MAX_TASKS,
|
|
171
|
+
_A2AServer,
|
|
172
|
+
default_create_message,
|
|
173
|
+
default_reply,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
server = _A2AServer(
|
|
177
|
+
[_echo_agent()],
|
|
178
|
+
context_factory=Context,
|
|
179
|
+
create_message=default_create_message,
|
|
180
|
+
reply=default_reply,
|
|
181
|
+
budget=None,
|
|
182
|
+
max_iterations=100,
|
|
183
|
+
)
|
|
184
|
+
for _ in range(_MAX_TASKS + 20):
|
|
185
|
+
server._record_for(Message.user("hi"))
|
|
186
|
+
assert len(server._tasks) <= _MAX_TASKS
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def test_server_stream_cancels_run_when_the_client_disconnects():
|
|
190
|
+
"""A dropped SSE client must cancel the in-flight run — otherwise it keeps
|
|
191
|
+
executing and is destroyed pending at loop shutdown ("Task was destroyed
|
|
192
|
+
but it is pending!")."""
|
|
193
|
+
from reactifact.a2a.models import MessageSendParams
|
|
194
|
+
from reactifact.a2a.server import _A2AServer, default_create_message, default_reply
|
|
195
|
+
|
|
196
|
+
slow = create_agent("slow", consumes=[Consume(UserMessage)], produces=[Slow()])
|
|
197
|
+
server = _A2AServer(
|
|
198
|
+
[slow],
|
|
199
|
+
context_factory=Context,
|
|
200
|
+
create_message=default_create_message,
|
|
201
|
+
reply=default_reply,
|
|
202
|
+
budget=None,
|
|
203
|
+
max_iterations=100,
|
|
204
|
+
)
|
|
205
|
+
params = MessageSendParams(message=Message.user("hi"))
|
|
206
|
+
|
|
207
|
+
async def run():
|
|
208
|
+
agen = server.stream("1", params)
|
|
209
|
+
assert (await agen.__anext__()).startswith("data:") # the working frame
|
|
210
|
+
puller = asyncio.create_task(agen.__anext__())
|
|
211
|
+
await asyncio.sleep(0) # enter the wait: the run task is in flight
|
|
212
|
+
puller.cancel()
|
|
213
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
214
|
+
await puller
|
|
215
|
+
await agen.aclose()
|
|
216
|
+
for _ in range(10):
|
|
217
|
+
await asyncio.sleep(0)
|
|
218
|
+
return [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
|
|
219
|
+
|
|
220
|
+
assert asyncio.run(run()) == []
|
|
221
|
+
|
|
222
|
+
|
|
157
223
|
def test_hitl_input_required_then_resume():
|
|
158
224
|
agent = create_agent(
|
|
159
225
|
"asker",
|
|
@@ -157,6 +157,116 @@ def test_web_router_openapi_publishes_event_schema(tmp_path):
|
|
|
157
157
|
assert "content" in responses["200"]["description"]
|
|
158
158
|
|
|
159
159
|
|
|
160
|
+
def test_web_router_request_factory_forwards_per_turn_context(tmp_path):
|
|
161
|
+
"""`request_factory` maps the FastAPI `Request` into the turn's `request=`."""
|
|
162
|
+
seen: dict[str, object] = {}
|
|
163
|
+
|
|
164
|
+
async def capture(call):
|
|
165
|
+
seen["user"] = call.request.get("user")
|
|
166
|
+
call.effects.create(A(text="answer"))
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
prod = produce(A)(capture)
|
|
170
|
+
agent = create_agent("cap", consumes=[Consume(Q)], produces=[prod])
|
|
171
|
+
store = SessionStore(FileKVBackend(os.path.join(tmp_path, "sess")))
|
|
172
|
+
assistant = ChatAssistant(
|
|
173
|
+
store=store,
|
|
174
|
+
agents=[agent],
|
|
175
|
+
user_message=Q,
|
|
176
|
+
reply=lambda ctx, msg_id: {"reply": "ok", "waiting": False},
|
|
177
|
+
max_concurrency=1,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
app = FastAPI()
|
|
181
|
+
app.include_router(
|
|
182
|
+
create_chat_router(
|
|
183
|
+
assistant,
|
|
184
|
+
request_factory=lambda request: {"user": request.headers.get("x-user", "")},
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
client = TestClient(app)
|
|
188
|
+
|
|
189
|
+
with client.stream(
|
|
190
|
+
"POST",
|
|
191
|
+
"/api/chat/stream",
|
|
192
|
+
json={"message": "hi", "session_id": "s1"},
|
|
193
|
+
headers={"X-User": "alice"},
|
|
194
|
+
) as response:
|
|
195
|
+
"".join(response.iter_text())
|
|
196
|
+
|
|
197
|
+
assert seen["user"] == "alice"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_web_router_async_request_factory_is_awaited(tmp_path):
|
|
201
|
+
"""An async `request_factory` must be awaited, not passed as a coroutine."""
|
|
202
|
+
seen: dict[str, object] = {}
|
|
203
|
+
|
|
204
|
+
async def capture(call):
|
|
205
|
+
seen["user"] = call.request.get("user")
|
|
206
|
+
call.effects.create(A(text="answer"))
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
prod = produce(A)(capture)
|
|
210
|
+
agent = create_agent("cap", consumes=[Consume(Q)], produces=[prod])
|
|
211
|
+
store = SessionStore(FileKVBackend(os.path.join(tmp_path, "sess")))
|
|
212
|
+
assistant = ChatAssistant(
|
|
213
|
+
store=store,
|
|
214
|
+
agents=[agent],
|
|
215
|
+
user_message=Q,
|
|
216
|
+
reply=lambda ctx, msg_id: {"reply": "ok", "waiting": False},
|
|
217
|
+
max_concurrency=1,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
async def request_factory(request):
|
|
221
|
+
return {"user": request.headers.get("x-user", "")}
|
|
222
|
+
|
|
223
|
+
app = FastAPI()
|
|
224
|
+
app.include_router(create_chat_router(assistant, request_factory=request_factory))
|
|
225
|
+
client = TestClient(app)
|
|
226
|
+
|
|
227
|
+
with client.stream(
|
|
228
|
+
"POST",
|
|
229
|
+
"/api/chat/stream",
|
|
230
|
+
json={"message": "hi", "session_id": "s1"},
|
|
231
|
+
headers={"X-User": "bob"},
|
|
232
|
+
) as response:
|
|
233
|
+
"".join(response.iter_text())
|
|
234
|
+
|
|
235
|
+
assert seen["user"] == "bob"
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def test_web_router_without_request_factory_has_no_request(tmp_path):
|
|
239
|
+
"""Default stays `request=None`: no behavior change for existing callers."""
|
|
240
|
+
seen: dict[str, object] = {}
|
|
241
|
+
|
|
242
|
+
async def capture(call):
|
|
243
|
+
seen["request"] = dict(call.request)
|
|
244
|
+
call.effects.create(A(text="answer"))
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
prod = produce(A)(capture)
|
|
248
|
+
agent = create_agent("cap", consumes=[Consume(Q)], produces=[prod])
|
|
249
|
+
store = SessionStore(FileKVBackend(os.path.join(tmp_path, "sess")))
|
|
250
|
+
assistant = ChatAssistant(
|
|
251
|
+
store=store,
|
|
252
|
+
agents=[agent],
|
|
253
|
+
user_message=Q,
|
|
254
|
+
reply=lambda ctx, msg_id: {"reply": "ok", "waiting": False},
|
|
255
|
+
max_concurrency=1,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
app = FastAPI()
|
|
259
|
+
app.include_router(create_chat_router(assistant))
|
|
260
|
+
client = TestClient(app)
|
|
261
|
+
|
|
262
|
+
with client.stream(
|
|
263
|
+
"POST", "/api/chat/stream", json={"message": "hi", "session_id": "s1"}
|
|
264
|
+
) as response:
|
|
265
|
+
"".join(response.iter_text())
|
|
266
|
+
|
|
267
|
+
assert seen["request"] == {}
|
|
268
|
+
|
|
269
|
+
|
|
160
270
|
def test_disconnect_cancels_the_running_turn(tmp_path):
|
|
161
271
|
"""A dropped SSE client must cancel in-flight work, not leave it burning.
|
|
162
272
|
|
|
@@ -355,6 +465,42 @@ def test_callable_resources_closed_after_each_turn(tmp_path):
|
|
|
355
465
|
assert all(llm.closed for llm in built)
|
|
356
466
|
|
|
357
467
|
|
|
468
|
+
def test_callable_resources_closed_when_session_open_fails(tmp_path):
|
|
469
|
+
"""A failing session open must not leak the freshly-built per-turn
|
|
470
|
+
``resources=`` instance — ``_open`` closes it before re-raising."""
|
|
471
|
+
from reactifact import RuntimeResources
|
|
472
|
+
|
|
473
|
+
built: list[_SpyLLM] = []
|
|
474
|
+
|
|
475
|
+
def fresh_resources():
|
|
476
|
+
llm = _SpyLLM()
|
|
477
|
+
built.append(llm)
|
|
478
|
+
return RuntimeResources(llm=llm)
|
|
479
|
+
|
|
480
|
+
class BoomStore:
|
|
481
|
+
def open(self, session_id, resources=None):
|
|
482
|
+
raise RuntimeError("backend down")
|
|
483
|
+
|
|
484
|
+
def delete_session(self, session_id):
|
|
485
|
+
pass
|
|
486
|
+
|
|
487
|
+
assistant = ChatAssistant(
|
|
488
|
+
store=BoomStore(),
|
|
489
|
+
agents=[ANSWER_AGENT],
|
|
490
|
+
user_message=Q,
|
|
491
|
+
reply=lambda ctx, mid: {"reply": "never", "waiting": False},
|
|
492
|
+
resources=fresh_resources,
|
|
493
|
+
max_concurrency=1,
|
|
494
|
+
fallback_reply="degraded",
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
events = _drain(assistant.stream("hi", session_id="s6"))
|
|
498
|
+
|
|
499
|
+
assert events[-1].payload["reply"] == "degraded"
|
|
500
|
+
assert len(built) == 1
|
|
501
|
+
assert built[0].closed is True
|
|
502
|
+
|
|
503
|
+
|
|
358
504
|
def test_shared_resources_instance_not_closed(tmp_path):
|
|
359
505
|
"""A plain (non-callable) resources= instance must survive the turn —
|
|
360
506
|
it's shared across future turns/sessions, not closed automatically."""
|