reactifact 0.11.1__tar.gz → 0.12.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {reactifact-0.11.1/reactifact.egg-info → reactifact-0.12.0}/PKG-INFO +1 -1
- {reactifact-0.11.1 → reactifact-0.12.0}/pyproject.toml +1 -1
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/__init__.py +1 -1
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/checkpoints.py +56 -27
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/resources.py +6 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/testing/__init__.py +2 -1
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/testing/assertions.py +9 -1
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/testing/lab.py +106 -1
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/tracing/__init__.py +2 -0
- reactifact-0.12.0/reactifact/tracing/columns.py +169 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/tracing/postgres.py +124 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/tracing/store.py +120 -1
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/tracing/templates/app.css +22 -5
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/tracing/templates/ui.html +48 -4
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/tracing/templates/ui_run.html +13 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/tracing/tracer.py +9 -4
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/tracing/web.py +17 -1
- {reactifact-0.11.1 → reactifact-0.12.0/reactifact.egg-info}/PKG-INFO +1 -1
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact.egg-info/SOURCES.txt +2 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_checkpoint.py +43 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_devops_web.py +54 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_repair_web.py +26 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_testing_lab.py +91 -0
- reactifact-0.12.0/tests/test_trace_columns.py +388 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/LICENSE +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/README.md +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/__main__.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/_extras.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/_httpx.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/agent_tool.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/agents.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/artifacts.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/audit.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/branching.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/budget.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/chat.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/cli/__init__.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/cli/branch.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/cli/common.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/cli/context.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/cli/graph.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/cli/replay.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/cli/scenario.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/cli/trace.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/commit.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/commit_log.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/consume.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/context.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/context_builder.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/effects.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/eval.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/events.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/interrupt.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/llm_agent.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/mcp/__init__.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/mcp/client.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/mcp/oauth.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/mcp/server.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/native_tool_use.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/operations.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/patches.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/produce.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/prompts.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/providers/__init__.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/providers/_retry.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/providers/anthropic.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/providers/azure.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/providers/cerebras.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/providers/chat.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/providers/contracts.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/providers/deepseek.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/providers/fake.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/providers/fireworks.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/providers/gemini.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/providers/github_models.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/providers/groq.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/providers/image.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/providers/mistral.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/providers/nvidia.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/providers/ollama.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/providers/openai.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/providers/openrouter.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/providers/perplexity.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/providers/qwen.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/providers/speech.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/providers/together.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/providers/video.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/providers/xai.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/providers/zai.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/py.typed +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/quick/__init__.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/quick/_shared.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/quick/agent.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/quick/chat.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/quick/models.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/quick/rag.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/quick/tools_agent.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/recipes/__init__.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/recipes/cleanup.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/recipes/identity.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/recipes/inputs.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/recipes/memory.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/recipes/plan_execute.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/recipes/reflection.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/recipes/resolve.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/recipes/rollback.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/recipes/search.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/recipes/skills.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/recipes/status.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/recipes/supervisor.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/recipes/text.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/recipes/tool_loop.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/redaction.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/relations.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/replay.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/request.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/runtime.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/scheduler.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/session.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/sources.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/streaming.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/structured.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/testing/exceptions.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/testing/fault.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/testing/golden.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/testing/mock.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/testing/record.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/testing/registry.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/tool_use.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/tools.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/tracing/_otlp.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/tracing/langfuse.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/tracing/models.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/tracing/otlp.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/tracing/templates/sessions.html +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/triggers.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/verify.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/viz.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact/web.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact.egg-info/dependency_links.txt +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact.egg-info/entry_points.txt +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact.egg-info/requires.txt +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/reactifact.egg-info/top_level.txt +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/setup.cfg +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_absent_consume.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_adaptive.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_agent_tool.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_anthropic_provider.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_artifacts.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_audit.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_backbone.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_branching.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_budget.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_canonical_ports.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_chat_web.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_checkpoints_concurrency.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_cli.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_cli_scenario.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_commit_log.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_concurrency.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_consumes_produces.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_context_builder.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_correlated_consume.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_debounce.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_determinism.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_devops.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_effects.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_eval.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_fintech_audit.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_forklab.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_forklab_web.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_friendly_api.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_gemini_provider.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_golden.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_hitl.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_image_provider.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_incident_commander.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_invalidation.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_join_consume.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_knowledge.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_knowledge_web.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_llm_ladder.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_loop_bound_client.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_mcp.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_medic_lab.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_medic_lab_web.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_multisource.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_native_tool_use.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_openai_provider.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_patches.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_ports.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_produce_styles.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_produce_trigger.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_prompts.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_provider_auth.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_provider_retry.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_providers_integration.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_quick.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_reacts_to.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_recipe_plan_execute.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_recipe_reflection.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_recipe_supervisor.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_recipe_tool_loop.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_recipes.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_recipes_inputs.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_recipes_memory.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_recipes_skills.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_redaction.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_relation_graph.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_relations.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_repair.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_replay.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_repo_agent.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_request.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_research.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_resources.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_retry.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_runtime.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_runtime_errors.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_scheduler_semantics.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_sessions.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_sources.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_sources_search.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_sources_vector.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_speech_provider.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_starter_app.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_streaming.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_structured.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_support_copilot.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_testing_assertions.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_testing_mock.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_testing_registry.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_tools.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_trace_tags.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_tracing.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_vendor_factories.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_vendor_multimodal_factories.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_verify.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_video_provider.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_view.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_viz.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_web_source.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_workspace.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/test_workspace_with_sources.py +0 -0
- {reactifact-0.11.1 → reactifact-0.12.0}/tests/tests_checkpoints_sqlite.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: reactifact
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.12.0
|
|
4
4
|
Summary: Reactive, artifact-driven agent runtime: agents transform versioned, typed, provenance-aware artifacts inside an evolving context
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/bzdvdn/reactifact
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "reactifact"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.12.0"
|
|
8
8
|
description = "Reactive, artifact-driven agent runtime: agents transform versioned, typed, provenance-aware artifacts inside an evolving context"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
import contextlib
|
|
4
5
|
import copy
|
|
5
6
|
import json
|
|
6
7
|
import sqlite3
|
|
@@ -301,6 +302,12 @@ class PostgreSQLKVBackend(KVBackend):
|
|
|
301
302
|
enough for session-checkpoint traffic (small, infrequent writes); reach for
|
|
302
303
|
`psycopg_pool.AsyncConnectionPool` yourself if you need many concurrent
|
|
303
304
|
writers sharing one DSN.
|
|
305
|
+
|
|
306
|
+
The connection **self-heals**: a query cancelled mid-flight (a client that
|
|
307
|
+
drops a streaming turn) or any protocol error leaves the shared libpq
|
|
308
|
+
connection busy/aborted, and a single poisoned connection would otherwise
|
|
309
|
+
fail every later session read/write. Each statement reconnects and retries
|
|
310
|
+
once, so one bad query cannot wedge the whole store.
|
|
304
311
|
"""
|
|
305
312
|
|
|
306
313
|
def __init__(self, dsn: str):
|
|
@@ -324,42 +331,64 @@ class PostgreSQLKVBackend(KVBackend):
|
|
|
324
331
|
self._conn = conn
|
|
325
332
|
return conn
|
|
326
333
|
|
|
327
|
-
async def
|
|
334
|
+
async def _reset(self) -> None:
|
|
335
|
+
"""Drops the cached connection so the next statement reconnects.
|
|
336
|
+
|
|
337
|
+
Called after any error: a connection that raised is never trusted again
|
|
338
|
+
(it may be mid-command or in an aborted transaction).
|
|
339
|
+
"""
|
|
340
|
+
conn, self._conn = self._conn, None
|
|
341
|
+
if conn is not None and not conn.closed:
|
|
342
|
+
with contextlib.suppress(Exception):
|
|
343
|
+
await conn.close()
|
|
344
|
+
|
|
345
|
+
async def _run(
|
|
346
|
+
self,
|
|
347
|
+
sql: str,
|
|
348
|
+
params: tuple[Any, ...] = (),
|
|
349
|
+
*,
|
|
350
|
+
fetch: str | None = None,
|
|
351
|
+
) -> Any:
|
|
352
|
+
"""Runs one statement under the lock, reconnecting once on failure.
|
|
353
|
+
|
|
354
|
+
All `kv_entries` statements are idempotent (upsert/delete/select), so a
|
|
355
|
+
retry after a dropped connection is safe.
|
|
356
|
+
"""
|
|
328
357
|
async with self._lock:
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
358
|
+
for attempt in (0, 1):
|
|
359
|
+
try:
|
|
360
|
+
conn = await self._connection()
|
|
361
|
+
async with conn.cursor() as cur:
|
|
362
|
+
await cur.execute(sql, params)
|
|
363
|
+
result = await getattr(cur, fetch)() if fetch else None
|
|
364
|
+
await conn.commit()
|
|
365
|
+
return result
|
|
366
|
+
except self._psycopg.Error:
|
|
367
|
+
await self._reset()
|
|
368
|
+
if attempt:
|
|
369
|
+
raise
|
|
370
|
+
raise AssertionError("unreachable") # pragma: no cover
|
|
371
|
+
|
|
372
|
+
async def set(self, key: str, data: dict[str, Any]) -> None:
|
|
373
|
+
await self._run(
|
|
374
|
+
"INSERT INTO kv_entries (key, data) VALUES (%s, %s) "
|
|
375
|
+
"ON CONFLICT (key) DO UPDATE SET data = EXCLUDED.data",
|
|
376
|
+
(key, json.dumps(data)),
|
|
377
|
+
)
|
|
337
378
|
|
|
338
379
|
async def get(self, key: str) -> dict[str, Any] | None:
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
await cur.execute("SELECT data FROM kv_entries WHERE key = %s", (key,))
|
|
343
|
-
row = await cur.fetchone()
|
|
380
|
+
row = await self._run(
|
|
381
|
+
"SELECT data FROM kv_entries WHERE key = %s", (key,), fetch="fetchone"
|
|
382
|
+
)
|
|
344
383
|
return cast(dict[str, Any], json.loads(row[0])) if row is not None else None
|
|
345
384
|
|
|
346
385
|
async def delete(self, key: str) -> None:
|
|
347
|
-
|
|
348
|
-
conn = await self._connection()
|
|
349
|
-
async with conn.cursor() as cur:
|
|
350
|
-
await cur.execute("DELETE FROM kv_entries WHERE key = %s", (key,))
|
|
351
|
-
await conn.commit()
|
|
386
|
+
await self._run("DELETE FROM kv_entries WHERE key = %s", (key,))
|
|
352
387
|
|
|
353
388
|
async def keys(self) -> list[str]:
|
|
354
|
-
|
|
355
|
-
conn = await self._connection()
|
|
356
|
-
async with conn.cursor() as cur:
|
|
357
|
-
await cur.execute("SELECT key FROM kv_entries")
|
|
358
|
-
rows = await cur.fetchall()
|
|
389
|
+
rows = await self._run("SELECT key FROM kv_entries", fetch="fetchall")
|
|
359
390
|
return [cast(str, r[0]) for r in rows]
|
|
360
391
|
|
|
361
392
|
async def aclose(self) -> None:
|
|
362
393
|
async with self._lock:
|
|
363
|
-
|
|
364
|
-
await self._conn.close()
|
|
365
|
-
self._conn = None
|
|
394
|
+
await self._reset()
|
|
@@ -51,6 +51,7 @@ class RuntimeResources:
|
|
|
51
51
|
context_builder: ContextBuilder | None = None,
|
|
52
52
|
verification_threshold: float | None = None,
|
|
53
53
|
redactor: Redactor | None = None,
|
|
54
|
+
trace_truncate: int | None = 1500,
|
|
54
55
|
id_factory: IdFactory | None = None,
|
|
55
56
|
**additional: Any,
|
|
56
57
|
):
|
|
@@ -67,6 +68,11 @@ class RuntimeResources:
|
|
|
67
68
|
# persisted session. `None` (default) reproduces the pre-hook behavior.
|
|
68
69
|
# See `reactifact.redaction`.
|
|
69
70
|
self.redactor = redactor
|
|
71
|
+
# How many characters of an artifact's JSON (and an LLM response) are
|
|
72
|
+
# kept in a trace. `None` disables truncation. Larger values make
|
|
73
|
+
# configurable table columns / deep fields resolvable, at the cost of
|
|
74
|
+
# trace size.
|
|
75
|
+
self.trace_truncate = trace_truncate
|
|
70
76
|
# Framework-wide pass/fail cutoff for `Verify` (verify.py): `None`
|
|
71
77
|
# means "use Verify's own DEFAULT_THRESHOLD". A `Verify` instance's
|
|
72
78
|
# own explicit `threshold=` still overrides this per agent.
|
|
@@ -20,7 +20,7 @@ from .assertions import (
|
|
|
20
20
|
from .exceptions import AssertionFailure, ScenarioError, ScenarioSkip
|
|
21
21
|
from .fault import ToolCallRecord, ToolCallRecorder, ToolFault
|
|
22
22
|
from .golden import GoldenRun, assert_golden, capture, prompt_hashes, replay_resources
|
|
23
|
-
from .lab import Scenario, ScenarioLab, ScenarioResult
|
|
23
|
+
from .lab import Scenario, ScenarioLab, ScenarioReport, ScenarioResult
|
|
24
24
|
from .mock import ResourceFault
|
|
25
25
|
from .record import Mode, mode_from_env
|
|
26
26
|
from .registry import ScenarioCase, collect, scenario
|
|
@@ -38,6 +38,7 @@ __all__ = [
|
|
|
38
38
|
"ScenarioCase",
|
|
39
39
|
"ScenarioError",
|
|
40
40
|
"ScenarioLab",
|
|
41
|
+
"ScenarioReport",
|
|
41
42
|
"ScenarioResult",
|
|
42
43
|
"ScenarioSkip",
|
|
43
44
|
"ToolAssertions",
|
|
@@ -272,9 +272,17 @@ class LLMAssertions:
|
|
|
272
272
|
def calls(self) -> int:
|
|
273
273
|
return len(self._calls())
|
|
274
274
|
|
|
275
|
+
@property
|
|
276
|
+
def prompt_tokens(self) -> int:
|
|
277
|
+
return sum(c.prompt_tokens for c in self._calls())
|
|
278
|
+
|
|
279
|
+
@property
|
|
280
|
+
def completion_tokens(self) -> int:
|
|
281
|
+
return sum(c.completion_tokens for c in self._calls())
|
|
282
|
+
|
|
275
283
|
@property
|
|
276
284
|
def tokens(self) -> int:
|
|
277
|
-
return
|
|
285
|
+
return self.prompt_tokens + self.completion_tokens
|
|
278
286
|
|
|
279
287
|
def max_calls(self, n: int) -> None:
|
|
280
288
|
if self.calls > n:
|
|
@@ -24,7 +24,7 @@ from __future__ import annotations
|
|
|
24
24
|
|
|
25
25
|
import asyncio
|
|
26
26
|
from collections.abc import Callable, Sequence
|
|
27
|
-
from dataclasses import dataclass
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
28
|
from pathlib import Path
|
|
29
29
|
from typing import Any, TypeVar
|
|
30
30
|
|
|
@@ -81,6 +81,97 @@ class _CapturingTracer(Tracer):
|
|
|
81
81
|
self.trace = trace
|
|
82
82
|
|
|
83
83
|
|
|
84
|
+
@dataclass
|
|
85
|
+
class ScenarioReport:
|
|
86
|
+
"""Aggregate cost/shape of a scenario run: what it took, in one object.
|
|
87
|
+
|
|
88
|
+
The reporting counterpart of the assertion groups — token totals, LLM/tool
|
|
89
|
+
counts, the agents that ran and the outcome, with `to_dict()` for a CI/log
|
|
90
|
+
artifact and `render()` for a readable multi-line summary. A single
|
|
91
|
+
`ScenarioResult.report` covers one turn; `Scenario.report` aggregates every
|
|
92
|
+
turn run so far.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
turns: int = 0
|
|
96
|
+
spans: int = 0
|
|
97
|
+
agents: list[str] = field(default_factory=list)
|
|
98
|
+
tool_calls: int = 0
|
|
99
|
+
llm_calls: int = 0
|
|
100
|
+
prompt_tokens: int = 0
|
|
101
|
+
completion_tokens: int = 0
|
|
102
|
+
duration_ms: float = 0.0
|
|
103
|
+
errors: int = 0
|
|
104
|
+
outcomes: list[str] = field(default_factory=list)
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def total_tokens(self) -> int:
|
|
108
|
+
return self.prompt_tokens + self.completion_tokens
|
|
109
|
+
|
|
110
|
+
def to_dict(self) -> dict[str, Any]:
|
|
111
|
+
return {
|
|
112
|
+
"turns": self.turns,
|
|
113
|
+
"spans": self.spans,
|
|
114
|
+
"agents": list(self.agents),
|
|
115
|
+
"tool_calls": self.tool_calls,
|
|
116
|
+
"llm_calls": self.llm_calls,
|
|
117
|
+
"prompt_tokens": self.prompt_tokens,
|
|
118
|
+
"completion_tokens": self.completion_tokens,
|
|
119
|
+
"total_tokens": self.total_tokens,
|
|
120
|
+
"duration_ms": self.duration_ms,
|
|
121
|
+
"errors": self.errors,
|
|
122
|
+
"outcomes": list(self.outcomes),
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
def render(self) -> str:
|
|
126
|
+
rows = [
|
|
127
|
+
("turns", str(self.turns)),
|
|
128
|
+
("agents", ", ".join(self.agents) or "—"),
|
|
129
|
+
("spans", str(self.spans)),
|
|
130
|
+
("tool calls", str(self.tool_calls)),
|
|
131
|
+
("llm calls", str(self.llm_calls)),
|
|
132
|
+
(
|
|
133
|
+
"tokens",
|
|
134
|
+
f"{self.prompt_tokens} in / {self.completion_tokens} out"
|
|
135
|
+
f" / {self.total_tokens} total",
|
|
136
|
+
),
|
|
137
|
+
("duration", f"{self.duration_ms:.1f} ms"),
|
|
138
|
+
("errors", str(self.errors)),
|
|
139
|
+
("outcomes", ", ".join(self.outcomes) or "—"),
|
|
140
|
+
]
|
|
141
|
+
width = max(len(key) for key, _ in rows)
|
|
142
|
+
return "\n".join(
|
|
143
|
+
["scenario report"] + [f" {key:<{width}} {value}" for key, value in rows]
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _scenario_report(
|
|
148
|
+
traces: list[RunTrace],
|
|
149
|
+
calls: list[ToolCallRecord],
|
|
150
|
+
*,
|
|
151
|
+
turns: int | None = None,
|
|
152
|
+
errors: int | None = None,
|
|
153
|
+
) -> ScenarioReport:
|
|
154
|
+
"""Builds a `ScenarioReport` off one or more run traces + recorded calls."""
|
|
155
|
+
spans = [span for trace in traces for span in trace.spans]
|
|
156
|
+
llm = [call for trace in traces for call in trace.llm_calls]
|
|
157
|
+
outcomes: list[str] = []
|
|
158
|
+
for trace in traces:
|
|
159
|
+
if trace.outcome and trace.outcome not in outcomes:
|
|
160
|
+
outcomes.append(trace.outcome)
|
|
161
|
+
return ScenarioReport(
|
|
162
|
+
turns=len(traces) if turns is None else turns,
|
|
163
|
+
spans=len(spans),
|
|
164
|
+
agents=sorted({span.agent for span in spans}),
|
|
165
|
+
tool_calls=len(calls),
|
|
166
|
+
llm_calls=len(llm),
|
|
167
|
+
prompt_tokens=sum(call.prompt_tokens for call in llm),
|
|
168
|
+
completion_tokens=sum(call.completion_tokens for call in llm),
|
|
169
|
+
duration_ms=round(sum(trace.duration_ms for trace in traces), 1),
|
|
170
|
+
errors=(sum(1 for span in spans if span.error) if errors is None else errors),
|
|
171
|
+
outcomes=outcomes,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
84
175
|
@dataclass
|
|
85
176
|
class ScenarioResult:
|
|
86
177
|
"""Everything a scenario assertion needs, read off one `ScenarioLab.run()`.
|
|
@@ -98,6 +189,15 @@ class ScenarioResult:
|
|
|
98
189
|
def artifacts(self, artifact_type: type[T]) -> ArtifactAssertions[T]:
|
|
99
190
|
return ArtifactAssertions(self.context, artifact_type)
|
|
100
191
|
|
|
192
|
+
@property
|
|
193
|
+
def report(self) -> ScenarioReport:
|
|
194
|
+
"""Token/latency/tool totals for this one turn (see `ScenarioReport`)."""
|
|
195
|
+
traces = [self.trace] if self.trace is not None else []
|
|
196
|
+
errors = None
|
|
197
|
+
if not traces and self.stats is not None:
|
|
198
|
+
errors = self.stats.errors
|
|
199
|
+
return _scenario_report(traces, self.calls, turns=1, errors=errors)
|
|
200
|
+
|
|
101
201
|
@property
|
|
102
202
|
def tools(self) -> ToolAssertions:
|
|
103
203
|
return ToolAssertions(self.calls)
|
|
@@ -389,3 +489,8 @@ class Scenario:
|
|
|
389
489
|
def errors(self) -> ErrorAssertions:
|
|
390
490
|
"""Isolated agent errors across every turn run so far."""
|
|
391
491
|
return ErrorAssertions(_combined_trace(self.all_traces), None)
|
|
492
|
+
|
|
493
|
+
@property
|
|
494
|
+
def report(self) -> ScenarioReport:
|
|
495
|
+
"""Aggregate `ScenarioReport` across every turn run so far."""
|
|
496
|
+
return _scenario_report(self.all_traces, self.all_calls)
|
|
@@ -5,6 +5,7 @@ Package: models (`models`), delivery (`tracer`), storage (`store`), UI
|
|
|
5
5
|
but without `web`, so that importing the package does not pull in FastAPI.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
+
from .columns import TraceColumn
|
|
8
9
|
from .langfuse import LangfuseTracer
|
|
9
10
|
from .models import (
|
|
10
11
|
AgentSpan,
|
|
@@ -41,6 +42,7 @@ __all__ = [
|
|
|
41
42
|
"Tag",
|
|
42
43
|
"TagAssignment",
|
|
43
44
|
"TraceAnnotator",
|
|
45
|
+
"TraceColumn",
|
|
44
46
|
"TraceReader",
|
|
45
47
|
"TraceSink",
|
|
46
48
|
"TraceStore",
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Configurable trace-table columns: pull a field out of a run's artifacts.
|
|
2
|
+
|
|
3
|
+
A `TraceColumn` names a column for the traces table and where its value comes
|
|
4
|
+
from: which agent's span, which artifact type, reads or writes, and a dotted
|
|
5
|
+
path into the artifact's data. A list step takes its first element unless an
|
|
6
|
+
explicit index is given, so `sources.title` means `sources[0].title`.
|
|
7
|
+
|
|
8
|
+
TraceColumn(label="Q", agent="start", type=Question, field="query")
|
|
9
|
+
TraceColumn(label="Answer", agent="normalize",
|
|
10
|
+
type=FinalResponse, field="answer")
|
|
11
|
+
TraceColumn(label="Top source", agent="search", type=Evidence,
|
|
12
|
+
field="sources.title")
|
|
13
|
+
|
|
14
|
+
Extraction runs over the artifact JSON persisted with each span. That JSON is
|
|
15
|
+
clipped by `RuntimeResources.trace_truncate` (default 1500 chars); a value cut
|
|
16
|
+
off mid-JSON cannot be parsed and falls back to the column's `default`.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
from collections.abc import Sequence
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from dataclasses import field as dataclass_field
|
|
25
|
+
from typing import Any, Literal
|
|
26
|
+
|
|
27
|
+
from .models import ArtifactRef
|
|
28
|
+
|
|
29
|
+
#: Which side of a span to read the artifact from.
|
|
30
|
+
Direction = Literal["read", "write", "any"]
|
|
31
|
+
|
|
32
|
+
#: How far to look for the artifact: this run alone, or its whole session
|
|
33
|
+
#: (useful for a chat, where the question lives in the first run and the final
|
|
34
|
+
#: answer in a later one — every row then shows the session's Q/A).
|
|
35
|
+
Scope = Literal["run", "session"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class TraceColumn:
|
|
40
|
+
"""One configurable column in the traces table.
|
|
41
|
+
|
|
42
|
+
`agent` / `type` narrow which artifacts are considered (both optional);
|
|
43
|
+
`direction` picks reads, writes or either; `index` selects among the
|
|
44
|
+
matches (default: the last one); `field` is a dotted path into that
|
|
45
|
+
artifact's JSON data (empty = the whole object). `scope="session"` looks
|
|
46
|
+
across every run of the row's session (chronological) instead of just this
|
|
47
|
+
run — so a chat's question (`index=0`) and final answer (`index=-1`) show on
|
|
48
|
+
every row. `default` is shown when nothing matches or the data is
|
|
49
|
+
unparseable.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
label: str
|
|
53
|
+
field: str
|
|
54
|
+
agent: str | None = None
|
|
55
|
+
type: str | type | None = None
|
|
56
|
+
direction: Direction = "any"
|
|
57
|
+
index: int = -1
|
|
58
|
+
scope: Scope = "run"
|
|
59
|
+
default: str = "—"
|
|
60
|
+
|
|
61
|
+
def type_name(self) -> str | None:
|
|
62
|
+
if self.type is None:
|
|
63
|
+
return None
|
|
64
|
+
return getattr(self.type, "__name__", str(self.type))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class SpanArtifacts:
|
|
69
|
+
"""The minimum a column needs off a span: its agent + read/write refs."""
|
|
70
|
+
|
|
71
|
+
agent: str
|
|
72
|
+
reads: list[ArtifactRef] = dataclass_field(default_factory=list)
|
|
73
|
+
writes: list[ArtifactRef] = dataclass_field(default_factory=list)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _candidates(
|
|
77
|
+
spans: Sequence[SpanArtifacts], column: TraceColumn
|
|
78
|
+
) -> list[ArtifactRef]:
|
|
79
|
+
"""Artifacts across `spans` matching the column's agent/type/direction."""
|
|
80
|
+
want = column.type_name()
|
|
81
|
+
found: list[ArtifactRef] = []
|
|
82
|
+
for span in spans:
|
|
83
|
+
if column.agent is not None and span.agent != column.agent:
|
|
84
|
+
continue
|
|
85
|
+
# `Runtime._collect_reads` records the triggering artifact first, then
|
|
86
|
+
# the other inputs — so the trigger (the newest artifact of the run) is
|
|
87
|
+
# first. Reverse reads so it comes *last*, making the default
|
|
88
|
+
# `index=-1` mean "most recent" across reads and writes alike.
|
|
89
|
+
reads = list(reversed(span.reads))
|
|
90
|
+
if column.direction == "write":
|
|
91
|
+
refs = span.writes
|
|
92
|
+
elif column.direction == "read":
|
|
93
|
+
refs = reads
|
|
94
|
+
else:
|
|
95
|
+
refs = [*reads, *span.writes]
|
|
96
|
+
found.extend(ref for ref in refs if want is None or ref.data_type == want)
|
|
97
|
+
return found
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _walk(value: Any, parts: Sequence[str]) -> Any:
|
|
101
|
+
"""Descends `value` along `parts`; a list step takes element 0 by default."""
|
|
102
|
+
for part in parts:
|
|
103
|
+
if value is None:
|
|
104
|
+
return None
|
|
105
|
+
if isinstance(value, dict):
|
|
106
|
+
value = value.get(part)
|
|
107
|
+
elif isinstance(value, (list, tuple)):
|
|
108
|
+
if part.lstrip("-").isdigit():
|
|
109
|
+
index = int(part)
|
|
110
|
+
value = value[index] if -len(value) <= index < len(value) else None
|
|
111
|
+
elif value:
|
|
112
|
+
first = value[0]
|
|
113
|
+
value = first.get(part) if isinstance(first, dict) else None
|
|
114
|
+
else:
|
|
115
|
+
value = None
|
|
116
|
+
else:
|
|
117
|
+
return None
|
|
118
|
+
return value
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _display(value: Any) -> str | None:
|
|
122
|
+
if isinstance(value, (list, tuple)):
|
|
123
|
+
value = value[0] if value else None
|
|
124
|
+
if value is None:
|
|
125
|
+
return None
|
|
126
|
+
if isinstance(value, bool):
|
|
127
|
+
return "true" if value else "false"
|
|
128
|
+
if isinstance(value, dict):
|
|
129
|
+
return json.dumps(value, ensure_ascii=False, default=str)
|
|
130
|
+
return str(value)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def extract_columns(
|
|
134
|
+
spans: Sequence[SpanArtifacts],
|
|
135
|
+
columns: Sequence[TraceColumn],
|
|
136
|
+
*,
|
|
137
|
+
session_spans: Sequence[SpanArtifacts] | None = None,
|
|
138
|
+
) -> dict[str, str]:
|
|
139
|
+
"""Resolves every column to a display string for one run.
|
|
140
|
+
|
|
141
|
+
`spans` are the run's own spans; `session_spans` (when given) are every span
|
|
142
|
+
of the row's session in chronological order, used by `scope="session"`
|
|
143
|
+
columns. Best-effort by design (a dashboard convenience): a missing
|
|
144
|
+
artifact, an out-of-range index, or JSON clipped by the trace truncation
|
|
145
|
+
limit yields the column's `default` instead of raising.
|
|
146
|
+
"""
|
|
147
|
+
out: dict[str, str] = {}
|
|
148
|
+
for column in columns:
|
|
149
|
+
source = session_spans if column.scope == "session" else spans
|
|
150
|
+
refs = _candidates(source if source is not None else spans, column)
|
|
151
|
+
ref: ArtifactRef | None = None
|
|
152
|
+
if refs:
|
|
153
|
+
try:
|
|
154
|
+
ref = refs[column.index]
|
|
155
|
+
except IndexError:
|
|
156
|
+
ref = None
|
|
157
|
+
value: Any = None
|
|
158
|
+
if ref is not None and ref.data:
|
|
159
|
+
try:
|
|
160
|
+
parsed = json.loads(ref.data)
|
|
161
|
+
except (ValueError, TypeError):
|
|
162
|
+
parsed = None
|
|
163
|
+
if isinstance(parsed, dict):
|
|
164
|
+
value = (
|
|
165
|
+
_walk(parsed, column.field.split(".")) if column.field else parsed
|
|
166
|
+
)
|
|
167
|
+
display = _display(value)
|
|
168
|
+
out[column.label] = display if display is not None else column.default
|
|
169
|
+
return out
|
|
@@ -18,9 +18,11 @@ be shared across requests.
|
|
|
18
18
|
|
|
19
19
|
from __future__ import annotations
|
|
20
20
|
|
|
21
|
+
from collections.abc import Sequence
|
|
21
22
|
from datetime import UTC, datetime
|
|
22
23
|
from typing import Any
|
|
23
24
|
|
|
25
|
+
from .columns import SpanArtifacts, TraceColumn, extract_columns
|
|
24
26
|
from .models import (
|
|
25
27
|
AgentSpan,
|
|
26
28
|
ArtifactRef,
|
|
@@ -238,6 +240,7 @@ class PostgresStore:
|
|
|
238
240
|
q: str | None = None,
|
|
239
241
|
sort: str = "started_at",
|
|
240
242
|
order: str = "desc",
|
|
243
|
+
columns: Sequence[TraceColumn] | None = None,
|
|
241
244
|
limit: int = 50,
|
|
242
245
|
offset: int = 0,
|
|
243
246
|
) -> dict[str, Any]:
|
|
@@ -287,6 +290,69 @@ class PostgresStore:
|
|
|
287
290
|
tags_by_run.setdefault(run_id, []).append(
|
|
288
291
|
{"tag": tag, "note": note, "color": color}
|
|
289
292
|
)
|
|
293
|
+
fields_by_run: dict[str, dict[str, str]] = {}
|
|
294
|
+
if run_ids and columns:
|
|
295
|
+
placeholders = ",".join("%s" for _ in run_ids)
|
|
296
|
+
await cur.execute(
|
|
297
|
+
"SELECT run_id, agent, reads, writes FROM spans "
|
|
298
|
+
f"WHERE run_id IN ({placeholders}) ORDER BY id",
|
|
299
|
+
tuple(run_ids),
|
|
300
|
+
)
|
|
301
|
+
spans_by_run: dict[str, list[SpanArtifacts]] = {}
|
|
302
|
+
for run_id, agent, reads, writes in await cur.fetchall():
|
|
303
|
+
spans_by_run.setdefault(run_id, []).append(
|
|
304
|
+
SpanArtifacts(
|
|
305
|
+
agent=agent,
|
|
306
|
+
reads=[ArtifactRef(**d) for d in reads],
|
|
307
|
+
writes=[ArtifactRef(**d) for d in writes],
|
|
308
|
+
)
|
|
309
|
+
)
|
|
310
|
+
needs_session = any(c.scope == "session" for c in columns)
|
|
311
|
+
session_order: dict[str, list[tuple[str, list[SpanArtifacts]]]] = {}
|
|
312
|
+
if needs_session:
|
|
313
|
+
sessions = sorted({r[1] for r in rows if r[1]})
|
|
314
|
+
for sid in sessions:
|
|
315
|
+
await cur.execute(
|
|
316
|
+
"SELECT id FROM runs WHERE session_id = %s "
|
|
317
|
+
"ORDER BY started_at ASC, id ASC",
|
|
318
|
+
(sid,),
|
|
319
|
+
)
|
|
320
|
+
ordered = [x[0] for x in await cur.fetchall()]
|
|
321
|
+
if not ordered:
|
|
322
|
+
continue
|
|
323
|
+
sph = ",".join("%s" for _ in ordered)
|
|
324
|
+
await cur.execute(
|
|
325
|
+
"SELECT run_id, agent, reads, writes FROM spans "
|
|
326
|
+
f"WHERE run_id IN ({sph}) ORDER BY id",
|
|
327
|
+
tuple(ordered),
|
|
328
|
+
)
|
|
329
|
+
by_run: dict[str, list[SpanArtifacts]] = {}
|
|
330
|
+
for run_id, agent, reads, writes in await cur.fetchall():
|
|
331
|
+
by_run.setdefault(run_id, []).append(
|
|
332
|
+
SpanArtifacts(
|
|
333
|
+
agent=agent,
|
|
334
|
+
reads=[ArtifactRef(**d) for d in reads],
|
|
335
|
+
writes=[ArtifactRef(**d) for d in writes],
|
|
336
|
+
)
|
|
337
|
+
)
|
|
338
|
+
session_order[sid] = [
|
|
339
|
+
(rid, by_run.get(rid, [])) for rid in ordered
|
|
340
|
+
]
|
|
341
|
+
for row in rows:
|
|
342
|
+
session_spans: list[SpanArtifacts] | None = None
|
|
343
|
+
if needs_session:
|
|
344
|
+
ordered = session_order.get(row[1])
|
|
345
|
+
if ordered is not None:
|
|
346
|
+
session_spans = []
|
|
347
|
+
for rid, spans in ordered:
|
|
348
|
+
session_spans.extend(spans)
|
|
349
|
+
if rid == row[0]:
|
|
350
|
+
break
|
|
351
|
+
fields_by_run[row[0]] = extract_columns(
|
|
352
|
+
spans_by_run.get(row[0], []),
|
|
353
|
+
columns,
|
|
354
|
+
session_spans=session_spans,
|
|
355
|
+
)
|
|
290
356
|
finally:
|
|
291
357
|
await conn.close()
|
|
292
358
|
|
|
@@ -301,6 +367,7 @@ class PostgresStore:
|
|
|
301
367
|
"completion_tokens": r[6],
|
|
302
368
|
"spans": r[7],
|
|
303
369
|
"tags": tags_by_run.get(r[0], []),
|
|
370
|
+
"fields": fields_by_run.get(r[0], {}),
|
|
304
371
|
}
|
|
305
372
|
for r in rows
|
|
306
373
|
]
|
|
@@ -374,6 +441,63 @@ class PostgresStore:
|
|
|
374
441
|
annotations=annotations,
|
|
375
442
|
)
|
|
376
443
|
|
|
444
|
+
async def field_values(
|
|
445
|
+
self, run_id: str, columns: Sequence[TraceColumn]
|
|
446
|
+
) -> dict[str, str]:
|
|
447
|
+
if not columns:
|
|
448
|
+
return {}
|
|
449
|
+
await self._ensure_schema()
|
|
450
|
+
conn = await self._psycopg.AsyncConnection.connect(self.dsn)
|
|
451
|
+
try:
|
|
452
|
+
async with conn.cursor() as cur:
|
|
453
|
+
await cur.execute(
|
|
454
|
+
"SELECT session_id FROM runs WHERE id = %s", (run_id,)
|
|
455
|
+
)
|
|
456
|
+
row = await cur.fetchone()
|
|
457
|
+
if row is None:
|
|
458
|
+
return {}
|
|
459
|
+
session_id = row[0]
|
|
460
|
+
|
|
461
|
+
async def spans_of(
|
|
462
|
+
run_ids: list[str],
|
|
463
|
+
) -> dict[str, list[SpanArtifacts]]:
|
|
464
|
+
placeholders = ",".join("%s" for _ in run_ids)
|
|
465
|
+
await cur.execute(
|
|
466
|
+
"SELECT run_id, agent, reads, writes FROM spans "
|
|
467
|
+
f"WHERE run_id IN ({placeholders}) ORDER BY id",
|
|
468
|
+
tuple(run_ids),
|
|
469
|
+
)
|
|
470
|
+
out: dict[str, list[SpanArtifacts]] = {}
|
|
471
|
+
for rid, agent, reads, writes in await cur.fetchall():
|
|
472
|
+
out.setdefault(rid, []).append(
|
|
473
|
+
SpanArtifacts(
|
|
474
|
+
agent=agent,
|
|
475
|
+
reads=[ArtifactRef(**d) for d in reads],
|
|
476
|
+
writes=[ArtifactRef(**d) for d in writes],
|
|
477
|
+
)
|
|
478
|
+
)
|
|
479
|
+
return out
|
|
480
|
+
|
|
481
|
+
run_spans = (await spans_of([run_id])).get(run_id, [])
|
|
482
|
+
session_spans: list[SpanArtifacts] | None = None
|
|
483
|
+
if session_id and any(c.scope == "session" for c in columns):
|
|
484
|
+
await cur.execute(
|
|
485
|
+
"SELECT id FROM runs WHERE session_id = %s "
|
|
486
|
+
"ORDER BY started_at ASC, id ASC",
|
|
487
|
+
(session_id,),
|
|
488
|
+
)
|
|
489
|
+
ordered = [r[0] for r in await cur.fetchall()]
|
|
490
|
+
if ordered:
|
|
491
|
+
by_run = await spans_of(ordered)
|
|
492
|
+
session_spans = []
|
|
493
|
+
for rid in ordered:
|
|
494
|
+
session_spans.extend(by_run.get(rid, []))
|
|
495
|
+
if rid == run_id:
|
|
496
|
+
break
|
|
497
|
+
finally:
|
|
498
|
+
await conn.close()
|
|
499
|
+
return extract_columns(run_spans, columns, session_spans=session_spans)
|
|
500
|
+
|
|
377
501
|
async def sessions(
|
|
378
502
|
self,
|
|
379
503
|
*,
|