reactifact 0.15.0__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.15.0/reactifact.egg-info → reactifact-0.15.1}/PKG-INFO +1 -1
- {reactifact-0.15.0 → reactifact-0.15.1}/pyproject.toml +1 -1
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/__init__.py +1 -1
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/a2a/server.py +37 -9
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/chat.py +18 -4
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/runtime.py +30 -7
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/streaming.py +13 -2
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/web.py +10 -3
- {reactifact-0.15.0 → reactifact-0.15.1/reactifact.egg-info}/PKG-INFO +1 -1
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_a2a.py +66 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_chat_web.py +74 -0
- reactifact-0.15.1/tests/test_streaming.py +156 -0
- reactifact-0.15.0/tests/test_streaming.py +0 -68
- {reactifact-0.15.0 → reactifact-0.15.1}/LICENSE +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/README.md +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/__main__.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/_extras.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/_httpx.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/a2a/__init__.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/a2a/client.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/a2a/models.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/a2a/remote.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/agent_tool.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/agents.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/artifacts.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/audit.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/authz.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/branching.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/budget.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/cache.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/checkpoints.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/cli/__init__.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/cli/branch.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/cli/common.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/cli/context.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/cli/eval.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/cli/graph.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/cli/replay.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/cli/scenario.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/cli/skills.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/cli/trace.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/commit.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/commit_log.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/consume.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/context.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/context_builder.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/effects.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/eval/__init__.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/eval/dataset.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/eval/evaluators.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/eval/judge.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/eval/online.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/eval/runner.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/eval/scoring.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/events.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/guardrails.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/interrupt.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/llm_agent.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/logging.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/mcp/__init__.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/mcp/client.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/mcp/oauth.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/mcp/server.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/metrics.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/native_tool_use.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/operations.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/patches.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/pricing.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/produce.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/prompts.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/providers/__init__.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/providers/_retry.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/providers/anthropic.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/providers/azure.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/providers/cerebras.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/providers/chat.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/providers/contracts.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/providers/deepseek.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/providers/fake.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/providers/fireworks.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/providers/gemini.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/providers/github_models.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/providers/groq.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/providers/image.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/providers/mistral.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/providers/nvidia.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/providers/ollama.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/providers/openai.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/providers/openrouter.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/providers/perplexity.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/providers/qwen.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/providers/speech.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/providers/together.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/providers/video.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/providers/xai.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/providers/zai.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/py.typed +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/quick/__init__.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/quick/_shared.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/quick/agent.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/quick/chat.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/quick/models.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/quick/rag.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/quick/tools_agent.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/quota.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/recipes/__init__.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/recipes/cleanup.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/recipes/conversation.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/recipes/identity.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/recipes/inputs.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/recipes/memory.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/recipes/plan_execute.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/recipes/reflection.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/recipes/resolve.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/recipes/rollback.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/recipes/search.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/recipes/skills.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/recipes/status.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/recipes/supervisor.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/recipes/text.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/recipes/tool_loop.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/redaction.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/relations.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/replay.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/request.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/resources.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/routing.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/scheduler.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/session.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/skills/reactifact/SKILL.md +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/skills/reactifact/references/effects-and-state.md +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/skills/reactifact/references/trust-and-safety.md +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/skills/reactifact-agents/SKILL.md +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/skills/reactifact-agents/references/consume.md +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/skills/reactifact-agents/references/effects.md +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/skills/reactifact-eval/SKILL.md +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/skills/reactifact-eval/references/online.md +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/skills/reactifact-from-langchain/SKILL.md +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/skills/reactifact-llm/SKILL.md +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/skills/reactifact-llm/references/providers.md +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/skills/reactifact-llm/references/tools.md +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/skills/reactifact-observability/SKILL.md +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/skills/reactifact-rag/SKILL.md +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/skills/reactifact-rag/references/sources.md +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/skills/reactifact-testing/SKILL.md +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/sources.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/structured.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/testing/__init__.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/testing/assertions.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/testing/exceptions.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/testing/fault.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/testing/golden.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/testing/lab.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/testing/mock.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/testing/pytest_plugin.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/testing/record.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/testing/registry.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/tool_use.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/tools.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/tracing/__init__.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/tracing/_otlp.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/tracing/columns.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/tracing/langfuse.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/tracing/models.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/tracing/otlp.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/tracing/postgres.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/tracing/store.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/tracing/templates/app.css +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/tracing/templates/sessions.html +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/tracing/templates/ui.html +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/tracing/templates/ui_run.html +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/tracing/tracer.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/tracing/web.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/triggers.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/types.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/verify.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact/viz.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact.egg-info/SOURCES.txt +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact.egg-info/dependency_links.txt +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact.egg-info/entry_points.txt +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact.egg-info/requires.txt +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/reactifact.egg-info/top_level.txt +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/setup.cfg +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_a2a_example.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_a2a_interop.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_absent_consume.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_adaptive.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_agent_tool.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_anthropic_provider.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_artifacts.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_audit.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_authz.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_backbone.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_branching.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_budget.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_cache.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_canonical_ports.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_chat_example.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_chat_memory.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_chat_outbox.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_checkpoint.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_checkpoints_concurrency.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_cli.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_cli_scenario.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_commit_log.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_compaction.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_concurrency.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_consumes_produces.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_context_builder.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_correlated_consume.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_debounce.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_determinism.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_devops.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_devops_online.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_devops_web.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_effects.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_eval.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_eval_dataset.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_eval_judge.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_eval_online.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_eval_trajectory.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_fintech_audit.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_forklab.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_forklab_web.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_friendly_api.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_gemini_provider.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_golden.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_guardrails.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_hitl.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_image_provider.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_incident_commander.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_invalidation.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_join_consume.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_knowledge.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_knowledge_web.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_llm_ladder.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_logging.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_loop_bound_client.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_mcp.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_medic_lab.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_medic_lab_web.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_metrics.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_multisource.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_native_tool_use.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_openai_provider.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_outbox.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_patches.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_pending_actions.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_ports.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_produce_styles.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_produce_trigger.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_prompts.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_provider_auth.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_provider_retry.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_providers_integration.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_public_api.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_quick.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_quota.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_reacts_to.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_recipe_conversation.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_recipe_plan_execute.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_recipe_reflection.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_recipe_supervisor.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_recipe_tool_loop.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_recipes.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_recipes_inputs.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_recipes_memory.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_recipes_skills.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_redaction.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_relation_graph.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_relations.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_repair.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_repair_web.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_replay.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_repo_agent.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_request.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_research.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_resources.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_retry.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_routing.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_runtime.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_runtime_errors.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_scheduler_semantics.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_sessions.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_shutdown.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_skills.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_sources.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_sources_search.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_sources_vector.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_speech_provider.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_starter_app.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_structured.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_support_copilot.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_testing_assertions.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_testing_golden.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_testing_lab.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_testing_mock.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_testing_provenance.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_testing_pytest_plugin.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_testing_registry.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_token_budget.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_tools.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_trace_columns.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_trace_tags.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_tracing.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_type_registry.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_vendor_factories.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_vendor_multimodal_factories.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_verify.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_video_provider.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_view.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_viz.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_web_source.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_workspace.py +0 -0
- {reactifact-0.15.0 → reactifact-0.15.1}/tests/test_workspace_with_sources.py +0 -0
- {reactifact-0.15.0 → 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.15.
|
|
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
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "reactifact"
|
|
7
|
-
version = "0.15.
|
|
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
|
|
@@ -83,7 +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,
|
|
86
|
-
request_factory: Callable[
|
|
87
|
+
request_factory: Callable[
|
|
88
|
+
[Request], Mapping[str, Any] | Awaitable[Mapping[str, Any]]
|
|
89
|
+
]
|
|
90
|
+
| None = None,
|
|
87
91
|
) -> APIRouter:
|
|
88
92
|
"""Builds the chat router on top of a `ChatAssistant`.
|
|
89
93
|
|
|
@@ -104,7 +108,8 @@ def create_chat_router(
|
|
|
104
108
|
- `request_factory(request) -> mapping` — build the per-turn `request=`
|
|
105
109
|
mapping (the same one `ChatAssistant.stream` takes) from the incoming
|
|
106
110
|
FastAPI `Request`, e.g. the authenticated user for ACL/identity-aware
|
|
107
|
-
runs.
|
|
111
|
+
runs. May be sync or async (an awaitable result is awaited). Without it
|
|
112
|
+
the turn runs with `request=None`.
|
|
108
113
|
|
|
109
114
|
Defaults reproduce the canonical contract exactly, so existing clients are
|
|
110
115
|
unaffected. The event schema (`ChatEvent`) and the effective names are
|
|
@@ -153,6 +158,8 @@ def create_chat_router(
|
|
|
153
158
|
)
|
|
154
159
|
async def chat_stream(req: ChatMessage, request: Request) -> StreamingResponse:
|
|
155
160
|
turn_request = request_factory(request) if request_factory else None
|
|
161
|
+
if inspect.isawaitable(turn_request):
|
|
162
|
+
turn_request = await turn_request
|
|
156
163
|
|
|
157
164
|
async def stream() -> AsyncIterator[str]:
|
|
158
165
|
async for event in assistant.stream(
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: reactifact
|
|
3
|
-
Version: 0.15.
|
|
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
|
|
@@ -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",
|
|
@@ -197,6 +197,44 @@ def test_web_router_request_factory_forwards_per_turn_context(tmp_path):
|
|
|
197
197
|
assert seen["user"] == "alice"
|
|
198
198
|
|
|
199
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
|
+
|
|
200
238
|
def test_web_router_without_request_factory_has_no_request(tmp_path):
|
|
201
239
|
"""Default stays `request=None`: no behavior change for existing callers."""
|
|
202
240
|
seen: dict[str, object] = {}
|
|
@@ -427,6 +465,42 @@ def test_callable_resources_closed_after_each_turn(tmp_path):
|
|
|
427
465
|
assert all(llm.closed for llm in built)
|
|
428
466
|
|
|
429
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
|
+
|
|
430
504
|
def test_shared_resources_instance_not_closed(tmp_path):
|
|
431
505
|
"""A plain (non-callable) resources= instance must survive the turn —
|
|
432
506
|
it's shared across future turns/sessions, not closed automatically."""
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import contextlib
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
from reactifact import Agent, Consume, Context, Patch, Runtime
|
|
6
|
+
from reactifact.streaming import EventHub, ProgressEvent
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class UserMsg(BaseModel):
|
|
10
|
+
text: str
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Done(BaseModel):
|
|
14
|
+
text: str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Announcer(Agent):
|
|
18
|
+
consumes = [Consume(UserMsg)]
|
|
19
|
+
|
|
20
|
+
async def run(self, event, context):
|
|
21
|
+
context.announce("Думаю над вопросом...", kind="status", source="brain")
|
|
22
|
+
context.announce("Найдено 3 соответствия", kind="status")
|
|
23
|
+
return Patch().create(Done(text="ok"))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Slow(Agent):
|
|
27
|
+
consumes = [Consume(UserMsg)]
|
|
28
|
+
|
|
29
|
+
async def run(self, event, context):
|
|
30
|
+
await asyncio.sleep(30) # stays running while the consumer goes away
|
|
31
|
+
return Patch().create(Done(text="ok"))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Stubborn(Agent):
|
|
35
|
+
consumes = [Consume(UserMsg)]
|
|
36
|
+
|
|
37
|
+
async def run(self, event, context):
|
|
38
|
+
try:
|
|
39
|
+
await asyncio.sleep(30)
|
|
40
|
+
except asyncio.CancelledError:
|
|
41
|
+
await asyncio.sleep(0.5) # ignores the cancel for a bit
|
|
42
|
+
return Patch().create(Done(text="ok"))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def collect_events(runtime):
|
|
46
|
+
return [ev async for ev in runtime.astream()]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_astream_yields_status_and_run_end():
|
|
50
|
+
ctx = Context()
|
|
51
|
+
runtime = Runtime(ctx, agents=[Announcer()])
|
|
52
|
+
ctx.create(UserMsg(text="привет"))
|
|
53
|
+
|
|
54
|
+
events = asyncio.run(collect_events(runtime))
|
|
55
|
+
|
|
56
|
+
assert events[0].kind == "run_start"
|
|
57
|
+
assert events[-1].kind == "run_end"
|
|
58
|
+
statuses = [e for e in events if e.kind == "status"]
|
|
59
|
+
assert [e.message for e in statuses] == [
|
|
60
|
+
"Думаю над вопросом...",
|
|
61
|
+
"Найдено 3 соответствия",
|
|
62
|
+
]
|
|
63
|
+
assert statuses[0].data["source"] == "brain"
|
|
64
|
+
assert events[-1].data["runs"] >= 1
|
|
65
|
+
assert events[-1].data["outcome"] in {"completed", "budget_runs_exceeded"}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_announce_is_noop_without_subscribers():
|
|
69
|
+
ctx = Context()
|
|
70
|
+
ctx.announce("никто не слушает", kind="status") # must not raise
|
|
71
|
+
assert isinstance(ctx._hub, EventHub)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_astream_leaves_no_pending_tasks_after_consuming():
|
|
75
|
+
"""Regression: the runner used to leave a cancelled-but-unawaited
|
|
76
|
+
``queue.get()``/``done.wait()`` future pending, which surfaced as
|
|
77
|
+
"Task was destroyed but it is pending!" when the event loop closed."""
|
|
78
|
+
|
|
79
|
+
async def run():
|
|
80
|
+
ctx = Context()
|
|
81
|
+
runtime = Runtime(ctx, agents=[Announcer()])
|
|
82
|
+
ctx.create(UserMsg(text="привет"))
|
|
83
|
+
async for _ in runtime.astream():
|
|
84
|
+
pass
|
|
85
|
+
await asyncio.sleep(0)
|
|
86
|
+
return [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
|
|
87
|
+
|
|
88
|
+
assert asyncio.run(run()) == []
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_astream_close_while_waiting_leaves_no_pending_tasks():
|
|
92
|
+
"""A dropped consumer closed while the stream is awaiting
|
|
93
|
+
``queue.get()``/``done.wait()`` must not leave those futures pending."""
|
|
94
|
+
|
|
95
|
+
async def run():
|
|
96
|
+
ctx = Context()
|
|
97
|
+
runtime = Runtime(ctx, agents=[Slow()])
|
|
98
|
+
ctx.create(UserMsg(text="привет"))
|
|
99
|
+
agen = runtime.astream()
|
|
100
|
+
assert (await agen.__anext__()).kind == "run_start"
|
|
101
|
+
|
|
102
|
+
puller = asyncio.create_task(agen.__anext__())
|
|
103
|
+
await asyncio.sleep(0) # enter the wait: queue.get()/done.wait() created
|
|
104
|
+
puller.cancel()
|
|
105
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
106
|
+
await puller
|
|
107
|
+
await agen.aclose()
|
|
108
|
+
for _ in range(10):
|
|
109
|
+
await asyncio.sleep(0)
|
|
110
|
+
return [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
|
|
111
|
+
|
|
112
|
+
assert asyncio.run(run()) == []
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_eventhub_multiple_subscribers():
|
|
116
|
+
hub = EventHub()
|
|
117
|
+
q1 = hub.subscribe()
|
|
118
|
+
q2 = hub.subscribe()
|
|
119
|
+
|
|
120
|
+
hub.publish(ProgressEvent(kind="status", message="x"))
|
|
121
|
+
ev1 = q1.get_nowait()
|
|
122
|
+
ev2 = q2.get_nowait()
|
|
123
|
+
assert ev1.message == ev2.message == "x"
|
|
124
|
+
|
|
125
|
+
hub.unsubscribe(q1)
|
|
126
|
+
hub.unsubscribe(q2)
|
|
127
|
+
assert not hub.has_subscribers
|
|
128
|
+
hub.publish(ProgressEvent(kind="status", message="y"))
|
|
129
|
+
assert q2.empty()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_eventhub_bounds_a_stuck_subscriber_by_dropping_oldest():
|
|
133
|
+
hub = EventHub(maxsize=3)
|
|
134
|
+
q = hub.subscribe()
|
|
135
|
+
for i in range(5):
|
|
136
|
+
hub.publish(ProgressEvent(kind="status", message=str(i)))
|
|
137
|
+
assert q.qsize() == 3
|
|
138
|
+
assert [q.get_nowait().message for _ in range(3)] == ["2", "3", "4"]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_astream_close_is_bounded_when_runner_ignores_cancel(monkeypatch):
|
|
142
|
+
"""A misbehaving agent that swallows `CancelledError` must not hang the
|
|
143
|
+
caller's disconnect path: `astream` gives up after a bounded grace."""
|
|
144
|
+
monkeypatch.setattr("reactifact.runtime._CANCEL_GRACE_SECONDS", 0.05)
|
|
145
|
+
|
|
146
|
+
async def run():
|
|
147
|
+
ctx = Context()
|
|
148
|
+
runtime = Runtime(ctx, agents=[Stubborn()])
|
|
149
|
+
ctx.create(UserMsg(text="привет"))
|
|
150
|
+
agen = runtime.astream()
|
|
151
|
+
assert (await agen.__anext__()).kind == "run_start"
|
|
152
|
+
started = asyncio.get_running_loop().time()
|
|
153
|
+
await agen.aclose()
|
|
154
|
+
return asyncio.get_running_loop().time() - started
|
|
155
|
+
|
|
156
|
+
assert asyncio.run(run()) < 0.4
|